import type { ComponentOverrideAnalysis, NormalizedOverride, OverrideFramework, } from "../core/component-overrides.ts"; /** * Turn analyzed `components.ts` overrides into the generated `components.ts` * module plus per-override hydration wrappers. * * The runtime overrides object already holds bare imported identifiers, so those * (when not hydrated) ride through a plain spread. Everything else needs a static * import Vite can see: path-string overrides import the file directly, while * hydrated overrides get a wrapper `.astro` that applies the `client:*` directive * (Astro directives must be written statically). The `islands` group is folded * into the MDX component map — it is just `mdx` with a default `client: "visible"`. */ export interface ComponentSlotWrapper { content: string; /** File name (no extension) under `.blume/src/generated/component-slots/`. */ name: string; } export interface ComponentSlotPlan { /** Frameworks used by resolved overrides; enable the matching Astro renderer. */ frameworks: Set; /** Contents of `.blume/src/generated/components.ts`. */ module: string; wrappers: ComponentSlotWrapper[]; } const EMPTY_MODULE = `// Generated by Blume. Do not edit. import type { ComponentOverride } from "blume/core/define-components.ts"; export const mdxComponents: Record = {}; export const layoutOverrides: Record = {}; `; // A user-supplied attribute value interpolated into a generated .astro tag: a // stray quote or newline would produce a malformed component and an opaque // Astro parse error pointing at the generated file, not the user's config. const attributeValue = (value: string): string => value.replaceAll(/["\n\r]/gu, " ").trim(); const CLIENT_LOAD = "client:load"; /** Astro client directive for a hydrated override. */ const directiveFor = (override: NormalizedOverride): string => { const framework = override.source?.framework; switch (override.client) { case "idle": { return "client:idle"; } case "visible": { return "client:visible"; } case "media": { return override.media ? `client:media="${attributeValue(override.media)}"` : CLIENT_LOAD; } case "only": { return framework ? `client:only="${attributeValue(framework)}"` : CLIENT_LOAD; } default: { return CLIENT_LOAD; } } }; const importClause = (variable: string, name: string, path: string): string => name === "default" ? `import ${variable} from ${JSON.stringify(path)};` : `import { ${name} as ${variable} } from ${JSON.stringify(path)};`; /** A wrapper `.astro` that statically imports a component and hydrates it. */ const wrapperContent = (override: NormalizedOverride): string => { // SAFETY: the only caller guards `if (!source)` and bails before invoking // this, so the override always carries a resolved source here. const { name, path } = override.source as NonNullable< NormalizedOverride["source"] >; const clause = name === "default" ? `import Component from ${JSON.stringify(path)};` : `import { ${name} as Component } from ${JSON.stringify(path)};`; return `--- // Generated by Blume. Do not edit. ${clause} --- `; }; /** * A filesystem-safe, injective token for an override key. Distinct keys must * never share a wrapper file ("Foo.Bar" vs "Foo_Bar" used to collide, racing * the same temp file and silently rendering the wrong component), so every * non-alphanumeric character is hex-escaped rather than collapsed — the same * hardening as `exampleSlug` in templates.ts. */ const sanitize = (value: string): string => value.replaceAll( /[^A-Za-z0-9]/gu, (char) => `_${(char.codePointAt(0) ?? 0).toString(16)}_` ); export const planComponentSlots = ( componentsFile: string | null, analysis: ComponentOverrideAnalysis | null ): ComponentSlotPlan => { const frameworks = new Set(); if (!componentsFile) { return { frameworks, module: EMPTY_MODULE, wrappers: [] }; } if (!analysis) { return { frameworks, module: `// Generated by Blume. Do not edit. import overrides from ${JSON.stringify(componentsFile)}; export const mdxComponents = overrides.mdx ?? {}; export const layoutOverrides = overrides.layout ?? {}; `, wrappers: [], }; } const wrappers: ComponentSlotWrapper[] = []; const importLines: string[] = []; // Explicit `key: Variable` map entries, per surface. const mdxEntries: string[] = []; const layoutEntries: string[] = []; let counter = 0; // Islands are MDX components hydrated by default, so plan them alongside `mdx`. const mdxOverrides = [...analysis.mdx, ...analysis.islands]; const plan = ( override: NormalizedOverride, group: "mdx" | "layout", entries: string[] ): void => { const { source } = override; if (source?.framework) { frameworks.add(source.framework); } // Bare identifier, not hydrated: the runtime object already has it. if (override.identifier && !override.client) { return; } if (!source) { // Unresolved (already warned): leave it on the runtime object. return; } const variable = `__blumeSlot${counter}`; counter += 1; if (override.client) { const name = `${group}-${sanitize(override.key)}`; wrappers.push({ content: wrapperContent(override), name }); importLines.push( importClause(variable, "default", `./component-slots/${name}.astro`) ); } else { importLines.push(importClause(variable, source.name, source.path)); } entries.push(`${JSON.stringify(override.key)}: ${variable}`); }; for (const override of mdxOverrides) { plan(override, "mdx", mdxEntries); } for (const override of analysis.layout) { plan(override, "layout", layoutEntries); } // `overrides.islands` is not spread: every valid island is hydrated, so it is // emitted as an explicit wrapper entry above (raw spreads only carry bare, // non-hydrated identifiers from `mdx`/`layout`). const moduleSource = `// Generated by Blume. Do not edit. import overrides from ${JSON.stringify(componentsFile)}; ${importLines.join("\n")}${importLines.length ? "\n" : ""}export const mdxComponents = { ...(overrides.mdx ?? {})${mdxEntries.length ? `, ${mdxEntries.join(", ")}` : ""} }; export const layoutOverrides = { ...(overrides.layout ?? {})${layoutEntries.length ? `, ${layoutEntries.join(", ")}` : ""} }; `; return { frameworks, module: moduleSource, wrappers }; };