@umami/shiso 1.6.0 → 1.8.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.
@@ -0,0 +1,3 @@
1
+ import { At as Info, Dt as Callout, Et as CardGroup, Ft as Button, It as Badge, Mt as Tip, Nt as Warning, Ot as Check, Pt as WarningBanner, Rt as Accordion, Tt as Card, _ as Tooltip, at as Tabs, bt as Columns, ct as ResponseField, dt as ParamField, ft as Icon, ht as Expandable, it as Tab, jt as Note, kt as Danger, lt as PropertiesTable, n as ZoomableImage, ot as Step, pt as Frame, st as Steps, ut as Param, xt as CodeGroup, yt as Column, zt as AccordionGroup } from "./chunks/docs.js";
2
+
3
+ export { Accordion, AccordionGroup, Badge, Button, Callout, Card, CardGroup, Check, CodeGroup, Column, Columns, Danger, Expandable, Frame, Icon, Info, Note, Param, ParamField, PropertiesTable, ResponseField, Step, Steps, Tab, Tabs, Tip, Tooltip, Warning, WarningBanner, ZoomableImage };
@@ -1,4 +1,5 @@
1
- import { g as BrowserRouter, m as BASE_URL, t as App } from "./chunks/App.js";
1
+ import { ir as BrowserRouter } from "./chunks/docs.js";
2
+ import { m as BASE_URL, t as App } from "./chunks/App.js";
2
3
  import { jsx } from "react/jsx-runtime";
3
4
 
4
5
  //#region src/entry-client.tsx
@@ -1,4 +1,5 @@
1
- import { _ as Router, a as docsSite, b as ABSOLUTE_URL_REGEX, c as getSeo, d as getDocModule, f as getLastModified, h as toAbsoluteUrl, i as docsHomeUrl, l as siteName, m as BASE_URL, n as buildHead, o as getLocaleByPathname, p as getScopeForPage, r as renderHeadToString, s as getRedirects, t as App, u as standalonePages, v as createPath, y as parsePath } from "./chunks/App.js";
1
+ import { cr as Router, fr as createPath, mr as ABSOLUTE_URL_REGEX, pr as parsePath } from "./chunks/docs.js";
2
+ import { a as docsSite, c as getSeo, d as getDocModule, f as getLastModified, h as toAbsoluteUrl, i as docsHomeUrl, l as siteName, m as BASE_URL, n as buildHead, o as getLocaleByPathname, p as getScopeForPage, r as renderHeadToString, s as getRedirects, t as App, u as standalonePages } from "./chunks/App.js";
2
3
  import * as React$1 from "react";
3
4
  import { jsx } from "react/jsx-runtime";
4
5
  import { renderToString } from "react-dom/server";
@@ -91,16 +92,17 @@ function getRoutes() {
91
92
  return [...docsSite.pages.map((page) => page.url), ...standalonePages.map((page) => page.path)];
92
93
  }
93
94
  /**
94
- * Source file for every routed page, so the prerenderer can publish raw
95
+ * Source file for every Markdown/MDX page, so the prerenderer can publish raw
95
96
  * markdown next to each HTML page (used by the contextual menu's copy/view
96
- * options and by AI tools). `filePath` is a module key like
97
- * "/content/docs/index.mdx", resolved against the project root.
97
+ * options and by AI tools). TSX standalone pages have no raw Markdown copy.
98
+ * `filePath` is a module key like "/content/docs/index.mdx", resolved against
99
+ * the project root.
98
100
  */
99
101
  function getMarkdownPages() {
100
102
  return [...docsSite.pages.map((page) => ({
101
103
  route: page.url,
102
104
  filePath: page.filePath
103
- })), ...standalonePages.map((page) => ({
105
+ })), ...standalonePages.filter((page) => !page.filePath.endsWith(".tsx")).map((page) => ({
104
106
  route: page.path,
105
107
  filePath: page.filePath
106
108
  }))];
package/docs.schema.json CHANGED
@@ -66,6 +66,10 @@
66
66
  "target": {
67
67
  "allOf": [{ "$ref": "#/definitions/targetValue" }],
68
68
  "description": "Browsing context for the logo link. Defaults from href."
69
+ },
70
+ "invert": {
71
+ "allOf": [{ "$ref": "#/definitions/booleanValue" }],
72
+ "description": "Render one logo and force it to white in dark mode with a CSS filter, instead of swapping to a dark variant. Best for monochrome logos."
69
73
  }
70
74
  }
