@loidolt/theme-docs 0.0.0-stage → 0.8.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -2
  3. package/dist/components/CodeSnippet.svelte +86 -0
  4. package/dist/components/CodeSnippet.svelte.d.ts +21 -0
  5. package/dist/components/DocsCard.svelte +50 -0
  6. package/dist/components/DocsCard.svelte.d.ts +19 -0
  7. package/dist/components/DocsHub.svelte +51 -0
  8. package/dist/components/DocsHub.svelte.d.ts +14 -0
  9. package/dist/components/Markdown.svelte +90 -0
  10. package/dist/components/Markdown.svelte.d.ts +23 -0
  11. package/dist/components/MarkdownPage.svelte +121 -0
  12. package/dist/components/MarkdownPage.svelte.d.ts +34 -0
  13. package/dist/components/MermaidDiagram.svelte +75 -0
  14. package/dist/components/MermaidDiagram.svelte.d.ts +16 -0
  15. package/dist/components/TableOfContents.svelte +84 -0
  16. package/dist/components/TableOfContents.svelte.d.ts +24 -0
  17. package/dist/components/TocPanel.svelte +56 -0
  18. package/dist/components/TocPanel.svelte.d.ts +19 -0
  19. package/dist/core/escape.d.ts +6 -0
  20. package/dist/core/escape.js +16 -0
  21. package/dist/core/frontmatter.d.ts +11 -0
  22. package/dist/core/frontmatter.js +110 -0
  23. package/dist/core/highlight.d.ts +32 -0
  24. package/dist/core/highlight.js +52 -0
  25. package/dist/core/index.d.ts +11 -0
  26. package/dist/core/index.js +12 -0
  27. package/dist/core/mermaid.d.ts +17 -0
  28. package/dist/core/mermaid.js +49 -0
  29. package/dist/core/render.d.ts +12 -0
  30. package/dist/core/render.js +261 -0
  31. package/dist/core/slug.d.ts +7 -0
  32. package/dist/core/slug.js +21 -0
  33. package/dist/core/types.d.ts +83 -0
  34. package/dist/core/types.js +1 -0
  35. package/dist/core/url.d.ts +7 -0
  36. package/dist/core/url.js +26 -0
  37. package/dist/index.d.ts +9 -0
  38. package/dist/index.js +9 -0
  39. package/dist/internal/enhance.d.ts +42 -0
  40. package/dist/internal/enhance.js +96 -0
  41. package/package.json +75 -4
