| 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')) |
| } |
|
|
| |
|
|
| 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}$/ |
|
|
| |
| |
| |
| |
| |
| 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' |
| } |
|
|