Skip to content

spaces: a page can show another page, live (transclusion) - #428

Open
nyblnet wants to merge 1 commit into
mainfrom
spaces-transclusion
Open

spaces: a page can show another page, live (transclusion)#428
nyblnet wants to merge 1 commit into
mainfrom
spaces-transclusion

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Transclusion: a page can show another page, live

A new embed block renders another page — or one heading's section of it —
inline, attributed to its source and clickable through to it. It stores a
reference and never a copy: there is no cached content on the block,
nothing to invalidate, and editing the source changes every embed of it.

{ "id": "b9", "type": "embed",
  "page": "p-design",     // the target page id — the SAME field pagelink uses
  "anchor": "Rollout",    // OPTIONAL, matched by NAME, not by position
  "html": "<a href=\"#p/p-design\">Design notes</a>" }   // the old-build fallback

The gap this closes

markdown.ts:121 carried the comment "an embed of a note is just a link to it,
because there is no transclusion in the model"
. The importer had parsed
Obsidian's ![[Page]] since it was written and silently downgraded every one to
a plain <a> — so importing a vault lost every embed with no warning. 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.

The hard parts

  • Cycles. viewEmbed takes the chain of pages already open above the block.
    A target on that chain renders a named placeholder saying which page
    repeats — never a blank, never recursion. There is also a depth cap of 3
    distinct pages, which is the shape a cycle check cannot see (a hundred pages
    each embedding the next is not a cycle).
  • Dangling refs. A target that is not a page renders a note saying so;
    validate() reports broken-embed (error), embed-cycle (error) and
    no-section (warning). Nothing throws.
  • Untrusted input. page and anchor come out of a mailed file. Target
    lookup is a linear scan of doc.pages, never an object index; the one plain
    object keyed on document data (HEADINGS, keyed on b.type) goes through
    Object.hasOwn in a single shared rankOf. Asserted with toString,
    __proto__, constructor, valueOf, hasOwnProperty.
  • Markdown round trip. Import → embed → export → import, asserted end to end
    with the target and the section intact.
  • Export / extract / graft. portable.ts follows the same path as
    pagelink through a shared isPageRef predicate: a grafted embed is remapped
    onto the renumbered page id (asserted by resolving it in the document it
    landed in), and a target that did not travel becomes the same honest
    [[Name]] text a page link becomes, anchor deleted with it.
  • Backlinks. Yes — an embed appears in "Linked from". It is the strongest
    reference in the model: the page is not merely mentioned, it is being shown
    somewhere else, so it is the reference you most want before rewriting a page.

Two correctness details worth calling out

Embedded content is rendered editable: false, stripped of data-block-id, and
the embed bodies are parked (detached) while the editor wires the page. The
editor's paint sweeps every [data-block-id], .sp-check, .sp-b-code and
table cell and hangs handlers that commit through store.block(id), which
resolves any id in the document — so without this a checkbox ticked inside an
embed would have committed to a page the editor was not showing. Measured in the
browser: ticking an embedded to-do leaves the source page's done unchanged,
while the host page's own 7 blocks keep all 7 gutters.

Format additivity. anchor absent = the whole page, and returning a section
to "the whole page" deletes the key. html is written alongside page so a
build that has never heard of embed renders a link to the source page.
Measured on the shipped renderer with a block carrying the exact shape under an
unknown type: visible clickable link, and page/anchor/an unknown key all
survive the round trip.

Where the code went

Nearly all of it is a new pure module, spaces/src/embed.ts (no DOM), so it
is asserted in node rather than clicked at. The touched files each get a small,
localized change: render.ts +1 case +1 helper, editor.ts a picker and a
wire() wrapper, model.ts one field and one condition, portable.ts two
conditions, agent.ts one validate branch, markdown.ts one parse branch.

Verification

node scripts/test-spaces.mjs        1010/1010 model · 175/175 agent · 45×5 journal
                                    90×4 calc · 25/25 undo · 34/34 invite · 6/6 roundtrip
                                    all spaces rigs passed
node scripts/test-sync-spaces.ts            3/3
node scripts/test-sync-spaces-session.ts    48/48
node scripts/build-spaces-i18n.mjs --check  608 × 8, all complete
cd spaces && tsc --noEmit -p tsconfig.json  clean
npm run build:single                        602KB → 275KB
node ../scripts/shell-gate.mjs …            splice contract OK

The model rig went 947 → 1010 checks (+63 for this feature).

Size: +4,252 B on the built shell, measured directly (origin/main builds at
277,769 B, this branch at 282,021 B). The size rig's "+23,567 B since reference"
is against a watermark that predates several merged branches; main alone is
+19,315 B over it.

