/** * DesignManager — manage revela visual design templates. * * User designs are stored in ~/.config/revela/designs//. * Built-in designs are shipped read-only with this package under designs//. * Each design directory contains DESIGN.md (required), design.css for CSS-native styling, * and optionally legacy preview.html. Current previews are generated from the built-in * page-template fixture plus the design CSS. */ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "fs" import { dirname, join, normalize, resolve, basename, relative, sep } from "path" import { tmpdir } from "os" import { pathToFileURL } from "url" import { parseFrontmatter } from "../frontmatter" import { collectDirectoryEntries, extractEntriesToDirectory, normalizePackageArchiveEntries, readTarArchive, writeTarArchive } from "./archive" import { DESIGNS_DIR, DEFAULT_DESIGN, loadConfig, saveConfig, } from "../config" import { childLog } from "../log" import { PAGE_TEMPLATE_CLASSES, listPageTemplateVocabulary, templateDeckCss } from "../page-templates" const designLog = childLog("designs") // Seed directory: built-in designs shipped with this package. const SEED_DIR = resolve(__dirname, "../..", "designs") const BUILT_IN_PREVIEW_PATH = resolve(__dirname, "..", "page-templates", "built-in-preview.html") const PREVIEW_FALLBACK_ASSET_DESIGN = "lucent" export interface DesignInfo { name: string description: string author: string version: string internal: boolean skillText: string } export interface ListDesignsOptions { includeInternal?: boolean } export interface CreateDesignPackageArgs { name: string base?: string designMd: string designCss?: string previewHtml?: string assets?: DesignPackageAssetInput[] overwrite?: boolean } export interface CreateDesignDraftArgs extends CreateDesignPackageArgs { workspaceRoot: string } export interface CreateDesignPackageResult { ok: true name: string path: string files: string[] assets: DesignPackageAssetInfo[] base?: string overwritten: boolean } export interface InstallDesignDraftArgs { workspaceRoot: string name: string overwrite?: boolean } export interface InstallDesignDraftResult extends CreateDesignPackageResult { sourcePath: string } export interface ValidateDesignPackageResult { ok: boolean name: string path: string hasDesignMd: boolean hasDesignCss: boolean hasPreview: boolean hasMarkers: boolean sections: string[] layouts: string[] components: string[] assets: DesignPackageAssetInfo[] errors: string[] warnings: string[] } export interface DesignPackageAssetInput { path: string content?: string contentBase64?: string sourcePath?: string } export interface DesignPackageAssetInfo { path: string kind: "cover-background" | "closing-background" | "background" | "logo" | "asset" mimeType: string bytes: number } export interface PackDesignPackageArgs { workspaceRoot?: string name: string source?: "draft" | "installed" outputPath?: string format?: "tar.gz" | "tar" overwrite?: boolean } export interface PackDesignPackageResult { ok: true name: string archivePath: string format: "tar.gz" | "tar" files: string[] assets: DesignPackageAssetInfo[] } export interface InstallDesignArchiveArgs { archivePath: string name?: string overwrite?: boolean } export interface InstallDesignArchiveResult extends CreateDesignPackageResult { archivePath: string } export interface DesignPreviewInfo { name: string designDir: string previewPath: string hasPreview: boolean } export interface MaterializeDesignPreviewArgs { workspaceRoot: string name: string source?: "draft" | "installed" | "builtin" } export interface MaterializeDesignPreviewResult { ok: true name: string source: "draft" | "installed" | "builtin" designDir: string previewDir: string previewPath: string previewUrl: string browserHandoff: { serveRoot: string path: string urlTemplate: string instructions: string } designCssPath: string files: string[] warnings: string[] } export interface DesignCssSnapshotResult { ok: true design: string snapshotName: string sourcePath: string snapshotDir: string cssPath: string href: string assetCount: number generatedFallback: boolean warnings: string[] } // --------------------------------------------------------------------------- // Seed // --------------------------------------------------------------------------- /** * Copy built-in designs from the package to ~/.config/revela/designs/. * Always overwrites to keep bundled designs up to date. * User-created designs (not in the seed directory) are never touched. */ export function seedBuiltinDesigns(): void { if (!existsSync(SEED_DIR)) return mkdirSync(DESIGNS_DIR, { recursive: true }) for (const entry of readdirSync(SEED_DIR)) { const src = join(SEED_DIR, entry) if (!statSync(src).isDirectory()) continue if (!existsSync(join(src, "DESIGN.md"))) continue const dst = join(DESIGNS_DIR, entry) mkdirSync(dst, { recursive: true }) cpSync(src, dst, { recursive: true, force: true }) } } // --------------------------------------------------------------------------- // Parse // --------------------------------------------------------------------------- /** Parse a DESIGN.md file into DesignInfo. Returns null on any error. */ export function parseDesignFile(filePath: string): DesignInfo | null { try { const text = readFileSync(filePath, "utf-8") const { meta, body } = parseFrontmatter(text) return { name: meta.name || basename(join(filePath, "..")), description: meta.description || "", author: meta.author || "unknown", version: meta.version || "0.0.0", internal: meta.internal === "true", skillText: body, } } catch (e) { designLog.warn("failed to parse design file — skipping", { filePath, error: e instanceof Error ? e.message : String(e), }) return null } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- function designDirHasPackage(dir: string): boolean { return existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, "DESIGN.md")) } function resolveDesignDir(nameInput?: string): string | null { const name = normalizeDesignName(nameInput || activeDesign()) const userDir = join(DESIGNS_DIR, name) const bundledDir = join(SEED_DIR, name) if (designDirHasPackage(userDir)) { if (designDirHasPackage(bundledDir) && isSeededBuiltinCopy(userDir, bundledDir)) return bundledDir return userDir } if (designDirHasPackage(bundledDir)) return bundledDir return null } function isSeededBuiltinCopy(userDir: string, bundledDir: string): boolean { const userInfo = parseDesignFile(join(userDir, "DESIGN.md")) const bundledInfo = parseDesignFile(join(bundledDir, "DESIGN.md")) if (!userInfo || !bundledInfo) return false return userInfo.author === bundledInfo.author && userInfo.version === bundledInfo.version && userInfo.description === bundledInfo.description } function readDesignsFromDir(root: string): Map { const designs = new Map() if (!existsSync(root)) return designs for (const entry of readdirSync(root).sort()) { const dir = join(root, entry) if (!designDirHasPackage(dir)) continue const info = parseDesignFile(join(dir, "DESIGN.md")) if (info) designs.set(entry, info) } return designs } /** List available designs, sorted by name. User designs override bundled designs with the same name. Internal designs are hidden by default. */ export function listDesigns(options: ListDesignsOptions = {}): DesignInfo[] { const includeInternal = options.includeInternal ?? false const available = readDesignsFromDir(SEED_DIR) for (const [entry, info] of readDesignsFromDir(DESIGNS_DIR)) { available.set(entry, info) } return [...available.values()] .filter((info) => includeInternal || !info.internal) .sort((a, b) => a.name.localeCompare(b.name)) } /** Get the name of the currently active design. */ export function activeDesign(): string { const cfg = loadConfig() return cfg.activeDesign || cfg.activeTemplate || DEFAULT_DESIGN } /** Set the active design. Throws if design is not installed. */ export function activateDesign(name: string): void { if (!designExists(name)) { throw new Error(`Design '${name}' is not installed`) } const cfg = loadConfig() cfg.activeDesign = name saveConfig(cfg) } /** Get the skill text body from a design's DESIGN.md. */ export function getDesignSkillMd(name?: string): string { const designName = normalizeDesignName(name || activeDesign()) const designDir = resolveDesignDir(designName) if (!designDir) { throw new Error(`Design '${designName}' is not installed`) } const mdPath = join(designDir, "DESIGN.md") const info = parseDesignFile(mdPath) if (!info) { throw new Error(`Failed to parse DESIGN.md for '${designName}'`) } return info.skillText } /** Resolve a design's preview.html path. Throws if the design is not installed. */ export function resolveDesignPreview(name?: string): DesignPreviewInfo { const designName = normalizeDesignName(name || activeDesign()) const designDir = resolveDesignDir(designName) if (!designDir) { throw new Error(`Design '${designName}' is not installed`) } const previewPath = join(designDir, "preview.html") return { name: designName, designDir, previewPath, hasPreview: existsSync(previewPath), } } export function materializeDesignPreview(args: MaterializeDesignPreviewArgs): MaterializeDesignPreviewResult { const name = normalizeDesignName(args.name) const { designDir, source } = resolveDesignPreviewSourceDir(args.workspaceRoot, name, args.source) const previewDir = resolve(args.workspaceRoot, ".revela", "previews", "designs", name) const previewPath = join(previewDir, "preview.html") const designCssPath = join(previewDir, "design.css") const warnings: string[] = [] if (!existsSync(BUILT_IN_PREVIEW_PATH)) { throw new Error(`Built-in design preview fixture is missing: ${BUILT_IN_PREVIEW_PATH}`) } rmSync(previewDir, { recursive: true, force: true }) mkdirSync(previewDir, { recursive: true }) const previewHtml = readFileSync(BUILT_IN_PREVIEW_PATH, "utf-8").replace(/data-design="built-in-preview"/g, `data-design="${name}"`) writeFileSync(previewPath, previewHtml, "utf-8") const cssRead = readDesignCssFromDir(name, designDir) warnings.push(...cssRead.warnings) writeFileSync(designCssPath, `${cssRead.css.trim()}\n`, "utf-8") const sourceAssets = join(designDir, "assets") const targetAssets = join(previewDir, "assets") if (existsSync(sourceAssets)) cpSync(sourceAssets, targetAssets, { recursive: true }) copyMissingBuiltInPreviewAssets(previewHtml, targetAssets) return { ok: true, name, source, designDir, previewDir, previewPath, previewUrl: pathToFileURL(previewPath).href, browserHandoff: { serveRoot: previewDir, path: "preview.html", urlTemplate: "http://127.0.0.1:/preview.html", instructions: "Start a read-only local static server from serveRoot, then reply with the localhost URL so the user can click it open in Codex Browser. Do not open the file:// preview directly.", }, designCssPath, files: listDesignPackageFiles(previewDir), warnings, } } export function resolveDesignPackageDir(name?: string): string { const designName = normalizeDesignName(name || activeDesign()) const designDir = resolveDesignDir(designName) if (!designDir) throw new Error(`Design '${designName}' is not installed`) return designDir } export function readDesignCss(name?: string): { css: string; path?: string; generatedFallback: boolean; warnings: string[] } { const designName = normalizeDesignName(name || activeDesign()) const designDir = resolveDesignPackageDir(designName) const cssPath = join(designDir, "design.css") if (existsSync(cssPath)) { return { css: readFileSync(cssPath, "utf-8"), path: cssPath, generatedFallback: false, warnings: [] } } const foundation = getDesignSection("foundation", designName) const cssBlocks = extractCssCodeBlocks(foundation) if (cssBlocks.length === 0) throw new Error(`Design '${designName}' has no design.css and no CSS fallback in @design:foundation.`) return { css: [ "/* Generated compatibility CSS from DESIGN.md. Add design.css to make this design fully CSS-package native. */", ...cssBlocks, templateDeckCss({ designName, designAssetBasePath: "./assets" }), ].join("\n\n"), generatedFallback: true, warnings: [`Design '${designName}' has no design.css; generated a deck-local compatibility CSS snapshot from DESIGN.md.`], } } export function materializeDesignCssSnapshot(input: { workspaceRoot: string outputPath: string designName?: string snapshotName?: string }): DesignCssSnapshotResult { const designName = normalizeDesignName(input.designName || activeDesign()) const snapshotName = normalizeDesignSnapshotName(input.snapshotName || designName) const designDir = resolveDesignPackageDir(designName) const outputDir = dirname(normalize(input.outputPath)) const snapshotRelDir = normalize(join(outputDir, "_revela-design", snapshotName)).replace(/\\/g, "/") const snapshotDir = resolve(input.workspaceRoot, snapshotRelDir) const cssPath = join(snapshotDir, "design.css") const cssRead = readDesignCss(designName) mkdirSync(snapshotDir, { recursive: true }) writeFileSync(cssPath, cssRead.css.endsWith("\n") ? cssRead.css : `${cssRead.css}\n`, "utf-8") const assetsDir = join(designDir, "assets") let assetCount = 0 if (existsSync(assetsDir) && statSync(assetsDir).isDirectory()) { const targetAssets = join(snapshotDir, "assets") rmSync(targetAssets, { recursive: true, force: true }) cpSync(assetsDir, targetAssets, { recursive: true }) assetCount = listDesignPackageFiles(targetAssets).length } const href = normalize(relative(outputDir || ".", normalize(join(snapshotRelDir, "design.css")))).replace(/\\/g, "/") return { ok: true, design: designName, snapshotName, sourcePath: cssRead.path || join(designDir, "DESIGN.md"), snapshotDir, cssPath, href: href.startsWith(".") ? href : `./${href}`, assetCount, generatedFallback: cssRead.generatedFallback, warnings: cssRead.warnings, } } function normalizeDesignSnapshotName(name: string): string { const normalized = name.trim().toLowerCase() if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(normalized)) { throw new Error("Design snapshot name must be kebab-case using lowercase letters, numbers, and hyphens") } return normalized } /** Normalize and validate a design package name. */ export function normalizeDesignName(name: string): string { const normalized = name.trim().toLowerCase() if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(normalized)) { throw new Error("Design name must be kebab-case using lowercase letters, numbers, and hyphens") } return normalized } /** Create a local design package in ~/.config/revela/designs//. */ export function createDesignPackage(args: CreateDesignPackageArgs): CreateDesignPackageResult { const name = normalizeDesignName(args.name) const designMd = args.designMd?.trim() const designCss = args.designCss?.trim() const previewHtml = args.previewHtml?.trim() if (!designMd) throw new Error("designMd is required") const target = join(DESIGNS_DIR, name) const existed = existsSync(target) if (existed && !args.overwrite) { throw new Error(`Design '${name}' already exists. Pass overwrite=true to replace it.`) } mkdirSync(DESIGNS_DIR, { recursive: true }) if (existed) { rmSync(target, { recursive: true, force: true }) } mkdirSync(target, { recursive: true }) writeFileSync(join(target, "DESIGN.md"), `${designMd}\n`, "utf-8") if (designCss) writeFileSync(join(target, "design.css"), `${designCss}\n`, "utf-8") if (previewHtml) writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8") writeDesignAssets(target, args.assets) const validation = validateDesignPackage(name) if (!validation.ok) { throw new Error(`Created design package is invalid: ${validation.errors.join("; ")}`) } return { ok: true, name, path: target, files: listDesignPackageFiles(target), assets: listDesignAssetsInDir(target), base: args.base, overwritten: existed, } } /** Create a project-local design draft under .revela/drafts/designs//. */ export function createDesignDraftPackage(args: CreateDesignDraftArgs): CreateDesignPackageResult { const name = normalizeDesignName(args.name) const designMd = args.designMd?.trim() const designCss = args.designCss?.trim() const previewHtml = args.previewHtml?.trim() if (!designMd) throw new Error("designMd is required") const target = designDraftDir(args.workspaceRoot, name) const existed = existsSync(target) if (existed && !args.overwrite) { throw new Error(`Design draft '${name}' already exists. Pass overwrite=true to replace it.`) } mkdirSync(dirname(target), { recursive: true }) if (existed) { rmSync(target, { recursive: true, force: true }) } mkdirSync(target, { recursive: true }) writeFileSync(join(target, "DESIGN.md"), `${designMd}\n`, "utf-8") if (designCss) writeFileSync(join(target, "design.css"), `${designCss}\n`, "utf-8") if (previewHtml) writeFileSync(join(target, "preview.html"), `${previewHtml}\n`, "utf-8") writeDesignAssets(target, args.assets) const validation = validateDesignDraftPackage(args.workspaceRoot, name) if (!validation.ok) { throw new Error(`Created design draft is invalid: ${validation.errors.join("; ")}`) } return { ok: true, name, path: target, files: listDesignPackageFiles(target), assets: listDesignAssetsInDir(target), base: args.base, overwritten: existed, } } /** Validate a project-local design draft. */ export function validateDesignDraftPackage(workspaceRoot: string, nameInput: string): ValidateDesignPackageResult { let name = nameInput try { name = normalizeDesignName(nameInput) } catch { // validateDesignPackageAt records the invalid-name error. } return validateDesignPackageAt(nameInput, designDraftDir(workspaceRoot, name)) } /** Install a validated project-local design draft into the user-level design registry. */ export function installDesignDraftPackage(args: InstallDesignDraftArgs): InstallDesignDraftResult { const name = normalizeDesignName(args.name) const sourcePath = designDraftDir(args.workspaceRoot, name) const validation = validateDesignDraftPackage(args.workspaceRoot, name) if (!validation.ok) { throw new Error(`Design draft is invalid: ${validation.errors.join("; ")}`) } const target = join(DESIGNS_DIR, name) const existed = existsSync(target) if (existed && !args.overwrite) { throw new Error(`Design '${name}' already exists. Pass overwrite=true to replace it.`) } try { mkdirSync(DESIGNS_DIR, { recursive: true }) if (existed) { rmSync(target, { recursive: true, force: true }) } cpSync(sourcePath, target, { recursive: true }) } catch (e) { throw new Error(`Installing design draft requires write access to Revela user config at ${DESIGNS_DIR}: ${e instanceof Error ? e.message : String(e)}`) } return { ok: true, name, path: target, sourcePath, files: listDesignPackageFiles(target), assets: listDesignAssetsInDir(target), overwritten: existed, } } /** Package an installed design or workspace draft as a shareable .tar or .tar.gz archive. */ export function packDesignPackage(args: PackDesignPackageArgs): PackDesignPackageResult { const name = normalizeDesignName(args.name) const source = args.source ?? (args.workspaceRoot && designDraftExists(args.workspaceRoot, name) ? "draft" : "installed") const sourceDir = source === "draft" ? designDraftDir(args.workspaceRoot || process.cwd(), name) : resolveDesignDir(name) if (!sourceDir || !existsSync(sourceDir)) throw new Error(`Design ${source} '${name}' is not available`) const validation = source === "draft" ? validateDesignDraftPackage(args.workspaceRoot || process.cwd(), name) : validateDesignPackage(name) if (!validation.ok) throw new Error(`Design ${source} is invalid: ${validation.errors.join("; ")}`) const format = args.format ?? "tar.gz" const archivePath = resolve(args.outputPath || join(args.workspaceRoot || process.cwd(), ".revela", "design-archives", `${name}.${format}`)) if (existsSync(archivePath) && !args.overwrite) throw new Error(`Archive already exists: ${archivePath}`) const entries = collectDirectoryEntries(sourceDir, name) writeTarArchive(entries, archivePath, format === "tar.gz") return { ok: true, name, archivePath, format, files: listDesignPackageFiles(sourceDir), assets: listDesignAssetsInDir(sourceDir), } } /** Install a .tar or .tar.gz design archive into the user-level design registry. */ export function installDesignArchive(args: InstallDesignArchiveArgs): InstallDesignArchiveResult { const archivePath = resolve(args.archivePath) if (!existsSync(archivePath)) throw new Error(`Archive does not exist: ${archivePath}`) if (!archivePath.endsWith(".tar") && !archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz")) { throw new Error("Design archive must be .tar, .tar.gz, or .tgz") } const entries = normalizePackageArchiveEntries(readTarArchive(archivePath)) const tmp = join(tmpdir(), `revela-design-install-${Date.now()}`) try { extractEntriesToDirectory(entries, tmp) const info = parseDesignFile(join(tmp, "DESIGN.md")) const name = normalizeDesignName(args.name || info?.name || basename(archivePath).replace(/\.tar\.gz$|\.tgz$|\.tar$/i, "")) const validation = validateDesignPackageAt(name, tmp) if (!validation.ok) throw new Error(`Design archive is invalid: ${validation.errors.join("; ")}`) const target = join(DESIGNS_DIR, name) const existed = existsSync(target) if (existed && !args.overwrite) throw new Error(`Design '${name}' already exists. Pass overwrite=true to replace it.`) mkdirSync(DESIGNS_DIR, { recursive: true }) if (existed) rmSync(target, { recursive: true, force: true }) cpSync(tmp, target, { recursive: true }) return { ok: true, name, path: target, archivePath, files: listDesignPackageFiles(target), assets: listDesignAssetsInDir(target), overwritten: existed, } } finally { rmSync(tmp, { recursive: true, force: true }) } } function hasDataAttribute(html: string, attr: string, value: string): boolean { const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") return new RegExp(`${attr}\\s*=\\s*(["'])${escaped}\\1`).test(html) } function hasSlideRole(html: string, role: string): boolean { const sectionRe = /]*class\s*=\s*(["'])[^"']*\bslide\b[^"']*\1[^>]*>/gi let match: RegExpExecArray | null while ((match = sectionRe.exec(html)) !== null) { if (hasDataAttribute(match[0], "data-slide-role", role)) return true } return false } function cssRuleHasClassSelector(selectors: string, className: string): boolean { const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") return new RegExp(`(^|[^a-zA-Z0-9_-])\\.${escaped}(?![a-zA-Z0-9_-])`).test(selectors) } function cssRuleHasFixedCanvasSize(body: string): boolean { const width = /(?:^|[;\s])width\s*:\s*1920px(?:\s*!important)?\s*(?:;|$)/i.test(body) const height = /(?:^|[;\s])height\s*:\s*1080px(?:\s*!important)?\s*(?:;|$)/i.test(body) return width && height } function hasFixedSizeCssRule(html: string, className: "slide-canvas"): boolean { const withoutComments = html.replace(/\/\*[\s\S]*?\*\//g, "") const ruleRe = /([^{}]+)\{([^{}]+)\}/g let match: RegExpExecArray | null while ((match = ruleRe.exec(withoutComments)) !== null) { if (cssRuleHasClassSelector(match[1] ?? "", className) && cssRuleHasFixedCanvasSize(match[2] ?? "")) { return true } } return false } /** Validate a local design package for the minimum Revela design contract. */ export function validateDesignPackage(nameInput: string): ValidateDesignPackageResult { let name = nameInput let hasValidName = true try { name = normalizeDesignName(nameInput) } catch { hasValidName = false // validateDesignPackageAt records the invalid-name error. } const dir = hasValidName ? resolveDesignDir(name) || join(DESIGNS_DIR, name) : join(DESIGNS_DIR, name) return validateDesignPackageAt(nameInput, dir) } function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesignPackageResult { let name = nameInput const errors: string[] = [] const warnings: string[] = [] try { name = normalizeDesignName(nameInput) } catch (e) { errors.push(e instanceof Error ? e.message : String(e)) } const mdPath = join(dir, "DESIGN.md") const cssPath = join(dir, "design.css") const previewPath = join(dir, "preview.html") const hasDesignMd = existsSync(mdPath) const hasDesignCss = existsSync(cssPath) const hasPreview = existsSync(previewPath) let hasMarkers = false let sections: string[] = [] let layouts: string[] = [] let components: string[] = [] let assets: DesignPackageAssetInfo[] = [] if (!existsSync(dir)) errors.push(`Design directory does not exist: ${dir}`) if (!hasDesignMd) errors.push("DESIGN.md is missing") if (!hasDesignCss) warnings.push("design.css is missing; DESIGN.md CSS fallback remains compatibility-only and should be migrated.") if (existsSync(dir)) assets = listDesignAssetsInDir(dir) if (hasDesignMd) { const info = parseDesignFile(mdPath) if (!info) { errors.push("DESIGN.md could not be parsed") } else { const parsed = parseDesignSections(info.skillText) hasMarkers = parsed.hasMarkers sections = Object.keys(parsed.sections) layouts = Object.keys(parsed.layouts) components = Object.keys(parsed.components) if (!hasMarkers) errors.push("DESIGN.md must include marker sections") if (!parsed.sections.foundation) errors.push("@design:foundation section is missing") if (!parsed.sections.rules) errors.push("@design:rules section is missing") if (layouts.length === 0) errors.push("At least one @layout section is required") if (components.length === 0) errors.push("At least one @component section is required") } } if (hasPreview) { const preview = readFileSync(previewPath, "utf-8") const designCss = hasDesignCss ? readFileSync(cssPath, "utf-8") : "" if (!preview.includes('
!hasDataAttribute(preview, "data-preview-component", component)) if (missingComponents.length > 0) { errors.push(`preview.html must showcase every @component; missing: ${missingComponents.join(", ")}`) } const missingLayoutPreviews = layouts.filter((layout) => !hasDataAttribute(preview, "data-preview-layout", layout)) if (missingLayoutPreviews.length > 0) { warnings.push(`preview.html should mark layout fixtures with data-preview-layout; missing: ${missingLayoutPreviews.join(", ")}`) } } if (hasDesignCss) { const designCss = readFileSync(cssPath, "utf-8") const missingCoreClasses = requiredDesignCssClasses().filter((className) => !cssTextHasClassSelector(designCss, className)) if (missingCoreClasses.length > 0) errors.push(`design.css must style core template classes; missing: ${missingCoreClasses.map((item) => `.${item}`).join(", ")}`) errors.push(...missingDesignCssAssetErrors(designCss, dir)) } const designText = hasDesignMd ? readFileSync(mdPath, "utf-8") : "" const designCss = hasDesignCss ? readFileSync(cssPath, "utf-8") : "" const tokenWarnings = designContractTokenWarnings(`${designText}\n${designCss}`) warnings.push(...tokenWarnings) return { ok: errors.length === 0, name, path: dir, hasDesignMd, hasDesignCss, hasPreview, hasMarkers, sections, layouts, components, assets, errors, warnings, } } function designContractTokenWarnings(text: string): string[] { const warnings: string[] = [] const checks = [ { label: "grid", pattern: /--grid-|grid columns|grid-column|column line|safe area|safe-area/i }, { label: "spacing", pattern: /--space-|spacing scale|baseline|rhythm unit|gap scale/i }, { label: "type scale", pattern: /--font-size-|type scale|typographic scale|line-height/i }, { label: "surface", pattern: /--surface|surface token|border token|shadow token/i }, ] for (const check of checks) { if (!check.pattern.test(text)) warnings.push(`DESIGN.md/design.css should document ${check.label} design tokens or an equivalent contract`) } return warnings } function previewReferencesDesignCss(preview: string): boolean { return /]*href=["'][^"']*design\.css["'][^>]*>/i.test(preview) } function requiredDesignCssClasses(): string[] { return [ "slide-canvas", "template-slide", "template-frame", "template-title", "template-card", "template-visual-slot-panel", ] } function cssTextHasClassSelector(css: string, className: string): boolean { const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "") const ruleRe = /([^{}]+)\{[^{}]*\}/g let match: RegExpExecArray | null while ((match = ruleRe.exec(withoutComments)) !== null) { if (cssRuleHasClassSelector(match[1] ?? "", className)) return true } return false } function missingDesignCssAssetErrors(css: string, designDir: string): string[] { const errors: string[] = [] const seen = new Set() const urlRe = /url\(\s*["']?([^"')]+)["']?\s*\)/gi let match: RegExpExecArray | null while ((match = urlRe.exec(css)) !== null) { const raw = match[1].trim() if (!raw || raw.startsWith("data:") || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("#")) continue const normalized = raw.replace(/^\.\/+/, "") if (!normalized.startsWith("assets/")) continue if (seen.has(normalized)) continue seen.add(normalized) if (!existsSync(join(designDir, ...normalized.split("/")))) errors.push(`design.css references missing asset: ${normalized}`) } return errors } function extractCssCodeBlocks(markdown: string): string[] { const blocks: string[] = [] const fenceRe = /```css\n([\s\S]*?)```/g let match: RegExpExecArray | null while ((match = fenceRe.exec(markdown)) !== null) { const body = match[1].trim() if (body) blocks.push(body) } return blocks } function designDraftDir(workspaceRoot: string, name: string): string { return resolve(workspaceRoot, ".revela", "drafts", "designs", name) } function resolveDesignPreviewSourceDir( workspaceRoot: string, name: string, requested?: "draft" | "installed" | "builtin", ): { designDir: string; source: "draft" | "installed" | "builtin" } { const draftDir = designDraftDir(workspaceRoot, name) if (requested === "draft") { if (!existsSync(draftDir)) throw new Error(`Design draft '${name}' does not exist: ${draftDir}`) return { designDir: draftDir, source: "draft" } } const installedDir = resolveDesignDir(name) if (requested === "installed" || requested === "builtin") { if (!installedDir) throw new Error(`Design '${name}' is not installed`) const source = requested === "builtin" ? "builtin" : installedDir.startsWith(SEED_DIR + sep) ? "builtin" : "installed" return { designDir: installedDir, source } } if (existsSync(draftDir)) return { designDir: draftDir, source: "draft" } if (!installedDir) throw new Error(`Design '${name}' is not available as a workspace draft or installed design`) return { designDir: installedDir, source: installedDir.startsWith(SEED_DIR + sep) ? "builtin" : "installed" } } function readDesignCssFromDir(name: string, designDir: string): { css: string; warnings: string[] } { const cssPath = join(designDir, "design.css") if (existsSync(cssPath)) return { css: readFileSync(cssPath, "utf-8"), warnings: [] } const mdPath = join(designDir, "DESIGN.md") const markdown = existsSync(mdPath) ? readFileSync(mdPath, "utf-8") : "" const cssBlocks = extractCssCodeBlocks(markdown) const css = [ "/* Generated preview compatibility CSS from DESIGN.md. Add design.css to make this design CSS-native. */", templateDeckCss(), ...cssBlocks, ].join("\n\n") return { css, warnings: [`Design '${name}' has no design.css; generated preview CSS from DESIGN.md compatibility blocks.`], } } function copyMissingBuiltInPreviewAssets(previewHtml: string, targetAssetsDir: string): void { const fallbackAssetsDir = join(SEED_DIR, PREVIEW_FALLBACK_ASSET_DESIGN, "assets") const assetRefs = new Set() const re = /(?:src|href)=["'](?:\.\/)?assets\/([^"']+)["']/g let match: RegExpExecArray | null while ((match = re.exec(previewHtml)) !== null) assetRefs.add(match[1]) if (assetRefs.size === 0) return mkdirSync(targetAssetsDir, { recursive: true }) for (const rel of assetRefs) { const target = resolve(targetAssetsDir, rel) if (existsSync(target)) continue const source = resolve(fallbackAssetsDir, rel) if (!existsSync(source) || !statSync(source).isFile()) continue mkdirSync(dirname(target), { recursive: true }) cpSync(source, target) } } function designDraftExists(workspaceRoot: string, name: string): boolean { const dir = designDraftDir(workspaceRoot, name) return existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, "DESIGN.md")) } function writeDesignAssets(targetDir: string, assets?: DesignPackageAssetInput[]): void { if (!assets || assets.length === 0) return for (const asset of assets) { const rel = normalizeAssetPath(asset.path) const target = resolve(targetDir, rel) if (target !== resolve(targetDir) && !target.startsWith(resolve(targetDir) + sep)) { throw new Error(`Asset path escapes design package: ${asset.path}`) } let bytes: Buffer if (asset.contentBase64 !== undefined) bytes = Buffer.from(asset.contentBase64, "base64") else if (asset.content !== undefined) bytes = Buffer.from(asset.content, "utf-8") else if (asset.sourcePath !== undefined) { const source = resolve(asset.sourcePath) if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`Asset source file does not exist: ${asset.sourcePath}`) bytes = readFileSync(source) } else { throw new Error(`Asset '${asset.path}' requires content, contentBase64, or sourcePath`) } mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, bytes as any) } } function normalizeAssetPath(pathInput: string): string { const normalized = pathInput.replace(/\\/g, "/").replace(/^\.\/+/, "") if (!normalized.startsWith("assets/")) throw new Error(`Design asset path must be located under assets/: ${pathInput}`) if (normalized.includes("\0") || normalized.startsWith("/") || normalized.split("/").some((part) => !part || part === "." || part === "..")) { throw new Error(`Design asset path must be located under assets/ and must not contain absolute paths, empty segments, '.', '..', or NUL characters: ${pathInput}`) } return normalized } function listDesignPackageFiles(dir: string): string[] { if (!existsSync(dir)) return [] const root = resolve(dir) const files: string[] = [] walk(root) return files.sort() function walk(current: string): void { for (const entry of readdirSync(current).sort()) { if (entry === ".DS_Store" || entry.startsWith(".")) continue const abs = join(current, entry) const stat = lstatSync(abs) if (stat.isSymbolicLink()) continue if (stat.isDirectory()) { walk(abs) continue } if (!stat.isFile()) continue files.push(relative(root, abs).split(sep).join("/")) } } } function listDesignAssetsInDir(dir: string): DesignPackageAssetInfo[] { const files = listDesignPackageFiles(dir).filter((file) => file.startsWith("assets/")) return files.map((file) => { const abs = join(dir, ...file.split("/")) return { path: file, kind: inferAssetKind(file), mimeType: mimeTypeForDesignAsset(file), bytes: statSync(abs).size, } }) } export function listDesignAssets(nameInput?: string): DesignPackageAssetInfo[] { const name = normalizeDesignName(nameInput || activeDesign()) const designDir = resolveDesignDir(name) if (!designDir) throw new Error(`Design '${name}' is not installed`) return listDesignAssetsInDir(designDir) } function inferAssetKind(path: string): DesignPackageAssetInfo["kind"] { const lower = path.toLowerCase() if (lower.includes("cover") && lower.includes("background")) return "cover-background" if ((lower.includes("closing") || lower.includes("close")) && lower.includes("background")) return "closing-background" if (lower.includes("background") || lower.includes("/bg-") || lower.includes("-bg.")) return "background" if (lower.includes("logo")) return "logo" return "asset" } function mimeTypeForDesignAsset(path: string): string { const lower = path.toLowerCase() if (lower.endsWith(".png")) return "image/png" if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg" if (lower.endsWith(".webp")) return "image/webp" if (lower.endsWith(".gif")) return "image/gif" if (lower.endsWith(".svg")) return "image/svg+xml" if (lower.endsWith(".css")) return "text/css" if (lower.endsWith(".json")) return "application/json" return "application/octet-stream" } // --------------------------------------------------------------------------- // Marker-based section / component parsing // --------------------------------------------------------------------------- export interface LayoutInfo { /** Full text content of the layout block (without marker lines). */ content: string /** Whether this layout type should be QA-checked for balance/rhythm. */ qa: boolean } export interface DesignSections { /** Map of @design: section → extracted content (without marker lines). */ sections: Record /** Map of @layout: → LayoutInfo with content + qa flag. */ layouts: Record /** Map of @component: → extracted content (without marker lines). */ components: Record /** Whether the DESIGN.md has any markers at all. */ hasMarkers: boolean } export interface DesignInventoryLayout { name: string qa: boolean description: string slots: string[] } export interface DesignInventoryComponent { name: string description: string nesting: { role: "container" | "content" | "fullbleed" | "utility" acceptsChildren: boolean allowedChildren?: string[] } contract?: DesignComponentContract } export interface DesignComponentContractVariant { name: string requiredDescendantClasses: string[] repeatedItemClass?: string requiredItemClasses?: string[] requireAlternatingClasses?: string[] } export interface DesignComponentContract { component: string kind: "structure" requiredRootClasses: string[] variants: DesignComponentContractVariant[] guidance: string } export interface DesignInventory { name: string sections: string[] layouts: DesignInventoryLayout[] components: DesignInventoryComponent[] pageTemplates: Array<{ templateId: string slots: string[] requiredClasses: string[] contractNotes: string[] }> assets: DesignPackageAssetInfo[] hasMarkers: boolean } /** * Parse a DESIGN.md body (no frontmatter) into sections, layouts, and components * using the three-layer HTML comment marker convention: * … * … * … * * The `qa` attribute on layout markers defaults to `true` when omitted. * Returns an object with empty maps and hasMarkers=false when no markers found. */ export function parseDesignSections(body: string): DesignSections { const sections: Record = {} const layouts: Record = {} const components: Record = {} const sectionRe = /([\s\S]*?)/g const layoutRe = /([\s\S]*?)/g const componentRe = /([\s\S]*?)/g let hasMarkers = false let match: RegExpExecArray | null while ((match = sectionRe.exec(body)) !== null) { hasMarkers = true sections[match[1]] = match[2].trim() } while ((match = layoutRe.exec(body)) !== null) { hasMarkers = true const qaAttr = match[2] // qa defaults to true when attribute is omitted const qa = qaAttr === "false" ? false : true layouts[match[1]] = { content: match[3].trim(), qa } } while ((match = componentRe.exec(body)) !== null) { hasMarkers = true components[match[1]] = match[2].trim() } return { sections, layouts, components, hasMarkers } } /** * Generate a compact Component Index table from parsed components. * Lists each component name with a one-line description (first non-empty * text line of the component block, stripped of markdown heading markers). */ export function generateComponentIndex(components: Record): string { const names = Object.keys(components) if (names.length === 0) return "" const rows = names.map((name) => { const body = components[name] // Extract first non-empty non-marker line as a short description const firstLine = body .split("\n") .map((l) => l.trim()) .find((l) => l && !l.startsWith("