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/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 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}'); });