fix(mcp): agent-write НЕ срезает ведущий ---…--- (front-matter strip — только импорт)
Ревью #493 (MEDIUM): вшив normalizeForeignMarkdown первым шагом в markdownToProseMirrorCanonical, коммит 4 распространил срез YAML front-matter (YAML_FRONT_MATTER_RE) на КАЖДЫЙ agent-write путь. convertProseMirrorToMarkdown эмитит `---` для horizontalRule, поэтому страница, начинающаяся с horizontalRule и содержащая второй `---`, при полном agent-write теряла всё до второго `---` — молчаливая потеря ранее сохранённого контента. Правка: разделил нормализацию. normalizeForeignMarkdown (серверный file-import boundary) по-прежнему срезает front-matter. Новый normalizeAgentMarkdown (agent-write, markdownToProseMirrorCanonical) делает ТОЛЬКО CRLF-нормализацию + rewrite GFM reference-сносок (тот самый drift, ради которого коммит 4) и НЕ трогает ведущий `---…---`. На каноническом сериализованном контенте rewrite — no-op (он не эмитит `[^id]:`-строк). Тесты: agent-write horizontalRule-led дока со вторым `---` сохраняет весь контент (round-trip); file-import с реальным YAML front-matter его по-прежнему срезает; agent-write всё ещё канонизирует GFM reference-сноски. Mutation-verify: strip обратно на agent-write → тесты потери контента краснеют. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ import { JSDOM } from "jsdom";
|
||||
// canonicalization wrapper stay mcp-side.
|
||||
import {
|
||||
markdownToProseMirror,
|
||||
normalizeForeignMarkdown,
|
||||
normalizeAgentMarkdown,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
||||
import { withPageLock } from "./page-lock.js";
|
||||
@@ -102,10 +102,14 @@ global.WebSocket = WebSocket;
|
||||
* `^[body]` footnotes carry their body at the reference point, so a comment can
|
||||
* no longer produce a reference-less footnote definition to be dropped.
|
||||
*
|
||||
* #493: `normalizeForeignMarkdown` runs FIRST, so an agent's `updatePageMarkdown`
|
||||
* body is normalized exactly like the server import path — GFM `[^id]` reference
|
||||
* footnotes become canonical inline `^[body]`, and a leading YAML front-matter
|
||||
* block is stripped — instead of leaking through as literal text / a bogus link.
|
||||
* #493: `normalizeAgentMarkdown` runs FIRST, so an agent's `updatePageMarkdown`
|
||||
* body gets the SAME GFM `[^id]` reference-footnote -> inline `^[body]` rewrite as
|
||||
* the server import path (instead of the reference leaking as literal text / a
|
||||
* bogus link). It DELIBERATELY does NOT strip a leading YAML front-matter block:
|
||||
* a full-body agent rewrite that opens with a `---…---` is (almost) always a
|
||||
* horizontalRule the serializer emitted, and stripping it would silently drop the
|
||||
* page's leading content (#493 review). The front-matter strip stays on the
|
||||
* server FILE-import boundary only (`normalizeForeignMarkdown`).
|
||||
*/
|
||||
export async function markdownToProseMirrorCanonical(
|
||||
markdownContent: string,
|
||||
@@ -115,7 +119,7 @@ export async function markdownToProseMirrorCanonical(
|
||||
// now-orphaned duplicate definitions.
|
||||
return canonicalizeFootnotes(
|
||||
normalizeAndMergeFootnotes(
|
||||
await markdownToProseMirror(normalizeForeignMarkdown(markdownContent)),
|
||||
await markdownToProseMirror(normalizeAgentMarkdown(markdownContent)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,11 +254,18 @@ function convertReferenceFootnotes(markdown: string): string {
|
||||
const YAML_FRONT_MATTER_RE = /^\uFEFF?---\n[\s\S]*?\n---\n?/;
|
||||
|
||||
/**
|
||||
* Normalize a foreign markdown string into Docmost's canonical markdown surface
|
||||
* so the strict canonical parser accepts it losslessly: normalize line endings,
|
||||
* strip a leading YAML front-matter block, then rewrite GFM reference footnotes
|
||||
* into inline footnotes. Add further fixture-driven foreign-surface cases here as
|
||||
* they are found.
|
||||
* Normalize a foreign markdown string from a FILE IMPORT into Docmost's canonical
|
||||
* markdown surface so the strict canonical parser accepts it losslessly: normalize
|
||||
* line endings, strip a leading YAML front-matter block, then rewrite GFM reference
|
||||
* footnotes into inline footnotes. Add further fixture-driven foreign-surface cases
|
||||
* here as they are found.
|
||||
*
|
||||
* FRONT-MATTER STRIP IS IMPORT-ONLY (#493 review): use this ONLY at the server
|
||||
* file-import boundary, where a `.md` file really can open with an Obsidian/Hugo
|
||||
* YAML header. Do NOT use it on the canonical AGENT-WRITE path — see
|
||||
* {@link normalizeAgentMarkdown} for why a full-body agent rewrite must NOT strip
|
||||
* a leading `---…---` (it is normally a horizontalRule the serializer emitted, and
|
||||
* stripping it would silently drop the page's leading content).
|
||||
*/
|
||||
export function normalizeForeignMarkdown(markdown: string): string {
|
||||
if (!markdown) return markdown;
|
||||
@@ -271,3 +278,26 @@ export function normalizeForeignMarkdown(markdown: string): string {
|
||||
const withoutFrontMatter = src.replace(YAML_FRONT_MATTER_RE, '').trimStart();
|
||||
return convertReferenceFootnotes(withoutFrontMatter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical AGENT-WRITE normalization: normalize line endings and rewrite GFM
|
||||
* `[^id]` reference footnotes to inline `^[body]` — but DELIBERATELY NOT strip a
|
||||
* leading YAML front-matter block.
|
||||
*
|
||||
* WHY the split (#493 review): the reference-footnote rewrite is the drift the
|
||||
* MCP page-write path (`updatePageMarkdown` -> `markdownToProseMirrorCanonical`)
|
||||
* needed unified with the server import (an agent may paste GFM footnotes). The
|
||||
* front-matter strip, however, is a FILE-import concern: on a full-body agent
|
||||
* rewrite a leading `---…---` is (almost) always a `horizontalRule` the
|
||||
* serializer emitted plus a later rule/heading — NOT a foreign YAML header — so
|
||||
* `YAML_FRONT_MATTER_RE` would match it and SILENTLY DELETE the page's leading
|
||||
* content (a page that starts with a horizontal rule and contains a second `---`
|
||||
* lost everything up to it). Agent writes must never lose already-stored content,
|
||||
* so this variant skips the strip. It IS a no-op on canonical serialized content
|
||||
* (which never emits `[^id]:` reference-definition lines).
|
||||
*/
|
||||
export function normalizeAgentMarkdown(markdown: string): string {
|
||||
if (!markdown) return markdown;
|
||||
const src = markdown.replace(/\r\n/g, '\n');
|
||||
return convertReferenceFootnotes(src);
|
||||
}
|
||||
|
||||
@@ -27,11 +27,17 @@ export {
|
||||
} from "./markdown-to-prosemirror.js";
|
||||
|
||||
// Foreign-markdown normalizer (#493): the input-liberal pre-pass that rewrites
|
||||
// GFM `[^id]` reference footnotes to canonical inline `^[body]` and strips a
|
||||
// leading YAML front-matter block, run at every canonical IMPORT boundary
|
||||
// (server import + mcp `markdownToProseMirrorCanonical`) so foreign markdown is
|
||||
// normalized identically everywhere instead of only in apps/server.
|
||||
export { normalizeForeignMarkdown } from "./foreign-markdown.js";
|
||||
// GFM `[^id]` reference footnotes to canonical inline `^[body]`. Two variants:
|
||||
// `normalizeForeignMarkdown` (server FILE-import boundary) ALSO strips a leading
|
||||
// YAML front-matter block; `normalizeAgentMarkdown` (canonical AGENT-WRITE path,
|
||||
// mcp `markdownToProseMirrorCanonical`) does NOT — a full-body agent rewrite must
|
||||
// not lose a leading `---…---` horizontalRule to the front-matter strip (#493
|
||||
// review). The reference-footnote rewrite is shared so agent + import stay unified
|
||||
// where it matters, without the content-losing strip on the write path.
|
||||
export {
|
||||
normalizeForeignMarkdown,
|
||||
normalizeAgentMarkdown,
|
||||
} from "./foreign-markdown.js";
|
||||
|
||||
// The Docmost tiptap schema mirror. Exposed so consumers (and the sync
|
||||
// engine's schema-validity regression tests) can build the exact ProseMirror
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
|
||||
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
|
||||
import { normalizeForeignMarkdown } from '../src/lib/foreign-markdown.js';
|
||||
import {
|
||||
normalizeForeignMarkdown,
|
||||
normalizeAgentMarkdown,
|
||||
} from '../src/lib/foreign-markdown.js';
|
||||
|
||||
/**
|
||||
* STEP 2 goldens for issue #345 (moved into the package with the normalizer in
|
||||
@@ -216,3 +219,53 @@ describe('foreign markdown import acceptance (normalizer + canonical parser)', (
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAgentMarkdown vs normalizeForeignMarkdown — front-matter strip is IMPORT-only (#493 review)', () => {
|
||||
// A page that OPENS with a horizontalRule and contains a later `---` serializes
|
||||
// to a `---…---`-shaped body. On a full-body AGENT rewrite this must NOT be
|
||||
// mistaken for YAML front-matter and stripped — that silently dropped the
|
||||
// page's leading content.
|
||||
const rulePage = '---\n\nIntro\n\nMore\n\n---\n\nRest';
|
||||
|
||||
it('normalizeAgentMarkdown does NOT strip a leading ---…--- (no content loss)', () => {
|
||||
expect(normalizeAgentMarkdown(rulePage)).toBe(rulePage);
|
||||
});
|
||||
|
||||
it('normalizeForeignMarkdown (file import) STILL strips a real leading YAML front-matter block', () => {
|
||||
const withYaml = '---\ntitle: My Page\ntags: [a, b]\n---\n\nBody here.';
|
||||
const out = normalizeForeignMarkdown(withYaml);
|
||||
expect(out).toBe('Body here.');
|
||||
// And the horizontalRule-shaped body IS stripped on the import path (its
|
||||
// documented file-import behavior) — the two variants differ ONLY here.
|
||||
expect(normalizeForeignMarkdown(rulePage)).not.toContain('Intro');
|
||||
});
|
||||
|
||||
it('agent-write round-trip keeps a horizontalRule-led doc with a second rule intact', async () => {
|
||||
// Simulate the serializer output for [horizontalRule, para, para, horizontalRule, para].
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'horizontalRule' },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Intro' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'More' }] },
|
||||
{ type: 'horizontalRule' },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Rest' }] },
|
||||
],
|
||||
};
|
||||
const body = convertProseMirrorToMarkdown(doc);
|
||||
// The agent-write normalization must NOT eat the head; re-import keeps every
|
||||
// paragraph's text.
|
||||
const back = await markdownToProseMirror(normalizeAgentMarkdown(body));
|
||||
const texts = JSON.stringify(back);
|
||||
for (const t of ['Intro', 'More', 'Rest']) expect(texts).toContain(t);
|
||||
// Both horizontal rules survive.
|
||||
expect(back.content.filter((n: any) => n.type === 'horizontalRule')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('agent-write STILL rewrites GFM reference footnotes (the shared drift-fix)', () => {
|
||||
const gfm = 'See[^1].\n\n[^1]: the note.';
|
||||
const out = normalizeAgentMarkdown(gfm);
|
||||
expect(out).toContain('^[the note.]');
|
||||
expect(out).not.toMatch(/\[\^1\]:/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user