From 605e4c1552dbd60f4912e1563703d09e9d4b7fbd Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 14 Aug 2026 20:35:31 +0200 Subject: [PATCH 1/2] fix: move the carve-grammars pin onto a build that carries both halves markup-carve/carve-wysiwyg#12 reported that typing ``` A [bonjour]{:fr} end. ``` in the source pane produced no span, and the pane wrote back ``` A \[bonjour]{:fr} end. ``` That is still the behavior at the pinned grammar build. The mark is missing because the loader parses with the engine carve-grammars installs for ITSELF - nested under carve-grammars, where no pin in this repository can reach it - and the pinned build carries the old one. The escape follows from the missing mark: an unrecognized construct is one text node, and the serializer escapes the bracket that would otherwise start a link on the way back out. The pin moves from 639db73 to c0e7291, both merged commits on carve-grammars main. Verified by reading the installed tree rather than the version string, which says 0.1.4 at both pins and proves nothing: | `node_modules/@markup-carve/carve-grammars` | 639db73 | c0e7291 | | --- | --- | --- | | `lang` in `tiptap/extensions/carve-span.js` | 4 occurrences, with `parseHTML` | 4 occurrences, with `parseHTML` | | the `':' + value` sugar in `tiptap/serializer.js` | present | present | | `attribution` in `tiptap/carve-to-pm.js` | 0 occurrences | 4 occurrences | | the engine it nests | 25e2c58 | 8d38a03 | The span half was already in the grammar at the old pin; the engine underneath it was not, which is the whole reason the earlier fix did not reach a user. Two tests cover the halves through the app's own import and serialize path. The language attribute one asserts on the `carveSpan` mark and on the absent backslash rather than on the round-tripped string: an unrecognized `[bonjour]{:fr}` writes back verbatim, so the string is a fixed point at both pins. The attribution one edits before serializing, because loading and serializing an untouched document returns the source envelope verbatim and passes at any pin. --- package-lock.json | 41 +++++++++++--- package.json | 2 +- tests/blockquote-attribution.test.ts | 83 ++++++++++++++++++++++++++++ tests/language-attribute.test.ts | 20 +++++++ 4 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 tests/blockquote-attribution.test.ts diff --git a/package-lock.json b/package-lock.json index 2ed2249..4a6a690 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#639db73ef4d8b32f7b7fad20e2ffafc3f4d9a8fc", + "@markup-carve/carve-grammars": "github:markup-carve/carve-grammars#c0e72914c6b76c81374b48d7a225cc66549934e4", "@tiptap/core": "^2.11.5", "@tiptap/extension-code-block": "^2.11.5", "@tiptap/extension-highlight": "^2.11.5", @@ -44,10 +44,13 @@ "license": "MIT" }, "node_modules/@markup-carve/carve": { - "version": "0.1.2", - "resolved": "git+ssh://git@github.com/markup-carve/carve-js.git#25e2c58216ede0fd2e19592f1d5b8d3c0f9e8880", - "integrity": "sha512-nN2Wh9yHlnnJdIREx6mA5AwrW+kLaE0364Vs2PIPA8Nn46yuKfSMTYapP56acUoFkNEulb5tkFD8vNzarzSSpw==", + "version": "0.1.3", + "resolved": "git+ssh://git@github.com/markup-carve/carve-js.git#8d38a03cc8664e761f81baa02a1bf54e7dde96a4", + "integrity": "sha512-bgWqsw+GotXoqZDAzuSDT3Rb4EoYlbSPlYhlb1pmEkGE531NnWnrZ4ndS/kkW/5bCd+ypS0hK6HOuFghQNa3Rw==", "license": "MIT", + "dependencies": { + "parse5": "^7.3.0" + }, "bin": { "carve": "dist/cli.js" }, @@ -65,11 +68,11 @@ }, "node_modules/@markup-carve/carve-grammars": { "version": "0.1.4", - "resolved": "git+ssh://git@github.com/markup-carve/carve-grammars.git#639db73ef4d8b32f7b7fad20e2ffafc3f4d9a8fc", - "integrity": "sha512-wceK/nPNKai0rmUODeLyti38Pxq2aTxdrtWsS3ouKxMKSTis/Lil1yu2KE5TN8mMEO5oweRp73KQYvt6+Nt/2g==", + "resolved": "git+ssh://git@github.com/markup-carve/carve-grammars.git#c0e72914c6b76c81374b48d7a225cc66549934e4", + "integrity": "sha512-mya89cbhDNIVUvNDvUWpcU2WEcwEg6Mchx+8wlk4/Kg6dpIuIHPn+B4d3hibtU/YK9hgRiqA9/bKtOGIaAB44Q==", "license": "MIT", "dependencies": { - "@markup-carve/carve": "github:markup-carve/carve-js#25e2c58216ede0fd2e19592f1d5b8d3c0f9e8880" + "@markup-carve/carve": "github:markup-carve/carve-js#8d38a03cc8664e761f81baa02a1bf54e7dde96a4" }, "peerDependencies": { "@shikijs/themes": "^2 || ^3", @@ -1612,6 +1615,30 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", diff --git a/package.json b/package.json index 7d51e8c..afd2177 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@markup-carve/carve": "^0.1.2", + "@markup-carve/carve-grammars": "github:markup-carve/carve-grammars#c0e72914c6b76c81374b48d7a225cc66549934e4", "@tiptap/core": "^2.11.5", "@tiptap/extension-code-block": "^2.11.5", "@tiptap/extension-highlight": "^2.11.5", @@ -30,7 +31,6 @@ "@tiptap/extension-underline": "^2.11.5", "@tiptap/pm": "^2.11.5", "@tiptap/starter-kit": "^2.11.5", - "@markup-carve/carve-grammars": "github:markup-carve/carve-grammars#639db73ef4d8b32f7b7fad20e2ffafc3f4d9a8fc", "highlight.js": "^11.11.1", "prismjs": "^1.29.0" }, diff --git a/tests/blockquote-attribution.test.ts b/tests/blockquote-attribution.test.ts new file mode 100644 index 0000000..1eeca5b --- /dev/null +++ b/tests/blockquote-attribution.test.ts @@ -0,0 +1,83 @@ +/** + * A quote's `^ …` attribution, 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. + * + * That is why every case here EDITS before serializing. Loading and serializing + * an untouched document returns the envelope verbatim and passes at any pin, + * which would make the check vacuous. + */ +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'; + +let editor: Editor; + +beforeAll(() => { + const el = document.createElement('div'); + document.body.appendChild(el); + editor = new Editor({ element: el, extensions: [CarveKit] }); +}); + +afterAll(() => { + editor?.destroy(); +}); + +/** Load Carve, type somewhere that is NOT the attribution, then serialize. */ +function editElsewhereAndSerialize(source: string): string { + editor.commands.setContent(carveToEditorDocument(source)); + editor.commands.insertContentAt(1, 'EDITED'); + return serializeToCarve(editor.getJSON()); +} + +/** + * The text the editor holds in its CONTENT tree, ignoring the document's + * `attrs`. The whole-document source envelope lives in `attrs`, so reading it + * would count the very thing whose loss is the bug: an attribution that is only + * in the envelope is not editable, and the first edit discards it. + * + * Deliberately shape-independent. The attribution has reached the editor as a + * caption inside a figure and as a caption inside the quote at different + * carve-grammars pins; what the user cares about is that it is in the document + * at all. + */ +function editableText(): string { + const walk = (node: { type?: string; text?: string; content?: unknown[] }): string => + node.type === 'text' + ? node.text ?? '' + : ((node.content ?? []) as Array[0]>).map(walk).join(' '); + const doc = editor.getJSON() as { content?: unknown[] }; + return ((doc.content ?? []) as Array[0]>).map(walk).join(' '); +} + +describe("a quote's attribution 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'); + expect(out).toContain('^ Steve Jobs'); + }); + + 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')); + expect(editableText()).toContain('Steve Jobs'); + }); + + it('keeps an attribution written one blank line below the quote', () => { + const out = editElsewhereAndSerialize('> quote text\n\n^ Source: Someone\n'); + expect(out).toContain('EDITED'); + expect(out).toContain('^ Source: Someone'); + }); + + it('control: the probe reads the content tree and not everything', () => { + editor.commands.setContent(carveToEditorDocument('> Just a quote\n')); + expect(editableText()).toContain('Just a quote'); + expect(editableText()).not.toContain('Steve Jobs'); + }); +}); diff --git a/tests/language-attribute.test.ts b/tests/language-attribute.test.ts index 5dddafc..2adcedf 100644 --- a/tests/language-attribute.test.ts +++ b/tests/language-attribute.test.ts @@ -55,6 +55,26 @@ describe('language attribute through import and serialize', () => { expect(out).not.toContain('lang="fr"'); }); + // The SHORT form typed in the source pane, which is what markup-carve/carve-wysiwyg#12 + // reported. The editor's loader parses it with the engine carve-grammars + // installs for itself, not with this app's own `@markup-carve/carve`, so no + // pin here could reach it - only the grammar pin can. + // + // Asserted on the MARK. `A [bonjour]{:fr} end.` that the engine does not + // recognize is one text node the serializer writes back verbatim, so the + // round-tripped string is a fixed point at both pins and a string assertion + // would be vacuous. The absent backslash is checked as well, since the escape + // is what the reporter saw. + it('parses the {:fr} shorthand typed in the source pane onto the span mark', () => { + const out = fromCarve('A [bonjour]{:fr} end.'); + const span = firstMarks().find((mark) => mark.type === 'carveSpan'); + expect(span, `no carveSpan mark in ${JSON.stringify(firstMarks())}`).toBeDefined(); + const attrs = span?.attrs as { lang?: string; keyValues?: Record } | undefined; + expect(attrs?.keyValues?.lang ?? attrs?.lang).toBe('fr'); + expect(out).toContain('[bonjour]{:fr}'); + expect(out).not.toContain('\\[bonjour]'); + }); + it('keeps a subtag intact in the sugar', () => { expect(fromCarve('A [x]{lang="zh-Hant"} end.')).toContain('[x]{:zh-Hant}'); }); From 5accc87a84b628373cee9050b563f79a28334ddd Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 14 Aug 2026 20:35:43 +0200 Subject: [PATCH 2/2] fix: the pin watchdog blocks only on a gap this repo can close Moving the grammar pin turned the spec-freshness arm red: ``` ::error::@markup-carve/carve-grammars is pinned to a build written against spec 988fdc8ea42b, which is 31 commit(s) behind the spec cca18744f8fe that the installed @markup-carve/carve build was written against. ``` The reading is true, and it is not this repository's to act on. carve-grammars pins the engine its own loader calls to an exact commit; the git build in this lockfile IS that commit, hoisted out of carve-grammars because this repo asks for a version range. The only lever here is the grammar pin, and rolling it back to close the gap would give up every fix that landed after it, including the one this bump exists for. Nor can the gap bite this way round: the app hands carve-grammars SOURCE, not an AST. `carveToProseMirror(source)` parses with the engine carve-grammars nests, and this repo's own engine is used only for the preview HTML in `carveToHtmlRaw`, which never reaches the grammar. There is no path where an older grammar is handed a newer engine's tree. So the arm now decides from the INSTALLED revisions rather than from how package.json spells the dependency: when the lockfile's engine is the exact commit the pinned grammar declares for itself, the finding is a warning naming carve-grammars as the repository that can close it. Any other engine is one this repo installed, and pairing it with an older grammar still fails. Nothing else is relaxed. Measured against the reworked script: - a pin naming a commit that is not on carve-grammars main: error, exit 1 - a lockfile that disagrees with package.json: error, exit 1 - a grammar pin whose own engine is not the installed one, with the grammar behind: error, exit 1 - the pin falling behind main: the existing warning, unchanged --- scripts/check-carve-pins.mjs | 54 ++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/scripts/check-carve-pins.mjs b/scripts/check-carve-pins.mjs index 122c16d..7945614 100644 --- a/scripts/check-carve-pins.mjs +++ b/scripts/check-carve-pins.mjs @@ -13,10 +13,15 @@ * 1. A `github:owner/repo#sha` pin must match the lockfile's resolved commit. * 2. That commit must be on the repository's default branch. Pinning an * unmerged branch build silently reverts everything that landed after it. - * 3. The spec revision the pinned carve-grammars build was written against - * must not be older than the spec revision of the carve-js build this - * editor installs. That is the drift this watchdog exists for: a grammar - * that predates the engine cannot represent what the engine parses. + * 3. The spec revision the pinned carve-grammars build was written against is + * compared with the spec revision of the carve-js build this editor + * installs. A grammar that predates the engine cannot represent what the + * engine parses. Whether that gap FAILS the check depends on whose engine it + * is, decided from the installed revisions: when the lockfile's engine is + * the exact commit carve-grammars pins for its own loader, it was hoisted + * out of carve-grammars and nothing here can move it, so the finding is a + * warning naming the repository that can. Any other engine is one this repo + * installed, and pairing it with an older grammar fails. * * Usage: node scripts/check-carve-pins.mjs [package-dir] * Reads package.json and package-lock.json from (default: cwd). @@ -58,6 +63,12 @@ function lockedCommit(lock, name) { return parseGitPin(lock.packages?.[`node_modules/${name}`]?.resolved ?? ''); } +/** A repository's package.json at a given ref, parsed. */ +async function packageJsonAt(repo, ref) { + const entry = await api(`/repos/${repo}/contents/package.json?ref=${ref}`); + return JSON.parse(Buffer.from(entry.content, entry.encoding).toString('utf8')); +} + /** The submodule commit a repository records at `path` for a given ref. */ async function submoduleSha(repo, ref, path) { const entry = await api(`/repos/${repo}/contents/${path}?ref=${ref}`); @@ -121,11 +132,38 @@ if (!engineLocked) { } else { const range = await compare(SPEC_REPO, grammarsSpec, engineSpec); if (range.ahead_by > 0) { - errors.push( - `${GRAMMARS} is pinned to a build written against spec ${grammarsSpec.slice(0, 12)}, ` + - `which is ${range.ahead_by} commit(s) behind the spec ${engineSpec.slice(0, 12)} that the installed ` + - `${ENGINE} build was written against. The grammar cannot represent what the engine parses.`, + // WHOSE engine this is decides whether the gap is this repo's to close, + // and that is settled from the INSTALLED revisions rather than from how + // package.json happens to spell the dependency. carve-grammars pins the + // engine its own loader calls to an exact commit; when the lockfile's + // engine IS that commit, it was hoisted out of carve-grammars and nothing + // in this repository can move it. The only lever would be rolling the + // grammar pin back, which gives up every fix that landed after it. + // + // The gap is not a hazard this repo can create either way: the app hands + // carve-grammars SOURCE, not an AST. `carveToProseMirror(source)` parses + // with the engine carve-grammars nests, and the app's own engine is used + // only for the preview HTML, which never touches the grammar. + // + // So the same finding is reported either way and blocks only where it can + // be acted on. A grammar whose spec revision trails the engine it bundles + // is the normal state right after that engine is bumped, and it is + // carve-grammars' own promotion gate that closes it. + const grammarsOwnEngine = parseGitPin( + (await packageJsonAt(grammarsPin.repo, grammarsPin.sha)).dependencies?.[ENGINE], ); + const message = + `${GRAMMARS} is pinned to a build written against spec ${grammarsSpec.slice(0, 12)}, ` + + `which is ${range.ahead_by} commit(s) behind the spec ${engineSpec.slice(0, 12)} that the installed ` + + `${ENGINE} build was written against. The grammar cannot represent what the engine parses.`; + if (grammarsOwnEngine?.sha === engineLocked.sha) { + warnings.push( + `${message} The installed engine is ${engineLocked.sha.slice(0, 12)}, the commit ${GRAMMARS} pins ` + + 'for its own loader, so closing the gap is a carve-grammars change and not one this repository can make.', + ); + } else { + errors.push(message); + } } else { // The other direction is not an error here: the editor cannot move the // engine that carve-grammars installs for its own loader, because that