124f5a45a2
mcp had its OWN drifted copy of the converter (markdown-converter.ts ~900 lines, docmost-schema.ts ~1270 lines, markdown-document.ts) — older than the shared package, missing the git-sync fixes AND the #293 canon. This switches mcp's converter CORE to @docmost/prosemirror-markdown, so mcp jumps straight to the canonical format and the drift-generating second copy is gone. - markdown-converter.ts / markdown-document.ts / docmost-schema.ts become thin re-export shims of the package (convertProseMirrorToMarkdown, the docmost:meta envelope, docmostExtensions + docmostSchema=getSchema(docmostExtensions)). The mcp-only helpers clampCalloutType/sanitizeCssColor are preserved verbatim in the schema shim (the package doesn't expose them via its barrel). ~2170 lines of the drifted converter/schema bodies deleted. - collaboration.ts drops its own ~360-line marked pipeline (preprocessCallouts, bridgeTaskLists, extractFootnotes, the footnoteRef extension) and re-points to the package's markdownToProseMirror, keeping markdownToProseMirrorCanonical and all the yjs/collab write glue. footnote-lex/analyze doc comments updated (they now describe advisory legacy-syntax diagnostics, not an importer). Schema parity verified: the package schema is a strict SUPERSET of mcp's old schema — every node and attr mcp declared is present (the package only adds status/pageEmbed/transclusion*/subpages.recursive/etc.), so nothing is silently dropped on the switch. The switch actually FIXES two pre-existing mcp data-loss bugs its own tests documented: htmlEmbed and pageBreak now round-trip (were dropped by the old mcp converter). Footnotes: the package assembles inline ^[body] footnotes on import (sequential fn-N ids, identical bodies merged), so mcp's canonicalizeFootnotes is now an idempotent no-op after it (verified). Legacy reference footnotes [^id]/[^id]: are inert literal text (canon #2 no-backward-compat) — lossless, the text survives verbatim. Build hygiene: packages/mcp/build/ is now gitignored and untracked, matching the git-sync/prosemirror-markdown convention (private package, rebuilt in CI/Docker, so src and prod can never silently diverge). This also removes a dead untracked build/_vendored_editor_ext/ artifact that a broad `git add` would otherwise commit. Dependency: packages/mcp/package.json gains @docmost/prosemirror-markdown (workspace:*); pnpm-lock.yaml gets the matching link importer (mirrors git-sync). mcp tests updated deliberately to the canonical forms (highlight ==, math $…$, image <!--img-->, drawio/media discriminators, subpages/pageBreak comments, textAlign, inline ^[…] footnotes) with strict assertions; 4 structural safety-net round-trip tests added. mcp: node --test 454 passed; tsc clean. package: 657 passed. git-sync: 268 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
5.5 KiB
TypeScript
137 lines
5.5 KiB
TypeScript
/**
|
|
* Legacy footnote diagnostics for imported Markdown (issue #166).
|
|
*
|
|
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
|
|
* conversion path, so it reports the same problems for `create_page`,
|
|
* `update_page` and `import_page_markdown`). It never changes the document — the
|
|
* importer still creates the page; this only surfaces footnote problems to the
|
|
* caller so an agent can fix its own markup instead of shipping broken footnotes.
|
|
*
|
|
* SCOPE after #293 STEP 5: the canonical import form is now inline `^[body]`
|
|
* footnotes (handled by `@docmost/prosemirror-markdown`), where these problems
|
|
* cannot arise. This scan therefore targets the LEGACY reference-style
|
|
* (`[^id]` / `[^id]:`) markup, which is now inert on import (left as literal
|
|
* text). The warnings remain useful as an advisory nudge when an agent still
|
|
* authors the old syntax, but they no longer describe what the importer builds.
|
|
*
|
|
* Detected problems:
|
|
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
|
|
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
|
|
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
|
|
* first would have been kept under the old first-wins import).
|
|
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
|
|
* the line, trimmed, starts with `|`) — footnotes in table cells often do not
|
|
* render as expected.
|
|
*/
|
|
|
|
import {
|
|
lexFootnoteLines,
|
|
forEachFootnoteReference,
|
|
} from "./footnote-lex.js";
|
|
|
|
export interface FootnoteDiagnostics {
|
|
/** Reference ids (distinct, document order) with no matching definition. */
|
|
danglingReferences: string[];
|
|
/** Definition ids whose first (kept) text is empty/whitespace. */
|
|
emptyDefinitions: string[];
|
|
/** Ids defined by two or more `[^id]:` lines (only the first is kept). */
|
|
duplicateDefinitions: string[];
|
|
/** Reference ids found inside a GFM table row (heuristic). */
|
|
referencesInTables: string[];
|
|
/** Human-readable warning lines for the tool result (one per problem class). */
|
|
warnings: string[];
|
|
}
|
|
|
|
/**
|
|
* Analyze the footnotes in a Markdown string. Pure; safe to call on any body.
|
|
*/
|
|
export function analyzeFootnotes(markdown: string): FootnoteDiagnostics {
|
|
// Distinct reference ids in first-appearance order, plus the set of ids seen
|
|
// inside a table row.
|
|
const refIds: string[] = [];
|
|
const refIdSet = new Set<string>();
|
|
const referencesInTables = new Set<string>();
|
|
const addRef = (id: string, inTable: boolean) => {
|
|
if (!refIdSet.has(id)) {
|
|
refIdSet.add(id);
|
|
refIds.push(id);
|
|
}
|
|
if (inTable) referencesInTables.add(id);
|
|
};
|
|
|
|
// Definition texts per id, in first-appearance order of the id.
|
|
const defTextsById = new Map<string, string[]>();
|
|
|
|
// Same lexer the importer uses, so the analysis matches exactly what import
|
|
// keeps/strips (#166): fenced lines are inert, definition lines are pulled.
|
|
for (const tok of lexFootnoteLines(markdown)) {
|
|
if (tok.inFence) continue;
|
|
if (tok.definition) {
|
|
const { id, text } = tok.definition;
|
|
const arr = defTextsById.get(id);
|
|
if (arr) arr.push(text);
|
|
else defTextsById.set(id, [text]);
|
|
// A definition's TEXT can itself reference another footnote (`[^a]: see
|
|
// [^b]`); count those so such a `[^b]` is not falsely reported dangling.
|
|
forEachFootnoteReference(text, (rid) => addRef(rid, false));
|
|
continue;
|
|
}
|
|
const inTable = tok.line.trimStart().startsWith("|");
|
|
forEachFootnoteReference(tok.line, (id) => addRef(id, inTable));
|
|
}
|
|
|
|
const danglingReferences = refIds.filter((id) => !defTextsById.has(id));
|
|
const duplicateDefinitions: string[] = [];
|
|
const emptyDefinitions: string[] = [];
|
|
for (const [id, texts] of defTextsById) {
|
|
if (texts.length >= 2) duplicateDefinitions.push(id);
|
|
// First-wins: the kept definition is the first one; flag it if it is blank.
|
|
if ((texts[0] ?? "").trim().length === 0) emptyDefinitions.push(id);
|
|
}
|
|
const tableRefs = [...referencesInTables];
|
|
|
|
const warnings: string[] = [];
|
|
const list = (ids: string[]) => ids.map((id) => `[^${id}]`).join(", ");
|
|
if (danglingReferences.length > 0) {
|
|
warnings.push(
|
|
`Footnote reference(s) with no matching definition: ${list(danglingReferences)} (each will render as an empty footnote in the editor).`,
|
|
);
|
|
}
|
|
if (emptyDefinitions.length > 0) {
|
|
warnings.push(
|
|
`Footnote definition(s) with empty text: ${list(emptyDefinitions)}.`,
|
|
);
|
|
}
|
|
if (duplicateDefinitions.length > 0) {
|
|
warnings.push(
|
|
`Footnote id(s) defined more than once (only the first definition was kept): ${list(duplicateDefinitions)}.`,
|
|
);
|
|
}
|
|
if (tableRefs.length > 0) {
|
|
warnings.push(
|
|
`Footnote marker(s) inside a table row (footnotes in table cells may not render as expected): ${list(tableRefs)}.`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
danglingReferences,
|
|
emptyDefinitions,
|
|
duplicateDefinitions,
|
|
referencesInTables: tableRefs,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The optional `footnoteWarnings` field for a page-write tool result: present
|
|
* (with the warning lines) only when `markdown` has footnote problems, omitted
|
|
* otherwise. One helper so all three call sites (create/update/import) attach the
|
|
* field identically. Spread into the result: `{ ...result, ...footnoteWarningsField(text) }`.
|
|
*/
|
|
export function footnoteWarningsField(markdown: string): {
|
|
footnoteWarnings?: string[];
|
|
} {
|
|
const { warnings } = analyzeFootnotes(markdown);
|
|
return warnings.length > 0 ? { footnoteWarnings: warnings } : {};
|
|
}
|