Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
54 changes: 46 additions & 8 deletions scripts/check-carve-pins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package-dir> (default: cwd).
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions tests/blockquote-attribution.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof walk>[0]>).map(walk).join(' ');
const doc = editor.getJSON() as { content?: unknown[] };
return ((doc.content ?? []) as Array<Parameters<typeof walk>[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');
});
});
20 changes: 20 additions & 0 deletions tests/language-attribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> } | 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}');
});
Expand Down
Loading