657 lines
21 KiB
TypeScript
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(", ")})` : ""}`;
|
|
}
|