import { z } from 'zod'; import { COMMENT_POLICIES } from './comments'; /** * Pond schemas and views shared between api and web (issue #21). * Ponds are the top-level content container; every self-registered * person owns exactly one personal pond (data-model.md §ponds). */ export const POND_TYPES = ['personal', 'shared'] as const; export type PondType = (typeof POND_TYPES)[number]; export const SIDEBAR_SORT_MODES = ['alpha', 'created', 'manual'] as const; export type SidebarSortMode = (typeof SIDEBAR_SORT_MODES)[number]; /** How the sidebar presents a pond's pages (issue #108): as the page tree * (`folders`) or grouped under the label tree (`labels`). */ export const SIDEBAR_VIEW_MODES = ['folders', 'labels'] as const; export type SidebarViewMode = (typeof SIDEBAR_VIEW_MODES)[number]; /** One font slot per ADR 0016; values reference the curated catalog. */ const fontSlotSchema = z.object({ family: z.string().min(1).max(80), weight: z.number().int().min(100).max(900), }); /** The three font slots, each defaulted to the vision fonts (ADR 0016). */ export const pondFontsSchema = z.object({ heading: fontSlotSchema.default({ family: 'Roboto', weight: 400 }), body: fontSlotSchema.default({ family: 'Roboto', weight: 200 }), mono: fontSlotSchema.default({ family: 'Fira Code', weight: 400 }), }); export type PondFonts = z.infer; /** * Pond `settings` jsonb. Parsing `{}` yields the documented defaults * (sidebar sort `alpha`; fonts Roboto 400 / Roboto 200 / Fira Code, * ADR 0016) — persisted settings therefore only need to store what the * pond actually changed. */ /** * Pond accent theme (issue #186, ADR 0018 stage C): `null` inherits the * viewer's own theme. Only the accent family is pond-themable; the hex is * validated here, WCAG conformance arises at render time via * `deriveAccentTokens` — never as a server-side check. */ export const pondThemeSchema = z.object({ accent: z .string() .regex(/^#[0-9a-f]{6}$/i, 'validation.invalid') .nullable() .default(null), }); export type PondTheme = z.infer; export const pondSettingsSchema = z.object({ sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'), /** The pond default for the sidebar's page presentation (issue #108); * every member can override it locally (`ui.sidebar.view.`). */ sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'), fonts: pondFontsSchema.default({}), /** Who may write comments (issue #91): every reader, or editors only. */ commentPolicy: z.enum(COMMENT_POLICIES).default('readers'), /** Per-pond opt-in to the public REST API (issue #104, default off): * without it the pond and its content answer 404 through the API even * for a token whose user could see them in the app. */ apiEnabled: z.boolean().default(false), /** Per-pond opt-in to the built-in MCP endpoint (issue #105, default * off) — independent of the REST opt-in. */ mcpEnabled: z.boolean().default(false), theme: pondThemeSchema.default({}), }); export type PondSettings = z.infer; /** The machine-access surfaces a pond can opt into (issues #104/#105). */ export type PondExposureFeature = 'api' | 'mcp'; export function pondFeatureEnabled(settings: PondSettings, feature: PondExposureFeature): boolean { return feature === 'api' ? settings.apiEnabled : settings.mcpEnabled; } export const pondNameSchema = z .string() .trim() .min(1, 'validation.required') .max(80, 'validation.tooLong'); export const createPondInputSchema = z.object({ name: pondNameSchema, description: z.string().trim().max(500, 'validation.tooLong').default(''), }); export type CreatePondInput = z.infer; export const updatePondInputSchema = z .object({ name: pondNameSchema, description: z.string().trim().max(500, 'validation.tooLong'), sidebarSort: z.enum(SIDEBAR_SORT_MODES), sidebarView: z.enum(SIDEBAR_VIEW_MODES), fonts: pondFontsSchema, commentPolicy: z.enum(COMMENT_POLICIES), apiEnabled: z.boolean(), mcpEnabled: z.boolean(), theme: pondThemeSchema, }) .partial(); export type UpdatePondInput = z.infer; /** What the api returns for a pond; settings come fully defaulted. */ export interface PondView { id: string; slug: string; name: string; description: string; type: PondType; ownerId: string; settings: PondSettings; createdAt: string; deletedAt: string | null; } const MAX_SLUG_LENGTH = 60; /** * Derives a URL-safe slug: German transliteration (ä→ae …), diacritics * stripped, everything else collapsed to single hyphens. Returns '' when * nothing survives — callers pick their fallback (e.g. the username). * Uniqueness (numeric suffixes) is the caller's job, not slugify's. */ export function slugify(input: string): string { return ( input // Precompose first (#127): macOS file names arrive NFD-decomposed, and // the umlaut digraphs below only match the precomposed forms. .normalize('NFC') .toLowerCase() .replace(/ä/g, 'ae') .replace(/ö/g, 'oe') .replace(/ü/g, 'ue') .replace(/ß/g, 'ss') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, MAX_SLUG_LENGTH) .replace(/-+$/, '') ); }