Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ghost/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion ghost/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
28 changes: 23 additions & 5 deletions ghost/mcp-server/src/core/lexical-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,44 @@ interface Part {

const TABLE_RE = /<table[\s\S]*?<\/table>/g;
const CARD_MARKER = "<!-- card -->";
// Fenced ``` blocks and single-line inline `code`, masked before boundary
// detection so a <table> or <!-- card --> 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 <table> 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 <!-- card --> markers. Without
// either boundary, the whole post is one markdown card.
// between them is further split on explicit <!-- card --> 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"
Expand Down
31 changes: 31 additions & 0 deletions ghost/mcp-server/test/lexical-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <!-- card --> inside inline code", () => {
const r = buildLexical("An explicit `<!-- card -->` marker in prose.");
expect(r.cardCount).toBe(1);
expect(r.cardSummary).toBe("markdown");
});

it("ignores <table> and <!-- card --> inside a fenced block", () => {
const md =
"Before.\n\n```html\n<table><tr><td>x</td></tr></table>\n" +
"<!-- card -->\n```\n\nAfter.";
const r = buildLexical(md);
expect(r.cardCount).toBe(1);
expect(r.cardSummary).toBe("markdown");
});

it("keeps a real <table> but ignores one shown in inline code", () => {
const md =
"Shown: `<table><tr><td>x</td></tr></table>`.\n\n" +
"<table><tr><td>y</td></tr></table>\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 `<!-- card -->` sits mid-line.";
const child = children(buildLexical(md))[0] as { markdown: string };
expect(child.markdown).toContain("24 hex chars");
expect(child.markdown).toContain("`<!-- card -->`");
});
});
Loading