@umami/shiso 1.4.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.
@@ -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 />
@@ -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';
@@ -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 {
@@ -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
  };
@@ -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
+ }