@dogsbay/render-html 0.2.0-beta.117 → 0.2.0-beta.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,17 +25,31 @@ const html = renderBlocks(dogsbayMdToTree(markdown).tree, ctx);
25
25
  The output uses Tailwind utility classes and the theme tokens. Styling it needs the Dogsbay
26
26
  theme CSS, compiled for the classes the page uses.
27
27
 
28
+ ## API reference pages
29
+
30
+ Autodoc symbols (`api-class`, `api-member`, `api-doc`, `api-params`,
31
+ `api-source`) and OpenAPI operations (`endpoint`) render with the markup of the
32
+ Astro API components. Set `ctx.embeddedEndpoints` from `apiPageLayout(tree)`
33
+ (`@dogsbay/serialize-core`), which also says when the page should drop its
34
+ reading width and table of contents. Include `css/api.css`.
35
+
36
+ ```ts
37
+ import { apiPageLayout } from "@dogsbay/serialize-core";
38
+ const layout = apiPageLayout(tree); // { wide, embeddedEndpoints }
39
+ const html = renderBlocks(tree, { ...ctx, embeddedEndpoints: layout.embeddedEndpoints });
40
+ ```
41
+
28
42
  ## Stylesheets
29
43
 
30
44
  `css/` holds the stylesheets for this markup: the theme presets (`theme-default.css`,
31
- `theme-mintlify.css`), `docs.css`, `steps.css` and `code-rich.css`. Import them as
45
+ `theme-mintlify.css`), `docs.css`, `steps.css`, `code-rich.css` and `api.css`. Import them as
32
46
  `@dogsbay/render-html/css/<file>`. They are Tailwind 4 sources (`@apply`, `@utility`,
33
47
  tokens), so compile them with the page's classes.
34
48
 
35
49
  ## Copied sources
36
50
 
37
51
  `alert-icons.ts`, `badge-variants.ts`, `kbd-variants.ts`, `file-tree.ts`,
38
- `code-markers.ts`, `tone-classes.ts` and everything in `css/` are copies from `@dogsbay/ui` and
52
+ `code-markers.ts`, `tone-classes.ts`, `api-markdown.ts` and everything in `css/` are copies from `@dogsbay/ui` and
39
53
  `@dogsbay/format-astro`. Copying them keeps this package free of Astro.
40
54
 
41
55
  After changing them upstream, run `pnpm --filter @dogsbay/render-html sync-sources`.
