From 2fbd62dd8ad1bf271675c64968dfe36175ddad5c Mon Sep 17 00:00:00 2001 From: nyblnet <306268465+nyblnet@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:13:35 +0100 Subject: [PATCH] spaces: a page can be saved as a template, and the daily note can start from one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page templates live in doc.templates — a collection, not pages carrying a flag. doc.pages is enumerated in ~40 places (search, graph, backlinks, issuesOf and every view, the Markdown export, About, the agent API, print, preview); a flagged page needs a gate at every one of them and at every surface added afterwards by someone who has never heard of templates. Measured: with the collection, search for a template-only word returns 0, the Markdown export does not contain it, stats.pages equals doc.pages, the sidebar has no row for it and the graph counts 15 pages, not 16. What it costs is that a template travels with the FILE and not with a grafted subtree, which is stated in the changelog and in docs/DECISIONS.md rather than left to be discovered. doc.journalTemplate names the template new daily notes start from; absent means a blank entry, so every file written before this behaves as it always did, and turning the setting off deletes the key. Tokens expand ONCE, at instantiation, and the model stores the result — no field system, and the new page is an ordinary page an older build reads the same way. {{date}}, {{date:iso}}, {{date:short}}, {{date+1:iso}}, {{time}}, {{title}}; anything else stays literal. A journal entry gets the date it is FOR: backfilling 2026-01-05 writes 2026-01-05 and 2026-01-06, verified in the browser. Two prototype guards, because the ids come out of a file somebody mailed you: templateById scans a list rather than indexing an object, and the date-format lookup uses Object.hasOwn. Both are pinned by rig assertions that were watched to fail under deliberate sabotage. New logic is in spaces/src/templates.ts and spaces/src/templateui.ts; the editor gains a narrow TemplateHost and four call sites. Rig coverage is in scripts/test-spaces-model.ts — a cross-zone touch of the shared rig surface, claimed on the board by path. +7,368 B compressed (285,137 B vs 277,769 B for the same tree without it). --- docs/DECISIONS.md | 55 ++++++ scripts/test-spaces-model.ts | 188 ++++++++++++++++++++ spaces/CHANGELOG.md | 27 +++ spaces/README.md | 2 + spaces/src/editor.ts | 56 +++++- spaces/src/i18n/de.ts | 21 +++ spaces/src/i18n/es.ts | 21 +++ spaces/src/i18n/fr.ts | 21 +++ spaces/src/i18n/it.ts | 21 +++ spaces/src/i18n/ja.ts | 21 +++ spaces/src/i18n/packed.ts | 21 +++ spaces/src/i18n/pt.ts | 21 +++ spaces/src/i18n/zh-Hans.ts | 21 +++ spaces/src/i18n/zh-Hant.ts | 21 +++ spaces/src/model.ts | 19 ++- spaces/src/styles.css | 7 + spaces/src/templates.ts | 321 +++++++++++++++++++++++++++++++++++ spaces/src/templateui.ts | 297 ++++++++++++++++++++++++++++++++ 18 files changed, 1159 insertions(+), 2 deletions(-) create mode 100644 spaces/src/templates.ts create mode 100644 spaces/src/templateui.ts 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: '