// Generates the Yjs updates behind CARLOS Docs' sample documents // (internal/docs/seed.go). Same reasoning as generate.mjs: there is no // Go-side Yjs encoder, so seed content has to arrive as a real update // produced by real yjs, exactly like a person's own typing would // produce one — never a text column written directly. // // The .txt files under internal/docs/seeddata/ are the editable // source; this script is what turns an edited .txt back into the // matching .update.bin the app actually applies. Run it after editing // any seed .txt file, and commit both together — the .bin is the // artefact, the .txt is the reason it looks the way it does. // // As of the fragment task, the update built here is a Y.XmlFragment // on root "prosemirror" (the same root and shape // fixtures/generate-fragment.mjs uses for core/src/fragment.rs's own // wire fixtures — see that file's own comment), not a bare Y.Text — // the read view now renders these through the CRDT's fragment // renderer, and a Y.Text-only update reads back empty through that // path. import * as Y from 'yjs' import { readFileSync, writeFileSync, readdirSync } from 'node:fs' import { join, dirname, basename } from 'node:path' import { fileURLToPath } from 'node:url' const seedDir = join( dirname(fileURLToPath(import.meta.url)), '..', 'internal', 'docs', 'seeddata', ) const names = readdirSync(seedDir) .filter((f) => f.endsWith('.txt')) .map((f) => basename(f, '.txt')) .sort() // titles mirrors internal/docs/seed.go's seedDocs Title field, by Base // — kept in sync by hand, the same way this script's own Base names // already mirror seed.go's. blocksFor (below) uses it to decide // whether a document's opening block is a literal echo of its own // title (drop it) or real content that merely looks similar (keep // it). If the two ever drift, the failure is visible and harmless // either way: a document whose title changed here keeps a stray // opening paragraph until this map is updated, never the reverse // (silently eating real content it doesn't recognise as a title). const titles = { 'packing-list': 'Packing List — Thursday Site Visit', 'proposal': 'Proposal: Refitting the Elm Street Reading Room', 'meeting-notes': 'Notes — Tuesday Planning Sync', 'documentation': 'Quick Reference: Resetting the Site Printer', 'letter': 'Letter to the Harbourview Allotment Committee', 'history': 'A History of the Millbrook Waterworks', } // blocksFor turns a .txt source into the paragraph/heading blocks its // matching Y.XmlFragment gets built from: split on blank lines, then // classify each block. A block reads as a heading only when it looks // like one — short, a single line, no trailing sentence-ending // punctuation — and never for the very first block (i === 0): that // guard exists independently of the title check below, because a // short, unpunctuated opening line is not always a title echo — // letter.txt's own opening block, "Dear Members of the Harbourview // Allotment Committee,", fits the heading shape (52 chars, one line, // trailing comma) but is a real salutation, not a section break. This // is what gives "Early years" and its neighbours in the Millbrook // waterworks history their real shape, rather than leaving every // block a paragraph. // // title, when given, is compared against the *first* paragraph only // (trimmed, exact match) — fix round 1: an earlier version of this // function dropped every document's opening block unconditionally, on // the assumption that a seed .txt always opens by restating its own // title. That is true for five of the six seed documents but not // letter.txt, whose opening line is real content the unconditional // version would have silently deleted. Comparing against the actual // title, rather than guessing from shape or position, is what lets // this stay correct for both cases at once. The comparison runs after // classification (on the finished `blocks` array), not before it on // `paras`: dropping the first *paragraph* before classifying would // shift every remaining block's index down by one, and the i !== 0 // guard above would then wrongly protect the new index-0 block (e.g. // history.txt's "Early years") from ever becoming a heading. function blocksFor(text, title) { const paras = text .split(/\n\s*\n/) .map((s) => s.trim()) .filter(Boolean) const blocks = paras.map((p, i) => { const looksLikeHeading = p.length < 60 && !p.includes('\n') && !/[.!?:]$/.test(p) && i !== 0 if (looksLikeHeading) { const h = new Y.XmlElement('heading') h.setAttribute('level', '2') const t = new Y.XmlText() t.insert(0, p) h.insert(0, [t]) return h } const el = new Y.XmlElement('paragraph') const t = new Y.XmlText() t.insert(0, p) el.insert(0, [t]) return el }) if (blocks.length > 0 && title !== undefined && paras[0] === title.trim()) { return blocks.slice(1) } return blocks } for (const name of names) { const text = readFileSync(join(seedDir, `${name}.txt`), 'utf8') const doc = new Y.Doc() // Fixed clientID, same reasoning as generate.mjs: running this twice // must produce byte-identical output. It only needs to differ from // the *other* clients that ever touch this specific document, and a // freshly seeded document has none yet — the app's own random // per-document ClientID (internal/docs/store.go's Store.Create) is // what a real edit would be attributed to. doc.clientID = 1 const blocks = blocksFor(text, titles[name]) doc.getXmlFragment('prosemirror').insert(0, blocks) const update = Y.encodeStateAsUpdate(doc) writeFileSync(join(seedDir, `${name}.update.bin`), Buffer.from(update)) console.log(`${name}: ${text.length} chars -> ${blocks.length} blocks -> ${update.length} bytes`) }