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
1 change: 1 addition & 0 deletions apps/docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.astro/
71 changes: 71 additions & 0 deletions apps/docs/astro.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// cloudhal.dev (ADR-0037, ADR-0039): Starlight over the repo's docs/, served as static assets
// by the `cloudhal-dev` Worker. scripts/postbuild.ts adds the schemas, the Markdown copies,
// llms.txt and the redirects to dist/.
import starlight from "@astrojs/starlight";
import { defineConfig } from "astro/config";

export default defineConfig({
site: "https://cloudhal.dev",
trailingSlash: "never",
build: { format: "directory" },
integrations: [
starlight({
title: "Cloudhal",
description: "Open-source, agent-native site platform.",
social: [
{ icon: "github", label: "GitHub", href: "https://github.com/cloudhal-dev/cloudhal" },
],
customCss: ["./src/styles/custom.css"],
head: [
{
tag: "link",
attrs: { rel: "alternate", type: "text/markdown", href: "/llms.txt", title: "llms.txt" },
},
],
lastUpdated: false,
sidebar: [
{
label: "Start",
items: [
{ label: "Overview", link: "/docs" },
{ label: "Deploy to Cloudflare", link: "/docs/guides/deploy-button" },
{ label: "Playground", link: "https://try.cloudhal.com" },
],
},
{ label: "Guides", items: [{ autogenerate: { directory: "docs/guides" } }] },
{
label: "Contributing",
collapsed: true,
items: [
{ label: "How to contribute", link: "/docs/contributing" },
{ label: "Code structure", link: "/docs/engineering/code-structure" },
{ label: "Threat model", link: "/docs/security/threat-model" },
{
label: "Studio design",
collapsed: true,
items: [{ autogenerate: { directory: "docs/design" } }],
},
],
},
{
label: "Decisions (ADRs)",
collapsed: true,
items: [{ autogenerate: { directory: "docs/adr" } }],
},
{
label: "Proposals (RFCs)",
collapsed: true,
items: [{ autogenerate: { directory: "docs/rfcs" } }],
},
{
label: "Reference",
items: [
{ label: "JSON Schemas v1", link: "/schemas/v1/index.json" },
{ label: "Cloudhal-Fetch", link: "/fetch" },
{ label: "llms.txt", link: "/llms.txt" },
],
},
],
}),
],
});
17 changes: 14 additions & 3 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,24 @@
"name": "@cloudhal/docs",
"version": "0.0.1",
"private": true,
"description": "cloudhal.dev: the developer site (docs, JSON Schemas, the fetcher page). ADR-0037.",
"description": "cloudhal.dev: the developer site (Starlight over the repo's docs/, JSON Schemas, docs for agents). ADR-0037, ADR-0039.",
"type": "module",
"scripts": {
"build": "node scripts/build.ts",
"deploy": "node scripts/build.ts && wrangler deploy"
"dev": "astro dev",
"build": "astro build && node scripts/postbuild.ts",
"preview": "wrangler dev",
"deploy": "pnpm build && wrangler deploy",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"@astrojs/starlight": "catalog:",
"astro": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"wrangler": "catalog:"
}
}
1 change: 1 addition & 0 deletions apps/docs/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 0 additions & 89 deletions apps/docs/scripts/build.ts

This file was deleted.

139 changes: 139 additions & 0 deletions apps/docs/scripts/postbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Runs after `astro build` and completes cloudhal.dev's `dist/` (ADR-0037, ADR-0039):
*
* - `static/` (`_headers`, robots.txt) and `_redirects` from `src/lib/redirects.ts`;
* - `/schemas/v1/<name>.schema.json` at the permanent `$id` URLs, plus `index.json`; the build
* fails when a schema's `$id` is not its URL here;
* - every page as Markdown at `<path>.md` and `<path>/index.md`, links absolute;
* - `/llms.txt`, `/llms-full.txt` and one pair per section (llmstxt.org).
*/
import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { allPages, type Page } from "../src/lib/pages.ts";
import { redirectLines } from "../src/lib/redirects.ts";
import { ORIGIN } from "../src/lib/repo-docs.ts";

const APP = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const REPO = path.resolve(APP, "../..");
const SCHEMAS = path.join(REPO, "packages/core-contracts/schemas");
const DIST = path.join(APP, "dist");

/** Sections with their own `llms.txt`, in index order. */
const SECTIONS = [
{ key: "guides", prefix: "docs/guides/", title: "Guides" },
{ key: "contributing", prefix: "docs/", title: "Contributing and internals" },
{ key: "adr", prefix: "docs/adr", title: "Architecture decisions" },
{ key: "rfcs", prefix: "docs/rfcs", title: "Proposals" },
] as const;

interface SchemaEntry {
readonly name: string;
readonly url: string;
readonly title: string;
}

async function copySchemas(): Promise<SchemaEntry[]> {
const out = path.join(DIST, "schemas/v1");
await mkdir(out, { recursive: true });
const entries: SchemaEntry[] = [];
for (const file of (await readdir(SCHEMAS)).filter((f) => f.endsWith(".schema.json")).sort()) {
const text = await readFile(path.join(SCHEMAS, file), "utf8");
const schema = JSON.parse(text) as { $id?: string; title?: string };
const url = `${ORIGIN}/schemas/v1/${file}`;
if (schema.$id !== url) throw new Error(`${file}: $id is ${schema.$id}, expected ${url}`);
await writeFile(path.join(out, file), text);
entries.push({ name: file, url, title: schema.title ?? file });
}
await writeFile(
path.join(out, "index.json"),
`${JSON.stringify({ version: 1, schemas: entries }, null, 2)}\n`,
);
return entries;
}

