@rightkit/doc-render 0.0.0-stage → 0.1.0

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.
Files changed (49) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +2 -2
  4. package/dist/citations/formatter.d.ts +7 -0
  5. package/dist/citations/formatter.js +106 -0
  6. package/dist/citations/types.d.ts +46 -0
  7. package/dist/citations/types.js +6 -0
  8. package/dist/detectors/callout.d.ts +13 -0
  9. package/dist/detectors/callout.js +32 -0
  10. package/dist/detectors/comparison.d.ts +15 -0
  11. package/dist/detectors/comparison.js +38 -0
  12. package/dist/detectors/descriptionList.d.ts +9 -0
  13. package/dist/detectors/descriptionList.js +16 -0
  14. package/dist/detectors/faq.d.ts +14 -0
  15. package/dist/detectors/faq.js +37 -0
  16. package/dist/detectors/frontmatter.d.ts +11 -0
  17. package/dist/detectors/frontmatter.js +23 -0
  18. package/dist/detectors/index.d.ts +22 -0
  19. package/dist/detectors/index.js +85 -0
  20. package/dist/detectors/nodeText.d.ts +3 -0
  21. package/dist/detectors/nodeText.js +11 -0
  22. package/dist/detectors/richTable.d.ts +10 -0
  23. package/dist/detectors/richTable.js +35 -0
  24. package/dist/detectors/stepper.d.ts +12 -0
  25. package/dist/detectors/stepper.js +58 -0
  26. package/dist/detectors/types.d.ts +26 -0
  27. package/dist/detectors/types.js +2 -0
  28. package/dist/footnotes.d.ts +12 -0
  29. package/dist/footnotes.js +23 -0
  30. package/dist/headingSlugs.d.ts +4 -0
  31. package/dist/headingSlugs.js +19 -0
  32. package/dist/index.d.ts +15 -0
  33. package/dist/index.js +11 -0
  34. package/dist/parseMarkdown.d.ts +7 -0
  35. package/dist/parseMarkdown.js +39 -0
  36. package/dist/remarkCitations.d.ts +13 -0
  37. package/dist/remarkCitations.js +78 -0
  38. package/dist/remarkDefListAbbr.d.ts +30 -0
  39. package/dist/remarkDefListAbbr.js +292 -0
  40. package/dist/remarkWikiLink.d.ts +10 -0
  41. package/dist/remarkWikiLink.js +63 -0
  42. package/dist/renderers.d.ts +17 -0
  43. package/dist/renderers.js +88 -0
  44. package/dist/security.d.ts +12 -0
  45. package/dist/security.js +78 -0
  46. package/dist/shikiHighlighter.d.ts +26 -0
  47. package/dist/shikiHighlighter.js +184 -0
  48. package/package.json +64 -3
  49. package/styles.css +10 -0
