@umami/shiso 0.55.0 → 0.61.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 (99) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +9 -49
  3. package/bin/shiso.mjs +132 -0
  4. package/docs.schema.json +835 -0
  5. package/mdx.config.ts +143 -0
  6. package/package.json +73 -83
  7. package/scripts/check-package.mjs +18 -0
  8. package/scripts/generate-icon-registry.mjs +196 -0
  9. package/scripts/generate-last-modified.mjs +128 -0
  10. package/scripts/generate-search-index.mjs +252 -0
  11. package/scripts/load-docs-config.mjs +244 -0
  12. package/scripts/prerender.mjs +187 -0
  13. package/scripts/validate-config.mjs +104 -0
  14. package/scripts/vite-docs-config.mjs +60 -0
  15. package/src/App.tsx +39 -0
  16. package/src/components/Banner.tsx +69 -0
  17. package/src/components/CodeBlock.tsx +46 -0
  18. package/src/components/ConfiguredIcon.tsx +15 -0
  19. package/src/components/ContextualMenu.tsx +93 -0
  20. package/src/components/DocContent.tsx +100 -0
  21. package/src/components/Docs.tsx +126 -0
  22. package/src/components/Footer.tsx +84 -0
  23. package/src/components/Header.tsx +72 -0
  24. package/src/components/Layout.tsx +16 -0
  25. package/src/components/PageLinks.tsx +71 -0
  26. package/src/components/Search.tsx +208 -0
  27. package/src/components/SideNav.tsx +347 -0
  28. package/src/components/SocialIcon.tsx +88 -0
  29. package/src/components/ThemeToggle.tsx +34 -0
  30. package/src/components/TopNav.tsx +127 -0
  31. package/src/components/docs/Accordion.tsx +68 -0
  32. package/src/components/docs/Badge.tsx +171 -0
  33. package/src/components/docs/Callout.tsx +73 -0
  34. package/src/components/docs/Card.tsx +158 -0
  35. package/src/components/docs/CodeGroup.tsx +73 -0
  36. package/src/components/docs/Columns.tsx +20 -0
  37. package/src/components/docs/Expandable.tsx +28 -0
  38. package/src/components/docs/Frame.tsx +56 -0
  39. package/src/components/docs/Icon.tsx +30 -0
  40. package/src/components/docs/ParamField.tsx +45 -0
  41. package/src/components/docs/PropertiesTable.tsx +84 -0
  42. package/src/components/docs/ResponseField.tsx +36 -0
  43. package/src/components/docs/Steps.tsx +47 -0
  44. package/src/components/docs/Tabs.tsx +116 -0
  45. package/src/components/docs/Tooltip.tsx +21 -0
  46. package/src/components/docs/index.ts +15 -0
  47. package/src/components/docs/styles.ts +82 -0
  48. package/src/components/docs/utils.ts +118 -0
  49. package/src/components/icons/index.ts +17 -0
  50. package/src/components/ui/accordion.tsx +69 -0
  51. package/src/components/ui/alert.tsx +69 -0
  52. package/src/components/ui/badge.tsx +49 -0
  53. package/src/components/ui/button.tsx +58 -0
  54. package/src/components/ui/card.tsx +88 -0
  55. package/src/components/ui/collapsible.tsx +15 -0
  56. package/src/components/ui/command.tsx +173 -0
  57. package/src/components/ui/dialog.tsx +137 -0
  58. package/src/components/ui/dropdown-menu.tsx +257 -0
  59. package/src/components/ui/scroll-area.tsx +71 -0
  60. package/src/components/ui/sheet.tsx +124 -0
  61. package/src/components/ui/tabs.tsx +73 -0
  62. package/src/components/ui/tooltip.tsx +52 -0
  63. package/src/declarations.d.ts +9 -0
  64. package/src/entry-client.tsx +17 -0
  65. package/src/entry-server.tsx +77 -0
  66. package/src/generated/last-modified.ts +2 -0
  67. package/src/lib/content.ts +44 -0
  68. package/src/lib/docs-config.ts +682 -0
  69. package/src/lib/head.ts +220 -0
  70. package/src/lib/icon-registry.generated.ts +4 -0
  71. package/src/lib/icons.ts +29 -0
  72. package/src/lib/inline-markdown.tsx +86 -0
  73. package/src/lib/mdast.ts +56 -0
  74. package/src/lib/paths.ts +86 -0
  75. package/src/lib/remark-toc.ts +71 -0
  76. package/src/lib/search/config.ts +43 -0
  77. package/src/lib/search/provider.ts +80 -0
  78. package/src/lib/search/providers/local.ts +15 -0
  79. package/src/lib/search-index.generated.ts +4 -0
  80. package/src/lib/search.ts +100 -0
  81. package/src/lib/site-config.ts +104 -0
  82. package/src/lib/site-model.ts +221 -0
  83. package/src/lib/slug.ts +38 -0
  84. package/src/lib/types.ts +478 -0
  85. package/src/lib/utils.ts +6 -0
  86. package/src/pages/DocPage.tsx +33 -0
  87. package/src/styles/global.css +268 -0
  88. package/src/styles/tokens.css +114 -0
  89. package/types/client.d.ts +3 -0
  90. package/types/search.d.ts +17 -0
  91. package/vite.config.ts +342 -0
  92. package/LICENSE +0 -21
  93. package/dist/index.css +0 -189
  94. package/dist/index.d.ts +0 -57
  95. package/dist/index.js +0 -464
  96. package/dist/index.mjs +0 -437
  97. package/server/index.d.ts +0 -30
  98. package/server/index.js +0 -189
  99. package/styles.css +0 -4766
