@dogsbay/render-html 0.2.0-beta.124 → 0.2.0-beta.126
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 +35 -0
- package/css/docs.css +5 -4
- package/dist/diff.d.ts +77 -0
- package/dist/diff.js +508 -0
- package/dist/render.d.ts +7 -0
- package/dist/render.js +65 -14
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -27,6 +27,41 @@ const html = renderBlocks(dogsbayMdToTree(markdown).tree, ctx);
|
|
|
27
27
|
The output uses Tailwind utility classes and the theme tokens. Styling it needs the Dogsbay
|
|
28
28
|
theme CSS, compiled for the classes the page uses.
|
|
29
29
|
|
|
30
|
+
## Source lines (scroll sync)
|
|
31
|
+
|
|
32
|
+
Parse with `sourceLines: true` (`dogsbayMdToTree` in format-dogsbay-md, or
|
|
33
|
+
`ditaTopicToTree` in format-dita) and each block carries its 1-based source line
|
|
34
|
+
on `meta.line`. Render with `createRenderContext({ dataLines: true })` and
|
|
35
|
+
it is written as `data-line="N"` on each block's first element and on list
|
|
36
|
+
items, so an editor can map a click in the preview to the source and scroll the
|
|
37
|
+
preview to the cursor. Off by default; only a positive integer is ever written.
|
|
38
|
+
|
|
39
|
+
## Changes (diff marks)
|
|
40
|
+
|
|
41
|
+
A compared tree carries its marks on the nodes: `diff` (`"added"`, `"removed"`, `"changed"`,
|
|
42
|
+
`"moved"`), `diffInline` (word segments, `{ op: "eq" | "ins" | "del", text }`), `diffPeer` (the
|
|
43
|
+
node's previous version) and `diffMovedFrom` (the old heading path). These fields are the
|
|
44
|
+
contract; Dogsbay's comparison service produces them. `renderBlocks` reads them as plain
|
|
45
|
+
fields and draws them as format-astro's comparison pages do. A tree without marks renders
|
|
46
|
+
exactly as before.
|
|
47
|
+
|
|
48
|
+
- Added, removed, changed and moved blocks get `data-diff` on their first element; a moved
|
|
49
|
+
one also `data-diff-from` ("Guide › Setup").
|
|
50
|
+
- Changed prose shows its words as `<ins class="db-diff-ins">` and `<del class="db-diff-del">`.
|
|
51
|
+
- A changed code block becomes one block with removed and added lines marked by number; its
|
|
52
|
+
copy button copies the new code. The clean new block sits beside it for "hide changes".
|
|
53
|
+
- Changed raw HTML keeps the previous version in a "Show previous version" disclosure.
|
|
54
|
+
- A tab whose panel changed gets a glyph and the words "changed in this comparison" on its
|
|
55
|
+
trigger.
|
|
56
|
+
- Folded unchanged sections (tree-diff's `foldUnchangedSections`) are `details` with the class
|
|
57
|
+
`db-diff-fold`.
|
|
58
|
+
- With `dataLines`, `data-line` stays on the marked element, so a change maps back to its
|
|
59
|
+
source line.
|
|
60
|
+
|
|
61
|
+
Add the stylesheet, `DIFF_CSS` from `@dogsbay/render-html/diff`, to the page. Its marks are
|
|
62
|
+
not colour alone: borders, "added"/"removed" labels, strike-through and the tab glyphs carry
|
|
63
|
+
them too. Set `data-diff-hidden` on `<html>` to read the new version without the marks.
|
|
64
|
+
|
|
30
65
|
## API reference pages
|
|
31
66
|
|
|
32
67
|
Autodoc symbols (`api-class`, `api-member`, `api-doc`, `api-params`,
|
package/css/docs.css
CHANGED
|
@@ -35,10 +35,11 @@
|
|
|
35
35
|
.docs-prose {
|
|
36
36
|
line-height: 1.7;
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
&
|
|
40
|
-
&
|
|
41
|
-
&
|
|
38
|
+
/* A heading that is part of a component (a card title: h3.not-prose) keeps the component look */
|
|
39
|
+
& h1:not(.dba-api *):not(.not-prose) { font-family: var(--font-heading); font-size: 2rem; font-weight: 700; margin-top: 0; margin-bottom: 1rem; line-height: 1.2; letter-spacing: -0.025em; }
|
|
40
|
+
& h2:not(.dba-api *):not(.not-prose) { font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 0.75rem; line-height: 1.3; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; letter-spacing: -0.015em; }
|
|
41
|
+
& h3:not(.dba-api *):not(.not-prose) { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
|
|
42
|
+
& h4:not(.dba-api *):not(.not-prose) { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
|
|
42
43
|
|
|
43
44
|
& p:not(.dba-api *) { margin-top: 0.75rem; margin-bottom: 0.75rem; }
|
|
44
45
|
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DiffTree marks, read STRUCTURALLY (no dependency on @dogsbay/tree-diff):
|
|
3
|
+
* `diff`, `diffInline`, `diffPeer`, `diffMovedFrom` on a TreeNode
|
|
4
|
+
* (plans/tree-diff-rendering.md §2). The pieces both renderers share live
|
|
5
|
+
* here — format-astro decorates its Astro output with them, render-html its
|
|
6
|
+
* HTML — so a comparison page and a preview mark a change the same way.
|
|
7
|
+
*
|
|
8
|
+
* A tree with no marks renders exactly as it did: nothing here runs until a
|
|
9
|
+
* node carries one.
|
|
10
|
+
*/
|
|
11
|
+
import type { TreeNode } from "@dogsbay/types";
|
|
12
|
+
/** Structural mirror of @dogsbay/tree-diff's DiffNode fields. */
|
|
13
|
+
export interface DiffMarks {
|
|
14
|
+
diff?: "added" | "removed" | "changed" | "moved";
|
|
15
|
+
diffInline?: {
|
|
16
|
+
op: "eq" | "ins" | "del";
|
|
17
|
+
text: string;
|
|
18
|
+
}[];
|
|
19
|
+
diffPeer?: TreeNode;
|
|
20
|
+
diffMovedFrom?: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare const diffMarksOf: (node: TreeNode) => DiffMarks;
|
|
23
|
+
export declare function hasDiffMarks(node: TreeNode): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Render the merged word-level segments of a changed prose node.
|
|
26
|
+
* Formatting inside changed prose flattens to text in this view —
|
|
27
|
+
* the marks are the content (the unchanged siblings keep their full
|
|
28
|
+
* inline rendering).
|
|
29
|
+
*/
|
|
30
|
+
export declare function inlineSegmentsToHtml(segments: NonNullable<DiffMarks["diffInline"]>): string;
|
|
31
|
+
export interface MergedCodeDiff {
|
|
32
|
+
/** Unified code: unchanged lines, removed lines, added lines. */
|
|
33
|
+
code: string;
|
|
34
|
+
/** 1-based line numbers, for CodeRich's diffAdd/diffRemove props. */
|
|
35
|
+
addLines: number[];
|
|
36
|
+
removeLines: number[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Merge two code texts into ONE unified block, reporting the changed
|
|
40
|
+
* lines BY NUMBER rather than by `[!code]` comment markers.
|
|
41
|
+
*
|
|
42
|
+
* Markers were the first design and they leak: Shiki can only strip a
|
|
43
|
+
* marker it recognizes as a comment in the grammar, so in `plaintext`
|
|
44
|
+
* — which every unknown language (`package-install`, doc-tool tokens)
|
|
45
|
+
* falls back to — the marker renders as literal text in the reader's
|
|
46
|
+
* code. Line numbers are language-independent and can't leak.
|
|
47
|
+
*
|
|
48
|
+
* Returns null over the line cap; callers fall back to before/after.
|
|
49
|
+
*/
|
|
50
|
+
export declare function buildMergedCodeDiff(oldCode: string, newCode: string): MergedCodeDiff | null;
|
|
51
|
+
/** [2,3,4,7] → "2-4,7" */
|
|
52
|
+
export declare const toLineSpec: (lines: number[]) => string;
|
|
53
|
+
/** Whether a code change is in whitespace only (named, not hidden: indentation is semantic in YAML and Python). */
|
|
54
|
+
export declare const whitespaceOnly: (oldCode: string, newCode: string) => boolean;
|
|
55
|
+
/** A changed code node as ONE block: removed and added lines by number, the copy button copying the new code. */
|
|
56
|
+
export declare function mergedCodeNode(node: TreeNode): {
|
|
57
|
+
node: TreeNode;
|
|
58
|
+
whitespaceOnly: boolean;
|
|
59
|
+
} | null;
|
|
60
|
+
/** The glyph a tab trigger carries for a change in its panel: shape, not colour alone (WCAG 1.4.1). */
|
|
61
|
+
export declare const TAB_GLYPH: Record<string, string>;
|
|
62
|
+
/** A tab's own mark, or "changed" when anything inside it is marked. */
|
|
63
|
+
export declare function tabDiffSignal(tab: TreeNode): string | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* The diff stylesheet the generated site carries — theme-token based,
|
|
66
|
+
* both color modes, no hardcoded palette (key-decisions doctrine).
|
|
67
|
+
*/
|
|
68
|
+
/** Attributes into the first opening tag of rendered HTML (a wrapper would be invalid inside a table or a list). */
|
|
69
|
+
export declare function injectAttrs(rendered: string, attrs: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* A block's marks on its rendered HTML, as format-astro's decorateDiff does for Astro: `data-diff` on its first
|
|
72
|
+
* element; changed code as one merged block with the clean new render beside it; changed raw HTML with the
|
|
73
|
+
* previous version in a disclosure, so a deletion inside opaque markup is not invisible. Word-level prose marks
|
|
74
|
+
* (`diffInline`) are rendered by the caller, inside the block. `renderPeer` renders a node through the same renderer.
|
|
75
|
+
*/
|
|
76
|
+
export declare function decorateDiffHtml(node: TreeNode, rendered: string, renderPeer: (peer: TreeNode) => string): string;
|
|
77
|
+
export declare const DIFF_CSS = "/* tree-diff decorations \u2014 one DiffTree, rendered by the normal components.\n Colours read the Tailwind theme token (--color-success) first, then the raw one (--success): a page built\n with \"@theme inline\" (render-html's) has only the raw ones, and the pills fell back to Canvas, white in a\n page that declares no color-scheme, under light text in dark mode.\n Mark styling is GUARDED by html:not([data-diff-hidden]) rather than\n force-reset under hidden mode: a reset like \"border-left: none\n !important\" also clobbers a component's OWN border (an added warning\n callout lost its left edge \u2014 user find, adapters/mysql). Guarded\n rules simply stop applying, and component styling survives. */\n[data-diff] { position: relative; }\nhtml:not([data-diff-hidden]) [data-diff=\"added\"] {\n background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 12%, transparent);\n border-left: 3px solid var(--color-success, var(--success, oklch(0.72 0.19 150)));\n padding-left: 0.75rem;\n}\nhtml:not([data-diff-hidden]) [data-diff=\"removed\"] {\n background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 10%, transparent);\n border-left: 3px solid var(--color-destructive, var(--destructive, oklch(0.64 0.21 25)));\n padding-left: 0.75rem;\n opacity: 0.75;\n}\nhtml:not([data-diff-hidden]) [data-diff=\"removed\"] :is(p, li, code, pre) { text-decoration: line-through; text-decoration-thickness: 1px; }\nhtml:not([data-diff-hidden]) [data-diff=\"changed\"] {\n border-left: 3px solid var(--color-warning, var(--warning, oklch(0.8 0.16 85)));\n padding-left: 0.75rem;\n}\nhtml:not([data-diff-hidden]) [data-diff=\"moved\"] {\n border-left: 3px dashed var(--color-info, var(--info, oklch(0.7 0.14 240)));\n padding-left: 0.75rem;\n}\nhtml:not([data-diff-hidden]) [data-diff=\"moved\"][data-diff-from]::after {\n content: \"moved from \" attr(data-diff-from);\n display: block;\n font-size: 0.75rem;\n color: var(--color-muted-foreground, var(--muted-foreground, inherit));\n opacity: 0.8;\n}\n\n/* Explicit \"added\"/\"removed\" label pills straddling the block's top\n edge. Components with their own chrome (callouts, code blocks)\n camouflage a tint-only mark \u2014 a green-tinted warning callout reads\n as a callout variant, not as new content (user feedback,\n better-auth adapters/mysql). Table rows are excluded (a pill can't\n straddle a tr); nested marks defer to the outermost label. */\nhtml:not([data-diff-hidden]) [data-diff=\"added\"]:not(tr, th, td)::before,\nhtml:not([data-diff-hidden]) [data-diff=\"removed\"]:not(tr, th, td)::before {\n position: absolute;\n top: 0;\n right: 0.6rem;\n transform: translateY(-55%);\n font-size: 0.65rem;\n font-weight: 600;\n letter-spacing: 0.04em;\n line-height: 1.4;\n padding: 0 0.5rem;\n border-radius: 99px;\n z-index: 1;\n text-decoration: none;\n color: var(--color-foreground, var(--foreground, CanvasText));\n}\nhtml:not([data-diff-hidden]) [data-diff=\"added\"]:not(tr, th, td)::before {\n content: \"added\";\n background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 25%, var(--color-background, var(--background, Canvas)));\n border: 1px solid color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 45%, transparent);\n}\nhtml:not([data-diff-hidden]) [data-diff=\"removed\"]:not(tr, th, td)::before {\n content: \"removed\";\n background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 20%, var(--color-background, var(--background, Canvas)));\n border: 1px solid color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 45%, transparent);\n}\n[data-diff] [data-diff]::before { content: none !important; }\n\n/* Nav change markers (the sidebar is client-rendered from nav.json, so\n the styles ship here rather than with the component). Same contract as\n the tab markers: a GLYPH carries the meaning, colour reinforces it,\n visually-hidden text announces it. */\n.db-nav-mark {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n margin-left: auto;\n border-radius: 99px;\n font-size: 0.75rem;\n font-weight: 700;\n line-height: 1;\n color: var(--color-muted-foreground, var(--muted-foreground, currentColor));\n}\n.db-nav-mark[data-nav-mark=\"added\"] { color: var(--color-success, var(--success, oklch(0.72 0.19 150))); }\n.db-nav-mark[data-nav-mark=\"changed\"] { color: var(--color-warning, var(--warning, oklch(0.8 0.16 85))); }\n.db-nav-mark[data-nav-mark=\"removed\"] { color: var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))); }\n.db-nav-mark[data-nav-mark=\"moved\"] { color: var(--color-info, var(--info, oklch(0.7 0.14 240))); }\n/* a subtree marker is a hint about descendants, not a claim about this row */\n.db-nav-mark[data-nav-mark-subtree] { opacity: 0.55; }\n/* removed pages get a SECOND non-colour signal */\n/* Belt-and-braces: a subtree mark must never strike through its own row. */\n[data-nav-mark-row=\"removed\"]:not([data-nav-mark-subtree]) { text-decoration: line-through; opacity: 0.7; }\n@media (forced-colors: active) {\n .db-nav-mark { color: CanvasText; }\n}\n\n/* Which tab changed? A marked panel is invisible until opened, so its\n trigger carries a marker (the group's bar says \"in here\", the marker\n says \"this one\"). The marker is a GLYPH, not a colored dot: colour\n alone fails WCAG 1.4.1 and background-tinted dots vanish entirely in\n forced-colors mode. Colour reinforces; shape carries the meaning.\n Visually-hidden text rides along (emitted by the serializer). */\n.db-tab-mark {\n display: inline-block;\n margin-left: 0.35rem;\n font-size: 0.75rem;\n font-weight: 700;\n line-height: 1;\n vertical-align: middle;\n color: var(--color-muted-foreground, var(--muted-foreground, currentColor));\n}\n[data-diff-hidden] .db-tab-mark { display: none; }\n.db-tab-mark[data-diff-tab=\"changed\"] { color: var(--color-warning, var(--warning, oklch(0.8 0.16 85))); }\n.db-tab-mark[data-diff-tab=\"added\"] { color: var(--color-success, var(--success, oklch(0.72 0.19 150))); }\n.db-tab-mark[data-diff-tab=\"removed\"] { color: var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))); }\n.db-tab-mark[data-diff-tab=\"moved\"] { color: var(--color-info, var(--info, oklch(0.7 0.14 240))); }\n@media (forced-colors: active) {\n .db-tab-mark { color: CanvasText; }\n}\nins.db-diff-ins {\n text-decoration: none;\n background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 22%, transparent);\n border-radius: 2px;\n padding: 0 1px;\n}\ndel.db-diff-del {\n background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 18%, transparent);\n border-radius: 2px;\n padding: 0 1px;\n}\n.db-diff-pair { display: grid; gap: 0.25rem; margin-block: 1rem; }\n.db-diff-pair-label {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--color-muted-foreground, var(--muted-foreground, inherit));\n opacity: 0.8;\n}\ntr[data-diff] { border-left-width: 3px; }\n\n/* Folded unchanged sections are CHROME, not content callouts:\n dashed, dimmed, compact \u2014 visually \"collapsed context\". */\n.db-diff-fold {\n border: 1px dashed color-mix(in oklch, currentColor 25%, transparent) !important;\n border-left-width: 1px !important;\n background: transparent !important;\n opacity: 0.7;\n margin-block: 0.5rem !important;\n}\n.db-diff-fold:hover, .db-diff-fold[open] { opacity: 1; }\n.db-diff-fold summary, .db-diff-fold [data-slot=\"trigger\"] {\n font-size: 0.85rem;\n}\n\n/* The comparison banner at the top of each reader page. */\n.db-diff-banner-row { display: flex; flex-wrap: wrap; gap: 0.4rem 0.8rem; align-items: center; width: 100%; }\n.db-diff-banner {\n border: 1px solid color-mix(in oklch, currentColor 15%, transparent);\n border-radius: 8px;\n padding: 0.75rem 1rem;\n margin-block-end: 1.5rem;\n font-size: 0.85rem;\n display: flex;\n flex-wrap: wrap;\n gap: 0.4rem 1rem;\n align-items: center;\n}\n.db-diff-banner .db-chip {\n display: inline-block;\n border-radius: 99px;\n padding: 0.05rem 0.6rem;\n font-size: 0.75rem;\n border: 1px solid transparent;\n}\n.db-chip-added { background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 15%, transparent); }\n.db-chip-removed { background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 12%, transparent); }\n.db-chip-changed { background: color-mix(in oklch, var(--color-warning, var(--warning, oklch(0.8 0.16 85))) 15%, transparent); }\n.db-chip-moved { background: color-mix(in oklch, var(--color-info, var(--info, oklch(0.7 0.14 240))) 12%, transparent); }\n.db-chip-muted { border-color: color-mix(in oklch, currentColor 25%, transparent); opacity: 0.8; }\n\n/* The persistent view-switcher bar (Reader / Docs-ops / Agent):\n perspectives on ONE comparison \u2014 tabs are links, verification\n lives in the chrome, the Monitor CTA sits next to the value. */\n.db-cmp-bar {\n position: sticky;\n /* BELOW the docs header, not under it: both were sticky at top:0 and\n the header's higher z-index simply swallowed this bar on scroll \u2014\n the chrome carrying the trust contract was the chrome that vanished.\n --db-header-height is declared by DocsLayout (the header sizes itself\n from the same variable, so they cannot drift). */\n top: var(--db-header-height, 3.25rem);\n z-index: 30;\n display: flex;\n flex-wrap: wrap;\n gap: 0.4rem 0.9rem;\n align-items: center;\n padding: 0.5rem 0.9rem;\n margin-block-end: 1rem;\n border: 1px solid color-mix(in oklch, currentColor 15%, transparent);\n border-radius: 8px;\n background: var(--color-background, var(--background, Canvas));\n font-size: 0.8rem;\n}\n.db-cmp-bar .db-chip {\n display: inline-block;\n border-radius: 99px;\n padding: 0.05rem 0.6rem;\n font-size: 0.75rem;\n border: 1px solid transparent;\n}\n.db-cmp-bar button.db-chip { cursor: pointer; background: transparent; font: inherit; font-size: 0.75rem; color: inherit; }\n.db-cmp-tabs {\n display: inline-flex;\n gap: 0.15rem;\n border: 1px solid color-mix(in oklch, currentColor 20%, transparent);\n border-radius: 99px;\n padding: 0.1rem;\n}\n.db-cmp-tab {\n padding: 0.1rem 0.7rem;\n border-radius: 99px;\n text-decoration: none;\n color: inherit;\n}\n.db-cmp-tab[aria-current=\"page\"] {\n background: color-mix(in oklch, currentColor 12%, transparent);\n font-weight: 600;\n}\n.db-cmp-pairs { position: relative; display: inline-block; }\n.db-cmp-pairs summary { cursor: pointer; }\n.db-cmp-pairs ul {\n position: absolute;\n top: 100%;\n left: 0;\n margin: 0.25rem 0 0;\n padding: 0.35rem 0.75rem;\n list-style: none;\n background: var(--color-background, var(--background, Canvas));\n border: 1px solid color-mix(in oklch, currentColor 20%, transparent);\n border-radius: 8px;\n white-space: nowrap;\n z-index: 40;\n}\n.db-cmp-monitor { text-decoration: none; border-color: color-mix(in oklch, currentColor 30%, transparent) !important; }\n.db-cmp-monitor:hover { background: color-mix(in oklch, currentColor 10%, transparent); }\n\n/* A wholly added/removed PAGE carries one mark around all its content.\n The per-block tint would be overwhelming at page scale, so page level\n gets a stronger edge and a much lighter wash \u2014 legible at a glance,\n still readable as prose. The \"added\"/\"removed\" pill (the ::before on\n [data-diff]) rides on this wrapper, which is exactly right: it labels\n the page, once. */\nhtml:not([data-diff-hidden]) [data-diff].db-diff-page {\n border-left-width: 5px;\n padding-left: 1rem;\n padding-block: 0.5rem;\n border-radius: 0 6px 6px 0;\n}\nhtml:not([data-diff-hidden]) [data-diff=\"added\"].db-diff-page {\n background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 5%, transparent);\n}\nhtml:not([data-diff-hidden]) [data-diff=\"removed\"].db-diff-page {\n background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 5%, transparent);\n opacity: 1; /* the page is READABLE \u2014 the mark says removed, not the dimming */\n}\n/* \u2026and a removed PAGE is not struck through \u2014 that is for a deleted line,\n not for a document the reader still needs to read. */\n[data-diff=\"removed\"].db-diff-page :is(p, li, code, pre) { text-decoration: none; }\n\n/* Changed raw HTML: the faithful new render, plus the previous version\n one click away. Without it a deletion inside opaque markup (a dropped\n table column) is simply invisible \u2014 the reader is told \"something\n changed\" and shown no evidence. */\n.db-diff-html-before {\n margin-block-start: 0.5rem;\n font-size: 0.8rem;\n border: 1px dashed color-mix(in oklch, currentColor 25%, transparent);\n border-radius: 6px;\n padding: 0.25rem 0.6rem;\n}\n/* Full-strength text: the summary is a CONTROL \u2014 dimming it (opacity or\n muted-foreground) drops it below 4.5:1 and axe rightly fails it. */\n.db-diff-html-before summary { cursor: pointer; color: inherit; }\n.db-diff-html-before[open] summary { margin-block-end: 0.5rem; }\n[data-diff-hidden] .db-diff-html-before { display: none; }\n\n/* Merged code diffs carry a hidden clean twin (the post-change\n render); Hide highlights swaps them. */\n.db-diff-code { position: relative; }\n.db-diff-code-clean { display: none; }\n[data-diff-hidden] .db-diff-code-clean { display: block; }\n[data-diff-hidden] .db-diff-code-merged { display: none; }\n\n/* Whitespace-only code edits are still marked (indentation is\n semantic in YAML/Python/markdown) \u2014 but NAMED, so the reader\n is not hunting an invisible 3-space shift. */\nhtml:not([data-diff-hidden]) .db-diff-code-ws .db-diff-code-merged::before {\n content: \"whitespace only\";\n position: absolute;\n top: 0;\n right: 0.6rem;\n transform: translateY(-55%);\n z-index: 1;\n font-size: 0.65rem;\n font-weight: 600;\n letter-spacing: 0.04em;\n line-height: 1.4;\n padding: 0 0.5rem;\n border-radius: 99px;\n color: var(--color-foreground, var(--foreground, CanvasText));\n background: color-mix(in oklch, var(--color-warning, var(--warning, oklch(0.8 0.16 85))) 22%, var(--color-background, var(--background, Canvas)));\n border: 1px solid color-mix(in oklch, var(--color-warning, var(--warning, oklch(0.8 0.16 85))) 45%, transparent);\n}\n\n/* Hide highlights \u2014 read the target version clean. One attribute on\n <html>: the guarded mark rules above stop applying by themselves;\n only content that does not exist in the target version (removed\n blocks, del runs, the \"before\" code side) needs hiding, plus the\n ins tint. */\n[data-diff-hidden] :is([data-diff=\"removed\"], del.db-diff-del, .db-diff-pair-label) { display: none !important; }\n[data-diff-hidden] ins.db-diff-ins { background: transparent; padding: 0; }\n";
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
export const diffMarksOf = (node) => node;
|
|
2
|
+
export function hasDiffMarks(node) {
|
|
3
|
+
const m = diffMarksOf(node);
|
|
4
|
+
return Boolean(m.diff || m.diffInline);
|
|
5
|
+
}
|
|
6
|
+
const escapeHtmlText = (s) => s
|
|
7
|
+
.replace(/&/g, "&")
|
|
8
|
+
.replace(/</g, "<")
|
|
9
|
+
.replace(/>/g, ">")
|
|
10
|
+
.replace(/\{/g, "{")
|
|
11
|
+
.replace(/\}/g, "}");
|
|
12
|
+
/**
|
|
13
|
+
* Render the merged word-level segments of a changed prose node.
|
|
14
|
+
* Formatting inside changed prose flattens to text in this view —
|
|
15
|
+
* the marks are the content (the unchanged siblings keep their full
|
|
16
|
+
* inline rendering).
|
|
17
|
+
*/
|
|
18
|
+
export function inlineSegmentsToHtml(segments) {
|
|
19
|
+
return segments
|
|
20
|
+
.map((s) => {
|
|
21
|
+
const text = escapeHtmlText(s.text);
|
|
22
|
+
if (s.op === "ins")
|
|
23
|
+
return `<ins class="db-diff-ins">${text}</ins>`;
|
|
24
|
+
if (s.op === "del")
|
|
25
|
+
return `<del class="db-diff-del">${text}</del>`;
|
|
26
|
+
return text;
|
|
27
|
+
})
|
|
28
|
+
.join("");
|
|
29
|
+
}
|
|
30
|
+
/** Quadratic-LCS guard, same rationale as tree-diff's word differ. */
|
|
31
|
+
const CODE_DIFF_MAX_LINES = 1500;
|
|
32
|
+
/**
|
|
33
|
+
* Merge two code texts into ONE unified block, reporting the changed
|
|
34
|
+
* lines BY NUMBER rather than by `[!code]` comment markers.
|
|
35
|
+
*
|
|
36
|
+
* Markers were the first design and they leak: Shiki can only strip a
|
|
37
|
+
* marker it recognizes as a comment in the grammar, so in `plaintext`
|
|
38
|
+
* — which every unknown language (`package-install`, doc-tool tokens)
|
|
39
|
+
* falls back to — the marker renders as literal text in the reader's
|
|
40
|
+
* code. Line numbers are language-independent and can't leak.
|
|
41
|
+
*
|
|
42
|
+
* Returns null over the line cap; callers fall back to before/after.
|
|
43
|
+
*/
|
|
44
|
+
export function buildMergedCodeDiff(oldCode, newCode) {
|
|
45
|
+
const a = oldCode.split("\n");
|
|
46
|
+
const b = newCode.split("\n");
|
|
47
|
+
if (a.length > CODE_DIFF_MAX_LINES || b.length > CODE_DIFF_MAX_LINES)
|
|
48
|
+
return null;
|
|
49
|
+
const m = a.length;
|
|
50
|
+
const n = b.length;
|
|
51
|
+
const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
52
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
53
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
54
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const out = [];
|
|
58
|
+
const addLines = [];
|
|
59
|
+
const removeLines = [];
|
|
60
|
+
let i = 0;
|
|
61
|
+
let j = 0;
|
|
62
|
+
const emit = (text, kind) => {
|
|
63
|
+
out.push(text);
|
|
64
|
+
if (kind === "add")
|
|
65
|
+
addLines.push(out.length);
|
|
66
|
+
else if (kind === "remove")
|
|
67
|
+
removeLines.push(out.length);
|
|
68
|
+
};
|
|
69
|
+
while (i < m && j < n) {
|
|
70
|
+
if (a[i] === b[j]) {
|
|
71
|
+
emit(b[j]);
|
|
72
|
+
i++;
|
|
73
|
+
j++;
|
|
74
|
+
}
|
|
75
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
76
|
+
emit(a[i], "remove");
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
emit(b[j], "add");
|
|
81
|
+
j++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
while (i < m)
|
|
85
|
+
emit(a[i++], "remove");
|
|
86
|
+
while (j < n)
|
|
87
|
+
emit(b[j++], "add");
|
|
88
|
+
return { code: out.join("\n"), addLines, removeLines };
|
|
89
|
+
}
|
|
90
|
+
/** [2,3,4,7] → "2-4,7" */
|
|
91
|
+
export const toLineSpec = (lines) => {
|
|
92
|
+
const parts = [];
|
|
93
|
+
for (let i = 0; i < lines.length;) {
|
|
94
|
+
let j = i;
|
|
95
|
+
while (j + 1 < lines.length && lines[j + 1] === lines[j] + 1)
|
|
96
|
+
j++;
|
|
97
|
+
parts.push(i === j ? String(lines[i]) : `${lines[i]}-${lines[j]}`);
|
|
98
|
+
i = j + 1;
|
|
99
|
+
}
|
|
100
|
+
return parts.join(",");
|
|
101
|
+
};
|
|
102
|
+
const codeTextOf = (node) => node.props?.code ?? node.html ?? "";
|
|
103
|
+
/** Whether a code change is in whitespace only (named, not hidden: indentation is semantic in YAML and Python). */
|
|
104
|
+
export const whitespaceOnly = (oldCode, newCode) => oldCode !== newCode && oldCode.replace(/\s+/g, "") === newCode.replace(/\s+/g, "");
|
|
105
|
+
/** A changed code node as ONE block: removed and added lines by number, the copy button copying the new code. */
|
|
106
|
+
export function mergedCodeNode(node) {
|
|
107
|
+
const peer = diffMarksOf(node).diffPeer;
|
|
108
|
+
if (!peer)
|
|
109
|
+
return null;
|
|
110
|
+
const oldCode = codeTextOf(peer);
|
|
111
|
+
const newCode = codeTextOf(node);
|
|
112
|
+
const merged = buildMergedCodeDiff(oldCode, newCode);
|
|
113
|
+
if (merged === null)
|
|
114
|
+
return null;
|
|
115
|
+
return {
|
|
116
|
+
node: {
|
|
117
|
+
...node,
|
|
118
|
+
html: undefined,
|
|
119
|
+
props: { ...node.props, code: merged.code, copyText: newCode, diffAdd: toLineSpec(merged.addLines), diffRemove: toLineSpec(merged.removeLines) },
|
|
120
|
+
},
|
|
121
|
+
whitespaceOnly: whitespaceOnly(oldCode, newCode),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** The glyph a tab trigger carries for a change in its panel: shape, not colour alone (WCAG 1.4.1). */
|
|
125
|
+
export const TAB_GLYPH = { added: "+", changed: "•", removed: "−", moved: "→" };
|
|
126
|
+
/** A tab's own mark, or "changed" when anything inside it is marked. */
|
|
127
|
+
export function tabDiffSignal(tab) {
|
|
128
|
+
const own = diffMarksOf(tab).diff;
|
|
129
|
+
if (own)
|
|
130
|
+
return own;
|
|
131
|
+
const deep = (n) => hasDiffMarks(n) || (n.children ?? []).some(deep);
|
|
132
|
+
return (tab.children ?? []).some(deep) ? "changed" : undefined;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The diff stylesheet the generated site carries — theme-token based,
|
|
136
|
+
* both color modes, no hardcoded palette (key-decisions doctrine).
|
|
137
|
+
*/
|
|
138
|
+
/** Attributes into the first opening tag of rendered HTML (a wrapper would be invalid inside a table or a list). */
|
|
139
|
+
export function injectAttrs(rendered, attrs) {
|
|
140
|
+
const m = rendered.match(/<([A-Za-z][\w.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/);
|
|
141
|
+
if (!m || m.index === undefined)
|
|
142
|
+
return rendered;
|
|
143
|
+
const insertAt = m.index + 1 + m[1].length;
|
|
144
|
+
return `${rendered.slice(0, insertAt)} ${attrs}${rendered.slice(insertAt)}`;
|
|
145
|
+
}
|
|
146
|
+
const attrEsc = (s) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
147
|
+
/**
|
|
148
|
+
* A block's marks on its rendered HTML, as format-astro's decorateDiff does for Astro: `data-diff` on its first
|
|
149
|
+
* element; changed code as one merged block with the clean new render beside it; changed raw HTML with the
|
|
150
|
+
* previous version in a disclosure, so a deletion inside opaque markup is not invisible. Word-level prose marks
|
|
151
|
+
* (`diffInline`) are rendered by the caller, inside the block. `renderPeer` renders a node through the same renderer.
|
|
152
|
+
*/
|
|
153
|
+
export function decorateDiffHtml(node, rendered, renderPeer) {
|
|
154
|
+
const marks = diffMarksOf(node);
|
|
155
|
+
if (!marks.diff && !marks.diffInline)
|
|
156
|
+
return rendered;
|
|
157
|
+
if (marks.diff === "changed" && marks.diffPeer && node.type === "code") {
|
|
158
|
+
const merged = mergedCodeNode(node);
|
|
159
|
+
if (merged) {
|
|
160
|
+
return (`<div data-diff="changed" class="db-diff-code${merged.whitespaceOnly ? " db-diff-code-ws" : ""}">` +
|
|
161
|
+
`<div class="db-diff-code-merged">${renderPeer(merged.node)}</div>` +
|
|
162
|
+
`<div class="db-diff-code-clean">${rendered}</div></div>\n`);
|
|
163
|
+
}
|
|
164
|
+
return (`<div class="db-diff-pair" data-diff="changed"><div class="db-diff-pair-label">before</div>` +
|
|
165
|
+
injectAttrs(renderPeer(marks.diffPeer), 'data-diff="removed"') +
|
|
166
|
+
`<div class="db-diff-pair-label">after</div>${injectAttrs(rendered, 'data-diff="added"')}</div>\n`);
|
|
167
|
+
}
|
|
168
|
+
const attrs = marks.diff === "moved" && marks.diffMovedFrom
|
|
169
|
+
? `data-diff="moved" data-diff-from="${attrEsc(marks.diffMovedFrom.join(" › "))}"`
|
|
170
|
+
: `data-diff="${attrEsc(marks.diff ?? "changed")}"`;
|
|
171
|
+
if (node.type === "html") {
|
|
172
|
+
if (marks.diff === "changed" && marks.diffPeer) {
|
|
173
|
+
return (`<div ${attrs} class="db-diff-html">${rendered}` +
|
|
174
|
+
`<details class="db-diff-html-before"><summary>Show previous version</summary><div data-diff="removed">${renderPeer(marks.diffPeer)}</div></details></div>\n`);
|
|
175
|
+
}
|
|
176
|
+
return `<div ${attrs}>${rendered}</div>\n`;
|
|
177
|
+
}
|
|
178
|
+
return injectAttrs(rendered, attrs);
|
|
179
|
+
}
|
|
180
|
+
export const DIFF_CSS = `/* tree-diff decorations — one DiffTree, rendered by the normal components.
|
|
181
|
+
Colours read the Tailwind theme token (--color-success) first, then the raw one (--success): a page built
|
|
182
|
+
with "@theme inline" (render-html's) has only the raw ones, and the pills fell back to Canvas, white in a
|
|
183
|
+
page that declares no color-scheme, under light text in dark mode.
|
|
184
|
+
Mark styling is GUARDED by html:not([data-diff-hidden]) rather than
|
|
185
|
+
force-reset under hidden mode: a reset like "border-left: none
|
|
186
|
+
!important" also clobbers a component's OWN border (an added warning
|
|
187
|
+
callout lost its left edge — user find, adapters/mysql). Guarded
|
|
188
|
+
rules simply stop applying, and component styling survives. */
|
|
189
|
+
[data-diff] { position: relative; }
|
|
190
|
+
html:not([data-diff-hidden]) [data-diff="added"] {
|
|
191
|
+
background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 12%, transparent);
|
|
192
|
+
border-left: 3px solid var(--color-success, var(--success, oklch(0.72 0.19 150)));
|
|
193
|
+
padding-left: 0.75rem;
|
|
194
|
+
}
|
|
195
|
+
html:not([data-diff-hidden]) [data-diff="removed"] {
|
|
196
|
+
background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 10%, transparent);
|
|
197
|
+
border-left: 3px solid var(--color-destructive, var(--destructive, oklch(0.64 0.21 25)));
|
|
198
|
+
padding-left: 0.75rem;
|
|
199
|
+
opacity: 0.75;
|
|
200
|
+
}
|
|
201
|
+
html:not([data-diff-hidden]) [data-diff="removed"] :is(p, li, code, pre) { text-decoration: line-through; text-decoration-thickness: 1px; }
|
|
202
|
+
html:not([data-diff-hidden]) [data-diff="changed"] {
|
|
203
|
+
border-left: 3px solid var(--color-warning, var(--warning, oklch(0.8 0.16 85)));
|
|
204
|
+
padding-left: 0.75rem;
|
|
205
|
+
}
|
|
206
|
+
html:not([data-diff-hidden]) [data-diff="moved"] {
|
|
207
|
+
border-left: 3px dashed var(--color-info, var(--info, oklch(0.7 0.14 240)));
|
|
208
|
+
padding-left: 0.75rem;
|
|
209
|
+
}
|
|
210
|
+
html:not([data-diff-hidden]) [data-diff="moved"][data-diff-from]::after {
|
|
211
|
+
content: "moved from " attr(data-diff-from);
|
|
212
|
+
display: block;
|
|
213
|
+
font-size: 0.75rem;
|
|
214
|
+
color: var(--color-muted-foreground, var(--muted-foreground, inherit));
|
|
215
|
+
opacity: 0.8;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* Explicit "added"/"removed" label pills straddling the block's top
|
|
219
|
+
edge. Components with their own chrome (callouts, code blocks)
|
|
220
|
+
camouflage a tint-only mark — a green-tinted warning callout reads
|
|
221
|
+
as a callout variant, not as new content (user feedback,
|
|
222
|
+
better-auth adapters/mysql). Table rows are excluded (a pill can't
|
|
223
|
+
straddle a tr); nested marks defer to the outermost label. */
|
|
224
|
+
html:not([data-diff-hidden]) [data-diff="added"]:not(tr, th, td)::before,
|
|
225
|
+
html:not([data-diff-hidden]) [data-diff="removed"]:not(tr, th, td)::before {
|
|
226
|
+
position: absolute;
|
|
227
|
+
top: 0;
|
|
228
|
+
right: 0.6rem;
|
|
229
|
+
transform: translateY(-55%);
|
|
230
|
+
font-size: 0.65rem;
|
|
231
|
+
font-weight: 600;
|
|
232
|
+
letter-spacing: 0.04em;
|
|
233
|
+
line-height: 1.4;
|
|
234
|
+
padding: 0 0.5rem;
|
|
235
|
+
border-radius: 99px;
|
|
236
|
+
z-index: 1;
|
|
237
|
+
text-decoration: none;
|
|
238
|
+
color: var(--color-foreground, var(--foreground, CanvasText));
|
|
239
|
+
}
|
|
240
|
+
html:not([data-diff-hidden]) [data-diff="added"]:not(tr, th, td)::before {
|
|
241
|
+
content: "added";
|
|
242
|
+
background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 25%, var(--color-background, var(--background, Canvas)));
|
|
243
|
+
border: 1px solid color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 45%, transparent);
|
|
244
|
+
}
|
|
245
|
+
html:not([data-diff-hidden]) [data-diff="removed"]:not(tr, th, td)::before {
|
|
246
|
+
content: "removed";
|
|
247
|
+
background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 20%, var(--color-background, var(--background, Canvas)));
|
|
248
|
+
border: 1px solid color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 45%, transparent);
|
|
249
|
+
}
|
|
250
|
+
[data-diff] [data-diff]::before { content: none !important; }
|
|
251
|
+
|
|
252
|
+
/* Nav change markers (the sidebar is client-rendered from nav.json, so
|
|
253
|
+
the styles ship here rather than with the component). Same contract as
|
|
254
|
+
the tab markers: a GLYPH carries the meaning, colour reinforces it,
|
|
255
|
+
visually-hidden text announces it. */
|
|
256
|
+
.db-nav-mark {
|
|
257
|
+
display: inline-flex;
|
|
258
|
+
align-items: center;
|
|
259
|
+
justify-content: center;
|
|
260
|
+
flex-shrink: 0;
|
|
261
|
+
width: 1rem;
|
|
262
|
+
height: 1rem;
|
|
263
|
+
margin-left: auto;
|
|
264
|
+
border-radius: 99px;
|
|
265
|
+
font-size: 0.75rem;
|
|
266
|
+
font-weight: 700;
|
|
267
|
+
line-height: 1;
|
|
268
|
+
color: var(--color-muted-foreground, var(--muted-foreground, currentColor));
|
|
269
|
+
}
|
|
270
|
+
.db-nav-mark[data-nav-mark="added"] { color: var(--color-success, var(--success, oklch(0.72 0.19 150))); }
|
|
271
|
+
.db-nav-mark[data-nav-mark="changed"] { color: var(--color-warning, var(--warning, oklch(0.8 0.16 85))); }
|
|
272
|
+
.db-nav-mark[data-nav-mark="removed"] { color: var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))); }
|
|
273
|
+
.db-nav-mark[data-nav-mark="moved"] { color: var(--color-info, var(--info, oklch(0.7 0.14 240))); }
|
|
274
|
+
/* a subtree marker is a hint about descendants, not a claim about this row */
|
|
275
|
+
.db-nav-mark[data-nav-mark-subtree] { opacity: 0.55; }
|
|
276
|
+
/* removed pages get a SECOND non-colour signal */
|
|
277
|
+
/* Belt-and-braces: a subtree mark must never strike through its own row. */
|
|
278
|
+
[data-nav-mark-row="removed"]:not([data-nav-mark-subtree]) { text-decoration: line-through; opacity: 0.7; }
|
|
279
|
+
@media (forced-colors: active) {
|
|
280
|
+
.db-nav-mark { color: CanvasText; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* Which tab changed? A marked panel is invisible until opened, so its
|
|
284
|
+
trigger carries a marker (the group's bar says "in here", the marker
|
|
285
|
+
says "this one"). The marker is a GLYPH, not a colored dot: colour
|
|
286
|
+
alone fails WCAG 1.4.1 and background-tinted dots vanish entirely in
|
|
287
|
+
forced-colors mode. Colour reinforces; shape carries the meaning.
|
|
288
|
+
Visually-hidden text rides along (emitted by the serializer). */
|
|
289
|
+
.db-tab-mark {
|
|
290
|
+
display: inline-block;
|
|
291
|
+
margin-left: 0.35rem;
|
|
292
|
+
font-size: 0.75rem;
|
|
293
|
+
font-weight: 700;
|
|
294
|
+
line-height: 1;
|
|
295
|
+
vertical-align: middle;
|
|
296
|
+
color: var(--color-muted-foreground, var(--muted-foreground, currentColor));
|
|
297
|
+
}
|
|
298
|
+
[data-diff-hidden] .db-tab-mark { display: none; }
|
|
299
|
+
.db-tab-mark[data-diff-tab="changed"] { color: var(--color-warning, var(--warning, oklch(0.8 0.16 85))); }
|
|
300
|
+
.db-tab-mark[data-diff-tab="added"] { color: var(--color-success, var(--success, oklch(0.72 0.19 150))); }
|
|
301
|
+
.db-tab-mark[data-diff-tab="removed"] { color: var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))); }
|
|
302
|
+
.db-tab-mark[data-diff-tab="moved"] { color: var(--color-info, var(--info, oklch(0.7 0.14 240))); }
|
|
303
|
+
@media (forced-colors: active) {
|
|
304
|
+
.db-tab-mark { color: CanvasText; }
|
|
305
|
+
}
|
|
306
|
+
ins.db-diff-ins {
|
|
307
|
+
text-decoration: none;
|
|
308
|
+
background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 22%, transparent);
|
|
309
|
+
border-radius: 2px;
|
|
310
|
+
padding: 0 1px;
|
|
311
|
+
}
|
|
312
|
+
del.db-diff-del {
|
|
313
|
+
background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 18%, transparent);
|
|
314
|
+
border-radius: 2px;
|
|
315
|
+
padding: 0 1px;
|
|
316
|
+
}
|
|
317
|
+
.db-diff-pair { display: grid; gap: 0.25rem; margin-block: 1rem; }
|
|
318
|
+
.db-diff-pair-label {
|
|
319
|
+
font-size: 0.7rem;
|
|
320
|
+
text-transform: uppercase;
|
|
321
|
+
letter-spacing: 0.08em;
|
|
322
|
+
color: var(--color-muted-foreground, var(--muted-foreground, inherit));
|
|
323
|
+
opacity: 0.8;
|
|
324
|
+
}
|
|
325
|
+
tr[data-diff] { border-left-width: 3px; }
|
|
326
|
+
|
|
327
|
+
/* Folded unchanged sections are CHROME, not content callouts:
|
|
328
|
+
dashed, dimmed, compact — visually "collapsed context". */
|
|
329
|
+
.db-diff-fold {
|
|
330
|
+
border: 1px dashed color-mix(in oklch, currentColor 25%, transparent) !important;
|
|
331
|
+
border-left-width: 1px !important;
|
|
332
|
+
background: transparent !important;
|
|
333
|
+
opacity: 0.7;
|
|
334
|
+
margin-block: 0.5rem !important;
|
|
335
|
+
}
|
|
336
|
+
.db-diff-fold:hover, .db-diff-fold[open] { opacity: 1; }
|
|
337
|
+
.db-diff-fold summary, .db-diff-fold [data-slot="trigger"] {
|
|
338
|
+
font-size: 0.85rem;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* The comparison banner at the top of each reader page. */
|
|
342
|
+
.db-diff-banner-row { display: flex; flex-wrap: wrap; gap: 0.4rem 0.8rem; align-items: center; width: 100%; }
|
|
343
|
+
.db-diff-banner {
|
|
344
|
+
border: 1px solid color-mix(in oklch, currentColor 15%, transparent);
|
|
345
|
+
border-radius: 8px;
|
|
346
|
+
padding: 0.75rem 1rem;
|
|
347
|
+
margin-block-end: 1.5rem;
|
|
348
|
+
font-size: 0.85rem;
|
|
349
|
+
display: flex;
|
|
350
|
+
flex-wrap: wrap;
|
|
351
|
+
gap: 0.4rem 1rem;
|
|
352
|
+
align-items: center;
|
|
353
|
+
}
|
|
354
|
+
.db-diff-banner .db-chip {
|
|
355
|
+
display: inline-block;
|
|
356
|
+
border-radius: 99px;
|
|
357
|
+
padding: 0.05rem 0.6rem;
|
|
358
|
+
font-size: 0.75rem;
|
|
359
|
+
border: 1px solid transparent;
|
|
360
|
+
}
|
|
361
|
+
.db-chip-added { background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 15%, transparent); }
|
|
362
|
+
.db-chip-removed { background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 12%, transparent); }
|
|
363
|
+
.db-chip-changed { background: color-mix(in oklch, var(--color-warning, var(--warning, oklch(0.8 0.16 85))) 15%, transparent); }
|
|
364
|
+
.db-chip-moved { background: color-mix(in oklch, var(--color-info, var(--info, oklch(0.7 0.14 240))) 12%, transparent); }
|
|
365
|
+
.db-chip-muted { border-color: color-mix(in oklch, currentColor 25%, transparent); opacity: 0.8; }
|
|
366
|
+
|
|
367
|
+
/* The persistent view-switcher bar (Reader / Docs-ops / Agent):
|
|
368
|
+
perspectives on ONE comparison — tabs are links, verification
|
|
369
|
+
lives in the chrome, the Monitor CTA sits next to the value. */
|
|
370
|
+
.db-cmp-bar {
|
|
371
|
+
position: sticky;
|
|
372
|
+
/* BELOW the docs header, not under it: both were sticky at top:0 and
|
|
373
|
+
the header's higher z-index simply swallowed this bar on scroll —
|
|
374
|
+
the chrome carrying the trust contract was the chrome that vanished.
|
|
375
|
+
--db-header-height is declared by DocsLayout (the header sizes itself
|
|
376
|
+
from the same variable, so they cannot drift). */
|
|
377
|
+
top: var(--db-header-height, 3.25rem);
|
|
378
|
+
z-index: 30;
|
|
379
|
+
display: flex;
|
|
380
|
+
flex-wrap: wrap;
|
|
381
|
+
gap: 0.4rem 0.9rem;
|
|
382
|
+
align-items: center;
|
|
383
|
+
padding: 0.5rem 0.9rem;
|
|
384
|
+
margin-block-end: 1rem;
|
|
385
|
+
border: 1px solid color-mix(in oklch, currentColor 15%, transparent);
|
|
386
|
+
border-radius: 8px;
|
|
387
|
+
background: var(--color-background, var(--background, Canvas));
|
|
388
|
+
font-size: 0.8rem;
|
|
389
|
+
}
|
|
390
|
+
.db-cmp-bar .db-chip {
|
|
391
|
+
display: inline-block;
|
|
392
|
+
border-radius: 99px;
|
|
393
|
+
padding: 0.05rem 0.6rem;
|
|
394
|
+
font-size: 0.75rem;
|
|
395
|
+
border: 1px solid transparent;
|
|
396
|
+
}
|
|
397
|
+
.db-cmp-bar button.db-chip { cursor: pointer; background: transparent; font: inherit; font-size: 0.75rem; color: inherit; }
|
|
398
|
+
.db-cmp-tabs {
|
|
399
|
+
display: inline-flex;
|
|
400
|
+
gap: 0.15rem;
|
|
401
|
+
border: 1px solid color-mix(in oklch, currentColor 20%, transparent);
|
|
402
|
+
border-radius: 99px;
|
|
403
|
+
padding: 0.1rem;
|
|
404
|
+
}
|
|
405
|
+
.db-cmp-tab {
|
|
406
|
+
padding: 0.1rem 0.7rem;
|
|
407
|
+
border-radius: 99px;
|
|
408
|
+
text-decoration: none;
|
|
409
|
+
color: inherit;
|
|
410
|
+
}
|
|
411
|
+
.db-cmp-tab[aria-current="page"] {
|
|
412
|
+
background: color-mix(in oklch, currentColor 12%, transparent);
|
|
413
|
+
font-weight: 600;
|
|
414
|
+
}
|
|
415
|
+
.db-cmp-pairs { position: relative; display: inline-block; }
|
|
416
|
+
.db-cmp-pairs summary { cursor: pointer; }
|
|
417
|
+
.db-cmp-pairs ul {
|
|
418
|
+
position: absolute;
|
|
419
|
+
top: 100%;
|
|
420
|
+
left: 0;
|
|
421
|
+
margin: 0.25rem 0 0;
|
|
422
|
+
padding: 0.35rem 0.75rem;
|
|
423
|
+
list-style: none;
|
|
424
|
+
background: var(--color-background, var(--background, Canvas));
|
|
425
|
+
border: 1px solid color-mix(in oklch, currentColor 20%, transparent);
|
|
426
|
+
border-radius: 8px;
|
|
427
|
+
white-space: nowrap;
|
|
428
|
+
z-index: 40;
|
|
429
|
+
}
|
|
430
|
+
.db-cmp-monitor { text-decoration: none; border-color: color-mix(in oklch, currentColor 30%, transparent) !important; }
|
|
431
|
+
.db-cmp-monitor:hover { background: color-mix(in oklch, currentColor 10%, transparent); }
|
|
432
|
+
|
|
433
|
+
/* A wholly added/removed PAGE carries one mark around all its content.
|
|
434
|
+
The per-block tint would be overwhelming at page scale, so page level
|
|
435
|
+
gets a stronger edge and a much lighter wash — legible at a glance,
|
|
436
|
+
still readable as prose. The "added"/"removed" pill (the ::before on
|
|
437
|
+
[data-diff]) rides on this wrapper, which is exactly right: it labels
|
|
438
|
+
the page, once. */
|
|
439
|
+
html:not([data-diff-hidden]) [data-diff].db-diff-page {
|
|
440
|
+
border-left-width: 5px;
|
|
441
|
+
padding-left: 1rem;
|
|
442
|
+
padding-block: 0.5rem;
|
|
443
|
+
border-radius: 0 6px 6px 0;
|
|
444
|
+
}
|
|
445
|
+
html:not([data-diff-hidden]) [data-diff="added"].db-diff-page {
|
|
446
|
+
background: color-mix(in oklch, var(--color-success, var(--success, oklch(0.72 0.19 150))) 5%, transparent);
|
|
447
|
+
}
|
|
448
|
+
html:not([data-diff-hidden]) [data-diff="removed"].db-diff-page {
|
|
449
|
+
background: color-mix(in oklch, var(--color-destructive, var(--destructive, oklch(0.64 0.21 25))) 5%, transparent);
|
|
450
|
+
opacity: 1; /* the page is READABLE — the mark says removed, not the dimming */
|
|
451
|
+
}
|
|
452
|
+
/* …and a removed PAGE is not struck through — that is for a deleted line,
|
|
453
|
+
not for a document the reader still needs to read. */
|
|
454
|
+
[data-diff="removed"].db-diff-page :is(p, li, code, pre) { text-decoration: none; }
|
|
455
|
+
|
|
456
|
+
/* Changed raw HTML: the faithful new render, plus the previous version
|
|
457
|
+
one click away. Without it a deletion inside opaque markup (a dropped
|
|
458
|
+
table column) is simply invisible — the reader is told "something
|
|
459
|
+
changed" and shown no evidence. */
|
|
460
|
+
.db-diff-html-before {
|
|
461
|
+
margin-block-start: 0.5rem;
|
|
462
|
+
font-size: 0.8rem;
|
|
463
|
+
border: 1px dashed color-mix(in oklch, currentColor 25%, transparent);
|
|
464
|
+
border-radius: 6px;
|
|
465
|
+
padding: 0.25rem 0.6rem;
|
|
466
|
+
}
|
|
467
|
+
/* Full-strength text: the summary is a CONTROL — dimming it (opacity or
|
|
468
|
+
muted-foreground) drops it below 4.5:1 and axe rightly fails it. */
|
|
469
|
+
.db-diff-html-before summary { cursor: pointer; color: inherit; }
|
|
470
|
+
.db-diff-html-before[open] summary { margin-block-end: 0.5rem; }
|
|
471
|
+
[data-diff-hidden] .db-diff-html-before { display: none; }
|
|
472
|
+
|
|
473
|
+
/* Merged code diffs carry a hidden clean twin (the post-change
|
|
474
|
+
render); Hide highlights swaps them. */
|
|
475
|
+
.db-diff-code { position: relative; }
|
|
476
|
+
.db-diff-code-clean { display: none; }
|
|
477
|
+
[data-diff-hidden] .db-diff-code-clean { display: block; }
|
|
478
|
+
[data-diff-hidden] .db-diff-code-merged { display: none; }
|
|
479
|
+
|
|
480
|
+
/* Whitespace-only code edits are still marked (indentation is
|
|
481
|
+
semantic in YAML/Python/markdown) — but NAMED, so the reader
|
|
482
|
+
is not hunting an invisible 3-space shift. */
|
|
483
|
+
html:not([data-diff-hidden]) .db-diff-code-ws .db-diff-code-merged::before {
|
|
484
|
+
content: "whitespace only";
|
|
485
|
+
position: absolute;
|
|
486
|
+
top: 0;
|
|
487
|
+
right: 0.6rem;
|
|
488
|
+
transform: translateY(-55%);
|
|
489
|
+
z-index: 1;
|
|
490
|
+
font-size: 0.65rem;
|
|
491
|
+
font-weight: 600;
|
|
492
|
+
letter-spacing: 0.04em;
|
|
493
|
+
line-height: 1.4;
|
|
494
|
+
padding: 0 0.5rem;
|
|
495
|
+
border-radius: 99px;
|
|
496
|
+
color: var(--color-foreground, var(--foreground, CanvasText));
|
|
497
|
+
background: color-mix(in oklch, var(--color-warning, var(--warning, oklch(0.8 0.16 85))) 22%, var(--color-background, var(--background, Canvas)));
|
|
498
|
+
border: 1px solid color-mix(in oklch, var(--color-warning, var(--warning, oklch(0.8 0.16 85))) 45%, transparent);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* Hide highlights — read the target version clean. One attribute on
|
|
502
|
+
<html>: the guarded mark rules above stop applying by themselves;
|
|
503
|
+
only content that does not exist in the target version (removed
|
|
504
|
+
blocks, del runs, the "before" code side) needs hiding, plus the
|
|
505
|
+
ins tint. */
|
|
506
|
+
[data-diff-hidden] :is([data-diff="removed"], del.db-diff-del, .db-diff-pair-label) { display: none !important; }
|
|
507
|
+
[data-diff-hidden] ins.db-diff-ins { background: transparent; padding: 0; }
|
|
508
|
+
`;
|
package/dist/render.d.ts
CHANGED
|
@@ -73,6 +73,13 @@ export interface RenderContext {
|
|
|
73
73
|
lastHeadingLevel?: number;
|
|
74
74
|
/** While rendering an autodoc symbol's body: that symbol's heading level. */
|
|
75
75
|
apiParentLevel?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Write each block's source line (`meta.line`, from the parsers'
|
|
78
|
+
* `sourceLines` option) as `data-line` on its first element, and on list
|
|
79
|
+
* items, so a host can map the page back to the source (an editor's scroll
|
|
80
|
+
* sync). Off by default; only a positive integer is ever written.
|
|
81
|
+
*/
|
|
82
|
+
dataLines?: boolean;
|
|
76
83
|
}
|
|
77
84
|
export { FOREIGN_END, FOREIGN_START };
|
|
78
85
|
export { scriptUrl };
|
package/dist/render.js
CHANGED
|
@@ -15,6 +15,7 @@ import { fileTreeHtml } from "./file-tree.js";
|
|
|
15
15
|
import { stripMarkersForCopy } from "./code-markers.js";
|
|
16
16
|
import { icons as ALERT_ICONS, chevronSvg as ALERT_CHEVRON } from "./alert-icons.js";
|
|
17
17
|
import { parseLineRanges, plainHighlighter } from "./lines.js";
|
|
18
|
+
import { decorateDiffHtml, diffMarksOf, hasDiffMarks, inlineSegmentsToHtml, TAB_GLYPH, tabDiffSignal } from "./diff.js";
|
|
18
19
|
import { scriptUrl } from "./url.js";
|
|
19
20
|
import { API_NODE_TYPES, apiCodeInputs, renderApiNode } from "./api.js";
|
|
20
21
|
export { FOREIGN_END, FOREIGN_START };
|
|
@@ -89,6 +90,8 @@ function decorate(html, n) {
|
|
|
89
90
|
return html;
|
|
90
91
|
}
|
|
91
92
|
function inline(n, ctx) {
|
|
93
|
+
if (n.type === DIFF_RUN)
|
|
94
|
+
return inlineSegmentsToHtml(n.segments ?? []);
|
|
92
95
|
switch (n.type) {
|
|
93
96
|
case "text":
|
|
94
97
|
return decorate(esc(n.text), n);
|
|
@@ -275,10 +278,47 @@ export function renderBlocks(nodes, ctx) {
|
|
|
275
278
|
if (!plannedHeadingIds.has(ctx))
|
|
276
279
|
plannedHeadingIds.set(ctx, planHeadingIds(nodes, new Set()));
|
|
277
280
|
let out = "";
|
|
278
|
-
for (const n of nodes)
|
|
279
|
-
|
|
281
|
+
for (const n of nodes) {
|
|
282
|
+
// A DiffTree's marks (from @dogsbay/tree-diff, read structurally); an unmarked node renders as it always did
|
|
283
|
+
const html = markedBlock(n, ctx);
|
|
284
|
+
out += ctx.dataLines ? withDataLine(html, n) : html;
|
|
285
|
+
}
|
|
280
286
|
return out;
|
|
281
287
|
}
|
|
288
|
+
/** A block with its DiffTree marks, if it has any (an unmarked block renders exactly as it always did). */
|
|
289
|
+
function markedBlock(n, ctx, render = (x) => block(x, ctx)) {
|
|
290
|
+
return hasDiffMarks(n) ? decorateDiffHtml(n, render(withWordRun(n)), render) : render(n);
|
|
291
|
+
}
|
|
292
|
+
/** The inline node that carries a changed block's word marks; inline() draws it as `<ins>`/`<del>`. */
|
|
293
|
+
const DIFF_RUN = "dogsbay-diff-run";
|
|
294
|
+
/**
|
|
295
|
+
* A changed prose block with word-level marks, its inline run replaced by the merged `<ins>`/`<del>` run, so
|
|
296
|
+
* the marks sit in the block's own markup. Only where that run is the block's whole text: tree-diff takes a
|
|
297
|
+
* list item's words from its whole subtree (so not with a nested list), a step's from its own line only. A
|
|
298
|
+
* heading keeps its new text, because its id and TOC entry come from it; any other block is marked changed only.
|
|
299
|
+
*/
|
|
300
|
+
function withWordRun(n) {
|
|
301
|
+
const segments = diffMarksOf(n).diffInline;
|
|
302
|
+
if (!segments || !n.inline?.length || !WORD_RUN_TYPES.has(n.type))
|
|
303
|
+
return n;
|
|
304
|
+
if (n.children?.length && n.type !== "step")
|
|
305
|
+
return n;
|
|
306
|
+
return { ...n, inline: [{ type: DIFF_RUN, segments }] };
|
|
307
|
+
}
|
|
308
|
+
const WORD_RUN_TYPES = new Set(["paragraph", "list-item", "li", "step", "td", "th", "dt", "dd"]);
|
|
309
|
+
/** A node's source line, when it is a positive integer (ctx.dataLines). */
|
|
310
|
+
const sourceLine = (n) => {
|
|
311
|
+
const line = n.meta?.line;
|
|
312
|
+
return typeof line === "number" && Number.isInteger(line) && line > 0 ? line : undefined;
|
|
313
|
+
};
|
|
314
|
+
/** `data-line` on the first element of a block's HTML (none when it starts with text or a comment). */
|
|
315
|
+
function withDataLine(html, n) {
|
|
316
|
+
const line = sourceLine(n);
|
|
317
|
+
if (line === undefined)
|
|
318
|
+
return html;
|
|
319
|
+
// The source's line wins over a `data-line` already in raw HTML (it would send scroll sync elsewhere)
|
|
320
|
+
return html.replace(/^(\s*<[A-Za-z][\w:-]*)(?=[\s/>])([^>]*?)(?:\sdata-line=(?:"[^"]*"|'[^']*'|[^\s>]*))?/, `$1 data-line="${line}"$2`);
|
|
321
|
+
}
|
|
282
322
|
const str = (v) => (v == null || v === "" ? undefined : String(v));
|
|
283
323
|
/** A list item or step: the item's own inline run followed by its block children. */
|
|
284
324
|
function itemBody(n, ctx) {
|
|
@@ -292,7 +332,8 @@ function listItem(n, ctx) {
|
|
|
292
332
|
const lead = n.inline && n.inline.length ? renderInline(n.inline, ctx) : "";
|
|
293
333
|
const rest = renderBlocks(n.children, ctx);
|
|
294
334
|
const body = rest && lead ? `<p>${task}${lead}</p>${rest}` : task + lead + rest;
|
|
295
|
-
|
|
335
|
+
const line = ctx.dataLines ? sourceLine(n) : undefined;
|
|
336
|
+
return `<li${attrs(passthrough(p))}${line ? ` data-line="${line}"` : ""}>${body}</li>`;
|
|
296
337
|
}
|
|
297
338
|
function block(n, ctx) {
|
|
298
339
|
const p = (n.props ?? {});
|
|
@@ -335,9 +376,9 @@ function block(n, ctx) {
|
|
|
335
376
|
return `<h${level}${attrs({ id, ...rest })}>${renderInline(n.inline, ctx)}${anchor}</h${level}>\n`;
|
|
336
377
|
}
|
|
337
378
|
case "unordered-list":
|
|
338
|
-
return `<ul${attrs(passthrough(p))}>${(n.children ?? []).map((c) =>
|
|
379
|
+
return `<ul${attrs(passthrough(p))}>${(n.children ?? []).map((c) => markedBlock(c, ctx, (x) => listItem(x, ctx))).join("")}</ul>\n`;
|
|
339
380
|
case "ordered-list":
|
|
340
|
-
return `<ol${attrs({ ...passthrough(p), start: p.start != null && Number(p.start) !== 1 ? p.start : undefined })}>${(n.children ?? []).map((c) =>
|
|
381
|
+
return `<ol${attrs({ ...passthrough(p), start: p.start != null && Number(p.start) !== 1 ? p.start : undefined })}>${(n.children ?? []).map((c) => markedBlock(c, ctx, (x) => listItem(x, ctx))).join("")}</ol>\n`;
|
|
341
382
|
case "list-item":
|
|
342
383
|
return listItem(n, ctx);
|
|
343
384
|
case "blockquote":
|
|
@@ -397,7 +438,7 @@ function block(n, ctx) {
|
|
|
397
438
|
return figure(n, ctx);
|
|
398
439
|
case "substeps": {
|
|
399
440
|
// SubSteps.astro: a lettered list; inside a step, steps.css styles it as the nested list.
|
|
400
|
-
const items = (n.children ?? []).map((c) => (c.type === "step" ?
|
|
441
|
+
const items = (n.children ?? []).map((c) => (c.type === "step" ? markedBlock(c, ctx) : `<li>${markedBlock(c, ctx)}</li>`)).join("");
|
|
401
442
|
return `<ol${attrs({ ...passthrough(p, "sub-steps-list"), role: "list" })}>${items}</ol>\n`;
|
|
402
443
|
}
|
|
403
444
|
case "annotation-list":
|
|
@@ -649,7 +690,8 @@ function codeBlock(n, ctx) {
|
|
|
649
690
|
// A block with annotations takes CodeRich's styles, as the Astro pipeline routes it to CodeRich.
|
|
650
691
|
const diffs = diffAdd.length > 0 || diffRemove.length > 0;
|
|
651
692
|
const rich = lineNumbers || highlights.length > 0 || diffs || code.includes("[!code");
|
|
652
|
-
|
|
693
|
+
// Plain by its TAG, as CodeBlock.astro: a language the highlighter has no grammar for (mermaid) keeps its label
|
|
694
|
+
const plain = !lang || plainHighlighter.resolve(lang) === "text";
|
|
653
695
|
const copyLabel = esc(ctx.t("copyCode"));
|
|
654
696
|
const copyBtn = (extra = "") => `<button type="button" data-part="copy" aria-label="${copyLabel}" class="inline-flex h-7 w-7 items-center justify-center rounded-md text-code-fg/50 transition-colors hover:bg-code-fg/10 hover:text-code-fg/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring${extra}">${COPY_ICON}${CHECK_ICON}</button>`;
|
|
655
697
|
let header = "";
|
|
@@ -737,11 +779,15 @@ function avatar(p, ctx) {
|
|
|
737
779
|
const alt = str(p.alt) ?? "";
|
|
738
780
|
const initials = str(p.fallback) || alt.split(" ").map((w) => w[0] ?? "").join("").slice(0, 2).toUpperCase();
|
|
739
781
|
const { src: _s, alt: _a, fallback: _f, ...rest } = p;
|
|
740
|
-
// As Avatar.astro: the initials stand in when the image fails to load.
|
|
782
|
+
// As Avatar.astro: the initials stand in when the image fails to load. The name is on the avatar itself, so a
|
|
783
|
+
// reader hears it with no image, or a failed one (hidden, its alt with it).
|
|
741
784
|
const inner = src
|
|
742
|
-
? `<img class="aspect-square h-full w-full object-cover" src="${esc(assetUrl(src, ctx))}" alt="
|
|
785
|
+
? `<img class="aspect-square h-full w-full object-cover" src="${esc(assetUrl(src, ctx))}" alt="" width="40" height="40" onerror="this.hidden=true;this.nextElementSibling.hidden=false"><span class="${AVATAR_FALLBACK}" aria-hidden="true" hidden>${esc(initials)}</span>`
|
|
743
786
|
: `<span class="${AVATAR_FALLBACK}" aria-hidden="true">${esc(initials)}</span>`;
|
|
744
|
-
|
|
787
|
+
// An author's own aria-label wins (an empty one does not), as in Avatar.astro
|
|
788
|
+
const label = alt ? { role: "img", "aria-label": str(rest["aria-label"]) ?? alt } : {};
|
|
789
|
+
const { "aria-label": _l, ...own } = passthrough(rest, AVATAR);
|
|
790
|
+
return `<span${attrs({ ...(alt ? own : { ...own, "aria-label": _l }), ...label })}>${inner}</span>`;
|
|
745
791
|
}
|
|
746
792
|
const CHEVRON = '<svg class="h-3 w-3 transition-transform group-open:rotate-90" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
|
|
747
793
|
/**
|
|
@@ -762,7 +808,7 @@ function example(n, ctx) {
|
|
|
762
808
|
}
|
|
763
809
|
function steps(n, ctx) {
|
|
764
810
|
const p = (n.props ?? {});
|
|
765
|
-
const items = (n.children ?? []).map((c) => (c.type === "step" ?
|
|
811
|
+
const items = (n.children ?? []).map((c) => (c.type === "step" ? markedBlock(c, ctx) : `<li>${markedBlock(c, ctx)}</li>`)).join("");
|
|
766
812
|
return `<ol${attrs({ ...passthrough(p, "steps-list"), role: "list" })}>${items}</ol>\n`;
|
|
767
813
|
}
|
|
768
814
|
// ─── Tabs (Tabs*.astro + db-tabs) ───────────────────────────────────
|
|
@@ -780,7 +826,12 @@ function tabs(n, ctx) {
|
|
|
780
826
|
while (used.has(v))
|
|
781
827
|
v += ` (${i + 1})`;
|
|
782
828
|
used.add(v);
|
|
783
|
-
|
|
829
|
+
// A change inside a hidden panel is marked on its trigger: a glyph and its words, not colour alone
|
|
830
|
+
const signal = tabDiffSignal(k);
|
|
831
|
+
const mark = signal
|
|
832
|
+
? `<span class="db-tab-mark" data-diff-tab="${esc(signal)}"><span aria-hidden="true">${TAB_GLYPH[signal] ?? "•"}</span><span class="sr-only">${esc(signal)} in this comparison</span></span>`
|
|
833
|
+
: "";
|
|
834
|
+
return { value: v, label: esc(str(k.props?.label) ?? `Tab ${i + 1}`) + mark, body: () => renderBlocks(k.children, ctx) };
|
|
784
835
|
});
|
|
785
836
|
const syncKey = str(p.syncKey) ?? str(p["sync-key"]) ?? str(p.group);
|
|
786
837
|
return tabGroup(items, ctx, passthrough(p, "my-4 w-full overflow-hidden rounded-lg border border-border"), syncKey, str(p.variant));
|
|
@@ -910,7 +961,7 @@ function card(n, ctx) {
|
|
|
910
961
|
// An image path is shown as an image, as the Astro pipeline's cardIconHtml does.
|
|
911
962
|
const iconHtml = !ic ? "" : /\.(svg|png|jpe?g|webp|avif)$/i.test(ic) ? `<img src="${esc(assetUrl(ic, ctx))}" alt="" class="size-5 mb-2" loading="lazy">` : namedIcon(ic, "mb-2 size-5 text-muted-foreground", ctx);
|
|
912
963
|
const inner = insideLink(!!href, ctx, () => iconHtml +
|
|
913
|
-
(title ? `<h3 class="m-0 border-0 p-0 text-base font-semibold">${renderInline(parseInline(title), ctx)}</h3>` : "") +
|
|
964
|
+
(title ? `<h3 class="not-prose m-0 border-0 p-0 text-base font-semibold">${renderInline(parseInline(title), ctx)}</h3>` : "") +
|
|
914
965
|
(desc ? `<p class="m-0 mt-1 text-sm text-muted-foreground [text-decoration:none]">${renderInline(parseInline(desc), ctx)}</p>` : "") +
|
|
915
966
|
// Body blocks in the same description wrapper as the Astro pipeline's card (a <div>: they carry their own <p>).
|
|
916
967
|
(n.children?.length ? `<div class="text-sm text-muted-foreground mt-1 [text-decoration:none]">${renderBlocks(n.children, ctx)}</div>` : ""));
|
|
@@ -918,7 +969,7 @@ function card(n, ctx) {
|
|
|
918
969
|
const toned = lookup(CARD_TONE_CLASSES, str(p.tone));
|
|
919
970
|
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"}` : ""}`;
|
|
920
971
|
return href
|
|
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>`
|
|
972
|
+
? `<div${attrs(passthrough(p, cls))}><div class="p-0"><a href="${esc(linkUrl(href, ctx))}" class="not-prose block text-foreground [text-decoration:none]">${inner}</a></div></div>`
|
|
922
973
|
: `<div${attrs(passthrough(p, cls))}><div class="p-0">${inner}</div></div>`;
|
|
923
974
|
}
|
|
924
975
|
function linkCard(n, ctx) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/render-html",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.126",
|
|
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/
|
|
34
|
-
"@dogsbay/
|
|
35
|
-
"@dogsbay/
|
|
33
|
+
"@dogsbay/serialize-core": "0.2.0-beta.126",
|
|
34
|
+
"@dogsbay/types": "0.2.0-beta.126",
|
|
35
|
+
"@dogsbay/format-dogsbay-md": "0.2.0-beta.126"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"katex": "^0.16.0"
|