Add connected pages: hierarchy, backlinks, and live embeds
release gate / check (push) Failing after 7m7s

Implement Notion-like upgrade phase 3: owner-scoped nested pages with cycle rejection and child promotion on delete; Markdown-derived internal links and backlinks; live collection embeds resolved in the content-save transaction. Add migration 008, API/docs, integration coverage, and page/sidebar UI.
This commit is contained in:
Andrew S Erwin
2026-10-19 22:37:21 -05:00
parent b9721ff13f
commit 9db69efe4b
12 changed files with 938 additions and 28 deletions
+64 -1
View File
@@ -9,7 +9,70 @@ export const ACTIVE_PAGE_CHANGED = 'computer:active-page-changed'
export const notifyActivePage = (title: string | null) =>
window.dispatchEvent(new CustomEvent<string | null>(ACTIVE_PAGE_CHANGED, { detail: title }))
export type PageSummary = { id: string; title: string; pinned: boolean; position: number; _count?: { bookmarks: number } }
export type PageSummary = {
id: string
title: string
pinned: boolean
position: number
parentId: string | null
_count?: { bookmarks: number; children?: number }
}
// Connected pages (phase 3): the same link forms the editor understands,
// shared by the page client, sidebar and Markdown preview. Page links use
// `computer:page/<id>`; /pages?id=<id> is accepted for hand-written links.
export function internalPageId(href: string): string | null {
const value = href.trim().replace(/^<|>$/g, '')
let raw = ''
if (value.startsWith('computer://page/')) raw = value.slice('computer://page/'.length)
else if (value.startsWith('computer:page/')) raw = value.slice('computer:page/'.length)
else if (value.startsWith('page/')) raw = value.slice('page/'.length)
else if (value.startsWith('/pages/')) raw = value.slice('/pages/'.length)
else if (value.startsWith('/pages?') || value.startsWith('computer:page?id=') || value.startsWith('computer://page?id=')) {
try {
const id = new URL(value, 'http://localhost').searchParams.get('id')
raw = id ?? ''
} catch { raw = '' }
}
raw = raw.replace(/\/$/, '')
return /^[A-Za-z0-9_-]{1,128}$/.test(raw) ? raw : null
}
export const pageLinkMarkdown = (page: Pick<PageSummary, 'id' | 'title'>) =>
`[${page.title.replace(/[\[\]\n]/g, ' ')}](computer:page/${page.id})`
export type PageEmbedMarker = { collectionId: string; viewId: string | null }
export function parsePageEmbed(value: string): PageEmbedMarker | null {
const m = /^\{\{\s*collection:([A-Za-z0-9_-]{1,128})(?:\s*\|\s*view=([A-Za-z0-9_-]{1,128}))?\s*\}\}$/.exec(value.trim())
if (!m) return null
return { collectionId: m[1], viewId: m[2] ?? null }
}
export const collectionEmbedMarkdown = (collectionId: string, viewId?: string | null) =>
`{{collection:${collectionId}${viewId ? `|view=${viewId}` : ''}}}`
// Ids below `id` in the current page tree. Used to keep the parent picker
// from offering a descendant (the server rejects the cycle regardless).
export function descendantPageIds(pages: PageSummary[], id: string): Set<string> {
const out = new Set<string>()
const children = new Map<string, string[]>()
for (const p of pages) {
if (!p.parentId) continue
const list = children.get(p.parentId) ?? []
list.push(p.id)
children.set(p.parentId, list)
}
const walk = (parent: string) => {
for (const child of children.get(parent) ?? []) {
if (out.has(child)) continue
out.add(child)
walk(child)
}
}
walk(id)
return out
}
// Pages start as "Untitled 1", "Untitled 2", ... until the user renames them.
// Pick the lowest positive integer not already used by an untitled page.