Jason's Q168: a visual editor for the Console's project documents, held to row 148's rendering rules. Library. ProseMirror with prosemirror-markdown and markdown-it, chosen in the phase 1 library report (LIBRARY.md, REPORT.md in the packet). packages/webui/third_party/editor/ holds 19 upstream packages as source, byte for byte, each with its license, plus NOTICE and PROVENANCE.json. Build. packages/webui/editor/ holds the model, view, entry and build.mjs. esbuild 0.28.2 is an exact devDependency of packages/webui with its own lock; editor/toolchain.mjs checks the version and the binary's sha256 before any build. MOSAIC_REQUIRE_EDITOR_BUILD=1 turns a missing or different esbuild into a failure, and the rebuild test then checks the committed src/public/editor.js and editor.css match a fresh build. The built bundle is committed under Mos's conditions (mos-0755, mos-0765). Console. The docs view gets Visual and Source modes. A save still writes Markdown through the existing project write path, with the same stale-base and read-only handling. A mode switch reuses the draft and reads nothing from the project. Links and images keep row 148's target rules, and no raw HTML runs. serve.mjs serves /editor.js and /editor.css; index.html loads them. Owner Researcher, brief docs/plans/2026-10-16_q168-wysiwyg-editor.md. Round 5 is a rebase ontod88613bac(row 155); only docs/TOOLS.md, webui/README.md, public/bus.js and serve.mjs differ from the round before. Reviewers on candidate manifest 3a4bc21c...3b05: Dewey (security) approve 29027, Filbert approve 29026. Mos's decision 123 go for public.diff 902f0244 (handwritten part ea59079e): mos-0824. Sage gate on72c3fa598with MOSAIC_REQUIRE_EDITOR_BUILD=1: GATE_PASSED 25/25 (node 2397, selftests 422), 22:07:29Z to 22:13:50Z; the rebuild test ran and passed. Nothing live.
76 lines
4.4 KiB
JavaScript
76 lines
4.4 KiB
JavaScript
// Row 154 (#1648, Q168): the Console's visual editor, the parts that need no browser. The routes, the
|
|
// round trip (open and save with no edit writes the same bytes), and the rules the editor source keeps.
|
|
// The imported source and the build are editor-provenance.test.mjs's; the page is editor-browser.test.mjs's.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { loadEditor } from './helpers/editor-bundle.mjs';
|
|
import { EVERY } from './helpers/every-element.mjs';
|
|
import { fixture } from './fixture.mjs';
|
|
|
|
const webui = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const repo = join(webui, '../..');
|
|
const editor = loadEditor();
|
|
|
|
test('the editor routes serve the built files with their types and no cache', async t => {
|
|
const f = await fixture();
|
|
t.after(() => f.close());
|
|
for (const [path, type] of [['/editor.js', 'text/javascript'], ['/editor.css', 'text/css']]) {
|
|
const response = await fetch(f.base + path);
|
|
assert.equal(response.status, 200, path);
|
|
assert.ok(response.headers.get('content-type').startsWith(type), path);
|
|
assert.equal(response.headers.get('cache-control'), 'no-store', path);
|
|
assert.equal(await response.text(), readFileSync(join(webui, 'src/public', path), 'utf8'), `${path} is the built file`);
|
|
}
|
|
const index = await (await fetch(f.base + '/')).text();
|
|
assert.match(index, /<link rel="stylesheet" href="\/editor\.css">/);
|
|
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('open and save with no edit writes the same bytes: the goals review and a doc with every element', () => {
|
|
const goals = readFileSync(join(repo, 'docs/plans/2026-09-27_goals-review.md'), 'utf8');
|
|
assert.equal(editor.roundTrip(goals), goals);
|
|
assert.equal(editor.roundTrip(EVERY), EVERY);
|
|
// the same with the other line ending conventions a file can end in
|
|
for (const text of ['', '\n', '\n\n\n', 'no newline', '# T\n', '# T\n\n\n\ntext\n\n\n', '\n\n indented first line\n']) assert.equal(editor.roundTrip(text), text, JSON.stringify(text));
|
|
});
|
|
|
|
test('open and save with no edit writes the same bytes: every repository doc under docs/ and spec/', () => {
|
|
const found = [];
|
|
const walk = dir => {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.isDirectory()) { if (!['node_modules', '.git', 'v1'].includes(e.name)) walk(join(dir, e.name)); }
|
|
else if (e.name.endsWith('.md')) found.push(join(dir, e.name));
|
|
}
|
|
};
|
|
for (const d of ['docs', 'spec']) { try { walk(join(repo, d)); } catch (e) { if (e.code !== 'ENOENT') throw e; } }
|
|
assert.ok(found.length > 20, `found ${found.length} docs`);
|
|
const changed = [];
|
|
for (const file of found) {
|
|
const text = readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); // CRLF is saved as LF, as row 148's save does
|
|
if (editor.roundTrip(text) !== text) changed.push(file.slice(repo.length + 1));
|
|
}
|
|
assert.deepEqual(changed, [], 'files the editor would change on an open and save with no edit');
|
|
});
|
|
|
|
test('the editor source builds markup only with DOM calls, and the bundle has no eval', () => {
|
|
const sinks = /\binnerHTML\b|\bouterHTML\b|\binsertAdjacentHTML\b|\bdocument\.write\b|\beval\s*\(|\bnew\s+Function\b|\bsrcdoc\b|\bcreateContextualFragment\b|\bDOMParser\b/;
|
|
for (const file of ['entry.mjs', 'model.mjs', 'view.mjs']) {
|
|
const source = readFileSync(join(webui, 'editor', file), 'utf8').split('\n').filter(l => !l.trim().startsWith('//')).join('\n');
|
|
assert.doesNotMatch(source, sinks, `editor/${file}`);
|
|
}
|
|
const bundle = readFileSync(join(webui, 'src/public/editor.js'), 'utf8');
|
|
assert.doesNotMatch(bundle, /\beval\s*\(|\bnew\s+Function\b/, 'src/public/editor.js');
|
|
});
|
|
|
|
test('the link gate and the image rule are the preview\'s', () => {
|
|
const model = readFileSync(join(webui, 'editor/model.mjs'), 'utf8');
|
|
assert.match(model, /globalThis\.MosaicMarkdown\?\.linkTarget/, 'the link target goes through the one gate');
|
|
assert.doesNotMatch(model, /tag:\s*'img/, 'no schema rule makes an <img>');
|
|
// a file's link the gate refuses stays source text, byte for byte, on a round trip and an edit-free save
|
|
const hostile = '[a](javascript:alert(1)) [b](data:text/html,x) [c](vbscript:x) [d](//evil.example/x) [e](https://ok.example/)\n';
|
|
assert.equal(editor.roundTrip(hostile), hostile);
|
|
});
|