@umami/shiso 0.54.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.
- package/CHANGELOG.md +12 -0
- package/README.md +9 -49
- package/bin/shiso.mjs +132 -0
- package/docs.schema.json +835 -0
- package/mdx.config.ts +143 -0
- package/package.json +73 -83
- package/scripts/check-package.mjs +18 -0
- package/scripts/generate-icon-registry.mjs +196 -0
- package/scripts/generate-last-modified.mjs +128 -0
- package/scripts/generate-search-index.mjs +252 -0
- package/scripts/load-docs-config.mjs +244 -0
- package/scripts/prerender.mjs +187 -0
- package/scripts/validate-config.mjs +104 -0
- package/scripts/vite-docs-config.mjs +60 -0
- package/src/App.tsx +39 -0
- package/src/components/Banner.tsx +69 -0
- package/src/components/CodeBlock.tsx +46 -0
- package/src/components/ConfiguredIcon.tsx +15 -0
- package/src/components/ContextualMenu.tsx +93 -0
- package/src/components/DocContent.tsx +100 -0
- package/src/components/Docs.tsx +126 -0
- package/src/components/Footer.tsx +84 -0
- package/src/components/Header.tsx +72 -0
- package/src/components/Layout.tsx +16 -0
- package/src/components/PageLinks.tsx +71 -0
- package/src/components/Search.tsx +208 -0
- package/src/components/SideNav.tsx +347 -0
- package/src/components/SocialIcon.tsx +88 -0
- package/src/components/ThemeToggle.tsx +34 -0
- package/src/components/TopNav.tsx +127 -0
- package/src/components/docs/Accordion.tsx +68 -0
- package/src/components/docs/Badge.tsx +171 -0
- package/src/components/docs/Callout.tsx +73 -0
- package/src/components/docs/Card.tsx +158 -0
- package/src/components/docs/CodeGroup.tsx +73 -0
- package/src/components/docs/Columns.tsx +20 -0
- package/src/components/docs/Expandable.tsx +28 -0
- package/src/components/docs/Frame.tsx +56 -0
- package/src/components/docs/Icon.tsx +30 -0
- package/src/components/docs/ParamField.tsx +45 -0
- package/src/components/docs/PropertiesTable.tsx +84 -0
- package/src/components/docs/ResponseField.tsx +36 -0
- package/src/components/docs/Steps.tsx +47 -0
- package/src/components/docs/Tabs.tsx +116 -0
- package/src/components/docs/Tooltip.tsx +21 -0
- package/src/components/docs/index.ts +15 -0
- package/src/components/docs/styles.ts +82 -0
- package/src/components/docs/utils.ts +118 -0
- package/src/components/icons/index.ts +17 -0
- package/src/components/ui/accordion.tsx +69 -0
- package/src/components/ui/alert.tsx +69 -0
- package/src/components/ui/badge.tsx +49 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +88 -0
- package/src/components/ui/collapsible.tsx +15 -0
- package/src/components/ui/command.tsx +173 -0
- package/src/components/ui/dialog.tsx +137 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/scroll-area.tsx +71 -0
- package/src/components/ui/sheet.tsx +124 -0
- package/src/components/ui/tabs.tsx +73 -0
- package/src/components/ui/tooltip.tsx +52 -0
- package/src/declarations.d.ts +9 -0
- package/src/entry-client.tsx +17 -0
- package/src/entry-server.tsx +77 -0
- package/src/generated/last-modified.ts +2 -0
- package/src/lib/content.ts +44 -0
- package/src/lib/docs-config.ts +682 -0
- package/src/lib/head.ts +220 -0
- package/src/lib/icon-registry.generated.ts +4 -0
- package/src/lib/icons.ts +29 -0
- package/src/lib/inline-markdown.tsx +86 -0
- package/src/lib/mdast.ts +56 -0
- package/src/lib/paths.ts +86 -0
- package/src/lib/remark-toc.ts +71 -0
- package/src/lib/search/config.ts +43 -0
- package/src/lib/search/provider.ts +80 -0
- package/src/lib/search/providers/local.ts +15 -0
- package/src/lib/search-index.generated.ts +4 -0
- package/src/lib/search.ts +100 -0
- package/src/lib/site-config.ts +104 -0
- package/src/lib/site-model.ts +221 -0
- package/src/lib/slug.ts +38 -0
- package/src/lib/types.ts +478 -0
- package/src/lib/utils.ts +6 -0
- package/src/pages/DocPage.tsx +33 -0
- package/src/styles/global.css +268 -0
- package/src/styles/tokens.css +114 -0
- package/types/client.d.ts +3 -0
- package/types/search.d.ts +17 -0
- package/vite.config.ts +342 -0
- package/LICENSE +0 -21
- package/dist/index.css +0 -189
- package/dist/index.d.ts +0 -57
- package/dist/index.js +0 -464
- package/dist/index.mjs +0 -437
- package/server/index.d.ts +0 -30
- package/server/index.js +0 -189
- package/styles.css +0 -4766
package/src/lib/head.ts
ADDED
|
@@ -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, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"')
|
|
38
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|
package/src/lib/icons.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/mdast.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -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
|
+
}
|