#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { brandMarkSvg, 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/steipete/imsg";
const repoEditBase = `${repoBase}/edit/main/docs`;
const cname = readCname();
const siteBase = cname ? `https://${cname}` : "";
const productName = "imsg";
const productTagline = "Messages.app from your terminal";
const productDescription =
"A macOS command-line tool for Messages.app — read your local chat database, stream new iMessage and SMS messages, send text and files through Messages automation, and expose the same surfaces over JSON and JSON-RPC.";
const brewInstall = "brew install steipete/tap/imsg";
const sections = [
["Start", ["index.md", "install.md", "quickstart.md", "permissions.md"]],
["Read", ["chats.md", "history.md", "watch.md", "groups.md", "attachments.md"]],
["Send", ["send.md"]],
["Integrate", ["json.md", "rpc.md", "completions.md"]],
["Operate", ["troubleshooting.md", "advanced-imcore.md", "RELEASING.md"]],
];
const buildExcludes = [];
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(`
${inline(paragraph.join(" "), currentRel)}
`);
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(`${inner}
`);
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) {
html.push(`${highlightCode(fence.lines.join("\n"), fence.lang)}
`);
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("
");
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(`${inner}
`);
} else {
html.push(`#${inner}`);
}
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) => `${inline(c, currentRel)} | `).join("");
const tb = rows.map((r) => `${r.map((c, idx) => `| ${inline(c, currentRel)} | `).join("")}
`).join("");
html.push(``);
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(`${inline((bullet || numbered)[1], currentRel)}`);
continue;
}
paragraph.push(line.trim());
}
flushParagraph();
closeList();
flushBlockquote();
return html.join("\n");
}
function highlightCode(code, lang) {
const normalized = String(lang || "text").toLowerCase();
if (["bash", "sh", "shell", "zsh"].includes(normalized)) return highlightBash(code);
if (normalized === "json") return highlightJSON(code);
if (["yaml", "yml"].includes(normalized)) return highlightConfig(code, "yaml");
return escapeHtml(code);
}
function highlightBash(code) {
return code.split("\n").map((line) => {
if (/^\s*#/.test(line)) return span("comment", line);
return highlightSegments(line, /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`[^`]*`|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|--?[A-Za-z0-9][A-Za-z0-9_-]*|\b(?:brew|cat|cd|chmod|cp|csrutil|defaults|do|done|else|export|fi|for|grep|if|imsg|in|jq|make|mkdir|osascript|open|rm|sqlite3|swift|tail|then|while|xattr)\b|#.*)/g, (token) => {
if (token.startsWith("#")) return span("comment", token);
if (/^["'`]/.test(token)) return span("string", token);
if (token.startsWith("$")) return span("variable", token);
if (token.startsWith("-")) return span("option", token);
return span("keyword", token);
});
}).join("\n");
}
function highlightJSON(code) {
return highlightSegments(code, /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|\b(?:true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, (token) => {
if (token.endsWith(":")) return `${span("key", token.slice(0, -1))}:`;
if (token.startsWith('"')) return span("string", token);
if (/^(?:true|false|null)$/.test(token)) return span("literal", token);
return span("number", token);
});
}
function highlightConfig(code, lang) {
return code.split("\n").map((line) => {
if (/^\s*#/.test(line)) return span("comment", line);
const commentMatch = line.match(/(^|[^"'])#.*/);
const commentStart = commentMatch ? commentMatch.index + commentMatch[1].length : -1;
const body = commentStart >= 0 ? line.slice(0, commentStart) : line;
const comment = commentStart >= 0 ? line.slice(commentStart) : "";
const highlighted = lang === "yaml"
? highlightSegments(body, /(^\s*[A-Za-z0-9_.-]+(?=\s*:))|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|\b(?:true|false|null)\b|-?\b\d+(?:\.\d+)?\b/g, configToken)
: escapeHtml(body);
return highlighted + (comment ? span("comment", comment) : "");
}).join("\n");
}
function configToken(token) {
if (/^\s*[A-Za-z0-9_.-]+$/.test(token)) {
const leading = token.match(/^\s*/)[0];
return `${escapeHtml(leading)}${span("key", token.slice(leading.length))}`;
}
if (/^["']/.test(token)) return span("string", token);
if (/^(?:true|false|null)$/.test(token)) return span("literal", token);
return span("number", token);
}
function highlightSegments(text, pattern, classify) {
let out = "";
let last = 0;
for (const match of text.matchAll(pattern)) {
out += escapeHtml(text.slice(last, match.index));
out += classify(match[0]);
last = match.index + match[0].length;
}
return out + escapeHtml(text.slice(last));
}
function span(kind, value) {
return `${escapeHtml(value)}`;
}
function inline(text, currentRel) {
const stash = [];
let out = text.replace(/`([^`]+)`/g, (_, code) => {
stash.push(`${escapeHtml(code)}`);
return `\u0000${stash.length - 1}\u0000`;
});
out = escapeHtml(out)
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1$2")
.replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1$2")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => `${label}`)
.replace(/<(https?:\/\/[^\s<>]+)>/g, '$1');
out = out.replace(/\\\|/g, "|");
out = out.replace(/<br>/g, "
");
return out.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]);
}
function rewriteHref(href, currentRel) {
if (/^(https?:|mailto:|tel:|#)/.test(href)) return href;
const [raw, hash = ""] = href.split("#");
if (!raw) return hash ? `#${hash}` : "";
if (raw.startsWith("/")) {
const target = permalinkMap.get(normalizePermalink(raw));
if (target) {
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
const out = hrefToOutRel(target.outRel, currentOut);
return hash ? `${out}#${hash}` : out;
}
return href;
}
if (!raw.endsWith(".md")) return href;
const from = path.posix.dirname(currentRel);
const target = path.posix.normalize(path.posix.join(from, raw));
let rewritten = pageMap.get(target)?.outRel || outPath(target);
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
rewritten = hrefToOutRel(rewritten, currentOut);
return `${rewritten}${hash ? `#${hash}` : ""}`;
}
function tocFromHtml(html) {
const items = [];
const re = /([\s\S]*?)<\/h[23]>/g;
let m;
while ((m = re.exec(html))) {
const text = m[3]
.replace(/]*>.*?<\/a>/, "")
.replace(/<[^>]+>/g, "")
.trim();
items.push({ level: Number(m[1]), id: m[2], text });
}
if (items.length < 2) return "";
return ``;
}
function isHomePage(page) {
if (page.frontmatter.permalink && normalizePermalink(page.frontmatter.permalink) === "/") return true;
return page.rel === "index.md" || page.rel === "README.md";
}
function homeHero(page) {
const description = page.frontmatter.description || productDescription;
const installRel = pageMap.get("install.md")?.outRel
? hrefToOutRel(pageMap.get("install.md").outRel, page.outRel)
: "install.html";
const quickstartRel = pageMap.get("quickstart.md")?.outRel
? hrefToOutRel(pageMap.get("quickstart.md").outRel, page.outRel)
: "quickstart.html";
const surfaces = ["Chats", "History", "Watch", "Send", "React", "Groups", "Attachments", "JSON", "JSON-RPC"];
return `
macOS · Messages.app
${escapeHtml(productTagline)}
${escapeHtml(description)}
$
${escapeHtml(brewInstall)}
${surfaces.map((s) => `${escapeHtml(s)}`).join("")}
Other install options →
`;
}
function standardHero(page, sectionName, editUrl) {
return ``;
}
function layout({ page, html, toc, prev, next, sectionName }) {
const depth = page.outRel.split("/").length - 1;
const rootPrefix = depth ? "../".repeat(depth) : "";
const editUrl = `${repoEditBase}/${page.rel}`;
const home = isHomePage(page);
const prevNext = !home && (prev || next) ? pageNavHtml(prev, next, page.outRel) : "";
const heroBlock = home ? homeHero(page) : standardHero(page, sectionName, editUrl);
const articleClass = home ? "doc doc-home" : "doc";
const tocBlock = home ? "" : toc;
const titleSuffix = home ? `${productName} — ${productTagline}` : `${page.title} — ${productName}`;
const description = page.frontmatter.description || (home ? productDescription : `${page.title} — ${productName} CLI documentation.`);
const canonicalUrl = pageCanonicalUrl(page);
const socialImage = siteBase ? `${siteBase}/favicon.svg` : `${rootPrefix}favicon.svg`;
const socialMeta = [
["link", "rel", "canonical", "href", canonicalUrl],
["meta", "property", "og:type", "content", "website"],
["meta", "property", "og:site_name", "content", productName],
["meta", "property", "og:title", "content", titleSuffix],
["meta", "property", "og:description", "content", description],
["meta", "property", "og:url", "content", canonicalUrl],
["meta", "property", "og:image", "content", socialImage],
["meta", "name", "twitter:card", "content", "summary"],
["meta", "name", "twitter:title", "content", titleSuffix],
["meta", "name", "twitter:description", "content", description],
["meta", "name", "twitter:image", "content", socialImage],
].map(tagHtml).join("\n ");
return `
${escapeHtml(titleSuffix)}
${socialMeta}
${heroBlock}
${html}${prevNext}
${tocBlock}
`;
}
function pageCanonicalUrl(page) {
if (!siteBase) return page.outRel;
if (page.outRel === "index.html") return `${siteBase}/`;
const rel = page.outRel.endsWith("/index.html") ? page.outRel.slice(0, -"index.html".length) : page.outRel;
return `${siteBase}/${rel}`;
}
function tagHtml([tag, k1, v1, k2, v2]) {
return tag === "link" ? `` : ``;
}
function pageNavHtml(prev, next, currentOutRel) {
const cell = (page, dir) => {
if (!page) return "";
return `${dir === "prev" ? "Previous" : "Next"}${escapeHtml(page.title)}`;
};
return ``;
}
function navHtml(currentPage) {
return nav
.map((section) => `${escapeHtml(section.name)}
${section.pages.map((page) => {
const href = hrefToOutRel(page.outRel, currentPage.outRel);
const active = page.rel === currentPage.rel ? " active" : "";
return `${escapeHtml(navTitle(page))}`;
}).join("")}`)
.join("");
}
function navTitle(page) {
if (page.rel === "index.md") return "Overview";
return page.title;
}
function hrefToOutRel(targetOutRel, currentOutRel) {
const currentDir = path.posix.dirname(currentOutRel);
if (targetOutRel.endsWith("/index.html")) {
const targetDir = targetOutRel.slice(0, -"index.html".length);
const rel = path.posix.relative(currentDir, targetDir || ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
if (targetOutRel === "index.html") {
const rel = path.posix.relative(currentDir, ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
}
function slug(text) {
return text.toLowerCase().replace(/`/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function validateLinks(outputDir) {
const failures = [];
const placeholderHrefs = /^(url|path|file|dir|name)$/i;
for (const file of allHtml(outputDir)) {
const html = fs.readFileSync(file, "utf8");
for (const match of html.matchAll(/href="([^"]+)"/g)) {
const href = match[1];
if (/^(#|https?:|mailto:|tel:|javascript:)/.test(href)) continue;
if (placeholderHrefs.test(href)) continue;
const [rawPath, anchor = ""] = href.split("#");
const targetPath = rawPath
? path.resolve(path.dirname(file), rawPath)
: file;
const target = fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
? path.join(targetPath, "index.html")
: targetPath;
if (!fs.existsSync(target)) {
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing ${path.relative(outputDir, target)}`);
continue;
}
if (anchor) {
const targetHtml = fs.readFileSync(target, "utf8");
if (!targetHtml.includes(`id="${anchor}"`) && !targetHtml.includes(`name="${anchor}"`)) {
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing anchor`);
}
}
}
}
if (failures.length) {
throw new Error(`broken docs links:\n${failures.join("\n")}`);
}
}
function allHtml(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allHtml(full);
return entry.name.endsWith(".html") ? [full] : [];
})
.sort();
}