muddle/core/backport.ts
2026-10-23 09:12:49 -04:00

657 lines
21 KiB
TypeScript

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<BackportResult> {
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<string[]> {
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<Record<string, string>> {
if (!existsSync(path)) return {};
const source = await readFile(path, "utf8");
const values: Record<string, string> = {};
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<void>,
collections: string[],
site: Record<string, string>,
): Promise<void> {
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<void>,
kind: BackportSource,
sourceRoot: string,
): Promise<void> {
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<string>();
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<string>();
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<string>,
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<ts.JsxAttributeLike>,
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(/<!--[\s\S]*?-->/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 (/^<!|^<\?/.test(token)) continue;
if (token.startsWith("<")) {
const match = /^<([\w:-]+)([\s\S]*?)\/?\s*>$/.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<string, string>();
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(
/<slot\s+name=["']([\w-]+)["']\s*\/?\s*>/g,
'<muddle-slot data-name="$1"></muddle-slot>',
)
.replace(/<slot\s*\/?\s*>/g, "<muddle-slot></muddle-slot>");
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, string>): 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(", ")})` : ""}`;
}