feat: carry unrun wikilink node and tests onto current refactor

This commit is contained in:
Herb authored and Jason Woltje committed 2026-10-24 22:50:34 -05:00
1 parent bbec5e97f1
commit 8feda29eb4
9 files changed
+550 -2

No files matched your search

+53
View File
@@ -68,3 +68,56 @@ HTML/entity writer for dirty blocks; Q&A stays text and is a known limit.
Measurement VM setup matches loadEditor. Browser TMPDIR must use the short
non-/tmp scratch root. Sweep Node/Chromium after mutant/timeout and at clear.
All remain authored and unrun while253 owns the slot.
Filbert follow-up at2026-10-22T16:45:34Z: the authored N3 browser fixture
now explicitly asserts the separate reference-definition block ends in its
original exact bytes after the same-block edit. This test change is unrun
and uncommitted with the other product/test authoring, pending CLEAR.
## R2 restart on current refactor, sage-4164
New worktree: /home/jwoltje/.t3/worktrees/mosaic-stack/herb-row214-r2.
Branch herb/row214-r2, base487500a16f4ab5902c318509fdec6acc56a543ca.
Carried dee9f3d8d and e2710e92a as67ac3c25a andde42a59d4. Copied only
Herb's local model, new editor test/helper, measure.mjs and pending BUILD
note. The old worktree remains untouched. No stash or force push.
The current editor model/build/toolchain/BUILD.json/shipped bundle and
loader are identical to the old base. Entry/view and package dependencies
are unchanged. The current test fixture adds configDirectory:root; its
landed change is retained. The C2-11a brief section is unchanged. The
visual-mode condition now lives at public/bus.js:1054. No product behavior
beyond the accepted inert inline node is added by the rebase.
Row266 overlap: none. Our paths do not touch chat*.mjs, chat.js or chat.css,
and the intersection with origin/fethry/row266's actual diff is empty.
HASH-PINS.json records that ref's SHA and merge base.
Hash audit: grepped packages/*/tests for each actual/planned changed file
and filename, and scanned495 test/fixture files for literal prior SHA256
or Git-blob pins. No such pins found. Existing editor-provenance checks
read BUILD.json dynamically; regenerating it and the bundle is required.
The loader and browser/route tests use editor.js but pin no source hash.
No tests or pin asserts were weakened. The complete references and planned
generated paths are in HASH-PINS.json.
Sage-4164 explicitly permits the unrun node/tests to be committed now. This
supersedes the earlier packet's instruction to keep them uncommitted until
green. Static authoring is not a tested candidate. SOURCE.json records its
source hashes and the unbuilt generated-output status. The measurement now
pins this current execution base, not the historical accepted-doc base.
No Node, npm, measurement, compiler, test, browser, Docker or mutant has
run. Claim already succeeded at rev3234 on10-22; no new queue move is
needed. Push authorization is only herb/row214-r2. Row257's landing gate
and then266 own the slot; all execution remains held for Sage's CLEAR.
Planned focused grant: baseline measurement; the same-block structural
red; pinned npm ci/build; whole editor-wikilink.test.mjs (10 no-edit tests
with/without a final newline and3 browser tests), editor.test.mjs,
editor-browser.test.mjs and editor-provenance.test.mjs singly; one
serializer-removal mutant and restored focused green. Set
MOSAIC_REQUIRE_EDITOR_BUILD=1 for provenance and the later whole webui
gate. Capture all skip names, raw logs, short scratch TMPDIR and final
Node/Chromium process sweep. Two failures stop a gate; no rerun after an
out-of-row failure without reporting to Sage.
+24
View File
@@ -126,3 +126,27 @@ N6: every granted browser/test process gets TMPDIR from
mktemp -d /mnt/storage/scratch/tmp/gXXX. Never /tmp. Sweep owned Chromium
and Node processes by cwd and the temp-root path after the mutant, after
any timeout, and before clearing the slot. Retain/report uncertain owners.
## R2 current-base check, sage-4164 (2026-10-24)
The execution base is now487500a16, on herb/row214-r2, not the historical
aca16404c above. The two accepted document commits were cherry-picked
cleanly. The model, build script, toolchain, BUILD.json, editor.js, view,
entry and editor test loader are byte-identical between those bases. The
current fixture's new configDirectory isolation is retained; it is not
copied from the old worktree. The visual-mode check is now bus.js:1054.
The current C2-11a brief section is unchanged. Row266's chat modules,
public/chat.js and public/chat.css are outside this row, with no overlap.
measure.mjs now pins487500a16's Git objects and BUILD.json. The baseline
bundle still lacks the atom, and the first executed structural regression
will use it before any rebuild. The required measurement, red/green, pinned
rebuild, mutant kill and full gate remain unrun and required. Runtime
validation is not inferred from the unchanged source or this static check.
Sage-4164 explicitly authorizes committing the preserved local node/tests
on the new branch before execution. The authored source commit is static
work, not a green candidate. No Node, npm, rebuild or browser starts until
Sage sends CLEAR. HASH-PINS.json records the test-tree audit and row266
intersection. The gate still requires MOSAIC_REQUIRE_EDITOR_BUILD=1, a
named skip list, whole webui and every scripts/test-*.sh.
+276
View File
@@ -0,0 +1,276 @@
{
"base": "487500a16f4ab5902c318509fdec6acc56a543ca",
"testFilesScanned": 495,
"plannedPaths": [
"packages/webui/editor/model.mjs",
"packages/webui/tests/editor-wikilink.test.mjs",
"packages/webui/tests/helpers/wikilink-cases.mjs",
"agents/herb/work/queue-214/measure.mjs",
"agents/herb/work/queue-214/DESIGN.md",
"agents/herb/work/queue-214/MANIFEST.md",
"agents/herb/work/queue-214/BUILD.md",
"packages/webui/src/public/editor.js",
"packages/webui/editor/BUILD.json",
"agents/herb/work/queue-214/HASH-PINS.json"
],
"literalPriorBlobPins": [],
"pathReferences": [
{
"test": "packages/webui/tests/editor-wikilink.test.mjs",
"line": 6,
"text": "import { WIKILINK_CASES } from './helpers/wikilink-cases.mjs';"
},
{
"test": "packages/webui/tests/browser.test.mjs",
"line": 21,
"text": "// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, same origin; the list is the pin, so another script is a visible change."
},
{
"test": "packages/webui/tests/browser.test.mjs",
"line": 22,
"text": "assert.deepEqual(await b.evaluate('[...document.querySelectorAll(\"script\")].map(s => s.getAttribute(\"src\"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js']);"
},
{
"test": "packages/webui/tests/sso.test.mjs",
"line": 1,
"text": "// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an"
},
{
"test": "packages/webui/tests/user-option-swap.test.mjs",
"line": 133,
"text": "['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'],"
},
{
"test": "packages/webui/tests/serve.test.mjs",
"line": 38,
"text": "for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/editor.js', '/editor.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {"
},
{
"test": "packages/webui/tests/editor-provenance.test.mjs",
"line": 18,
"text": "const build = JSON.parse(readFileSync(join(webui, 'editor/BUILD.json'), 'utf8'));"
},
{
"test": "packages/webui/tests/editor-provenance.test.mjs",
"line": 92,
"text": "const bundle = readFileSync(join(webui, 'src/public/editor.js'), 'utf8');"
},
{
"test": "packages/webui/tests/editor-provenance.test.mjs",
"line": 167,
"text": "const info = await run(out, join(out, 'BUILD.json'));"
},
{
"test": "packages/webui/tests/editor-provenance.test.mjs",
"line": 171,
"text": "assert.equal(readFileSync(join(out, 'BUILD.json'), 'utf8'), readFileSync(join(webui, 'editor/BUILD.json'), 'utf8'));"
},
{
"test": "packages/webui/tests/editor-browser.test.mjs",
"line": 103,
"text": "assert.equal(await h.q('typeof window.MosaicEditor.mount'), 'function', 'editor.js ran under script-src self');"
},
{
"test": "packages/webui/tests/editor-browser.test.mjs",
"line": 423,
"text": "// (The editor's own reading of a file can give a single line break in a title or alt, never a blank line; see BUILD.md.)"
},
{
"test": "packages/webui/tests/granted-user.test.mjs",
"line": 231,
"text": "['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'],"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 20,
"text": "for (const [path, type] of [['/editor.js', 'text/javascript'], ['/editor.css', 'text/css']]) {"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 29,
"text": "assert.ok(index.indexOf('src=\"/editor.js\"') < index.indexOf('src=\"/bus.js\"'), 'the editor loads before the page script, so bus.js can mount it');"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 60,
"text": "for (const file of ['entry.mjs', 'model.mjs', 'view.mjs']) {"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 64,
"text": "const bundle = readFileSync(join(webui, 'src/public/editor.js'), 'utf8');"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 65,
"text": "assert.doesNotMatch(bundle, /\\beval\\s*\\(|\\bnew\\s+Function\\b/, 'src/public/editor.js');"
},
{
"test": "packages/webui/tests/editor.test.mjs",
"line": 69,
"text": "const model = readFileSync(join(webui, 'editor/model.mjs'), 'utf8');"
},
{
"test": "packages/webui/tests/helpers/editor-bundle.mjs",
"line": 1,
"text": "// Loads src/public/editor.js (and markdown-preview.js, the link gate it needs) into a vm context with a"
},
{
"test": "packages/webui/tests/helpers/editor-bundle.mjs",
"line": 15,
"text": "for (const f of ['markdown-preview.js', 'editor.js']) vm.runInContext(readFileSync(join(pub, f), 'utf8'), sandbox, { filename: f });"
},
{
"test": "packages/plugins/tests/harness.test.mjs",
"line": 44,
"text": "// pi 0.85.1 --thinking and claude 2.1.302 --effort, as probed (BUILD.md); claude has no off or minimal."
},
{
"test": "packages/bus/tests/revoke-window.test.mjs",
"line": 4,
"text": "// a limit stub and a usage stub for the limit stop. BUILD.md (agents/morgana/work/queue-306) names"
},
{
"test": "packages/bus/tests/access-revoke.test.mjs",
"line": 5,
"text": "// lstat key; `edit` changes the document and the stamp together, as a hand edit does. BUILD.md"
},
{
"test": "packages/bus/tests/land.test.mjs",
"line": 410,
"text": "// landHeld still counts it, so it stays listed (BUILD.md follow-up 5)."
},
{
"test": "packages/bus/tests/session-clear.test.mjs",
"line": 13,
"text": "// docs/plans/2026-10-21_t1-sessions.md, the contract in agents/gizmoduck/work/queue-271/BUILD.md."
},
{
"test": "packages/bus/tests/grant-executor.test.mjs",
"line": 1027,
"text": "// credential-unavailable; BUILD.md records the difference)."
},
{
"test": "packages/bus/tests/host-run.test.mjs",
"line": 343,
"text": "// the options are checked here and the call runs with an alias no user file holds (BUILD.md)."
},
{
"test": "packages/business/tests/workflow-roles.test.mjs",
"line": 123,
"text": "// of staff launches it. The broker and launcher states are row 137's tests (BUILD.md, round 3)."
},
{
"test": "packages/business/tests/access.test.mjs",
"line": 1,
"text": "// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md"
},
{
"test": "packages/cli/tests/revoke-window.test.mjs",
"line": 9,
"text": "// poll, with threadsState replaced by the caller. BUILD.md (agents/morgana/work/queue-306) names the"
},
{
"test": "packages/cli/tests/stack-instances.test.mjs",
"line": 3,
"text": "// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T56 in"
},
{
"test": "packages/cli/tests/session-launch.test.mjs",
"line": 297,
"text": "// Q2 (BUILD.md:177-179, t1-sessions.md:109): an earlier host's sessionless run of the instance is"
},
{
"test": "packages/cli/tests/case1-accept.test.mjs",
"line": 2,
"text": "// (agents/darkwing/work/queue-195/DESIGN.md); deviations in agents/darkwing/work/queue-195/BUILD.md."
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 4,
"text": "// agents/darkwing/work/queue-162/BUILD.md. Every run uses a temp data root, temp units and stand-in"
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 915,
"text": "// COUNTED or dropped from it shows here. Row 181's BUILD.md names each writer."
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 928,
"text": "task_comment_cursors:'no time column; a restore drops its advances uncounted (BUILD.md)',"
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 1668,
"text": "// refusal is the guard for a file that changes after that (BUILD.md, round 2)."
},
{
"test": "packages/cli/tests/stack-release.test.mjs",
"line": 2331,
"text": "// agents/darkwing/work/queue-190/DESIGN.md. T-I1, T-I2, T-I5, T-I10 and the loader half of T-I7 are in"
},
{
"test": "packages/cli/tests/publish-socket.test.mjs",
"line": 236,
"text": "// sage-1440 on BUILD.md 14: a push between checkPublish and the git push refuses, with one code"
},
{
"test": "packages/cli/tests/stack-upgrade.test.mjs",
"line": 3,
"text": "// agents/darkwing/work/queue-195/DESIGN.md, as the I3 brief changes them; deviations:"
},
{
"test": "packages/cli/tests/stack-upgrade.test.mjs",
"line": 4,
"text": "// agents/darkwing/work/queue-233/BUILD.md. The stand-ins and the module fixture are"
},
{
"test": "packages/cli/tests/stack-release-external.test.mjs",
"line": 2,
"text": "// test list, agents/darkwing/work/queue-195/DESIGN.md; deviations: agents/darkwing/work/queue-232/BUILD.md."
},
{
"test": "packages/cli/tests/case1-hostcheck.test.mjs",
"line": 3,
"text": "// (agents/darkwing/work/queue-195/DESIGN.md at b7239bb66). Every case copies the script into a scratch"
},
{
"test": "packages/cli/tests/thread-revoke.test.mjs",
"line": 5,
"text": "// The bus half is packages/bus/tests/access-revoke.test.mjs; BUILD.md"
},
{
"test": "packages/cli/tests/access.test.mjs",
"line": 308,
"text": "// outside R5 (BUILD.md). The test pins it, and nothing is written."
},
{
"test": "packages/cli/tests/access.test.mjs",
"line": 316,
"text": "// Row 193c (#1705): `user add` and `user remove`. BUILD.md (agents/megavolt/work/queue-207) names"
},
{
"test": "packages/cli/tests/access.test.mjs",
"line": 455,
"text": "// refuses access.reload alone, as a bus would refuse a call it can't take. BUILD.md"
},
{
"test": "packages/cli/tests/launcher.test.mjs",
"line": 799,
"text": "// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file."
},
{
"test": "packages/cli/tests/lifecycle.test.mjs",
"line": 778,
"text": "// N6 (sage-2246, DESIGN.md:118-120): an end signals the runs the verb named and the instance's runs"
}
],
"row266": {
"ref": "origin/fethry/row266",
"tip": "6418ec063389728e0d8883c94812b12d2074eb43",
"diffBase": "487500a16f4ab5902c318509fdec6acc56a543ca",
"overlap": []
},
"interpretation": "No literal SHA256 or Git-blob hash pins found for planned changed files. Editor provenance compares generated output to BUILD.json and requires regeneration/rebuild, not an external fixed hash."
}
+10 -1
View File
@@ -1,6 +1,7 @@
# Row214 implementation manifest, draft before measurement
Pinned base: aca16404c. Brief: docs/plans/2026-10-20_case2-rows.md,
Execution base: 487500a16f4ab5902c318509fdec6acc56a543ca on herb/row214-r2.
Historical accepted design base: aca16404c. Brief: docs/plans/2026-10-20_case2-rows.md,
C2-11a. Also read at gosalyn/case2-06-idea-caps51cc0445c; that section is
unchanged. Q222's reply-limit settings belong to C2-06, not this row.
@@ -15,9 +16,17 @@ unchanged. Q222's reply-limit settings belong to C2-06, not this row.
| agents/herb/work/queue-214/DESIGN.md | both measurement outcomes, implementation and acceptance |
| agents/herb/work/queue-214/BUILD.md | rulings and receipts, pending runs |
| agents/herb/work/queue-214/MANIFEST.md | this reviewed path list |
| agents/herb/work/queue-214/HASH-PINS.json | static test hash-pin and row266 overlap audit |
| agents/herb/work/queue-214/SOURCE.json | authored source hashes and pending generated-output status |
editor.css is an unchanged build output; no source CSS is touched. No
entry/view, library, route, save, click, documentPath or dependency changes.
Generated changes do not exist yet. This is a design/path manifest, not a
frozen tested candidate. Candidate hashes and measurement results follow
only after the granted runs. Filbert reviews the plan before the build.
R2 under sage-4164: the node, measurement and tests are committed as static,
unrun authoring on the current base. Filbert accepted the design/path scope
on2026-10-22; runtime evidence and regenerated files still require CLEAR.
No chat*.mjs or public/chat.{js,css} path changes. The baseline measurement
now reads487500a16 Git blobs, still verified against its BUILD.json.
+32
View File
@@ -0,0 +1,32 @@
{
"base": "487500a16f4ab5902c318509fdec6acc56a543ca",
"status": "authored; no execution; not a gate-ready frozen candidate",
"sourcePaths": [
{
"path": "packages/webui/editor/model.mjs",
"mode": "100644",
"sha256": "d56b75264c2c099fa0fc8c9f7a48d8ee701d8657ca64b9bd9492ec27c9446901"
},
{
"path": "packages/webui/tests/editor-wikilink.test.mjs",
"mode": "100644",
"sha256": "73360cfc64b2f4d2f7888b576743baea59ea9a53557934507744775fafdc8ab5"
},
{
"path": "packages/webui/tests/helpers/wikilink-cases.mjs",
"mode": "100644",
"sha256": "afa4dc7188548457b2d1966b9437561ba411d7bc9aaaa38bff626c235a64e19c"
},
{
"path": "agents/herb/work/queue-214/measure.mjs",
"mode": "100644",
"sha256": "f81987cdae5c4f7e5ce353162674b3c3510e44f22d6d3beb262a5d198db4e88d"
}
],
"pendingGeneratedPaths": [
"packages/webui/src/public/editor.js",
"packages/webui/editor/BUILD.json"
],
"unchangedGeneratedCss": "packages/webui/src/public/editor.css",
"runtimeEvidence": null
}
+27
View File
@@ -0,0 +1,27 @@
// Run only under Sage's explicit CLEAR. Reads the pinned baseline Git objects,
// not the edited model or rebuilt candidate bundle. Never writes product files.
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import vm from 'node:vm';
import { WIKILINK_CASES } from '../../../../packages/webui/tests/helpers/wikilink-cases.mjs';
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
const base = '487500a16f4ab5902c318509fdec6acc56a543ca';
const git = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
const read = path => git('show', `${base}:${path}`);
const source = read('packages/webui/src/public/editor.js');
const build = JSON.parse(read('packages/webui/editor/BUILD.json'));
const sha256 = createHash('sha256').update(source).digest('hex');
if (sha256 !== build.outputs['src/public/editor.js'].sha256) throw Error('baseline bundle differs from its recorded build');
const context = { console }; context.window = context; context.globalThis = context;
vm.createContext(context);
for (const [name, text] of [['markdown-preview.js', read('packages/webui/src/public/markdown-preview.js')], ['editor.js', source]])
vm.runInContext(text, context, { filename: name });
const results = WIKILINK_CASES.map(({ name, text }) => {
const output = context.MosaicEditor.roundTrip(text);
return { name, input: text, output, equal: output === text };
});
console.log(JSON.stringify({ base: git('rev-parse', base).trim(), bundleSha256: sha256,
measuredAt: new Date().toISOString(), allEqual: results.every(r => r.equal), results }, null, 2));
+35 -1
View File
@@ -23,6 +23,13 @@ export const linkTarget = raw => {
return typeof gate === 'function' ? gate(raw) : null;
};
// C2-11a: an inert single-line atom keeps its exact source, including labels
// and heading fragments. HTML/entity-like targets stay ordinary text so the
// existing escaped-text serialiser still protects rewritten blocks.
const wikilinkSource = /^!?\[\[[^\[\]\r\n<>&]+\]\]$/;
const wikilinkAt = /!?\[\[[^\[\]\r\n<>&]+\]\]/y;
const isWikilink = value => typeof value === 'string' && wikilinkSource.test(value);
const kept = { raw: { default: null }, sig: { default: null }, lead: { default: '' } };
const block = attrs => ({ ...kept, ...attrs });
@@ -34,7 +41,7 @@ export const schema = new Schema({
horizontal_rule: { group: 'block', attrs: block({}), parseDOM: [{ tag: 'hr' }], toDOM() { return ['div', ['hr']]; } },
heading: {
attrs: block({ level: { default: 1 } }),
content: '(text | image)*', group: 'block', defining: true,
content: '(text | image | wikilink)*', group: 'block', defining: true,
parseDOM: [1, 2, 3, 4, 5, 6].map(level => ({ tag: 'h' + level, attrs: { level } })),
toDOM(node) { return ['h' + +node.attrs.level, 0]; },
},
@@ -79,6 +86,16 @@ export const schema = new Schema({
return ['span', { class: 'md-image', 'data-src': a.src, 'data-alt': a.alt, 'data-title': a.title }, shown];
},
},
// No href, image, target resolution or click handler. C2-11b owns opening.
wikilink: {
inline: true, group: 'inline', atom: true,
attrs: { source: {} },
parseDOM: [{ tag: 'span.md-wikilink[data-source]', getAttrs: dom => {
const source = dom.getAttribute('data-source');
return isWikilink(source) ? { source } : false;
} }],
toDOM(node) { return ['span', { class: 'md-wikilink', 'data-source': node.attrs.source }, node.attrs.source]; },
},
hard_break: { inline: true, group: 'inline', selectable: false, parseDOM: [{ tag: 'br' }], toDOM() { return ['br']; } },
},
marks: {
@@ -118,6 +135,16 @@ export const schema = new Schema({
// ------------------------------------------------------------------ Markdown to nodes
const splitter = MarkdownIt('commonmark', { html: true }).enable('table').enable('strikethrough');
const tokenizer = MarkdownIt('commonmark', { html: false }).enable('table').enable('strikethrough');
// The escape and backtick rules run first. A sticky match avoids copying the
// paragraph suffix at every bracket, and never crosses a link-label boundary.
tokenizer.inline.ruler.before('link', 'wikilink', (state, silent) => {
wikilinkAt.lastIndex = state.pos;
const found = wikilinkAt.exec(state.src);
if (!found || state.pos + found[0].length > state.posMax) return false;
if (!silent) { const token = state.push('wikilink', '', 0); token.content = found[0]; }
state.pos += found[0].length;
return true;
});
// Soft breaks stay line breaks in the text, so a wrapped paragraph keeps its wrapping. A link whose
// target the gate refuses makes the whole block source text (`refused`), so nothing rewrites it.
@@ -151,6 +178,7 @@ const parser = new MarkdownParser(schema, prepared, {
hr: { node: 'horizontal_rule' },
image: { node: 'image', getAttrs: tok => ({ src: tok.attrGet('src'), title: tok.attrGet('title') || null, alt: tok.content || null }) },
hardbreak: { node: 'hard_break' },
wikilink: { node: 'wikilink', getAttrs: tok => ({ source: tok.content }) },
em: { mark: 'em' },
strong: { mark: 'strong' },
link: { mark: 'link', getAttrs: tok => ({ href: tok.attrGet('href'), title: tok.attrGet('title') || null }) },
@@ -192,6 +220,12 @@ const serializer = new MarkdownSerializer({
const title = oneLine(a.title);
state.write('![' + state.esc(oneLine(a.alt) || '') + '](' + a.src.replace(/[()]/g, '\\$&') + (title ? ' "' + title.replace(/[\\"]/g, '\\$&') + '"' : '') + ')');
},
wikilink(state, node) {
const source = node.attrs.source, valid = isWikilink(source);
// A programmatic invalid atom must not bypass the same text escaping that
// protects pasted HTML and one-line attributes elsewhere in this model.
state.text(valid ? source : oneLine(String(source ?? '')), !valid);
},
raw_block(state, node) { state.text(node.textContent, false); state.closeBlock(node); },
}, {
...defaultMarkdownSerializer.marks,
@@ -0,0 +1,80 @@
// C2-11a. Unchanged files alone cannot detect escaped wikilinks: the editor
// keeps raw source. The browser checks the structured node and a dirty block.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadEditor } from './helpers/editor-bundle.mjs';
import { WIKILINK_CASES } from './helpers/wikilink-cases.mjs';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
const editor = loadEditor();
for (const { name, text } of WIKILINK_CASES) test(`wikilink round-trip: ${name}`, () => {
assert.equal(editor.roundTrip(text), text);
assert.equal(editor.roundTrip(text.trimEnd()), text.trimEnd(), 'no trailing newline');
});
const page = async t => {
const f = await fixture(); t.after(() => f.close());
const b = await browser(); t.after(() => b.close());
await b.navigate(f.base);
return b;
};
const mount = text => `(() => {
window.__wikiEditor?.destroy();
const host = document.createElement('div'); document.body.append(host);
window.__wikiEditor = window.MosaicEditor.mount(host, {text: ${JSON.stringify(text)}});
return true;
})()`;
const atoms = `(() => {
const result = [];
window.__wikiEditor.view.state.doc.descendants(node => {
if (node.type.name === 'wikilink') result.push({source: node.attrs.source, atom: node.isAtom, inline: node.isInline});
}); return result;
})()`;
test('wikilinks are inline atoms and survive a neighbouring edit in every required context', { timeout: 120000 }, async t => {
const b = await page(t);
for (const { name, text, sources } of WIKILINK_CASES) {
await b.evaluate(mount(text));
assert.deepEqual(await b.evaluate(atoms), sources.map(source => ({ source, atom: true, inline: true })), name);
assert.equal(await b.evaluate('document.querySelectorAll(".md-wikilink a, .md-wikilink img, .md-wikilink iframe").length'), 0, 'no navigation or resource elements');
const changed = await b.evaluate(`(() => {
const e = window.__wikiEditor; let end;
e.view.state.doc.descendants((node, pos) => { if (node.isTextblock && node.type.name !== 'raw_block') end = pos + node.nodeSize - 1; });
e.view.dispatch(e.view.state.tr.insertText(' changed', end));
const output = e.text(); e.setText(output); return output;
})()`);
assert.ok(changed.includes(' changed'), name);
for (const source of sources) assert.ok(changed.includes(source), `${name}: spelling survives dirty-block serialisation`);
assert.deepEqual(await b.evaluate(atoms), sources.map(source => ({ source, atom: true, inline: true })), `${name}: survives reparse`);
if (text.startsWith('---')) assert.ok(changed.startsWith(text.slice(0, text.indexOf('\n\n') + 2)), 'front matter is untouched');
if (name === 'reference-definition collision')
assert.ok(changed.endsWith('\n\n[a]: u\n'), 'the separate reference-definition block keeps its exact bytes');
}
});
test('code, escaped openers, malformed syntax and HTML-like targets stay outside wikilink atoms', { timeout: 120000 }, async t => {
const b = await page(t);
const text = '\\[[escaped]] `[[inline-code]]`\n\n```\n[[fenced-code]]\n```\n\n[[open\n\n[[]]\n\n[[Q&A]]\n\n[[<img src=x onerror=alert(1)>]]\n';
assert.equal(editor.roundTrip(text), text);
await b.evaluate(mount(text));
assert.deepEqual(await b.evaluate(atoms), []);
assert.equal(await b.evaluate('document.querySelector(".ProseMirror").querySelectorAll("img, iframe, script").length'), 0);
});
test('wikilink DOM paste accepts only bounded single-line inert syntax; malformed attrs serialise as escaped text', { timeout: 120000 }, async t => {
const b = await page(t); await b.evaluate(mount('[[a]]\n'));
const result = await b.evaluate(`(() => {
const e = window.__wikiEditor, spec = e.view.state.schema.nodes.wikilink.spec;
const attrs = source => spec.parseDOM[0].getAttrs({getAttribute: () => source});
const invalid = ['not a link', '[[a]]\\n\\n<script>x</script>', '[[<img src=x>]]', '[[a&copy;]]', null].map(source => attrs(source));
const bad = e.view.state.schema.nodes.wikilink.create({source:'[[a]]\\n\\n<img src=x onerror=alert(1)>'});
e.view.dispatch(e.view.state.tr.replaceWith(1, e.view.state.doc.content.size - 1, bad));
return {valid:attrs('[[a#h|label]]'), invalid, output:e.text()};
})()`);
assert.deepEqual(result.valid, { source: '[[a#h|label]]' });
assert.deepEqual(result.invalid, [false, false, false, false, false]);
assert.ok(!result.output.includes('\n\n<img'));
assert.doesNotMatch(result.output, /(?<!\\)<img/);
assert.match(result.output, /\\<img/);
});
@@ -0,0 +1,13 @@
// C2-11a byte fixtures shared by the baseline measurement and regression tests.
export const WIKILINK_CASES = [
{ name: 'plain', text: '[[a]]\n', sources: ['[[a]]'] },
{ name: 'labelled', text: '[[a|b]]\n', sources: ['[[a|b]]'] },
{ name: 'heading target', text: '[[a#h]]\n', sources: ['[[a#h]]'] },
{ name: 'list item', text: '- [[a]]\n', sources: ['[[a]]'] },
{ name: 'next to front matter', text: '---\nrelated: "[[metadata]]"\n---\n\n[[a]]\n', sources: ['[[a]]'] },
{ name: 'heading block and combined label', text: '# See [[a#h|Title]]\n', sources: ['[[a#h|Title]]'] },
{ name: 'spaces, Unicode and adjacent links', text: 'See [[ Héllo there # A heading | a label ]][[next]]\n', sources: ['[[ Héllo there # A heading | a label ]]', '[[next]]'] },
{ name: 'reference-definition collision', text: '[[a]]\n\n[a]: u\n', sources: ['[[a]]'] },
{ name: 'inside an ordinary link', text: '[x [[a]]](u)\n', sources: ['[[a]]'] },
{ name: 'inert embed', text: '![[cover.png]]\n', sources: ['![[cover.png]]'] },
];