Skip to main content

ZIO Blocks Homepage Implementation Plan

Superseded in part: hosting, framework and deployment (Astro in landing/, Netlify Git builds) were replaced by the unified Docusaurus site; see 2026-10-07-docusaurus-migration-design.md. Audience, page structure, brand and copy rules still apply.

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a standalone, evaluator-first landing page for ZIO Blocks in landing/ (Astro, deployed on Netlify), whose copy, code, and block catalog are generated from docs/index.md.

Architecture: A Node build step parses docs/index.md into landing/src/data/site.json (tagline, principles, hero snippet, 11-category block catalog, four deep dives, guides, compatibility, latest release version) and copies brand SVGs from assets/logo/. Astro renders six static sections from that JSON. Four tiny client scripts (tabs, catalog filter, copy, and shared by pure helper modules) enhance HTML that is already complete without JavaScript.

Tech Stack: Astro (static output, built-in Shiki via astro:components <Code>), Node 22+ node:test for tests (no test dependencies), self-hosted Manrope variable font, npx @lhci/cli in CI only (not a repo dependency).

Spec: docs/superpowers/specs/2026-10-06-homepage-design.md

Global Constraints​

  • Brand: every shape is square and flat. No gradient(, box-shadow, text-shadow, border-radius, rotate(, or drop-shadow anywhere in landing/src (enforced by a test). No single block recoloured.
  • Palette (exact hex): Ultramarine #2D3F8F, Ink #141A2E, Lifted blue #4B5FC4, Paper ink #F3F5FC, white #FFFFFF, hairline #E4E7F0, muted #7C859E.
  • Typeface: Manrope, self-hosted (SIL OFL); ExtraBold (800) headings, Regular body.
  • The wordmark is the logo image only. Never set the text "ZIO Blocks" next to the logo. The logo <img> has alt="ZIO Blocks".
  • All copy and code come from docs/index.md. No stars, testimonials, logo walls, benchmark numbers, Discord link, or hero illustration.
  • Page must be fully readable with JavaScript disabled; all animation disabled under prefers-reduced-motion.
  • WCAG AA contrast in light and dark. Muted #7C859E is allowed for rules and on ink surfaces only; on light surfaces text uses #5B6580 or darker.
  • Lighthouse 95+ in all four categories, responsive from 360 px.
  • Do not modify docs/ content, website/, or README.md. .github/workflows/ci.yml is generated: never hand-edit it; it changes only through sbt ciGenerateGithubWorkflow after editing project/CiWorkflow.scala.
  • Tests use node:test + node:assert/strict; assert full values with deepEqual / equal, never .includes() on rendered output (use extraction helpers, then equal).
  • New files are plain ESM .mjs / .js with no TypeScript, so tests run with no tooling.
  • Commit after each task, conventional-commit style (feat:, test:, chore:, ci:, docs:).

Review Focus​

Failure modes the spec implies but no obvious test covers, most likely first. Each has a test in the owning task.

  1. Platform/Scala cell edge cases (3.x only, JVM only, JVM · JS): parsed into exact arrays, unknown values fail the build with the row named. (Task 3)
  2. A row appended to docs/index.md later (new block, new category, new platform): appears with the right URL, or fails loudly naming the row; never silently dropped. (Task 3)
  3. GitHub API down, rate-limited, or returning junk/pre-release tags: build succeeds with the pinned fallback version. (Task 4)
  4. Visitor without JavaScript: every category, every deep-dive panel, and every tile is in the static HTML and not hidden; copy buttons and tablists do not render useful-looking dead controls. (Tasks 7, 8, 9)
  5. 360 px phone: install line and long artifact names (zio-blocks-schema-messagepack, zio-blocks-data-migration) scroll or wrap inside their box, never the page. Descriptions containing <, &, or backticks render escaped. (Tasks 2, 6, 8, 10)

Deviations from the spec (resolved in this plan)​

  • "Duplicate artifact" build failure is dropped. Six docs/index.md rows legitimately share zio-blocks-scope and three config rows share one page. The build fails on a duplicate block name instead.
  • Scope tab "shows the compile-time escape error". docs/index.md contains no compiler output and none is invented. The tab shows the existing solution code, whose comment already marks the line that would not compile.
  • Hero headline is the tagline in docs/index.md ("Modular building blocks for modern Scala applications—no effect system required."), keeping "all copy from docs/index.md". The social-card tagline "Type-safe, modular building blocks for Scala" becomes the <title> and description.
  • Hero animation is CSS-only, so there is no assemble script.
  • Data file is site.json, not blocks.json, because it holds more than blocks.
  • Compatibility names come from the ## Compatibility table in docs/index.md (includes versions, e.g. "ZIO 2.x").

File Structure​

landing/
package.json scripts, astro dependency, engines
astro.config.mjs static output, site URL
netlify.toml build, publish, ignore, cache headers
lighthouserc.json 95+ assertions for CI
README.md how it works, how to run
.gitignore node_modules, dist, .astro, generated data and brand copies
scripts/
lib/markdown.mjs fence-aware section/fence/table/bullet primitives
lib/parse-index.mjs docs/index.md -> structured site data
lib/version.mjs latest release lookup with fallback
build-catalog.mjs orchestrates: parse + version + write site.json + copy brand assets
check-links.mjs external link checker for CI
src/
data/site.json GENERATED, gitignored
lib/inline.mjs tiny inline-markdown renderer (escape, code, bold, em, links)
lib/filter.mjs pure catalog filter predicate
scripts/tabs.js catalog.js copy.js client enhancement
styles/global.css tokens, base, shared primitives
layouts/Base.astro head, meta, skip link, js class
components/ ModuleField, Hero, Principles, DeepDives, Catalog, Switching, Footer (.astro)
pages/index.astro six sections in order
public/fonts/ Manrope variable woff2 + OFL
public/brand/ GENERATED copies from assets/logo, gitignored
test/unit/*.test.mjs parser, markdown, inline, version, filter, links, brand rules
test/dist.test.mjs static-output assertions (needs a build)
project/CiWorkflow.scala MODIFIED: new `landing` job (zio-sbt-ci); build.sbt adds it to ciBuildJobs
.github/workflows/ci.yml REGENERATED by `sbt ciGenerateGithubWorkflow` (never by hand)
.github/workflows/landing.yml FALLBACK ONLY, if zio-sbt-ci cannot express the job

Interfaces (shared by all tasks)​

site.json shape, produced by Task 4 and consumed by Tasks 6-9:

{
version: '0.0.56',
tagline: 'Modular building blocks for modern Scala applications—no effect system required.',
lead: string, // 2nd paragraph of "What Is ZIO Blocks?"
principles: [{ name: 'Zero Lock-In', text: string }],
hero: { install: 'libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.56"',
jsonCode: 'val jsonStr = alice.toJsonString', jsonResult: '{"name":"Alice","age":30}' },
compatibility: ['ZIO 2.x', 'Cats Effect 3.x', 'Kyo', 'Ox', 'Akka', 'Plain Scala'],
blockCount: number,
categories: [{ name, note: string|null, blocks: [{ name, docsUrl, artifact,
platforms: ['JVM','JS'], scala: ['2.13','3.x'], description }] }],
deepDives: [{ id: 'schema'|'scope'|'async'|'sql', title, intro,
problem: { paragraphs: string[], code: {lang, source}|null },
solution: { paragraphs: string[], code: {lang, source} },
learnMore: [{ title, url, description }] }],
guides: [{ title, url, description }]
}

scripts/lib/markdown.mjs exports: CatalogError, splitSections(md, level) -> { preamble, sections: [{title, body}] }, fences(body) -> [{info, lang, source}], paragraphs(text) -> string[], proseBeforeFence(body) -> string, parseTable(body) -> { header, rows } | null, bullets(body) -> string[].

scripts/lib/parse-index.mjs exports: docsUrl(link) -> string, firstSentence(text) -> string, parseIndex(md) -> ParsedIndex (site.json minus version, blockCount; hero.install still contains 0.0.56).

scripts/lib/version.mjs exports: FALLBACK_VERSION, resolveVersion({ fetchFn?, token?, warn? }) -> Promise<string>.

src/lib/inline.mjs exports: escapeHtml(s), renderInline(text, linkFor?) -> string.

src/lib/filter.mjs exports: matches(tile, f) -> boolean, with tile = { category, platforms, scala }, f = { category|null, platform|null, scala|null }.


Task 1: Scaffold landing/ with base layout, tokens, font, and head tests​

Files:

  • Create: landing/package.json, landing/astro.config.mjs, landing/.gitignore, landing/src/styles/global.css, landing/src/layouts/Base.astro, landing/src/pages/index.astro, landing/public/fonts/manrope-latin-wght-normal.woff2, landing/public/fonts/OFL.txt, landing/test/dist.test.mjs
  • Test: landing/test/dist.test.mjs

Interfaces:

  • Produces: Base.astro (props: none; slots: default; wraps <html lang="en">, head, skip link, <main id="main"> is provided by the page), global.css tokens (--ink, --bg, --fg, --fg-soft, --label, --rule, --accent, --link, --lifted, --paper-ink, --ink-soft, --ink-rule, --field, --code-bg, --font, --mono, --gutter, --measure, --seam) and utility classes .wrap, .label, .section, .btn, .btn-primary, .btn-ghost, .codecard, .copy, .skip, [hidden].

  • Step 1: Create the project and install Astro

mkdir -p landing && cd landing
cat > package.json <<'EOF'
{
"name": "zio-blocks-landing",
"private": true,
"type": "module",
"version": "0.0.0",
"engines": { "node": ">=22" },
"scripts": {
"catalog": "node scripts/build-catalog.mjs",
"dev": "npm run catalog && astro dev",
"build": "npm run catalog && astro build",
"preview": "astro preview",
"test": "node --test \"test/unit/*.test.mjs\"",
"check": "npm run build && node --test test/dist.test.mjs",
"check:links": "node scripts/check-links.mjs"
}
}
EOF
npm install astro

Expected: package-lock.json created, astro listed under dependencies. Note the installed version (npm ls astro) for the README in Task 10.

  • Step 2: Add .gitignore and Astro config

landing/.gitignore:

node_modules
dist
.astro
src/data/site.json
public/brand

landing/astro.config.mjs:

import { defineConfig } from 'astro/config';

// Netlify exposes the primary site URL as `URL`; fall back to the local dev server.
export default defineConfig({
site: process.env.URL ?? 'http://localhost:4321',
output: 'static',
// Keep template whitespace so the static-output tests extract text deterministically.
compressHTML: false,
devToolbar: { enabled: false },
});
  • Step 3: Download Manrope (variable, latin) and its license
mkdir -p public/fonts
curl -fsSL -o public/fonts/manrope-latin-wght-normal.woff2 \
"https://cdn.jsdelivr.net/fontsource/fonts/manrope:vf@latest/latin-wght-normal.woff2"
curl -fsSL -o public/fonts/OFL.txt \
"https://cdn.jsdelivr.net/npm/@fontsource-variable/manrope/LICENSE"
file public/fonts/manrope-latin-wght-normal.woff2
grep -c "SIL OPEN FONT LICENSE" public/fonts/OFL.txt

Expected: file reports Web Open Font Format (Version 2); grep -c prints 1 or more. If either fails, stop and report; do not substitute another font.

  • Step 4: Write the failing head test

landing/test/dist.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const html = readFileSync(new URL('../dist/index.html', import.meta.url), 'utf8');

const decode = (s) =>
s.replace(/&#34;|&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
/** Text content of an HTML fragment: tags dropped, entities decoded, whitespace collapsed. */
export const text = (fragment) =>
decode(fragment.replace(/<\/?(?:span|code|strong|em|a)\b[^>]*>/g, '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
const meta = (name) => new RegExp(`<meta name="${name}" content="([^"]*)"`).exec(html)?.[1];

test('document head', () => {
assert.match(html, /^<!DOCTYPE html>/i);
assert.match(html, /<html lang="en"/);
assert.equal(
text(/<title>([\s\S]*?)<\/title>/.exec(html)[1]),
'ZIO Blocks — Type-safe, modular building blocks for Scala',
);
assert.equal(
decode(meta('description')),
'Type-safe, modular building blocks for Scala. Standalone libraries with zero or minimal dependencies, designed to work with any Scala stack.',
);
assert.equal(meta('color-scheme'), 'light dark');
assert.match(html, /<link rel="icon" type="image\/svg\+xml" href="\/brand\/zio-blocks-mark-favicon\.svg"/);
assert.match(html, /<a class="skip[^"]*" href="#main"/);
});
  • Step 5: Run it and confirm it fails

Run: cd landing && npm test is not wired to this file, so run npm run build 2>&1 | tail -5; node --test test/dist.test.mjs Expected: build fails (scripts/build-catalog.mjs missing) or the test fails with ENOENT dist/index.html. For this task only, temporarily build with npx astro build (skips the catalog step; the page does not import site.json yet) and expect the test to FAIL on the title assertion.

  • Step 6: Write tokens and base styles

landing/src/styles/global.css:

@font-face {
font-family: 'Manrope';
src: url('/fonts/manrope-latin-wght-normal.woff2') format('woff2');
font-weight: 200 800;
font-style: normal;
font-display: swap;
}

:root {
color-scheme: light dark;

/* Brand palette (assets/logo/README.md) */
--ultramarine: #2d3f8f;
--ink: #141a2e;
--lifted: #4b5fc4;
--paper-ink: #f3f5fc;
--hairline: #e4e7f0;
--muted: #7c859e;

/* Ink-surface helpers */
--field: #1c2547;
--code-bg: #0d1224;
--ink-rule: #232b4a;
--ink-soft: #a9b1ce;

/* Semantic tokens, light */
--bg: #ffffff;
--fg: var(--ink);
--fg-soft: #4a5470;
--label: #5b6580;
--rule: var(--hairline);
--accent: var(--ultramarine);
--link: var(--ultramarine);
--surface: var(--paper-ink);

--font: 'Manrope', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
--gutter: clamp(1rem, 4vw, 2.5rem);
--measure: 72rem;
--seam: 6px;
}

@media (prefers-color-scheme: dark) {
:root {
--bg: var(--ink);
--fg: var(--paper-ink);
--fg-soft: var(--ink-soft);
--label: #9aa3c0;
--rule: var(--ink-rule);
--accent: var(--lifted);
--link: var(--paper-ink);
--surface: #1a2140;
}
}

*,
*::before,
*::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 1rem;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
img, svg { display: block; max-width: 100%; }
a { color: var(--link); text-underline-offset: 0.2em; }
code { font-family: var(--mono); font-size: 0.9em; }
:focus-visible { outline: 2px solid var(--lifted); outline-offset: 2px; }
[hidden] { display: none !important; }

.wrap { max-width: var(--measure); margin-inline: auto; padding-inline: var(--gutter); }
.label {
margin: 0 0 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--label);
}
.section { border-top: 1px solid var(--rule); padding-block: clamp(3rem, 7vw, 5.5rem); }
.section h2 {
margin: 0 0 1rem;
font-size: clamp(1.5rem, 3vw, 2.25rem);
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.01em;
text-wrap: balance;
}

.skip { position: absolute; left: -999px; top: 0; z-index: 10; }
.skip:focus { left: 1rem; top: 1rem; padding: 0.5rem 1rem; background: #fff; color: var(--ink); }

.btn {
display: inline-block;
padding: 0.7rem 1.1rem;
border: 2px solid transparent;
font-weight: 700;
font-size: 0.9375rem;
text-decoration: none;
}
.btn-primary { background: var(--lifted); color: #fff; }
.btn-primary:hover { background: #fff; color: var(--ink); }
.btn-ghost { border-color: var(--lifted); color: var(--paper-ink); }
.btn-ghost:hover { border-color: #fff; }

/* Code surfaces: always ink, in both themes */
.codecard {
position: relative;
background: var(--code-bg);
color: var(--paper-ink);
border: 1px solid var(--ink-rule);
}
.codecard pre {
margin: 0;
padding: 1rem 1.25rem;
overflow-x: auto;
font-family: var(--mono);
font-size: 0.8125rem;
line-height: 1.65;
}
.astro-code { background-color: transparent !important; }

/* Copy buttons exist only when JavaScript can make them work */
.copy {
display: none;
padding: 0.2rem 0.55rem;
border: 1px solid var(--lifted);
background: transparent;
color: var(--paper-ink);
font: 600 0.6875rem/1.4 var(--font);
letter-spacing: 0.04em;
cursor: pointer;
}
.js .copy { display: inline-block; }
.copy:hover { background: var(--lifted); }

@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
  • Step 7: Write the base layout and a minimal page

landing/src/layouts/Base.astro:

---
import '../styles/global.css';

const title = 'ZIO Blocks — Type-safe, modular building blocks for Scala';
const description =
'Type-safe, modular building blocks for Scala. Standalone libraries with zero or minimal dependencies, designed to work with any Scala stack.';
const canonical = new URL(Astro.url.pathname, Astro.site).href;
const ogImage = new URL('/brand/zio-blocks-social-og.png', Astro.site).href;
---

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#141a2e" />
<link rel="canonical" href={canonical} />
<link rel="icon" type="image/svg+xml" href="/brand/zio-blocks-mark-favicon.svg" />
<link rel="preload" href="/fonts/manrope-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<script is:inline>document.documentElement.classList.add('js');</script>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<slot />
</body>
</html>

landing/src/pages/index.astro:

---
import Base from '../layouts/Base.astro';
---

<Base>
<main id="main"></main>
</Base>
  • Step 8: Copy brand assets by hand for this task only

The catalog step (Task 4) will do this automatically; until then:

mkdir -p public/brand
cp ../assets/logo/zio-blocks-mark-favicon.svg ../assets/logo/zio-blocks-social-og.png public/brand/
npx astro build && node --test test/dist.test.mjs

Expected: PASS (1 test).

  • Step 9: Commit
cd .. && git add landing && git commit -m "feat(landing): scaffold Astro site with base layout, tokens, and Manrope"

Task 2: Markdown primitives and inline renderer​

Files:

  • Create: landing/scripts/lib/markdown.mjs, landing/src/lib/inline.mjs
  • Test: landing/test/unit/markdown.test.mjs, landing/test/unit/inline.test.mjs

Interfaces:

  • Produces: everything listed under "Interfaces" for markdown.mjs and inline.mjs.

  • Step 1: Write the failing markdown tests

landing/test/unit/markdown.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CatalogError, splitSections, fences, paragraphs, proseBeforeFence, parseTable, bullets,
} from '../../scripts/lib/markdown.mjs';

const DOC = [
'---', 'id: x', '---', '', '**Tagline**', '',
'## One', 'intro', '', '### Sub A', 'a body', '```scala', '## not a heading', '```',
'### Sub B', 'b body', '',
'## Two', 'two body',
].join('\n');

test('splitSections splits on exact level and ignores headings inside fences', () => {
const top = splitSections(DOC, 2);
assert.equal(top.preamble, '---\nid: x\n---\n\n**Tagline**\n');
assert.deepEqual(top.sections.map((s) => s.title), ['One', 'Two']);
const sub = splitSections(top.sections[0].body, 3);
assert.equal(sub.preamble, 'intro\n');
assert.deepEqual(sub.sections, [
{ title: 'Sub A', body: 'a body\n```scala\n## not a heading\n```' },
{ title: 'Sub B', body: 'b body\n' },
]);
});

test('splitSections ends a section at a higher-level heading', () => {
const doc = '### Orphan\nx\n## Top\ny\n### Child\nz';
const sub = splitSections(doc, 3);
assert.deepEqual(sub.sections.map((s) => s.title), ['Orphan', 'Child']);
assert.equal(sub.sections[0].body, 'x');
});

test('fences returns lang without mdoc modifiers, source verbatim', () => {
const body = 'text\n```scala mdoc:compile-only\nval a = 1\n val b = 2\n```\n````\nnested ``` ok\n````\n';
assert.deepEqual(fences(body), [
{ info: 'scala mdoc:compile-only', lang: 'scala', source: 'val a = 1\n val b = 2' },
{ info: '', lang: 'text', source: 'nested ``` ok' },
]);
});

test('paragraphs joins wrapped lines and drops empties', () => {
assert.deepEqual(paragraphs('one\ntwo\n\n\nthree\n---\n'), ['one two', 'three']);
});

test('proseBeforeFence stops at the first fence', () => {
assert.equal(proseBeforeFence('before\n```x\ncode\n```\nafter'), 'before');
assert.equal(proseBeforeFence('only prose'), 'only prose');
});

test('parseTable returns header and rows, null when there is no table', () => {
const body = 'note\n\n| A | B |\n|---|:-:|\n| 1 | two words |\n| 3 | 4 |\n\n---\n';
assert.deepEqual(parseTable(body), { header: ['A', 'B'], rows: [['1', 'two words'], ['3', '4']] });
assert.equal(parseTable('no table here'), null);
});

test('parseTable rejects a row with the wrong number of cells', () => {
assert.throws(
() => parseTable('| A | B |\n|---|---|\n| only one |'),
(e) => e instanceof CatalogError && e.message === 'table row "| only one |" has 1 cells, expected 2',
);
});

test('bullets returns only top-level dash items', () => {
assert.deepEqual(bullets('intro\n- one\n- two\n - nested\nend'), ['one', 'two']);
});
  • Step 2: Run to verify failure

Run: cd landing && node --test test/unit/markdown.test.mjs Expected: FAIL, Cannot find module '.../markdown.mjs'.

  • Step 3: Implement markdown.mjs
/** Raised for any problem with the shape of docs/index.md; the message names the offender. */
export class CatalogError extends Error {
constructor(message) {
super(message);
this.name = 'CatalogError';
}
}

/** Marks each line as inside or outside a fenced code block. The fence lines themselves count as fenced. */
function annotate(md) {
let open = null;
return md.split('\n').map((text) => {
if (open === null) {
const m = /^(`{3,})/.exec(text);
if (m) {
open = m[1];
return { text, fenced: true };
}
return { text, fenced: false };
}
if (text.startsWith(open) && text.slice(open.length).trim() === '') open = null;
return { text, fenced: true };
});
}

/**
* Splits `md` on headings of exactly `level` hashes. Deeper headings stay in the section body; a
* shallower heading ends the current section (its own content is not returned).
*/
export function splitSections(md, level) {
const marker = '#'.repeat(level) + ' ';
const raw = [{ title: null, lines: [] }];
for (const { text, fenced } of annotate(md)) {
const hashes = fenced ? null : /^(#{1,6}) /.exec(text);
if (hashes && hashes[1].length === level) {
raw.push({ title: text.slice(marker.length).trim(), lines: [] });
} else if (hashes && hashes[1].length < level) {
raw.push({ title: null, lines: [] });
} else {
raw[raw.length - 1].lines.push(text);
}
}
return {
preamble: raw[0].lines.join('\n'),
sections: raw.slice(1).filter((s) => s.title !== null).map((s) => ({ title: s.title, body: s.lines.join('\n') })),
};
}

/** Fenced code blocks in order. `lang` is the first word of the info string (mdoc modifiers dropped). */
export function fences(body) {
const out = [];
let open = null;
for (const line of body.split('\n')) {
if (open === null) {
const m = /^(`{3,})\s*(.*)$/.exec(line);
if (m) open = { ticks: m[1], info: m[2].trim(), lines: [] };
} else if (line.startsWith(open.ticks) && line.slice(open.ticks.length).trim() === '') {
out.push({ info: open.info, lang: open.info.split(/\s+/)[0] || 'text', source: open.lines.join('\n') });
open = null;
} else {
open.lines.push(line);
}
}
return out;
}

/** Blank-line separated paragraphs with wrapped lines joined by a space; horizontal rules dropped. */
export function paragraphs(text) {
return text
.split(/\n\s*\n/)
.map((p) => p.split('\n').map((l) => l.trim()).filter((l) => l !== '' && !/^-{3,}$/.test(l)).join(' '))
.filter((p) => p !== '');
}

/** Everything before the first fenced block, trimmed. */
export function proseBeforeFence(body) {
const lines = [];
for (const line of body.split('\n')) {
if (/^`{3,}/.test(line)) break;
lines.push(line);
}
return lines.join('\n').trim();
}

/** The first pipe table in `body`, or null. Rows must match the header's cell count. */
export function parseTable(body) {
const rows = body.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('|'));
if (rows.length < 2) return null;
const cells = (line) => line.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
const header = cells(rows[0]);
if (!cells(rows[1]).every((c) => /^:?-+:?$/.test(c))) {
throw new CatalogError(`table after header "${rows[0]}" has no separator row`);
}
const parsed = rows.slice(2).map((line) => {
const row = cells(line);
if (row.length !== header.length) {
throw new CatalogError(`table row "${line}" has ${row.length} cells, expected ${header.length}`);
}
return row;
});
return { header, rows: parsed };
}

/** Top-level `- ` list items (nested items are ignored). */
export function bullets(body) {
return body.split('\n').filter((l) => l.startsWith('- ')).map((l) => l.slice(2).trim());
}
  • Step 4: Run to verify pass

Run: node --test test/unit/markdown.test.mjs Expected: PASS (8 tests).

  • Step 5: Write the failing inline tests

landing/test/unit/inline.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { escapeHtml, renderInline } from '../../src/lib/inline.mjs';

test('escapeHtml escapes the five significant characters', () => {
assert.equal(escapeHtml(`<a href="x">&'</a>`), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;');
});

test('renderInline handles code, bold, emphasis and links', () => {
assert.equal(
renderInline('Use `Scope.defer` for **safe** and *quick* cleanup, see [docs](https://x.dev/a).'),
'Use <code>Scope.defer</code> for <strong>safe</strong> and <em>quick</em> cleanup, see <a href="https://x.dev/a">docs</a>.',
);
});

test('renderInline escapes raw HTML and leaves code spans literal', () => {
assert.equal(
renderInline('a <b>bold</b> & `List<Int> **not bold**`'),
'a &lt;b&gt;bold&lt;/b&gt; &amp; <code>List&lt;Int&gt; **not bold**</code>',
);
});

test('renderInline maps link targets through linkFor', () => {
assert.equal(
renderInline('[ref](./reference/a.md)', (u) => `https://zio.dev/${u.slice(2)}`),
'<a href="https://zio.dev/reference/a.md">ref</a>',
);
});
  • Step 6: Run to verify failure, then implement

Run: node --test test/unit/inline.test.mjs. Expected: FAIL (module not found).

landing/src/lib/inline.mjs:

const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };

export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ESCAPES[c]);

/**
* Renders the small markdown subset used in docs/index.md prose: `code`, **bold**, *emphasis*,
* [links](url). Everything else is escaped. Code spans are protected from the other rules.
*/
export function renderInline(text, linkFor = (url) => url) {
const codes = [];
const withSlots = text.replace(/`([^`]+)`/g, (_, code) => {
codes.push(`<code>${escapeHtml(code)}</code>`);
return `\u0000${codes.length - 1}\u0000`;
});
return escapeHtml(withSlots)
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${linkFor(url)}">${label}</a>`)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[Number(i)]);
}

Run: node --test test/unit/inline.test.mjs. Expected: PASS (4 tests).

  • Step 7: Commit
cd .. && git add landing && git commit -m "feat(landing): add fence-aware markdown primitives and inline renderer"

Task 3: docs/index.md parser​

Files:

  • Create: landing/scripts/lib/parse-index.mjs
  • Test: landing/test/unit/parse-index.test.mjs

Interfaces:

  • Consumes: markdown.mjs exports from Task 2.

  • Produces: docsUrl, firstSentence, parseIndex as specified under "Interfaces".

  • Step 1: Write the failing tests

landing/test/unit/parse-index.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { CatalogError } from '../../scripts/lib/markdown.mjs';
import { docsUrl, firstSentence, parseIndex } from '../../scripts/lib/parse-index.mjs';

const dive = (title, problemCode, extra = '') => `## ${title}

${title} intro sentence. More words.

### The Problem

${title} problem prose:

${problemCode}

### The Solution

${title} solution prose.

\`\`\`scala mdoc
val ${title.toLowerCase()} = 1 // comment
\`\`\`

### Learn More

- [${title} reference](./reference/${title.toLowerCase()}.md) — the full API
- [\`${title.toLowerCase()}-examples\`](https://github.com/zio/zio-blocks/blob/main/x.scala) — a demo ${extra}

---
`;

const GOOD = `---
id: index
title: "ZIO Blocks"
---

**Modular blocks—no effect system required.**

[![Development](https://img.shields.io/badge/Project%20Stage-Development-green.svg)](https://github.com/zio/zio/wiki/Project-Stages) ![CI Badge](https://github.com/zio/zio-blocks/workflows/CI/badge.svg) [![Maven Central](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Frepo1.maven.org%2Fmaven2%2Fdev%2Fzio%2Fzio-blocks-config_3%2Fmaven-metadata.xml&label=Maven%20Central)](https://central.sonatype.com/artifact/dev.zio/zio-blocks-config_3) [![Sonatype Snapshot](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Fcentral.sonatype.com%2Frepository%2Fmaven-snapshots%2Fdev%2Fzio%2Fzio-blocks-config_3%2Fmaven-metadata.xml&label=Sonatype%20Snapshot)](https://central.sonatype.com/repository/maven-snapshots/dev/zio/zio-blocks-config_3/) [![ZIO Blocks](https://img.shields.io/github/stars/zio/zio-blocks?style=social)](https://github.com/zio/zio-blocks)

## What Is ZIO Blocks?

First paragraph.

The philosophy is simple. Use what you need.

## Core Principles

- **Zero Lock-In**: No dependency on any effect system. Use a block with your stack.
- **Modular**: Each block is a separate artifact.

## Getting Started

\`\`\`scala
libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.56"
\`\`\`

\`\`\`scala mdoc:compile-only
val alice = Person("Alice", 30)
val jsonStr = alice.toJsonString // {"name":"Alice","age":30}
\`\`\`

## All Blocks

### Meta Programming

JSON is built in.

| Block | Artifact | Platform | Scala | Description |
|-------|----------|----------|-------|-------------|
| [Schema](./reference/schema/index.md) | \`zio-blocks-schema\` | JVM · JS | 2.13 · 3.x | Type-safe schemas with \`derive\` & <codecs> |
| [Avro Codec](./reference/schema/built-in-codecs/avro.md) | \`zio-blocks-schema-avro\` | JVM | 2.13 · 3.x | Avro binary |

### Web & HTTP

| Block | Artifact | Platform | Scala | Description |
|-------|----------|----------|-------|-------------|
| [Mux](./reference/mux.mdx) | \`zio-blocks-mux\` | JVM · JS | 3.x | Multiplexer |

---

${dive('Schema', '```javascript\nconst data = await res.json();\n```')}
${dive('Scope', '```scala\nval db = open()\n```')}
${dive('Async', '')}
${dive('SQL', '')}
## Compatibility

| Stack | Compatible |
|-------|------------|
| ZIO 2.x | ✅ |
| Kyo | ✅ |
| Nope | ❌ |

## Guides

- [Getting Started with Async](./guides/async-getting-started.md) - Create and compose
- [Migrating from ZIO Schema](./guides/zio-schema-migration.md) - Step-by-step port
`;

test('docsUrl maps repo-relative links to zio.dev URLs', () => {
assert.equal(docsUrl('./reference/schema/index.md'), 'https://zio.dev/zio-blocks/reference/schema/');
assert.equal(docsUrl('./reference/mux.mdx'), 'https://zio.dev/zio-blocks/reference/mux');
assert.equal(docsUrl('./reference/ringbuffer/index.mdx'), 'https://zio.dev/zio-blocks/reference/ringbuffer/');
assert.equal(docsUrl('./guides/zio-schema-migration.md'), 'https://zio.dev/zio-blocks/guides/zio-schema-migration');
assert.equal(docsUrl('./reference/schema/built-in-codecs/avro.md'), 'https://zio.dev/zio-blocks/reference/schema/built-in-codecs/avro');
});

test('docsUrl rejects anything that is not a ./ markdown path', () => {
for (const bad of ['https://example.com/a.md', '../a.md', './a.txt', 'reference/a.md']) {
assert.throws(() => docsUrl(bad), (e) => e instanceof CatalogError && e.message === `unmapped docs link "${bad}"`);
}
});

test('firstSentence keeps version numbers and file names intact', () => {
assert.equal(
firstSentence('Most blocks build for Scala.js and Scala 2.13 and 3.x. The catalog records exceptions.'),
'Most blocks build for Scala.js and Scala 2.13 and 3.x.',
);
assert.equal(firstSentence('No terminator'), 'No terminator');
});

test('parseIndex extracts the header content', () => {
const p = parseIndex(GOOD);
assert.equal(p.tagline, 'Modular blocks—no effect system required.');
assert.equal(p.lead, 'The philosophy is simple. Use what you need.');
assert.deepEqual(p.principles, [
{ name: 'Zero Lock-In', text: 'No dependency on any effect system.' },
{ name: 'Modular', text: 'Each block is a separate artifact.' },
]);
assert.deepEqual(p.hero, {
install: 'libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.56"',
jsonCode: 'val jsonStr = alice.toJsonString',
jsonResult: '{"name":"Alice","age":30}',
});
assert.deepEqual(p.compatibility, ['ZIO 2.x', 'Kyo']);
});

test('parseIndex extracts the catalog with exact platform and Scala arrays', () => {
assert.deepEqual(parseIndex(GOOD).categories, [
{
name: 'Meta Programming',
note: 'JSON is built in.',
blocks: [
{
name: 'Schema', docsUrl: 'https://zio.dev/zio-blocks/reference/schema/', artifact: 'zio-blocks-schema',
platforms: ['JVM', 'JS'], scala: ['2.13', '3.x'], description: 'Type-safe schemas with `derive` & <codecs>',
},
{
name: 'Avro Codec', docsUrl: 'https://zio.dev/zio-blocks/reference/schema/built-in-codecs/avro',
artifact: 'zio-blocks-schema-avro', platforms: ['JVM'], scala: ['2.13', '3.x'], description: 'Avro binary',
},
],
},
{
name: 'Web & HTTP',
note: null,
blocks: [
{
name: 'Mux', docsUrl: 'https://zio.dev/zio-blocks/reference/mux', artifact: 'zio-blocks-mux',
platforms: ['JVM', 'JS'], scala: ['3.x'], description: 'Multiplexer',
},
],
},
]);
});

test('parseIndex extracts the four deep dives', () => {
const dives = parseIndex(GOOD).deepDives;
assert.deepEqual(dives.map((d) => d.id), ['schema', 'scope', 'async', 'sql']);
assert.deepEqual(dives[0], {
id: 'schema', title: 'Schema', intro: 'Schema intro sentence. More words.',
problem: { paragraphs: ['Schema problem prose:'], code: { info: 'javascript', lang: 'javascript', source: 'const data = await res.json();' } },
solution: { paragraphs: ['Schema solution prose.'], code: { info: 'scala mdoc', lang: 'scala', source: 'val schema = 1 // comment' } },
learnMore: [
{ title: 'Schema reference', url: 'https://zio.dev/zio-blocks/reference/schema', description: 'the full API' },
{ title: '`schema-examples`', url: 'https://github.com/zio/zio-blocks/blob/main/x.scala', description: 'a demo' },
],
});
assert.equal(dives[2].problem.code, null);
});

test('parseIndex extracts guides', () => {
assert.deepEqual(parseIndex(GOOD).guides, [
{ title: 'Getting Started with Async', url: 'https://zio.dev/zio-blocks/guides/async-getting-started', description: 'Create and compose' },
{ title: 'Migrating from ZIO Schema', url: 'https://zio.dev/zio-blocks/guides/zio-schema-migration', description: 'Step-by-step port' },
]);
});

const failsWith = (md, message) =>
assert.throws(() => parseIndex(md), (e) => e instanceof CatalogError && e.message === message);

test('parseIndex fails loudly, naming the offender', () => {
failsWith(GOOD.replace('| JVM · JS | 2.13 · 3.x | Type-safe', '| JVM · Native | 2.13 · 3.x | Type-safe'),
'Schema: unknown platform "Native"');
failsWith(GOOD.replace('| JVM | 2.13 · 3.x | Avro binary |', '| JVM | 2.13 · 2.12 | Avro binary |'),
'Avro Codec: unknown Scala version "2.12"');
failsWith(GOOD.replace('| `zio-blocks-schema-avro` |', '| zio-blocks-schema-avro |'),
'Avro Codec: artifact cell "zio-blocks-schema-avro" is not a `code` span');
failsWith(GOOD.replace('[Avro Codec]', '[Schema]'), 'block "Schema" is listed twice');
failsWith(GOOD.replace('(./reference/mux.mdx)', '(https://example.com/mux)'), 'unmapped docs link "https://example.com/mux"');
failsWith(GOOD.replace('| Block | Artifact | Platform | Scala | Description |\n|-------|----------|----------|-------|-------------|\n| [Mux]',
'| Block | Artifact | Platform | Scala |\n|-------|----------|----------|-------|\n| [Mux]').replace('| JVM · JS | 3.x | Multiplexer |', '| JVM · JS | 3.x |'),
'category "Web & HTTP": expected columns Block | Artifact | Platform | Scala | Description');
failsWith(GOOD.replace('## Core Principles', '## Core Principle'), 'docs/index.md has no "## Core Principles" section');
failsWith(GOOD.replace('toJsonString', 'toJson'), 'Getting Started: no code line containing toJsonString with a // result comment');
failsWith(GOOD.replace('### The Solution\n\nSchema solution prose.\n\n```scala mdoc\nval schema = 1 // comment\n```', '### The Solution\n\nSchema solution prose.'),
'Schema: "The Solution" has no code block');
});

test('parseIndex accepts the real docs/index.md', () => {
const real = parseIndex(readFileSync(new URL('../../../docs/index.md', import.meta.url), 'utf8'));
assert.deepEqual(real.deepDives.map((d) => d.id), ['schema', 'scope', 'async', 'sql']);
assert.ok(real.categories.length > 0);
for (const b of real.categories.flatMap((c) => c.blocks)) {
assert.match(b.artifact, /^zio-blocks-[a-z0-9-]+$/);
assert.match(b.docsUrl, /^https:\/\/zio\.dev\/zio-blocks\//);
assert.ok(b.platforms.length > 0 && b.scala.length > 0 && b.description !== '');
}
assert.ok(real.hero.install.includes('0.0.56'));
assert.equal(real.hero.jsonResult, '{"name":"Alice","age":30}');
});
  • Step 2: Run to verify failure

Run: cd landing && node --test test/unit/parse-index.test.mjs Expected: FAIL, module not found.

  • Step 3: Implement parse-index.mjs
import {
CatalogError, splitSections, fences, paragraphs, proseBeforeFence, parseTable, bullets,
} from './markdown.mjs';

const DOCS_BASE = 'https://zio.dev/zio-blocks/';
const PLATFORMS = ['JVM', 'JS'];
const SCALA_VERSIONS = ['2.13', '3.x'];
const BLOCK_COLUMNS = ['Block', 'Artifact', 'Platform', 'Scala', 'Description'];
const DIVES = [
['schema', 'Schema'],
['scope', 'Scope'],
['async', 'Async'],
['sql', 'SQL'],
];

/** `./reference/schema/index.md` -> `https://zio.dev/zio-blocks/reference/schema/`. */
export function docsUrl(link) {
if (!/^\.\/[\w\-./]+\.mdx?$/.test(link)) throw new CatalogError(`unmapped docs link "${link}"`);
let path = link.slice(2).replace(/\.mdx?$/, '');
if (path === 'index') path = '';
else if (path.endsWith('/index')) path = path.slice(0, -'index'.length);
return DOCS_BASE + path;
}

/** Text up to the first sentence end (". " followed by a capital); version numbers do not end it. */
export function firstSentence(text) {
const m = /^(.*?[.!?])\s+(?=[A-Z])/.exec(text);
return m ? m[1] : text;
}

const linkTarget = (url) => (/^https?:\/\//.test(url) ? url : docsUrl(url));

function section(sections, title, where = 'docs/index.md') {
const found = sections.find((s) => s.title === title);
if (!found) throw new CatalogError(`${where} has no "## ${title}" section`);
return found.body;
}

function parseList(cell, allowed, what, name) {
const items = cell.split('·').map((s) => s.trim()).filter(Boolean);
if (items.length === 0) throw new CatalogError(`${name}: empty ${what} cell`);
for (const item of items) {
if (!allowed.includes(item)) throw new CatalogError(`${name}: unknown ${what} "${item}"`);
}
return items;
}

function parseBlock(cells) {
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(cells[0]);
if (!link) throw new CatalogError(`block cell "${cells[0]}" is not a [name](link)`);
const name = link[1];
const artifact = /^`([a-z0-9-]+)`$/.exec(cells[1]);
if (!artifact) throw new CatalogError(`${name}: artifact cell "${cells[1]}" is not a \`code\` span`);
return {
name,
docsUrl: docsUrl(link[2]),
artifact: artifact[1],
platforms: parseList(cells[2], PLATFORMS, 'platform', name),
scala: parseList(cells[3], SCALA_VERSIONS, 'Scala version', name),
description: cells[4],
};
}

function parseCategories(body) {
const seen = new Set();
const categories = [];
for (const { title, body: catBody } of splitSections(body, 3).sections) {
const table = parseTable(catBody);
if (!table) continue;
if (table.header.join(' | ') !== BLOCK_COLUMNS.join(' | ')) {
throw new CatalogError(`category "${title}": expected columns ${BLOCK_COLUMNS.join(' | ')}`);
}
const note = paragraphs(catBody.split('\n').filter((l) => !l.trim().startsWith('|')).join('\n'));
const blocks = table.rows.map((cells) => {
const block = parseBlock(cells);
if (seen.has(block.name)) throw new CatalogError(`block "${block.name}" is listed twice`);
seen.add(block.name);
return block;
});
categories.push({ name: title, note: note.length ? note.join(' ') : null, blocks });
}
if (categories.length === 0) throw new CatalogError('"## All Blocks" contains no category tables');
return categories;
}

function parseHero(body) {
const blocks = fences(body);
const install = blocks.find((b) => b.source.includes('libraryDependencies'));
if (!install) throw new CatalogError('Getting Started: no libraryDependencies code block');
const line = blocks.flatMap((b) => b.source.split('\n')).find((l) => l.includes('toJsonString') && l.includes('//'));
const m = line && /^(.*?)\s*\/\/\s*(.+)$/.exec(line);
if (!m) throw new CatalogError('Getting Started: no code line containing toJsonString with a // result comment');
return { install: install.source.trim(), jsonCode: m[1].trim(), jsonResult: m[2].trim() };
}

function parseDive([id, title], sections) {
const body = section(sections, title);
const sub = splitSections(body, 3);
const need = (t) => {
const found = sub.sections.find((s) => s.title === t);
if (!found) throw new CatalogError(`${title}: no "### ${t}" section`);
return found.body;
};
const problem = need('The Problem');
const solution = need('The Solution');
const solutionCode = fences(solution)[0];
if (!solutionCode) throw new CatalogError(`${title}: "The Solution" has no code block`);
const intro = paragraphs(sub.preamble)[0];
if (!intro) throw new CatalogError(`${title}: no intro paragraph`);
return {
id,
title,
intro,
problem: { paragraphs: paragraphs(proseBeforeFence(problem)), code: fences(problem)[0] ?? null },
solution: { paragraphs: paragraphs(proseBeforeFence(solution)), code: solutionCode },
learnMore: bullets(need('Learn More')).map((item) => {
const m = /^\[(.+?)\]\((.+?)\) [—-] (.+)$/.exec(item);
if (!m) throw new CatalogError(`${title}: unparsable Learn More item "${item}"`);
return { title: m[1], url: linkTarget(m[2]), description: m[3] };
}),
};
}

/** Parses docs/index.md. `hero.install` still contains the `0.0.56` placeholder. */
export function parseIndex(md) {
const top = splitSections(md, 2);
const tagline = /^\*\*(.+)\*\*$/m.exec(top.preamble)?.[1];
if (!tagline) throw new CatalogError('docs/index.md has no bold tagline line before the first section');

const lead = paragraphs(section(top.sections, 'What Is ZIO Blocks?'))[1];
if (!lead) throw new CatalogError('"What Is ZIO Blocks?" has no second paragraph');

const principles = bullets(section(top.sections, 'Core Principles')).map((item) => {
const m = /^\*\*(.+?)\*\*: (.+)$/.exec(item);
if (!m) throw new CatalogError(`unparsable principle "${item}"`);
return { name: m[1], text: firstSentence(m[2]) };
});

const compat = parseTable(section(top.sections, 'Compatibility'));
const compatibility = (compat?.rows ?? []).filter((r) => r[1].includes('✅')).map((r) => r[0]);

return {
tagline,
lead,
principles,
hero: parseHero(section(top.sections, 'Getting Started')),
compatibility,
categories: parseCategories(section(top.sections, 'All Blocks')),
deepDives: DIVES.map((d) => parseDive(d, top.sections)),
guides: bullets(section(top.sections, 'Guides')).map((item) => {
const m = /^\[(.+?)\]\((.+?)\) - (.+)$/.exec(item);
if (!m) throw new CatalogError(`unparsable guide "${item}"`);
return { title: m[1], url: docsUrl(m[2]), description: m[3] };
}),
};
}
  • Step 4: Run to verify pass

Run: node --test test/unit/parse-index.test.mjs Expected: PASS (all tests, including the real docs/index.md). If the real-file test fails, fix the parser or the regex, not the fixture; show the failing row to the user if docs/index.md itself is malformed.

  • Step 5: Commit
cd .. && git add landing && git commit -m "feat(landing): parse docs/index.md into catalog, deep dives, and guides"

Task 4: Release version resolver and build-catalog step​

Files:

  • Create: landing/scripts/lib/version.mjs, landing/scripts/build-catalog.mjs
  • Test: landing/test/unit/version.test.mjs, landing/test/unit/build-catalog.test.mjs

Interfaces:

  • Consumes: parseIndex (Task 3).

  • Produces: resolveVersion, FALLBACK_VERSION; build({ repoRoot?, outDir?, version? }) which writes landing/src/data/site.json (shape under "Interfaces") and copies brand assets to landing/public/brand/.

  • Step 1: Write the failing version tests

landing/test/unit/version.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FALLBACK_VERSION, resolveVersion } from '../../scripts/lib/version.mjs';

const json = (body, ok = true, status = 200) => async () => ({ ok, status, json: async () => body });
const run = async (fetchFn) => {
const warnings = [];
const version = await resolveVersion({ fetchFn, token: undefined, warn: (m) => warnings.push(m) });
return { version, warnings };
};

test('uses the latest release tag without the leading v', async () => {
assert.deepEqual(await run(json({ tag_name: 'v0.0.56' })), { version: '0.0.56', warnings: [] });
});

test('falls back on HTTP errors, network errors, bad JSON, and unexpected tags', async () => {
const cases = [
[json({}, false, 403), 'HTTP 403'],
[async () => { throw new Error('getaddrinfo ENOTFOUND'); }, 'getaddrinfo ENOTFOUND'],
[async () => ({ ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); } }), 'Unexpected token <'],
[json({ tag_name: 'v0.0.57-RC1' }), 'unexpected tag "v0.0.57-RC1"'],
[json({}), 'unexpected tag "undefined"'],
];
for (const [fetchFn, reason] of cases) {
assert.deepEqual(await run(fetchFn), {
version: FALLBACK_VERSION,
warnings: [`landing: could not resolve latest release (${reason}); using ${FALLBACK_VERSION}`],
});
}
});

test('sends the token when one is provided', async () => {
let headers;
await resolveVersion({
token: 'abc',
warn: () => {},
fetchFn: async (_url, init) => { headers = init.headers; return { ok: true, status: 200, json: async () => ({ tag_name: 'v1.2.3' }) }; },
});
assert.equal(headers.authorization, 'Bearer abc');
});
  • Step 2: Run to verify failure, then implement

Run: cd landing && node --test test/unit/version.test.mjs. Expected: FAIL (module not found).

landing/scripts/lib/version.mjs:

/** Used when the GitHub API cannot be reached; bump occasionally, it only affects the install line. */
export const FALLBACK_VERSION = '0.0.56';

const LATEST = 'https://api.github.com/repos/zio/zio-blocks/releases/latest';

/** Latest published (non-draft, non-prerelease) release of zio-blocks, or FALLBACK_VERSION with a warning. */
export async function resolveVersion({
fetchFn = fetch,
token = process.env.GITHUB_TOKEN,
warn = console.warn,
} = {}) {
try {
const res = await fetchFn(LATEST, {
headers: {
accept: 'application/vnd.github+json',
'user-agent': 'zio-blocks-landing',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const tag = (await res.json()).tag_name;
const version = typeof tag === 'string' ? tag.replace(/^v/, '') : '';
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`unexpected tag "${tag}"`);
return version;
} catch (e) {
warn(`landing: could not resolve latest release (${e.message}); using ${FALLBACK_VERSION}`);
return FALLBACK_VERSION;
}
}

Run: node --test test/unit/version.test.mjs. Expected: PASS (3 tests).

  • Step 3: Write the failing build test

landing/test/unit/build-catalog.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { build, BRAND_ASSETS } from '../../scripts/build-catalog.mjs';

test('build writes site.json with the version substituted and copies brand assets', async () => {
const out = mkdtempSync(join(tmpdir(), 'landing-'));
await build({ outDir: out, version: '9.9.9' });

const site = JSON.parse(readFileSync(join(out, 'src/data/site.json'), 'utf8'));
assert.equal(site.version, '9.9.9');
assert.equal(site.hero.install, 'libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "9.9.9"');
assert.equal(site.blockCount, site.categories.flatMap((c) => c.blocks).length);
assert.ok(site.blockCount >= 40);
assert.equal(JSON.stringify(site).includes('0.0.56'), false);
for (const file of BRAND_ASSETS) assert.ok(existsSync(join(out, 'public/brand', file)), file);
});
  • Step 4: Run to verify failure, then implement

Run: node --test test/unit/build-catalog.test.mjs. Expected: FAIL (module not found).

landing/scripts/build-catalog.mjs:

import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { join } from 'node:path';
import { parseIndex } from './lib/parse-index.mjs';
import { resolveVersion } from './lib/version.mjs';

const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
const LANDING_ROOT = fileURLToPath(new URL('../', import.meta.url));

/** Brand files the site references, copied from assets/logo at build time (never committed twice). */
export const BRAND_ASSETS = [
'zio-blocks-logo-on-dark.svg',
'zio-blocks-logo-mono-white.svg',
'zio-blocks-mark-favicon.svg',
'zio-blocks-social-og.png',
];

export async function build({ repoRoot = REPO_ROOT, outDir = LANDING_ROOT, version } = {}) {
const parsed = parseIndex(await readFile(join(repoRoot, 'docs/index.md'), 'utf8'));
const resolved = version ?? (await resolveVersion());

const site = {
version: resolved,
...parsed,
hero: { ...parsed.hero, install: parsed.hero.install.replaceAll('0.0.56', resolved) },
blockCount: parsed.categories.reduce((n, c) => n + c.blocks.length, 0),
};

await mkdir(join(outDir, 'src/data'), { recursive: true });
await writeFile(join(outDir, 'src/data/site.json'), JSON.stringify(site, null, 2) + '\n');

await mkdir(join(outDir, 'public/brand'), { recursive: true });
for (const file of BRAND_ASSETS) {
await copyFile(join(repoRoot, 'assets/logo', file), join(outDir, 'public/brand', file));
}
return site;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const site = await build();
console.log(`landing: ${site.blockCount} blocks in ${site.categories.length} categories, version ${site.version}`);
} catch (e) {
console.error(`landing: ${e.message}`);
process.exit(1);
}
}

Run: node --test test/unit/build-catalog.test.mjs then npm test. Expected: PASS for all unit tests.

  • Step 5: Run the real build step once

Run: npm run catalog Expected: prints landing: 47 blocks in 11 categories, version 0.0.56 (counts follow the current docs; a different number is fine if the docs changed). src/data/site.json and public/brand/* exist and are untracked by git (git status --short landing shows nothing for them).

  • Step 6: Commit
cd .. && git add landing && git commit -m "feat(landing): generate site.json and brand assets from docs/index.md"

Task 5: Pure client helpers and brand-rule test​

Files:

  • Create: landing/src/lib/filter.mjs
  • Test: landing/test/unit/filter.test.mjs, landing/test/unit/brand-rules.test.mjs

Interfaces:

  • Produces: matches(tile, f) as specified.

  • Step 1: Write failing tests

landing/test/unit/filter.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { matches } from '../../src/lib/filter.mjs';

const tile = { category: 'Web & HTTP', platforms: ['JVM', 'JS'], scala: ['3.x'] };
const none = { category: null, platform: null, scala: null };

test('no filters matches everything', () => {
assert.equal(matches(tile, none), true);
});

test('each filter narrows independently and filters combine with AND', () => {
assert.equal(matches(tile, { ...none, category: 'Web & HTTP' }), true);
assert.equal(matches(tile, { ...none, category: 'Streams' }), false);
assert.equal(matches(tile, { ...none, platform: 'JS' }), true);
assert.equal(matches({ ...tile, platforms: ['JVM'] }, { ...none, platform: 'JS' }), false);
assert.equal(matches(tile, { ...none, scala: '2.13' }), false);
assert.equal(matches(tile, { category: 'Web & HTTP', platform: 'JVM', scala: '3.x' }), true);
assert.equal(matches(tile, { category: 'Web & HTTP', platform: 'JVM', scala: '2.13' }), false);
});

landing/test/unit/brand-rules.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

const FORBIDDEN = [/gradient\(/, /box-shadow/, /text-shadow/, /border-radius/, /rotate\(/, /drop-shadow/];

function sources(dir) {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return name === 'data' ? [] : sources(path);
return /\.(astro|css|js|mjs)$/.test(name) ? [path] : [];
});
}

test('landing/src never uses a decoration the brand rules forbid', () => {
const root = new URL('../../src', import.meta.url).pathname;
const offenders = [];
for (const file of sources(root)) {
const text = readFileSync(file, 'utf8');
for (const re of FORBIDDEN) if (re.test(text)) offenders.push(`${file}: ${re}`);
}
assert.deepEqual(offenders, []);
});
  • Step 2: Run to verify failure, then implement

Run: cd landing && node --test test/unit/filter.test.mjs. Expected: FAIL (module not found). The brand-rules test passes already (nothing forbidden exists yet); keep it as a guard.

landing/src/lib/filter.mjs:

/** Whether a catalog tile passes the active filters. `null` means "any". */
export function matches(tile, f) {
return (
(f.category === null || tile.category === f.category) &&
(f.platform === null || tile.platforms.includes(f.platform)) &&
(f.scala === null || tile.scala.includes(f.scala))
);
}

Run: npm test. Expected: PASS for everything.

  • Step 3: Commit
cd .. && git add landing && git commit -m "feat(landing): add catalog filter predicate and brand-rule guard"

Task 6: Hero, nav, module field​

Files:

  • Create: landing/src/components/ModuleField.astro, landing/src/components/Hero.astro, landing/src/scripts/copy.js
  • Modify: landing/src/pages/index.astro, landing/test/dist.test.mjs

Interfaces:

  • Consumes: site.json (tagline, lead, hero, compatibility), /brand/zio-blocks-logo-on-dark.svg.

  • Produces: <section id="hero"> containing <header> nav, <h1>, .lead, .codecard with a [data-copy] button, CTAs, .stack; src/scripts/copy.js (also used by Task 8).

  • Step 1: Add the failing hero test

Append to landing/test/dist.test.mjs:

import site from '../src/data/site.json' with { type: 'json' };

const between = (open, close) => {
const start = html.indexOf(open);
assert.notEqual(start, -1, `missing ${open}`);
return html.slice(start, html.indexOf(close, start));
};
/** HTML of a top-level section: from its opening tag to the next top-level section or footer. */
const sec = (id) => {
const start = html.indexOf(`<section id="${id}"`);
assert.notEqual(start, -1, `missing <section id="${id}"`);
const next = html.slice(start + 1).search(/<(?:section|footer) id="/);
return next === -1 ? html.slice(start) : html.slice(start, start + 1 + next);
};

test('hero', () => {
const hero = sec('hero');
const [head, tail] = site.tagline.split('—');
assert.equal(text(/<h1[^>]*>([\s\S]*?)<\/h1>/.exec(hero)[1]), `${head.trim()} — ${tail.trim()}`);
assert.equal(text(/<p class="lead[^"]*"[^>]*>([\s\S]*?)<\/p>/.exec(hero)[1]), text(site.lead.replace(/\*+|`/g, '')));
assert.equal(
text(/<pre[\s\S]*?<\/pre>/.exec(hero)[0]),
`${site.hero.install} ${site.hero.jsonCode} // ${site.hero.jsonResult}`,
);
assert.equal(decode(/data-copy="([^"]*)"/.exec(hero)[1]), site.hero.install);
assert.equal(text(/<p class="stack[^"]*"[^>]*>([\s\S]*?)<\/p>/.exec(hero)[1]), `Works with ${site.compatibility.join(' · ')}`);
assert.match(hero, /<img[^>]*src="\/brand\/zio-blocks-logo-on-dark\.svg"[^>]*alt="ZIO Blocks"/);
assert.match(hero, /href="https:\/\/github\.com\/zio\/zio-blocks"/);
assert.match(hero, /href="https:\/\/zio\.dev\/zio-blocks\/"/);
});

Note: the lead contains **bold**/*em* in docs/index.md; the assertion strips the markers and compares text only.

  • Step 2: Run to verify failure

Run: cd landing && npm run build 2>&1 | tail -3; node --test test/dist.test.mjs Expected: hero FAILS with missing <section id="hero".

  • Step 3: Implement the copy script

landing/src/scripts/copy.js:

// Enhances every [data-copy] button. Buttons are hidden by CSS until the `js` class is set.
for (const button of document.querySelectorAll('[data-copy]')) {
const label = button.textContent;
button.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(button.dataset.copy);
button.textContent = 'Copied';
} catch {
button.textContent = 'Copy failed';
}
setTimeout(() => { button.textContent = label; }, 1500);
});
}
  • Step 4: Implement the module field

landing/src/components/ModuleField.astro:

---
// Decorative field of equal squares, in the spirit of the social card: a 3-wide grid of modules
// bleeding off the right edge. `1` = a module, `0` = a gap.
const PATTERN = [
[1, 1, 1],
[0, 0, 1],
[0, 0, 0],
[0, 1, 0],
[1, 1, 0],
[1, 1, 1],
];
const squares = PATTERN.flatMap((row, r) => row.map((on, c) => (on ? { r, c } : null)).filter(Boolean));
---

<div class="field" aria-hidden="true">
{squares.map((s, i) => <span class="sq" style={`--c:${s.c};--r:${s.r};--i:${i}`}></span>)}
</div>

<style>
.field {
--m: clamp(64px, 9vw, 132px);
position: absolute;
inset: 0 calc(var(--m) * -0.4) 0 auto;
width: calc(3 * var(--m) + 2 * var(--seam));
pointer-events: none;
}
.sq {
position: absolute;
width: var(--m);
height: var(--m);
left: calc(var(--c) * (var(--m) + var(--seam)));
top: calc(var(--r) * (var(--m) + var(--seam)));
background: var(--field);
}
@media (max-width: 720px) {
.field { opacity: 0.5; }
}
@media (prefers-reduced-motion: no-preference) {
.sq { animation: rise 0.6s both; animation-delay: calc(var(--i) * 70ms); }
@keyframes rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
}
}
</style>
  • Step 5: Implement the hero

landing/src/components/Hero.astro:

---
import { Code } from 'astro:components';
import ModuleField from './ModuleField.astro';
import site from '../data/site.json';
import { renderInline } from '../lib/inline.mjs';

const [head, tail] = site.tagline.split('—');
const snippet = `${site.hero.install}\n\n${site.hero.jsonCode} // ${site.hero.jsonResult}`;
---

<section id="hero" class="hero" aria-labelledby="hero-title">
<ModuleField />
<div class="wrap inner">
<header class="nav">
<a href="/" class="logo"><img src="/brand/zio-blocks-logo-on-dark.svg" alt="ZIO Blocks" width="190" height="44" /></a>
<nav aria-label="Primary">
<a href="https://zio.dev/zio-blocks/">Docs</a>
<a href="#catalog">Blocks</a>
<a href="#switching">Guides</a>
<a href="https://github.com/zio/zio-blocks">GitHub</a>
</nav>
</header>

<div class="copy-col">
<h1 id="hero-title">
{head.trim()}{tail && <> —<br /><span>{tail.trim()}</span></>}
</h1>
<p class="lead" set:html={renderInline(site.lead)} />

<div class="codecard">
<Code code={snippet} lang="scala" theme="github-dark" />
<button class="copy" type="button" data-copy={site.hero.install} aria-label="Copy the install line">Copy</button>
</div>

<p class="cta">
<a class="btn btn-primary" href="https://zio.dev/zio-blocks/">Get started</a>
<a class="btn btn-ghost" href="https://github.com/zio/zio-blocks">GitHub</a>
</p>

<p class="stack">Works with {site.compatibility.join(' · ')}</p>
</div>
</div>
</section>

<script>
import '../scripts/copy.js';
</script>

<style>
.hero {
position: relative;
overflow: hidden;
background: var(--ink);
color: var(--paper-ink);
padding-bottom: clamp(3rem, 8vw, 6rem);
}
.inner { position: relative; }
.nav {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.5rem;
align-items: center;
justify-content: space-between;
padding-block: 1.25rem;
border-bottom: 1px solid var(--ink-rule);
}
.logo img { width: clamp(130px, 22vw, 190px); height: auto; }
nav { display: flex; flex-wrap: wrap; gap: 0.25rem 1.5rem; font-size: 0.9375rem; font-weight: 600; }
nav a { color: var(--paper-ink); text-decoration: none; padding-block: 0.25rem; }
nav a:hover { text-decoration: underline; }

.copy-col { max-width: 40rem; padding-top: clamp(2.5rem, 7vw, 5rem); }
h1 {
margin: 0 0 1rem;
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.02em;
text-wrap: balance;
}
h1 span { color: #fff; }
.lead { margin: 0 0 1.75rem; max-width: 34rem; color: var(--ink-soft); font-size: 1.0625rem; }
.lead :global(strong) { color: var(--paper-ink); }
.codecard :global(pre) { padding-right: 4.5rem; }
.codecard .copy { position: absolute; top: 0.6rem; right: 0.6rem; }
.cta { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 1.5rem 0 0; }
.stack { margin: 2rem 0 0; color: var(--muted); font-size: 0.8125rem; letter-spacing: 0.04em; }
</style>

Note: --muted (#7C859E) on Ink is 4.7:1, allowed here by the Global Constraints.

  • Step 6: Mount the hero and rebuild

landing/src/pages/index.astro:

---
import Base from '../layouts/Base.astro';
import Hero from '../components/Hero.astro';
---

<Base>
<main id="main">
<Hero />
</main>
</Base>

Run: npm run build 2>&1 | tail -3; node --test test/dist.test.mjs Expected: PASS (document head, hero). Run npm test; the brand-rule guard must still pass.

  • Step 7: Visual check at phone and desktop widths
npx astro preview --port 4321 &
sleep 2
OUT=/tmp/claude-1000/-home-milad-sources-scala-zio-blocks-worktrees-homepage/674df4b4-e4c5-4e69-9029-f649d1fba1a6/scratchpad
for w in 360 1280; do
chromium --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=$w,900 \
--screenshot=$OUT/hero-$w.png http://localhost:4321/ >/dev/null 2>&1
done
kill %1

Read both PNGs. Check: logo and nav visible; headline wraps; the code card scrolls inside itself at 360 px (no page-level horizontal scroll); module squares bleed off the right edge. Fix anything wrong before committing.

  • Step 8: Commit
cd .. && git add landing && git commit -m "feat(landing): add ink hero with module field, install snippet, and stack strip"

Task 7: Principles and deep dives​

Files:

  • Create: landing/src/components/Principles.astro, landing/src/components/DeepDives.astro, landing/src/scripts/tabs.js
  • Modify: landing/src/pages/index.astro, landing/test/dist.test.mjs

Interfaces:

  • Consumes: site.principles, site.deepDives, site.categories (Meta Programming codec names for the format chips).

  • Produces: <section id="principles">, <section id="deep-dives"> with role="tablist" and role="tabpanel" elements; tabs.js.

  • Step 1: Add the failing tests

Append to landing/test/dist.test.mjs:

test('principles', () => {
const s = sec('principles');
const items = [...s.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/g)].map((m) => text(m[1]));
assert.deepEqual(items, site.principles.map((p, i) => `0${i + 1} ${p.name} ${p.text}`));
});

test('deep dives render every panel visible without JavaScript', () => {
const s = sec('deep-dives');
const tabs = [...s.matchAll(/role="tab"[^>]*>([\s\S]*?)<\/button>/g)].map((m) => text(m[1]));
assert.deepEqual(tabs, site.deepDives.map((d) => d.title));
const panels = [...s.matchAll(/<div[^>]*role="tabpanel"[^>]*>/g)].map((m) => m[0]);
assert.equal(panels.length, site.deepDives.length);
for (const p of panels) assert.doesNotMatch(p, /\bhidden\b/);
for (const d of site.deepDives) {
assert.match(s, new RegExp(`id="panel-${d.id}"`));
assert.match(text(s), new RegExp(d.title));
}
assert.match(s, /aria-selected="true"/);
});

test('schema panel lists the format chips derived from the catalog', () => {
const s = sec('deep-dives');
const chips = [...s.matchAll(/<li class="chip"[^>]*>([\s\S]*?)<\/li>/g)].map((m) => text(m[1]));
const codecs = site.categories
.find((c) => c.name === 'Meta Programming')
.blocks.filter((b) => b.name.endsWith(' Codec'))
.map((b) => b.name.replace(/ Codec$/, ''));
assert.deepEqual(chips, ['JSON', ...codecs]);
});
  • Step 2: Run to verify failure

Run: cd landing && npm run build 2>&1 | tail -3; node --test test/dist.test.mjs Expected: the three new tests FAIL (missing <section id="principles" etc.).

  • Step 3: Implement Principles

landing/src/components/Principles.astro:

---
import site from '../data/site.json';
---

<section id="principles" class="section" aria-labelledby="principles-title">
<div class="wrap">
<p class="label">01 &nbsp; Principles</p>
<h2 id="principles-title">Use what you need, nothing more</h2>
<ol class="list">
{site.principles.map((p, i) => (
<li>
<span class="n">0{i + 1}</span>
<strong>{p.name}</strong>
<span class="t">{p.text}</span>
</li>
))}
</ol>
</div>
</section>

<style>
.list { list-style: none; margin: 2rem 0 0; padding: 0; border-top: 1px solid var(--rule); }
li {
display: grid;
grid-template-columns: 2.5rem 11rem 1fr;
gap: 0.25rem 1rem;
padding-block: 1rem;
border-bottom: 1px solid var(--rule);
align-items: baseline;
}
.n { font-size: 0.6875rem; letter-spacing: 0.14em; color: var(--label); font-weight: 600; }
strong { font-weight: 800; }
.t { color: var(--fg-soft); }
@media (max-width: 640px) {
li { grid-template-columns: 2rem 1fr; }
.t { grid-column: 2; }
}
</style>
  • Step 4: Implement the tabs script

landing/src/scripts/tabs.js:

// ARIA tabs. Without JavaScript every panel is visible and the tablist is hidden by CSS.
function initTabs(list) {
const tabs = [...list.querySelectorAll('[role="tab"]')];
const panels = tabs.map((tab) => document.getElementById(tab.getAttribute('aria-controls')));

function select(index, focus = false) {
tabs.forEach((tab, i) => {
const on = i === index;
tab.setAttribute('aria-selected', String(on));
tab.tabIndex = on ? 0 : -1;
panels[i].hidden = !on;
});
if (focus) tabs[index].focus();
}

tabs.forEach((tab, i) => {
tab.addEventListener('click', () => select(i));
tab.addEventListener('keydown', (e) => {
const target = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
if (target === undefined) return;
e.preventDefault();
select((target + tabs.length) % tabs.length, true);
});
});
select(0);
}

document.querySelectorAll('[role="tablist"]').forEach(initTabs);
  • Step 5: Implement DeepDives

landing/src/components/DeepDives.astro:

---
import { Code } from 'astro:components';
import site from '../data/site.json';
import { renderInline } from '../lib/inline.mjs';

const codecs =
site.categories
.find((c) => c.name === 'Meta Programming')
?.blocks.filter((b) => b.name.endsWith(' Codec'))
.map((b) => b.name.replace(/ Codec$/, '')) ?? [];
const formats = ['JSON', ...codecs];
---

<section id="deep-dives" class="section" aria-labelledby="deep-dives-title">
<div class="wrap">
<p class="label">02 &nbsp; Deep dives</p>
<h2 id="deep-dives-title">Four blocks, in code</h2>

<div class="tablist" role="tablist" aria-label="Deep dives">
{site.deepDives.map((d, i) => (
<button role="tab" type="button" id={`tab-${d.id}`} aria-controls={`panel-${d.id}`} aria-selected={i === 0 ? 'true' : 'false'} tabindex={i === 0 ? 0 : -1}>{d.title}</button>
))}
</div>

{site.deepDives.map((d) => (
<div class="panel" role="tabpanel" id={`panel-${d.id}`} aria-labelledby={`tab-${d.id}`}>
<h3>{d.title}</h3>
<p class="intro" set:html={renderInline(d.intro)} />
<div class="cols">
<div class="col">
<p class="label">The problem</p>
{d.problem.paragraphs.map((p) => <p set:html={renderInline(p)} />)}
{d.problem.code && <div class="codecard"><Code code={d.problem.code.source} lang={d.problem.code.lang} theme="github-dark" /></div>}
</div>
<div class="col">
<p class="label">The solution</p>
{d.solution.paragraphs.map((p) => <p set:html={renderInline(p)} />)}
<div class="codecard"><Code code={d.solution.code.source} lang={d.solution.code.lang} theme="github-dark" /></div>
</div>
</div>
{d.id === 'schema' && formats.length > 1 && (
<div class="formats">
<p class="label">One schema, many formats</p>
<ul>{formats.map((f) => <li class="chip">{f}</li>)}</ul>
</div>
)}
<ul class="more">
{d.learnMore.map((l) => (
<li><a href={l.url}><span set:html={renderInline(l.title)} /></a> &mdash; {l.description}</li>
))}
</ul>
</div>
))}
</div>
</section>

<script>
import '../scripts/tabs.js';
</script>

<style>
.tablist { display: none; gap: 0; margin: 1.5rem 0; border-bottom: 1px solid var(--rule); }
:global(.js) .tablist { display: flex; flex-wrap: wrap; }
[role='tab'] {
padding: 0.75rem 1.25rem;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: var(--fg-soft);
font: 700 1rem/1.2 var(--font);
cursor: pointer;
}
[role='tab'][aria-selected='true'] { color: var(--fg); border-bottom-color: var(--accent); }
.panel { margin-top: 2rem; }
:global(.js) .panel h3 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.panel h3 { margin: 0 0 0.5rem; font-size: 1.5rem; font-weight: 800; }
.intro { max-width: 46rem; color: var(--fg-soft); font-size: 1.0625rem; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 1.5rem; }
.col { min-width: 0; }
.col p { color: var(--fg-soft); }
.col .label { color: var(--label); }
.formats { margin-top: 2rem; }
.formats ul { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0; padding: 0; list-style: none; }
.chip { padding: 0.25rem 0.75rem; border: 1px solid var(--accent); font-size: 0.875rem; font-weight: 600; }
.more { margin: 1.5rem 0 0; padding: 0; list-style: none; color: var(--fg-soft); }
.more li { padding-block: 0.25rem; }
@media (max-width: 800px) {
.cols { grid-template-columns: 1fr; }
}
</style>

Note: the static HTML has no hidden on any panel and shows each panel's <h3>; when JS runs the js class hides the redundant <h3> visually and the tab acts as the label.

  • Step 6: Mount, build, test

Edit landing/src/pages/index.astro to render <Principles /> and <DeepDives /> after <Hero /> (import both).

Run: npm run build 2>&1 | tail -3; node --test test/dist.test.mjs; npm test Expected: PASS everywhere.

  • Step 7: Visual and keyboard check

Take 360 px and 1280 px screenshots as in Task 6 Step 7 (full page: add --window-size=W,2600). Confirm: tabs hidden with JS disabled is not testable here, so also run Chromium with scripts off to see the no-JS layout:

chromium --headless --no-sandbox --disable-gpu --hide-scrollbars --window-size=1280,4200 \
--blink-settings=scriptEnabled=false --screenshot=$OUT/nojs-1280.png http://localhost:4321/ >/dev/null 2>&1

Expected: with scripts off, all four deep dives are stacked and readable, no tab strip, no copy button. Fix anything that is not.

  • Step 8: Commit
cd .. && git add landing && git commit -m "feat(landing): add principles and tabbed deep dives"

Task 8: Block catalog with filters and copy​

Files:

  • Create: landing/src/components/Catalog.astro, landing/src/scripts/catalog.js
  • Modify: landing/src/pages/index.astro, landing/test/dist.test.mjs

Interfaces:

  • Consumes: site.categories, site.blockCount, matches from src/lib/filter.mjs, copy.js.

  • Produces: <section id="catalog"> with [data-catalog], [data-filter="category|platform|scala"] button groups, [data-category-section] groups, [data-tile] list items, [data-status], [data-empty].

  • Step 1: Add the failing test

Append to landing/test/dist.test.mjs:

test('catalog renders every block as a visible tile with its artifact and docs link', () => {
const s = sec('catalog');
const tiles = [...s.matchAll(/<li class="tile"[^>]*data-tile[^>]*>([\s\S]*?)<\/li>/g)];
const blocks = site.categories.flatMap((c) => c.blocks);
assert.equal(tiles.length, blocks.length);
assert.equal(blocks.length, site.blockCount);
tiles.forEach((t, i) => {
const b = blocks[i];
assert.doesNotMatch(t[0], /^<li[^>]*\bhidden\b/);
assert.equal(text(/<h4[^>]*>([\s\S]*?)<\/h4>/.exec(t[1])[1]), b.name);
assert.equal(/<h4[^>]*><a href="([^"]*)"/.exec(t[1])[1], b.docsUrl);
assert.equal(text(/<code class="artifact"[^>]*>([\s\S]*?)<\/code>/.exec(t[1])[1]), b.artifact);
assert.equal(decode(/data-copy="([^"]*)"/.exec(t[1])[1]), b.artifact);
});
});

test('catalog tiles carry filterable data attributes', () => {
const s = sec('catalog');
const first = site.categories[0].blocks[0];
assert.match(
s,
new RegExp(`data-category="${site.categories[0].name.replace(/&/g, '&amp;')}" data-platforms="${first.platforms.join(' ')}" data-scala="${first.scala.join(' ')}"`),
);
});

test('catalog filter controls list each category, platform and Scala version once', () => {
const s = sec('catalog');
const group = (name) => {
const g = new RegExp(`data-filter="${name}"[^>]*>([\\s\\S]*?)</div>`).exec(s)[1];
return [...g.matchAll(/<button[^>]*>([\s\S]*?)<\/button>/g)].map((m) => text(m[1]));
};
assert.deepEqual(group('category'), ['All', ...site.categories.map((c) => c.name)]);
assert.deepEqual(group('platform'), ['Any', 'JVM', 'JS']);
assert.deepEqual(group('scala'), ['Any', '2.13', '3.x']);
assert.equal(text(/data-status[^>]*>([\s\S]*?)<\//.exec(s)[1]), `${site.blockCount} of ${site.blockCount} blocks`);
});
  • Step 2: Run to verify failure

Run: cd landing && npm run build 2>&1 | tail -3; node --test test/dist.test.mjs Expected: the three new tests FAIL.

  • Step 3: Implement the filter script

landing/src/scripts/catalog.js:

import { matches } from '../lib/filter.mjs';
import './copy.js';

const root = document.querySelector('[data-catalog]');
if (root) {
const tiles = [...root.querySelectorAll('[data-tile]')].map((el) => ({
el,
category: el.dataset.category,
platforms: el.dataset.platforms.split(' '),
scala: el.dataset.scala.split(' '),
}));
const sections = [...root.querySelectorAll('[data-category-section]')];
const status = root.querySelector('[data-status]');
const empty = root.querySelector('[data-empty]');
const state = { category: null, platform: null, scala: null };

function apply() {
let shown = 0;
for (const tile of tiles) {
const ok = matches(tile, state);
tile.el.hidden = !ok;
if (ok) shown += 1;
}
for (const section of sections) {
section.hidden = [...section.querySelectorAll('[data-tile]')].every((el) => el.hidden);
}
status.textContent = `${shown} of ${tiles.length} blocks`;
empty.hidden = shown !== 0;
}

for (const group of root.querySelectorAll('[data-filter]')) {
group.addEventListener('click', (e) => {
const button = e.target.closest('button');
if (!button) return;
state[group.dataset.filter] = button.dataset.value === '' ? null : button.dataset.value;
for (const b of group.querySelectorAll('button')) b.setAttribute('aria-pressed', String(b === button));
apply();
});
}
apply();
}
  • Step 4: Implement the Catalog

landing/src/components/Catalog.astro:

---
import site from '../data/site.json';
import { renderInline } from '../lib/inline.mjs';

const blocks = site.categories.flatMap((c) => c.blocks);
const platforms = [...new Set(blocks.flatMap((b) => b.platforms))];
const scalaVersions = [...new Set(blocks.flatMap((b) => b.scala))];
---

<section id="catalog" class="section" aria-labelledby="catalog-title">
<div class="wrap" data-catalog>
<p class="label">03 &nbsp; Block catalog</p>
<h2 id="catalog-title">{site.blockCount} blocks, take only what you need</h2>
<p class="sub">Each block is a separate artifact under <code>dev.zio</code>. Copy the artifact name, add it to your build, and use it.</p>

<div class="filters">
<div class="group" data-filter="category" aria-label="Category">
<button type="button" data-value="" aria-pressed="true">All</button>
{site.categories.map((c) => <button type="button" data-value={c.name} aria-pressed="false">{c.name}</button>)}
</div>
<div class="group" data-filter="platform" aria-label="Platform">
<button type="button" data-value="" aria-pressed="true">Any</button>
{platforms.map((p) => <button type="button" data-value={p} aria-pressed="false">{p}</button>)}
</div>
<div class="group" data-filter="scala" aria-label="Scala version">
<button type="button" data-value="" aria-pressed="true">Any</button>
{scalaVersions.map((v) => <button type="button" data-value={v} aria-pressed="false">{v}</button>)}
</div>
<p class="status" data-status aria-live="polite">{site.blockCount} of {site.blockCount} blocks</p>
</div>

{site.categories.map((c) => (
<section class="cat" data-category-section>
<h3>{c.name}</h3>
{c.note && <p class="note" set:html={renderInline(c.note)} />}
<ul class="grid">
{c.blocks.map((b) => (
<li class="tile" data-tile data-category={c.name} data-platforms={b.platforms.join(' ')} data-scala={b.scala.join(' ')}>
<h4><a href={b.docsUrl}>{b.name}</a></h4>
<p class="desc" set:html={renderInline(b.description)} />
<div class="meta">
<code class="artifact">{b.artifact}</code>
<button class="copy" type="button" data-copy={b.artifact} aria-label={`Copy artifact name ${b.artifact}`}>Copy</button>
</div>
<p class="badges">{b.platforms.join(' · ')} &nbsp;/&nbsp; Scala {b.scala.join(' · ')}</p>
</li>
))}
</ul>
</section>
))}

<p class="none" data-empty hidden>No blocks match these filters.</p>
</div>
</section>

<script>
import '../scripts/catalog.js';
</script>

<style>
.sub { max-width: 40rem; color: var(--fg-soft); }
.filters { display: grid; gap: 0.75rem; margin: 2rem 0; }
.group { display: none; flex-wrap: nowrap; gap: 0.5rem; overflow-x: auto; padding-bottom: 0.25rem; }
:global(.js) .group { display: flex; }
.group button {
flex: none;
padding: 0.4rem 0.8rem;
border: 1px solid var(--rule);
background: transparent;
color: var(--fg);
font: 600 0.8125rem/1.2 var(--font);
cursor: pointer;
}
.group button[aria-pressed='true'] { background: var(--accent); border-color: var(--accent); color: #fff; }
.status { margin: 0; color: var(--label); font-size: 0.8125rem; }
.cat { margin-top: 2.5rem; }
.cat h3 { margin: 0 0 0.25rem; font-size: 1.125rem; font-weight: 800; }
.note { margin: 0 0 0.75rem; color: var(--fg-soft); font-size: 0.9375rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: var(--seam); margin: 1rem 0 0; padding: 0; list-style: none; }
.tile { display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; background: var(--surface); border: 1px solid var(--rule); min-width: 0; }
.tile h4 { margin: 0; font-size: 1rem; font-weight: 800; }
.desc { margin: 0; color: var(--fg-soft); font-size: 0.875rem; flex: 1; }
.meta { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; min-width: 0; }
.artifact { overflow-wrap: anywhere; font-size: 0.75rem; }
.tile .copy { color: var(--fg); border-color: var(--accent); }
.tile .copy:hover { color: #fff; background: var(--accent); }
.badges { margin: 0; color: var(--label); font-size: 0.75rem; letter-spacing: 0.04em; }
.none { color: var(--fg-soft); }
</style>
  • Step 5: Mount, build, test

Add <Catalog /> after <DeepDives /> in index.astro (import it).

Run: npm run build 2>&1 | tail -3; node --test test/dist.test.mjs; npm test Expected: PASS everywhere.

  • Step 6: Behaviour check in a real browser

Serve with npx astro preview --port 4321 &, then drive it with Chromium's DevTools protocol using Node's built-in WebSocket (no dependency). Write landing/scripts/probe-catalog.mjs is NOT committed; run it from the scratchpad instead:

OUT=/tmp/claude-1000/-home-milad-sources-scala-zio-blocks-worktrees-homepage/674df4b4-e4c5-4e69-9029-f649d1fba1a6/scratchpad
chromium --headless --no-sandbox --disable-gpu --remote-debugging-port=9333 about:blank >/dev/null 2>&1 &
sleep 2
node --input-type=module -e '
const t = await (await fetch("http://localhost:9333/json/new?http://localhost:4321/", { method: "PUT" })).json();
const ws = new WebSocket(t.webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let id = 0; const pending = new Map();
ws.onmessage = (m) => { const d = JSON.parse(m.data); pending.get(d.id)?.(d); };
const call = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); });
const run = async (expression) => (await call("Runtime.evaluate", { expression, returnByValue: true })).result.result.value;
await new Promise((r) => setTimeout(r, 1500));
console.log("status0:", await run("document.querySelector(\"[data-status]\").textContent"));
await run("document.querySelector(\"[data-filter=platform] button[data-value=JS]\").click()");
console.log("status JS:", await run("document.querySelector(\"[data-status]\").textContent"));
await run("document.querySelector(\"[data-filter=scala] button[data-value=\\\"2.13\\\"]\").click(); document.querySelector(\"[data-filter=category] button[data-value=Streams]\").click()");
console.log("status combo:", await run("document.querySelector(\"[data-status]\").textContent"));
console.log("tab 2 selected:", await run("document.querySelector(\"#tab-scope\").click(), document.querySelector(\"#panel-schema\").hidden + \",\" + document.querySelector(\"#panel-scope\").hidden"));
console.log("page overflow x:", await run("document.documentElement.scrollWidth > innerWidth"));
ws.close(); process.exit(0);
'
kill %1 %2 2>/dev/null

Expected: status0 equals N of N blocks; status JS is smaller than N; status combo is a valid count with the category filter applied; tab 2 selected: true,false; page overflow x: false. Fix the script or CSS and re-run if any value is off. Also repeat the overflow check with Emulation.setDeviceMetricsOverride width 360 (add the call before reading scrollWidth) and confirm false.

  • Step 7: Commit
cd .. && git add landing && git commit -m "feat(landing): add filterable block catalog with copy buttons"

Files:

  • Create: landing/src/components/Switching.astro, landing/src/components/Footer.astro
  • Modify: landing/src/pages/index.astro, landing/test/dist.test.mjs

Interfaces:

  • Consumes: site.guides, site.hero.install, /brand/zio-blocks-logo-mono-white.svg.

  • Produces: <section id="switching"> and <footer id="footer">.

  • Step 1: Add the failing tests

Append to landing/test/dist.test.mjs:

test('switching cost features the migration guide and lists the rest', () => {
const s = sec('switching');
const migration = site.guides.find((g) => /migrat/i.test(g.title));
const feature = /<a class="feature" href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/.exec(s);
assert.equal(feature[1], migration.url);
assert.equal(text(feature[2]), `${migration.title} ${migration.description}`);
const others = [...s.matchAll(/<li[^>]*><a href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g)].map((m) => [m[1], text(m[2])]);
assert.deepEqual(others, site.guides.filter((g) => g !== migration).map((g) => [g.url, g.title]));
});

test('footer repeats the install line and links out', () => {
const f = between('<footer id="footer"', '</footer>');
assert.equal(decode(/data-copy="([^"]*)"/.exec(f)[1]), site.hero.install);
assert.match(f, /src="\/brand\/zio-blocks-logo-mono-white\.svg"[^>]*alt="ZIO Blocks"/);
for (const href of ['https://zio.dev/zio-blocks/', 'https://github.com/zio/zio-blocks']) {
assert.match(f, new RegExp(`href="${href.replace(/[./]/g, '\\$&')}"`));
}
});

test('page order, anchors, images, and leftovers', () => {
const ids = [...html.matchAll(/<(?:section|footer) id="([^"]+)"/g)].map((m) => m[1]);
assert.deepEqual(ids, ['hero', 'principles', 'deep-dives', 'catalog', 'switching', 'footer']);
const allIds = new Set([...html.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]));
for (const [, target] of html.matchAll(/href="#([^"]+)"/g)) assert.ok(allIds.has(target), `dangling #${target}`);
for (const img of html.matchAll(/<img\b[^>]*>/g)) assert.match(img[0], /\balt="[^"]+"/);
assert.equal((html.match(/<h1\b/g) ?? []).length, 1);
assert.equal(html.includes('0.0.56'), false);
for (const [, href] of html.matchAll(/href="(https?:[^"]+)"/g)) assert.match(href, /^https:/);
});
  • Step 2: Run to verify failure

Run: cd landing && npm run build 2>&1 | tail -3; node --test test/dist.test.mjs Expected: the three new tests FAIL.

  • Step 3: Implement Switching

landing/src/components/Switching.astro:

---
import site from '../data/site.json';

const feature = site.guides.find((g) => /migrat/i.test(g.title));
const rest = site.guides.filter((g) => g !== feature);
---

<section id="switching" class="section" aria-labelledby="switching-title">
<div class="wrap">
<p class="label">04 &nbsp; Switching cost</p>
<h2 id="switching-title">Adopt it on your timeline</h2>

{feature && (
<a class="feature" href={feature.url}>
<span class="k">Coming from ZIO Schema?</span>
<strong>{feature.title}</strong>
<span class="d">{feature.description}</span>
</a>
)}

<p class="label more">More guides</p>
<ul class="guides">
{rest.map((g) => <li><a href={g.url}>{g.title}</a> <span>&mdash; {g.description}</span></li>)}
</ul>
</div>
</section>

<style>
.feature {
display: grid;
gap: 0.25rem;
max-width: 44rem;
margin-top: 1.5rem;
padding: 1.25rem 1.5rem;
border: 2px solid var(--accent);
color: var(--fg);
text-decoration: none;
}
.feature:hover { background: var(--surface); }
.k { font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--label); }
.feature strong { font-size: 1.25rem; font-weight: 800; }
.d { color: var(--fg-soft); }
.more { margin-top: 2.5rem; }
.guides { margin: 0; padding: 0; list-style: none; }
.guides li { padding-block: 0.4rem; border-bottom: 1px solid var(--rule); }
.guides span { color: var(--fg-soft); }
</style>
  • Step 4: Implement Footer

landing/src/components/Footer.astro:

---
import site from '../data/site.json';
---

<footer id="footer" class="footer">
<div class="wrap">
<p class="cta">Add a block and use it.</p>
<div class="install">
<code>{site.hero.install}</code>
<button class="copy" type="button" data-copy={site.hero.install} aria-label="Copy the install line">Copy</button>
</div>
<div class="bottom">
<img src="/brand/zio-blocks-logo-mono-white.svg" alt="ZIO Blocks" width="160" height="37" />
<nav aria-label="Footer">
<a href="https://zio.dev/zio-blocks/">Docs</a>
<a href="https://zio.dev/zio-blocks/reference/schema/">Reference</a>
<a href="#switching">Guides</a>
<a href="https://github.com/zio/zio-blocks">GitHub</a>
</nav>
</div>
</div>
</footer>

<script>
import '../scripts/copy.js';
</script>

<style>
.footer { background: var(--ink); color: var(--paper-ink); padding-block: clamp(3rem, 7vw, 5rem) 2rem; }
.cta { margin: 0 0 1.25rem; font-size: clamp(1.5rem, 3vw, 2.25rem); font-weight: 800; letter-spacing: -0.01em; }
.install {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
max-width: 44rem;
padding: 0.9rem 1.1rem;
background: var(--code-bg);
border: 1px solid var(--ink-rule);
}
.install code { overflow-x: auto; white-space: nowrap; font-size: 0.8125rem; }
.bottom {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
justify-content: space-between;
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--ink-rule);
}
.bottom img { width: 160px; height: auto; }
nav { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; font-weight: 600; font-size: 0.9375rem; }
nav a { color: var(--paper-ink); text-decoration: none; }
nav a:hover { text-decoration: underline; }
</style>
  • Step 5: Assemble the page and run everything

landing/src/pages/index.astro:

---
import Base from '../layouts/Base.astro';
import Hero from '../components/Hero.astro';
import Principles from '../components/Principles.astro';
import DeepDives from '../components/DeepDives.astro';
import Catalog from '../components/Catalog.astro';
import Switching from '../components/Switching.astro';
import Footer from '../components/Footer.astro';
---

<Base>
<main id="main">
<Hero />
<Principles />
<DeepDives />
<Catalog />
<Switching />
</main>
<Footer />
</Base>

Run: npm run build 2>&1 | tail -3; node --test test/dist.test.mjs; npm test Expected: PASS everywhere (dist: head, hero, principles, deep dives, schema chips, catalog x3, switching, footer, page order).

  • Step 6: Commit
cd .. && git add landing && git commit -m "feat(landing): add switching-cost section, footer, and assemble the page"

Files:

  • Create: landing/scripts/check-links.mjs, landing/test/unit/check-links.test.mjs, landing/netlify.toml, landing/lighthouserc.json, landing/README.md
  • Modify: project/CiWorkflow.scala, build.sbt (ciBuildJobs), .github/workflows/ci.yml (regenerated), AGENTS.md
  • Fallback only: .github/workflows/landing.yml

Interfaces:

  • Produces: extractExternalLinks(html) -> string[] (exported), CLI node scripts/check-links.mjs exiting non-zero on broken links.

  • Step 1: Write the failing link-extraction test

landing/test/unit/check-links.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { extractExternalLinks } from '../../scripts/check-links.mjs';

test('extractExternalLinks returns unique https links in order and ignores anchors and relative paths', () => {
const html = `
<a href="https://zio.dev/zio-blocks/">a</a>
<a href="#catalog">b</a>
<a href="/brand/x.svg">c</a>
<a href="https://github.com/zio/zio-blocks">d</a>
<a href="https://zio.dev/zio-blocks/">e</a>
<a href="https://zio.dev/zio-blocks/reference/schema/?a=1&amp;b=2">f</a>`;
assert.deepEqual(extractExternalLinks(html), [
'https://zio.dev/zio-blocks/',
'https://github.com/zio/zio-blocks',
'https://zio.dev/zio-blocks/reference/schema/?a=1&b=2',
]);
});
  • Step 2: Run to verify failure, then implement

Run: cd landing && node --test test/unit/check-links.test.mjs. Expected: FAIL (module not found).

landing/scripts/check-links.mjs:

import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';

export function extractExternalLinks(html) {
const links = [...html.matchAll(/href="(https:[^"]+)"/g)].map((m) => m[1].replace(/&amp;/g, '&'));
return [...new Set(links)];
}

/** Fails (returns a reason) only on 404/410, 5xx after a retry, or network errors; 403/429 are warnings. */
async function check(url) {
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(20_000), headers: { 'user-agent': 'zio-blocks-landing-linkcheck' } });
if (res.status === 404 || res.status === 410) return { url, fail: `HTTP ${res.status}` };
if (res.status === 403 || res.status === 429) return { url, warn: `HTTP ${res.status}` };
if (res.status < 500) return { url };
if (attempt === 1) return { url, fail: `HTTP ${res.status}` };
} catch (e) {
if (attempt === 1) return { url, fail: e.message };
}
}
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const html = await readFile(new URL('../dist/index.html', import.meta.url), 'utf8');
const urls = extractExternalLinks(html);
const results = [];
for (let i = 0; i < urls.length; i += 6) results.push(...(await Promise.all(urls.slice(i, i + 6).map(check))));
for (const r of results.filter((r) => r.warn)) console.warn(`warn ${r.warn} ${r.url}`);
const failed = results.filter((r) => r.fail);
for (const r of failed) console.error(`FAIL ${r.fail} ${r.url}`);
console.log(`landing: checked ${urls.length} external links, ${failed.length} broken`);
process.exit(failed.length ? 1 : 0);
}

Run: npm test (expect PASS), then npm run build && npm run check:links. Expected: checked N external links, 0 broken. If any zio.dev URL 404s, the docsUrl mapping in Task 3 is wrong for that row: show the URL to the user and fix the mapping rule (do not delete the link).

  • Step 3: Netlify and Lighthouse config

landing/netlify.toml:

# Netlify site settings: base directory "landing", build command and publish dir come from here.
[build]
command = "npm run build"
publish = "dist"
# Skip the build when nothing the site depends on changed (exit 0 = skip).
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- . ../docs/index.md ../assets/logo"

[build.environment]
NODE_VERSION = "24"

[[headers]]
for = "/fonts/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
for = "/_astro/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"

landing/lighthouserc.json:

{
"ci": {
"collect": { "staticDistDir": "./dist", "numberOfRuns": 3 },
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.95 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"categories:best-practices": ["error", { "minScore": 0.95 }],
"categories:seo": ["error", { "minScore": 0.95 }]
}
}
}
}
  • Step 4: Add a landing job to project/CiWorkflow.scala (zio-sbt-ci first)

