7 Commits

Author SHA1 Message Date
xarmian 08a5f1c7d2 fix(editor): a block marker after a hard break stays text (BUG-3584) (#2060)
* fix(editor): a block marker after a hard break stays text (BUG-3584)

prosemirror-markdown escapes a leading -, +, *, >, # or 1. only at a block
start, but a hard break starts a new line too, so 'First line' + hard break
+ '- not a list item' was stored as 'First line\\\n- not a list item' and read
back as a paragraph plus a bullet list. PadText treats a line start as a block
start for that escape, restoring the flag after. Editor and materializer share
PadText (htmlProseExtensions), so parity holds.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* test(materialize): corpus covers block markers after a hard break (BUG-3584)

The BUG-3583 corpus block gains lines starting '- ', '# ' and '> ' after hard
breaks (one block for both, so adding it did not reshuffle the seeded cases);
a vantage probe pins that the corpus reaches the escaped form. corpus.json
regenerated; go test ./internal/materialize passes on the rebuilt bundle.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(editor): every block marker after a hard break stays text, and nothing else changes (BUG-3584)

The first fix (atBlockStart for the line) missed `1)`, indented bullets,
thematic breaks and setext underlines, and escaped markers that open no
block. PadText now writes, at any line start, the escaped form of exactly
the markers that would open a block (lineStartMarker): a bullet or ordered
marker followed by a space/tab/end, `>`, `#{1,6}` followed by a space/tab/
end, or a line of only -, =, _ or *. `#tag`, `-5`, `1.5` keep their form.
At a true block start prosemirror-markdown's escape stays in charge except
for what it missed (`1)` and an indented bullet/quote, both lists before).

Measured against main's serializer over 28 strings at a block start and
after a hard break: every difference is one of those bug fixes. Corpus
regenerated (the first version's `\-item` escape is gone); go test
./internal/materialize passes on the rebuilt bundle; 22 unit tests.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 10:33:32 -04:00
xarmian 8d910dc20a fix(editor): bold, italic and strike always serialize to markdown that reads back (BUG-3583) (#2057)
* fix(editor): bold, italic and strike always serialize to markdown that reads back (BUG-3583)

CommonMark lets a `**` / `*` / `~~` run open only where it is left-flanking
and close only where it is right-flanking. A mark whose text starts with
punctuation after a letter, or ends with punctuation before a letter, has no
delimiter that can do both. Measured on the headless editor with the editor's
own parser: every such case lost its mark on reload. tiptap-markdown slides an
unflankable delimiter inward (the punctuation silently loses the mark), and
skips that for a mark at output offset 0, so a bold run starting a block went
out as `**Hello.**World` and read back as literal asterisks.

flankingMarks.ts replaces bold, italic and strike (and re-adds code and
underline, keeping StarterKit's mark order) in the body editor, the
materializer (htmlProseExtensions, so parity holds) and the comment editor:
- each run is written with its delimiter and, when it closes, tested against
  the characters actually around it with markdown-it's scanDelims; a run that
  cannot flank is rewritten to <strong>/<em>/<s>, which parse back to the
  same mark (html: true) and render through renderMarkdown's allow-list;
- tiptap-markdown's sliding is switched off for these marks only (a guarded
  patch on prosemirror-markdown's base markString);
- a textblock whose output could still mis-pair (a run ending in punctuation
  before a letter, or merged same-character runs) is parsed back and
  compared per character; on a mismatch it is re-rendered with the HTML form.
Code needs nothing (measured).

Fuzz (6000 paragraphs of letters, punctuation and spaces under overlapping
marks, links and hard breaks, in paragraphs, quotes and list items): marks
lost 0 (old serializer 211-228 per 1500); text altered 0-1 (old 163-172, the
rest a pre-existing line-start escaping gap); output changed where the old
one already round-tripped: 3 per 1500, all one whitespace placement.
Serialization time on 500 paragraphs: equal to main for ordinary prose;
the extra parse runs only on the textblocks that can fail.

Corpus: a block with the failing shapes is added to the materializer parity
generator; corpus.json regenerated; Go materialize tests pass on the new
bundle. New e2e: typed bold "Hello." + "World" is stored as
<strong>Hello.</strong>World; on main it is stored as **Hello.**World.
(A reload of an open tab does not show the defect on main: it re-seeds from
the op-log. The stored body is what readers and a pruned editor see.)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(web): backlink snippets drop inline tags; every reader of an HTML mark pinned (BUG-3583)

The lead's check before GO: the fix stores runs markdown delimiters cannot
express as inline HTML, so every other reader of items.content must show it
as formatting or plain text, never literal tags.

- Share pages and their collection views: renderMarkdown's allow-list renders
  <strong>/<em>/<s> (e2e).
- The editor seeding from the body (after an op-log prune): bold (e2e).
- Search palette snippets: already stripped tags (e2e pins it).
- Backlink snippets showed the raw slice, so `<strong>` appeared literally,
  and the slice could cut a tag in two (`…g>Hello.`). Tags and cut fragments
  are now dropped. Red on main in the new e2e, green here.
- Email/notification templates carry no item content; the workspace and
  artifact exports hand out the raw body, where inline HTML is valid
  CommonMark/GFM; nothing syncs bodies to GitHub (read from the code).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 08:02:21 -04:00
xarmian 92305fba4d fix(web): marks never sit on inline atoms, so a tab's editor equals its Y.Doc (BUG-3568) (#2038)
ProseMirror lets a mark sit on an inline atom (a hard break, an inline
attachment image or chip). Bolding, striking or linking a range that held
one marked it, and markdown or HTML parsing marked it when the source
wrapped it (**a\
b**). y-tiptap never writes marks on non-text inline nodes to the Y.Doc,
so that tab's editor held a document its own Y.Doc did not. It flushed one
body while every other tab, a reload and the materializer derived another.
This is the ~0.7% tab divergence materializerParity found. Measured: both
Y.Docs identical, and only the marking tab's ProseMirror doc differs; a
one-tab repro with no relay reproduces it 4 of 4.

InlineAtomNoMarks (extensions/inlineAtomNoMarks.ts) is an appendTransaction
plugin that strips marks from inline non-text nodes in the same update. It
fires only on AddMarkStep, AddNodeMarkStep, or a replace whose slice holds a
marked atom, so typing pays nothing. It is registered in Editor.svelte and
the headless materializer. No node spec or persisted shape changes, so no
SCHEMA_VERSION question; NodeSpec.marks cannot do this, since it governs a
node's content (measured: it left the marks in place).

One-time normalisation: a body holding a mark across a hard break
(**a\
b**) flushes as **a**\
**b**, the form every Y-derived door already produced, so this touches only
bodies that already flip-flopped. It is stable from then on.

materializerParity: MAX_TAB_DISAGREEMENTS 2 -> 0. The corpus is regenerated;
seed-300922 is now a case. The generator is not deterministic run to run
(Yjs client ids), so a regen also rewrites a couple of other cases'
expectations; each corpus is self-consistent.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 21:57:46 -04:00
xarmian 1eecf710c8 fix(web): angle-bracketed prose survives the editor: <Button>, Promise<T>, comments and unknown tags are text, not dropped markup (BUG-3557) (#2035)
The editor parsed markdown with html enabled, so `<Name>` in prose was a
tag the schema could not hold: dropped on the next flush (one keystroke,
or an agent's write applied by an open tab, which answered 200 while the
text vanished). `<Table>` became an empty table that was then removed.

htmlProse.ts derives the allowlist from the schema's own parse rules, so
the HTML the editor itself writes (tables with spans, HTML-only marks)
still reads back:
- an inline tag passes only if written lower case, matching an inline
  node's or a mark's rule, and closing one that passed; anything else
  is literal text;
- an HTML block passes only if every tag in it matches a rule; otherwise
  it is kept whole as an HtmlBlock (stored as a ```html fence);
- comments are visible text.
PadText escapes `<` only where the parse would read a tag, so
`Promise<T>` round-trips byte for byte. Both the editor and the headless
materializer take the same extensions, right after StarterKit.

materializerParity now skips and counts cases where the two live tabs
converge to different documents (BUG-3568, pre-existing: 2 of 300 seeds
on main), at most 2 of 50, and still requires the materializer to match
one of them; such cases stay out of the Go corpus (lead ruling).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 20:12:18 -04:00
xarmian f0c65fe08c chore(web): adopt Tiptap 3.31.4 and regenerate the materializer corpus (TASK-3421) (#1811)
* chore(web): adopt Tiptap 3.31.4 and regenerate the materializer corpus (TASK-3421)

3.31.4's TableCell/TableHeader renderHTML stop rendering a default
colspan="1" / rowspan="1". parseHTML is unchanged, so a stored body
carrying them parses to the same document. Replaying all 50 old corpus
op-logs through the 3.31.4 bundle reproduces the old output exactly once
those attributes are removed (and fails on 28 cases without removing them).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(web): pin that a pre-3.31.4 table body still dedupes on open (TASK-3421)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 15:52:54 -04:00
xarmian 1e16d2228b fix(web): link text with markdown/HTML-significant characters survives a save (BUG-3315 U1) (#1686)
A wiki link's display text was mangled on its way through the editor, and
the first save of any item linking such a title pinned the mangled text as
a display override, with no rename involved (a census found 76 stored
overrides of this shape among 107 that differ from their target's title).

Load: escapeMarkdownLinkText escaped only brackets and backslashes, so the
html-enabled parser read the title as markdown. `*real*` became emphasis,
`<topic>` was dropped as a tag, `&gt;` was decoded. It now backslash-escapes
\ [ ] * _ ~ ` < > &, all ASCII punctuation CommonMark lets a backslash
escape.

Save: unescapeMarkdownLinkText undid only bracket and backslash escapes,
while the serializer also backslash-escapes other punctuation and writes
< > & as entities. It now undoes both, in one pass.

One residue is intrinsic and pinned by a test: the serializer writes a
literal `&gt;` and a literal `>` as the same bytes, so a title containing
entity-shaped text still becomes an override (0 of 9,620 live titles do).

The materializer corpus: two expectations change (a backtick and `<b>` in
link text, both this fix), taken from the goja bundle's output. A full
regeneration was not used because the generator is nondeterministic: with
unchanged code it rewrites all 50 cases' rows and 1 expected value.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 07:19:04 -04:00
xarmian fa2755e183 feat(materialize): headless op-log materializer bundle, goja runner and worker (TASK-2198 U2) (#1678)
* feat(materialize): headless op-log materializer bundle, goja runner and worker (TASK-2198 U2)

Builds the editor's own JS (Yjs -> y-tiptap -> Tiptap schema -> tiptap-markdown
-> the tab's flush pipeline) into one IIFE, embeds it, and runs it in goja. Not
wired into the server yet (U3 supervisor, U4 write path).

- web/src/lib/collab/materializer/: bundle entry (extension list mirrors
  Editor.svelte's collab branch via the U1 shared modules), op-log replay
  mirroring wsProvider's MESSAGE_SYNC arm, linkedom DOM shim and polyfills
  from the spike, and a vendored patch of prosemirror-markdown
  MarkdownSerializerState.atBlank (quadratic under goja; equivalent).
- web/scripts/build-materializer.mjs (esbuild) -> web/build-materializer/,
  run by `npm run build`; embedded as pad.MaterializerJS.
- internal/materialize: Runner (Go-native atob/btoa/TextEncoder/TextDecoder/
  getRandomValues, schema-version check, ctx interrupt with a clean VM after),
  and RunWorker's length-prefixed JSON protocol behind the hidden
  `pad __materialize-worker` command.
- Parity: vitest drives two real Editor.svelte mounts and compares
  materialize() with the tab's flush on 50 seeded cases; the same cases are
  committed as testdata/corpus.json and replayed in goja (TestCorpusParity).
- CI Go jobs, release tests, Docker and Nix produce the bundle; linkedom and
  esbuild are new devDependencies.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* test(materialize): pin the atBlank patch's equivalence under V8 too (TASK-2198 U2, codex r1)

Codex r1 claimed upstream's /(^|\n)$/ is true for a trailing CR in JS. It is
not (checked in V8: false for "\r" and "a\u2028", as the patch returns),
but the only equivalence test ran under goja and the patch header cited a
V8-side test that did not exist. This adds it, with the line-terminator edge
cases, and it fails when the patch checks CR instead of LF.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* build(nix): vendorHash for goja (TASK-2198 U2)

Taken from the Nix job's recompute on c7fa1931 (specified sha256-iEqv…,
got sha256-P1bQ…), so main does not depend on the heal job after merge.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 02:50:10 -04:00