import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import { transform } from "lightningcss"; import ts from "typescript"; export type BackportSource = "astro" | "zola"; export interface BackportResult { source: BackportSource; output: string; files: string[]; } const ASSET_EXTENSIONS = new Set([ ".avif", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp", ".woff", ".woff2", ".ttf", ".pdf", ".txt", ]); export function detectBackportSource(root: string): BackportSource { if ( existsSync(join(root, "config.toml")) || (existsSync(join(root, "content")) && existsSync(join(root, "templates"))) ) return "zola"; if ( existsSync(join(root, "astro.config.mjs")) || existsSync(join(root, "astro.config.ts")) || existsSync(join(root, "src", "pages")) ) return "astro"; throw new Error(`Could not identify a Zola or Astro project in ${root}`); } export async function backportProject( input: string, output: string, source = detectBackportSource(input), ): Promise { const inputRoot = resolve(input); const outputRoot = resolve(output); if (inputRoot === outputRoot) { throw new Error( "The Muddle destination must be different from the source directory", ); } const files: string[] = []; const write = async (path: string, content: string) => { await mkdir(dirname(path), { recursive: true }); await writeFile(path, content); files.push(relative(outputRoot, path).replaceAll("\\", "/")); }; const contentRoot = join( inputRoot, source === "astro" ? "src/content" : "content", ); await writeProjectShell( outputRoot, write, await collectionNames(contentRoot), source === "zola" ? await zolaSiteConfig(join(inputRoot, "config.toml")) : {}, ); const mappings = source === "astro" ? [ { from: "src/pages", to: "modules/app/views" }, { from: "src/layouts", to: "modules/app/views/layouts" }, { from: "src/components", to: "modules/app/views/components" }, { from: "src/content", to: "content" }, { from: "public", to: "public" }, { from: "src/assets", to: "public/assets" }, { from: "src/styles", to: "styles" }, ] : [ { from: "templates", to: "modules/app/views" }, { from: "content", to: "content" }, { from: "static", to: "public" }, { from: "sass", to: "styles" }, ]; for (const mapping of mappings) { const folder = join(inputRoot, mapping.from); if (existsSync(folder)) { await convertFolder( folder, join(outputRoot, mapping.to), write, source, folder, ); } } return { source, output: outputRoot, files }; } async function collectionNames(root: string): Promise { if (!existsSync(root)) return []; const names: string[] = []; for (const entry of await readdir(root, { withFileTypes: true })) { if (entry.isDirectory()) names.push(entry.name.replace(/[^\w-]/g, "-")); } return names.sort(); } async function zolaSiteConfig(path: string): Promise> { if (!existsSync(path)) return {}; const source = await readFile(path, "utf8"); const values: Record = {}; for (const key of ["title", "base_url", "description", "default_language"]) { const match = new RegExp( `^\\s*${key}\\s*=\\s*["']([^"']*)["']\\s*$`, "m", ).exec(source); if (match?.[1] !== undefined) values[key] = match[1]; } return values; } async function writeProjectShell( root: string, write: (path: string, content: string) => Promise, collections: string[], site: Record, ): Promise { const collectionConfig = collections.length ? `,\n collections: [${collections.map((name) => `{ name: ${JSON.stringify(name)}, dir: ${JSON.stringify(`content/${name}`)}, loader: "markdown" }`).join(", ")}]` : ""; await write( join(root, "package.json"), `{\n "name": "muddle-backport",\n "private": true,\n "type": "module",\n "scripts": { "dev": "muddle dev", "build": "muddle build", "typegen": "muddle typegen" },\n "dependencies": { "muddle": "latest" }\n}\n`, ); await write( join(root, "ct.config.ts"), `import { defineConfig } from "muddle/contracts";\nimport { markdownIntegration, stcssIntegration, sassIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n root: import.meta.dir,\n views: "modules/app/views",\n viewRoots: ["modules/app/views"],\n integrations: [markdownIntegration(), stcssIntegration(), sassIntegration()]${collectionConfig},\n prerender: { routes: ["/"], outDir: "dist" },\n});\n`, ); await write( join(root, "index.ts"), `import { join } from "node:path";\nimport { App } from "muddle/core/app";\nimport { prerender } from "muddle/adapters/static";\nimport { createViews } from "muddle/views/api";\nimport { resolved } from "./config.ts";\nimport { AppModule } from "./modules/app/app.module.ts";\n\nexport const app = await App.create({ modules: [AppModule], config: resolved, integrations: resolved.integrations, views: createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components }) });\nexport async function build() {\n if (resolved.prerender) await prerender(app, { ...resolved.prerender, outDir: join(resolved.root, resolved.prerender.outDir ?? "dist") });\n}\n`, ); await write( join(root, "config.ts"), `import { resolveConfig } from "muddle/core/config";\nimport config from "./ct.config.ts";\nexport const resolved = resolveConfig(config);\n`, ); await write( join(root, "modules/app/app.module.ts"), `import { Module } from "muddle/decorators";\nimport { AppController } from "./app.controller.ts";\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`, ); await write( join(root, "modules/app/site.config.ts"), `/** Static values imported from the source site's configuration. */\nexport const site = ${JSON.stringify({ title: site.title ?? "Muddle backport", baseUrl: site.base_url ?? "", description: site.description ?? "", language: site.default_language ?? "en" }, null, 2)} as const;\n`, ); await write( join(root, "modules/app/app.controller.ts"), `import { Controller, View, ViewRoot } from "muddle/decorators";\nimport { site } from "./site.config.ts";\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n @View("index.spug")\n index() { return { title: site.title, site }; }\n}\n`, ); } async function convertFolder( source: string, destination: string, write: (path: string, content: string) => Promise, kind: BackportSource, sourceRoot: string, ): Promise { for (const entry of await readdir(source, { withFileTypes: true })) { const from = join(source, entry.name); const to = join(destination, entry.name); if (entry.isDirectory()) { await convertFolder(from, to, write, kind, sourceRoot); continue; } const extension = extname(entry.name).toLowerCase(); if ( extension === ".astro" || extension === ".html" || extension === ".htm" || extension === ".tera" ) { await write( to.replace(/\.(astro|html?|tera)$/i, ".spug"), kind === "astro" ? astroToSpug(await readFile(from, "utf8"), from, sourceRoot) : zolaToSpug(await readFile(from, "utf8")), ); } else if (extension === ".tsx" || extension === ".jsx") { await write( to.replace(/\.(tsx|jsx)$/i, ".spug"), reactToSpug(await readFile(from, "utf8"), from), ); } else if (extension === ".css") { await write( to.replace(/\.css$/i, ".stcss"), cssToStcss(await readFile(from, "utf8"), from), ); } else if (extension === ".scss" || extension === ".sass") { await write( to.replace(/\.(scss|sass)$/i, ".scss"), await readFile(from, "utf8"), ); } else if ( extension === ".md" || extension === ".markdown" || ASSET_EXTENSIONS.has(extension) ) { await mkdir(dirname(to), { recursive: true }); await cp(from, to); } } } export function reactToSpug( source: string, filename = "component.tsx", ): string { const file = ts.createSourceFile( filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX, ); const analysis = analyzeReactComponent(source, filename); const imports = new Set(); let root: | ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | undefined; const visit = (node: ts.Node): void => { if ( ts.isImportDeclaration(node) && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) ) { for (const element of node.importClause.namedBindings.elements) imports.add(element.name.text); } if (ts.isImportDeclaration(node) && node.importClause?.name) imports.add(node.importClause.name.text); if (ts.isReturnStatement(node) && node.expression && !root) { const expression = unwrapJsxExpression(node.expression); if (isJsxNode(expression)) root = expression; } if (ts.isArrowFunction(node) && !root && ts.isExpression(node.body)) { const expression = unwrapJsxExpression(node.body); if (isJsxNode(expression)) root = expression; } ts.forEachChild(node, visit); }; visit(file); if (!root) throw new Error(`No JSX return value found in ${filename}`); const aot = analysis.state.map( (state) => ` const ${state.name} = ${state.initial};`, ); const script = aot.length || analysis.hooks.length ? `javascript!\n // React hooks lowered during static component composition: ${analysis.hooks.join(", ") || "none"}\n${aot.join("\n")}\nend\n` : ""; return `${script}${jsxToSpug(root, imports).trim()}\n`; } export interface ReactHookAnalysis { hooks: string[]; state: { name: string; initial: string }[]; } /** Finds React hooks and extracts AOT-safe initial state from a component. */ export function analyzeReactComponent( source: string, filename = "component.tsx", ): ReactHookAnalysis { const file = ts.createSourceFile( filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX, ); const hooks = new Set(); const state: { name: string; initial: string }[] = []; const visit = (node: ts.Node): void => { if (ts.isCallExpression(node)) { const name = ts.isIdentifier(node.expression) ? node.expression.text : ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : ""; if (/^use[A-Z]/.test(name)) hooks.add(name); if ( name === "useState" && ts.isVariableDeclaration(node.parent) && ts.isArrayBindingPattern(node.parent.name) ) { const binding = node.parent.name.elements[0]; if ( binding && ts.isBindingElement(binding) && ts.isIdentifier(binding.name) ) { state.push({ name: binding.name.text, initial: node.arguments[0]?.getText() ?? "undefined", }); } } } ts.forEachChild(node, visit); }; visit(file); return { hooks: [...hooks].sort(), state }; } function unwrapJsxExpression(node: ts.Expression): ts.Expression { return ts.isParenthesizedExpression(node) ? unwrapJsxExpression(node.expression) : node; } function isJsxNode( node: ts.Node, ): node is ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment { return ( ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node) ); } function jsxToSpug( node: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment, imports: Set, depth = 0, ): string { const pad = " ".repeat(depth); if (ts.isJsxText(node)) { const text = node.getText().replace(/\s+/g, " ").trim(); return text ? `${pad}| ${text}` : ""; } if (ts.isJsxExpression(node)) { if (!node.expression) return ""; return `${pad}= ${node.expression.getText()}`; } if (ts.isJsxFragment(node)) return node.children .map((child) => jsxToSpug(child, imports, depth)) .filter(Boolean) .join("\n"); const opening = ts.isJsxElement(node) ? node.openingElement : node; const rawName = opening.tagName.getText(); const name = rawName === "Comp" ? "button" : rawName; const component = (/^[A-Z]/.test(name) || imports.has(name)) && rawName !== "Comp"; const attributes = jsxAttributes(opening.attributes.properties, component); const line = component ? `${pad}component ${kebab(name)}` : `${pad}${name}${attributes.inline}`; const props = component && attributes.props.length ? `\n${attributes.props.map((property) => `${pad} ${property}`).join("\n")}` : ""; if (ts.isJsxSelfClosingElement(node)) { return depth === 0 ? `${line}${props}\n${pad} if content\n${pad} != await content()` : `${line}${props}`; } const children = node.children .map((child) => jsxToSpug(child, imports, depth + 1)) .filter(Boolean) .join("\n"); return [line + props, children].filter(Boolean).join("\n"); } function jsxAttributes( properties: ts.NodeArray, component = false, ): { inline: string; props: string[] } { const inline: string[] = []; const props: string[] = []; for (const property of properties) { if (ts.isJsxSpreadAttribute(property)) continue; const name = property.name.getText(); if (name === "ref") continue; const initializer = property.initializer; if (!initializer) { inline.push(`${name}=true`); continue; } const value = ts.isStringLiteral(initializer) ? JSON.stringify(initializer.text) : ts.isJsxExpression(initializer) && initializer.expression ? `(${initializer.expression.getText()})` : JSON.stringify(initializer.getText()); const normalized = name === "className" ? "class" : name; const classValue = normalized === "class" && ts.isJsxExpression(initializer) && initializer.expression ? (staticClassValue(initializer.expression) ?? JSON.stringify("muddle-react")) : value; if (component) props.push(`${normalized}: ${classValue}`); else inline.push(`${normalized}=${classValue}`); } return { inline: inline.length ? `(${inline.join(", ")})` : "", props }; } function staticClassValue(expression: ts.Expression): string | undefined { if (ts.isStringLiteral(expression)) return JSON.stringify(expression.text); if (!ts.isCallExpression(expression)) return undefined; const classes = expression.arguments.flatMap((argument) => ts.isStringLiteral(argument) ? [argument.text] : [], ); return classes.length ? JSON.stringify(classes.join(" ")) : undefined; } function kebab(value: string): string { return value .replace(/([a-z0-9])([A-Z])/g, "$1-$2") .replace(/_/g, "-") .toLowerCase(); } export function cssToStcss(source: string, filename = "style.css"): string { const css = transform({ filename, code: Buffer.from(source), minify: false, }).code.toString(); let depth = 0; const lines: string[] = []; for (const token of css.replace(/\r/g, "").split(/([{};])/)) { const value = token.trim(); if (!value) continue; if (value === "}") { depth = Math.max(0, depth - 1); lines.push(`${" ".repeat(depth)}}`); continue; } if (value === "{") { const previous = lines.pop() ?? ""; lines.push(`${previous} {`); depth += 1; continue; } if (value === ";") { const previous = lines.pop() ?? ""; lines.push(`${previous};`); continue; } lines.push(`${" ".repeat(depth)}${value}`); } return `${lines.join("\n").trim()}\n`; } export function htmlToSpug(source: string): string { const body = source .replace(/^---[\s\S]*?---\s*/m, "") .replace(/^---[\s\S]*?---\s*/m, "") .replace(//g, ""); const tokens = body.match(/<[^>]+>|[^<]+/g) ?? []; const lines: string[] = []; let depth = 0; for (const token of tokens) { if (/^<\//.test(token)) { depth = Math.max(0, depth - 1); continue; } if (/^$/.exec(token); if (!match) continue; const tag = match[1]!; const attrs = attributesToSpug(match[2] ?? ""); lines.push(`${" ".repeat(depth)}${tag}${attrs}`); if ( !/\/$/.test(token) && !new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr", ]).has(tag) ) depth += 1; } else { const text = token.replace(/\s+/g, " ").trim(); if (text) { lines.push( `${" ".repeat(depth)}| ${text .replace(/\{\{\s*(.*?)\s*\}\}/g, "#{ $1 }") .replace(/\{([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\}/g, "#{ $1 }")}`, ); } } } return `${lines.join("\n")}\n`; } /** Converts an Astro source file without evaluating its frontmatter. */ export function astroToSpug( source: string, file = "page.astro", pagesRoot = "", ): string { const frontmatter = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(source)?.[1] ?? ""; let body = source.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, ""); const imports = new Map(); for (const entry of frontmatter.matchAll( /import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["'];?/g, )) { imports.set(entry[1]!, entry[2]!); } const layout = /^\s*<([A-Z][\w$]*)\b([^>]*)>([\s\S]*)<\/\1>\s*$/.exec(body); let prefix = ""; if (layout && imports.has(layout[1]!)) { const imported = imports.get(layout[1]!)!; if (/(?:^|\/)layouts\//.test(imported)) { const absoluteLayout = resolve(dirname(file), imported); const pageRelative = relative(pagesRoot, file); const layoutRelative = relative( dirname(pagesRoot), absoluteLayout, ).replace(/\.astro$/, ".spug"); const target = relative(dirname(pageRelative), layoutRelative).replaceAll( "\\", "/", ); prefix = `extends ${target}\n\nblock content\n`; body = layout[3]!; } } body = body .replace( //g, '', ) .replace(//g, ""); const converted = htmlToSpug(body) .replace(/^(\s*)muddle-slot\(data-name="([\w-]+)"\)\s*$/gm, "$1block $2") .replace(/^(\s*)muddle-slot\s*$/gm, "$1block content"); const componentized = componentCalls(converted, imports); return prefix ? `${prefix}${indent(componentized)}` : componentized; } /** Converts Tera inheritance, blocks, includes and expressions into SPUG. */ export function zolaToSpug(source: string): string { let result = source .replace(/\{#([\s\S]*?)#\}/g, "") .replace( /\{%\s*extends\s+["']([^"']+)["']\s*%\}/g, (_all, path: string) => `extends ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`, ) .replace( /\{%\s*include\s+["']([^"']+)["']\s*%\}/g, (_all, path: string) => `include ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`, ) .replace(/\{%\s*block\s+([\w-]+)\s*%\}/g, "block $1") .replace(/\{%\s*endblock\s*%\}/g, "") .replace(/\{%\s*if\s+(.+?)\s*%\}/g, "if $1") .replace(/\{%\s*endif\s*%\}/g, "") .replace(/\{%\s*for\s+([\w$]+)\s+in\s+(.+?)\s*%\}/g, "each $1 in $2") .replace(/\{%\s*endfor\s*%\}/g, "") .replace(/\{\{\s*([^}]+?)\s*\}\}/g, "#{ $1 }"); result = htmlToSpug(result); return result.replace(/\| (extends|include|block|if|each) (.+)$/gm, "$1 $2"); } function componentCalls(source: string, imports: Map): string { const output: string[] = []; for (const line of source.split("\n")) { const match = /^(\s*)([A-Z][\w$]*)(.*)$/.exec(line); if (!match || !imports.has(match[2]!)) { output.push(line); continue; } const imported = imports.get(match[2]!)!; const name = imported .replace(/^.*\//, "") .replace(/\.astro$/, "") .replace( /[A-Z]/g, (letter, index) => `${index ? "-" : ""}${letter.toLowerCase()}`, ); const attributeText = /\((.*)\)$/.exec(match[3]!)?.[1]; output.push(`${match[1]}component ${name}`); for (const pair of attributeText?.split(/\s*,\s*/) ?? []) { const [key = "", raw = "true"] = pair.split(/=(.+)/); const expression = /^\((.*)\)$/.exec(raw.trim())?.[1] ?? raw.trim().replace(/^\{\s*|\s*\}$/g, ""); const dynamic = raw.trim().startsWith("{") || raw.trim().startsWith("("); output.push( `${match[1]} ${key.trim()}: ${dynamic ? expression : expression === "true" ? "true" : JSON.stringify(expression)}`, ); } } return `${output.filter(Boolean).join("\n").trim()}\n`; } function indent(source: string): string { return ( source .split("\n") .filter(Boolean) .map((line) => ` ${line}`) .join("\n") + "\n" ); } function attributesToSpug(raw: string): string { const classes: string[] = []; const attributes: string[] = []; for (const match of raw.matchAll( /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g, )) { const name = match[1]!; const value = match[2] ?? match[3] ?? match[4]; if (name === "class" && value) { classes.push(...value.split(/\s+/).filter(Boolean)); continue; } if (name === "id" && value && !value.startsWith("{")) { attributes.push(`id=${JSON.stringify(value)}`); continue; } if (value === undefined) attributes.push(`${name}=true`); else if (/^\{[\s\S]*\}$/.test(value)) attributes.push(`${name}=(${value.slice(1, -1).trim()})`); else attributes.push(`${name}=${JSON.stringify(value)}`); } return `${classes.map((value) => `.${value}`).join("")}${attributes.length ? `(${attributes.join(", ")})` : ""}`; }