Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
188 changes: 188 additions & 0 deletions scripts/test-spaces-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> = {}): 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('<b>{{title}}</b>', { title: '<script>' }, (s) => s.replace(/</g, '&lt;')) === '<b>&lt;script></b>',
'the encoder is applied to the VALUE and not to the surrounding html')
ok(expandTokens('{{date:iso}}', { date: '2026-13-99' }) === todayISO(),
'a nonsense date in the context falls back to today rather than to a rolled-over wrong day')

// APPLYING. keepTitle is what the journal needs.
const dated: PageTemplate = {
id: 't2', name: 'Daily', title: '{{date:iso}} log',
blocks: [{ id: 'b1', type: 'p', html: 'Written on {{date:iso}}' }],
}
const target: Page = { id: 'pz', title: 'Untitled', blocks: [{ id: 'old', type: 'p', html: 'gone' }] }
applyTemplate(target, dated, { date: '2026-03-14' })
ok(target.title === '2026-03-14 log', 'the template title is expanded onto the page')
ok(target.blocks.length === 1 && target.blocks[0].html === 'Written on 2026-03-14',
'and the blocks REPLACE what was there')
ok(target.blocks[0].id !== 'old' && target.blocks[0].id !== 'b1', 'with a fresh id')

const kept: Page = { id: 'pk', title: '2026-03-14', journal: '2026-03-14', blocks: [] }
applyTemplate(kept, dated, { date: '2026-03-14' }, true)
ok(kept.title === '2026-03-14', 'keepTitle leaves a journal entry titled by its own date')
ok(kept.blocks[0].html === 'Written on 2026-03-14', 'and still takes the blocks')

// THE JOURNAL, END TO END. The entry's OWN date, never today's — a template
// with {{date}} in it must be right on the day you backfill, too.
const jdoc = mk({ templates: [dated], journalTemplate: 't2' })
const plan = planJournal(jdoc, '2026-03-15')
const jtpl = journalTemplate(jdoc)!
applyTemplate(plan.page, jtpl, { date: String(plan.page.journal) }, true)
ok(plan.page.blocks[0].html === 'Written on 2026-03-15',
'a journal entry made for the 15th says the 15th, whatever day it is made on')
ok(plan.page.title === '2026-03-15', 'and keeps the ISO title the journal model depends on')

// THE COLLECTION. A default is an ABSENT KEY, on the way back as well.
const cdoc = mk()
putTemplate(cdoc, { id: 't1', name: 'One', blocks: [] })
putTemplate(cdoc, { id: 't2', name: 'Two', blocks: [] })
ok(templatesOf(cdoc).length === 2, 'two templates go in')
putTemplate(cdoc, { id: 't1', name: 'One again', blocks: [] })
ok(templatesOf(cdoc).length === 2 && templateById(cdoc, 't1')?.name === 'One again',
'the same id REPLACES rather than duplicating')
setJournalTemplate(cdoc, 't2')
ok(cdoc.journalTemplate === 't2', 'the daily-note setting points at a real template')
setJournalTemplate(cdoc, 'nope')
ok(!('journalTemplate' in cdoc), 'an id that names nothing DELETES the key rather than storing a dangling one')
setJournalTemplate(cdoc, 't2')
removeTemplate(cdoc, 't2')
ok(!('journalTemplate' in cdoc), 'removing the journal template takes the setting with it')
removeTemplate(cdoc, 't1')
ok(!('templates' in cdoc), 'removing the last template deletes the key — back to byte-identical with a file that never had one')

// ADDITIVITY (PLATFORM §3). An older build must round-trip all of this.
const round = mk({
templates: [{ id: 't1', name: 'One', blocks: [{ id: 'b1', type: 'p', html: 'x' }], futureField: 9 }],
journalTemplate: 't1',
})
const back = (parseDoc(JSON.stringify(round)) as { doc: SpacesDoc }).doc
ok(JSON.stringify(back.templates) === JSON.stringify(round.templates),
'templates survive a parse → serialize → parse round trip byte-for-byte')
ok(back.journalTemplate === 't1', 'and so does the daily-note setting')
ok((templatesOf(back)[0] as Record<string, unknown>).futureField === 9,
'a field a LATER build put on a template is still there after this one has read it')

