From 2314d4b09ba136b3fc9ba255b76abec6cc315f0d Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 15 Aug 2026 20:23:32 +0200 Subject: [PATCH 1/2] feat: a composite figure is an editable figure in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markup-carve/carve-grammars#225 gave the Tiptap layer a `carveFigureGroup` node and moved its engine pin onto a carve-js build that parses a bare `::: figure` into a `figure_group`. Both halves were the blocker; this bumps the pin onto them and wires the editor up. A group now loads as one editable node: its direct `carveFigure` and table children are the panels in source order, the `^ ` line below the closing fence is the group's own caption, and everything else in the body stays where it was written. Before this, the whole document became one opaque `carveUnsupported` atom whose source lived in the document envelope - lossless to load and save, and gone on the first edit anywhere. `tests/composite-figure.test.ts` said in as many words that it would go red the day carve-grammars gave the group a schema entry, and that this was the signal to model it here. It went red, and it now asserts the rich shape, the round trip through the app's own import and serialize path, and an EDIT - the case the opaque atom could never survive. The titled and labelled openers, which are a different production, keep their old reading; that pair used to prove nothing because the engine could not tell them apart, and the test now asserts the two READINGS DIFFER so it fails rather than agrees with itself if the pin ever moves back. THE BUMP ALSO SURFACED A SILENT REGRESSION, and it is not about figures. `pruneDefaults` in src/editor.ts removed attributes the editor materialized from schema defaults, so an unedited document could be recognized and its source envelope honored - but it only removed NULL ones. A default does not have to be null: carve-grammars#221 gave `carveCaption` a `short` attribute defaulting to `false`, so every document holding a caption came back carrying `{"short":false}`, never compared equal, lost its envelope, and was written back from the lossy rich model. The visible loss was `{#fig-x}` above a titled `::: figure` disappearing on save. Nothing failed - the round trip just became lossy, which is the failure the envelope exists to prevent. The fix compares the mounted document with the one that was loaded, both reduced to what the author wrote, and hands the serializer the ORIGINAL document when they match. Reducing BOTH sides is the other half: 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 exists to keep. Handing over the original rather than a reconstruction of it is what makes the envelope usable at all - its fingerprint was taken over that exact JSON. Also here: - Preview and editor styles for the group. The preview pane holds the engine's HTML (`figure.carve-figure-group` around `div.carve-figure-panels`) and the editor holds CarveKit's node, which has no panels row, so both shapes are styled to read as one figure. - `tests/blockquote-attribution.test.ts` is `blockquote-caption.test.ts`. Its assertions were already shape-independent and passed unchanged; its docblock claimed the engine carries a quote's caption as an `attribution` field, which PART 9 §4a said and §4b withdrew (markup-carve/carve#1213). A captioned quote is a `figure` whose target is the quote, and the loader builds the `carveFigure`/`carveCaption` pair for it. - Two README limitations retired. The composite-figure one is this change. The `{:TAG}`-in-the-source-pane one was already false before it - carve-wysiwyg#14 moved the pin onto a build that parses it, and `tests/language-attribute.test.ts` has asserted it since - so the README was contradicting a passing test. --- README.md | 35 ++--- package-lock.json | 12 +- package.json | 2 +- src/editor.ts | 113 ++++++++++---- src/style.css | 43 ++++++ ...ion.test.ts => blockquote-caption.test.ts} | 24 +-- tests/composite-figure.test.ts | 145 ++++++++++-------- 7 files changed, 248 insertions(+), 126 deletions(-) rename tests/{blockquote-attribution.test.ts => blockquote-caption.test.ts} (77%) 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)); }); }); From 0920384f215f064125a93aced6e6d08f6b2565cc Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 15 Aug 2026 20:24:26 +0200 Subject: [PATCH 2/2] test: the envelope check has to reduce BOTH documents, not just the mounted one Reducing only the mounted document was a mutation the suite did not notice. It is a real failure: the bridge sets some attributes to a value that is also the schema default - `carveComment` declares `block` with a default of `false` and writes `block: false` for every `%%` line - so a one-sided reduction makes those documents stop matching their own loaded form and discards the envelope they depend on. The pair is what pins it. A `{#fig-x}` above a `::: note` passes either way; the same document with a `%%` line inside loses the attribute line under a one-sided reduction. A third case edits the document and asserts the output CHANGED, so a serializer that simply returned the loaded source could not pass the first two. --- tests/roundtrip.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) 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'); + }); +});