@umami/shiso 1.3.0 → 1.5.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 +17 -0
- package/bin/shiso.mjs +10 -1
- package/config.js +8 -0
- package/dist/chunks/App.js +270 -66
- package/dist/chunks/local.js +12 -3
- package/dist/chunks/pagefind.js +100 -0
- package/dist/entry-client.js +1 -1
- package/dist/entry-server.js +16 -5
- package/dist/search.js +10 -1
- package/docs.schema.json +30 -27
- package/package.json +10 -1
- package/scripts/build-runtime.mjs +1 -0
- package/scripts/check-package.mjs +1 -0
- package/scripts/generate-icon-registry.mjs +12 -2
- package/scripts/generate-search-index.mjs +4 -3
- package/scripts/load-shiso-config.mjs +167 -0
- package/scripts/pagefind-index.mjs +127 -0
- package/scripts/prerender.mjs +4 -2
- package/scripts/validate-config.mjs +23 -10
- package/scripts/vite-docs-config.mjs +52 -13
- package/src/App.tsx +12 -3
- package/src/components/DocContent.tsx +18 -4
- package/src/components/Header.tsx +61 -24
- package/src/components/Search.tsx +29 -2
- package/src/components/TopNav.tsx +9 -7
- package/src/components/docs/Button.tsx +67 -0
- package/src/components/docs/index.ts +1 -0
- package/src/components/ui/command.tsx +2 -2
- package/src/declarations.d.ts +7 -0
- package/src/entry-server.tsx +20 -3
- package/src/lib/content.ts +10 -2
- package/src/lib/head.ts +15 -7
- package/src/lib/locale.ts +1 -1
- package/src/lib/paths.ts +11 -7
- package/src/lib/search/provider.ts +13 -2
- package/src/lib/search/providers/pagefind.ts +180 -0
- package/src/lib/search.ts +32 -4
- package/src/lib/site-config.ts +21 -3
- package/src/lib/site-model.ts +7 -2
- package/src/lib/standalone-pages.ts +129 -0
- package/src/lib/types.ts +34 -4
- package/src/pages/StandalonePage.tsx +42 -0
- package/types/config.d.ts +19 -0
- package/types/search.d.ts +9 -0
- package/vite.config.ts +58 -18
|
@@ -136,7 +136,7 @@ function CommandItem({
|
|
|
136
136
|
<CommandPrimitive.Item
|
|
137
137
|
data-slot="command-item"
|
|
138
138
|
className={cn(
|
|
139
|
-
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
|
139
|
+
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-muted data-[selected=true]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[selected=true]:*:[svg]:text-foreground",
|
|
140
140
|
className,
|
|
141
141
|
)}
|
|
142
142
|
{...props}
|
|
@@ -152,7 +152,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>)
|
|
|
152
152
|
<span
|
|
153
153
|
data-slot="command-shortcut"
|
|
154
154
|
className={cn(
|
|
155
|
-
'ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground',
|
|
155
|
+
'ml-auto text-xs tracking-widest text-muted-foreground group-data-[selected=true]/command-item:text-foreground',
|
|
156
156
|
className,
|
|
157
157
|
)}
|
|
158
158
|
{...props}
|
package/src/declarations.d.ts
CHANGED
|
@@ -7,3 +7,10 @@ declare module 'virtual:shiso-docs-config' {
|
|
|
7
7
|
const config: DocsConfig;
|
|
8
8
|
export default config;
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
declare module 'virtual:shiso-config' {
|
|
12
|
+
import type { ResolvedShisoConfig } from '@/lib/types';
|
|
13
|
+
|
|
14
|
+
const config: ResolvedShisoConfig;
|
|
15
|
+
export default config;
|
|
16
|
+
}
|
package/src/entry-server.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getRedirects,
|
|
13
13
|
getSeo,
|
|
14
14
|
siteName,
|
|
15
|
+
standalonePages,
|
|
15
16
|
} from '@/lib/site-config';
|
|
16
17
|
|
|
17
18
|
export interface RenderResult {
|
|
@@ -28,7 +29,7 @@ export interface SitemapEntry {
|
|
|
28
29
|
|
|
29
30
|
/** Base-relative routes for every scope. The prerenderer prepends the deploy base itself. */
|
|
30
31
|
export function getRoutes(): string[] {
|
|
31
|
-
return docsSite.pages.map(page => page.url);
|
|
32
|
+
return [...docsSite.pages.map(page => page.url), ...standalonePages.map(page => page.path)];
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/** Redirect rules with exact-match sources, for static redirect pages. */
|
|
@@ -41,12 +42,15 @@ export { getRedirects };
|
|
|
41
42
|
* "/content/docs/index.mdx", resolved against the project root.
|
|
42
43
|
*/
|
|
43
44
|
export function getMarkdownPages(): { route: string; filePath: string }[] {
|
|
44
|
-
return
|
|
45
|
+
return [
|
|
46
|
+
...docsSite.pages.map(page => ({ route: page.url, filePath: page.filePath })),
|
|
47
|
+
...standalonePages.map(page => ({ route: page.path, filePath: page.filePath })),
|
|
48
|
+
];
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
52
|
* Absolute URLs for the sitemap, honoring `seo.indexing` and per-page
|
|
49
|
-
* noindex. Empty when
|
|
53
|
+
* noindex. Empty when the shiso.config `siteUrl` is not configured, since a sitemap
|
|
50
54
|
* of relative URLs is invalid.
|
|
51
55
|
*/
|
|
52
56
|
export function getSitemapEntries(): SitemapEntry[] {
|
|
@@ -71,6 +75,19 @@ export function getSitemapEntries(): SitemapEntry[] {
|
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
// Standalone pages are always navigable; only frontmatter noindex opts out.
|
|
79
|
+
for (const page of standalonePages) {
|
|
80
|
+
if (getDocModule(page.filePath)?.frontmatter?.noindex === true) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const url = toAbsoluteUrl(page.path);
|
|
85
|
+
|
|
86
|
+
if (url) {
|
|
87
|
+
entries.push({ url, lastmod: getLastModified(page.filePath) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
return entries;
|
|
75
92
|
}
|
|
76
93
|
|
package/src/lib/content.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LAST_MODIFIED } from '@/generated/last-modified';
|
|
2
|
-
import { CONTENT_DIR } from '@/lib/paths';
|
|
2
|
+
import { CONTENT_DIR, PAGES_DIR } from '@/lib/paths';
|
|
3
3
|
import type { DocModule } from '@/lib/types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -12,7 +12,7 @@ import type { DocModule } from '@/lib/types';
|
|
|
12
12
|
* without Suspense, at the cost of bundling all pages together.
|
|
13
13
|
*
|
|
14
14
|
* The glob pattern must be a literal for Vite to statically analyze it, so it
|
|
15
|
-
* covers all of `content/` and the configured
|
|
15
|
+
* covers all of `content/` and the configured shiso.config `contentDir` is applied at
|
|
16
16
|
* lookup time instead. That also lets later versioned/localized content roots
|
|
17
17
|
* (`content/v2`, `content/es`) work without touching this glob.
|
|
18
18
|
*/
|
|
@@ -30,6 +30,14 @@ export function resolveDocFile(fileSlug: string, contentDir = CONTENT_DIR): stri
|
|
|
30
30
|
return candidates.find(candidate => candidate in docModules);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Resolves a standalone page slug (docs.json `pages[].page`) to a module key
|
|
35
|
+
* under the fixed content/pages root.
|
|
36
|
+
*/
|
|
37
|
+
export function resolvePageFile(fileSlug: string): string | undefined {
|
|
38
|
+
return resolveDocFile(fileSlug, PAGES_DIR);
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
export function getDocModule(filePath: string): DocModule | undefined {
|
|
34
42
|
return docModules[filePath];
|
|
35
43
|
}
|
package/src/lib/head.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getPageTitle,
|
|
9
9
|
getScopeByPathname,
|
|
10
10
|
getSeo,
|
|
11
|
+
getStandalonePage,
|
|
11
12
|
showTimestamp,
|
|
12
13
|
siteConfig,
|
|
13
14
|
siteModel,
|
|
@@ -49,18 +50,25 @@ function escapeJsonLd(value: string): string {
|
|
|
49
50
|
|
|
50
51
|
export function buildHead(pathname: string): HeadTag[] {
|
|
51
52
|
const page = getPageByPathname(pathname);
|
|
52
|
-
const
|
|
53
|
+
const standalone = page ? null : getStandalonePage(pathname);
|
|
54
|
+
const filePath = page?.filePath || standalone?.filePath;
|
|
55
|
+
const doc = filePath ? getDocModule(filePath) : undefined;
|
|
53
56
|
const frontmatter = doc?.frontmatter;
|
|
54
57
|
|
|
55
58
|
const seo = getSeo();
|
|
56
|
-
const pageTitle = frontmatter?.title || page?.label;
|
|
59
|
+
const pageTitle = frontmatter?.title || standalone?.title || page?.label;
|
|
57
60
|
const title = getPageTitle(pageTitle);
|
|
58
61
|
const description = frontmatter?.description || siteConfig.description;
|
|
59
|
-
const canonical = page
|
|
62
|
+
const canonical = page
|
|
63
|
+
? toAbsoluteUrl(page.url)
|
|
64
|
+
: standalone
|
|
65
|
+
? toAbsoluteUrl(standalone.path)
|
|
66
|
+
: undefined;
|
|
60
67
|
// `seo.indexing: "all"` opts hidden pages (and hidden versions/languages)
|
|
61
68
|
// into the index; explicit per-page `noindex` frontmatter always wins.
|
|
62
69
|
const hidden = !!page && (!!page.hidden || !!getScopeByPathname(pathname).hidden);
|
|
63
|
-
const noindex =
|
|
70
|
+
const noindex =
|
|
71
|
+
(!page && !standalone) || frontmatter?.noindex === true || (hidden && seo.indexing !== 'all');
|
|
64
72
|
|
|
65
73
|
const tags: HeadTag[] = [{ tag: 'title', children: title }];
|
|
66
74
|
|
|
@@ -76,9 +84,9 @@ export function buildHead(pathname: string): HeadTag[] {
|
|
|
76
84
|
tags.push({ tag: 'meta', attrs: { name: 'robots', content: 'noindex' } });
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
// Open Graph
|
|
87
|
+
// Open Graph. Standalone pages (home, landing) are websites, not articles.
|
|
80
88
|
tags.push(
|
|
81
|
-
{ tag: 'meta', attrs: { property: 'og:type', content: 'article' } },
|
|
89
|
+
{ tag: 'meta', attrs: { property: 'og:type', content: standalone ? 'website' : 'article' } },
|
|
82
90
|
{ tag: 'meta', attrs: { property: 'og:title', content: title } },
|
|
83
91
|
);
|
|
84
92
|
|
|
@@ -106,7 +114,7 @@ export function buildHead(pathname: string): HeadTag[] {
|
|
|
106
114
|
|
|
107
115
|
// Last-modified time, when the timestamp feature is on for this page.
|
|
108
116
|
const lastModified =
|
|
109
|
-
|
|
117
|
+
filePath && showTimestamp(frontmatter?.timestamp) ? getLastModified(filePath) : undefined;
|
|
110
118
|
|
|
111
119
|
if (lastModified) {
|
|
112
120
|
tags.push({ tag: 'meta', attrs: { property: 'article:modified_time', content: lastModified } });
|
package/src/lib/locale.ts
CHANGED
|
@@ -18,7 +18,7 @@ export function isValidLocale(value: string | undefined): value is string {
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Locale for a page: its scope's language code when valid, then the
|
|
21
|
-
* site-wide
|
|
21
|
+
* site-wide shiso.config `locale`, then en-US.
|
|
22
22
|
*/
|
|
23
23
|
export function resolveLocale(language: string | undefined, fallback: string | undefined): string {
|
|
24
24
|
if (isValidLocale(language)) {
|
package/src/lib/paths.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type { ShisoOptions } from '@/lib/types';
|
|
1
|
+
import shiso from 'virtual:shiso-config';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* All URL construction goes through this module.
|
|
@@ -14,10 +13,11 @@ import type { ShisoOptions } from '@/lib/types';
|
|
|
14
13
|
* DOCS_PREFIX but not BASE_URL. React Router's `basename` adds BASE_URL, so
|
|
15
14
|
* only code that bypasses the router (prerender output paths, canonical URLs,
|
|
16
15
|
* raw <a href>) needs `toHref`.
|
|
16
|
+
*
|
|
17
|
+
* Values from `virtual:shiso-config` arrive with defaults applied and already
|
|
18
|
+
* normalized by scripts/load-shiso-config.mjs.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
|
-
const shiso = ((rawConfig as { $shiso?: ShisoOptions }).$shiso || {}) as ShisoOptions;
|
|
20
|
-
|
|
21
21
|
/** Strips trailing slashes; "/" and "" both normalize to "". */
|
|
22
22
|
function normalizePrefix(value: string): string {
|
|
23
23
|
const trimmed = value.trim().replace(/\/+$/, '');
|
|
@@ -31,13 +31,17 @@ function normalizePrefix(value: string): string {
|
|
|
31
31
|
|
|
32
32
|
export const BASE_URL = normalizePrefix(import.meta.env?.BASE_URL || '/');
|
|
33
33
|
|
|
34
|
-
export const DOCS_PREFIX =
|
|
34
|
+
export const DOCS_PREFIX = shiso.docsPrefix;
|
|
35
35
|
|
|
36
36
|
/** Content directory, relative to the project root, without leading/trailing slashes. */
|
|
37
|
-
export const CONTENT_DIR =
|
|
37
|
+
export const CONTENT_DIR = shiso.contentDir;
|
|
38
|
+
|
|
39
|
+
/** Fixed root for standalone (non-docs) page files. Not configurable, so page
|
|
40
|
+
* slugs can never collide with the docs content tree. */
|
|
41
|
+
export const PAGES_DIR = 'content/pages';
|
|
38
42
|
|
|
39
43
|
/** Absolute origin used for canonical and og:url tags. Undefined when unconfigured. */
|
|
40
|
-
export const SITE_URL = shiso.siteUrl
|
|
44
|
+
export const SITE_URL = shiso.siteUrl;
|
|
41
45
|
|
|
42
46
|
/** Joins path segments with exactly one slash between them. */
|
|
43
47
|
export function joinPath(...parts: (string | undefined)[]): string {
|
|
@@ -21,6 +21,8 @@ export interface ResolvedSearchProvider {
|
|
|
21
21
|
|
|
22
22
|
const factories = new Map<string, SearchProviderFactory>();
|
|
23
23
|
|
|
24
|
+
const BUILTIN_PROVIDERS = new Set(['local', 'pagefind']);
|
|
25
|
+
|
|
24
26
|
function normalizeProviderId(id: string): string {
|
|
25
27
|
return id.trim().toLowerCase();
|
|
26
28
|
}
|
|
@@ -32,9 +34,9 @@ function normalizeProviderId(id: string): string {
|
|
|
32
34
|
export function registerSearchProvider(id: string, factory: SearchProviderFactory): () => void {
|
|
33
35
|
const providerId = normalizeProviderId(id);
|
|
34
36
|
|
|
35
|
-
if (!providerId || providerId
|
|
37
|
+
if (!providerId || BUILTIN_PROVIDERS.has(providerId)) {
|
|
36
38
|
throw new Error(
|
|
37
|
-
'Search provider ids must be non-empty and cannot replace the built-in "local"
|
|
39
|
+
'Search provider ids must be non-empty and cannot replace the built-in providers ("local", "pagefind").',
|
|
38
40
|
);
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -67,6 +69,15 @@ export async function resolveSearchProvider(
|
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
if (requestedId === 'pagefind') {
|
|
73
|
+
const { createPagefindSearchProvider } = await import('@/lib/search/providers/pagefind');
|
|
74
|
+
return {
|
|
75
|
+
provider: createPagefindSearchProvider(options),
|
|
76
|
+
providerId: 'pagefind',
|
|
77
|
+
fellBack: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
const factory = factories.get(requestedId);
|
|
71
82
|
|
|
72
83
|
if (factory) {
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { SearchContext, SearchResult } from '@/lib/search';
|
|
2
|
+
import type { SearchProvider } from '@/lib/search/provider';
|
|
3
|
+
|
|
4
|
+
/** Subset of the Pagefind browser API used by this provider. The types are
|
|
5
|
+
* declared locally because the `pagefind` package is an optional dependency
|
|
6
|
+
* and its browser bundle only exists in built output. */
|
|
7
|
+
interface PagefindSubResult {
|
|
8
|
+
title?: string;
|
|
9
|
+
url: string;
|
|
10
|
+
excerpt?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PagefindFragment {
|
|
14
|
+
url: string;
|
|
15
|
+
excerpt?: string;
|
|
16
|
+
meta?: { title?: string };
|
|
17
|
+
sub_results?: PagefindSubResult[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PagefindResult {
|
|
21
|
+
id: string;
|
|
22
|
+
score?: number;
|
|
23
|
+
data(): Promise<PagefindFragment>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PagefindApi {
|
|
27
|
+
options(options: Record<string, unknown>): Promise<void>;
|
|
28
|
+
init(): Promise<void>;
|
|
29
|
+
search(query: string, options?: Record<string, unknown>): Promise<{ results: PagefindResult[] }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Backend = { kind: 'pagefind'; api: PagefindApi } | { kind: 'local'; provider: SearchProvider };
|
|
33
|
+
|
|
34
|
+
const DEFAULT_LIMIT = 10;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sanitizes a Pagefind excerpt: keeps the `<mark>` highlight tags (rendered
|
|
38
|
+
* as highlights by the search dialog, never as raw HTML), strips every other
|
|
39
|
+
* tag, and decodes basic HTML entities.
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizePagefindExcerpt(excerpt: string): string {
|
|
42
|
+
return excerpt
|
|
43
|
+
.replace(/<(?!\/?mark>)[^>]*>/g, '')
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/'/g, "'")
|
|
48
|
+
.replace(/&/g, '&')
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Converts a Pagefind result URL into a router-relative path: strips a
|
|
53
|
+
* trailing `/index.html` and trailing slash while preserving `#anchor`. */
|
|
54
|
+
export function normalizePagefindUrl(url: string): string {
|
|
55
|
+
const hashIndex = url.indexOf('#');
|
|
56
|
+
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
|
57
|
+
let pathname = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
58
|
+
|
|
59
|
+
pathname = pathname.replace(/\/index\.html$/, '/');
|
|
60
|
+
|
|
61
|
+
if (pathname.length > 1) {
|
|
62
|
+
pathname = pathname.replace(/\/+$/, '') || '/';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return `${pathname}${hash}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Maps loaded Pagefind fragments to `SearchResult`s. Each sub-result (a
|
|
69
|
+
* heading-bounded section) becomes its own result; duplicate URLs are
|
|
70
|
+
* dropped because the search dialog keys items by URL. */
|
|
71
|
+
export function mapPagefindResults(
|
|
72
|
+
fragments: { fragment: PagefindFragment; score?: number }[],
|
|
73
|
+
limit: number,
|
|
74
|
+
): SearchResult[] {
|
|
75
|
+
const results: SearchResult[] = [];
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
|
|
78
|
+
for (const { fragment, score } of fragments) {
|
|
79
|
+
const page = fragment.meta?.title || fragment.url;
|
|
80
|
+
const subResults = fragment.sub_results?.length
|
|
81
|
+
? fragment.sub_results
|
|
82
|
+
: [{ url: fragment.url, excerpt: fragment.excerpt }];
|
|
83
|
+
|
|
84
|
+
for (const subResult of subResults) {
|
|
85
|
+
const url = normalizePagefindUrl(subResult.url);
|
|
86
|
+
|
|
87
|
+
if (seen.has(url)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
seen.add(url);
|
|
92
|
+
|
|
93
|
+
const hasAnchor = url.includes('#');
|
|
94
|
+
const heading =
|
|
95
|
+
hasAnchor && subResult.title && subResult.title !== page ? subResult.title : undefined;
|
|
96
|
+
const excerpt = subResult.excerpt || fragment.excerpt;
|
|
97
|
+
|
|
98
|
+
results.push({
|
|
99
|
+
url,
|
|
100
|
+
page,
|
|
101
|
+
heading,
|
|
102
|
+
snippet: excerpt ? sanitizePagefindExcerpt(excerpt) : undefined,
|
|
103
|
+
score: score ?? 0,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (results.length >= limit) {
|
|
107
|
+
return results;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Built-in provider backed by a Pagefind index generated during `shiso build`.
|
|
117
|
+
* When the Pagefind bundle is unavailable (for example in dev, where no
|
|
118
|
+
* prerendered HTML exists), it falls back to the local provider.
|
|
119
|
+
*/
|
|
120
|
+
export function createPagefindSearchProvider(
|
|
121
|
+
options: Record<string, unknown> = {},
|
|
122
|
+
): SearchProvider {
|
|
123
|
+
let backend: Promise<Backend> | null = null;
|
|
124
|
+
|
|
125
|
+
async function loadBackend(): Promise<Backend> {
|
|
126
|
+
// Mirrors the BASE_URL normalization in `@/lib/paths` without importing
|
|
127
|
+
// it — that module depends on `virtual:shiso-docs-config`, which is not
|
|
128
|
+
// available to the standalone `@umami/shiso/search` bundle.
|
|
129
|
+
const base = (import.meta.env?.BASE_URL || '/').trim().replace(/\/+$/, '');
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const api: PagefindApi = await import(/* @vite-ignore */ `${base}/pagefind/pagefind.js`);
|
|
133
|
+
const ranking = options.ranking;
|
|
134
|
+
|
|
135
|
+
await api.options({
|
|
136
|
+
baseUrl: '/',
|
|
137
|
+
...(ranking && typeof ranking === 'object' ? { ranking } : {}),
|
|
138
|
+
});
|
|
139
|
+
await api.init();
|
|
140
|
+
|
|
141
|
+
return { kind: 'pagefind', api };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.warn(
|
|
144
|
+
'[shiso] Pagefind bundle not found — falling back to local search. ' +
|
|
145
|
+
'This is expected in dev; run "shiso build" to generate the Pagefind index.',
|
|
146
|
+
error,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const { createLocalSearchProvider } = await import('@/lib/search/providers/local');
|
|
150
|
+
|
|
151
|
+
return { kind: 'local', provider: createLocalSearchProvider({}) };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
async search(query: string, limit = DEFAULT_LIMIT, context?: SearchContext) {
|
|
157
|
+
backend ||= loadBackend();
|
|
158
|
+
const resolved = await backend;
|
|
159
|
+
|
|
160
|
+
if (resolved.kind === 'local') {
|
|
161
|
+
return resolved.provider.search(query, limit, context);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const filters =
|
|
165
|
+
context?.scopeId && context.scopeId !== 'default'
|
|
166
|
+
? { filters: { scope: context.scopeId } }
|
|
167
|
+
: undefined;
|
|
168
|
+
const response = await resolved.api.search(query, filters);
|
|
169
|
+
const fragments: { fragment: PagefindFragment; score?: number }[] = [];
|
|
170
|
+
|
|
171
|
+
// Hydrate fragments lazily; each page yields at least one result, so
|
|
172
|
+
// `limit` pages is always enough to fill `limit` results.
|
|
173
|
+
for (const result of response.results.slice(0, limit)) {
|
|
174
|
+
fragments.push({ fragment: await result.data(), score: result.score });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return mapPagefindResults(fragments, limit);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
package/src/lib/search.ts
CHANGED
|
@@ -55,7 +55,11 @@ export interface SearchResult {
|
|
|
55
55
|
page: string;
|
|
56
56
|
/** Section heading, absent for the page intro. */
|
|
57
57
|
heading?: string;
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Snippet of section text around the first match, when the text matched.
|
|
60
|
+
* Matched terms may be wrapped in `<mark>` tags; the search dialog renders
|
|
61
|
+
* them as highlights (never as raw HTML).
|
|
62
|
+
*/
|
|
59
63
|
snippet?: string;
|
|
60
64
|
/** Provider-specific relevance score. Use 0 when a provider does not expose one. */
|
|
61
65
|
score: number;
|
|
@@ -63,11 +67,34 @@ export interface SearchResult {
|
|
|
63
67
|
|
|
64
68
|
const SNIPPET_RADIUS = 60;
|
|
65
69
|
|
|
66
|
-
function
|
|
70
|
+
function escapeRegExp(value: string): string {
|
|
71
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Wraps every term occurrence in `<mark>` so the dialog can highlight it. */
|
|
75
|
+
export function highlightTerms(snippet: string, terms: string[]): string {
|
|
76
|
+
if (!terms.length) {
|
|
77
|
+
return snippet;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Longer terms first, so overlapping terms highlight the longest match.
|
|
81
|
+
const pattern = new RegExp(
|
|
82
|
+
[...terms]
|
|
83
|
+
.sort((a, b) => b.length - a.length)
|
|
84
|
+
.map(escapeRegExp)
|
|
85
|
+
.join('|'),
|
|
86
|
+
'gi',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return snippet.replace(pattern, '<mark>$&</mark>');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeSnippet(text: string, index: number, length: number, terms: string[]): string {
|
|
67
93
|
const start = Math.max(0, index - SNIPPET_RADIUS);
|
|
68
94
|
const end = Math.min(text.length, index + length + SNIPPET_RADIUS);
|
|
95
|
+
const excerpt = `${start > 0 ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`;
|
|
69
96
|
|
|
70
|
-
return
|
|
97
|
+
return highlightTerms(excerpt, terms);
|
|
71
98
|
}
|
|
72
99
|
|
|
73
100
|
export function searchIndex(records: SearchRecord[], query: string, limit = 10): SearchResult[] {
|
|
@@ -119,7 +146,8 @@ export function searchIndex(records: SearchRecord[], query: string, limit = 10):
|
|
|
119
146
|
url: record.id ? `${record.url}#${record.id}` : record.url,
|
|
120
147
|
page: record.page,
|
|
121
148
|
heading: record.heading,
|
|
122
|
-
snippet:
|
|
149
|
+
snippet:
|
|
150
|
+
snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength, terms) : undefined,
|
|
123
151
|
score,
|
|
124
152
|
});
|
|
125
153
|
}
|
package/src/lib/site-config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import shisoConfig from 'virtual:shiso-config';
|
|
1
2
|
import rawConfig from 'virtual:shiso-docs-config';
|
|
2
|
-
import { resolveDocFile } from '@/lib/content';
|
|
3
|
+
import { resolveDocFile, resolvePageFile } from '@/lib/content';
|
|
3
4
|
import {
|
|
4
5
|
assertDocsConfig,
|
|
5
6
|
getDefaultScope,
|
|
@@ -8,8 +9,9 @@ import {
|
|
|
8
9
|
normalizeDocsSite,
|
|
9
10
|
} from '@/lib/docs-config';
|
|
10
11
|
import { getTextDirection, resolveLocale } from '@/lib/locale';
|
|
11
|
-
import { stripBase } from '@/lib/paths';
|
|
12
|
+
import { DOCS_PREFIX, stripBase } from '@/lib/paths';
|
|
12
13
|
import { resolveSiteModel } from '@/lib/site-model';
|
|
14
|
+
import { getStandalonePageByPathname, normalizeStandalonePages } from '@/lib/standalone-pages';
|
|
13
15
|
import type {
|
|
14
16
|
DocsConfig,
|
|
15
17
|
DocsScope,
|
|
@@ -18,6 +20,7 @@ import type {
|
|
|
18
20
|
NormalizedDocsSite,
|
|
19
21
|
RedirectRule,
|
|
20
22
|
SeoConfig,
|
|
23
|
+
StandalonePage,
|
|
21
24
|
} from '@/lib/types';
|
|
22
25
|
|
|
23
26
|
assertDocsConfig(rawConfig, 'docs.json');
|
|
@@ -33,7 +36,22 @@ export const docsConfig: NormalizedDocsConfig = getDefaultScope(docsSite).docs;
|
|
|
33
36
|
/** Landing page of the default scope: the site-wide "docs home" URL. */
|
|
34
37
|
export const docsHomeUrl = getDefaultScope(docsSite).firstPageUrl;
|
|
35
38
|
|
|
36
|
-
export const siteModel = resolveSiteModel(siteConfig, docsConfig);
|
|
39
|
+
export const siteModel = resolveSiteModel(siteConfig, docsConfig, shisoConfig);
|
|
40
|
+
|
|
41
|
+
/** Standalone pages declared with the top-level `pages` key, e.g. a home page. */
|
|
42
|
+
export const standalonePages: StandalonePage[] = normalizeStandalonePages(
|
|
43
|
+
siteConfig,
|
|
44
|
+
resolvePageFile,
|
|
45
|
+
docsSite,
|
|
46
|
+
{ docsPrefix: DOCS_PREFIX },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** True when a standalone page owns "/", replacing the root docs redirect. */
|
|
50
|
+
export const hasRootStandalonePage = standalonePages.some(page => page.path === '/');
|
|
51
|
+
|
|
52
|
+
export function getStandalonePage(pathname: string): StandalonePage | null {
|
|
53
|
+
return getStandalonePageByPathname(standalonePages, stripBase(pathname));
|
|
54
|
+
}
|
|
37
55
|
|
|
38
56
|
/** Scope that owns the current pathname; the default scope for unknown paths. */
|
|
39
57
|
export function getScopeByPathname(pathname: string): DocsScope {
|
package/src/lib/site-model.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
NormalizedLink,
|
|
13
13
|
NormalizedNavbar,
|
|
14
14
|
ResolvedContextualOption,
|
|
15
|
+
ResolvedShisoConfig,
|
|
15
16
|
SiteModel,
|
|
16
17
|
ThemeLabels,
|
|
17
18
|
} from '@/lib/types';
|
|
@@ -90,7 +91,11 @@ function normalizeFooter(config: DocsConfig['footer']): NormalizedFooter | null
|
|
|
90
91
|
return socials.length || links.length || attribution ? { socials, links, attribution } : null;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
export function resolveSiteModel(
|
|
94
|
+
export function resolveSiteModel(
|
|
95
|
+
config: DocsConfig,
|
|
96
|
+
docs: NormalizedDocsConfig,
|
|
97
|
+
shiso?: ResolvedShisoConfig,
|
|
98
|
+
): SiteModel {
|
|
94
99
|
const appearance = config.appearance || {};
|
|
95
100
|
const logo = config.logo
|
|
96
101
|
? typeof config.logo === 'string'
|
|
@@ -128,7 +133,7 @@ export function resolveSiteModel(config: DocsConfig, docs: NormalizedDocsConfig)
|
|
|
128
133
|
error404: { ...config.errors?.['404'], redirect: config.errors?.['404']?.redirect !== false },
|
|
129
134
|
showTimestamp: config.metadata?.timestamp === true,
|
|
130
135
|
drilldown: config.interaction?.drilldown,
|
|
131
|
-
locale:
|
|
136
|
+
locale: shiso?.locale || 'en-US',
|
|
132
137
|
labels: SHISO_THEME_LABELS,
|
|
133
138
|
docs,
|
|
134
139
|
};
|