@dogsbay/render-html 0.2.0-beta.121 → 0.2.0-beta.123

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
@@ -9,17 +9,19 @@ markup and Tailwind classes of the Dogsbay Astro components.
9
9
 
10
10
  ```ts
11
11
  import { dogsbayMdToTree } from "@dogsbay/format-dogsbay-md/browser";
12
- import { renderBlocks, type RenderContext } from "@dogsbay/render-html";
13
-
14
- const ctx: RenderContext = {
15
- link: (href) => href,
16
- asset: (src) => src,
17
- headings: [], ids: new Set(), unknown: new Set(), seq: 0,
18
- t: (key) => key,
12
+ import { createRenderContext, renderBlocks } from "@dogsbay/render-html";
13
+
14
+ // One context per page. Pass only what you choose; the renderer's own state
15
+ // (headings, ids, …) is created for you, and UI strings default to English.
16
+ const ctx = createRenderContext({
17
+ // link: (href) => …, // default: unchanged
18
+ // asset: (src) => …, // default: unchanged
19
+ // t: (key) => myStrings[key], // empty answers fall back to UI_STRINGS
19
20
  // highlighter: await createHighlighter(), // @dogsbay/highlight
20
21
  // math: katexMath, // @dogsbay/render-html/math (needs katex)
21
- };
22
+ });
22
23
  const html = renderBlocks(dogsbayMdToTree(markdown).tree, ctx);
24
+ // ctx.headings: the h2/h3 for a table of contents; ctx.unknown: node types it did not know
23
25
  ```
24
26
 
25
27
  The output uses Tailwind utility classes and the theme tokens. Styling it needs the Dogsbay
package/css/docs.css CHANGED
@@ -51,8 +51,9 @@
51
51
  looked like a colour bug in one button rather than a cascade bug in both.
52
52
  (No backticks in this comment: the whole stylesheet is a template
53
53
  literal.) */
