@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Loidolt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,103 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @loidolt/theme-docs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Accessible Markdown documentation for Svelte 5, in the Loidolt look: pages, contents, code and
|
|
4
|
+
diagrams.
|
|
5
|
+
|
|
6
|
+
- **Safe by default.** Raw HTML in markdown is shown as text, links and images keep only safe
|
|
7
|
+
URLs, and every extension escapes what it writes. Markdown from a CMS or a pull request cannot
|
|
8
|
+
put a script on your page unless you let it (`allowHtml`, ideally with a `sanitize` hook).
|
|
9
|
+
- **Server-rendered.** The whole document — headings, tables, footnotes, the table of contents —
|
|
10
|
+
is in the first render, so pages prerender with their content and search engines see it.
|
|
11
|
+
Highlighting and diagrams arrive in the browser, or render on the server with
|
|
12
|
+
`renderMarkdown` in a `load`.
|
|
13
|
+
- **Themed.** Rendered markdown takes `.ldt-prose`. Code is highlighted with Shiki's
|
|
14
|
+
CSS-variables theme mapped to the `--loidolt-syntax-*` tokens, so it follows light and dark
|
|
15
|
+
without a second pass; Mermaid diagrams are drawn in the theme's colours and redrawn when it
|
|
16
|
+
changes.
|
|
17
|
+
- **Accessible.** One level-1 heading per page; headings link to themselves; code blocks are
|
|
18
|
+
named and keyboard-reachable; task-list checkboxes, footnotes and diagrams all carry names.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install @loidolt/theme-docs
|
|
24
|
+
# optional: highlighting and diagrams
|
|
25
|
+
npm install shiki mermaid
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The docs styles ship with `@loidolt/theme-styles`. Register the optional pieces once:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { setHighlighterLoader, setMermaidLoader } from '@loidolt/theme-docs';
|
|
32
|
+
|
|
33
|
+
setHighlighterLoader(() => import('shiki'));
|
|
34
|
+
setMermaidLoader(() => import('mermaid').then((module) => module.default));
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Without them, code is plain and diagrams show their source — nothing breaks.
|
|
38
|
+
|
|
39
|
+
```svelte
|
|
40
|
+
<script lang="ts">
|
|
41
|
+
import { MarkdownPage } from '@loidolt/theme-docs';
|
|
42
|
+
let { data } = $props();
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<MarkdownPage source={data.markdown} next={{ title: 'Tokens', href: '/docs/tokens' }} />
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Components
|
|
49
|
+
|
|
50
|
+
| Component | What it is |
|
|
51
|
+
| ----------------- | ---------------------------------------------------------------------------- |
|
|
52
|
+
| `Markdown` | A markdown document as loidolt prose, with highlighting, diagrams and copy |
|
|
53
|
+
| `MarkdownPage` | A full page: title and description from frontmatter, contents, previous/next |
|
|
54
|
+
| `CodeSnippet` | One highlighted code block with a filename and copy button |
|
|
55
|
+
| `MermaidDiagram` | A diagram as a named picture with a description and its source |
|
|
56
|
+
| `TableOfContents` | A page's headings, marking the one being read |
|
|
57
|
+
| `TocPanel` | Contents beside the page, or behind a button on narrow screens |
|
|
58
|
+
| `DocsHub` | An index of pages in sections |
|
|
59
|
+
| `DocsCard` | One page in an index; the whole card is the link |
|
|
60
|
+
|
|
61
|
+
## Markdown
|
|
62
|
+
|
|
63
|
+
GitHub-flavoured markdown (tables, task lists, strikethrough, autolinks), plus:
|
|
64
|
+
|
|
65
|
+
- **Frontmatter** in a leading `---` block: strings, numbers, booleans, lists and one level of
|
|
66
|
+
maps, parsed strictly (errors name the line). `parseFrontmatter` takes a full YAML parser.
|
|
67
|
+
- **Admonitions**: `:::note|tip|important|warning|caution [title]` … `:::`, and GitHub's
|
|
68
|
+
`> [!NOTE]` alerts, rendered in the alert tones as notes.
|
|
69
|
+
- **Footnotes**: `text[^id]` and `[^id]: note`, numbered by first use, linked both ways.
|
|
70
|
+
- **Definition lists**: a term line followed by `: definition` lines.
|
|
71
|
+
- **Code**: ` ```ts title="file.ts" ` (or ` ```ts:file.ts `) adds a filename.
|
|
72
|
+
- **Diagrams**: ` ```mermaid ` fences.
|
|
73
|
+
|
|
74
|
+
### Rendering ahead of time
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// +page.server.ts
|
|
78
|
+
import { renderMarkdown } from '@loidolt/theme-docs/core';
|
|
79
|
+
import { createShikiHighlight } from '@loidolt/theme-docs/core';
|
|
80
|
+
import * as shiki from 'shiki';
|
|
81
|
+
|
|
82
|
+
export async function load() {
|
|
83
|
+
const result = await renderMarkdown(source, {
|
|
84
|
+
stripTitle: true,
|
|
85
|
+
highlight: createShikiHighlight(shiki),
|
|
86
|
+
});
|
|
87
|
+
return { result };
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```svelte
|
|
92
|
+
<MarkdownPage result={data.result} />
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`renderMarkdown` returns `{ html, toc, headings, frontmatter, title, highlighted }`.
|
|
96
|
+
`renderMarkdownSync` does the same without highlighting. Options include `allowHtml`,
|
|
97
|
+
`sanitize`, `idPrefix` (for two documents on one page), `headingLinks`, `tocDepth`,
|
|
98
|
+
`stripTitle` and every user-facing string (`admonitionLabels`, `copyLabel`, `codeLabel`,
|
|
99
|
+
`taskLabels`, `footnotesLabel`, `footnoteBackLabel`).
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Attachment } from 'svelte/attachments';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import { LiveRegion, createAnnouncer, cx } from '@loidolt/theme-svelte';
|
|
5
|
+
import { loadHighlighter } from '../core/highlight.js';
|
|
6
|
+
import { handleCopy } from '../internal/enhance.js';
|
|
7
|
+
|
|
8
|
+
interface Props extends HTMLAttributes<HTMLElement> {
|
|
9
|
+
code: string;
|
|
10
|
+
/** Language for highlighting, e.g. `ts`, `svelte`, `css`. */
|
|
11
|
+
lang?: string;
|
|
12
|
+
/** Shown above the code, and names it. */
|
|
13
|
+
filename?: string;
|
|
14
|
+
/** Names the code for assistive tech when there is no filename. Defaults to "‹lang› code". */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Offer a copy button. */
|
|
17
|
+
copyable?: boolean;
|
|
18
|
+
copyLabel?: string;
|
|
19
|
+
copiedLabel?: string;
|
|
20
|
+
/** Highlight with the registered Shiki loader, when there is one. */
|
|
21
|
+
highlight?: boolean;
|
|
22
|
+
class?: string;
|
|
23
|
+
ref?: HTMLElement | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
code,
|
|
28
|
+
lang = '',
|
|
29
|
+
filename,
|
|
30
|
+
label,
|
|
31
|
+
copyable = true,
|
|
32
|
+
copyLabel = 'Copy',
|
|
33
|
+
copiedLabel = 'Copied',
|
|
34
|
+
highlight = true,
|
|
35
|
+
class: className,
|
|
36
|
+
ref = $bindable(null),
|
|
37
|
+
...rest
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
const announcer = createAnnouncer();
|
|
41
|
+
let highlighted = $state<string | null>(null);
|
|
42
|
+
|
|
43
|
+
$effect(() => {
|
|
44
|
+
const [text, language, wanted] = [code, lang, highlight];
|
|
45
|
+
highlighted = null;
|
|
46
|
+
if (!wanted) return;
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
void loadHighlighter()
|
|
49
|
+
.then((run) => run?.(text, language))
|
|
50
|
+
.then((html) => {
|
|
51
|
+
if (!cancelled && html) highlighted = html;
|
|
52
|
+
})
|
|
53
|
+
.catch(() => {});
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const name = $derived(filename ?? label ?? (lang ? `${lang} code` : 'Code'));
|
|
60
|
+
const copy: Attachment<HTMLElement> = (node) =>
|
|
61
|
+
handleCopy(node, { copiedLabel, onCopied: () => announcer.announce(copiedLabel) });
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<!-- eslint-disable svelte/no-at-html-tags -- Shiki's spans, built from escaped source -->
|
|
65
|
+
<figure
|
|
66
|
+
bind:this={ref}
|
|
67
|
+
class={cx('ldt-code ldt-code-snippet', className)}
|
|
68
|
+
data-lang={lang || undefined}
|
|
69
|
+
{@attach copy}
|
|
70
|
+
{...rest}
|
|
71
|
+
>
|
|
72
|
+
{#if filename || copyable}
|
|
73
|
+
<figcaption class="ldt-code__header">
|
|
74
|
+
{#if filename}<span class="ldt-code__filename">{filename}</span>{/if}
|
|
75
|
+
{#if copyable}<button type="button" class="ldt-code__copy" data-ldt-copy>{copyLabel}</button
|
|
76
|
+
>{/if}
|
|
77
|
+
</figcaption>
|
|
78
|
+
{/if}
|
|
79
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
80
|
+
<pre class="ldt-code__pre" tabindex="0" aria-label={name}><code
|
|
81
|
+
class={lang ? `language-${lang}` : undefined}
|
|
82
|
+
>{#if highlighted}{@html highlighted}{:else}{code}{/if}</code
|
|
83
|
+
></pre>
|
|
84
|
+
<LiveRegion message={announcer.message} politeness={announcer.politeness} />
|
|
85
|
+
</figure>
|
|
86
|
+
<!-- eslint-enable svelte/no-at-html-tags -->
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
interface Props extends HTMLAttributes<HTMLElement> {
|
|
3
|
+
code: string;
|
|
4
|
+
/** Language for highlighting, e.g. `ts`, `svelte`, `css`. */
|
|
5
|
+
lang?: string;
|
|
6
|
+
/** Shown above the code, and names it. */
|
|
7
|
+
filename?: string;
|
|
8
|
+
/** Names the code for assistive tech when there is no filename. Defaults to "‹lang› code". */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Offer a copy button. */
|
|
11
|
+
copyable?: boolean;
|
|
12
|
+
copyLabel?: string;
|
|
13
|
+
copiedLabel?: string;
|
|
14
|
+
/** Highlight with the registered Shiki loader, when there is one. */
|
|
15
|
+
highlight?: boolean;
|
|
16
|
+
class?: string;
|
|
17
|
+
ref?: HTMLElement | null;
|
|
18
|
+
}
|
|
19
|
+
declare const CodeSnippet: import("svelte").Component<Props, {}, "ref">;
|
|
20
|
+
type CodeSnippet = ReturnType<typeof CodeSnippet>;
|
|
21
|
+
export default CodeSnippet;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { Card, cx } from '@loidolt/theme-svelte';
|
|
4
|
+
import type { HeadingLevel } from '@loidolt/theme-svelte';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
title: string;
|
|
8
|
+
href: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
/** A short label above the title, e.g. a category. */
|
|
11
|
+
eyebrow?: string;
|
|
12
|
+
/** Opens in a new tab, and says so to assistive tech. */
|
|
13
|
+
external?: boolean;
|
|
14
|
+
externalLabel?: string;
|
|
15
|
+
headingLevel?: HeadingLevel;
|
|
16
|
+
/** Anything else inside the card, below the description. */
|
|
17
|
+
children?: Snippet;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
title,
|
|
23
|
+
href,
|
|
24
|
+
description,
|
|
25
|
+
eyebrow,
|
|
26
|
+
external = false,
|
|
27
|
+
externalLabel = '(opens in a new tab)',
|
|
28
|
+
headingLevel = 3,
|
|
29
|
+
children,
|
|
30
|
+
class: className,
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<!-- The whole card is the link, so the target is the card, not a line of text inside it. -->
|
|
35
|
+
<Card
|
|
36
|
+
{href}
|
|
37
|
+
{headingLevel}
|
|
38
|
+
class={cx('ldt-docs-card', className)}
|
|
39
|
+
target={external ? '_blank' : undefined}
|
|
40
|
+
rel={external ? 'noopener' : undefined}
|
|
41
|
+
>
|
|
42
|
+
{#snippet header()}
|
|
43
|
+
{#if eyebrow}<p class="ldt-docs-card__eyebrow">{eyebrow}</p>{/if}
|
|
44
|
+
<svelte:element this={`h${headingLevel}`} class="ldt-card__title"
|
|
45
|
+
>{title}{#if external}<span class="ldt-sr-only"> {externalLabel}</span>{/if}</svelte:element
|
|
46
|
+
>
|
|
47
|
+
{#if description}<p class="ldt-card__description">{description}</p>{/if}
|
|
48
|
+
{/snippet}
|
|
49
|
+
{@render children?.()}
|
|
50
|
+
</Card>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HeadingLevel } from '@loidolt/theme-svelte';
|
|
3
|
+
interface Props {
|
|
4
|
+
title: string;
|
|
5
|
+
href: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
/** A short label above the title, e.g. a category. */
|
|
8
|
+
eyebrow?: string;
|
|
9
|
+
/** Opens in a new tab, and says so to assistive tech. */
|
|
10
|
+
external?: boolean;
|
|
11
|
+
externalLabel?: string;
|
|
12
|
+
headingLevel?: HeadingLevel;
|
|
13
|
+
/** Anything else inside the card, below the description. */
|
|
14
|
+
children?: Snippet;
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
declare const DocsCard: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type DocsCard = ReturnType<typeof DocsCard>;
|
|
19
|
+
export default DocsCard;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { cx } from '@loidolt/theme-svelte';
|
|
4
|
+
import type { HeadingLevel } from '@loidolt/theme-svelte';
|
|
5
|
+
import type { DocsSection } from '../core/types.js';
|
|
6
|
+
import DocsCard from './DocsCard.svelte';
|
|
7
|
+
|
|
8
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
/** Groups of pages, each with a heading and a grid of cards. */
|
|
10
|
+
sections: DocsSection[];
|
|
11
|
+
/** Level of the section headings; the cards take the next one. */
|
|
12
|
+
headingLevel?: HeadingLevel;
|
|
13
|
+
class?: string;
|
|
14
|
+
ref?: HTMLDivElement | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
sections,
|
|
19
|
+
headingLevel = 2,
|
|
20
|
+
class: className,
|
|
21
|
+
ref = $bindable(null),
|
|
22
|
+
...rest
|
|
23
|
+
}: Props = $props();
|
|
24
|
+
|
|
25
|
+
const cardLevel = $derived(Math.min(headingLevel + 1, 6) as HeadingLevel);
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<div bind:this={ref} class={cx('ldt-docs-hub', className)} {...rest}>
|
|
29
|
+
{#each sections as section (section.title)}
|
|
30
|
+
<section class="ldt-docs-hub__section">
|
|
31
|
+
<svelte:element this={`h${headingLevel}`} class="ldt-docs-hub__title"
|
|
32
|
+
>{section.title}</svelte:element
|
|
33
|
+
>
|
|
34
|
+
{#if section.description}<p class="ldt-docs-hub__description">{section.description}</p>{/if}
|
|
35
|
+
<ul class="ldt-docs-hub__grid">
|
|
36
|
+
{#each section.items as item (item.href)}
|
|
37
|
+
<li>
|
|
38
|
+
<DocsCard
|
|
39
|
+
title={item.title}
|
|
40
|
+
href={item.href}
|
|
41
|
+
description={item.description}
|
|
42
|
+
eyebrow={item.eyebrow}
|
|
43
|
+
external={item.external}
|
|
44
|
+
headingLevel={cardLevel}
|
|
45
|
+
/>
|
|
46
|
+
</li>
|
|
47
|
+
{/each}
|
|
48
|
+
</ul>
|
|
49
|
+
</section>
|
|
50
|
+
{/each}
|
|
51
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
import type { HeadingLevel } from '@loidolt/theme-svelte';
|
|
3
|
+
import type { DocsSection } from '../core/types.js';
|
|
4
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
/** Groups of pages, each with a heading and a grid of cards. */
|
|
6
|
+
sections: DocsSection[];
|
|
7
|
+
/** Level of the section headings; the cards take the next one. */
|
|
8
|
+
headingLevel?: HeadingLevel;
|
|
9
|
+
class?: string;
|
|
10
|
+
ref?: HTMLDivElement | null;
|
|
11
|
+
}
|
|
12
|
+
declare const DocsHub: import("svelte").Component<Props, {}, "ref">;
|
|
13
|
+
type DocsHub = ReturnType<typeof DocsHub>;
|
|
14
|
+
export default DocsHub;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import type { Attachment } from 'svelte/attachments';
|
|
4
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
5
|
+
import { LiveRegion, createAnnouncer, createTokenColors, cx } from '@loidolt/theme-svelte';
|
|
6
|
+
import { renderMarkdown, renderMarkdownSync } from '../core/render.js';
|
|
7
|
+
import type { MarkdownOptions, RenderedMarkdown } from '../core/types.js';
|
|
8
|
+
import { diagramRoles, enhanceDiagrams, handleCopy } from '../internal/enhance.js';
|
|
9
|
+
|
|
10
|
+
interface Props extends HTMLAttributes<HTMLElement> {
|
|
11
|
+
/** Markdown to render. The first render is synchronous, so it is in the server's HTML. */
|
|
12
|
+
source?: string;
|
|
13
|
+
/** Or a result from `renderMarkdown` — e.g. rendered and highlighted in a server `load`. */
|
|
14
|
+
result?: RenderedMarkdown;
|
|
15
|
+
/** Rendering options: HTML policy, heading links, labels… See `MarkdownOptions`. */
|
|
16
|
+
options?: MarkdownOptions;
|
|
17
|
+
/** What a copy button says, and what is announced, once its code is copied. */
|
|
18
|
+
copiedLabel?: string;
|
|
19
|
+
/** Names a drawn Mermaid diagram. */
|
|
20
|
+
diagramLabel?: string;
|
|
21
|
+
/** Labels the disclosure holding a diagram's source. */
|
|
22
|
+
diagramSourceLabel?: string;
|
|
23
|
+
/** Called with each render — the synchronous one, then the highlighted one if it differs. */
|
|
24
|
+
onRender?: (result: RenderedMarkdown) => void;
|
|
25
|
+
class?: string;
|
|
26
|
+
ref?: HTMLElement | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
source = '',
|
|
31
|
+
result,
|
|
32
|
+
options = {},
|
|
33
|
+
copiedLabel = 'Copied',
|
|
34
|
+
diagramLabel = 'Diagram',
|
|
35
|
+
diagramSourceLabel = 'Diagram source',
|
|
36
|
+
onRender,
|
|
37
|
+
class: className,
|
|
38
|
+
ref = $bindable(null),
|
|
39
|
+
...rest
|
|
40
|
+
}: Props = $props();
|
|
41
|
+
|
|
42
|
+
const announcer = createAnnouncer();
|
|
43
|
+
const palette = createTokenColors(diagramRoles, { element: () => ref });
|
|
44
|
+
|
|
45
|
+
const initial = $derived(result ?? renderMarkdownSync(source, options));
|
|
46
|
+
let highlighted = $state.raw<RenderedMarkdown | null>(null);
|
|
47
|
+
const shown = $derived(highlighted ?? initial);
|
|
48
|
+
|
|
49
|
+
// In the browser, render again with highlighting when a highlighter is registered.
|
|
50
|
+
$effect(() => {
|
|
51
|
+
const [text, settings, given] = [source, options, result];
|
|
52
|
+
highlighted = null;
|
|
53
|
+
if (given || !text) return;
|
|
54
|
+
let cancelled = false;
|
|
55
|
+
void renderMarkdown(text, settings).then((next) => {
|
|
56
|
+
if (!cancelled && next.highlighted) highlighted = next;
|
|
57
|
+
});
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
$effect(() => {
|
|
64
|
+
const current = shown;
|
|
65
|
+
untrack(() => onRender?.(current));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Diagrams: drawn once the HTML is in place, and redrawn when the theme changes.
|
|
69
|
+
$effect(() => {
|
|
70
|
+
void shown.html;
|
|
71
|
+
const colors = palette.colors;
|
|
72
|
+
const root = ref;
|
|
73
|
+
if (root)
|
|
74
|
+
void enhanceDiagrams(root, colors, { label: diagramLabel, sourceLabel: diagramSourceLabel });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
$effect(() => {
|
|
78
|
+
if (ref) untrack(() => palette.refresh());
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const copy: Attachment<HTMLElement> = (node) =>
|
|
82
|
+
handleCopy(node, { copiedLabel, onCopied: () => announcer.announce(copiedLabel) });
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<article bind:this={ref} class={cx('ldt-prose ldt-markdown', className)} {@attach copy} {...rest}>
|
|
86
|
+
<!-- Rendered by `renderMarkdown`: raw HTML escaped unless `allowHtml`, URLs allow-listed. -->
|
|
87
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
88
|
+
{@html shown.html}
|
|
89
|
+
</article>
|
|
90
|
+
<LiveRegion message={announcer.message} politeness={announcer.politeness} />
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
import type { MarkdownOptions, RenderedMarkdown } from '../core/types.js';
|
|
3
|
+
interface Props extends HTMLAttributes<HTMLElement> {
|
|
4
|
+
/** Markdown to render. The first render is synchronous, so it is in the server's HTML. */
|
|
5
|
+
source?: string;
|
|
6
|
+
/** Or a result from `renderMarkdown` — e.g. rendered and highlighted in a server `load`. */
|
|
7
|
+
result?: RenderedMarkdown;
|
|
8
|
+
/** Rendering options: HTML policy, heading links, labels… See `MarkdownOptions`. */
|
|
9
|
+
options?: MarkdownOptions;
|
|
10
|
+
/** What a copy button says, and what is announced, once its code is copied. */
|
|
11
|
+
copiedLabel?: string;
|
|
12
|
+
/** Names a drawn Mermaid diagram. */
|
|
13
|
+
diagramLabel?: string;
|
|
14
|
+
/** Labels the disclosure holding a diagram's source. */
|
|
15
|
+
diagramSourceLabel?: string;
|
|
16
|
+
/** Called with each render — the synchronous one, then the highlighted one if it differs. */
|
|
17
|
+
onRender?: (result: RenderedMarkdown) => void;
|
|
18
|
+
class?: string;
|
|
19
|
+
ref?: HTMLElement | null;
|
|
20
|
+
}
|
|
21
|
+
declare const Markdown: import("svelte").Component<Props, {}, "ref">;
|
|
22
|
+
type Markdown = ReturnType<typeof Markdown>;
|
|
23
|
+
export default Markdown;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import { PageHeader, cx } from '@loidolt/theme-svelte';
|
|
5
|
+
import { renderMarkdownSync } from '../core/render.js';
|
|
6
|
+
import type { DocsLink, MarkdownOptions, RenderedMarkdown } from '../core/types.js';
|
|
7
|
+
import Markdown from './Markdown.svelte';
|
|
8
|
+
import TocPanel from './TocPanel.svelte';
|
|
9
|
+
|
|
10
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
11
|
+
source?: string;
|
|
12
|
+
/** A result rendered ahead of time — pass `stripTitle: true` when rendering it. */
|
|
13
|
+
result?: RenderedMarkdown;
|
|
14
|
+
options?: MarkdownOptions;
|
|
15
|
+
/**
|
|
16
|
+
* The page title. Defaults to the frontmatter `title`, then the document's first `#`
|
|
17
|
+
* heading, which is left out of the body so the page has one level-1 heading.
|
|
18
|
+
*/
|
|
19
|
+
title?: string;
|
|
20
|
+
/** Defaults to the frontmatter `description`. */
|
|
21
|
+
description?: string;
|
|
22
|
+
/** Defaults to the frontmatter `eyebrow` or `section`. */
|
|
23
|
+
eyebrow?: string;
|
|
24
|
+
/** Show the table of contents beside the page (behind a button on narrow screens). */
|
|
25
|
+
toc?: boolean;
|
|
26
|
+
tocTitle?: string;
|
|
27
|
+
previous?: DocsLink;
|
|
28
|
+
next?: DocsLink;
|
|
29
|
+
previousLabel?: string;
|
|
30
|
+
nextLabel?: string;
|
|
31
|
+
/** Names the previous/next navigation. */
|
|
32
|
+
pagerLabel?: string;
|
|
33
|
+
/** Content after the document, before the previous/next links. */
|
|
34
|
+
footer?: Snippet;
|
|
35
|
+
class?: string;
|
|
36
|
+
ref?: HTMLDivElement | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let {
|
|
40
|
+
source = '',
|
|
41
|
+
result,
|
|
42
|
+
options = {},
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
eyebrow,
|
|
46
|
+
toc = true,
|
|
47
|
+
tocTitle = 'On this page',
|
|
48
|
+
previous,
|
|
49
|
+
next,
|
|
50
|
+
previousLabel = 'Previous',
|
|
51
|
+
nextLabel = 'Next',
|
|
52
|
+
pagerLabel = 'More pages',
|
|
53
|
+
footer,
|
|
54
|
+
class: className,
|
|
55
|
+
ref = $bindable(null),
|
|
56
|
+
...rest
|
|
57
|
+
}: Props = $props();
|
|
58
|
+
|
|
59
|
+
let article = $state<HTMLElement | null>(null);
|
|
60
|
+
const pageOptions = $derived({ ...options, stripTitle: true });
|
|
61
|
+
// The synchronous render gives the server its title and contents; `Markdown` reports any
|
|
62
|
+
// later (highlighted) render, which has the same headings.
|
|
63
|
+
let reported = $state.raw<RenderedMarkdown | null>(null);
|
|
64
|
+
const current = $derived(reported ?? result ?? renderMarkdownSync(source, pageOptions));
|
|
65
|
+
|
|
66
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
|
|
67
|
+
const meta = $derived(current.frontmatter ?? {});
|
|
68
|
+
const heading = $derived(title ?? current.title ?? '');
|
|
69
|
+
const hasToc = $derived(toc && current.toc.length > 0);
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<div
|
|
73
|
+
bind:this={ref}
|
|
74
|
+
class={cx('ldt-markdown-page', hasToc && 'ldt-markdown-page--toc', className)}
|
|
75
|
+
{...rest}
|
|
76
|
+
>
|
|
77
|
+
<!-- First in reading order: the "On this page" button leads on narrow screens, and the
|
|
78
|
+
contents sit beside the page on wide ones. -->
|
|
79
|
+
{#if hasToc}
|
|
80
|
+
<TocPanel
|
|
81
|
+
class="ldt-markdown-page__toc"
|
|
82
|
+
items={current.toc}
|
|
83
|
+
title={tocTitle}
|
|
84
|
+
container={article}
|
|
85
|
+
/>
|
|
86
|
+
{/if}
|
|
87
|
+
<div class="ldt-markdown-page__main">
|
|
88
|
+
{#if heading}
|
|
89
|
+
<PageHeader
|
|
90
|
+
title={heading}
|
|
91
|
+
description={description ?? text(meta.description)}
|
|
92
|
+
eyebrow={eyebrow ?? text(meta.eyebrow) ?? text(meta.section)}
|
|
93
|
+
headingLevel={1}
|
|
94
|
+
/>
|
|
95
|
+
{/if}
|
|
96
|
+
<Markdown
|
|
97
|
+
bind:ref={article}
|
|
98
|
+
{source}
|
|
99
|
+
{result}
|
|
100
|
+
options={pageOptions}
|
|
101
|
+
onRender={(rendered) => (reported = rendered)}
|
|
102
|
+
/>
|
|
103
|
+
{@render footer?.()}
|
|
104
|
+
{#if previous || next}
|
|
105
|
+
<nav class="ldt-markdown-page__pager" aria-label={pagerLabel}>
|
|
106
|
+
{#if previous}
|
|
107
|
+
<a class="ldt-markdown-page__previous" href={previous.href} rel="prev">
|
|
108
|
+
<span class="ldt-markdown-page__direction">{previousLabel}</span>
|
|
109
|
+
<span>{previous.title}</span>
|
|
110
|
+
</a>
|
|
111
|
+
{/if}
|
|
112
|
+
{#if next}
|
|
113
|
+
<a class="ldt-markdown-page__next" href={next.href} rel="next">
|
|
114
|
+
<span class="ldt-markdown-page__direction">{nextLabel}</span>
|
|
115
|
+
<span>{next.title}</span>
|
|
116
|
+
</a>
|
|
117
|
+
{/if}
|
|
118
|
+
</nav>
|
|
119
|
+
{/if}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import type { DocsLink, MarkdownOptions, RenderedMarkdown } from '../core/types.js';
|
|
4
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
source?: string;
|
|
6
|
+
/** A result rendered ahead of time — pass `stripTitle: true` when rendering it. */
|
|
7
|
+
result?: RenderedMarkdown;
|
|
8
|
+
options?: MarkdownOptions;
|
|
9
|
+
/**
|
|
10
|
+
* The page title. Defaults to the frontmatter `title`, then the document's first `#`
|
|
11
|
+
* heading, which is left out of the body so the page has one level-1 heading.
|
|
12
|
+
*/
|
|
13
|
+
title?: string;
|
|
14
|
+
/** Defaults to the frontmatter `description`. */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Defaults to the frontmatter `eyebrow` or `section`. */
|
|
17
|
+
eyebrow?: string;
|
|
18
|
+
/** Show the table of contents beside the page (behind a button on narrow screens). */
|
|
19
|
+
toc?: boolean;
|
|
20
|
+
tocTitle?: string;
|
|
21
|
+
previous?: DocsLink;
|
|
22
|
+
next?: DocsLink;
|
|
23
|
+
previousLabel?: string;
|
|
24
|
+
nextLabel?: string;
|
|
25
|
+
/** Names the previous/next navigation. */
|
|
26
|
+
pagerLabel?: string;
|
|
27
|
+
/** Content after the document, before the previous/next links. */
|
|
28
|
+
footer?: Snippet;
|
|
29
|
+
class?: string;
|
|
30
|
+
ref?: HTMLDivElement | null;
|
|
31
|
+
}
|
|
32
|
+
declare const MarkdownPage: import("svelte").Component<Props, {}, "ref">;
|
|
33
|
+
type MarkdownPage = ReturnType<typeof MarkdownPage>;
|
|
34
|
+
export default MarkdownPage;
|