diff --git a/README.md b/README.md index 8d3f7e8..9692f3c 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,12 @@ silently discarded. - Admonition divs (`:::warning`) and their container class. - Footnotes (reference + definition), including their authored labels. - Unsupported constructs such as frontmatter through source preservation. +- Block attributes above a construct the editor models only partly - a + `{#fig-x}` over a `:::` fence - through the document's source envelope. Loads + go through `setCarveDocument` and saves through `editorToCarve` for that + reason: Tiptap's `setContent` replaces the doc's content and leaves the doc + 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 @@ -100,6 +106,16 @@ silently discarded. 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/src/editor.ts b/src/editor.ts index 321a2cd..c5ad619 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -8,6 +8,7 @@ * change. */ import { Editor } from '@tiptap/core'; +import type { JSONContent } from '@tiptap/core'; import { CarveKit, serializeToCarve } from '@markup-carve/carve-grammars/tiptap'; export interface CarveEditorOptions { @@ -16,21 +17,124 @@ export interface CarveEditorOptions { onUpdate?: (carve: string) => void; } +/** The document-level attribute names CarveKit's source envelope uses. */ +const ENVELOPE_ATTRS = ['carveSource', 'carveFingerprint', 'carveSourceLayout'] as const; + +type Envelope = Record; + +/** + * The source envelope of the document currently loaded into each editor. + * + * WHY THIS IS HELD HERE RATHER THAN ON THE DOCUMENT. `carveToProseMirror` + * returns a doc carrying `carveSource` / `carveFingerprint` / + * `carveSourceLayout` whenever the rich model would be lossy to write back, and + * `serializeToCarve` writes that source verbatim for as long as the fingerprint + * still matches the document - which is how a construct with no exact editor + * model survives load/save instead of being normalized away. + * + * Tiptap's `setContent` does not carry them. It dispatches + * `tr.replaceWith(0, doc.content.size, document)`, which replaces the doc's + * CONTENT and leaves the doc NODE - and therefore its attributes - as it was, + * so the envelope never reaches `editor.getJSON()`. Measured, not assumed: the + * import produces the three attrs and the editor hands back all three as + * `null`. + * + * The visible symptom was a block-attribute line above a fenced div. CarveKit's + * `carveDiv` models the class but not the `{#id}`, so the rich document is + * lossy, the bridge produced an envelope, the envelope was dropped, and + * + * {#fig-x} + * ::: figure + * ![one](a.png) + * ^ (a) One + * ::: + * + * came back out without its `{#fig-x}` on the first save. + * + * A WeakMap rather than a field on the editor so nothing is retained after an + * editor is destroyed. Re-attaching is safe without any staleness check of its + * own: the fingerprint is that check, and the serializer falls through to + * ordinary serialization the moment the document is edited. + */ +const envelopes = new WeakMap(); + +/** The envelope attrs of a bridge document, or null when it carries none. */ +function envelopeOf(doc: JSONContent): Envelope | null { + const attrs = (doc as { attrs?: Envelope }).attrs; + if (!attrs) return null; + const kept: Envelope = {}; + for (const name of ENVELOPE_ATTRS) { + if (attrs[name] != null) kept[name] = attrs[name]; + } + return Object.keys(kept).length ? kept : null; +} + +/** + * Drop attributes the editor materialized from schema defaults. + * + * 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 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. + */ +function pruneDefaults(value: unknown): unknown { + if (Array.isArray(value)) return value.map(pruneDefaults); + if (!value || typeof value !== 'object') return value; + const out: Record = {}; + for (const [key, inner] of Object.entries(value)) { + if (inner === null) continue; + out[key] = pruneDefaults(inner); + } + 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. */ +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 } }; +} + export function createCarveEditor(opts: CarveEditorOptions): Editor { - const editor = new Editor({ + const editor: Editor = new Editor({ element: opts.element, extensions: [CarveKit], content: opts.content ?? '', onUpdate: ({ editor }) => { - opts.onUpdate?.(serializeToCarve(editor.getJSON())); + opts.onUpdate?.(editorToCarve(editor)); }, }); return editor; } +/** + * Load a bridge document into the editor, keeping its source envelope. + * + * Every load goes through here rather than through `setContent` directly, so + * the previous document's envelope cannot outlive it. + */ +export function setCarveDocument(editor: Editor, doc: JSONContent): void { + const envelope = envelopeOf(doc); + if (envelope) envelopes.set(editor, envelope); + else envelopes.delete(editor); + editor.commands.setContent(doc); +} + /** Serialize the current editor document to Carve markup. */ export function editorToCarve(editor: Editor): string { - return serializeToCarve(editor.getJSON()); + return serializeToCarve(withEnvelope(editor, editor.getJSON())); } /** Re-serialize an arbitrary ProseMirror/Tiptap JSON doc. */ diff --git a/src/main.ts b/src/main.ts index 2bd4342..6ad3915 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,7 @@ import { Editor } from '@tiptap/core'; import type {} from '@tiptap/starter-kit'; import type {} from '@tiptap/extension-link'; import type {} from '@tiptap/extension-underline'; -import { createCarveEditor, editorToCarve } from './editor'; +import { createCarveEditor, editorToCarve, setCarveDocument } from './editor'; import { carveToEditorDocument, carveToHtmlRaw } from './carve-import'; const SAMPLE = `# Carve WYSIWYG @@ -62,9 +62,10 @@ function refreshOutputs(carve: string): void { /** Load Carve source into the editor (import direction). */ function loadCarve(source: string): void { - const editorDocument = carveToEditorDocument(source); + // setCarveDocument, not setContent: the bridge's source envelope rides on + // the document's own attributes, which setContent does not carry (editor.ts). // emitUpdate defaults to false; we refresh the outputs explicitly below. - editor.commands.setContent(editorDocument); + setCarveDocument(editor, carveToEditorDocument(source)); refreshOutputs(editorToCarve(editor)); } diff --git a/src/types/carve-grammars.d.ts b/src/types/carve-grammars.d.ts index af86bcd..660c396 100644 --- a/src/types/carve-grammars.d.ts +++ b/src/types/carve-grammars.d.ts @@ -18,6 +18,18 @@ declare module '@markup-carve/carve-grammars/tiptap' { options?: CarveLoaderOptions, ): JSONContent; + /** + * Convert a Carve AST document to a ProseMirror/Tiptap JSON document. + * + * The seam the loader itself uses. Declared here because it is the only way + * to exercise a node type the INSTALLED engine cannot parse yet - see + * tests/composite-figure.test.ts. + */ + export function astToProseMirror( + ast: unknown, + options?: CarveLoaderOptions & { source?: string }, + ): JSONContent; + /** Serialize a Tiptap/ProseMirror JSON document to Carve markup. */ export function serializeToCarve(doc: unknown): string; diff --git a/tests/blockquote-attribution.test.ts b/tests/blockquote-attribution.test.ts index 1eeca5b..6f5a8a3 100644 --- a/tests/blockquote-attribution.test.ts +++ b/tests/blockquote-attribution.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { Editor } from '@tiptap/core'; import { CarveKit, serializeToCarve } from '@markup-carve/carve-grammars/tiptap'; import { carveToEditorDocument } from '../src/carve-import'; +import { setCarveDocument } from '../src/editor'; let editor: Editor; @@ -32,8 +33,12 @@ afterAll(() => { /** Load Carve, type somewhere that is NOT the attribution, then serialize. */ function editElsewhereAndSerialize(source: string): string { - editor.commands.setContent(carveToEditorDocument(source)); + setCarveDocument(editor, carveToEditorDocument(source)); editor.commands.insertContentAt(1, 'EDITED'); + // serializeToCarve on the raw editor JSON, deliberately NOT editorToCarve: + // the point is what the CONTENT tree holds, and the app's serializer would + // re-attach the document's source envelope, which is the very thing this + // test must not read. return serializeToCarve(editor.getJSON()); } @@ -65,7 +70,7 @@ describe("a quote's attribution survives an edit", () => { }); it('holds the attribution in an editable node, not only the source envelope', () => { - editor.commands.setContent(carveToEditorDocument('> Stay hungry, stay foolish.\n^ Steve Jobs\n')); + setCarveDocument(editor, carveToEditorDocument('> Stay hungry, stay foolish.\n^ Steve Jobs\n')); expect(editableText()).toContain('Steve Jobs'); }); @@ -76,7 +81,7 @@ describe("a quote's attribution survives an edit", () => { }); it('control: the probe reads the content tree and not everything', () => { - editor.commands.setContent(carveToEditorDocument('> Just a quote\n')); + setCarveDocument(editor, carveToEditorDocument('> Just a quote\n')); expect(editableText()).toContain('Just a quote'); expect(editableText()).not.toContain('Steve Jobs'); }); diff --git a/tests/composite-figure.test.ts b/tests/composite-figure.test.ts new file mode 100644 index 0000000..0eb397a --- /dev/null +++ b/tests/composite-figure.test.ts @@ -0,0 +1,129 @@ +/** + * Composite figures (Carve PART 9 section 4c, markup-carve/carve#1215) in the editor. + * + * WHERE THE MAPPING LIVES, because this repository is 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. + * + * What this file does instead is measure that boundary rather than assert it, + * in the two states it can be in: + * + * 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. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Editor } from '@tiptap/core'; +import { CarveKit, serializeToCarve, 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'; + +let editor: Editor; + +beforeAll(() => { + const el = document.createElement('div'); + document.body.appendChild(el); + editor = new Editor({ element: el, extensions: [CarveKit] }); +}); + +afterAll(() => { + editor?.destroy(); +}); + +/** The app's own import + serialize path, exactly as main.ts drives it. */ +function roundTrip(source: string): string { + setCarveDocument(editor, carveToEditorDocument(source)); + return editorToCarve(editor); +} + +const GROUP = [ + '{#fig-x}', + '::: figure', + '{#fig-a}', + '![one](a.png)', + '^ (a) One', + '', + '{#fig-b}', + '![two](b.png)', + '^ (b) Two', + ':::', + '^ Group caption', + '', +].join('\n'); + +/** + * 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. + */ +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('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('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, { + 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}'); + }); +}); diff --git a/tests/fixtures/figure-group.ast.json b/tests/fixtures/figure-group.ast.json new file mode 100644 index 0000000..ca7cfbe --- /dev/null +++ b/tests/fixtures/figure-group.ast.json @@ -0,0 +1,131 @@ +{ + "source": "{#fig-x}\n::: figure\n{#fig-a}\n![one](a.png)\n^ (a) One\n\n{#fig-b}\n![two](b.png)\n^ (b) Two\n:::\n^ Group caption\n", + "ast": { + "type": "document", + "children": [ + { + "type": "figure_group", + "children": [ + { + "type": "figure", + "target": { + "type": "image", + "src": "a.png", + "alt": "one", + "pos": { + "startLine": 4, + "endLine": 4, + "startColumn": 1, + "endColumn": 14, + "startOffset": 29, + "endOffset": 42 + } + }, + "caption": [ + { + "type": "text", + "value": "(a) One", + "pos": { + "startLine": 5, + "endLine": 5, + "startColumn": 3, + "endColumn": 10, + "startOffset": 45, + "endOffset": 52 + } + } + ], + "pos": { + "startLine": 4, + "endLine": 5, + "startColumn": 1, + "endColumn": 10, + "startOffset": 29, + "endOffset": 52 + }, + "attrs": { + "id": "fig-a", + "order": [ + "#id" + ] + } + }, + { + "type": "figure", + "target": { + "type": "image", + "src": "b.png", + "alt": "two", + "pos": { + "startLine": 8, + "endLine": 8, + "startColumn": 1, + "endColumn": 14, + "startOffset": 63, + "endOffset": 76 + } + }, + "caption": [ + { + "type": "text", + "value": "(b) Two", + "pos": { + "startLine": 9, + "endLine": 9, + "startColumn": 3, + "endColumn": 10, + "startOffset": 79, + "endOffset": 86 + } + } + ], + "pos": { + "startLine": 8, + "endLine": 9, + "startColumn": 1, + "endColumn": 10, + "startOffset": 63, + "endOffset": 86 + }, + "attrs": { + "id": "fig-b", + "order": [ + "#id" + ] + } + } + ], + "caption": [ + { + "type": "text", + "value": "Group caption", + "pos": { + "startLine": 11, + "endLine": 11, + "startColumn": 3, + "endColumn": 16, + "startOffset": 93, + "endOffset": 106 + } + } + ], + "pos": { + "startLine": 2, + "endLine": 11, + "startColumn": 1, + "endColumn": 16, + "startOffset": 9, + "endOffset": 106 + }, + "attrs": { + "id": "fig-x", + "order": [ + "#id" + ] + } + } + ], + "srcByteLength": 107 + }, + "capturedFrom": "markup-carve/carve-js 3f5dd8cb6b48d3dbf064f1a350c8493b93030170" +} diff --git a/tests/language-attribute.test.ts b/tests/language-attribute.test.ts index 2adcedf..d328da3 100644 --- a/tests/language-attribute.test.ts +++ b/tests/language-attribute.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { Editor } from '@tiptap/core'; import { CarveKit, serializeToCarve } from '@markup-carve/carve-grammars/tiptap'; import { carveToEditorDocument } from '../src/carve-import'; +import { editorToCarve, setCarveDocument } from '../src/editor'; let editor: Editor; @@ -27,8 +28,8 @@ afterAll(() => { /** Load Carve source the way the app does, then serialize the editor state. */ function fromCarve(source: string): string { - editor.commands.setContent(carveToEditorDocument(source)); - return serializeToCarve(editor.getJSON()); + setCarveDocument(editor, carveToEditorDocument(source)); + return editorToCarve(editor); } /** Load HTML the way a paste does, then serialize the editor state. */ diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index e5ab56a..8a306ff 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -3,15 +3,17 @@ * serializeToCarve -> Carve source. * * This drives the exact same path the app uses: carveToEditorDocument(), then - * editor.commands.setContent(), then serializeToCarve(editor.getJSON()). + * setCarveDocument() (which is setContent plus the source envelope), then + * editorToCarve(). * * The loader's preservation mode guarantees that constructs without an * editable Tiptap representation survive load/save instead of disappearing. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { Editor } from '@tiptap/core'; -import { CarveKit, serializeToCarve } from '@markup-carve/carve-grammars/tiptap'; +import { CarveKit } from '@markup-carve/carve-grammars/tiptap'; import { carveToEditorDocument } from '../src/carve-import'; +import { editorToCarve, setCarveDocument } from '../src/editor'; let editor: Editor; @@ -27,8 +29,8 @@ afterAll(() => { /** Run the app's full import + serialize round trip on a Carve source string. */ function roundTrip(source: string): string { - editor.commands.setContent(carveToEditorDocument(source)); - return serializeToCarve(editor.getJSON()); + setCarveDocument(editor, carveToEditorDocument(source)); + return editorToCarve(editor); } interface Sample {