i18n: 11 new strings in all 8 catalogs, then packed.ts grepped for each
one — all 11 present, each with 8 non-zero columns. (100% coverage is reported
from swept keys and is not evidence; the block label/hint are swept from
blocks.ts by the packer's second pass, which is why they land.)

Sabotage

Every assertion added here was watched to fail. 17 mutations, each applied
alone, rig run, restored, rig green again:

sabotage failures
cycle guard removed 2
depth cap removed 1
depth cap off by one (>>=) 1
Object.hasOwnin on the heading table 5
section ends at < instead of <= rank 1
isPageRef forgets embed 5
linkEmbeds keeps a stale anchor 1
importer stops making embeds 5
planImport skips linkEmbeds 4
export drops the ! prefix 4
export drops the section 2
extract ignores embeds 1
graft ignores embeds 2
backlinks ignore embeds 1
validate loses broken-embed / embed-cycle / no-section 1 each

Two of these failed to fail on the first run, and both were fixed rather than
explained away:

  • Object.hasOwnin inside sectionOf changed nothing observable: the
    native function it hands back fails > 0 anyway, so the guard was real and
    its proof was not. Both readers now share one rankOf, and the sabotage is
    visible through headingsOf.
  • The backlink assertion was passing on the embed's own fallback html link,
    which the inline sweep already finds — so it never touched isPageRef at all.
    It now uses an agent-written embed with no html, which is exactly the
    block that would silently have had no backlink.

Browser verification

Built, copied to a uniquely-named file, served over http://127.0.0.1:8931
(marker: .sp-embed computes border-inline-start-width: 3px, and nested page
content renders at all — impossible on the old build). Measured with
getBoundingClientRect/elementFromPoint, not by reading back a property:

  • whole-page embed 159px tall with the source's real content; section-only embed
    106px with just that section; header reads Beta › Risks and is an <a> to
    #p/B
  • missing target, missing section and a cycle each render a named note at 51px —
    no blank boxes, no hang
  • 0 [data-block-id], 0 contenteditable, 0 gutters inside embed bodies; the
    host page keeps all 7 of its own
  • clicking an embedded checkbox leaves the source page's model untouched
  • Insert → "Embed a page" lists pages and their sections, excludes the current
    page, and writes {type, page, anchor, html}

Cross-zone touch, flagged not hidden

scripts/test-spaces-model.ts is an ops-owned shared-surface file. The
coverage for this feature lands there because that is where the spaces model rig
lives; no other ops file is touched. docs/spaces-agents.md gains an Embeds
section (it is a contract with people outside this repo) and the changelog entry
is in spaces/CHANGELOG.md, not the root one.

Not verified

  • Collaboration: an embed's page/anchor are ordinary flat LWW registers, so
    they behave like any other block field, but no two-replica convergence run was
    done for this block type specifically.
  • Print and the file-manager still preview go through the same renderPage, so
    they draw embeds; neither was rendered to paper or through qlmanage here.
  • The heading-name match is case- and whitespace-insensitive but not
    accent-folded — Café and Cafe are different sections. That is deliberate
    (a fold is a locale decision), but it is untested behaviour worth naming.

Adds an `embed` block: transclusion of another page, or of one heading's
section of it, attributed to its source and clickable through to it. The
block stores a REFERENCE and never a copy, so the source page stays the
single copy of the words.

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]]` / `![[Page#Section]]` is an embed now and exports back as
itself; an `![[...]]` inside a sentence stays a link, because a block
cannot live in the middle of one.

The reader is never shown a blank box: a loop renders as a named
placeholder saying which page repeats, a chain is followed at most three
pages deep, a deleted target says so, and an `anchor` matching no
heading says that rather than quietly widening to the whole page.
validate() names all four. An embed backlinks like a pagelink, and
survives extract and graft - a target that did not travel becomes the
same honest [[Name]] text.

Nearly all the logic is in a new pure module, spaces/src/embed.ts, so it
is asserted in node rather than clicked at; render.ts, editor.ts,
model.ts, portable.ts, agent.ts and markdown.ts each gain a small,
localized change. Embedded content is never editable, carries no
data-block-id, and is parked while the editor wires the page, so a
checkbox ticked inside an embed cannot commit to a page the editor is
not showing.

scripts/test-spaces-model.ts is a shared-surface file; the coverage for
this feature lands there, flagged as the cross-zone touch it is.
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Build size

main (0100083) → spaces-transclusion (fbdab91)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 275.4 KiB +4.2 KiB (+1.53%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

Updated: 2026-09-09T01:16:41Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant