diff --git a/README.md b/README.md index 9692f3c..7ef942e 100644 --- a/README.md +++ b/README.md @@ -93,29 +93,24 @@ silently discarded. node's attributes behind, so the envelope has to be re-attached. See `src/editor.ts`. - The language attribute: an imported `{lang="fr"}` span keeps its value on the - span mark and serializes back as the `{:fr}` sugar, a `` in pasted - HTML parses onto the same mark, and a value that is not a language tag keeps - the `{lang="..."}` spelling. Asserted in `tests/language-attribute.test.ts`. + span mark and serializes back as the `{:fr}` sugar, the `{:fr}` shorthand + typed straight into the source pane parses onto that mark, a `` in + pasted HTML parses onto the same mark, and a value that is not a language tag + keeps the `{lang="..."}` spelling. Asserted in + `tests/language-attribute.test.ts`. +- **Composite figures** (`::: figure` with no title and no label, Carve PART 9 + section 4c): the group is one editable `carveFigureGroup`, its direct figure + and table children are the panels in source order, and the `^ ` caption below + the closing fence is the group's. Everything else in the body stays where it + was written. An opener carrying a quoted title or a `[label]` is a different + production and remains the generic container it always was. The mapping is + the CarveKit schema in `@markup-carve/carve-grammars`, not this app; both + halves of it - an engine that parses the construct and a schema entry that + models it - arrived with markup-carve/carve-grammars#225. Asserted in + `tests/composite-figure.test.ts`. **Lossy / normalized (documented, not hidden):** -- **`{:TAG}` written directly in the Carve source pane** does not become a span - on import. The Tiptap layer carries the attribute in both directions, but the - parse happens in the carve-js build that carve-grammars pins for its own - loader (an exact commit inside carve-grammars, so this repository's pins - cannot move it), and that build predates the production: the run stays - literal text and comes back with the bracket escaped. Authoring the same span - as `{lang="fr"}` works today and serializes as `{:fr}`. -- **Composite figures** (`::: figure` with no title and no label, Carve PART 9 - section 4c) are not modelled as figures. The mapping is the CarveKit schema in - `@markup-carve/carve-grammars`, not this app, and the engine that parses the - editor's input is the one carve-grammars pins for its own loader - which - predates the construct. So today a composite figure is a generic container - and round-trips as one; when that engine moves, the group arrives as a single - read-only source atom until carve-grammars gives it a schema entry. - `tests/composite-figure.test.ts` holds both states and fails when the second - one changes, which is the signal to model it here. Tracked as - markup-carve/carve-wysiwyg#15. - **CriticMarkup containing its own closing delimiter** (`+}` / `-}` inside `{+...+}` / `{-...-}`) cannot round-trip - Carve provides no escape for it. This is an upstream serializer limitation noted in carve-grammars. diff --git a/package-lock.json b/package-lock.json index 4a6a690..504f619 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@markup-carve/carve": "^0.1.2", - "@markup-carve/carve-grammars": "github:markup-carve/carve-grammars#c0e72914c6b76c81374b48d7a225cc66549934e4", + "@markup-carve/carve-grammars": "github:markup-carve/carve-grammars#8a5d4c85f7d51daaa258bb10c9f1338113a02ed3", "@tiptap/core": "^2.11.5", "@tiptap/extension-code-block": "^2.11.5", "@tiptap/extension-highlight": "^2.11.5", @@ -45,8 +45,8 @@ }, "node_modules/@markup-carve/carve": { "version": "0.1.3", - "resolved": "git+ssh://git@github.com/markup-carve/carve-js.git#8d38a03cc8664e761f81baa02a1bf54e7dde96a4", - "integrity": "sha512-bgWqsw+GotXoqZDAzuSDT3Rb4EoYlbSPlYhlb1pmEkGE531NnWnrZ4ndS/kkW/5bCd+ypS0hK6HOuFghQNa3Rw==", + "resolved": "git+ssh://git@github.com/markup-carve/carve-js.git#3f5dd8cb6b48d3dbf064f1a350c8493b93030170", + "integrity": "sha512-vN3XZe8fsIQtaYc7dBjNUOW2R4M3I5iH8P8Ep3WNM+3EYtblPt6ywP4q7hFaBkPSS0lkUrUWwrITRP5VsIQ0XA==", "license": "MIT", "dependencies": { "parse5": "^7.3.0" @@ -68,11 +68,11 @@ }, "node_modules/@markup-carve/carve-grammars": { "version": "0.1.4", - "resolved": "git+ssh://git@github.com/markup-carve/carve-grammars.git#c0e72914c6b76c81374b48d7a225cc66549934e4", - "integrity": "sha512-mya89cbhDNIVUvNDvUWpcU2WEcwEg6Mchx+8wlk4/Kg6dpIuIHPn+B4d3hibtU/YK9hgRiqA9/bKtOGIaAB44Q==", + "resolved": "git+ssh://git@github.com/markup-carve/carve-grammars.git#8a5d4c85f7d51daaa258bb10c9f1338113a02ed3", + "integrity": "sha512-3DgSWWQFawEMPjBhYq683KVClDlv6Uk21o9CvBNdUGVJlUCv1n7iYxchql5v++upVjGQ5sxNGOhpn5lu8G6kLw==", "license": "MIT", "dependencies": { - "@markup-carve/carve": "github:markup-carve/carve-js#8d38a03cc8664e761f81baa02a1bf54e7dde96a4" + "@markup-carve/carve": "github:markup-carve/carve-js#3f5dd8cb6b48d3dbf064f1a350c8493b93030170" }, "peerDependencies": { "@shikijs/themes": "^2 || ^3", diff --git a/package.json b/package.json index afd2177..f562fee 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@markup-carve/carve": "^0.1.2", - "@markup-carve/carve-grammars": "github:markup-carve/carve-grammars#c0e72914c6b76c81374b48d7a225cc66549934e4", + "@markup-carve/carve-grammars": "github:markup-carve/carve-grammars#8a5d4c85f7d51daaa258bb10c9f1338113a02ed3", "@tiptap/core": "^2.11.5", "@tiptap/extension-code-block": "^2.11.5", "@tiptap/extension-highlight": "^2.11.5", diff --git a/src/editor.ts b/src/editor.ts index c5ad619..337a8c7 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -7,7 +7,7 @@ * exposes the serializer so the live Carve source pane can update on every * change. */ -import { Editor } from '@tiptap/core'; +import { Editor, getSchema } from '@tiptap/core'; import type { JSONContent } from '@tiptap/core'; import { CarveKit, serializeToCarve } from '@markup-carve/carve-grammars/tiptap'; @@ -56,7 +56,13 @@ type Envelope = Record; * own: the fingerprint is that check, and the serializer falls through to * ordinary serialization the moment the document is edited. */ -const envelopes = new WeakMap(); +const loaded = new WeakMap(); + +/** + * The CarveKit schema, read once, so the attribute values Tiptap materializes + * from a node's declared defaults can be told from values the bridge set. + */ +const schema = getSchema([CarveKit]); /** The envelope attrs of a bridge document, or null when it carries none. */ function envelopeOf(doc: JSONContent): Envelope | null { @@ -69,42 +75,93 @@ function envelopeOf(doc: JSONContent): Envelope | null { return Object.keys(kept).length ? kept : null; } +/** Every attribute the schema declares for a node or mark, with its default. */ +function schemaDefaults(type: unknown): Record | null { + if (typeof type !== 'string') return null; + const spec = schema.nodes[type] ?? schema.marks[type]; + if (!spec) return null; + const out: Record = {}; + for (const [name, attr] of Object.entries(spec.spec.attrs ?? {})) { + out[name] = (attr as { default?: unknown }).default; + } + return out; +} + /** - * Drop attributes the editor materialized from schema defaults. + * A document reduced to what the AUTHOR wrote, with everything the schema would + * put back stripped out. + * + * The point is to recognize an unedited document after a mount. Tiptap hands + * back every attribute a node's schema declares, so `{"class":"figure"}` comes + * back as `{"id":null,"keyValues":null,"label":null,"class":"figure", + * "title":null}` and the mounted document never compares equal to the one that + * was loaded. * - * The envelope is guarded by a FINGERPRINT of the document it was taken from, - * and the fingerprint is over the bridge's JSON - which carries only the attrs - * that were actually set. Tiptap hands back every attribute the schema - * declares, so `{"class":"figure"}` returns as - * `{"id":null,"keyValues":null,"label":null,"class":"figure","title":null}` and - * the two never compare equal. Re-attaching the envelope without this is - * therefore inert: the fingerprint check fails every time and the verbatim - * source is never used. + * DROPPING NULLS IS NOT ENOUGH, and that is what this used to do. A default + * does not have to be null: `carveCaption` declares `short` with a default of + * `false`, so a caption came back carrying `{"short":false}` - not null, not + * pruned, never equal. The moment carve-grammars added that attribute, every + * document holding a caption stopped being recognized, the source envelope was + * discarded, and a block-attribute line above a construct the rich model does + * not carry was written back out without it. Nothing failed: the round trip + * simply became lossy, which is the failure mode the envelope exists to + * prevent. * - * Dropping nulls is not a reinterpretation of the document. The serializer - * reads every one of these with optional chaining, so an absent attribute and a - * null one already mean the same thing to it - what changes is only whether the - * fingerprint can recognize its own document. + * SYMMETRY IS THE OTHER HALF. This runs over BOTH documents, never over the + * mounted one alone. The bridge does set some attributes to a value that is + * also the schema default - `carveComment` writes `block: false` for a `%%` + * line - so pruning only the mounted side would break exactly the documents + * pruning is meant to keep. */ -function pruneDefaults(value: unknown): unknown { - if (Array.isArray(value)) return value.map(pruneDefaults); +function authored(value: unknown): unknown { + if (Array.isArray(value)) return value.map(authored); if (!value || typeof value !== 'object') return value; + const node = value as Record; + const defaults = schemaDefaults(node['type']); const out: Record = {}; - for (const [key, inner] of Object.entries(value)) { + for (const [key, inner] of Object.entries(node)) { if (inner === null) continue; - out[key] = pruneDefaults(inner); + if (key !== 'attrs' || !inner || typeof inner !== 'object') { + out[key] = authored(inner); + continue; + } + const attrs: Record = {}; + for (const [name, attrValue] of Object.entries(inner as Record)) { + if (attrValue === null) continue; + if (defaults && name in defaults && stable(attrValue) === stable(defaults[name])) continue; + attrs[name] = authored(attrValue); + } + if (Object.keys(attrs).length) out['attrs'] = attrs; } - const attrs = out['attrs']; - if (attrs && typeof attrs === 'object' && !Object.keys(attrs).length) delete out['attrs']; return out; } -/** `editor.getJSON()` with the loaded document's envelope put back on it. */ +/** Key-order-independent string form, so two equal documents compare equal. */ +function stable(value: unknown): string { + if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']'; + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + return '{' + Object.keys(value as object).sort() + .map((k) => JSON.stringify(k) + ':' + stable((value as Record)[k])) + .join(',') + '}'; +} + +/** + * What to serialize: the document as loaded when the editor still holds it, and + * the editor's own JSON once it has been edited. + * + * Handing the serializer the ORIGINAL document rather than a reconstruction of + * it is what makes the envelope usable. Its fingerprint was taken over that + * exact JSON, and the envelope is only honored while the fingerprint matches - + * so anything less than the original is a guess at what the fingerprint will + * accept. After an edit there is nothing to preserve: the editor's document IS + * the document, and ordinary serialization is correct. + */ function withEnvelope(editor: Editor, json: JSONContent): JSONContent { - const envelope = envelopes.get(editor); - if (!envelope) return json; - const pruned = pruneDefaults(json) as JSONContent; - return { ...pruned, attrs: { ...(pruned.attrs ?? {}), ...envelope } }; + const entry = loaded.get(editor); + if (!entry) return json; + const unedited = stable(authored(json.content ?? [])) + === stable(authored(entry.doc.content ?? [])); + return unedited ? entry.doc : json; } export function createCarveEditor(opts: CarveEditorOptions): Editor { @@ -127,8 +184,8 @@ export function createCarveEditor(opts: CarveEditorOptions): Editor { */ export function setCarveDocument(editor: Editor, doc: JSONContent): void { const envelope = envelopeOf(doc); - if (envelope) envelopes.set(editor, envelope); - else envelopes.delete(editor); + if (envelope) loaded.set(editor, { doc, envelope }); + else loaded.delete(editor); editor.commands.setContent(doc); } diff --git a/src/style.css b/src/style.css index c607464..0380114 100644 --- a/src/style.css +++ b/src/style.css @@ -125,3 +125,46 @@ body { .hard-break { color: var(--muted); } .carve-insert { text-decoration: underline; text-decoration-color: #9ece6a; } .carve-delete { text-decoration: line-through; text-decoration-color: #f7768e; } + +/* + * Composite figures (Carve PART 9 section 4c). + * + * TWO SHAPES, ONE LOOK. The preview pane holds the engine's HTML - a + * `figure.carve-figure-group` wrapping a `div.carve-figure-panels` - while the + * editor holds CarveKit's node, which renders the panels directly inside the + * group with no panels row. Both are styled here so the group reads as one + * figure on either side of the split. + */ +.carve-figure-group, +figure[data-carve-figure-group] { + margin: 0.75rem 0; + padding: 0.5rem; + border: 1px solid var(--accent); + border-radius: 4px; +} +.carve-figure-panels, +figure[data-carve-figure-group] { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-start; +} +/* The group's own caption spans the row rather than sitting beside a panel. */ +figure[data-carve-figure-group] > figcaption { + flex-basis: 100%; +} +.carve-figure-panel, +figure[data-carve-figure-group] > figure[data-carve-figure] { + flex: 1 1 12rem; + margin: 0; +} +.carve-figure-panel img, +figure[data-carve-figure-group] img { + max-width: 100%; +} +.carve-figure-group > figcaption, +figure[data-carve-figure-group] > figcaption { + margin-top: 0.5rem; + font-style: italic; + color: var(--muted); +} diff --git a/tests/blockquote-attribution.test.ts b/tests/blockquote-caption.test.ts similarity index 77% rename from tests/blockquote-attribution.test.ts rename to tests/blockquote-caption.test.ts index 6f5a8a3..5056a0c 100644 --- a/tests/blockquote-attribution.test.ts +++ b/tests/blockquote-caption.test.ts @@ -1,13 +1,19 @@ /** - * A quote's `^ …` attribution, through the editor's own import and serialize - * path. + * A caption on a quote, through the editor's own import and serialize path. * - * The engine carries a caption on a quote as an `attribution` field on - * `block_quote` rather than a `figure`/`figcaption` pair, and the pinned - * carve-grammars loader has to project that field onto an editable node. When - * it does not, the line survives only inside the whole-document source - * envelope, which is keyed to a fingerprint of the untouched document - so the - * FIRST EDIT drops it. + * WHICH SHAPE THE ENGINE USES HAS CHANGED TWICE, and this file deliberately + * does not care. PART 9 section 4a briefly made the caption an `attribution` + * field on `block_quote`; that clause is withdrawn (markup-carve/carve#1213), + * and section 4b now says a quote is not a special host - a captioned quote is + * a `figure` whose target is the quote, which the loader projects onto a + * `carveFigure`/`carveCaption` pair like any other captioned host. The + * assertions below are about the line reaching the user's document, not about + * the node it arrives in, so they held across both pins. + * + * What they DO depend on is the line reaching an editable node rather than + * only the whole-document source envelope, which is keyed to a fingerprint of + * the untouched document - so the FIRST EDIT drops anything that lives only + * there. * * That is why every case here EDITS before serializing. Loading and serializing * an untouched document returns the envelope verbatim and passes at any pin, @@ -62,7 +68,7 @@ function editableText(): string { return ((doc.content ?? []) as Array[0]>).map(walk).join(' '); } -describe("a quote's attribution survives an edit", () => { +describe("a quote's caption survives an edit", () => { it('keeps the attribution when something else is edited', () => { const out = editElsewhereAndSerialize('> Stay hungry, stay foolish.\n^ Steve Jobs\n'); expect(out).toContain('EDITED'); diff --git a/tests/composite-figure.test.ts b/tests/composite-figure.test.ts index 0eb397a..96f44bd 100644 --- a/tests/composite-figure.test.ts +++ b/tests/composite-figure.test.ts @@ -1,33 +1,28 @@ /** - * Composite figures (Carve PART 9 section 4c, markup-carve/carve#1215) in the editor. + * Composite figures (Carve PART 9 section 4c, markup-carve/carve#1122) in the editor. * - * WHERE THE MAPPING LIVES, because this repository is not it. The + * WHERE THE MAPPING LIVES, because this repository is still not it. The * Carve <-> ProseMirror bridge - the CarveKit schema, `carveToProseMirror` and * `serializeToCarve` - is `@markup-carve/carve-grammars`, and the engine that * parses the source on the way in is the one carve-grammars nests for its own - * loader, not the `@markup-carve/carve` this app installs for the preview - * pane. So a `figure_group` node type reaches the editor only when - * carve-grammars ships both an engine that parses it and a schema entry that - * models it. Nothing in `src/` can do either. + * loader. What changed is that carve-grammars now ships both halves: an engine + * that parses a bare `::: figure` into a `figure_group`, and a + * `carveFigureGroup` schema entry that models it + * (markup-carve/carve-grammars#225). * - * What this file does instead is measure that boundary rather than assert it, - * in the two states it can be in: + * This file used to pin the DEGRADED state - a group arriving as one opaque + * source atom - and said in as many words that it would go red the day + * carve-grammars gave the group a schema entry, which is the signal to wire the + * editor up to it. That day is this commit, and what follows is the wiring: + * the rich shape the editor holds, the round trip through the app's own import + * and serialize path, and an EDIT, which is the case the opaque atom could + * never survive. * - * 1. THE ENGINE THE EDITOR ACTUALLY RUNS TODAY predates section 4c, so a bare - * `::: figure` is a generic container and survives the round trip. That is - * the state, and it is worth a test because "it happens to work" and "it is - * modelled" look identical from the outside. - * 2. WHAT ARRIVES WHEN THE ENGINE MOVES, exercised through a `figure_group` - * AST captured from an engine that has the node. It reaches the editor as - * one opaque source atom - lossless, and not editable as a figure. - * - * The second test is the handshake: it goes red the day carve-grammars gives - * the group a real schema entry, which is the signal to wire the editor up to - * it. See markup-carve/carve-wysiwyg#15. + * Closes markup-carve/carve-wysiwyg#15. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { Editor } from '@tiptap/core'; -import { CarveKit, serializeToCarve, astToProseMirror } from '@markup-carve/carve-grammars/tiptap'; +import { CarveKit, astToProseMirror } from '@markup-carve/carve-grammars/tiptap'; import { carveToEditorDocument } from '../src/carve-import'; import { editorToCarve, setCarveDocument } from '../src/editor'; import fixture from './fixtures/figure-group.ast.json'; @@ -50,6 +45,14 @@ function roundTrip(source: string): string { return editorToCarve(editor); } +interface Node { + type: string; + attrs?: Record; + content?: Node[]; +} + +const types = (node: Node | undefined): string[] => (node?.content ?? []).map((c) => c.type); + const GROUP = [ '{#fig-x}', '::: figure', @@ -69,61 +72,79 @@ const GROUP = [ * The CONTROL spelling. Under section 4c an opener carrying a quoted title is * NOT the composite production - it stays a generic container. * - * IT DOES NOT DISCRIMINATE ANYTHING YET, and saying so is the point. The engine - * the editor runs predates section 4c, so both spellings parse to the same - * generic container and this pair proves only that neither is mangled. Reading - * a passing pair as evidence that the two are told apart is the exact mistake - * this construct invites, so the discrimination is asserted where it can be - - * against the captured AST below - and not here. + * IT DISCRIMINATES NOW, which it did not before. The engine the editor ran + * predated section 4c, so both spellings parsed to the same generic container + * and the pair proved only that neither was mangled; the comment here said so. + * The installed engine tells them apart, and the first case below asserts the + * two READINGS differ rather than only asserting each one - so if the pin ever + * moves back to an engine without section 4c, this fails instead of quietly + * agreeing with itself. */ const TITLED = GROUP.replace('::: figure', '::: figure "A titled figure div"'); describe('composite figures through the editor', () => { - it('a bare figure container survives the round trip under the engine in use', () => { - expect(roundTrip(GROUP)).toBe(GROUP); + it('a bare opener is a group and a titled one is not - and the two differ', () => { + const group = carveToEditorDocument(GROUP) as Node; + const titled = carveToEditorDocument(TITLED) as Node; + + expect(group.content![0]!.type).toBe('carveFigureGroup'); + expect(titled.content![0]!.type).toBe('carveDiv'); + expect(group.content![0]!.type).not.toBe(titled.content![0]!.type); + // The titled reading keeps its metadata, which is what makes it a lossless + // degradation rather than a different document. + expect(titled.content![0]!.attrs?.title).toBe('A titled figure div'); + }); + + it('the panels are the figure children, in source order, under the group caption', () => { + const group = (carveToEditorDocument(GROUP) as Node).content![0]!; + expect(types(group)).toEqual(['carveFigure', 'carveFigure', 'carveCaption']); + expect(group.attrs?.id).toBe('fig-x'); + expect(group.content!.filter((c) => c.type === 'carveFigure').map((p) => p.attrs?.id)) + .toEqual(['fig-a', 'fig-b']); + const caption = group.content![group.content!.length - 1]!; + expect(JSON.stringify(caption)).toContain('Group caption'); + }); + + it('a bare figure container survives the round trip', () => { + // The serializer writes no trailing newline, which is true of every + // document it writes and not of this construct. + expect(roundTrip(GROUP)).toBe(GROUP.replace(/\n$/, '')); }); it('so does the titled spelling, which is a different production', () => { expect(roundTrip(TITLED)).toBe(TITLED); }); - it('the engine in use does not produce the node yet', () => { - // The premise the two tests above rest on, checked rather than assumed: if - // this ever fails, they stopped describing a pre-section-4c engine and the - // group test above may be passing for a different reason than it says. - const doc = carveToEditorDocument(GROUP) as { content?: Array<{ type?: string }> }; - expect(doc.content?.[0]?.type).toBe('carveDiv'); + it('the group survives an edit, which is what the opaque atom could not do', () => { + // The case the whole ticket is about. As one `carveUnsupported` atom the + // group's source lived in the document envelope, which is keyed to a + // fingerprint of the untouched document - so the FIRST edit anywhere + // discarded it. Loading and serializing an untouched document returns the + // envelope verbatim and would pass either way, which is why this edits. + setCarveDocument(editor, carveToEditorDocument(GROUP)); + editor.commands.insertContentAt(1, 'EDITED'); + const out = editorToCarve(editor); + expect(out).toContain('EDITED'); + expect(out).toContain('::: figure'); + expect(out).toContain('![one](a.png)'); + expect(out).toContain('![two](b.png)'); + expect(out).toContain('^ Group caption'); + expect(out).toContain('{#fig-x}'); }); - it('a figure_group reaches the editor as one opaque atom, not as a figure', () => { - // The AST an engine WITH section 4c hands the bridge, captured from - // fixture.capturedFrom - the installed engine cannot produce it, so it is - // supplied rather than parsed. - // - // WHEN THIS FAILS, carve-grammars has given the group a schema entry. - // That is the moment to model it here: drop this expectation, assert the - // rich shape, and give the preview pane styles for - // carve-figure-group / carve-figure-panels / carve-figure-panel. - const doc = astToProseMirror(fixture.ast, { + it('the captured engine AST maps to the same node as the live parse', () => { + // fixture.capturedFrom names the carve-js commit this AST came from. It was + // supplied because the installed engine could not produce it; it now can, + // so the fixture's job changes from standing in for the engine to checking + // that a document arriving as an AST - `--from-json`, another engine, a + // stored document - maps exactly as source does. + const fromAst = astToProseMirror(fixture.ast, { unsupported: 'preserve', source: fixture.source, - }) as { content: Array<{ type: string; attrs?: { carveSource?: string } }> }; - - expect(doc.content.map((n) => n.type)).toEqual(['carveUnsupported']); - - // Lossless as SOURCE, which is the guarantee `preserve` actually makes - - // the group is not silently dropped and not flattened into its panels. - const kept = doc.content[0]!.attrs!.carveSource!; - expect(kept).toContain('::: figure'); - expect(kept).toContain('![one](a.png)'); - expect(kept).toContain('^ Group caption'); - expect(serializeToCarve(doc)).toBe(kept); - - // And the loss that IS there, named rather than left to be discovered: a - // block's position excludes the block-attribute line above it (every block - // type, not just this one), so the slice starts at the opening fence and - // the group's own `{#fig-x}` is not in it. - expect(kept.startsWith('::: figure')).toBe(true); - expect(kept).not.toContain('{#fig-x}'); + }) as Node; + const fromSource = carveToEditorDocument(fixture.source) as Node; + + expect(fromAst.content!.map((n) => n.type)).toEqual(['carveFigureGroup']); + expect(JSON.stringify(fromAst.content)).toBe(JSON.stringify(fromSource.content)); }); }); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 8a306ff..409b24b 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -106,3 +106,42 @@ describe('Carve round trip (source -> AST -> editor -> source)', () => { }); } }); + +/** + * The source envelope, which is what carries a block-attribute line above a + * construct the rich model does not fully hold. It is honored only while the + * mounted document is still recognizable as the one that was loaded, and + * `src/editor.ts` decides that by reducing both documents to what the author + * wrote - dropping every attribute Tiptap materialized from a schema default. + * + * BOTH sides, and that is the point of the second case. The bridge sets some + * attributes to a value that is ALSO the schema default, so reducing only the + * mounted side makes those documents stop matching and quietly discards the + * envelope they depend on. That is not hypothetical: `carveComment` declares + * `block` with a default of `false` and the bridge writes `block: false` for + * every `%%` line. + */ +describe('the source envelope survives a mount', () => { + it('keeps a block-attribute line above a container the editor models partly', () => { + expect(roundTrip('{#fig-x}\n::: note\nBody.\n:::\n')).toContain('{#fig-x}'); + }); + + it('keeps it when the document also holds an attribute set to its own default', () => { + // A `%%` line, whose `block: false` equals the schema default. Reducing + // only the mounted document loses the `{#fig-x}` here while the case above + // still passes, so the pair is what pins the symmetry. + const out = roundTrip('{#fig-x}\n::: note\n%% a note\n:::\n'); + expect(out).toContain('{#fig-x}'); + expect(out).toContain('%% a note'); + }); + + it('control: an edited document is serialized from the editor, not the envelope', () => { + setCarveDocument(editor, carveToEditorDocument('{#fig-x}\n::: note\nBody.\n:::\n')); + editor.commands.insertContentAt(2, 'EDITED'); + const out = editorToCarve(editor); + expect(out).toContain('EDITED'); + // Without this the two cases above would pass for a serializer that + // returned the loaded source unconditionally, which is not preservation. + expect(out).not.toBe('{#fig-x}\n::: note\nBody.\n:::\n'); + }); +});