diff --git a/apps/site-tasks/build-how-to.mjs b/apps/site-tasks/build-how-to.mjs new file mode 100644 index 00000000..85e122ea --- /dev/null +++ b/apps/site-tasks/build-how-to.mjs @@ -0,0 +1,160 @@ +// Generates the `how-to` content collection from the single source of +// truth at `crates/compiler/zo-how-to/`. Each example is a `.zo` (code) +// plus an optional sibling `.md` (explanation). The site never reads the +// crate directly — this prebuild step mirrors every example into +// `apps/site/src/content/how-to///.md`, embedding +// the raw code as a YAML literal block scalar and the explanation as the +// body. Run from `prebuild`. + +import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = dirname(fileURLToPath(import.meta.url)); +const HOWTO = join(ROOT, "..", "..", "crates", "compiler", "zo-how-to"); +const OUT = join(ROOT, "..", "site", "src", "content", "how-to"); + +// Subdirs that may sit next to sources but aren't examples (build +// artifacts, shared fixtures) — never walked. +const SKIP_DIRS = new Set(["deps", "samples"]); + +// `001_hello` -> { order: 1, name: "hello" }. No prefix keeps the bare +// stem and sorts last. The prefix is for on-disk ordering only; it never +// reaches the slug or title. +function parsePrefix(stem) { + const match = stem.match(/^(\d+)_(.+)$/); + + if (match) return { order: Number(match[1]), name: match[2] }; + + return { order: Number.POSITIVE_INFINITY, name: stem }; +} + +// Indent every line by 2 spaces so the raw `.zo` is a valid YAML literal +// block scalar — consistent indentation greater than the `code:` key is +// all YAML needs, so blank lines and `--` comments can't break the parse. +function indentCode(source) { + return source + .replace(/\s+$/, "") + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +} + +// Lifts a `title:` from the explanation's optional frontmatter and +// returns the body without it. Tiny on purpose — only `title` matters. +function splitExplanation(raw) { + if (!raw.startsWith("---\n")) return { title: null, body: raw.trim() }; + + const end = raw.indexOf("\n---\n", 4); + + if (end === -1) return { title: null, body: raw.trim() }; + + const yaml = raw.slice(4, end); + const body = raw.slice(end + 5).trim(); + const match = yaml.match(/^title:\s*(.+)$/m); + + return { title: match ? match[1].trim() : null, body }; +} + +function humanize(name) { + return name.replace(/[_-]+/g, " "); +} + +// Collect `{ category, group, name, order, title, code, body }` for every +// `.zo` under each category root and its one level of group subdirs. +async function collectExamples() { + const examples = []; + + // Top-level domains are whatever directories sit under `zo-how-to` + // (`zo`, `zsx`, `providers`, …) — discovered, not hardcoded, so a + // restructure needs no generator change. + const roots = await readdir(HOWTO, { withFileTypes: true }); + const categories = roots + .filter((entry) => entry.isDirectory() && !SKIP_DIRS.has(entry.name)) + .map((entry) => entry.name) + .sort(); + + for (const category of categories) { + const categoryDir = join(HOWTO, category); + const sources = []; + + for (const entry of await readdir(categoryDir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith(".zo")) { + sources.push({ dir: categoryDir, group: null, file: entry.name }); + } else if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) { + const groupDir = join(categoryDir, entry.name); + + for (const sub of await readdir(groupDir, { withFileTypes: true })) { + if (sub.isFile() && sub.name.endsWith(".zo")) { + sources.push({ dir: groupDir, group: entry.name, file: sub.name }); + } + } + } + } + + for (const source of sources) { + const stem = source.file.slice(0, -".zo".length); + const { order, name } = parsePrefix(stem); + const code = await readFile(join(source.dir, source.file), "utf8"); + + const explanationPath = join(source.dir, `${stem}.md`); + let title = null; + let body = ""; + + if (existsSync(explanationPath)) { + const explanation = splitExplanation( + await readFile(explanationPath, "utf8"), + ); + + title = explanation.title; + body = explanation.body; + } + + examples.push({ + category, + group: source.group, + name, + order, + title: title ?? humanize(name), + code, + body, + }); + } + } + + return examples; +} + +const examples = await collectExamples(); + +// Rebuild from scratch so deleted/renamed examples never linger. +await rm(OUT, { recursive: true, force: true }); + +for (const example of examples) { + const dir = example.group + ? join(OUT, example.category, example.group) + : join(OUT, example.category); + + await mkdir(dir, { recursive: true }); + + const frontmatter = [ + "---", + `category: ${example.category}`, + example.group ? `group: ${example.group}` : null, + `title: ${JSON.stringify(example.title)}`, + Number.isFinite(example.order) ? `order: ${example.order}` : null, + "code: |", + indentCode(example.code), + "---", + "", + example.body, + "", + ] + .filter((line) => line !== null) + .join("\n"); + + await writeFile(join(dir, `${example.name}.md`), frontmatter); +} + +console.log(`[how-to] generated ${examples.length} example(s) -> ${OUT}`); diff --git a/apps/site/.gitignore b/apps/site/.gitignore index f11654f0..4e4f97cf 100644 --- a/apps/site/.gitignore +++ b/apps/site/.gitignore @@ -8,6 +8,8 @@ dist/ public/install.sh # generated by scripts/build-llms-full.mjs — source is content/initiation/en/ public/docs/llms.txt +# generated by site-tasks/build-how-to.mjs — source is crates/compiler/zo-how-to/ +src/content/how-to/ # dependencies node_modules/ diff --git a/apps/site/messages/de.json b/apps/site/messages/de.json index ac16b31d..037e07a1 100644 --- a/apps/site/messages/de.json +++ b/apps/site/messages/de.json @@ -20,6 +20,7 @@ "foundations_description": "Angetrieben von modernen, verlässlichen Fundamenten für eine hochleistungsfähige Grafik- und Web-Integration.", "foundations_title": "## fundamente", "header_initiation": "Einführung", + "header_how_to": "Anleitungen", "header_spec": "Spec", "header_news": "News", "hero_tagline": "Verwandle deine Gedanken sofort in typsichere Software und Ui.", diff --git a/apps/site/messages/en.json b/apps/site/messages/en.json index 080bc924..9d1eda87 100644 --- a/apps/site/messages/en.json +++ b/apps/site/messages/en.json @@ -20,6 +20,7 @@ "foundations_description": "Powered by modern and reliable foundations to deliver high-performance graphics and web integration.", "foundations_title": "## foundations", "header_initiation": "Initiation", + "header_how_to": "How-to", "header_spec": "Spec", "header_news": "News", "hero_tagline": "Turn your thoughts into type-safe software and Ui instantly.", diff --git a/apps/site/messages/fr.json b/apps/site/messages/fr.json index 57a6d5fb..08bd057c 100644 --- a/apps/site/messages/fr.json +++ b/apps/site/messages/fr.json @@ -20,6 +20,7 @@ "foundations_description": "Propulsé par des fondations modernes et fiables pour offrir une intégration haute performance graphique et web.", "foundations_title": "## fondations", "header_initiation": "Initiation", + "header_how_to": "Tutoriels", "header_spec": "Spec", "header_news": "Actus", "hero_tagline": "Transforme tes pensées en logiciels à typage sûr et en UI instantanément.", diff --git a/apps/site/messages/ja.json b/apps/site/messages/ja.json index d23cfec1..50fd4c83 100644 --- a/apps/site/messages/ja.json +++ b/apps/site/messages/ja.json @@ -20,6 +20,7 @@ "foundations_description": "現代的で信頼できる基盤に支えられ、高性能グラフィックスウェブ統合を実現する。", "foundations_title": "## 基盤", "header_initiation": "入門", + "header_how_to": "チュートリアル", "header_spec": "仕様", "header_news": "ニュース", "hero_tagline": "あなたの考えを、型安全なソフトウェアと Ui に即座に変える。", diff --git a/apps/site/messages/zh.json b/apps/site/messages/zh.json index fdc6a1a7..68b19480 100644 --- a/apps/site/messages/zh.json +++ b/apps/site/messages/zh.json @@ -20,6 +20,7 @@ "foundations_description": "由现代且可靠的基础驱动,提供高性能图形网页集成。", "foundations_title": "## 基础", "header_initiation": "入门", + "header_how_to": "教程", "header_spec": "规范", "header_news": "新闻", "hero_tagline": "把你的想法瞬间变成类型安全的软件和 Ui。", diff --git a/apps/site/package.json b/apps/site/package.json index b781ff7c..16d8c101 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -6,13 +6,15 @@ "node": "24.x" }, "scripts": { + "predev": "npm run zo:howto", "dev": "astro dev", - "prebuild": "npm run zo:install && npm run zo:docs", + "prebuild": "npm run zo:install && npm run zo:docs && npm run zo:howto", "build": "astro build", "preview": "astro preview", "astro": "astro", "zo:install": "cp ../../tasks/zo-install.sh public/install.sh", - "zo:docs": "node ../site-tasks/build-llms-full.mjs" + "zo:docs": "node ../site-tasks/build-llms-full.mjs", + "zo:howto": "node ../site-tasks/build-how-to.mjs" }, "dependencies": { "@astrojs/sitemap": "^3.7.3", diff --git a/apps/site/src/components/structure/header.astro b/apps/site/src/components/structure/header.astro index 9d0dd22c..c844686b 100644 --- a/apps/site/src/components/structure/header.astro +++ b/apps/site/src/components/structure/header.astro @@ -16,6 +16,7 @@ const { class: className, id } = Astro.props;