e2a3b5fc4d
Ten media/embed node types move their TOP-LEVEL serialization off raw schema HTML onto a readable markdown target plus an always-emitted discriminator comment whose NAME selects the node type. The schema-HTML form is retained on the raw-HTML/columns path (comments are dropped by the DOM parse stage there). image-form <!--name …--> youtube, video, audio, drawio, excalidraw link-form [text](src)<!--name …--> pdf, attachment, embed (text=filename/provider) standalone <!--pageembed …--> / <!--transclusion …--> pageEmbed, transclusionReference The comment NAME is the node-type discriminator and is ALWAYS emitted, even when the attr JSON is empty (`<!--youtube-->`), so a bare `` is never mistaken for an `image` and a bare `[t](u)` stays a plain link — no URL-sniffing. src rides in the markdown target; every other non-default attr (incl. the id links attachmentId/sourcePageId/transclusionId) rides in the comment JSON (stable key order, numerics stringified, align="center" omitted). New src/lib/media-html.ts: byte-exact builders reproducing the schema HTML each old processNode case returned. Both the serializer's raw-HTML path (blockToHtml, now de-delegated from `return processNode(block)` to explicit per-type cases) and the importer call these, so serialize and parse cannot drift. Import (applyCommentDirectives): image-form binds the preceding <img> (src from it), link-form the preceding <a> (src=href, text=filename/provider), standalone replaces the comment (same leading-doc-level handling as #5). Each rebuilds the schema element via the media-html builder, then swaps it in; the empty-<p> hoist is absorbed by stripEmptyParagraphs. Fail-open: wrong element/position/name or malformed JSON -> inert, no throw. Link-form visible text is escaped (escapeLinkText) for the FULL set of CommonMark inline-active punctuation (\ ` * _ ~ [ ] < & ! ( )), not just [ ] \: the label is parsed as inline content, so a filename/provider like `report *v2*.pdf` or `.pdf` would otherwise lose the markup (or fragment the parse) when the importer reads a.textContent back — a data-loss regression vs the old data-attachment-name form. Adversarial round-trip fixtures lock byte- and value-stability for emphasis/code/strike/autolink/entity/image markers and nested-link names. Tests: new media-comments.test.ts (40 cases: per-type exact md + lossless byte-stable round-trip incl. id links, minimal-node discriminator-still-emitted, in-column schema-HTML form, discriminator integrity, fail-open, active-punct filenames). Goldens in media-roundtrip / markdown-converter-golden / markdown-converter / diagram-roundtrip updated to the md+comment form (columns stay schema-HTML). The former known-limitation image-diagrams fixture is now byte- AND canonically-stable (canon #8 omits the diagram align="center" default) and was promoted from an it.fails into the green corpus (11-image-diagrams.json). git-sync stabilize.test.ts: the "diagram materializes data-align=center" fixpoint moved into a column (where the raw-HTML asymmetry still holds), since top level is now byte-stable. package vitest: 540 passed; tsc clean. git-sync: 268 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
convertProseMirrorToMarkdown,
|
|
markdownToProseMirror,
|
|
docsCanonicallyEqual,
|
|
} from 'docmost-client';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Media / atom node round-trip coverage (audio, video, pdf, attachment, embed,
|
|
// youtube). The existing specs (corpus + property test) exercise the EXPORT
|
|
// direction of these nodes only; their parseHTML branches (the INVERSE parse of
|
|
// the exported HTML) are otherwise unprotected. Each test runs the full
|
|
// export -> import -> export pipeline and pins:
|
|
// - the exact md1 byte string the converter emits,
|
|
// - whether md2 is byte-stable (md2 === md1) or grows by a materialized
|
|
// schema default on the first import,
|
|
// - the re-parsed doc2 attrs (NOTE: parseHTML reads via getAttribute and so
|
|
// returns STRINGS for numeric attrs, which is what breaks naive canonical
|
|
// equality), and
|
|
// - docsCanonicallyEqual(doc, doc2) where the spec asserts a specific result.
|
|
//
|
|
// `convertProseMirrorToMarkdown` requires a full doc ({type:'doc', content:[]}),
|
|
// so each spec's `doc=[...]` content array is wrapped via mkDoc().
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Wrap a content array (as the specs express `doc`) into a real PM doc. */
|
|
const mkDoc = (content: any[]) => ({ type: 'doc', content });
|
|
|
|
/** export -> import -> export, returning both markdowns and the re-parsed doc. */
|
|
async function roundTrip(doc: any) {
|
|
const md1 = convertProseMirrorToMarkdown(doc);
|
|
const doc2 = await markdownToProseMirror(md1);
|
|
const md2 = convertProseMirrorToMarkdown(doc2);
|
|
return { md1, md2, doc2 };
|
|
}
|
|
|
|
/** Find the first node of a given type anywhere in a PM doc tree. */
|
|
const findFirst = (node: any, type: string): any => {
|
|
if (node && node.type === type) return node;
|
|
for (const child of node?.content || []) {
|
|
const hit = findFirst(child, type);
|
|
if (hit) return hit;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
describe('media atom round-trip (audio/video/pdf/attachment/embed/youtube)', () => {
|
|
// 1. audio with ALL optional attrs ---------------------------------------
|
|
it('audio with src+attachmentId+size: byte-stable, size re-parses to the STRING "9001"', async () => {
|
|
const doc = mkDoc([
|
|
{ type: 'audio', attrs: { src: '/a.mp3', attachmentId: 'att-7', size: 9001 } },
|
|
]);
|
|
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
|
|
// #293 canon #8 image-form: `` + the ALWAYS-emitted `audio`
|
|
// discriminator carrying the other attrs as JSON (numerics stringified).
|
|
expect(md1).toBe('<!--audio {"attachmentId":"att-7","size":"9001"}-->');
|
|
// Byte-stable: a second export reproduces the first exactly.
|
|
expect(md2).toBe(md1);
|
|
|
|
const audio = findFirst(doc2, 'audio');
|
|
expect(audio).not.toBeNull();
|
|
expect(audio.type).toBe('audio');
|
|
expect(audio.attrs.src).toBe('/a.mp3');
|
|
expect(audio.attrs.attachmentId).toBe('att-7');
|
|
// NOTE: the schema's data-size parseHTML returns getAttribute() -> a STRING,
|
|
// so the number 9001 comes back as the string '9001'.
|
|
expect(audio.attrs.size).toBe('9001');
|
|
});
|
|
|
|
// 2. fully-populated video -----------------------------------------------
|
|
it('video with all attrs: byte-stable; numeric attrs re-parse to STRINGS; canonical equality FALSE', async () => {
|
|
const doc = mkDoc([
|
|
{
|
|
type: 'video',
|
|
attrs: {
|
|
src: '/v.mp4',
|
|
alt: 'clip',
|
|
attachmentId: 'att-1',
|
|
width: 640,
|
|
height: 480,
|
|
size: 1234,
|
|
align: 'center',
|
|
aspectRatio: 1.777,
|
|
},
|
|
},
|
|
]);
|
|
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
|
|
// #293 canon #8 image-form: align="center" (the schema default) is OMITTED,
|
|
// so the whole optional set except align rides in the `video` comment JSON.
|
|
expect(md1).toBe(
|
|
'<!--video {"alt":"clip","attachmentId":"att-1","width":"640","height":"480","size":"1234","aspectRatio":"1.777"}-->',
|
|
);
|
|
expect(md2).toBe(md1);
|
|
|
|
const video = findFirst(doc2, 'video');
|
|
expect(video).not.toBeNull();
|
|
expect(video.attrs.alt).toBe('clip');
|
|
// All numeric attrs come back as STRINGS via getAttribute().
|
|
expect(video.attrs.width).toBe('640');
|
|
expect(video.attrs.height).toBe('480');
|
|
expect(video.attrs.size).toBe('1234');
|
|
expect(video.attrs.aspectRatio).toBe('1.777');
|
|
|
|
// Byte-stable export but NOT canonically equal: the numeric width/height/
|
|
// size/aspectRatio came back as strings, so deep-equal of the canonical
|
|
// forms fails (align:'center' is normalized away, the numbers are not).
|
|
expect(docsCanonicallyEqual(doc, doc2)).toBe(false);
|
|
});
|
|
|
|
// 3. minimal video (only src) --------------------------------------------
|
|
it('minimal video (src only): the discriminator is STILL emitted; byte-stable; round-trips to VIDEO not image', async () => {
|
|
const doc = mkDoc([{ type: 'video', attrs: { src: '/v.mp4' } }]);
|
|
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
|
|
// #293 canon #8: the comment is the type discriminator, so it is emitted even
|
|
// with no extra attrs (name-only). Without it a bare `` would be read
|
|
// as an `image`. align="center" default is omitted, so the form is byte-stable
|
|
// (unlike the old div-form, which gained data-align="center" on import).
|
|
expect(md1).toBe('<!--video-->');
|
|
expect(md2).toBe(md1);
|
|
|
|
// Critically: it round-trips to a VIDEO node, NOT an image.
|
|
const video = findFirst(doc2, 'video');
|
|
expect(video).not.toBeNull();
|
|
expect(video.type).toBe('video');
|
|
expect(findFirst(doc2, 'image')).toBeNull();
|
|
expect(video.attrs.src).toBe('/v.mp4');
|
|
|
|
// align:'center' is normalized away via KNOWN_DEFAULTS.video, so the
|
|
// documents ARE canonically equal.
|
|
expect(docsCanonicallyEqual(doc, doc2)).toBe(true);
|
|
});
|
|
|
|
// 4. pdf with no numeric attrs (positive control) -------------------------
|
|
it('pdf with src+name+attachmentId (no numerics): byte- AND canonically-stable', async () => {
|
|
const doc = mkDoc([
|
|
{ type: 'pdf', attrs: { src: '/d.pdf', name: 'd.pdf', attachmentId: 'att-9' } },
|
|
]);
|
|
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
|
|
// #293 canon #8 link-form: `[name](src)` + the `pdf` discriminator; the id
|
|
// link (attachmentId) is data-loss-critical and rides in the comment JSON.
|
|
expect(md1).toBe('[d.pdf](/d.pdf)<!--pdf {"attachmentId":"att-9"}-->');
|
|
expect(md2).toBe(md1);
|
|
|
|
const pdf = findFirst(doc2, 'pdf');
|
|
expect(pdf).not.toBeNull();
|
|
expect(pdf.attrs.src).toBe('/d.pdf');
|
|
expect(pdf.attrs.name).toBe('d.pdf');
|
|
expect(pdf.attrs.attachmentId).toBe('att-9');
|
|
|
|
// No numeric attrs to coerce to strings, so the round-trip is BOTH byte- and
|
|
// canonically-stable (the positive control vs. the numeric-divergence cases).
|
|
expect(docsCanonicallyEqual(doc, doc2)).toBe(true);
|
|
});
|
|
|
|
// 5. attachment with numeric size ----------------------------------------
|
|
it('attachment with url+name+mime+size+attachmentId: byte-stable; size STRING; canonical FALSE', async () => {
|
|
const doc = mkDoc([
|
|
{
|
|
type: 'attachment',
|
|
attrs: {
|
|
url: '/f.zip',
|
|
name: 'f.zip',
|
|
mime: 'application/zip',
|
|
size: 512,
|
|
attachmentId: 'att-3',
|
|
},
|
|
},
|
|
]);
|
|
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
|
|
// #293 canon #8 link-form: url is the target, name the visible text, and
|
|
// mime/size/attachmentId ride in the `attachment` discriminator comment.
|
|
expect(md1).toBe(
|
|
'[f.zip](/f.zip)<!--attachment {"mime":"application/zip","size":"512","attachmentId":"att-3"}-->',
|
|
);
|
|
expect(md2).toBe(md1);
|
|
|
|
const att = findFirst(doc2, 'attachment');
|
|
expect(att).not.toBeNull();
|
|
expect(att.attrs.url).toBe('/f.zip');
|
|
expect(att.attrs.name).toBe('f.zip');
|
|
expect(att.attrs.mime).toBe('application/zip');
|
|
expect(att.attrs.attachmentId).toBe('att-3');
|
|
// data-attachment-size parseHTML -> getAttribute() -> STRING.
|
|
expect(att.attrs.size).toBe('512');
|
|
|
|
// The numeric size coerced to a string breaks canonical equality.
|
|
expect(docsCanonicallyEqual(doc, doc2)).toBe(false);
|
|
});
|
|
|
|
// 6. embed WITH explicit width/height/align (byte-stable) ----------------
|
|
it('embed with explicit src+provider+align+width+height: byte-stable; width/height STRINGS', async () => {
|
|
const doc = mkDoc([
|
|
{
|
|
type: 'embed',
|
|
attrs: {
|
|
src: 'https://x.com/e',
|
|
provider: 'iframe',
|
|
align: 'left',
|
|
width: 600,
|
|
height: 400,
|
|
},
|
|
},
|
|
]);
|
|
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
|
|
// #293 canon #8 link-form: provider is the visible text; the non-default
|
|
// align/width/height (defaults center/800/600) ride in the comment JSON.
|
|
expect(md1).toBe(
|
|
'[iframe](https://x.com/e)<!--embed {"align":"left","width":"600","height":"400"}-->',
|
|
);
|
|
expect(md2).toBe(md1);
|
|
|
|
const embed = findFirst(doc2, 'embed');
|
|
expect(embed).not.toBeNull();
|
|
expect(embed.attrs.src).toBe('https://x.com/e');
|
|
expect(embed.attrs.provider).toBe('iframe');
|
|
expect(embed.attrs.align).toBe('left');
|
|
// data-width / data-height parseHTML -> getAttribute() -> STRINGS.
|
|
expect(embed.attrs.width).toBe('600');
|
|
expect(embed.attrs.height).toBe('400');
|
|
});
|
|
|
|
// 7. minimal embed (only src+provider) -----------------------------------
|
|
it('minimal embed (src+provider): discriminator still emitted; byte-stable; defaults materialize as NUMBERS 800/600', async () => {
|
|
const doc = mkDoc([
|
|
{ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe' } },
|
|
]);
|
|
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
|
|
// #293 canon #8: align/width/height are all at their schema defaults
|
|
// (center/800/600), so the discriminator is name-only. Unlike the old
|
|
// div-form (which grew three data-* attrs on import), the md-form OMITS the
|
|
// defaults on BOTH exports, so it is byte-stable from export #1.
|
|
expect(md1).toBe('[iframe](https://x.com/e)<!--embed-->');
|
|
expect(md2).toBe(md1);
|
|
|
|
const embed = findFirst(doc2, 'embed');
|
|
expect(embed).not.toBeNull();
|
|
expect(embed.type).toBe('embed');
|
|
// Round-trips to EMBED, not a plain link.
|
|
expect(embed.attrs.provider).toBe('iframe');
|
|
expect(embed.attrs.align).toBe('center');
|
|
// NOTE: these come from the addAttributes default (NOT parseHTML), so they
|
|
// are the NUMBERS 800/600 — parseHTML only runs when the attribute is present.
|
|
expect(embed.attrs.width).toBe(800);
|
|
expect(embed.attrs.height).toBe(600);
|
|
});
|
|
|
|
// 8. youtube with src+width+height+align ---------------------------------
|
|
it('youtube with src+width+height+align(right): byte-stable; width/height STRINGS; canonical FALSE', async () => {
|
|
const doc = mkDoc([
|
|
{
|
|
type: 'youtube',
|
|
attrs: {
|
|
src: 'https://youtu.be/abc',
|
|
width: 560,
|
|
height: 315,
|
|
align: 'right',
|
|
},
|
|
},
|
|
]);
|
|
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
|
|
// #293 canon #8 image-form: width/height/align(right) ride in the `youtube`
|
|
// discriminator comment (align "right" is non-default so it is kept).
|
|
expect(md1).toBe(
|
|
'<!--youtube {"width":"560","height":"315","align":"right"}-->',
|
|
);
|
|
expect(md2).toBe(md1);
|
|
|
|
const yt = findFirst(doc2, 'youtube');
|
|
expect(yt).not.toBeNull();
|
|
expect(yt.attrs.src).toBe('https://youtu.be/abc');
|
|
expect(yt.attrs.align).toBe('right');
|
|
// data-width / data-height parseHTML -> getAttribute() -> STRINGS.
|
|
expect(yt.attrs.width).toBe('560');
|
|
expect(yt.attrs.height).toBe('315');
|
|
|
|
// Numeric width/height coerced to strings; align='right' is non-default so
|
|
// it is kept (not in KNOWN_DEFAULTS.youtube's normalization). Canonical FALSE.
|
|
expect(docsCanonicallyEqual(doc, doc2)).toBe(false);
|
|
});
|
|
});
|