DGit UI › Components
Docs
Docs pages as HTML strings, no framework: Markdown with Mintlify's components (Steps, Accordion, callouts, Card, Frame, Columns, Tabs, CodeGroup, Icon, Tooltip), lucide icons under Font Awesome's names, the side menu, On this page, previous/next, search (a JSON index, no service), llms.txt, and the styles as strings and as docs.css.
Install
npx shadcn add @dgit/docsFirst time? Add the registry to components.json: see Get started.
docs.ts
/*
* DGit UI docs: Markdown (and the Mintlify components docs sites use) to an HTML string, plus the
* pieces of a docs site around it: side menu, "On this page", previous/next, the ask row, llms.txt.
* No framework and no DOM: it runs in a Worker, Node, Bun or the browser. Styles: lib/docs-css.ts
* (strings) or docs.css (the same rules as a file).
*
* What it reads
* - The core, as 0bridge's docs print it: `#`–`###` headings with anchors, paragraphs, `-` and
* `1.` lists (a numbered list keeps its number past a code block), GFM tables, `>` notes, fenced
* code, `code`, **bold**, _italic_, links (https:, mailto:, /path, #anchor and relative pages).
* - Added (only where the core would have printed the text as it was): code fences with blank
* lines and titles (```js app.js), `####`–`######`, `---` rules, images, nested and `*`/`+`
* lists, a list right after a line of text, lists with blank lines between items or indented
* blocks (code, tables, paragraphs) under an item, `> [!NOTE]` alerts, `<br />`, front matter
* (`frontMatter()`).
* - Mintlify components, as Mintlify writes them (MDX, indented bodies, `cols={2}`):
* <Steps>/<Step title>, <Accordion title>/<AccordionGroup>, <Note>/<Tip>/<Info>/<Warning>/
* <Check>/<Danger>, <Card title href icon>/<CardGroup cols>, <Frame caption>, <Columns cols>,
* <Tabs>/<Tab title>, <CodeGroup>, and raw <img>, <br>, <div style={{…}}>. A component's body is
* Markdown; text after its closing tag on the same line carries on after it. In a line of text:
* <Icon icon size color /> and <Tooltip tip href>…</Tooltip>. No JavaScript: accordions are
* <details>, tabs and code groups are radio buttons, tooltips are title attributes.
* - Icons (Card, Icon) come from the `icon` option; lib/docs-icons.ts makes one from lucide icons,
* with Font Awesome's names (Mintlify's) mapped to lucide's.
* - Moving a site from Mintlify: `headingId: mintlifyHeadingId` keeps its section anchors, menu
* groups take Mintlify's group icons (DocGroup.icon), and docsScript(labels) speaks the page's language.
* - Search (opt in): searchIndex() at build time, docsSearch() for the button, dialog and script,
* DOCS_CSS.search for its styles. No service: a JSON file and a few lines of script.
*
* Input is trusted (your own docs): text is escaped, but the raw HTML above is passed through with
* a short list of attributes and no javascript: URLs.
*/
/** HTML-escape text and attribute values: & < > " ' as numeric references. */
export const escape = (v: string) => v.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
/** A heading's anchor: "Over SSH" → "over-ssh", "1. 처리 목적" → "1-처리-목적". */
export const headingId = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "");
/**
* Mintlify's anchors, for the `headingId` option: each space a hyphen, other letters and signs kept
* (·, ×, 「」, %, ②), ASCII punctuation dropped, a closing parenthesis a space. "② 고르기" →
* "②-고르기", "회전 시간 · 세션: 얼마나" → "회전-시간-·-세션-얼마나". Checked on 250 headings of the
* Photoito guide against its Mintlify site.
*/
export const mintlifyHeadingId = (text: string) =>
text
.toLowerCase()
.replace(/\)/g, " ")
.replace(/[!-amp;-,./:-@[-^`{-~]/g, "")
.trim()
.replace(/\s+/g, "-");
export interface MarkdownOptions {
/**
* SVG markup for `<Card icon="…">` and `<Icon icon="…" />` (Mintlify names Font Awesome or lucide
* icons): lucideIcons() in lib/docs-icons.ts. No icon when it returns nothing.
*/
icon?: (name: string) => string | undefined;
/** Prefix for ids the renderer makes (tab groups), when several documents share a page. Default "md". */
idPrefix?: string;
/** The heading anchor's label for screen readers. Default "Link to this section". */
anchorLabel?: string;
/**
* A heading's id from its text. Default headingId(). A site moving from another tool passes that
* tool's rule, so links to its sections keep working (mintlifyHeadingId() for Mintlify).
*/
headingId?: (text: string) => string;
}
export interface Rendered {
html: string;
/** True when anything beyond the core was used: the page then needs DOCS_CSS.extra too. */
extended: boolean;
}
interface Ctx {
o: MarkdownOptions;
extended: boolean;
ids: number;
steps?: { n: number };
tabs?: { name: string; n: number };
}
// ── Inline ─────────────────────────────────────────────────
const LINK = /\[([^\]]+)\]\(((?:https?:|mailto:)[^)\s]+|\/[^)\s]*|#[^)\s]*|(?![a-z][a-z0-9+.-]*:)[^)\s]+)\)/gi;
/** `` and ``, after escaping (quotes are "). */
const IMAGE = /!\[([^\]]*)\]\(([^)\s]+)(?: "([^)]*?)")?\)/g;
/** Inline Markdown on escaped text: `code`, **bold**, _italic_, images, [links](url), <br />, <Icon />, <Tooltip>. */
export function inline(s: string, o: Pick<MarkdownOptions, "icon"> = {}): string {
let out = s
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
.replace(/(^|\W)_([^_]+)_(?=\W|$)/g, "$1<i>$2</i>");
if (out.includes("![")) out = out.replace(IMAGE, (_, alt: string, src: string, title?: string) => `<img src="${safeUrl(src)}" alt="${alt}"${title ? ` title="${title}"` : ""} loading="lazy">`);
out = out.replace(LINK, '<a href="$2">$1</a>');
if (out.includes("<br")) out = out.replace(/<br\s*\/?>/g, "<br>");
if (out.includes("<Icon") || out.includes("<Tooltip")) out = out.split(/(<code>.*?<\/code>)/).map((part, i) => (i % 2 ? part : part.replace(JSX_INLINE, (_, name: string, attrs: string, body?: string) => jsxInline(name, attrs, body, o)))).join("");
return out;
}
/** <Icon … /> and <Tooltip …>…</Tooltip> in escaped text (quotes are "). */
const JSX_ATTR = String.raw`\s+[A-Za-z]+(?:=(?:".*?"|'.*?'|\{[^}]*\}))?`;
const JSX_INLINE = new RegExp(String.raw`<(Icon|Tooltip)((?:${JSX_ATTR})*)\s*(?:\/>|>(.*?)<\/\1>)`, "g");
function jsxInline(name: string, attrs: string, body: string | undefined, o: Pick<MarkdownOptions, "icon">): string {
const a: Record<string, string> = {};
for (const m of attrs.matchAll(/([A-Za-z]+)(?:=(?:"(.*?)"|'(.*?)'|\{(.*?)\}))?/g)) a[m[1]!] = (m[2] ?? m[3] ?? m[4] ?? "true").replace(/^("|')(.*)\1$/, "$2").trim();
if (name === "Icon") {
const svg = a.icon && o.icon?.(a.icon);
if (!svg) return "";
const css = [/^\d+$/.test(a.size ?? "") ? `font-size:${a.size}px` : "", a.color ? `color:${a.color}` : ""].filter(Boolean).join(";");
return `<span class="ic"${css ? ` style="${css}"` : ""}>${svg}</span>`;
}
const tip = a.tip ? ` title="${a.tip}"` : "";
return a.href ? `<a class="tt" href="${safeUrl(a.href)}"${tip}>${body ?? ""}</a>` : `<span class="tt"${tip} tabindex="0">${body ?? ""}</span>`;
}
// ── Blocks ─────────────────────────────────────────────────
/** A fence's info string, "js app.js {1-3}": the language and the title (Mintlify's file name or tab label). */
const fenceInfo = (info: string) => {
const [lang = "", ...rest] = info.replace(/\{[^}]*\}/g, "").trim().split(/\s+/);
return { lang, title: rest.join(" ").replace(/^title=/, "").replace(/^(["'])(.*)\1$/, "$2") };
};
/** A code block; with a title, shown above the code (DOCS_CSS.extra). */
const pre = (code: string, title = "") => `<pre${title ? ` data-title="${escape(title)}"` : ""}><code>${escape(code)}</code></pre>`;
/** A table row's cells; `\|` is a literal pipe. */
const cells = (line: string) =>
line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split(/(?<!\\)\|/)
.map((c) => c.trim().replace(/\\\|/g, "|"));
const isTable = (lines: string[]) => lines.length >= 3 && lines.every((l) => l.startsWith("|")) && /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(lines[1]!);
const ITEM = /^( *)([-*+]|\d+[.)]) +(.*)$/;
/** Lists the core doesn't take: nested, `*`/`+`, `1)`, wrapped lines, text right before the list. */
function list(lines: string[], c: Ctx): string | null {
const first = lines.findIndex((l) => ITEM.test(l));
if (first < 0 || lines.slice(first + 1).some((l) => !ITEM.test(l) && !/^ +\S/.test(l))) return null;
if (lines.slice(0, first).some((l) => /^ /.test(l))) return null;
interface Item { indent: number; ordered: boolean; n: number; text: string; kids: Item[] }
const root: Item = { indent: -1, ordered: false, n: 1, text: "", kids: [] };
const stack = [root];
let last: Item | null = null;
for (const l of lines.slice(first)) {
const m = ITEM.exec(l);
if (!m) {
last!.text += ` ${l.trim()}`;
continue;
}
const indent = m[1]!.length;
while (stack.length > 1 && stack.at(-1)!.indent >= indent) stack.pop();
if (last && indent > last.indent && last !== stack.at(-1)) stack.push(last);
const item: Item = { indent, ordered: /\d/.test(m[2]!), n: Number.parseInt(m[2]!, 10) || 1, text: m[3]!, kids: [] };
stack.at(-1)!.kids.push(item);
last = item;
}
const html = (items: Item[]): string => {
const ordered = items[0]!.ordered;
const start = ordered && items[0]!.n !== 1 ? ` start="${items[0]!.n}"` : "";
return `<${ordered ? "ol" : "ul"}${start}>${items.map((i) => `<li>${inline(escape(i.text), c.o)}${i.kids.length ? html(i.kids) : ""}</li>`).join("")}</${ordered ? "ol" : "ul"}>`;
};
const intro = lines.slice(0, first);
return `${intro.length ? `<p>${inline(escape(intro.join(" ")), c.o)}</p>\n` : ""}${html(root.kids)}`;
}
/**
* A list starting at src[i] whose items are apart (blank lines between them) or hold indented blocks
* under them (code, tables, more paragraphs): one list, each item's content rendered as Markdown.
* Null for a list without blank lines, which blocks() takes.
*/
function looseList(src: string, i: number, c: Ctx): { html: string; end: number } | null {
const lines = src.slice(i).split("\n");
const first = ITEM.exec(lines[0]!)!;
const base = first[1]!.length,
ordered = /\d/.test(first[2]!);
const item = (l: string) => {
const m = ITEM.exec(l);
return m && m[1]!.length === base && /\d/.test(m[2]!) === ordered ? m : null;
};
let n = 1,
loose = false;
while (n < lines.length) {
const l = lines[n]!;
if (l === "") {
let k = n + 1;
while (lines[k] === "") k++;
const next = lines[k];
if (next === undefined || !(item(next) || /^ */.exec(next)![0].length > base)) break;
loose = true;
n = k;
} else if (/^(```|<)/.test(l)) break;
else n++;
}
if (!loose) return null;
const items: { indent: number; lines: string[] }[] = [];
for (const l of lines.slice(0, n)) {
const m = item(l);
if (m) items.push({ indent: m[0].length - m[3]!.length, lines: [m[3]!] });
else {
const it = items.at(-1)!;
it.lines.push(l.slice(Math.min(it.indent, /^ */.exec(l)![0].length)));
}
}
const html = items.map((it) => {
const h = render(it.lines.join("\n"), c);
return `<li>${!h.includes("\n") && h.startsWith("<p>") && h.indexOf("<p>", 1) < 0 ? h.slice(3, -4) : h}</li>`;
});
const start = ordered ? Number.parseInt(first[2]!, 10) : 1;
const tag = ordered ? "ol" : "ul";
return { html: `<${tag}${start !== 1 ? ` start="${start}"` : ""}>${html.join("")}</${tag}>`, end: Math.min(src.length, i + lines.slice(0, n).join("\n").length + 1) };
}
const ALERT = /^> ?\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\] *$/i;
const ALERT_KIND: Record<string, string> = { note: "note", tip: "tip", important: "info", warning: "warning", caution: "danger" };
/** Blank-line separated blocks: the core first, in its order, then what it would have left as text. */
function blocks(md: string, c: Ctx): string[] {
const out: string[] = [];
const anchor = escape(c.o.anchorLabel ?? "Link to this section");
const slug = c.o.headingId ?? headingId;
for (const block of md.split(/\n{2,}/)) {
const lines = block.split("\n");
const h = /^(#{1,3}) (.*)$/.exec(block);
if (lines[0]!.startsWith("```") && lines.at(-1) === "```") out.push(`<pre><code>${escape(lines.slice(1, -1).join("\n"))}</code></pre>`);
else if (isTable(lines)) {
const [head, , ...rows] = lines.map(cells);
const row = (tag: string, r: string[]) => `<tr>${r.map((x) => `<${tag}>${inline(escape(x), c.o)}</${tag}>`).join("")}</tr>`;
out.push(`<div class="tbl"><table><thead>${row("th", head!)}</thead><tbody>${rows.map((r) => row("td", r!)).join("")}</tbody></table></div>`);
} else if (lines.every((l) => l.startsWith(">"))) {
const alert = ALERT.exec(lines[0]!);
if (alert) {
c.extended = true;
out.push(callout(ALERT_KIND[alert[1]!.toLowerCase()]!, render(lines.slice(1).map((l) => l.replace(/^> ?/, "")).join("\n"), c)));
} else out.push(`<blockquote class="callout">${inline(escape(lines.map((l) => l.replace(/^> ?/, "")).join(" ")), c.o)}</blockquote>`);
} else if (lines.every((l) => /^\d+\. /.test(l))) {
const n = Number.parseInt(lines[0]!, 10);
out.push(`<ol${n !== 1 ? ` start="${n}"` : ""}>${lines.map((l) => `<li>${inline(escape(l.replace(/^\d+\. /, "")), c.o)}</li>`).join("")}</ol>`);
} else if (h && lines.length === 1) {
const n = h[1]!.length,
id = escape(slug(h[2]!));
out.push(n === 1 ? `<h1>${inline(escape(h[2]!), c.o)}</h1>` : `<h${n} id="${id}">${inline(escape(h[2]!), c.o)}<a class="hash" href="#${id}" aria-label="${anchor}">#</a></h${n}>`);
} else if (lines.every((l) => l.startsWith("- "))) out.push(`<ul>${lines.map((l) => `<li>${inline(escape(l.slice(2)), c.o)}</li>`).join("")}</ul>`);
else {
// Beyond the core: the core printed each of these as a paragraph of the text as written.
const deep = /^(#{4,6}) (.*)$/.exec(block);
const lst = lines.some((l) => ITEM.test(l)) ? list(lines, c) : null;
if (deep && lines.length === 1) {
const n = deep[1]!.length,
id = escape(slug(deep[2]!));
out.push(`<h${n} id="${id}">${inline(escape(deep[2]!), c.o)}<a class="hash" href="#${id}" aria-label="${anchor}">#</a></h${n}>`);
} else if (lines.length === 1 && /^ {0,3}([-*_])( *\1){2,} *$/.test(block)) out.push("<hr>");
else if (lst) out.push(lst);
else {
out.push(`<p>${inline(escape(lines.join(" ")), c.o)}</p>`);
continue;
}
c.extended = true;
}
}
return out;
}
// ── Components and raw HTML ────────────────────────────────
type Attrs = Record<string, string | true>;
interface Tag {
name: string;
attrs: Attrs;
/** Index just past the tag's `>`. */
end: number;
selfClosing: boolean;
}
/** Read `<Name a="x" b={2} c>` (across lines) starting at src[i] === "<". */
function readTag(src: string, i: number): Tag | null {
const m = /^<([A-Za-z][A-Za-z0-9]*)/.exec(src.slice(i));
if (!m) return null;
const attrs: Attrs = {};
let j = i + m[0].length;
for (;;) {
while (/\s/.test(src[j] ?? "")) j++;
if (src.startsWith("/>", j)) return { name: m[1]!, attrs, end: j + 2, selfClosing: true };
if (src[j] === ">") return { name: m[1]!, attrs, end: j + 1, selfClosing: false };
const key = /^[A-Za-z_:][\w:.-]*/.exec(src.slice(j));
if (!key) return null;
j += key[0].length;
while (src[j] === " ") j++;
if (src[j] !== "=") {
attrs[key[0]] = true;
continue;
}
j++;
while (src[j] === " ") j++;
const q = src[j];
if (q === '"' || q === "'") {
const close = src.indexOf(q, j + 1);
if (close < 0) return null;
attrs[key[0]] = src.slice(j + 1, close);
j = close + 1;
} else if (q === "{") {
let depth = 0,
k = j;
for (; k < src.length; k++) {
if (src[k] === "{") depth++;
else if (src[k] === "}" && --depth === 0) break;
}
if (k >= src.length) return null;
attrs[key[0]] = src.slice(j + 1, k).trim();
j = k + 1;
} else return null;
}
}
/** `"2"`, `2`, `{"x"}` → the string inside. */
const attr = (a: Attrs, k: string) => {
const v = a[k];
if (v === undefined || v === true) return v === true ? "true" : undefined;
return v.replace(/^(["'`])([\s\S]*)\1$/, "$2");
};
const flag = (a: Attrs, k: string) => a[k] === true || attr(a, k) === "true";
const cols = (a: Attrs) => {
const n = Number.parseInt(attr(a, "cols") ?? "2", 10);
return Math.min(Math.max(Number.isFinite(n) ? n : 2, 1), 6);
};
/** A URL for href/src: anything but a script scheme. */
const safeUrl = (u: string) => (/^\s*(javascript|vbscript|data(?!:image\/))\s*:/i.test(u) ? "#" : u);
const UNITLESS = new Set(["opacity", "z-index", "flex", "flex-grow", "flex-shrink", "font-weight", "line-height", "order", "zoom"]);
/** `{{ maxHeight: "620px", gap: 12 }}` or `"max-height:620px"` → CSS text. */
function style(v: string): string {
if (!v.startsWith("{")) return v;
const parts: string[] = [];
for (const m of v.matchAll(/([A-Za-z-]+)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?[\d.]+))/g)) {
const prop = m[1]!.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
const val = m[2] ?? m[3] ?? (UNITLESS.has(prop) || m[4] === "0" ? m[4]! : `${m[4]}px`);
parts.push(`${prop}:${val}`);
}
return parts.join(";");
}
const VOID = new Set(["img", "br", "hr", "source"]);
/** Block-level HTML taken on a line of its own (inline HTML other than <br> stays text). */
const HTML_TAGS = new Set([...VOID, "div", "video", "figure", "figcaption", "picture"]);
const HTML_ATTRS = new Set(["src", "srcset", "alt", "title", "width", "height", "loading", "href", "id", "controls", "autoplay", "muted", "loop", "playsinline", "poster", "type"]);
/** Raw HTML, with the attributes above, class/className and style (JSX objects become CSS). */
function htmlTag(t: Tag, body: string | null, c: Ctx): string {
const out: string[] = [];
for (const [k, v] of Object.entries(t.attrs)) {
const key = k === "className" ? "class" : k;
if (v === true) {
if (HTML_ATTRS.has(key)) out.push(` ${key}`);
continue;
}
const val = attr(t.attrs, k)!;
if (key === "style") out.push(` style="${escape(style(val))}"`);
else if (key === "class" || HTML_ATTRS.has(key)) out.push(` ${key}="${escape(key === "src" || key === "href" ? safeUrl(val) : val)}"`);
}
const open = `<${t.name}${out.join("")}>`;
return VOID.has(t.name) ? open : `${open}${body ? render(body, c) : ""}</${t.name}>`;
}
const ICON = (d: string) =>
`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
const ICONS: Record<string, string> = {
note: ICON('<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>'),
info: ICON('<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>'),
tip: ICON('<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5M9 18h6M10 22h4"/>'),
warning: ICON('<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>'),
danger: ICON('<path d="M7.86 2h8.28L22 7.86v8.28L16.14 22H7.86L2 16.14V7.86z"/><path d="M12 8v4M12 16h.01"/>'),
check: ICON('<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>'),
};
const chevron = (size = 16) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const callout = (kind: string, body: string) => `<div class="${kind === "note" ? "note" : `note ${kind}`}" role="note">${ICONS[kind]}<div>${body}</div></div>`;
/** A Mintlify component; `body` is its Markdown (dedented), null when self-closing. */
function component(t: Tag, body: string | null, c: Ctx): string {
const a = t.attrs;
const md = (s: string | null) => (s ? render(s, c) : "");
const title = (k = "title") => inline(escape(attr(a, k) ?? ""), c.o);
switch (t.name) {
case "Note":
case "Tip":
case "Info":
case "Warning":
case "Check":
case "Danger":
return callout(t.name.toLowerCase(), md(body));
case "Steps": {
const outer = c.steps;
c.steps = { n: 0 };
const html = `<ol class="steps">${md(body)}</ol>`;
c.steps = outer;
return html;
}
case "Step": {
if (!c.steps) {
c.steps = { n: 0 };
const html = `<ol class="steps">${component(t, body, c)}</ol>`;
c.steps = undefined;
return html;
}
const n = Number.parseInt(attr(a, "stepNumber") ?? "", 10) || ++c.steps.n;
c.steps.n = n;
return `<li class="step"><span class="step-n" aria-hidden="true">${n}</span><div class="step-t">${title()}</div>${body ? `<div class="step-b">${md(body)}</div>` : ""}</li>`;
}
case "AccordionGroup":
return `<div class="accs">${md(body)}</div>`;
case "Accordion":
case "Expandable":
return `<details class="acc"${flag(a, "defaultOpen") ? " open" : ""}><summary>${title()}${chevron()}</summary><div class="acc-b">${md(body)}</div></details>`;
case "CardGroup":
return `<div class="cards" style="--cols:${cols(a)}">${md(body)}</div>`;
case "Columns":
return `<div class="cols" style="--cols:${cols(a)}">${md(body)}</div>`;
case "Card": {
const href = attr(a, "href");
const icon = attr(a, "icon") && c.o.icon?.(attr(a, "icon")!);
const text = `${a.title ? `<span class="card-t">${title()}</span>` : ""}${body ? `<div class="card-b">${md(body)}</div>` : ""}`;
// `horizontal`: the icon on the left, the text on its right. Laid out inline, so no stylesheet changes for it.
const row = flag(a, "horizontal");
const inner = row
? `${icon ? `<span class="card-i" style="margin:0">${icon}</span>` : ""}<span class="card-c" style="display:flex;flex-direction:column;gap:2px;min-width:0">${text}</span>`
: `${icon ? `<span class="card-i">${icon}</span>` : ""}${text}`;
const cls = row ? ' class="card card-h" style="flex-direction:row;align-items:center;gap:14px"' : ' class="card"';
return href ? `<a${cls} href="${escape(safeUrl(href))}"${/^https?:/.test(href) ? ' target="_blank" rel="noopener"' : ""}>${inner}</a>` : `<div${cls}>${inner}</div>`;
}
case "Frame": {
const caption = attr(a, "caption");
return `<figure class="frame">${md(body)}${caption ? `<figcaption>${inline(escape(caption), c.o)}</figcaption>` : ""}</figure>`;
}
case "Tabs": {
const outer = c.tabs;
c.tabs = { name: `${c.o.idPrefix ?? "md"}-tabs-${++c.ids}`, n: 0 };
const html = `<div class="tabs">${md(body)}</div>`;
c.tabs = outer;
return html;
}
case "CodeGroup": {
// Each fence a tab, labelled with its title or language.
const name = `${c.o.idPrefix ?? "md"}-tabs-${++c.ids}`;
const codes = [...(body ?? "").matchAll(/^```([^\n]*)\n([\s\S]*?)\n?```[ \t]*$/gm)];
return `<div class="tabs codes">${codes
.map((m, k) => {
const f = fenceInfo(m[1]!);
const id = `${name}-${k + 1}`;
return `<input type="radio" name="${name}" id="${id}"${k ? "" : " checked"}><label for="${id}">${escape(f.title || f.lang || "Code")}</label><div class="tab">${pre(m[2]!)}</div>`;
})
.join("")}</div>`;
}
case "Tab": {
if (!c.tabs) return md(body);
const id = `${c.tabs.name}-${++c.tabs.n}`;
return `<input type="radio" name="${c.tabs.name}" id="${id}"${c.tabs.n === 1 ? " checked" : ""}><label for="${id}">${title()}</label><div class="tab">${md(body)}</div>`;
}
default:
// An unknown component: keep what's inside.
return md(body);
}
}
/** Components that sit in a line of text (inline()), even at the start of one. */
const INLINE = new Set(["Icon", "Tooltip"]);
const COMPONENTS = new Set(["Steps", "Step", "AccordionGroup", "Accordion", "Expandable", "Note", "Tip", "Info", "Warning", "Check", "Danger", "CardGroup", "Card", "Columns", "Frame", "Tabs", "Tab", "CodeGroup"]);
/** A component or HTML element starting at src[i], to its closing tag (same-name tags nest). */
function element(src: string, i: number): { tag: Tag; body: string | null; end: number } | null {
const t = readTag(src, i);
if (!t) return null;
const known = /^[A-Z]/.test(t.name) ? !INLINE.has(t.name) : HTML_TAGS.has(t.name);
if (!known) return null;
if (t.selfClosing || VOID.has(t.name)) return { tag: t, body: null, end: t.end };
const re = new RegExp(`<(/?)${t.name}(?=[\\s/>])`, "g");
re.lastIndex = t.end;
let depth = 1;
for (let m = re.exec(src); m; m = re.exec(src)) {
if (m[1]) {
if (--depth === 0) {
const close = src.indexOf(">", m.index);
return { tag: t, body: src.slice(t.end, m.index), end: close + 1 };
}
} else {
const inner = readTag(src, m.index);
if (inner && !inner.selfClosing) depth++;
if (inner) re.lastIndex = inner.end;
}
}
return null;
}
/** Remove the common indent; lines of only spaces become empty. */
function dedent(s: string): string {
const lines = s.replace(/^[ \t]*\n/, "").replace(/\n[ \t]*$/, "").split("\n");
const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => /^ */.exec(l)![0].length));
return lines.map((l) => (l.trim() ? l.slice(Number.isFinite(indent) ? indent : 0) : "")).join("\n");
}
// ── Documents ──────────────────────────────────────────────
/** Split into fenced code, components/HTML and runs of plain Markdown; render each. */
function render(md: string, c: Ctx): string {
const src = md.trim().replace(/^[ \t]+$/gm, "");
const out: string[] = [];
let text: string[] = [];
const flush = () => {
while (text.length && text[0] === "") text.shift();
while (text.length && text.at(-1) === "") text.pop();
if (text.length) out.push(...blocks(text.join("\n"), c));
text = [];
};
let i = 0;
while (i < src.length) {
let eol = src.indexOf("\n", i);
if (eol < 0) eol = src.length;
const line = src.slice(i, eol);
const lead = /^ */.exec(line)![0].length;
// ```fences, blank lines and all (the core took them only without blank lines).
if (line.startsWith("```")) {
const close = src.slice(eol).search(/\n```[ \t]*(\n|$)/);
if (close >= 0) {
const body = src.slice(eol + 1, eol + close);
const end = src.indexOf("\n", eol + close + 1);
const next = end < 0 ? src.length : end;
const blankAfter = next >= src.length || src[next + 1] === "\n";
const startsBlock = text.length === 0 || text.at(-1) === "";
const { title } = fenceInfo(line.slice(3));
if (title || !(startsBlock && blankAfter && !body.includes("\n\n") && /^```$/.test(src.slice(eol + close + 1, next)))) c.extended = true;
flush();
out.push(pre(body, title));
i = next + 1;
continue;
}
}
// MDX comments: {/* … */} and <!-- … -->, on their own lines.
const comment = /^\s*(\{\/\*|<!--)/.test(line) && /^[ \t]*(\{\/\*[\s\S]*?\*\/\}|<!--[\s\S]*?-->)[ \t]*(\n|$)/.exec(src.slice(i));
if (comment) {
c.extended = true;
i += comment[0].length;
continue;
}
// A list with blank lines between its items or blocks under them (a tight one goes to blocks()).
if (lead < 4 && (text.length === 0 || text.at(-1) === "") && ITEM.test(line)) {
const l = looseList(src, i, c);
if (l) {
c.extended = true;
flush();
out.push(l.html);
i = l.end;
continue;
}
}
if (line[lead] === "<") {
const el = element(src, i + lead);
if (el) {
c.extended = true;
flush();
const body = el.body === null ? null : dedent(el.body);
out.push(/^[A-Z]/.test(el.tag.name) ? component(el.tag, body, c) : htmlTag(el.tag, body, c));
// Text after the closing tag on its line carries on as the next line.
i = el.end;
while (src[i] === " " || src[i] === "\t") i++;
if (src[i] === "\n") i++;
continue;
}
}
if (line.includes("<Icon") || line.includes("<Tooltip")) c.extended = true;
text.push(line);
i = eol + 1;
}
flush();
return out.join("\n");
}
/** Markdown to HTML. */
export function markdown(md: string, o: MarkdownOptions = {}): string {
return renderMarkdown(md, o).html;
}
/** Markdown to HTML, and whether it used anything beyond the core (so the page needs DOCS_CSS.extra). */
export function renderMarkdown(md: string, o: MarkdownOptions = {}): Rendered {
const c: Ctx = { o, extended: false, ids: 0 };
const html = render(md, c);
return { html, extended: c.extended };
}
/** YAML-style front matter (`key: value` lines between `---`), and the Markdown after it. */
export function frontMatter(md: string): { data: Record<string, string>; body: string } {
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(md);
if (!m) return { data: {}, body: md };
const data: Record<string, string> = {};
for (const line of m[1]!.split(/\r?\n/)) {
const kv = /^([\w-]+):\s*(.*)$/.exec(line);
if (kv) data[kv[1]!] = kv[2]!.replace(/^(["'])(.*)\1$/, "$2");
}
return { data, body: md.slice(m[0].length) };
}
/** A page's description for search and link previews: its first paragraph as plain text, about `max` characters. */
export function lede(md: string, max = 160): string {
const p = md.split(/\n{2,}/).find((b) => !/^(#|```|- |\d+\. |_|\||>|<|\{\/\*|---)/.test(b)) ?? "";
const t = p.replace(/\n/g, " ").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/[`*]/g, "").replace(/(^|\W)_([^_]+)_/g, "$1$2").trim();
if (t.length <= max) return t;
const end = t.lastIndexOf(". ", max - 1);
return end >= 60 ? t.slice(0, end + 1) : `${t.slice(0, t.lastIndexOf(" ", max - 3))}…`;
}
// ── Around the article ─────────────────────────────────────
export interface DocLink {
href: string;
title: string;
}
export interface DocGroup {
name: string;
/** SVG markup shown before the name (svgIcon() in lib/docs-icons.ts), as Mintlify's group icons. */
icon?: string;
/** Pages, or a group inside the group (a folding sub-menu; `open` starts it open). */
pages: (DocLink | (DocGroup & { open?: boolean }))[];
}
/** The side menu: each group's name, then its pages, with the current one marked. */
export function docMenu(groups: DocGroup[], current: string): string {
const links = (pages: DocGroup["pages"]): string =>
pages
.map((x) =>
"href" in x
? `<a href="${escape(x.href)}"${x.href === current ? ' aria-current="page"' : ""}>${escape(x.title)}</a>`
: `<details class="dgrp"${x.open || inGroup(x, current) ? " open" : ""}><summary>${x.icon ?? ""}${escape(x.name)}${chevron()}</summary>${links(x.pages)}</details>`,
)
.join("");
return groups.map((g) => `<b>${g.icon ?? ""}${escape(g.name)}</b>${links(g.pages)}`).join("");
}
const inGroup = (g: DocGroup, href: string): boolean => g.pages.some((x) => ("href" in x ? x.href === href : inGroup(x, href)));
/** Every page of the menu in order (for previous/next, sitemaps, llms.txt). */
export const menuPages = (groups: DocGroup[]): DocLink[] => {
const flat = (pages: DocGroup["pages"]): DocLink[] => pages.flatMap((x) => ("href" in x ? [x] : flat(x.pages)));
return groups.flatMap((g) => flat(g.pages));
};
/** "On this page": a link to each `##` heading (outside code); `headingId` as given to renderMarkdown(). */
export function tocLinks(md: string, o: Pick<MarkdownOptions, "headingId"> = {}): string {
const prose = md.replace(/^```[^\n]*\n[\s\S]*?\n```[ \t]*$/gm, "");
return [...prose.matchAll(/^## (.+)$/gm)].map((m) => `<a href="#${escape((o.headingId ?? headingId)(m[1]!))}">${inline(escape(m[1]!))}</a>`).join("");
}
/** Previous and next page cards. */
export const prevNext = (prev: DocLink | undefined, next: DocLink | undefined, labels = { prev: "Previous", next: "Next" }) =>
`<div class="pn">${prev ? `<a href="${escape(prev.href)}"><span>${escape(labels.prev)}</span><b>${escape(prev.title)}</b></a>` : ""}${next ? `<a class="next" href="${escape(next.href)}"><span>${escape(labels.next)}</span><b>${escape(next.title)}</b></a>` : ""}</div>`;
/** "Last updated" under the title. */
export const updatedLine = (day: string, label = "Last updated") => `<p class="upd">${escape(label)} <time datetime="${escape(day)}">${escape(day)}</time></p>`;
/** Copy the page's Markdown, or open it in Claude or ChatGPT with `prompt`. Needs DOCS_SCRIPT. */
export function askRow(o: { markdownUrl: string; prompt: string; labels?: { copy: string; claude: string; chatgpt: string } }): string {
const q = encodeURIComponent(o.prompt);
const l = o.labels ?? { copy: "Copy page", claude: "Open in Claude", chatgpt: "Open in ChatGPT" };
return `<div class="ask"><button type="button" data-copy-md="${escape(o.markdownUrl)}">${escape(l.copy)}</button><a href="https://claude.ai/new?q=${q}" target="_blank" rel="noopener">${escape(l.claude)}</a><a href="https://chatgpt.com/?q=${q}" target="_blank" rel="noopener">${escape(l.chatgpt)}</a></div>`;
}
/** Put `row` right after the article's first paragraph (the lede under the title). */
export const afterLede = (html: string, row: string) => {
const i = html.indexOf("</p>");
return i < 0 ? row + html : html.slice(0, i + 4) + row + html.slice(i + 4);
};
/**
* The docs page: the menu (folded into one row on phones), the article and "On this page".
* `article` is the inside of <article class="doc">: the rendered Markdown and anything after it.
*/
export function docsLayout(o: { menu: string; title: string; article: string; toc: string; label?: string; tocLabel?: string }): string {
const label = escape(o.label ?? "Docs");
return (
`<div class="docs">` +
`<details class="dnav-m"><summary><span>${label}</span><b>${escape(o.title)}</b>${chevron(14)}</summary><nav aria-label="${label}">${o.menu}</nav></details>` +
`<nav class="dnav" aria-label="${label}">${o.menu}</nav>` +
`<article class="doc">${o.article}</article>` +
`${o.toc ? `<aside class="toc" aria-label="${escape(o.tocLabel ?? "On this page")}"><b>${escape(o.tocLabel ?? "On this page")}</b>${o.toc}</aside>` : ""}</div>`
);
}
/**
* The page script: a copy button on each code block, "Copy page" (data-copy-md), and the "On this
* page" rail following the section being read. The only script a docs page needs. `labels` for a
* page in another language (the code button's tooltip and label, and what "Copy page" says once done).
*/
export const docsScript = (labels: { copy?: string; copyCode?: string; copied?: string } = {}) => {
const l = { copy: "Copy", copyCode: "Copy this command", copied: "Copied", ...labels };
const js = (v: string) => JSON.stringify(v).replace(/</g, "\\u003c");
return `<script>
const ICONS='<svg class="cp" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="ok" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
document.querySelectorAll(".doc pre").forEach(p=>{const b=document.createElement("button");b.type="button";b.className="cpy";b.title=${js(l.copy)};b.setAttribute("aria-label",${js(l.copyCode)});b.innerHTML=ICONS;b.dataset.copyCode="";p.appendChild(b)});
const done=b=>{if(b.classList.contains("cpy")){b.classList.add("done");return setTimeout(()=>b.classList.remove("done"),1500)}const l=b.textContent;b.textContent=${js(l.copied)};setTimeout(()=>b.textContent=l,1500)};
document.addEventListener("click",async e=>{
const c=e.target.closest("[data-copy-code]");
if(c){await navigator.clipboard.writeText(c.parentElement.querySelector("code").textContent);return done(c)}
const b=e.target.closest("[data-copy-md]");
if(b){await navigator.clipboard.writeText(await (await fetch(b.dataset.copyMd)).text());done(b)}
});
const links=[...document.querySelectorAll(".toc a")];
if(links.length){const io=new IntersectionObserver(es=>es.forEach(en=>{if(en.isIntersecting)links.forEach(l=>l.classList.toggle("on",l.getAttribute("href")==="#"+en.target.id))}),{rootMargin:"-80px 0px -70% 0px"});document.querySelectorAll(".doc h2[id]").forEach(h=>io.observe(h))}
</script>`;
};
/** docsScript() in English. */
export const DOCS_SCRIPT = docsScript();
// ── Search ─────────────────────────────────────────────────
/** One entry of the search index: [address with #section, page title, section heading ("" for the top), plain text]. */
export type SearchEntry = [href: string, page: string, heading: string, text: string];
/** A page's Markdown/MDX as plain text: components' titles kept (Step, Accordion, Card), tags, images and Markdown marks dropped. */
export function plainText(md: string): string {
return md
.replace(/\{\/\*[\s\S]*?\*\/\}|<!--[\s\S]*?-->/g, " ")
.replace(/^```[^\n]*$/gm, " ")
.replace(/<[A-Za-z][^>]*?\stitle=(["'])(.*?)\1[^>]*>/g, " $2 ")
.replace(/<\/?[A-Za-z][^>]*>/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/^\s*(#{1,6}|>|[-*+]|\d+[.)])\s+/gm, "")
.replace(/^\|?[\s:|-]+\|?$/gm, " ")
.replace(/\*\*|__|`/g, "")
.replace(/\|/g, " ")
.replace(/(^|\W)_([^_]+)_(?=\W|$)/g, "$1$2")
.replace(/\s+/g, " ")
.trim();
}
/**
* The search index for docsSearch(): each page cut at its `##` and `###` headings, so a result opens
* at its section. Serve it as JSON (`JSON.stringify(searchIndex(pages))`) next to the pages; the
* script fetches it the first time search opens. `headingId` as given to renderMarkdown().
*/
export function searchIndex(pages: { href: string; title: string; md: string }[], o: Pick<MarkdownOptions, "headingId"> = {}): SearchEntry[] {
const id = o.headingId ?? headingId;
const out: SearchEntry[] = [];
for (const p of pages) {
const prose = p.md.replace(/^```[^\n]*\n[\s\S]*?\n```[ \t]*$/gm, (code) => code.replace(/^#/gm, " #"));
const parts = prose.split(/^(#{2,3} .+)$/m);
out.push([p.href, p.title, "", plainText(parts[0]!)]);
for (let i = 1; i < parts.length; i += 2) {
const heading = parts[i]!.replace(/^#+ /, "").trim();
out.push([`${p.href}#${id(heading)}`, p.title, plainText(heading), plainText(parts[i + 1] ?? "")]);
}
}
return out;
}
const SEARCH_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`;
/**
* Search over the pages, with no service behind it: a button for the header (it shrinks to an icon
* on phones), a dialog, and a small script that fetches `index` (searchIndex() as JSON) on first
* open. Ctrl K / ⌘K or "/" opens it; ↑ ↓ Enter pick a result; Esc closes. Matching is by parts of
* words in any language (every word typed must appear; spaces don't matter, so 프레임관리 finds
* 프레임 관리), titles and headings first. Styles: DOCS_CSS.search.
*/
export function docsSearch(o: { index: string; labels?: { button?: string; placeholder?: string; empty?: string; close?: string } }): { button: string; dialog: string } {
const l = { button: "Search", placeholder: "Search the docs", empty: "No results", close: "Close", ...o.labels };
const js = (v: string) => JSON.stringify(v).replace(/</g, "\\u003c");
const button = `<button type="button" class="srch-b" data-search-open aria-haspopup="dialog">${SEARCH_ICON}<span>${escape(l.button)}</span><kbd>⌘K</kbd></button>`;
const dialog =
`<dialog class="srch" aria-label="${escape(l.button)}"><div class="srch-in">${SEARCH_ICON}<input type="search" placeholder="${escape(l.placeholder)}" autocomplete="off" spellcheck="false" aria-controls="srch-r"><button type="button" data-search-close>${escape(l.close)}</button></div><ul id="srch-r" role="listbox"></ul></dialog>` +
`<script>(()=>{const d=document.querySelector("dialog.srch"),q=d.querySelector("input"),ul=d.querySelector("ul"),E=${js(l.empty)};let ix,sel=0,hits=[];
const esc=s=>s.replace(/[&<>"]/g,c=>"&#"+c.charCodeAt(0)+";"),norm=s=>s.toLowerCase(),flat=s=>s.replace(/\\s+/g,"");
const load=()=>ix||(ix=fetch(${js(o.index)}).then(r=>r.json()).then(a=>a.map(e=>{const h=norm(e[1]+" "+e[2]+" "+e[3]);return{e,h,f:flat(h),t:flat(norm(e[1])),s:flat(norm(e[2]))}})).catch(()=>(ix=null,[])));
const has=(x,w)=>x.h.includes(w)||x.f.includes(w);
const snip=(t,ws)=>{const lt=norm(t);let i=-1;for(const w of ws){i=lt.indexOf(w);if(i>=0)break}const a=Math.max(0,i-40),s=(a?"…":"")+t.slice(a,a+140)+(t.length>a+140?"…":"");let h=esc(s);for(const w of ws)if(w)h=h.replace(new RegExp(esc(w).replace(/[.*+?^\${}()|[\\]\\\\]/g,"\\\\amp;"),"gi"),m=>"<mark>"+m+"</mark>");return h};
const show=()=>{[...ul.children].forEach((li,i)=>li.setAttribute("aria-selected",i===sel));ul.children[sel]?.scrollIntoView({block:"nearest"})};
const run=async()=>{const v=norm(q.value.trim());if(!v){ul.innerHTML="";hits=[];return}const ws=v.split(/\\s+/),all=await load();if(v!==norm(q.value.trim()))return;
hits=all.filter(x=>ws.every(w=>has(x,w))).map(x=>({x,r:ws.reduce((n,w)=>n+(x.t.includes(w)?4:0)+(x.s.includes(w)?2:0),0)+(x.t===flat(v)||x.s===flat(v)?8:0)+(x.e[2]?0:1)})).sort((a,b)=>b.r-a.r).slice(0,20).map(h=>h.x);
sel=0;ul.innerHTML=hits.length?hits.map((x,i)=>'<li role="option"><a href="'+esc(x.e[0])+'"><b>'+esc(x.e[2]||x.e[1])+'</b>'+(x.e[2]?'<span>'+esc(x.e[1])+'</span>':"")+'<p>'+snip(x.e[3]||x.e[2],ws)+'</p></a></li>').join(""):'<li class="srch-0">'+esc(E)+'</li>';show()};
const open=()=>{if(!d.open){d.showModal();q.select();load()}};
document.addEventListener("keydown",e=>{if((e.key==="k"||e.key==="K")&&(e.metaKey||e.ctrlKey)||e.key==="/"&&!/input|textarea|select/i.test(document.activeElement.tagName)&&!document.activeElement.isContentEditable){e.preventDefault();open()}});
document.addEventListener("click",e=>{if(e.target.closest("[data-search-open]"))open();else if(e.target===d||e.target.closest("[data-search-close]"))d.close();else if(e.target.closest("dialog.srch a"))d.close()});
q.addEventListener("input",run);
q.addEventListener("keydown",e=>{if(e.key==="ArrowDown"||e.key==="ArrowUp"){e.preventDefault();if(hits.length){sel=(sel+(e.key==="ArrowDown"?1:hits.length-1))%hits.length;show()}}else if(e.key==="Enter"&&hits[sel]){e.preventDefault();d.close();location.href=hits[sel].e[0]}});
})()</script>`;
return { button, dialog };
}
// ── For agents ─────────────────────────────────────────────
/** llms.txt (llmstxt.org): a title, a one-paragraph summary, then sections of links. */
export const llmsTxt = (o: { name: string; summary: string; sections: { name: string; links: { title: string; url: string }[] }[] }) =>
`# ${o.name}\n\n> ${o.summary}\n\n${o.sections.map((s) => `## ${s.name}\n\n${s.links.map((l) => `- [${l.title}](${l.url})`).join("\n")}`).join("\n\n")}\n`;
/** llms-full.txt: every page's Markdown in one file, each under its address. */
export const llmsFullTxt = (o: { title: string; intro: string; pages: { url: string; updated?: string; md: string }[] }) =>
`# ${o.title}\n\n${o.intro}\n\n${o.pages.map((p) => `Source: ${p.url}\n${p.updated ? `Last updated: ${p.updated}\n` : ""}\n${p.md.trim()}`).join("\n\n---\n\n")}\n`;
docs-icons.ts
/*
* Icons for lib/docs.ts (<Card icon="…">, <Icon icon="…" />): lucide icons as SVG strings, with no
* framework. The `lucide` package exports each icon as an IconNode; import only the ones your pages
* use, so a Worker bundles those few and not the whole set:
*
* import { House, Settings } from "lucide";
* import { lucideIcons } from "@/lib/docs-icons";
* renderMarkdown(md, { icon: lucideIcons({ House, Settings }) });
*
* Mintlify pages name Font Awesome icons; FA_TO_LUCIDE turns those into lucide's (`gear` → settings),
* and docIcons() lists the lucide exports a page needs, to write the import above.
*/
/** lucide's icon format: the SVG's children as [tag, attributes]. */
export type IconNode = [tag: string, attrs: Record<string, string | number | undefined>][];
/** An IconNode as an <svg> string: 24×24 view box, stroke in currentColor, hidden from screen readers. */
export function svgIcon(node: IconNode, o: { size?: number; strokeWidth?: number } = {}): string {
const size = o.size ?? 16;
const kids = node
.map(([tag, attrs]) => `<${tag}${Object.entries(attrs).map(([k, v]) => (v === undefined || k === "key" ? "" : ` ${k}="${String(v).replace(/"/g, """)}"`)).join("")}/>`)
.join("");
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${o.strokeWidth ?? 2}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${kids}</svg>`;
}
/**
* Font Awesome names (as Mintlify pages write them) to lucide names. First every icon the Photoito
* guide uses, its pages and docs.json (brands and names lucide doesn't have go to the nearest
* shape: apple → apple, google-play → play), then common ones. A name not here is tried as a
* lucide name as it is.
*/
export const FA_TO_LUCIDE: Record<string, string> = {
// The Photoito guide
apple: "apple",
bell: "bell",
bullhorn: "megaphone",
"chart-column": "chart-column",
"clock-rotate-left": "rotate-ccw-clock",
"cloud-arrow-up": "cloud-upload",
coins: "coins",
comments: "messages-square",
"credit-card": "credit-card",
dashboard: "layout-dashboard",
desktop: "monitor",
"digital-tachograph": "layout-dashboard",
display: "monitor",
droplet: "droplet",
folder: "folder",
gear: "settings",
"google-play": "play",
house: "house",
image: "image",
key: "key",
"mobile-screen": "smartphone",
"plane-landing-gear": "globe",
"power-off": "power",
receipt: "receipt",
"ruler-combined": "ruler-dimension-line",
"share-nodes": "share-2",
seedling: "sprout",
sliders: "sliders-horizontal",
store: "store",
"table-columns": "columns-2",
ticket: "ticket",
"triangle-exclamation": "triangle-alert",
tv: "tv",
"user-group": "users",
"wand-magic-sparkles": "wand-sparkles",
warning: "triangle-alert",
// Common ones
"arrow-up-right-from-square": "external-link",
"arrows-rotate": "refresh-cw",
bolt: "zap",
book: "book",
"book-open": "book-open",
"calendar-days": "calendar-days",
camera: "camera",
"cart-shopping": "shopping-cart",
"chart-line": "chart-line",
"circle-check": "circle-check",
"circle-exclamation": "circle-alert",
"circle-info": "info",
"circle-play": "circle-play",
"circle-question": "circle-question-mark",
"circle-xmark": "circle-x",
envelope: "mail",
"file-lines": "file-text",
fire: "flame",
gauge: "gauge",
headset: "headset",
lightbulb: "lightbulb",
"list-check": "list-checks",
"magnifying-glass": "search",
"money-bill": "banknote",
pen: "pen",
"pen-to-square": "square-pen",
print: "printer",
qrcode: "qr-code",
rotate: "refresh-cw",
"screwdriver-wrench": "wrench",
"trash-can": "trash",
xmark: "x",
};
/** "rotate-ccw-clock" → "RotateCcwClock", the name lucide exports it under. */
export const lucideExport = (name: string) => name.replace(/(^|-)([a-z0-9])/g, (_, __, ch: string) => ch.toUpperCase());
/**
* The `icon` option for renderMarkdown(): a Font Awesome or lucide name to the icon's SVG, from the
* lucide icons you pass by their export names ({ House, Settings }). Nothing for a name not passed.
*/
export function lucideIcons(icons: Record<string, IconNode>, o: { size?: number; strokeWidth?: number } = {}) {
return (name: string): string | undefined => {
const node = icons[lucideExport(FA_TO_LUCIDE[name] ?? name)];
return node && svgIcon(node, o);
};
}
/** The lucide exports a page's icon="…" attributes need, sorted: `import { … } from "lucide"`. */
export const docIcons = (md: string) =>
[...new Set([...md.matchAll(/\bicon=["'{]+([\w-]+)/g)].map((m) => lucideExport(FA_TO_LUCIDE[m[1]!] ?? m[1]!)))].sort();
docs-css.ts
/*
* DGit UI docs styles, as strings: `<style>${DOCS_CSS.content}</style>` in pages built as HTML
* strings (a Worker, a static generator). docs.css has the same rules as a file, for projects that
* import CSS; scripts/docs-css.ts writes it from this one.
*
* Every rule hangs off the classes lib/docs.ts writes (.doc, .docs, .dnav, .toc, .pn, .ask…) and
* reads these colors: --fg (text), --bg (page), --line (hairlines), --muted (secondary text), --card
* and --card2 (fills), --hover, --dim (link underlines), --ok, --warn, --warn-bg. A page that defines
* them (0bridge does) uses the sections as they are; on the DGit theme add `tokens` first.
*
* The look: monochrome, 1px hairlines, 14px corners, no shadows, light and dark from the colors.
* Each section starts and ends with a newline, so `<style>${…}</style>` reads one rule a line.
*/
/**
* The colors above from the DGit theme (dgit.css): --fg is foreground, --bg background, --line
* border, --muted muted-foreground, --card secondary (gray-100), --card2 gray-200, --hover accent,
* --dim gray-500; --ok, --warn and --warn-bg are the theme's own. Inside .doc and .docs, --muted
* and --card are the docs' text and fill colors, not shadcn's. Lists get back the markers
* Tailwind's reset takes away.
*/
const tokens = `
.doc,.docs{--fg:var(--foreground);--bg:var(--background);--line:var(--border);--muted:var(--muted-foreground);--card:var(--secondary);--card2:hsla(var(--dgit-gray-200),1);--hover:var(--accent);--dim:hsla(var(--dgit-gray-500),1)}
.doc ul{list-style:disc}.doc ol{list-style:decimal}
`;
/** One visible focus ring for keyboard users, and no smooth scrolling or long transitions when motion is reduced. */
const focus = `
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--fg);outline-offset:2px;border-radius:6px}
@media (prefers-reduced-motion:reduce){*,::before,::after{scroll-behavior:auto!important;transition-duration:.01ms!important}}
`;
/** Tables (scrolling sideways on their own, never the page), `>` notes, the "Last updated" line and a closing call to action. */
const content = `
.doc .tbl{margin:0 0 18px;overflow-x:auto;border:1px solid var(--line);border-radius:14px}
.doc table{width:100%;border-collapse:collapse;font-size:14px;line-height:1.5}
.doc th,.doc td{padding:10px 14px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line)}
.doc th{font-weight:500;color:var(--muted);font-size:13px;white-space:nowrap}
.doc tbody tr:last-child td{border-bottom:0}
.doc td:first-child{font-weight:500}
.doc blockquote.callout{margin:0 0 16px;padding:12px 16px;border-radius:14px;background:var(--card);color:var(--fg);font-size:15px;line-height:1.6}
.doc blockquote.callout code{background:var(--bg)}
.doc .upd{margin:0 0 12px;font-size:13px;color:var(--muted)}
.doc .cta{margin:40px 0 0;padding:20px 22px;border-radius:14px;background:var(--card)}.doc .cta p{margin:0 0 10px}
.doc .cta a.pill{display:inline-flex;align-items:center;height:36px;padding:0 18px;border-radius:999px;background:var(--fg);color:var(--bg);text-decoration:none;font-weight:500;font-size:14px}
`;
/** A single article in a narrow column (policies, support pages, comparisons): 15px text, bordered code. */
const article = `
.doc{line-height:1.65;font-size:15px}
.doc h1{text-align:left;margin:0 0 4px}
.doc h2{font-size:18px;font-weight:600;letter-spacing:-.2px;margin:32px 0 8px}
.doc p,.doc ul{margin:0 0 12px}.doc ul{padding-left:20px}.doc li{margin:4px 0}
.doc a{color:inherit}.doc code{font:500 13px "Geist Mono",ui-monospace,monospace}
.doc > p:first-of-type{color:var(--muted);margin-bottom:24px}
.doc pre{margin:0 0 16px;padding:12px 16px;border:1px solid var(--line);border-radius:14px;overflow-x:auto}.doc pre code{font-size:13px;line-height:1.6;background:none;padding:0;border-radius:0}
.doc ol{margin:0 0 12px;padding-left:22px}
.doc a.hash{margin-left:8px;color:var(--muted);text-decoration:none;opacity:0}.doc h2:hover a.hash,.doc h3:hover a.hash{opacity:1}
`;
/**
* A docs site: the side menu by group, the article, an "On this page" rail on wide screens, the ask
* row, previous/next; on phones the menu folds into one row. 16px text, filled code with a copy button.
*/
const layout = `
.docs{display:grid;grid-template-columns:200px minmax(0,1fr) 180px;gap:48px;align-items:start;padding-top:24px}
.dnav,.toc{position:sticky;top:84px;display:flex;flex-direction:column;font-size:14px}
.dnav b,.toc b{font-size:12px;font-weight:500;color:var(--muted);padding:0 10px 6px}
.dnav b:not(:first-child){margin-top:18px}
.dnav a{padding:6px 10px;border-radius:8px;color:var(--muted);text-decoration:none}
.dnav a:hover,.toc a:hover{color:var(--fg)}.dnav a[aria-current]{background:var(--card);color:var(--fg);font-weight:500}
.toc a{padding:4px 10px;color:var(--muted);text-decoration:none;font-size:13px;border-left:1px solid var(--line)}
.toc a.on{color:var(--fg);border-left-color:var(--fg)}
.dnav-m{display:none}
.doc{min-width:0;line-height:1.7;font-size:16px}
.doc h1{margin:0 0 12px;text-align:left;font-size:40px;font-weight:500;letter-spacing:-.03em;line-height:1.1}
.doc>p:first-of-type{color:var(--muted);font-size:18px;line-height:1.55;margin-bottom:20px}
.doc h2{font-size:24px;font-weight:500;letter-spacing:-.02em;line-height:1.3;margin:48px 0 12px;scroll-margin-top:88px}
.doc h3{font-size:17px;font-weight:500;margin:28px 0 8px;scroll-margin-top:88px}
.doc h2 a.hash,.doc h3 a.hash{margin-left:8px;color:var(--muted);text-decoration:none;opacity:0;transition:opacity .15s}
.doc h2:hover a.hash,.doc h3:hover a.hash,.doc a.hash:focus-visible{opacity:1}
.doc p,.doc ul,.doc ol{margin:0 0 14px}.doc ul,.doc ol{padding-left:22px}.doc li{margin:6px 0}
.doc li::marker{color:var(--muted)}
.doc a{color:inherit;text-underline-offset:3px;text-decoration-color:var(--dim)}.doc a:hover{text-decoration-color:var(--fg)}
.doc code{font:500 13.5px "Geist Mono",ui-monospace,monospace;background:var(--card);padding:2px 6px;border-radius:6px;overflow-wrap:anywhere}
.doc pre{position:relative;margin:0 0 18px;padding:16px 64px 16px 18px;border:0;border-radius:14px;background:var(--card);overflow-x:auto}
.doc pre code{font-size:13.5px;line-height:1.65;background:none;padding:0;border-radius:0;overflow-wrap:normal}
.doc pre .cpy{position:absolute;top:10px;right:10px;width:32px;height:32px;padding:0;display:grid;place-items:center;border-radius:9px;border:1px solid var(--line);background:var(--bg);color:var(--muted);cursor:pointer;transition:color .15s,background .15s}
.copybtn:hover,.cpy:hover{background:var(--hover);color:var(--fg)} .copybtn .ok,.cpy .ok{display:none} .copybtn.done .cp,.cpy.done .cp{display:none} .copybtn.done .ok,.cpy.done .ok{display:block;color:var(--ok)}
.ask{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 36px;padding-bottom:28px;border-bottom:1px solid var(--line)}
.ask a,.ask button{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:none;color:var(--fg);font-family:inherit;font-size:13px;font-weight:500;line-height:1;text-decoration:none;cursor:pointer}
.ask a:hover,.ask button:hover{background:var(--hover)}
.pn{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:56px}
.pn a{display:flex;flex-direction:column;gap:2px;padding:16px 18px;border-radius:14px;background:var(--card);text-decoration:none;transition:background .15s}
.pn a:hover{background:var(--card2)} .pn a.next{text-align:right;grid-column:2}
.pn span{font-size:12px;color:var(--muted)} .pn b{font-weight:500}
@media (max-width:1100px){.docs{grid-template-columns:190px minmax(0,1fr)}.toc{display:none}}
@media (max-width:820px){
.docs{grid-template-columns:minmax(0,1fr);gap:0;padding:0 8px}
.dnav{display:none}
.dnav-m{display:block;margin:0 -8px 24px;border-radius:14px;background:var(--card)}
.dnav-m summary{display:flex;align-items:center;justify-content:space-between;gap:8px;height:46px;padding:0 16px;list-style:none;cursor:pointer;font-size:15px}
.dnav-m summary::-webkit-details-marker{display:none}
.dnav-m summary span{color:var(--muted);font-size:13px}.dnav-m summary b{font-weight:500;margin-right:auto}
.dnav-m summary svg{transition:transform .2s}.dnav-m[open] summary svg{transform:rotate(180deg)}
.dnav-m nav{display:flex;flex-direction:column;padding:0 8px 10px;font-size:15px;max-height:60vh;overflow:auto}
.dnav-m nav b{font-size:12px;font-weight:500;color:var(--muted);padding:12px 10px 4px}
.dnav-m nav a{padding:10px;border-radius:8px;color:var(--muted);text-decoration:none}
.dnav-m nav a[aria-current]{background:var(--card2);color:var(--fg);font-weight:500}
.doc h1{font-size:32px}.doc>p:first-of-type{font-size:17px}.doc h2{font-size:21px;margin-top:40px}
.pn{grid-template-columns:1fr}.pn a.next{grid-column:auto}
}
`;
/**
* What goes beyond the core (renderMarkdown() says when a page used it): h4–h6, rules, images,
* nested and loose lists, code titles, and the components: callouts, steps, accordions, cards,
* columns, frames, tabs, code groups, icons, tooltips, and folding groups in the side menu.
*/
const extra = `
.doc h4,.doc h5,.doc h6{font-size:15px;font-weight:600;margin:24px 0 6px;scroll-margin-top:88px}
.doc h4 a.hash,.doc h5 a.hash,.doc h6 a.hash{margin-left:8px;color:var(--muted);text-decoration:none;opacity:0}.doc h4:hover a.hash,.doc h5:hover a.hash,.doc h6:hover a.hash{opacity:1}
.doc hr{margin:32px 0;border:0;border-top:1px solid var(--line)}
.doc img{max-width:100%;height:auto}
.doc li>ul,.doc li>ol{margin:4px 0 0}
.doc li>p,.doc li>pre,.doc li>.tbl{margin:8px 0}.doc li>:first-child{margin-top:0}.doc li>:last-child{margin-bottom:0}
.doc pre[data-title]{position:relative;padding-top:50px}
.doc pre[data-title]::before{content:attr(data-title);position:absolute;top:0;left:0;right:0;padding:9px 18px;border-bottom:1px solid var(--line);font:500 12.5px/1.5 "Geist Mono",ui-monospace,monospace;color:var(--muted);white-space:nowrap}
.doc .ic{display:inline-flex;vertical-align:-.125em}.doc .ic svg{width:1em;height:1em}
.doc .tt{text-decoration:underline dotted var(--dim);text-underline-offset:3px;cursor:help}
.doc .note{display:flex;gap:12px;margin:0 0 16px;padding:12px 16px;border-radius:14px;background:var(--card);font-size:15px;line-height:1.6}
.doc .note>svg{flex:none;margin-top:4px;color:var(--muted)}
.doc .note>div{flex:1;min-width:0}.doc .note>div>:last-child{margin-bottom:0}.doc .note code{background:var(--bg)}
.doc .note.warning,.doc .note.danger{background:var(--warn-bg)}.doc .note.warning>svg,.doc .note.danger>svg{color:var(--warn)}.doc .note.check>svg{color:var(--ok)}
.doc .steps{list-style:none;margin:0 0 18px;padding:0}
.doc .step{position:relative;margin:0;padding:0 0 24px 44px}
.doc .step::before{content:"";position:absolute;left:13px;top:34px;bottom:6px;width:1px;background:var(--line)}
.doc .step:last-child{padding-bottom:4px}.doc .step:last-child::before{display:none}
.doc .step-n{position:absolute;left:0;top:0;display:grid;place-items:center;width:27px;height:27px;border:1px solid var(--line);border-radius:999px;background:var(--bg);font:500 13px/1 "Geist Mono",ui-monospace,monospace}
.doc .step-t{padding-top:1px;margin-bottom:8px;font-weight:500;line-height:1.55}
.doc .step-b>:last-child{margin-bottom:0}
.doc .acc,.doc .accs{margin:0 0 12px;border:1px solid var(--line);border-radius:14px}.doc .accs{margin-bottom:18px}
.doc .accs>.acc{margin:0;border:0;border-radius:0}.doc .accs>.acc+.acc{border-top:1px solid var(--line)}
.doc .acc summary{display:flex;align-items:center;gap:12px;padding:12px 16px;font-weight:500;line-height:1.5;list-style:none;cursor:pointer}
.doc .acc summary::-webkit-details-marker{display:none}
.doc .acc summary svg{flex:none;margin-left:auto;color:var(--muted);transition:transform .2s}.doc .acc summary:hover svg{color:var(--fg)}.doc .acc[open]>summary svg{transform:rotate(180deg)}
.doc .acc-b{padding:0 16px 14px}.doc .acc-b>:last-child{margin-bottom:0}
.doc .cards,.doc .cols{display:grid;grid-template-columns:repeat(var(--cols,2),minmax(0,1fr));gap:12px;margin:0 0 18px}
.doc .cols{gap:16px;align-items:start}.doc .cols>*{margin:0}
.doc .card{display:flex;flex-direction:column;gap:4px;margin:0 0 12px;padding:16px 18px;border:1px solid var(--line);border-radius:14px;color:inherit;text-decoration:none;transition:background .15s}
.doc .cards>.card{margin:0}.doc a.card:hover{background:var(--hover)}
.doc .card-i{display:flex;margin-bottom:6px}.doc .card-i svg{width:20px;height:20px}
.doc .card-t{font-weight:500;line-height:1.4}
.doc .card-b{color:var(--muted);font-size:14px;line-height:1.55}.doc .card-b p{margin:0 0 8px}.doc .card-b>:last-child{margin-bottom:0}
.doc .frame{margin:0 0 18px;padding:8px;border:1px solid var(--line);border-radius:14px}.doc .frame>p{margin:0}
.doc .frame img,.doc .frame video{display:block;max-width:100%;height:auto;margin:0 auto;border-radius:8px}
.doc .frame figcaption{padding:8px 8px 2px;text-align:center;font-size:13px;line-height:1.5;color:var(--muted)}
.doc .tabs{position:relative;display:flex;flex-wrap:wrap;margin:0 0 18px;border:1px solid var(--line);border-radius:14px}
.doc .tabs>input{position:absolute;opacity:0;pointer-events:none}
.doc .tabs>label{padding:10px 14px;margin-bottom:-1px;border-bottom:1px solid transparent;color:var(--muted);font-size:14px;font-weight:500;cursor:pointer}
.doc .tabs>label:hover,.doc .tabs>input:checked+label{color:var(--fg)}.doc .tabs>input:checked+label{border-bottom-color:var(--fg)}
.doc .tabs>input:focus-visible+label{outline:2px solid var(--fg);outline-offset:-4px;border-radius:10px}
.doc .tab{display:none;order:1;width:100%;padding:16px 18px;border-top:1px solid var(--line)}.doc .tab>:last-child{margin-bottom:0}
.doc .tabs>input:checked+label+.tab{display:block}
.doc .codes{background:var(--card)}.doc .codes>.tab{padding:0}.doc .codes pre{margin:0;border-radius:0 0 13px 13px}
.dgrp summary{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-radius:8px;color:var(--muted);list-style:none;cursor:pointer}
.dgrp summary::-webkit-details-marker{display:none}
.dgrp summary:hover{color:var(--fg)}.dgrp summary svg{transition:transform .2s}.dgrp[open]>summary svg{transform:rotate(180deg)}
.dnav .dgrp>a,.dnav-m .dgrp>a{display:block;padding-left:22px}
@media (max-width:640px){.doc .cards,.doc .cols{grid-template-columns:minmax(0,1fr)}}
`;
/**
* Search (docsSearch() in lib/docs.ts): the header button (an icon on phones), the dialog, its
* results. Opt in: docsStyle({ search: true }) or `${DOCS_CSS.search}`.
*/
const search = `
.srch-b{flex:none;display:inline-flex;align-items:center;gap:8px;height:34px;min-width:220px;padding:0 8px 0 12px;border:1px solid var(--line);border-radius:999px;background:var(--card);color:var(--muted);font:inherit;font-size:14px;cursor:pointer}
.srch-b:hover{color:var(--fg)}.srch-b svg{flex:none}
.srch-b kbd{margin-left:auto;padding:3px 6px;border:1px solid var(--line);border-radius:6px;background:var(--bg);font:500 11px/1 "Geist Mono",ui-monospace,monospace;color:var(--muted)}
@media (hover:none){.srch-b kbd{display:none}}
@media (max-width:820px){.srch-b{min-width:0;width:34px;padding:0;justify-content:center}.srch-b span,.srch-b kbd{display:none}}
dialog.srch{width:min(640px,calc(100vw - 24px));max-height:min(560px,calc(100dvh - 48px));margin:10vh auto auto;padding:0;border:1px solid var(--line);border-radius:16px;background:var(--bg);color:var(--fg);overflow:hidden;flex-direction:column}
dialog.srch[open]{display:flex}dialog.srch::backdrop{background:rgba(0,0,0,.45)}
.srch-in{display:flex;align-items:center;gap:10px;padding:0 10px 0 16px;border-bottom:1px solid var(--line);color:var(--muted)}
.srch-in input{flex:1;min-width:0;height:52px;border:0;background:none;color:var(--fg);font:inherit;font-size:16px;outline:none}
.srch-in input::-webkit-search-cancel-button{display:none}
.srch-in button{height:28px;padding:0 10px;border:1px solid var(--line);border-radius:8px;background:none;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
dialog.srch ul{list-style:none;margin:0;padding:6px;overflow:auto}dialog.srch ul:empty{display:none}
dialog.srch li a{display:block;padding:10px 12px;border-radius:10px;color:inherit;text-decoration:none}
dialog.srch li[aria-selected="true"] a,dialog.srch li a:hover{background:var(--card)}
dialog.srch li b{font-weight:500}dialog.srch li span{margin-left:8px;font-size:12px;color:var(--muted)}
dialog.srch li p{margin:2px 0 0;font-size:13px;line-height:1.5;color:var(--muted);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
dialog.srch mark{background:none;color:var(--fg);font-weight:600}
.srch-0{padding:24px 12px;text-align:center;color:var(--muted);font-size:14px}
@media (max-width:640px){dialog.srch{margin:8px auto auto;max-height:calc(100dvh - 16px)}}
`;
export const DOCS_CSS = { tokens, focus, content, article, layout, extra, search };
/** A docs site's styles in one <style>: focus, content, layout and extra, with the DGit color names first when `tokens`, and search's when `search`. */
export const docsStyle = (o: { tokens?: boolean; search?: boolean } = {}) => `<style>${o.tokens ? tokens : ""}${focus}${content}${layout}${extra}${o.search ? search : ""}</style>`;
docs.css
/*
* DGit UI docs: the styles for the HTML lib/docs.ts writes (a docs site: side menu, article, "On
* this page", and the Mintlify-style components). Import it after the theme:
*
* @import "tailwindcss";
* @import "./dgit.css";
* @import "./docs.css";
*
* Generated from lib/docs-css.ts (scripts/docs-css.ts in dgitco/ui); edit that file. The narrow
* single-article look (DOCS_CSS.article) is only in lib/docs-css.ts.
*/
/* The docs' color names, from the DGit theme. Inside .doc and .docs, --muted and --card are the
docs' secondary text and fill colors, not shadcn's. */
.doc,.docs{--fg:var(--foreground);--bg:var(--background);--line:var(--border);--muted:var(--muted-foreground);--card:var(--secondary);--card2:hsla(var(--dgit-gray-200),1);--hover:var(--accent);--dim:hsla(var(--dgit-gray-500),1)}
.doc ul{list-style:disc}.doc ol{list-style:decimal}
/* Focus ring, reduced motion */
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--fg);outline-offset:2px;border-radius:6px}
@media (prefers-reduced-motion:reduce){*,::before,::after{scroll-behavior:auto!important;transition-duration:.01ms!important}}
/* Tables, notes, "Last updated", call to action */
.doc .tbl{margin:0 0 18px;overflow-x:auto;border:1px solid var(--line);border-radius:14px}
.doc table{width:100%;border-collapse:collapse;font-size:14px;line-height:1.5}
.doc th,.doc td{padding:10px 14px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line)}
.doc th{font-weight:500;color:var(--muted);font-size:13px;white-space:nowrap}
.doc tbody tr:last-child td{border-bottom:0}
.doc td:first-child{font-weight:500}
.doc blockquote.callout{margin:0 0 16px;padding:12px 16px;border-radius:14px;background:var(--card);color:var(--fg);font-size:15px;line-height:1.6}
.doc blockquote.callout code{background:var(--bg)}
.doc .upd{margin:0 0 12px;font-size:13px;color:var(--muted)}
.doc .cta{margin:40px 0 0;padding:20px 22px;border-radius:14px;background:var(--card)}.doc .cta p{margin:0 0 10px}
.doc .cta a.pill{display:inline-flex;align-items:center;height:36px;padding:0 18px;border-radius:999px;background:var(--fg);color:var(--bg);text-decoration:none;font-weight:500;font-size:14px}
/* Docs layout: side menu, article, "On this page", ask row, previous/next */
.docs{display:grid;grid-template-columns:200px minmax(0,1fr) 180px;gap:48px;align-items:start;padding-top:24px}
.dnav,.toc{position:sticky;top:84px;display:flex;flex-direction:column;font-size:14px}
.dnav b,.toc b{font-size:12px;font-weight:500;color:var(--muted);padding:0 10px 6px}
.dnav b:not(:first-child){margin-top:18px}
.dnav a{padding:6px 10px;border-radius:8px;color:var(--muted);text-decoration:none}
.dnav a:hover,.toc a:hover{color:var(--fg)}.dnav a[aria-current]{background:var(--card);color:var(--fg);font-weight:500}
.toc a{padding:4px 10px;color:var(--muted);text-decoration:none;font-size:13px;border-left:1px solid var(--line)}
.toc a.on{color:var(--fg);border-left-color:var(--fg)}
.dnav-m{display:none}
.doc{min-width:0;line-height:1.7;font-size:16px}
.doc h1{margin:0 0 12px;text-align:left;font-size:40px;font-weight:500;letter-spacing:-.03em;line-height:1.1}
.doc>p:first-of-type{color:var(--muted);font-size:18px;line-height:1.55;margin-bottom:20px}
.doc h2{font-size:24px;font-weight:500;letter-spacing:-.02em;line-height:1.3;margin:48px 0 12px;scroll-margin-top:88px}
.doc h3{font-size:17px;font-weight:500;margin:28px 0 8px;scroll-margin-top:88px}
.doc h2 a.hash,.doc h3 a.hash{margin-left:8px;color:var(--muted);text-decoration:none;opacity:0;transition:opacity .15s}
.doc h2:hover a.hash,.doc h3:hover a.hash,.doc a.hash:focus-visible{opacity:1}
.doc p,.doc ul,.doc ol{margin:0 0 14px}.doc ul,.doc ol{padding-left:22px}.doc li{margin:6px 0}
.doc li::marker{color:var(--muted)}
.doc a{color:inherit;text-underline-offset:3px;text-decoration-color:var(--dim)}.doc a:hover{text-decoration-color:var(--fg)}
.doc code{font:500 13.5px "Geist Mono",ui-monospace,monospace;background:var(--card);padding:2px 6px;border-radius:6px;overflow-wrap:anywhere}
.doc pre{position:relative;margin:0 0 18px;padding:16px 64px 16px 18px;border:0;border-radius:14px;background:var(--card);overflow-x:auto}
.doc pre code{font-size:13.5px;line-height:1.65;background:none;padding:0;border-radius:0;overflow-wrap:normal}
.doc pre .cpy{position:absolute;top:10px;right:10px;width:32px;height:32px;padding:0;display:grid;place-items:center;border-radius:9px;border:1px solid var(--line);background:var(--bg);color:var(--muted);cursor:pointer;transition:color .15s,background .15s}
.copybtn:hover,.cpy:hover{background:var(--hover);color:var(--fg)} .copybtn .ok,.cpy .ok{display:none} .copybtn.done .cp,.cpy.done .cp{display:none} .copybtn.done .ok,.cpy.done .ok{display:block;color:var(--ok)}
.ask{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 36px;padding-bottom:28px;border-bottom:1px solid var(--line)}
.ask a,.ask button{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:none;color:var(--fg);font-family:inherit;font-size:13px;font-weight:500;line-height:1;text-decoration:none;cursor:pointer}
.ask a:hover,.ask button:hover{background:var(--hover)}
.pn{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:56px}
.pn a{display:flex;flex-direction:column;gap:2px;padding:16px 18px;border-radius:14px;background:var(--card);text-decoration:none;transition:background .15s}
.pn a:hover{background:var(--card2)} .pn a.next{text-align:right;grid-column:2}
.pn span{font-size:12px;color:var(--muted)} .pn b{font-weight:500}
@media (max-width:1100px){.docs{grid-template-columns:190px minmax(0,1fr)}.toc{display:none}}
@media (max-width:820px){
.docs{grid-template-columns:minmax(0,1fr);gap:0;padding:0 8px}
.dnav{display:none}
.dnav-m{display:block;margin:0 -8px 24px;border-radius:14px;background:var(--card)}
.dnav-m summary{display:flex;align-items:center;justify-content:space-between;gap:8px;height:46px;padding:0 16px;list-style:none;cursor:pointer;font-size:15px}
.dnav-m summary::-webkit-details-marker{display:none}
.dnav-m summary span{color:var(--muted);font-size:13px}.dnav-m summary b{font-weight:500;margin-right:auto}
.dnav-m summary svg{transition:transform .2s}.dnav-m[open] summary svg{transform:rotate(180deg)}
.dnav-m nav{display:flex;flex-direction:column;padding:0 8px 10px;font-size:15px;max-height:60vh;overflow:auto}
.dnav-m nav b{font-size:12px;font-weight:500;color:var(--muted);padding:12px 10px 4px}
.dnav-m nav a{padding:10px;border-radius:8px;color:var(--muted);text-decoration:none}
.dnav-m nav a[aria-current]{background:var(--card2);color:var(--fg);font-weight:500}
.doc h1{font-size:32px}.doc>p:first-of-type{font-size:17px}.doc h2{font-size:21px;margin-top:40px}
.pn{grid-template-columns:1fr}.pn a.next{grid-column:auto}
}
/* Beyond the core: h4–h6, rules, images, nested lists, components, menu groups */
.doc h4,.doc h5,.doc h6{font-size:15px;font-weight:600;margin:24px 0 6px;scroll-margin-top:88px}
.doc h4 a.hash,.doc h5 a.hash,.doc h6 a.hash{margin-left:8px;color:var(--muted);text-decoration:none;opacity:0}.doc h4:hover a.hash,.doc h5:hover a.hash,.doc h6:hover a.hash{opacity:1}
.doc hr{margin:32px 0;border:0;border-top:1px solid var(--line)}
.doc img{max-width:100%;height:auto}
.doc li>ul,.doc li>ol{margin:4px 0 0}
.doc li>p,.doc li>pre,.doc li>.tbl{margin:8px 0}.doc li>:first-child{margin-top:0}.doc li>:last-child{margin-bottom:0}
.doc pre[data-title]{position:relative;padding-top:50px}
.doc pre[data-title]::before{content:attr(data-title);position:absolute;top:0;left:0;right:0;padding:9px 18px;border-bottom:1px solid var(--line);font:500 12.5px/1.5 "Geist Mono",ui-monospace,monospace;color:var(--muted);white-space:nowrap}
.doc .ic{display:inline-flex;vertical-align:-.125em}.doc .ic svg{width:1em;height:1em}
.doc .tt{text-decoration:underline dotted var(--dim);text-underline-offset:3px;cursor:help}
.doc .note{display:flex;gap:12px;margin:0 0 16px;padding:12px 16px;border-radius:14px;background:var(--card);font-size:15px;line-height:1.6}
.doc .note>svg{flex:none;margin-top:4px;color:var(--muted)}
.doc .note>div{flex:1;min-width:0}.doc .note>div>:last-child{margin-bottom:0}.doc .note code{background:var(--bg)}
.doc .note.warning,.doc .note.danger{background:var(--warn-bg)}.doc .note.warning>svg,.doc .note.danger>svg{color:var(--warn)}.doc .note.check>svg{color:var(--ok)}
.doc .steps{list-style:none;margin:0 0 18px;padding:0}
.doc .step{position:relative;margin:0;padding:0 0 24px 44px}
.doc .step::before{content:"";position:absolute;left:13px;top:34px;bottom:6px;width:1px;background:var(--line)}
.doc .step:last-child{padding-bottom:4px}.doc .step:last-child::before{display:none}
.doc .step-n{position:absolute;left:0;top:0;display:grid;place-items:center;width:27px;height:27px;border:1px solid var(--line);border-radius:999px;background:var(--bg);font:500 13px/1 "Geist Mono",ui-monospace,monospace}
.doc .step-t{padding-top:1px;margin-bottom:8px;font-weight:500;line-height:1.55}
.doc .step-b>:last-child{margin-bottom:0}
.doc .acc,.doc .accs{margin:0 0 12px;border:1px solid var(--line);border-radius:14px}.doc .accs{margin-bottom:18px}
.doc .accs>.acc{margin:0;border:0;border-radius:0}.doc .accs>.acc+.acc{border-top:1px solid var(--line)}
.doc .acc summary{display:flex;align-items:center;gap:12px;padding:12px 16px;font-weight:500;line-height:1.5;list-style:none;cursor:pointer}
.doc .acc summary::-webkit-details-marker{display:none}
.doc .acc summary svg{flex:none;margin-left:auto;color:var(--muted);transition:transform .2s}.doc .acc summary:hover svg{color:var(--fg)}.doc .acc[open]>summary svg{transform:rotate(180deg)}
.doc .acc-b{padding:0 16px 14px}.doc .acc-b>:last-child{margin-bottom:0}
.doc .cards,.doc .cols{display:grid;grid-template-columns:repeat(var(--cols,2),minmax(0,1fr));gap:12px;margin:0 0 18px}
.doc .cols{gap:16px;align-items:start}.doc .cols>*{margin:0}
.doc .card{display:flex;flex-direction:column;gap:4px;margin:0 0 12px;padding:16px 18px;border:1px solid var(--line);border-radius:14px;color:inherit;text-decoration:none;transition:background .15s}
.doc .cards>.card{margin:0}.doc a.card:hover{background:var(--hover)}
.doc .card-i{display:flex;margin-bottom:6px}.doc .card-i svg{width:20px;height:20px}
.doc .card-t{font-weight:500;line-height:1.4}
.doc .card-b{color:var(--muted);font-size:14px;line-height:1.55}.doc .card-b p{margin:0 0 8px}.doc .card-b>:last-child{margin-bottom:0}
.doc .frame{margin:0 0 18px;padding:8px;border:1px solid var(--line);border-radius:14px}.doc .frame>p{margin:0}
.doc .frame img,.doc .frame video{display:block;max-width:100%;height:auto;margin:0 auto;border-radius:8px}
.doc .frame figcaption{padding:8px 8px 2px;text-align:center;font-size:13px;line-height:1.5;color:var(--muted)}
.doc .tabs{position:relative;display:flex;flex-wrap:wrap;margin:0 0 18px;border:1px solid var(--line);border-radius:14px}
.doc .tabs>input{position:absolute;opacity:0;pointer-events:none}
.doc .tabs>label{padding:10px 14px;margin-bottom:-1px;border-bottom:1px solid transparent;color:var(--muted);font-size:14px;font-weight:500;cursor:pointer}
.doc .tabs>label:hover,.doc .tabs>input:checked+label{color:var(--fg)}.doc .tabs>input:checked+label{border-bottom-color:var(--fg)}
.doc .tabs>input:focus-visible+label{outline:2px solid var(--fg);outline-offset:-4px;border-radius:10px}
.doc .tab{display:none;order:1;width:100%;padding:16px 18px;border-top:1px solid var(--line)}.doc .tab>:last-child{margin-bottom:0}
.doc .tabs>input:checked+label+.tab{display:block}
.doc .codes{background:var(--card)}.doc .codes>.tab{padding:0}.doc .codes pre{margin:0;border-radius:0 0 13px 13px}
.dgrp summary{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-radius:8px;color:var(--muted);list-style:none;cursor:pointer}
.dgrp summary::-webkit-details-marker{display:none}
.dgrp summary:hover{color:var(--fg)}.dgrp summary svg{transition:transform .2s}.dgrp[open]>summary svg{transform:rotate(180deg)}
.dnav .dgrp>a,.dnav-m .dgrp>a{display:block;padding-left:22px}
@media (max-width:640px){.doc .cards,.doc .cols{grid-template-columns:minmax(0,1fr)}}
/* Search: docsSearch()'s button and dialog */
.srch-b{flex:none;display:inline-flex;align-items:center;gap:8px;height:34px;min-width:220px;padding:0 8px 0 12px;border:1px solid var(--line);border-radius:999px;background:var(--card);color:var(--muted);font:inherit;font-size:14px;cursor:pointer}
.srch-b:hover{color:var(--fg)}.srch-b svg{flex:none}
.srch-b kbd{margin-left:auto;padding:3px 6px;border:1px solid var(--line);border-radius:6px;background:var(--bg);font:500 11px/1 "Geist Mono",ui-monospace,monospace;color:var(--muted)}
@media (hover:none){.srch-b kbd{display:none}}
@media (max-width:820px){.srch-b{min-width:0;width:34px;padding:0;justify-content:center}.srch-b span,.srch-b kbd{display:none}}
dialog.srch{width:min(640px,calc(100vw - 24px));max-height:min(560px,calc(100dvh - 48px));margin:10vh auto auto;padding:0;border:1px solid var(--line);border-radius:16px;background:var(--bg);color:var(--fg);overflow:hidden;flex-direction:column}
dialog.srch[open]{display:flex}dialog.srch::backdrop{background:rgba(0,0,0,.45)}
.srch-in{display:flex;align-items:center;gap:10px;padding:0 10px 0 16px;border-bottom:1px solid var(--line);color:var(--muted)}
.srch-in input{flex:1;min-width:0;height:52px;border:0;background:none;color:var(--fg);font:inherit;font-size:16px;outline:none}
.srch-in input::-webkit-search-cancel-button{display:none}
.srch-in button{height:28px;padding:0 10px;border:1px solid var(--line);border-radius:8px;background:none;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
dialog.srch ul{list-style:none;margin:0;padding:6px;overflow:auto}dialog.srch ul:empty{display:none}
dialog.srch li a{display:block;padding:10px 12px;border-radius:10px;color:inherit;text-decoration:none}
dialog.srch li[aria-selected="true"] a,dialog.srch li a:hover{background:var(--card)}
dialog.srch li b{font-weight:500}dialog.srch li span{margin-left:8px;font-size:12px;color:var(--muted)}
dialog.srch li p{margin:2px 0 0;font-size:13px;line-height:1.5;color:var(--muted);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
dialog.srch mark{background:none;color:var(--fg);font-weight:600}
.srch-0{padding:24px 12px;text-align:center;color:var(--muted);font-size:14px}
@media (max-width:640px){dialog.srch{margin:8px auto auto;max-height:calc(100dvh - 16px)}}