From ccd98f73512dc054f130059b81137d293fb53431 Mon Sep 17 00:00:00 2001 From: nyblnet <306268465+nyblnet@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:19:55 +0100 Subject: [PATCH] spaces: footnotes, anchored as text and numbered at render time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A footnote reference is the literal text `[^label]` in a block's html and the notes live in a document-level `doc.footnotes` map (bento/type's shape, so a note outlives the paragraph pointing at it). The number is derived from order of appearance when the page is drawn and is never stored. The anchor is the part that could not be copied from type. type anchors by character offset into a block's `text` runs; a spaces block carries `html`, and an offset into html is moved by canonicalize() reordering mark nesting, by sanitizeInline() unwrapping and stripping on every read, and by the CRDT merging html as one register. A text token moves with the prose through all three because it IS the prose. It also needs no new tag, attribute or href scheme — and a new allowlist entry would be a one-way data hazard, stripping this build's references in every build already shipped. Numbering is per page and derived, so inserting a reference above two others renumbers them with nothing in the file changing. Verified in the built shell: `[^1]` renders as "2" while block.html still says `[^1]`. Markdown round trips both halves — references pass through untouched, `[^1]:` definitions are lifted out before the block parser can downgrade them to paragraphs, and the exporter writes them back per page. Imports of several files deconflict colliding labels and rewrite that file's references; the subtree graft does the same. validate() reports a dangling reference (warning, with its block), an orphaned note (info, kept never deleted) and a label outside the token grammar. Neither can throw on a hand-edited `footnotes` field; every lookup uses Object.hasOwn. Print: the section is endnotes at the foot of the document page, kept whole across a sheet break. `float: footnote` is the typographic answer and no browser implements it, so it would silently not print at all. Costs 3,176 bytes on the shell. --- docs/DECISIONS.md | 47 ++++ docs/spaces-agents.md | 43 ++++ scripts/test-spaces-agent.ts | 51 +++++ scripts/test-spaces-model.ts | 220 ++++++++++++++++++ spaces/CHANGELOG.md | 27 +++ spaces/src/about.ts | 11 + spaces/src/agent.ts | 35 +++ spaces/src/editor.ts | 92 +++++++- spaces/src/footnotes.ts | 431 +++++++++++++++++++++++++++++++++++ spaces/src/i18n/de.ts | 3 + spaces/src/i18n/es.ts | 3 + spaces/src/i18n/fr.ts | 3 + spaces/src/i18n/it.ts | 3 + spaces/src/i18n/ja.ts | 3 + spaces/src/i18n/packed.ts | 3 + spaces/src/i18n/pt.ts | 3 + spaces/src/i18n/zh-Hans.ts | 3 + spaces/src/i18n/zh-Hant.ts | 3 + spaces/src/markdown.ts | 47 +++- spaces/src/model.ts | 29 ++- spaces/src/portable.ts | 34 +++ spaces/src/render.ts | 105 ++++++++- spaces/src/styles.css | 59 +++++ 23 files changed, 1252 insertions(+), 6 deletions(-) create mode 100644 spaces/src/footnotes.ts diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3c143b54..60c2d544 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -14,6 +14,53 @@ Decision. Why. Pointers. --- +## 2026-09-10 — bento/spaces footnotes: the reference is a TEXT TOKEN, and the number is derived + +**Decision.** A footnote in `bento/spaces` is `doc.footnotes` (a document-level +map, label → inline html — bento/type's shape) plus the literal text `[^label]` +inside a block's `html`. The number a reader sees is derived at render time from +order of appearance, per page, and is never stored. + +**Why a text token rather than an anchor.** bento/type anchors a reference by +character offset into a block's `text` runs, and can: it owns the run list and +rewrites every offset in one place. A spaces block carries `html`, and an offset +into html is a position in one particular serialization of a position — three +things move it without changing a word of the prose: `canonicalize()` reorders +mark nesting at every typing-run close, `sanitizeInline()` unwraps and strips on +every read of untrusted html, and the CRDT merges `html` as one register, which +an offset in a second register cannot merge with. + +The other candidate was an inline marker element (``, or an `` +with a new scheme in `HREF_OK`). Rejected because a new allowlist entry is a +ONE-WAY DATA HAZARD, which sanitize.ts's own comment on `HREF_OK` already spells +out: a reference written by this build would be STRIPPED, silently, by every +build shipped before it, on the first edit that touched the block. A text token +is round-tripped byte-for-byte by builds that already exist — verified by +loading a footnoted document into a shell built from the previous release. + +**Why the number is derived.** Footnotes renumber on insertion, so a stored +number is wrong the moment a sentence moves and nothing says so. Same rule as +calc.ts's magic notes and slides' dynamic fields: store the token, derive the +output. The label is therefore an identifier, not a number, exactly as in pandoc +and Obsidian — which is also why the markdown round trip is the identity +function on the reference half. + +**Consequences a future session should not treat as bugs.** (1) While a block is +being EDITED the author sees `[^1]`, not a superscript — injecting marker markup +into a contenteditable host puts it one keystroke from being committed into +`html` (`host.innerHTML` is written to the model on every `input`), which loses +the reference and stores a literal "1". The derived form is drawn in reading +view, print and the file-manager still. (2) A dangling reference is still +numbered and gets an empty row, because that is the authoring gesture and +because what is missing is the note, not the reference. (3) An orphaned note is +reported, never deleted. (4) `[^…]` inside a `code` block is not scanned. + +**Pointers.** `spaces/src/footnotes.ts` (the whole argument, at length), +`spaces/CHANGELOG.md`, `docs/spaces-agents.md` §Footnotes, rigs in +`scripts/test-spaces-model.ts` and `scripts/test-spaces-agent.ts`. + +--- + ## 2026-08-19 — Cross-app embedding: static render + source, never a second renderer **Decision.** One block/element shape, `bento/embed`, shared by every app in both diff --git a/docs/spaces-agents.md b/docs/spaces-agents.md index 8d151b43..ae9697ef 100644 --- a/docs/spaces-agents.md +++ b/docs/spaces-agents.md @@ -203,6 +203,42 @@ Links are same-document fragments: `href` must match `^(https?:|mailto:|#p/)`. Anything else is stripped. +## Footnotes + +A footnote reference is the literal text `[^label]` inside a block's `html`, +and the notes live in one document-level table: + +```json +{ + "pages": [{ "id": "p1", "title": "Coffee", "blocks": [ + { "id": "b8", "type": "p", "html": "Coffee grows in the tropics.[^1]" } + ] }], + "footnotes": { "1": "Between Cancer and Capricorn." } +} +``` + +The reference is **text, not markup** — no tag, no attribute, nothing for the +sanitizer to allow — so it survives every edit and every sanitize pass exactly +the way the word beside it does, and a build that predates footnotes shows the +sentence with `[^1]` in it and round-trips the `footnotes` key untouched. + +A label is `[A-Za-z0-9_-]{1,32}`. It is an **identifier, not a number**: notes +are numbered by order of appearance and the number is derived when the page is +drawn, so inserting a reference earlier on the page renumbers everything after +it and nothing in the file changes. Never write a number into the model and +never expect `[^1]` to render as 1. + +Numbering is **per page** — the page is what prints and what a reader reads. +The section at the foot of a page is derived too: it is that page's references, +in order, so there is no block to add and nothing to keep in step. A note's +value is inline `html`, under the same allowlist as a block's. + +`[^label]` inside a `code` block is left alone. It is not scanned in one and +never becomes a reference. + +Markdown import and export both speak `[^1]` and `[^1]: the note.`, so an +Obsidian or Pandoc vault keeps its footnotes in both directions. + ## The issue tracker **An issue is a page.** There is no issue type and no flag: a page carrying a @@ -357,6 +393,13 @@ markup inside inline `html` (and markup that is dropped whole), hrefs outside th allowlist, images with no `alt`, no size, a missing `asset:` or a remote `src`, a `home` naming nothing, pages with no blocks, and assets nothing references. +On footnotes it adds `dangling-footnote` (**warning**: a `[^label]` with no +note behind it — the reference still renders, numbered, into an empty note), +`orphan-footnote` (**info**: a note in `doc.footnotes` that nothing references, +so it is never numbered and never printed — it is kept, never deleted) and +`unreachable-footnote` (**warning**: a label outside the grammar above, which +no `[^label]` can ever match). + On the tracker it adds: `prop-html-stale` (a value whose readable `html` says something else — the check worth running after any hand edit), `unknown-field-value` and `unknown-field-key` (**info**, because that is how a diff --git a/scripts/test-spaces-agent.ts b/scripts/test-spaces-agent.ts index 3f188ef2..14f27314 100644 --- a/scripts/test-spaces-agent.ts +++ b/scripts/test-spaces-agent.ts @@ -924,5 +924,56 @@ const issue = (id: string, values: Record, extra: Record f.code)) + ok(codes.has('dangling-footnote'), 'a reference with no note behind it is reported') + ok(codes.has('orphan-footnote'), 'a note nothing references is reported') + ok(codes.has('unreachable-footnote'), + 'a label no [^token] could ever match is reported — the note is stranded, silently') + + const dang = v.findings.filter((f) => f.code === 'dangling-footnote') + ok(dang.length === 1 && dang[0].block === 'b1' && dang[0].page === 'p1', + 'the dangling one names the page and block it is in, so it can be found') + ok(dang.every((f) => f.severity === 'warning'), + 'and it is a WARNING, not an error — no word was lost, only the connection') + ok(v.findings.filter((f) => f.code === 'orphan-footnote').every((f) => f.severity === 'info'), + 'an orphaned note is INFO: it is still somebody’s writing, sitting in the file') + ok(v.findings.filter((f) => f.code.endsWith('footnote')).every((f) => !!f.fix && !!f.message), + 'every footnote finding says what is wrong AND how to fix it') + + // NEITHER MAY EVER THROW. `doc.footnotes` arrives in a file somebody mailed + // you, so every shape a hand edit or a generator can produce has to be inert. + for (const bad of ['yes', 7, null, [], { a: 5 }, { a: null }]) { + let threw = false + try { + validateDoc(load([p('p1', [{ id: 'b1', type: 'p', html: 'x[^a]' }])], { footnotes: bad })) + } catch { threw = true } + ok(!threw, `validate() survives footnotes: ${JSON.stringify(bad)}`) + } + + // a document with matched references and notes reports NEITHER — a validator + // that cries wolf on good documents is one an agent learns to skip + const clean = validateDoc(load([ + p('p1', [{ id: 'b1', type: 'p', html: 'A claim.[^1]' }]), + ], { footnotes: { '1': 'the note' } })) + const cleanCodes = new Set(clean.findings.map((f) => f.code)) + ok(!cleanCodes.has('dangling-footnote') && !cleanCodes.has('orphan-footnote'), + 'a document whose footnotes all match reports nothing about them') +} + console.log(`\n${checks - failures}/${checks} checks passed`) if (failures) process.exit(1) diff --git a/scripts/test-spaces-model.ts b/scripts/test-spaces-model.ts index a61883d3..1844c242 100644 --- a/scripts/test-spaces-model.ts +++ b/scripts/test-spaces-model.ts @@ -44,6 +44,9 @@ import { VIEW_LAYOUTS, layoutOf, nextLayout, } from '../spaces/src/fields.ts' import { inlineHtml, parseNote, planImport } from '../spaces/src/markdown.ts' +import { + notesOnPage, markRefs, noteOf, orphanNotes, danglingRefs, refsIn, definitionLines, +} from '../spaces/src/footnotes.ts' import { canonicalMarks, applyMark, clearMarks, markActive, linkAt, linkAttrs, htmlToMd, CLASS_OK, keepClasses, @@ -3594,5 +3597,222 @@ function fsTable(f: string): string { } +// ---- 9. FOOTNOTES ---------------------------------------------------------- +// +// The anchor is a TEXT TOKEN (`[^1]`) in a block's html and the number is +// DERIVED at render time from order of appearance — spaces/src/footnotes.ts +// argues both at length. What follows pins the two properties that make that +// design worth anything, and they are asserted BEHAVIOURALLY (the functions are +// imported and run) rather than by grepping the source, because a source grep +// passes through a live regression: this rig has watched that happen twice. +{ + const mkdoc = (pages: SpacesDoc["pages"], footnotes?: Record): SpacesDoc => + ({ + format: FORMAT, version: 1, docId: 'd1', title: 'T', + pages, theme: {} as never, ...(footnotes ? { footnotes } : {}), + }) as unknown as SpacesDoc + + // ---- numbering is derived, so an INSERT renumbers what follows ----------- + // + // THE LOAD-BEARING ONE. If the number were stored, this is the case that + // would be wrong and silent: the author inserts a note earlier in the page + // and every note after it keeps the number it was born with. + { + const page = { + id: 'p1', title: 'P', + blocks: [ + { id: 'b1', type: 'p', html: 'The first claim.[^a]' }, + { id: 'b2', type: 'p', html: 'The second claim.[^b]' }, + ], + } + const doc = mkdoc([page], { a: 'note A', b: 'note B' }) + + const before = notesOnPage(doc, page as never) + ok(before.num.get('a') === 1 && before.num.get('b') === 2, + 'footnotes number 1,2 in order of appearance') + // RENDERED, not just the map: markRefs is what the reader actually sees. + ok(markRefs('The second claim.[^b]', before, 'p1').includes('>2'), + 'the second reference RENDERS as 2') + + // now insert a THIRD note ahead of both of them + page.blocks.unshift({ id: 'b0', type: 'p', html: 'An earlier aside.[^c]' }) + ;(doc as { footnotes: Record }).footnotes.c = 'note C' + + const after = notesOnPage(doc, page as never) + ok(after.num.get('c') === 1 && after.num.get('a') === 2 && after.num.get('b') === 3, + 'inserting a footnote BEFORE the others renumbers them: c=1, a=2, b=3') + ok(markRefs('The first claim.[^a]', after, 'p1').includes('>2'), + 'the first claim RENDERS as 2 now — the number moved with the page, not with the note') + ok(markRefs('The second claim.[^b]', after, 'p1').includes('>3'), + 'the second claim RENDERS as 3') + // and the DOCUMENT still says what the author wrote + ok(page.blocks[1].html === 'The first claim.[^a]', + 'the model still holds the label, never the number — nothing was rewritten') + + // a repeat of the same label reuses its number, as every footnote system does + const rep = notesOnPage(mkdoc([{ id: 'p9', title: 'P', blocks: [ + { id: 'x', type: 'p', html: 'one[^a] two[^b] again[^a]' }, + ] }], { a: 'A', b: 'B' }), { id: 'p9', title: 'P', blocks: [ + { id: 'x', type: 'p', html: 'one[^a] two[^b] again[^a]' }, + ] } as never) + ok(rep.order.length === 2 && rep.num.get('a') === 1 && rep.num.get('b') === 2, + 'the same label twice on a page is one note, numbered once') + } + + // ---- markdown round trip: [^1] in and [^1] out --------------------------- + { + const src = [ + '# Imported', + '', + 'Coffee is grown in the tropics.[^1] Tea is not.[^tea]', + '', + '[^1]: Between the Tropics of Cancer and Capricorn.', + '[^tea]: Mostly, anyway.', + '', + ].join('\n') + const note = parseNote(src, 'file') + ok(note.footnotes?.['1'] === 'Between the Tropics of Cancer and Capricorn.', + 'a [^1]: definition line is taken out of the file and becomes a note') + ok(note.footnotes?.tea === 'Mostly, anyway.', 'a named label survives too') + const prose = note.blocks.map((b) => b.html ?? '').join(' ') + ok(prose.includes('[^1]') && prose.includes('[^tea]'), + 'the REFERENCES pass through untouched — markdown and this model spell them the same') + ok(!prose.includes('Tropics of Cancer'), + 'the definition line did NOT arrive as a paragraph (the silent-downgrade failure)') + + // ...and back out again + const doc = mkdoc([{ id: 'p1', title: 'Imported', blocks: note.blocks }], note.footnotes) + const lines = definitionLines(doc, doc.pages[0], (h) => h) + ok(lines[0] === '[^1]: Between the Tropics of Cancer and Capricorn.', + 'exporting writes the definition back in the same syntax') + ok(lines.length === 2 && lines[1] === '[^tea]: Mostly, anyway.', + '…for every note the page references, in rendered order') + + // the whole loop, twice: parse → export → parse must be a fixed point + const again = parseNote(['# Imported', '', prose, '', ...lines, ''].join('\n'), 'file') + ok(JSON.stringify(again.footnotes) === JSON.stringify(note.footnotes), + 'markdown round trip is lossless: the same notes come back under the same labels') + } + + // ---- a note is not read through the prototype chain ---------------------- + // + // `doc.footnotes` is DATA OUT OF A FILE. A bare lookup hands back + // Object.prototype.toString for the label `toString` — a FUNCTION, which is + // truthy, so a `?? ''` never fires and its source gets stringified into the + // page. That exact bug has shipped twice in this app. + { + const doc = mkdoc([{ id: 'p1', title: 'P', blocks: [ + { id: 'b1', type: 'p', html: 'trap[^toString] and[^constructor]' }, + ] }]) + ok(noteOf(doc, 'toString') === undefined, 'noteOf("toString") is undefined, not a function') + const notes = notesOnPage(doc, doc.pages[0]) + ok(notes.dangling.length === 2, 'both prototype labels are DANGLING, not satisfied') + const out = markRefs('trap[^toString] and[^constructor]', notes, 'p1') + ok(!out.includes('function') && !out.includes('native code'), + 'and nothing from Object.prototype reaches the rendered html') + } + + // ---- hostile / hand-edited shapes never throw ---------------------------- + { + for (const bad of ['yes', 42, null, [], { a: 5 }, { 'a b': 'x' }]) { + const doc = mkdoc([{ id: 'p1', title: 'P', blocks: [{ id: 'b', type: 'p', html: 'x[^a]' }] }]) + ;(doc as Record).footnotes = bad + let threw = false + try { + const n = notesOnPage(doc, doc.pages[0]) + markRefs('x[^a]', n, 'p1') + orphanNotes(doc) + danglingRefs(doc) + } catch { threw = true } + ok(!threw, `a footnotes field of ${JSON.stringify(bad)} is ignored, never iterated into a throw`) + } + } + + // ---- an unknown reference stays the author's own text -------------------- + { + const doc = mkdoc([{ id: 'p1', title: 'P', blocks: [ + { id: 'b', type: 'p', html: 'see [^nope] and [^yes]' }, + ] }], { yes: 'here' }) + const n = notesOnPage(doc, doc.pages[0]) + const out = markRefs('see [^nope] and [^yes]', n, 'p1') + // A DANGLING REFERENCE IS STILL NUMBERED, deliberately: it gets a marker + // and an EMPTY row in the section, which is both the authoring gesture + // (type [^1], get a slot to write into) and the honest reading — the note + // is missing, not the reference. Leaving it as raw `[^nope]` in the reading + // view was the other candidate and shows the reader syntax they never typed. + ok((out.match(/sp-fnref/g) ?? []).length === 2, + 'a reference with no note is still numbered — the note is what is missing, not the reference') + ok(!out.includes('[^nope]'), '…so no raw token is left in the reading view') + ok(danglingRefs(doc).length === 1 && danglingRefs(doc)[0].label === 'nope', + 'and it is reported as dangling, with the block it is in') + // a token this page's numbering does not know is untouched — that is the + // path a table cell from another page, or a half-typed `[^`, takes + ok(markRefs('other [^elsewhere]', n, 'p1') === 'other [^elsewhere]', + 'a token outside this page’s numbering is left exactly as the author typed it') + } + + // ---- an orphaned note is reported and never deleted ---------------------- + { + const doc = mkdoc([{ id: 'p1', title: 'P', blocks: [{ id: 'b', type: 'p', html: 'nothing here' }] }], + { a: 'a note nobody points at' }) + ok(JSON.stringify(orphanNotes(doc)) === '["a"]', 'a note nothing references is reported as an orphan') + ok(noteOf(doc, 'a') === 'a note nobody points at', '…and is still in the document, untouched') + } + + // ---- a code block is not scanned ---------------------------------------- + { + const doc = mkdoc([{ id: 'p1', title: 'P', blocks: [ + { id: 'b', type: 'code', html: 'grep "[^a]" file', lang: 'sh' }, + ] }], { a: 'A' }) + ok(notesOnPage(doc, doc.pages[0]).order.length === 0, + '`[^a]` in a shell snippet is a shell snippet, not a footnote') + } + + // ---- format additivity: the key survives a build that ignores it --------- + { + const json = JSON.stringify({ + format: FORMAT, version: 1, docId: 'd', title: 'T', + pages: [{ id: 'p', title: 'P', blocks: [{ id: 'b', type: 'p', html: 'x[^1]' }] }], + theme: {}, footnotes: { '1': 'kept' }, + }) + const res = parseDoc(json) + ok(res.ok && (res.doc as { footnotes?: Record }).footnotes?.['1'] === 'kept', + 'doc.footnotes round-trips parseDoc untouched') + ok(res.ok && JSON.parse(JSON.stringify(res.doc)).footnotes['1'] === 'kept', + '…and survives re-serialization, which is what an older build does with it') + } + + // ---- importing two files that both number from 1 ------------------------ + // + // Two vaults collide by construction. Without the rename, the second file's + // [^1] would be answered by the FIRST file's note: a wrong footnote, which is + // worse than a missing one because nothing looks broken. + { + const plan = planImport([ + { path: 'a.md', text: '# A\n\nAlpha.[^1]\n\n[^1]: from A\n' }, + { path: 'b.md', text: '# B\n\nBeta.[^1]\n\n[^1]: from B\n' }, + ], { rootTitle: 'Imported' }) + const bodies = Object.values(plan.footnotes).sort() + ok(bodies.length === 2 && bodies[0] === 'from A' && bodies[1] === 'from B', + 'both files’ notes survive the import — neither is swallowed by the other') + const pageA = plan.pages.find((p) => p.title === 'A')! + const pageB = plan.pages.find((p) => p.title === 'B')! + const refA = refsIn(pageA.blocks.map((b) => b.html ?? '').join(' '))[0] + const refB = refsIn(pageB.blocks.map((b) => b.html ?? '').join(' '))[0] + ok(refA !== refB, 'the colliding label was renamed in exactly one of the two files') + ok(plan.footnotes[refA] === 'from A' && plan.footnotes[refB] === 'from B', + 'and each page’s reference resolves to ITS OWN note') + + // the same file twice is not a collision — re-importing must not fork a note + const same = planImport([ + { path: 'a.md', text: '# A\n\nAlpha.[^1]\n\n[^1]: from A\n' }, + { path: 'c/a.md', text: '# A2\n\nAlpha.[^1]\n\n[^1]: from A\n' }, + ], { rootTitle: 'Imported' }) + ok(Object.keys(same.footnotes).length === 1, + 'an identical note under the same label is the same note, not a fork') + } +} + + console.log(`\n${checks - failures}/${checks} checks passed`) if (failures) process.exit(1) diff --git a/spaces/CHANGELOG.md b/spaces/CHANGELOG.md index 820b8829..257ead2a 100644 --- a/spaces/CHANGELOG.md +++ b/spaces/CHANGELOG.md @@ -477,6 +477,33 @@ Versions follow `0.MINOR.PATCH` while pre-1.0. 2026, from the same file. `bento.journal()` opens today's for an agent, and `bento.journal('2026-08-06')` any day's. +- **Footnotes.** A mark in the prose, the note at the foot of the page — write + `[^1]` where the mark goes and the note appears as a numbered slot below the + page to write into. Notes print, export as `[^1]: the note.` and import back + the same way, so an Obsidian or Pandoc vault keeps its footnotes in both + directions rather than losing them silently on the way in. + + **The number is never stored.** Footnotes are numbered by order of appearance + and the number is worked out when the page is drawn, the way a magic note's + answer and a slide's page number are: put a new reference above two existing + ones and they renumber to 2 and 3 with nothing in the file changing. Measured + in the built shell — `[^1]` renders as "2" while `block.html` still says + `[^1]`. A stored number would have been wrong from the first sentence anyone + moved, and nothing would have said so. + + **The reference is text, not markup**, which is the whole reason it survives + editing: `[^1]` moves with the prose through a keystroke, a sanitize pass, a + canonicalisation and a merge exactly the way the word beside it does, because + there is no offset to keep in step and no attribute for the allowlist to have + an opinion about. It also means a build that predates this shows the sentence + with `[^1]` in it and hands the `footnotes` key back untouched — verified by + loading a footnoted document into a shell built from the previous release. + + A reference whose note has been deleted still renders, numbered, into an + empty note; a note whose reference has gone is kept, never quietly dropped. + `bento.validate()` reports both (`dangling-footnote`, `orphan-footnote`) and + names the block. Costs 3,176 bytes on the shell. + ## [0.1.0] — 2026-08-03 First release. diff --git a/spaces/src/about.ts b/spaces/src/about.ts index 1485adae..274ff018 100644 --- a/spaces/src/about.ts +++ b/spaces/src/about.ts @@ -41,6 +41,7 @@ import { appearanceSection } from './appearance' import { esc, textOf } from './sanitize' import { docForExport } from './model' import { htmlToMd } from './marks.ts' +import { definitionLines } from './footnotes.ts' import { humanBytes } from './assets' import { SPEC, mdLayout, type MdCtx } from './blocks' import { parseDoc, uid } from './model' @@ -893,6 +894,16 @@ export function toMarkdown(store: Store): string { out.push(...lines.flatMap((l) => l.split('\n')).map((l) => (l ? quote + l : quote.trimEnd()))) out.push(sep) }) + // FOOTNOTE DEFINITIONS, AFTER THE PAGE THEY BELONG TO. + // + // Per page, not once at the end, because markdown scopes a definition to + // its file and the export is ONE file: a `[^1]` on page nine answered by + // page one's note would be a wrong footnote rather than a missing one. + // The references themselves need nothing here — `[^1]` is already the + // text in `html`, so htmlToMd carries it out unchanged, which is the + // whole point of the anchor being a text token (src/footnotes.ts). + const defs = definitionLines(store.doc, page, htmlToMd) + if (defs.length) { out.push(...defs, '') } } } walk() diff --git a/spaces/src/agent.ts b/spaces/src/agent.ts index e2b412a7..a688784e 100644 --- a/spaces/src/agent.ts +++ b/spaces/src/agent.ts @@ -34,6 +34,7 @@ import { type SpacesDoc, type Page, type Block, buildIndex, isRemote, newBlock, import { SPECS, SPEC } from './blocks.ts' import { sanitizeInline, textOf, inertBody, esc, UNWRAP } from './sanitize.ts' import { orphanAssets, humanBytes } from './assets.ts' +import { danglingRefs, orphanNotes, LABEL_OK } from './footnotes.ts' import { type FieldSpec, ISSUE_FIELDS, fieldsOf, fieldByKey, optionOf, propBlock, propHtml, valuesOf, isIssue, headerLength, } from './fields.ts' @@ -539,6 +540,40 @@ export function validateDoc(doc: SpacesDoc): ValidateResult { fix: `Delete these keys to shrink the file: ${orphans.slice(0, 8).join(', ')}${orphans.length > 8 ? ', …' : ''}` }) } + // ---- footnotes ----------------------------------------------------------- + // BOTH HALVES ARE REPORTED AND NEITHER IS AN ERROR, because neither loses a + // word: a dangling reference still shows the `[^1]` the author typed and a + // note nothing points at is still the note they wrote. What they lose is the + // CONNECTION, and a connection is exactly the thing an author cannot see is + // missing by reading the page. Neither can throw — every lookup here goes + // through footnotes.ts, which uses Object.hasOwn and tolerates a + // `"footnotes": "yes"` out of a hand-edited file. + for (const d of danglingRefs(doc)) { + add({ page: d.pageId, block: d.blockId, code: 'dangling-footnote', severity: 'warning', path: 'footnotes', + message: `A footnote reference [^${d.label}] has no note behind it, so it renders as the literal text "[^${d.label}]" instead of a number.`, + fix: `Add "${d.label}" to doc.footnotes, or delete the [^${d.label}] from the text.` }) + } + const loose = orphanNotes(doc) + if (loose.length) { + // ONE finding, like the orphan assets above and for the same reason: the + // actionable fact is the total plus the labels, not a row each. + add({ code: 'orphan-footnote', severity: 'info', path: 'footnotes', + message: `${loose.length} footnote(s) in doc.footnotes are referenced by nothing, so they are never numbered and never printed: ${loose.slice(0, 8).join(', ')}${loose.length > 8 ? ', …' : ''}`, + fix: 'Put a [^label] back in the text, or delete the key from doc.footnotes.' }) + } + const rawNotes = (doc as { footnotes?: unknown }).footnotes + if (rawNotes && typeof rawNotes === 'object' && !Array.isArray(rawNotes)) { + // A label outside the token grammar can never be REFERENCED — `[^a b]` + // does not match — so the note is unreachable however many times it is + // written into the prose. Silent, and only findable from here. + const bad = Object.keys(rawNotes as object).filter((k) => !LABEL_OK.test(k)) + if (bad.length) { + add({ code: 'unreachable-footnote', severity: 'warning', path: 'footnotes', + message: `${bad.length} footnote label(s) are outside the reference grammar (letters, digits, "_" and "-", up to 32), so no [^label] can ever point at them: ${bad.slice(0, 5).map((b) => JSON.stringify(b)).join(', ')}`, + fix: 'Rename the key to a plain label and update the [^label] in the text.' }) + } + } + const counts: Record = { error: 0, warning: 0, info: 0 } for (const f of findings) counts[f.severity]++ return { ok: counts.error === 0, counts, findings } diff --git a/spaces/src/editor.ts b/spaces/src/editor.ts index e682b5b1..0acd80b6 100644 --- a/spaces/src/editor.ts +++ b/spaces/src/editor.ts @@ -18,6 +18,7 @@ import * as collabUi from './collabui.ts' import { syncNoticeText } from './syncnotice.ts' import { Store } from './store' import { renderPage, toneLabel, paintCode } from './render' +import { notesOnPage } from './footnotes.ts' import { wireCanvas, placeNewCard } from './canvas.ts' import { CODE_LANGS, langLabel, normLang } from './highlight' import { canonicalize, escText, sanitizeInline, textOf } from './sanitize' @@ -1172,10 +1173,36 @@ export class Editor { }) } + /** + * Repaint if — and only if — this page's footnote NUMBERING has changed. + * + * DERIVE, DO NOT COMMIT: the same shape slides uses for linked charts and + * connectors. Nothing is written to the document here; the section at the + * foot of the page is a function of the references in the blocks, so the + * only thing that can be stale is the DOM. The signature is the ordered + * label list, which is exactly what the section and every marker are drawn + * from — so an unconditional repaint on blur would be a caret-losing + * flicker on every click, and no repaint at all would leave a note the + * author just referenced with nowhere to write it. + */ + private syncFootnotes(): void { + const page = this.store.page + if (!page) return + const sig = notesOnPage(this.store.doc, page).order.join('\u001F') + if (sig === this.fnSig) return + this.fnSig = sig + this.paintPage() + } + + private fnSig = '' + // ---- the page ----------------------------------------------------------- private paintPage(): void { const s = this.store const page = s.page + // the baseline `syncFootnotes` compares against — set here so switching + // pages can never leave the previous page's signature behind + this.fnSig = page ? notesOnPage(s.doc, page).order.join('\u001F') : '' // The bar holds a reference to the block host it is floating over, and this // is about to replace every one of them. this.format?.close() @@ -2308,9 +2335,50 @@ export class Editor { const clean = canonicalize(b.html) if (clean !== b.html) { b.html = clean; host.innerHTML = clean } } + // A FOOTNOTE REFERENCE TYPED INTO THIS BLOCK CHANGES THE PAGE. + // + // The section at the foot is derived from every block's references, so + // adding or deleting a `[^1]` renumbers the notes and adds or removes a + // row. Repainting on `input` would do it a keystroke sooner and take + // the caret with it — half of `[^1` is not a reference, so every one of + // those keystrokes is a signature change. Blur is the first moment the + // caret is not the thing being protected. + this.syncFootnotes() }) } + // FOOTNOTE BODIES. `data-edit-note` and not `data-edit`: the generic + // handler above writes its host's html to a BLOCK, and a note is not one. + // The label is the key into doc.footnotes and it is derived from the text — + // so a note is created by the first keystroke into an empty slot and the + // slot itself came from a `[^label]` somebody typed. + if (!s.readOnly && !this.reading) { + for (const body of view.querySelectorAll('[data-edit-note]')) { + const label = body.dataset.editNote! + body.addEventListener('input', () => { + if (this.painting) return + s.runEdit(`fn:${label}`, () => { + const table = (s.doc.footnotes ??= {}) + table[label] = body.innerHTML + }) + }) + body.addEventListener('blur', () => { + if (this.painting) return + s.endRun() + const table = s.doc.footnotes + if (!table || !Object.hasOwn(table, label)) return + const clean = canonicalize(table[label]) + // AN EMPTIED NOTE IS DELETED, not stored as ''. An empty string is a + // note that exists and says nothing, which reads to validate() as a + // satisfied reference and prints as a blank numbered line; deleting + // the key puts the reference back to dangling, which is the truth and + // is what the author just did. + if (!clean.trim()) delete table[label] + else if (clean !== table[label]) { table[label] = clean; body.innerHTML = clean } + }) + } + } + for (const box of view.querySelectorAll('.sp-check')) { box.addEventListener('change', () => { const id = (box.closest('[data-block-id]') as HTMLElement).dataset.blockId! @@ -4583,12 +4651,18 @@ export class Editor { for (const page of plan.pages) { for (const b of page.blocks) if (b.html) b.html = sanitizeInline(b.html) } + for (const [label, body] of Object.entries(plan.footnotes)) { + plan.footnotes[label] = sanitizeInline(body) + } // ONE step: pages, images and fonts land together or not at all. s.commit(() => { s.doc.pages.push(...plan.pages) if (Object.keys(plan.assets).length) Object.assign((s.doc.assets ??= {}), plan.assets) if (plan.fonts.length) (s.doc.fonts ??= []).push(...plan.fonts) + // ADDITIONS ONLY here (unlike the Markdown path, whose plan starts from + // this table): planGraft returns what the host does not already hold. + if (Object.keys(plan.footnotes).length) Object.assign((s.doc.footnotes ??= {}), plan.footnotes) }) if (plan.pages[0]) s.goToPage(plan.pages[0].id) this.repaint() @@ -4734,6 +4808,9 @@ export class Editor { const plan = planImport(files, { rootTitle: t('Imported notes'), resolveExisting: (target) => existing.get(target), + // so an imported `[^1]` that would land on a note this space already has + // is renamed, in the plan, along with the references to it + existingNotes: s.doc.footnotes, }) // ---- images ------------------------------------------------------------ @@ -4795,6 +4872,11 @@ export class Editor { for (const page of plan.pages) { for (const b of page.blocks) if (b.html) b.html = sanitizeInline(b.html) } + // A NOTE IS INLINE HTML OUT OF SOMEBODY ELSE'S FILE and goes through the + // same gate as a block's, in the same pass, for the same reason. + for (const [label, body] of Object.entries(plan.footnotes)) { + plan.footnotes[label] = sanitizeInline(body) + } // The import already lands under exactly one root (planImport wraps a mixed // selection); re-homing that root is the whole of "add these under this @@ -4805,7 +4887,15 @@ export class Editor { for (const page of plan.pages) if (!page.parent || !arrived.has(page.parent)) page.parent = under } - s.commit(() => { s.doc.pages.push(...plan.pages) }) + s.commit(() => { + s.doc.pages.push(...plan.pages) + // `plan.footnotes` STARTED from this document's own table and had the + // imported notes merged into it, renaming collisions — so it is assigned + // whole rather than spread over the existing one. Absent stays absent + // when nothing has footnotes, so importing plain notes does not add an + // empty key to the file. + if (Object.keys(plan.footnotes).length) s.doc.footnotes = plan.footnotes + }) if (plan.pages[0]) s.goToPage(plan.pages[0].id) this.repaint() this.status(t('Imported')) diff --git a/spaces/src/footnotes.ts b/spaces/src/footnotes.ts new file mode 100644 index 00000000..a929072c --- /dev/null +++ b/spaces/src/footnotes.ts @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// FOOTNOTES: a mark in the prose, the note at the foot of the page. +// +// The whole of this file is PURE — no DOM, no import that touches one — so +// scripts/test-spaces-model.ts imports it directly and asserts the parts that +// actually break: the numbering under an insert, the markdown round trip, an +// orphaned note and a dangling reference. +// +// --------------------------------------------------------------------------- +// THE SHAPE +// --------------------------------------------------------------------------- +// +// doc.footnotes: { "