@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.
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/dist/components/CodeSnippet.svelte +86 -0
- package/dist/components/CodeSnippet.svelte.d.ts +21 -0
- package/dist/components/DocsCard.svelte +50 -0
- package/dist/components/DocsCard.svelte.d.ts +19 -0
- package/dist/components/DocsHub.svelte +51 -0
- package/dist/components/DocsHub.svelte.d.ts +14 -0
- package/dist/components/Markdown.svelte +90 -0
- package/dist/components/Markdown.svelte.d.ts +23 -0
- package/dist/components/MarkdownPage.svelte +121 -0
- package/dist/components/MarkdownPage.svelte.d.ts +34 -0
- package/dist/components/MermaidDiagram.svelte +75 -0
- package/dist/components/MermaidDiagram.svelte.d.ts +16 -0
- package/dist/components/TableOfContents.svelte +84 -0
- package/dist/components/TableOfContents.svelte.d.ts +24 -0
- package/dist/components/TocPanel.svelte +56 -0
- package/dist/components/TocPanel.svelte.d.ts +19 -0
- package/dist/core/escape.d.ts +6 -0
- package/dist/core/escape.js +16 -0
- package/dist/core/frontmatter.d.ts +11 -0
- package/dist/core/frontmatter.js +110 -0
- package/dist/core/highlight.d.ts +32 -0
- package/dist/core/highlight.js +52 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.js +12 -0
- package/dist/core/mermaid.d.ts +17 -0
- package/dist/core/mermaid.js +49 -0
- package/dist/core/render.d.ts +12 -0
- package/dist/core/render.js +261 -0
- package/dist/core/slug.d.ts +7 -0
- package/dist/core/slug.js +21 -0
- package/dist/core/types.d.ts +83 -0
- package/dist/core/types.js +1 -0
- package/dist/core/url.d.ts +7 -0
- package/dist/core/url.js +26 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/internal/enhance.d.ts +42 -0
- package/dist/internal/enhance.js +96 -0
- package/package.json +75 -4
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import { createTokenColors, cx } from '@loidolt/theme-svelte';
|
|
5
|
+
import { diagramRoles, drawDiagram } from '../internal/enhance.js';
|
|
6
|
+
|
|
7
|
+
interface Props extends HTMLAttributes<HTMLElement> {
|
|
8
|
+
/** Mermaid source. */
|
|
9
|
+
code: string;
|
|
10
|
+
/** Names the diagram — say what it shows, e.g. "Order lifecycle". */
|
|
11
|
+
label: string;
|
|
12
|
+
/** What it shows, in words, for readers who cannot see it. Shown under the diagram. */
|
|
13
|
+
description?: string;
|
|
14
|
+
/** Labels the disclosure holding the source. */
|
|
15
|
+
sourceLabel?: string;
|
|
16
|
+
class?: string;
|
|
17
|
+
ref?: HTMLElement | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
code,
|
|
22
|
+
label,
|
|
23
|
+
description,
|
|
24
|
+
sourceLabel = 'Diagram source',
|
|
25
|
+
class: className,
|
|
26
|
+
ref = $bindable(null),
|
|
27
|
+
...rest
|
|
28
|
+
}: Props = $props();
|
|
29
|
+
|
|
30
|
+
const id = $props.id();
|
|
31
|
+
const palette = createTokenColors(diagramRoles, { element: () => ref });
|
|
32
|
+
let diagram = $state<HTMLDivElement | null>(null);
|
|
33
|
+
let drawn = $state(false);
|
|
34
|
+
|
|
35
|
+
$effect(() => {
|
|
36
|
+
if (ref) untrack(() => palette.refresh());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Drawn in the browser, and again whenever the source or the theme changes.
|
|
40
|
+
$effect(() => {
|
|
41
|
+
const [target, source, colors] = [diagram, code, palette.colors];
|
|
42
|
+
if (!target) return;
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
void drawDiagram(target, source, colors).then((ok) => {
|
|
45
|
+
if (!cancelled) drawn = ok;
|
|
46
|
+
});
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<figure bind:this={ref} class={cx('ldt-mermaid', className)} {...rest}>
|
|
54
|
+
<div
|
|
55
|
+
bind:this={diagram}
|
|
56
|
+
class="ldt-mermaid__diagram"
|
|
57
|
+
role="img"
|
|
58
|
+
aria-label={label}
|
|
59
|
+
aria-describedby={description ? `${id}-description` : undefined}
|
|
60
|
+
hidden={!drawn}
|
|
61
|
+
></div>
|
|
62
|
+
{#if !drawn}
|
|
63
|
+
<!-- Until the diagram is drawn — or if Mermaid is unavailable — the source stands in. -->
|
|
64
|
+
<pre class="ldt-mermaid__fallback">{code}</pre>
|
|
65
|
+
{/if}
|
|
66
|
+
{#if description}<figcaption id={`${id}-description`} class="ldt-mermaid__caption">
|
|
67
|
+
{description}
|
|
68
|
+
</figcaption>{/if}
|
|
69
|
+
{#if drawn}
|
|
70
|
+
<details class="ldt-mermaid__source">
|
|
71
|
+
<summary>{sourceLabel}</summary>
|
|
72
|
+
<pre>{code}</pre>
|
|
73
|
+
</details>
|
|
74
|
+
{/if}
|
|
75
|
+
</figure>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
interface Props extends HTMLAttributes<HTMLElement> {
|
|
3
|
+
/** Mermaid source. */
|
|
4
|
+
code: string;
|
|
5
|
+
/** Names the diagram — say what it shows, e.g. "Order lifecycle". */
|
|
6
|
+
label: string;
|
|
7
|
+
/** What it shows, in words, for readers who cannot see it. Shown under the diagram. */
|
|
8
|
+
description?: string;
|
|
9
|
+
/** Labels the disclosure holding the source. */
|
|
10
|
+
sourceLabel?: string;
|
|
11
|
+
class?: string;
|
|
12
|
+
ref?: HTMLElement | null;
|
|
13
|
+
}
|
|
14
|
+
declare const MermaidDiagram: import("svelte").Component<Props, {}, "ref">;
|
|
15
|
+
type MermaidDiagram = ReturnType<typeof MermaidDiagram>;
|
|
16
|
+
export default MermaidDiagram;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { cx } from '@loidolt/theme-svelte';
|
|
4
|
+
import type { TocItem } from '../core/types.js';
|
|
5
|
+
|
|
6
|
+
interface Props extends HTMLAttributes<HTMLElement> {
|
|
7
|
+
/** The headings to list — `toc` from `renderMarkdown`. */
|
|
8
|
+
items: TocItem[];
|
|
9
|
+
/** Names the list; shown above it. */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** Where the headings are. Scroll tracking only looks inside it. Defaults to the document. */
|
|
12
|
+
container?: HTMLElement | null;
|
|
13
|
+
/** The heading being read. Bindable; follows scrolling. */
|
|
14
|
+
activeId?: string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Space at the top of the viewport that hides content, e.g. a sticky header, in pixels. A
|
|
17
|
+
* heading counts as current once it clears it.
|
|
18
|
+
*/
|
|
19
|
+
offset?: number;
|
|
20
|
+
/** A link was followed — `TocPanel` closes its drawer on this. */
|
|
21
|
+
onNavigate?: (item: TocItem) => void;
|
|
22
|
+
class?: string;
|
|
23
|
+
ref?: HTMLElement | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
items,
|
|
28
|
+
title = 'On this page',
|
|
29
|
+
container = null,
|
|
30
|
+
activeId = $bindable(null),
|
|
31
|
+
offset = 72,
|
|
32
|
+
onNavigate,
|
|
33
|
+
class: className,
|
|
34
|
+
ref = $bindable(null),
|
|
35
|
+
...rest
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
|
|
38
|
+
const id = $props.id();
|
|
39
|
+
const shallowest = $derived(Math.min(...items.map((item) => item.depth)));
|
|
40
|
+
|
|
41
|
+
// Scroll-spy: a heading is current while it sits in a band near the top of the viewport; between
|
|
42
|
+
// headings — deep in a long section — the last one stays current.
|
|
43
|
+
$effect(() => {
|
|
44
|
+
const scope: Document | HTMLElement = container ?? document;
|
|
45
|
+
const targets = items
|
|
46
|
+
.map((item) => scope.querySelector<HTMLElement>(`#${CSS.escape(item.id)}`))
|
|
47
|
+
.filter((element): element is HTMLElement => element !== null);
|
|
48
|
+
if (!targets.length || typeof IntersectionObserver === 'undefined') return;
|
|
49
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- observer bookkeeping, never rendered
|
|
50
|
+
const inBand = new Set<string>();
|
|
51
|
+
const observer = new IntersectionObserver(
|
|
52
|
+
(entries) => {
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (entry.isIntersecting) inBand.add(entry.target.id);
|
|
55
|
+
else inBand.delete(entry.target.id);
|
|
56
|
+
}
|
|
57
|
+
const first = targets.find((target) => inBand.has(target.id));
|
|
58
|
+
if (first) activeId = first.id;
|
|
59
|
+
},
|
|
60
|
+
{ rootMargin: `-${offset}px 0px -60% 0px` }
|
|
61
|
+
);
|
|
62
|
+
for (const target of targets) observer.observe(target);
|
|
63
|
+
return () => observer.disconnect();
|
|
64
|
+
});
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<nav bind:this={ref} class={cx('ldt-toc', className)} aria-labelledby={`${id}-title`} {...rest}>
|
|
68
|
+
<p class="ldt-toc__title" id={`${id}-title`}>{title}</p>
|
|
69
|
+
<ol class="ldt-toc__list">
|
|
70
|
+
{#each items as item (item.id)}
|
|
71
|
+
<li class="ldt-toc__item" style:--ldt-toc-level={item.depth - shallowest}>
|
|
72
|
+
<a
|
|
73
|
+
class="ldt-toc__link"
|
|
74
|
+
href={`#${item.id}`}
|
|
75
|
+
aria-current={activeId === item.id ? 'location' : undefined}
|
|
76
|
+
onclick={() => {
|
|
77
|
+
activeId = item.id;
|
|
78
|
+
onNavigate?.(item);
|
|
79
|
+
}}>{item.text}</a
|
|
80
|
+
>
|
|
81
|
+
</li>
|
|
82
|
+
{/each}
|
|
83
|
+
</ol>
|
|
84
|
+
</nav>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
import type { TocItem } from '../core/types.js';
|
|
3
|
+
interface Props extends HTMLAttributes<HTMLElement> {
|
|
4
|
+
/** The headings to list — `toc` from `renderMarkdown`. */
|
|
5
|
+
items: TocItem[];
|
|
6
|
+
/** Names the list; shown above it. */
|
|
7
|
+
title?: string;
|
|
8
|
+
/** Where the headings are. Scroll tracking only looks inside it. Defaults to the document. */
|
|
9
|
+
container?: HTMLElement | null;
|
|
10
|
+
/** The heading being read. Bindable; follows scrolling. */
|
|
11
|
+
activeId?: string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Space at the top of the viewport that hides content, e.g. a sticky header, in pixels. A
|
|
14
|
+
* heading counts as current once it clears it.
|
|
15
|
+
*/
|
|
16
|
+
offset?: number;
|
|
17
|
+
/** A link was followed — `TocPanel` closes its drawer on this. */
|
|
18
|
+
onNavigate?: (item: TocItem) => void;
|
|
19
|
+
class?: string;
|
|
20
|
+
ref?: HTMLElement | null;
|
|
21
|
+
}
|
|
22
|
+
declare const TableOfContents: import("svelte").Component<Props, {}, "ref" | "activeId">;
|
|
23
|
+
type TableOfContents = ReturnType<typeof TableOfContents>;
|
|
24
|
+
export default TableOfContents;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { Drawer, cx } from '@loidolt/theme-svelte';
|
|
4
|
+
import type { TocItem } from '../core/types.js';
|
|
5
|
+
import TableOfContents from './TableOfContents.svelte';
|
|
6
|
+
|
|
7
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
items: TocItem[];
|
|
9
|
+
title?: string;
|
|
10
|
+
/** Where the headings are; see `TableOfContents`. */
|
|
11
|
+
container?: HTMLElement | null;
|
|
12
|
+
/** The heading being read. Bindable. */
|
|
13
|
+
activeId?: string | null;
|
|
14
|
+
offset?: number;
|
|
15
|
+
/** On narrow screens the contents open from a button with this label. */
|
|
16
|
+
openLabel?: string;
|
|
17
|
+
closeLabel?: string;
|
|
18
|
+
class?: string;
|
|
19
|
+
ref?: HTMLDivElement | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
items,
|
|
24
|
+
title = 'On this page',
|
|
25
|
+
container = null,
|
|
26
|
+
activeId = $bindable(null),
|
|
27
|
+
offset,
|
|
28
|
+
openLabel = 'On this page',
|
|
29
|
+
closeLabel,
|
|
30
|
+
class: className,
|
|
31
|
+
ref = $bindable(null),
|
|
32
|
+
...rest
|
|
33
|
+
}: Props = $props();
|
|
34
|
+
|
|
35
|
+
let open = $state(false);
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<!-- Both forms render on the server; CSS picks one by width, so nothing jumps on hydration. -->
|
|
39
|
+
<div bind:this={ref} class={cx('ldt-toc-panel', className)} {...rest}>
|
|
40
|
+
<div class="ldt-toc-panel__wide">
|
|
41
|
+
<TableOfContents {items} {title} {container} {offset} bind:activeId />
|
|
42
|
+
</div>
|
|
43
|
+
<div class="ldt-toc-panel__compact">
|
|
44
|
+
<Drawer bind:open {title} side="right" triggerVariant="quiet" triggerSize="sm" {closeLabel}>
|
|
45
|
+
{#snippet trigger()}{openLabel}{/snippet}
|
|
46
|
+
<TableOfContents
|
|
47
|
+
{items}
|
|
48
|
+
{title}
|
|
49
|
+
{container}
|
|
50
|
+
{offset}
|
|
51
|
+
bind:activeId
|
|
52
|
+
onNavigate={() => (open = false)}
|
|
53
|
+
/>
|
|
54
|
+
</Drawer>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
import type { TocItem } from '../core/types.js';
|
|
3
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
4
|
+
items: TocItem[];
|
|
5
|
+
title?: string;
|
|
6
|
+
/** Where the headings are; see `TableOfContents`. */
|
|
7
|
+
container?: HTMLElement | null;
|
|
8
|
+
/** The heading being read. Bindable. */
|
|
9
|
+
activeId?: string | null;
|
|
10
|
+
offset?: number;
|
|
11
|
+
/** On narrow screens the contents open from a button with this label. */
|
|
12
|
+
openLabel?: string;
|
|
13
|
+
closeLabel?: string;
|
|
14
|
+
class?: string;
|
|
15
|
+
ref?: HTMLDivElement | null;
|
|
16
|
+
}
|
|
17
|
+
declare const TocPanel: import("svelte").Component<Props, {}, "ref" | "activeId">;
|
|
18
|
+
type TocPanel = ReturnType<typeof TocPanel>;
|
|
19
|
+
export default TocPanel;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Text made safe to place in HTML, as content or inside a quoted attribute. */
|
|
2
|
+
export declare const escapeHtml: (value: string) => string;
|
|
3
|
+
/** HTML entities back to text — for turning rendered heading HTML into plain text. */
|
|
4
|
+
export declare const decodeEntities: (value: string) => string;
|
|
5
|
+
/** The text of an HTML fragment, tags dropped and entities decoded. */
|
|
6
|
+
export declare const textOf: (html: string) => string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const ENTITIES = {
|
|
2
|
+
'&': '&',
|
|
3
|
+
'<': '<',
|
|
4
|
+
'>': '>',
|
|
5
|
+
'"': '"',
|
|
6
|
+
"'": ''',
|
|
7
|
+
};
|
|
8
|
+
/** Text made safe to place in HTML, as content or inside a quoted attribute. */
|
|
9
|
+
export const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ENTITIES[char]);
|
|
10
|
+
/** HTML entities back to text — for turning rendered heading HTML into plain text. */
|
|
11
|
+
export const decodeEntities = (value) => value
|
|
12
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
13
|
+
.replace(/&#x([\da-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)))
|
|
14
|
+
.replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, name) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' })[name] ?? '');
|
|
15
|
+
/** The text of an HTML fragment, tags dropped and entities decoded. */
|
|
16
|
+
export const textOf = (html) => decodeEntities(html.replace(/<[^>]*>/g, '')).trim();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type FrontmatterValue = string | number | boolean | null | FrontmatterValue[] | {
|
|
2
|
+
[key: string]: FrontmatterValue;
|
|
3
|
+
};
|
|
4
|
+
export type Frontmatter = Record<string, FrontmatterValue>;
|
|
5
|
+
/** Splits a leading `---` block from the body. */
|
|
6
|
+
export declare function splitFrontmatter(source: string): {
|
|
7
|
+
frontmatter: string | null;
|
|
8
|
+
body: string;
|
|
9
|
+
};
|
|
10
|
+
/** Parses the YAML subset described above. Throws a `RangeError` naming the line. */
|
|
11
|
+
export declare function parseFrontmatter(text: string): Frontmatter;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Frontmatter: the `---` block at the top of a markdown file. Parsed with a small, strict
|
|
3
|
+
* subset of YAML — strings, numbers, booleans, null, lists and one level of nested maps —
|
|
4
|
+
* which covers what documentation pages carry. Anything else is an error with its line
|
|
5
|
+
* number, rather than a silent misreading. Pass `parseFrontmatter` to use a full YAML parser.
|
|
6
|
+
*/
|
|
7
|
+
/** Splits a leading `---` block from the body. */
|
|
8
|
+
export function splitFrontmatter(source) {
|
|
9
|
+
const match = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(source);
|
|
10
|
+
return match
|
|
11
|
+
? { frontmatter: match[1], body: source.slice(match[0].length) }
|
|
12
|
+
: { frontmatter: null, body: source };
|
|
13
|
+
}
|
|
14
|
+
function scalar(raw, line) {
|
|
15
|
+
const value = raw.trim();
|
|
16
|
+
if (value === '' || value === '~' || value === 'null')
|
|
17
|
+
return null;
|
|
18
|
+
if (value === 'true')
|
|
19
|
+
return true;
|
|
20
|
+
if (value === 'false')
|
|
21
|
+
return false;
|
|
22
|
+
if (/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(value))
|
|
23
|
+
return Number(value);
|
|
24
|
+
if (/^"([^"\\]|\\.)*"$/.test(value)) {
|
|
25
|
+
return JSON.parse(value);
|
|
26
|
+
}
|
|
27
|
+
if (/^'([^']|'')*'$/.test(value))
|
|
28
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
29
|
+
if (value.startsWith('[')) {
|
|
30
|
+
if (!value.endsWith(']'))
|
|
31
|
+
throw new RangeError(`Frontmatter line ${line}: unclosed list`);
|
|
32
|
+
const inner = value.slice(1, -1).trim();
|
|
33
|
+
return inner ? splitList(inner, line).map((item) => scalar(item, line)) : [];
|
|
34
|
+
}
|
|
35
|
+
if (/^[{|>&*!%@`]/.test(value)) {
|
|
36
|
+
throw new RangeError(`Frontmatter line ${line}: "${value[0]}" needs a full YAML parser`);
|
|
37
|
+
}
|
|
38
|
+
// A plain scalar ends at a comment.
|
|
39
|
+
return value.replace(/\s+#.*$/, '');
|
|
40
|
+
}
|
|
41
|
+
/** Splits an inline list on commas outside quotes. */
|
|
42
|
+
function splitList(inner, line) {
|
|
43
|
+
const items = [];
|
|
44
|
+
let current = '';
|
|
45
|
+
let quote = null;
|
|
46
|
+
for (const char of inner) {
|
|
47
|
+
if (quote) {
|
|
48
|
+
if (char === quote)
|
|
49
|
+
quote = null;
|
|
50
|
+
}
|
|
51
|
+
else if (char === '"' || char === "'") {
|
|
52
|
+
quote = char;
|
|
53
|
+
}
|
|
54
|
+
else if (char === ',') {
|
|
55
|
+
items.push(current);
|
|
56
|
+
current = '';
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
current += char;
|
|
60
|
+
}
|
|
61
|
+
if (quote)
|
|
62
|
+
throw new RangeError(`Frontmatter line ${line}: unclosed quote`);
|
|
63
|
+
return [...items, current];
|
|
64
|
+
}
|
|
65
|
+
/** Parses the YAML subset described above. Throws a `RangeError` naming the line. */
|
|
66
|
+
export function parseFrontmatter(text) {
|
|
67
|
+
const lines = text.split(/\r?\n/);
|
|
68
|
+
const root = {};
|
|
69
|
+
let parent = null;
|
|
70
|
+
lines.forEach((content, index) => {
|
|
71
|
+
const line = index + 1;
|
|
72
|
+
if (!content.trim() || /^\s*#/.test(content))
|
|
73
|
+
return;
|
|
74
|
+
const indent = content.length - content.trimStart().length;
|
|
75
|
+
const trimmed = content.trim();
|
|
76
|
+
if (indent === 0) {
|
|
77
|
+
const match = /^([^:#\s][^:]*?)\s*:(?:\s+(.*))?$/.exec(trimmed);
|
|
78
|
+
if (!match)
|
|
79
|
+
throw new RangeError(`Frontmatter line ${line}: expected "key: value"`);
|
|
80
|
+
const [, key, rest] = match;
|
|
81
|
+
if (rest === undefined || rest.trim() === '') {
|
|
82
|
+
root[key] = null;
|
|
83
|
+
parent = { key, kind: null };
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
root[key] = scalar(rest, line);
|
|
87
|
+
parent = null;
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!parent)
|
|
92
|
+
throw new RangeError(`Frontmatter line ${line}: indented, but under no key`);
|
|
93
|
+
if (trimmed.startsWith('- ') || trimmed === '-') {
|
|
94
|
+
if (parent.kind === 'map')
|
|
95
|
+
throw new RangeError(`Frontmatter line ${line}: list item in a map`);
|
|
96
|
+
parent.kind = 'list';
|
|
97
|
+
const list = (root[parent.key] ??= []);
|
|
98
|
+
list.push(scalar(trimmed.slice(1), line));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const match = /^([^:#\s][^:]*?)\s*:(?:\s+(.*))?$/.exec(trimmed);
|
|
102
|
+
if (!match || parent.kind === 'list') {
|
|
103
|
+
throw new RangeError(`Frontmatter line ${line}: expected "key: value" or "- item"`);
|
|
104
|
+
}
|
|
105
|
+
parent.kind = 'map';
|
|
106
|
+
const map = (root[parent.key] ??= {});
|
|
107
|
+
map[match[1]] = scalar(match[2] ?? '', line);
|
|
108
|
+
});
|
|
109
|
+
return root;
|
|
110
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HighlightFunction } from './types.js';
|
|
2
|
+
/** The slice of the `shiki` module used here. */
|
|
3
|
+
export interface ShikiModule {
|
|
4
|
+
createHighlighter(options: {
|
|
5
|
+
themes: unknown[];
|
|
6
|
+
langs: string[];
|
|
7
|
+
}): Promise<ShikiHighlighter>;
|
|
8
|
+
createCssVariablesTheme(options: {
|
|
9
|
+
name: string;
|
|
10
|
+
variablePrefix: string;
|
|
11
|
+
fontStyle?: boolean;
|
|
12
|
+
}): unknown;
|
|
13
|
+
bundledLanguages?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export interface ShikiHighlighter {
|
|
16
|
+
codeToHtml(code: string, options: {
|
|
17
|
+
lang: string;
|
|
18
|
+
theme: string;
|
|
19
|
+
}): string;
|
|
20
|
+
loadLanguage(...langs: string[]): Promise<void>;
|
|
21
|
+
getLoadedLanguages(): string[];
|
|
22
|
+
}
|
|
23
|
+
export type ShikiLoader = () => Promise<unknown> | unknown;
|
|
24
|
+
/** Registers how to load Shiki. `null` unregisters. */
|
|
25
|
+
export declare function setHighlighterLoader(loader: ShikiLoader | null): void;
|
|
26
|
+
/** A highlighter over Shiki: loads each language the first time it is seen. */
|
|
27
|
+
export declare function createShikiHighlight(shiki: ShikiModule): HighlightFunction;
|
|
28
|
+
/**
|
|
29
|
+
* The highlighter, from the registered loader or a global `shiki`. Resolves `null` when there
|
|
30
|
+
* is neither — code then stays plain, never broken.
|
|
31
|
+
*/
|
|
32
|
+
export declare function loadHighlighter(): Promise<HighlightFunction | null>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
let registered = null;
|
|
2
|
+
let cached = null;
|
|
3
|
+
/** Registers how to load Shiki. `null` unregisters. */
|
|
4
|
+
export function setHighlighterLoader(loader) {
|
|
5
|
+
registered = loader;
|
|
6
|
+
cached = null;
|
|
7
|
+
}
|
|
8
|
+
const THEME = 'loidolt';
|
|
9
|
+
/** A highlighter over Shiki: loads each language the first time it is seen. */
|
|
10
|
+
export function createShikiHighlight(shiki) {
|
|
11
|
+
const theme = shiki.createCssVariablesTheme({
|
|
12
|
+
name: THEME,
|
|
13
|
+
variablePrefix: '--ldt-syntax-',
|
|
14
|
+
fontStyle: true,
|
|
15
|
+
});
|
|
16
|
+
const ready = shiki.createHighlighter({ themes: [theme], langs: [] });
|
|
17
|
+
return async (code, lang) => {
|
|
18
|
+
const highlighter = await ready;
|
|
19
|
+
const language = lang.toLowerCase();
|
|
20
|
+
if (!language || language === 'text' || language === 'plaintext')
|
|
21
|
+
return null;
|
|
22
|
+
if (!highlighter.getLoadedLanguages().includes(language)) {
|
|
23
|
+
if (shiki.bundledLanguages && !(language in shiki.bundledLanguages))
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
await highlighter.loadLanguage(language);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const html = highlighter.codeToHtml(code, { lang: language, theme: THEME });
|
|
33
|
+
// Keep the highlighted spans only; the frame around them is ours.
|
|
34
|
+
return /<code[^>]*>([\s\S]*)<\/code>/.exec(html)?.[1] ?? null;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The highlighter, from the registered loader or a global `shiki`. Resolves `null` when there
|
|
39
|
+
* is neither — code then stays plain, never broken.
|
|
40
|
+
*/
|
|
41
|
+
export function loadHighlighter() {
|
|
42
|
+
cached ??= Promise.resolve()
|
|
43
|
+
.then(registered ?? (() => globalThis.shiki))
|
|
44
|
+
.then((module) => {
|
|
45
|
+
const shiki = module?.default?.createHighlighter
|
|
46
|
+
? module.default
|
|
47
|
+
: module;
|
|
48
|
+
return typeof shiki?.createHighlighter === 'function' ? createShikiHighlight(shiki) : null;
|
|
49
|
+
})
|
|
50
|
+
.catch(() => null);
|
|
51
|
+
return cached;
|
|
52
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export { renderMarkdown, renderMarkdownSync } from './render.js';
|
|
3
|
+
export { parseFrontmatter, splitFrontmatter } from './frontmatter.js';
|
|
4
|
+
export type { Frontmatter, FrontmatterValue } from './frontmatter.js';
|
|
5
|
+
export { createSlugger, slugify } from './slug.js';
|
|
6
|
+
export { safeUrl } from './url.js';
|
|
7
|
+
export { decodeEntities, escapeHtml, textOf } from './escape.js';
|
|
8
|
+
export { createShikiHighlight, loadHighlighter, setHighlighterLoader } from './highlight.js';
|
|
9
|
+
export type { ShikiHighlighter, ShikiLoader, ShikiModule } from './highlight.js';
|
|
10
|
+
export { loadMermaid, renderMermaid, setMermaidLoader } from './mermaid.js';
|
|
11
|
+
export type { MermaidApi, MermaidLoader } from './mermaid.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Plain TypeScript with no Svelte import: the markdown pipeline and its parts, for a server
|
|
3
|
+
* `load`, a build script or a test.
|
|
4
|
+
*/
|
|
5
|
+
export * from './types.js';
|
|
6
|
+
export { renderMarkdown, renderMarkdownSync } from './render.js';
|
|
7
|
+
export { parseFrontmatter, splitFrontmatter } from './frontmatter.js';
|
|
8
|
+
export { createSlugger, slugify } from './slug.js';
|
|
9
|
+
export { safeUrl } from './url.js';
|
|
10
|
+
export { decodeEntities, escapeHtml, textOf } from './escape.js';
|
|
11
|
+
export { createShikiHighlight, loadHighlighter, setHighlighterLoader } from './highlight.js';
|
|
12
|
+
export { loadMermaid, renderMermaid, setMermaidLoader } from './mermaid.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The slice of the Mermaid API used here. */
|
|
2
|
+
export interface MermaidApi {
|
|
3
|
+
initialize(config: Record<string, unknown>): void;
|
|
4
|
+
render(id: string, source: string): Promise<{
|
|
5
|
+
svg: string;
|
|
6
|
+
}>;
|
|
7
|
+
}
|
|
8
|
+
export type MermaidLoader = () => Promise<unknown> | unknown;
|
|
9
|
+
/** Registers how to load Mermaid. `null` unregisters. */
|
|
10
|
+
export declare function setMermaidLoader(loader: MermaidLoader | null): void;
|
|
11
|
+
/** Mermaid, from the registered loader or a global `mermaid`, or `null` when neither is there. */
|
|
12
|
+
export declare function loadMermaid(): Promise<MermaidApi | null>;
|
|
13
|
+
/**
|
|
14
|
+
* Renders one diagram. Mermaid's configuration is global, so renders are queued: two diagrams
|
|
15
|
+
* in different themes must not configure each other.
|
|
16
|
+
*/
|
|
17
|
+
export declare function renderMermaid(api: MermaidApi, id: string, source: string, themeVariables: Record<string, string>): Promise<string>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Mermaid diagrams — an optional peer this package never imports itself. Register it once:
|
|
3
|
+
*
|
|
4
|
+
* import { setMermaidLoader } from '@loidolt/theme-docs';
|
|
5
|
+
* setMermaidLoader(() => import('mermaid').then((module) => module.default));
|
|
6
|
+
*/
|
|
7
|
+
let registered = null;
|
|
8
|
+
let cached = null;
|
|
9
|
+
let queue = Promise.resolve();
|
|
10
|
+
/** Registers how to load Mermaid. `null` unregisters. */
|
|
11
|
+
export function setMermaidLoader(loader) {
|
|
12
|
+
registered = loader;
|
|
13
|
+
cached = null;
|
|
14
|
+
}
|
|
15
|
+
/** Mermaid, from the registered loader or a global `mermaid`, or `null` when neither is there. */
|
|
16
|
+
export function loadMermaid() {
|
|
17
|
+
cached ??= Promise.resolve()
|
|
18
|
+
.then(registered ?? (() => globalThis.mermaid))
|
|
19
|
+
.then((module) => {
|
|
20
|
+
const api = module?.default?.render
|
|
21
|
+
? module.default
|
|
22
|
+
: module;
|
|
23
|
+
return typeof api?.render === 'function' ? api : null;
|
|
24
|
+
})
|
|
25
|
+
.catch(() => null);
|
|
26
|
+
return cached;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Renders one diagram. Mermaid's configuration is global, so renders are queued: two diagrams
|
|
30
|
+
* in different themes must not configure each other.
|
|
31
|
+
*/
|
|
32
|
+
export function renderMermaid(api, id, source, themeVariables) {
|
|
33
|
+
const run = queue.then(async () => {
|
|
34
|
+
api.initialize({
|
|
35
|
+
startOnLoad: false,
|
|
36
|
+
securityLevel: 'strict',
|
|
37
|
+
theme: 'base',
|
|
38
|
+
themeVariables,
|
|
39
|
+
// Square state boxes, like everything else here. Mermaid's own styles round them, and they
|
|
40
|
+
// sit in the diagram's unlayered `<style>`, out of reach of the stylesheet. Flowchart
|
|
41
|
+
// shapes keep their corners, which carry meaning.
|
|
42
|
+
themeCSS: '.statediagram-state rect.basic { rx: 0; ry: 0; }',
|
|
43
|
+
fontFamily: 'inherit',
|
|
44
|
+
});
|
|
45
|
+
return (await api.render(id, source)).svg;
|
|
46
|
+
});
|
|
47
|
+
queue = run.catch(() => undefined);
|
|
48
|
+
return run;
|
|
49
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MarkdownOptions, RenderedMarkdown } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Renders markdown without syntax highlighting — synchronous, for a first render or wherever
|
|
4
|
+
* nothing can be awaited. Code blocks are escaped plain text in the same frame.
|
|
5
|
+
*/
|
|
6
|
+
export declare function renderMarkdownSync(source: string, options?: MarkdownOptions): RenderedMarkdown;
|
|
7
|
+
/**
|
|
8
|
+
* Renders markdown to HTML with a table of contents, frontmatter and highlighted code. Safe for
|
|
9
|
+
* untrusted markdown by default: raw HTML is shown as text, and links and images only keep
|
|
10
|
+
* safe URLs. Works on the server — await it in a SvelteKit `load` to prerender pages.
|
|
11
|
+
*/
|
|
12
|
+
export declare function renderMarkdown(source: string, options?: MarkdownOptions): Promise<RenderedMarkdown>;
|