@classic-homes/theme-docs 0.1.0 → 0.3.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 (53) hide show
  1. package/dist/lib/components/Breadcrumbs.svelte +55 -0
  2. package/dist/lib/components/Breadcrumbs.svelte.d.ts +14 -0
  3. package/dist/lib/components/CategoryIndex.svelte +51 -0
  4. package/dist/lib/components/CategoryIndex.svelte.d.ts +13 -0
  5. package/dist/lib/components/DocPage.svelte +179 -0
  6. package/dist/lib/components/DocPage.svelte.d.ts +62 -0
  7. package/dist/lib/components/DocPager.svelte +49 -0
  8. package/dist/lib/components/DocPager.svelte.d.ts +12 -0
  9. package/dist/lib/components/MarkdownPage.svelte +3 -1
  10. package/dist/lib/components/MermaidDiagram.svelte +2 -0
  11. package/dist/lib/components/MermaidInit.svelte +44 -3
  12. package/dist/lib/components/MermaidInit.svelte.d.ts +5 -2
  13. package/dist/lib/components/TableOfContents.svelte +114 -125
  14. package/dist/lib/components/TableOfContents.svelte.d.ts +11 -4
  15. package/dist/lib/components/TagIndex.svelte +42 -0
  16. package/dist/lib/components/TagIndex.svelte.d.ts +14 -0
  17. package/dist/lib/components/TagList.svelte +45 -0
  18. package/dist/lib/components/TagList.svelte.d.ts +15 -0
  19. package/dist/lib/components/TocPanel.svelte +27 -9
  20. package/dist/lib/components/TocPanel.svelte.d.ts +10 -3
  21. package/dist/lib/components/enhance.d.ts +29 -0
  22. package/dist/lib/components/enhance.js +179 -0
  23. package/dist/lib/components/sidebar.d.ts +33 -0
  24. package/dist/lib/components/sidebar.js +84 -0
  25. package/dist/lib/content/browser.d.ts +6 -0
  26. package/dist/lib/content/browser.js +5 -0
  27. package/dist/lib/content/index.d.ts +13 -0
  28. package/dist/lib/content/index.js +12 -0
  29. package/dist/lib/content/load.d.ts +77 -0
  30. package/dist/lib/content/load.js +366 -0
  31. package/dist/lib/content/nav.d.ts +36 -0
  32. package/dist/lib/content/nav.js +81 -0
  33. package/dist/lib/content/render.d.ts +38 -0
  34. package/dist/lib/content/render.js +89 -0
  35. package/dist/lib/content/types.d.ts +96 -0
  36. package/dist/lib/content/types.js +5 -0
  37. package/dist/lib/index.d.ts +14 -2
  38. package/dist/lib/index.js +14 -2
  39. package/dist/lib/parser/api.d.ts +12 -0
  40. package/dist/lib/parser/api.js +10 -0
  41. package/dist/lib/parser/extensions.d.ts +27 -15
  42. package/dist/lib/parser/extensions.js +58 -53
  43. package/dist/lib/parser/index.d.ts +4 -1
  44. package/dist/lib/parser/index.js +104 -27
  45. package/dist/lib/sanitize/index.d.ts +11 -0
  46. package/dist/lib/sanitize/index.js +122 -0
  47. package/dist/lib/search/index.d.ts +57 -0
  48. package/dist/lib/search/index.js +107 -0
  49. package/dist/lib/styles/markdown.css +138 -0
  50. package/dist/lib/types/frontmatter.d.ts +21 -0
  51. package/dist/lib/vite/index.d.ts +17 -0
  52. package/dist/lib/vite/index.js +38 -0
  53. package/package.json +51 -4
@@ -1,22 +1,31 @@
1
1
  <script lang="ts">
