@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,129 @@
1
+ import type { DocsConfig, NormalizedDocsSite, StandalonePage } from '@/lib/types';
2
+
3
+ /**
4
+ * Standalone pages: routes outside the docs navigation, declared with the
5
+ * top-level `pages` key in docs.json. They render with the site chrome
6
+ * (banner, header, footer) but no sidebar or table of contents, and a
7
+ * `path: "/"` entry replaces the default root redirect to the docs home.
8
+ */
9
+
10
+ export interface NormalizeStandaloneOptions {
11
+ /** Docs prefix ("" or "/prefix"); standalone paths may not live under it. */
12
+ docsPrefix?: string;
13
+ }
14
+
15
+ function invalid(message: string): Error {
16
+ return new Error(`Invalid docs config: ${message}`);
17
+ }
18
+
19
+ /** Trims and canonicalizes a standalone route path; throws when malformed. */
20
+ function normalizePath(rawPath: unknown): string {
21
+ const value = typeof rawPath === 'string' ? rawPath.trim() : '';
22
+
23
+ if (!value.startsWith('/')) {
24
+ throw invalid(`standalone page path "${String(rawPath)}" must start with "/".`);
25
+ }
26
+
27
+ if (/[:*]/.test(value)) {
28
+ throw invalid(`standalone page path "${value}" must not use wildcard patterns.`);
29
+ }
30
+
31
+ if (/\.mdx?$/i.test(value)) {
32
+ throw invalid(
33
+ `standalone page path "${value}" must be a route, not a file — drop the extension.`,
34
+ );
35
+ }
36
+
37
+ const collapsed = value.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
38
+ return collapsed || '/';
39
+ }
40
+
41
+ /** Mirrors normalizePageReference in docs-config.ts for the `page` slug. */
42
+ function normalizePageSlug(rawSlug: unknown): string {
43
+ const value = typeof rawSlug === 'string' ? rawSlug : '';
44
+
45
+ return (
46
+ value
47
+ .trim()
48
+ .replace(/\\/g, '/')
49
+ .replace(/^\/+/, '')
50
+ .replace(/^pages\//, '')
51
+ .replace(/\.mdx?$/, '')
52
+ .replace(/\/+$/, '') || 'index'
53
+ );
54
+ }
55
+
56
+ export function normalizeStandalonePages(
57
+ config: DocsConfig,
58
+ resolvePageFile: (fileSlug: string) => string | undefined,
59
+ site: NormalizedDocsSite,
60
+ options: NormalizeStandaloneOptions = {},
61
+ ): StandalonePage[] {
62
+ const items = config.pages || [];
63
+
64
+ if (!items.length) {
65
+ return [];
66
+ }
67
+
68
+ const docsPrefix = options.docsPrefix || '';
69
+ const pages: StandalonePage[] = [];
70
+ const seen = new Set<string>();
71
+
72
+ for (const item of items) {
73
+ const path = normalizePath(item?.path);
74
+
75
+ if (seen.has(path)) {
76
+ throw invalid(`duplicate standalone page path "${path}".`);
77
+ }
78
+
79
+ seen.add(path);
80
+
81
+ if (path === '/404') {
82
+ throw invalid('standalone page path "/404" is reserved for the error page.');
83
+ }
84
+
85
+ const docsPage = site.pageByUrl[path];
86
+
87
+ if (docsPage) {
88
+ throw invalid(
89
+ `standalone page path "${path}" collides with the docs page "${docsPage.fileSlug}". ` +
90
+ 'Standalone pages must live outside the docs navigation.',
91
+ );
92
+ }
93
+
94
+ if (docsPrefix && (path === docsPrefix || path.startsWith(`${docsPrefix}/`))) {
95
+ throw invalid(
96
+ `standalone page path "${path}" is inside the docs prefix "${docsPrefix}". ` +
97
+ 'Standalone pages must live outside the docs tree.',
98
+ );
99
+ }
100
+
101
+ const fileSlug = normalizePageSlug(item?.page);
102
+ const filePath = resolvePageFile(fileSlug);
103
+
104
+ if (!filePath) {
105
+ throw new Error(
106
+ `Missing standalone page file for "${fileSlug}": expected ` +
107
+ `"content/pages/${fileSlug}.mdx" or ".md".`,
108
+ );
109
+ }
110
+
111
+ pages.push({ path, filePath, title: item?.title?.trim() || undefined });
112
+ }
113
+
114
+ return pages;
115
+ }
116
+
117
+ /**
118
+ * Exact standalone page lookup by base-relative pathname. Tolerates trailing
119
+ * slashes and an explicit `/index` suffix, like getPageByPathname.
120
+ */
121
+ export function getStandalonePageByPathname(
122
+ pages: StandalonePage[],
123
+ pathname: string,
124
+ ): StandalonePage | null {
125
+ const trimmed = pathname.replace(/\/+$/, '') || '/';
126
+ const collapsed = trimmed === '/index' ? '/' : trimmed.replace(/\/index$/, '') || '/';
127
+
128
+ return pages.find(page => page.path === trimmed || page.path === collapsed) || null;
129
+ }
package/src/lib/types.ts CHANGED
@@ -101,8 +101,9 @@ export type LogoOption =
101
101
  | string
102
102
  | { light?: string; dark?: string; href?: string; target?: LinkTarget };
103
103
 
104
- /** Shiso-only configuration. Namespaced so docs.json stays portable. */
105
- export interface ShisoOptions {
104
+ /** Project-level settings supplied by shiso.config.ts. Mirrors the public
105
+ * shape exported from "@umami/shiso/config". */
106
+ export interface ShisoConfig {
106
107
  /** Where docs pages are mounted within the site. Default "/docs"; "" for root. */
107
108
  docsPrefix?: string;
108
109
  /** Content directory relative to the project root. Default "content/docs". */
@@ -113,6 +114,14 @@ export interface ShisoOptions {
113
114
  locale?: string;
114
115
  }
115
116
 
117
+ /** ShisoConfig after defaults and normalization, as served by `virtual:shiso-config`. */
118
+ export interface ResolvedShisoConfig {
119
+ docsPrefix: string;
120
+ contentDir: string;
121
+ siteUrl?: string;
122
+ locale: string;
123
+ }
124
+
116
125
  export type LinkTarget = '_self' | '_blank';
117
126
 
118
127
  /** A user-configured link. Presentation is determined entirely by its fields. */
@@ -166,6 +175,26 @@ export interface RedirectRule {
166
175
  destination: string;
167
176
  }
168
177
 
178
+ /** A standalone page outside the docs navigation, e.g. a landing page. */
179
+ export interface StandalonePageItem {
180
+ /** Route path, starting with "/". "/" replaces the root redirect to docs. */
181
+ path: string;
182
+ /** File slug under content/pages, e.g. "home" for content/pages/home.mdx. */
183
+ page: string;
184
+ /** Page title used in the document head. Frontmatter title wins. */
185
+ title?: string;
186
+ }
187
+
188
+ /** Normalized standalone page. */
189
+ export interface StandalonePage {
190
+ /** Base-relative route, e.g. "/" or "/about". */
191
+ path: string;
192
+ /** Module key of the MDX file, e.g. "/content/pages/home.mdx". */
193
+ filePath: string;
194
+ /** Config-level head-title override. */
195
+ title?: string;
196
+ }
197
+
169
198
  export interface SeoConfig {
170
199
  /** Extra meta tags added to every page, e.g. { "og:image": "/social.png" }. */
171
200
  metatags?: Record<string, string>;
@@ -219,7 +248,7 @@ export interface FontsConfig extends FontSpec {
219
248
  export interface SearchConfig {
220
249
  /** Placeholder text for the search input. */
221
250
  prompt?: string;
222
- /** Registered provider id. Defaults to the built-in local provider. */
251
+ /** Provider id: "local" (default), "pagefind", or a runtime-registered id. */
223
252
  provider?: string;
224
253
  /** Provider-specific configuration. */
225
254
  options?: Record<string, unknown>;
@@ -271,7 +300,6 @@ export interface BackgroundConfig {
271
300
 
272
301
  export interface DocsConfig {
273
302
  $schema?: string;
274
- $shiso?: ShisoOptions;
275
303
  theme?: string;
276
304
  name?: string;
277
305
  colors?: ThemeColors;
@@ -279,6 +307,8 @@ export interface DocsConfig {
279
307
  favicon?: string;
280
308
  description?: string;
281
309
  navigation: NavigationConfig;
310
+ /** Standalone pages outside the docs navigation, e.g. a landing page at "/". */
311
+ pages?: StandalonePageItem[];
282
312
  navbar?: NavbarConfig;
283
313
  footer?: FooterConfig;
284
314
  banner?: BannerConfig;
@@ -0,0 +1,42 @@
1
+ import { useEffect } from 'react';
2
+ import { useLocation } from 'react-router';
3
+ import { Docs } from '@/components/Docs';
4
+ import { Footer } from '@/components/Footer';
5
+ import { getDocModule } from '@/lib/content';
6
+ import type { SiteModel, StandalonePage } from '@/lib/types';
7
+
8
+ /**
9
+ * A standalone (non-docs) page: site chrome from Layout (banner, header),
10
+ * the MDX content at full container width — no sidebar, TOC, or pager — and
11
+ * the footer. MDX components come from the app-level MDXProvider.
12
+ */
13
+ export function StandalonePageView({ page, site }: { page: StandalonePage; site: SiteModel }) {
14
+ const { pathname } = useLocation();
15
+ const doc = getDocModule(page.filePath);
16
+
17
+ // Start each newly loaded page at the top, like Docs does for docs routes.
18
+ // biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger
19
+ useEffect(() => {
20
+ if (!window.location.hash) {
21
+ window.scrollTo({ top: 0, left: 0 });
22
+ }
23
+ }, [pathname]);
24
+
25
+ // Normalization guarantees the file exists; this is a build-drift safety net.
26
+ if (!doc) {
27
+ return <Docs page={null} doc={null} site={site} />;
28
+ }
29
+
30
+ const Content = doc.default;
31
+
32
+ return (
33
+ <div className="flex min-h-full flex-col">
34
+ <article className="grow py-8">
35
+ <div className="docs-markdown">
36
+ <Content />
37
+ </div>
38
+ </article>
39
+ <Footer footer={site.footer} />
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Public types for shiso.config.ts. Kept self-contained (no imports) so the
3
+ * config file typechecks in consuming projects without pulling in the runtime.
4
+ */
5
+
6
+ /** Project-level settings supplied by shiso.config.ts. All fields are optional. */
7
+ export interface ShisoConfig {
8
+ /** Route prefix for docs pages within the site. Default "/docs"; "" serves docs at the site root. */
9
+ docsPrefix?: string;
10
+ /** Content directory relative to the project root. Default "content/docs". */
11
+ contentDir?: string;
12
+ /** Absolute site origin (e.g. "https://docs.example.com") used for canonical URLs, og:url, and the sitemap. */
13
+ siteUrl?: string;
14
+ /** Locale used for deterministic date formatting. Default "en-US". */
15
+ locale?: string;
16
+ }
17
+
18
+ /** Identity helper that types a shiso.config.ts default export. */
19
+ export declare function defineConfig(config: ShisoConfig): ShisoConfig;
package/types/search.d.ts CHANGED
@@ -3,6 +3,10 @@ export interface SearchResult {
3
3
  page: string;
4
4
  score: number;
5
5
  heading?: string;
6
+ /**
7
+ * Matched terms may be wrapped in `<mark>` tags; the search dialog renders
8
+ * them as highlights (never as raw HTML).
9
+ */
6
10
  snippet?: string;
7
11
  }
8
12
 
@@ -24,4 +28,9 @@ export type SearchProviderFactory = (
24
28
  options: Record<string, unknown>,
25
29
  ) => SearchProvider | Promise<SearchProvider>;
26
30
 
31
+ /**
32
+ * Registers a runtime search provider. Call this before rendering the app.
33
+ * The ids "local" and "pagefind" are reserved for the built-in providers.
34
+ * The returned cleanup function only removes this exact registration.
35
+ */
27
36
  export function registerSearchProvider(id: string, factory: SearchProviderFactory): () => void;
package/vite.config.ts CHANGED
@@ -9,7 +9,7 @@ import { generateIconRegistry } from './scripts/generate-icon-registry.mjs';
9
9
  import { shisoLastModified } from './scripts/generate-last-modified.mjs';
10
10
  import { generateSearchIndex } from './scripts/generate-search-index.mjs';
11
11
  import { createDocsConfigModule } from './scripts/vite-docs-config.mjs';
12
- import type { DocsConfig } from './src/lib/types.ts';
12
+ import type { DocsConfig, ResolvedShisoConfig } from './src/lib/types.ts';
13
13
 
14
14
  /**
15
15
  * Keeps src/lib/icon-registry.generated.ts in sync with the `icon="name"` values
@@ -37,15 +37,25 @@ function shisoIconRegistry(getDocsConfig: () => DocsConfig, root: string, output
37
37
  * Keeps src/lib/search-index.generated.ts in sync with content, so the search
38
38
  * dialog can query page text without a server.
39
39
  */
40
- function shisoSearchIndex(getDocsConfig: () => DocsConfig, root: string, output: string): Plugin {
40
+ function shisoSearchIndex(
41
+ getDocsConfig: () => DocsConfig,
42
+ getShisoConfig: () => ResolvedShisoConfig,
43
+ root: string,
44
+ output: string,
45
+ ): Plugin {
41
46
  return {
42
47
  name: 'shiso-search-index',
43
48
  async buildStart() {
44
- await generateSearchIndex({ config: getDocsConfig(), root, output });
49
+ await generateSearchIndex({ config: getDocsConfig(), shiso: getShisoConfig(), root, output });
45
50
  },
46
51
  async handleHotUpdate({ file }) {
47
- if (/\.(md|mdx)$/.test(file) || file.endsWith('docs.json')) {
48
- await generateSearchIndex({ config: getDocsConfig(), root, output });
52
+ if (/\.(md|mdx)$/.test(file) || file.endsWith('docs.json') || /shiso\.config\.\w+$/.test(file)) {
53
+ await generateSearchIndex({
54
+ config: getDocsConfig(),
55
+ shiso: getShisoConfig(),
56
+ root,
57
+ output,
58
+ });
49
59
  }
50
60
  },
51
61
  };
@@ -288,7 +298,11 @@ function shisoHtml(getDocsConfig: () => DocsConfig): Plugin {
288
298
  * production builds. The contextual menu's copy/view options and AI links
289
299
  * depend on these URLs.
290
300
  */
291
- function shisoMarkdownDev(getDocsConfig: () => DocsConfig, root: string): Plugin {
301
+ function shisoMarkdownDev(
302
+ getDocsConfig: () => DocsConfig,
303
+ getShisoConfig: () => ResolvedShisoConfig,
304
+ root: string,
305
+ ): Plugin {
292
306
  return {
293
307
  name: 'shiso-markdown-dev',
294
308
  apply: 'serve',
@@ -300,17 +314,8 @@ function shisoMarkdownDev(getDocsConfig: () => DocsConfig, root: string): Plugin
300
314
  return next();
301
315
  }
302
316
 
303
- const shiso =
304
- (getDocsConfig() as { $shiso?: { docsPrefix?: string; contentDir?: string } }).$shiso ||
305
- {};
306
- const contentDir = (shiso.contentDir ?? 'content/docs').replace(/^\/+|\/+$/g, '');
307
- const prefixValue = (shiso.docsPrefix ?? '/docs').trim().replace(/\/+$/, '');
308
- const docsPrefix =
309
- !prefixValue || prefixValue === '/'
310
- ? ''
311
- : prefixValue.startsWith('/')
312
- ? prefixValue
313
- : `/${prefixValue}`;
317
+ // Values arrive with defaults applied and already normalized.
318
+ const { contentDir, docsPrefix } = getShisoConfig();
314
319
 
315
320
  let route = decodeURIComponent(url).slice(0, -'.md'.length);
316
321
  const base = server.config.base.replace(/\/+$/, '');
@@ -319,6 +324,39 @@ function shisoMarkdownDev(getDocsConfig: () => DocsConfig, root: string): Plugin
319
324
  route = route.slice(base.length);
320
325
  }
321
326
 
327
+ // Standalone pages (top-level `pages` key) live under content/pages,
328
+ // outside the docs prefix. "/index.md" maps to the "/" entry.
329
+ const routeKey = (route.replace(/\/+$/, '') || '/').replace(/^\/index$/, '/');
330
+ const standalone = (getDocsConfig().pages || []).find(
331
+ item => (item?.path?.trim().replace(/\/+$/, '') || '/') === routeKey,
332
+ );
333
+
334
+ if (standalone?.page) {
335
+ const pagesRoot = path.resolve(root, 'content/pages');
336
+ const pageSlug = standalone.page
337
+ .trim()
338
+ .replace(/^\/+/, '')
339
+ .replace(/\.mdx?$/, '');
340
+
341
+ for (const candidate of [`${pageSlug}.mdx`, `${pageSlug}.md`]) {
342
+ const filePath = path.resolve(pagesRoot, candidate);
343
+
344
+ // Never read outside the pages directory.
345
+ if (!filePath.startsWith(pagesRoot + path.sep)) {
346
+ break;
347
+ }
348
+
349
+ try {
350
+ const source = await readFile(filePath, 'utf8');
351
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
352
+ res.end(source);
353
+ return;
354
+ } catch {
355
+ // Try the next candidate.
356
+ }
357
+ }
358
+ }
359
+
322
360
  if (docsPrefix && route.startsWith(docsPrefix)) {
323
361
  route = route.slice(docsPrefix.length);
324
362
  }
@@ -364,6 +402,7 @@ export default defineConfig(async () => {
364
402
  root: projectRoot,
365
403
  });
366
404
  const getDocsConfig = configModule.getConfig as () => DocsConfig;
405
+ const getShisoConfig = configModule.getShisoConfig as () => ResolvedShisoConfig;
367
406
 
368
407
  return {
369
408
  optimizeDeps: {
@@ -385,11 +424,12 @@ export default defineConfig(async () => {
385
424
  }),
386
425
  shisoSearchIndex(
387
426
  getDocsConfig,
427
+ getShisoConfig,
388
428
  projectRoot,
389
429
  path.join(generatedRoot, 'search-index.generated.ts'),
390
430
  ),
391
431
  shisoHtml(getDocsConfig),
392
- shisoMarkdownDev(getDocsConfig, projectRoot),
432
+ shisoMarkdownDev(getDocsConfig, getShisoConfig, projectRoot),
393
433
  shisoMdx(),
394
434
  react({ include: /\.(mdx|md|tsx|ts|jsx|js)$/ }),
395
435
  ],