diff --git a/README.md b/README.md index 0fa693c8..023962c4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ zo (pronounced `/zuː/` just like "zoo") iS A PRACTiCAL, LiGHTWEiGHT, CROSS-PLAT **JOiN THE DEVOLUTiON.** -[home](https://zo.compilords.house) — [install](./crates/compiler/zo-notes/public/guidelines/01-install.md) — [initiation](https://zo.compilords.house/initiation) — [spec](https://zo.compilords.house/spec) — [news](https://zo.compilords.house/news) — [benchmark](crates/compiler/zo-benches) — [discord](https://discord.gg/JaNc4Nk5xw) +[home](https://zo.compilords.house) — [install](./crates/compiler/zo-notes/public/guidelines/01-install.md) — [initiation](https://zo.compilords.house/initiation) — [how-to](https://zo.compilords.house/how-to) — [spec](https://zo.compilords.house/spec) — [news](https://zo.compilords.house/news) — [benchmark](crates/compiler/zo-benches) — [discord](https://discord.gg/JaNc4Nk5xw) ## usage. diff --git a/apps/site-tasks/build-how-to.mjs b/apps/site-tasks/build-how-to.mjs index 85e122ea..5184f405 100644 --- a/apps/site-tasks/build-how-to.mjs +++ b/apps/site-tasks/build-how-to.mjs @@ -1,12 +1,20 @@ // 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"; +// truth at `crates/compiler/zo-how-to/`. Each example is a `.zo` (code), +// an optional sibling `.md` (explanation), an optional +// `-- EXPECTED OUTPUT:` block (stdout programs), and an optional sibling +// image (visual programs). The site never reads the crate directly — +// this prebuild step mirrors everything into +// `apps/site/src/content/how-to/...` and copies sibling images into +// `public/how-to/...`. Run from `prebuild` / `predev`. + +import { + readdir, + readFile, + writeFile, + mkdir, + rm, + copyFile, +} from "node:fs/promises"; import { existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,14 +22,17 @@ 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"); +const PUBLIC = join(ROOT, "..", "site", "public", "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"]); +// Sibling preview formats for visual examples, in preference order. +const IMAGE_EXTS = ["gif", "webp", "png", "jpg", "jpeg", "svg"]; + // `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. +// stem and sorts last. function parsePrefix(stem) { const match = stem.match(/^(\d+)_(.+)$/); @@ -30,10 +41,10 @@ function parsePrefix(stem) { 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) { +// Indent every line by 2 spaces so a multi-line string is a valid YAML +// literal block scalar — consistent indent is all YAML needs, so blank +// lines and `--` comments can't break the parse. +function indentBlock(source) { return source .replace(/\s+$/, "") .split("\n") @@ -41,8 +52,36 @@ function indentCode(source) { .join("\n"); } +// Split a `.zo` into its code and the `-- EXPECTED OUTPUT:` block (the +// same directive the test runner verifies). The output lines are `-- ` +// comments; strip the prefix. The block is removed from the code so the +// rendered source never shows the trailer. +function splitOutput(raw) { + const expectedIdx = raw.indexOf("-- EXPECTED OUTPUT:"); + const stdinIdx = raw.indexOf("-- @stdin:"); + + // The shown code stops at the first trailing test directive + // (`-- @stdin:` or `-- EXPECTED OUTPUT:`) — both are runner-only and + // shouldn't appear in the code pane. + const markers = [expectedIdx, stdinIdx].filter((idx) => idx !== -1); + const code = markers.length ? raw.slice(0, Math.min(...markers)) : raw; + + if (expectedIdx === -1) return { code, output: null }; + + const output = raw + .slice(expectedIdx) + .split("\n") + .slice(1) + .map((line) => line.replace(/^\s*--\s?/, "")) + .join("\n") + .replace(/^\n+/, "") + .replace(/\n+$/, ""); + + return { code, output: output.length ? output : null }; +} + // Lifts a `title:` from the explanation's optional frontmatter and -// returns the body without it. Tiny on purpose — only `title` matters. +// returns the body without it. function splitExplanation(raw) { if (!raw.startsWith("---\n")) return { title: null, body: raw.trim() }; @@ -61,8 +100,25 @@ 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. +// A sibling `.` image, copied to `public/how-to/.` +// and returned as a site URL. `null` when none exists. +async function siblingImage(dir, stem, rel) { + for (const ext of IMAGE_EXTS) { + const source = join(dir, `${stem}.${ext}`); + + if (existsSync(source)) { + const dest = join(PUBLIC, `${rel}.${ext}`); + + await mkdir(dirname(dest), { recursive: true }); + await copyFile(source, dest); + + return `/how-to/${rel}.${ext}`; + } + } + + return null; +} + async function collectExamples() { const examples = []; @@ -96,7 +152,13 @@ async function collectExamples() { 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 rel = source.group + ? `${category}/${source.group}/${name}` + : `${category}/${name}`; + + const { code, output } = splitOutput( + await readFile(join(source.dir, source.file), "utf8"), + ); const explanationPath = join(source.dir, `${stem}.md`); let title = null; @@ -111,6 +173,8 @@ async function collectExamples() { body = explanation.body; } + const image = await siblingImage(source.dir, stem, rel); + examples.push({ category, group: source.group, @@ -118,6 +182,8 @@ async function collectExamples() { order, title: title ?? humanize(name), code, + output, + image, body, }); } @@ -126,10 +192,13 @@ async function collectExamples() { return examples; } -const examples = await collectExamples(); - -// Rebuild from scratch so deleted/renamed examples never linger. +// Rebuild both trees from scratch so deleted/renamed examples and their +// images never linger. Clear BEFORE collecting — collectExamples copies +// sibling images into PUBLIC, so wiping it afterwards would delete them. await rm(OUT, { recursive: true, force: true }); +await rm(PUBLIC, { recursive: true, force: true }); + +const examples = await collectExamples(); for (const example of examples) { const dir = example.group @@ -144,8 +213,11 @@ for (const example of examples) { example.group ? `group: ${example.group}` : null, `title: ${JSON.stringify(example.title)}`, Number.isFinite(example.order) ? `order: ${example.order}` : null, + example.image ? `image: ${JSON.stringify(example.image)}` : null, "code: |", - indentCode(example.code), + indentBlock(example.code), + example.output ? "output: |" : null, + example.output ? indentBlock(example.output) : null, "---", "", example.body, diff --git a/apps/site/.gitignore b/apps/site/.gitignore index 4e4f97cf..84a39688 100644 --- a/apps/site/.gitignore +++ b/apps/site/.gitignore @@ -10,6 +10,7 @@ public/install.sh public/docs/llms.txt # generated by site-tasks/build-how-to.mjs — source is crates/compiler/zo-how-to/ src/content/how-to/ +public/how-to/ # dependencies node_modules/ diff --git a/apps/site/src/components/atoms/link.astro b/apps/site/src/components/atoms/link.astro index 98bdfc92..b2c344ee 100644 --- a/apps/site/src/components/atoms/link.astro +++ b/apps/site/src/components/atoms/link.astro @@ -3,12 +3,13 @@ interface Props { class?: string; href: string; label: string; + target?: string; } -const { href, label, class: className } = Astro.props; +const { href, label, class: className, target } = Astro.props; --- -{label} +{label}