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

84 lines
12 KiB
TypeScript

import { existsSync } from "node:fs";
import { mkdir, readdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
export type MuddleTemplate = "docs" | "minimal";
export interface TemplateResult {
name: MuddleTemplate;
output: string;
files: string[];
}
const sharedFiles: Record<string, string> = {
"package.json": `${JSON.stringify(
{
name: "my-muddle-site",
private: true,
type: "module",
scripts: { dev: "muddle dev .", build: "muddle build ." },
dependencies: { muddle: "latest" },
},
null,
2,
)}\n`,
"tsconfig.json": `${JSON.stringify(
{
compilerOptions: {
target: "ESNext",
module: "Preserve",
moduleResolution: "bundler",
strict: true,
noEmit: true,
experimentalDecorators: true,
allowImportingTsExtensions: true,
},
},
null,
2,
)}\n`,
"config.ts": `import { resolveConfig } from "muddle";\nimport config from "./ct.config.ts";\n\nexport const resolved = resolveConfig(config);\n`,
"modules/app/app.module.ts": `import { Module } from "muddle";\nimport { AppController } from "./app.controller.ts";\n\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`,
};
const minimalFiles: Record<string, string> = {
"ct.config.ts": `import { defineConfig } from "muddle";\nimport { stcssIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n\troot: import.meta.dir,\n\tviews: "modules/app/views",\n\tviewRoots: ["modules/app/views"],\n\tstyles: "styles",\n\tintegrations: [stcssIntegration({ dir: "styles" })],\n\tprerender: { routes: ["/"], outDir: "dist" },\n});\n`,
"index.ts": `import { join } from "node:path";\nimport { App, createViews, prerender, serve } from "muddle";\nimport { AppModule } from "./modules/app/app.module.ts";\nimport { resolved } from "./config.ts";\n\nconst views = createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components });\nexport const app = await App.create({ modules: [AppModule], views, config: resolved, integrations: resolved.integrations });\n\nexport async function build() {\n\tawait prerender(app, { ...resolved.prerender, outDir: join(resolved.root, "dist") });\n}\n\nif (import.meta.main) {\n\tif (process.argv.includes("--prerender")) await build();\n\telse console.log(\`muddle demo on \${serve(app, { port: 3000 }).url}\`);\n}\n`,
"modules/app/app.controller.ts": `import { Controller, View, ViewRoot } from "muddle";\n\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n\t@View("index.spug")\n\tindex() {\n\t\treturn { title: "Muddle", message: "A small, fast Muddle project." };\n\t}\n}\n`,
"modules/app/views/index.spug": `doctype html\nhtml(lang="en")\n head\n meta(charset="UTF-8")\n meta(name="viewport" content="width=device-width, initial-scale=1")\n title= title\n link(rel="stylesheet" href="/_muddle/styles.css")\n body\n main.shell\n a.mark(href="/") muddle\n section.hero@demo.hero()\n p.kicker Vite-like starter\n h1= message\n p Edit the Spug view and STCSS stylesheet, then run <code>muddle dev .</code>.\n a.action(href="#next") Open the canvas\n section#next.card@demo.next()\n h2 Start with one route.\n p Build a static site with ordinary HTML, CSS, and server-side composition.\n`,
"styles/app.stcss": `:root { --ink: #1f2937; --muted: #64748b; --surface: #ffffff; --accent: #646cff; }\n* { box-sizing: border-box; }\nbody { margin: 0; color: var(--ink); background: #f8fafc; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }\n.shell { width: min(100% - 2rem, 68rem); margin: 0 auto; padding: 1.5rem 0 5rem; }\n.mark { color: var(--ink); font-weight: 800; letter-spacing: -0.06em; text-decoration: none; }\n.hero { max-width: 48rem; padding: 8rem 0 5rem; }\n.kicker { margin: 0; color: var(--accent); font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }\nh1 { margin: .7rem 0 1rem; font-size: clamp(3.5rem, 9vw, 7.25rem); line-height: .94; letter-spacing: -.075em; }\n.hero p { max-width: 34rem; color: var(--muted); font-size: 1.1rem; line-height: 1.65; }\n.action { display: inline-block; margin-top: 1rem; padding: .8rem 1rem; border-radius: .55rem; background: var(--accent); color: white; font-weight: 700; text-decoration: none; }\n.card { max-width: 38rem; padding: 1.5rem; border: 1px solid #e2e8f0; border-radius: 1rem; background: var(--surface); }\n.card h2 { margin: 0; letter-spacing: -.04em; }\n.card p { color: var(--muted); line-height: 1.6; }\n`,
"README.md": `# Minimal Muddle\n\nRun \`bun install\`, then \`bun run dev\`. Build the static output with \`bun run build\`.\n`,
};
const docsFiles: Record<string, string> = {
"ct.config.ts": `import { defineConfig } from "muddle";\nimport { stcssIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n\troot: import.meta.dir,\n\tviews: "modules/app/views",\n\tviewRoots: ["modules/app/views"],\n\tstyles: "styles",\n\tintegrations: [stcssIntegration({ dir: "styles" })],\n\tprerender: { routes: ["/"], outDir: "dist", predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] } },\n});\n`,
"index.ts": `import { join } from "node:path";\nimport { App, createViews, prerender, serve } from "muddle";\nimport { AppModule } from "./modules/app/app.module.ts";\nimport { resolved } from "./config.ts";\n\nconst views = createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components });\nexport const app = await App.create({ modules: [AppModule], views, config: resolved, integrations: resolved.integrations });\n\nexport async function build() {\n\tawait prerender(app, { ...resolved.prerender, outDir: join(resolved.root, "dist"), predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] } });\n}\n\nif (import.meta.main) {\n\tif (process.argv.includes("--prerender")) await build();\n\telse console.log(\`muddle docs on \${serve(app, { port: 3000 }).url}\`);\n}\n`,
"modules/app/app.controller.ts": `import { Controller, View, ViewRoot } from "muddle";\n\nconst sections = [\n\t{ id: "overview", title: "Overview", body: "Muddle composes static pages from Spug and STCSS." },\n\t{ id: "predict", title: "Reflective prediction", body: "Dotted anchors form groups, so the renderer discovers matching elements before it writes the page." },\n\t{ id: "deploy", title: "Deploy", body: "The finished bundle is HTML and CSS with precomputed responsive relations." },\n];\n\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n\t@View("index.spug")\n\tindex() { return { title: "Muddle Docs", sections }; }\n}\n`,
"modules/app/views/index.spug": `doctype html\nhtml(lang="en")\n head\n meta(charset="UTF-8")\n meta(name="viewport" content="width=device-width, initial-scale=1")\n title= title\n link(rel="stylesheet" href="/_muddle/styles.css")\n body\n header.topbar\n a.brand(href="/") muddle\n span.topbar__tag Documentation\n div.docs\n aside.sidebar\n p.sidebar__title Guide\n nav.sidebar__nav(aria-label="Documentation")\n each section in sections\n a.sidebar__link@docs.{section.id}(href='#' + section.id)= section.title\n main.content\n p.eyebrow Muddle documentation\n h1 Static interfaces, with measured relationships.\n p.lede This template uses responsive layout prediction and reflective anchor groups. Resize it, then follow the highlighted section.\n each section in sections\n section.doc-section@docs.{section.id}(id=section.id)\n h2= section.title\n p= section.body\n if section.id === "predict"\n pre\n code .sidebar:reflect(@docs) { transition: background 180ms ease; }\n`,
"styles/app.stcss": `:root { --ink: #20212a; --muted: #6d6f7c; --line: #e7e7ec; --paper: #fff; --wash: #f7f7fb; --accent: #7656df; }\n* { box-sizing: border-box; }\nhtml { scroll-behavior: smooth; }\nbody { margin: 0; color: var(--ink); background: var(--paper); font-family: Inter, ui-sans-serif, system-ui, sans-serif; }\n.topbar { position: sticky; z-index: 2; top: 0; display: flex; justify-content: space-between; align-items: center; height: 3.75rem; padding: 0 max(1rem, calc((100% - 78rem) / 2)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--paper) 88%, transparent); backdrop-filter: blur(14px); }\n.brand { color: var(--ink); font-weight: 800; letter-spacing: -.055em; text-decoration: none; }\n.topbar__tag, .sidebar__title, .eyebrow { color: var(--muted); font-size: .72rem; font-weight: 760; letter-spacing: .1em; text-transform: uppercase; }\n.docs { display: grid; grid-template-columns: 15rem minmax(0, 48rem); gap: clamp(2rem, 7vw, 7rem); width: min(100% - 2rem, 78rem); margin: 0 auto; }\n.sidebar { position: sticky; top: 5.5rem; align-self: start; padding: 2rem 0; }\n.sidebar__title { margin: 0 0 .65rem; }\n.sidebar__nav { display: grid; gap: .18rem; }\n.sidebar__link { position: relative; display: block; overflow: hidden; padding: .55rem .7rem; border-radius: .45rem; color: var(--muted); text-decoration: none; transition: color 180ms ease, transform 180ms ease; }\n.sidebar__link::before { position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: #ede8ff; content: ""; transform: scaleX(0); transform-origin: left; transition: transform 220ms cubic-bezier(.2,.8,.2,1); }\n.sidebar__link:hover, .sidebar__link:focus-visible { color: var(--accent); transform: translateX(.18rem); }\n.sidebar__link:hover::before, .sidebar__link:focus-visible::before { transform: scaleX(1); }\n.sidebar__link:target, .sidebar__link:target::before { color: var(--accent); transform: scaleX(1); }\n:reflect(@docs) { scroll-margin-top: 5.5rem; }\n.content { padding: clamp(3rem, 10vw, 7rem) 0 8rem; }\nh1 { max-width: 42rem; margin: .7rem 0 1.2rem; font-size: clamp(3rem, 6vw, 5.8rem); line-height: .95; letter-spacing: -.075em; }\n.lede { max-width: 42rem; color: var(--muted); font-size: 1.12rem; line-height: 1.7; }\n.doc-section { padding: 5rem 0; border-bottom: 1px solid var(--line); }\n.doc-section h2 { margin: 0 0 .8rem; font-size: 1.7rem; letter-spacing: -.045em; }\n.doc-section p { max-width: 38rem; color: var(--muted); line-height: 1.7; }\npre { overflow: auto; padding: 1rem; border: 1px solid var(--line); border-radius: .7rem; background: var(--wash); }\n@media (max-width: 48rem) { .docs { display: block; } .sidebar { position: sticky; top: 3.75rem; z-index: 1; display: block; margin: 0 -1rem; padding: .65rem 1rem; border-bottom: 1px solid var(--line); background: var(--paper); } .sidebar__title { display: none; } .sidebar__nav { display: flex; overflow: auto; } .sidebar__link { white-space: nowrap; } .content { padding-top: 3rem; } }\n`,
"README.md": `# Muddle Docs\n\nRun \`bun install\`, then \`bun run dev\`. The docs shell has a responsive sidebar and uses the \`docs\` reflective anchor group. Build it with \`bun run build\`.\n`,
};
export const muddleTemplates: readonly MuddleTemplate[] = ["docs", "minimal"];
export async function createTemplate(
name: MuddleTemplate,
destination: string,
): Promise<TemplateResult> {
const output = resolve(destination);
if (existsSync(output) && (await readdir(output)).length > 0) {
throw new Error(`Refusing to overwrite non-empty directory ${output}`);
}
const files = {
...sharedFiles,
...(name === "docs" ? docsFiles : minimalFiles),
};
const written: string[] = [];
for (const [relative, source] of Object.entries(files)) {
const file = join(output, relative);
await mkdir(join(file, ".."), { recursive: true });
await writeFile(file, source);
written.push(file);
}
return { name, output, files: written };
}