22 KiB
Row 154 (#1648) phase 1: candidate report
Researcher, 2026-10-18 (figures re-checked against the result files at 15:20Z). Brief: docs/plans/2026-10-16_q168-wysiwyg-editor.md
(blob 9d01d989c1). Search table and the rejected candidates: LIBRARY.md. This report is for Sage to take to Mos for the
decision 123 go. No editor code is in the repository and nothing was installed there. Phase 2 has not started.
Recommendation
Candidate A: ProseMirror + prosemirror-markdown + markdown-it, built here from tagged source into one unminified
bundle with esbuild, with a block-splice save so an unedited file saves the same bytes. All MIT except one
BSD-2-Clause package (entities). No separate sanitizer: markdown parsing runs with html:false, and the editor schema
is the whitelist. The schema needs two overrides (links, images) that I measured as necessary, below.
This is a recommendation, not a go. Five points need a ruling from Mos (end of this report).
1. Project, origin, pins
Twenty packages are bundled. Every pin is a tag in the project's own repository; each tagged tree was compared with the
npm package of the same version and every file present in both is byte-identical (results/pins.txt;
punycode.js differs only in package.json's name). The ProseMirror tags exist on the project's own forge, not on the
GitHub mirrors (which lack the current tags), so the provenance file should record that URL.
| Package | Version | License | Commit | Tree | Origin |
|---|---|---|---|---|---|
| prosemirror-model | 1.25.12 | MIT | 4211aeeaa3d738454a5d1428a95c1a210e86d787 | 30600a5e07c4ec5c63f1fb1c8ab392e2bc019b9f | code.haverbeke.berlin/prosemirror/prosemirror-model |
| prosemirror-state | 1.4.4 | MIT | d6fdcd19c4f7f68206b0a8d49649860365672585 | b781a85e73fb7c3f37bc669e7dc27e7d16f592d6 | …/prosemirror-state |
| prosemirror-view | 1.42.6 | MIT | f5a5b93a78f6f723c1fbf1a2eec1556a022cd0ee | d73328db75884503bf74157722236efe4a6ef867 | …/prosemirror-view |
| prosemirror-transform | 1.12.2 | MIT | a93e228a10b76698e0878df2e1f881389e3a94ec | a0aaeb587f5fa15efa9fadaab03e7fcb26bbe64e | …/prosemirror-transform |
| prosemirror-inputrules | 1.5.1 | MIT | 089aa3852a55d551211af90aae2560c554ccf476 | 056aa2801460da409048c2eef074f6e513bb1233 | …/prosemirror-inputrules |
| prosemirror-markdown | 1.13.8 | MIT | e3a5926aa3236af2a463d824f4f22455460b008f | 1467a53e37b554d0f0329a5017781db89ecc3da1 | …/prosemirror-markdown |
| prosemirror-commands | 1.7.2 | MIT | 59fee8d89be2d033b28c2bac68e6736e2989bf9d | b5e1865a2d532f1424f409dc5be1d56ac077b855 | …/prosemirror-commands |
| prosemirror-history | 1.5.1 | MIT | 44956b6a1929d7bc99aca9a8ec01c790260fa45f | 8c1e6d7ec040a87886e7f39fbf3029b0c2961ca9 | …/prosemirror-history |
| prosemirror-keymap | 1.2.3 | MIT | 081f24f65b6a394174a4bdf07b9a55a00361f1b7 | 9031f125a6b53618b55b12b9272573a5aba1677c | …/prosemirror-keymap |
| prosemirror-schema-basic | 1.2.5 | MIT | 531f1cad1b8059616dbe68407ab4d446b6f882f4 | c7b80fc1ee25b75fa506bd400d2d479a16b33a9c | …/prosemirror-schema-basic |
| prosemirror-schema-list | 1.5.1 | MIT | e65a144f2285178a20cbdea5b0ee16925be7d6c5 | 12f3dd9c2735f86f54af2442e2600a14dc14f61f | …/prosemirror-schema-list |
| markdown-it | 14.3.3 | MIT | d372f0d8a68bef0b0acaa68868ce8ae8ba85b384 | add6fa27b88402728a549324407204b5ca0b9799 | github.com/markdown-it/markdown-it |
| linkify-it | 5.0.2 | MIT | 50a0c914f834b201cab25ff4faefd1f832b37332 | c8b101da7bbcff3a3de72621fc7e9abf31903eab | github.com/markdown-it/linkify-it |
| uc.micro | 2.1.0 | MIT | d91cec10974f125be7accd4dd870900fd18fdec7 | 085c2a8b6c255efbd5ccd1fb13c4fc012a3521da | github.com/markdown-it/uc.micro |
| mdurl | 2.1.0 | MIT | dd913f6290a4d8772240a401aab993eb65c5786d | 36d7a3131965a4e77531f549b89ecb73eede5080 | github.com/markdown-it/mdurl |
| punycode.js | 2.3.1 (tag v2.3.1) | MIT | 9e1b2cda98d215d3a73fcbfe93c62e021f4ba768 | f95d544642e1599cd27de41416654b7b7a950031 | github.com/mathiasbynens/punycode.js |
| entities | 4.5.0 (tag v4.5.0) | BSD-2-Clause | 61afd4701eaa736978b13c7351cd3de9a96b04bc | 23f047805a33dc7ce35dbdb137c947e5526d8d5c | github.com/fb55/entities |
| orderedmap | 2.1.1 | MIT | 6a30ebbbecd30fed8bda884c7acf4ca9c6a02405 | 49c032b36195f4f3eb5bdf3aa9a0a7b70b0d523c | github.com/marijnh/orderedmap |
| rope-sequence | 1.3.4 | MIT | 11db1ba5aa2abf31ff8040bdf01fabdb9db34c7a | 4006f2f2db7ababcbe84527ccc71afc6b69c19b3 | github.com/marijnh/rope-sequence |
| w3c-keyname | 2.2.8 | MIT | 5ea93b04bb62230c7de4165e3a9b5c840f05cd44 | d6851bd96544a49c9ffd99a0b0eae920656b098d | github.com/marijnh/w3c-keyname |
prosemirror-markdown 1.13.8 depends on markdown-it ^14, so 14.3.3 (what the npm lock resolves under it) is the
parser, not markdown-it 15. argparse (Python-2.0 license) is in the npm lock only for markdown-it's CLI and is not
bundled. @types/* packages are in the lock for typings and are not bundled.
2. Licenses
licenses/ holds 10 license files (8 distinct by checksum: orderedmap = w3c-keyname, and punycode.js = uc.micro).
I copied one file for the ProseMirror group (prosemirror-model.license) and did not compare the other ten ProseMirror
packages' license files with it; the provenance step in phase 2 should copy each package's own file. Holders, from the copied files:
- Marijn Haverbeke and others:
prosemirror-model(copied), assumed the same for the other 10 ProseMirror packages (not checked),orderedmap,rope-sequence,w3c-keyname(MIT). - Vitaly Puzrin (and Alex Kocharin on
markdown-it,mdurl):markdown-it,linkify-it,mdurl(MIT). - Mathias Bynens:
uc.micro,punycode.js(MIT; the two files are byte-identical). - Felix Böhm:
entities(BSD-2-Clause: keep the notice in source and in binary form, with the disclaimer).
All are permissive: the obligation is to keep the notice and the disclaimer with the code, including the built bundle
(--legal-comments=inline keeps the license comments the packages carry; phase 2 should also ship a NOTICE/LICENSE
file next to the imported source, as the brief asks). The repository has no root LICENSE, and package.json
says "license": "UNLICENSED". MIT and BSD-2 allow that: they impose notice-retention, not a license on our code. I am not a
lawyer and this is not a legal opinion; Mos should know the repository's own license position is unstated.
Among the other candidates in LIBRARY.md, only E (BlockNote core, MPL-2.0) and the transitive dual-licensed dompurify in B
and F are outside MIT/BSD/ISC.
3. Sanitizer, and the schema overrides this design needs
There is no DOMPurify in this closure; nothing in the bundle sanitizes HTML strings because nothing parses HTML into the page.
- Load path: Markdown is parsed by markdown-it with
html:false(raw HTML in a file becomes text), then mapped into the schema byprosemirror-markdown. The editor DOM is rendered byprosemirror-viewthrough the schema'stoDOM. - Paste path (the only HTML sink):
prosemirror-viewparses clipboard HTML in a detached document created withdocument.implementation.createHTMLDocument, which does not run scripts or load images, and maps it through the schema'sparseDOMrules, so only schema nodes and marks survive. This is the same mechanism as the sanitizer: the schema is the allow-list. - Measured with the pinned bundle (jsdom;
results/hostile-html.txt; 12 hostile inputs throughDOMParserandDOMSerializerwith the stock schema):- Dropped:
onclick,onmouseover,styleattributes,<script>,<svg>,<iframe>,<style>,<form>,<object>,<math>. - Kept by the stock schema:
<img src="x">(theonerroris dropped but the image stays),<a href="javascript:alert(3)">and<a href="data:text/html,…">. The stocklinkandimagespecs therefore do not meet the brief's rendering rules. Phase 2 must replace them: thelinkmark'sparseDOMandtoDOMpass the href through the samewindow.MosaicMarkdown.linkTargetrow 148 uses (http:, https: or relative, withrel="noopener noreferrer", nothing else), and theimagenode is replaced by text (the alt text with the source shown), never an<img>. The paste path then cannot reintroduce either. This must be tested with the brief's<script>,onerrorandjavascript:browser test, on the paste path as well as the load path.
- Dropped:
- Advisories. OSV batch query on the 29-package lockfile (
results/osv-a-pm.json), 2026-10-18: 0 advisories. Positive control the same day (output not saved in the packet;repro/osv.shhas no control): dompurify 2.3.3 returned 20 and markdown-it 12.2.0 returned 3. The build tool's own lock (27 packages, esbuild 0.28.2) also returned 0 (results/osv-i-build.json). An advisory-free answer is a snapshot of that day; phase 2 should re-run it in the landing record. - eval and network. No
eval(,new Function,document.write,fetch(,XMLHttpRequestorimport(in the built bundle (grep -cEreturns 0 on the 17,963-line file, which contains all 132 inputs). A grep is a screen, not proof that no code path builds a function from a string by other means. The bundle makes no network call of its own.
4. Source size, and what is removed or rewritten
The built bundle reads 132 input files (131 third-party plus the 11-line prototype entry), 25,899 lines, from the esbuild metafile of the pinned build, counting each file's lines on disk. (An earlier count of mine, 127 files and 25,706 lines, used a different method and is superseded.) The prototype entry has no schema or editor code yet, so phase 2 adds to this.
| Package | Files | Lines |
|---|---|---|
| prosemirror-view | 12 | 6,459 |
| prosemirror-model | 12 | 3,574 |
| prosemirror-transform | 10 | 2,113 |
| prosemirror-state | 5 | 1,092 |
| prosemirror-markdown | 4 | 917 |
| prosemirror-commands | 1 | 786 |
| prosemirror-history | 1 | 466 |
| prosemirror-schema-list, -basic, -keymap | 3 | 538 |
| prosemirror-inputrules | 4 | 247 |
| orderedmap, rope-sequence, w3c-keyname | 3 | 444 |
| ProseMirror side | 55 | 16,636 |
| markdown-it | 53 | 6,291 |
| entities | 8 | 1,124 |
| linkify-it | 2 | 839 |
| mdurl | 5 | 541 |
| punycode.js | 1 | 443 |
| uc.micro | 7 | 14 |
| markdown-it side | 76 | 9,252 |
| prototype entry (ours) | 1 | 11 |
A reviewer reads about 25,900 lines of third-party source (about 1,200 of them are entities tables) plus the editor code and CSS we
write in phase 2. For scale, the existing markdown-preview.js is 338 lines. The table counts the files the build reads, not
every file in a package's directory.
What the imported directory would hold: the files above (the src/ of the ProseMirror packages, the index.mjs and lib/
of the markdown-it family, entities/src), each package's license file, and a provenance file with the package, the
commit and the tree hash from section 1. What it would not hold: dist/, .d.ts/.d.cts, tests, benchmarks, READMEs,
rollup.config.* and anything else the build does not read. The brief's tree-hash-vs-provenance test then hashes each package
directory against the tree hash recorded in the provenance file (the tagged tree minus those excluded paths, so the test
needs a rule for which paths count; I would pin the rule in the test and not in prose).
Rewritten: nothing in the imported third-party source. All our changes go in our own files: the entry module, the schema, the splice save, and the editor CSS.
Not human-readable, three generated files (results/ lists the line counts):
entities/src/generated/decode-data-html.ts: 8 lines, 47,698 bytes, encoded strings. The tag hasmaps/andscripts/write-decode-map.ts, which look like its generator (I did not run it, so I have not shown they reproduce the file).uc.micro/categories/P/regex.mjs(2,042 bytes, one line) and.../S/regex.mjs(2,551 bytes): generated Unicode regexes. I did not find a generator script in the taggeduc.microtree.
The same generated files exist in every markdown-it build. Options: (a) accept them as data, with the provenance file stating they are
generated (and entities' generator and input beside its table); (b) regenerate in the build. I did not run any generator, so (b) is
not shown to work; (a) costs nothing and is what I would do unless a reviewer objects.
5. Build, and how a reviewer reproduces it byte for byte
Tool. esbuild 0.28.2. In my scratch build it is the only dependency of a package.json with an exact version
("esbuild":"0.28.2"); nothing in the repository pins it yet, so phase 2 would add that pin. Lockfile integrity hash sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==
(platform binary @esbuild/linux-x64 0.28.2, sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==;
file sha256 e1698a3d5c6c0798fee4fd3b5cc816651f460c63d390a7a26ea4beb0b1884100, 11.4 MB, statically linked, stripped).
This is a prebuilt Go binary. It is a build tool pulled by the lockfile, not vendored or committed, and the bundle it produces is
what Q167 D3 requires to be rebuilt from source. Whether that satisfies D3 is Mos's call (question 2).
Recipe (repro/build-pinned.sh, run from a directory with the pinned trees checked out; no network):
esbuild entry.mjs --bundle --format=iife --target=es2022 --platform=browser \
--legal-comments=inline --log-level=warning \
--alias:prosemirror-{model,state,view,transform,inputrules,markdown}=<pkg>/src/index.ts \
--alias:prosemirror-{commands,history,keymap,schema-basic,schema-list}=<pkg>/src/<name>.ts \
--alias:orderedmap=<pkg>/index.js --alias:rope-sequence=<pkg>/index.js --alias:w3c-keyname=<pkg>/index.js \
--alias:markdown-it=<pkg>/index.mjs --alias:mdurl=<pkg>/index.mjs --alias:uc.micro=<pkg>/index.mjs \
--alias:linkify-it=<pkg>/index.mjs --alias:punycode.js=<pkg>/punycode.js --alias:entities=<pkg>/src/index.ts \
--outfile=editor.js
No minify flag: the output is readable. For the ProseMirror packages the alias target is the file each package's
prepare script feeds pm-buildhelper (checked for prosemirror-view: src/index.ts; the others follow the script in
repro/build-pinned.sh). punycode.js is the tag's CommonJS file; esbuild wraps it. No --external, no plugins, no define.
The entry in repro/entry.mjs is a measurement prototype that exports the pieces; the phase 2 entry will differ, so the hash
above is the hash of the prototype, and phase 2 records its own.
Result. The pinned trees were exported with git archive <tag> (no npm packages in the build input). Two builds in a
sandbox with no network gave identical output: sha256
9587a1706eb5fa8d4d2c79976be6b1d84a0513fe814eeee381b6ecf384d15077, 17,963 lines, 722,595 bytes (results/build-hashes.txt).
A reviewer repeats the same recipe from the same tags and compares the hash. The earlier prototype that aliased
two packages to npm dist also built identically twice but is not the plan; this one uses tag sources only.
One esbuild warning appears and is harmless: prosemirror-transform's import "./mark" is ignored because the package says
sideEffects:false; addMark and removeMark are still in the bundle (imported by transform.ts directly).
Caveats on the reproduction claim. My builds ran with the host's Node and the native binary in a bwrap --unshare-net
sandbox. I proved identical output on this machine twice; I have not rebuilt on another machine, another architecture, or another
esbuild platform package, and the macOS and Windows binaries are different files. Phase 2's test should record the hash and fail
on drift. An alternative with no prebuilt tool (a small concatenating build with Node's type stripping) is possible but would be new
code to review, because bare imports cannot use an import map under this CSP; I did not build it.
6. Markdown normalisation (the measured part)
Corpus: the 414 .md files in this checkout (results/corpus.txt). There is no spec/north-star.md; the nearest file is the 3-line
packages/project/templates/north-star.md, which is in the corpus. Test: parse, serialise, compare bytes, no edit.
| Stack | Byte-identical, no edit | Notes |
|---|---|---|
| ProseMirror + prosemirror-markdown, stock | 1 of 414 | repro/rt.mjs |
| Tiptap 3.32.0 + @tiptap/markdown | 1 of 414 | drops tables entirely |
| Milkdown 7.22.3, commonmark + gfm | 59 of 414 | 142 of 414 with - bullets set |
| Block splice (below), A | 414 of 414 (12,879 blocks, 98 with front matter) | exact by construction |
What the stock serialiser changes (each seen in the corpus; small cases in repro/micro.mjs): bullets - to *;
hard-wrapped paragraphs joined onto one line; tables flattened into one paragraph; setext headings to ATX; ~~~ fences and indented
code to backtick fences; reference links inlined; entities decoded; _em_ to *em*; a hard break becomes backslash plus newline;
~~strike~~ and task markers escaped; YAML front matter turns into a rule plus a heading; 3+ blank lines collapse to one; CRLF
collapses.
Block splice (the proposal for phase 2). The document is split into top-level blocks using markdown-it's token map (the source
line range of each block). The editor holds those blocks; on save each block that is unchanged since load writes back its original
source text, and only an edited block is re-serialised. Gaps between blocks (blank lines) are kept as they were. Tables, raw HTML
blocks, rules and front matter (762 HTML blocks, 346 tables and 205 rules in this corpus; 98 files start with front matter) are opaque source blocks: shown, and edited as
source text, never reserialised. This satisfies the brief's "same bytes if no edits".
Two honest limits:
-
The 414-of-414 figure measures the splice arithmetic (gaps plus block slices rebuild every file), not the editor. A file the editor loads and saves untouched is exact because untouched blocks emit their stored text; phase 2's round-trip test (
spec/north-star.mdas the brief says, which does not exist here, so a repo file chosen by Sage, plus a document with every preview element) is what proves it end to end. -
An edited block is rewritten in the editor's canonical Markdown, not its original style. Measured on the 11,566 blocks that are not opaque (
results/per-block-roundtrip.txt), serialising each block alone and comparing with its own source text:Block Count Stock serialiser exact With soft breaks kept and -bulletsparagraph 5,639 32% 97% heading 3,655 99% 99% bullet list 1,326 0% 85% fence 468 100% 100% indented code block 242 0% 0% (becomes a fence) ordered list 228 25% 84% blockquote 8 13% 63% all 11,566 52% 94% So with two serialiser settings, about 94% of blocks would come back unchanged even after an edit, and the other 6% are rewritten (for example indented code to a fenced block, a list's continuation indent, wrapped lines). A list or quote is one top-level block, so an edit anywhere in a list reserialises that whole list. The brief says reviewers accept "the exact normalisation"; this is that statement, and the numbers are from one repository's docs.
7. What this means for the Console
- Routing.
serve.mjsservespackages/webui/src/public/from an explicit route table (lines 24 and 25: file name to content type). A single bundle is one route (/editor.js) and one CSS file is another (/editor.css, carryingprosemirror.css's 54 lines and ours); unbundled serving would need about 131 routes or a new directory route.serve.mjsis not in the brief's owned-file list, so the two route entries need Sage's say-so. - CSP.
script-src 'self'is enough for a same-origin<script src="/editor.js">; no CSP change is planned, and if phase 2 finds one is needed it stops there, as the brief says. I have not run the bundle in a browser; jsdom was the environment, so the brief's browser test is the first real check. - Existing code.
markdown-preview.jsstays; the editor callswindow.MosaicMarkdown.linkTargetrather than copy the link rule.
8. Open items and risks
- Five questions for Mos, below.
- The build is proven reproducible on one machine, twice; not across machines (section 5).
- Three generated data files are not human-readable (section 4).
- Candidates D, E, G, H were not run through the round trip; B and C were. If Mos wants the choice to be between measured round trips, D and G would be next.
- The advisory check is a snapshot (2026-10-18).
- Phase 2's sizing: the editor view, schema overrides, splice save, tests and CSS are new code. I have not estimated their size.
Questions for Mos (decision 123)
- Go on candidate A (ProseMirror + prosemirror-markdown + markdown-it; 19 MIT packages plus
entitiesBSD-2-Clause), importing its source into one new directory in the repository? - Build tool. Accept
esbuild0.28.2 (prebuilt Go binary, exact-pinned with lockfile integrity, not committed) as the pinned build tool, as the brief's "builds from pinned tools" intends? Or require a no-prebuilt-tool build (more code to review, unbuilt)? - Generated tables. Accept the three generated data files as imported data with generator and input beside them (section 4 option (a))?
- Normalisation. Accept that an unedited file saves byte-identical and an edited block is rewritten in canonical Markdown, with the table, raw HTML, rule and front-matter blocks edited only as source text?
- Licensing. The repository has no root
LICENSEand saysUNLICENSED; the imported code is MIT and BSD-2-Clause and needs only its notices kept. Does Mos want anything stated about that before import?
For Sage: serve.mjs needs two route entries (/editor.js, /editor.css) and is outside the brief's owned files.
Packet contents
LIBRARY.md (search), REPORT.md (this file), licenses/ (10 files), results/ (pins, OSV for 8 lockfiles, build hashes,
hostile-HTML run, splice and per-block round-trip output, the corpus list), repro/ (build script and entry, the measurement
scripts; they carry the scratch paths I ran them from, which a reviewer must edit). Scratch lives outside the repository at
/mnt/storage/scratch/researcher/q154/ and is not part of the evidence.