package/css/api.css ADDED
@@ -0,0 +1,136 @@
1
+ /* Generated by packages/render-html/scripts/sync-sources.mjs from @dogsbay/ui api-symbol.css, api-source.css, EndpointSection.astro, PropertyItem.astro. Do not edit. */
2
+ /* API Symbol badges, coloured by kind. Text on its own 20% tint must reach
3
+ WCAG AA (4.5:1) on the page background: the light colours were L 0.5–0.6
4
+ (3.2–4.0:1) and are L 0.44 (5.0:1 or more); render-html's
5
+ tests/contrast.test.ts measures every kind in both modes. */
6
+ .api-badge {
7
+ display: inline-flex;
8
+ align-items: center;
9
+ border-radius: 0.375rem;
10
+ padding: 0.125rem 0.5rem;
11
+ font-size: 0.7rem;
12
+ font-weight: 600;
13
+ letter-spacing: 0.025em;
14
+ text-transform: uppercase;
15
+ vertical-align: middle;
16
+ flex-shrink: 0;
17
+ }
18
+
19
+ .api-badge-class {
20
+ background-color: oklch(0.44 0.18 265 / 0.2);
21
+ color: oklch(0.44 0.18 265);
22
+ }
23
+ .dark .api-badge-class {
24
+ background-color: oklch(0.7 0.15 265 / 0.2);
25
+ color: oklch(0.7 0.15 265);
26
+ }
27
+
28
+ .api-badge-meth {
29
+ background-color: oklch(0.44 0.15 155 / 0.2);
30
+ color: oklch(0.44 0.15 155);
31
+ }
32
+ .dark .api-badge-meth {
33
+ background-color: oklch(0.7 0.15 155 / 0.2);
34
+ color: oklch(0.7 0.15 155);
35
+ }
36
+
37
+ .api-badge-attr {
38
+ background-color: oklch(0.44 0.15 55 / 0.2);
39
+ color: oklch(0.44 0.15 55);
40
+ }
41
+ .dark .api-badge-attr {
42
+ background-color: oklch(0.75 0.15 55 / 0.2);
43
+ color: oklch(0.75 0.15 55);
44
+ }
45
+
46
+ .api-badge-func {
47
+ background-color: oklch(0.44 0.18 330 / 0.2);
48
+ color: oklch(0.44 0.18 330);
49
+ }
50
+ .dark .api-badge-func {
51
+ background-color: oklch(0.7 0.15 330 / 0.2);
52
+ color: oklch(0.7 0.15 330);
53
+ }
54
+
55
+ .api-badge-module {
56
+ background-color: oklch(0.44 0.12 200 / 0.2);
57
+ color: oklch(0.44 0.12 200);
58
+ }
59
+ .dark .api-badge-module {
60
+ background-color: oklch(0.65 0.12 200 / 0.2);
61
+ color: oklch(0.65 0.12 200);
62
+ }
63
+
64
+ /* API Symbol container */
65
+ .api-symbol {
66
+ margin-top: 2rem;
67
+ padding-top: 1.5rem;
68
+ border-top: 1px solid var(--border, oklch(0.5 0 0 / 0.15));
69
+ }
70
+
71
+ .api-symbol:first-child {
72
+ margin-top: 0;
73
+ padding-top: 0;
74
+ border-top: none;
75
+ }
76
+
77
+ .api-symbol-heading {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 0.5rem;
81
+ margin-bottom: 0.75rem;
82
+ }
83
+
84
+ .api-symbol-heading code {
85
+ font-size: 1.25em;
86
+ font-weight: 600;
87
+ }
88
+
89
+ .api-symbol-bases {
90
+ font-size: 0.875rem;
91
+ color: var(--muted-foreground);
92
+ margin-bottom: 0.5rem;
93
+ }
94
+
95
+ .api-source {
96
+ margin-top: 0.75rem;
97
+ margin-bottom: 0.75rem;
98
+ border: 1px solid var(--color-border, oklch(0.85 0 0));
99
+ border-radius: 0.375rem;
100
+ overflow: hidden;
101
+ }
102
+
103
+ .api-source summary {
104
+ cursor: pointer;
105
+ padding: 0.5rem 0.75rem;
106
+ font-size: 0.8125rem;
107
+ font-weight: 500;
108
+ color: var(--color-muted-foreground, oklch(0.55 0 0));
109
+ background: var(--color-muted, oklch(0.96 0 0));
110
+ user-select: none;
111
+ }
112
+
113
+ .api-source summary:hover {
114
+ color: var(--color-foreground, oklch(0.15 0 0));
115
+ }
116
+
117
+ .api-source[open] summary {
118
+ border-bottom: 1px solid var(--color-border, oklch(0.85 0 0));
119
+ }
120
+
121
+ /* Remove extra margin from the code block inside */
122
+ .api-source pre {
123
+ margin: 0 !important;
124
+ border-radius: 0 !important;
125
+ border: none !important;
126
+ }
127
+
128
+
129
+ details[open] > summary [data-section-chevron] {
130
+ transform: rotate(90deg);
131
+ }
132
+
133
+
134
+ details[open] > summary [data-property-chevron] {
135
+ transform: rotate(90deg);
136
+ }
@@ -0,0 +1,7 @@
1
+ /** Block Markdown → HTML (an operation's description); headings become bold paragraphs. */
2
+ export declare function apiBlockHtml(text: string): string;
3
+ /** One line of Markdown → inline HTML (a parameter or property). */
4
+ export declare function apiInlineHtml(text: string): string;
5
+ /** A schema property list with every `description` rendered inline,
6
+ * through nested `properties` / `items`. A copy; the node is untouched. */
7
+ export declare function apiPropertiesHtml(list: unknown): unknown;
@@ -0,0 +1,50 @@
1
+ // Generated by packages/render-html/scripts/sync-sources.mjs from @dogsbay/format-astro/src/api-markdown.ts. Do not edit.
2
+ /**
3
+ * API description text → HTML, for the API components that render it with
4
+ * `set:html` (EndpointCard, PropertyItem, ParameterTable).
5
+ *
6
+ * OpenAPI defines `description` as CommonMark, and every producer of an
7
+ * `endpoint` node (format-openapi, the MDX adapters) supplies text or
8
+ * Markdown, never HTML. It was passed through as HTML: a spec's markdown
9
+ * showed as `## Authentication` and `**Creating a token**`, and a spec from
10
+ * someone else's repository could put a `<script>` into the built site.
11
+ * Rendered here with raw HTML OFF (`<` is escaped) and markdown-it's link
12
+ * validation (no `javascript:` / `vbscript:` / `file:` / `data:` links).
13
+ */
14
+ import MarkdownIt from "markdown-it";
15
+ const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
16
+ /**
17
+ * The description's own headings as bold paragraphs: an operation sits
18
+ * under the page's outline (a tag h2, or only the page h1), and an `## Auth`
19
+ * in a spec description put an h2 inside the card, skipping or breaking the
20
+ * page's heading order (axe heading-order).
21
+ */
22
+ const blockMd = new MarkdownIt({ html: false, linkify: false, typographer: false });
23
+ blockMd.renderer.rules.heading_open = () => "<p><strong>";
24
+ blockMd.renderer.rules.heading_close = () => "</strong></p>\n";
25
+ /** Block Markdown → HTML (an operation's description); headings become bold paragraphs. */
26
+ export function apiBlockHtml(text) {
27
+ return blockMd.render(text).trim();
28
+ }
29
+ /** One line of Markdown → inline HTML (a parameter or property). */
30
+ export function apiInlineHtml(text) {
31
+ return md.renderInline(text.replace(/\s*\n\s*/g, " ")).trim();
32
+ }
33
+ /** A schema property list with every `description` rendered inline,
34
+ * through nested `properties` / `items`. A copy; the node is untouched. */
35
+ export function apiPropertiesHtml(list) {
36
+ if (!Array.isArray(list))
37
+ return list;
38
+ return list.map((p) => {
39
+ if (!p || typeof p !== "object")
40
+ return p;
41
+ const o = { ...p };
42
+ if (typeof o.description === "string")
43
+ o.description = apiInlineHtml(o.description);
44
+ if (o.properties)
45
+ o.properties = apiPropertiesHtml(o.properties);
46
+ if (o.items)
47
+ o.items = apiPropertiesHtml(o.items);
48
+ return o;
49
+ });
50
+ }
package/dist/api.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * API reference nodes → HTML, with the markup and classes the Astro pipeline
3
+ * gets from format-astro's serializer and the @dogsbay/ui components:
4
+ *
5
+ * - code symbols (`api-class`, `api-member`, `api-doc`, `api-params`,
6
+ * `api-source`): ApiSymbol, ApiDoc, ApiParams, ApiSource;
7
+ * - an OpenAPI operation (`endpoint`): ApiLayout with an EndpointCard
8
+ * (ApiUrlBar, EndpointSection, PropertyList, SchemaViewer, ResponseTabs)
9
+ * and an ApiCodePanel (CodeSamples, ExampleBlock).
10
+ *
11
+ * Class lists are the ones Astro writes after tailwind-variants merges them
12
+ * (apps/demo/dist/api/*), not the components' source strings: the merge
13
+ * drops `border-l-4` and `border-l-current` from the URL bar, for one.
14
+ *
15
+ * Content: operation descriptions are CommonMark rendered with raw HTML off
16
+ * (api-markdown.ts, copied from format-astro); autodoc's `html` / `docHtml`
17
+ * are author HTML and go through rewriteHtml, so the host's sanitizer and
18
+ * link rewriting apply to them as to any raw HTML. Behaviour needs no new
19
+ * script: sample and response tabs are db-tabs, sections are <details>,
20
+ * and the URL bar's copy button sits in a db-code-block.
21
+ */
22
+ import type { TreeNode } from "@dogsbay/types";
23
+ import { type RenderContext } from "./render.js";
24
+ /** The node types this module renders. */
25
+ export declare const API_NODE_TYPES: Set<string>;
26
+ export declare function renderApiNode(n: TreeNode, ctx: RenderContext): string;
27
+ /**
28
+ * The code an API node renders as code blocks, with the language each is
29
+ * highlighted in, for highlightInputs: a highlighter that loads grammars per
30
+ * language must see them before rendering.
31
+ */
32
+ export declare function apiCodeInputs(n: TreeNode): Array<{
33
+ lang: string | undefined;
34
+ code: string;
35
+ }>;
package/dist/api.js ADDED
@@ -0,0 +1,359 @@
1
+ import { apiBlockHtml, apiPropertiesHtml } from "./api-markdown.js";
2
+ import { attrs, esc } from "./html.js";
3
+ import { CHECK_ICON, COPY_ICON, codeHtml, renderBlocks, rewriteHtml, tabGroup, uniqueId } from "./render.js";
4
+ const str = (v) => (v == null || v === "" ? undefined : String(v));
5
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => x && typeof x === "object") : []);
6
+ /** The node types this module renders. */
7
+ export const API_NODE_TYPES = new Set(["api-class", "api-member", "api-doc", "api-params", "api-source", "endpoint"]);
8
+ export function renderApiNode(n, ctx) {
9
+ switch (n.type) {
10
+ case "api-class":
11
+ case "api-member":
12
+ return apiSymbol(n, ctx);
13
+ case "api-doc":
14
+ return docSegments(str(n.props?.html) ?? "", "[&>p:first-child]:mt-0 [&>p:last-child]:mb-2", ctx);
15
+ case "api-params":
16
+ return apiParams(n, ctx);
17
+ case "api-source":
18
+ return apiSource(n, ctx);
19
+ case "endpoint":
20
+ return endpoint(n, ctx);
21
+ default:
22
+ return "";
23
+ }
24
+ }
25
+ // ─── Code symbols ────────────────────────────────────────────────────
26
+ /** ApiSymbol.astro: kind badge, the name as a heading, signature, bases, then the body. */
27
+ function apiSymbol(n, ctx) {
28
+ const p = (n.props ?? {});
29
+ const name = str(p.name) ?? "";
30
+ const kind = str(p.kind) ?? "func";
31
+ const signature = str(p.signature);
32
+ const bases = Array.isArray(p.bases) ? p.bases.map(String) : [];
33
+ // apiSymbolLevels' rule (@dogsbay/serialize-core), applied as the page renders: one below the
34
+ // last heading before the symbol (h2 with none), or one below the enclosing symbol.
35
+ const level = Math.min(6, (ctx.apiParentLevel ?? ctx.lastHeadingLevel ?? 1) + 1);
36
+ const badgeKind = kind === "method" ? "meth" : kind;
37
+ // ApiSymbol's slug, so a link to #mypkg-client reaches the same symbol on both pipelines.
38
+ const id = uniqueId(ctx, name.toLowerCase().replace(/[^\w]/g, "-").replace(/-+/g, "-"));
39
+ if (ctx.apiSymbolHeadings)
40
+ ctx.headings.push({ depth: level, id, text: name });
41
+ return (`<div class="api-symbol not-prose" data-kind="${esc(kind)}">` +
42
+ `<div class="api-symbol-heading"><span class="api-badge api-badge-${esc(badgeKind)}">${esc(badgeKind)}</span>` +
43
+ `<h${level} id="${esc(id)}" class="!mt-0 !mb-0 !border-0"><code class="text-lg font-semibold">${esc(name)}</code></h${level}></div>` +
44
+ (signature ? codeHtml(signature, "python", ctx) : "") +
45
+ (bases.length ? `<p class="api-symbol-bases">Bases: ${bases.map((b) => `<code>${esc(b)}</code>`).join("")}</p>` : "") +
46
+ `<div class="mt-3">${nested(ctx, level, () => renderBlocks(n.children, ctx))}</div></div>\n`);
47
+ }
48
+ function nested(ctx, level, f) {
49
+ const was = ctx.apiParentLevel;
50
+ ctx.apiParentLevel = level;
51
+ try {
52
+ return f();
53
+ }
54
+ finally {
55
+ ctx.apiParentLevel = was;
56
+ }
57
+ }
58
+ const CODE_IN_HTML = /<pre><code(?:\s+class="language-(\w+)")?>([\s\S]*?)<\/code><\/pre>/g;
59
+ const decodeCode = (s) => s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
60
+ /** ApiDoc's and ApiParams' split: the code blocks in autodoc HTML become code blocks, the rest stays prose. */
61
+ function splitCode(html) {
62
+ const out = [];
63
+ let last = 0;
64
+ for (const m of html.matchAll(CODE_IN_HTML)) {
65
+ if (m.index > last)
66
+ out.push({ html: html.slice(last, m.index) });
67
+ out.push({ code: decodeCode(m[2]), lang: m[1] || "python" });
68
+ last = m.index + m[0].length;
69
+ }
70
+ if (last < html.length)
71
+ out.push({ html: html.slice(last) });
72
+ return out;
73
+ }
74
+ function docSegments(html, edges, ctx) {
75
+ return splitCode(html)
76
+ .map((s) => "code" in s
77
+ ? codeHtml(s.code, s.lang, ctx)
78
+ : `<div class="prose prose-sm dark:prose-invert max-w-none ${edges}">${rewriteHtml(s.html, ctx)}</div>`)
79
+ .join("");
80
+ }
81
+ /** ApiParams.astro: a two-column table, the description with its type and default under it. */
82
+ function apiParams(n, ctx) {
83
+ const rows = list(n.props?.params)
84
+ .map((p) => {
85
+ const type = str(p.type);
86
+ const dflt = str(p.default);
87
+ return (`<tr class="border-b border-border/50"><td class="py-3 px-4 align-top"><code class="text-sm font-medium">${esc(str(p.name) ?? "")}</code></td>` +
88
+ `<td class="py-3 px-4 align-top">${docSegments(str(p.docHtml) ?? "", "[&>p:first-child]:mt-0 [&>p:last-child]:mb-0", ctx)}` +
89
+ `<div class="flex items-center gap-4 mt-2 text-xs text-muted-foreground">` +
90
+ (type ? `<span><strong>TYPE:</strong> <code class="text-xs">${esc(type)}</code></span>` : "") +
91
+ (dflt ? `<span class="ml-auto"><strong>DEFAULT:</strong> <code class="text-xs">${esc(dflt)}</code></span>` : "") +
92
+ `</div></td></tr>`);
93
+ })
94
+ .join("");
95
+ const th = "py-3 px-4 text-left font-semibold text-muted-foreground uppercase tracking-wider text-xs";
96
+ return (`<table class="api-params w-full text-sm border-collapse not-prose"><thead><tr class="border-b border-border">` +
97
+ `<th class="${th} w-[140px]">Parameter</th><th class="${th}">Description</th></tr></thead><tbody>${rows}</tbody></table>\n`);
98
+ }
99
+ /** ApiSource.astro: the source in a closed disclosure. */
100
+ function apiSource(n, ctx) {
101
+ const p = (n.props ?? {});
102
+ const file = str(p.file);
103
+ const line = Number(p.line) || undefined;
104
+ const label = file ? `Source code in ${file}${line ? `, line ${line}` : ""}` : "Source code";
105
+ return `<details class="api-source"><summary>${esc(label)}</summary>${codeHtml(str(p.code) ?? "", "python", ctx)}</details>\n`;
106
+ }
107
+ // ─── OpenAPI operation ───────────────────────────────────────────────
108
+ const METHODS = ["get", "post", "put", "patch", "delete", "head"];
109
+ function methodBadge(method) {
110
+ const v = METHODS.includes(method) ? ` bg-api-${method}/20 text-api-${method}` : method === "options" ? " bg-muted/20 text-muted-foreground" : "";
111
+ return `<span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-bold uppercase tracking-wide font-mono${v}" data-method="${esc(method)}">${esc(method)}</span>`;
112
+ }
113
+ /** ApiUrlBar.astro: method, base URL, the path with {params} tinted, a copy button. */
114
+ function urlBar(method, path, baseUrl) {
115
+ const tint = METHODS.includes(method)
116
+ ? `border px-4 py-3 bg-api-${method}/10 border-l-api-${method}`
117
+ : method === "options"
118
+ ? "border px-4 py-3 bg-muted/40 border-l-muted-foreground"
119
+ : "border border-l-current px-4 py-3";
120
+ const parts = path
121
+ .split(/(\{[^}]+\})/g)
122
+ .map((s) => (s.startsWith("{") ? `<span class="text-api-put">${esc(s)}</span>` : `<span>${esc(s)}</span>`))
123
+ .join("");
124
+ // The copy button sits in a db-code-block, whose behaviour copies data-copy-text: no script of its own.
125
+ const copy = `<db-code-block class="shrink-0" data-copy-text="${esc(baseUrl + path)}"><button type="button" data-part="copy" aria-label="Copy to clipboard" ` +
126
+ `class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">${COPY_ICON}${CHECK_ICON}</button></db-code-block>`;
127
+ return (`<div class="flex items-center gap-3 rounded-lg ${tint}">${methodBadge(method)}` +
128
+ `<code class="flex-1 truncate text-sm font-medium">${baseUrl ? `<span class="text-muted-foreground">${esc(baseUrl)}</span>` : ""}${parts}</code>${copy}</div>`);
129
+ }
130
+ const SECTION_CHEVRON = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200" data-section-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>';
131
+ const PROPERTY_CHEVRON = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3 shrink-0 text-muted-foreground transition-transform duration-200" data-property-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>';
132
+ const DEPRECATED_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>';
133
+ /** EndpointSection.astro: an open <details> with a turning chevron. */
134
+ function section(title, body) {
135
+ return (`<details open class="rounded-lg border bg-card"><summary class="flex cursor-pointer items-center gap-2 px-4 py-3 text-sm font-semibold list-none [&::-webkit-details-marker]:hidden hover:bg-muted/50 select-none">` +
136
+ `${SECTION_CHEVRON}<span>${esc(title)}</span></summary><div class="px-4 pb-4 pt-1">${body}</div></details>`);
137
+ }
138
+ const TYPE_TINT = {
139
+ string: "bg-api-type-string/20 text-api-type-string",
140
+ number: "bg-api-type-number/20 text-api-type-number",
141
+ integer: "bg-api-type-number/20 text-api-type-number",
142
+ boolean: "bg-api-type-boolean/20 text-api-type-boolean",
143
+ object: "bg-api-type-object/20 text-api-type-object",
144
+ array: "bg-api-type-array/20 text-api-type-array",
145
+ null: "bg-muted/20 text-muted-foreground",
146
+ };
147
+ /** PropertyItem.astro: one parameter or schema property; nested properties/items open in a <details>. */
148
+ function propertyItem(p, level, ctx) {
149
+ const name = esc(str(p.name) ?? "");
150
+ const type = str(p.type) ?? "";
151
+ const format = str(p.format);
152
+ const description = str(p.description);
153
+ const children = list(p.properties).length ? list(p.properties) : list(p.items);
154
+ const indent = level * 16;
155
+ const badge = `<span class="rounded px-1.5 py-0.5 text-[10px] font-medium ${Object.hasOwn(TYPE_TINT, type) ? TYPE_TINT[type] : TYPE_TINT.string}">${esc(type)}${format ? esc(` <${format}>`) : ""}</span>` +
156
+ (p.nullable === true ? '<span class="text-muted-foreground text-[10px]">nullable</span>' : "");
157
+ const required = p.required === true ? '<span class="text-[10px] font-semibold uppercase text-api-required">required</span>' : "";
158
+ const outer = `border-b border-border last:border-b-0${p.deprecated === true ? " opacity-60" : ""}`;
159
+ const pad = indent > 0 ? ` style="padding-left: ${16 + indent}px;"` : "";
160
+ // Descriptions are HTML from api-markdown (raw HTML off); rewriteHtml still resolves their links.
161
+ const desc = (cls, style = "") => (description ? `<p class="${cls}"${style}>${rewriteHtml(description, ctx)}</p>` : "");
162
+ if (children.length) {
163
+ return (`<div class="${outer}"><details><summary class="flex cursor-pointer items-start justify-between gap-4 px-4 py-3 list-none [&::-webkit-details-marker]:hidden hover:bg-muted/50 rounded"${pad}>` +
164
+ `<div class="flex items-center gap-2 min-w-0">${PROPERTY_CHEVRON}<code class="text-sm font-semibold">${name}</code>${badge}</div>` +
165
+ `<div class="flex shrink-0 items-center gap-2">${required}</div></summary>` +
166
+ desc("px-4 pb-1 text-sm text-muted-foreground", ` style="padding-left: ${32 + indent}px;"`) +
167
+ `<div class="border-l border-border" style="margin-left: ${24 + indent}px;">${children.map((c) => propertyItem(c, level + 1, ctx)).join("")}</div></details></div>`);
168
+ }
169
+ const sub = level > 0 ? " pl-5" : "";
170
+ const enums = Array.isArray(p.enum) ? p.enum : undefined;
171
+ return (`<div class="${outer}"><div class="flex items-start justify-between gap-4 px-4 py-3"${pad}><div class="min-w-0 flex-1">` +
172
+ `<div class="flex items-center gap-2">${level > 0 ? '<span class="size-3 shrink-0"></span>' : ""}<code class="text-sm font-semibold">${name}</code>${badge}</div>` +
173
+ desc(`mt-1 text-sm text-muted-foreground${sub}`) +
174
+ (enums
175
+ ? `<div class="mt-1.5 flex flex-wrap items-center gap-1 text-[10px]${sub}"><span class="text-muted-foreground">Enum:</span>${enums.map((v) => `<code class="rounded bg-muted px-1.5 py-0.5">${esc(v)}</code>`).join("")}</div>`
176
+ : "") +
177
+ (p.example !== undefined ? `<div class="mt-1 text-xs text-muted-foreground${sub}">Example: <code class="rounded bg-muted px-1 py-0.5">${esc(JSON.stringify(p.example))}</code></div>` : "") +
178
+ (p.default !== undefined ? `<div class="mt-1 text-xs text-muted-foreground${sub}">Default: <code class="rounded bg-muted px-1 py-0.5">${esc(JSON.stringify(p.default))}</code></div>` : "") +
179
+ `</div><div class="flex shrink-0 items-center gap-2 pt-0.5">${required}</div></div></div>`);
180
+ }
181
+ const LOCATIONS = [
182
+ ["path", "Path Parameters"],
183
+ ["query", "Query Parameters"],
184
+ ["header", "Header Parameters"],
185
+ ["cookie", "Cookie Parameters"],
186
+ ];
187
+ /** PropertyList.astro: parameters grouped by location, each group's label a <p> (an h4 skipped a level under an h2). */
188
+ function propertyList(params, ctx) {
189
+ const groups = LOCATIONS.map(([loc, label]) => ({ label, items: params.filter((p) => p.in === loc) })).filter((g) => g.items.length);
190
+ return (`<div>` +
191
+ groups
192
+ .map((g) => `<div class="mb-4 last:mb-0"><p class="mb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">${g.label}</p>` +
193
+ `<div class="rounded-lg border bg-card">${g.items.map((p) => propertyItem(p, 0, ctx)).join("")}</div></div>`)
194
+ .join("") +
195
+ `</div>`);
196
+ }
197
+ /** SchemaViewer.astro. */
198
+ function schemaViewer(props, ctx) {
199
+ return `<div class="rounded-lg border bg-card"><div>${props.map((p) => propertyItem(p, 0, ctx)).join("")}</div></div>`;
200
+ }
201
+ const TABS_ROOT = { class: "my-4 w-full overflow-hidden rounded-lg border border-border" };
202
+ /** Distinct tab values: db-tabs selects by value, so two Python samples ("Sync", "Async") need two. */
203
+ function distinct(values) {
204
+ const used = new Set();
205
+ return values.map((v, i) => {
206
+ while (used.has(v))
207
+ v += ` (${i + 1})`;
208
+ used.add(v);
209
+ return v;
210
+ });
211
+ }
212
+ function statusTint(status) {
213
+ const c = status[0];
214
+ return c >= "2" && c <= "5" ? `bg-api-status-${c}xx/20 text-api-status-${c}xx` : "bg-muted/20 text-muted-foreground";
215
+ }
216
+ /** ResponseTabs.astro: one tab per status, its description and schema. */
217
+ function responseTabs(responses, ctx) {
218
+ const values = distinct(responses.map((r) => String(r.status ?? "")));
219
+ return tabGroup(responses.map((r, i) => {
220
+ const status = String(r.status ?? "");
221
+ const description = str(r.description);
222
+ return {
223
+ value: values[i],
224
+ label: `<span class="rounded px-1.5 py-0.5 text-xs font-mono font-bold ${statusTint(status)}">${esc(status)}</span>`,
225
+ body: () => (description ? `<p class="mb-3 text-sm text-muted-foreground">${esc(description)}</p>` : "") +
226
+ (Array.isArray(r.schema) ? schemaViewer(list(r.schema), ctx) : ""),
227
+ };
228
+ }), ctx, TABS_ROOT);
229
+ }
230
+ const SAMPLE_LABELS = {
231
+ curl: "cURL",
232
+ python: "Python",
233
+ typescript: "TypeScript",
234
+ javascript: "JavaScript",
235
+ go: "Go",
236
+ ruby: "Ruby",
237
+ java: "Java",
238
+ csharp: "C#",
239
+ php: "PHP",
240
+ rust: "Rust",
241
+ };
242
+ const sampleLang = (language) => (language === "curl" ? "bash" : language);
243
+ /** CodeSamples.astro: one tab per language. */
244
+ function codeSamples(samples, ctx) {
245
+ const values = distinct(samples.map((s) => str(s.language) ?? ""));
246
+ return tabGroup(samples.map((s, i) => {
247
+ const language = str(s.language) ?? "";
248
+ return {
249
+ value: values[i],
250
+ label: esc(str(s.label) ?? SAMPLE_LABELS[language] ?? language),
251
+ body: () => codeHtml(str(s.code) ?? "", sampleLang(language), ctx),
252
+ };
253
+ }), ctx, TABS_ROOT);
254
+ }
255
+ const CONTENT_TYPE_LANG = {
256
+ "application/json": "json",
257
+ "application/xml": "xml",
258
+ "application/x-www-form-urlencoded": "plaintext",
259
+ "text/xml": "xml",
260
+ "text/html": "html",
261
+ "text/plain": "plaintext",
262
+ "text/yaml": "yaml",
263
+ "application/yaml": "yaml",
264
+ "application/x-yaml": "yaml",
265
+ "multipart/form-data": "plaintext",
266
+ };
267
+ /** ExampleBlock.astro's language for a content type. */
268
+ function exampleLang(contentType) {
269
+ if (!contentType)
270
+ return "json";
271
+ const base = contentType.split(";")[0].trim();
272
+ const known = CONTENT_TYPE_LANG[contentType] ?? CONTENT_TYPE_LANG[base];
273
+ if (known)
274
+ return known;
275
+ if (base.includes("json"))
276
+ return "json";
277
+ if (base.includes("xml"))
278
+ return "xml";
279
+ if (base.includes("yaml") || base.includes("yml"))
280
+ return "yaml";
281
+ return "json";
282
+ }
283
+ /** The code column's titles: labels, not headings (an operation page has only its h1 above them). */
284
+ const PANEL_LABEL = "mb-3 text-xs font-semibold uppercase tracking-wider text-white/60";
285
+ /** The code column's blocks, in format-astro's order: samples, the request body example, response examples. */
286
+ function codePanels(p) {
287
+ const out = [];
288
+ const samples = list(p.codeSamples);
289
+ if (samples.length)
290
+ out.push({ title: "Code samples", samples });
291
+ if (p.requestBodyExample !== undefined)
292
+ out.push({ title: "Request body", code: JSON.stringify(p.requestBodyExample, null, 2), lang: "json" });
293
+ for (const r of list(p.responses)) {
294
+ if (r.example === undefined)
295
+ continue;
296
+ out.push({ title: `Response ${String(r.status ?? "")}`, code: JSON.stringify(r.example, null, 2), lang: exampleLang(str(r.contentType)) });
297
+ }
298
+ return out;
299
+ }
300
+ /** format-astro's endpointToAstro: ApiLayout, the EndpointCard on the left, the code panel on the right. */
301
+ function endpoint(n, ctx) {
302
+ const p = (n.props ?? {});
303
+ const method = (str(p.method) ?? "get").toLowerCase();
304
+ const path = str(p.path) ?? "";
305
+ const baseUrl = str(p.baseUrl) ?? "";
306
+ const summary = str(p.summary);
307
+ // The card hides a description equal to the summary (compared as text: the rendered HTML never equals it).
308
+ const description = typeof p.description === "string" && p.description && p.description !== p.summary ? apiBlockHtml(p.description) : undefined;
309
+ const deprecated = p.deprecated === true;
310
+ const parameters = list(apiPropertiesHtml(p.parameters));
311
+ const requestBody = list(apiPropertiesHtml(p.requestBody));
312
+ const responses = list(p.responses).map((r) => (Array.isArray(r.schema) ? { ...r, schema: apiPropertiesHtml(r.schema) } : r));
313
+ const sections = (parameters.length ? section("Parameters", propertyList(parameters, ctx)) : "") +
314
+ (requestBody.length ? section("Request body", schemaViewer(requestBody, ctx)) : "") +
315
+ (responses.length ? section("Responses", responseTabs(responses, ctx)) : "");
316
+ const card = `<div${attrs({ class: "space-y-4", "data-component": "endpoint-card", "data-deprecated": deprecated })}>` +
317
+ urlBar(method, path, baseUrl) +
318
+ (deprecated
319
+ ? `<div class="flex items-center gap-2 rounded-lg border border-api-deprecated/30 bg-api-deprecated/10 px-4 py-2 text-sm text-api-deprecated">${DEPRECATED_ICON}<span>This endpoint is deprecated.</span></div>`
320
+ : "") +
321
+ (summary ? `<p class="text-sm text-muted-foreground">${esc(summary)}</p>` : "") +
322
+ (description ? `<div class="text-sm text-muted-foreground">${rewriteHtml(description, ctx)}</div>` : "") +
323
+ `<div class="space-y-3">${sections}</div></div>`;
324
+ const panels = codePanels(p)
325
+ .map((c) => c.samples
326
+ ? `<div><p class="${PANEL_LABEL}">${esc(c.title)}</p>${codeSamples(c.samples, ctx)}</div>`
327
+ : `<div><p class="${PANEL_LABEL}">${esc(c.title)}</p>${codeHtml(c.code, c.lang, ctx)}</div>`)
328
+ .join("");
329
+ // Embedded among prose unless the host says the page is an operation page (apiPageLayout in @dogsbay/serialize-core).
330
+ const page = ctx.embeddedEndpoints === false;
331
+ return (`<div class="dba-api grid grid-cols-1 lg:grid-cols-2 ${page ? "lg:min-h-[calc(100vh-3rem)]" : "my-6 rounded-lg border overflow-hidden"}">` +
332
+ `<div class="${page ? "px-6 py-10" : "p-6"} lg:border-r">${card}</div>` +
333
+ `<div class="bg-[var(--api-panel-bg)] text-[var(--api-panel-fg)]"><div class="${page ? "px-6 py-10 lg:sticky lg:top-12 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto" : "p-6"}">` +
334
+ `<div class="space-y-8">${panels}</div></div></div></div>\n`);
335
+ }
336
+ /**
337
+ * The code an API node renders as code blocks, with the language each is
338
+ * highlighted in, for highlightInputs: a highlighter that loads grammars per
339
+ * language must see them before rendering.
340
+ */
341
+ export function apiCodeInputs(n) {
342
+ const p = (n.props ?? {});
343
+ const fromHtml = (html) => splitCode(html ?? "").flatMap((s) => ("code" in s ? [{ lang: s.lang, code: s.code }] : []));
344
+ switch (n.type) {
345
+ case "api-class":
346
+ case "api-member":
347
+ return str(p.signature) ? [{ lang: "python", code: String(p.signature) }] : [];
348
+ case "api-source":
349
+ return [{ lang: "python", code: str(p.code) ?? "" }];
350
+ case "api-doc":
351
+ return fromHtml(str(p.html));
352
+ case "api-params":
353
+ return list(p.params).flatMap((x) => fromHtml(str(x.docHtml)));
354
+ case "endpoint":
355
+ return codePanels(p).flatMap((c) => c.samples ? c.samples.map((s) => ({ lang: sampleLang(str(s.language) ?? ""), code: str(s.code) ?? "" })) : [{ lang: c.lang, code: c.code }]);
356
+ default:
357
+ return [];
358
+ }
359
+ }
package/dist/render.d.ts CHANGED
@@ -62,6 +62,23 @@ export interface RenderContext {
62
62
  * sanitizer here; without one, raw HTML is written as authored.
63
63
  */
64
64
  rawHtml?: (html: string) => string;
65
+ /**
66
+ * OpenAPI operations sit inline among prose (true, the default) or fill an
67
+ * operation page (false): `apiPageLayout(tree).embeddedEndpoints` from
68
+ * @dogsbay/serialize-core, the rule format-astro applies.
69
+ */
70
+ embeddedEndpoints?: boolean;
71
+ /**
72
+ * List autodoc symbol names (api-class, api-member) in `headings`, for the
73
+ * table of contents. Off by default, as in the Astro pipeline, which lists
74
+ * them only when a live `:::autodoc` resolved: a 130-method module page
75
+ * would flood the TOC. Their ids are reserved either way.
76
+ */
77
+ apiSymbolHeadings?: boolean;
78
+ /** The level of the last heading rendered outside an autodoc symbol (set as rendering goes). */
79
+ lastHeadingLevel?: number;
80
+ /** While rendering an autodoc symbol's body: that symbol's heading level. */
81
+ apiParentLevel?: number;
65
82
  }
