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
38 changes: 37 additions & 1 deletion docs/spaces-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ unique ids the first time.
| `divider` | — | `<hr>` |
| `image` | `src` (see below), `alt`, `caption`, `width` (10–100 **%**), `w`/`h` (intrinsic px) | `<figure>` |
| `pagelink` | `page` | a card linking to another page |
| `embed` | `page`, `anchor`, `html` | a live view of another page, or one section of it — see **Embeds** |
| `link` | `url`, `title`, `desc`, `site`, `icon`, `image`, `html` | a card linking OUT of the space — see **Link cards** |
| `prop` | `key`, `value`, `html` | one field value — see **The issue tracker** |
| `view` | `layout`, `groupBy`, `html` | a board or list of this space's issues |
Expand Down Expand Up @@ -108,6 +109,38 @@ as it is for `prop`. A `javascript:` or `data:` url renders as a dead card that
keeps its title rather than as a link — `validate()` reports both that and a
remote `image`. Use `pagelink`, not this, for a page inside the space.

### Embeds

An `embed` block shows another page **live**. It stores a reference and never a
copy: the source page is the truth, and editing it changes every embed of it.

```jsonc
{ "id": "b9", "type": "embed",
"page": "p-design", // the target page id, exactly as pagelink means it
"anchor": "Rollout", // OPTIONAL — one heading on that page, matched by NAME
"html": "<a href=\"#p/p-design\">Design notes</a>" }
```

Write `html` too, for the reason `link` and `prop` do: it is what a build that
predates this type renders, so an older shell shows a link to the source page
instead of a blank.

`anchor` names an `h1`/`h2`/`h3` on the target, case- and
whitespace-insensitively, and the section runs to the next heading of the same
or higher rank — so an h2 takes its h3s with it. Leave it out for the whole
page; **a name that matches nothing is reported by `validate()`, never quietly
widened back to the whole page.**

Two limits, both deliberate and both visible to the reader rather than silent:
a page cannot embed itself or anything that leads back to it (the loop renders
as a named placeholder), and an embed chain is followed at most **three** pages
deep. `validate()` reports both, plus a target that is not a page.

An embed produces a **backlink** on its target, exactly as a `pagelink` does —
it is the strongest reference in the model, since the page it names is being
shown somewhere else. In Markdown it is Obsidian's `![[Page]]` / `![[Page#Section]]`,
in both directions: a vault's embeds import as embeds and export as themselves.

### Callouts

`tone` is one of `note` `tip` `important` `warning` `caution` — GitHub's alert
Expand Down Expand Up @@ -277,6 +310,7 @@ notifications, automation. The file is the team boundary and the capability.
| a topic that belongs *under* another | `parent` on the page | the tree is the navigation |
| a reference to another page | an inline `#p/` link | it produces a backlink on the target automatically, at no cost |
| a list of sub-pages | one `pagelink` block each | a visible card beats a bare link for a hub page |
| one page's material that belongs on another too | an `embed`, narrowed with `anchor` | the source stays the single copy — a pasted duplicate is what goes stale |
| steps someone will tick off | `todo` | state lives in the document, so it survives sharing |
| an aside, or detail most readers skip | `toggle` with its body as `parent` children | folds away, and always PRINTS expanded |
| a warning the reader must not miss | `callout` with the `tone` that fits | it is boxed, named and legible in print and without colour vision — but three per page and none of them registers |
Expand Down Expand Up @@ -352,7 +386,9 @@ findings.filter(f => f.severity === 'error')
Each finding is `{code, severity, message, fix, page?, block?, path?}`. It
reports duplicate and missing ids, a page inside its own subtree (which is the
one way a page becomes unreachable), parents naming nothing, `#p/` links and
`pagelink` cards pointing at pages that do not exist, unknown block types, block
`pagelink` cards and `embed` blocks pointing at pages that do not exist
(`broken-embed`), embeds that loop back to their own page (`embed-cycle`) and
embed anchors that name no heading (`no-section`), unknown block types, block
markup inside inline `html` (and markup that is dropped whole), hrefs outside the
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.
Expand Down
293 changes: 292 additions & 1 deletion scripts/test-spaces-model.ts

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions spaces/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,31 @@ 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.

- **A page can show another page: transclusion.** The new `embed` block draws a
live view of another page — or of one heading's section of it, chosen by name
— attributed to its source and clickable through to it. It stores a
REFERENCE and never a copy, so the source page stays the single copy of the
words and every embed of it changes when it does.

