@umami/shiso 1.4.0 → 1.6.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,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
@@ -1,6 +1,4 @@
1
- import '@fontsource/inter/400.css';
2
- import '@fontsource/inter/700.css';
3
- import '@fontsource/inter/800.css';
1
+ import '@fontsource-variable/inter/index.css';
4
2
  import '@fontsource/jetbrains-mono/400.css';
5
3
  import 'highlight.js/styles/github.css';
6
4
  import '@umami/shiso/styles.css';
@@ -11,8 +9,9 @@ import { CodeBlock } from '@/components/CodeBlock';
11
9
  import * as docsComponents from '@/components/docs/index';
12
10
  import { Layout } from '@/components/Layout';
13
11
  import { TooltipProvider } from '@/components/ui/tooltip';
14
- import { docsHomeUrl, siteModel } from '@/lib/site-config';
12
+ import { docsHomeUrl, hasRootStandalonePage, siteModel, standalonePages } from '@/lib/site-config';
15
13
  import { DocPage } from '@/pages/DocPage';
14
+ import { StandalonePageView } from '@/pages/StandalonePage';
16
15
 
17
16
  const mdxComponents = {
18
17
  ...docsComponents,
@@ -26,8 +25,16 @@ export function App() {
26
25
  <MDXProvider components={mdxComponents}>
27
26
  <Layout site={siteModel}>
28
27
  <Routes>
29
- {/* When the default scope's landing page is the root there is nothing to redirect. */}
30
- {docsHomeUrl !== '/' ? (
28
+ {standalonePages.map(page => (
29
+ <Route
30
+ key={page.path}
31
+ path={page.path}
32
+ element={<StandalonePageView page={page} site={siteModel} />}
33
+ />
34
+ ))}
35
+ {/* When the default scope's landing page is the root, or a
36
+ standalone page owns "/", there is nothing to redirect. */}
37
+ {docsHomeUrl !== '/' && !hasRootStandalonePage ? (
31
38
  <Route path="/" element={<Navigate to={docsHomeUrl} replace />} />
32
39
  ) : null}
33
40
  <Route path="*" element={<DocPage site={siteModel} />} />
@@ -101,16 +101,18 @@ export function DocContent({ page, doc, site }: DocContentProps) {
101
101
 
102
102
  return (
103
103
  <article className="min-w-0 grow" {...pagefindAttrs}>
104
- {eyebrow && <div className="text-sm font-bold text-primary">{eyebrow}</div>}
104
+ {eyebrow && <div className="text-sm font-medium text-primary">{eyebrow}</div>}
105
105
  <div className="flex items-start justify-between gap-4">
106
106
  {title && (
107
- <h1 className="mt-2 text-4xl text-foreground leading-[1.2] tracking-[-0.03em] [font-family:var(--font-heading)] [font-weight:var(--font-heading-weight,800)]">
107
+ <h1 className="mt-2 text-4xl text-foreground leading-[1.2] tracking-[-0.03em] [font-family:var(--font-heading)] [font-weight:var(--font-heading-weight,700)]">
108
108
  {title}
109
109
  </h1>
110
110
  )}
111
111
  <ContextualMenu options={contextualOptions} labels={site.labels} />
112
112
  </div>
113
- {description && <p className="mt-1 mb-6 text-base text-muted-foreground">{description}</p>}
113
+ {description && (
114
+ <p className="mt-3 mb-8 text-lg text-muted-foreground leading-relaxed">{description}</p>
115
+ )}
114
116
  <div className="docs-markdown">
115
117
  <Content />
116
118
  </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 />
@@ -11,7 +11,7 @@ import {
11
11
  CommandList,
12
12
  } from '@/components/ui/command';
13
13
  import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
14
- import type { SearchResult } from '@/lib/search';
14
+ import { highlightTerms, type SearchResult } from '@/lib/search';
15
15
  import type { ResolvedSearchConfig } from '@/lib/search/config';
16
16
  import { resolveSearchProvider, type SearchProvider } from '@/lib/search/provider';
17
17
  import { getScopeByPathname } from '@/lib/site-config';
@@ -44,6 +44,12 @@ function renderSnippet(snippet: string) {
44
44
  );
45
45
  }
46
46
 
47
+ function renderWithQueryHighlight(text: string, query: string) {
48
+ const terms = query.trim().split(/\s+/).filter(Boolean);
49
+ if (!terms.length) return text;
50
+ return renderSnippet(highlightTerms(text, terms));
51
+ }
52
+
47
53
  /**
48
54
  * Provider-neutral search dialog. The selected provider and its index or
49
55
  * client are loaded on demand, so search stays out of the initial bundle.
@@ -224,8 +230,13 @@ export function Search({ config, labels }: { config: ResolvedSearchConfig; label
224
230
  onSelect={() => select(result)}
225
231
  >
226
232
  <div className="text-[0.9rem] font-semibold text-foreground">
227
- {result.page}
228
- {result.heading ? ` › ${result.heading}` : ''}
233
+ {renderWithQueryHighlight(result.page, query)}
234
+ {result.heading ? (
235
+ <>
236
+ {' › '}
237
+ {renderWithQueryHighlight(result.heading, query)}
238
+ </>
239
+ ) : null}
229
240
  </div>
230
241
  {result.snippet && (
231
242
  <div className="mt-[0.15rem] line-clamp-2 text-[0.8rem] text-muted-foreground">
@@ -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
+ }
@@ -24,16 +24,16 @@ export function PropertiesTable({ children }: PropertiesTableProps) {
24
24
 
25
25
  return (
26
26
  <div className="my-4 overflow-x-auto rounded-lg border border-border px-6 py-2">
27
- <table className="m-0 min-w-[40rem] table-fixed border-collapse">
27
+ <table className="m-0 min-w-[40rem] table-fixed border-collapse border-0">
28
28
  <thead>
29
29
  <tr>
30
- <th className="w-1/3 border-x-0 border-t-0 border-b border-border bg-transparent px-0 py-2 pr-6 text-left text-sm font-semibold text-foreground">
30
+ <th className="w-1/5 border-x-0 border-t-0 border-b border-border bg-transparent px-0 py-2 pr-6 text-left text-sm font-semibold text-foreground">
31
31
  Name
32
32
  </th>
33
- <th className="w-1/3 border-x-0 border-t-0 border-b border-border bg-transparent px-0 py-2 pr-6 text-left text-sm font-semibold text-foreground">
33
+ <th className="w-1/4 border-x-0 border-t-0 border-b border-border bg-transparent px-0 py-2 pr-6 text-left text-sm font-semibold text-foreground">
34
34
  Type
35
35
  </th>
36
- <th className="w-1/3 border-x-0 border-t-0 border-b border-border bg-transparent px-0 py-2 text-left text-sm font-semibold text-foreground">
36
+ <th className="border-x-0 border-t-0 border-b border-border bg-transparent px-0 py-2 text-left text-sm font-semibold text-foreground">
37
37
  Description
38
38
  </th>
39
39
  </tr>
@@ -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)) {