@@ -0,0 +1,261 @@
1
+ import { Marked } from 'marked';
2
+ import { escapeHtml, textOf } from './escape.js';
3
+ import { parseFrontmatter as parseSubset, splitFrontmatter, } from './frontmatter.js';
4
+ import { loadHighlighter } from './highlight.js';
5
+ import { createSlugger } from './slug.js';
6
+ import { safeUrl } from './url.js';
7
+ const ADMONITION_TONES = {
8
+ note: 'info',
9
+ tip: 'success',
10
+ important: 'info',
11
+ warning: 'warning',
12
+ caution: 'error',
13
+ };
14
+ const DEFAULT_ADMONITION_LABELS = {
15
+ note: 'Note',
16
+ tip: 'Tip',
17
+ important: 'Important',
18
+ warning: 'Warning',
19
+ caution: 'Caution',
20
+ };
21
+ /** Marks where a code block's HTML goes, once highlighting (or not) has decided what it is. */
22
+ // Private-use characters: they cannot occur in rendered markdown, which escapes nothing into them.
23
+ const CODE_MARK = (index) => `\uE000ldt-code-${index}\uE001`;
24
+ /** Splits a fence's info string: ```ts title="app.ts"``` or ```ts:app.ts```. */
25
+ function fenceInfo(info) {
26
+ const [first = '', ...rest] = info.trim().split(/\s+/);
27
+ const [lang, colonName] = first.split(':');
28
+ const titled = /(?:title|filename)=(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(rest.join(' '));
29
+ return {
30
+ lang: lang ?? '',
31
+ filename: titled?.[1] ?? titled?.[2] ?? titled?.[3] ?? colonName ?? null,
32
+ };
33
+ }
34
+ function parse(source, options) {
35
+ const { allowHtml = false, idPrefix = '', headingLinks = true, stripTitle = false, admonitionLabels = {}, copyLabel = 'Copy', codeLabel = (lang) => (lang ? `${lang} code` : 'Code'), taskLabels = { done: 'Done', open: 'Not done' }, footnotesLabel = 'Footnotes', footnoteBackLabel = (number) => `Back to reference ${number}`, } = options;
36
+ const { frontmatter: rawFrontmatter, body } = splitFrontmatter(source);
37
+ const frontmatter = rawFrontmatter === null ? null : (options.parseFrontmatter ?? parseSubset)(rawFrontmatter);
38
+ // Every call gets its own state: nothing is shared between documents, even concurrently.
39
+ const slug = createSlugger(idPrefix);
40
+ const headings = [];
41
+ const codes = [];
42
+ const footnoteDefinitions = new Map();
43
+ const footnoteNumbers = new Map();
44
+ const footnoteHtml = new Map();
45
+ const admonition = {
46
+ name: 'admonition',
47
+ level: 'block',
48
+ start: (src) => src.match(/^(:::|> ?\[!)/m)?.index,
49
+ tokenizer(src) {
50
+ const fenced = /^:::[ \t]*(note|tip|important|warning|caution)(?:[ \t]+([^\n]*))?\n([\s\S]*?)\n:::[ \t]*(?:\n|$)/i.exec(src);
51
+ const alert = /^> ?\[!(note|tip|important|warning|caution)\][ \t]*\n((?:>[^\n]*(?:\n|$))*)/i.exec(src);
52
+ const match = fenced ?? alert;
53
+ if (!match)
54
+ return undefined;
55
+ const kind = match[1].toLowerCase();
56
+ const title = fenced ? (fenced[2]?.trim() ?? '') : '';
57
+ const content = fenced ? fenced[3] : alert[2].replace(/^> ?/gm, '');
58
+ return {
59
+ type: 'admonition',
60
+ raw: match[0],
61
+ kind,
62
+ titleTokens: title ? this.lexer.inlineTokens(title) : [],
63
+ tokens: this.lexer.blockTokens(content, []),
64
+ };
65
+ },
66
+ childTokens: ['titleTokens'],
67
+ renderer(token) {
68
+ const kind = token.kind;
69
+ const title = token.titleTokens.length
70
+ ? this.parser.parseInline(token.titleTokens)
71
+ : escapeHtml(admonitionLabels[kind] ?? DEFAULT_ADMONITION_LABELS[kind]);
72
+ return `<div class="ldt-alert ldt-alert--${ADMONITION_TONES[kind]} ldt-admonition" data-kind="${kind}" role="note"><p class="ldt-alert__title">${title}</p><div class="ldt-alert__description">${this.parser.parse(token.tokens)}</div></div>\n`;
73
+ },
74
+ };
75
+ const footnoteDefinition = {
76
+ name: 'footnoteDefinition',
77
+ level: 'block',
78
+ start: (src) => src.match(/^\[\^[^\]\s]+\]:/m)?.index,
79
+ tokenizer(src) {
80
+ const match = /^\[\^([^\]\s]+)\]:[ \t]*([^\n]*(?:\n(?: {2,}|\t)[^\n]*)*)(?:\n|$)/.exec(src);
81
+ if (!match)
82
+ return undefined;
83
+ const tokens = this.lexer.inlineTokens(match[2].replace(/\n\s+/g, ' '));
84
+ if (!footnoteDefinitions.has(match[1]))
85
+ footnoteDefinitions.set(match[1], tokens);
86
+ return { type: 'footnoteDefinition', raw: match[0], id: match[1], tokens };
87
+ },
88
+ renderer(token) {
89
+ footnoteHtml.set(token.id, this.parser.parseInline(token.tokens));
90
+ return '';
91
+ },
92
+ };
93
+ const footnoteReference = {
94
+ name: 'footnoteReference',
95
+ level: 'inline',
96
+ start: (src) => src.indexOf('[^'),
97
+ tokenizer(src) {
98
+ const match = /^\[\^([^\]\s]+)\](?!:)/.exec(src);
99
+ return match ? { type: 'footnoteReference', raw: match[0], id: match[1] } : undefined;
100
+ },
101
+ renderer(token) {
102
+ const id = token.id;
103
+ if (!footnoteDefinitions.has(id))
104
+ return escapeHtml(token.raw);
105
+ let number = footnoteNumbers.get(id);
106
+ if (number === undefined) {
107
+ number = footnoteNumbers.size + 1;
108
+ footnoteNumbers.set(id, number);
109
+ }
110
+ const safe = escapeHtml(slugOf(id));
111
+ return `<sup class="ldt-footnote-ref"><a id="${escapeHtml(idPrefix)}fnref-${safe}" href="#${escapeHtml(idPrefix)}fn-${safe}" aria-describedby="${escapeHtml(idPrefix)}footnotes-label">${number}</a></sup>`;
112
+ },
113
+ };
114
+ const definitionList = {
115
+ name: 'definitionList',
116
+ level: 'block',
117
+ start: (src) => src.match(/^[^\n]+\n:[ \t]/m)?.index,
118
+ tokenizer(src) {
119
+ const match = /^([^\n#>*+\-|`:\s][^\n]*)\n((?::[ \t]+[^\n]*(?:\n|$))+)/.exec(src);
120
+ if (!match || /^\d+[.)]\s/.test(match[1]))
121
+ return undefined;
122
+ const definitions = match[2]
123
+ .split('\n')
124
+ .filter((line) => line.startsWith(':'))
125
+ .map((line) => this.lexer.inlineTokens(line.replace(/^:[ \t]+/, '')));
126
+ return {
127
+ type: 'definitionList',
128
+ raw: match[0],
129
+ term: this.lexer.inlineTokens(match[1]),
130
+ definitions,
131
+ };
132
+ },
133
+ childTokens: ['term'],
134
+ renderer(token) {
135
+ const definitions = token.definitions
136
+ .map((tokens) => `<dd>${this.parser.parseInline(tokens)}</dd>`)
137
+ .join('');
138
+ return `<dl><dt>${this.parser.parseInline(token.term)}</dt>${definitions}</dl>\n`;
139
+ },
140
+ };
141
+ /** A footnote id made safe for an HTML id and a URL fragment. */
142
+ const slugOf = (id) => id.replace(/[^\p{L}\p{N}_-]/gu, '-');
143
+ const marked = new Marked({
144
+ gfm: true,
145
+ async: false,
146
+ extensions: [admonition, footnoteDefinition, footnoteReference, definitionList],
147
+ renderer: {
148
+ heading({ tokens, depth }) {
149
+ const inner = this.parser.parseInline(tokens);
150
+ const text = textOf(inner);
151
+ const id = slug(text);
152
+ const isTitle = stripTitle && depth === 1 && !headings.some((heading) => heading.depth === 1);
153
+ headings.push({ id, text, depth });
154
+ if (isTitle)
155
+ return '';
156
+ const content = headingLinks
157
+ ? `<a class="ldt-heading-anchor" href="#${escapeHtml(id)}">${inner}</a>`
158
+ : inner;
159
+ return `<h${depth} id="${escapeHtml(id)}">${content}</h${depth}>\n`;
160
+ },
161
+ code({ text, lang }) {
162
+ const { lang: language, filename } = fenceInfo(lang ?? '');
163
+ if (language === 'mermaid') {
164
+ // The source, until `Markdown` turns it into a picture; readable if it never does.
165
+ return `<pre class="ldt-mermaid" data-ldt-mermaid>${escapeHtml(text)}</pre>\n`;
166
+ }
167
+ codes.push({ text, lang: language });
168
+ const header = `<figcaption class="ldt-code__header">${filename ? `<span class="ldt-code__filename">${escapeHtml(filename)}</span>` : ''}<button type="button" class="ldt-code__copy" data-ldt-copy>${escapeHtml(copyLabel)}</button></figcaption>`;
169
+ return `<figure class="ldt-code"${language ? ` data-lang="${escapeHtml(language)}"` : ''}>${header}<pre class="ldt-code__pre" tabindex="0" aria-label="${escapeHtml(filename ?? codeLabel(language))}"><code${language ? ` class="language-${escapeHtml(language)}"` : ''}>${CODE_MARK(codes.length - 1)}</code></pre></figure>\n`;
170
+ },
171
+ html({ text }) {
172
+ return allowHtml ? text : escapeHtml(text);
173
+ },
174
+ link({ href, title, tokens }) {
175
+ const inner = this.parser.parseInline(tokens);
176
+ let url = safeUrl(href, 'link');
177
+ if (url === null)
178
+ return `<span class="ldt-unsafe-link">${inner}</span>`;
179
+ // Authors link to headings by their plain slug; with a prefix, the ids carry it too.
180
+ if (idPrefix && url.startsWith('#') && !url.startsWith(`#${idPrefix}`)) {
181
+ url = `#${idPrefix}${url.slice(1)}`;
182
+ }
183
+ const external = /^https?:\/\//i.test(url);
184
+ return `<a href="${escapeHtml(url)}"${title ? ` title="${escapeHtml(title)}"` : ''}${external ? ' rel="noopener"' : ''}>${inner}</a>`;
185
+ },
186
+ image({ href, title, text }) {
187
+ const url = safeUrl(href, 'image');
188
+ if (url === null)
189
+ return escapeHtml(text);
190
+ return `<img src="${escapeHtml(url)}" alt="${escapeHtml(text)}"${title ? ` title="${escapeHtml(title)}"` : ''} loading="lazy">`;
191
+ },
192
+ checkbox({ checked }) {
193
+ return `<input type="checkbox" disabled${checked ? ' checked' : ''} aria-label="${escapeHtml(checked ? taskLabels.done : taskLabels.open)}"> `;
194
+ },
195
+ },
196
+ });
197
+ let html = marked.parse(body);
198
+ if (footnoteNumbers.size) {
199
+ const items = [...footnoteNumbers.entries()]
200
+ .sort(([, a], [, b]) => a - b)
201
+ .map(([id, number]) => {
202
+ const safe = escapeHtml(slugOf(id));
203
+ return `<li id="${escapeHtml(idPrefix)}fn-${safe}">${footnoteHtml.get(id) ?? ''} <a class="ldt-footnote-back" href="#${escapeHtml(idPrefix)}fnref-${safe}" aria-label="${escapeHtml(footnoteBackLabel(number))}">↩</a></li>`;
204
+ })
205
+ .join('');
206
+ html += `<section class="ldt-footnotes" aria-labelledby="${escapeHtml(idPrefix)}footnotes-label"><h2 class="ldt-sr-only" id="${escapeHtml(idPrefix)}footnotes-label">${escapeHtml(footnotesLabel)}</h2><ol>${items}</ol></section>\n`;
207
+ }
208
+ return { html, codes, headings, frontmatter };
209
+ }
210
+ function finish(parsed, codeHtml, options) {
211
+ const [min, max] = options.tocDepth ?? [2, 3];
212
+ let html = parsed.html.replace(/\uE000ldt-code-(\d+)\uE001/g, (_, index) => {
213
+ const code = parsed.codes[Number(index)];
214
+ return codeHtml[Number(index)] ?? escapeHtml(code.text);
215
+ });
216
+ if (options.sanitize)
217
+ html = options.sanitize(html);
218
+ const title = parsed.frontmatter?.title;
219
+ return {
220
+ html,
221
+ toc: parsed.headings.filter((heading) => heading.depth >= min && heading.depth <= max),
222
+ headings: parsed.headings,
223
+ frontmatter: parsed.frontmatter,
224
+ title: typeof title === 'string'
225
+ ? title
226
+ : (parsed.headings.find((heading) => heading.depth === 1)?.text ?? null),
227
+ highlighted: codeHtml.some((code) => code !== null),
228
+ };
229
+ }
230
+ /**
231
+ * Renders markdown without syntax highlighting — synchronous, for a first render or wherever
232
+ * nothing can be awaited. Code blocks are escaped plain text in the same frame.
233
+ */
234
+ export function renderMarkdownSync(source, options = {}) {
235
+ const parsed = parse(source, options);
236
+ return finish(parsed, parsed.codes.map(() => null), options);
237
+ }
238
+ /**
239
+ * Renders markdown to HTML with a table of contents, frontmatter and highlighted code. Safe for
240
+ * untrusted markdown by default: raw HTML is shown as text, and links and images only keep
241
+ * safe URLs. Works on the server — await it in a SvelteKit `load` to prerender pages.
242
+ */
243
+ export async function renderMarkdown(source, options = {}) {
244
+ const parsed = parse(source, options);
245
+ let highlight = null;
246
+ if (typeof options.highlight === 'function')
247
+ highlight = options.highlight;
248
+ else if (options.highlight !== false)
249
+ highlight = await loadHighlighter();
250
+ const codeHtml = highlight
251
+ ? await Promise.all(parsed.codes.map(async ({ text, lang }) => {
252
+ try {
253
+ return (await highlight(text, lang)) ?? null;
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }))
259
+ : parsed.codes.map(() => null);
260
+ return finish(parsed, codeHtml, options);
261
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A URL fragment from heading text: lower-cased, with letters and digits of any script kept and
3
+ * everything else collapsed to single hyphens. "Café & Crème" → `café-crème`.
4
+ */
5
+ export declare function slugify(text: string): string;
6
+ /** Hands out unique slugs: the second "Usage" becomes `usage-2`. */
7
+ export declare function createSlugger(prefix?: string): (text: string) => string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A URL fragment from heading text: lower-cased, with letters and digits of any script kept and
3
+ * everything else collapsed to single hyphens. "Café & Crème" → `café-crème`.
4
+ */
5
+ export function slugify(text) {
6
+ return text
7
+ .normalize('NFKC')
8
+ .toLowerCase()
9
+ .replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
10
+ .replace(/^-+|-+$/g, '');
11
+ }
12
+ /** Hands out unique slugs: the second "Usage" becomes `usage-2`. */
13
+ export function createSlugger(prefix = '') {
14
+ const seen = new Map();
15
+ return (text) => {
16
+ const base = slugify(text) || 'section';
17
+ const count = (seen.get(base) ?? 0) + 1;
18
+ seen.set(base, count);
19
+ return `${prefix}${count === 1 ? base : `${base}-${count}`}`;
20
+ };
21
+ }
@@ -0,0 +1,83 @@
1
+ import type { Frontmatter } from './frontmatter.js';
2
+ /** One heading, for a table of contents. */
3
+ export interface TocItem {
4
+ id: string;
5
+ /** The heading's text, without markup. */
6
+ text: string;
7
+ /** 1 for `#`, 2 for `##`, … */
8
+ depth: number;
9
+ }
10
+ /** What rendering a markdown document produces. */
11
+ export interface RenderedMarkdown {
12
+ html: string;
13
+ /** The headings a table of contents should list (see `tocDepth`). */
14
+ toc: TocItem[];
15
+ /** Every heading, at any depth. */
16
+ headings: TocItem[];
17
+ frontmatter: Frontmatter | null;
18
+ /** The frontmatter `title`, else the first level-1 heading. */
19
+ title: string | null;
20
+ /** Whether code blocks were syntax-highlighted. */
21
+ highlighted: boolean;
22
+ }
23
+ /** Highlights one block of code, returning the HTML to go inside its `<code>`, or `null` to leave it plain. */
24
+ export type HighlightFunction = (code: string, lang: string) => Promise<string | null> | string | null;
25
+ export type AdmonitionKind = 'note' | 'tip' | 'important' | 'warning' | 'caution';
26
+ export interface MarkdownOptions {
27
+ /**
28
+ * Pass raw HTML in the markdown through. Off by default, when it is shown as text: turn it on
29
+ * only for markdown you wrote, or with a `sanitize` hook.
30
+ */
31
+ allowHtml?: boolean;
32
+ /** Runs last, on the finished HTML — plug a sanitizer such as DOMPurify in here. */
33
+ sanitize?: (html: string) => string;
34
+ /** Prepended to every generated id, so two documents on one page cannot collide. */
35
+ idPrefix?: string;
36
+ /** Make each heading a link to itself, for sharing a section. */
37
+ headingLinks?: boolean;
38
+ /**
39
+ * Leave the first `#` heading out of the HTML (it is still the `title` and in `headings`) —
40
+ * for a page that shows the title itself, as `MarkdownPage` does.
41
+ */
42
+ stripTitle?: boolean;
43
+ /** The heading depths the table of contents lists. Defaults to `[2, 3]`. */
44
+ tocDepth?: [number, number];
45
+ /**
46
+ * Highlight code blocks. Defaults to the registered Shiki loader when there is one
47
+ * (`setHighlighterLoader`); `false` never highlights; or pass your own function.
48
+ */
49
+ highlight?: boolean | HighlightFunction;
50
+ /** Replace the built-in frontmatter parser, e.g. with a full YAML library. */
51
+ parseFrontmatter?: (text: string) => Frontmatter;
52
+ /** Headings for admonitions, by kind. */
53
+ admonitionLabels?: Partial<Record<AdmonitionKind, string>>;
54
+ copyLabel?: string;
55
+ /** Names a code block for assistive tech. */
56
+ codeLabel?: (lang: string) => string;
57
+ /** How a task list's checkboxes are named: done, and not yet done. */
58
+ taskLabels?: {
59
+ done: string;
60
+ open: string;
61
+ };
62
+ footnotesLabel?: string;
63
+ footnoteBackLabel?: (number: number) => string;
64
+ }
65
+ /** A page in a docs index. */
66
+ export interface DocsItem {
67
+ title: string;
68
+ href: string;
69
+ description?: string;
70
+ eyebrow?: string;
71
+ external?: boolean;
72
+ }
73
+ /** A group of pages in a docs index. */
74
+ export interface DocsSection {
75
+ title: string;
76
+ description?: string;
77
+ items: DocsItem[];
78
+ }
79
+ /** A neighbouring page, for previous and next links. */
80
+ export interface DocsLink {
81
+ title: string;
82
+ href: string;
83
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Whether a URL is safe to put in a link or image, and the URL if so. Relative URLs, fragments
3
+ * and `http(s)`, `mailto` and `tel` pass; images also take raster `data:` URLs. Everything else —
4
+ * `javascript:`, `vbscript:`, `data:text/html`, anything unfamiliar — is refused, since markdown
5
+ * from anywhere but your own repository can carry it.
6
+ */
7
+ export declare function safeUrl(url: string, kind?: 'link' | 'image'): string | null;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whether a URL is safe to put in a link or image, and the URL if so. Relative URLs, fragments
3
+ * and `http(s)`, `mailto` and `tel` pass; images also take raster `data:` URLs. Everything else —
4
+ * `javascript:`, `vbscript:`, `data:text/html`, anything unfamiliar — is refused, since markdown
5
+ * from anywhere but your own repository can carry it.
6
+ */
7
+ export function safeUrl(url, kind = 'link') {
8
+ // Browsers ignore control characters and whitespace inside a scheme; so must the check.
9
+ const probe = [...url]
10
+ .filter((char) => {
11
+ const code = char.codePointAt(0);
12
+ return code > 0x20 && (code < 0x7f || code > 0x9f);
13
+ })
14
+ .join('')
15
+ .toLowerCase();
16
+ const scheme = /^([a-z][a-z\d+.-]*):/.exec(probe)?.[1];
17
+ if (!scheme)
18
+ return url;
19
+ if (['http', 'https'].includes(scheme))
20
+ return url;
21
+ if (kind === 'link' && ['mailto', 'tel'].includes(scheme))
22
+ return url;
23
+ if (kind === 'image' && /^data:image\/(png|jpe?g|gif|webp|avif);/.test(probe))
24
+ return url;
25
+ return null;
26
+ }
@@ -0,0 +1,9 @@
1
+ export { default as CodeSnippet } from './components/CodeSnippet.svelte';
2
+ export { default as DocsCard } from './components/DocsCard.svelte';
3
+ export { default as DocsHub } from './components/DocsHub.svelte';
4
+ export { default as Markdown } from './components/Markdown.svelte';
5
+ export { default as MarkdownPage } from './components/MarkdownPage.svelte';
6
+ export { default as MermaidDiagram } from './components/MermaidDiagram.svelte';
7
+ export { default as TableOfContents } from './components/TableOfContents.svelte';
8
+ export { default as TocPanel } from './components/TocPanel.svelte';
9
+ export * from './core/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { default as CodeSnippet } from './components/CodeSnippet.svelte';
2
+ export { default as DocsCard } from './components/DocsCard.svelte';
3
+ export { default as DocsHub } from './components/DocsHub.svelte';
4
+ export { default as Markdown } from './components/Markdown.svelte';
5
+ export { default as MarkdownPage } from './components/MarkdownPage.svelte';
6
+ export { default as MermaidDiagram } from './components/MermaidDiagram.svelte';
7
+ export { default as TableOfContents } from './components/TableOfContents.svelte';
8
+ export { default as TocPanel } from './components/TocPanel.svelte';
9
+ export * from './core/index.js';
@@ -0,0 +1,42 @@
1
+ /** Theme colours a diagram is drawn with, as Mermaid's `themeVariables`. */
2
+ export interface DiagramColors {
3
+ background: string;
4
+ surface: string;
5
+ surfaceAlt: string;
6
+ text: string;
7
+ muted: string;
8
+ border: string;
9
+ accent: string;
10
+ }
11
+ export declare const diagramRoles: {
12
+ readonly background: "background";
13
+ readonly surface: "surface";
14
+ readonly surfaceAlt: "surfaceAlt";
15
+ readonly text: "text";
16
+ readonly muted: "textMuted";
17
+ readonly border: "borderStrong";
18
+ readonly accent: "accent";
19
+ };
20
+ export declare const themeVariables: (colors: DiagramColors) => Record<string, string>;
21
+ /**
22
+ * Draws a diagram into `target` from `source`. Leaves `target` alone — still showing the source —
23
+ * when Mermaid is not available or the source does not parse.
24
+ */
25
+ export declare function drawDiagram(target: HTMLElement, source: string, colors: DiagramColors): Promise<boolean>;
26
+ /**
27
+ * Turns every `<pre data-ldt-mermaid>` in `root` into a figure: the drawn diagram, named for
28
+ * assistive tech, with its source kept in a disclosure beneath. Redraws figures it already made
29
+ * (for a theme change).
30
+ */
31
+ export declare function enhanceDiagrams(root: HTMLElement, colors: DiagramColors, { label, sourceLabel }: {
32
+ label: string;
33
+ sourceLabel: string;
34
+ }): Promise<void>;
35
+ /**
36
+ * Copies a code block's text when its copy button is pressed, and says so on the button for a
37
+ * moment. One listener serves every block in `root`.
38
+ */
39
+ export declare function handleCopy(root: HTMLElement, { copiedLabel, onCopied }: {
40
+ copiedLabel: string;
41
+ onCopied?: (text: string) => void;
42
+ }): () => void;
@@ -0,0 +1,96 @@
1
+ import { loadMermaid, renderMermaid } from '../core/mermaid.js';
2
+ import { escapeHtml } from '../core/escape.js';
3
+ export const diagramRoles = {
4
+ background: 'background',
5
+ surface: 'surface',
6
+ surfaceAlt: 'surfaceAlt',
7
+ text: 'text',
8
+ muted: 'textMuted',
9
+ border: 'borderStrong',
10
+ accent: 'accent',
11
+ };
12
+ export const themeVariables = (colors) => ({
13
+ background: colors.background,
14
+ primaryColor: colors.surfaceAlt,
15
+ primaryTextColor: colors.text,
16
+ primaryBorderColor: colors.border,
17
+ secondaryColor: colors.surface,
18
+ tertiaryColor: colors.background,
19
+ lineColor: colors.muted,
20
+ textColor: colors.text,
21
+ mainBkg: colors.surfaceAlt,
22
+ nodeBorder: colors.border,
23
+ clusterBkg: colors.surface,
24
+ noteBkgColor: colors.surface,
25
+ noteTextColor: colors.text,
26
+ edgeLabelBackground: colors.background,
27
+ fontSize: '14px',
28
+ });
29
+ let counter = 0;
30
+ /**
31
+ * Draws a diagram into `target` from `source`. Leaves `target` alone — still showing the source —
32
+ * when Mermaid is not available or the source does not parse.
33
+ */
34
+ export async function drawDiagram(target, source, colors) {
35
+ const api = await loadMermaid();
36
+ if (!api)
37
+ return false;
38
+ try {
39
+ counter += 1;
40
+ const svg = await renderMermaid(api, `ldt-mermaid-${counter}`, source, themeVariables(colors));
41
+ target.innerHTML = svg;
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ /**
49
+ * Turns every `<pre data-ldt-mermaid>` in `root` into a figure: the drawn diagram, named for
50
+ * assistive tech, with its source kept in a disclosure beneath. Redraws figures it already made
51
+ * (for a theme change).
52
+ */
53
+ export async function enhanceDiagrams(root, colors, { label, sourceLabel }) {
54
+ for (const pre of root.querySelectorAll('pre[data-ldt-mermaid]')) {
55
+ const source = pre.textContent ?? '';
56
+ const figure = document.createElement('figure');
57
+ figure.className = 'ldt-mermaid';
58
+ figure.dataset.ldtMermaidSource = source;
59
+ figure.innerHTML = `<div class="ldt-mermaid__diagram" role="img" aria-label="${escapeHtml(label)}"></div><details class="ldt-mermaid__source"><summary>${escapeHtml(sourceLabel)}</summary><pre>${escapeHtml(source)}</pre></details>`;
60
+ const diagram = figure.firstElementChild;
61
+ if (await drawDiagram(diagram, source, colors))
62
+ pre.replaceWith(figure);
63
+ }
64
+ for (const figure of root.querySelectorAll('figure[data-ldt-mermaid-source]')) {
65
+ const diagram = figure.querySelector('.ldt-mermaid__diagram');
66
+ if (diagram)
67
+ await drawDiagram(diagram, figure.dataset.ldtMermaidSource ?? '', colors);
68
+ }
69
+ }
70
+ /**
71
+ * Copies a code block's text when its copy button is pressed, and says so on the button for a
72
+ * moment. One listener serves every block in `root`.
73
+ */
74
+ export function handleCopy(root, { copiedLabel, onCopied }) {
75
+ const listener = async (event) => {
76
+ const button = event.target?.closest('[data-ldt-copy]');
77
+ if (!button || !root.contains(button))
78
+ return;
79
+ const code = button.closest('.ldt-code')?.querySelector('code')?.textContent ?? '';
80
+ try {
81
+ await navigator.clipboard.writeText(code);
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ const original = button.dataset.label ?? button.textContent ?? '';
87
+ button.dataset.label = original;
88
+ button.textContent = copiedLabel;
89
+ onCopied?.(code);
90
+ setTimeout(() => {
91
+ button.textContent = original;
92
+ }, 2000);
93
+ };
94
+ root.addEventListener('click', listener);
95
+ return () => root.removeEventListener('click', listener);
96
+ }
package/package.json CHANGED
@@ -1,6 +1,77 @@
1
1
  {
2
2
  "name": "@loidolt/theme-docs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.8.0",
4
+ "description": "Accessible Markdown documentation components for Loidolt applications",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "svelte": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "svelte": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./core": {
16
+ "types": "./dist/core/index.d.ts",
17
+ "default": "./dist/core/index.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "sideEffects": false,
25
+ "scripts": {
26
+ "build": "svelte-package -i src/lib -o dist",
27
+ "prepack": "npm run build",
28
+ "typecheck": "svelte-check --tsconfig ./tsconfig.json",
29
+ "test": "vitest run && vitest run --config vitest.config.ssr.ts",
30
+ "test:coverage": "vitest run --coverage",
31
+ "clean": "rimraf dist"
32
+ },
33
+ "dependencies": {
34
+ "@loidolt/theme-svelte": "^0.8.0",
35
+ "@loidolt/theme-tokens": "^0.8.0",
36
+ "marked": "^18.0.0"
37
+ },
38
+ "peerDependencies": {
39
+ "mermaid": "^11.0.0 || ^12.0.0",
40
+ "shiki": "^3.0.0 || ^4.0.0",
41
+ "svelte": "^5.33.0"
42
+ },
43
+ "devDependencies": {
44
+ "@sveltejs/package": "^2.5.8",
45
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
46
+ "@testing-library/jest-dom": "^6.8.0",
47
+ "@testing-library/svelte": "^5.2.8",
48
+ "@testing-library/user-event": "^14.6.1",
49
+ "@types/node": "^24.3.0",
50
+ "@vitest/coverage-v8": "^4.1.11",
51
+ "jsdom": "^27.0.0",
52
+ "rimraf": "^6.0.1",
53
+ "shiki": "^4.0.0",
54
+ "svelte": "^5.56.10",
55
+ "svelte-check": "^4.3.2",
56
+ "typescript": "^6.0.3",
57
+ "vitest": "^4.0.0",
58
+ "vitest-axe": "^0.1.0"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public",
62
+ "provenance": true
63
+ },
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "git+https://github.com/loidolt/loidolt-theme.git",
67
+ "directory": "packages/docs"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "mermaid": {
71
+ "optional": true
72
+ },
73
+ "shiki": {
74
+ "optional": true
75
+ }
76
+ }
77
+ }