773 lines
30 KiB
JavaScript
773 lines
30 KiB
JavaScript
#!/usr/bin/env node
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
|
||
import { css, faviconSvg, js, preThemeScript, themeToggleHtml } from "./docs-site-assets.mjs";
|
||
|
||
const root = process.cwd();
|
||
const docsDir = path.join(root, "docs");
|
||
const outDir = path.join(root, "dist", "docs-site");
|
||
const repoBase = "https://github.com/openclaw/clickclack";
|
||
const repoEditBase = `${repoBase}/edit/main/docs`;
|
||
const cname = readCname();
|
||
const siteBase = cname ? `https://${cname}` : "";
|
||
|
||
const productName = "ClickClack";
|
||
const productTagline = "Self-hostable chat with claws.";
|
||
const productDescription =
|
||
"ClickClack is a tiny single-binary chat server with Slack-style threads, Discord-ish warmth, an OpenAPI-first surface, and a TypeScript SDK. SQLite by default. Built for small teams, communities, bots, and anyone who would rather host their own.";
|
||
const installSnippet = "go run ./apps/api/cmd/clickclack serve";
|
||
|
||
const sections = [
|
||
["Welcome", ["README.md", "install.md", "quickstart.md"]],
|
||
["Features", [
|
||
"features/auth.md",
|
||
"features/workspaces.md",
|
||
"features/messages.md",
|
||
"features/threads.md",
|
||
"features/reactions.md",
|
||
"features/realtime.md",
|
||
"features/search.md",
|
||
"features/uploads.md",
|
||
"features/dms.md",
|
||
"features/integrations.md",
|
||
]],
|
||
["Operate", [
|
||
"cli.md",
|
||
"agent-friendly-cli.md",
|
||
"configuration.md",
|
||
"deployment.md",
|
||
"development.md",
|
||
"releasing.md",
|
||
"sdk.md",
|
||
]],
|
||
["Reference", [
|
||
"architecture/overview.md",
|
||
"api/overview.md",
|
||
"data-model.md",
|
||
]],
|
||
];
|
||
|
||
const buildExcludes = [/^drafts\//];
|
||
|
||
fs.rmSync(outDir, { recursive: true, force: true });
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
|
||
const allPages = allMarkdown(docsDir).map((file) => {
|
||
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
|
||
const raw = fs.readFileSync(file, "utf8");
|
||
const { frontmatter, body } = parseFrontmatter(raw);
|
||
const cleaned = stripStrayDirectives(body);
|
||
const title = frontmatter.title || firstHeading(cleaned) || titleize(path.basename(rel, ".md"));
|
||
return { file, rel, title, outRel: outPath(rel, frontmatter), markdown: cleaned, frontmatter };
|
||
});
|
||
|
||
const pages = allPages.filter((page) => !buildExcludes.some((re) => re.test(page.rel)));
|
||
const pageMap = new Map(pages.map((page) => [page.rel, page]));
|
||
const permalinkMap = new Map();
|
||
for (const page of pages) {
|
||
if (page.frontmatter.permalink) {
|
||
permalinkMap.set(normalizePermalink(page.frontmatter.permalink), page);
|
||
}
|
||
}
|
||
|
||
const nav = sections
|
||
.map(([name, rels]) => ({
|
||
name,
|
||
pages: rels.map((rel) => pageMap.get(rel)).filter(Boolean),
|
||
}))
|
||
.filter((section) => section.pages.length);
|
||
|
||
const sectionByRel = new Map();
|
||
for (const section of nav) for (const page of section.pages) sectionByRel.set(page.rel, section.name);
|
||
const orderedPages = nav.flatMap((s) => s.pages);
|
||
|
||
for (const page of pages) {
|
||
const html = markdownToHtml(page.markdown, page.rel);
|
||
const toc = tocFromHtml(html);
|
||
const idx = orderedPages.findIndex((p) => p.rel === page.rel);
|
||
const prev = idx > 0 ? orderedPages[idx - 1] : null;
|
||
const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : null;
|
||
const sectionName = sectionByRel.get(page.rel) || "Reference";
|
||
const pageOut = path.join(outDir, page.outRel);
|
||
fs.mkdirSync(path.dirname(pageOut), { recursive: true });
|
||
fs.writeFileSync(pageOut, layout({ page, html, toc, prev, next, sectionName }), "utf8");
|
||
}
|
||
|
||
fs.writeFileSync(path.join(outDir, "favicon.svg"), faviconSvg(), "utf8");
|
||
fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8");
|
||
if (cname) fs.writeFileSync(path.join(outDir, "CNAME"), cname, "utf8");
|
||
validateLinks(outDir);
|
||
console.log(`built docs site: ${path.relative(root, outDir)}`);
|
||
|
||
function readCname() {
|
||
for (const candidate of [path.join(docsDir, "CNAME"), path.join(root, "CNAME")]) {
|
||
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf8").trim();
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function parseFrontmatter(raw) {
|
||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
|
||
if (!match) return { frontmatter: {}, body: raw };
|
||
const fm = {};
|
||
for (const line of match[1].split("\n")) {
|
||
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
|
||
if (!m) continue;
|
||
let value = m[2];
|
||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||
value = value.slice(1, -1);
|
||
}
|
||
fm[m[1]] = value;
|
||
}
|
||
return { frontmatter: fm, body: raw.slice(match[0].length) };
|
||
}
|
||
|
||
function stripStrayDirectives(body) {
|
||
return body
|
||
.replace(/\r\n/g, "\n")
|
||
.split("\n")
|
||
.filter((line) => !/^\s*\{:\s*[^}]*\}\s*$/.test(line))
|
||
.map((line) => line.replace(/\s*\{:\s*[^}]*\}\s*$/, ""))
|
||
.join("\n");
|
||
}
|
||
|
||
function normalizePermalink(value) {
|
||
let v = value.trim();
|
||
if (!v) return "/";
|
||
if (!v.startsWith("/")) v = `/${v}`;
|
||
if (v.length > 1 && v.endsWith("/")) v = v.slice(0, -1);
|
||
return v;
|
||
}
|
||
|
||
function allMarkdown(dir) {
|
||
return fs
|
||
.readdirSync(dir, { withFileTypes: true })
|
||
.flatMap((entry) => {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) return allMarkdown(full);
|
||
return entry.name.endsWith(".md") ? [full] : [];
|
||
})
|
||
.sort();
|
||
}
|
||
|
||
function outPath(rel, frontmatter = {}) {
|
||
if (frontmatter.permalink) {
|
||
const permalink = normalizePermalink(frontmatter.permalink);
|
||
if (permalink === "/") return "index.html";
|
||
return `${permalink.slice(1)}/index.html`;
|
||
}
|
||
if (rel === "index.md") return "index.html";
|
||
if (rel === "README.md") return "index.html";
|
||
if (rel.endsWith("/README.md")) return rel.replace(/README\.md$/, "index.html");
|
||
return rel.replace(/\.md$/, ".html");
|
||
}
|
||
|
||
function firstHeading(markdown) {
|
||
return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
||
}
|
||
|
||
function titleize(input) {
|
||
return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase());
|
||
}
|
||
|
||
function markdownToHtml(markdown, currentRel) {
|
||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||
const html = [];
|
||
let paragraph = [];
|
||
let list = null;
|
||
let fence = null;
|
||
let blockquote = [];
|
||
|
||
const flushParagraph = () => {
|
||
if (!paragraph.length) return;
|
||
html.push(`<p>${inline(paragraph.join(" "), currentRel)}</p>`);
|
||
paragraph = [];
|
||
};
|
||
const closeList = () => {
|
||
if (!list) return;
|
||
html.push(`</${list}>`);
|
||
list = null;
|
||
};
|
||
const flushBlockquote = () => {
|
||
if (!blockquote.length) return;
|
||
const inner = markdownToHtml(blockquote.join("\n"), currentRel);
|
||
html.push(`<blockquote>${inner}</blockquote>`);
|
||
blockquote = [];
|
||
};
|
||
const splitRow = (line) => {
|
||
let trimmed = line.trim();
|
||
if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
|
||
if (trimmed.endsWith("|") && !trimmed.endsWith("\\|")) trimmed = trimmed.slice(0, -1);
|
||
const cells = [];
|
||
let current = "";
|
||
for (let idx = 0; idx < trimmed.length; idx++) {
|
||
const char = trimmed[idx];
|
||
if (char === "\\" && trimmed[idx + 1] === "|") {
|
||
current += "\\|";
|
||
idx += 1;
|
||
continue;
|
||
}
|
||
if (char === "|") {
|
||
cells.push(current.trim().replace(/\\\|/g, "|"));
|
||
current = "";
|
||
continue;
|
||
}
|
||
current += char;
|
||
}
|
||
cells.push(current.trim().replace(/\\\|/g, "|"));
|
||
return cells;
|
||
};
|
||
const isDivider = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
const fenceMatch = line.match(/^```([\w+-]+)?\s*$/);
|
||
if (fenceMatch) {
|
||
flushParagraph();
|
||
closeList();
|
||
flushBlockquote();
|
||
if (fence) {
|
||
const body = highlightCode(fence.lines.join("\n"), fence.lang);
|
||
html.push(`<pre><code class="language-${escapeAttr(fence.lang)}">${body}</code></pre>`);
|
||
fence = null;
|
||
} else {
|
||
fence = { lang: fenceMatch[1] || "text", lines: [] };
|
||
}
|
||
continue;
|
||
}
|
||
if (fence) {
|
||
fence.lines.push(line);
|
||
continue;
|
||
}
|
||
if (/^>\s?/.test(line)) {
|
||
flushParagraph();
|
||
closeList();
|
||
blockquote.push(line.replace(/^>\s?/, ""));
|
||
continue;
|
||
}
|
||
flushBlockquote();
|
||
if (!line.trim()) {
|
||
flushParagraph();
|
||
closeList();
|
||
continue;
|
||
}
|
||
if (/^\s*---+\s*$/.test(line)) {
|
||
flushParagraph();
|
||
closeList();
|
||
html.push("<hr>");
|
||
continue;
|
||
}
|
||
const heading = line.match(/^(#{1,4})\s+(.+)$/);
|
||
if (heading) {
|
||
flushParagraph();
|
||
closeList();
|
||
const level = heading[1].length;
|
||
const text = heading[2].trim();
|
||
const id = slug(text);
|
||
const inner = inline(text, currentRel);
|
||
if (level === 1) {
|
||
html.push(`<h1 id="${id}">${inner}</h1>`);
|
||
} else {
|
||
html.push(`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-label="Anchor link">#</a>${inner}</h${level}>`);
|
||
}
|
||
continue;
|
||
}
|
||
if (line.trimStart().startsWith("|") && line.includes("|", line.indexOf("|") + 1) && isDivider(lines[i + 1] || "")) {
|
||
flushParagraph();
|
||
closeList();
|
||
const header = splitRow(line);
|
||
const aligns = splitRow(lines[i + 1]).map((cell) => {
|
||
const left = cell.startsWith(":");
|
||
const right = cell.endsWith(":");
|
||
return right && left ? "center" : right ? "right" : left ? "left" : "";
|
||
});
|
||
i += 1;
|
||
const rows = [];
|
||
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith("|")) {
|
||
i += 1;
|
||
rows.push(splitRow(lines[i]));
|
||
}
|
||
const th = header.map((c, idx) => `<th${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</th>`).join("");
|
||
const tb = rows.map((r) => `<tr>${r.map((c, idx) => `<td${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</td>`).join("")}</tr>`).join("");
|
||
html.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`);
|
||
continue;
|
||
}
|
||
const bullet = line.match(/^\s*-\s+(.+)$/);
|
||
const numbered = line.match(/^\s*\d+\.\s+(.+)$/);
|
||
if (bullet || numbered) {
|
||
flushParagraph();
|
||
const tag = bullet ? "ul" : "ol";
|
||
if (list && list !== tag) closeList();
|
||
if (!list) {
|
||
list = tag;
|
||
html.push(`<${tag}>`);
|
||
}
|
||
html.push(`<li>${inline((bullet || numbered)[1], currentRel)}</li>`);
|
||
continue;
|
||
}
|
||
paragraph.push(line.trim());
|
||
}
|
||
flushParagraph();
|
||
closeList();
|
||
flushBlockquote();
|
||
return html.join("\n");
|
||
}
|
||
|
||
function inline(text, currentRel) {
|
||
const stash = [];
|
||
let out = text.replace(/`([^`]+)`/g, (_, code) => {
|
||
stash.push(`<code>${escapeHtml(code)}</code>`);
|
||
return ` |