import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' export function newId(len = 10) { const bytes = crypto.randomBytes(len) let out = '' for (let i = 0; i < len; i++) out += ID_ALPHABET[bytes[i] % ID_ALPHABET.length] return out } export function atomicWriteFile(filePath, data) { fs.mkdirSync(path.dirname(filePath), { recursive: true }) const tmp = path.join(path.dirname(filePath), `.tmp-${newId(6)}`) fs.writeFileSync(tmp, data) fs.renameSync(tmp, filePath) } export function readJSON(filePath, fallback) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch { return fallback } } export function writeJSON(filePath, value) { atomicWriteFile(filePath, JSON.stringify(value, null, 2)) } export function b64encode(uint8) { return Buffer.from(uint8).toString('base64url') } export function b64decode(str) { return new Uint8Array(Buffer.from(str, 'base64url')) } // --- signed session cookies --- export function signSession(payload, secret) { const body = Buffer.from(JSON.stringify(payload)).toString('base64url') const mac = crypto.createHmac('sha256', secret).update(body).digest('base64url') return `${body}.${mac}` } export function verifySession(value, secret) { if (!value || !value.includes('.')) return null const [body, mac] = value.split('.') const expected = crypto.createHmac('sha256', secret).update(body).digest('base64url') const a = Buffer.from(mac) const b = Buffer.from(expected) if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null try { return JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) } catch { return null } } export function parseCookies(header) { const out = {} if (!header) return out for (const part of header.split(';')) { const idx = part.indexOf('=') if (idx === -1) continue out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim()) } return out } export const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{1,38}$/ export const DOC_ID_RE = /^[A-Za-z0-9_-]{1,64}$/ // --- multi-page projects --- // A project (former "doc") holds pages. Each page is its own Yjs document. // Collab document names: the bare project id for the "home" page (legacy // compatible), or `${projectId}::${slug}` for other pages. `_structure` is a // reserved page whose YAML code block defines the sidebar tree. export const PAGE_SLUG_RE = /^(_structure|[a-z0-9][a-z0-9-]{0,39})$/ export const DOC_NAME_RE = /^[A-Za-z0-9_-]{1,64}(::(_structure|[a-z0-9][a-z0-9-]{0,39}))?$/ export function projectIdOf(docName) { return String(docName).split('::')[0] } export function pageSlugOf(docName) { const parts = String(docName).split('::') return parts[1] || 'home' } export function docNameFor(projectId, slug) { return !slug || slug === 'home' ? projectId : `${projectId}::${slug}` } export function slugify(title) { const s = String(title || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 40) return s || 'page' }