@@ -0,0 +1,292 @@
1
+ const DEF_RE = /^:(?:\s+(.*))?$/;
2
+ const ABBR_DEF_RE = /^\*\[([^\]]+)\]:\s*(.+)$/;
3
+ /** Transform Pandoc-style definition lists and abbreviation definitions into custom mdast nodes. */
4
+ export function remarkDefListAbbr() {
5
+ return (tree) => {
6
+ const abbreviations = new Map();
7
+ tree.children = transformTopLevel(tree.children, abbreviations);
8
+ if (abbreviations.size > 0) {
9
+ expandAbbreviations(tree.children, abbreviations);
10
+ }
11
+ };
12
+ }
13
+ function transformTopLevel(children, abbreviations) {
14
+ const out = [];
15
+ let i = 0;
16
+ while (i < children.length) {
17
+ const child = children[i];
18
+ if (child && child.type === "paragraph") {
19
+ const abbrSplit = splitAbbreviationDefinition(child, abbreviations);
20
+ if (abbrSplit) {
21
+ out.push(abbrSplit.abbr);
22
+ if (abbrSplit.remainder) {
23
+ out.push(transformTopLevel([abbrSplit.remainder], abbreviations)[0]);
24
+ }
25
+ i += 1;
26
+ continue;
27
+ }
28
+ const inlineList = parseInlineDefinitionList(child);
29
+ if (inlineList) {
30
+ out.push({
31
+ ...inlineList,
32
+ position: child.position,
33
+ });
34
+ i += 1;
35
+ continue;
36
+ }
37
+ }
38
+ const result = consumeDefinitionList(children, i);
39
+ if (result) {
40
+ out.push(result.list);
41
+ i = result.nextIndex;
42
+ continue;
43
+ }
44
+ if (child && "children" in child && Array.isArray(child.children)) {
45
+ out.push(transformNode(child, abbreviations));
46
+ i += 1;
47
+ continue;
48
+ }
49
+ out.push(child);
50
+ i += 1;
51
+ }
52
+ return out;
53
+ }
54
+ function transformNode(node, abbreviations) {
55
+ if (node.type === "paragraph" || node.type === "heading" || node.type === "blockquote") {
56
+ const typed = node;
57
+ const copy = { ...typed, children: [...typed.children] };
58
+ copy.children = transformInline(copy.children, abbreviations);
59
+ return copy;
60
+ }
61
+ if (node.type === "list") {
62
+ const typed = node;
63
+ const copy = { ...typed, children: [...typed.children] };
64
+ copy.children = copy.children.map((item) => transformNode(item, abbreviations));
65
+ return copy;
66
+ }
67
+ if (node.type === "listItem") {
68
+ const typed = node;
69
+ const copy = { ...typed, children: [...typed.children] };
70
+ copy.children = transformTopLevel(copy.children, abbreviations);
71
+ return copy;
72
+ }
73
+ return node;
74
+ }
75
+ function splitAbbreviationDefinition(node, abbreviations) {
76
+ const text = paragraphText(node);
77
+ const lines = text.split("\n");
78
+ const first = lines[0] ?? "";
79
+ const m = ABBR_DEF_RE.exec(first);
80
+ if (!m)
81
+ return null;
82
+ const short = m[1] ?? "";
83
+ const long = m[2]?.trim() ?? "";
84
+ abbreviations.set(short, long);
85
+ const remainder = lines.length > 1
86
+ ? {
87
+ type: "paragraph",
88
+ children: [{ type: "text", value: lines.slice(1).join("\n") }],
89
+ }
90
+ : null;
91
+ return { abbr: { type: "abbreviationDefinition", short, long }, remainder };
92
+ }
93
+ function parseInlineDefinitionList(node) {
94
+ if (node.children.some((child) => child.type !== "text"))
95
+ return null;
96
+ const text = paragraphText(node);
97
+ const lines = text.split("\n");
98
+ const items = [];
99
+ let i = 0;
100
+ while (i < lines.length) {
101
+ const terms = [];
102
+ while (i < lines.length && !DEF_RE.exec(lines[i] ?? "")) {
103
+ terms.push(lines[i] ?? "");
104
+ i += 1;
105
+ }
106
+ if (terms.length === 0)
107
+ break;
108
+ const details = [];
109
+ while (i < lines.length) {
110
+ const m = DEF_RE.exec(lines[i] ?? "");
111
+ if (!m)
112
+ break;
113
+ details.push(m[1] ?? "");
114
+ i += 1;
115
+ }
116
+ if (details.length === 0)
117
+ break;
118
+ items.push({
119
+ type: "descriptionItem",
120
+ terms: terms.map((t) => ({ type: "descriptionTerm", children: [{ type: "text", value: t }] })),
121
+ details: details.map((d) => ({
122
+ type: "descriptionDetails",
123
+ children: [{ type: "text", value: d }],
124
+ })),
125
+ });
126
+ }
127
+ if (items.length === 0)
128
+ return null;
129
+ // Only treat the whole paragraph as a definition list if every line was consumed.
130
+ const consumed = items.reduce((sum, item) => sum + item.terms.length + item.details.length, 0);
131
+ if (consumed !== lines.length)
132
+ return null;
133
+ return { type: "descriptionList", children: items };
134
+ }
135
+ function consumeDefinitionList(children, start) {
136
+ const items = [];
137
+ let firstTermParagraph = null;
138
+ let lastDetailParagraph = null;
139
+ let i = start;
140
+ while (i < children.length) {
141
+ const termResult = consumeTerms(children, i);
142
+ if (!termResult)
143
+ break;
144
+ const detailsResult = consumeDetails(children, termResult.nextIndex);
145
+ if (detailsResult.details.length === 0)
146
+ break;
147
+ if (!firstTermParagraph)
148
+ firstTermParagraph = termResult.terms[0] ?? null;
149
+ lastDetailParagraph = detailsResult.details[detailsResult.details.length - 1] ?? null;
150
+ items.push({
151
+ type: "descriptionItem",
152
+ terms: termResult.terms.map((t) => ({ type: "descriptionTerm", children: t.children })),
153
+ details: detailsResult.details.map((d) => ({ type: "descriptionDetails", children: d.children })),
154
+ });
155
+ i = detailsResult.nextIndex;
156
+ }
157
+ if (items.length === 0)
158
+ return null;
159
+ const position = firstTermParagraph?.position && lastDetailParagraph?.position
160
+ ? { start: firstTermParagraph.position.start, end: lastDetailParagraph.position.end }
161
+ : undefined;
162
+ return {
163
+ list: { type: "descriptionList", children: items, position },
164
+ nextIndex: i,
165
+ };
166
+ }
167
+ function consumeTerms(children, start) {
168
+ const terms = [];
169
+ let i = start;
170
+ while (i < children.length) {
171
+ const child = children[i];
172
+ if (child?.type !== "paragraph")
173
+ break;
174
+ const text = paragraphText(child);
175
+ if (DEF_RE.exec(text) || ABBR_DEF_RE.test(text))
176
+ break;
177
+ terms.push(child);
178
+ i += 1;
179
+ }
180
+ if (terms.length === 0)
181
+ return null;
182
+ return { terms, nextIndex: i };
183
+ }
184
+ function consumeDetails(children, start) {
185
+ const details = [];
186
+ let i = start;
187
+ while (i < children.length) {
188
+ const child = children[i];
189
+ if (child?.type !== "paragraph")
190
+ break;
191
+ const text = paragraphText(child);
192
+ const m = DEF_RE.exec(text);
193
+ if (!m)
194
+ break;
195
+ const inner = m[1] ?? "";
196
+ const stripped = {
197
+ ...child,
198
+ children: inner ? [{ type: "text", value: inner }] : [],
199
+ };
200
+ details.push(stripped);
201
+ i += 1;
202
+ }
203
+ return { details, nextIndex: i };
204
+ }
205
+ function paragraphText(node) {
206
+ return node.children
207
+ .filter((c) => c.type === "text")
208
+ .map((c) => c.value)
209
+ .join("");
210
+ }
211
+ function transformInline(children, abbreviations) {
212
+ const out = [];
213
+ for (const child of children) {
214
+ if (child.type === "text" && abbreviations.size > 0) {
215
+ out.push(...splitAbbreviations(child, abbreviations));
216
+ continue;
217
+ }
218
+ if (child.type === "abbr") {
219
+ out.push(child);
220
+ continue;
221
+ }
222
+ if ("children" in child && Array.isArray(child.children)) {
223
+ out.push({
224
+ ...child,
225
+ children: transformInline(child.children, abbreviations),
226
+ });
227
+ continue;
228
+ }
229
+ out.push(child);
230
+ }
231
+ return out;
232
+ }
233
+ function splitAbbreviations(text, abbreviations) {
234
+ const sorted = Array.from(abbreviations.entries()).sort((a, b) => b[0].length - a[0].length);
235
+ let parts = [text];
236
+ for (const [short, long] of sorted) {
237
+ const next = [];
238
+ const pattern = new RegExp(`(^|[^A-Za-z0-9])(${escapeRegExp(short)})(?=[^A-Za-z0-9]|$)`, "g");
239
+ for (const part of parts) {
240
+ if (part.type !== "text") {
241
+ next.push(part);
242
+ continue;
243
+ }
244
+ const value = part.value;
245
+ const matches = Array.from(value.matchAll(pattern));
246
+ let lastIndex = 0;
247
+ for (const match of matches) {
248
+ const index = match.index ?? 0;
249
+ const leading = match[1];
250
+ const before = value.slice(lastIndex, index + leading.length);
251
+ if (before) {
252
+ next.push({ type: "text", value: before });
253
+ }
254
+ next.push({
255
+ type: "abbr",
256
+ title: long,
257
+ children: [{ type: "text", value: short }],
258
+ });
259
+ lastIndex = index + leading.length + short.length;
260
+ }
261
+ const remaining = value.slice(lastIndex);
262
+ if (remaining) {
263
+ next.push({ type: "text", value: remaining });
264
+ }
265
+ lastIndex = 0;
266
+ }
267
+ parts = next;
268
+ }
269
+ return parts;
270
+ }
271
+ function escapeRegExp(value) {
272
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
273
+ }
274
+ function expandAbbreviations(children, abbreviations) {
275
+ for (let i = 0; i < children.length; i++) {
276
+ const child = children[i];
277
+ if (child.type === "paragraph" || child.type === "heading" || child.type === "blockquote") {
278
+ const typed = child;
279
+ typed.children = transformInline(typed.children, abbreviations);
280
+ }
281
+ else if (child.type === "descriptionList") {
282
+ for (const item of child.children) {
283
+ for (const term of item.terms) {
284
+ term.children = transformInline(term.children, abbreviations);
285
+ }
286
+ for (const detail of item.details) {
287
+ detail.children = transformInline(detail.children, abbreviations);
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }
@@ -0,0 +1,10 @@
1
+ import type { Root } from "mdast";
2
+ export interface WikiLinkNode {
3
+ type: "wikiLink";
4
+ target: string;
5
+ hash: string;
6
+ display: string | null;
7
+ children: [];
8
+ }
9
+ /** Transform plain `[[target]]` / `[[target#hash|display]]` text segments into wiki-link nodes. */
10
+ export declare function remarkWikiLink(): (tree: Root) => void;
@@ -0,0 +1,63 @@
1
+ const WIKI_LINK_RE = /\[\[([^\]\n]+?)\]\]/g;
2
+ /** Transform plain `[[target]]` / `[[target#hash|display]]` text segments into wiki-link nodes. */
3
+ export function remarkWikiLink() {
4
+ return (tree) => {
5
+ tree.children = processChildren(tree.children);
6
+ };
7
+ }
8
+ function processChildren(children) {
9
+ const out = [];
10
+ for (const child of children) {
11
+ const node = child;
12
+ if (node.type === "text" && typeof node.value === "string") {
13
+ const split = splitWikiLink(node.value);
14
+ out.push(...(split ?? [child]));
15
+ continue;
16
+ }
17
+ if (Array.isArray(node.children)) {
18
+ out.push({
19
+ ...node,
20
+ children: processChildren(node.children),
21
+ });
22
+ continue;
23
+ }
24
+ out.push(child);
25
+ }
26
+ return out;
27
+ }
28
+ function splitWikiLink(value) {
29
+ const parts = [];
30
+ let lastIndex = 0;
31
+ let match = WIKI_LINK_RE.exec(value);
32
+ while (match !== null) {
33
+ if (match.index > lastIndex) {
34
+ parts.push({ type: "text", value: value.slice(lastIndex, match.index) });
35
+ }
36
+ parts.push(parseWikiLink(match[1]));
37
+ lastIndex = match.index + match[0].length;
38
+ WIKI_LINK_RE.lastIndex = lastIndex;
39
+ match = WIKI_LINK_RE.exec(value);
40
+ }
41
+ if (parts.length === 0)
42
+ return null;
43
+ if (lastIndex < value.length) {
44
+ parts.push({ type: "text", value: value.slice(lastIndex) });
45
+ }
46
+ return parts;
47
+ }
48
+ function parseWikiLink(inner) {
49
+ const pipeIndex = inner.lastIndexOf("|");
50
+ const left = pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner;
51
+ const display = pipeIndex >= 0 ? inner.slice(pipeIndex + 1) : null;
52
+ const hashIndex = left.indexOf("#");
53
+ let target = "";
54
+ let hash = "";
55
+ if (hashIndex >= 0) {
56
+ target = left.slice(0, hashIndex);
57
+ hash = left.slice(hashIndex + 1);
58
+ }
59
+ else {
60
+ target = left;
61
+ }
62
+ return { type: "wikiLink", target, hash, display, children: [] };
63
+ }
@@ -0,0 +1,17 @@
1
+ import { type ReactNode } from "react";
2
+ import type { CslEntry } from "./citations/types.js";
3
+ export interface MarkdownRendererProps {
4
+ source: string;
5
+ className?: string;
6
+ onOpenLink?: (href: string) => void;
7
+ bibliography?: Map<string, CslEntry>;
8
+ }
9
+ export interface HtmlRendererProps {
10
+ html: string;
11
+ className?: string;
12
+ safeMode?: boolean;
13
+ }
14
+ export declare function MarkdownRenderer({ source, className, onOpenLink, bibliography }: MarkdownRendererProps): import("react").JSX.Element;
15
+ export declare function HtmlRenderer({ html, className, safeMode }: HtmlRendererProps): import("react").JSX.Element;
16
+ export declare function renderMarkdown(source: string, options?: Omit<MarkdownRendererProps, "source">): ReactNode;
17
+ export declare function renderHtml(html: string, options?: Omit<HtmlRendererProps, "html">): ReactNode;
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createElement } from "react";
3
+ import { buildHeadingSlugs } from "./headingSlugs.js";
4
+ import { collectFootnotes } from "./footnotes.js";
5
+ import { nodeText } from "./detectors/nodeText.js";
6
+ import { parseToTree } from "./parseMarkdown.js";
7
+ import { safeImageHref, safeLinkHref, sanitizeHtml } from "./security.js";
8
+ import { formatBibliographyEntry, formatCitation } from "./citations/formatter.js";
9
+ function textContent(children) { return children.map((child) => nodeText(child)).join(""); }
10
+ function renderChildren(children, context) {
11
+ return children.map((child, index) => renderNode(child, `${context.key}:${index}`, context));
12
+ }
13
+ function renderNode(node, key, parent) {
14
+ const raw = node;
15
+ const context = { ...parent, key };
16
+ const children = node.children ?? [];
17
+ switch (raw.type) {
18
+ case "text": return raw.value;
19
+ case "paragraph": return _jsx("p", { children: renderChildren(children, context) }, key);
20
+ case "heading": return createElement(`h${raw.depth}`, { key, id: parent.headingSlugs.get(node) }, renderChildren(children, context));
21
+ case "emphasis": return _jsx("em", { children: renderChildren(children, context) }, key);
22
+ case "strong": return _jsx("strong", { children: renderChildren(children, context) }, key);
23
+ case "delete": return _jsx("del", { children: renderChildren(children, context) }, key);
24
+ case "inlineCode": return _jsx("code", { children: raw.value }, key);
25
+ case "code": return _jsx("pre", { className: "rk-doc-code", children: _jsx("code", { children: raw.value }) }, key);
26
+ case "blockquote": return _jsx("blockquote", { children: renderChildren(children, context) }, key);
27
+ case "list": return createElement(raw.ordered ? "ol" : "ul", { key, start: raw.ordered && raw.start !== 1 ? raw.start : undefined }, renderChildren(children, context));
28
+ case "listItem": return _jsx("li", { children: renderChildren(children, context) }, key);
29
+ case "link": {
30
+ const href = safeLinkHref(raw.url);
31
+ if (!href)
32
+ return _jsx("span", { children: renderChildren(children, context) }, key);
33
+ return _jsx("a", { href: href, rel: "noreferrer noopener", target: "_blank", onClick: (event) => { if (parent.onOpenLink) {
34
+ event.preventDefault();
35
+ parent.onOpenLink(href);
36
+ } }, children: renderChildren(children, context) }, key);
37
+ }
38
+ case "image": {
39
+ const src = safeImageHref(raw.url);
40
+ return src ? _jsx("img", { src: src, alt: raw.alt ?? "", title: raw.title ?? undefined, loading: "lazy" }, key) : _jsx("span", { children: raw.alt ?? "" }, key);
41
+ }
42
+ case "thematicBreak": return _jsx("hr", {}, key);
43
+ case "break": return _jsx("br", {}, key);
44
+ case "html": return _jsx("span", { dangerouslySetInnerHTML: { __html: sanitizeHtml(raw.value) } }, key);
45
+ case "table": return _jsxs("table", { children: [_jsx("thead", { children: _jsx("tr", { children: raw.children[0]?.children.map((cell, i) => _jsx("th", { children: renderChildren(cell.children, context) }, i)) }) }), _jsx("tbody", { children: raw.children.slice(1).map((row, i) => _jsx("tr", { children: row.children.map((cell, j) => _jsx("td", { children: renderChildren(cell.children, context) }, j)) }, i)) })] }, key);
46
+ case "footnoteReference": {
47
+ const footnote = parent.footnotes.get(raw.identifier);
48
+ return footnote ? _jsx("sup", { children: _jsx("a", { href: `#fn-${raw.identifier}`, id: `fnref-${raw.identifier}`, children: footnote.num }) }, key) : null;
49
+ }
50
+ case "math": return _jsx("pre", { className: "rk-doc-math", children: raw.value }, key);
51
+ case "inlineMath": return _jsx("code", { className: "rk-doc-math-inline", children: raw.value }, key);
52
+ case "definition":
53
+ case "footnoteDefinition":
54
+ case "yaml":
55
+ case "toml": return null;
56
+ case "imageReference":
57
+ case "linkReference": return _jsx("span", { children: textContent(children) }, key);
58
+ case "citation": {
59
+ const entry = parent.bibliography.get(node.key);
60
+ return _jsx("span", { children: entry ? formatCitation(entry, node.locator) : `[${node.key}]` }, key);
61
+ }
62
+ case "bibliography": return _jsx(Bibliography, { entries: [...parent.bibliography.values()] }, key);
63
+ default: return children.length ? _jsx("span", { children: renderChildren(children, context) }, key) : null;
64
+ }
65
+ }
66
+ function Bibliography({ entries }) {
67
+ return _jsxs("section", { className: "rk-doc-bibliography", children: [_jsx("h2", { children: "References" }), _jsx("ol", { children: entries.map((entry) => _jsx("li", { children: formatBibliographyEntry(entry) }, entry.id)) })] });
68
+ }
69
+ export function MarkdownRenderer({ source, className, onOpenLink, bibliography = new Map() }) {
70
+ const tree = parseToTree(source);
71
+ const headingSlugs = buildHeadingSlugs(tree);
72
+ const footnotes = collectFootnotes(tree);
73
+ const context = { headingSlugs, footnotes, onOpenLink, bibliography, key: "root" };
74
+ const content = renderChildren(tree.children, context);
75
+ if (footnotes.size)
76
+ content.push(_jsxs("section", { className: "rk-doc-footnotes", children: [_jsx("h2", { children: "Notes" }), _jsx("ol", { children: [...footnotes.entries()].map(([id, footnote]) => _jsx("li", { id: `fn-${id}`, children: renderChildren(footnote.children, { ...context, key: `fn:${id}` }) }, id)) })] }, "footnotes"));
77
+ return _jsx("article", { className: `rk-doc-markdown ${className ?? ""}`, children: content });
78
+ }
79
+ export function HtmlRenderer({ html, className, safeMode = true }) {
80
+ const content = safeMode ? sanitizeHtml(html) : html;
81
+ return _jsx("article", { className: `rk-doc-html ${className ?? ""}`, dangerouslySetInnerHTML: { __html: content } });
82
+ }
83
+ export function renderMarkdown(source, options) {
84
+ return _jsx(MarkdownRenderer, { source: source, ...options });
85
+ }
86
+ export function renderHtml(html, options) {
87
+ return _jsx(HtmlRenderer, { html: html, ...options });
88
+ }
@@ -0,0 +1,12 @@
1
+ /** Allow only links that can leave the application through an explicit host adapter. */
2
+ export declare function safeLinkHref(href?: string): string | undefined;
3
+ /** Relative image references are safe in a host-controlled document root; remote images use the
4
+ * same strict URL policy as links. Data URLs are intentionally unavailable. */
5
+ export declare function safeImageHref(href?: string): string | undefined;
6
+ /** Sanitize raw HTML before it reaches a React HTML boundary. Browser DOMPurify is used when a
7
+ * DOM exists; with no DOM (SSR/node) the markup is escaped to inert text. */
8
+ export declare function sanitizeHtml(html: string): string;
9
+ export declare function sanitizeMarkdownHtml(html: string, options?: {
10
+ safeMode?: boolean;
11
+ }): string;
12
+ export declare function isSafeUri(uri: string): boolean;
@@ -0,0 +1,78 @@
1
+ import DOMPurify from "dompurify";
2
+ function hasControlCharacter(value) {
3
+ for (const char of value) {
4
+ const code = char.charCodeAt(0);
5
+ if (code <= 31 || code === 127)
6
+ return true;
7
+ }
8
+ return false;
9
+ }
10
+ /** Allow only links that can leave the application through an explicit host adapter. */
11
+ export function safeLinkHref(href) {
12
+ if (!href)
13
+ return undefined;
14
+ const trimmed = href.trim();
15
+ if (!trimmed || hasControlCharacter(trimmed))
16
+ return undefined;
17
+ const protocolMatch = trimmed.match(/^([a-z][a-z0-9+.-]*):/i);
18
+ if (!protocolMatch)
19
+ return undefined;
20
+ const protocol = protocolMatch[1].toLowerCase();
21
+ if (protocol !== "http" && protocol !== "https" && protocol !== "mailto")
22
+ return undefined;
23
+ if ((protocol === "http" || protocol === "https") && !/^https?:\/\//i.test(trimmed))
24
+ return undefined;
25
+ try {
26
+ const parsed = new URL(trimmed);
27
+ return parsed.protocol === `${protocol}:` ? trimmed : undefined;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ /** Relative image references are safe in a host-controlled document root; remote images use the
34
+ * same strict URL policy as links. Data URLs are intentionally unavailable. */
35
+ export function safeImageHref(href) {
36
+ if (!href)
37
+ return undefined;
38
+ const trimmed = href.trim();
39
+ if (!trimmed || hasControlCharacter(trimmed) || /^data:|^javascript:|^vbscript:/i.test(trimmed))
40
+ return undefined;
41
+ if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed))
42
+ return safeLinkHref(trimmed);
43
+ if (trimmed.startsWith("//") || trimmed.startsWith("\\\\"))
44
+ return undefined;
45
+ return trimmed;
46
+ }
47
+ const FORBIDDEN_TAGS = ["script", "style", "iframe", "object", "embed", "form", "base", "meta", "link", "template", "svg", "math", "canvas"];
48
+ const URI_ATTRIBUTES = new Set(["href", "src", "action", "formaction", "xlink:href"]);
49
+ /** Without a DOM there is no trustworthy HTML parser, so fail closed: render the raw markup as
50
+ * inert text. Regex sanitizing is never a security boundary (e.g. `<img src=x/onerror=...>`). */
51
+ function escapeHtml(html) {
52
+ return html.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
53
+ }
54
+ /** Sanitize raw HTML before it reaches a React HTML boundary. Browser DOMPurify is used when a
55
+ * DOM exists; with no DOM (SSR/node) the markup is escaped to inert text. */
56
+ export function sanitizeHtml(html) {
57
+ if (typeof document === "undefined" || !DOMPurify.isSupported)
58
+ return escapeHtml(html);
59
+ return DOMPurify.sanitize(html, {
60
+ ALLOWED_TAGS: ["a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "section", "small", "span", "strong", "sub", "sup", "table", "tbody", "td", "th", "thead", "tr", "u", "ul"],
61
+ ALLOWED_ATTR: ["alt", "class", "colspan", "height", "href", "id", "rel", "rowspan", "src", "target", "title", "width"],
62
+ FORBID_TAGS: FORBIDDEN_TAGS,
63
+ FORBID_ATTR: ["style", "srcdoc"],
64
+ ALLOW_UNKNOWN_PROTOCOLS: false,
65
+ RETURN_TRUSTED_TYPE: false,
66
+ ADD_ATTR: ["target", "rel"],
67
+ ALLOW_DATA_ATTR: false,
68
+ }).replace(/\s+(href|src)=(['"])([^'"\s]*)\2/gi, (full, attribute, quote, value) => {
69
+ const safe = URI_ATTRIBUTES.has(attribute.toLowerCase()) && (attribute.toLowerCase() === "src" ? safeImageHref(value) : safeLinkHref(value));
70
+ return safe ? ` ${attribute}=${quote}${safe}${quote}` : "";
71
+ });
72
+ }
73
+ export function sanitizeMarkdownHtml(html, options = {}) {
74
+ return options.safeMode === false ? html : sanitizeHtml(html);
75
+ }
76
+ export function isSafeUri(uri) {
77
+ return Boolean(safeLinkHref(uri) ?? safeImageHref(uri));
78
+ }
@@ -0,0 +1,26 @@
1
+ import type { HighlighterCore } from "shiki/core";
2
+ /**
3
+ * The one Shiki highlighter shared by the Markdown preview (`CodeBlock`), the hybrid editor's
4
+ * code-fence decorations and the language validator.
5
+ *
6
+ * Built on `shiki/core` rather than `shiki/bundle/web` so the build ships only what is listed here:
7
+ * two themes and a curated grammar set, each grammar its own lazy chunk. Oniguruma stays the regex
8
+ * engine — the JavaScript engine tokenizes identically but transpiling a grammar's patterns made the
9
+ * first highlight of a fence several times slower (≈1.6–2 s vs ≈0.3 s for a 40-line TS/C++ fence).
10
+ * Its WASM ships as a binary asset fetched on first use, not as a base64 JS module that has to be
11
+ * parsed. When WebAssembly is unavailable (macOS Lockdown Mode) the JavaScript engine takes over.
12
+ */
13
+ export declare const SHIKI_LIGHT_THEME = "github-light";
14
+ export declare const SHIKI_DARK_THEME = "github-dark";
15
+ /**
16
+ * Canonical Shiki language id for a fence info string, `"text"` for the plain-text names, or
17
+ * `null` when the language is not in the curated set.
18
+ */
19
+ export declare function resolveShikiLanguage(language: string | null | undefined): string | null;
20
+ /** Lazy singleton — Shiki, its engine and the two themes load when the first code block needs them. */
21
+ export declare function getShikiHighlighter(): Promise<HighlighterCore>;
22
+ /**
23
+ * Loads the grammar for `language` into `highlighter` and returns the id to pass to Shiki, or
24
+ * `null` when the language is unknown or its grammar failed to load (callers render plain text).
25
+ */
26
+ export declare function ensureShikiLanguage(highlighter: HighlighterCore, language: string): Promise<string | null>;