66
83
  export { FOREIGN_END, FOREIGN_START };
67
84
  export { scriptUrl };
@@ -87,6 +104,8 @@ export declare function inlineText(nodes: InlineNode[] | undefined): string;
87
104
  */
88
105
  export declare function rewriteHtml(raw: string, ctx: RenderContext): string;
89
106
  export declare function renderBlocks(nodes: TreeNode[] | undefined, ctx: RenderContext): string;
107
+ export declare const COPY_ICON = "<svg data-part=\"copy-icon\" xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg>";
108
+ export declare const CHECK_ICON = "<svg data-part=\"check-icon\" hidden xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M20 6 9 17l-5-5\"/></svg>";
90
109
  /** What highlighting a code node asks of the highlighter; the look-ahead in `build` uses it to highlight blocks ahead of their page. */
91
110
  export interface CodeRequest {
92
111
  code: string;
@@ -112,3 +131,15 @@ export declare function highlightInputs(tree: unknown): Array<{
112
131
  lang: string | undefined;
113
132
  code: string;
114
133
  }>;
134
+ /** A code block for code a component holds in a prop (an API signature, a sample), as CodeBlock.astro renders it. */
135
+ export declare function codeHtml(code: string, lang: string | undefined, ctx: RenderContext): string;
136
+ export interface TabItem {
137
+ /** The tab's value (db-tabs matches triggers, panels and synced groups by it). */
138
+ value: string;
139
+ /** The trigger's content, as HTML. */
140
+ label: string;
141
+ /** The panel's content, as HTML; rendered in order, after the earlier panels. */
142
+ body: () => string;
143
+ }
144
+ /** A `db-tabs` group with Tabs.astro's markup, the first tab selected. */
145
+ export declare function tabGroup(items: TabItem[], ctx: RenderContext, rootAttrs: Record<string, unknown>, syncKey?: string): string;
package/dist/render.js CHANGED
@@ -9,6 +9,7 @@ import { stripMarkersForCopy } from "./code-markers.js";
9
9
  import { icons as ALERT_ICONS, chevronSvg as ALERT_CHEVRON } from "./alert-icons.js";
10
10
  import { parseLineRanges, plainHighlighter } from "./lines.js";
11
11
  import { scriptUrl } from "./url.js";
12
+ import { API_NODE_TYPES, apiCodeInputs, renderApiNode } from "./api.js";
12
13
  export { FOREIGN_END, FOREIGN_START };
13
14
  export { scriptUrl };
14
15
  /** ctx.link, unless the target is a script link: then "#", and it is reported. */
@@ -226,6 +227,24 @@ export function rewriteHtml(raw, ctx) {
226
227
  return `${sp}${attr}${eq}"${esc(out)}"`;
227
228
  }));
