cowrite-dev / server /pages.js
lvwerra's picture
lvwerra HF Staff
Proposed pages live in the tree, where they would land
aeb6b2b
Raw
History Blame Contribute Delete
13.3 kB
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 || ''))
}