@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/bin/shiso.mjs +10 -1
  3. package/config.js +8 -0
  4. package/dist/chunks/App.js +270 -66
  5. package/dist/chunks/local.js +12 -3
  6. package/dist/chunks/pagefind.js +100 -0
  7. package/dist/entry-client.js +1 -1
  8. package/dist/entry-server.js +16 -5
  9. package/dist/search.js +10 -1
  10. package/docs.schema.json +30 -27
  11. package/package.json +10 -1
  12. package/scripts/build-runtime.mjs +1 -0
  13. package/scripts/check-package.mjs +1 -0
  14. package/scripts/generate-icon-registry.mjs +12 -2
  15. package/scripts/generate-search-index.mjs +4 -3
  16. package/scripts/load-shiso-config.mjs +167 -0
  17. package/scripts/pagefind-index.mjs +127 -0
  18. package/scripts/prerender.mjs +4 -2
  19. package/scripts/validate-config.mjs +23 -10
  20. package/scripts/vite-docs-config.mjs +52 -13
  21. package/src/App.tsx +12 -3
  22. package/src/components/DocContent.tsx +18 -4
  23. package/src/components/Header.tsx +61 -24
  24. package/src/components/Search.tsx +29 -2
  25. package/src/components/TopNav.tsx +9 -7
  26. package/src/components/docs/Button.tsx +67 -0
  27. package/src/components/docs/index.ts +1 -0
  28. package/src/components/ui/command.tsx +2 -2
  29. package/src/declarations.d.ts +7 -0
  30. package/src/entry-server.tsx +20 -3
  31. package/src/lib/content.ts +10 -2
  32. package/src/lib/head.ts +15 -7
  33. package/src/lib/locale.ts +1 -1
  34. package/src/lib/paths.ts +11 -7
  35. package/src/lib/search/provider.ts +13 -2
  36. package/src/lib/search/providers/pagefind.ts +180 -0
  37. package/src/lib/search.ts +32 -4
  38. package/src/lib/site-config.ts +21 -3
  39. package/src/lib/site-model.ts +7 -2
  40. package/src/lib/standalone-pages.ts +129 -0
  41. package/src/lib/types.ts +34 -4
  42. package/src/pages/StandalonePage.tsx +42 -0
  43. package/types/config.d.ts +19 -0
  44. package/types/search.d.ts +9 -0
  45. 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}
@@ -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
+ }
@@ -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 docsSite.pages.map(page => ({ route: page.url, filePath: page.filePath }));
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 `$shiso.siteUrl` is not configured, since a sitemap
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
 
@@ -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 `$shiso.contentDir` is applied at
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 doc = page ? getDocModule(page.filePath) : undefined;
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 ? toAbsoluteUrl(page.url) : undefined;
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 = !page || frontmatter?.noindex === true || (hidden && seo.indexing !== 'all');
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
- page && showTimestamp(frontmatter?.timestamp) ? getLastModified(page.filePath) : undefined;
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 `$shiso.locale`, then en-US.
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 rawConfig from 'virtual:shiso-docs-config';
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 = normalizePrefix(shiso.docsPrefix ?? '/docs');
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 = (shiso.contentDir ?? 'content/docs').replace(/^\/+|\/+$/g, '');
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?.replace(/\/+$/, '') || undefined;
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 === 'local') {
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" provider.',
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(/&lt;/g, '<')
45
+ .replace(/&gt;/g, '>')
46
+ .replace(/&quot;/g, '"')
47
+ .replace(/&#39;/g, "'")
48
+ .replace(/&amp;/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
- /** Snippet of section text around the first match, when the text matched. */
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 makeSnippet(text: string, index: number, length: number): string {
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 `${start > 0 ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`;
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: snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength) : undefined,
149
+ snippet:
150
+ snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength, terms) : undefined,
123
151
  score,
124
152
  });
125
153
  }
@@ -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 {
@@ -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(config: DocsConfig, docs: NormalizedDocsConfig): SiteModel {
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: config.$shiso?.locale?.trim() || 'en-US',
136
+ locale: shiso?.locale || 'en-US',
132
137
  labels: SHISO_THEME_LABELS,
133
138
  docs,
134
139
  };