@classic-homes/theme-docs 0.1.0 → 0.2.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 (52) 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 +172 -0
  6. package/dist/lib/components/DocPage.svelte.d.ts +60 -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 +2 -0
  12. package/dist/lib/components/TableOfContents.svelte +114 -125
  13. package/dist/lib/components/TableOfContents.svelte.d.ts +11 -4
  14. package/dist/lib/components/TagIndex.svelte +42 -0
  15. package/dist/lib/components/TagIndex.svelte.d.ts +14 -0
  16. package/dist/lib/components/TagList.svelte +45 -0
  17. package/dist/lib/components/TagList.svelte.d.ts +15 -0
  18. package/dist/lib/components/TocPanel.svelte +27 -9
  19. package/dist/lib/components/TocPanel.svelte.d.ts +10 -3
  20. package/dist/lib/components/enhance.d.ts +29 -0
  21. package/dist/lib/components/enhance.js +179 -0
  22. package/dist/lib/components/sidebar.d.ts +33 -0
  23. package/dist/lib/components/sidebar.js +84 -0
  24. package/dist/lib/content/browser.d.ts +6 -0
  25. package/dist/lib/content/browser.js +5 -0
  26. package/dist/lib/content/index.d.ts +13 -0
  27. package/dist/lib/content/index.js +12 -0
  28. package/dist/lib/content/load.d.ts +77 -0
  29. package/dist/lib/content/load.js +366 -0
  30. package/dist/lib/content/nav.d.ts +36 -0
  31. package/dist/lib/content/nav.js +81 -0
  32. package/dist/lib/content/render.d.ts +38 -0
  33. package/dist/lib/content/render.js +84 -0
  34. package/dist/lib/content/types.d.ts +90 -0
  35. package/dist/lib/content/types.js +5 -0
  36. package/dist/lib/index.d.ts +14 -2
  37. package/dist/lib/index.js +14 -2
  38. package/dist/lib/parser/api.d.ts +12 -0
  39. package/dist/lib/parser/api.js +10 -0
  40. package/dist/lib/parser/extensions.d.ts +27 -15
  41. package/dist/lib/parser/extensions.js +58 -53
  42. package/dist/lib/parser/index.d.ts +4 -1
  43. package/dist/lib/parser/index.js +104 -27
  44. package/dist/lib/sanitize/index.d.ts +11 -0
  45. package/dist/lib/sanitize/index.js +122 -0
  46. package/dist/lib/search/index.d.ts +57 -0
  47. package/dist/lib/search/index.js +82 -0
  48. package/dist/lib/styles/markdown.css +138 -0
  49. package/dist/lib/types/frontmatter.d.ts +21 -0
  50. package/dist/lib/vite/index.d.ts +17 -0
  51. package/dist/lib/vite/index.js +38 -0
  52. package/package.json +51 -4
