/** * Pure helpers for palette extraction, kept free of puppeteer / fs / process * so they can be unit-tested directly. The orchestration that drives a headless * browser lives in `generate-palette.ts`. */ export type ThemeAppearance = 'light' | 'dark' /** Theme metadata read from a theme package's `package.json`. */ export interface ThemePackage { name?: string /** Explicit appearance declared by the theme (`"light"` | `"dark"`). */ themeAppearance?: ThemeAppearance styleSheets?: string[] /** * Marks the package as a unified theme. A theme styles the UI, the editor, * and the Markdown preview at once, so this is a boolean flag rather than the * old per-surface `"ui"` | `"syntax"` | `"preview"` string. */ theme?: boolean } /** * Derives a theme's appearance the way Inkdrop's desktop app does (see * `ThemePackage#getAppearance`): dark when the package name contains "dark" or * `themeAppearance` is "dark", otherwise light. Used to pick which branch of a * `light-dark()` value to keep. * * @param theme - The theme package's metadata. * @returns The resolved appearance. */ export function deriveAppearance(theme: ThemePackage): ThemeAppearance { return theme.name?.includes('dark') || theme.themeAppearance === 'dark' ? 'dark' : 'light' } /** * Builds the standalone HTML document that is loaded into a headless browser to * compute a theme's CSS custom properties. * * It pulls in Inkdrop's base CSS plus the theme's own stylesheets so that the * computed style of the probed element (see {@link THEME_TYPE_PROBE_SELECTORS}) * resolves every themed variable. Inkdrop's base stylesheets are passed in as * already-resolved absolute URLs (they live in this package's own dependencies, * not the theme's), while the theme's own `styles/` links stay relative and * resolve against the `` — i.e. the theme project root. * * The body holds a `.cm-editor` and a `.mde-preview` element so that syntax and * preview extraction can read their scoped tokens off the matching element: * Inkdrop scopes the editor's design tokens under `.cm-editor` and the rendered * markdown's under `.mde-preview`, and a theme may override them there (e.g. * Solarized re-maps the `--hsl-*` ramp under `.cm-editor, .mde-preview * .codeblock`), so those variables resolve to the theme's values inside the * matching element. See {@link THEME_TYPE_PROBE_SELECTORS}. * * @param theme - The theme package's metadata (`name`, `styleSheets`). * @param baseUrl - File URL used as the document ``; the theme's own * `styles/` links resolve against it. * @param baseStyleSheetURLs - Absolute URLs of Inkdrop's base stylesheets, * resolved from this package's dependency graph by the caller. * @param appearance - Optional forced appearance; adds a `-mode` * body class when provided. * @returns The full HTML document as a string. */ export function buildPreviewHTML( theme: ThemePackage, baseUrl: string, baseStyleSheetURLs: string[], appearance?: ThemeAppearance ): string { const baseCSSLinks = baseStyleSheetURLs .map((href) => ``) .join('\n') const themeCSSLinks = (theme.styleSheets ?? []) .map((filePath) => ``) .join('\n') const bodyClass = [theme.name, appearance && `${appearance}-mode`].filter(Boolean).join(' ') return ` ${baseCSSLinks} ${themeCSSLinks}
` } /** * The categorised CSS variable manifest shipped by `@inkdropapp/css` as * `variables.json`: an object keyed by category (`ui`, `status`, `tags`, * `task-progress`, `markdown`, `syntax`), each mapping to its variable names. */ export type ThemeVariableManifest = Record /** * The surfaces a unified theme styles. `generate-palette` extracts every * surface's variables in a single run and merges them into one palette. */ export const THEME_TYPES = ['ui', 'syntax', 'preview'] as const export type ThemeType = (typeof THEME_TYPES)[number] /** * Manifest categories contributed by each theme type. A `ui` theme covers the * app chrome (`ui`) plus the note `status`, `tags`, and `task-progress` * palettes; a `syntax` theme covers the editor `syntax` tokens; a `preview` * theme covers the rendered `markdown` preview plus its `mermaid` diagram * colours. */ const THEME_TYPE_CATEGORIES: Record = { ui: ['ui', 'status', 'tags', 'task-progress'], syntax: ['syntax'], preview: ['markdown', 'mermaid'] } /** * Selects the CSS variable names a given theme type contributes, in * declaration order, from the categorised manifest shipped by `@inkdropapp/css` * 0.7.0+ (which keys `variables.json` by category). Categories missing from the * manifest are skipped so it degrades gracefully across `@inkdropapp/css` * versions. * * @param manifest - The categorised manifest (category -> variable names). * @param type - The theme type whose variables to extract. * @returns The variable names belonging to the type's categories. */ export function selectVariableNames(manifest: ThemeVariableManifest, type: ThemeType): string[] { return THEME_TYPE_CATEGORIES[type].flatMap((category) => manifest[category] ?? []) } /** * The DOM selector whose computed style carries each theme type's CSS * variables. The `ui` tokens live on the document root, so `` resolves * them. The editor (`syntax`) and rendered-markdown (`preview`) tokens are * scoped by Inkdrop under `.cm-editor` and `.mde-preview` respectively, and a * theme can override them there — so they resolve to the theme's values only * inside the matching element. */ const THEME_TYPE_PROBE_SELECTORS: Record = { ui: 'body', syntax: '.cm-editor', preview: '.mde-preview' } /** A surface's variable names paired with the element to read them off. */ export interface ProbeGroup { /** The surface (theme type) this group covers. */ type: ThemeType /** The selector whose computed style carries this group's variables. */ probeSelector: string /** The group's CSS variable names, in declaration order. */ variableNames: string[] } /** * Builds the extraction plan for a unified theme: each surface's variable names * paired with the element they must be read off. A unified theme styles the UI, * the editor, and the preview at once, and each surface's tokens are scoped * under a different element (see {@link THEME_TYPE_PROBE_SELECTORS}), so every group * is extracted from its own probe element and the results are merged into a * single palette. * * @param manifest - The categorised manifest shipped by `@inkdropapp/css`. * @returns One {@link ProbeGroup} per surface, in `ui`, `syntax`, `preview` * order — the merge order of the final palette. */ export function buildProbeGroups(manifest: ThemeVariableManifest): ProbeGroup[] { return THEME_TYPES.map((type) => ({ type, probeSelector: THEME_TYPE_PROBE_SELECTORS[type], variableNames: selectVariableNames(manifest, type) })) } /** * Projects the raw computed CSS variables onto the theme's declared variable * names, preserving their declaration order. Names absent from `computed` map * to `undefined` (and are therefore dropped by `JSON.stringify`). * * @param variableNames - The theme's declared CSS variable names. * @param computed - All `--*` custom properties read from the rendered body. * @returns A record of `variableName -> computed value`. */ export function mapThemeVariables( variableNames: string[], computed: Record ): Record { return variableNames.reduce( (acc, name) => { acc[name] = computed[name] return acc }, {} as Record ) } /** Index of the `)` matching the `(` at `openIndex`, or -1 when unbalanced. */ function matchingParen(value: string, openIndex: number): number { let depth = 0 for (let i = openIndex; i < value.length; i++) { if (value[i] === '(') depth++ else if (value[i] === ')' && --depth === 0) return i } return -1 } /** * Splits `value` at its first top-level (paren-depth-zero) comma into * `[before, after]`. Commas inside nested function calls (e.g. `hsl(…)`) are * ignored. Returns `[value, '']` when there is no top-level comma. */ function splitTopLevelComma(value: string): [string, string] { let depth = 0 for (let i = 0; i < value.length; i++) { const ch = value[i] if (ch === '(') depth++ else if (ch === ')') depth-- else if (ch === ',' && depth === 0) return [value.slice(0, i), value.slice(i + 1)] } return [value, ''] } /** * Replaces every `light-dark(, )` in a CSS value with the branch * matching `appearance`, recursively and in place. * * Inkdrop's mobile app (React Native) can't evaluate the CSS `light-dark()` * function, so the generated palette must carry already-resolved raw values. * The browser leaves `light-dark()` unresolved inside custom properties, so we * pick the branch ourselves — mirroring how the desktop app selects light/dark * via the body's `color-scheme`. Nested calls (the kept branch may itself * contain `light-dark()`) and calls embedded in larger values (shadows, * gradients, borders) are handled. * * @param value - A CSS value that may contain one or more `light-dark()` calls. * @param appearance - Which branch to keep (`light` → first, `dark` → second). * @returns The value with all `light-dark()` calls resolved. */ export function resolveLightDark(value: string, appearance: ThemeAppearance): string { const marker = 'light-dark(' const start = value.indexOf(marker) if (start === -1) return value const open = start + marker.length - 1 const close = matchingParen(value, open) if (close === -1) return value // unbalanced — leave the value untouched const [light, dark] = splitTopLevelComma(value.slice(open + 1, close)) const chosen = (appearance === 'dark' ? dark : light).trim() return ( value.slice(0, start) + resolveLightDark(chosen, appearance) + resolveLightDark(value.slice(close + 1), appearance) ) } /** * Finds every outermost `color-mix(…)` call in a CSS value, in source order. * Nested calls are part of their enclosing call and are not listed separately, * since the browser resolves the whole expression at once. Unbalanced calls are * skipped. * * @param value - A CSS value that may contain `color-mix()` calls. * @returns The full text of each outermost `color-mix(…)` call. */ export function findColorMixCalls(value: string): string[] { const marker = 'color-mix(' const calls: string[] = [] let from = 0 for (let start = value.indexOf(marker); start !== -1; start = value.indexOf(marker, from)) { const close = matchingParen(value, start + marker.length - 1) if (close === -1) break calls.push(value.slice(start, close + 1)) from = close + 1 } return calls } /** * Replaces every outermost `color-mix(…)` call in a CSS value with its resolved * colour. Calls missing from `resolved` (e.g. ones the browser rejected) are left * as they are. * * Like `light-dark()`, the browser leaves `color-mix()` unevaluated inside * custom properties, and Inkdrop's mobile app can't evaluate it either, so the * palette must carry the mixed result. The mixing itself happens in the * headless browser (see {@link findColorMixCalls}); this only splices the * results back in, so mixes embedded in larger values (shadows, borders) work. * * @param value - A CSS value that may contain `color-mix()` calls. * @param resolved - Map of `color-mix(…)` call text to its resolved colour. * @returns The value with each resolvable call replaced. */ export function replaceColorMix(value: string, resolved: Record): string { return findColorMixCalls(value).reduce( (acc, call) => (resolved[call] === undefined ? acc : acc.replace(call, resolved[call])), value ) } /** Clamps `n` into `[0, 1]`. */ const clamp01 = (n: number) => Math.min(1, Math.max(0, n)) /** * Converts a browser-computed sRGB colour — `color(srgb r g b)` or * `color(srgb r g b / a)`, as Chrome serialises `rgb(from r g b / alpha)` * — into `#rrggbb`, or `rgba(r, g, b, a)` when translucent. Channels outside * the sRGB gamut (e.g. from an `oklch` mix) are clamped. `none` counts as 0. * * @param computed - The computed colour string. * @returns The formatted colour, or `undefined` if `computed` isn't an sRGB * `color()` value. */ export function formatSRGBColor(computed: string): string | undefined { const match = /^color\(srgb\s+([^)]*)\)$/.exec(computed.trim()) if (!match) return undefined const [channels, alphaPart] = match[1].split('/') const parse = (token: string) => (token === 'none' ? 0 : Number(token)) const rgb = channels.trim().split(/\s+/).map(parse) const alpha = alphaPart === undefined ? 1 : parse(alphaPart.trim()) if (rgb.length !== 3 || [...rgb, alpha].some(Number.isNaN)) return undefined const [r, g, b] = rgb.map((c) => Math.round(clamp01(c) * 255)) const a = Math.round(clamp01(alpha) * 1000) / 1000 if (a === 1) { return '#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('') } return `rgba(${r}, ${g}, ${b}, ${a})` }