This closes a hole in the Obsidian import that nothing reported. `markdown.ts`
had parsed `![[Page]]` since the importer was written and carried a comment
saying "an embed of a note is just a link to it, because there is no
transclusion in the model" — so a vault arrived with every embed silently
demoted to a plain link. A whole line of `![[Page]]` or `![[Page#Section]]`
is now an embed, and exports back as itself; an `![[…]]` inside a sentence is
still a link, because a block cannot live in the middle of one.

What the reader is never shown is a blank box. A loop (A embeds B embeds A)
renders as a named placeholder that says which page repeats, an embed chain
is followed at most three pages deep, a target that has been deleted says so,
and an `anchor` that matches no heading says THAT rather than quietly falling
back to the whole page. `validate()` names all four (`broken-embed`,
`embed-cycle`, `no-section`). An embed also appears in "Linked from" like a
page link does — it is the strongest reference in the model, and the one you
most want to be warned about before rewriting a page — and it survives being
extracted or grafted into another space, where a target that did not travel
becomes the same honest `[[Name]]` text a page link becomes.

## [0.1.0] — 2026-08-03

First release.
Expand Down
32 changes: 30 additions & 2 deletions spaces/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { isPageRef, anchorOf, sectionOf, embedReaches } from './embed.ts'
import {
type FieldSpec, ISSUE_FIELDS, fieldsOf, fieldByKey, optionOf, propBlock, propHtml, valuesOf, isIssue, headerLength,
} from './fields.ts'
Expand Down Expand Up @@ -305,7 +306,7 @@ export function validateDoc(doc: SpacesDoc): ValidateResult {
add({ page: p.id, code: 'no-blocks', severity: 'error', path: 'blocks',
message: `Page "${p.title}" has no blocks, so there is nothing in it to put a caret in — it cannot be typed into.`,
fix: 'Give it at least one block, e.g. { "type": "p", "html": "" }.' })
} else if (!blocks.some((b) => textOf(b.html).trim() || b.type === 'image' || b.type === 'media' || b.type === 'pagelink' || b.type === 'link' || b.type === 'divider')) {
} else if (!blocks.some((b) => textOf(b.html).trim() || b.type === 'image' || b.type === 'media' || b.type === 'pagelink' || b.type === 'embed' || b.type === 'link' || b.type === 'divider')) {
add({ page: p.id, code: 'empty-page', severity: 'info',
message: `Page "${p.title}" has blocks but no content.`,
fix: 'Write something, or remove the page. A deliberately blank page (an inbox, a stub) is fine — this is only a note.' })
Expand Down Expand Up @@ -393,6 +394,31 @@ export function validateDoc(doc: SpacesDoc): ValidateResult {
}
}

// AN EMBED IS A PAGELINK THAT SHOWS ITS TARGET, so it can go wrong in
// three ways instead of one — and every one of them is silent to a
// reader who never saw the page it was supposed to be showing.
if (b.type === 'embed') {
const target = typeof b.page === 'string' ? b.page : ''
const anchor = anchorOf(b)
if (!target || !pageIx.has(target)) {
add({ ...at, code: 'broken-embed', severity: 'error', path: 'page',
message: `An embed points at "${target || '(nothing)'}", which is not a page — it renders as a note saying so instead of the content.`,
fix: 'Set page to a real page id, or remove the block.' })
} else if (embedReaches(doc, p.id, target)) {
// NAMED, not merely counted: the renderer stops the loop safely (a
// placeholder where the repeat would be), so this is not a crash
// waiting to happen — it is content the author believes is on the
// page and that nobody will ever see.
add({ ...at, code: 'embed-cycle', severity: 'error', path: 'page',
message: `This embed of "${pageIx.get(target)?.title ?? target}" leads back to this page, so the loop is cut short and the rest of the embed is not shown.`,
fix: 'Point the embed at a page that does not embed this one, or narrow it to a section with anchor.' })
} else if (anchor && !sectionOf(pageIx.get(target)!, anchor)) {
add({ ...at, code: 'no-section', severity: 'warning', path: 'anchor',
message: `No heading named "${anchor}" on "${pageIx.get(target)?.title ?? target}" — the embed shows a note instead of that section.`,
fix: 'Match anchor to a heading on that page, or remove anchor to embed the whole page.' })
}
}