@@ -0,0 +1,179 @@
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
+ const COPY_ICON = '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
6
+ const CHECK_ICON = '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
7
+ /**
8
+ * Add a "Copy code" button to every highlighted code block under `root`, as Docusaurus
9
+ * does. A polite live region announces "Copied" for screen readers.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * $effect(() => enhanceCodeBlocks(article));
14
+ * ```
15
+ */
16
+ export function enhanceCodeBlocks(root) {
17
+ const added = [];
18
+ const wrapped = [];
19
+ const timers = [];
20
+ const status = document.createElement('span');
21
+ status.className = 'docs-sr-only';
22
+ status.setAttribute('role', 'status');
23
+ status.setAttribute('aria-live', 'polite');
24
+ root.appendChild(status);
25
+ for (const pre of root.querySelectorAll('pre.shiki')) {
26
+ let wrapper = pre.parentElement;
27
+ if (!wrapper?.classList.contains('code-block-wrapper')) {
28
+ wrapper = document.createElement('div');
29
+ wrapper.className = 'code-block-wrapper';
30
+ pre.replaceWith(wrapper);
31
+ wrapper.appendChild(pre);
32
+ wrapped.push(wrapper);
33
+ }
34
+ if (wrapper.querySelector(':scope > .code-copy-button'))
35
+ continue;
36
+ wrapper.classList.add('has-copy-button');
37
+ const button = document.createElement('button');
38
+ button.type = 'button';
39
+ button.className = 'code-copy-button';
40
+ button.setAttribute('aria-label', 'Copy code');
41
+ button.title = 'Copy code';
42
+ button.innerHTML = COPY_ICON;
43
+ button.addEventListener('click', async () => {
44
+ if (!(await copyText((pre.textContent ?? '').replace(/\n$/, '')))) {
45
+ status.textContent = 'Copy failed';
46
+ return;
47
+ }
48
+ button.innerHTML = CHECK_ICON;
49
+ button.dataset.copied = '';
50
+ status.textContent = 'Copied';
51
+ timers.push(setTimeout(() => {
52
+ button.innerHTML = COPY_ICON;
53
+ delete button.dataset.copied;
54
+ status.textContent = '';
55
+ }, 2000));
56
+ });
57
+ wrapper.appendChild(button);
58
+ added.push(button);
59
+ }
60
+ return () => {
61
+ for (const timer of timers)
62
+ clearTimeout(timer);
63
+ for (const button of added) {
64
+ button.parentElement?.classList.remove('has-copy-button');
65
+ button.remove();
66
+ }
67
+ for (const wrapper of wrapped)
68
+ wrapper.replaceWith(...wrapper.childNodes);
69
+ status.remove();
70
+ };
71
+ }
72
+ /**
73
+ * Copy text with the Clipboard API, which needs a secure context (HTTPS or localhost),
74
+ * falling back to a hidden textarea and `execCommand('copy')` elsewhere.
75
+ */
76
+ async function copyText(text) {
77
+ try {
78
+ if (navigator.clipboard && window.isSecureContext) {
79
+ await navigator.clipboard.writeText(text);
80
+ return true;
81
+ }
82
+ }
83
+ catch {
84
+ // fall through to the legacy path
85
+ }
86
+ const area = document.createElement('textarea');
87
+ area.value = text;
88
+ area.setAttribute('readonly', '');
89
+ area.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
90
+ document.body.appendChild(area);
91
+ const focused = document.activeElement;
92
+ area.select();
93
+ try {
94
+ return document.execCommand('copy');
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ finally {
100
+ area.remove();
101
+ focused?.focus();
102
+ }
103
+ }
104
+ const SKIP = new Set(['PRE', 'CODE', 'SCRIPT', 'STYLE', 'MARK', 'SVG', 'TEXTAREA']);
105
+ /**
106
+ * Wrap occurrences of `terms` in text under `root` with `<mark class="search-highlight">`,
107
+ * as Docusaurus does for `?_highlight=` after a search. Code blocks are left alone.
108
+ * Returns a cleanup that removes the marks. Scrolls nothing; the first mark is returned
109
+ * for the caller to scroll to if it wants.
110
+ */
111
+ export function highlightTerms(root, terms) {
112
+ const words = [...new Set(terms.map((t) => t.trim()).filter((t) => t.length >= 2))];
113
+ if (words.length === 0)
114
+ return { cleanup: () => { }, first: null };
115
+ const pattern = new RegExp(words
116
+ .sort((a, b) => b.length - a.length)
117
+ .map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
118
+ .join('|'), 'gi');
119
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
120
+ acceptNode(node) {
121
+ for (let el = node.parentElement; el && el !== root; el = el.parentElement) {
122
+ if (SKIP.has(el.tagName.toUpperCase()))
123
+ return NodeFilter.FILTER_REJECT;
124
+ }
125
+ return NodeFilter.FILTER_ACCEPT;
126
+ },
127
+ });
128
+ const nodes = [];
129
+ while (walker.nextNode())
130
+ nodes.push(walker.currentNode);
131
+ const marks = [];
132
+ for (const node of nodes) {
133
+ const text = node.data;
134
+ pattern.lastIndex = 0;
135
+ if (!pattern.test(text))
136
+ continue;
137
+ pattern.lastIndex = 0;
138
+ const fragment = document.createDocumentFragment();
139
+ let last = 0;
140
+ for (const match of text.matchAll(pattern)) {
141
+ const at = match.index ?? 0;
142
+ if (at > last)
143
+ fragment.append(text.slice(last, at));
144
+ const mark = document.createElement('mark');
145
+ mark.className = 'search-highlight';
146
+ mark.textContent = match[0];
147
+ fragment.append(mark);
148
+ marks.push(mark);
149
+ last = at + match[0].length;
150
+ }
151
+ if (last < text.length)
152
+ fragment.append(text.slice(last));
153
+ node.replaceWith(fragment);
154
+ }
155
+ return {
156
+ first: marks[0] ?? null,
157
+ cleanup: () => {
158
+ for (const mark of marks) {
159
+ const parent = mark.parentNode;
160
+ mark.replaceWith(mark.textContent ?? '');
161
+ parent?.normalize();
162
+ }
163
+ },
164
+ };
165
+ }
166
+ /**
167
+ * Give a rendered mermaid SVG an image role and a name: its `accTitle`/`<title>` when it
168
+ * has one, else "Diagram". Mermaid's own `aria-labelledby` (from `accTitle`) is kept.
169
+ */
170
+ export function labelDiagram(container) {
171
+ const svg = container.querySelector('svg');
172
+ if (!svg)
173
+ return;
174
+ svg.setAttribute('role', 'img');
175
+ if (svg.hasAttribute('aria-labelledby') || svg.hasAttribute('aria-label'))
176
+ return;
177
+ const title = svg.querySelector('title')?.textContent?.trim();
178
+ svg.setAttribute('aria-label', title || 'Diagram');
179
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Docs sidebars on theme-svelte's `Sidebar` / `DashboardLayout`.
3
+ */
4
+ import { type NavSection } from '@classic-homes/theme-svelte';
5
+ import type { NavItem } from '../content/types.js';
6
+ export interface ToSidebarNavOptions {
7
+ /** Section title for the sidebar's top-level pages (default: none) */
8
+ title?: string;
9
+ /** Prefix for section and item IDs, so several sidebars keep separate expansion state */
10
+ idPrefix?: string;
11
+ /** Label of the entry for a category's own page (default: `'Overview'`) */
12
+ overviewLabel?: string;
13
+ }
14
+ /**
15
+ * Map a docs sidebar (`Sidebar.items` from `loadDocs`) onto theme-svelte navigation.
16
+ *
17
+ * theme-svelte's sidebar has three levels: a section heading, its items, and one level of
18
+ * children. Top-level pages become an untitled first section, each top-level category a
19
+ * collapsible section, and a nested category an item with children. A category's own page
20
+ * (linked doc or generated index) becomes its first entry. Anything nested deeper is
21
+ * flattened to its pages under the nearest level the theme renders.
22
+ *
23
+ * Sections and items on the path to `current` are expanded; so is any category whose
24
+ * `_category_.json` sets `collapsed: false`.
25
+ */
26
+ export declare function toSidebarNav(items: NavItem[], current: string, options?: ToSidebarNavOptions): NavSection[];
27
+ /**
28
+ * Open the sections and items leading to the active entry, without saving it as the
29
+ * reader's preference. The sidebar restores each reader's saved expansion when it mounts;
30
+ * call this after every navigation (SvelteKit: `afterNavigate`) to keep the current page
31
+ * visible on top of that.
32
+ */
33
+ export declare function revealActive(navigation: NavSection[]): void;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Docs sidebars on theme-svelte's `Sidebar` / `DashboardLayout`.
3
+ */
4
+ import { sidebarStore, } from '@classic-homes/theme-svelte';
5
+ import { navContains } from '../content/nav.js';
6
+ /**
7
+ * Map a docs sidebar (`Sidebar.items` from `loadDocs`) onto theme-svelte navigation.
8
+ *
9
+ * theme-svelte's sidebar has three levels: a section heading, its items, and one level of
10
+ * children. Top-level pages become an untitled first section, each top-level category a
11
+ * collapsible section, and a nested category an item with children. A category's own page
12
+ * (linked doc or generated index) becomes its first entry. Anything nested deeper is
13
+ * flattened to its pages under the nearest level the theme renders.
14
+ *
15
+ * Sections and items on the path to `current` are expanded; so is any category whose
16
+ * `_category_.json` sets `collapsed: false`.
17
+ */
18
+ export function toSidebarNav(items, current, options = {}) {
19
+ const { title, idPrefix = '', overviewLabel = 'Overview' } = options;
20
+ const link = (name, route) => ({
21
+ id: `${idPrefix}${route}`,
22
+ name,
23
+ href: route,
24
+ active: route === current,
25
+ });
26
+ const entriesOf = (category) => [
27
+ ...(category.route ? [link(overviewLabel, category.route)] : []),
28
+ ...category.items.map(toItem),
29
+ ];
30
+ function toItem(item) {
31
+ if (item.type === 'page')
32
+ return link(item.title, item.route);
33
+ return {
34
+ id: `${idPrefix}${item.label}:${item.route ?? ''}`,
35
+ name: item.label,
36
+ children: entriesOf(item).map((child) => (child.children ? flatten(child) : child)),
37
+ expanded: navContains(item, current) || item.collapsed === false,
38
+ };
39
+ }
40
+ /** Deeper than the theme renders: keep the entry, pointing at its first page. */
41
+ function flatten(item) {
42
+ const leaves = (i) => i.children ? i.children.flatMap(leaves) : [i];
43
+ const all = leaves(item);
44
+ return { ...item, children: undefined, href: all[0]?.href, active: all.some((l) => l.active) };
45
+ }
46
+ const sections = [];
47
+ const pages = items.filter((item) => item.type === 'page');
48
+ if (pages.length) {
49
+ sections.push({ id: `${idPrefix}:pages`, title, items: pages.map(toItem) });
50
+ }
51
+ for (const item of items) {
52
+ if (item.type !== 'category')
53
+ continue;
54
+ sections.push({
55
+ id: `${idPrefix}${item.label}`,
56
+ title: item.label,
57
+ items: entriesOf(item),
58
+ collapsible: item.collapsible !== false,
59
+ expanded: navContains(item, current) || item.collapsed === false,
60
+ });
61
+ }
62
+ return sections;
63
+ }
64
+ /**
65
+ * Open the sections and items leading to the active entry, without saving it as the
66
+ * reader's preference. The sidebar restores each reader's saved expansion when it mounts;
67
+ * call this after every navigation (SvelteKit: `afterNavigate`) to keep the current page
68
+ * visible on top of that.
69
+ */
70
+ export function revealActive(navigation) {
71
+ const sections = [];
72
+ const items = [];
73
+ const walk = (list) => list.some((item) => {
74
+ const inside = Boolean(item.active) || (item.children ? walk(item.children) : false);
75
+ if (inside && item.children)
76
+ items.push(item.id);
77
+ return inside;
78
+ });
79
+ for (const section of navigation)
80
+ if (walk(section.items))
81
+ sections.push(section.id);
82
+ sidebarStore.forceExpandSections(sections);
83
+ sidebarStore.forceExpandItems(items);
84
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `@classic-homes/theme-docs/nav` — content-model types and sidebar helpers without any
3
+ * Node imports, for layouts and client code that work with a `loadDocs` bundle.
4
+ */
5
+ export * from './nav.js';
6
+ export type * from './types.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `@classic-homes/theme-docs/nav` — content-model types and sidebar helpers without any
3
+ * Node imports, for layouts and client code that work with a `loadDocs` bundle.
4
+ */
5
+ export * from './nav.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@classic-homes/theme-docs/content` — a Docusaurus-style docs directory as data.
3
+ *
4
+ * `loadDocs` reads markdown into routes, autogenerated sidebars, generated-index pages
5
+ * and tags; `renderDocs` turns it into HTML with tables of contents and search text.
6
+ * Run both at build time and ship the result as JSON. Node-only (reads the file system);
7
+ * the navigation helpers are also exported from `@classic-homes/theme-docs/nav` for the
8
+ * browser.
9
+ */
10
+ export { loadDocs, parseFrontmatter, routeOf, type LoadDocsOptions, type LoadedDocs, type AnchorLink, type BrokenLinkPolicy, type TagDefinition, } from './load.js';
11
+ export { renderDocs, plainText, type RenderDocsOptions, type RenderDocsResult } from './render.js';
12
+ export * from './nav.js';
13
+ export type * from './types.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@classic-homes/theme-docs/content` — a Docusaurus-style docs directory as data.
3
+ *
4
+ * `loadDocs` reads markdown into routes, autogenerated sidebars, generated-index pages
5
+ * and tags; `renderDocs` turns it into HTML with tables of contents and search text.
6
+ * Run both at build time and ship the result as JSON. Node-only (reads the file system);
7
+ * the navigation helpers are also exported from `@classic-homes/theme-docs/nav` for the
8
+ * browser.
9
+ */
10
+ export { loadDocs, parseFrontmatter, routeOf, } from './load.js';
11
+ export { renderDocs, plainText } from './render.js';
12
+ export * from './nav.js';
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Read a directory of markdown into the docs content model, the way Docusaurus does:
3
+ * routes from file paths, autogenerated sidebars ordered by `sidebar_position` and
4
+ * `_category_.json`, generated-index category pages, tags, and relative `.md` links
5
+ * rewritten to routes. Node-only (reads the file system).
6
+ */
7
+ import type { FrontmatterData } from '../types/frontmatter.js';
8
+ import type { DocSource, DocsContent } from './types.js';
9
+ export type BrokenLinkPolicy = 'throw' | 'warn' | 'ignore';
10
+ export interface TagDefinition {
11
+ label?: string;
12
+ description?: string;
13
+ }
14
+ export interface LoadDocsOptions {
15
+ /** The docs directory */
16
+ dir: string;
17
+ /** Route the docs directory is served at, as content links write it. Default: `/docs` */
18
+ routeBase?: string;
19
+ /**
20
+ * Path the whole site is served under (SvelteKit `paths.base`, Docusaurus `baseUrl`), e.g.
21
+ * `/svelte`. Prefixed to every route, and to root-relative links and images in content,
22
+ * so markdown can keep writing `/docs/...`. Default: `''`
23
+ */
24
+ baseUrl?: string;
25
+ /**
26
+ * Directories (relative to `dir`) that each get their own autogenerated sidebar, like
27
+ * Docusaurus `{type: 'autogenerated', dirName}`. Default: one sidebar for the whole tree.
28
+ */
29
+ sidebarRoots?: string[];
30
+ /**
31
+ * Closed tag vocabulary (the contents of a Docusaurus `tags.yml`). Pages using a tag
32
+ * outside it fail the load, as `onInlineTags: 'throw'` does. Default: any tag is allowed.
33
+ */
34
+ tags?: Record<string, TagDefinition | null>;
35
+ /** Broken `.md` links and unknown `routeBase` links. Default: `'throw'`, as Docusaurus */
36
+ onBrokenLinks?: BrokenLinkPolicy;
37
+ /** Load pages with `draft: true`. Default: false */
38
+ includeDrafts?: boolean;
39
+ /**
40
+ * Extra rules for each page, run once every page is known. Throw to fail the load.
41
+ * The place for site-specific checks (required front matter, access rules, …).
42
+ */
43
+ validate?: (page: DocSource, content: Omit<DocsContent, 'warnings'>) => void;
44
+ }
45
+ /** A link from one page to a heading, checked by `renderDocs` once pages have IDs. */
46
+ export interface AnchorLink {
47
+ /** File the link is written in */
48
+ file: string;
49
+ /** Route of the linked page */
50
+ route: string;
51
+ anchor: string;
52
+ }
53
+ /** `loadDocs` result plus the anchor links found while resolving (for `renderDocs`). */
54
+ export interface LoadedDocs extends DocsContent {
55
+ anchorLinks: AnchorLink[];
56
+ }
57
+ /** Parse YAML front matter; returns `{ data, body }`. Throws on invalid YAML. */
58
+ export declare function parseFrontmatter(raw: string, file: string): {
59
+ data: FrontmatterData;
60
+ body: string;
61
+ };
62
+ /** `it/network/vpn/index.md` → `/docs/it/network/vpn`. */
63
+ export declare function routeOf(file: string, routeBase?: string): string;
64
+ /**
65
+ * Read a docs directory into pages, sidebars, generated-index pages and tags.
66
+ *
67
+ * Routes come from file paths; `slug` and `id` front matter are rejected rather than
68
+ * silently ignored. Relative links to `.md`/`.mdx` files become routes (anchors kept),
69
+ * Docusaurus' `pathname://` prefix is unwrapped, and links under `routeBase` are checked.
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * const docs = loadDocs({ dir: 'docs', sidebarRoots: ['guides', 'reference'] });
74
+ * const { pages } = await renderDocs(docs, { parse: { headingIdStyle: 'github' } });
75
+ * ```
76
+ */
77
+ export declare function loadDocs(options: LoadDocsOptions): LoadedDocs;