const urlOf = (page: Page) => (page.id === "index" ? `${ORIGIN}/` : `${ORIGIN}/${page.id}`);

function markdownOf(page: Page): string {
const front = [
"---",
`title: ${JSON.stringify(page.title)}`,
`description: ${JSON.stringify(page.description)}`,
`url: ${urlOf(page)}`,
...(page.file
? [`source: https://github.com/cloudhal-dev/cloudhal/blob/main/${page.file}`]
: []),
"---",
"",
];
return `${front.join("\n")}# ${page.title}\n\n${page.body.trim()}\n`;
}

async function writeMarkdown(pages: readonly Page[]): Promise<void> {
for (const page of pages) {
const md = markdownOf(page);
const base = page.id === "index" ? "index" : page.id;
await mkdir(path.dirname(path.join(DIST, `${base}.md`)), { recursive: true });
await writeFile(path.join(DIST, `${base}.md`), md);
if (page.id !== "index") {
await mkdir(path.join(DIST, page.id), { recursive: true });
await writeFile(path.join(DIST, page.id, "index.md"), md);
}
}
}

function sectionOf(page: Page): (typeof SECTIONS)[number] | undefined {
if (!page.id.startsWith("docs/")) return undefined;
return SECTIONS.find((s) =>
s.key === "contributing"
? !/^docs\/(guides|adr|rfcs)(\/|$)/.test(page.id)
: page.id === s.prefix || page.id.startsWith(s.prefix),
);
}

const linkLine = (page: Page) => `- [${page.title}](${urlOf(page)}.md): ${page.description}`;

function llmsIndex(pages: readonly Page[], schemas: readonly SchemaEntry[]): string {
const lines = [
"# Cloudhal",
"",
"> Agent-native site platform: sites are typed graphs, agents change them through an MCP server behind a policy engine, and every change is a snapshot you can diff, preview and roll back. Runs on Cloudflare or self-hosted. Open source, TypeScript.",
"",
"Every page below is Markdown. The full text is in /llms-full.txt; each section also has its own llms.txt and llms-full.txt.",
"",
"## Start",
"",
...pages.filter((p) => !p.id.startsWith("docs/") || p.id === "docs").map(linkLine),
];
for (const section of SECTIONS) {
lines.push("", `## ${section.title}`, "");
lines.push(...pages.filter((p) => p.id !== "docs" && sectionOf(p) === section).map(linkLine));
}
lines.push("", "## JSON Schemas (v1)", "", ...schemas.map((s) => `- [${s.title}](${s.url})`), "");
return lines.join("\n");
}

const fullText = (pages: readonly Page[]) => pages.map(markdownOf).join("\n\n");

async function writeLlms(pages: readonly Page[], schemas: readonly SchemaEntry[]): Promise<void> {
await writeFile(path.join(DIST, "llms.txt"), llmsIndex(pages, schemas));
await writeFile(path.join(DIST, "llms-full.txt"), fullText(pages));
for (const section of SECTIONS) {
const own = pages.filter((p) => p.id !== "docs" && sectionOf(p) === section);
const dir = path.join(DIST, "docs", section.key);
await mkdir(dir, { recursive: true });
await writeFile(
path.join(dir, "llms.txt"),
[`# Cloudhal: ${section.title}`, "", ...own.map(linkLine), ""].join("\n"),
);
await writeFile(path.join(dir, "llms-full.txt"), fullText(own));
}
}

await cp(path.join(APP, "static"), DIST, { recursive: true });
await writeFile(path.join(DIST, "_redirects"), `${redirectLines().join("\n")}\n`);
const schemas = await copySchemas();
const pages = allPages(REPO, true);
await writeMarkdown(pages);
await writeLlms(pages, schemas);
console.log(`cloudhal.dev: ${pages.length} pages, ${schemas.length} schemas`);
61 changes: 61 additions & 0 deletions apps/docs/src/content.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Starlight's `docs` collection, loaded from the repo's `docs/` (ADR-0039) instead of
* `src/content/docs`: titles come from each file's H1, descriptions from its first paragraph,
* and relative links are rewritten to site or GitHub URLs (`src/lib/repo-docs.ts`).
*/

import { defineCollection } from "astro:content";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { docsSchema } from "@astrojs/starlight/schema";
import type { Loader } from "astro/loaders";
import { allPages } from "./lib/pages.ts";

const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");

const repoDocs: Loader = {
name: "cloudhal-repo-docs",
async load({ store, parseData, renderMarkdown, watcher }) {
const sync = async () => {
store.clear();
const pages = allPages(REPO);
const isFolder = (id: string) => pages.some((p) => p.id.startsWith(`${id}/`));
for (const page of pages) {
const data = await parseData({
id: page.id,
data: {
title: page.title,
description: page.description,
...(page.label || page.order !== undefined
? {
sidebar: {
...(page.label ? { label: page.label } : {}),
...(page.order !== undefined ? { order: page.order } : {}),
},
}
: {}),
...page.data,
},
});
store.set({
id: page.id,
data,
body: page.body,
rendered: await renderMarkdown(page.body),
// Starlight builds autogenerated sidebar groups from paths under its collection
// folder; the files do not exist there (the source is `page.file`).
filePath: `src/content/docs/${isFolder(page.id) ? `${page.id}/index` : page.id}.md`,
});
}
};
await sync();
watcher?.add(path.join(REPO, "docs"));
watcher?.on("change", (changed) => {
if (changed.startsWith(path.join(REPO, "docs"))) void sync();
});
},
};

export const collections = {
docs: defineCollection({ loader: repoDocs, schema: docsSchema() }),
};
Loading
Loading