#!/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 = 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(`${th}${tb}
`); 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 `

    ${escapeHtml(sectionName)}

    ${escapeHtml(page.title)}

    `; } 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(); }