# ADR 019: Templates are core; the journal template is a property of the graph

> Logseq's templates are three things: a block with template:: name is a template; /template inserts a copy of its subtree at the caret, with <% today %>-style tokens expanded; and…

Source: https://nooklet.danielalder.cz/decisions/019-templates

Date: 2026-09-12. Status: accepted.

## Context

Logseq's templates are three things: a block with `template:: name` is a template; `/template`
inserts a copy of its subtree at the caret, with `<% today %>`-style tokens expanded; and
`:default-templates {:journals "name"}` in `config.edn` makes every new journal day start from
one. Demand is not in doubt (research/13 §4.2 item 2: a 44k-view how-to thread, the default
config, Logseq's own roadmap calling it "text template revival"). `PLAN.md` §17 deferred them as
"later as a plugin or slash command", and `docs/spec/commands-and-keymap.md` R54 says templates
are "not a core slash item".

Three facts about the code as it stands decide the shape:

1. **There is no client plugin host.** `@nooklet/plugin-api` declares `registerSlashCommand`, but
   nothing in `apps/web` implements `ClientPluginContext`; `SlashMenu` ranks a static `SLASH_ITEMS`
   list. The built-in `mermaid` plugin's `/mermaid` has never reached the menu (B-103).
2. **A journal day is born in two places** — `views/VirtualJournalDay.tsx` on the first keystroke
   and `packages/server/src/data-api.ts#journal()` when an agent's `page_append` names a day that
   does not exist — and neither has a hook a plugin could attach to.
3. **There is no settings API.** `setting` is a server table read by three startup helpers; the
   client's preferences live in `localStorage` (ADR 018 deliberately left the journal title format
   per-device for this reason).

## Decision

**Templates are core.** One command (`block.insertTemplate`), one slash row, a picker, and a
pure module in `@nooklet/core` (`templates.ts`) that both the client and the server use to turn
a template subtree into `block.create` ops. The block copy is fresh ids, template-only properties
dropped, tokens expanded — the same on either side because it is the same function.

**The journal template is chosen by a property, not a setting.** `journal-template:: true` on
the template block. It is read by both birth paths from what they already have — the client's
replica, the server's database — and written by the Settings panel as an ordinary `block.prop`
op. Several claimants: the oldest block wins, by the same query on both sides.

**Date tokens are written in a display format and resolve anyway.** The client expands
`<% today %>` in the reader's journal title format, matching what `/today` inserts. The server
has no reader, so it uses the format the graph came with (`journal-format.ts`) or the default.
Both are `[[links]]` that resolve to the same page, because reference keys canonicalise
(ADR 018). In a journal template `today` is that journal's day, not the wall clock's: an agent
creating tomorrow's page gets tomorrow's date in it.

## Why not the alternatives

**A built-in plugin, as PLAN §17 and research/13 said.** The honest cost is a client plugin host
that does not exist — loader, `EditorApi`, a `registerSlashCommand` that `SlashMenu` actually
consults — plus a server-side "page created" hook for the journal template, plus a plugin
settings UI. That is most of a milestone to deliver a feature whose whole implementation is
smaller than the host it would need. It would also make the journal template the only thing in
the graph that behaves differently depending on whether a plugin is enabled. The plugin API is
still the right place for *someone else's* template engine; this one is the same kind of core
convention that `favorite::` and `icon::` already are.

**A server setting (`setting` table) named in Settings.** Where Logseq keeps it. Rejected because
there is no op to read or write settings, `ops/**` is not this change's to extend, and — the
real reason — the client would then need the server to know which template to insert on a day
it creates offline. A property is in the replica already. It also syncs, appears in the markdown
mirror, and an agent can set it with `block_update`, which a settings row could not offer
without three more ops.

**A per-device preference in `localStorage`, like the theme.** Would have to be set on every
device and could never reach the server's birth path at all.

**Apply the journal template on first render instead of at creation.** "If a journal page has no
blocks, insert the template" would cover both paths with one client-side rule. Rejected: it
writes to a page the person only looked at, it races with a sync that is about to deliver the
blocks the other device already wrote, and an API-created day would read empty to the agent that
created it until some browser happened to open it.

**Reuse the empty bullet by deleting it and inserting fresh blocks.** Simpler than merging the
template's first block into the block being edited. Rejected because `BlockTree` deliberately
keeps a row whose id it is editing even when a refetch no longer contains it (an optimistic
insert looks the same), so the deleted bullet would linger as a ghost row until the next change.
Merging writes the text through `EditorHost.replaceRange` — the editor's own path — and the rest
by ops.

## Consequences

- `PLAN.md` §2/§17 and spec R54 need their "templates are not core" sentences updated; both
  files belong to the coordinator this session.
- `template-including-parent:: false` is honoured (Logseq's switch for "insert the children, not
  the block"), so imported templates keep behaving. Natural-language tokens (`<% next friday %>`)
  are not implemented; an unknown token stays in the text rather than vanishing.
- The picker is a second popup opened by a command rather than by a text trigger, so it is
  mounted imperatively into `document.body` instead of by `CommandLayer`. It keeps the editor
  focused and takes the characters typed to filter at the document's capture phase, the way the
  slash menu keeps its query out of the block.
- Inserting a template from `/template` bypasses the editor's undo history (`BlockTree`'s
  `commit`), so Cmd/Ctrl+Z does not remove it (B-108; fixed, see the amendment below). The ops are ordinary and `batch_undo`
  reverses an API-side insertion as usual.
- A template's `properties` are copied to the copy, including `scheduled`/`deadline`/`repeat`;
  `template`, `journal-template` and `template-including-parent` are dropped from every copied
  node, so a template nested inside a template does not register a duplicate on each insert.
- Client-created days now apply the page, the template and the typed block in one `applyOps`
  batch from one clock (`getOpClock`), sized from the loaded template, rather than three
  sequential single-op writes.

## Amendment 2026-09-13: the insertion is one editor batch (B-108)

`/template` no longer writes around the editor. `data/templates.ts` builds the ops without
applying them (`templateAfterOps`, `templateIntoBlockOps`) and the command hands them to
`EditorHost.commitOps`, which the mounted `BlockTree` commits through the same `runStructural`
path as a split — so the whole insertion is one Cmd/Ctrl+Z, and redo puts it back. The "into an
empty bullet" text is now a `block.text` op in that batch instead of an `EditorHost.replaceRange`:
the reason for `replaceRange` above (an op beside the open buffer is flushed over) does not apply
to a batch the editor commits itself, since it syncs its buffer to what it commits. When no
mounted editor shows the block (the person left the page while the template was being read) the
command applies the ops directly, which is correct but not undoable from the keyboard.

Cost: the tree's undo can only restore properties it models (`invert.ts#propValueBefore`). A
generic property the template writes onto the bullet is undone to "absent", which is only wrong
if the empty bullet already carried that same key — logged as B-191.