zio-sbt-ci renders ci.yml from project/CiWorkflow.scala, and the lint job fails if ci.yml drifts from it, so the landing checks belong there. Verified against the 0.8.6 plugin: Job accepts arbitrary steps, timeouts, and conditions; SingleStep has no working-directory field (so each command starts with cd landing &&); triggers are workflow-wide, so the job runs on every pull request (it needs only Node and takes a couple of minutes).

Add this inside object CiWorkflow, after buildDocs (reuses the actions/setup-node@v7 major and Node 24.12.0 that buildDocs already uses):

/**
* Builds and checks the landing page in `landing/`: unit tests, static-output assertions, external link
* check, and Lighthouse. zio-sbt-ci triggers are workflow-wide, so this runs on every pull request.
* `SingleStep` has no working directory, hence the `cd landing &&` prefixes.
*/
lazy val landing: Def.Initialize[Job] = Def.setting(
Job(
id = "landing",
name = "Landing",
jobTimeout = Some(20),
steps = Seq(
Checkout.value,
SingleStep(
name = "Setup Node.js",
uses = Some(ActionRef("actions/setup-node@v7")),
parameters = Map(
"node-version" -> Json.Str("24.12.0"),
"cache" -> Json.Str("npm"),
"cache-dependency-path" -> Json.Str("landing/package-lock.json")
)
),
SingleStep(name = "Install landing dependencies", run = Some("cd landing && npm ci")),
SingleStep(name = "Landing unit tests", run = Some("cd landing && npm test")),
SingleStep(
name = "Build landing and check the static output",
run = Some("cd landing && npm run check"),
env = Map("GITHUB_TOKEN" -> "${{ secrets.GITHUB_TOKEN }}")
),
SingleStep(name = "Check landing external links", run = Some("cd landing && npm run check:links")),
SingleStep(
name = "Landing Lighthouse (95+)",
run = Some("cd landing && npx --yes @lhci/cli@0.15.x autorun")
)
)
)
)