2
2
  /**
3
- * TableOfContents - Auto-generated table of contents from headings
3
+ * TableOfContents - "On this page" navigation with scroll-spy
4
4
  *
5
- * Extracts headings from HTML content and generates a navigable
6
- * table of contents with smooth scrolling. Styled after Docusaurus TOC.
5
+ * Pass `toc` (from `parseMarkdown`/`renderDocs`) to render on the server, or `html` to
6
+ * extract headings in the browser. The heading being read is marked `aria-current`.
7
+ * Entries are plain `#id` links, so the browser (or your router) scrolls and records the
8
+ * hash; give headings a `scroll-margin-top` to clear a fixed header (`.markdown-content`
9
+ * does, from `--docs-header-offset`). Styled after Docusaurus.
7
10
  */
8
- import { onMount } from 'svelte';
11
+ import { untrack } from 'svelte';
9
12
  import { cn } from '../utils.js';
10
13
  import type { TocEntry } from '../types/docs.js';
11
14
 
12
15
  interface Props {
13
- /** HTML content to extract headings from */
14
- html: string;
15
- /** Maximum heading depth to include (default: 3) */
16
+ /** Prebuilt entries, e.g. `page.toc` from `renderDocs`. Takes precedence over `html`. */
17
+ toc?: TocEntry[];
18
+ /** HTML to extract headings from in the browser, when there is no `toc` */
19
+ html?: string;
20
+ /** Shallowest heading level to include (default: 1) */
21
+ minDepth?: number;
22
+ /** Deepest heading level to include (default: 3) */
16
23
  maxDepth?: number;
24
+ /** Height of any fixed header, in px: where a heading counts as reached (default: 80) */
25
+ offset?: number;
17
26
  /** Custom class */
18
27
  class?: string;
19
- /** Title for the TOC section */
28
+ /** Title for the TOC section. Empty hides it (the nav is then labelled "On this page"). */
20
29
  title?: string;
21
30
  /** Make the TOC sticky */
22
31
  sticky?: boolean;
@@ -24,151 +33,131 @@
24
33
  }
25
34
 
26
35
  let {
36
+ toc,
27
37
  html,
38
+ minDepth = 1,
28
39
  maxDepth = 3,
40
+ offset = 80,
29
41
  class: className,
30
42
  title = 'On this page',
31
43
  sticky = false,
32
44
  ...restProps
33
45
  }: Props = $props();
34
46
 
35
- let toc = $state<TocEntry[]>([]);
36
- let activeId = $state<string | null>(null);
37
- let ignoreObserver = false;
38
-
39
- onMount(async () => {
40
- const { extractToc } = await import('../parser/index.js');
41
- toc = extractToc(html, maxDepth);
42
-
43
- // Set up intersection observer to track active heading
44
- // 100ms delay ensures DOM is fully rendered after markdown parsing completes
45
- // This is necessary because markdown content is rendered asynchronously
46
- setTimeout(() => {
47
- const headings = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
48
- if (headings.length === 0) return;
49
-
50
- // Set initial active heading based on scroll position
51
- const firstVisibleHeading = Array.from(headings).find((heading) => {
52
- const rect = heading.getBoundingClientRect();
53
- return rect.top >= 0 && rect.top < window.innerHeight / 2;
54
- });
55
- if (firstVisibleHeading) {
56
- activeId = firstVisibleHeading.id;
57
- } else if (headings[0]) {
58
- activeId = headings[0].id;
59
- }
60
-
61
- const observer = new IntersectionObserver(
62
- (entries) => {
63
- // Skip if we recently clicked a TOC link
64
- if (ignoreObserver) return;
47
+ const uid = $props.id();
65
48
 
66
- // Find the first intersecting entry
67
- const intersecting = entries.filter((e) => e.isIntersecting);
68
- if (intersecting.length > 0) {
69
- // Sort by position and take the topmost
70
- const topmost = intersecting.sort(
71
- (a, b) => a.boundingClientRect.top - b.boundingClientRect.top
72
- )[0];
73
- activeId = topmost.target.id;
74
- }
75
- },
76
- {
77
- // rootMargin: top right bottom left
78
- // -80px top: accounts for fixed header height (~80px)
79
- // -70% bottom: heading becomes "active" when it enters the top 30% of viewport
80
- // This creates a natural reading experience where the heading is highlighted
81
- // before the user has scrolled past it
82
- rootMargin: '-80px 0px -70% 0px',
83
- threshold: 0,
84
- }
85
- );
86
-
87
- headings.forEach((heading) => observer.observe(heading));
88
-
89
- return () => {
90
- headings.forEach((heading) => observer.unobserve(heading));
91
- };
92
- }, 100);
93
- });
49
+ let extracted = $state<TocEntry[]>([]);
50
+ const entries = $derived(
51
+ (toc ?? extracted).filter((entry) => entry.level >= minDepth && entry.level <= maxDepth)
52
+ );
53
+ let activeId = $state<string | null>(null);
94
54
 
