diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3c143b54..9474ee91 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6390,3 +6390,58 @@ chance to run and it is cheap. Reconciliation for this cycle: 41 commits, 40 mapped, 1 correctly absent, run by bento-team-slides. Claude-Session: https://claude.ai/code/session_01Jcfdy8A69nonyATtm8vRy8 + +## 2026-09-09 — bento/spaces page templates live in `doc.templates`, not in flagged pages + +A page template is a saved page shape that new pages start from, and there were +two honest places to put it. **A template could be a PAGE** carrying a flag and +hidden from the sidebar — no new format shape, and it is editable with the page +editor for free. **Or a SEPARATE COLLECTION**, `doc.templates`, which nothing +that walks pages can see. The second was chosen, and the reason is a count. + +`doc.pages` is enumerated in roughly forty places in this app: search, the graph +(`graph.ts`), backlinks and the tree (`buildIndex`), `issuesOf` and every board, +table and gallery view in `fields.ts`, the Markdown export in `portable.ts`, the +About counts, the agent API's `pages`/`stats`/`outline`/`validate`, the archive +list, the print sheet, and the file-manager preview. A flagged page needs a gate +at every one of them — and, the part that decides it, at every surface added +AFTERWARDS by someone who has never heard of templates. This zone has already +shipped that exact class twice: an allow-list applied BEFORE an indirection +(`isRemote` on the string an author wrote rather than on the resolved asset, +2026-08-28), and a source-grep assertion that passed straight through a live +regression (#392). A separate collection cannot be forgotten by a surface that +does not know it exists. + +**What the choice costs is real and is stated rather than hidden.** A template +is not a page, so it is not searched, not in the graph, not back-linked, not +printed, and not in the Markdown export — `extractSpace` walks pages. Grafting a +subtree from another space brings that subtree's pages and brings NO templates. +So `doc.templates` is document data that travels with the FILE and not with a +subtree. A page-flag design would have grafted; it would also have leaked into +all thirteen surfaces above, and one forgotten gate is a template appearing in a +reader's search results or, worse, in an export they hand to somebody else. + +**Tokens expand ONCE, at instantiation, and the model stores the result.** +bento/slides resolves `{{page}}`/`{{date}}` at RENDER time because a footer must +re-number when slides move (`resolveFields`, v0.9.12). A template has no such +need: the moment the page is made is the moment its date is decided, and a live +field would mean a page whose text changed under its author overnight. So this +is a string substitution at creation and the new page is an ORDINARY page — an +older build reads it exactly as this one does, with no field system to +understand. `{{date}}`, `{{date:iso}}`, `{{date:short}}`, `{{date+1:iso}}`, +`{{time}}` and `{{title}}`; anything else stays literal, so a `{{mustache}}` in +somebody's prose survives. + +**The date a journal template writes is the ENTRY'S date, never today's.** +Backfilling Tuesday's note on Thursday must write Tuesday, or the feature lies +on every entry except the one made on the day. `doc.journalTemplate` names the +template new daily notes start from; absent means a blank entry, which is what +every file written before this gets. + +Two prototype guards, both because the ids come out of a file somebody mailed +you: `templateById` scans a list rather than indexing an object (so +`journalTemplate:"constructor"` resolves to nothing), and the date-format lookup +uses `Object.hasOwn` (so `{{date:constructor}}` is literal text rather than +`Object`'s constructor stringified into the reader's page). Both are pinned by +assertions in `scripts/test-spaces-model.ts` that were watched to fail under +deliberate sabotage. diff --git a/scripts/test-spaces-model.ts b/scripts/test-spaces-model.ts index a61883d3..563051c2 100644 --- a/scripts/test-spaces-model.ts +++ b/scripts/test-spaces-model.ts @@ -63,6 +63,12 @@ import type { Block, Page } from '../spaces/src/model.ts' import { buildGraph, layoutGraph, stepLayout, nodeRadius, graphBounds, } from '../spaces/src/graph.ts' +import { + type PageTemplate, applyTemplate, expandTokens, instantiateBlocks, journalTemplate, + makeTemplate, putTemplate, removeTemplate, setJournalTemplate, templateById, templatesOf, +} from '../spaces/src/templates.ts' +import { planJournal, todayISO } from '../spaces/src/journal.ts' +import { newPage } from '../spaces/src/model.ts' let failures = 0 let checks = 0 @@ -3594,5 +3600,187 @@ function fsTable(f: string): string { } + +// ---- page templates -------------------------------------------------------- +// BEHAVIOURAL, every one of them: the functions are imported and run. This zone +// has measured twice that a source-grep assertion passes straight through a +// live regression (#392), so nothing here reads the source of anything. +{ + const mk = (over: Record = {}): SpacesDoc => + (parseDoc(doc(over)) as { doc: SpacesDoc }).doc + + // TOLERANCE. `doc.templates` arrives out of a file somebody mailed you. + ok(templatesOf(mk()).length === 0, 'a document with no templates has none') + ok(templatesOf(mk({ templates: 7 })).length === 0, 'a non-array templates field yields none, not a throw') + ok(templatesOf(mk({ templates: [null, 3, {}, { id: '' }, { id: 't1' }] })).length === 1, + 'entries with no usable id are dropped and the good one survives') + ok(templatesOf(mk({ templates: [{ id: 't1' }] }))[0].blocks.length === 0, + 'a template with no blocks array reads as an empty one') + ok(templatesOf(mk({ templates: [{ id: 't1' }] }))[0].name === 't1', + 'a template with no name falls back to its id rather than to undefined') + + // THE INDIRECTION THIS APP HAS SHIPPED TWICE. An id out of the document must + // never reach Object.prototype. + const proto = mk({ templates: [{ id: 't1', name: 'A', blocks: [] }] }) + ok(templateById(proto, 'constructor') === undefined, '"constructor" is not a template') + ok(templateById(proto, '__proto__') === undefined, '"__proto__" is not a template') + ok(templateById(proto, 'toString') === undefined, '"toString" is not a template') + ok(templateById(proto, 't1')?.name === 'A', 'a real id still resolves') + ok(journalTemplate(mk({ journalTemplate: 'constructor' })) === undefined, + 'a journal setting naming a prototype key resolves to nothing') + + // CAPTURE. What a template must NOT carry is the half worth asserting. + const src: Page = { + id: 'p9', title: 'Standup', icon: '📓', width: 'wide', + parent: 'p1', journal: '2026-01-02', archived: true, + comments: [{ id: 'c1', author: 'A', at: '2026-01-01', text: 'private' }], + blocks: [ + { id: 'b1', type: 'h2', html: 'Notes' }, + { id: 'b2', type: 'todo', html: 'ship it', parent: 'b1' }, + ], + } + const tpl = makeTemplate(src, 'Standup') + ok(tpl.name === 'Standup' && tpl.title === 'Standup', 'the name is taken, and the title with it') + ok(tpl.icon === '📓' && tpl.width === 'wide', 'the icon and the width travel') + ok(!('parent' in tpl) && !('journal' in tpl) && !('archived' in tpl) && !('comments' in tpl), + 'the parent, the date, the archive flag and the review threads do NOT') + src.blocks[0].html = 'edited afterwards' + ok(tpl.blocks[0].html === 'Notes', 'the capture is a deep copy — editing the page cannot reach it') + + // INSTANTIATION. Fresh ids, remapped parents, and a block that can hold a caret. + const made = instantiateBlocks(tpl) + ok(made.length === 2, 'both blocks arrive') + ok(made.every((b) => b.id !== 'b1' && b.id !== 'b2'), 'every block gets a FRESH id') + ok(new Set(made.map((b) => b.id)).size === 2, 'and the fresh ids are distinct') + ok(made[1].parent === made[0].id, 'a parent link is remapped to the new ids, not left pointing at the template') + const orphan = instantiateBlocks({ id: 'x', name: 'x', blocks: [{ id: 'b1', type: 'p', html: 'a', parent: 'gone' }] }) + ok(orphan[0].parent === undefined, 'a parent naming a block outside the template is dropped, as parseDoc drops one') + ok(instantiateBlocks({ id: 'x', name: 'x', blocks: [] }).length === 1, + 'an empty template still yields one block — a page with none has nowhere to put the caret') + + // TOKENS. Expanded ONCE, here, and the model stores the result. + const ctx = { date: '2026-03-14', locale: 'en-GB', title: 'Ledger' } + ok(expandTokens('{{date:iso}}', ctx) === '2026-03-14', '{{date:iso}} is the ISO date') + ok(expandTokens('{{date+1:iso}}', ctx) === '2026-03-15', '{{date+1:iso}} is the next calendar day') + ok(expandTokens('{{date-14:iso}}', ctx) === '2026-02-28', '{{date-14:iso}} steps back across a month end') + ok(expandTokens('{{title}}', ctx) === 'Ledger', '{{title}} is the page title') + ok(expandTokens('{{date}}', ctx).includes('2026') && expandTokens('{{date}}', ctx) !== '2026-03-14', + '{{date}} is the reader-facing long form, not the ISO string') + ok(expandTokens('a {{ date : iso }} b', ctx) === 'a 2026-03-14 b', 'whitespace inside the braces is tolerated') + ok(expandTokens('nothing here', ctx) === 'nothing here', 'a string with no tokens comes back identical') + ok(expandTokens('{{author}}', ctx) === '{{author}}', 'a token this build does not know stays literal') + // THE PROTOTYPE GUARD, on the format record. Without Object.hasOwn this + // resolves to Object's constructor and stringifies a function into the page. + ok(expandTokens('{{date:constructor}}', ctx) === '{{date:constructor}}', + '{{date:constructor}} is literal text, not Object.prototype.constructor') + ok(expandTokens('{{date:toString}}', ctx) === '{{date:toString}}', 'and neither is toString a date format') + ok(expandTokens('{{title+1}}', ctx) === '{{title+1}}', 'an offset on a non-date token is not a token at all') + ok(expandTokens('{{title}}', { title: '