@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
@@ -1,7 +1,7 @@
1
- import { Marked, Renderer } from 'marked';
1
+ import { Marked, Parser, Renderer } from 'marked';
2
2
  import yaml from 'js-yaml';
3
3
  import { getHighlighter, escapeHtml } from '../highlighter/index.js';
4
- import { getAllExtensions, resetFootnoteStore, renderFootnotes } from './extensions.js';
4
+ import { createFootnoteStore, getAllExtensions, renderFootnotes, renderMermaidPlaceholder, } from './extensions.js';
5
5
  import { createSlugger, EXPLICIT_ID_PATTERN } from './slug.js';
6
6
  /** Undo the entity escaping marked applies to heading text, so `A & B` slugs as `A & B`. */
7
7
  function decodeEntities(text) {
@@ -56,8 +56,11 @@ function extractFrontmatter(content) {
56
56
  * @param options.theme - Shiki theme for code highlighting ('github-dark' | 'github-light' | 'one-dark-pro')
57
57
  * @param options.generateHeadingIds - Whether to generate IDs for headings (default: true)
58
58
  * @param options.headingIdStyle - 'default', or 'github' for GitHub/Docusaurus-compatible IDs
59
+ * @param options.headingAnchors - Add a `#` permalink to each heading (default: false)
59
60
  * @param options.components - Tag names to render as component placeholders (see `mountComponents`)
60
- * @returns Parsed markdown with frontmatter data, cleaned markdown, and rendered HTML
61
+ * @param options.externalLinks - Open http(s) links in a new tab, announced to screen readers
62
+ * @param options.langAlias - Map fence names to Shiki languages, e.g. `{ ios: 'text' }`
63
+ * @returns Parsed markdown with frontmatter data, cleaned markdown, rendered HTML, and TOC
61
64
  *
62
65
  * @example
63
66
  * ```typescript
@@ -75,32 +78,34 @@ function extractFrontmatter(content) {
75
78
  * ```
76
79
  */
77
80
  export async function parseMarkdown(content, options = {}) {
78
- const { theme = 'github-dark', generateHeadingIds = true, headingIdStyle = 'default', components = [], } = options;
81
+ const { theme: requestedTheme = 'github-dark', generateHeadingIds = true, headingIdStyle = 'default', headingAnchors = false, components = [], externalLinks = false, langAlias = {}, } = options;
79
82
  // Extract frontmatter
80
83
  const { data: frontmatter, content: markdownContent } = extractFrontmatter(content);
81
- // Get Shiki highlighter
84
+ // Get Shiki highlighter, with this page's theme and code languages loaded
82
85
  const highlighter = await getHighlighter();
83
- // Reset footnote store for this document
84
- resetFootnoteStore();
86
+ const theme = await ensureTheme(highlighter, requestedTheme);
87
+ const languageOf = (name) => langAlias[name] ?? name;
88
+ const highlightable = await ensureLanguages(highlighter, fenceLanguages(markdownContent).map(languageOf));
89
+ // Footnotes collected for this document only
90
+ const footnotes = createFootnoteStore();
85
91
  // Configure marked with custom code renderer
86
92
  const renderer = new Renderer();
87
93
  renderer.code = ({ text, lang }) => {
88
- const language = lang || 'text';
89
- // Skip mermaid blocks - they're handled by the mermaid extension
94
+ // The info string is the language, then optional meta: ```bash title="install.sh"
95
+ const [, name = '', meta = ''] = (lang ?? '').trim().match(/^(\S*)\s*(.*)$/) ?? [];
96
+ const language = languageOf(name) || 'text';
90
97
  if (language === 'mermaid') {
91
- return '';
92
- }
93
- try {
94
- const highlighted = highlighter.codeToHtml(text, {
95
- lang: language,
96
- theme: theme,
97
- });
98
- return highlighted;
99
- }
100
- catch {
101
- // Fallback for unsupported languages
102
- return `<pre class="shiki"><code class="language-${language}">${escapeHtml(text)}</code></pre>`;
98
+ // Normally caught by the mermaid extension; this covers fences it doesn't match
99
+ return renderMermaidPlaceholder(text);
103
100
  }
101
+ const pre = highlightable.has(language)
102
+ ? highlighter.codeToHtml(text, { lang: language, theme })
103
+ : `<pre class="shiki"><code class="language-${escapeHtml(language)}">${escapeHtml(text)}</code></pre>`;
104
+ const title = meta.match(/\btitle=(?:"([^"]*)"|'([^']*)'|(\S+))/);
105
+ const titleText = title ? (title[1] ?? title[2] ?? title[3]) : '';
106
+ return titleText
107
+ ? `<div class="code-block-wrapper"><div class="code-block-filename">${escapeHtml(titleText)}</div>${pre}</div>`
108
+ : pre;
104
109
  };
105
110
  if (generateHeadingIds) {
106
111
  const slug = createSlugger(headingIdStyle);
@@ -108,22 +113,44 @@ export async function parseMarkdown(content, options = {}) {
108
113
  // Slug the heading's text, not its markdown: `## Use **sudo**` → `use-sudo`
109
114
  const plain = this.parser.parseInline(tokens, this.parser.textRenderer);
110
115
  const explicitId = plain.match(EXPLICIT_ID_PATTERN)?.[1];
111
- const id = slug(decodeEntities(plain.replace(EXPLICIT_ID_PATTERN, '')), explicitId);
116
+ // Raw inline HTML (`## A <b>&amp;</b> B`) comes through as tags; slug only its text
117
+ const text = decodeEntities(plain.replace(EXPLICIT_ID_PATTERN, '').replace(/<[^>]*>/g, ''));
118
+ const id = escapeHtml(slug(text, explicitId));
112
119
  // Render inline tokens so markdown inside headings (bold, code, links) works
113
120
  const inner = this.parser.parseInline(tokens).replace(EXPLICIT_ID_PATTERN, '');
114
- return `<h${depth} id="${escapeHtml(id)}">${inner}</h${depth}>`;
121
+ const anchor = headingAnchors
122
+ ? `<a class="hash-link" href="#${id}" aria-label="Direct link to ${escapeHtml(text)}">&#8203;</a>`
123
+ : '';
124
+ return `<h${depth} id="${id}">${inner}${anchor}</h${depth}>`;
115
125
  };
116
126
  }
127
+ if (externalLinks) {
128
+ const { target = '_blank', rel = 'noopener noreferrer' } = externalLinks === true ? {} : externalLinks;
129
+ renderer.link = function (token) {
130
+ const html = Renderer.prototype.link.call(this, token);
131
+ if (!/^https?:\/\//i.test(token.href))
132
+ return html;
133
+ return html
134
+ .replace(/^<a /, `<a class="external-link" target="${escapeHtml(target)}" rel="${escapeHtml(rel)}" `)
135
+ .replace(/<\/a>$/, '<span class="external-link-hint"> (opens in new tab)</span></a>');
136
+ };
137
+ }
138
+ // Images below the fold shouldn't hold up the page
139
+ renderer.image = function (token) {
140
+ return Renderer.prototype.image
141
+ .call(this, token)
142
+ .replace(/^<img /, '<img loading="lazy" decoding="async" ');
143
+ };
117
144
  // Use a per-call Marked instance: the renderer captures per-call state
118
145
  // (theme, highlighter), and `marked.use` on the shared global instance
119
146
  // accumulates extensions across calls.
120
147
  const md = new Marked();
121
148
  // Apply all markdown extensions (admonitions, footnotes, definition lists, mermaid)
122
- md.use(...getAllExtensions(components));
149
+ md.use(...getAllExtensions(components, footnotes));
123
150
  md.use({ renderer, gfm: true });
124
151
  let html = await md.parse(markdownContent);
125
152
  // Append footnotes section if any were referenced
126
- const footnotesHtml = renderFootnotes();
153
+ const footnotesHtml = renderFootnotes(footnotes, (tokens) => Parser.parseInline(tokens, md.defaults));
127
154
  if (footnotesHtml) {
128
155
  html += footnotesHtml;
129
156
  }
@@ -131,8 +158,55 @@ export async function parseMarkdown(content, options = {}) {
131
158
  frontmatter,
132
159
  markdown: markdownContent,
133
160
  html,
161
+ toc: extractToc(html, 6),
134
162
  };
135
163
  }
164
+ /** Languages named on code fences (```bash, ~~~yaml), mermaid excluded. */
165
+ function fenceLanguages(markdown) {
166
+ const names = new Set();
167
+ for (const [, name] of markdown.matchAll(/^ {0,3}(?:`{3,}|~{3,})[ \t]*([^\s`{]+)/gm)) {
168
+ if (name !== 'mermaid')
169
+ names.add(name);
170
+ }
171
+ return [...names];
172
+ }
173
+ /** Shiki's built-in plain-text languages, which never need loading. */
174
+ const PLAIN_LANGUAGES = new Set(['text', 'txt', 'plain', 'plaintext', 'ansi']);
175
+ const warnedLanguages = new Set();
176
+ /**
177
+ * Load every language a page uses before rendering, since marked renders code blocks
178
+ * synchronously. Returns the names that can be highlighted; the rest render as plain code.
179
+ */
180
+ async function ensureLanguages(highlighter, names) {
181
+ const ok = new Set(PLAIN_LANGUAGES);
182
+ await Promise.all(names.map(async (name) => {
183
+ if (ok.has(name))
184
+ return;
185
+ try {
186
+ await highlighter.loadLanguage(name);
187
+ ok.add(name);
188
+ }
189
+ catch {
190
+ if (!warnedLanguages.has(name)) {
191
+ warnedLanguages.add(name);
192
+ console.warn(`[docs] No syntax highlighting for "${name}"; rendering as plain text`);
193
+ }
194
+ }
195
+ }));
196
+ return ok;
197
+ }
198
+ async function ensureTheme(highlighter, theme) {
199
+ if (highlighter.getLoadedThemes().includes(theme))
200
+ return theme;
201
+ try {
202
+ await highlighter.loadTheme(theme);
203
+ return theme;
204
+ }
205
+ catch {
206
+ console.warn(`[docs] Unknown theme "${theme}"; using github-dark`);
207
+ return 'github-dark';
208
+ }
209
+ }
136
210
  /**
137
211
  * Extract table of contents entries from rendered HTML content.
138
212
  *
@@ -161,8 +235,11 @@ export function extractToc(html, maxDepth = 3) {
161
235
  if (level <= maxDepth) {
162
236
  toc.push({
163
237
  level,
164
- id: match[2],
165
- text: match[3].replace(/<[^>]*>/g, ''),
238
+ id: decodeEntities(match[2]),
239
+ text: decodeEntities(match[3]
240
+ .replace(/<a class="hash-link"[\s\S]*?<\/a>/g, '')
241
+ .replace(/<[^>]*>/g, '')
242
+ .trim()),
166
243
  });
167
244
  }
168
245
  }
@@ -0,0 +1,11 @@
1
+ export interface SanitizeResult {
2
+ /** The cleaned HTML */
3
+ html: string;
4
+ /**
5
+ * What was removed, e.g. `<script>`, `p[onclick]`, `a[href=javascript:]`. Report these
6
+ * from a build so a page that loses markup says so instead of quietly rendering differently.
7
+ */
8
+ removed: string[];
9
+ }
10
+ /** Sanitize one page's rendered HTML. */
11
+ export declare function sanitizeDocsHtml(html: string): SanitizeResult;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `@classic-homes/theme-docs/sanitize` — allowlist sanitizer for HTML from `parseMarkdown`.
3
+ *
4
+ * `parseMarkdown` passes raw HTML in markdown straight through. When pages are written by
5
+ * more than a few trusted people (a git repo with many authors, a browser editor), run its
6
+ * output through `sanitizeDocsHtml` so markup cannot run script, embed frames or set event
7
+ * handlers for every reader.
8
+ *
9
+ * The allowlist is what the parser itself produces — GFM tables and task lists, footnotes,
10
+ * definition lists, Shiki highlighting, admonition icons, heading permalinks, mermaid and
11
+ * component placeholders — plus the safe inline HTML authors commonly write
12
+ * (details/summary, kbd, sub/sup, br, mark).
13
+ *
14
+ * Server/build-time only: it depends on `sanitize-html` (and through it, postcss), which
15
+ * you don't want in a browser bundle.
16
+ */
17
+ import sanitizeHtml from 'sanitize-html';
18
+ const SVG = ['svg', 'path', 'circle', 'line', 'polyline', 'polygon', 'rect', 'g'];
19
+ const OPTIONS = {
20
+ allowedTags: [
21
+ ...sanitizeHtml.defaults.allowedTags, // p, a, lists, tables, code, pre, headings, blockquote, …
22
+ 'img',
23
+ 'details',
24
+ 'summary',
25
+ 'kbd',
26
+ 'sup',
27
+ 'sub',
28
+ 'del',
29
+ 's',
30
+ 'ins',
31
+ 'mark',
32
+ 'dl',
33
+ 'dt',
34
+ 'dd',
35
+ 'section',
36
+ 'input',
37
+ 'br',
38
+ 'hr',
39
+ ...SVG,
40
+ ],
41
+ allowedAttributes: {
42
+ '*': ['class', 'id', 'title', 'aria-hidden', 'aria-label', 'role'],
43
+ a: ['href', 'name', 'target', 'rel'],
44
+ img: ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
45
+ pre: ['style', 'tabindex'],
46
+ span: ['style'],
47
+ code: ['style'],
48
+ div: ['data-component', 'data-props', 'data-mermaid'],
49
+ input: ['type', 'checked', 'disabled'],
50
+ ol: ['start', 'type'],
51
+ td: ['align', 'colspan', 'rowspan'],
52
+ th: ['align', 'colspan', 'rowspan', 'scope'],
53
+ details: ['open'],
54
+ svg: [
55
+ 'viewBox',
56
+ 'fill',
57
+ 'stroke',
58
+ 'stroke-width',
59
+ 'stroke-linecap',
60
+ 'stroke-linejoin',
61
+ 'width',
62
+ 'height',
63
+ 'xmlns',
64
+ ],
65
+ path: ['d', 'fill', 'stroke'],
66
+ circle: ['cx', 'cy', 'r', 'fill', 'stroke'],
67
+ line: ['x1', 'x2', 'y1', 'y2'],
68
+ polyline: ['points'],
69
+ polygon: ['points'],
70
+ rect: ['x', 'y', 'width', 'height', 'rx', 'ry'],
71
+ },
72
+ // Shiki colours code by inline style; nothing else may set styles.
73
+ allowedStyles: {
74
+ '*': {
75
+ color: [/^#[0-9a-f]{3,8}$/i, /^var\(--[\w-]+\)$/],
76
+ 'background-color': [/^#[0-9a-f]{3,8}$/i, /^var\(--[\w-]+\)$/],
77
+ 'font-style': [/^italic$/],
78
+ 'font-weight': [/^(bold|\d{3})$/],
79
+ 'text-decoration': [/^(underline|line-through)$/],
80
+ },
81
+ },
82
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
83
+ allowProtocolRelative: false,
84
+ // Keep the text of a dropped element (e.g. an unknown wrapper) but never of script/style.
85
+ disallowedTagsMode: 'discard',
86
+ nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript', 'iframe', 'object', 'embed'],
87
+ parser: { lowerCaseAttributeNames: false },
88
+ };
89
+ const SVG_TAGS = new Set(SVG);
90
+ const allowedTags = new Set(OPTIONS.allowedTags);
91
+ const allowedAttributes = OPTIONS.allowedAttributes;
92
+ const allowedAttrs = (tag) => [
93
+ ...allowedAttributes['*'],
94
+ ...(allowedAttributes[tag] ?? []),
95
+ ];
96
+ /** Sanitize one page's rendered HTML. */
97
+ export function sanitizeDocsHtml(html) {
98
+ const removed = new Set();
99
+ const clean = sanitizeHtml(html, {
100
+ ...OPTIONS,
101
+ transformTags: {
102
+ '*': (tagName, rawAttribs) => {
103
+ // HTML attribute names are case-insensitive, and content written as MDX uses JSX
104
+ // casing (`colSpan`, `rowSpan`). Lowercase them so the allowlist matches; SVG keeps
105
+ // its case-sensitive names (`viewBox`).
106
+ const attribs = SVG_TAGS.has(tagName)
107
+ ? rawAttribs
108
+ : Object.fromEntries(Object.entries(rawAttribs).map(([name, value]) => [name.toLowerCase(), value]));
109
+ if (!allowedTags.has(tagName))
110
+ removed.add(`<${tagName}>`);
111
+ else
112
+ for (const name of Object.keys(attribs))
113
+ if (!allowedAttrs(tagName).includes(name))
114
+ removed.add(`${tagName}[${name}]`);
115
+ if (attribs.href && /^\s*(javascript|data|vbscript):/i.test(attribs.href))
116
+ removed.add(`${tagName}[href=${attribs.href.split(':')[0].trim()}:]`);
117
+ return { tagName, attribs };
118
+ },
119
+ },
120
+ });
121
+ return { html: clean, removed: [...removed] };
122
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `@classic-homes/theme-docs/search` — full-text search over rendered docs (MiniSearch).
3
+ *
4
+ * Build the index at build time with `buildSearchIndex`, ship its JSON, and query it on
5
+ * the server or in the browser with `createSearch`. A `filter` runs inside MiniSearch,
6
+ * before results and snippets are built, so access rules can drop hits a reader may not
7
+ * see without their text ever leaving the index.
8
+ */
9
+ import { type Options } from 'minisearch';
10
+ import type { RenderedDoc } from '../content/types.js';
11
+ /** Fields stored with each hit, available to `filter` and in results. */
12
+ export interface StoredFields {
13
+ title: string;
14
+ description: string;
15
+ sidebar: string | null;
16
+ }
17
+ /**
18
+ * Index options. `buildSearchIndex` and `createSearch` must use the same ones, so both
19
+ * read them from here.
20
+ */
21
+ export declare const SEARCH_OPTIONS: Options;
22
+ type Indexable = Pick<RenderedDoc, 'route' | 'title' | 'description' | 'sidebar' | 'toc' | 'text'>;
23
+ /** Index every page. Returns MiniSearch's serialized form; write it out as JSON. */
24
+ export declare function buildSearchIndex(pages: Indexable[]): string;
25
+ export interface SearchHit {
26
+ route: string;
27
+ title: string;
28
+ description: string;
29
+ sidebar: string | null;
30
+ /** About 160 characters of the page around the first match (or its description) */
31
+ snippet: string;
32
+ /** Terms that matched, for highlighting */
33
+ terms: string[];
34
+ score: number;
35
+ }
36
+ export interface SearchQueryOptions {
37
+ /** Keep a hit only when this returns true. Runs before snippets are built. */
38
+ filter?: (hit: StoredFields & {
39
+ route: string;
40
+ }) => boolean;
41
+ /** Maximum hits. Default: 12 */
42
+ limit?: number;
43
+ /** Queries shorter than this return nothing. Default: 2 */
44
+ minLength?: number;
45
+ }
46
+ /**
47
+ * Load a serialized index for querying.
48
+ *
49
+ * @param indexJson - What `buildSearchIndex` returned (string or parsed object)
50
+ * @param texts - Page text by route, for snippets. Without it, snippets use descriptions.
51
+ */
52
+ export declare function createSearch(indexJson: string | object, texts?: Map<string, string> | Record<string, string>): {
53
+ search(query: string, options?: SearchQueryOptions): SearchHit[];
54
+ };
55
+ /** About 160 characters around the first matched term. */
56
+ export declare function snippet(text: string, terms: string[]): string;
57
+ export {};
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `@classic-homes/theme-docs/search` — full-text search over rendered docs (MiniSearch).
3
+ *
4
+ * Build the index at build time with `buildSearchIndex`, ship its JSON, and query it on
5
+ * the server or in the browser with `createSearch`. A `filter` runs inside MiniSearch,
6
+ * before results and snippets are built, so access rules can drop hits a reader may not
7
+ * see without their text ever leaving the index.
8
+ */
9
+ import MiniSearch from 'minisearch';
10
+ /**
11
+ * Index options. `buildSearchIndex` and `createSearch` must use the same ones, so both
12
+ * read them from here.
13
+ */
14
+ export const SEARCH_OPTIONS = {
15
+ fields: ['title', 'headings', 'description', 'text'],
16
+ storeFields: ['title', 'description', 'sidebar'],
17
+ searchOptions: {
18
+ boost: { title: 4, headings: 2, description: 1.5 },
19
+ prefix: true,
20
+ fuzzy: 0.15,
21
+ combineWith: 'AND',
22
+ },
23
+ };
24
+ /** Index every page. Returns MiniSearch's serialized form; write it out as JSON. */
25
+ export function buildSearchIndex(pages) {
26
+ const index = new MiniSearch(SEARCH_OPTIONS);
27
+ index.addAll(pages.map((page) => ({
28
+ id: page.route,
29
+ title: page.title,
30
+ description: page.description,
31
+ sidebar: page.sidebar,
32
+ headings: page.toc.map((entry) => entry.text).join(' '),
33
+ text: page.text,
34
+ })));
35
+ return JSON.stringify(index);
36
+ }
37
+ /**
38
+ * Load a serialized index for querying.
39
+ *
40
+ * @param indexJson - What `buildSearchIndex` returned (string or parsed object)
41
+ * @param texts - Page text by route, for snippets. Without it, snippets use descriptions.
42
+ */
43
+ export function createSearch(indexJson, texts) {
44
+ const index = MiniSearch.loadJSON(typeof indexJson === 'string' ? indexJson : JSON.stringify(indexJson), SEARCH_OPTIONS);
45
+ const textOf = (route) => texts instanceof Map ? texts.get(route) : texts?.[route];
46
+ return {
47
+ search(query, options = {}) {
48
+ const { filter, limit = 12, minLength = 2 } = options;
49
+ const trimmed = query.trim();
50
+ if (trimmed.length < minLength)
51
+ return [];
52
+ const results = index.search(trimmed, {
53
+ ...(filter && {
54
+ filter: (hit) => filter({ ...hit, route: String(hit.id) }),
55
+ }),
56
+ });
57
+ return results.slice(0, limit).map((hit) => {
58
+ const route = String(hit.id);
59
+ const stored = hit;
60
+ return {
61
+ route,
62
+ title: stored.title,
63
+ description: stored.description,
64
+ sidebar: stored.sidebar,
65
+ snippet: snippet(textOf(route) ?? stored.description ?? '', hit.terms),
66
+ terms: hit.terms,
67
+ score: hit.score,
68
+ };
69
+ });
70
+ },
71
+ };
72
+ }
73
+ /** About 160 characters around the first matched term. */
74
+ export function snippet(text, terms) {
75
+ const lower = text.toLowerCase();
76
+ const at = Math.min(...terms.map((term) => lower.indexOf(term.toLowerCase())).filter((i) => i >= 0));
77
+ if (!Number.isFinite(at))
78
+ return text.length > 160 ? `${text.slice(0, 160).trim()}…` : text;
79
+ const start = Math.max(0, at - 60);
80
+ const end = start + 160;
81
+ return `${start > 0 ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`;
82
+ }
@@ -272,6 +272,135 @@
272
272
  border-top-right-radius: 0;
273
273
  }
274
274
 
275
+ /* Fence titles (```bash title="install.sh") rendered by parseMarkdown */
276
+ .markdown-content .code-block-filename {
277
+ padding: 0.5rem 1rem;
278
+ font-size: 0.875rem;
279
+ font-family:
280
+ ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
281
+ color: hsl(var(--muted-foreground));
282
+ background-color: hsl(var(--muted) / 0.5);
283
+ border: 1px solid hsl(var(--border));
284
+ border-bottom: 0;
285
+ border-top-left-radius: 0.5rem;
286
+ border-top-right-radius: 0.5rem;
287
+ }
288
+
289
+ /* Copy buttons (enhanceCodeBlocks / DocPage) */
290
+ .code-block-wrapper.has-copy-button {
291
+ position: relative;
292
+ }
293
+
294
+ .code-copy-button {
295
+ position: absolute;
296
+ top: 0.5rem;
297
+ right: 0.5rem;
298
+ display: inline-flex;
299
+ align-items: center;
300
+ justify-content: center;
301
+ width: 2rem;
302
+ height: 2rem;
303
+ padding: 0.375rem;
304
+ border-radius: 0.375rem;
305
+ border: 1px solid hsl(0 0% 100% / 0.2);
306
+ background-color: hsl(0 0% 0% / 0.4);
307
+ color: hsl(0 0% 100% / 0.85);
308
+ cursor: pointer;
309
+ opacity: 0;
310
+ transition: opacity 150ms;
311
+ }
312
+
313
+ .code-block-filename + .shiki + .code-copy-button {
314
+ top: calc(0.5rem + 2.3rem);
315
+ }
316
+
317
+ .code-block-wrapper:hover .code-copy-button,
318
+ .code-copy-button:focus-visible,
319
+ .code-copy-button[data-copied] {
320
+ opacity: 1;
321
+ }
322
+
323
+ .code-copy-button:focus-visible {
324
+ outline: 2px solid hsl(var(--ring));
325
+ outline-offset: 2px;
326
+ }
327
+
328
+ .code-copy-button svg {
329
+ width: 100%;
330
+ height: 100%;
331
+ }
332
+
333
+ @media (hover: none) {
334
+ .code-copy-button {
335
+ opacity: 1;
336
+ }
337
+ }
338
+
339
+ .docs-sr-only {
340
+ position: absolute;
341
+ width: 1px;
342
+ height: 1px;
343
+ margin: -1px;
344
+ overflow: hidden;
345
+ clip: rect(0, 0, 0, 0);
346
+ white-space: nowrap;
347
+ }
348
+
349
+ /* Search terms carried over from a search result (?highlight=) */
350
+ .markdown-content mark.search-highlight {
351
+ background-color: hsl(var(--warning) / 0.35);
352
+ color: inherit;
353
+ border-radius: 0.125rem;
354
+ padding: 0 0.0625rem;
355
+ }
356
+
357
+ /* ==========================================================================
358
+ Heading permalinks (headingAnchors option)
359
+ ========================================================================== */
360
+
361
+ /* Anchor targets clear a fixed header when jumped to (DocPage sets the variable) */
362
+ .markdown-content [id] {
363
+ scroll-margin-top: var(--docs-header-offset, 5rem);
364
+ }
365
+
366
+ .markdown-content .hash-link {
367
+ margin-left: 0.5rem;
368
+ color: hsl(var(--primary));
369
+ text-decoration: none;
370
+ opacity: 0;
371
+ }
372
+
373
+ .markdown-content .hash-link::before {
374
+ content: '#';
375
+ }
376
+
377
+ .markdown-content :is(h1, h2, h3, h4, h5, h6):hover .hash-link,
378
+ .markdown-content .hash-link:focus-visible {
379
+ opacity: 1;
380
+ }
381
+
382
+ /* ==========================================================================
383
+ External links (externalLinks option)
384
+ ========================================================================== */
385
+
386
+ .markdown-content .external-link::after {
387
+ content: '\2197';
388
+ margin-left: 0.125rem;
389
+ font-size: 0.75em;
390
+ }
391
+
392
+ .markdown-content .external-link-hint {
393
+ position: absolute;
394
+ width: 1px;
395
+ height: 1px;
396
+ padding: 0;
397
+ margin: -1px;
398
+ overflow: hidden;
399
+ clip: rect(0, 0, 0, 0);
400
+ white-space: nowrap;
401
+ border: 0;
402
+ }
403
+
275
404
  /* ==========================================================================
276
405
  Admonitions / Callouts
277
406
  ========================================================================== */
@@ -477,6 +606,15 @@
477
606
  height: auto;
478
607
  }
479
608
 
609
+ /* Mermaid writes a diagram's classDef names onto its SVG nodes, so a diagram
610
+ with `classDef hidden …` gets nodes classed `hidden` — which Tailwind's
611
+ .hidden utility collapses. Not scoped to .markdown-content: mermaid measures
612
+ labels in a temporary SVG appended to <body> before placing the diagram. */
613
+ svg g.node.hidden,
614
+ svg g.cluster.hidden {
615
+ display: inline;
616
+ }
617
+
480
618
  .markdown-content .mermaid-diagram pre.mermaid {
481
619
  margin: 0;
482
620
  padding: 0;
@@ -50,6 +50,12 @@ export interface ParsedMarkdown {
50
50
  markdown: string;
51
51
  /** Rendered HTML content */
52
52
  html: string;
53
+ /** Every heading with an ID, in document order (all levels; filter by `level` as needed) */
54
+ toc: {
55
+ text: string;
56
+ level: number;
57
+ id: string;
58
+ }[];
53
59
  }
54
60
  /** Parser options */
55
61
  export interface ParseOptions {
@@ -67,6 +73,21 @@ export interface ParseOptions {
67
73
  * line of its own (`<RequestForm id="x" />`). Mount them with `mountComponents`.
68
74
  */
69
75
  components?: readonly string[];
76
+ /** Append a `#` permalink (`a.hash-link`) to each heading, as Docusaurus does. Default: false */
77
+ headingAnchors?: boolean;
78
+ /**
79
+ * Open `http(s)` links in a new tab with a visually hidden "(opens in new tab)" note.
80
+ * `true` uses `target="_blank" rel="noopener noreferrer"`. Default: false
81
+ */
82
+ externalLinks?: boolean | {
83
+ target?: string;
84
+ rel?: string;
85
+ };
86
+ /**
87
+ * Map code fence names to Shiki languages, e.g. `{ ios: 'text', caddyfile: 'nginx' }`.
88
+ * Languages Shiki doesn't know render as plain code.
89
+ */
90
+ langAlias?: Record<string, string>;
70
91
  }
71
92
  /** MarkdownPage component props */
72
93
  export interface MarkdownPageProps {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@classic-homes/theme-docs/vite` — regenerate docs content while you write.
3
+ *
4
+ * Runs your `generate` step (typically `loadDocs` → `renderDocs` → write JSON) when the
5
+ * dev server starts and whenever a file under `watch` changes, then reloads the page.
6
+ * Builds run it once before bundling, so the generated files are always current.
7
+ */
8
+ import type { Plugin } from 'vite';
9
+ export interface DocsContentPluginOptions {
10
+ /** Directories (or files) whose changes trigger `generate` */
11
+ watch: string | string[];
12
+ /** Produce the content the app imports. Throw to report a failure. */
13
+ generate: () => void | Promise<void>;
14
+ /** Wait this long after the last change before regenerating. Default: 200ms */
15
+ debounce?: number;
16
+ }
17
+ export declare function docsContent(options: DocsContentPluginOptions): Plugin;