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: { "