diff --git a/.gitignore b/.gitignore
index 07b99934..dad5a8ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,8 +10,7 @@
*.local
*.log
apps/docs/.story/
-apps/docs/content/docs/components/*/*/meta.json
-apps/docs/content/docs/components/*/*/props.mdx
+apps/docs/content/docs/components/*/*/
apps/docs/src/generated/
apps/docs/src/routeTree.gen.ts
dist/
diff --git a/apps/docs/content/docs/components/actions/button/index.mdx b/apps/docs/content/docs/components/actions/button.mdx
similarity index 100%
rename from apps/docs/content/docs/components/actions/button/index.mdx
rename to apps/docs/content/docs/components/actions/button.mdx
diff --git a/apps/docs/content/docs/components/actions/icon-button/index.mdx b/apps/docs/content/docs/components/actions/icon-button.mdx
similarity index 100%
rename from apps/docs/content/docs/components/actions/icon-button/index.mdx
rename to apps/docs/content/docs/components/actions/icon-button.mdx
diff --git a/apps/docs/content/docs/components/actions/link/index.mdx b/apps/docs/content/docs/components/actions/link.mdx
similarity index 100%
rename from apps/docs/content/docs/components/actions/link/index.mdx
rename to apps/docs/content/docs/components/actions/link.mdx
diff --git a/apps/docs/content/docs/components/feedback/loading-skeleton/index.mdx b/apps/docs/content/docs/components/feedback/loading-skeleton.mdx
similarity index 100%
rename from apps/docs/content/docs/components/feedback/loading-skeleton/index.mdx
rename to apps/docs/content/docs/components/feedback/loading-skeleton.mdx
diff --git a/apps/docs/content/docs/components/feedback/loading-spinner/index.mdx b/apps/docs/content/docs/components/feedback/loading-spinner.mdx
similarity index 100%
rename from apps/docs/content/docs/components/feedback/loading-spinner/index.mdx
rename to apps/docs/content/docs/components/feedback/loading-spinner.mdx
diff --git a/apps/docs/content/docs/components/forms/checkbox/index.mdx b/apps/docs/content/docs/components/forms/checkbox.mdx
similarity index 96%
rename from apps/docs/content/docs/components/forms/checkbox/index.mdx
rename to apps/docs/content/docs/components/forms/checkbox.mdx
index 1056c303..b15b0137 100644
--- a/apps/docs/content/docs/components/forms/checkbox/index.mdx
+++ b/apps/docs/content/docs/components/forms/checkbox.mdx
@@ -43,7 +43,7 @@ wrong.
-Read [Validation](/components/forms/validation) for how to word the message.
+Read [Validation](/docs/validation) for how to word the message.
## Labels with Text
diff --git a/apps/docs/content/docs/components/forms/combobox-field/index.mdx b/apps/docs/content/docs/components/forms/combobox-field.mdx
similarity index 97%
rename from apps/docs/content/docs/components/forms/combobox-field/index.mdx
rename to apps/docs/content/docs/components/forms/combobox-field.mdx
index 3ac79350..0f7a38aa 100644
--- a/apps/docs/content/docs/components/forms/combobox-field/index.mdx
+++ b/apps/docs/content/docs/components/forms/combobox-field.mdx
@@ -46,8 +46,7 @@ message leaves someone who cannot see the border colour with no way to know what
-Read [Validation](/components/forms/validation) for server validation and for how to word the
-message.
+Read [Validation](/docs/validation) for server validation and for how to word the message.
## Groups
diff --git a/apps/docs/content/docs/components/forms/meta.json b/apps/docs/content/docs/components/forms/meta.json
index 28183ccb..405e6226 100644
--- a/apps/docs/content/docs/components/forms/meta.json
+++ b/apps/docs/content/docs/components/forms/meta.json
@@ -1,4 +1,4 @@
{
"title": "Forms",
- "pages": ["checkbox", "combobox-field", "text-field", "validation"]
+ "pages": ["checkbox", "combobox-field", "text-field"]
}
diff --git a/apps/docs/content/docs/components/forms/text-field/index.mdx b/apps/docs/content/docs/components/forms/text-field.mdx
similarity index 95%
rename from apps/docs/content/docs/components/forms/text-field/index.mdx
rename to apps/docs/content/docs/components/forms/text-field.mdx
index a2196f73..6d8e81b1 100644
--- a/apps/docs/content/docs/components/forms/text-field/index.mdx
+++ b/apps/docs/content/docs/components/forms/text-field.mdx
@@ -41,8 +41,7 @@ message leaves someone who cannot see the border colour with no way to know what
-Read [Validation](/components/forms/validation) for server validation and for how to word the
-message.
+Read [Validation](/docs/validation) for server validation and for how to word the message.
## Prefix and suffix
diff --git a/apps/docs/content/docs/components/layout/box/index.mdx b/apps/docs/content/docs/components/layout/box.mdx
similarity index 97%
rename from apps/docs/content/docs/components/layout/box/index.mdx
rename to apps/docs/content/docs/components/layout/box.mdx
index 36f5478d..19e3b04d 100644
--- a/apps/docs/content/docs/components/layout/box/index.mdx
+++ b/apps/docs/content/docs/components/layout/box.mdx
@@ -45,7 +45,7 @@ cascade upward from `initial`, so only specify the changes.
```
-See [Layout](/layout) for the available keys and fixed thresholds.
+See [Layout](/docs/layout) for the available keys and fixed thresholds.
## Spacing values
diff --git a/apps/docs/content/docs/components/meta.json b/apps/docs/content/docs/components/meta.json
index 66681419..87887746 100644
--- a/apps/docs/content/docs/components/meta.json
+++ b/apps/docs/content/docs/components/meta.json
@@ -13,7 +13,6 @@
"forms/checkbox",
"forms/combobox-field",
"forms/text-field",
- "forms/validation",
"---Layout---",
"layout/box",
"---Typography---",
diff --git a/apps/docs/content/docs/components/primitives/button/index.mdx b/apps/docs/content/docs/components/primitives/button.mdx
similarity index 100%
rename from apps/docs/content/docs/components/primitives/button/index.mdx
rename to apps/docs/content/docs/components/primitives/button.mdx
diff --git a/apps/docs/content/docs/components/primitives/checkbox/index.mdx b/apps/docs/content/docs/components/primitives/checkbox.mdx
similarity index 100%
rename from apps/docs/content/docs/components/primitives/checkbox/index.mdx
rename to apps/docs/content/docs/components/primitives/checkbox.mdx
diff --git a/apps/docs/content/docs/components/primitives/combobox/index.mdx b/apps/docs/content/docs/components/primitives/combobox.mdx
similarity index 100%
rename from apps/docs/content/docs/components/primitives/combobox/index.mdx
rename to apps/docs/content/docs/components/primitives/combobox.mdx
diff --git a/apps/docs/content/docs/components/primitives/field/index.mdx b/apps/docs/content/docs/components/primitives/field.mdx
similarity index 100%
rename from apps/docs/content/docs/components/primitives/field/index.mdx
rename to apps/docs/content/docs/components/primitives/field.mdx
diff --git a/apps/docs/content/docs/components/primitives/input-group/index.mdx b/apps/docs/content/docs/components/primitives/input-group.mdx
similarity index 98%
rename from apps/docs/content/docs/components/primitives/input-group/index.mdx
rename to apps/docs/content/docs/components/primitives/input-group.mdx
index dd487b4b..eb782c15 100644
--- a/apps/docs/content/docs/components/primitives/input-group/index.mdx
+++ b/apps/docs/content/docs/components/primitives/input-group.mdx
@@ -57,7 +57,7 @@ scales with the control without a `size` of its own.
The icon is decorative and hidden from assistive technology, so it marks a problem but does not
describe it. Always give an invalid composition an error message. See
-[Validation](/components/forms/validation).
+[Validation](/docs/validation).
## Styling and composition
diff --git a/apps/docs/content/docs/components/primitives/visually-hidden/index.mdx b/apps/docs/content/docs/components/primitives/visually-hidden.mdx
similarity index 100%
rename from apps/docs/content/docs/components/primitives/visually-hidden/index.mdx
rename to apps/docs/content/docs/components/primitives/visually-hidden.mdx
diff --git a/apps/docs/content/docs/components/typography/blockquote/index.mdx b/apps/docs/content/docs/components/typography/blockquote.mdx
similarity index 100%
rename from apps/docs/content/docs/components/typography/blockquote/index.mdx
rename to apps/docs/content/docs/components/typography/blockquote.mdx
diff --git a/apps/docs/content/docs/components/typography/code/index.mdx b/apps/docs/content/docs/components/typography/code.mdx
similarity index 100%
rename from apps/docs/content/docs/components/typography/code/index.mdx
rename to apps/docs/content/docs/components/typography/code.mdx
diff --git a/apps/docs/content/docs/components/typography/em/index.mdx b/apps/docs/content/docs/components/typography/em.mdx
similarity index 100%
rename from apps/docs/content/docs/components/typography/em/index.mdx
rename to apps/docs/content/docs/components/typography/em.mdx
diff --git a/apps/docs/content/docs/components/typography/emoji/index.mdx b/apps/docs/content/docs/components/typography/emoji.mdx
similarity index 100%
rename from apps/docs/content/docs/components/typography/emoji/index.mdx
rename to apps/docs/content/docs/components/typography/emoji.mdx
diff --git a/apps/docs/content/docs/components/typography/heading/index.mdx b/apps/docs/content/docs/components/typography/heading.mdx
similarity index 100%
rename from apps/docs/content/docs/components/typography/heading/index.mdx
rename to apps/docs/content/docs/components/typography/heading.mdx
diff --git a/apps/docs/content/docs/components/typography/kbd/index.mdx b/apps/docs/content/docs/components/typography/kbd.mdx
similarity index 100%
rename from apps/docs/content/docs/components/typography/kbd/index.mdx
rename to apps/docs/content/docs/components/typography/kbd.mdx
diff --git a/apps/docs/content/docs/components/typography/numeral/index.mdx b/apps/docs/content/docs/components/typography/numeral.mdx
similarity index 100%
rename from apps/docs/content/docs/components/typography/numeral/index.mdx
rename to apps/docs/content/docs/components/typography/numeral.mdx
diff --git a/apps/docs/content/docs/components/typography/quote/index.mdx b/apps/docs/content/docs/components/typography/quote.mdx
similarity index 100%
rename from apps/docs/content/docs/components/typography/quote/index.mdx
rename to apps/docs/content/docs/components/typography/quote.mdx
diff --git a/apps/docs/content/docs/components/typography/strong/index.mdx b/apps/docs/content/docs/components/typography/strong.mdx
similarity index 100%
rename from apps/docs/content/docs/components/typography/strong/index.mdx
rename to apps/docs/content/docs/components/typography/strong.mdx
diff --git a/apps/docs/content/docs/components/typography/text/index.mdx b/apps/docs/content/docs/components/typography/text.mdx
similarity index 98%
rename from apps/docs/content/docs/components/typography/text/index.mdx
rename to apps/docs/content/docs/components/typography/text.mdx
index 3f4490de..5f271b4c 100644
--- a/apps/docs/content/docs/components/typography/text/index.mdx
+++ b/apps/docs/content/docs/components/typography/text.mdx
@@ -8,7 +8,7 @@ props:
---
`Text` applies Luke UI typography without adding heading semantics. It needs a Luke UI theme root.
-See [Getting started](/installation).
+See [Getting started](/docs/installation).
diff --git a/apps/docs/content/docs/components/visuals/icon/index.mdx b/apps/docs/content/docs/components/visuals/icon.mdx
similarity index 98%
rename from apps/docs/content/docs/components/visuals/icon/index.mdx
rename to apps/docs/content/docs/components/visuals/icon.mdx
index b24f9f57..1b3027c3 100644
--- a/apps/docs/content/docs/components/visuals/icon/index.mdx
+++ b/apps/docs/content/docs/components/visuals/icon.mdx
@@ -10,7 +10,7 @@ props:
`Icon` renders a symbol from the generated Luke UI spritesheet. It needs an
`IconSpritesheetProvider` ancestor so it can resolve the spritesheet URL.
-To find an icon, or to set up the spritesheet, see [Iconography](/iconography).
+To find an icon, or to set up the spritesheet, see [Iconography](/docs/iconography).
diff --git a/apps/docs/content/docs/applying-a-theme.mdx b/apps/docs/content/docs/docs/applying-a-theme.mdx
similarity index 94%
rename from apps/docs/content/docs/applying-a-theme.mdx
rename to apps/docs/content/docs/docs/applying-a-theme.mdx
index abff1065..18fc216a 100644
--- a/apps/docs/content/docs/applying-a-theme.mdx
+++ b/apps/docs/content/docs/docs/applying-a-theme.mdx
@@ -78,5 +78,5 @@ because the two resolve at equal precedence and stylesheet order decides the win
## Next steps
-Read [Authoring a theme](/authoring-a-theme) to create a product-owned stylesheet. Use the
-[token reference](/token-reference) when a custom element needs public semantic values.
+Read [Authoring a theme](/docs/authoring-a-theme) to create a product-owned stylesheet. Use the
+[token reference](/docs/token-reference) when a custom element needs public semantic values.
diff --git a/apps/docs/content/docs/authoring-a-theme.mdx b/apps/docs/content/docs/docs/authoring-a-theme.mdx
similarity index 96%
rename from apps/docs/content/docs/authoring-a-theme.mdx
rename to apps/docs/content/docs/docs/authoring-a-theme.mdx
index 5f038359..7e544af7 100644
--- a/apps/docs/content/docs/authoring-a-theme.mdx
+++ b/apps/docs/content/docs/docs/authoring-a-theme.mdx
@@ -83,7 +83,7 @@ attribute as bundled themes.
The theme needs no identity class unless the same document also loads another theme. When it does,
call `getThemeClassName` from `@luke-ui/react/theme` with the `name` your `ThemeInput` declares.
That is the same helper the bundled themes use for their own classes. See
-[Applying a theme](/applying-a-theme) for where to put the result.
+[Applying a theme](/docs/applying-a-theme) for where to put the result.
Applications must load any non-system font files their `typography` selects.
@@ -151,5 +151,6 @@ control over its lightness. Otherwise, author a single value and let `defineThem
## Next steps
-Read [Applying a theme](/applying-a-theme) for root, mode, and portal setup. The
-[token reference](/token-reference) lists the semantic variables every generated theme provides.
+Read [Applying a theme](/docs/applying-a-theme) for root, mode, and portal setup. The
+[token reference](/docs/token-reference) lists the semantic variables every generated theme
+provides.
diff --git a/apps/docs/content/docs/color.mdx b/apps/docs/content/docs/docs/color.mdx
similarity index 96%
rename from apps/docs/content/docs/color.mdx
rename to apps/docs/content/docs/docs/color.mdx
index a87bc33d..4178bc55 100644
--- a/apps/docs/content/docs/color.mdx
+++ b/apps/docs/content/docs/docs/color.mdx
@@ -89,7 +89,7 @@ describes meaning. The component decides when to apply it, such as only while in
permanent outline.
Luke UI measures a semantic border for contrast, but does not guarantee it reaches 3:1. See
-[contrast validation](/authoring-a-theme#contrast-validation). Never rely on one alone to
+[contrast validation](/docs/authoring-a-theme#contrast-validation). Never rely on one alone to
communicate a required state such as selected, invalid, or checked. Pair it with text, an icon, or
another cue that is separately contrast-gated.
@@ -132,10 +132,10 @@ foregrounds for `info`, `success`, and `warning`.
## Continue learning
-
+
Follow the system preference or choose a fixed colour mode.
-
+
Browse the full public semantic-colour contract.
diff --git a/apps/docs/content/docs/composition.mdx b/apps/docs/content/docs/docs/composition.mdx
similarity index 98%
rename from apps/docs/content/docs/composition.mdx
rename to apps/docs/content/docs/docs/composition.mdx
index 1977f605..f92dfe47 100644
--- a/apps/docs/content/docs/composition.mdx
+++ b/apps/docs/content/docs/docs/composition.mdx
@@ -112,6 +112,6 @@ import { vars } from '@luke-ui/react/theme';
/>;
```
-Read [Styling](/styling) for recipes, semantic variables, and layout utilities. Read the
+Read [Styling](/docs/styling) for recipes, semantic variables, and layout utilities. Read the
[primitive documentation](/components/primitives/button) before you build a custom component from a
specific primitive.
diff --git a/apps/docs/content/docs/forms.mdx b/apps/docs/content/docs/docs/forms.mdx
similarity index 95%
rename from apps/docs/content/docs/forms.mdx
rename to apps/docs/content/docs/docs/forms.mdx
index 97df9f74..4f74218e 100644
--- a/apps/docs/content/docs/forms.mdx
+++ b/apps/docs/content/docs/docs/forms.mdx
@@ -81,7 +81,7 @@ const [email, setEmail] = useState(INITIAL_EMAIL);
## Validation
Luke UI supports browser validation, custom validation, and server validation. The
-[Validation guide](/components/forms/validation) explains each approach.
+[Validation guide](/docs/validation) explains each approach.
- Set native constraints such as `isRequired`, `minLength`, `pattern`, or `type="email"` for browser
validation.
@@ -90,4 +90,4 @@ Luke UI supports browser validation, custom validation, and server validation. T
`name`.
Start with native form behaviour. When a form library owns the form state, read
-[React Hook Form](/react-hook-form) or [TanStack Form](/tanstack-form).
+[React Hook Form](/docs/react-hook-form) or [TanStack Form](/docs/tanstack-form).
diff --git a/apps/docs/content/docs/iconography.mdx b/apps/docs/content/docs/docs/iconography.mdx
similarity index 100%
rename from apps/docs/content/docs/iconography.mdx
rename to apps/docs/content/docs/docs/iconography.mdx
diff --git a/apps/docs/content/docs/installation.mdx b/apps/docs/content/docs/docs/installation.mdx
similarity index 93%
rename from apps/docs/content/docs/installation.mdx
rename to apps/docs/content/docs/docs/installation.mdx
index 125db870..fcceb051 100644
--- a/apps/docs/content/docs/installation.mdx
+++ b/apps/docs/content/docs/docs/installation.mdx
@@ -48,13 +48,13 @@ must use a specific mode. Without it, Luke UI follows the system preference.
## Continue learning
-
+
Learn how component APIs, recipes, utilities, and tokens fit together.
-
+
Build responsive structure with Box.
-
+
Understand how the theme root, identity, and tokens fit together.
diff --git a/apps/docs/content/docs/layout.mdx b/apps/docs/content/docs/docs/layout.mdx
similarity index 98%
rename from apps/docs/content/docs/layout.mdx
rename to apps/docs/content/docs/docs/layout.mdx
index 28acf701..aa42b8f7 100644
--- a/apps/docs/content/docs/layout.mdx
+++ b/apps/docs/content/docs/docs/layout.mdx
@@ -78,7 +78,7 @@ defines its spacing and type steps in source.
See the Box example, props, and custom div rendering contract.
-
+
Learn where layout utilities sit beside components and recipes.
diff --git a/apps/docs/content/docs/docs/meta.json b/apps/docs/content/docs/docs/meta.json
new file mode 100644
index 00000000..00431b59
--- /dev/null
+++ b/apps/docs/content/docs/docs/meta.json
@@ -0,0 +1,25 @@
+{
+ "title": "Documentation",
+ "root": true,
+ "pages": [
+ "---Overview---",
+ "installation",
+ "---Foundations---",
+ "composition",
+ "styling",
+ "layout",
+ "typography",
+ "iconography",
+ "---Theming---",
+ "theming",
+ "applying-a-theme",
+ "color",
+ "authoring-a-theme",
+ "token-reference",
+ "---Guides---",
+ "forms",
+ "validation",
+ "react-hook-form",
+ "tanstack-form"
+ ]
+}
diff --git a/apps/docs/content/docs/react-hook-form.mdx b/apps/docs/content/docs/docs/react-hook-form.mdx
similarity index 96%
rename from apps/docs/content/docs/react-hook-form.mdx
rename to apps/docs/content/docs/docs/react-hook-form.mdx
index 60dfff14..01ead474 100644
--- a/apps/docs/content/docs/react-hook-form.mdx
+++ b/apps/docs/content/docs/docs/react-hook-form.mdx
@@ -67,7 +67,7 @@ which hands each field's error state to the browser. React Aria then calls `setC
field the resolver rejected, so the browser blocks the form before the `submit` event fires and
pressing the button does nothing.
-Read [Validation](/components/forms/validation) for the other `validationBehavior` options.
+Read [Validation](/docs/validation) for the other `validationBehavior` options.
## Focus the first invalid field
diff --git a/apps/docs/content/docs/styling.mdx b/apps/docs/content/docs/docs/styling.mdx
similarity index 83%
rename from apps/docs/content/docs/styling.mdx
rename to apps/docs/content/docs/docs/styling.mdx
index 7e0ce124..64cfbf3d 100644
--- a/apps/docs/content/docs/styling.mdx
+++ b/apps/docs/content/docs/docs/styling.mdx
@@ -17,8 +17,8 @@ Work from the most specific public API to the broadest one.
3. Build custom UI with public recipes and semantic variables when the component is not provided.
4. Author a custom theme when the product needs a different visual foundation everywhere.
-Read [Composition](/composition) when the choice is between a component and a documented primitive
-rather than between styling mechanisms.
+Read [Composition](/docs/composition) when the choice is between a component and a documented
+primitive rather than between styling mechanisms.
| Guidance | Practices |
| -------- | ---------------------------------------------------------------------------------------------------- |
@@ -42,7 +42,7 @@ defines its type and spacing steps in source.
`Box` and layout utilities from `@luke-ui/react/styles` handle spacing, sizing, positioning, and
responsive structure around components. They do not set semantic colour, typography, or pseudo
-states. Read [Layout](/layout) for the full API.
+states. Read [Layout](/docs/layout) for the full API.
### Build custom UI with public recipes and variables
@@ -57,12 +57,13 @@ The public `vars` token contract lets an application-owned element follow the ac
The token contract is public. Component selectors, generated palette values, and theme
-implementation details are not. See the [token reference](/token-reference) for the full contract.
+implementation details are not. See the [token reference](/docs/token-reference) for the full
+contract.
### Change the visual foundation with a custom theme
-Use a [custom theme](/authoring-a-theme) when a product needs a different identity, typeface, or
-semantic colour system. `defineTheme` produces a static stylesheet for the full semantic contract
+Use a [custom theme](/docs/authoring-a-theme) when a product needs a different identity, typeface,
+or semantic colour system. `defineTheme` produces a static stylesheet for the full semantic contract
from a curated accent and neutral character. It is not a per-component override tool.
## Set up static styles
@@ -73,7 +74,8 @@ that root, so it does not reset unrelated application content.
-Read [Applying a theme](/applying-a-theme) for colour modes, portals, and other theme setup details.
+Read [Applying a theme](/docs/applying-a-theme) for colour modes, portals, and other theme setup
+details.
## Use application CSS alongside Luke UI
@@ -104,22 +106,22 @@ import { Box } from '@luke-ui/react/box';
```
When a CSS Module or application stylesheet needs a token, use the stable `--luke-*` variable listed
-in the [token reference](/token-reference). Do not couple application CSS to undocumented selectors
-or implementation attributes.
+in the [token reference](/docs/token-reference). Do not couple application CSS to undocumented
+selectors or implementation attributes.
## Continue learning
-
+
Use Box and Sprinkles for responsive structure.
-
+
Choose semantic surfaces and roles for custom UI.
-
+
Browse every public semantic CSS variable.
-
+
Create a product-owned visual foundation when the bundled identities do not fit.
diff --git a/apps/docs/content/docs/tanstack-form.mdx b/apps/docs/content/docs/docs/tanstack-form.mdx
similarity index 97%
rename from apps/docs/content/docs/tanstack-form.mdx
rename to apps/docs/content/docs/docs/tanstack-form.mdx
index 2ac84497..86935062 100644
--- a/apps/docs/content/docs/tanstack-form.mdx
+++ b/apps/docs/content/docs/docs/tanstack-form.mdx
@@ -64,7 +64,7 @@ hands each field's error state to the browser. React Aria then calls `setCustomV
the schema rejected, so the browser blocks the form before the `submit` event fires and pressing the
button does nothing.
-Read [Validation](/components/forms/validation) for the other `validationBehavior` options.
+Read [Validation](/docs/validation) for the other `validationBehavior` options.
## Focus the first invalid field
diff --git a/apps/docs/content/docs/theming.mdx b/apps/docs/content/docs/docs/theming.mdx
similarity index 80%
rename from apps/docs/content/docs/theming.mdx
rename to apps/docs/content/docs/docs/theming.mdx
index bb06a55d..c3062222 100644
--- a/apps/docs/content/docs/theming.mdx
+++ b/apps/docs/content/docs/docs/theming.mdx
@@ -21,7 +21,8 @@ Colour-mode scopes nest freely. Theme identities do not.
Loading more than one theme in the same document needs an explicit identity class, so one theme wins
over the other's `:root` fallback.
-Read [Applying a theme](/applying-a-theme) for the exact imports, classes, and colour-mode rules.
+Read [Applying a theme](/docs/applying-a-theme) for the exact imports, classes, and colour-mode
+rules.
## Component variants
@@ -43,22 +44,22 @@ foundation across the system.
The active theme exposes a typed, public `vars` contract. Use it when a custom element sits beside
Luke UI components.
-Read [Token reference](/token-reference) for the full contract. Read [Colour](/color) for the
-semantic roles it exposes.
+Read [Token reference](/docs/token-reference) for the full contract. Read [Colour](/docs/color) for
+the semantic roles it exposes.
## Next steps
-
+
Load the CSS, set the root classes, and preserve them through portals.
-
+
Choose semantic surfaces and roles for custom UI.
-
+
Compile a product-owned stylesheet from a curated `defineTheme` input.
-
+
Browse the public semantic CSS variables for custom UI.
diff --git a/apps/docs/content/docs/token-reference.mdx b/apps/docs/content/docs/docs/token-reference.mdx
similarity index 95%
rename from apps/docs/content/docs/token-reference.mdx
rename to apps/docs/content/docs/docs/token-reference.mdx
index 196e9690..f617baba 100644
--- a/apps/docs/content/docs/token-reference.mdx
+++ b/apps/docs/content/docs/docs/token-reference.mdx
@@ -3,7 +3,7 @@ title: Token reference
description: Public semantic CSS variables to build custom Luke UI elements.
---
-import { TokenExplorer } from '../../src/components/token-explorer';
+import { TokenExplorer } from '../../../src/components/token-explorer';
`vars` is Luke UI's typed public token contract. Each path resolves to a stable `--luke-*` CSS
variable. For example, `vars.color.background.danger.solid.hover` resolves to
@@ -38,7 +38,7 @@ The spacing properties also accept an object keyed by breakpoint, cascading up f
{children}
```
-Read [Layout](/layout) for Box, Sprinkles, and the full breakpoint list.
+Read [Layout](/docs/layout) for Box, Sprinkles, and the full breakpoint list.
### Radius
diff --git a/apps/docs/content/docs/typography.mdx b/apps/docs/content/docs/docs/typography.mdx
similarity index 100%
rename from apps/docs/content/docs/typography.mdx
rename to apps/docs/content/docs/docs/typography.mdx
diff --git a/apps/docs/content/docs/components/forms/validation.mdx b/apps/docs/content/docs/docs/validation.mdx
similarity index 100%
rename from apps/docs/content/docs/components/forms/validation.mdx
rename to apps/docs/content/docs/docs/validation.mdx
diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx
deleted file mode 100644
index 3c8022fa..00000000
--- a/apps/docs/content/docs/index.mdx
+++ /dev/null
@@ -1,15 +0,0 @@
----
-title: Introduction
-description: Components, themes, and layout utilities for React applications.
----
-
-Luke UI is a React based design system and component library for building applications. It ships
-static CSS, two bundled themes, and layout utilities that share one semantic token contract with its
-components.
-
-Use Luke UI when you need an accessible component, a visual foundation you can theme, or responsive
-layout without writing CSS.
-
-Read [Getting started](/installation) to install Luke UI, load a theme, and render your first
-component. Browse [Components](/components) for the full catalogue of atoms, composed components,
-and primitives.
diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json
index 7bfef02b..3b58d771 100644
--- a/apps/docs/content/docs/meta.json
+++ b/apps/docs/content/docs/meta.json
@@ -1,25 +1,4 @@
{
"title": "Documentation",
- "pages": [
- "---Overview---",
- "index",
- "installation",
- "---Foundations---",
- "composition",
- "styling",
- "layout",
- "typography",
- "iconography",
- "---Theming---",
- "theming",
- "applying-a-theme",
- "color",
- "authoring-a-theme",
- "token-reference",
- "---Guides---",
- "forms",
- "react-hook-form",
- "tanstack-form",
- "quality"
- ]
+ "pages": ["docs"]
}
diff --git a/apps/docs/content/docs/quality.mdx b/apps/docs/content/docs/quality.mdx
deleted file mode 100644
index 16aa9151..00000000
--- a/apps/docs/content/docs/quality.mdx
+++ /dev/null
@@ -1,119 +0,0 @@
----
-title: Quality
-description:
- What Luke UI guarantees for accessibility, motion, and testing, and what your application still
- owns.
----
-
-Luke UI guarantees part of your application's quality. Your application owns the remaining work.
-
-## Accessibility
-
-Luke UI supplies component mechanics and semantics as part of its contract. This guide defines those
-guarantees. Each component page documents its specific requirements. Applications supply meaningful
-names and content, and own the focus order of custom compositions.
-
-### Labels
-
-**Luke UI does.** `TextField` and `ComboboxField` accept visible labels through `label`. They
-associate each label, description, and error message with the control.
-
-`Checkbox` accepts its visible label through `children`. Its `description` prop adds associated
-supporting text.
-
-**You do.** Pass a visible label. Use `aria-label` only when surrounding content already names the
-control.
-
-Do not use a placeholder as the only label. It disappears after someone enters a value.
-
-### Contrast
-
-**Luke UI does.** The theme compiler guarantees 4.5:1 contrast for its required text and surface
-pairs. It guarantees 3:1 for required focus and control borders.
-
-**You do.** Do not rely on a role border alone to show state. Pair it with text, an icon, or a
-guaranteed surface change.
-
-Read [Contrast validation](/authoring-a-theme#contrast-validation) for the full matrix.
-
-### Focus
-
-**Luke UI does.** The theme root gives `:focus-visible` elements a two-pixel focus ring. Forced
-colours replace its colour with the system `Highlight` colour.
-
-**You do.** Give custom controls an interactive role and keyboard behaviour. Override the default
-ring only when another visible indicator replaces it.
-
-### Target size
-
-**Luke UI does.** Small built-in buttons and fields measure 32 CSS pixels. Medium built-in buttons
-and fields measure 40 CSS pixels.
-
-Standalone links provide a 24 by 24 CSS-pixel minimum target. Inline links keep the text exception.
-
-**You do.** Check target size when custom CSS changes a control. Check it when you compose a
-primitive.
-
-### Reduced motion
-
-**Luke UI does.** Its animated controls remove their animations and transitions under
-`prefers-reduced-motion: reduce`.
-
-The theme root provides the same rule as a fallback.
-
-**You do.** Add `prefers-reduced-motion` handling to application animations. Application styles can
-override the fallback rule.
-
-### Forced colours
-
-**Luke UI does.** `Button`, `IconButton`, `Link`, `Checkbox`, `TextField`, and `ComboboxField`
-define forced-colour states. Visual tests cover selected states for these components.
-
-`LoadingSpinner` and `LoadingSkeleton` also define forced-colour rendering.
-
-**You do.** Test custom UI under `forced-colors: active`. Luke UI only adapts the markup it renders.
-
-### Component accessibility notes
-
-Open each component page for its specific requirements:
-
-- Actions: [Button](/components/actions/button), [Icon Button](/components/actions/icon-button), and
- [Link](/components/actions/link).
-- Forms: [Combobox Field](/components/forms/combobox-field).
-- Feedback: [Loading Spinner](/components/feedback/loading-spinner) and
- [Loading Skeleton](/components/feedback/loading-skeleton).
-- Visuals: [Icon](/components/visuals/icon).
-- Typography: [Emoji](/components/typography/emoji).
-- Primitives: [Button primitive](/components/primitives/button).
-
-## Responsive behaviour
-
-**Luke UI does.** `Box` and `createSprinkles` accept responsive values for every layout property.
-Values use the named breakpoints from `initial` through `xxlarge`.
-
-**You do.** Choose the breakpoints for your page. Test the layout at each supported viewport width.
-
-Read [Layout](/layout) for breakpoint values and responsive syntax.
-
-## Locale-aware numerals
-
-**Luke UI does.** `Numeral` formats a value with `Intl.NumberFormat`. It reads the locale from React
-Aria's `I18nProvider`.
-
-**You do.** Wrap the application in an `I18nProvider` with the correct locale. Use `Numeral` for
-numbers that someone sees.
-
-Read [Numeral](/components/typography/numeral) for supported formats.
-
-## Testing
-
-**Luke UI does.** Behaviour tests cover public interactions and accessibility contracts. Visual
-tests cover selected forced-colour and reduced-motion states.
-
-The theme compiler enforces its contrast matrix.
-
-**You do.** Test the complete application. Run an automated accessibility check against rendered
-pages.
-
-Test each flow with a keyboard. Check forced colours, reduced motion, custom colour contrast, and
-supported locales.
diff --git a/apps/docs/scripts/generate-components-index.ts b/apps/docs/scripts/generate-components-index.ts
index 3beb0ced..bf45905f 100644
--- a/apps/docs/scripts/generate-components-index.ts
+++ b/apps/docs/scripts/generate-components-index.ts
@@ -22,8 +22,8 @@ interface ComponentIndexGroup {
* component guide, grouped by category in sidebar order. The components landing page renders from
* this array, so the documented set can never drift from the guides that live on disk.
*/
-export function generateComponentsIndex(): string {
- const groups = readGroups();
+export function generateComponentsIndex(rootDir: string = componentsDir): string {
+ const groups = readGroups(rootDir);
const groupEntries = groups
.map(
@@ -63,8 +63,8 @@ ${groupEntries}
`;
}
-function readGroups(): ReadonlyArray {
- const rootMeta = readJson(resolve(componentsDir, 'meta.json')) as { pages?: Array };
+function readGroups(rootDir: string): ReadonlyArray {
+ const rootMeta = readJson(resolve(rootDir, 'meta.json')) as { pages?: Array };
const entries = rootMeta.pages ?? [];
const groups: Array = [];
let currentTitle = 'Components';
@@ -76,10 +76,12 @@ function readGroups(): ReadonlyArray {
continue;
}
- const guidePath = resolve(componentsDir, `${entry}/index.mdx`);
+ const guidePath = resolve(rootDir, `${entry}.mdx`);
if (!existsSync(guidePath)) continue;
const frontmatter = readFrontmatter(readFileSync(guidePath, 'utf8'));
+ if (frontmatter.source === undefined) continue;
+
let lastGroup = groups[groups.length - 1];
if (!lastGroup) {
lastGroup = { entries: [], title: currentTitle };
@@ -104,18 +106,23 @@ function readJson(path: string): unknown {
return JSON.parse(readFileSync(path, 'utf8'));
}
-function readFrontmatter(contents: string): { description?: string; title?: string } {
+function readFrontmatter(contents: string): {
+ description?: string;
+ source?: string;
+ title?: string;
+} {
const frontmatter = contents.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '';
return {
description: readFrontmatterValue(frontmatter, 'description'),
+ source: readFrontmatterValue(frontmatter, 'source'),
title: readFrontmatterValue(frontmatter, 'title'),
};
}
function readFrontmatterValue(
frontmatter: string,
- key: 'description' | 'title',
+ key: 'description' | 'source' | 'title',
): string | undefined {
const lines = frontmatter.split('\n');
const keyPrefix = `${key}:`;
@@ -146,7 +153,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(
const output = generateComponentsIndex();
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, output);
- const entryCount = readGroups().flatMap((group) => group.entries).length;
+ const entryCount = readGroups(componentsDir).flatMap((group) => group.entries).length;
// oxlint-disable-next-line no-console
console.log(`generate-components-index: wrote ${entryCount} entries`);
}
diff --git a/apps/docs/scripts/generate-props-pages.ts b/apps/docs/scripts/generate-props-pages.ts
index f25158f7..79cfc6b5 100644
--- a/apps/docs/scripts/generate-props-pages.ts
+++ b/apps/docs/scripts/generate-props-pages.ts
@@ -1,15 +1,30 @@
-import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ rmdirSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const componentsDir = resolve(scriptDir, '../content/docs/components');
-const META_JSON = `{
+/**
+ * Fumadocs joins `pagesIndex` to this folder. `../` is the sibling authored guide, which
+ * becomes the folder's clickable index.
+ */
+function renderMetaJson(componentName: string): string {
+ return `{
\t"pages": ["!props"],
-\t"collapsible": false
+\t"collapsible": false,
+\t"pagesIndex": "../${componentName}"
}
`;
+}
export interface PropsEntry {
heading?: string;
@@ -25,12 +40,13 @@ export interface ComponentFrontmatter {
/**
* Emits each component's generated Props page and `meta.json` from the `props` frontmatter
- * declared on its `index.mdx`. `remarkAutoTypeTable` resolves types during MDX compilation and
- * `TypeTable` carries shiki-highlighted `ReactNode` fields, neither of which survives a loader
- * boundary, so the Props page is generated MDX rather than rendered from frontmatter at runtime.
+ * declared on its `/.mdx` guide. `remarkAutoTypeTable` resolves types during MDX
+ * compilation and `TypeTable` carries shiki-highlighted `ReactNode` fields, neither of which
+ * survives a loader boundary, so the Props page is generated MDX rather than rendered from
+ * frontmatter at runtime.
*
- * Only writes files whose content changed, and removes generated output for components that no
- * longer declare `props`.
+ * Only writes files whose content changed, and removes generator-owned output for components that
+ * do not declare `props` or whose guide is missing.
*/
export function generatePropsPages(rootDir: string = componentsDir): {
componentCount: number;
@@ -39,28 +55,37 @@ export function generatePropsPages(rootDir: string = componentsDir): {
let componentCount = 0;
let removedCount = 0;
- for (const componentDir of findComponentDirs(rootDir)) {
- const indexPath = resolve(componentDir, 'index.mdx');
- const propsPath = resolve(componentDir, 'props.mdx');
- const metaPath = resolve(componentDir, 'meta.json');
-
- if (!existsSync(indexPath)) {
- if (removeIfExists(propsPath)) removedCount++;
- if (removeIfExists(metaPath)) removedCount++;
- continue;
- }
-
- const frontmatter = parseComponentFrontmatter(readFileSync(indexPath, 'utf8'));
-
- if (frontmatter.props.length === 0) {
- if (removeIfExists(propsPath)) removedCount++;
- if (removeIfExists(metaPath)) removedCount++;
- continue;
+ for (const group of readdirSync(rootDir, { withFileTypes: true })) {
+ if (!group.isDirectory()) continue;
+ const groupDir = resolve(rootDir, group.name);
+
+ for (const componentName of discoverComponentNames(groupDir)) {
+ const guidePath = resolve(groupDir, `${componentName}.mdx`);
+ const outputDir = resolve(groupDir, componentName);
+ const propsPath = resolve(outputDir, 'props.mdx');
+ const metaPath = resolve(outputDir, 'meta.json');
+
+ if (!existsSync(guidePath)) {
+ if (removeIfExists(propsPath)) removedCount++;
+ if (removeIfExists(metaPath)) removedCount++;
+ removeDirIfEmpty(outputDir);
+ continue;
+ }
+
+ const frontmatter = parseComponentFrontmatter(readFileSync(guidePath, 'utf8'));
+
+ if (frontmatter.props.length === 0) {
+ if (removeIfExists(propsPath)) removedCount++;
+ if (removeIfExists(metaPath)) removedCount++;
+ removeDirIfEmpty(outputDir);
+ continue;
+ }
+
+ componentCount++;
+ mkdirSync(outputDir, { recursive: true });
+ writeIfChanged(propsPath, renderPropsPage(frontmatter));
+ writeIfChanged(metaPath, renderMetaJson(componentName));
}
-
- componentCount++;
- writeIfChanged(propsPath, renderPropsPage(frontmatter));
- writeIfChanged(metaPath, META_JSON);
}
return { componentCount, removedCount };
@@ -96,7 +121,7 @@ function renderAutoTypeTable(entry: PropsEntry): string {
export function parseComponentFrontmatter(contents: string): ComponentFrontmatter {
const match = contents.match(/^---\n([\s\S]*?)\n---\n/);
- if (!match?.[1]) throw new Error('index.mdx is missing frontmatter');
+ if (!match?.[1]) throw new Error('guide is missing frontmatter');
const lines = match[1].split('\n');
const blocks = groupFrontmatterBlocks(lines);
@@ -173,23 +198,22 @@ function parsePropsEntries(lines: ReadonlyArray): ReadonlyArray {
- const dirs: Array = [];
-
- for (const group of readdirSync(directory, { withFileTypes: true })) {
- if (!group.isDirectory()) continue;
- const groupDir = resolve(directory, group.name);
-
- for (const component of readdirSync(groupDir, { withFileTypes: true })) {
- if (!component.isDirectory()) continue;
- // Every component directory, including one whose `index.mdx` has gone. Its generated
- // output has to be cleaned up, and skipping it here would leave an orphaned Props page
- // serving stale content from a route nobody can see in review, because it is gitignored.
- dirs.push(resolve(groupDir, component.name));
+/**
+ * Every component name in a group: the union of authored `*.mdx` guides and existing directories,
+ * so stale generated output is still discovered after a guide is deleted or renamed.
+ */
+function discoverComponentNames(groupDir: string): ReadonlyArray {
+ const names = new Set();
+
+ for (const entry of readdirSync(groupDir, { withFileTypes: true })) {
+ if (entry.isDirectory()) {
+ names.add(entry.name);
+ } else if (entry.name.endsWith('.mdx')) {
+ names.add(entry.name.slice(0, -'.mdx'.length));
}
}
- return dirs.sort();
+ return [...names].sort();
}
function writeIfChanged(filepath: string, content: string): boolean {
@@ -209,6 +233,13 @@ function removeIfExists(filepath: string): boolean {
return true;
}
+/** Removes the component output directory when it is empty. */
+function removeDirIfEmpty(dirPath: string): void {
+ if (!existsSync(dirPath)) return;
+ if (readdirSync(dirPath).length > 0) return;
+ rmdirSync(dirPath);
+}
+
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
mkdirSync(componentsDir, { recursive: true });
const { componentCount, removedCount } = generatePropsPages();
diff --git a/apps/docs/src/components/not-found.tsx b/apps/docs/src/components/not-found.tsx
index 62180cc8..acfead6f 100644
--- a/apps/docs/src/components/not-found.tsx
+++ b/apps/docs/src/components/not-found.tsx
@@ -17,7 +17,7 @@ export function NotFound() {
The page you are looking for might have been removed, had its name changed, or is
temporarily unavailable.
-
+
Back to Home
diff --git a/apps/docs/src/components/site-nav.browser.test.tsx b/apps/docs/src/components/site-nav.browser.test.tsx
index 85abcc3a..cb62cf11 100644
--- a/apps/docs/src/components/site-nav.browser.test.tsx
+++ b/apps/docs/src/components/site-nav.browser.test.tsx
@@ -52,14 +52,21 @@ test('marks the components destination active on a component page', async () =>
expect(getCurrentLinks()).toHaveLength(1);
});
-test('offers search and theme controls from the mobile bar', async () => {
- await page.viewport(390, 800);
- await renderAt('/', );
+test('marks the docs destination active on a docs page', async () => {
+ await page.viewport(1024, 800);
+ await renderAt('/docs/installation', );
await expect
.element(page.getByRole('link', { name: 'Docs' }))
.toHaveAttribute('aria-current', 'page');
expect(getCurrentLinks()).toHaveLength(1);
+});
+
+test('offers search and theme controls from the mobile bar with no destination active on the landing page', async () => {
+ await page.viewport(390, 800);
+ await renderAt('/', );
+
+ expect(getCurrentLinks()).toHaveLength(0);
const wordmark = page.getByRole('link', { name: 'Luke UI' }).element();
expect(wordmark.scrollWidth).toBeLessThanOrEqual(wordmark.clientWidth);
diff --git a/apps/docs/src/components/site-nav.tsx b/apps/docs/src/components/site-nav.tsx
index c97c37ea..638fd6dc 100644
--- a/apps/docs/src/components/site-nav.tsx
+++ b/apps/docs/src/components/site-nav.tsx
@@ -89,8 +89,7 @@ function SiteWordmark() {
const linkProps = useLinkProps({
activeProps: {},
className: 'flex h-14 shrink-0 items-center truncate font-semibold text-sm',
- params: { _splat: '' },
- to: '/$',
+ to: '/',
});
return (
diff --git a/apps/docs/src/lib/component-doc-contract.test.ts b/apps/docs/src/lib/component-doc-contract.test.ts
index 639935ef..2da43918 100644
--- a/apps/docs/src/lib/component-doc-contract.test.ts
+++ b/apps/docs/src/lib/component-doc-contract.test.ts
@@ -18,6 +18,7 @@ test('accepts a focused component-doc scaffold without placeholder prose', () =>
const docsDir = createDocsFixture({
guide: `---
title: Status Badge
+source: packages/@luke-ui/react/src/status-badge
---
@@ -32,6 +33,7 @@ test('reports placeholders and the wrong primary example', () => {
guide: `---
title: Status Badge
description: Status Badge component.
+source: packages/@luke-ui/react/src/status-badge
---
\`StatusBadge\` from \`@luke-ui/react/status-badge\`.
@@ -43,11 +45,11 @@ TODO: Describe accessibility considerations.
});
expect(findComponentDocContractIssues({ docsDir })).toEqual([
- 'status-badge/index.mdx: replace the generic component description',
- 'status-badge/index.mdx: remove the generator TODO',
- 'status-badge/index.mdx: remove the accessibility placeholder',
- 'status-badge/index.mdx: remove the package-path placeholder',
- 'status-badge/index.mdx: primary example must use status-badge/basic',
+ 'actions/status-badge.mdx: replace the generic component description',
+ 'actions/status-badge.mdx: remove the generator TODO',
+ 'actions/status-badge.mdx: remove the accessibility placeholder',
+ 'actions/status-badge.mdx: remove the package-path placeholder',
+ 'actions/status-badge.mdx: primary example must use status-badge/basic',
]);
});
@@ -64,9 +66,9 @@ function createDocsFixture({ guide }: { guide: string }): string {
testDirectories.push(directory);
const docsDir = join(directory, 'components');
- const componentDir = join(docsDir, 'status-badge');
- mkdirSync(componentDir, { recursive: true });
- writeFileSync(join(componentDir, 'index.mdx'), guide);
+ const groupDir = join(docsDir, 'actions');
+ mkdirSync(groupDir, { recursive: true });
+ writeFileSync(join(groupDir, 'status-badge.mdx'), guide);
return docsDir;
}
diff --git a/apps/docs/src/lib/component-doc-contract.ts b/apps/docs/src/lib/component-doc-contract.ts
index 41d49167..ed59b232 100644
--- a/apps/docs/src/lib/component-doc-contract.ts
+++ b/apps/docs/src/lib/component-doc-contract.ts
@@ -7,6 +7,7 @@ interface ComponentDocContractOptions {
interface Frontmatter {
description?: string;
+ source?: string;
title?: string;
}
@@ -26,29 +27,26 @@ export function findComponentDocContractIssues({
const resolvedDocsDir = resolve(docsDir);
const issues: Array = [];
- for (const guidePath of findFiles(resolvedDocsDir, 'index.mdx')) {
- const componentDir = resolve(guidePath, '..');
- const relativeComponentDir = relative(resolvedDocsDir, componentDir);
-
- // Skip the components-index listing page itself, which lives directly in docsDir rather
- // than in a per-component directory.
- if (relativeComponentDir === '') continue;
-
+ for (const guidePath of findComponentGuides(resolvedDocsDir)) {
+ const relativeGuidePath = relative(resolvedDocsDir, guidePath);
const guide = readFileSync(guidePath, 'utf8');
const guideFrontmatter = readFrontmatter(guide);
- findPlaceholders(issues, `${relativeComponentDir}/index.mdx`, guide, guideFrontmatter);
+ // Only a component guide declares a package `source:`.
+ if (guideFrontmatter.source === undefined) continue;
- const componentName = basename(componentDir);
+ findPlaceholders(issues, relativeGuidePath, guide, guideFrontmatter);
+
+ const componentName = basename(guidePath, '.mdx');
const expectedExamples = [`${componentName}/basic`];
- if (relativeComponentDir.startsWith('primitives/')) {
+ if (relativeGuidePath.startsWith('primitives/')) {
expectedExamples.push(`${componentName}-primitive/basic`);
}
const primaryExample = guide.match(/ {
- const files: Array = [];
-
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
- const path = resolve(directory, entry.name);
- if (entry.isDirectory()) {
- files.push(...findFiles(path, fileName));
- continue;
+/**
+ * Discovers authored `*.mdx` files directly inside each group directory. Generated Props pages
+ * live one level deeper as `//props.mdx`, so they stay out. The caller skips files
+ * that do not declare `source:`.
+ */
+function findComponentGuides(directory: string): Array {
+ const guides: Array = [];
+
+ for (const group of readdirSync(directory, { withFileTypes: true })) {
+ if (!group.isDirectory()) continue;
+ const groupDir = resolve(directory, group.name);
+
+ for (const entry of readdirSync(groupDir, { withFileTypes: true })) {
+ if (!entry.isFile() || !entry.name.endsWith('.mdx')) continue;
+ guides.push(resolve(groupDir, entry.name));
}
- if (entry.name === fileName) files.push(path);
}
- return files.sort();
+ return guides.sort();
}
diff --git a/apps/docs/src/lib/component-page-navigation.test.ts b/apps/docs/src/lib/component-page-navigation.test.ts
index 60f96629..05594e28 100644
--- a/apps/docs/src/lib/component-page-navigation.test.ts
+++ b/apps/docs/src/lib/component-page-navigation.test.ts
@@ -18,7 +18,7 @@ test('marks Props as current on a component props page', () => {
});
test('does not add component navigation to other docs pages', () => {
- expect(getComponentPageNavigation('/installation')).toBeNull();
+ expect(getComponentPageNavigation('/docs/installation')).toBeNull();
expect(getComponentPageNavigation('/components/actions')).toBeNull();
expect(getComponentPageNavigation('/components/actions/button/examples')).toBeNull();
});
diff --git a/apps/docs/src/lib/component-page-tree.test.ts b/apps/docs/src/lib/component-page-tree.test.ts
new file mode 100644
index 00000000..349ed882
--- /dev/null
+++ b/apps/docs/src/lib/component-page-tree.test.ts
@@ -0,0 +1,79 @@
+import type { Item, Node, Root } from 'fumadocs-core/page-tree';
+import { loader } from 'fumadocs-core/source';
+import { expect, test } from 'vite-plus/test';
+import { docs } from '../../.source/server.js';
+
+const source = loader({
+ baseUrl: '/',
+ source: docs.toFumadocsSource(),
+});
+
+/** `Components` is a root folder, so Fumadocs puts it on `tree.fallback`, not `tree.children`. */
+function allNodes(tree: Root): ReadonlyArray {
+ return tree.fallback ? [...tree.children, ...tree.fallback.children] : tree.children;
+}
+
+/** Find a clickable node by URL. Display names repeat across groups (Actions Button vs Primitives Button). */
+function findClickableNode(nodes: ReadonlyArray, url: string): Item | undefined {
+ for (const node of nodes) {
+ if (node.type === 'page' && node.url === url) return node;
+ if (node.type === 'folder') {
+ if (node.index?.url === url) return node.index;
+ const found = findClickableNode(node.children, url);
+ if (found !== undefined) return found;
+ }
+ }
+ return undefined;
+}
+
+function findAnyNodeWithUrl(nodes: ReadonlyArray, url: string): Node | undefined {
+ for (const node of nodes) {
+ if (node.type === 'page' && node.url === url) return node;
+ if (node.type === 'folder') {
+ if (node.index?.url === url) return node;
+ const found = findAnyNodeWithUrl(node.children, url);
+ if (found !== undefined) return found;
+ }
+ }
+ return undefined;
+}
+
+test('guides live under /docs, not the docs content root', () => {
+ const tree = source.getPageTree();
+ const nodes = allNodes(tree);
+
+ expect(findClickableNode(nodes, '/docs/installation')?.url).toBe('/docs/installation');
+ expect(findAnyNodeWithUrl(nodes, '/installation')).toBeUndefined();
+ expect(source.getPage(['docs', 'installation'])).toBeDefined();
+ expect(source.getPage(['installation'])).toBeUndefined();
+});
+
+const components: ReadonlyArray<{ group: string; name: string }> = [
+ { group: 'actions', name: 'button' },
+ { group: 'actions', name: 'icon-button' },
+ { group: 'forms', name: 'text-field' },
+ { group: 'layout', name: 'box' },
+ { group: 'typography', name: 'heading' },
+ { group: 'primitives', name: 'visually-hidden' },
+];
+
+for (const { group, name } of components) {
+ test(`the sidebar entry for /components/${group}/${name} is clickable and hides its Props page`, () => {
+ const tree = source.getPageTree();
+ const nodes = allNodes(tree);
+ const guideUrl = `/components/${group}/${name}`;
+ const propsUrl = `${guideUrl}/props`;
+
+ const clickable = findClickableNode(nodes, guideUrl);
+ expect(clickable, `expected a clickable node for ${guideUrl}`).toBeDefined();
+ expect(clickable?.url).toBe(guideUrl);
+
+ expect(
+ findAnyNodeWithUrl(nodes, propsUrl),
+ `${propsUrl} should not appear in the page tree`,
+ ).toBeUndefined();
+
+ expect(source.getPage(['components', group, name])).toBeDefined();
+ expect(source.getPage(['components', group, name, 'props'])).toBeDefined();
+ });
+}
diff --git a/apps/docs/src/lib/components-index-generator.test.ts b/apps/docs/src/lib/components-index-generator.test.ts
index 10548767..9ca6ba53 100644
--- a/apps/docs/src/lib/components-index-generator.test.ts
+++ b/apps/docs/src/lib/components-index-generator.test.ts
@@ -1,4 +1,5 @@
-import { readFileSync } from 'node:fs';
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { expect, test } from 'vite-plus/test';
import { generateComponentsIndex } from '../../scripts/generate-components-index.js';
@@ -43,3 +44,48 @@ test('emits the generated file on disk that the docs app imports', () => {
const emittedPath = resolve(import.meta.dirname, '../generated/components-index.generated.ts');
expect(readFileSync(emittedPath, 'utf8')).toBe(emitted);
});
+
+test('excludes a topical page with no source frontmatter from the index', () => {
+ const scratchDir = mkdtempSync(resolve(tmpdir(), 'components-index-'));
+
+ try {
+ writeFileSync(
+ resolve(scratchDir, 'meta.json'),
+ JSON.stringify({ pages: ['actions/button', 'forms/topic'] }),
+ );
+
+ mkdirSync(resolve(scratchDir, 'actions'), { recursive: true });
+ writeFileSync(
+ resolve(scratchDir, 'actions/button.mdx'),
+ `---
+title: Button
+description: A labelled control for actions in an interface.
+source: packages/example/src/button
+---
+
+Body.
+`,
+ );
+
+ mkdirSync(resolve(scratchDir, 'forms'), { recursive: true });
+ writeFileSync(
+ resolve(scratchDir, 'forms/topic.mdx'),
+ `---
+title: Topic
+description: A topical page with no source.
+---
+
+Body.
+`,
+ );
+
+ const generated = generateComponentsIndex(scratchDir);
+
+ expect(generated).toContain("url: '/components/actions/button'");
+ expect(generated).toContain("name: 'Button'");
+ expect(generated).not.toContain('/components/forms/topic');
+ expect(generated).not.toContain("name: 'Topic'");
+ } finally {
+ rmSync(scratchDir, { recursive: true, force: true });
+ }
+});
diff --git a/apps/docs/src/lib/docs-links.test.ts b/apps/docs/src/lib/docs-links.test.ts
index cc6ad06a..26067e7e 100644
--- a/apps/docs/src/lib/docs-links.test.ts
+++ b/apps/docs/src/lib/docs-links.test.ts
@@ -12,10 +12,6 @@ function findAllMdxFiles(directory: string): Array {
});
}
-function docsPagePath(slug: string): string {
- return resolve(contentDir, `${slug}.mdx`);
-}
-
function docsLinks(contents: string): Array {
const links: Array = [];
@@ -40,7 +36,8 @@ function docsLinks(contents: string): Array {
function docsLinkTargetExists(pathname: string): boolean {
const segments = pathname.split('/').filter((segment) => segment !== '');
- if (segments.length === 0) return existsSync(docsPagePath('index'));
+ // `/` is the landing route, not an MDX page — no docs page links to it.
+ if (segments.length === 0) return false;
const base = resolve(contentDir, ...segments);
return (
diff --git a/apps/docs/src/lib/generate-props-pages.test.ts b/apps/docs/src/lib/generate-props-pages.test.ts
index 41a989bf..38ed7991 100644
--- a/apps/docs/src/lib/generate-props-pages.test.ts
+++ b/apps/docs/src/lib/generate-props-pages.test.ts
@@ -4,6 +4,7 @@ import {
mkdtempSync,
readdirSync,
readFileSync,
+ renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
@@ -83,27 +84,33 @@ source: packages/@luke-ui/react/src/field/primitive
`);
});
-test('every component index.mdx declaring props has a generated Props page on disk', () => {
+test('every component guide declaring props has a generated Props page on disk', () => {
for (const group of readdirSync(componentsDir, { withFileTypes: true })) {
if (!group.isDirectory()) continue;
const groupDir = resolve(componentsDir, group.name);
- for (const component of readdirSync(groupDir, { withFileTypes: true })) {
- if (!component.isDirectory()) continue;
- const componentDir = resolve(groupDir, component.name);
- const indexPath = resolve(componentDir, 'index.mdx');
- if (!existsSync(indexPath)) continue;
+ for (const entry of readdirSync(groupDir, { withFileTypes: true })) {
+ if (!entry.isFile() || !entry.name.endsWith('.mdx')) continue;
+ const componentName = entry.name.slice(0, -'.mdx'.length);
- const frontmatter = parseComponentFrontmatter(readFileSync(indexPath, 'utf8'));
+ const frontmatter = parseComponentFrontmatter(
+ readFileSync(resolve(groupDir, entry.name), 'utf8'),
+ );
if (frontmatter.props.length === 0) continue;
- const relativeComponentDir = `${group.name}/${component.name}`;
- const propsPath = resolve(componentDir, 'props.mdx');
- const metaPath = resolve(componentDir, 'meta.json');
+ const outputDir = resolve(groupDir, componentName);
+ const relativeOutputDir = `${group.name}/${componentName}`;
+ const propsPath = resolve(outputDir, 'props.mdx');
+ const metaPath = resolve(outputDir, 'meta.json');
- expect(existsSync(propsPath), `${relativeComponentDir}/props.mdx should exist`).toBe(true);
- expect(existsSync(metaPath), `${relativeComponentDir}/meta.json should exist`).toBe(true);
+ expect(existsSync(propsPath), `${relativeOutputDir}/props.mdx should exist`).toBe(true);
+ expect(existsSync(metaPath), `${relativeOutputDir}/meta.json should exist`).toBe(true);
expect(readFileSync(propsPath, 'utf8')).toBe(renderPropsPage(frontmatter));
+ expect(JSON.parse(readFileSync(metaPath, 'utf8'))).toEqual({
+ pages: ['!props'],
+ collapsible: false,
+ pagesIndex: `../${componentName}`,
+ });
}
}
});
@@ -115,23 +122,32 @@ afterEach(() => {
scratchDir = undefined;
});
-function writeScratchComponent(indexContents: string): {
+function writeScratchGuide(
+ name: string,
+ guideContents: string,
+): {
+ guidePath: string;
metaPath: string;
+ outputDir: string;
propsPath: string;
rootDir: string;
} {
- scratchDir = mkdtempSync(resolve(tmpdir(), 'luke-ui-props-'));
- const componentDir = resolve(scratchDir, 'actions/button');
- mkdirSync(componentDir, { recursive: true });
- writeFileSync(resolve(componentDir, 'index.mdx'), indexContents);
+ scratchDir ??= mkdtempSync(resolve(tmpdir(), 'luke-ui-props-'));
+ const groupDir = resolve(scratchDir, 'actions');
+ mkdirSync(groupDir, { recursive: true });
+ const guidePath = resolve(groupDir, `${name}.mdx`);
+ writeFileSync(guidePath, guideContents);
+ const outputDir = resolve(groupDir, name);
return {
- metaPath: resolve(componentDir, 'meta.json'),
- propsPath: resolve(componentDir, 'props.mdx'),
+ guidePath,
+ metaPath: resolve(outputDir, 'meta.json'),
+ outputDir,
+ propsPath: resolve(outputDir, 'props.mdx'),
rootDir: scratchDir,
};
}
-const SCRATCH_INDEX = `---
+const SCRATCH_GUIDE = `---
title: Button
props:
- name: ButtonProps
@@ -141,18 +157,65 @@ props:
Guide body.
`;
-test('removes generated output for a component whose index.mdx has gone', () => {
- const { metaPath, propsPath, rootDir } = writeScratchComponent(SCRATCH_INDEX);
+test('generates props.mdx and meta.json under // from the /.mdx guide', () => {
+ const { metaPath, outputDir, propsPath, rootDir } = writeScratchGuide('button', SCRATCH_GUIDE);
expect(generatePropsPages(rootDir)).toEqual({ componentCount: 1, removedCount: 0 });
expect(existsSync(propsPath)).toBe(true);
expect(existsSync(metaPath)).toBe(true);
+ expect(resolve(propsPath, '..')).toBe(outputDir);
+ expect(JSON.parse(readFileSync(metaPath, 'utf8'))).toEqual({
+ pages: ['!props'],
+ collapsible: false,
+ pagesIndex: '../button',
+ });
+});
+
+test('removes generated output and the empty output directory once the guide is removed', () => {
+ const { guidePath, metaPath, outputDir, propsPath, rootDir } = writeScratchGuide(
+ 'button',
+ SCRATCH_GUIDE,
+ );
+
+ expect(generatePropsPages(rootDir)).toEqual({ componentCount: 1, removedCount: 0 });
+ expect(existsSync(outputDir)).toBe(true);
// Deleting or renaming the guide must not leave an orphaned Props page behind. It would keep
// serving stale content from a route that is gitignored, so review would never surface it.
- rmSync(resolve(rootDir, 'actions/button/index.mdx'));
+ rmSync(guidePath);
expect(generatePropsPages(rootDir)).toEqual({ componentCount: 0, removedCount: 2 });
expect(existsSync(propsPath)).toBe(false);
expect(existsSync(metaPath)).toBe(false);
+ expect(existsSync(outputDir)).toBe(false);
+});
+
+test('removes generated output for the old name once a guide is renamed', () => {
+ const { guidePath, metaPath, outputDir, propsPath, rootDir } = writeScratchGuide(
+ 'button',
+ SCRATCH_GUIDE,
+ );
+
+ expect(generatePropsPages(rootDir)).toEqual({ componentCount: 1, removedCount: 0 });
+
+ renameSync(guidePath, resolve(rootDir, 'actions/icon-button.mdx'));
+
+ expect(generatePropsPages(rootDir)).toEqual({ componentCount: 1, removedCount: 2 });
+ expect(existsSync(propsPath)).toBe(false);
+ expect(existsSync(metaPath)).toBe(false);
+ expect(existsSync(outputDir)).toBe(false);
+ expect(existsSync(resolve(rootDir, 'actions/icon-button/props.mdx'))).toBe(true);
+});
+
+test('leaves the output directory in place when it still holds files the generator does not own', () => {
+ const { outputDir, rootDir } = writeScratchGuide('button', SCRATCH_GUIDE);
+
+ expect(generatePropsPages(rootDir)).toEqual({ componentCount: 1, removedCount: 0 });
+
+ writeFileSync(resolve(outputDir, 'notes.txt'), 'kept by hand');
+ rmSync(resolve(rootDir, 'actions/button.mdx'));
+
+ expect(generatePropsPages(rootDir)).toEqual({ componentCount: 0, removedCount: 2 });
+ expect(existsSync(outputDir)).toBe(true);
+ expect(existsSync(resolve(outputDir, 'notes.txt'))).toBe(true);
});
diff --git a/apps/docs/src/lib/markdown-page-path.test.ts b/apps/docs/src/lib/markdown-page-path.test.ts
index f76583a8..81dea9ab 100644
--- a/apps/docs/src/lib/markdown-page-path.test.ts
+++ b/apps/docs/src/lib/markdown-page-path.test.ts
@@ -1,8 +1,8 @@
import { expect, test } from 'vite-plus/test';
import { getMarkdownPagePath } from './markdown-page-path.js';
-test('maps a component folder index to the guide Markdown URL', () => {
- expect(getMarkdownPagePath('components/actions/button/index.mdx')).toBe(
+test('maps a component guide to the guide Markdown URL', () => {
+ expect(getMarkdownPagePath('components/actions/button.mdx')).toBe(
'/components/actions/button.md',
);
});
@@ -12,3 +12,11 @@ test('keeps the props segment in the props Markdown URL', () => {
'/components/actions/button/props.md',
);
});
+
+test('collapses a genuine index page to its parent Markdown URL', () => {
+ expect(getMarkdownPagePath('components/index.mdx')).toBe('/components.md');
+});
+
+test('maps a guide to its nested Markdown URL', () => {
+ expect(getMarkdownPagePath('docs/installation.mdx')).toBe('/docs/installation.md');
+});
diff --git a/apps/docs/src/lib/site-destinations.test.ts b/apps/docs/src/lib/site-destinations.test.ts
index 5695ac13..801d58d8 100644
--- a/apps/docs/src/lib/site-destinations.test.ts
+++ b/apps/docs/src/lib/site-destinations.test.ts
@@ -1,23 +1,30 @@
import { expect, test } from 'vite-plus/test';
import { getActiveSiteDestination } from './site-destinations.js';
-test('the docs destination covers the routes no other destination claims', () => {
- expect(getActiveSiteDestination('/')?.label).toBe('Docs');
- expect(getActiveSiteDestination('/theming')?.label).toBe('Docs');
+test('the landing page has no active destination', () => {
+ expect(getActiveSiteDestination('/')).toBeUndefined();
});
-test('the components destination wins over the docs root it sits under', () => {
+test('the docs destination covers /docs and its nested pages', () => {
+ expect(getActiveSiteDestination('/docs')?.label).toBe('Docs');
+ expect(getActiveSiteDestination('/docs/installation')?.label).toBe('Docs');
+ expect(getActiveSiteDestination('/docs/theming')?.label).toBe('Docs');
+});
+
+test('the components destination wins over pages nested under it', () => {
expect(getActiveSiteDestination('/components')?.label).toBe('Components');
expect(getActiveSiteDestination('/components/actions/button')?.label).toBe('Components');
expect(getActiveSiteDestination('/components/primitives/field/props')?.label).toBe('Components');
});
-test('the playground destination wins over the docs root it sits under', () => {
+test('the playground destination covers its nested pages', () => {
expect(getActiveSiteDestination('/playground')?.label).toBe('Playground');
expect(getActiveSiteDestination('/playground/preview')?.label).toBe('Playground');
});
test('a route that only shares a name prefix with a destination is not active', () => {
- expect(getActiveSiteDestination('/playgrounds')?.label).toBe('Docs');
- expect(getActiveSiteDestination('/component')?.label).toBe('Docs');
+ expect(getActiveSiteDestination('/playgrounds')).toBeUndefined();
+ expect(getActiveSiteDestination('/component')).toBeUndefined();
+ expect(getActiveSiteDestination('/docsify')).toBeUndefined();
+ expect(getActiveSiteDestination('/documentation')).toBeUndefined();
});
diff --git a/apps/docs/src/lib/site-destinations.ts b/apps/docs/src/lib/site-destinations.ts
index 0053ce3f..148afca2 100644
--- a/apps/docs/src/lib/site-destinations.ts
+++ b/apps/docs/src/lib/site-destinations.ts
@@ -8,7 +8,7 @@ export interface SiteDestination {
}
export const siteDestinations: ReadonlyArray = [
- { activePath: '/', label: 'Docs', url: '/' },
+ { activePath: '/docs', label: 'Docs', url: '/docs/installation' },
{ activePath: '/components', label: 'Components', url: '/components' },
{ activePath: '/playground', label: 'Playground', url: '/playground' },
{
@@ -33,6 +33,5 @@ export function getActiveSiteDestination(pathname: string): SiteDestination | un
}
function isAtOrBelow(pathname: string, activePath: string): boolean {
- if (activePath === '/') return true;
return pathname === activePath || pathname.startsWith(`${activePath}/`);
}
diff --git a/apps/docs/src/lib/storybook.test.ts b/apps/docs/src/lib/storybook.test.ts
index 42a60141..dd6c680d 100644
--- a/apps/docs/src/lib/storybook.test.ts
+++ b/apps/docs/src/lib/storybook.test.ts
@@ -1,12 +1,26 @@
import { expect, test } from 'vite-plus/test';
import { getStorybookStoryUrl } from './storybook.js';
-test('links component folder indexes to their Storybook docs', () => {
- expect(getStorybookStoryUrl('components/actions/icon-button/index.mdx', '/')).toBe(
- 'http://localhost:6006/?path=/docs/actions-iconbutton--docs',
- );
+test('links component guides to their Storybook docs', () => {
+ expect(
+ getStorybookStoryUrl(
+ 'components/actions/icon-button.mdx',
+ '/',
+ 'packages/@luke-ui/react/src/icon-button',
+ ),
+ ).toBe('http://localhost:6006/?path=/docs/actions-iconbutton--docs');
+});
+
+test('does not link component-shaped pages with no source frontmatter', () => {
+ expect(getStorybookStoryUrl('components/forms/topic.mdx', '/')).toBeNull();
});
test('does not link component props pages to Storybook', () => {
- expect(getStorybookStoryUrl('components/actions/icon-button/props.mdx', '/')).toBeNull();
+ expect(
+ getStorybookStoryUrl(
+ 'components/actions/icon-button/props.mdx',
+ '/',
+ 'packages/@luke-ui/react/src/icon-button',
+ ),
+ ).toBeNull();
});
diff --git a/apps/docs/src/lib/storybook.ts b/apps/docs/src/lib/storybook.ts
index a0e6e3c8..286f3183 100644
--- a/apps/docs/src/lib/storybook.ts
+++ b/apps/docs/src/lib/storybook.ts
@@ -2,14 +2,19 @@ import { withBasePath } from './base-path.js';
const STORYBOOK_DEV_URL = 'http://localhost:6006';
-const COMPONENT_DOC_PATH = /^components\/([^/]+)\/([^/]+)\/index\.mdx$/;
+const COMPONENT_DOC_PATH = /^components\/([^/]+)\/([^/]+)\.mdx$/;
export function getStorybookBaseUrl(basePath: string): string {
if (import.meta.env.DEV) return STORYBOOK_DEV_URL;
return withBasePath('/storybook', basePath).replace(/\/$/, '');
}
-export function getStorybookStoryUrl(pagePath: string, basePath: string): string | null {
+export function getStorybookStoryUrl(
+ pagePath: string,
+ basePath: string,
+ source?: string | null,
+): string | null {
+ if (!source) return null;
const match = COMPONENT_DOC_PATH.exec(pagePath);
if (!match?.[1] || !match[2]) return null;
const [, category, name] = match;
diff --git a/apps/docs/src/lib/theming-docs.test.ts b/apps/docs/src/lib/theming-docs.test.ts
index 68eafeb5..2db06cce 100644
--- a/apps/docs/src/lib/theming-docs.test.ts
+++ b/apps/docs/src/lib/theming-docs.test.ts
@@ -2,7 +2,8 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { extname, resolve } from 'node:path';
import { expect, test } from 'vite-plus/test';
-const contentDir = resolve(import.meta.dirname, '../../content/docs');
+const contentDir = resolve(import.meta.dirname, '../../content/docs/docs');
+const allDocsContentDir = resolve(import.meta.dirname, '../../content/docs');
const examplesDir = resolve(import.meta.dirname, '../examples');
const SETTLED_THEMING_PAGES = [
@@ -89,7 +90,7 @@ test('no rendered example applies a theme identity class', () => {
'getThemeClassName',
];
- for (const file of findAllMdxFiles(contentDir)) {
+ for (const file of findAllMdxFiles(allDocsContentDir)) {
const contents = readFileSync(file, 'utf8');
const examplePattern = /]*\bsrc\s*=\s*["']([^"']+)["']/g;
diff --git a/apps/docs/src/routes/$.tsx b/apps/docs/src/routes/$.tsx
index 021b698b..cfa97aba 100644
--- a/apps/docs/src/routes/$.tsx
+++ b/apps/docs/src/routes/$.tsx
@@ -55,9 +55,9 @@ const loader = createServerFn({
const markdownPath = `${page.url === '/' ? '/index' : page.url}.md`;
const rawComponentNavigation = getComponentPageNavigation(page.url);
// A guide-depth URL only gets Guide/Props tabs when a props page actually
- // exists alongside it — a standalone guide page (e.g. a shared Validation
- // page with no API surface of its own) sits at the same URL depth but has
- // no props.mdx sibling, so showing the tab would link to a 404.
+ // exists alongside it — a standalone guide page with no API surface of its
+ // own sits at the same URL depth but has no props.mdx sibling, so showing
+ // the tab would link to a 404.
const hasPropsPage =
rawComponentNavigation?.current === 'props' || source.getPage([...slugs, 'props']) != null;
const componentNavigation = hasPropsPage ? rawComponentNavigation : null;
@@ -76,7 +76,7 @@ const loader = createServerFn({
path: page.path,
reactAriaUrl: page.data.reactAria ?? null,
sourceUrl: page.data.source ? `${GITHUB_TREE_URL}/${page.data.source}` : null,
- storybookUrl: getStorybookStoryUrl(page.path, import.meta.env.BASE_URL),
+ storybookUrl: getStorybookStoryUrl(page.path, import.meta.env.BASE_URL, page.data.source),
};
});
diff --git a/apps/docs/src/routes/index.tsx b/apps/docs/src/routes/index.tsx
new file mode 100644
index 00000000..66b470fa
--- /dev/null
+++ b/apps/docs/src/routes/index.tsx
@@ -0,0 +1,37 @@
+import { Heading } from '@luke-ui/react/heading';
+import { Text } from '@luke-ui/react/text';
+import { createFileRoute } from '@tanstack/react-router';
+import Link from 'fumadocs-core/link';
+import { SiteNav } from '../components/site-nav.js';
+
+export const Route = createFileRoute('/')({
+ component: Home,
+ head: () => ({
+ meta: [{ title: 'Luke UI' }],
+ }),
+});
+
+function Home() {
+ return (
+ <>
+
+
+
+ Introduction
+
+
+ Components, themes, and layout utilities for React applications.
+
+
+ Luke UI is a React design system and component library. It ships static CSS, two bundled
+ themes, and layout utilities that share a semantic token contract.
+
+
+ Read Getting started to install Luke UI, load a
+ theme, and render your first component. Browse Components{' '}
+ for the full catalogue.
+
+
+ >
+ );
+}
diff --git a/apps/docs/vitest.config.ts b/apps/docs/vitest.config.ts
index b0700bd9..c6bcd761 100644
--- a/apps/docs/vitest.config.ts
+++ b/apps/docs/vitest.config.ts
@@ -1,9 +1,11 @@
import tailwindcss from '@tailwindcss/vite';
+import mdx from 'fumadocs-mdx/vite';
import { defineConfig } from 'vite-plus';
import { playwright } from 'vite-plus/test/browser-playwright';
+import * as sourceConfig from './source.config.js';
export default defineConfig({
- plugins: [tailwindcss()],
+ plugins: [tailwindcss(), mdx(sourceConfig)],
optimizeDeps: {
include: [
'next-themes',
diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index aff495d8..41fe8721 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -70,5 +70,5 @@ regenerate the spritesheet and the `iconNames` union:
pnpm --dir packages/@luke-ui/react run generate:icons
```
-The generated `iconNames` export drives the docs gallery at `/overview/iconography`, so a new icon
+The generated `iconNames` export drives the docs gallery at `/docs/iconography`, so a new icon
appears there with no further changes.
diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md
index ec695a50..acbc4d72 100644
--- a/docs/DOCUMENTATION.md
+++ b/docs/DOCUMENTATION.md
@@ -5,7 +5,8 @@ Use this guide when you write or move Luke UI documentation.
## Primary docs surface
The hosted docs app in `apps/docs` is the primary docs surface for app developers and library
-authors. Component prose lives in `apps/docs/content/docs/**/*.mdx`.
+authors. Authored guides live under `apps/docs/content/docs/docs/`, at `/docs/`. Component
+guides live under `apps/docs/content/docs/components/`, at `/components//`.
Do not add generated package docs or `*.docs.md` files under `packages/@luke-ui/react/src/`.
@@ -13,7 +14,8 @@ The package README links to the hosted docs. Fumadocs provides:
- `/llms.txt` for the component index.
- `/llms-full.txt` for full docs.
-- Per-page Markdown by appending `.md` to a docs URL.
+- Per-page Markdown by appending `.md` to a docs URL, for example `/docs/installation.md` or
+ `/components/actions/button.md`.
## Writing style
@@ -90,12 +92,13 @@ Do not document exports that are not public API.
## MDX page structure
-Each component has a folder named for its URL slug:
+`/.mdx` is the authored component guide. It keeps the `/components//` URL.
-- `index.mdx` is the component guide and keeps the `/components//` URL.
-- `props.mdx` is the API reference at `/components///props`.
-- `meta.json` uses `"pages": ["!props"]` and `"collapsible": false` so the component stays one
- ordinary sidebar link without exposing the props page in the tree.
+`//props.mdx` and `//meta.json` are generated from the guide's `props`
+frontmatter by `scripts/generate-props-pages.ts`. `props.mdx` is the API reference at
+`/components///props`. `meta.json` uses `"pages": ["!props"]` and
+`"collapsible": false` so the component stays one ordinary sidebar link. It sets
+`"pagesIndex": "../"` so the folder's sidebar entry points to the sibling guide.
Keep the guide and Props frontmatter titles and descriptions identical. The component generator
leaves editorial descriptions out instead of adding placeholder copy. Add one useful description to
@@ -147,10 +150,13 @@ that would be less clear inside a complete example.
## Site chrome
Every surface shares one top nav, `SiteNav` in `apps/docs/src/components/site-nav.tsx`. It carries
-the wordmark, the primary destinations, search, and the appearance controls. The destination list
-and its active-route matching live in `apps/docs/src/lib/site-destinations.ts`, so the nav and the
-docs layout navigate to the same places. Appearance controls belong to the nav on every surface, not
-to the docs sidebar footer.
+the wordmark, the primary destinations, search, and the appearance controls. The wordmark links to
+`/`, the landing page. Docs opens `/docs/installation`. Components opens `/components`. The
+destination list and its active-route matching live in `apps/docs/src/lib/site-destinations.ts`, so
+the nav and the docs layout navigate to the same places. Appearance controls belong to the nav on
+every surface, not to the docs sidebar footer.
+
+The landing page at `/` renders `SiteNav` with no docs sidebar. It has no active destination.
The docs routes use Fumadocs' notebook layout with `nav.mode: 'top'`, which spans the header across
the full width and starts the sidebar beneath it. `apps/docs/src/lib/layout.shared.tsx` supplies the
@@ -182,7 +188,7 @@ subpath. The import map and editor types are generated by `apps/docs/scripts/`
## API reference
-Hosted component guide files contain prose and example blocks. Their sibling `props.mdx` files
+Hosted component guide files contain prose and example blocks. Their generated `props.mdx` files
contain `` API references generated from TypeScript types. Hidden props pages stay
in the docs source collection so direct routes, search, and full-text docs exports can include them.
diff --git a/packages/@luke-ui/react/README.md b/packages/@luke-ui/react/README.md
index e4a89053..d1b47529 100644
--- a/packages/@luke-ui/react/README.md
+++ b/packages/@luke-ui/react/README.md
@@ -34,7 +34,7 @@ theme wins. Import it from that theme's own entrypoint, for example
## Components and docs
Full component documentation, interactive examples, and API reference are at
-[lukebennett88.github.io/luke-ui/docs](https://lukebennett88.github.io/luke-ui/docs).
+[lukebennett88.github.io/luke-ui](https://lukebennett88.github.io/luke-ui).
AI agents can fetch documentation at:
diff --git a/packages/turbo-generators/src/component-creation-plan.test.ts b/packages/turbo-generators/src/component-creation-plan.test.ts
index 422350f2..404c16a4 100644
--- a/packages/turbo-generators/src/component-creation-plan.test.ts
+++ b/packages/turbo-generators/src/component-creation-plan.test.ts
@@ -21,7 +21,7 @@ describe('createComponentPlan', () => {
packageExportPath: './status-badge',
});
expect(plan.files.map((file) => file.path).sort()).toEqual([
- 'apps/docs/content/docs/components/feedback/status-badge/index.mdx',
+ 'apps/docs/content/docs/components/feedback/status-badge.mdx',
'apps/docs/src/examples/status-badge/basic.tsx',
'packages/@luke-ui/react/src/recipes/status-badge.css.ts',
'packages/@luke-ui/react/src/status-badge/component-test-registration.ts',
@@ -31,7 +31,7 @@ describe('createComponentPlan', () => {
'packages/@luke-ui/react/src/status-badge/status-badge.visual.test.tsx',
]);
const guide = plan.files.find((file) => {
- return file.path.endsWith('/status-badge/index.mdx');
+ return file.path.endsWith('feedback/status-badge.mdx');
})?.contents;
expect(guide).toContain('src="status-badge/basic"');
expect(guide).not.toContain('description=');
@@ -207,7 +207,7 @@ describe('createComponentPlan', () => {
expect(source).toContain('className={className}');
});
- it('scaffolds a index.mdx that generate:props can turn into a props.mdx', () => {
+ it('scaffolds a /.mdx guide that generate:props can turn into a props.mdx', () => {
const plan = createComponentPlan({
docsGroup: 'feedback',
name: 'StatusBadge',
@@ -216,9 +216,9 @@ describe('createComponentPlan', () => {
});
const guide = plan.files.find((file) =>
- file.path.endsWith('/status-badge/index.mdx'),
+ file.path.endsWith('feedback/status-badge.mdx'),
)?.contents;
- if (guide === undefined) throw new Error('Expected the scaffold to write index.mdx.');
+ if (guide === undefined) throw new Error('Expected the scaffold to write the guide.');
const frontmatter = parseComponentFrontmatter(guide);
expect(frontmatter.props).toEqual([
diff --git a/packages/turbo-generators/src/component-creation-plan.ts b/packages/turbo-generators/src/component-creation-plan.ts
index e528f205..06296668 100644
--- a/packages/turbo-generators/src/component-creation-plan.ts
+++ b/packages/turbo-generators/src/component-creation-plan.ts
@@ -97,7 +97,7 @@ export function createComponentPlan(input: CreateComponentInput): ComponentCreat
},
{
contents: renderHostedDocsPage({ displayName, name, pascalName }),
- path: `apps/docs/content/docs/components/${docsGroup}/${name}/index.mdx`,
+ path: `apps/docs/content/docs/components/${docsGroup}/${name}.mdx`,
},
];