71
75
  }
@@ -95,7 +99,7 @@
95
99
  },
96
100
  "page": {
97
101
  "allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
98
- "description": "File slug under content/pages, e.g. \"home\" for content/pages/home.mdx."
102
+ "description": "File slug under content/pages, e.g. \"home\" for content/pages/home.tsx, home.mdx, or home.md."
99
103
  },
100
104
  "title": {
101
105
  "allOf": [{ "$ref": "#/definitions/stringValue" }],
@@ -814,7 +818,8 @@
814
818
  "type": "object",
815
819
  "allOf": [
816
820
  { "anyOf": [{ "required": ["$ref"] }, { "required": ["tab"] }] },
817
- { "anyOf": [{ "required": ["$ref"] }, { "required": ["groups"] }, { "required": ["pages"] }, { "required": ["dropdowns"] }] }
821
+ { "anyOf": [{ "required": ["$ref"] }, { "required": ["groups"] }, { "required": ["pages"] }, { "required": ["dropdowns"] }, { "required": ["href"] }] },
822
+ { "not": { "anyOf": [{ "required": ["href", "groups"] }, { "required": ["href", "pages"] }, { "required": ["href", "dropdowns"] }] } }
818
823
  ],
819
824
  "additionalProperties": false,
820
825
  "properties": {
@@ -823,6 +828,7 @@
823
828
  "groups": { "$ref": "#/definitions/groupList" },
824
829
  "pages": { "$ref": "#/definitions/pageItemList" },
825
830
  "dropdowns": { "$ref": "#/definitions/dropdownList" },
831
+ "href": { "allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }], "description": "Link tab: navigates to this URL (a standalone page or external site) instead of owning docs pages. Cannot be combined with groups, pages, or dropdowns." },
826
832
  "icon": { "allOf": [{ "$ref": "#/definitions/stringValue" }], "description": "Icon name shown beside the tab label." },
827
833
  "hidden": { "allOf": [{ "$ref": "#/definitions/booleanValue" }], "description": "Hide the tab from the top navigation." }
828
834
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umami/shiso",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Open-source documentation framework for Markdown and MDX sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,10 @@
15
15
  "types": "./types/client.d.ts",
16
16
  "default": "./dist/entry-client.js"
17
17
  },
18
+ "./components": {
19
+ "types": "./types/components.d.ts",
20
+ "default": "./dist/components.js"
21
+ },
18
22
  "./search": {
19
23
  "types": "./types/search.d.ts",
20
24
  "default": "./dist/search.js"
@@ -66,6 +66,7 @@ const bundle = await rolldown({
66
66
  input: {
67
67
  'entry-client': path.join(sourceRoot, 'entry-client.tsx'),
68
68
  'entry-server': path.join(sourceRoot, 'entry-server.tsx'),
69
+ components: path.join(sourceRoot, 'components/docs/index.ts'),
69
70
  search: path.join(sourceRoot, 'lib/search/provider.ts'),
70
71
  },
71
72
  external: isExternal,
@@ -7,6 +7,7 @@ for (const relativeFile of [
7
7
  'bin/shiso.mjs',
8
8
  'dist/entry-client.js',
9
9
  'dist/entry-server.js',
10
+ 'dist/components.js',
10
11
  'dist/search.js',
11
12
  'docs.schema.json',
12
13
  'scripts/lib/mdast.mjs',
@@ -14,6 +15,7 @@ for (const relativeFile of [
14
15
  'scripts/lib/slug.mjs',
15
16
  'src/styles/global.css',
16
17
  'types/client.d.ts',
18
+ 'types/components.d.ts',
17
19
  'types/search.d.ts',
18
20
  'vite.config.ts',
19
21
  ]) {
@@ -18,7 +18,7 @@ import { promisify } from 'node:util';
18
18
  const execFileAsync = promisify(execFile);
19
19
 
20
20
  const DEFAULT_ROOT = process.cwd();
21
- const CONTENT_EXTENSIONS = new Set(['.md', '.mdx']);
21
+ const CONTENT_EXTENSIONS = new Set(['.md', '.mdx', '.tsx']);
22
22
 
23
23
  async function collectContentFiles(dir, files = []) {
24
24
  let entries;
@@ -115,7 +115,7 @@ export function shisoLastModified(options = {}) {
115
115
  await generateLastModified(options);
116
116
  },
117
117
  async handleHotUpdate({ file }) {
118
- if (/\.(md|mdx)$/.test(file)) {
118
+ if (/\.(md|mdx|tsx)$/.test(file)) {
119
119
  await generateLastModified(options);
120
120
  }
121
121
  },
@@ -6,7 +6,12 @@ import { ThemeToggle } from '@/components/ThemeToggle';
6
6
  import { TopNav } from '@/components/TopNav';
7
7
  import { VersionSwitcher } from '@/components/VersionSwitcher';
8
8
  import { isExternalHref } from '@/lib/paths';
9
- import { docsHomeUrl, getScopeByPathname, hasRootStandalonePage } from '@/lib/site-config';
9
+ import {
10
+ docsHomeUrl,
11
+ getPageFrontmatter,
12
+ getScopeByPathname,
13
+ hasRootStandalonePage,
14
+ } from '@/lib/site-config';
10
15
  import type { NormalizedLink, SiteModel } from '@/lib/types';
11
16
 
12
17
  /**
@@ -62,6 +67,7 @@ export function Header({ site }: { site: SiteModel }) {
62
67
  const { pathname } = useLocation();
63
68
  // The header renders the navigation of whichever scope owns the current page.
64
69
  const docs = getScopeByPathname(pathname).docs;
70
+ const showSearch = getPageFrontmatter(pathname)?.search !== false;
65
71
  // The brand links to the standalone home page when one owns "/".
66
72
  const brandHref = logo?.href || (hasRootStandalonePage ? '/' : docsHomeUrl);
67
73
  const hasBrand = !!name || !!logo?.light || !!logo?.dark;
@@ -69,8 +75,18 @@ export function Header({ site }: { site: SiteModel }) {
69
75
  'inline-flex items-center gap-2 text-xl font-bold text-foreground tracking-[-0.03em]';
70
76
  const brandContent = (
71
77
  <>
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}
78
+ {/* With `invert`, one logo serves both modes: a CSS filter forces it to
79
+ white in dark mode instead of swapping to a dark variant. */}
80
+ {logo?.invert && logo.light ? (
81
+ <img src={logo.light} alt="" className="h-6 w-auto dark:brightness-0 dark:invert" />
82
+ ) : (
83
+ <>
84
+ {logo?.light ? <img src={logo.light} alt="" className="h-6 w-auto dark:hidden" /> : null}
85
+ {logo?.dark ? (
86
+ <img src={logo.dark} alt="" className="hidden h-6 w-auto dark:block" />
87
+ ) : null}
88
+ </>
89
+ )}
74
90
  {name ? <span>{name}</span> : null}
75
91
  </>
76
92
  );
@@ -104,7 +120,7 @@ export function Header({ site }: { site: SiteModel }) {
104
120
  {docs.showTabs ? <TopNav docs={docs} label={labels.sections} /> : null}
105
121
  </div>
106
122
  <div className="flex min-w-0 items-center gap-2 justify-self-end">
107
- <Search config={search} labels={labels} />
123
+ {showSearch ? <Search config={search} labels={labels} /> : null}
108
124
  {navbar?.links.map(link => (
109
125
  <NavbarLinkItem key={link.href} link={link} />
110
126
  ))}
@@ -7,6 +7,7 @@ import {
7
7
  DropdownMenuItem,
8
8
  DropdownMenuTrigger,
9
9
  } from '@/components/ui/dropdown-menu';
10
+ import { isExternalHref } from '@/lib/paths';
10
11
  import { getStandalonePage } from '@/lib/site-config';
11
12
  import type { DocsTab, LinkTarget, NavNode, NormalizedDocsConfig } from '@/lib/types';
12
13
  import { cn } from '@/lib/utils';
@@ -112,6 +113,23 @@ export function TopNav({ docs, label }: { docs: NormalizedDocsConfig; label: str
112
113
  );
113
114
  }
114
115
 
116
+ // Link tabs may point outside the app; render external URLs as plain
117
+ // anchors so the browser handles them.
118
+ if (tab.link && isExternalHref(tab.url)) {
119
+ return (
120
+ <a
121
+ key={tab.id}
122
+ href={tab.url}
123
+ className={tabClass(tab)}
124
+ target="_blank"
125
+ rel="noreferrer"
126
+ >
127
+ <ConfiguredIcon icon={tab.icon} />
128
+ {tab.label}
129
+ </a>
130
+ );
131
+ }
132
+
115
133
  return (
116
134
  <Link
117
135
  key={tab.id}
@@ -28,12 +28,12 @@ export interface CardProps {
28
28
  export type CardType = 'note' | 'info' | 'warning' | 'tip' | 'check' | 'danger';
29
29
 
30
30
  const CARD_TYPE_ICONS: Record<CardType, ReactNode> = {
31
- note: <InfoIcon size={16} />,
32
- info: <InfoIcon size={16} />,
33
- warning: <TriangleAlert size={16} />,
34
- tip: <Lightbulb size={16} />,
35
- check: <CheckIcon size={16} />,
36
- danger: <CircleAlert size={16} />,
31
+ note: <InfoIcon size={24} />,
32
+ info: <InfoIcon size={24} />,
33
+ warning: <TriangleAlert size={24} />,
34
+ tip: <Lightbulb size={24} />,
35
+ check: <CheckIcon size={24} />,
36
+ danger: <CircleAlert size={24} />,
37
37
  };
38
38
 
39
39
  const CARD_TYPE_HOVER: Record<CardType, string> = {
@@ -55,14 +55,14 @@ function DocsCardContent({
55
55
  type,
56
56
  children,
57
57
  }: Omit<CardProps, 'href'>) {
58
- const resolvedIcon = resolveIcon(icon, 16) || (type ? CARD_TYPE_ICONS[type] : null);
58
+ const resolvedIcon = resolveIcon(icon, 24) || (type ? CARD_TYPE_ICONS[type] : null);
59
59
 
60
60
  return (
61
61
  <CardContent className="p-0">
62
62
  {img ? <img src={img} alt="" className={styles.cardImage} /> : null}
63
63
  <div className={styles.cardInner} data-slot="card-inner">
64
64
  <div className={styles.cardMain} data-slot="card-main">
65
- <div className={styles.cardHeader}>
65
+ <div className={styles.cardHeader} data-slot="card-header">
66
66
  {resolvedIcon ? (
67
67
  <span
68
68
  className={`${styles.cardIcon} ${type ? 'text-inherit' : 'text-primary'}`}
@@ -1,12 +1,14 @@
1
1
  /** Shared Tailwind utility groups for the built-in MDX components. */
2
2
  export const styles = {
3
- accordion: 'my-3',
4
- accordionGroup:
5
- 'my-4 overflow-hidden rounded-lg border border-border [&_[data-slot=accordion-item]]:rounded-none [&_[data-slot=accordion-item]]:border-0 [&_[data-slot=accordion-item]+[data-slot=accordion-item]]:border-t [&_[data-slot=accordion-trigger]]:rounded-none',
6
- accordionItem: 'rounded-lg border border-border bg-transparent',
7
- accordionTrigger:
8
- 'items-center px-4 py-3 text-base font-semibold text-foreground hover:no-underline',
9
- accordionContent: 'px-4 pb-4 text-base leading-7',
3
+ /* Accordions keep the native shadcn look: no outer box, hairline separators,
4
+ small medium-weight triggers with hover underline. Only margins are added.
5
+ The trigger header is an <h3> and panel text renders as <p>, so the
6
+ .docs-markdown typography margins must be cancelled for those elements. */
7
+ accordion: 'my-3 [&_h3:has(>[data-slot=accordion-trigger])]:m-0',
8
+ accordionGroup: 'my-4 [&_h3:has(>[data-slot=accordion-trigger])]:m-0',
9
+ accordionItem: '',
10
+ accordionTrigger: 'text-foreground',
11
+ accordionContent: 'text-muted-foreground [&_p]:mt-0 [&_p:last-child]:mb-0',
10
12
  expandableTrigger:
11
13
  'items-center px-4 py-3 text-sm font-normal text-foreground hover:no-underline',
12
14
 
@@ -25,17 +27,17 @@ export const styles = {
25
27
  'border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-300',
26
28
  danger: 'border-destructive/40 bg-destructive/10 text-destructive',
27
29
 
28
- card: 'block h-full gap-0 rounded-lg border border-border bg-transparent p-4 text-base ring-0 hover:border-input',
30
+ card: 'block h-full gap-0 rounded-lg border border-border bg-transparent p-4 text-base ring-0 hover:border-primary',
29
31
  cardHorizontal:
30
- '[&_[data-slot=card-inner]]:items-center [&_[data-slot=card-main]]:flex-row [&_[data-slot=card-main]]:items-center [&_[data-slot=card-main]]:gap-3 [&_[data-slot=card-body]]:m-0',
32
+ '[&_[data-slot=card-inner]]:items-center [&_[data-slot=card-main]]:flex-row [&_[data-slot=card-main]]:items-center [&_[data-slot=card-main]]:gap-3 [&_[data-slot=card-body]]:m-0 [&_[data-slot=card-header]]:flex-row [&_[data-slot=card-header]]:items-center [&_[data-slot=card-header]]:gap-2',
31
33
  cardTyped:
32
34
  '[&_[data-slot=card-title]]:text-inherit [&_[data-slot=card-body]]:text-inherit [&_[data-slot=card-cta]]:text-inherit [&_[data-slot=card-arrow]]:text-inherit',
33
35
  cardImageLayout: 'p-0 [&_[data-slot=card-inner]]:p-6',
34
36
  cardImage: 'block aspect-video w-full border-border border-b object-cover',
35
37
  cardInner: 'flex items-start justify-between gap-3',
36
38
  cardMain: 'flex min-w-0 flex-col gap-2',
37
- cardHeader: 'flex items-center gap-2',
38
- cardIcon: 'inline-flex shrink-0 items-center justify-center [&_img]:size-4 [&_svg]:size-4',
39
+ cardHeader: 'flex flex-col items-start gap-3',
40
+ cardIcon: 'inline-flex shrink-0 items-center justify-center [&_img]:size-6 [&_svg]:size-6',
39
41
  cardTitle: 'text-base font-semibold text-foreground',
40
42
  cardBody: 'text-muted-foreground [&>:first-child]:mt-0 [&>:last-child]:mb-0',
41
43
  cardCta:
@@ -24,7 +24,7 @@ function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
24
24
 
25
25
  function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
26
26
  return (
27
- <AccordionPrimitive.Header className="m-0 flex">
27
+ <AccordionPrimitive.Header className="flex">
28
28
  <AccordionPrimitive.Trigger
29
29
  data-slot="accordion-trigger"
30
30
  className={cn(
@@ -22,7 +22,17 @@ function ScrollArea({ className, children, scrollbars = 'vertical', ...props }:
22
22
  >
23
23
  <ScrollAreaPrimitive.Viewport
24
24
  data-slot="scroll-area-viewport"
25
- className="box-border h-full w-full max-w-full rounded-[inherit] overscroll-contain outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
25
+ className={cn(
26
+ 'box-border h-full w-full max-w-full rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1',
27
+ // Contain overscroll only on the managed axis; containing both axes
28
+ // would swallow vertical wheel events over horizontal-only scroll
29
+ // areas (e.g. code blocks) and block page scrolling.
30
+ scrollbars === 'both'
31
+ ? 'overscroll-contain'
32
+ : hasHorizontalScrollbar
33
+ ? 'overscroll-x-contain'
34
+ : 'overscroll-y-contain',
35
+ )}
26
36
  >
27
37
  <ScrollAreaPrimitive.Content
28
38
  data-slot="scroll-area-content"
@@ -36,15 +36,18 @@ export function getRoutes(): string[] {
36
36
  export { getRedirects };
37
37
 
38
38
  /**
39
- * Source file for every routed page, so the prerenderer can publish raw
39
+ * Source file for every Markdown/MDX page, so the prerenderer can publish raw
40
40
  * markdown next to each HTML page (used by the contextual menu's copy/view
41
- * options and by AI tools). `filePath` is a module key like
42
- * "/content/docs/index.mdx", resolved against the project root.
41
+ * options and by AI tools). TSX standalone pages have no raw Markdown copy.
42
+ * `filePath` is a module key like "/content/docs/index.mdx", resolved against
43
+ * the project root.
43
44
  */
44
45
  export function getMarkdownPages(): { route: string; filePath: string }[] {
45
46
  return [
46
47
  ...docsSite.pages.map(page => ({ route: page.url, filePath: page.filePath })),
47
- ...standalonePages.map(page => ({ route: page.path, filePath: page.filePath })),
48
+ ...standalonePages
49
+ .filter(page => !page.filePath.endsWith('.tsx'))
50
+ .map(page => ({ route: page.path, filePath: page.filePath })),
48
51
  ];
49
52
  }
50
53
 
@@ -3,10 +3,9 @@ import { CONTENT_DIR, PAGES_DIR } from '@/lib/paths';
3
3
  import type { DocModule } from '@/lib/types';
4
4
 
5
5
  /**
6
- * Eagerly imports every content file at build time. Each module exports:
7
- * - default: the compiled MDX component
8
- * - frontmatter: parsed YAML frontmatter
9
- * - toc: heading anchors injected by the remark-toc plugin
6
+ * Eagerly imports every content file at build time. Markdown/MDX modules
7
+ * export a compiled component plus generated frontmatter and TOC values. TSX
8
+ * standalone pages export their component and may export frontmatter directly.
10
9
  *
11
10
  * Eager loading keeps server prerendering and client hydration in sync
12
11
  * without Suspense, at the cost of bundling all pages together.
@@ -16,7 +15,7 @@ import type { DocModule } from '@/lib/types';
16
15
  * lookup time instead. That also lets later versioned/localized content roots
17
16
  * (`content/v2`, `content/es`) work without touching this glob.
18
17
  */
19
- export const docModules = import.meta.glob('/content/**/*.{md,mdx}', {
18
+ export const docModules = import.meta.glob('/content/**/*.{md,mdx,tsx}', {
20
19
  eager: true,
21
20
  }) as Record<string, DocModule>;
22
21
 
@@ -35,7 +34,12 @@ export function resolveDocFile(fileSlug: string, contentDir = CONTENT_DIR): stri
35
34
  * under the fixed content/pages root.
36
35
  */
37
36
  export function resolvePageFile(fileSlug: string): string | undefined {
38
- return resolveDocFile(fileSlug, PAGES_DIR);
37
+ const candidates = [
38
+ `/${PAGES_DIR}/${fileSlug}.tsx`,
39
+ `/${PAGES_DIR}/${fileSlug}.mdx`,
40
+ `/${PAGES_DIR}/${fileSlug}.md`,
41
+ ];
42
+ return candidates.find(candidate => candidate in docModules);
39
43
  }
40
44
 
41
45
  export function getDocModule(filePath: string): DocModule | undefined {
@@ -665,7 +665,15 @@ function normalizeScope(
665
665
  nodes.push(...collectPages(tab.pages, context, state));
666
666
  }
667
667
 
668
- if (state.pages.length === pagesBefore) {
668
+ const isLinkTab = typeof tab.href === 'string' && !!tab.href;
669
+
670
+ if (isLinkTab && state.pages.length !== pagesBefore) {
671
+ throw new Error(
672
+ `Invalid docs config: tab "${tabLabel}" cannot define both "href" and page entries.`,
673
+ );
674
+ }
675
+
676
+ if (!isLinkTab && state.pages.length === pagesBefore) {
669
677
  throw new Error(
670
678
  `Invalid docs config: tab "${tabLabel}" does not contain any supported page entries.`,
671
679
  );
@@ -781,14 +789,16 @@ function normalizeScope(
781
789
  const normalizedTabs = tabs.map((tab, index) => {
782
790
  const tabId = tabIds[index];
783
791
  const firstPage = firstVisiblePageByTab.get(tabId);
792
+ const href = typeof tab.href === 'string' && tab.href ? tab.href : undefined;
784
793
 
785
794
  return {
786
795
  id: tabId,
787
796
  label: tab.tab?.trim() || '',
788
- url: firstPage?.url || pageToUrl('index', docsPrefix),
797
+ url: href || firstPage?.url || pageToUrl('index', docsPrefix),
789
798
  icon: tab.icon,
790
799
  presentation: tab.presentation || 'tab',
791
800
  hidden: tab.hidden || undefined,
801
+ link: href ? true : undefined,
792
802
  };
793
803
  });
794
804
 
@@ -1,6 +1,6 @@
1
1
  import shisoConfig from 'virtual:shiso-config';
2
2
  import rawConfig from 'virtual:shiso-docs-config';
3
- import { resolveDocFile, resolvePageFile } from '@/lib/content';
3
+ import { getDocModule, resolveDocFile, resolvePageFile } from '@/lib/content';
4
4
  import {
5
5
  assertDocsConfig,
6
6
  getDefaultScope,
@@ -126,6 +126,12 @@ export function getPageByPathname(pathname: string): NormalizedDocsPage | null {
126
126
  return getSitePageByPathname(docsSite, stripBase(pathname));
127
127
  }
128
128
 
129
+ /** Frontmatter for the docs or standalone page that owns a pathname. */
130
+ export function getPageFrontmatter(pathname: string) {
131
+ const page = getStandalonePage(pathname) || getPageByPathname(pathname);
132
+ return page ? getDocModule(page.filePath)?.frontmatter : undefined;
133
+ }
134
+
129
135
  export function getPageTitle(pageTitle?: string): string {
130
136
  if (pageTitle && siteName) {
131
137
  return `${pageTitle} – ${siteName}`;
@@ -107,6 +107,7 @@ export function resolveSiteModel(
107
107
  target: config.logo.href
108
108
  ? resolveLinkTarget(config.logo.href, config.logo.target)
109
109
  : undefined,
110
+ invert: config.logo.invert === true,
110
111
  }
111
112
  : null;
112
113
 
@@ -28,7 +28,7 @@ function normalizePath(rawPath: unknown): string {
28
28
  throw invalid(`standalone page path "${value}" must not use wildcard patterns.`);
29
29
  }
30
30
 
31
- if (/\.mdx?$/i.test(value)) {
31
+ if (/\.(?:mdx?|tsx)$/i.test(value)) {
32
32
  throw invalid(
33
33
  `standalone page path "${value}" must be a route, not a file — drop the extension.`,
34
34
  );
@@ -48,7 +48,7 @@ function normalizePageSlug(rawSlug: unknown): string {
48
48
  .replace(/\\/g, '/')
49
49
  .replace(/^\/+/, '')
50
50
  .replace(/^pages\//, '')
51
- .replace(/\.mdx?$/, '')
51
+ .replace(/\.(?:mdx?|tsx)$/, '')
52
52
  .replace(/\/+$/, '') || 'index'
53
53
  );
54
54
  }
@@ -104,7 +104,7 @@ export function normalizeStandalonePages(
104
104
  if (!filePath) {
105
105
  throw new Error(
106
106
  `Missing standalone page file for "${fileSlug}": expected ` +
107
- `"content/pages/${fileSlug}.mdx" or ".md".`,
107
+ `"content/pages/${fileSlug}.tsx", ".mdx", or ".md".`,
108
108
  );
109
109
  }
110
110
 
package/src/lib/types.ts CHANGED
@@ -46,6 +46,8 @@ export interface TabItem {
46
46
  groups?: GroupItem[];
47
47
  pages?: PageItem[];
48
48
  dropdowns?: DropdownItem[];
49
+ /** Link tab: navigates to this URL instead of owning docs pages. */
50
+ href?: string;
49
51
  icon?: string;
50
52
  hidden?: boolean;
51
53
  /** Internal normalized presentation; not a docs.json field. */
@@ -99,7 +101,15 @@ export interface ThemeColors {
99
101
 
100
102
  export type LogoOption =
101
103
  | string
102
- | { light?: string; dark?: string; href?: string; target?: LinkTarget };
104
+ | {
105
+ light?: string;
106
+ dark?: string;
107
+ href?: string;
108
+ target?: LinkTarget;
109
+ /** Force the logo to white in dark mode with a CSS filter instead of
110
+ * swapping to a dark variant. Best for monochrome logos. */
111
+ invert?: boolean;
112
+ };
103
113
 
104
114
  /** Project-level settings supplied by shiso.config.ts. Mirrors the public
105
115
  * shape exported from "@umami/shiso/config". */
@@ -179,7 +189,7 @@ export interface RedirectRule {
179
189
  export interface StandalonePageItem {
180
190
  /** Route path, starting with "/". "/" replaces the root redirect to docs. */
181
191
  path: string;
182
- /** File slug under content/pages, e.g. "home" for content/pages/home.mdx. */
192
+ /** File slug under content/pages, e.g. "home" for content/pages/home.tsx. */
183
193
  page: string;
184
194
  /** Page title used in the document head. Frontmatter title wins. */
185
195
  title?: string;
@@ -189,7 +199,7 @@ export interface StandalonePageItem {
189
199
  export interface StandalonePage {
190
200
  /** Base-relative route, e.g. "/" or "/about". */
191
201
  path: string;
192
- /** Module key of the MDX file, e.g. "/content/pages/home.mdx". */
202
+ /** Module key of the TSX, MDX, or Markdown file. */
193
203
  filePath: string;
194
204
  /** Config-level head-title override. */
195
205
  title?: string;
@@ -337,6 +347,8 @@ export interface DocsTab {
337
347
  icon?: string;
338
348
  presentation: 'tab' | 'dropdown';
339
349
  hidden?: boolean;
350
+ /** True for link tabs configured with `href` instead of docs pages. */
351
+ link?: boolean;
340
352
  }
341
353
 
342
354
  export interface NormalizedDocsPage {
@@ -494,7 +506,13 @@ export interface ThemeLabels {
494
506
 
495
507
  export interface SiteModel {
496
508
  name?: string;
497
- logo: { light?: string; dark?: string; href?: string; target?: LinkTarget } | null;
509
+ logo: {
510
+ light?: string;
511
+ dark?: string;
512
+ href?: string;
513
+ target?: LinkTarget;
514
+ invert?: boolean;
515
+ } | null;
498
516
  navbar: NormalizedNavbar | null;
499
517
  footer: NormalizedFooter | null;
500
518
  banner: BannerConfig | null;
@@ -537,6 +555,8 @@ export interface DocFrontmatter {
537
555
  title?: string;
538
556
  description?: string;
539
557
  noindex?: boolean;
558
+ /** Hide the header search control and disable its shortcut on this page. */
559
+ search?: false;
540
560
  /** Overrides the site-wide `metadata.timestamp` setting for this page. */
541
561
  timestamp?: boolean;
542
562
  /** Related pages rendered above the prev/next pager. */
@@ -7,8 +7,8 @@ import type { SiteModel, StandalonePage } from '@/lib/types';
7
7
 
8
8
  /**
9
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.
10
+ * content at full container width — no sidebar, TOC, or pager — and the
11
+ * footer. Markdown/MDX gets docs typography; TSX owns its presentation.
12
12
  */
13
13
  export function StandalonePageView({ page, site }: { page: StandalonePage; site: SiteModel }) {
14
14
  const { pathname } = useLocation();
@@ -28,14 +28,21 @@ export function StandalonePageView({ page, site }: { page: StandalonePage; site:
28
28
  }
29
29
 
30
30
  const Content = doc.default;
31
+ const isComponentPage = page.filePath.endsWith('.tsx');
31
32
 
32
33
  return (
33
34
  <div className="flex min-h-full flex-col">
34
- <article className="grow py-8">
35
- <div className="docs-markdown">
35
+ {isComponentPage ? (
36
+ <div className="grow">
36
37
  <Content />
37
38
  </div>
38
- </article>
39
+ ) : (
40
+ <article className="grow py-8">
41
+ <div className="docs-markdown">
42
+ <Content />
43
+ </div>
44
+ </article>
45
+ )}
39
46
  <Footer footer={site.footer} />
40
47
  </div>
41
48
  );