oficina / docs Public

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
Paul Campbell pushed by paul@keymail.dev c476a6af6ad50dab2631784b394aecc3af700896 parent 50325c2
12 files changed, +296 −35
  • fixtures/generate-seed.mjs +44 −2
  • internal/docs/handlers.go +14 −10
  • internal/docs/sample_documents_test.go +11 −6
  • internal/docs/seed_test.go +104 −11
  • internal/docs/seeddata/documentation.update.bin binary
  • internal/docs/seeddata/history.update.bin binary
  • internal/docs/seeddata/letter.update.bin binary
  • internal/docs/seeddata/meeting-notes.update.bin binary
  • internal/docs/seeddata/packing-list.update.bin binary
  • internal/docs/seeddata/proposal.update.bin binary
  • internal/docs/store.go +29 −0
  • internal/docs/views/show.html +94 −6

Intent

Human

<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}}