@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.
- package/CHANGELOG.md +17 -0
- package/bin/shiso.mjs +9 -1
- package/config.js +8 -0
- package/dist/chunks/App.js +240 -62
- package/dist/entry-client.js +1 -1
- package/dist/entry-server.js +16 -5
- package/docs.schema.json +29 -26
- package/package.json +7 -1
- package/scripts/build-runtime.mjs +1 -0
- package/scripts/generate-search-index.mjs +4 -3
- package/scripts/load-shiso-config.mjs +167 -0
- package/scripts/prerender.mjs +4 -2
- package/scripts/validate-config.mjs +23 -10
- package/scripts/vite-docs-config.mjs +52 -13
- package/src/App.tsx +12 -3
- package/src/components/Header.tsx +61 -24
- package/src/components/TopNav.tsx +9 -7
- package/src/components/docs/Button.tsx +67 -0
- package/src/components/docs/index.ts +1 -0
- package/src/declarations.d.ts +7 -0
- package/src/entry-server.tsx +20 -3
- package/src/lib/content.ts +10 -2
- package/src/lib/head.ts +15 -7
- package/src/lib/locale.ts +1 -1
- package/src/lib/paths.ts +11 -7
- package/src/lib/site-config.ts +21 -3
- package/src/lib/site-model.ts +7 -2
- package/src/lib/standalone-pages.ts +129 -0
- package/src/lib/types.ts +33 -3
- package/src/pages/StandalonePage.tsx +42 -0
- package/types/config.d.ts +19 -0
- package/vite.config.ts +58 -18
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|
package/src/declarations.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/entry-server.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/lib/content.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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 {
|
package/src/lib/site-config.ts
CHANGED
|
@@ -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 {
|
package/src/lib/site-model.ts
CHANGED
|
@@ -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(
|
|
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:
|
|
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
|
+
}
|