600 lines
34 KiB
JavaScript
600 lines
34 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const root = process.cwd();
|
|
const docsDir = path.join(root, "docs");
|
|
const outDir = path.join(root, "dist", "docs-site");
|
|
const repoEditBase = "https://github.com/steipete/discrawl/edit/main/docs";
|
|
|
|
const sections = [
|
|
["Start", ["README.md", "install.md", "configuration.md", "bot-setup.md", "security.md", "contact.md"]],
|
|
["Guides", rels("guides")],
|
|
["Commands", rels("commands")],
|
|
];
|
|
|
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
const pages = allMarkdown(docsDir).map((file) => {
|
|
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
|
|
const markdown = fs.readFileSync(file, "utf8");
|
|
const title = firstHeading(markdown) || titleize(path.basename(rel, ".md"));
|
|
return { file, rel, title, outRel: outPath(rel), markdown };
|
|
});
|
|
|
|
const pageMap = new Map(pages.map((page) => [page.rel, 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) || "Discrawl docs";
|
|
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, "discrawl.svg"), discrawlSvg(), "utf8");
|
|
fs.writeFileSync(path.join(outDir, "CNAME"), "discrawl.sh\n", "utf8");
|
|
fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8");
|
|
console.log(`built docs site: ${path.relative(root, outDir)}`);
|
|
|
|
function rels(dir) {
|
|
const full = path.join(docsDir, dir);
|
|
if (!fs.existsSync(full)) return [];
|
|
return fs
|
|
.readdirSync(full)
|
|
.filter((name) => name.endsWith(".md"))
|
|
.sort((a, b) => (a === "README.md" ? -1 : b === "README.md" ? 1 : a.localeCompare(b)))
|
|
.map((name) => `${dir}/${name}`);
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
|
|
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 splitRow = (line) => line.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map((s) => s.trim());
|
|
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();
|
|
if (fence) {
|
|
html.push(`<pre><code class="language-${fence.lang}">${escapeHtml(fence.lines.join("\n"))}</code></pre>`);
|
|
fence = null;
|
|
} else {
|
|
fence = { lang: fenceMatch[1] || "text", lines: [] };
|
|
}
|
|
continue;
|
|
}
|
|
if (fence) {
|
|
fence.lines.push(line);
|
|
continue;
|
|
}
|
|
if (!line.trim()) {
|
|
flushParagraph();
|
|
closeList();
|
|
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();
|
|
return html.join("\n");
|
|
}
|
|
|
|
function inline(text, currentRel) {
|
|
const stash = [];
|
|
let out = text.replace(/`([^`]+)`/g, (_, code) => {
|
|
stash.push(`<code>${escapeHtml(code)}</code>`);
|
|
return `\u0000${stash.length - 1}\u0000`;
|
|
});
|
|
out = escapeHtml(out)
|
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`);
|
|
return out.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]);
|
|
}
|
|
|
|
function rewriteHref(href, currentRel) {
|
|
if (/^(https?:|mailto:|#)/.test(href)) return href;
|
|
const [raw, hash = ""] = href.split("#");
|
|
if (!raw) return `#${hash}`;
|
|
if (!raw.endsWith(".md")) return href;
|
|
const from = path.posix.dirname(currentRel);
|
|
const target = path.posix.normalize(path.posix.join(from, raw));
|
|
let rewritten = outPath(target);
|
|
const currentOut = outPath(currentRel);
|
|
rewritten = path.posix.relative(path.posix.dirname(currentOut), rewritten) || "index.html";
|
|
return `${rewritten}${hash ? `#${hash}` : ""}`;
|
|
}
|
|
|
|
function tocFromHtml(html) {
|
|
const items = [];
|
|
const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
|
|
let m;
|
|
while ((m = re.exec(html))) {
|
|
const text = m[3]
|
|
.replace(/<a class="anchor"[^>]*>.*?<\/a>/, "")
|
|
.replace(/<[^>]+>/g, "")
|
|
.trim();
|
|
items.push({ level: Number(m[1]), id: m[2], text });
|
|
}
|
|
if (items.length < 2) return "";
|
|
return `<nav class="toc" aria-label="On this page"><h2>On this page</h2>${items
|
|
.map((i) => `<a class="toc-l${i.level}" href="#${i.id}">${escapeHtml(i.text)}</a>`)
|
|
.join("")}</nav>`;
|
|
}
|
|
|
|
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 isHome = page.rel === "README.md";
|
|
const prevNext = !isHome && (prev || next) ? pageNavHtml(prev, next, rootPrefix) : "";
|
|
const heroBlock = isHome ? landingHero(rootPrefix) : standardHero(page, sectionName, editUrl);
|
|
const articleClass = isHome ? "doc doc-home" : "doc";
|
|
const tocBlock = isHome ? "" : toc;
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>${escapeHtml(isHome ? "Discrawl" : `${page.title} - Discrawl`)}</title>
|
|
<meta name="description" content="Discrawl: mirror Discord into local SQLite for offline search and analysis.">
|
|
<link rel="icon" href="${rootPrefix}discrawl.svg">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>${css()}</style>
|
|
</head>
|
|
<body${isHome ? ' class="home"' : ""}>
|
|
<button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
|
|
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
|
</button>
|
|
<div class="shell">
|
|
<aside class="sidebar">
|
|
<a class="brand" href="${rootPrefix}index.html" aria-label="Discrawl docs home">
|
|
<img src="${rootPrefix}discrawl.svg" alt="">
|
|
<span><strong>discrawl</strong><small>discord -> sqlite</small></span>
|
|
</a>
|
|
<label class="search"><span>filter</span><input id="doc-search" type="search" placeholder="sync, wiretap, search..."></label>
|
|
<nav>${navHtml(page.rel, rootPrefix)}</nav>
|
|
<footer class="side-foot">
|
|
<a href="https://github.com/steipete/discrawl" rel="noopener">github</a>
|
|
<a href="${rootPrefix}contact.html">contact</a>
|
|
</footer>
|
|
</aside>
|
|
<main>
|
|
${heroBlock}
|
|
<div class="doc-grid${isHome ? " doc-grid-home" : ""}">
|
|
<article class="${articleClass}">${html}${prevNext}</article>
|
|
${tocBlock}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<script>${js()}</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function standardHero(page, sectionName, editUrl) {
|
|
return `<header class="hero">
|
|
<div class="hero-text">
|
|
<p class="eyebrow">${escapeHtml(sectionName.toLowerCase())}</p>
|
|
<h1>${escapeHtml(page.title)}</h1>
|
|
</div>
|
|
<div class="hero-meta">
|
|
<a class="repo" href="https://github.com/steipete/discrawl" rel="noopener">github</a>
|
|
<a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">edit</a>
|
|
</div>
|
|
</header>`;
|
|
}
|
|
|
|
function landingHero(rootPrefix) {
|
|
const features = [
|
|
["bot api sync", "Fan out across every guild a bot can see. Channels, threads, members, attachments, mentions, FTS5 - all into one SQLite file."],
|
|
["desktop wiretap", "Read local Discord Desktop cache for classifiable messages and proven DMs. No user token. No selfbot. Auth tokens never extracted."],
|
|
["fts + semantic", "<code>unicode61</code> tokenizer for fast literal search. Optional embeddings (OpenAI, Ollama) for semantic and hybrid recall."],
|
|
["git-backed mirrors", "Publish a sharded NDJSON snapshot to a private repo. Readers <code>subscribe</code>, search offline, and never need a bot token."],
|
|
["live tail", "Gateway tail keeps the archive warm. Periodic repair sweeps catch anything the live stream missed."],
|
|
["offline analysis", "<code>digest</code>, <code>analytics</code>, <code>members</code>, raw read-only <code>sql</code> against the local archive."],
|
|
];
|
|
const cards = features
|
|
.map(([title, body]) => `<article class="feature"><h3>${escapeHtml(title)}</h3><p>${body}</p></article>`)
|
|
.join("");
|
|
return `<header class="hero hero-home">
|
|
<div class="hero-text">
|
|
<p class="eyebrow">discord -> sqlite -> answers</p>
|
|
<h1>Server history you can actually <em>search</em>.</h1>
|
|
<p class="lede">Discrawl mirrors Discord guilds into local SQLite so you can grep, query, and run analytics on org memory without depending on Discord search. Bring a bot token, or read everything offline from a Git snapshot.</p>
|
|
<div class="cta">
|
|
<a class="cta-primary" href="${rootPrefix}install.html">Get started</a>
|
|
<a class="cta-secondary" href="https://github.com/steipete/discrawl" rel="noopener">View on GitHub</a>
|
|
</div>
|
|
</div>
|
|
<pre class="hero-snippet" aria-hidden="true"><code><span class="prompt">$</span> discrawl init
|
|
<span class="comment"># discovered 3 guilds, default = maintainers</span>
|
|
<span class="prompt">$</span> discrawl sync --full
|
|
<span class="comment"># 312k messages, 14k attachments, fts ready</span>
|
|
<span class="prompt">$</span> discrawl search "panic: nil pointer"
|
|
<span class="comment"># 23 hits across 5 channels</span></code></pre>
|
|
</header>
|
|
<section class="features" aria-label="Highlights">${cards}</section>`;
|
|
}
|
|
|
|
function pageNavHtml(prev, next, rootPrefix) {
|
|
const cell = (page, dir) => {
|
|
if (!page) return "";
|
|
return `<a class="page-nav-${dir}" href="${rootPrefix}${page.outRel}"><small>${dir === "prev" ? "<- prev" : "next ->"}</small><span>${escapeHtml(page.title)}</span></a>`;
|
|
};
|
|
return `<nav class="page-nav" aria-label="Pager">${cell(prev, "prev")}${cell(next, "next")}</nav>`;
|
|
}
|
|
|
|
function navHtml(currentRel, rootPrefix) {
|
|
return nav
|
|
.map((section) => `<section><h2>${section.name.toLowerCase()}</h2>${section.pages.map((page) => {
|
|
const href = rootPrefix + page.outRel;
|
|
const active = page.rel === currentRel ? " active" : "";
|
|
return `<a class="nav-link${active}" href="${href}">${escapeHtml(page.title)}</a>`;
|
|
}).join("")}</section>`)
|
|
.join("");
|
|
}
|
|
|
|
function css() {
|
|
return `
|
|
:root{--bg:#0c0f14;--panel:#11151c;--panel-2:#161b24;--ink:#e6ecf3;--ink-dim:#aab3c1;--muted:#6b7585;--line:#1f2530;--line-soft:#262d39;--cyan:#5fe3d4;--magenta:#f364a2;--amber:#f7c177;--violet:#a594ff;--shadow:0 14px 40px rgba(0,0,0,.45)}
|
|
@media (prefers-color-scheme: light){:root{--bg:#f4f6fa;--panel:#ffffff;--panel-2:#f8fafc;--ink:#0f172a;--ink-dim:#3f4a5c;--muted:#64748b;--line:#dde3ec;--line-soft:#e7ecf3;--cyan:#0d9488;--magenta:#c026d3;--amber:#b45309;--violet:#6d28d9;--shadow:0 10px 30px rgba(15,23,42,.08)}}
|
|
*{box-sizing:border-box}
|
|
html{scroll-behavior:smooth;scroll-padding-top:24px}
|
|
body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;line-height:1.65;overflow-x:hidden;-webkit-font-smoothing:antialiased;font-feature-settings:"ss01","cv11"}
|
|
body:before{content:"";position:fixed;inset:0;pointer-events:none;background:radial-gradient(1200px 600px at 90% -20%,rgba(95,227,212,.08),transparent 60%),radial-gradient(900px 500px at -10% 110%,rgba(243,100,162,.07),transparent 60%);z-index:0}
|
|
::selection{background:var(--magenta);color:var(--bg)}
|
|
a{color:var(--cyan);text-decoration:none;border-bottom:1px solid transparent;transition:color .15s,border-color .15s}
|
|
a:hover{color:var(--magenta);border-bottom-color:var(--magenta)}
|
|
.shell{position:relative;z-index:1;display:grid;grid-template-columns:264px minmax(0,1fr);min-height:100vh}
|
|
|
|
/* sidebar */
|
|
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:22px 18px 14px;background:var(--panel);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent;display:flex;flex-direction:column}
|
|
.sidebar::-webkit-scrollbar{width:6px}
|
|
.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
|
|
.brand{display:flex;align-items:center;gap:10px;color:var(--ink);text-decoration:none;border:0;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--line)}
|
|
.brand:hover{color:var(--ink)}
|
|
.brand img{width:32px;height:32px;border-radius:7px}
|
|
.brand strong{display:block;font-family:"JetBrains Mono",ui-monospace,monospace;font-size:1.05rem;line-height:1;letter-spacing:-.01em;font-weight:700}
|
|
.brand small{display:block;color:var(--muted);font-size:.66rem;margin-top:5px;font-family:"JetBrains Mono",ui-monospace,monospace;letter-spacing:.06em}
|
|
.search{display:block;margin:0 0 18px}
|
|
.search span{display:block;color:var(--muted);font-size:.62rem;font-weight:600;text-transform:uppercase;letter-spacing:.18em;margin-bottom:6px;font-family:"JetBrains Mono",ui-monospace,monospace}
|
|
.search input{width:100%;border:1px solid var(--line);background:var(--panel-2);border-radius:6px;padding:8px 10px;font:500 .82rem/1.4 "JetBrains Mono",ui-monospace,monospace;color:var(--ink);outline:none;transition:border-color .15s,box-shadow .15s}
|
|
.search input::placeholder{color:var(--muted)}
|
|
.search input:focus{border-color:var(--cyan);box-shadow:0 0 0 2px rgba(95,227,212,.15)}
|
|
nav{flex:1}
|
|
nav section{margin:0 0 18px}
|
|
nav h2{font-size:.6rem;color:var(--muted);text-transform:uppercase;letter-spacing:.2em;margin:0 0 6px;font-weight:700;font-family:"JetBrains Mono",ui-monospace,monospace;padding:0 4px}
|
|
.nav-link{display:block;color:var(--ink-dim);text-decoration:none;border:0;border-radius:5px;padding:5px 10px;margin:1px 0;font-size:.86rem;line-height:1.4;font-family:"JetBrains Mono",ui-monospace,monospace;transition:background .12s,color .12s}
|
|
.nav-link:hover{background:var(--panel-2);color:var(--cyan)}
|
|
.nav-link.active{background:linear-gradient(90deg,rgba(95,227,212,.14),rgba(95,227,212,.04));color:var(--cyan);position:relative}
|
|
.nav-link.active:before{content:"";position:absolute;left:-1px;top:6px;bottom:6px;width:2px;background:var(--cyan);border-radius:2px}
|
|
.side-foot{display:flex;gap:14px;padding-top:14px;margin-top:8px;border-top:1px solid var(--line);font-size:.74rem;font-family:"JetBrains Mono",ui-monospace,monospace}
|
|
.side-foot a{color:var(--muted);border:0}
|
|
.side-foot a:hover{color:var(--magenta)}
|
|
|
|
/* main */
|
|
main{min-width:0;padding:30px clamp(20px,4.5vw,56px) 80px;max-width:1180px;margin:0 auto;width:100%;position:relative}
|
|
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:14px 0 20px;position:relative;flex-wrap:wrap}
|
|
.hero:after{content:"";position:absolute;left:0;bottom:-1px;width:64px;height:2px;background:linear-gradient(90deg,var(--cyan),var(--magenta));border-radius:2px}
|
|
.hero-text{min-width:0;flex:1 1 320px}
|
|
.eyebrow{margin:0 0 10px;color:var(--magenta);font-weight:700;text-transform:uppercase;letter-spacing:.18em;font-size:.66rem;font-family:"JetBrains Mono",ui-monospace,monospace}
|
|
.hero h1{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:clamp(1.8rem,3.2vw,2.6rem);line-height:1.05;letter-spacing:-.02em;margin:0;font-weight:700;color:var(--ink)}
|
|
.hero-meta{display:flex;gap:6px;flex:0 0 auto}
|
|
.repo,.edit{border:1px solid var(--line);color:var(--ink-dim);text-decoration:none;border-radius:6px;padding:6px 12px;font-weight:500;font-size:.78rem;background:var(--panel);font-family:"JetBrains Mono",ui-monospace,monospace;transition:border-color .15s,color .15s}
|
|
.repo:hover,.edit:hover{border-color:var(--cyan);color:var(--cyan)}
|
|
|
|
/* landing hero */
|
|
.hero-home{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:40px;align-items:center;border-bottom:0;padding:32px 0 14px}
|
|
.hero-home:after{display:none}
|
|
.hero-home .eyebrow{margin-bottom:16px;color:var(--cyan)}
|
|
.hero-home h1{font-size:clamp(2.1rem,4.6vw,3.6rem);line-height:1.02;letter-spacing:-.025em;font-weight:700;margin:0 0 18px;max-width:18ch}
|
|
.hero-home h1 em{font-style:normal;color:var(--magenta);font-weight:700;background:linear-gradient(180deg,transparent 60%,rgba(243,100,162,.18) 60%);padding:0 .12em}
|
|
.lede{margin:0 0 24px;color:var(--ink-dim);font-size:clamp(1rem,1.2vw,1.06rem);line-height:1.6;max-width:48ch}
|
|
.cta{display:flex;gap:10px;flex-wrap:wrap}
|
|
.cta-primary,.cta-secondary{display:inline-flex;align-items:center;border-radius:7px;padding:10px 18px;font-weight:600;font-size:.86rem;text-decoration:none;font-family:"JetBrains Mono",ui-monospace,monospace;transition:transform .15s,box-shadow .15s,background .15s,border-color .15s,color .15s;border:1px solid transparent}
|
|
.cta-primary{background:var(--cyan);color:var(--bg);border-color:var(--cyan)}
|
|
.cta-primary:hover{background:var(--magenta);border-color:var(--magenta);color:var(--bg);transform:translateY(-1px);box-shadow:0 8px 22px rgba(243,100,162,.25)}
|
|
.cta-secondary{border-color:var(--line);color:var(--ink);background:transparent}
|
|
.cta-secondary:hover{border-color:var(--cyan);color:var(--cyan);transform:translateY(-1px)}
|
|
.hero-snippet{margin:0;background:var(--panel);color:var(--ink);border-radius:10px;padding:20px 22px;font:500 .84rem/1.7 "JetBrains Mono",ui-monospace,monospace;border:1px solid var(--line);box-shadow:var(--shadow);overflow:hidden;position:relative}
|
|
.hero-snippet:before{content:"$ wiretap";position:absolute;top:8px;right:14px;font-size:.62rem;color:var(--muted);letter-spacing:.14em;text-transform:uppercase}
|
|
.hero-snippet code{background:transparent;border:0;padding:0;color:inherit;font:inherit;display:block;white-space:pre}
|
|
.hero-snippet .prompt{color:var(--cyan)}
|
|
.hero-snippet .comment{color:var(--muted)}
|
|
|
|
/* feature grid */
|
|
.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:32px 0 8px}
|
|
.feature{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:18px 18px 16px;transition:border-color .15s,transform .15s,box-shadow .15s;position:relative;overflow:hidden}
|
|
.feature:before{content:"";position:absolute;top:0;left:0;width:100%;height:2px;background:linear-gradient(90deg,var(--cyan),var(--magenta));opacity:0;transition:opacity .15s}
|
|
.feature:hover{border-color:var(--cyan);transform:translateY(-2px);box-shadow:var(--shadow)}
|
|
.feature:hover:before{opacity:1}
|
|
.feature h3{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:.95rem;margin:0 0 8px;font-weight:600;letter-spacing:-.01em;line-height:1.2;color:var(--ink)}
|
|
.feature p{margin:0;color:var(--ink-dim);font-size:.9rem;line-height:1.55}
|
|
.feature code{font-size:.86em;background:var(--panel-2);border:1px solid var(--line-soft);border-radius:4px;padding:.04em .3em;color:var(--cyan)}
|
|
|
|
/* layout: doc + toc */
|
|
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:36px;margin-top:32px}
|
|
.doc-grid-home{margin-top:14px}
|
|
.doc-home{background:transparent;box-shadow:none;border:0;padding:8px clamp(18px,3vw,30px) 0;max-width:74ch;margin-inline:auto;width:100%}
|
|
.doc-home>:first-child{margin-top:0}
|
|
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 200px;justify-content:start}.doc-grid-home{grid-template-columns:minmax(0,1fr)}}
|
|
.doc{min-width:0;max-width:74ch;background:var(--panel);box-shadow:var(--shadow);border:1px solid var(--line);border-radius:10px;padding:clamp(22px,3.6vw,42px);overflow-wrap:break-word}
|
|
.doc-home{max-width:none}
|
|
.doc h1{display:none}
|
|
.doc h2{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:1.45rem;line-height:1.2;margin:1.9em 0 .6em;font-weight:600;letter-spacing:-.015em;position:relative;color:var(--ink)}
|
|
.doc h3{font-size:1.08rem;margin:1.6em 0 .35em;position:relative;font-weight:600;font-family:"JetBrains Mono",ui-monospace,monospace;color:var(--ink)}
|
|
.doc h4{font-size:.92rem;margin:1.3em 0 .2em;color:var(--cyan);position:relative;font-weight:600;font-family:"JetBrains Mono",ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}
|
|
.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:0}
|
|
.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1em;top:0;color:var(--muted);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s,color .12s;border:0}
|
|
.doc :is(h2,h3,h4):hover .anchor{opacity:.55}
|
|
.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--magenta)}
|
|
.doc p{margin:0 0 1.05em;color:var(--ink-dim)}
|
|
.doc ul,.doc ol{padding-left:1.35rem;margin:0 0 1.2em;color:var(--ink-dim)}
|
|
.doc li{margin:.25em 0}
|
|
.doc li>p{margin:0 0 .4em}
|
|
.doc strong{font-weight:600;color:var(--ink)}
|
|
.doc code{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:.84em;background:var(--panel-2);border:1px solid var(--line-soft);border-radius:4px;padding:.08em .34em;color:var(--cyan)}
|
|
.doc pre{position:relative;overflow:auto;background:var(--panel-2);color:var(--ink);border-radius:8px;padding:18px 22px;border:1px solid var(--line);margin:1.35em 0;font-size:.86em;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
|
|
.doc pre::-webkit-scrollbar{height:8px}
|
|
.doc pre::-webkit-scrollbar-thumb{background:var(--line);border-radius:8px}
|
|
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
.doc pre .copy{position:absolute;top:8px;right:8px;background:var(--panel);color:var(--ink-dim);border:1px solid var(--line);border-radius:5px;padding:3px 9px;font:600 .68rem/1 "JetBrains Mono",monospace;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s,color .15s}
|
|
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
|
|
.doc pre .copy:hover{border-color:var(--cyan);color:var(--cyan)}
|
|
.doc pre .copy.copied{background:var(--cyan);border-color:var(--cyan);color:var(--bg);opacity:1}
|
|
.doc blockquote{margin:1.4em 0;padding:12px 16px;border-left:2px solid var(--magenta);background:var(--panel-2);border-radius:0 6px 6px 0;color:var(--ink-dim)}
|
|
.doc blockquote p:last-child{margin-bottom:0}
|
|
.doc table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:.92em}
|
|
.doc th,.doc td{border-bottom:1px solid var(--line);padding:9px 10px;text-align:left}
|
|
.doc th{font-weight:600;color:var(--cyan);font-family:"JetBrains Mono",ui-monospace,monospace;font-size:.85em;text-transform:uppercase;letter-spacing:.06em}
|
|
.doc td{color:var(--ink-dim)}
|
|
.doc hr{border:0;border-top:1px solid var(--line);margin:2em 0}
|
|
|
|
/* toc */
|
|
.toc{position:sticky;top:24px;align-self:start;font-size:.82rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent;font-family:"JetBrains Mono",ui-monospace,monospace}
|
|
.toc::-webkit-scrollbar{width:5px}
|
|
.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
|
|
.toc h2{font-size:.6rem;color:var(--muted);text-transform:uppercase;letter-spacing:.2em;margin:0 0 10px;font-weight:700}
|
|
.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.4;border-left:2px solid transparent;margin-left:-12px;transition:color .12s,border-color .12s;border-bottom:0}
|
|
.toc a:hover{color:var(--cyan)}
|
|
.toc a.active{color:var(--cyan);border-left-color:var(--cyan);font-weight:600}
|
|
.toc-l3{padding-left:22px!important;font-size:.92em}
|
|
@media(max-width:1179px){.toc{display:none}}
|
|
|
|
/* prev/next pager */
|
|
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:48px}
|
|
.page-nav>a{display:block;border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:14px 18px;text-decoration:none;color:var(--ink);transition:border-color .15s,transform .15s,box-shadow .15s;border-bottom:1px solid var(--line)}
|
|
.page-nav>a:hover{border-color:var(--cyan);transform:translateY(-1px);box-shadow:var(--shadow)}
|
|
.page-nav small{display:block;color:var(--muted);font-size:.66rem;text-transform:uppercase;letter-spacing:.16em;margin-bottom:5px;font-weight:700;font-family:"JetBrains Mono",ui-monospace,monospace}
|
|
.page-nav span{display:block;font-weight:600;line-height:1.3;font-family:"JetBrains Mono",ui-monospace,monospace;font-size:.92rem}
|
|
.page-nav-prev{text-align:left}
|
|
.page-nav-next{text-align:right;grid-column:2}
|
|
.page-nav-prev:only-child{grid-column:1}
|
|
|
|
/* mobile nav toggle */
|
|
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:40px;height:40px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:10px 9px;flex-direction:column;align-items:stretch;justify-content:space-between;box-shadow:var(--shadow)}
|
|
.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s,opacity .2s}
|
|
.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}
|
|
.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}
|
|
.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
|
|
|
|
/* mobile */
|
|
@media(max-width:900px){
|
|
.shell{display:block}
|
|
.sidebar{position:fixed;inset:0 30% 0 0;max-width:300px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease;box-shadow:var(--shadow);background:var(--panel);pointer-events:none}
|
|
.sidebar.open{transform:translateX(0);pointer-events:auto}
|
|
.nav-toggle{display:flex}
|
|
main{padding:64px 18px 56px}
|
|
.hero{padding-top:8px}
|
|
.hero h1{font-size:clamp(1.6rem,7vw,2rem)}
|
|
.hero-meta{width:100%;justify-content:flex-start}
|
|
.hero-home{grid-template-columns:1fr;gap:24px;padding-top:8px}
|
|
.hero-home h1{font-size:clamp(1.95rem,8vw,2.5rem);max-width:none}
|
|
.hero-snippet{font-size:.76rem;padding:16px 16px}
|
|
.features{grid-template-columns:1fr;margin-top:22px}
|
|
.doc{padding:20px;border-radius:8px}
|
|
.doc-home{padding:0 18px}
|
|
.doc-grid{margin-top:22px;gap:24px}
|
|
.doc :is(h2,h3,h4) .anchor{display:none}
|
|
}
|
|
@media(max-width:520px){
|
|
main{padding:60px 14px 48px}
|
|
.doc{padding:18px 16px}
|
|
.doc-home{padding-inline:16px}
|
|
.doc pre{margin-left:-16px;margin-right:-16px;border-radius:0;border-left:0;border-right:0}
|
|
}
|
|
`;
|
|
}
|
|
|
|
function js() {
|
|
return `
|
|
const sidebar=document.querySelector('.sidebar');
|
|
const toggle=document.querySelector('.nav-toggle');
|
|
const mobileNav=window.matchMedia('(max-width: 900px)');
|
|
const sidebarFocusable='a[href],button,input,select,textarea,[tabindex]';
|
|
function setSidebarFocusable(enabled){
|
|
sidebar?.querySelectorAll(sidebarFocusable).forEach((el)=>{
|
|
if(enabled){
|
|
if(el.dataset.sidebarTabindex!==undefined){
|
|
if(el.dataset.sidebarTabindex)el.setAttribute('tabindex',el.dataset.sidebarTabindex);
|
|
else el.removeAttribute('tabindex');
|
|
delete el.dataset.sidebarTabindex;
|
|
}
|
|
}else if(el.dataset.sidebarTabindex===undefined){
|
|
el.dataset.sidebarTabindex=el.getAttribute('tabindex')??'';
|
|
el.setAttribute('tabindex','-1');
|
|
}
|
|
});
|
|
}
|
|
function setSidebarOpen(open){
|
|
if(!sidebar||!toggle)return;
|
|
sidebar.classList.toggle('open',open);
|
|
toggle.setAttribute('aria-expanded',open?'true':'false');
|
|
if(mobileNav.matches){
|
|
sidebar.inert=!open;
|
|
if(open)sidebar.removeAttribute('aria-hidden');
|
|
else sidebar.setAttribute('aria-hidden','true');
|
|
setSidebarFocusable(open);
|
|
}else{
|
|
sidebar.inert=false;
|
|
sidebar.removeAttribute('aria-hidden');
|
|
setSidebarFocusable(true);
|
|
}
|
|
}
|
|
setSidebarOpen(false);
|
|
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
|
|
document.addEventListener('click',(e)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(e.target)||toggle?.contains(e.target))return;setSidebarOpen(false)});
|
|
document.addEventListener('keydown',(e)=>{if(e.key==='Escape')setSidebarOpen(false)});
|
|
const syncSidebarForViewport=()=>setSidebarOpen(sidebar?.classList.contains('open')??false);
|
|
if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarForViewport);
|
|
else mobileNav.addListener?.(syncSidebarForViewport);
|
|
|
|
const input=document.getElementById('doc-search');
|
|
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
|
|
|
|
document.querySelectorAll('.doc pre').forEach(pre=>{const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='copy';btn.addEventListener('click',async()=>{const code=pre.querySelector('code')?.textContent??'';try{await navigator.clipboard.writeText(code);btn.textContent='copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='failed';setTimeout(()=>{btn.textContent='copy'},1400)}});pre.appendChild(btn)});
|
|
|
|
const tocLinks=document.querySelectorAll('.toc a');
|
|
if(tocLinks.length){const map=new Map();tocLinks.forEach(a=>{const id=a.getAttribute('href').slice(1);const el=document.getElementById(id);if(el)map.set(el,a)});const setActive=l=>{tocLinks.forEach(x=>x.classList.remove('active'));l.classList.add('active')};const obs=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);if(visible.length){const link=map.get(visible[0].target);if(link)setActive(link)}},{rootMargin:'-15% 0px -65% 0px',threshold:0});map.forEach((_,el)=>obs.observe(el))}
|
|
`;
|
|
}
|
|
|
|
function discrawlSvg() {
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-label="Discrawl">
|
|
<rect width="120" height="120" rx="22" fill="#0c0f14"/>
|
|
<rect x="20" y="28" width="80" height="58" rx="6" fill="none" stroke="#5fe3d4" stroke-width="3"/>
|
|
<line x1="20" y1="44" x2="100" y2="44" stroke="#5fe3d4" stroke-width="2"/>
|
|
<circle cx="28" cy="36" r="2" fill="#f364a2"/>
|
|
<circle cx="36" cy="36" r="2" fill="#f7c177"/>
|
|
<circle cx="44" cy="36" r="2" fill="#5fe3d4"/>
|
|
<text x="28" y="60" font-family="JetBrains Mono, monospace" font-size="9" font-weight="700" fill="#5fe3d4">SELECT *</text>
|
|
<text x="28" y="72" font-family="JetBrains Mono, monospace" font-size="9" font-weight="700" fill="#aab3c1">FROM msgs</text>
|
|
<text x="28" y="82" font-family="JetBrains Mono, monospace" font-size="9" font-weight="700" fill="#f364a2">_</text>
|
|
<rect x="20" y="92" width="80" height="6" rx="2" fill="#161b24"/>
|
|
<rect x="20" y="92" width="48" height="6" rx="2" fill="#5fe3d4"/>
|
|
<circle cx="22" cy="106" r="3" fill="#5fe3d4"/>
|
|
<circle cx="32" cy="106" r="3" fill="#a594ff"/>
|
|
<circle cx="42" cy="106" r="3" fill="#f364a2"/>
|
|
</svg>`;
|
|
}
|
|
|
|
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);
|
|
}
|