// THEY ARE NOT PAGES, and that is the whole reason for the separate
// collection: nothing that walks doc.pages can see them, including the two
// surfaces that would have needed a gate first.
const walled = mk({
pages: [
{ id: 'p1', title: 'One', blocks: [{ id: 'b1', type: 'p', html: 'hi' }] },
{ id: 'p2', title: 'Two', blocks: [{ id: 'b2', type: 'p', html: 'hi' }] },
],
templates: [{
id: 't1', name: 'Linky',
blocks: [{ id: 'tb1', type: 'p', html: 'see <a href="#p/p1">One</a>' }],
}],
})
const widx = buildIndex(walled)
ok(widx.page.size === 2, 'the page index counts the pages and not the template')
ok((widx.backlinks.get('p1') ?? []).length === 0,
'a link inside a TEMPLATE creates no backlink — the graph, the sidebar and the export all walk pages')
ok(widx.block.get('tb1') === undefined, 'a template block is not in the block index either')

// …until it is instantiated, at which point it IS an ordinary page and every
// one of those surfaces sees it. A feature that is invisible forever is not
// a feature.
//
// Asserted on the INDEX rather than on the backlink, because sanitizeInline
// has no DOM in node and its fallback strips every tag — including the <a>
// this would need. The browser pass in the PR covers the link itself; what
// node can prove is the part that matters here, that the instantiated page
// is an ordinary indexed page and its blocks are ordinary indexed blocks.
const born = newPage('From the template')
applyTemplate(born, templatesOf(walled)[0])
walled.pages.push(born)
const bidx = buildIndex(walled)
ok(bidx.page.size === 3, 'a page MADE from a template is in the page index, like any other page')
ok(bidx.block.get(born.blocks[0].id)?.pageId === born.id,
'and its blocks are in the block index, under it')
ok(born.blocks[0].html?.includes('One'), 'with the template\'s words intact')

// docContentKey: saving a template is a real edit, so crash recovery sees it.
const before = mk()
const after = mk()
putTemplate(after, { id: 't1', name: 'One', blocks: [] })
ok(docContentKey(before) !== docContentKey(after), 'adding a template changes the content key')
}


console.log(`\n${checks - failures}/${checks} checks passed`)
if (failures) process.exit(1)
27 changes: 27 additions & 0 deletions spaces/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

- **Page templates, and a template for the daily note.** Open a page you would
like to reuse, and the page's ⋯ menu offers **Save as template**. From then on
the + above the page list offers a blank page or any of your templates, and
the new page arrives with the blocks, the icon and the width of the one you
saved. With no templates saved the + makes a blank page exactly as it always
did — the picker only appears once there is something in it.

The one that earns the feature is **Use for daily notes**: pick a template in
⋯ → Templates… and every new journal entry starts with your structure instead
of an empty page. It is the most-used workflow in Obsidian and Logseq and it
was the only thing a daily note here could not do.

Write `{{date}}` anywhere in a template and each new page gets its own date
there — `{{date:iso}}` for `2026-03-14`, `{{date:short}}` for a tight space,
`{{date+1:iso}}` for tomorrow, plus `{{time}}` and `{{title}}`. Expanded ONCE,
when the page is made, so what lands in the file is ordinary text an older
build reads the same way. A journal entry gets the date it is FOR: backfilling
Tuesday's note on Thursday writes Tuesday.

Templates live in the document (`doc.templates`) rather than as hidden pages,
so they never appear in search, the graph, backlinks, the sidebar or the
Markdown export — and the flip side, stated plainly: they travel with the FILE
and not with a page you graft into another space. Additive as ever: a file
written before this has no templates key and opens unchanged, and turning the
daily-note setting off deletes the key rather than storing a default. Saving
or deleting a template is one ⌘Z.

## [0.1.0] — 2026-08-03

First release.
Expand Down
2 changes: 2 additions & 0 deletions spaces/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ load contract and format additivity.
| `src/sanitize.ts` | the inline allowlist — the only thing between a file someone mailed you and script execution |
| `src/store.ts` | undo, and the **typing run** |
| `src/journal.ts` | daily notes — the date is `page.journal`, never the title |
| `src/templates.ts` | page templates — `doc.templates`, a collection and NOT flagged pages, and the one-time `{{date}}` expansion |
| `src/templateui.ts` | the three surfaces for them: save this page, pick one from the +, and the manager |
| `src/calc.ts` | magic notes — the evaluator behind a line ending in `=`. No eval, ever |
| `src/render.ts` | model → DOM, shared by the editor, reading view and print |
| `src/highlight.ts` | the code lexer — text → `{kind, a, b}` ranges, no DOM, no strings |
Expand Down
Loading
Loading