Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
116 changes: 94 additions & 22 deletions apps/site-tasks/build-how-to.mjs
Original file line number Diff line number Diff line change
@@ -1,27 +1,38 @@
// 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/<category>/<group?>/<name>.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";

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+)_(.+)$/);

Expand All @@ -30,19 +41,47 @@ 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")
.map((line) => ` ${line}`)
.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() };

Expand All @@ -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 `<stem>.<ext>` image, copied to `public/how-to/<rel>.<ext>`
// 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 = [];

Expand Down Expand Up @@ -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;
Expand All @@ -111,13 +173,17 @@ async function collectExamples() {
body = explanation.body;
}

const image = await siblingImage(source.dir, stem, rel);

examples.push({
category,
group: source.group,
name,
order,
title: title ?? humanize(name),
code,
output,
image,
body,
});
}
Expand All @@ -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
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/site/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
5 changes: 3 additions & 2 deletions apps/site/src/components/atoms/link.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
---

<a class={className} href={href}>{label}</a>
<a class={className} href={href} target={target}>{label}</a>

<style>
a {
Expand Down
10 changes: 10 additions & 0 deletions apps/site/src/components/structure/reachout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const socials = [
class="socials-item"
href={social.link}
label={social.name}
target="_blank"
/>)
}
</div>
Expand All @@ -58,6 +59,11 @@ const socials = [
content="For Ai agents: <a href='llms.txt'>llms.txt</a> (curated index) and <a href='docs/llms-full.txt'>llms-full.txt</a> (full docs)."
/>

<Paragraph
class="privacy"
content=" Privacy: No cookies, no ads, no tracking. It's like you were never here."
/>

<script>
import { watcher } from "../../core/watcher";
import { observer } from "../../core/observer";
Expand Down Expand Up @@ -139,16 +145,20 @@ const socials = [
}
}

:global(.privacy),
:global(.human-faq),
:global(.ai-llm) {
margin: 32px 0;
font-size: calc(var(--base) * 3);
}

:global(.privacy),
:global(.ai-llm),
:global(.human-faq) {
margin-bottom: 8px;
}

:global(.privacy),
:global(.ai-llm) {
margin-top: 8px;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/site/src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ const howto = defineCollection({
title: z.string().optional(),
order: z.number().optional(),
code: z.string(),
output: z.string().optional(),
image: z.string().optional(),
}),
});

Expand Down
74 changes: 65 additions & 9 deletions apps/site/src/pages/how-to/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import NavBar, { type Item } from "../../components/navigation/navbar.astro";
import Header from "../../components/structure/header.astro";
import Screen from "../../components/structure/screen.astro";
import Editor from "../../components/interactive/editor.astro";
import Reachout from "../../components/structure/reachout.astro";

const slug = Astro.params.slug;
const examples = await loadHowTo();
Expand All @@ -20,6 +21,7 @@ const crumb = example.group
: example.category;

const { Content } = await render(example.entry);
const { code, output, image } = example.entry.data;
const items: Item[] = await getHowToNavItems();

const title = `zo — ${example.title}.`;
Expand All @@ -38,14 +40,31 @@ const description = `A zo ${crumb} example: ${example.title}.`;
</header>

<div class="example__split">
<div class="example__code">
<Editor code={example.entry.data.code} />
<div class="example__main">
<div class="example__code">
<span class="example__code-label f-cirka">program</span>
<Editor code={code} />
</div>

{image
? <img
class="example__image"
src={image}
alt={`${example.title} preview`}
/>
: output
? <div class="example__output">
<span class="example__output-label f-cirka">output</span>
<pre class="example__output-pre f-iosevka">{output}</pre>
</div>
: null}
</div>
<div class="example__doc">
<Content />
</div>
</div>
</article>
<Reachout />
</Screen>
</Base>

Expand All @@ -69,6 +88,7 @@ const description = `A zo ${crumb} example: ${example.title}.`;
.example__title {
font-size: 1.75rem;
margin: 0 0 2px;
text-transform: capitalize;
}

.example__title::before { content: "# "; }
Expand All @@ -85,19 +105,55 @@ const description = `A zo ${crumb} example: ${example.title}.`;
align-items: start;
}

.example__code pre {
margin: 0;
padding: 16px;
overflow-x: auto;
/* Left column: code stacked over its result (output / image). */
.example__main {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}

/* Code and output share one labeled dark-panel look: a small
uppercase header (`program` / `output`) over the content. */
.example__code,
.example__output {
background: #000;
border-radius: 8px;
overflow: hidden;
}

.example__code-label,
.example__output-label {
display: block;
padding: 6px 12px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.5;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.example__code pre,
.example__output-pre {
margin: 0;
padding: 12px;
overflow-x: auto;
background: transparent;
white-space: pre;
}

/* Preview image for visual examples. */
.example__image {
display: block;
width: 100%;
height: auto;
}

/* Mobile / narrow: stack — explanation on top, code below — so code
is never squeezed into an unreadable column. */
/* Mobile / narrow: stack — explanation on top, then code + result —
so code is never squeezed into an unreadable column. */
@media (max-width: 1023px) {
.example__doc { order: 1; }
.example__code { order: 2; }
.example__main { order: 2; }
}

/* Desktop: side-by-side, code left, explanation right. */
Expand Down
Loading
Loading