mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
fix(deps): share Lezer node properties across editor languages
fix(deps): share Lezer node properties across editor languages The installed graph gave syntax highlighters @lezer/common 1.5.1 and language parsers 1.5.2. Their independent NodeProp counters collided, so highlighting ordinary code read unrelated metadata as tags and crashed with tags-is-not-iterable. Override @lezer/common to one compatible version in both manifests. Extend the installed-graph check and exercise Python, JavaScript, HTML and SQL highlighting through the editor dependencies. All four examples failed before the override and pass with it. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
846336e5a0
commit
a3749aac46
4 files changed
+59
-12
No files matched your search
+2
-1
@@ -122,7 +122,8 @@
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"@codemirror/state": "^6.7.2",
|
||||
"@codemirror/view": "^6.43.11"
|
||||
"@codemirror/view": "^6.43.11",
|
||||
"@lezer/common": "^1.5.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,3 +44,6 @@ overrides:
|
||||
# range forces every consumer onto one resolution.
|
||||
"@codemirror/state": "^6.7.2"
|
||||
"@codemirror/view": "^6.43.11"
|
||||
# Parsers and highlighters must share NodeProp IDs from one Lezer instance.
|
||||
# Mixed copies can interpret unrelated node metadata as highlighting tags.
|
||||
"@lezer/common": "^1.5.2"
|
||||
@@ -18,3 +18,13 @@ pnpm --filter @paperclipai/ui build-storybook
|
||||
## Typical use
|
||||
|
||||
Install the package, then serve or copy the built files from `node_modules/@paperclipai/ui/dist`.
|
||||
|
||||
## Editor dependency identity
|
||||
|
||||
Keep the root and workspace overrides for `@codemirror/state`,
|
||||
`@codemirror/view`, and `@lezer/common` aligned. CodeMirror requires shared
|
||||
extension identity, while Lezer parsers and syntax highlighters require shared
|
||||
`NodeProp` IDs. Multiple Lezer copies can crash code-block highlighting with
|
||||
`tags is not iterable`. `src/lib/codemirror-single-instance.test.ts` checks the
|
||||
installed dependency graph and highlights sample code through the editor's real
|
||||
language dependencies. GitHub Actions owns regeneration of `pnpm-lock.yaml`.
|
||||
@@ -1,21 +1,29 @@
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// CodeMirror validates extensions with instanceof, so the installed graph
|
||||
// must contain exactly one physical copy of these packages. Two resolved
|
||||
// versions ship two module instances and crash the editor at runtime with
|
||||
// "Unrecognized extension value in extension set" — a failure no unit test
|
||||
// of the editor itself catches, because each test file sees only one copy.
|
||||
// CodeMirror validates extensions with instanceof, and Lezer allocates
|
||||
// NodeProp IDs within each module instance. The installed graph must contain
|
||||
// one physical copy of their shared primitives. Duplicates can crash extension
|
||||
// validation or read unrelated syntax metadata as highlighting tags.
|
||||
// The pnpm.overrides entries in the root package.json hold the graph to a
|
||||
// single resolution; this file pins that invariant against the graph the
|
||||
// current install actually resolved, so it holds wherever the tests run —
|
||||
// CI (which installs from the lockfile it regenerates for the PR) and
|
||||
// local checkouts alike.
|
||||
const SINGLE_INSTANCE_PACKAGES = ["@codemirror/state", "@codemirror/view"];
|
||||
const SINGLE_INSTANCE_PACKAGES = [
|
||||
["@codemirror/state", 6],
|
||||
["@codemirror/view", 6],
|
||||
["@lezer/common", 1],
|
||||
] as const;
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
const uiRoot = path.resolve(__dirname, "../..");
|
||||
const uiRequire = createRequire(path.join(uiRoot, "package.json"));
|
||||
const editorRequire = createRequire(uiRequire.resolve("@mdxeditor/editor"));
|
||||
const languagesRequire = createRequire(editorRequire.resolve("@codemirror/language-data"));
|
||||
const languageRequire = createRequire(languagesRequire.resolve("@codemirror/language"));
|
||||
const workspaceManifest = readFileSync(
|
||||
path.join(repoRoot, "pnpm-workspace.yaml"),
|
||||
"utf8",
|
||||
@@ -92,7 +100,7 @@ function reachableCopies(target: string): string[] {
|
||||
}
|
||||
|
||||
describe("codemirror single-instance invariant", () => {
|
||||
for (const pkg of SINGLE_INSTANCE_PACKAGES) {
|
||||
for (const [pkg, major] of SINGLE_INSTANCE_PACKAGES) {
|
||||
it(`keeps the ${pkg} override in the root manifest and its workspace mirror`, () => {
|
||||
// Removing the override is the only way a second copy can come
|
||||
// back (an override rewrites every dependent's range), so the
|
||||
@@ -100,9 +108,9 @@ describe("codemirror single-instance invariant", () => {
|
||||
expect(
|
||||
rootManifest.pnpm?.overrides?.[pkg],
|
||||
`${pkg} must stay in pnpm.overrides (root package.json); without ` +
|
||||
"it the graph can resolve two copies and instanceof checks " +
|
||||
"inside the editor break.",
|
||||
).toMatch(/^\^6\./);
|
||||
"it the graph can resolve two copies and shared editor " +
|
||||
"primitives no longer have the same identity.",
|
||||
).toMatch(new RegExp(`^\\^${major}\\.`));
|
||||
expect(
|
||||
workspaceManifest,
|
||||
`pnpm-workspace.yaml mirrors the pnpm.overrides block and must ` +
|
||||
@@ -119,11 +127,36 @@ describe("codemirror single-instance invariant", () => {
|
||||
expect(
|
||||
copies,
|
||||
`the installed graph carries multiple physical copies of ${pkg}, ` +
|
||||
"which break instanceof checks inside the editor. Reinstall " +
|
||||
"which break shared primitives inside the editor. Reinstall " +
|
||||
"against the current manifests; if the copies persist, fix the " +
|
||||
"pnpm.overrides entry in the root package.json instead of " +
|
||||
"allowing a second copy.",
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Exercise the installed editor graph directly. Separate @lezer/common
|
||||
// instances allocate colliding NodeProp IDs: the highlighter then reads
|
||||
// another parser property as style tags and throws "tags is not iterable".
|
||||
// Using Node resolution also keeps a test bundler from hiding the split.
|
||||
it.each([
|
||||
["@codemirror/lang-python", "python", "def greet(name):\n return name + '!'\n"],
|
||||
["@codemirror/lang-javascript", "javascript", "function greet(name) { return name + '!'; }"],
|
||||
["@codemirror/lang-html", "html", '<div class="greeting">Hello</div>'],
|
||||
["@codemirror/lang-sql", "sql", "SELECT name FROM greetings WHERE id = 1;"],
|
||||
])("highlights code with %s through the editor's installed dependencies", (pkg, factory, code) => {
|
||||
const language = languagesRequire(pkg)[factory]().language;
|
||||
const { highlightTree, classHighlighter } = languageRequire("@lezer/highlight");
|
||||
const spans: Array<{ from: number; to: number; css: string }> = [];
|
||||
highlightTree(language.parser.parse(code), classHighlighter, (from: number, to: number, css: string) => {
|
||||
spans.push({ from, to, css });
|
||||
});
|
||||
expect(spans.length).toBeGreaterThan(0);
|
||||
for (const span of spans) {
|
||||
expect(span.from).toBeGreaterThanOrEqual(0);
|
||||
expect(span.to).toBeGreaterThan(span.from);
|
||||
expect(span.to).toBeLessThanOrEqual(code.length);
|
||||
expect(span.css).toMatch(/\S/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user