95
- function getIndentClass(level: number): string {
96
- // More pronounced indentation for hierarchy
97
- const indents: Record<number, string> = {
98
- 1: '',
99
- 2: '', // h2 is typically top-level in TOC (no indent)
100
- 3: 'pl-3', // h3 indented
101
- 4: 'pl-6', // h4 further indented
102
- 5: 'pl-9',
103
- 6: 'pl-12',
55
+ // Without a prebuilt toc, extract one from the HTML. The parser loads lazily so pages
56
+ // that pass `toc` never download it.
57
+ $effect(() => {
58
+ if (toc || !html) return;
59
+ const source = html;
60
+ let cancelled = false;
61
+ void import('../parser/index.js').then(({ extractToc }) => {
62
+ if (!cancelled) extracted = extractToc(source, 6);
63
+ });
64
+ return () => {
65
+ cancelled = true;
104
66
  };
105
- return indents[level] || '';
106
- }
67
+ });
107
68
 
108
- function getLevelStyles(level: number, isActive: boolean): string {
109
- const baseStyles = 'block py-1 rounded-sm';
69
+ // Scroll-spy over this TOC's own headings, as Docusaurus does it: on scroll, the current
70
+ // heading is the last one above the header line. At the bottom of the page, where the
71
+ // last headings can't reach that line, a clicked heading that's on screen stays current.
72
+ $effect(() => {
73
+ const ids = entries.map((entry) => entry.id);
74
+ const line = offset + 24;
75
+ if (ids.length === 0) return;
110
76
 
111
- if (isActive) {
112
- return cn(baseStyles, 'text-primary font-medium');
113
- }
77
+ let headings: HTMLElement[] = [];
78
+ let frame = 0;
114
79
 
115
- // Top-level items (h2) are more prominent
116
- if (level <= 2) {
117
- return cn(baseStyles, 'text-foreground/80 hover:text-foreground hover:bg-muted-hover');
80
+ function update() {
81
+ frame = 0;
82
+ if (headings.length === 0) return;
83
+ let current = headings[0];
84
+ for (const heading of headings) {
85
+ if (heading.getBoundingClientRect().top > line) break;
86
+ current = heading;
87
+ }
88
+ const atBottom =
89
+ window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
90
+ const chosen = activeId ? document.getElementById(activeId) : null;
91
+ if (atBottom && chosen && headings.includes(chosen)) {
92
+ const top = chosen.getBoundingClientRect().top;
93
+ if (top >= 0 && top < window.innerHeight) return;
94
+ }
95
+ activeId = current.id;
118
96
  }
97
+ // Throttle to one update per frame; hidden documents get no frames, so update directly.
98
+ const schedule = () => {
99
+ if (document.hidden) update();
100
+ else if (!frame) frame = requestAnimationFrame(update);
101
+ };
119
102
 
120
- // Deeper levels are more subdued
121
- return cn(
122
- baseStyles,
123
- 'text-muted-foreground text-[12px] hover:text-foreground hover:bg-muted-hover'
124
- );
125
- }
126
-
127
- function handleClick(event: MouseEvent, id: string) {
128
- event.preventDefault();
129
-
130
- // Immediately update active state on click
131
- activeId = id;
132
-
133
- // Temporarily ignore intersection observer to prevent it from
134
- // overwriting our selection during scroll animation
135
- ignoreObserver = true;
136
- setTimeout(() => {
137
- ignoreObserver = false;
138
- }, 1000); // Longer timeout to cover scroll animation
139
-
140
- // Find the heading element and scroll to it with offset
141
- const element = document.getElementById(id);
142
- if (element) {
143
- const headerOffset = 100; // Account for fixed header + some padding
144
- const elementPosition = element.getBoundingClientRect().top;
145
- const offsetPosition = elementPosition + window.scrollY - headerOffset;
103
+ // Effects run after the DOM update, so the headings these entries describe are in place.
104
+ headings = ids
105
+ .map((id) => document.getElementById(id))
106
+ .filter((el): el is HTMLElement => el !== null);
107
+ // untrack: update() reads activeId, which must not become a dependency of this effect
108
+ untrack(update);
109
+ // Capture, so scrolling inside a layout's own scroll container counts too.
110
+ window.addEventListener('scroll', schedule, { passive: true, capture: true });
111
+ window.addEventListener('resize', schedule, { passive: true });
112
+
113
+ return () => {
114
+ cancelAnimationFrame(frame);
115
+ window.removeEventListener('scroll', schedule, { capture: true });
116
+ window.removeEventListener('resize', schedule);
117
+ };
118
+ });
146
119
 
147
- window.scrollTo({
148
- top: offsetPosition,
149
- behavior: 'smooth',
150
- });
151
- }
120
+ const indents: Record<number, string> = {
121
+ 3: 'pl-3',
122
+ 4: 'pl-6',
123
+ 5: 'pl-9',
124
+ 6: 'pl-12',
125
+ };
126
+
127
+ function linkClass(level: number, isActive: boolean): string {
128
+ const base = 'block py-1 rounded-sm';
129
+ if (isActive) return cn(base, 'text-primary font-medium');
130
+ if (level <= 2)
131
+ return cn(base, 'text-foreground/80 hover:text-foreground hover:bg-muted-hover');
132
+ return cn(base, 'text-muted-foreground text-[12px] hover:text-foreground hover:bg-muted-hover');
152
133
  }
153
134
  </script>
154
135
 
155
- {#if toc.length > 0}
156
- <nav class={cn('text-sm', sticky && 'sticky top-4', className)} {...restProps}>
136
+ {#if entries.length > 0}
137
+ <nav
138
+ class={cn('text-sm', sticky && 'sticky top-4', className)}
139
+ aria-labelledby={title ? `${uid}-title` : undefined}
140
+ aria-label={title ? undefined : 'On this page'}
141
+ {...restProps}
142
+ >
157
143
  {#if title}
158
- <h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
144
+ <p
145
+ id="{uid}-title"
146
+ class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2"
147
+ >
159
148
  {title}
160
- </h4>
149
+ </p>
161
150
  {/if}
162
151
 
163
152
  <ul class="space-y-0.5">
164
- {#each toc as entry}
153
+ {#each entries as entry (entry.id)}
165
154
  {@const isActive = activeId === entry.id}
166
- <li class={getIndentClass(entry.level)}>
155
+ <li class={indents[entry.level] ?? ''}>
167
156
  <a
168
157
  href="#{entry.id}"
169
- onclick={(e) => handleClick(e, entry.id)}
170
- class={getLevelStyles(entry.level, isActive)}
171
- aria-current={isActive ? 'true' : undefined}
158
+ onclick={() => (activeId = entry.id)}
159
+ class={linkClass(entry.level, isActive)}
160
+ aria-current={isActive ? 'location' : undefined}
172
161
  >
173
162
  {entry.text}
174
163
  </a>
@@ -1,11 +1,18 @@
1
+ import type { TocEntry } from '../types/docs.js';
1
2
  interface Props {
2
- /** HTML content to extract headings from */
3
- html: string;
4
- /** Maximum heading depth to include (default: 3) */
3
+ /** Prebuilt entries, e.g. `page.toc` from `renderDocs`. Takes precedence over `html`. */
4
+ toc?: TocEntry[];
5
+ /** HTML to extract headings from in the browser, when there is no `toc` */
6
+ html?: string;
7
+ /** Shallowest heading level to include (default: 1) */
8
+ minDepth?: number;
9
+ /** Deepest heading level to include (default: 3) */
5
10
  maxDepth?: number;
11
+ /** Height of any fixed header, in px: where a heading counts as reached (default: 80) */
12
+ offset?: number;
6
13
  /** Custom class */
7
14
  class?: string;
8
- /** Title for the TOC section */
15
+ /** Title for the TOC section. Empty hides it (the nav is then labelled "On this page"). */
9
16
  title?: string;
10
17
  /** Make the TOC sticky */
11
18
  sticky?: boolean;
@@ -0,0 +1,42 @@
1
+ <script lang="ts">
2
+ /**
3
+ * TagIndex - every tag in use, grouped by first letter (the Docusaurus `/tags` page).
4
+ *
5
+ * Pass `tagCounts(tags, readablePages)` from `@classic-homes/theme-docs/nav`.
6
+ */
7
+ import { cn } from '../utils.js';
8
+ import TagList from './TagList.svelte';
9
+ import type { Tag } from '../content/types.js';
10
+
11
+ interface Props {
12
+ tags: (Tag & { count: number })[];
13
+ href?: (tag: Tag) => string;
14
+ /** Heading level for each letter (default: `h2`) */
15
+ as?: 'h2' | 'h3';
16
+ class?: string;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ let { tags, href, as = 'h2', class: className, ...restProps }: Props = $props();
21
+
22
+ const groups = $derived.by(() => {
23
+ const byLetter = new Map<string, (Tag & { count: number })[]>();
24
+ for (const tag of [...tags].sort((a, b) => a.label.localeCompare(b.label))) {
25
+ const letter = tag.label.charAt(0).toUpperCase();
26
+ if (!byLetter.has(letter)) byLetter.set(letter, []);
27
+ byLetter.get(letter)!.push(tag);
28
+ }
29
+ return [...byLetter];
30
+ });
31
+ </script>
32
+
33
+ <div class={cn('space-y-6', className)} {...restProps}>
34
+ {#each groups as [letter, group] (letter)}
35
+ <section>
36
+ <svelte:element this={as} class="mb-2 text-lg font-semibold text-foreground"
37
+ >{letter}</svelte:element
38
+ >
39
+ <TagList tags={group} {href} label="Tags starting with {letter}" />
40
+ </section>
41
+ {/each}
42
+ </div>
@@ -0,0 +1,14 @@
1
+ import type { Tag } from '../content/types.js';
2
+ interface Props {
3
+ tags: (Tag & {
4
+ count: number;
5
+ })[];
6
+ href?: (tag: Tag) => string;
7
+ /** Heading level for each letter (default: `h2`) */
8
+ as?: 'h2' | 'h3';
9
+ class?: string;
10
+ [key: string]: unknown;
11
+ }
12
+ declare const TagIndex: import("svelte").Component<Props, {}, "">;
13
+ type TagIndex = ReturnType<typeof TagIndex>;
14
+ export default TagIndex;
@@ -0,0 +1,45 @@
1
+ <script lang="ts">
2
+ /**
3
+ * TagList - a page's tags as links to their tag pages.
4
+ */
5
+ import { Badge } from '@classic-homes/theme-svelte';
6
+ import { cn } from '../utils.js';
7
+ import type { Tag } from '../content/types.js';
8
+
9
+ interface Props {
10
+ tags: (Tag & { count?: number })[];
11
+ /** Route of a tag's page (default: `/docs/tags/<id>`) */
12
+ href?: (tag: Tag) => string;
13
+ /** Accessible name of the list (default: "Tags") */
14
+ label?: string;
15
+ class?: string;
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ let {
20
+ tags,
21
+ href = (tag) => `/docs/tags/${encodeURIComponent(tag.id)}`,
22
+ label = 'Tags',
23
+ class: className,
24
+ ...restProps
25
+ }: Props = $props();
26
+ </script>
27
+
28
+ {#if tags.length}
29
+ <ul aria-label={label} class={cn('flex flex-wrap gap-2', className)} {...restProps}>
30
+ {#each tags as tag (tag.id)}
31
+ <li>
32
+ <a
33
+ href={href(tag)}
34
+ title={tag.description || undefined}
35
+ class="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
36
+ >
37
+ <Badge variant="secondary" clickable>
38
+ {tag.label}{#if tag.count !== undefined}<span class="ml-1 opacity-70">{tag.count}</span
39
+ >{/if}
40
+ </Badge>
41
+ </a>
42
+ </li>
43
+ {/each}
44
+ </ul>
45
+ {/if}
@@ -0,0 +1,15 @@
1
+ import type { Tag } from '../content/types.js';
2
+ interface Props {
3
+ tags: (Tag & {
4
+ count?: number;
5
+ })[];
6
+ /** Route of a tag's page (default: `/docs/tags/<id>`) */
7
+ href?: (tag: Tag) => string;
8
+ /** Accessible name of the list (default: "Tags") */
9
+ label?: string;
10
+ class?: string;
11
+ [key: string]: unknown;
12
+ }
13
+ declare const TagList: import("svelte").Component<Props, {}, "">;
14
+ type TagList = ReturnType<typeof TagList>;
15
+ export default TagList;
@@ -5,19 +5,28 @@
5
5
  * Provides a mobile flyout panel and optional desktop sidebar for the
6
6
  * TableOfContents component. Features a paper folder tab trigger on mobile
7
7
  * that slides out a panel from the right side. The tab is physically attached
8
- * to the panel so they move together as one unit.
8
+ * to the panel so they move together as one unit. While open, the panel is a modal
9
+ * dialog that keeps focus inside; while closed, its links are inert.
9
10
  */
10
11
  import { onMount } from 'svelte';
11
12
  import { fade } from 'svelte/transition';
13
+ import { useFocusTrap } from '@classic-homes/theme-svelte';
12
14
  import { cn } from '../utils.js';
13
15
  import TableOfContents from './TableOfContents.svelte';
16
+ import type { TocEntry } from '../types/docs.js';
14
17
 
15
18
  interface Props {
16
- /** HTML content to extract headings from */
17
- html: string;
19
+ /** Prebuilt entries, e.g. `page.toc` from `renderDocs`. Takes precedence over `html`. */
20
+ toc?: TocEntry[];
21
+ /** HTML to extract headings from in the browser, when there is no `toc` */
22
+ html?: string;
23
+ /** Shallowest heading level to include (default: 1) */
24
+ minDepth?: number;
18
25
  /** Maximum heading depth to include (default: 3) */
19
26
  maxDepth?: number;
20
- /** Breakpoint for desktop view (default: 'xl') */
27
+ /** Height of any fixed header, in px (default: 80) */
28
+ offset?: number;
29
+ /** Breakpoint for desktop view (default: 'lg') */
21
30
  breakpoint?: 'lg' | 'xl' | '2xl';
22
31
  /** Show desktop sidebar (default: true) */
23
32
  showDesktop?: boolean;
@@ -31,8 +40,11 @@
31
40
  }
32
41
 
33
42
  let {
43
+ toc,
34
44
  html,
45
+ minDepth = 1,
35
46
  maxDepth = 3,
47
+ offset = 80,
36
48
  breakpoint = 'lg',
37
49
  showDesktop = true,
38
50
  mobileClass,
@@ -42,6 +54,8 @@
42
54
  }: Props = $props();
43
55
 
44
56
  let open = $state(false);
57
+ const { action: trapFocus } = useFocusTrap({ active: () => open });
58
+ const hasContent = $derived(toc ? toc.length > 0 : Boolean(html));
45
59
  let tabButtonRef = $state<HTMLButtonElement | null>(null);
46
60
  let previousActiveElement: HTMLElement | null = null;
47
61
 
@@ -135,7 +149,7 @@
135
149
  </script>
136
150
 
137
151
  <!-- Mobile TOC Panel with attached tab - always rendered, slides via CSS transform -->
138
- {#if html}
152
+ {#if hasContent}
139
153
  <!-- Backdrop - only shown when open -->
140
154
  {#if open}
141
155
  <button
@@ -148,7 +162,11 @@
148
162
 
149
163
  <!-- Panel container with attached tab -->
150
164
  <div
165
+ use:trapFocus
151
166
  class={cn(breakpointHide, 'toc-panel-container', open && 'toc-panel-open', mobileClass)}
167
+ role={open ? 'dialog' : undefined}
168
+ aria-modal={open ? 'true' : undefined}
169
+ aria-label={open ? title : undefined}
152
170
  {...restProps}
153
171
  >
154
172
  <!-- Tab - physically attached to panel -->
@@ -165,7 +183,7 @@
165
183
  </button>
166
184
 
167
185
  <!-- Panel content -->
168
- <div class="toc-panel-content">
186
+ <div class="toc-panel-content" inert={!open}>
169
187
  <div class="toc-panel-header">
170
188
  <span class="text-sm font-medium text-foreground">{title}</span>
171
189
  <button class={closeButtonClasses} onclick={close} aria-label="Close table of contents">
@@ -186,14 +204,14 @@
186
204
  </button>
187
205
  </div>
188
206
  <div class="p-5">
189
- <TableOfContents {html} {maxDepth} title="" />
207
+ <TableOfContents {toc} {html} {minDepth} {maxDepth} {offset} title="" />
190
208
  </div>
191
209
  </div>
192
210
  </div>
193
211
  {/if}
194
212
 
195
213
  <!-- Desktop TOC Sidebar -->
196
- {#if html && showDesktop}
214
+ {#if hasContent && showDesktop}
197
215
  <aside
198
216
  class={cn(
199
217
  breakpointShow,
@@ -201,7 +219,7 @@
201
219
  desktopClass
202
220
  )}
203
221
  >
204
- <TableOfContents {html} {maxDepth} {title} />
222
+ <TableOfContents {toc} {html} {minDepth} {maxDepth} {offset} {title} />
205
223
  </aside>
206
224
  {/if}
207
225
 
@@ -1,9 +1,16 @@
1
+ import type { TocEntry } from '../types/docs.js';
1
2
  interface Props {
2
- /** HTML content to extract headings from */
3
- html: string;
3
+ /** Prebuilt entries, e.g. `page.toc` from `renderDocs`. Takes precedence over `html`. */
4
+ toc?: TocEntry[];
5
+ /** HTML to extract headings from in the browser, when there is no `toc` */
6
+ html?: string;
7
+ /** Shallowest heading level to include (default: 1) */
8
+ minDepth?: number;
4
9
  /** Maximum heading depth to include (default: 3) */
5
10
  maxDepth?: number;
6
- /** Breakpoint for desktop view (default: 'xl') */
11
+ /** Height of any fixed header, in px (default: 80) */
12
+ offset?: number;
13
+ /** Breakpoint for desktop view (default: 'lg') */
7
14
  breakpoint?: 'lg' | 'xl' | '2xl';
8
15
  /** Show desktop sidebar (default: true) */
9
16
  showDesktop?: boolean;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Progressive enhancements for rendered markdown. Call them once the HTML is in the DOM
3
+ * (e.g. from an `$effect`); each returns a cleanup function that undoes its changes.
4
+ */
5
+ /**
6
+ * Add a "Copy code" button to every highlighted code block under `root`, as Docusaurus
7
+ * does. A polite live region announces "Copied" for screen readers.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * $effect(() => enhanceCodeBlocks(article));
12
+ * ```
13
+ */
14
+ export declare function enhanceCodeBlocks(root: ParentNode): () => void;
15
+ /**
16
+ * Wrap occurrences of `terms` in text under `root` with `<mark class="search-highlight">`,
17
+ * as Docusaurus does for `?_highlight=` after a search. Code blocks are left alone.
18
+ * Returns a cleanup that removes the marks. Scrolls nothing; the first mark is returned
19
+ * for the caller to scroll to if it wants.
20
+ */
21
+ export declare function highlightTerms(root: ParentNode, terms: string[]): {
22
+ cleanup: () => void;
23
+ first: HTMLElement | null;
24
+ };
25
+ /**
26
+ * Give a rendered mermaid SVG an image role and a name: its `accTitle`/`<title>` when it
27
+ * has one, else "Diagram". Mermaid's own `aria-labelledby` (from `accTitle`) is kept.
28
+ */
29
+ export declare function labelDiagram(container: Element): void;