From c363519bc33612aa77751b62c224db7eaa897f73 Mon Sep 17 00:00:00 2001 From: Court Schuett Date: Fri, 3 Jul 2026 17:57:33 -0500 Subject: [PATCH] fix(ghost-mcp): make card/table splitting code-aware (v0.1.3) The lexical builder split on and ...
literally, including inside fenced blocks and inline code. A post that documents the card syntax (writing `` in prose) had its card cut at that marker. Mask fenced/inline code with NUL-delimited tokens before boundary detection, then restore verbatim, so markers/tables shown in code are left intact while real ones outside code still split. NUL can't occur in markdown, so tokens never collide with prose (e.g. "24 hex"). Bump package + plugin to 0.1.3 (lockstep) so the dev channel publishes. Claude-Session: https://claude.ai/code/session_01BfjjpBQzjignajX8sZXVam --- ghost/.claude-plugin/plugin.json | 2 +- ghost/mcp-server/package.json | 2 +- ghost/mcp-server/src/core/lexical-builder.ts | 28 ++++++++++++++--- ghost/mcp-server/test/lexical-builder.test.ts | 31 +++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/ghost/.claude-plugin/plugin.json b/ghost/.claude-plugin/plugin.json index 7affe9d..e574455 100644 --- a/ghost/.claude-plugin/plugin.json +++ b/ghost/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "0.1.2", + "version": "0.1.3", "description": "Write, revise, and push blog posts to a Ghost site from Claude Code — a Ghost Admin API MCP plus a plan→draft→revise→push skill set.", "author": { "name": "Court Schuett" diff --git a/ghost/mcp-server/package.json b/ghost/mcp-server/package.json index 411ad94..dd7cd69 100644 --- a/ghost/mcp-server/package.json +++ b/ghost/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "ghost-blog-mcp", - "version": "0.1.2", + "version": "0.1.3", "description": "MCP server for the Ghost Admin API", "type": "module", "repository": { diff --git a/ghost/mcp-server/src/core/lexical-builder.ts b/ghost/mcp-server/src/core/lexical-builder.ts index 73014be..78b32b5 100644 --- a/ghost/mcp-server/src/core/lexical-builder.ts +++ b/ghost/mcp-server/src/core/lexical-builder.ts @@ -11,26 +11,44 @@ interface Part { const TABLE_RE = //g; const CARD_MARKER = ""; +// Fenced ``` blocks and single-line inline `code`, masked before boundary +// detection so a or written *inside* code — e.g. a post +// documenting the card syntax — isn't mistaken for a real card boundary. +const CODE_SPAN_RE = /```[\s\S]*?```|`[^`\n]*`/g; +// NUL never occurs in markdown, so mask tokens can't collide with prose. +const NUL = String.fromCharCode(0); +const TOKEN_RE = new RegExp(`${NUL}(\\d+)${NUL}`, "g"); // Split markdown into Ghost lexical cards. Top-level
blocks become // their own `html` cards (preserving inline styling and keeping each prose // chunk between tables independently editable in the Ghost UI); the prose -// between them is further split on explicit markers. Without -// either boundary, the whole post is one markdown card. +// between them is further split on explicit markers. Code spans +// are masked first so boundaries appearing inside them are left intact. +// Without either boundary, the whole post is one markdown card. export function buildLexical(markdown: string): LexicalResult { + const codeSpans: string[] = []; + const masked = markdown.replace(CODE_SPAN_RE, (span) => { + const token = `${NUL}${codeSpans.length}${NUL}`; + codeSpans.push(span); + return token; + }); + const restore = (s: string): string => + s.replace(TOKEN_RE, (_, i) => codeSpans[Number(i)]); + const parts: Part[] = []; let lastIndex = 0; let m: RegExpExecArray | null; TABLE_RE.lastIndex = 0; - while ((m = TABLE_RE.exec(markdown)) !== null) { - pushMarkdownParts(parts, markdown.slice(lastIndex, m.index)); + while ((m = TABLE_RE.exec(masked)) !== null) { + pushMarkdownParts(parts, masked.slice(lastIndex, m.index)); parts.push({ type: "html", content: m[0] }); lastIndex = m.index + m[0].length; } - pushMarkdownParts(parts, markdown.slice(lastIndex)); + pushMarkdownParts(parts, masked.slice(lastIndex)); const children = parts + .map((p) => ({ type: p.type, content: restore(p.content) })) .filter((p) => p.content.trim().length > 0) .map((p) => p.type === "markdown" diff --git a/ghost/mcp-server/test/lexical-builder.test.ts b/ghost/mcp-server/test/lexical-builder.test.ts index 87df98f..0322f93 100644 --- a/ghost/mcp-server/test/lexical-builder.test.ts +++ b/ghost/mcp-server/test/lexical-builder.test.ts @@ -35,4 +35,35 @@ describe("buildLexical", () => { const root = JSON.parse(buildLexical("Hi.").lexical).root; expect(root).toMatchObject({ type: "root", version: 1, indent: 0 }); }); + + it("ignores inside inline code", () => { + const r = buildLexical("An explicit `` marker in prose."); + expect(r.cardCount).toBe(1); + expect(r.cardSummary).toBe("markdown"); + }); + + it("ignores
and inside a fenced block", () => { + const md = + "Before.\n\n```html\n
x
\n" + + "\n```\n\nAfter."; + const r = buildLexical(md); + expect(r.cardCount).toBe(1); + expect(r.cardSummary).toBe("markdown"); + }); + + it("keeps a real but ignores one shown in inline code", () => { + const md = + "Shown: `
x
`.\n\n" + + "
y
\n\nEnd."; + const r = buildLexical(md); + expect(r.cardSummary).toBe("markdown, html, markdown"); + expect(r.cardCount).toBe(3); + }); + + it("restores code verbatim without mangling nearby numbers", () => { + const md = "A key is 24 hex chars and `` sits mid-line."; + const child = children(buildLexical(md))[0] as { markdown: string }; + expect(child.markdown).toContain("24 hex chars"); + expect(child.markdown).toContain("``"); + }); });