In build.sbt, change ciBuildJobs := Seq(CiWorkflow.buildDocs.value) to ciBuildJobs := Seq(CiWorkflow.buildDocs.value, CiWorkflow.landing.value). Leave ciPullRequestApprovalJobs unchanged: dependency-bot PRs should not wait on a network-dependent link check.

Also add one line to the doc comment at the top of CiWorkflow.scala noting that the landing job covers landing/.

  • Step 5: Regenerate ci.yml and verify the lint check accepts it

Run from the repo root, using the AGENTS.md sbt template:

export PATH="$PWD/.git/bin:$PATH"
ROOT="$(git rev-parse --show-toplevel)" && mkdir -p "$ROOT/.git/agent-logs"
LOG="$ROOT/.git/agent-logs/sbt-$(date +%s)-$$.log"
sbt --client -Dsbt.color=false ciGenerateGithubWorkflow >"$LOG" 2>&1; echo "Exit: $? | Log: $LOG"
git diff --stat .github/workflows
sbt --client -Dsbt.color=false ciCheckGithubWorkflow >"$LOG" 2>&1; echo "Exit: $? | Log: $LOG"

Expected: both exit 0. git diff shows only .github/workflows/ci.yml changed, containing a new landing: job (and landing in the aggregate ci job's needs). Read the diff: the job's steps must match the Scala above, and no other job may change. If any other job changes, the plugin version in the environment differs from the repo's; stop and report.

Fallback, only if sbt cannot compile the job or the plugin rejects it (record the exact error in the commit body): revert CiWorkflow.scala and build.sbt, and create .github/workflows/landing.yml with the same steps as an independent workflow:

name: Landing

on:
pull_request:
paths: ['landing/**', 'docs/index.md', 'assets/logo/**', '.github/workflows/landing.yml']
push:
branches: [main]
paths: ['landing/**', 'docs/index.md', 'assets/logo/**', '.github/workflows/landing.yml']

permissions:
contents: read

concurrency:
group: landing-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
landing:
name: landing
runs-on: ubuntu-latest
defaults:
run:
working-directory: landing
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24.12.0
cache: npm
cache-dependency-path: landing/package-lock.json
- run: npm ci
- run: npm test
- run: npm run check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm run check:links
- run: npx --yes @lhci/cli@0.15.x autorun

Then run sbt --client -Dsbt.color=false ciCheckGithubWorkflow (same logging template). If the plugin deletes or flags landing.yml, stop and ask the user: the last resort is to rely on Netlify's build and deploy previews.

  • Step 6: Docs

landing/README.md:

# ZIO Blocks landing page

Standalone Astro site deployed on Netlify. Copy, code, the block catalog, guides, and compatibility list are
generated from `../docs/index.md`; brand SVGs are copied from `../assets/logo/`. Production docs live on
zio.dev; this site only links to them.

## Commands

| Command | What it does |
| --- | --- |
| `npm run dev` | Regenerate `src/data/site.json`, then start the dev server |
| `npm run build` | Regenerate data, then build `dist/` |
| `npm test` | Unit tests (parser, versions, filters, brand rules) |
| `npm run check` | Build, then assert the static output |
| `npm run check:links` | Check external links in `dist/index.html` (needs network) |

## The `docs/index.md` contract

The parser fails the build, naming the row, if the page changes shape. It relies on: the bold tagline line, the
`## What Is ZIO Blocks?`, `## Core Principles`, `## Getting Started`, `## All Blocks` (one `###` per category, with
a five-column table `Block | Artifact | Platform | Scala | Description`), `## Schema`/`Scope`/`Async`/`SQL` (each
with `### The Problem`, `### The Solution`, `### Learn More`), `## Compatibility`, and `## Guides` sections.
Platforms are `JVM`/`JS`; Scala versions are `2.13`/`3.x`. A new value needs a one-line change in
`scripts/lib/parse-index.mjs`.

## Netlify setup (once, by a maintainer)

Create a site from this repository with base directory `landing`. `netlify.toml` supplies the build command,
publish directory, Node version, and cache headers. The latest release version is read from the GitHub API at
build time and falls back to the pinned `FALLBACK_VERSION` in `scripts/lib/version.mjs`.

Append to AGENTS.md, under ## Boundaries → ### Always, one bullet:

- **Landing page lives in `landing/`.** It is generated from `docs/index.md` (table shapes, section titles, and the `0.0.56` placeholder are a contract; see `landing/README.md`). After touching `docs/index.md`, run `cd landing && npm test && npm run check`. Never hand-edit `landing/src/data/site.json` or `landing/public/brand/` (generated, gitignored).
  • Step 7: Run everything once more and commit

Run: cd landing && npm test && npm run check && npm run check:links Expected: all green.

cd .. && git add landing project/CiWorkflow.scala build.sbt .github/workflows AGENTS.md && git commit -m "ci(landing): add link checker, Netlify config, and a zio-sbt-ci landing job"

Task 11: Verification pass (visual, accessibility, performance, brand)​

Files:

  • Modify: whatever the checks below show is wrong (CSS/markup in landing/src)

  • Step 1: Lighthouse locally

cd landing && npm run build
npx --yes @lhci/cli@0.15.x autorun

Expected: all four category assertions pass (95+). If one fails, read the failing audit in the output, fix the cause (do not lower the threshold), rebuild, rerun.

  • Step 2: Contrast and focus check, both themes

Take desktop screenshots in light and dark (--force-dark-mode --enable-features=WebContentsForceDark is not reliable; instead emulate via DevTools Emulation.setEmulatedMedia with prefers-color-scheme: dark, reusing the CDP snippet from Task 8 Step 6). Read the images. Check: label text, descriptions, tile text, and filter buttons are legible in both themes; focus ring is visible when tabbing (press Tab via Input.dispatchKeyEvent and screenshot once).

  • Step 3: Phone width and no-JS pass

Using the CDP snippet, set device metrics width 360, then assert document.documentElement.scrollWidth <= innerWidth is true, and screenshot the full page height. Read it: install line scrolls inside its card; long artifact names (zio-blocks-schema-messagepack, zio-blocks-data-migration) wrap inside tiles; filter button rows scroll horizontally inside their own row, not the page. Repeat with --blink-settings=scriptEnabled=false and confirm everything is readable and no dead Copy buttons or empty tab strips are visible.

  • Step 4: Reduced motion

Emulate prefers-reduced-motion: reduce via Emulation.setEmulatedMedia, reload, and confirm via getComputedStyle(document.querySelector('.sq')).animationName that it equals none.

  • Step 5: Brand audit

Read assets/logo/zio-blocks-brand-sheet.svg's rendered screenshot next to the hero screenshot. Check against the brand README's "Don't" list: no gradient, shadow, stroke, rotation, or rounded corner anywhere; the wordmark appears only as the logo image; palette hexes in global.css match the Global Constraints exactly (grep -n -i -E "#[0-9a-f]{6}" landing/src/styles/global.css). npm test already enforces the forbidden-decoration list.

  • Step 6: Final run and commit

Run: cd landing && npm test && npm run check && npm run check:links Expected: all green.

cd .. && git add -A landing && git commit -m "fix(landing): address verification findings"

(Skip the commit if nothing changed.)

Report to the user: the Lighthouse scores, the screenshots checked, any deviation from the plan, and the manual follow-ups they own (create the Netlify site, choose the domain).