app: seed documents as ProseMirror fragments, render the read view server-side
Task 3 of the editing plan. The seed generator (fixtures/generate-seed.mjs) now splits each .txt source on blank lines and builds a Y.XmlFragment on root "prosemirror" instead of a bare Y.Text, classifying short, punctuation-free single lines as level-2 headings — this is what gives "Early years" and its neighbours in the Millbrook waterworks history their real shape. Regenerated all six .update.bin fixtures (client id still pinned; regenerating twice produces byte-identical output). Store.HTML mirrors Store.Text's replay exactly and returns template.HTML, so the read view (handlers.go's show, views/show.html) can render real paragraphs, headings, and marks instead of a pre-wrapped text dump. Store.Text now legitimately reads back empty for a seeded document (its content lives on the "prosemirror" root, not "content"), so the seed tests that used to assert through Text now assert through HTML instead — tightened, not weakened: TestSeedContentMatchesItsSource strips tags and also checks for real <p> markup. Added document typography to [docs-text] (headings, paragraph rhythm, lists, blockquote, marks) using literal colours, keeping the canvas's color-scheme: light — a document is paper and must not follow the reader's dark-mode preference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
fixtures/generate-seed.mjs+44 −2internal/docs/handlers.go+14 −10internal/docs/sample_documents_test.go+11 −6internal/docs/seed_test.go+104 −11internal/docs/seeddata/documentation.update.binbinaryinternal/docs/seeddata/history.update.binbinaryinternal/docs/seeddata/letter.update.binbinaryinternal/docs/seeddata/meeting-notes.update.binbinaryinternal/docs/seeddata/packing-list.update.binbinaryinternal/docs/seeddata/proposal.update.binbinaryinternal/docs/store.go+29 −0internal/docs/views/show.html+94 −6
Intent
<task-notification> <task-id>a1a3747df3ec3311a</task-id> <tool-use-id>toolu_014KcqKxjB8simmtuHdWB3gi</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-docs/70f302d1-9d5d-4eb2-8b38-e09a15fdd1c3/tasks/a1a3747df3ec3311a.output</output-file> <status>completed</status> <summary>Agent "Editing Task 2 ABI v2 HTML" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Status: DONE. Commit: `50325c2` (branch `editing`). Cases covered by `TestCheckABIVersion`/`TestCheckABIVersionErrorNamesBothNumbers`: equal (nil), reported lower, reported higher, reported zero, and the error text names both numbers exactly (`"core module reports ABI 5, host understands 2"`). Confirmed I watched it fail before the fix: stubbed `checkABIVersion` to always return `nil` (simulating the deleted check), ran the new tests, and 3 of 4 subtests plus the message test failed as expected; restored the real comparison and reran to green. Also kept `TestNewDocSucceedsAgainstTheCurrentModule` (calls `NewDoc`, which drives `instantiate`) so the extraction stays wired to something real, not just unit-tested in isolation. `.amadan/ci.d/10-gate` exits 0.</result> <usage><subagent_tokens>109010</subagent_tokens><tool_uses>19</tool_uses><duration_ms>158480</duration_ms></usage> </task-notification>
diff --git a/fixtures/generate-seed.mjs b/fixtures/generate-seed.mjs| index 47054fe..456396f 100644 |
| --- a/fixtures/generate-seed.mjs |
| +++ b/fixtures/generate-seed.mjs |
| @@ -9,6 +9,14 @@ |
| // 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' |
| @@ -27,6 +35,39 @@ const names = readdirSync(seedDir) |
| .map((f) => basename(f, '.txt')) |
| .sort() |
| +// 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, which is always |
| +// the document's own opening line (usually a restatement of its |
| +// title) rather than 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. |
| +function blocksFor(text) { |
| + const paras = text |
| + .split(/\n\s*\n/) |
| + .map((s) => s.trim()) |
| + .filter(Boolean) |
| + return 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 |
| + }) |
| +} |
| + |
| for (const name of names) { |
| const text = readFileSync(join(seedDir, `${name}.txt`), 'utf8') |
| @@ -38,9 +79,10 @@ for (const name of names) { |
| // per-document ClientID (internal/docs/store.go's Store.Create) is |
| // what a real edit would be attributed to. |
| doc.clientID = 1 |
| - doc.getText('content').insert(0, text) |
| + const blocks = blocksFor(text) |
| + 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 -> ${update.length} bytes`) |
| + console.log(`${name}: ${text.length} chars -> ${blocks.length} blocks -> ${update.length} bytes`) |
| } |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index c0b46c7..d864d2b 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -2,6 +2,7 @@ package docs |
| import ( |
| "errors" |
| + "html/template" |
| "log/slog" |
| "net/http" |
| "strings" |
| @@ -163,19 +164,22 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) { |
| // showPage is GET /d/{sqid}'s view data (spec §6.1/§6.2). Title is the |
| // document's own title — data, not authored copy, printed as-is the |
| -// same way docRow.Title is on the list. Text is Store.Text's |
| -// plain-text replay of the CRDT through wazero, never a database |
| -// column; a document with no updates yet (a document just created) |
| -// replays to "", which views/show.html must render as an ordinary |
| -// empty canvas rather than erroring. |
| +// same way docRow.Title is on the list. HTML is Store.HTML's rendered |
| +// replay of the CRDT's ProseMirror fragment through wazero, never a |
| +// database column; a document with no updates yet (a document just |
| +// created) replays to "", which views/show.html must render as an |
| +// ordinary empty canvas rather than erroring. It is template.HTML, not |
| +// string, so the template can emit it unescaped — Store.HTML's own |
| +// doc comment is where that "trusted markup" decision is made. |
| type showPage struct { |
| Title string |
| - Text string |
| + HTML template.HTML |
| SignOutLabel string // see listPage.SignOutLabel's own doc comment |
| } |
| // show serves GET /d/{sqid}: the document read-only, its title and |
| -// its CRDT text inside a page-shaped canvas (views/show.html). An |
| +// its CRDT content — rendered server-side as HTML through Store.HTML, |
| +// not plain text — inside a page-shaped canvas (views/show.html). An |
| // unknown sqid and a sqid someone else owns 404 identically — spec |
| // §4.2's contract that a document you cannot see is indistinguishable |
| // from one that does not exist — because Store.Get's owner-scoped |
| @@ -203,14 +207,14 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - text, err := h.store.Text(r.Context(), doc) |
| + html, err := h.store.HTML(r.Context(), doc) |
| if err != nil { |
| - h.logger.Error("docs: document text", "sqid", sqid, "err", err) |
| + h.logger.Error("docs: document html", "sqid", sqid, "err", err) |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| return |
| } |
| - data := showPage{Title: doc.Title, Text: text, SignOutLabel: rastrillo.T(r, "docs.signout.label")} |
| + data := showPage{Title: doc.Title, HTML: html, SignOutLabel: rastrillo.T(r, "docs.signout.label")} |
| if err := h.render.page(w, r, "show", data); err != nil { |
| h.logger.Error("docs: render show", "err", err) |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
diff --git a/internal/docs/sample_documents_test.go b/internal/docs/sample_documents_test.go| index 72f10fd..e0e2bea 100644 |
| --- a/internal/docs/sample_documents_test.go |
| +++ b/internal/docs/sample_documents_test.go |
| @@ -38,8 +38,13 @@ func newSampleDocumentsTestApp(t *testing.T, sampleDocuments string) *docs.App { |
| // EnsurePerson call, on an instance that opted in via |
| // $DOCS_SAMPLE_DOCUMENTS, gets seed.go's own sample set — exactly six |
| // documents (len(seedDocs) in seed.go), owned by them, each with real, |
| -// substantial text read back through the actual CRDT (Store.Text), |
| -// never a row count standing in for content that was never applied. |
| +// substantial content read back through the actual CRDT, never a row |
| +// count standing in for content that was never applied. |
| +// |
| +// Read through Store.HTML, not Store.Text: the seed generator now |
| +// builds a Y.XmlFragment on root "prosemirror" (fixtures/generate-seed.mjs), |
| +// so a seeded document's Store.Text legitimately replays to "" — HTML |
| +// is the read path that actually replays what Seed wrote. |
| func TestNewPersonWithSampleDocumentsOnGetsExactlySix(t *testing.T) { |
| app := newSampleDocumentsTestApp(t, "1") |
| ctx := context.Background() |
| @@ -58,12 +63,12 @@ func TestNewPersonWithSampleDocumentsOnGetsExactlySix(t *testing.T) { |
| } |
| for _, d := range list { |
| - text, err := app.Store().Text(ctx, d) |
| + html, err := app.Store().HTML(ctx, d) |
| if err != nil { |
| - t.Fatalf("Text(%q): %v", d.Title, err) |
| + t.Fatalf("HTML(%q): %v", d.Title, err) |
| } |
| - if len(strings.TrimSpace(text)) < 200 { |
| - t.Errorf("%q has %d characters of text — want substantial, real content", d.Title, len(text)) |
| + if len(strings.TrimSpace(string(html))) < 200 { |
| + t.Errorf("%q has %d characters of HTML — want substantial, real content", d.Title, len(html)) |
| } |
| if d.OwnerID != owner { |
| t.Errorf("%q has OwnerID %d, want %d", d.Title, d.OwnerID, owner) |
diff --git a/internal/docs/seed_test.go b/internal/docs/seed_test.go| index 1c11da6..9ee561e 100644 |
| --- a/internal/docs/seed_test.go |
| +++ b/internal/docs/seed_test.go |
| @@ -2,16 +2,28 @@ package docs_test |
| import ( |
| "context" |
| + "regexp" |
| "strings" |
| "testing" |
| "github.com/carlosframework/docs/internal/docs" |
| ) |
| +// tagPattern strips element markup so TestSeedContentMatchesItsSource |
| +// can hold the *prose* honest, not the exact tags — the block |
| +// classifier in fixtures/generate-seed.mjs is free to change which |
| +// element a given block becomes without this test caring. |
| +var tagPattern = regexp.MustCompile(`<[^>]*>`) |
| + |
| // TestSeedProducesReadableDocuments is the task brief's own test, |
| -// verbatim: Seed must leave at least four documents behind, and every |
| -// one of them must carry substantial text read back through the real |
| -// CRDT (Store.Text), not a database column. |
| +// adapted for this task's own move: Seed must leave at least four |
| +// documents behind, and every one of them must carry substantial |
| +// content read back through the real CRDT — as of this task, through |
| +// Store.HTML, not Store.Text. The seed generator now builds a |
| +// Y.XmlFragment on root "prosemirror" (fixtures/generate-seed.mjs), |
| +// not a Y.Text on root "content", so Store.Text legitimately reads |
| +// back empty for a seeded document; HTML is the read path that |
| +// actually replays what Seed wrote. |
| func TestSeedProducesReadableDocuments(t *testing.T) { |
| app, _ := docs.NewForTest(t) |
| ctx := context.Background() |
| @@ -31,12 +43,12 @@ func TestSeedProducesReadableDocuments(t *testing.T) { |
| // Every seeded document must have real content, through the CRDT. |
| for _, d := range list { |
| - text, err := app.Store().Text(ctx, d) |
| + html, err := app.Store().HTML(ctx, d) |
| if err != nil { |
| - t.Fatalf("Text(%q): %v", d.Title, err) |
| + t.Fatalf("HTML(%q): %v", d.Title, err) |
| } |
| - if len(text) < 200 { |
| - t.Errorf("%q has %d characters — seed content should be substantial", d.Title, len(text)) |
| + if len(html) < 200 { |
| + t.Errorf("%q has %d characters of HTML — seed content should be substantial", d.Title, len(html)) |
| } |
| } |
| } |
| @@ -75,12 +87,20 @@ func TestSeedVariesTimestamps(t *testing.T) { |
| // TestSeedContentMatchesItsSource holds seed.go's committed |
| // .update.bin fixtures honest against the .txt files they were built |
| // from (fixtures/generate-seed.mjs): if a .txt is edited without |
| -// regenerating its .bin, the document Store.Text() actually replays |
| +// regenerating its .bin, the document Store.HTML() actually replays |
| // stops matching what a person editing seeddata/ would expect to see, |
| // silently — this test is what makes that loud instead. It is the Go |
| // side of the same contract the wire-compatibility gate's own |
| // fixtures (fixtures/generate.mjs, scripts/wire-gate.sh) hold for |
| // fixtures/data. |
| +// |
| +// This checked Store.Text() before this task: the seed generator now |
| +// builds a Y.XmlFragment on root "prosemirror", not a Y.Text on root |
| +// "content" (generate-seed.mjs's own comment), so Text legitimately |
| +// reads back empty for a seeded document — HTML is the read path |
| +// that actually replays what Seed wrote. Tags are stripped before the |
| +// prose checks below so this test holds the content honest, not the |
| +// exact element the block classifier chose for a given block. |
| func TestSeedContentMatchesItsSource(t *testing.T) { |
| app, _ := docs.NewForTest(t) |
| ctx := context.Background() |
| @@ -114,11 +134,15 @@ func TestSeedContentMatchesItsSource(t *testing.T) { |
| } |
| for _, d := range list { |
| - text, err := app.Store().Text(ctx, d) |
| + html, err := app.Store().HTML(ctx, d) |
| if err != nil { |
| - t.Fatalf("Text(%q): %v", d.Title, err) |
| + t.Fatalf("HTML(%q): %v", d.Title, err) |
| + } |
| + if !strings.Contains(string(html), "<p>") { |
| + t.Errorf("%q rendered no paragraphs: %.120q", d.Title, html) |
| } |
| - if strings.TrimSpace(text) == "" { |
| + text := strings.TrimSpace(tagPattern.ReplaceAllString(string(html), " ")) |
| + if text == "" { |
| t.Errorf("%q replayed to empty text", d.Title) |
| } |
| // A loose but real cross-check: the replayed text should read |
| @@ -129,3 +153,72 @@ func TestSeedContentMatchesItsSource(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestSeededDocumentsRenderAsHTML holds the seeded documents to the |
| +// ProseMirror-fragment round trip, not merely the plain-text one |
| +// TestSeedContentMatchesItsSource already covers: the seed generator |
| +// (fixtures/generate-seed.mjs) now builds a Y.XmlFragment, and |
| +// Store.HTML reads it back through core's fragment renderer |
| +// (core/src/fragment.rs). A fixture that applies cleanly but renders |
| +// to nothing — the empty-fragment failure Task 1 introduced for the |
| +// old Y.Text-only seed data — is the failure this test exists to |
| +// catch. |
| +func TestSeededDocumentsRenderAsHTML(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "render@example.com") |
| + if err := docs.Seed(ctx, app.Store(), owner); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + docList, err := app.Store().List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List: %v", err) |
| + } |
| + for _, d := range docList { |
| + html, err := app.Store().HTML(ctx, d) |
| + if err != nil { |
| + t.Fatalf("HTML(%q): %v", d.Title, err) |
| + } |
| + if !strings.Contains(string(html), "<p>") { |
| + t.Errorf("%q rendered no paragraphs: %.120q", d.Title, html) |
| + } |
| + } |
| +} |
| + |
| +// TestWaterworksHistoryHasHeadings pins generate-seed.mjs's own block |
| +// classifier: a short line with no trailing full stop, followed by a |
| +// blank line, becomes a level-2 heading rather than an ordinary |
| +// paragraph. "A History of the Millbrook Waterworks" is the one seed |
| +// document long enough to have real section breaks ("Early years", |
| +// "Construction and the first reservoir", ...) — if this renders no |
| +// <h2>, the classifier stopped doing its job. |
| +func TestWaterworksHistoryHasHeadings(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "headings@example.com") |
| + if err := docs.Seed(ctx, app.Store(), owner); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + docList, _ := app.Store().List(ctx, owner) |
| + for _, d := range docList { |
| + if !strings.Contains(d.Title, "Millbrook") { |
| + continue |
| + } |
| + html, err := app.Store().HTML(ctx, d) |
| + if err != nil { |
| + t.Fatalf("HTML: %v", err) |
| + } |
| + if !strings.Contains(string(html), "<h2>") { |
| + t.Error("the long history rendered no headings — the block " + |
| + "classifier in generate-seed.mjs is not doing its job") |
| + } |
| + return |
| + } |
| + t.Fatal("the Millbrook waterworks document is missing from the seed set") |
| +} |
diff --git a/internal/docs/seeddata/documentation.update.bin b/internal/docs/seeddata/documentation.update.bin| index 147634d..7800d06 100644 |
| Binary files a/internal/docs/seeddata/documentation.update.bin and b/internal/docs/seeddata/documentation.update.bin differ |
diff --git a/internal/docs/seeddata/history.update.bin b/internal/docs/seeddata/history.update.bin| index 4209405..34ad719 100644 |
| Binary files a/internal/docs/seeddata/history.update.bin and b/internal/docs/seeddata/history.update.bin differ |
diff --git a/internal/docs/seeddata/letter.update.bin b/internal/docs/seeddata/letter.update.bin| index 37be790..66e57a7 100644 |
| Binary files a/internal/docs/seeddata/letter.update.bin and b/internal/docs/seeddata/letter.update.bin differ |
diff --git a/internal/docs/seeddata/meeting-notes.update.bin b/internal/docs/seeddata/meeting-notes.update.bin| index 492083c..e4c59a7 100644 |
| Binary files a/internal/docs/seeddata/meeting-notes.update.bin and b/internal/docs/seeddata/meeting-notes.update.bin differ |
diff --git a/internal/docs/seeddata/packing-list.update.bin b/internal/docs/seeddata/packing-list.update.bin| index b8fc2d9..fe07e31 100644 |
| Binary files a/internal/docs/seeddata/packing-list.update.bin and b/internal/docs/seeddata/packing-list.update.bin differ |
diff --git a/internal/docs/seeddata/proposal.update.bin b/internal/docs/seeddata/proposal.update.bin| index 5278def..02ddc05 100644 |
| Binary files a/internal/docs/seeddata/proposal.update.bin and b/internal/docs/seeddata/proposal.update.bin differ |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index a4a3cff..479b123 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -5,6 +5,7 @@ import ( |
| crand "crypto/rand" |
| "errors" |
| "fmt" |
| + "html/template" |
| "log/slog" |
| mrand "math/rand/v2" |
| "strings" |
| @@ -400,6 +401,34 @@ func (s *Store) Text(ctx context.Context, doc *Document) (string, error) { |
| return text, nil |
| } |
| +// HTML returns doc's current content rendered as HTML — read from the |
| +// real CRDT core by replaying its update log, never from a database |
| +// column. Mirrors Text exactly (same replay through s.replay, same |
| +// defer h.Close(ctx)); the only difference is the core call this |
| +// makes once the handle is open (h.HTML instead of h.Text) and the |
| +// wrapping return type. |
| +// |
| +// Returning template.HTML rather than string is deliberate, not a |
| +// convenience: core/src/fragment.rs escapes every text run and emits |
| +// no attributes at all (Task 2's renderer), so this markup is safe to |
| +// place in a template unescaped — and the typed return is where that |
| +// "this is trusted markup" decision is made, once, next to the |
| +// escaping that earns it, rather than left for views/show.html to |
| +// assert with a {{ ... | html }} pipeline of its own. |
| +func (s *Store) HTML(ctx context.Context, doc *Document) (template.HTML, error) { |
| + h, err := s.replay(ctx, doc) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer h.Close(ctx) |
| + |
| + html, err := h.HTML(ctx) |
| + if err != nil { |
| + return "", fmt.Errorf("docs: read html for document %d: %w", doc.ID, err) |
| + } |
| + return template.HTML(html), nil |
| +} |
| + |
| // ApplyUpdate applies a new Yjs update to doc: it replays the |
| // existing log to rebuild current state, applies update against that |
| // live core instance, and only once the core has accepted it does it |
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html| index 443710e..615c011 100644 |
| --- a/internal/docs/views/show.html |
| +++ b/internal/docs/views/show.html |
| @@ -2,9 +2,12 @@ |
| handlers.go's show builds the view data: Title is the document's |
| own title, printed as-is the same way list.html's row titles are |
| (data, not authored copy — not resolved through rastrillo.T); |
| - Text is Store.Text's plain-text replay of the CRDT through wazero, |
| - never a database column, and is empty for a document with no |
| - updates yet — an ordinary empty canvas below, not an error. |
| + HTML is Store.HTML's server-rendered replay of the CRDT's |
| + ProseMirror fragment through wazero, never a database column, and |
| + is empty for a document with no updates yet — an ordinary empty |
| + canvas below, not an error. It is template.HTML, so {{.HTML}} |
| + below is written out unescaped on purpose — see Store.HTML's own |
| + doc comment for where that trust decision is made and why. |
| [docs-canvas]/[docs-page]/[docs-text] are this app's own |
| vocabulary (SKILL.md §7: "ui is rastrillo's vocabulary, not |
| @@ -63,12 +66,97 @@ |
| color: #1a1a1a; |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 12px rgba(0, 0, 0, 0.12); |
| } |
| + /* [docs-text] holds Store.HTML's rendered markup (core/src/fragment.rs |
| + — <p>, <h1>-<h6>, <ul>/<ol>/<li>, <blockquote>, and the marks |
| + <strong>/<em>/<code>; no attributes are ever emitted, so nothing |
| + here targets one). white-space is the browser default (normal), |
| + not pre-wrap: that was for the old plain-text read view, and real |
| + block markup carries its own line breaks now — pre-wrap on real |
| + HTML would double up on the blank lines <p> already produces. |
| + Colours below are literal, same reasoning as [docs-page] above: |
| + a document's own marks must not repaint under the reader's |
| + colour scheme. */ |
| [docs-text] { |
| - white-space: pre-wrap; |
| overflow-wrap: break-word; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: 12pt; |
| - line-height: 1.5; |
| + line-height: 1.6; |
| + } |
| + /* First and last block lose their outer margin so [docs-page]'s own |
| + 1in padding is the only air above the first line and below the |
| + last — otherwise a leading <p> or <h1> would stack its own |
| + margin-block-start on top of the page padding. */ |
| + [docs-text] > :first-child { |
| + margin-block-start: 0; |
| + } |
| + [docs-text] > :last-child { |
| + margin-block-end: 0; |
| + } |
| + [docs-text] p { |
| + margin-block: 0 1em; |
| + } |
| + /* Headings clamp to level 1-6 (fragment.rs), but every seed document |
| + today only ever produces <h2> (generate-seed.mjs's block |
| + classifier) — sized so a heading reads as a heading against 12pt |
| + body text without shouting the way a bare browser default h1 |
| + would. */ |
| + [docs-text] h1, |
| + [docs-text] h2, |
| + [docs-text] h3, |
| + [docs-text] h4, |
| + [docs-text] h5, |
| + [docs-text] h6 { |
| + font-family: inherit; |
| + font-weight: 700; |
| + line-height: 1.3; |
| + margin-block: 1.5em 0.5em; |
| + } |
| + [docs-text] h1 { |
| + font-size: 1.6em; |
| + } |
| + [docs-text] h2 { |
| + font-size: 1.3em; |
| + } |
| + [docs-text] h3 { |
| + font-size: 1.1em; |
| + } |
| + [docs-text] h4, |
| + [docs-text] h5, |
| + [docs-text] h6 { |
| + font-size: 1em; |
| + font-style: italic; |
| + } |
| + [docs-text] ul, |
| + [docs-text] ol { |
| + margin-block: 0 1em; |
| + padding-inline-start: 1.4em; |
| + } |
| + [docs-text] li { |
| + margin-block-end: 0.35em; |
| + } |
| + [docs-text] li > :last-child { |
| + margin-block-end: 0; |
| + } |
| + [docs-text] blockquote { |
| + margin-block: 1em; |
| + margin-inline: 0; |
| + padding-inline-start: 1em; |
| + border-inline-start: 3px solid #c8c8c8; |
| + color: #4a4a4a; |
| + font-style: italic; |
| + } |
| + [docs-text] strong { |
| + font-weight: 700; |
| + } |
| + [docs-text] em { |
| + font-style: italic; |
| + } |
| + [docs-text] code { |
| + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; |
| + font-size: 0.85em; |
| + background: #f0f0f0; |
| + padding: 0.15em 0.35em; |
| + border-radius: 3px; |
| } |
| </style> |
| <header rst-page-header> |
| @@ -87,7 +175,7 @@ |
| </header> |
| <div docs-canvas> |
| <div docs-page> |
| - <div docs-text>{{.Text}}</div> |
| + <div docs-text>{{.HTML}}</div> |
| </div> |
| </div> |
| {{end}} |