import { existsSync, mkdirSync, writeFileSync } from "fs" import { basename, dirname, isAbsolute, normalize, resolve } from "path" import { activeDesign, getDesignSection, materializeDesignCssSnapshot } from "../design/designs" export type DeckFoundationMode = "create" | "repair" export type DeckFoundationStatus = "created" | "updated" export interface CreateDeckFoundationInput { workspaceRoot: string outputPath: string title: string language: string designName?: string mode?: DeckFoundationMode overwrite?: boolean } export interface CreateDeckFoundationResult { ok: true outputPath: string design: string includedSections: string[] status: DeckFoundationStatus next: string[] } interface FoundationParts { fontLinks: string[] cssBlocks: string[] scriptBlocks: string[] } const SLIDES_START = "" const SLIDES_END = "" export function createDeckFoundation(input: CreateDeckFoundationInput): CreateDeckFoundationResult { const outputPath = normalizeOutputPath(input.outputPath) const targetPath = safeWorkspaceFilePath(input.workspaceRoot, outputPath) const mode = input.mode ?? "create" const canOverwrite = input.overwrite === true || mode === "repair" const existed = existsSync(targetPath) if (existed && !canOverwrite) { throw new Error(`Deck HTML already exists at ${outputPath}. Pass overwrite=true or mode=repair to replace the foundation shell.`) } const design = input.designName || activeDesign() const foundation = getDesignSection("foundation", design) const parts = parseFoundationParts(foundation) if (parts.scriptBlocks.length === 0) throw new Error(`Design '${design}' foundation does not include a SlidePresentation JavaScript code block.`) const snapshot = materializeDesignCssSnapshot({ workspaceRoot: input.workspaceRoot, outputPath, designName: design, snapshotName: activeDesignSnapshotName(outputPath), }) const html = renderFoundationHtml({ language: input.language || "en", title: input.title || "Revela Deck", fontLinks: parts.fontLinks, stylesheetHrefs: [snapshot.href], script: parts.scriptBlocks.map(guardEmptyDeckNavigation).join("\n\n"), }) mkdirSync(dirname(targetPath), { recursive: true }) writeFileSync(targetPath, html, "utf-8") return { ok: true, outputPath, design, includedSections: [ "design:foundation", parts.fontLinks.length > 0 ? "foundation:font-links" : "foundation:font-links:none", snapshot.generatedFallback ? "design-css:fallback" : "design-css:snapshot", `design-css:active-snapshot:${snapshot.snapshotName}`, snapshot.assetCount > 0 ? "design-assets:snapshot" : "design-assets:none", "foundation:SlidePresentation", ], status: existed ? "updated" : "created", next: [ "Fetch active design rules before patching slides.", "Fetch required layouts and components from the design before adding slide content.", "Patch slides between the revela-slides markers chapter by chapter, then run artifact QA.", ], } } export function activeDesignSnapshotName(outputPath: string): string { const stem = basename(normalizeOutputPath(outputPath), ".html") .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, "") return `${stem || "deck"}-active` } export function normalizeOutputPath(outputPath: string): string { const trimmed = outputPath.trim() if (!trimmed) throw new Error("outputPath is required") if (!trimmed.endsWith(".html")) throw new Error("Deck foundation outputPath must end in .html") if (isAbsolute(trimmed)) throw new Error("Deck foundation outputPath must be workspace-relative") const segments = trimmed.split(/[\\/]+/) if (segments.includes("..")) throw new Error("Deck foundation outputPath must not contain parent-directory traversal") return normalize(trimmed).replace(/\\/g, "/") } function safeWorkspaceFilePath(workspaceRoot: string, outputPath: string): string { const root = resolve(workspaceRoot) const target = resolve(root, outputPath) if (target !== root && !target.startsWith(`${root}/`)) { throw new Error("Deck foundation outputPath must stay inside the workspace") } return target } function parseFoundationParts(foundation: string): FoundationParts { const fontLinks = extractFontLinks(foundation) const cssBlocks: string[] = [] const scriptBlocks: string[] = [] const fenceRe = /```([\w-]*)\n([\s\S]*?)```/g let match: RegExpExecArray | null while ((match = fenceRe.exec(foundation)) !== null) { const lang = (match[1] || "").toLowerCase() const body = match[2].trim() if (!body) continue if (lang === "css") cssBlocks.push(body) if ((lang === "javascript" || lang === "js") && body.includes("class SlidePresentation")) { scriptBlocks.push(body) } } return { fontLinks, cssBlocks, scriptBlocks } } function extractFontLinks(foundation: string): string[] { const links: string[] = [] const seen = new Set() const linkRe = /]*(?:fonts\.googleapis|fonts\.gstatic|rel=["']preconnect["'])[^>]*>/gi let match: RegExpExecArray | null while ((match = linkRe.exec(foundation)) !== null) { const link = match[0].trim() if (seen.has(link)) continue seen.add(link) links.push(link) } return links } function guardEmptyDeckNavigation(script: string): string { return script.replace( /new\s+SlidePresentation\s*\(\s*\)\s*;?/g, 'if (document.querySelector(".slide")) { new SlidePresentation(); }', ) } function renderFoundationHtml(input: { language: string title: string fontLinks: string[] stylesheetHrefs: string[] script: string }): string { const fontLinks = input.fontLinks.map((link) => ` ${link}`).join("\n") const stylesheetLinks = input.stylesheetHrefs.map((href) => ` `).join("\n") return [ "", ``, "", " ", " ", ` ${escapeHtml(input.title)}`, fontLinks, stylesheetLinks, "", "", ` ${SLIDES_START}`, ` ${SLIDES_END}`, " ", "", "", "", ].filter((line) => line !== "").join("\n") } function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") } function escapeAttribute(value: string): string { return escapeHtml(value.trim() || "en") } export function deckFoundationMarkers(): { start: string; end: string } { return { start: SLIDES_START, end: SLIDES_END } }