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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .async-pipeline/tasks.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@
"value": "async-pipeline run-task api-surface-generate"
},
{
"name": "pipeline:task:docs",
"value": "async-pipeline run-task docs"
"name": "pipeline:task:docs.site",
"value": "async-pipeline run-task docs.site"
},
{
"name": "pipeline:task:sync-check",
Expand All @@ -71,7 +71,7 @@
},
{
"name": "pipeline:docs",
"value": "async-pipeline run-task docs"
"value": "async-pipeline run-task docs.site"
},
{
"name": "pipeline:github:check",
Expand Down Expand Up @@ -158,8 +158,8 @@
"value": "async-pipeline run-task api-surface-generate"
},
{
"name": "pipeline:task:docs",
"value": "async-pipeline run-task docs"
"name": "pipeline:task:docs.site",
"value": "async-pipeline run-task docs.site"
},
{
"name": "pipeline:task:sync-check",
Expand All @@ -175,7 +175,7 @@
},
{
"name": "pipeline:docs",
"value": "async-pipeline run-task docs"
"value": "async-pipeline run-task docs.site"
},
{
"name": "pipeline:github:check",
Expand Down Expand Up @@ -222,6 +222,6 @@
"value": "async-pipeline run verify --force"
}
],
"hash": "sha256:1c159852638b60608aa39ebb8f1a5841bde1ef8010cabfe7e8c81eac3635f7f3",
"generatedAt": "2026-06-14T11:16:50.730Z"
"hash": "sha256:5f179042f0de7a5e090c0f234980b53f348f793f702a72c0b7f9fe287f87236e",
"generatedAt": "2026-06-14T19:47:53.026Z"
}
7 changes: 6 additions & 1 deletion .fallowrc.jsonc
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
{
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
"ignoreDependencies": [
"@async/api-contract"
],
"ignorePatterns": [
"examples/**"
"examples/**",
"scripts/build-pages.js"
],
"entry": [
"db.config.example.mjs",
"pipeline.ts",
"scripts/**/*.js",
"scripts/**/*.ts",
"src/**/*.test.ts",
Expand Down
8 changes: 4 additions & 4 deletions .github/async-pipeline.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"generator": "@async/pipeline",
"config": "pipeline.ts",
"workflow": ".github/workflows/async-pipeline.yml",
"hash": "sha256:4aef2ae561d80b5f616aa169c3d277a90c7123166116ff2170d8524b411837d0",
"generatedAt": "2026-06-14T11:16:50.683Z",
"hash": "sha256:205c87f8bfd6e77a11e27cc598263a16e75f2f1abb08c33384fc06802b1928d6",
"generatedAt": "2026-06-14T19:47:52.985Z",
"triggers": {
"pull_request": {},
"push": {
Expand All @@ -19,7 +19,7 @@
{
"id": "pages",
"target": [
"docs"
"docs.site"
],
"trigger": [
"pr",
Expand All @@ -31,7 +31,7 @@
"pages": {
"build": {
"kind": "static",
"path": "./website/dist"
"path": ".async/pages"
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/async-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ jobs:
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: "./website/dist"
path: ".async/pages"

pages-deploy:
name: pages-deploy
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,6 @@ and runs on Node.js 24:
- `npm pack --dry-run`

Docs site deployment is generated from the `pages` job in `pipeline.ts` and
publishes `website/dist/` to GitHub Pages on pushes to `main`.
publishes `.async/pages/` to GitHub Pages on pushes to `main`.

Dependabot is configured in `.github/dependabot.yml` for GitHub Actions updates.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"examples": "pnpm run build && pnpm run build:test && node ./.tmp/test-build/scripts/serve-examples.js",
"pipeline:api-surface": "async-pipeline run-task api-surface",
"pipeline:api-surface:generate": "async-pipeline run-task api-surface-generate",
"pipeline:docs": "async-pipeline run-task docs",
"pipeline:docs": "async-pipeline run-task docs.site",
"pipeline:github:check": "async-pipeline github check",
"pipeline:github:generate": "async-pipeline github generate",
"pipeline:pages": "async-pipeline run pages",
Expand All @@ -37,6 +37,7 @@
"pipeline:task:api-surface": "async-pipeline run-task api-surface",
"pipeline:task:api-surface-generate": "async-pipeline run-task api-surface-generate",
"pipeline:task:docs": "async-pipeline run-task docs",
"pipeline:task:docs.site": "async-pipeline run-task docs.site",
"pipeline:task:sync-check": "async-pipeline run-task sync-check",
"pipeline:verify": "async-pipeline run verify",
"pipeline:verify:force": "async-pipeline run verify --force",
Expand Down
20 changes: 10 additions & 10 deletions pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ export default definePipeline({
runners: ["package"],
targets: [{ package: "@async/db" }],
jobs: ["pages", "preview", "publish", "publish-github", "release-doctor", "snapshot", "verify"],
tasks: ["api-surface", "api-surface-generate", "docs", "sync-check"],
tasks: ["api-surface", "api-surface-generate", "docs.site", "sync-check"],
scripts: {
"api-surface": "run-task api-surface",
"api-surface:generate": "run-task api-surface-generate",
"docs": "run-task docs",
"docs": "run-task docs.site",
"github:check": "github check",
"sync:check": "sync check",
"verify:force": "run verify --force",
Expand Down Expand Up @@ -102,12 +102,12 @@ export default definePipeline({
cache: true,
run: sh`pnpm run test`
}),
docs: task({
description: "Build the static docs site that GitHub Pages uploads from website/dist.",
inputs: ["docs/**/*.md", "website/**/*", "scripts/tasks/docs-build.js", "package.json", "pnpm-lock.yaml"],
outputs: ["website/dist/**"],
"docs.site": task({
description: "Build the standardized GitHub Pages documentation site.",
inputs: ["README.md", "docs/**/*.md", "scripts/build-pages.js"],
outputs: [".async/pages/**"],
cache: false,
run: sh`pnpm run docs:build`
run: sh`node scripts/build-pages.js`
}),
"release-doctor": task({
description: "Reconcile release state across npm, GitHub Packages, and GitHub Releases after package verification.",
Expand All @@ -117,7 +117,7 @@ export default definePipeline({
}),
pack: task({
description: "Verify the publishable package contents.",
dependsOn: ["check", "test", "docs", "api-surface", "sync-check"],
dependsOn: ["check", "test", "docs.site", "api-surface", "sync-check"],
inputs: ["production"],
cache: false,
run: sh`npm pack --dry-run`
Expand Down Expand Up @@ -167,11 +167,11 @@ export default definePipeline({
trigger: ["pr", "main", "release"]
}),
pages: job({
target: "docs",
target: "docs.site",
trigger: ["pr", "main", "manual"],
github: {
pages: {
build: { kind: "static", path: "./website/dist" }
build: { kind: "static", path: ".async/pages" }
}
}
}),
Expand Down
201 changes: 201 additions & 0 deletions scripts/build-pages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env node
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";

const site = {
"title": "@async/db",
"repo": "db",
"stage": "Alpha",
"description": "Gradual data workflow from JSON fixtures to generated types, local APIs, writable stores, and real persistence.",
"lead": "Move from local prototype data to typed APIs and durable state in small steps.",
"quickstart": "pnpm add @async/db\npnpm run pipeline:verify",
"docsRoots": [
"docs"
]
};
const outDir = ".async/pages";
const asyncProjects = [
["@async/db", "https://async.github.io/db/", "Data workflow"],
["@async/web", "https://async.github.io/web/", "Web runtime"],
["@async/pipeline", "https://async.github.io/pipeline/", "Pipeline workflows"],
["@async/dispatch", "https://async.github.io/dispatch/", "Goal-first coordination"],
["@async/auto-git", "https://async.github.io/auto-git/", "Git handoffs"],
["@async/api-contract", "https://async.github.io/api-contract/", "API ledgers"],
["@async/claims", "https://async.github.io/claims/", "Doc claim checks"]
];

await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });

const readme = await readFile("README.md", "utf8");
const docs = await collectDocs(site.docsRoots);
for (const doc of docs) {
const markdown = await readFile(doc.path, "utf8");
const htmlPath = join(outDir, doc.href);
await mkdir(dirname(htmlPath), { recursive: true });
await writeFile(htmlPath, layout({
title: doc.title,
body: renderMarkdown(markdown),
rootPrefix: "../".repeat(doc.href.split("/").length - 1)
}));
}

await writeFile(join(outDir, "index.html"), layout({
title: site.title,
body: home(readme, docs),
rootPrefix: ""
}));

function home(readme, docs) {
const guideLinks = docs.length ? docs.map((doc) =>
`<a class="guide-link" href="${doc.href}"><span>${escapeHtml(doc.title)}</span><small>${escapeHtml(doc.source)}</small></a>`
).join("\n") : "<p>No guide pages are published for this repo yet.</p>";
const related = asyncProjects
.filter(([name]) => name !== site.title)
.map(([name, url, label]) => `<a class="related" href="${url}"><strong>${name}</strong><span>${label}</span></a>`)
.join("\n");
return `
<section class="hero">
<p class="eyebrow">${escapeHtml(site.stage)} / Async</p>
<h1>${escapeHtml(site.title)}</h1>
<p class="lead">${renderInline(site.description)}</p>
<p class="sublead">${renderInline(site.lead)}</p>
<div class="actions">
<a class="primary-link" href="https://github.com/async/${site.repo}">GitHub</a>
<a href="https://www.npmjs.com/package/${encodeURIComponent(site.title)}">npm</a>
</div>
</section>
<section>
<h2>Start</h2>
<pre><code>${escapeHtml(site.quickstart)}</code></pre>
</section>
<section>
<h2>Guides</h2>
<div class="guide-grid">${guideLinks}</div>
</section>
<section>
<h2>Related Async Projects</h2>
<div class="related-grid">${related}</div>
</section>
<section>
<h2>README</h2>
<div class="markdown">${renderMarkdown(readme)}</div>
</section>
`;
}

function layout({ title, body, rootPrefix }) {
const nav = asyncProjects.map(([name, url]) =>
`<a href="${url}"${name === site.title ? " aria-current=\"page\"" : ""}>${name.replace("@async/", "")}</a>`
).join("\n");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(site.description)}">
<style>
:root{color-scheme:dark;--bg:#15202b;--soft:#192734;--raised:#1f2f3d;--border:#38444d;--border2:#2f3c47;--text:#f7f9f9;--muted:#8b98a5;--body:#cfd9de;--blue:#1d9bf0;--green:#00ba7c;--code:#0f1720;--shadow:0 24px 80px rgba(2,6,23,.32)}
*{box-sizing:border-box}html{min-height:100%;background:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.02) 1px,transparent 1px),linear-gradient(180deg,#15202b 0%,#111923 100%);background-size:40px 40px,40px 40px,auto}body{margin:0;color:var(--body);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.65}a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline;text-decoration-thickness:2px;text-underline-offset:3px}.container{width:min(100% - 32px,1080px);margin:48px auto 72px}.page{overflow:hidden;background:rgba(25,39,52,.94);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow)}.topbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px;padding:16px clamp(24px,4vw,56px);background:rgba(15,23,32,.72);border-bottom:1px solid var(--border)}.brand{display:inline-flex;align-items:center;gap:10px;color:var(--text);font-weight:850}.mark{display:grid;width:26px;height:26px;grid-template-columns:repeat(2,1fr);gap:4px}.mark span{border:1px solid var(--blue);border-radius:3px}.mark span:nth-child(2){border-color:var(--green)}.mark span:nth-child(3){border-color:#facc15}.mark span:nth-child(4){border-color:#7dd3fc}.nav{display:flex;flex-wrap:wrap;gap:14px;font-size:.92rem;font-weight:750}.nav a{color:var(--muted)}.nav a[aria-current=page]{color:var(--text)}main{padding:clamp(24px,4vw,56px)}.eyebrow{margin:0 0 12px;color:var(--blue);font-size:.78rem;font-weight:850;letter-spacing:.08em;text-transform:uppercase}h1,h2,h3{color:var(--text);line-height:1.2;letter-spacing:0}h1{max-width:820px;margin:0 0 16px;font-size:clamp(2.25rem,5vw,4.5rem);font-weight:850}h2{margin:48px 0 16px;padding-top:28px;border-top:1px solid var(--border);font-size:clamp(1.45rem,3vw,2rem)}h3{margin:28px 0 10px;font-size:1.14rem}.lead{max-width:820px;color:var(--text);font-size:clamp(1.18rem,2.4vw,1.45rem);line-height:1.45}.sublead{max-width:820px;color:var(--muted);font-size:1.05rem}.actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:24px}.actions a{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 16px;color:var(--text);font-weight:800;background:rgba(15,23,32,.54);border:1px solid var(--border);border-radius:10px}.actions .primary-link{color:#06101f;background:var(--blue);border-color:var(--blue)}.guide-grid,.related-grid{display:grid;gap:12px;margin-top:18px}@media (min-width:760px){.guide-grid,.related-grid{grid-template-columns:repeat(2,1fr)}}.guide-link,.related{display:block;padding:16px 18px;background:rgba(15,23,32,.48);border:1px solid var(--border2);border-radius:12px}.guide-link span,.related strong{display:block;color:var(--text);font-weight:800}.guide-link small,.related span{display:block;margin-top:4px;color:var(--muted)}pre{overflow-x:auto;margin:1rem 0 1.5rem;padding:18px 20px;color:var(--body);background:linear-gradient(180deg,#101923 0%,#0d141d 100%);border:1px solid var(--border2);border-radius:12px}code{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:.92em}p code,li code{padding:.1rem .35rem;color:var(--text);background:rgba(15,23,32,.65);border:1px solid var(--border2);border-radius:6px}.markdown p{max-width:860px}.markdown table{display:block;max-width:100%;overflow:auto;border-collapse:collapse}.markdown th,.markdown td{padding:8px 10px;border:1px solid var(--border2)}.markdown blockquote{margin:18px 0;padding:2px 0 2px 18px;color:var(--muted);border-left:3px solid var(--blue)}footer{padding:20px clamp(24px,4vw,56px);color:var(--muted);border-top:1px solid var(--border)}
</style>
</head>
<body>
<div class="container"><div class="page"><header class="topbar"><a class="brand" href="${rootPrefix}index.html"><span class="mark"><span></span><span></span><span></span><span></span></span><span>${escapeHtml(site.title)}</span></a><nav class="nav">${nav}</nav></header><main>${body}</main><footer>Built by <code>pnpm run pipeline:pages</code>. Workflow source: <code>${escapeHtml(site.pipelineFile ?? "pipeline.ts")}</code>.</footer></div></div>
</body>
</html>
`;
}

async function collectDocs(roots) {
const docs = [];
for (const root of roots) {
try { await stat(root); } catch { continue; }
await walk(root, docs);
}
return docs.sort((a, b) => a.title.localeCompare(b.title));
}

async function walk(dir, docs) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (["node_modules", ".git", ".async", "dist", "_site"].includes(entry.name)) continue;
await walk(path, docs);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const source = await readFile(path, "utf8");
const rel = relative(process.cwd(), path).replaceAll("\\", "/");
const href = rel.replace(/\.md$/, ".html");
docs.push({ path, source: rel, href, title: firstHeading(source) || basename(path, ".md") });
}
}

function renderMarkdown(source) {
const lines = source.split(/\r?\n/);
const html = [];
let list = "";
let code = null;
let table = [];
const closeList = () => { if (list) { html.push(`</${list}>`); list = ""; } };
const closeTable = () => { if (table.length) { html.push(renderTable(table)); table = []; } };
for (const line of lines) {
if (line.startsWith("```")) {
closeList(); closeTable();
if (code) { html.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`); code = null; } else { code = []; }
continue;
}
if (code) { code.push(line); continue; }
if (line.startsWith("|")) { closeList(); table.push(line); continue; }
closeTable();
if (/^###\s+/.test(line)) { closeList(); html.push(`<h3 id="${slug(line.slice(4))}">${renderInline(line.slice(4))}</h3>`); continue; }
if (/^##\s+/.test(line)) { closeList(); html.push(`<h2 id="${slug(line.slice(3))}">${renderInline(line.slice(3))}</h2>`); continue; }
if (/^#\s+/.test(line)) { closeList(); html.push(`<h2 id="${slug(line.slice(2))}">${renderInline(line.slice(2))}</h2>`); continue; }
if (/^-\s+/.test(line)) { if (list !== "ul") { closeList(); list = "ul"; html.push("<ul>"); } html.push(`<li>${renderInline(line.slice(2))}</li>`); continue; }
if (/^\d+\.\s+/.test(line)) { if (list !== "ol") { closeList(); list = "ol"; html.push("<ol>"); } html.push(`<li>${renderInline(line.replace(/^\d+\.\s+/, ""))}</li>`); continue; }
if (/^>\s?/.test(line)) { closeList(); html.push(`<blockquote>${renderInline(line.replace(/^>\s?/, ""))}</blockquote>`); continue; }
if (!line.trim()) { closeList(); continue; }
closeList(); html.push(`<p>${renderInline(line.trim())}</p>`);
}
closeList(); closeTable();
return html.join("\n");
}

function renderTable(lines) {
const rows = lines.map((line) => line.trim().slice(1, -1).split("|").map((cell) => cell.trim()));
const body = rows.filter((row) => !row.every((cell) => /^:?-{3,}:?$/.test(cell))).map((row, index) => {
const tag = index === 0 ? "th" : "td";
return `<tr>${row.map((cell) => `<${tag}>${renderInline(cell)}</${tag}>`).join("")}</tr>`;
}).join("\n");
return `<table>${body}</table>`;
}

function renderInline(value) {
return String(value).split(/(`[^`]+`)/g).map((segment) => {
if (segment.startsWith("`") && segment.endsWith("`")) {
return `<code>${escapeHtml(segment.slice(1, -1))}</code>`;
}
return renderTextLinks(segment);
}).join("");
}

function renderTextLinks(value) {
return String(value).split(/(\[[^\]]+\]\([^)]+\))/g).map((segment) => {
const match = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(segment);
if (match) return `<a href="${escapeHtml(match[2])}">${escapeHtml(match[1])}</a>`;
let html = escapeHtml(segment);
for (const [name, url] of asyncProjects) {
if (name === site.title) continue;
html = html.replace(new RegExp(escapeRegExp(name), "g"), `<a href="${url}">${name}</a>`);
}
return html;
}).join("");
}

function firstHeading(source) {
return source.split(/\r?\n/).find((line) => line.startsWith("# "))?.replace(/^#\s+/, "").trim() || "";
}
function slug(value) { return String(value).toLowerCase().replace(/<[^>]+>/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section"; }
function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function escapeHtml(value) { return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); }
Loading
Loading