228
229
  }
230
+ /**
231
+ * Headings with ids inside a block of HTML (an OpenAPI embed's descriptions,
232
+ * which format-openapi renders with ids): each id is made unique on the page
233
+ * and h2/h3 join the outline, as the CLI splices `embed.headings` into the
234
+ * Astro page's.
235
+ */
236
+ function proseHeadings(html, ctx) {
237
+ return html.replace(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi, (m, lvl, at, inner) => {
238
+ const idm = /\sid="([^"]*)"/.exec(at);
239
+ if (!idm)
240
+ return m;
241
+ const id = uniqueId(ctx, unesc(idm[1]));
242
+ const depth = Number(lvl);
243
+ if (depth === 2 || depth === 3)
244
+ ctx.headings.push({ depth, id, text: unesc(inner.replace(/<[^>]*>/g, "")).trim() });
245
+ return `<h${lvl}${at.replace(idm[0], ` id="${esc(id)}"`)}>${inner}</h${lvl}>`;
246
+ });
247
+ }
229
248
  // ─── Blocks ──────────────────────────────────────────────────────────
230
249
  export function renderBlocks(nodes, ctx) {
231
250
  if (!nodes)
@@ -283,6 +302,8 @@ function block(n, ctx) {
283
302
  const id = uniqueId(ctx, str(p.id) ?? str(p.slug) ?? slugify(text));
284
303
  if (level === 2 || level === 3)
285
304
  ctx.headings.push({ depth: level, id, text });
305
+ if (ctx.apiParentLevel === undefined)
306
+ ctx.lastHeadingLevel = level;
286
307
  // Astro serializer: a hover pilcrow link, hidden from assistive tech (the heading is the target).
287
308
  const anchor = level > 1 ? `<a href="#${esc(id)}" class="ml-2 text-muted-foreground no-underline opacity-0 group-hover:opacity-100 focus:opacity-100" aria-hidden="true" tabindex="-1">&para;</a>` : "";
288
309
  const { id: _i, ...rest } = passthrough(p, "group scroll-mt-20");
@@ -436,6 +457,17 @@ function block(n, ctx) {
436
457
  return `<pre class="my-4 overflow-x-auto rounded-md border bg-muted p-4 text-sm"><code class="math-display">${esc(n.html ?? inlineText(n.inline))}</code></pre>\n`;
437
458
  case "related":
438
459
  return `<nav class="related-resources" aria-label="${esc(ctx.t("related"))}">${renderInline(n.inline, ctx)}${renderBlocks(n.children, ctx)}</nav>\n`;
460
+ case "prose":
461
+ // Inline content or a block of HTML with no wrapper (format-astro's proseToAstro): an OpenAPI
462
+ // embed's descriptions arrive this way, rendered by format-openapi with raw HTML off.
463
+ return n.inline ? renderInline(n.inline, ctx) : typeof n.html === "string" ? proseHeadings(rewriteHtml(n.html, ctx), ctx) + "\n" : "";
464
+ case "api-class":
465
+ case "api-member":
466
+ case "api-doc":
467
+ case "api-params":
468
+ case "api-source":
469
+ case "endpoint":
470
+ return renderApiNode(n, ctx);
439
471
  case "include": {
440
472
  const target = str(p.path) ?? str(p.src) ?? str(p.file) ?? "";
441
473
  const remote = /https?:\/\/\S+/.exec(target)?.[0];
@@ -495,8 +527,8 @@ const BUTTON_VARIANTS = {
495
527
  destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
496
528
  };
497
529
  // ─── Code (CodeBlock.astro, with db-code-block for copy) ────────────
498
- const COPY_ICON = '<svg data-part="copy-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
499
- const CHECK_ICON = '<svg data-part="check-icon" hidden xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>';
530
+ export const COPY_ICON = '<svg data-part="copy-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
531
+ export const CHECK_ICON = '<svg data-part="check-icon" hidden xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>';
500
532
  /** A rendered diagram, as `@dogsbay/ui`'s Diagram.astro: dark mode inverts it and turns the hues back. */
501
533
  function diagramFigure(lang, svg, title, p) {
502
534
  return (`<figure${attrs({ ...passthrough(p, "diagram not-prose my-4"), "data-lang": lang })}>` +
@@ -542,6 +574,9 @@ export function highlightInputs(tree) {
542
574
  const { lang, code } = codeRequest({ type: "code", props: { ...p, language: str(p.lang) ?? "mermaid" }, html: str(p.code) ?? "" });
543
575
  out.push({ lang, code });
544
576
  }
577
+ else if (API_NODE_TYPES.has(n.type)) {
578
+ out.push(...apiCodeInputs(n));
579
+ }
545
580
  if (n.children)
546
581
  walk(n.children);
547
582
  if (n.props)
@@ -552,6 +587,10 @@ export function highlightInputs(tree) {
552
587
  walk(tree);
553
588
  return out;
554
589
  }
590
+ /** A code block for code a component holds in a prop (an API signature, a sample), as CodeBlock.astro renders it. */
591
+ export function codeHtml(code, lang, ctx) {
592
+ return codeBlock({ type: "code", props: lang ? { language: lang } : {}, html: code }, ctx);
593
+ }
555
594
  function codeBlock(n, ctx) {
556
595
  const p = (n.props ?? {});
557
596
  const rawLang = str(p.language) ?? str(p.lang);
@@ -690,27 +729,31 @@ function tabs(n, ctx) {
690
729
  const kids = (n.children ?? []).filter((c) => c.type === "tab");
691
730
  if (!kids.length)
692
731
  return renderBlocks(n.children, ctx);
693
- const group = ++ctx.seq;
694
732
  const used = new Set();
695
733
  // The label itself: synced groups match by value, and a slug would merge C, C++ and C#.
696
- const values = kids.map((k, i) => {
734
+ const items = kids.map((k, i) => {
697
735
  let v = (str(k.props?.label) ?? `Tab ${i + 1}`).trim();
698
736
  while (used.has(v))
699
737
  v += ` (${i + 1})`;
700
738
  used.add(v);
701
- return v;
739
+ return { value: v, label: esc(str(k.props?.label) ?? `Tab ${i + 1}`), body: () => renderBlocks(k.children, ctx) };
702
740
  });
703
741
  const syncKey = str(p.syncKey) ?? str(p["sync-key"]) ?? str(p.group);
742
+ return tabGroup(items, ctx, passthrough(p, "my-4 w-full overflow-hidden rounded-lg border border-border"), syncKey);
743
+ }
744
+ /** A `db-tabs` group with Tabs.astro's markup, the first tab selected. */
745
+ export function tabGroup(items, ctx, rootAttrs, syncKey) {
746
+ const group = ++ctx.seq;
704
747
  let list = "";
705
748
  let panels = "";
706
- kids.forEach((k, i) => {
749
+ items.forEach((it, i) => {
707
750
  const tid = `tab-${group}-${i + 1}`;
708
751
  const pid = `tabpanel-${group}-${i + 1}`;
709
752
  const sel = i === 0;
710
- list += `<button data-part="trigger" data-value="${esc(values[i])}" role="tab" type="button" id="${tid}" aria-controls="${pid}" aria-selected="${sel}" tabindex="${sel ? 0 : -1}"${sel ? ' data-state="active"' : ""} class="${TAB_TRIGGER}">${esc(str(k.props?.label) ?? `Tab ${i + 1}`)}</button>`;
711
- panels += `<div data-part="content" data-value="${esc(values[i])}" role="tabpanel" id="${pid}" aria-labelledby="${tid}" tabindex="0"${sel ? ' data-state="active"' : " hidden"} class="${TAB_CONTENT}">${renderBlocks(k.children, ctx)}</div>`;
753
+ list += `<button data-part="trigger" data-value="${esc(it.value)}" role="tab" type="button" id="${tid}" aria-controls="${pid}" aria-selected="${sel}" tabindex="${sel ? 0 : -1}"${sel ? ' data-state="active"' : ""} class="${TAB_TRIGGER}">${it.label}</button>`;
754
+ panels += `<div data-part="content" data-value="${esc(it.value)}" role="tabpanel" id="${pid}" aria-labelledby="${tid}" tabindex="0"${sel ? ' data-state="active"' : " hidden"} class="${TAB_CONTENT}">${it.body()}</div>`;
712
755
  });
713
- return `<db-tabs${attrs({ ...passthrough(p, "my-4 w-full overflow-hidden rounded-lg border border-border"), "default-value": values[0], "sync-key": syncKey })}><div data-part="list" role="tablist" aria-orientation="horizontal" class="flex items-center border-b border-border bg-muted p-1 text-muted-foreground">${list}</div>${panels}</db-tabs>\n`;
756
+ return `<db-tabs${attrs({ ...rootAttrs, "default-value": items[0]?.value, "sync-key": syncKey })}><div data-part="list" role="tablist" aria-orientation="horizontal" class="flex items-center border-b border-border bg-muted p-1 text-muted-foreground">${list}</div>${panels}</db-tabs>\n`;
714
757
  }
715
758
  // ─── Accordion (Accordion*.astro + db-accordion) ────────────────────
716
759
  function accordion(n, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/render-html",
3
- "version": "0.2.0-beta.117",
3
+ "version": "0.2.0-beta.119",
4
4
  "description": "Dogsbay MD TreeNode → HTML with the Astro components' markup and classes. No highlighter, math or CSS compiler unless you pass one; Worker- and browser-safe",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -29,9 +29,10 @@
29
29
  ],
30
30
  "dependencies": {
31
31
  "htmlparser2": "^10.1.0",
32
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.117",
33
- "@dogsbay/serialize-core": "0.2.0-beta.117",
34
- "@dogsbay/types": "0.2.0-beta.117"
32
+ "markdown-it": "^14.3.0",
33
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.119",
34
+ "@dogsbay/serialize-core": "0.2.0-beta.119",
35
+ "@dogsbay/types": "0.2.0-beta.119"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "katex": "^0.16.0"
@@ -42,6 +43,7 @@
42
43
  }
43
44
  },
44
45
  "devDependencies": {
46
+ "@types/markdown-it": "^14.1.2",
45
47
  "@types/node": "^22.20.1",
46
48
  "esbuild": "^0.25.0",
47
49
  "katex": "^0.16.47",