if (b.type === 'link') {
// A CARD IS NOT A LINK UNTIL ITS URL IS ONE. `linkCard` returns '' for
// an empty url and for anything outside https:/http:/mailto: — and a
Expand Down Expand Up @@ -600,7 +626,9 @@ export function outlineDoc(doc: SpacesDoc): OutlineResult {
if (b.type === 'h1' || b.type === 'h2' || b.type === 'h3') {
headings.push({ id: b.id, level: Number(b.type.slice(1)) as 1 | 2 | 3, text })
}
if (b.type === 'pagelink' && typeof b.page === 'string' && !links.includes(b.page)) links.push(b.page)
// pagelink AND embed: both name a page, and an outline that listed only
// the first would under-report exactly the dependency that hurts most.
if (isPageRef(b) && !links.includes(String(b.page))) links.push(String(b.page))
for (const m of (b.html ?? '').matchAll(/href\s*=\s*["']#p\/([^"']+)["']/g)) {
if (!links.includes(m[1])) links.push(m[1])
}
Expand Down
19 changes: 19 additions & 0 deletions spaces/src/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import type { Block } from './model'
import { effectiveParents, tableOf, writeTable, linkCard } from './model.ts'
import { anchorOf, embedToMd } from './embed.ts'
import type { IconName } from './icons'

export interface BlockSpec {
Expand Down Expand Up @@ -243,6 +244,24 @@ export const SPECS: BlockSpec[] = [
tag: 'div', custom: true,
toMd: (b, _text, _indent, ctx) => [`→ [[${ctx.titleOf(String(b.page)) ?? '?'}]]`],
},
{
// TRANSCLUSION — the pagelink's other half. A pagelink says where
// something is; an embed shows it, live, from the source.
//
// `text: false` for the reason a pagelink has none: the block's content is
// somewhere else, and an editable line beside it would be a second place
// to type that nothing displays. Its `html` is written by the editor as a
// plain link to the target, which is what a build that predates this type
// renders (render.ts default case) — the additivity fallback, not a copy.
type: 'embed', label: 'Embed a page', hint: 'A live view of another page', icon: 'page',
tag: 'div', custom: true,
// BACK TO THE SYNTAX IT CAME FROM. markdown.ts parses Obsidian's
// `![[Page]]` and, before this type existed, silently turned every one
// into a plain link — so a vault lost every embed on the way in and the
// export had nothing to write back. The two halves are one round trip and
// scripts/test-spaces-model.ts asserts it in both directions.
toMd: (b, _text, _indent, ctx) => [embedToMd(ctx.titleOf(String(b.page)), anchorOf(b))],
},
{
// A LINK TO SOMEWHERE ON THE WEB — the outward-facing sibling of pagelink.
//
Expand Down
124 changes: 122 additions & 2 deletions spaces/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from './fields'
import { planImport, type SourceFile } from './markdown'
import { extractSpace, planGraft } from './portable'
import { headingsOf } from './embed.ts'
import { countOutsideTags, replaceOutsideTags } from './findreplace'
import { asksForAnswer, evaluate, format, pageContext } from './calc'
import { t, locale } from './i18n'
Expand Down Expand Up @@ -281,11 +282,16 @@ export class Editor {
close()
const page = this.store.page
if (!page) return
const fresh = newBlock(item.type === 'pagelink' ? 'p' : item.type)
// pagelink and embed both start life as a paragraph and become
// themselves only when a page has been chosen — dismissing the
// picker must leave a block you can type in, never a card pointing
// at nothing.
const fresh = newBlock(item.type === 'pagelink' || item.type === 'embed' ? 'p' : item.type)
SPEC.get(fresh.type)?.init?.(fresh)
this.store.commit(() => { page.blocks.push(fresh) })
this.paintPage()
if (item.type === 'pagelink') this.insertPageCard(fresh.id)
else if (item.type === 'embed') this.insertEmbed(fresh.id)
// the block is already a `link` — dismissing the dialog leaves an
// empty card with its own way back in, never a half-made block
else if (item.type === 'link') this.openLinkCard(fresh.id)
Expand Down Expand Up @@ -2259,8 +2265,35 @@ export class Editor {
return true
}

/** Attach behaviour to a freshly painted page. */
/**
* Attach behaviour to a freshly painted page.
*
* EMBEDDED CONTENT IS PARKED FOR THE DURATION, and that is not tidiness.
* Everything below sweeps the painted page by class or attribute — every
* `.sp-check`, every `.sp-b-code`, every table cell — and hangs a handler
* that commits through `store.block(id)`, which resolves ANY id in the
* document. An embed draws ANOTHER page's blocks inside this one, so without
* this a tick in an embedded checklist would commit to a page the editor is
* not showing, and a language chip would be appended into somebody else's
* paragraph. The renderer already strips `data-block-id` from that subtree;
* this closes the half that keys on classes instead.
*
* Detached and restored rather than filtered at each of the fifteen sweeps:
* one guarantee in one place cannot be forgotten by the sixteenth.
*/
private wire(view: HTMLElement): void {
const parked: Array<[HTMLElement, Comment]> = []
for (const body of view.querySelectorAll<HTMLElement>('.sp-embed-body')) {
const mark = document.createComment('embed')
body.replaceWith(mark)
parked.push([body, mark])
}
try { this.wireOwn(view) } finally {
for (const [body, mark] of parked) mark.replaceWith(body)
}
}

private wireOwn(view: HTMLElement): void {
const s = this.store

const title = view.querySelector<HTMLElement>('[data-page-title]')
Expand Down Expand Up @@ -3657,6 +3690,7 @@ export class Editor {
// the "/" that opened the menu is a command, not content
if (blk && (blk.html ?? '').trim() === '/') blk.html = ''
if (item.type === 'pagelink') this.insertPageCard(blockId)
else if (item.type === 'embed') this.insertEmbed(blockId)
else if (item.type === 'link') { this.setType(blockId, 'link'); this.openLinkCard(blockId) }
else this.setType(blockId, item.type)
}
Expand Down Expand Up @@ -3716,6 +3750,92 @@ export class Editor {
})
}

/**
* THE ONE WRITER for an embed's target — the same rule as a link card's
* fields (applyLinkCard below), for the same reason.
*
* `html` is written alongside `page`, always, and it is a LINK to the target
* rather than a copy of anything: a build that has never heard of `embed`
* renders an unknown type's html (render.ts default case), so an older shell
* opening this file shows a link to the source page instead of a blank box.
* An embed written without it is a block that vanishes in last year's shell.
*
* A section returned to "the whole page" DELETES `anchor` rather than
* storing an empty one — a default is never bytes in the file (PLATFORM §3).
*/
private applyEmbed(blockId: string, pageId: string, anchor?: string): void {
const s = this.store
s.commit(() => {
const b = s.block(blockId)
const target = s.index.page.get(pageId)
if (!b) return
b.type = 'embed'
b.page = pageId
if (anchor) b.anchor = anchor
else delete b.anchor
b.html = `<a href="#p/${pageId}">${escapeHtml(target?.title || t('Untitled'))}</a>`
})
this.paintPage()
}

/**
* Choose what an embed shows: a page, or one section of it.
*
* ITS OWN PICKER rather than openPagePicker with a flag, because the list is
* a different list — every page AND every heading on it, so "show me the
* Rollout section of the plan" is one gesture instead of choose-then-hunt.
* The heading names come from embed.ts `headingsOf`, which is the same list
* `sectionOf` matches against, so a section you can pick here is a section
* that resolves.
*/
private insertEmbed(blockId: string): void {
const s = this.store
if (s.readOnly || this.reading) return
this.openOverlay(t('Embed a page'), (card, close) => {
const input = document.createElement('input')
input.className = 'sp-find'
input.placeholder = t('Find a page or a section…')
const list = el('ul', 'sp-results')
const row = (label: string, sub: string, then: () => void) => {
const li = document.createElement('li')
const b = document.createElement('button')
b.className = 'sp-result'
b.type = 'button'
b.innerHTML =
`<span class="sp-result-ico">${ICONS.page}</span>` +
`<span class="sp-result-txt"><strong>${escapeHtml(label)}</strong>` +
(sub ? `<span>${escapeHtml(sub)}</span>` : '') + '</span>'
b.addEventListener('click', () => { close(); then() })
li.append(b)
list.append(li)
}
const run = () => {
const q = input.value.trim().toLowerCase()
list.innerHTML = ''
for (const p of s.doc.pages) {
// A PAGE CANNOT EMBED ITSELF, so it is not offered. The renderer
// stops that loop safely either way; offering it would be offering a
// placeholder.
if (p.id === s.pageId) continue
const title = p.title || t('Untitled')
const heads = headingsOf(p).filter((h) => !q || h.text.toLowerCase().includes(q))
const hit = !q || title.toLowerCase().includes(q)
if (hit) row(title, t('The whole page'), () => this.applyEmbed(blockId, p.id))
for (const h of (hit ? headingsOf(p) : heads)) {
row(`${title} › ${h.text}`, t('That section only'),
() => this.applyEmbed(blockId, p.id, h.text))
}
if (list.childElementCount > 40) break
}
if (!list.childElementCount) list.append(el('li', 'sp-noresult', t('No page matches')))
}
input.addEventListener('input', run)
card.append(input, list)
run()
setTimeout(() => input.focus(), 0)
})
}

/**
* THE ONE WRITER for a link card's fields.
*
Expand Down
Loading
Loading