@@ -0,0 +1,220 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { getDocModule, getLastModified } from '@/lib/content';
3
+ import { SITE_URL, toAbsoluteUrl } from '@/lib/paths';
4
+ import {
5
+ getPageByPathname,
6
+ getPageTitle,
7
+ getSeo,
8
+ showTimestamp,
9
+ siteConfig,
10
+ siteName,
11
+ } from '@/lib/site-config';
12
+
13
+ /**
14
+ * The document head is a pure function of the route.
15
+ *
16
+ * Every input — docs.json and the eagerly-globbed frontmatter — is available
17
+ * synchronously on both the server and the client, so there is no need for a
18
+ * head manager, a context provider, or a data router. `renderHeadToString` and
19
+ * `applyHead` consume the exact same `buildHead` output, which makes the
20
+ * prerendered markup and post-navigation DOM identical by construction.
21
+ */
22
+
23
+ export interface HeadTag {
24
+ tag: 'title' | 'meta' | 'link' | 'script';
25
+ attrs?: Record<string, string>;
26
+ children?: string;
27
+ }
28
+
29
+ /** Marks tags this module owns, so client navigation can replace exactly its own. */
30
+ export const HEAD_MARKER = 'data-shiso-head';
31
+
32
+ function escapeHtml(value: string): string {
33
+ return value
34
+ .replace(/&/g, '&amp;')
35
+ .replace(/</g, '&lt;')
36
+ .replace(/>/g, '&gt;')
37
+ .replace(/"/g, '&quot;')
38
+ .replace(/'/g, '&#39;');
39
+ }
40
+
41
+ /** JSON-LD is script content, not attribute content: only `<` and `&` need care. */
42
+ function escapeJsonLd(value: string): string {
43
+ return value.replace(/</g, '\\u003c');
44
+ }
45
+
46
+ export function buildHead(pathname: string): HeadTag[] {
47
+ const page = getPageByPathname(pathname);
48
+ const doc = page ? getDocModule(page.filePath) : undefined;
49
+ const frontmatter = doc?.frontmatter;
50
+
51
+ const seo = getSeo();
52
+ const pageTitle = frontmatter?.title || page?.label;
53
+ const title = getPageTitle(pageTitle);
54
+ const description = frontmatter?.description || siteConfig.description;
55
+ const canonical = page ? toAbsoluteUrl(page.url) : undefined;
56
+ // `seo.indexing: "all"` opts hidden pages into the index; explicit
57
+ // per-page `noindex` frontmatter always wins.
58
+ const noindex =
59
+ !page || frontmatter?.noindex === true || (!!page.hidden && seo.indexing !== 'all');
60
+
61
+ const tags: HeadTag[] = [{ tag: 'title', children: title }];
62
+
63
+ if (description) {
64
+ tags.push({ tag: 'meta', attrs: { name: 'description', content: description } });
65
+ }
66
+
67
+ if (canonical) {
68
+ tags.push({ tag: 'link', attrs: { rel: 'canonical', href: canonical } });
69
+ }
70
+
71
+ if (noindex) {
72
+ tags.push({ tag: 'meta', attrs: { name: 'robots', content: 'noindex' } });
73
+ }
74
+
75
+ // Open Graph
76
+ tags.push(
77
+ { tag: 'meta', attrs: { property: 'og:type', content: 'article' } },
78
+ { tag: 'meta', attrs: { property: 'og:title', content: title } },
79
+ );
80
+
81
+ if (siteName) {
82
+ tags.push({ tag: 'meta', attrs: { property: 'og:site_name', content: siteName } });
83
+ }
84
+
85
+ if (description) {
86
+ tags.push({ tag: 'meta', attrs: { property: 'og:description', content: description } });
87
+ }
88
+
89
+ if (canonical) {
90
+ tags.push({ tag: 'meta', attrs: { property: 'og:url', content: canonical } });
91
+ }
92
+
93
+ // Twitter
94
+ tags.push(
95
+ { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } },
96
+ { tag: 'meta', attrs: { name: 'twitter:title', content: title } },
97
+ );
98
+
99
+ if (description) {
100
+ tags.push({ tag: 'meta', attrs: { name: 'twitter:description', content: description } });
101
+ }
102
+
103
+ // Last-modified time, when the timestamp feature is on for this page.
104
+ const lastModified =
105
+ page && showTimestamp(frontmatter?.timestamp) ? getLastModified(page.filePath) : undefined;
106
+
107
+ if (lastModified) {
108
+ tags.push({ tag: 'meta', attrs: { property: 'article:modified_time', content: lastModified } });
109
+ }
110
+
111
+ // Site-wide meta tags from `seo.metatags`. Emitted last so authors can see
112
+ // them win over the generated defaults in devtools; `og:*`-style keys are
113
+ // property attributes, the rest are name attributes.
114
+ for (const [key, content] of Object.entries(seo.metatags || {})) {
115
+ if (key === 'title' || typeof content !== 'string') {
116
+ continue;
117
+ }
118
+
119
+ const attribute = /^(og|article|fb|profile|book|music|video):/.test(key) ? 'property' : 'name';
120
+ tags.push({ tag: 'meta', attrs: { [attribute]: key, content } });
121
+ }
122
+
123
+ // Structured data. Only emitted with an absolute origin, since both
124
+ // TechArticle.url and BreadcrumbList items require resolvable URLs.
125
+ if (page && canonical && SITE_URL) {
126
+ const breadcrumbs = [
127
+ siteName ? { name: siteName, url: toAbsoluteUrl('/') } : null,
128
+ page.tabLabel && page.section !== page.tabLabel ? { name: page.tabLabel } : null,
129
+ page.section ? { name: page.section } : null,
130
+ { name: page.label, url: canonical },
131
+ ].filter((item): item is { name: string; url?: string } => !!item);
132
+
133
+ tags.push({
134
+ tag: 'script',
135
+ attrs: { type: 'application/ld+json' },
136
+ children: JSON.stringify([
137
+ {
138
+ '@context': 'https://schema.org',
139
+ '@type': 'TechArticle',
140
+ headline: pageTitle || page.label,
141
+ description,
142
+ url: canonical,
143
+ ...(siteName ? { isPartOf: { '@type': 'WebSite', name: siteName, url: SITE_URL } } : {}),
144
+ },
145
+ {
146
+ '@context': 'https://schema.org',
147
+ '@type': 'BreadcrumbList',
148
+ itemListElement: breadcrumbs.map((item, index) => ({
149
+ '@type': 'ListItem',
150
+ position: index + 1,
151
+ name: item.name,
152
+ ...(item.url ? { item: item.url } : {}),
153
+ })),
154
+ },
155
+ ]),
156
+ });
157
+ }
158
+
159
+ return tags;
160
+ }
161
+
162
+ export function renderHeadToString(tags: HeadTag[]): string {
163
+ return tags
164
+ .map(({ tag, attrs, children }) => {
165
+ const attributes = Object.entries(attrs || {})
166
+ .map(([key, value]) => ` ${key}="${escapeHtml(value)}"`)
167
+ .join('');
168
+
169
+ if (tag === 'meta' || tag === 'link') {
170
+ return `<${tag}${attributes} ${HEAD_MARKER} />`;
171
+ }
172
+
173
+ const content = tag === 'script' ? escapeJsonLd(children || '') : escapeHtml(children || '');
174
+
175
+ return `<${tag}${attributes} ${HEAD_MARKER}>${content}</${tag}>`;
176
+ })
177
+ .join('\n ');
178
+ }
179
+
180
+ export function applyHead(tags: HeadTag[]) {
181
+ const { head } = document;
182
+
183
+ head.querySelectorAll(`[${HEAD_MARKER}]`).forEach(node => {
184
+ node.remove();
185
+ });
186
+
187
+ for (const { tag, attrs, children } of tags) {
188
+ const element = document.createElement(tag);
189
+
190
+ for (const [key, value] of Object.entries(attrs || {})) {
191
+ element.setAttribute(key, value);
192
+ }
193
+
194
+ if (children !== undefined) {
195
+ element.textContent = children;
196
+ }
197
+
198
+ element.setAttribute(HEAD_MARKER, '');
199
+ head.append(element);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Applies the head for the current route on client navigation.
205
+ *
206
+ * The first render after hydration is skipped: the prerendered head is already
207
+ * correct, and rewriting it would tear down and recreate every tag on load.
208
+ */
209
+ export function useHead(pathname: string) {
210
+ const hydratedPath = useRef<string | null>(null);
211
+
212
+ useEffect(() => {
213
+ if (hydratedPath.current === null) {
214
+ hydratedPath.current = pathname;
215
+ return;
216
+ }
217
+
218
+ applyHead(buildHead(pathname));
219
+ }, [pathname]);
220
+ }
@@ -0,0 +1,4 @@
1
+ // Build-time alias target. Shiso replaces this module with the project's generated registry.
2
+ import type { IconComponent } from '@/lib/icons';
3
+
4
+ export const ICON_REGISTRY: Record<string, IconComponent> = {};
@@ -0,0 +1,29 @@
1
+ import type { ComponentType, SVGProps } from 'react';
2
+ import { ICON_REGISTRY } from '@/lib/icon-registry.generated';
3
+
4
+ export type IconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
5
+
6
+ /** `circle-check`, `circle_check`, `CircleCheck` all resolve to the same icon. */
7
+ export function normalizeIconName(name: string): string {
8
+ return name
9
+ .trim()
10
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
11
+ .replace(/[\s_]+/g, '-')
12
+ .toLowerCase()
13
+ .replace(/-+/g, '-')
14
+ .replace(/^-|-$/g, '');
15
+ }
16
+
17
+ /**
18
+ * Looks up an author-supplied icon name. The registry is generated at build time from
19
+ * the names found in content, so an icon only resolves after it appears in a source file.
20
+ */
21
+ export function getIcon(name: string): IconComponent | undefined {
22
+ const icon = ICON_REGISTRY[normalizeIconName(name)];
23
+
24
+ if (!icon && import.meta.env?.DEV) {
25
+ console.warn(`Unknown icon "${name}". Check the name against the lucide icon set.`);
26
+ }
27
+
28
+ return icon;
29
+ }
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * Renders the small markdown subset allowed in config strings (banner content,
5
+ * error page descriptions): links, bold, italic, and inline code. Custom
6
+ * components are deliberately not supported — these strings come from
7
+ * docs.json, not MDX.
8
+ */
9
+
10
+ interface InlinePattern {
11
+ regex: RegExp;
12
+ render: (match: RegExpExecArray, key: number) => ReactNode;
13
+ }
14
+
15
+ const PATTERNS: InlinePattern[] = [
16
+ {
17
+ // [label](https://example.com)
18
+ regex: /\[([^\]]+)\]\(([^)\s]+)\)/,
19
+ render: (match, key) => {
20
+ const external = /^https?:\/\//.test(match[2]);
21
+
22
+ return (
23
+ <a
24
+ key={key}
25
+ href={match[2]}
26
+ target={external ? '_blank' : undefined}
27
+ rel={external ? 'noreferrer' : undefined}
28
+ >
29
+ {renderInlineMarkdown(match[1])}
30
+ </a>
31
+ );
32
+ },
33
+ },
34
+ {
35
+ // **bold**
36
+ regex: /\*\*([^*]+)\*\*/,
37
+ render: (match, key) => <strong key={key}>{renderInlineMarkdown(match[1])}</strong>,
38
+ },
39
+ {
40
+ // *italic* (single asterisk, not part of **)
41
+ regex: /(?<!\*)\*([^*]+)\*(?!\*)/,
42
+ render: (match, key) => <em key={key}>{renderInlineMarkdown(match[1])}</em>,
43
+ },
44
+ {
45
+ // _italic_
46
+ regex: /_([^_]+)_/,
47
+ render: (match, key) => <em key={key}>{renderInlineMarkdown(match[1])}</em>,
48
+ },
49
+ {
50
+ // `code` — contents render verbatim
51
+ regex: /`([^`]+)`/,
52
+ render: (match, key) => <code key={key}>{match[1]}</code>,
53
+ },
54
+ ];
55
+
56
+ export function renderInlineMarkdown(text: string): ReactNode[] {
57
+ const nodes: ReactNode[] = [];
58
+ let remaining = text;
59
+ let key = 0;
60
+
61
+ while (remaining) {
62
+ let earliest: { index: number; match: RegExpExecArray; pattern: InlinePattern } | null = null;
63
+
64
+ for (const pattern of PATTERNS) {
65
+ const match = pattern.regex.exec(remaining);
66
+
67
+ if (match && (!earliest || match.index < earliest.index)) {
68
+ earliest = { index: match.index, match, pattern };
69
+ }
70
+ }
71
+
72
+ if (!earliest) {
73
+ nodes.push(remaining);
74
+ break;
75
+ }
76
+
77
+ if (earliest.index > 0) {
78
+ nodes.push(remaining.slice(0, earliest.index));
79
+ }
80
+
81
+ nodes.push(earliest.pattern.render(earliest.match, key++));
82
+ remaining = remaining.slice(earliest.index + earliest.match[0].length);
83
+ }
84
+
85
+ return nodes;
86
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Minimal mdast helpers shared by the remark plugins and the search extractor.
3
+ *
4
+ * These are deliberately structural rather than typed against `@types/mdast`:
5
+ * the trees we walk contain MDX-specific nodes (`mdxjsEsm`, `mdxJsxFlowElement`)
6
+ * and we only ever read `type`, `depth`, `value`, and `children`.
7
+ */
8
+
9
+ export interface MdNode {
10
+ type?: string;
11
+ depth?: number;
12
+ value?: string;
13
+ lang?: string;
14
+ meta?: string;
15
+ children?: MdNode[];
16
+ data?: Record<string, unknown>;
17
+ /**
18
+ * The walker is also pointed at hast trees (where nodes carry `tagName` and
19
+ * `properties`), so callers can read node-kind-specific fields without a cast.
20
+ */
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ /** Depth-first pre-order walk, visiting the node itself first. */
25
+ export function walkTree(node: MdNode | undefined, visitor: (node: MdNode) => void) {
26
+ if (!node || typeof node !== 'object') {
27
+ return;
28
+ }
29
+
30
+ visitor(node);
31
+ node.children?.forEach(child => {
32
+ walkTree(child, visitor);
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Concatenates the text content of a node. JSX elements contribute their text
38
+ * children (so `<Tooltip>API key</Tooltip>` reads as "API key"), which is what
39
+ * both heading anchors and the search index want.
40
+ */
41
+ export function toText(node: MdNode | undefined): string {
42
+ if (!node) {
43
+ return '';
44
+ }
45
+
46
+ if (typeof node.value === 'string') {
47
+ return node.value;
48
+ }
49
+
50
+ return (node.children || []).map(toText).join('');
51
+ }
52
+
53
+ /** Text of a heading node, trimmed. */
54
+ export function headingText(node: MdNode): string {
55
+ return toText(node).trim();
56
+ }
@@ -0,0 +1,86 @@
1
+ import rawConfig from 'virtual:shiso-docs-config';
2
+ import type { ShisoOptions } from '@/lib/types';
3
+
4
+ /**
5
+ * All URL construction goes through this module.
6
+ *
7
+ * Two separate prefixes are involved and they are easy to confuse:
8
+ * - BASE_URL is Vite's `base` — where the whole site is mounted on the host
9
+ * (e.g. "/my-docs" when deployed to a subpath). Baked in at build time.
10
+ * - DOCS_PREFIX is where docs pages live *within* the site (default "/docs").
11
+ * Set "" to serve docs at the site root.
12
+ *
13
+ * Routes stored in the normalized config are base-relative: they include
14
+ * DOCS_PREFIX but not BASE_URL. React Router's `basename` adds BASE_URL, so
15
+ * only code that bypasses the router (prerender output paths, canonical URLs,
16
+ * raw <a href>) needs `toHref`.
17
+ */
18
+
19
+ const shiso = ((rawConfig as { $shiso?: ShisoOptions }).$shiso || {}) as ShisoOptions;
20
+
21
+ /** Strips trailing slashes; "/" and "" both normalize to "". */
22
+ function normalizePrefix(value: string): string {
23
+ const trimmed = value.trim().replace(/\/+$/, '');
24
+
25
+ if (!trimmed || trimmed === '/') {
26
+ return '';
27
+ }
28
+
29
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
30
+ }
31
+
32
+ export const BASE_URL = normalizePrefix(import.meta.env?.BASE_URL || '/');
33
+
34
+ export const DOCS_PREFIX = normalizePrefix(shiso.docsPrefix ?? '/docs');
35
+
36
+ /** Content directory, relative to the project root, without leading/trailing slashes. */
37
+ export const CONTENT_DIR = (shiso.contentDir ?? 'content/docs').replace(/^\/+|\/+$/g, '');
38
+
39
+ /** Absolute origin used for canonical and og:url tags. Undefined when unconfigured. */
40
+ export const SITE_URL = shiso.siteUrl?.replace(/\/+$/, '') || undefined;
41
+
42
+ /** Joins path segments with exactly one slash between them. */
43
+ export function joinPath(...parts: (string | undefined)[]): string {
44
+ const joined = parts
45
+ .filter((part): part is string => !!part)
46
+ .join('/')
47
+ .replace(/\/{2,}/g, '/');
48
+
49
+ return joined.startsWith('/') ? joined : `/${joined}`;
50
+ }
51
+
52
+ /** Converts a base-relative route to a host-absolute href (prepends BASE_URL). */
53
+ export function toHref(routePath: string): string {
54
+ if (isExternalHref(routePath)) {
55
+ return routePath;
56
+ }
57
+
58
+ return joinPath(BASE_URL, routePath);
59
+ }
60
+
61
+ /** Converts a base-relative route to a fully qualified URL, when SITE_URL is set. */
62
+ export function toAbsoluteUrl(routePath: string): string | undefined {
63
+ return SITE_URL ? `${SITE_URL}${toHref(routePath)}` : undefined;
64
+ }
65
+
66
+ /** Removes BASE_URL from an incoming pathname, yielding a base-relative route. */
67
+ export function stripBase(pathname: string): string {
68
+ if (BASE_URL && pathname.startsWith(BASE_URL)) {
69
+ return pathname.slice(BASE_URL.length) || '/';
70
+ }
71
+
72
+ return pathname;
73
+ }
74
+
75
+ /** Removes DOCS_PREFIX from a base-relative route, yielding a bare page slug path. */
76
+ export function stripDocsPrefix(routePath: string): string {
77
+ if (DOCS_PREFIX && routePath.startsWith(DOCS_PREFIX)) {
78
+ return routePath.slice(DOCS_PREFIX.length);
79
+ }
80
+
81
+ return routePath;
82
+ }
83
+
84
+ export function isExternalHref(href: string): boolean {
85
+ return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
86
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Remark plugin that collects headings from the document and injects
3
+ * `export const toc = [{ name, id, size }, ...]` into the compiled MDX module.
4
+ *
5
+ * Anchor ids come from `src/lib/slug.ts`, the same github-slugger instance
6
+ * `rehype-slug` uses, so TOC links always match the rendered heading ids.
7
+ */
8
+ import { valueToEstree } from 'estree-util-value-to-estree';
9
+ // Relative imports: this module is also loaded by vite.config.ts, which esbuild
10
+ // bundles without applying the "@/" resolve alias.
11
+ import { headingText, type MdNode, walkTree } from './mdast.ts';
12
+ import { createSlugger } from './slug.ts';
13
+ import type { TocEntry } from './types.ts';
14
+
15
+ function tocExportNode(toc: TocEntry[]): MdNode {
16
+ return {
17
+ type: 'mdxjsEsm',
18
+ value: '',
19
+ data: {
20
+ estree: {
21
+ type: 'Program',
22
+ sourceType: 'module',
23
+ body: [
24
+ {
25
+ type: 'ExportNamedDeclaration',
26
+ specifiers: [],
27
+ declaration: {
28
+ type: 'VariableDeclaration',
29
+ kind: 'const',
30
+ declarations: [
31
+ {
32
+ type: 'VariableDeclarator',
33
+ id: { type: 'Identifier', name: 'toc' },
34
+ init: valueToEstree(toc),
35
+ },
36
+ ],
37
+ },
38
+ },
39
+ ],
40
+ },
41
+ },
42
+ };
43
+ }
44
+
45
+ /** Extracts the heading outline from an mdast tree. Shared with the search indexer. */
46
+ export function collectToc(tree: MdNode): TocEntry[] {
47
+ const toc: TocEntry[] = [];
48
+ const slugger = createSlugger();
49
+
50
+ walkTree(tree, node => {
51
+ if (node.type !== 'heading' || typeof node.depth !== 'number') {
52
+ return;
53
+ }
54
+
55
+ const name = headingText(node);
56
+
57
+ if (!name) {
58
+ return;
59
+ }
60
+
61
+ toc.push({ name, id: slugger.slug(name), size: node.depth });
62
+ });
63
+
64
+ return toc;
65
+ }
66
+
67
+ export function remarkToc() {
68
+ return (tree: MdNode) => {
69
+ tree.children?.unshift(tocExportNode(collectToc(tree)));
70
+ };
71
+ }
@@ -0,0 +1,43 @@
1
+ import type { SearchConfig } from '@/lib/types';
2
+
3
+ export const DEFAULT_SEARCH_PROMPT = 'Search...';
4
+ export const DEFAULT_SEARCH_PROVIDER = 'local';
5
+ export const DEFAULT_SEARCH_SHORTCUT = 'k';
6
+ export const DEFAULT_SEARCH_SHORTCUT_LABEL = 'Ctrl K';
7
+
8
+ export interface ResolvedSearchConfig {
9
+ enabled: boolean;
10
+ prompt: string;
11
+ provider: string;
12
+ options: Record<string, unknown>;
13
+ shortcut: string | false;
14
+ shortcutLabel: string;
15
+ }
16
+
17
+ /** Normalizes docs.json search settings for both the UI and provider loader. */
18
+ export function resolveSearchConfig(
19
+ config: false | SearchConfig | undefined,
20
+ ): ResolvedSearchConfig {
21
+ if (config === false) {
22
+ return {
23
+ enabled: false,
24
+ prompt: DEFAULT_SEARCH_PROMPT,
25
+ provider: DEFAULT_SEARCH_PROVIDER,
26
+ options: {},
27
+ shortcut: false,
28
+ shortcutLabel: DEFAULT_SEARCH_SHORTCUT_LABEL,
29
+ };
30
+ }
31
+
32
+ return {
33
+ enabled: true,
34
+ prompt: config?.prompt?.trim() || DEFAULT_SEARCH_PROMPT,
35
+ provider: config?.provider?.trim().toLowerCase() || DEFAULT_SEARCH_PROVIDER,
36
+ options: config?.options || {},
37
+ shortcut:
38
+ config?.shortcut === false
39
+ ? false
40
+ : config?.shortcut?.trim().toLowerCase() || DEFAULT_SEARCH_SHORTCUT,
41
+ shortcutLabel: config?.shortcutLabel?.trim() || DEFAULT_SEARCH_SHORTCUT_LABEL,
42
+ };
43
+ }
@@ -0,0 +1,80 @@
1
+ import type { SearchResult } from '@/lib/search';
2
+
3
+ export interface SearchProvider {
4
+ search(query: string, limit?: number): Promise<SearchResult[]>;
5
+ }
6
+
7
+ export type SearchProviderFactory = (
8
+ options: Record<string, unknown>,
9
+ ) => SearchProvider | Promise<SearchProvider>;
10
+
11
+ export interface ResolvedSearchProvider {
12
+ provider: SearchProvider;
13
+ providerId: string;
14
+ fellBack: boolean;
15
+ }
16
+
17
+ const factories = new Map<string, SearchProviderFactory>();
18
+
19
+ function normalizeProviderId(id: string): string {
20
+ return id.trim().toLowerCase();
21
+ }
22
+
23
+ /**
24
+ * Registers a runtime search provider. Call this before rendering the app.
25
+ * The returned cleanup function only removes this exact registration.
26
+ */
27
+ export function registerSearchProvider(id: string, factory: SearchProviderFactory): () => void {
28
+ const providerId = normalizeProviderId(id);
29
+
30
+ if (!providerId || providerId === 'local') {
31
+ throw new Error(
32
+ 'Search provider ids must be non-empty and cannot replace the built-in "local" provider.',
33
+ );
34
+ }
35
+
36
+ factories.set(providerId, factory);
37
+
38
+ return () => {
39
+ if (factories.get(providerId) === factory) {
40
+ factories.delete(providerId);
41
+ }
42
+ };
43
+ }
44
+
45
+ async function createLocalProvider(options: Record<string, unknown>): Promise<SearchProvider> {
46
+ const { createLocalSearchProvider } = await import('@/lib/search/providers/local');
47
+ return createLocalSearchProvider(options);
48
+ }
49
+
50
+ /** Resolves a configured provider, falling back to local for unknown ids. */
51
+ export async function resolveSearchProvider(
52
+ id: string,
53
+ options: Record<string, unknown> = {},
54
+ ): Promise<ResolvedSearchProvider> {
55
+ const requestedId = normalizeProviderId(id) || 'local';
56
+
57
+ if (requestedId === 'local') {
58
+ return {
59
+ provider: await createLocalProvider(options),
60
+ providerId: 'local',
61
+ fellBack: false,
62
+ };
63
+ }
64
+
65
+ const factory = factories.get(requestedId);
66
+
67
+ if (factory) {
68
+ return {
69
+ provider: await factory(options),
70
+ providerId: requestedId,
71
+ fellBack: false,
72
+ };
73
+ }
74
+
75
+ return {
76
+ provider: await createLocalProvider({}),
77
+ providerId: 'local',
78
+ fellBack: true,
79
+ };
80
+ }
@@ -0,0 +1,15 @@
1
+ import type { SearchRecord } from '@/lib/search';
2
+ import { searchIndex } from '@/lib/search';
3
+ import type { SearchProvider } from '@/lib/search/provider';
4
+
5
+ /** Built-in provider backed by the section index generated during the build. */
6
+ export function createLocalSearchProvider(_options: Record<string, unknown> = {}): SearchProvider {
7
+ let records: Promise<SearchRecord[]> | null = null;
8
+
9
+ return {
10
+ async search(query, limit) {
11
+ records ||= import('@/lib/search-index.generated').then(module => module.SEARCH_INDEX);
12
+ return searchIndex(await records, query, limit);
13
+ },
14
+ };
15
+ }