@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
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Generates a Pagefind index over the prerendered HTML.
3
+ *
4
+ * Runs after `prerender.mjs` as the final step of `shiso build`, from the
5
+ * project root. It is a no-op unless docs.json resolves `search.provider`
6
+ * to "pagefind". Only pages carrying `data-pagefind-body` (the doc
7
+ * `<article>`) are indexed, so redirect stubs and the 404 page are skipped
8
+ * automatically.
9
+ *
10
+ * The base subdirectory of dist/client is indexed (not dist/client itself)
11
+ * so the recorded URLs are base-relative — the runtime provider hands them
12
+ * to a router whose `basename` re-applies the deploy base.
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import process from 'node:process';
18
+
19
+ import { loadDocsConfig } from './load-docs-config.mjs';
20
+
21
+ const root = process.cwd();
22
+ const clientDir = path.join(root, 'dist', 'client');
23
+
24
+ const { config } = await loadDocsConfig({ root });
25
+
26
+ const search = config.search;
27
+ const provider =
28
+ search === false
29
+ ? ''
30
+ : String(search?.provider || 'local')
31
+ .trim()
32
+ .toLowerCase();
33
+
34
+ if (provider !== 'pagefind') {
35
+ process.exit(0);
36
+ }
37
+
38
+ let pagefind;
39
+
40
+ try {
41
+ pagefind = await import('pagefind');
42
+ } catch (error) {
43
+ if (error?.code === 'ERR_MODULE_NOT_FOUND') {
44
+ console.error(
45
+ 'search.provider is "pagefind" but the "pagefind" package is not installed.\n' +
46
+ 'It is an optional dependency of @umami/shiso — install it in your project:\n\n' +
47
+ ' pnpm add -D pagefind\n',
48
+ );
49
+ process.exit(1);
50
+ }
51
+
52
+ throw error;
53
+ }
54
+
55
+ /** Vite's `base`, normalized to "" or "/prefix". Mirrors prerender.mjs. */
56
+ function readBase(template) {
57
+ const match = template.match(/<script[^>]+src="([^"]*)\/assets\//);
58
+ const base = match?.[1] ?? '';
59
+ return base === '/' ? '' : base;
60
+ }
61
+
62
+ const template = await readFile(path.join(clientDir, 'index.html'), 'utf8');
63
+ const base = readBase(template);
64
+ const indexRoot = path.join(clientDir, ...base.split('/').filter(Boolean));
65
+ const outputPath = path.join(indexRoot, 'pagefind');
66
+
67
+ // The permalink "#" appended to every heading (rehype-autolink-headings in
68
+ // mdx.config.ts) must not leak into indexed titles and excerpts.
69
+ const DEFAULT_EXCLUDE_SELECTORS = ['.heading-anchor'];
70
+
71
+ const searchOptions = search && typeof search === 'object' ? search.options || {} : {};
72
+ const excludeSelectors = [
73
+ ...DEFAULT_EXCLUDE_SELECTORS,
74
+ ...(Array.isArray(searchOptions.excludeSelectors) ? searchOptions.excludeSelectors : []),
75
+ ];
76
+
77
+ const { index, errors: createErrors } = await pagefind.createIndex({ excludeSelectors });
78
+
79
+ if (!index) {
80
+ console.error(`Pagefind index creation failed:\n${(createErrors || []).join('\n')}`);
81
+ process.exit(1);
82
+ }
83
+
84
+ const { page_count, errors } = await index.addDirectory({ path: indexRoot, glob: '**/*.html' });
85
+
86
+ if (errors?.length) {
87
+ console.error(`Pagefind indexing failed:\n${errors.join('\n')}`);
88
+ await pagefind.close();
89
+ process.exit(1);
90
+ }
91
+
92
+ if (!page_count) {
93
+ console.error(
94
+ 'Pagefind indexed 0 pages. Expected prerendered pages with a data-pagefind-body attribute ' +
95
+ `under ${path.relative(root, indexRoot)}.`,
96
+ );
97
+ await pagefind.close();
98
+ process.exit(1);
99
+ }
100
+
101
+ const { errors: writeErrors } = await index.writeFiles({ outputPath });
102
+
103
+ if (writeErrors?.length) {
104
+ console.error(`Pagefind bundle write failed:\n${writeErrors.join('\n')}`);
105
+ await pagefind.close();
106
+ process.exit(1);
107
+ }
108
+
109
+ await pagefind.close();
110
+
111
+ // `addDirectory` counts scanned files; the entry manifest counts pages that
112
+ // actually carried `data-pagefind-body` and made it into the index.
113
+ const entry = JSON.parse(await readFile(path.join(outputPath, 'pagefind-entry.json'), 'utf8'));
114
+ const indexedPages = Object.values(entry.languages || {}).reduce(
115
+ (total, language) => total + (language.page_count || 0),
116
+ 0,
117
+ );
118
+
119
+ if (!indexedPages) {
120
+ console.error(
121
+ 'Pagefind indexed 0 pages. Expected prerendered pages with a data-pagefind-body attribute ' +
122
+ `under ${path.relative(root, indexRoot)}.`,
123
+ );
124
+ process.exit(1);
125
+ }
126
+
127
+ console.log(`Pagefind indexed ${indexedPages} pages into ${path.relative(root, outputPath)}`);
@@ -86,7 +86,9 @@ for (const route of routes) {
86
86
  // Root entry. When the default scope's landing page is not the root itself the
87
87
  // root is a redirect; a meta refresh alone is slow and SEO-hostile, so pair it
88
88
  // with a canonical link and an immediate history-replacing navigation.
89
- if (docsHomeUrl && docsHomeUrl !== '/') {
89
+ // When a standalone page owns "/", the routes loop above already rendered the
90
+ // real home page into dist/client/index.html — do not overwrite it.
91
+ if (docsHomeUrl && docsHomeUrl !== '/' && !routes.includes('/')) {
90
92
  const target = withBase(`${docsHomeUrl}/`);
91
93
 
92
94
  await writePage(
@@ -144,7 +146,7 @@ for (const { source, destination } of redirects) {
144
146
  );
145
147
  }
146
148
 
147
- // Sitemap, only when $shiso.siteUrl provides an absolute origin.
149
+ // Sitemap, only when shiso.config siteUrl provides an absolute origin.
148
150
  const sitemapEntries = getSitemapEntries();
149
151
 
150
152
  if (sitemapEntries.length) {
@@ -52,25 +52,38 @@ export function suggestKey(unknownKey, knownKeys) {
52
52
  return bestDistance <= threshold ? best : null;
53
53
  }
54
54
 
55
+ const SHISO_KEY_MIGRATION =
56
+ '(root) "$shiso" is no longer supported in docs.json — move docsPrefix, contentDir, ' +
57
+ 'siteUrl, and locale to shiso.config.ts. See https://shiso.umami.is/docs/project-settings.';
58
+
55
59
  export function validateConfig(config, schema) {
56
60
  const ajv = new Ajv({ allErrors: true, allowUnionTypes: true });
57
61
 
58
62
  const validate = ajv.compile(schema);
59
63
  const knownKeys = getSchemaKeys(schema);
64
+ // Checked directly, not just via Ajv's additionalProperties error, so the
65
+ // migration message appears even when other errors change Ajv's output.
66
+ const hasLegacyShisoKey = !!config && typeof config === 'object' && '$shiso' in config;
67
+
68
+ if (!validate(config) || hasLegacyShisoKey) {
69
+ const errors = (validate.errors ?? [])
70
+ .filter(error => !(error.params?.additionalProperty === '$shiso' && !error.instancePath))
71
+ .map(error => {
72
+ const location = error.instancePath || '(root)';
73
+ const extra = error.params?.additionalProperty;
60
74
 
61
- if (!validate(config)) {
62
- const errors = (validate.errors ?? []).map(error => {
63
- const location = error.instancePath || '(root)';
64
- const extra = error.params?.additionalProperty;
75
+ if (extra) {
76
+ const suggestion = !error.instancePath && suggestKey(extra, knownKeys);
65
77
 
66
- if (extra) {
67
- const suggestion = !error.instancePath && suggestKey(extra, knownKeys);
78
+ return `${location} has unknown key "${extra}"${suggestion ? ` — did you mean "${suggestion}"?` : ''}`;
79
+ }
68
80
 
69
- return `${location} has unknown key "${extra}"${suggestion ? ` — did you mean "${suggestion}"?` : ''}`;
70
- }
81
+ return `${location} ${error.message}`;
82
+ });
71
83
 
72
- return `${location} ${error.message}`;
73
- });
84
+ if (hasLegacyShisoKey) {
85
+ errors.unshift(SHISO_KEY_MIGRATION);
86
+ }
74
87
 
75
88
  return { valid: false, errors };
76
89
  }
@@ -1,17 +1,22 @@
1
1
  import path from 'node:path';
2
2
  import { loadDocsConfig } from './load-docs-config.mjs';
3
+ import { SHISO_CONFIG_FILES, loadShisoConfig } from './load-shiso-config.mjs';
3
4
 
4
5
  export const VIRTUAL_DOCS_CONFIG_ID = 'virtual:shiso-docs-config';
6
+ export const VIRTUAL_SHISO_CONFIG_ID = 'virtual:shiso-config';
5
7
  const RESOLVED_DOCS_CONFIG_ID = `\0${VIRTUAL_DOCS_CONFIG_ID}`;
8
+ const RESOLVED_SHISO_CONFIG_ID = `\0${VIRTUAL_SHISO_CONFIG_ID}`;
6
9
 
7
10
  function renderConfigModule(config) {
8
11
  return `export default ${JSON.stringify(config)};`;
9
12
  }
10
13
 
11
14
  /**
12
- * Creates the single docs-config state shared by a Vite build and application
13
- * modules. The virtual module keeps Node-only file loading out of the browser
14
- * bundle and gives the later `$ref` resolver one integration point.
15
+ * Creates the single config state shared by a Vite build and application
16
+ * modules. Two virtual modules keep Node-only file loading out of the browser
17
+ * bundle: `virtual:shiso-docs-config` carries docs.json (with `$ref`s
18
+ * resolved) and `virtual:shiso-config` carries the resolved shiso.config.*
19
+ * options with defaults already applied.
15
20
  */
16
21
  export async function createDocsConfigModule({
17
22
  root = process.cwd(),
@@ -19,35 +24,69 @@ export async function createDocsConfigModule({
19
24
  } = {}) {
20
25
  const options = { root, configFile };
21
26
  let loaded = await loadDocsConfig(options);
27
+ let loadedShiso = await loadShisoConfig({ root });
28
+ // Candidate names are matched in handleHotUpdate (the dev watcher already
29
+ // covers the project root), so creating shiso.config.ts is picked up live.
30
+ // Only existing files may go through addWatchFile: Vite's dev import
31
+ // analysis re-resolves watched files from a load hook and errors on paths
32
+ // that do not exist.
33
+ const shisoCandidatePaths = SHISO_CONFIG_FILES.map(name => path.resolve(root, name));
22
34
 
23
35
  return {
24
36
  getConfig: () => loaded.config,
25
- getSourcePaths: () => loaded.sourcePaths,
37
+ getShisoConfig: () => loadedShiso.config,
38
+ getSourcePaths: () => [...loaded.sourcePaths, ...loadedShiso.sourcePaths],
26
39
  sourcePath: loaded.sourcePath,
40
+ shisoSourcePath: loadedShiso.sourcePath,
27
41
  plugin: {
28
42
  name: 'shiso-docs-config',
29
43
  enforce: 'pre',
30
44
  resolveId(id) {
31
- return id === VIRTUAL_DOCS_CONFIG_ID ? RESOLVED_DOCS_CONFIG_ID : undefined;
45
+ if (id === VIRTUAL_DOCS_CONFIG_ID) {
46
+ return RESOLVED_DOCS_CONFIG_ID;
47
+ }
48
+
49
+ if (id === VIRTUAL_SHISO_CONFIG_ID) {
50
+ return RESOLVED_SHISO_CONFIG_ID;
51
+ }
52
+
53
+ return undefined;
32
54
  },
33
55
  load(id) {
34
- if (id !== RESOLVED_DOCS_CONFIG_ID) {
35
- return undefined;
56
+ if (id === RESOLVED_DOCS_CONFIG_ID) {
57
+ for (const sourcePath of loaded.sourcePaths) {
58
+ this.addWatchFile(sourcePath);
59
+ }
60
+ return renderConfigModule(loaded.config);
36
61
  }
37
62
 
38
- for (const sourcePath of loaded.sourcePaths) {
39
- this.addWatchFile(sourcePath);
63
+ if (id === RESOLVED_SHISO_CONFIG_ID) {
64
+ for (const sourcePath of loadedShiso.sourcePaths) {
65
+ this.addWatchFile(sourcePath);
66
+ }
67
+ return renderConfigModule(loadedShiso.config);
40
68
  }
41
- return renderConfigModule(loaded.config);
69
+
70
+ return undefined;
42
71
  },
43
72
  async handleHotUpdate(context) {
44
- if (!loaded.sourcePaths.includes(path.resolve(context.file))) {
73
+ const changedPath = path.resolve(context.file);
74
+ const isDocsSource = loaded.sourcePaths.includes(changedPath);
75
+ const isShisoSource = shisoCandidatePaths.includes(changedPath);
76
+
77
+ if (!isDocsSource && !isShisoSource) {
45
78
  return;
46
79
  }
47
80
 
48
- loaded = await loadDocsConfig(options);
81
+ const resolvedId = isDocsSource ? RESOLVED_DOCS_CONFIG_ID : RESOLVED_SHISO_CONFIG_ID;
82
+
83
+ if (isDocsSource) {
84
+ loaded = await loadDocsConfig(options);
85
+ } else {
86
+ loadedShiso = await loadShisoConfig({ root });
87
+ }
49
88
 
50
- const configModule = context.server.moduleGraph.getModuleById(RESOLVED_DOCS_CONFIG_ID);
89
+ const configModule = context.server.moduleGraph.getModuleById(resolvedId);
51
90
 
52
91
  if (configModule) {
53
92
  context.server.moduleGraph.invalidateModule(configModule);
package/src/App.tsx CHANGED
@@ -11,8 +11,9 @@ import { CodeBlock } from '@/components/CodeBlock';
11
11
  import * as docsComponents from '@/components/docs/index';
12
12
  import { Layout } from '@/components/Layout';
13
13
  import { TooltipProvider } from '@/components/ui/tooltip';
14
- import { docsHomeUrl, siteModel } from '@/lib/site-config';
14
+ import { docsHomeUrl, hasRootStandalonePage, siteModel, standalonePages } from '@/lib/site-config';
15
15
  import { DocPage } from '@/pages/DocPage';
16
+ import { StandalonePageView } from '@/pages/StandalonePage';
16
17
 
17
18
  const mdxComponents = {
18
19
  ...docsComponents,
@@ -26,8 +27,16 @@ export function App() {
26
27
  <MDXProvider components={mdxComponents}>
27
28
  <Layout site={siteModel}>
28
29
  <Routes>
29
- {/* When the default scope's landing page is the root there is nothing to redirect. */}
30
- {docsHomeUrl !== '/' ? (
30
+ {standalonePages.map(page => (
31
+ <Route
32
+ key={page.path}
33
+ path={page.path}
34
+ element={<StandalonePageView page={page} site={siteModel} />}
35
+ />
36
+ ))}
37
+ {/* When the default scope's landing page is the root, or a
38
+ standalone page owns "/", there is nothing to redirect. */}
39
+ {docsHomeUrl !== '/' && !hasRootStandalonePage ? (
31
40
  <Route path="/" element={<Navigate to={docsHomeUrl} replace />} />
32
41
  ) : null}
33
42
  <Route path="*" element={<DocPage site={siteModel} />} />
@@ -59,8 +59,9 @@ export interface DocContentProps {
59
59
  }
60
60
 
61
61
  export function DocContent({ page, doc, site }: DocContentProps) {
62
+ const scope = getScopeForPage(docsSite, page);
62
63
  // Prev/next paging never crosses a version or language boundary.
63
- const pagerPages = getScopeForPage(docsSite, page).docs.pages.filter(item => !item.hidden);
64
+ const pagerPages = scope.docs.pages.filter(item => !item.hidden);
64
65
  const pageIndex = pagerPages.findIndex(item => item.slug === page.slug);
65
66
  const prev = pageIndex > 0 ? pagerPages[pageIndex - 1] : undefined;
66
67
  const next = pageIndex >= 0 ? pagerPages[pageIndex + 1] : undefined;
@@ -85,8 +86,21 @@ export function DocContent({ page, doc, site }: DocContentProps) {
85
86
  timeZone: 'UTC',
86
87
  });
87
88
 
89
+ // Pagefind indexing markers on the prerendered HTML. Inert unless the site
90
+ // uses the pagefind provider. Hidden pages/scopes mirror the local index's
91
+ // search visibility rules; the scope filter matches the id Search.tsx sends.
92
+ const pagefindAttrs =
93
+ page.hidden || scope.hidden
94
+ ? {}
95
+ : {
96
+ 'data-pagefind-body': '',
97
+ ...(page.scopeId !== 'default'
98
+ ? { 'data-pagefind-filter': 'scope[data-scope]', 'data-scope': page.scopeId }
99
+ : {}),
100
+ };
101
+
88
102
  return (
89
- <article className="min-w-0 grow">
103
+ <article className="min-w-0 grow" {...pagefindAttrs}>
90
104
  {eyebrow && <div className="text-sm font-bold text-primary">{eyebrow}</div>}
91
105
  <div className="flex items-start justify-between gap-4">
92
106
  {title && (
@@ -107,7 +121,7 @@ export function DocContent({ page, doc, site }: DocContentProps) {
107
121
  </div>
108
122
  )}
109
123
  {related.length > 0 && (
110
- <nav className="mt-8" aria-label={site.labels.relatedTopics}>
124
+ <nav className="mt-8" aria-label={site.labels.relatedTopics} data-pagefind-ignore>
111
125
  <div className="text-sm text-muted-foreground">{site.labels.relatedTopics}</div>
112
126
  <ul className="mt-3 flex flex-col gap-2 text-sm">
113
127
  {related.map(({ href, title, external }) => (
@@ -132,7 +146,7 @@ export function DocContent({ page, doc, site }: DocContentProps) {
132
146
  </ul>
133
147
  </nav>
134
148
  )}
135
- <div className="mt-8 flex items-center justify-between">
149
+ <div className="mt-8 flex items-center justify-between" data-pagefind-ignore>
136
150
  <NavigationButton {...prev} isPrev />
137
151
  <NavigationButton {...next} />
138
152
  </div>
@@ -1,31 +1,58 @@
1
- import { useLocation } from 'react-router';
1
+ import { Link, useLocation } from 'react-router';
2
2
  import { ConfiguredIcon } from '@/components/ConfiguredIcon';
3
3
  import { LanguageSwitcher } from '@/components/LanguageSwitcher';
4
4
  import { Search } from '@/components/Search';
5
5
  import { ThemeToggle } from '@/components/ThemeToggle';
6
6
  import { TopNav } from '@/components/TopNav';
7
7
  import { VersionSwitcher } from '@/components/VersionSwitcher';
8
- import { docsHomeUrl, getScopeByPathname } from '@/lib/site-config';
8
+ import { isExternalHref } from '@/lib/paths';
9
+ import { docsHomeUrl, getScopeByPathname, hasRootStandalonePage } from '@/lib/site-config';
9
10
  import type { NormalizedLink, SiteModel } from '@/lib/types';
10
11
 
12
+ /**
13
+ * Header hrefs come from config, so they may point inside the site or off it.
14
+ * In-app routes go through react-router; anything external (or explicitly
15
+ * opened in a new tab) stays a plain anchor and triggers a document load.
16
+ */
17
+ function isRoutedHref(href: string, target?: string): boolean {
18
+ return href.startsWith('/') && !isExternalHref(href) && target !== '_blank';
19
+ }
20
+
11
21
  function NavbarLinkItem({ link, primary = false }: { link: NormalizedLink; primary?: boolean }) {
12
22
  const iconOnly = !link.label;
13
23
  const accessibleLabel = link.ariaLabel || link.icon || link.href;
14
24
 
25
+ const className = primary
26
+ ? `ml-1 inline-flex items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground hover:opacity-90 ${iconOnly ? 'size-8' : 'gap-1.5 px-3.5 py-1.5'}`
27
+ : `inline-flex items-center rounded-md text-sm font-medium text-foreground hover:bg-accent hover:text-foreground ${iconOnly ? 'size-8 justify-center' : 'gap-1.5 px-2.5 py-1.5'}`;
28
+ const content = (
29
+ <>
30
+ <ConfiguredIcon icon={link.icon} />
31
+ {link.label}
32
+ </>
33
+ );
34
+
35
+ if (isRoutedHref(link.href, link.target)) {
36
+ return (
37
+ <Link
38
+ to={link.href}
39
+ className={className}
40
+ aria-label={iconOnly ? accessibleLabel : undefined}
41
+ >
42
+ {content}
43
+ </Link>
44
+ );
45
+ }
46
+
15
47
  return (
16
48
  <a
17
49
  href={link.href}
18
- className={
19
- primary
20
- ? `ml-1 inline-flex items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground hover:opacity-90 ${iconOnly ? 'size-8' : 'gap-1.5 px-3.5 py-1.5'}`
21
- : `inline-flex items-center rounded-md text-sm font-medium text-foreground hover:bg-accent hover:text-foreground ${iconOnly ? 'size-8 justify-center' : 'gap-1.5 px-2.5 py-1.5'}`
22
- }
50
+ className={className}
23
51
  target={link.target}
24
52
  rel={link.target === '_blank' ? 'noreferrer' : undefined}
25
53
  aria-label={iconOnly ? accessibleLabel : undefined}
26
54
  >
27
- <ConfiguredIcon icon={link.icon} />
28
- {link.label}
55
+ {content}
29
56
  </a>
30
57
  );
31
58
  }
@@ -35,28 +62,38 @@ export function Header({ site }: { site: SiteModel }) {
35
62
  const { pathname } = useLocation();
36
63
  // The header renders the navigation of whichever scope owns the current page.
37
64
  const docs = getScopeByPathname(pathname).docs;
38
- const brandHref = logo?.href || docsHomeUrl;
65
+ // The brand links to the standalone home page when one owns "/".
66
+ const brandHref = logo?.href || (hasRootStandalonePage ? '/' : docsHomeUrl);
39
67
  const hasBrand = !!name || !!logo?.light || !!logo?.dark;
68
+ const brandClassName =
69
+ 'inline-flex items-center gap-2 text-xl font-bold text-foreground tracking-[-0.03em]';
70
+ const brandContent = (
71
+ <>
72
+ {logo?.light ? <img src={logo.light} alt="" className="h-6 w-auto dark:hidden" /> : null}
73
+ {logo?.dark ? <img src={logo.dark} alt="" className="hidden h-6 w-auto dark:block" /> : null}
74
+ {name ? <span>{name}</span> : null}
75
+ </>
76
+ );
40
77
 
41
78
  return (
42
79
  <header className="sticky top-0 z-50 h-[var(--header-height)] shrink-0 border-border border-b bg-[color-mix(in_srgb,var(--background)_92%,transparent)] backdrop-blur-md">
43
80
  <div className="mx-auto grid h-full max-w-[1600px] grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-5">
44
81
  <div className="flex min-w-0 items-center gap-5 justify-self-start">
45
82
  {hasBrand ? (
46
- <a
47
- href={brandHref}
48
- target={logo?.target}
49
- rel={logo?.target === '_blank' ? 'noreferrer' : undefined}
50
- className="inline-flex items-center gap-2 text-xl font-bold text-foreground tracking-[-0.03em]"
51
- >
52
- {logo?.light ? (
53
- <img src={logo.light} alt="" className="h-6 w-auto dark:hidden" />
54
- ) : null}
55
- {logo?.dark ? (
56
- <img src={logo.dark} alt="" className="hidden h-6 w-auto dark:block" />
57
- ) : null}
58
- {name ? <span>{name}</span> : null}
59
- </a>
83
+ isRoutedHref(brandHref, logo?.target) ? (
84
+ <Link to={brandHref} className={brandClassName}>
85
+ {brandContent}
86
+ </Link>
87
+ ) : (
88
+ <a
89
+ href={brandHref}
90
+ target={logo?.target}
91
+ rel={logo?.target === '_blank' ? 'noreferrer' : undefined}
92
+ className={brandClassName}
93
+ >
94
+ {brandContent}
95
+ </a>
96
+ )
60
97
  ) : null}
61
98
  <div className="hidden items-center gap-2 lg:flex">
62
99
  <VersionSwitcher />
@@ -17,6 +17,33 @@ import { resolveSearchProvider, type SearchProvider } from '@/lib/search/provide
17
17
  import { getScopeByPathname } from '@/lib/site-config';
18
18
  import type { ThemeLabels } from '@/lib/types';
19
19
 
20
+ /** Enough results to make the list scroll; the dialog caps its own height. */
21
+ const RESULT_LIMIT = 30;
22
+
23
+ /**
24
+ * Renders a snippet, turning provider-supplied `<mark>` tags into highlight
25
+ * elements. Snippets are parsed — never injected as HTML — so any other
26
+ * markup in the text renders literally.
27
+ */
28
+ function renderSnippet(snippet: string) {
29
+ const parts = snippet.split(/<mark>(.*?)<\/mark>/g);
30
+
31
+ if (parts.length === 1) {
32
+ return snippet;
33
+ }
34
+
35
+ return parts.map((part, index) =>
36
+ index % 2 ? (
37
+ // biome-ignore lint/suspicious/noArrayIndexKey: parts are positional and never reorder.
38
+ <mark key={index} className="rounded-xs bg-primary/15 px-px text-primary">
39
+ {part}
40
+ </mark>
41
+ ) : (
42
+ part
43
+ ),
44
+ );
45
+ }
46
+
20
47
  /**
21
48
  * Provider-neutral search dialog. The selected provider and its index or
22
49
  * client are loaded on demand, so search stays out of the initial bundle.
@@ -71,7 +98,7 @@ export function Search({ config, labels }: { config: ResolvedSearchConfig; label
71
98
  // Search stays inside the version/language scope being browsed.
72
99
  // Providers that predate the context argument simply ignore it.
73
100
  const scope = getScopeByPathname(pathname);
74
- const nextResults = await activeProvider.search(value, undefined, {
101
+ const nextResults = await activeProvider.search(value, RESULT_LIMIT, {
75
102
  scopeId: scope.id,
76
103
  language: scope.language,
77
104
  version: scope.version,
@@ -202,7 +229,7 @@ export function Search({ config, labels }: { config: ResolvedSearchConfig; label
202
229
  </div>
203
230
  {result.snippet && (
204
231
  <div className="mt-[0.15rem] line-clamp-2 text-[0.8rem] text-muted-foreground">
205
- {result.snippet}
232
+ {renderSnippet(result.snippet)}
206
233
  </div>
207
234
  )}
208
235
  </CommandItem>
@@ -1,4 +1,3 @@
1
- import { cn } from '@/lib/utils';
2
1
  import { Link, useLocation, useNavigate } from 'react-router';
3
2
  import { ConfiguredIcon } from '@/components/ConfiguredIcon';
4
3
  import { ChevronRight } from '@/components/icons';
@@ -8,7 +7,9 @@ import {
8
7
  DropdownMenuItem,
9
8
  DropdownMenuTrigger,
10
9
  } from '@/components/ui/dropdown-menu';
10
+ import { getStandalonePage } from '@/lib/site-config';
11
11
  import type { DocsTab, LinkTarget, NavNode, NormalizedDocsConfig } from '@/lib/types';
12
+ import { cn } from '@/lib/utils';
12
13
 
13
14
  interface MenuLink {
14
15
  label: string;
@@ -55,12 +56,13 @@ export function TopNav({ docs, label }: { docs: NormalizedDocsConfig; label: str
55
56
  }
56
57
 
57
58
  const page = docs.pages.find(item => item.url === pathname);
58
- const selected =
59
- page?.tabId ||
60
- [...tabs]
61
- .sort((a, b) => b.url.length - a.url.length)
62
- .find(tab => pathname === tab.url || pathname.startsWith(`${tab.url}/`))?.id ||
63
- tabs[0]?.id;
59
+ const matchedTabId = [...tabs]
60
+ .sort((a, b) => b.url.length - a.url.length)
61
+ .find(tab => pathname === tab.url || pathname.startsWith(`${tab.url}/`))?.id;
62
+ // Standalone pages live outside the docs tree, so no tab owns them. Only
63
+ // unmatched *docs* routes fall back to highlighting the first tab.
64
+ const fallbackTabId = getStandalonePage(pathname) ? undefined : tabs[0]?.id;
65
+ const selected = page?.tabId || matchedTabId || fallbackTabId;
64
66
  const tabClass = (tab: DocsTab) =>
65
67
  cn(
66
68
  'flex h-full items-center gap-1 whitespace-nowrap border-transparent border-b-2 font-medium',
@@ -0,0 +1,67 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Link } from 'react-router';
3
+ import { Button as ButtonPrimitive, buttonVariants } from '@/components/ui/button';
4
+ import { cn } from '@/lib/utils';
5
+ import { resolveIcon } from './utils';
6
+
7
+ export interface ButtonProps {
8
+ href?: string;
9
+ variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link';
10
+ size?: 'default' | 'xs' | 'sm' | 'lg';
11
+ icon?: ReactNode | string;
12
+ className?: string;
13
+ children?: ReactNode;
14
+ }
15
+
16
+ /**
17
+ * MDX-facing button. Renders a link when `href` is set (internal routes go
18
+ * through react-router), otherwise a plain button element.
19
+ */
20
+ export function Button({
21
+ href,
22
+ variant = 'default',
23
+ size = 'default',
24
+ icon,
25
+ className,
26
+ children,
27
+ }: ButtonProps) {
28
+ const resolvedIcon = resolveIcon(icon, 16);
29
+ const content = (
30
+ <>
31
+ {resolvedIcon}
32
+ {children}
33
+ </>
34
+ );
35
+ // MDX wraps block-level children in <p>; strip its margins so the label
36
+ // stays centered against the icon.
37
+ const baseClassName = cn(resolvedIcon ? 'gap-2' : '', '[&_p]:m-0', className);
38
+
39
+ if (!href) {
40
+ return (
41
+ <ButtonPrimitive variant={variant} size={size} className={baseClassName}>
42
+ {content}
43
+ </ButtonPrimitive>
44
+ );
45
+ }
46
+
47
+ const linkClassName = cn(
48
+ buttonVariants({ variant, size }),
49
+ 'no-underline hover:no-underline active:no-underline',
50
+ baseClassName,
51
+ );
52
+ const external = /^https?:\/\//i.test(href);
53
+
54
+ if (external) {
55
+ return (
56
+ <a href={href} className={linkClassName} target="_blank" rel="noreferrer">
57
+ {content}
58
+ </a>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <Link to={href} className={linkClassName}>
64
+ {content}
65
+ </Link>
66
+ );
67
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './Accordion';
2
2
  export * from './Badge';
3
+ export * from './Button';
3
4
  export * from './Callout';
4
5
  export * from './Card';
5
6
  export * from './CodeGroup';