@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.
- package/dist/lib/components/Breadcrumbs.svelte +55 -0
- package/dist/lib/components/Breadcrumbs.svelte.d.ts +14 -0
- package/dist/lib/components/CategoryIndex.svelte +51 -0
- package/dist/lib/components/CategoryIndex.svelte.d.ts +13 -0
- package/dist/lib/components/DocPage.svelte +179 -0
- package/dist/lib/components/DocPage.svelte.d.ts +62 -0
- package/dist/lib/components/DocPager.svelte +49 -0
- package/dist/lib/components/DocPager.svelte.d.ts +12 -0
- package/dist/lib/components/MarkdownPage.svelte +3 -1
- package/dist/lib/components/MermaidDiagram.svelte +2 -0
- package/dist/lib/components/MermaidInit.svelte +44 -3
- package/dist/lib/components/MermaidInit.svelte.d.ts +5 -2
- package/dist/lib/components/TableOfContents.svelte +114 -125
- package/dist/lib/components/TableOfContents.svelte.d.ts +11 -4
- package/dist/lib/components/TagIndex.svelte +42 -0
- package/dist/lib/components/TagIndex.svelte.d.ts +14 -0
- package/dist/lib/components/TagList.svelte +45 -0
- package/dist/lib/components/TagList.svelte.d.ts +15 -0
- package/dist/lib/components/TocPanel.svelte +27 -9
- package/dist/lib/components/TocPanel.svelte.d.ts +10 -3
- package/dist/lib/components/enhance.d.ts +29 -0
- package/dist/lib/components/enhance.js +179 -0
- package/dist/lib/components/sidebar.d.ts +33 -0
- package/dist/lib/components/sidebar.js +84 -0
- package/dist/lib/content/browser.d.ts +6 -0
- package/dist/lib/content/browser.js +5 -0
- package/dist/lib/content/index.d.ts +13 -0
- package/dist/lib/content/index.js +12 -0
- package/dist/lib/content/load.d.ts +77 -0
- package/dist/lib/content/load.js +366 -0
- package/dist/lib/content/nav.d.ts +36 -0
- package/dist/lib/content/nav.js +81 -0
- package/dist/lib/content/render.d.ts +38 -0
- package/dist/lib/content/render.js +89 -0
- package/dist/lib/content/types.d.ts +96 -0
- package/dist/lib/content/types.js +5 -0
- package/dist/lib/index.d.ts +14 -2
- package/dist/lib/index.js +14 -2
- package/dist/lib/parser/api.d.ts +12 -0
- package/dist/lib/parser/api.js +10 -0
- package/dist/lib/parser/extensions.d.ts +27 -15
- package/dist/lib/parser/extensions.js +58 -53
- package/dist/lib/parser/index.d.ts +4 -1
- package/dist/lib/parser/index.js +104 -27
- package/dist/lib/sanitize/index.d.ts +11 -0
- package/dist/lib/sanitize/index.js +122 -0
- package/dist/lib/search/index.d.ts +57 -0
- package/dist/lib/search/index.js +107 -0
- package/dist/lib/styles/markdown.css +138 -0
- package/dist/lib/types/frontmatter.d.ts +21 -0
- package/dist/lib/vite/index.d.ts +17 -0
- package/dist/lib/vite/index.js +38 -0
- package/package.json +51 -4
package/dist/lib/parser/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
* @
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
+
// Raw inline HTML (`## A <b>&</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
|
-
|
|
121
|
+
const anchor = headingAnchors
|
|
122
|
+
? `<a class="hash-link" href="#${id}" aria-label="Direct link to ${escapeHtml(text)}">​</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]
|
|
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, cut at word boundaries. */
|
|
56
|
+
export declare function snippet(text: string, terms: string[]): string;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
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(withoutTitle(textOf(route), stored.title) ?? stored.description ?? '', hit.terms),
|
|
66
|
+
terms: hit.terms,
|
|
67
|
+
score: hit.score,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A page's text minus its leading title: the body's `# Title` heading is the first thing in
|
|
75
|
+
* the text, and the result already shows the title, so a snippet would repeat it.
|
|
76
|
+
*/
|
|
77
|
+
function withoutTitle(text, title) {
|
|
78
|
+
if (!text || !title || !text.startsWith(title))
|
|
79
|
+
return text;
|
|
80
|
+
return text.slice(title.length).trimStart();
|
|
81
|
+
}
|
|
82
|
+
/** About 160 characters around the first matched term, cut at word boundaries. */
|
|
83
|
+
export function snippet(text, terms) {
|
|
84
|
+
const lower = text.toLowerCase();
|
|
85
|
+
const at = Math.min(...terms.map((term) => lower.indexOf(term.toLowerCase())).filter((i) => i >= 0));
|
|
86
|
+
if (!Number.isFinite(at))
|
|
87
|
+
return clip(text, 0, 160);
|
|
88
|
+
return clip(text, Math.max(0, at - 60), Math.max(0, at - 60) + 160, at);
|
|
89
|
+
}
|
|
90
|
+
/** `text[start, end)` widened or narrowed to whole words (never past `keep`), with ellipses. */
|
|
91
|
+
function clip(text, start, end, keep = start) {
|
|
92
|
+
if (start > 0) {
|
|
93
|
+
// Start after the space before the cut, so the snippet opens on a whole word.
|
|
94
|
+
const space = text.indexOf(' ', start);
|
|
95
|
+
if (space >= 0 && space < keep)
|
|
96
|
+
start = space + 1;
|
|
97
|
+
}
|
|
98
|
+
if (end < text.length) {
|
|
99
|
+
const space = text.lastIndexOf(' ', end);
|
|
100
|
+
if (space > keep)
|
|
101
|
+
end = space;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
end = text.length;
|
|
105
|
+
}
|
|
106
|
+
return `${start > 0 ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`;
|
|
107
|
+
}
|
|
@@ -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 {
|