Spaces:
Sleeping
Sleeping
| import * as Y from 'yjs' | |
| import { yDocToProsemirrorJSON } from 'y-prosemirror' | |
| import * as store from './store.js' | |
| import { pageSlugOf, projectIdOf, PAGE_SLUG_RE } from './util.js' | |
| const FIELD = 'default' | |
| // --- structure YAML --- | |
| // The `_structure` page holds one YAML code block: an indented dash-list. | |
| // A bare slug is a page; a quoted label is a group header (a purely visual | |
| // separator in the sidebar — no page behind it). Nesting (2 spaces per level) | |
| // makes an entry a child of the one above it. Pages missing from the YAML are | |
| // listed as "unfiled" in the sidebar, so a broken edit never hides content. | |
| // | |
| // - home | |
| // - "Getting started": | |
| // - intro | |
| // - design | |
| // - communication | |
| // - references | |
| const PAGE_LINE_RE = /^(\s*)-\s*([A-Za-z0-9_-]+)\s*:?\s*$/ | |
| const GROUP_LINE_RE = /^(\s*)-\s*"([^"]+)"\s*:?\s*$/ | |
| export function parseStructure(text) { | |
| const root = { children: [] } | |
| const stack = [{ depth: -1, node: root }] | |
| const problems = [] | |
| for (const rawLine of String(text || '').split('\n')) { | |
| if (!rawLine.trim() || rawLine.trim().startsWith('#')) continue | |
| const g = rawLine.match(GROUP_LINE_RE) | |
| const m = g || rawLine.match(PAGE_LINE_RE) | |
| if (!m) { | |
| problems.push(rawLine.trim().slice(0, 60)) | |
| continue | |
| } | |
| const depth = Math.floor(m[1].length / 2) | |
| const node = g ? { group: g[2], children: [] } : { slug: m[2], children: [] } | |
| while (stack.length > 1 && stack[stack.length - 1].depth >= depth) stack.pop() | |
| stack[stack.length - 1].node.children.push(node) | |
| stack.push({ depth, node }) | |
| } | |
| return { tree: root.children, problems } | |
| } | |
| // --- structure mutations (drag-and-drop, group CRUD) --- | |
| // Pure text→text edits so the sidebar UI never rewrites the YAML wholesale: | |
| // each operation moves/inserts/removes the minimal set of lines, leaving | |
| // comments and hand-formatting elsewhere untouched. Nodes are addressed by | |
| // slug (pages — unique per project) or by path array for groups, where page | |
| // elements are bare slugs and group elements keep their quotes: ['design', '"Ideas"']. | |
| // Line-level parse: every page/group line with its depth, parent, and the | |
| // [line, blockEnd) range of the subtree that travels with it when moved. | |
| function parseItems(raw) { | |
| const lines = String(raw || '').split('\n') | |
| const items = [] | |
| const stack = [] // indexes into items | |
| for (let i = 0; i < lines.length; i++) { | |
| if (!lines[i].trim() || lines[i].trim().startsWith('#')) continue | |
| const g = lines[i].match(GROUP_LINE_RE) | |
| const m = g || lines[i].match(PAGE_LINE_RE) | |
| if (!m) continue | |
| const depth = Math.floor(m[1].length / 2) | |
| while (stack.length && items[stack[stack.length - 1]].depth >= depth) stack.pop() | |
| const parentIdx = stack.length ? stack[stack.length - 1] : -1 | |
| const el = g ? `"${g[2]}"` : m[2] | |
| const path = parentIdx === -1 ? [el] : [...items[parentIdx].path, el] | |
| items.push({ i, depth, kind: g ? 'group' : 'page', slug: g ? null : m[2], label: g ? g[2] : null, parentIdx, path, blockEnd: lines.length }) | |
| stack.push(items.length - 1) | |
| } | |
| for (let a = 0; a < items.length; a++) { | |
| for (let b = a + 1; b < items.length; b++) { | |
| if (items[b].depth <= items[a].depth) { | |
| items[a].blockEnd = items[b].i | |
| break | |
| } | |
| } | |
| } | |
| return { lines, items } | |
| } | |
| const samePath = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]) | |
| // ref: slug string (page) or path array (group or page-by-path) | |
| function findByRef(items, ref) { | |
| if (typeof ref === 'string') return items.find(it => it.kind === 'page' && it.slug === ref) || null | |
| if (Array.isArray(ref) && ref.length && ref.every(e => typeof e === 'string')) return items.find(it => samePath(it.path, ref)) || null | |
| return null | |
| } | |
| const childrenOf = (items, parentIdx) => items.filter(it => it.parentIdx === parentIdx) | |
| // Where to splice for "child `index` of `parent`" (parent = item or null for | |
| // root), on an already-parsed doc. Returns { line, depth }. | |
| function insertionPoint(lines, items, parent, index) { | |
| const kids = childrenOf(items, parent ? items.indexOf(parent) : -1) | |
| const n = Number.isInteger(index) ? index : Infinity | |
| if (n >= 0 && n < kids.length) return { line: kids[n].i, depth: parent ? parent.depth + 1 : 0 } | |
| if (!parent) return { line: lines.length, depth: 0 } | |
| return { line: parent.blockEnd, depth: parent.depth + 1 } | |
| } | |
| function shiftIndent(line, deltaLevels) { | |
| if (!line.trim()) return line | |
| const lead = line.match(/^ */)[0].length | |
| return ' '.repeat(Math.max(0, lead + deltaLevels * 2)) + line.trimStart() | |
| } | |
| export function validGroupLabel(label) { | |
| const clean = String(label ?? '').trim() | |
| if (!clean || clean.length > 60 || clean.includes('"') || clean.includes('\n')) return null | |
| return clean | |
| } | |
| // Move pages/groups (each with its whole subtree) under `parentRef` (null = | |
| // root) at `index` among the new parent's children, counted after every moved | |
| // node leaves its old spot. Multiple refs (sidebar multi-select) land as | |
| // consecutive siblings, in document order; a ref nested inside another moved | |
| // ref's subtree travels with it and is dropped from the list. A page slug | |
| // absent from the YAML (unfiled) gets a new line. Returns { raw } or { error }. | |
| // nodeRefs is always a LIST of refs (a bare ref is ambiguous: a path array | |
| // reads as a list of slugs) — single-node callers wrap in an array. | |
| export function moveNodesInYaml(raw, nodeRefs, parentRef, index) { | |
| const refs = Array.isArray(nodeRefs) ? nodeRefs : [] | |
| if (!refs.length) return { error: 'no nodes' } | |
| const { lines, items } = parseItems(raw) | |
| const resolved = [] | |
| const seen = new Set() | |
| for (const ref of refs) { | |
| if (typeof ref === 'string' && (ref === '_structure' || !PAGE_SLUG_RE.test(ref))) return { error: 'bad node' } | |
| const it = findByRef(items, ref) | |
| if (!it && typeof ref !== 'string') return { error: 'unknown node' } | |
| const id = it ? `l${it.i}` : `s${ref}` | |
| if (seen.has(id)) continue | |
| seen.add(id) | |
| resolved.push({ ref, it }) | |
| } | |
| const inMovedBlock = pos => resolved.some(r => r.it && pos > r.it.i && pos < r.it.blockEnd) | |
| // roots only: a selected node inside another selected subtree already travels | |
| const roots = resolved.filter(r => !r.it || !inMovedBlock(r.it.i)) | |
| if (parentRef != null) { | |
| const parent = findByRef(items, parentRef) | |
| if (!parent) return { error: 'unknown parent' } | |
| if (roots.some(r => r.it && parent.i >= r.it.i && parent.i < r.it.blockEnd)) return { error: 'cannot nest a node inside itself' } | |
| } | |
| // blocks in document order; unfiled (lineless) refs keep their given order at the end | |
| const inTree = roots.filter(r => r.it).sort((a, b) => a.it.i - b.it.i) | |
| const blocks = [ | |
| ...inTree.map(r => ({ block: lines.slice(r.it.i, r.it.blockEnd), depth: r.it.depth })), | |
| ...roots.filter(r => !r.it).map(r => ({ block: [`- ${r.ref}`], depth: 0 })), | |
| ] | |
| const rest = lines.slice() | |
| for (const r of [...inTree].reverse()) rest.splice(r.it.i, r.it.blockEnd - r.it.i) | |
| const after = parseItems(rest.join('\n')) | |
| const parent = parentRef == null ? null : findByRef(after.items, parentRef) | |
| if (parentRef != null && !parent) return { error: 'unknown parent' } | |
| const { line, depth } = insertionPoint(after.lines, after.items, parent, index) | |
| const moved = blocks.flatMap(b => b.block.map(l => shiftIndent(l, depth - b.depth))) | |
| const out = [...after.lines.slice(0, line), ...moved, ...after.lines.slice(line)] | |
| return { raw: out.join('\n') } | |
| } | |
| export function moveNodeInYaml(raw, nodeRef, parentRef, index) { | |
| return moveNodesInYaml(raw, [nodeRef], parentRef, index) | |
| } | |
| export function addGroupToYaml(raw, label, parentRef, index) { | |
| const clean = validGroupLabel(label) | |
| if (!clean) return { error: 'bad group label' } | |
| const { lines, items } = parseItems(raw) | |
| const parent = parentRef == null ? null : findByRef(items, parentRef) | |
| if (parentRef != null && !parent) return { error: 'unknown parent' } | |
| const siblings = childrenOf(items, parent ? items.indexOf(parent) : -1) | |
| if (siblings.some(s => s.kind === 'group' && s.label === clean)) return { error: 'a group with this label already exists here' } | |
| const { line, depth } = insertionPoint(lines, items, parent, index) | |
| const out = [...lines.slice(0, line), `${' '.repeat(depth)}- "${clean}":`, ...lines.slice(line)] | |
| return { raw: out.join('\n') } | |
| } | |
| export function renameGroupInYaml(raw, path, label) { | |
| const clean = validGroupLabel(label) | |
| if (!clean) return { error: 'bad group label' } | |
| const { lines, items } = parseItems(raw) | |
| const node = findByRef(items, path) | |
| if (!node || node.kind !== 'group') return { error: 'unknown group' } | |
| const siblings = childrenOf(items, node.parentIdx) | |
| if (siblings.some(s => s !== node && s.kind === 'group' && s.label === clean)) return { error: 'a group with this label already exists here' } | |
| lines[node.i] = `${' '.repeat(node.depth)}- "${clean}":` | |
| return { raw: lines.join('\n') } | |
| } | |
| // Remove a group header; its children are promoted one level, not deleted. | |
| export function dissolveGroupInYaml(raw, path) { | |
| const { lines, items } = parseItems(raw) | |
| const node = findByRef(items, path) | |
| if (!node || node.kind !== 'group') return { error: 'unknown group' } | |
| const body = lines.slice(node.i + 1, node.blockEnd).map(l => shiftIndent(l, -1)) | |
| const out = [...lines.slice(0, node.i), ...body, ...lines.slice(node.blockEnd)] | |
| return { raw: out.join('\n') } | |
| } | |
| export function defaultStructureYaml(slugs) { | |
| const rest = slugs.filter(s => s !== 'home' && s !== '_structure') | |
| return ['- home', ...rest.map(s => `- ${s}`)].join('\n') | |
| } | |
| function slugsInTree(tree, out = new Set()) { | |
| for (const n of tree) { | |
| if (n.slug) out.add(n.slug) | |
| slugsInTree(n.children, out) | |
| } | |
| return out | |
| } | |
| const pageField = docName => { | |
| const slug = pageSlugOf(docName) | |
| return slug === 'home' ? FIELD : `page:${slug}` | |
| } | |
| // Every page is a named fragment of the project's Yjs doc — live if the | |
| // project is open, otherwise reconstructed from its one persisted update. | |
| function pageYDoc(hocuspocus, docName) { | |
| const projectId = projectIdOf(docName) | |
| const live = hocuspocus.documents.get(projectId) | |
| if (live) return live | |
| const state = store.loadDocState(projectId) | |
| if (!state) return null | |
| const doc = new Y.Doc() | |
| Y.applyUpdate(doc, state) | |
| return doc | |
| } | |
| function readPageState(hocuspocus, docName) { | |
| const doc = pageYDoc(hocuspocus, docName) | |
| return doc ? yDocToProsemirrorJSON(doc, pageField(docName)) : null | |
| } | |
| function codeBlockText(pm) { | |
| for (const node of pm?.content || []) { | |
| if (node.type === 'codeBlock') return (node.content || []).map(c => c.text || '').join('') | |
| } | |
| return null | |
| } | |
| // short TTL cache: the sidebar polls this and every miss is a sync read from | |
| // bucket-mounted storage | |
| const structureCache = new Map() // projectId -> { ts, value } | |
| const STRUCTURE_TTL = 5000 | |
| export function clearStructureCache(projectId) { | |
| structureCache.delete(projectId) | |
| } | |
| // After a server-side structure mutation, the doc store to disk is debounced — | |
| // prime the cache with the known new yaml so reads are immediately consistent. | |
| export function primeStructureCache(projectId, raw) { | |
| const pages = store.pagesOf(projectId) | |
| if (!pages) return | |
| const { tree, problems } = parseStructure(raw) | |
| const inTree = slugsInTree(tree) | |
| const unfiled = Object.keys(pages).filter(s => s !== '_structure' && !inTree.has(s)) | |
| const titles = {} | |
| for (const [slug, meta] of Object.entries(pages)) titles[slug] = meta.title || slug | |
| structureCache.set(projectId, { ts: Date.now(), value: { tree, unfiled, titles, raw, problems } }) | |
| } | |
| export function getProjectStructure(hocuspocus, projectId) { | |
| const hit = structureCache.get(projectId) | |
| if (hit && Date.now() - hit.ts < STRUCTURE_TTL && !hocuspocus.documents.has(projectId)) return hit.value | |
| const pages = store.pagesOf(projectId) | |
| if (!pages) return null | |
| const slugs = Object.keys(pages) | |
| let raw = null | |
| const pending = [] | |
| try { | |
| const structureName = `${projectId}::_structure` | |
| const sdoc = pageYDoc(hocuspocus, structureName) | |
| if (sdoc) { | |
| raw = codeBlockText(yDocToProsemirrorJSON(sdoc, pageField(structureName))) | |
| sdoc.getMap('pageProposals').forEach(p => { | |
| if (p?.status === 'open') { | |
| pending.push({ | |
| id: p.id, | |
| slug: p.slug, | |
| title: p.title, | |
| author: p.author, | |
| author_type: p.authorType, | |
| rationale: p.rationale, | |
| // proposed placement, so the tree can show it where it would land | |
| parent: p.parent ?? null, | |
| index: Number.isInteger(p.index) ? p.index : null, | |
| }) | |
| } | |
| }) | |
| } | |
| } catch {} | |
| if (raw == null) raw = defaultStructureYaml(slugs) | |
| const { tree, problems } = parseStructure(raw) | |
| const inTree = slugsInTree(tree) | |
| const unfiled = slugs.filter(s => s !== '_structure' && !inTree.has(s)) | |
| const titles = {} | |
| for (const [slug, meta] of Object.entries(pages)) titles[slug] = meta.title || slug | |
| const value = { tree, unfiled, titles, raw, problems, pending } | |
| structureCache.set(projectId, { ts: Date.now(), value }) | |
| return value | |
| } | |
| export function isValidSlug(slug) { | |
| return PAGE_SLUG_RE.test(String(slug || '')) | |
| } | |