54
- & a:not(.dba-api *):not([data-variant]) { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
55
- & a:hover:not(.dba-api *):not([data-variant]) { opacity: 0.8; }
54
+ /* Not a whole-card link (LinkCard, the video card: `a.not-prose`): its underline ran under every line of the card */
55
+ & a:not(.dba-api *):not([data-variant]):not(.not-prose) { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
56
+ & a:hover:not(.dba-api *):not([data-variant]):not(.not-prose) { opacity: 0.8; }
56
57
 
57
58
  & strong:not(.dba-api *) { font-weight: 600; }
58
59
  & code:not(.dba-api *) { font-size: 0.875em; padding: 0.15em 0.35em; border-radius: 0.25rem; background: var(--muted); font-family: var(--font-code, ui-monospace, monospace); }
@@ -66,6 +67,9 @@
66
67
  & ol:not(.dba-api *) { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
67
68
  & li:not(.dba-api *) { margin: 0.25rem 0; }
68
69
  & li > ul:not(.dba-api *), & li > ol:not(.dba-api *) { margin: 0.25rem 0; }
70
+ /* A file tree draws its own folder and file icons (files/markup.ts): the list rules above gave it bullets too */
71
+ & [data-component="files"] ul:not(.dba-api *) { list-style: none; padding-left: 0; }
72
+ & [data-component="files"] li > ul:not(.dba-api *) { padding-left: 1rem; }
69
73
 
70
74
  & blockquote:not(.dba-api *) { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
71
75
 
@@ -91,7 +95,11 @@
91
95
  & .heading-anchor { text-decoration: none; opacity: 0; margin-right: 0.25rem; transition: opacity 0.2s; }
92
96
  & h1:hover .heading-anchor, & h2:hover .heading-anchor, & h3:hover .heading-anchor, & h4:hover .heading-anchor { opacity: 0.4; }
93
97
 
94
- & details:not(.dba-api *) { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
98
+ /* Raw HTML <details> only. CollapsibleAlert (:::details, collapsible
99
+ callouts) is a <details> too, with its own border and spacing; it
100
+ carries data-variant, and without the exclusion it got a second border
101
+ and 1rem of padding around its title bar. */
102
+ & details:not(.dba-api *):not([data-variant]) { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
95
103
  & summary:not(.dba-api *) { cursor: pointer; font-weight: 600; }
96
104
 
97
105
  /* mark is emitted by the highlight directive AND can arrive as raw HTML
@@ -28,6 +28,8 @@ export const BADGE_VARIANTS = {
28
28
  };
29
29
  /** The class list for a variant; an unknown variant gets the base classes only. */
30
30
  export function badgeClass(variant) {
31
- const v = BADGE_VARIANTS[(variant ?? "default")];
31
+ // Own keys only: `toString` or `__proto__` must not read Object.prototype
32
+ const key = variant ?? "default";
33
+ const v = Object.prototype.hasOwnProperty.call(BADGE_VARIANTS, key) ? BADGE_VARIANTS[key] : undefined;
32
34
  return v ? `${BADGE_BASE} ${v}` : BADGE_BASE;
33
35
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What the HTML renderer can and cannot represent — a CONTRACT (the
3
+ * format-docusaurus pattern): `validateCapabilities` rejects any non-full
4
+ * entry without a note, and `tests/capabilities.test.ts` asserts every
5
+ * `MATRIX_ROWS` type is declared exactly once. Measured against `render.ts`
6
+ * and `api.ts` by rendering real nodes, not read off the switch.
7
+ *
8
+ * This renderer has no export ledger. What it does record goes to the
9
+ * caller's RenderContext: `unknown` (node types and directives it has no
10
+ * case for, the example fallback panel, unresolved includes), `missingIcons`
11
+ * and `unsafeUrls`. dogsbay-ssg and @dogsbay/render turn those into build
12
+ * warnings. A degradation that lands in none of them is SILENT, and the
13
+ * notes below say so where it happens.
14
+ *
15
+ * Several cells depend on what the caller passes: math is `full` only with a
16
+ * `math` function (KaTeX), a diagram only with an `svg` prop or a `diagram`
17
+ * renderer, an icon only when the built-in Lucide set or the `icons`
18
+ * resolver knows it. Those are declared `partial`, because the renderer on
19
+ * its own does not guarantee them.
20
+ *
21
+ * Support levels: full — renders faithfully, with the Astro components'
22
+ * markup · partial — survives with reduced fidelity · none — dropped.
23
+ */
24
+ import type { CapabilityDeclaration } from "@dogsbay/serialize-core";
25
+ export declare const RENDER_HTML_CAPABILITIES: CapabilityDeclaration[];
@@ -0,0 +1,119 @@
1
+ export const RENDER_HTML_CAPABILITIES = [
2
+ // ── Blocks ───────────────────────────────────────────────
3
+ { feature: "heading", support: "full", rendersAs: "`<hN>` with a unique id and a hover pilcrow; h2/h3 join the TOC" },
4
+ { feature: "paragraph", support: "full", rendersAs: "`<p>` (a lone titled image becomes a figure with `imageCaptions: \"title\"`)" },
5
+ { feature: "blockquote", support: "full", rendersAs: "`<blockquote>`" },
6
+ { feature: "unordered-list", support: "full", rendersAs: "`<ul>`, task items as disabled checkboxes" },
7
+ { feature: "ordered-list", support: "full", rendersAs: "`<ol>` with `start`" },
8
+ { feature: "hr", support: "full", rendersAs: "`<hr>`" },
9
+ { feature: "table", support: "full", rendersAs: "scrollable `<table>` with caption, alignment, colspan/rowspan" },
10
+ {
11
+ feature: "code",
12
+ support: "full",
13
+ rendersAs: "`<db-code-block>` (CodeBlock / CodeRich markup); highlighted only when a Highlighter is passed, plain text otherwise",
14
+ },
15
+ {
16
+ feature: "math-block",
17
+ support: "partial",
18
+ rendersAs: "KaTeX HTML + MathML when `ctx.math` is passed",
19
+ note: "Without a math renderer the LaTeX source is shown in a `<pre><code class=\"math-display\">` — readable, not typeset. Not recorded.",
20
+ },
21
+ { feature: "deflist", support: "full", rendersAs: "`<dl>` / `<dt>` / `<dd>`" },
22
+ { feature: "figure", support: "full", rendersAs: "`<figure>` with `<figcaption>`" },
23
+ { feature: "footnote-list", support: "full", rendersAs: "`<section role=\"doc-endnotes\">` with backlinks" },
24
+ {
25
+ feature: "html",
26
+ support: "full",
27
+ rendersAs: "the HTML as authored, links rewritten, script URLs refused (reported in `unsafeUrls`); filtered by `ctx.rawHtml` when the host passes a sanitizer",
28
+ },
29
+ { feature: "html-container", support: "full", rendersAs: "its HTML as authored, or a `div.db-html-container` around its children" },
30
+ {
31
+ feature: "diagram",
32
+ support: "partial",
33
+ rendersAs: "`<figure class=\"diagram\">` around the SVG",
34
+ note: "Rendered only when the node carries an `svg` prop or the host passes `ctx.diagram`; otherwise the diagram SOURCE is shown as a code block. Not recorded.",
35
+ },
36
+ {
37
+ feature: "youtube",
38
+ support: "partial",
39
+ rendersAs: "a link card to youtube.com/watch (optional local poster image)",
40
+ note: "Deliberately no embed: the page must work offline and make no request to YouTube. The Astro pipeline embeds on click.",
41
+ },
42
+ // ── Components ───────────────────────────────────────────
43
+ { feature: "callout", support: "full", rendersAs: "Alert markup (12 variants, aliases mapped); collapsible → CollapsibleAlert `<details>`" },
44
+ { feature: "details", support: "full", rendersAs: "CollapsibleAlert `<details>`, as the Astro serializer" },
45
+ { feature: "details-sheet", support: "full", rendersAs: "`<db-details-sheet>` around the CollapsibleAlert `<details>` (a right-hand native `<dialog>` when the element script runs; a collapsible block with `js: none`)" },
46
+ { feature: "theme", support: "full", rendersAs: "a `<div class=\"dark …\" data-theme>` (THEME_SCOPES); an unknown name keeps the content and is reported" },
47
+ { feature: "tabs-variant", support: "full", rendersAs: "the variant's complete list and trigger classes in place of the base ones (TAB_VARIANTS)" },
48
+ { feature: "tone", support: "full", rendersAs: "each card's border and tint from the tone (CARD_TONE_CLASSES)" },
49
+ {
50
+ feature: "tabs",
51
+ support: "full",
52
+ rendersAs: "`<db-tabs>` with Tabs.astro markup, synced groups by label; a child that is not a tab stays in reading order around the group",
53
+ },
54
+ { feature: "steps", support: "full", rendersAs: "`<ol class=\"steps-list\">`; a non-step child is wrapped in its own `<li>`" },
55
+ {
56
+ feature: "accordion",
57
+ support: "full",
58
+ rendersAs: "`<db-accordion>` with Accordion.astro markup; a child that is not an item stays in reading order around the group",
59
+ },
60
+ { feature: "cards", support: "full", rendersAs: "a responsive card grid `<div>`" },
61
+ { feature: "card", support: "full", rendersAs: "Card.astro markup; icon, title, description, body; linked when `href` is set" },
62
+ { feature: "link-card", support: "full", rendersAs: "LinkCard.astro markup; a body of several blocks renders as a linked Card" },
63
+ { feature: "files", support: "full", rendersAs: "FileTree's markup: folder and file icons, nested" },
64
+ { feature: "grid", support: "full", rendersAs: "a 1–6 column grid; grid-item spans and placeholder cells" },
65
+ { feature: "link-button", support: "full", rendersAs: "an `<a>` with Button.astro's classes and variant" },
66
+ { feature: "related", support: "full", rendersAs: "`<nav class=\"related-resources\">`" },
67
+ {
68
+ feature: "example",
69
+ support: "partial",
70
+ rendersAs: "a figure: the rendered content, then its source in a collapsed panel",
71
+ note: "The Astro component's optional plain-Markdown panel (`fallback=true`) needs a second Markdown renderer and is left out; reported in `ctx.unknown`.",
72
+ },
73
+ {
74
+ feature: "container",
75
+ support: "partial",
76
+ rendersAs: "`<div data-directive=\"name\">` with its title and content",
77
+ note: "A `:::name` directive no renderer knows: content kept, but no component behaviour or styling. Reported in `ctx.unknown` as `:::name`.",
78
+ },
79
+ {
80
+ feature: "annotation-list",
81
+ support: "partial",
82
+ rendersAs: "a tinted box holding the annotation list (format-astro's wrapBlock)",
83
+ note: "The annotations are not attached to the code block's `(1)` markers as pop-ups; they read as a list below the code, as in the Astro pipeline.",
84
+ },
85
+ // ── API reference ────────────────────────────────────────
86
+ { feature: "endpoint", support: "full", rendersAs: "ApiLayout + EndpointCard + code panel; descriptions are CommonMark with raw HTML off" },
87
+ { feature: "api-doc", support: "full", rendersAs: "ApiDoc: prose with its code blocks split out" },
88
+ { feature: "api-class", support: "full", rendersAs: "ApiSymbol: kind badge, heading, signature, bases, body" },
89
+ { feature: "api-member", support: "full", rendersAs: "ApiSymbol, one level below its parent" },
90
+ { feature: "api-params", support: "full", rendersAs: "ApiParams two-column table" },
91
+ { feature: "api-source", support: "full", rendersAs: "ApiSource: a `<details>` with the source code" },
92
+ // ── Inline ───────────────────────────────────────────────
93
+ { feature: "link", support: "full", rendersAs: "`<a>` via `ctx.link`; inline buttons with Button.astro classes; script URLs become `#` and are reported" },
94
+ { feature: "image", support: "full", rendersAs: "`<img>` via `ctx.asset`, lazy; optional zoom marker" },
95
+ { feature: "kbd", support: "full", rendersAs: "one `<kbd>` keycap per key, joined by `+`" },
96
+ {
97
+ feature: "math",
98
+ support: "partial",
99
+ rendersAs: "KaTeX HTML + MathML when `ctx.math` is passed",
100
+ note: "Without a math renderer the LaTeX source is shown as `<code class=\"db-math\">`. Not recorded.",
101
+ },
102
+ { feature: "highlight", support: "full", rendersAs: "`<mark>`" },
103
+ { feature: "badge", support: "full", rendersAs: "Badge.astro `<span>` with its variant" },
104
+ {
105
+ feature: "icon",
106
+ support: "partial",
107
+ rendersAs: "Icon.astro markup around the resolved SVG or emoji",
108
+ note: "Resolved by `ctx.icons` or the built-in Lucide subset only; an unknown name becomes an empty `span.db-icon-missing`, reported in `ctx.missingIcons`.",
109
+ },
110
+ { feature: "footnote-ref", support: "full", rendersAs: "`<sup>` noteref; repeat references get distinct ids" },
111
+ { feature: "html-inline", support: "full", rendersAs: "the HTML as authored, links rewritten, filtered by `ctx.rawHtml` when passed" },
112
+ // ── Concerns the renderer records ────────────────────────
113
+ {
114
+ feature: "unrecognised node types",
115
+ support: "partial",
116
+ rendersAs: "block: its HTML, or a `div.db-<type>` with its inline and children; inline: nothing",
117
+ note: "Reported in `ctx.unknown` (blocks by type, inline as `inline:<type>`). An unknown INLINE node is dropped; an unknown block keeps its content.",
118
+ },
119
+ ];
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A RenderContext with defaults. RenderContext mixes what a host chooses
3
+ * (links, assets, highlighter, math, icons, strings) with the renderer's own
4
+ * per-page state (headings, ids, seq, unknown …); hosts built it by hand,
5
+ * reading render.d.ts to get the internals right, and a new internal field
6
+ * broke every host silently. Build it here instead: pass only what you choose.
7
+ */
8
+ import type { RenderContext } from "./render.js";
9
+ /** The UI strings the renderer asks for (ctx.t), in English. */
10
+ export declare const UI_STRINGS: Readonly<Record<string, string>>;
11
+ /** The per-page state the renderer keeps in the context: always fresh, never passed. */
12
+ type Internal = "headings" | "ids" | "unknown" | "seq" | "t" | "fnRefs" | "usedMath" | "inLink" | "lastHeadingLevel" | "apiParentLevel" | "missingIcons" | "unsafeUrls";
13
+ export type RenderContextOptions = Partial<Omit<RenderContext, Internal>> & {
14
+ /**
15
+ * Localised UI strings. An empty or missing answer falls back to English
16
+ * (UI_STRINGS), then to the renderer's own label (callout titles).
17
+ */
18
+ t?: (key: string) => string | undefined;
19
+ /** Ids the page already uses outside the rendered content (a skip link's target): generated ids avoid them. */
20
+ reservedIds?: Iterable<string>;
21
+ };
22
+ /** A fresh context for one page: identity links and assets, English strings, empty state. */
23
+ export declare function createRenderContext(options?: RenderContextOptions): RenderContext;
24
+ export {};
@@ -0,0 +1,25 @@
1
+ /** The UI strings the renderer asks for (ctx.t), in English. */
2
+ export const UI_STRINGS = {
3
+ backToContent: "Back to content",
4
+ close: "Close",
5
+ copyCode: "Copy code",
6
+ details: "Details",
7
+ related: "Related links",
8
+ };
9
+ /** A fresh context for one page: identity links and assets, English strings, empty state. */
10
+ export function createRenderContext(options = {}) {
11
+ const { t, reservedIds, ...chosen } = options;
12
+ return {
13
+ ...chosen,
14
+ // An explicit `link: undefined` must not remove the default
15
+ link: chosen.link ?? ((href) => href),
16
+ asset: chosen.asset ?? ((src) => src),
17
+ headings: [],
18
+ ids: new Set(reservedIds ?? []),
19
+ unknown: new Set(),
20
+ missingIcons: new Set(),
21
+ unsafeUrls: new Set(),
22
+ seq: 0,
23
+ t: (key) => t?.(key) || UI_STRINGS[key] || "",
24
+ };
25
+ }
@@ -1,8 +1,3 @@
1
- /**
2
- * Every class name in the page's class attributes, entity-decoded. Markup
3
- * from other tools (KaTeX, Mermaid), between the foreign markers, is skipped:
4
- * its class names are not Tailwind utilities.
5
- */
6
1
  export declare function pageClasses(html: string, into?: Set<string>): Set<string>;
7
2
  /**
8
3
  * Whitespace and comment removal. Tailwind's own minifier (Lightning CSS)
package/dist/css-tools.js CHANGED
@@ -11,11 +11,20 @@ const FOREIGN_BLOCK = new RegExp(`${FOREIGN_START}[\\s\\S]*?${FOREIGN_END}`, "g"
11
11
  * from other tools (KaTeX, Mermaid), between the foreign markers, is skipped:
12
12
  * its class names are not Tailwind utilities.
13
13
  */
14
+ const TAG = /<[a-zA-Z][^\s/>]*(?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?|\s*\/)*\s*>/g;
14
15
  export function pageClasses(html, into = new Set()) {
15
16
  const page = html.includes(FOREIGN_START) ? html.replace(FOREIGN_BLOCK, "") : html;
16
17
  // Only inside tags: text that merely looks like class="…" (escaped prose, code) must
17
18
  // not become CSS. A class compiled from text once wrote `</style>` into the stylesheet.
18
- for (const tag of page.matchAll(/<[a-zA-Z][^<>]*>/g))
19
+ // A quoted attribute value may contain `>` (`[&>svg]:size-4`, valid HTML):
20
+ // a tag pattern that stopped at the first `>` cut the tag mid-class and lost
21
+ // the WHOLE class list (the accordion trigger, callout icons, diagrams).
22
+ // So tags are read with HTML's attribute grammar: a quote opens a value ONLY
23
+ // right after `=`. Allowing quotes anywhere let an unquoted value with an
24
+ // apostrophe (`title=it's`) run on past the real `>` into the text, and
25
+ // text after it became CSS (review). A tag that does not parse is skipped:
26
+ // a missing style, never text compiled as CSS.
27
+ for (const tag of page.matchAll(TAG))
19
28
  for (const m of tag[0].matchAll(/\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
20
29
  for (const tok of (m[1] ?? m[2]).split(/\s+/))
21
30
  if (tok)
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * imports neither, so it carries no Shiki, KaTeX or Node built-ins.
7
7
  */
8
8
  export * from "./render.js";
9
+ export { createRenderContext, UI_STRINGS, type RenderContextOptions } from "./context.js";
9
10
  export * from "./lines.js";
10
11
  export { esc, attrs, passthrough, slugify, unesc } from "./html.js";
11
12
  export { icon, hasIcon, CALLOUTS } from "./icons.js";
13
+ export { RENDER_HTML_CAPABILITIES } from "./capabilities.js";
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@
6
6
  * imports neither, so it carries no Shiki, KaTeX or Node built-ins.
7
7
  */
8
8
  export * from "./render.js";
9
+ export { createRenderContext, UI_STRINGS } from "./context.js";
9
10
  export * from "./lines.js";
10
11
  export { esc, attrs, passthrough, slugify, unesc } from "./html.js";
11
12
  export { icon, hasIcon, CALLOUTS } from "./icons.js";
13
+ export { RENDER_HTML_CAPABILITIES } from "./capabilities.js";
package/dist/render.d.ts CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * TreeNode → HTML. Emits plain semantic HTML plus light-DOM custom
3
- * elements (`db-tabs`, `db-accordion`, `db-code-block`) whose markup
4
- * contract comes from `@dogsbay/elements`. Everything is a string, so
5
- * rendering is synchronous and runs anywhere.
6
- */
7
1
  import type { InlineNode, TreeNode } from "@dogsbay/types";
8
2
  import { FOREIGN_END, FOREIGN_START } from "./html.js";
9
3
  import { type Highlighter } from "./lines.js";
@@ -142,4 +136,4 @@ export interface TabItem {
142
136
  body: () => string;
143
137
  }
144
138
  /** 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;
139
+ export declare function tabGroup(items: TabItem[], ctx: RenderContext, rootAttrs: Record<string, unknown>, syncKey?: string, variant?: string): string;
package/dist/render.js CHANGED
@@ -1,9 +1,16 @@
1
+ /**
2
+ * TreeNode → HTML. Emits plain semantic HTML plus light-DOM custom
3
+ * elements (`db-tabs`, `db-accordion`, `db-code-block`) whose markup
4
+ * contract comes from `@dogsbay/elements`. Everything is a string, so
5
+ * rendering is synchronous and runs anywhere.
6
+ */
7
+ import { isDetailsSheet, renderAroundGroup } from "@dogsbay/serialize-core";
1
8
  import { parseInline } from "@dogsbay/format-dogsbay-md/browser";
2
9
  import { attrs, esc, FOREIGN_END, FOREIGN_START, passthrough, slugify, unesc } from "./html.js";
3
10
  import { CALLOUTS, hasIcon, icon } from "./icons.js";
4
11
  import { badgeClass } from "./badge-variants.js";
5
12
  import { KBD_CLASS } from "./kbd-variants.js";
6
- import { TONE_CLASSES } from "./tone-classes.js";
13
+ import { CARD_TONE_CLASSES, TAB_VARIANTS, THEME_SCOPES, TONE_CLASSES, lookup } from "./tone-classes.js";
7
14
  import { fileTreeHtml } from "./file-tree.js";
8
15
  import { stripMarkersForCopy } from "./code-markers.js";
9
16
  import { icons as ALERT_ICONS, chevronSvg as ALERT_CHEVRON } from "./alert-icons.js";
@@ -94,7 +101,7 @@ function inline(n, ctx) {
94
101
  const variant = n.button ? buttonVariant(n.button.variant) : undefined;
95
102
  const a = {
96
103
  // Button.astro's small size, whose text-xs replaces the base text-sm (tailwind-merge in tv()).
97
- ...passthrough(n.attrs, variant ? `${BUTTON_BASE.split(" ").filter((c) => c !== "text-sm").join(" ")} ${BUTTON_VARIANTS[variant] ?? BUTTON_VARIANTS.default} h-9 px-3 text-xs no-underline` : undefined),
104
+ ...passthrough(n.attrs, variant ? `${BUTTON_BASE.split(" ").filter((c) => c !== "text-sm").join(" ")} ${lookup(BUTTON_VARIANTS, variant) ?? BUTTON_VARIANTS.default} h-9 px-3 text-xs no-underline` : undefined),
98
105
  href,
99
106
  title: n.title,
100
107
  "data-variant": variant,
@@ -246,9 +253,27 @@ function proseHeadings(html, ctx) {
246
253
  });
247
254
  }
248
255
  // ─── Blocks ──────────────────────────────────────────────────────────
256
+ /**
257
+ * The heading ids a page will use, collected on the first (outermost)
258
+ * renderBlocks call for a context: a side sheet's title id must yield to a
259
+ * heading of the same name even when the sheet comes first, or the heading's
260
+ * TOC entry and links would open the sheet instead (#101).
261
+ */
262
+ const plannedHeadingIds = new WeakMap();
263
+ function planHeadingIds(nodes, out) {
264
+ for (const n of nodes) {
265
+ if (n.type === "heading")
266
+ out.add(str(n.props?.id) ?? str(n.props?.slug) ?? slugify(inlineText(n.inline)));
267
+ if (n.children)
268
+ planHeadingIds(n.children, out);
269
+ }
270
+ return out;
271
+ }
249
272
  export function renderBlocks(nodes, ctx) {
250
273
  if (!nodes)
251
274
  return "";
275
+ if (!plannedHeadingIds.has(ctx))
276
+ plannedHeadingIds.set(ctx, planHeadingIds(nodes, new Set()));
252
277
  let out = "";
253
278
  for (const n of nodes)
254
279
  out += block(n, ctx);
@@ -359,11 +384,11 @@ function block(n, ctx) {
359
384
  // Tight, like the Astro Step: the lead text is not wrapped in <p>.
360
385
  return `<li>${renderInline(n.inline, ctx)}${renderBlocks(n.children, ctx)}</li>`;
361
386
  case "tabs":
362
- return tabs(n, ctx);
387
+ return renderAroundGroup(n, "tab", (g) => tabs(g, ctx), (c) => renderBlocks([c], ctx), "");
363
388
  case "tab":
364
389
  return renderBlocks(n.children, ctx);
365
390
  case "accordion":
366
- return accordion(n, ctx);
391
+ return renderAroundGroup(n, "accordion-item", (g) => accordion(g, ctx), (c) => renderBlocks([c], ctx), "");
367
392
  case "accordion-item":
368
393
  return accordion({ type: "accordion", children: [n] }, ctx);
369
394
  case "details":
@@ -382,8 +407,21 @@ function block(n, ctx) {
382
407
  return avatar(p, ctx);
383
408
  case "example":
384
409
  return example(n, ctx);
385
- case "cards":
386
- return `<div${attrs(passthrough(p, "not-prose my-4 grid grid-cols-1 gap-4 sm:grid-cols-2"))}>${renderBlocks(n.children, ctx)}</div>\n`;
410
+ case "cards": {
411
+ // A group tone applies to every card that has none of its own
412
+ const tone = str(p.tone);
413
+ const kids = tone ? (n.children ?? []).map((c) => (c.type === "card" && !c.props?.tone ? { ...c, props: { ...c.props, tone } } : c)) : n.children;
414
+ return `<div${attrs(passthrough(p, "not-prose my-4 grid grid-cols-1 gap-4 sm:grid-cols-2"))}>${renderBlocks(kids, ctx)}</div>\n`;
415
+ }
416
+ // A scoped theme: `.dark` re-declares the tokens for its subtree (THEME_SCOPES)
417
+ case "theme": {
418
+ const scope = lookup(THEME_SCOPES, str(p.name));
419
+ if (!scope) {
420
+ ctx.unknown.add(`theme "${str(p.name) ?? ""}"`);
421
+ return renderBlocks(n.children, ctx);
422
+ }
423
+ return `<div${attrs({ ...passthrough(p, scope), "data-theme": str(p.name) })}>${renderBlocks(n.children, ctx)}</div>\n`;
424
+ }
387
425
  case "card":
388
426
  return card(n, ctx);
389
427
  case "link-card":
@@ -399,7 +437,7 @@ function block(n, ctx) {
399
437
  // `label` draws a placeholder cell in the `tone` colours, as GridItem does in the Astro pipeline.
400
438
  const label = str(p.label);
401
439
  const inner = label !== undefined
402
- ? `<div class="flex h-full min-h-16 items-center justify-center rounded-lg font-mono text-sm font-medium ${TONE_CLASSES[str(p.tone) ?? "primary-light"] ?? TONE_CLASSES["primary-light"]}">${esc(label)}</div>`
440
+ ? `<div class="flex h-full min-h-16 items-center justify-center rounded-lg font-mono text-sm font-medium ${lookup(TONE_CLASSES, str(p.tone)) ?? TONE_CLASSES["primary-light"]}">${esc(label)}</div>`
403
441
  : renderBlocks(n.children, ctx);
404
442
  const { label: _l, tone: _t, rowSpan: _r, ...rest } = p;
405
443
  const pass = passthrough(rest);
@@ -423,7 +461,7 @@ function block(n, ctx) {
423
461
  const href = str(p.href);
424
462
  const variant = buttonVariant(str(p.variant));
425
463
  const label = insideLink(true, ctx, () => (n.children?.length === 1 && n.children[0].type === "paragraph" ? renderInline(n.children[0].inline, ctx) : renderBlocks(n.children, ctx)));
426
- return `<p><a${attrs({ ...passthrough(p, `${BUTTON_BASE} ${BUTTON_VARIANTS[variant] ?? BUTTON_VARIANTS.default} h-10 px-4 py-2 no-underline`), "data-variant": variant, "data-size": "default", href: href ? linkUrl(href, ctx) : undefined })}>${label}</a></p>\n`;
464
+ return `<p><a${attrs({ ...passthrough(p, `${BUTTON_BASE} ${lookup(BUTTON_VARIANTS, variant) ?? BUTTON_VARIANTS.default} h-10 px-4 py-2 no-underline`), "data-variant": variant, "data-size": "default", href: href ? linkUrl(href, ctx) : undefined })}>${label}</a></p>\n`;
427
465
  }
428
466
  case "youtube": {
429
467
  // A card linking to the video, not an embed: the page must work offline, with no request to YouTube
@@ -478,6 +516,12 @@ function block(n, ctx) {
478
516
  ctx.unknown.add("include (unresolved)");
479
517
  return `<!-- unresolved include: ${esc(target)} -->\n`;
480
518
  }
519
+ // Raw HTML (a one-line `<details>` body) or an importer's wrapper: known,
520
+ // so not reported as unknown (that warned on content that rendered fine)
521
+ case "html-container":
522
+ if (n.html && !n.children?.length)
523
+ return rewriteHtml(n.html, ctx);
524
+ return `<div${attrs(passthrough(p, "db-html-container"))}>${renderBlocks(n.children, ctx)}</div>\n`;
481
525
  default: {
482
526
  ctx.unknown.add(n.type);
483
527
  if (n.html && !n.children?.length)
@@ -739,10 +783,12 @@ function tabs(n, ctx) {
739
783
  return { value: v, label: esc(str(k.props?.label) ?? `Tab ${i + 1}`), body: () => renderBlocks(k.children, ctx) };
740
784
  });
741
785
  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);
786
+ return tabGroup(items, ctx, passthrough(p, "my-4 w-full overflow-hidden rounded-lg border border-border"), syncKey, str(p.variant));
743
787
  }
744
788
  /** A `db-tabs` group with Tabs.astro's markup, the first tab selected. */
745
- export function tabGroup(items, ctx, rootAttrs, syncKey) {
789
+ export function tabGroup(items, ctx, rootAttrs, syncKey, variant) {
790
+ // A variant's class strings are complete: they replace the base ones (TAB_VARIANTS)
791
+ const v = lookup(TAB_VARIANTS, variant);
746
792
  const group = ++ctx.seq;
747
793
  let list = "";
748
794
  let panels = "";
@@ -750,10 +796,10 @@ export function tabGroup(items, ctx, rootAttrs, syncKey) {
750
796
  const tid = `tab-${group}-${i + 1}`;
751
797
  const pid = `tabpanel-${group}-${i + 1}`;
752
798
  const sel = i === 0;
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>`;
799
+ 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="${v?.trigger ?? TAB_TRIGGER}">${it.label}</button>`;
754
800
  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>`;
755
801
  });
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`;
802
+ return `<db-tabs${attrs({ ...rootAttrs, "default-value": items[0]?.value, "sync-key": syncKey })}><div data-part="list" role="tablist" aria-orientation="horizontal" class="${v?.list ?? "flex items-center border-b border-border bg-muted p-1 text-muted-foreground"}">${list}</div>${panels}</db-tabs>\n`;
757
803
  }
758
804
  // ─── Accordion (Accordion*.astro + db-accordion) ────────────────────
759
805
  function accordion(n, ctx) {
@@ -781,12 +827,29 @@ function accordion(n, ctx) {
781
827
  }
782
828
  // `:::details` renders as a CollapsibleAlert, as in the Astro serializer.
783
829
  function details(n, ctx) {
784
- const p = (n.props ?? {});
830
+ let p = (n.props ?? {});
831
+ // A side sheet with no id takes one from its title, so a link written as for
832
+ // a heading opens it (#101; extractHeadings does the same for a site build)
833
+ if (isDetailsSheet(p) && !str(p.id)) {
834
+ const base = slugify(str(p.title) ?? str(p.summary) ?? "");
835
+ if (base) {
836
+ const planned = plannedHeadingIds.get(ctx);
837
+ let id = base;
838
+ for (let k = 2; planned?.has(id) || ctx.ids.has(id); k++)
839
+ id = `${base}-${k}`;
840
+ p = { ...p, id: uniqueId(ctx, id) };
841
+ }
842
+ }
785
843
  const title = str(p.title) ?? str(p.summary) ?? ctx.t("details");
786
844
  // `variant` as documented, then `type`, as the Astro pipeline reads it.
787
845
  const type = (str(p.variant) ?? str(p.type) ?? "").toLowerCase();
788
846
  const variant = ALERT_VARIANT[VARIANT_ALIAS[type] ?? type] ? VARIANT_ALIAS[type] ?? type : "note";
789
- return collapsibleAlert(variant, renderInline(parseInline(title), ctx), renderBlocks(n.children, ctx), p.open != null && p.open !== false, p);
847
+ const html = collapsibleAlert(variant, renderInline(parseInline(title), ctx), renderBlocks(n.children, ctx), p.open != null && p.open !== false, p);
848
+ // `sheet`: the same details, which <db-details-sheet> opens as a side
849
+ // sheet when its script runs (and which stays a details when it does not)
850
+ if (!isDetailsSheet(p))
851
+ return html;
852
+ return `<db-details-sheet${attrs({ "close-label": ctx.t("close") || "Close" })}>${html.trimEnd()}</db-details-sheet>\n`;
790
853
  }
791
854
  function figure(n, ctx) {
792
855
  const p = (n.props ?? {});
@@ -851,7 +914,9 @@ function card(n, ctx) {
851
914
  (desc ? `<p class="m-0 mt-1 text-sm text-muted-foreground [text-decoration:none]">${renderInline(parseInline(desc), ctx)}</p>` : "") +
852
915
  // Body blocks in the same description wrapper as the Astro pipeline's card (a <div>: they carry their own <p>).
853
916
  (n.children?.length ? `<div class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${renderBlocks(n.children, ctx)}</div>` : ""));
854
- const cls = `rounded-lg border bg-card p-4 text-card-foreground shadow-sm${href ? " transition-colors hover:border-foreground/20" : ""}`;
917
+ // A tone (TONES) replaces the card's background and border colour
918
+ const toned = lookup(CARD_TONE_CLASSES, str(p.tone));
919
+ const cls = `rounded-lg border ${toned ?? "bg-card"} p-4 text-card-foreground shadow-sm${href ? ` transition-colors ${toned ? "hover:shadow-md" : "hover:border-foreground/20"}` : ""}`;
855
920
  return href
856
921
  ? `<div${attrs(passthrough(p, cls))}><div class="p-0"><a href="${esc(linkUrl(href, ctx))}" class="block text-foreground [text-decoration:none]">${inner}</a></div></div>`
857
922
  : `<div${attrs(passthrough(p, cls))}><div class="p-0">${inner}</div></div>`;
@@ -1 +1,9 @@
1
1
  export declare const TONE_CLASSES: Record<string, string>;
2
+ export declare const TONES: readonly ["info", "success", "warning", "danger"];
3
+ export declare const CARD_TONE_CLASSES: Record<string, string>;
4
+ export declare const TAB_VARIANTS: Record<string, {
5
+ list: string;
6
+ trigger: string;
7
+ }>;
8
+ export declare const THEME_SCOPES: Record<string, string>;
9
+ export declare function lookup<T>(table: Record<string, T>, key: unknown): T | undefined;
@@ -1,4 +1,4 @@
1
- // Generated by packages/render-html/scripts/sync-sources.mjs from @dogsbay/format-astro TONE_CLASSES. Do not edit.
1
+ // Generated by packages/render-html/scripts/sync-sources.mjs from @dogsbay/format-astro tone-classes.ts. Do not edit.
2
2
  export const TONE_CLASSES = {
3
3
  "primary": "bg-primary text-primary-foreground",
4
4
  "primary-strong": "bg-primary/80 text-primary-foreground",
@@ -13,3 +13,26 @@ export const TONE_CLASSES = {
13
13
  "purple": "bg-purple-500/15 text-purple-700 dark:text-purple-300",
14
14
  "red": "bg-red-500/15 text-red-700 dark:text-red-300"
15
15
  };
16
+ export const TONES = ["info", "success", "warning", "danger"];
17
+ export const CARD_TONE_CLASSES = {
18
+ "info": "border-info/50 bg-info/5",
19
+ "success": "border-success/50 bg-success/5",
20
+ "warning": "border-warning/50 bg-warning/5",
21
+ "danger": "border-danger/50 bg-danger/5"
22
+ };
23
+ export const TAB_VARIANTS = {
24
+ "pills": {
25
+ "list": "flex flex-wrap items-center gap-2 border-b-0 border-transparent bg-transparent p-0 px-3 pt-3 pb-1 text-muted-foreground",
26
+ "trigger": "inline-flex items-center justify-center whitespace-nowrap rounded-full border border-border px-4 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-selected:border-primary aria-selected:bg-primary aria-selected:text-primary-foreground aria-selected:shadow-none"
27
+ },
28
+ "underline": {
29
+ "list": "flex items-center gap-6 border-b border-border bg-transparent p-0 px-4 text-muted-foreground",
30
+ "trigger": "-mb-px inline-flex items-center justify-center whitespace-nowrap rounded-none border-b-2 border-transparent p-0 px-0.5 pt-3 pb-2 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:text-foreground aria-selected:border-primary aria-selected:bg-transparent aria-selected:text-foreground aria-selected:shadow-none"
31
+ }
32
+ };
33
+ export const THEME_SCOPES = {
34
+ "dark": "dark my-6 rounded-xl border border-border bg-background p-6 text-foreground [&>:first-child]:mt-0! [&>:last-child]:mb-0!"
35
+ };
36
+ export function lookup(table, key) {
37
+ return typeof key === "string" && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/render-html",
3
- "version": "0.2.0-beta.121",
3
+ "version": "0.2.0-beta.123",
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",
@@ -30,9 +30,9 @@
30
30
  "dependencies": {
31
31
  "htmlparser2": "^10.1.0",
32
32
  "markdown-it": "^14.3.0",
33
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.121",
34
- "@dogsbay/serialize-core": "0.2.0-beta.121",
35
- "@dogsbay/types": "0.2.0-beta.121"
33
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.123",
34
+ "@dogsbay/serialize-core": "0.2.0-beta.123",
35
+ "@dogsbay/types": "0.2.0-beta.123"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "katex": "^0.16.0"