[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. luke-ui.netlify.app/
0

Configure Feed

Select the types of activity you want to include in your feed.

Migrate docs to Fumadocs-native MDX with live interactive examples (#69)

Luke Bennett (Jul 6, 2026, 11:25 AM +1000) 1efae3db af18afc6

+1894 -4294
+8 -1
AGENTS.md
··· 10 10 - Scaffold components non-interactively: 11 11 `pnpm run generate:component --args <name> <atom|composed> <docs-group> <recipe|none>`. 12 12 - When you change code, update or delete the docs that describe it in the same change — comments, 13 - JSDoc, `.docs.md`, `README.md`, and `docs/*.md`. See 13 + JSDoc, MDX files in `apps/docs/content/docs/`, `README.md`, and `docs/*.md`. See 14 14 [docs/CONVENTIONS.md](docs/CONVENTIONS.md#keeping-docs-current). 15 + 16 + ## Dev loop 17 + 18 + - Run `pnpm run check` from the repo root to verify changes before committing. 19 + - Component prose now lives in MDX files in `apps/docs/content/docs/`, not `.docs.md` files in the 20 + package. Update the relevant MDX page in the same change as the component code. 21 + - Do not add or edit `.docs.md` files in `packages/@luke-ui/react/src/`.
+27 -28
CONTEXT.md
··· 23 23 **Primitive**. 24 24 25 25 **Component creation**: The act of adding the public surface a new Atom or Composed component needs 26 - to exist consistently. That includes source, stories, package docs, hosted docs, recipes when 27 - needed, and generated exports. 26 + to exist consistently. That includes source, stories, hosted docs, recipes when needed, and 27 + generated exports. 28 28 29 29 ## Relationships 30 30 31 31 - A **Composed** component is built from one or more **Atoms** or **Primitives**. 32 - - A **Primitive** is documented in package docs so library authors and agents can find it. It is 33 - omitted from the hosted docs primary navigation and index. 32 + - A **Primitive** is documented in the hosted docs app so library authors and agents can find it. It 33 + is omitted from the hosted docs primary navigation and index. 34 34 - The composed `Field` is a **Primitive** by audience, even though it composes other components. It 35 35 exists for library authors building `TextField`, `ComboboxField`, and similar components. 36 - - An **Atom** or **Composed** component gets a hosted docs page and a primary package-doc entry. 36 + - An **Atom** or **Composed** component gets a hosted docs page. 37 37 38 - ## Package docs shape 38 + ## Hosted docs shape 39 39 40 - Each `src/<component>/<component>.docs.md` follows the section order from 41 - [ADR-0006](docs/adr/0006-docs-md-structure-standard.md): 40 + Each component docs page at `apps/docs/content/docs/components/<group>/<component>.mdx` follows the 41 + section order from [ADR-0006](docs/adr/0006-docs-md-structure-standard.md) (now superseded by 42 + ADR-0007; the section ordering still applies): 42 43 43 44 - Usage lead-in with no explicit `## Usage` heading. 44 45 - `## Best Practices` table. ··· 46 47 - `## Accessibility` when the component has a user-facing accessibility contract. 47 48 - Cross-reference sections last. 48 49 49 - All prose is authored in the package (`src/<component>/<component>.docs.md`). `apps/docs` MDX files 50 - only wire the page together with frontmatter, an interactive demo, and an `<include>` for the 51 - generated package doc. Full rationale is in [ADR-0006](docs/adr/0006-docs-md-structure-standard.md). 50 + All prose is authored in the hosted docs app (`apps/docs/content/docs/`). The package no longer 51 + ships generated docs on npm. Full rationale is in 52 + [ADR-0007](docs/adr/0007-docs-moved-to-hosted-app.md). 52 53 53 54 ## Docs rule 54 55 55 - Two doc surfaces serve two audiences: 56 + The **hosted docs app** (`apps/docs`) is the primary docs surface. It serves both app developers and 57 + library authors. The package README links to it. 56 58 57 - - **Hosted docs** (`apps/docs`): for app developers. They document components an app developer can 58 - drop into a UI. 59 - - **Package docs** (shipped on npm under `packages/@luke-ui/react/docs/`): for anyone reading the 60 - package off npm, including library authors and coding agents. They document every public export 61 - path because each one is reachable through `package.json#exports`. 59 + | Tier | Hosted docs? | Notes | 60 + | --------- | ----------------- | ------------------------------------------------ | 61 + | Atom | yes (primary nav) | App-developer-facing | 62 + | Composed | yes (primary nav) | App-developer-facing | 63 + | Primitive | yes (specialist) | Listed in a "Library authors / advanced" section | 62 64 63 - | Tier | Hosted docs? | Package docs? | 64 - | --------- | ----------------- | ---------------------- | 65 - | Atom | yes (primary nav) | yes (primary index) | 66 - | Composed | yes (primary nav) | yes (primary index) | 67 - | Primitive | no | yes (specialist index) | 65 + **Specialist, not noise.** Primitive pages exist in the hosted docs so library authors and agents 66 + can find them. They are listed in a separate, de-emphasised "Library authors / advanced" section, 67 + never mixed into the primary index alongside atoms and composed components. The goal is reachability 68 + without crowding the main path. Do not document anything that is not part of the public API. 68 69 69 - **Specialist, not noise.** Primitive pages exist in package docs so library authors and agents can 70 - find them. They are listed in a separate, de-emphasised "Library authors / advanced" section in 71 - `README.md` and `llms.txt`, never mixed into the primary index alongside atoms and composed 72 - components. The goal is reachability without crowding the main path. Do not document anything that 73 - is not part of the public API. 70 + Package docs are no longer shipped on npm. 74 71 75 72 ## Decisions 76 73 ··· 78 75 - [ADR-0002](docs/adr/0002-primitive-package-path-convention.md): Primitive kits are exported at 79 76 `[composed]/primitive` 80 77 - [ADR-0003](docs/adr/0003-package-docs-surface.md): Package docs are a separate AI-native surface 78 + (superseded by ADR-0007) 81 79 - [ADR-0004](docs/adr/0004-styling-utilities-public-api.md): Styling utilities public API 82 80 - [ADR-0006](docs/adr/0006-docs-md-structure-standard.md): Standard structure for `.docs.md` prose 83 - files 81 + files (superseded by ADR-0007) 82 + - [ADR-0007](docs/adr/0007-docs-moved-to-hosted-app.md): Docs moved to the hosted docs app
+1 -6
knip.config.ts
··· 9 9 'src/router.tsx', 10 10 'src/routes/**/*.ts', 11 11 'src/routes/**/*.tsx', 12 + 'src/examples/**/*', 12 13 'src/styles/app.css', 13 - 'src/**/*.story.tsx', 14 14 'content/**/*.mdx', 15 15 ], 16 - ignoreDependencies: ['ts-morph'], 17 16 project: ['src/**/*.{ts,tsx}'], 18 - }, 19 - 'packages/@luke-ui/docs-tools': { 20 - entry: ['src/**/*.test.ts'], 21 - project: ['src/**/*.ts'], 22 17 }, 23 18 'packages/@luke-ui/react': { 24 19 entry: [
+61 -147
pnpm-lock.yaml
··· 18 18 '@changesets/cli': 19 19 specifier: ^2.31.0 20 20 version: 2.31.0 21 - '@fumadocs/story': 22 - specifier: ^1.1.2 23 - version: 1.1.2 24 21 '@orama/orama': 25 22 specifier: ^3.1.18 26 23 version: 3.1.18 ··· 117 114 fumadocs-mdx: 118 115 specifier: ^15.0.13 119 116 version: 15.0.13 117 + fumadocs-typescript: 118 + specifier: ^5.2.7 119 + version: 5.2.7 120 120 fumadocs-ui: 121 121 specifier: ^16.10.7 122 122 version: 16.10.7 ··· 156 156 tailwindcss: 157 157 specifier: ^4.3.2 158 158 version: 4.3.2 159 - ts-morph: 160 - specifier: ^28.0.0 161 - version: 28.0.0 162 159 tsx: 163 160 specifier: ^4.22.4 164 161 version: 4.22.4 ··· 204 201 205 202 apps/docs: 206 203 dependencies: 207 - '@fumadocs/story': 208 - specifier: 'catalog:' 209 - version: 1.1.2(dce24b41fef1c9431d2c90aa388db1d8) 210 - '@luke-ui/docs-tools': 211 - specifier: workspace:* 212 - version: link:../../packages/@luke-ui/docs-tools 213 204 '@luke-ui/react': 214 205 specifier: workspace:* 215 206 version: link:../../packages/@luke-ui/react 216 207 '@orama/orama': 217 208 specifier: 'catalog:' 218 209 version: 3.1.18 210 + '@react-aria/utils': 211 + specifier: 'catalog:' 212 + version: 3.34.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 219 213 '@tanstack/react-router': 220 214 specifier: 'catalog:' 221 215 version: 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) ··· 225 219 '@tanstack/start-static-server-functions': 226 220 specifier: 'catalog:' 227 221 version: 1.167.18(@tanstack/react-start@1.168.27(@voidzero-dev/vite-plus-core@0.2.2(@arethetypeswrong/core@0.18.4)(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.37)(yaml@2.9.0))(crossws@0.4.4(srvx@0.10.1))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.3)(rollup@4.59.0)) 222 + '@vanilla-extract/recipes': 223 + specifier: 'catalog:' 224 + version: 0.5.7(@vanilla-extract/css@1.21.1) 228 225 fumadocs-core: 229 226 specifier: 'catalog:' 230 227 version: 16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) 231 228 fumadocs-mdx: 232 229 specifier: 'catalog:' 233 230 version: 15.0.13(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.2.2(@arethetypeswrong/core@0.18.4)(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.37)(yaml@2.9.0))(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(react@19.2.7)(rolldown@1.1.3) 231 + fumadocs-typescript: 232 + specifier: 'catalog:' 233 + version: 5.2.7(@types/estree@1.0.9)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(fumadocs-ui@16.10.7(@tailwindcss/oxide@4.3.2)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 234 234 fumadocs-ui: 235 235 specifier: 'catalog:' 236 236 version: 16.10.7(@tailwindcss/oxide@4.3.2)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2) ··· 240 240 react: 241 241 specifier: 'catalog:' 242 242 version: 19.2.7 243 + react-aria-components: 244 + specifier: 'catalog:' 245 + version: 1.19.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 243 246 react-dom: 244 247 specifier: 'catalog:' 245 248 version: 19.2.7(react@19.2.7) 246 - ts-morph: 247 - specifier: 'catalog:' 248 - version: 28.0.0 249 249 vite: 250 250 specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 251 251 version: '@voidzero-dev/vite-plus-core@0.2.2(@arethetypeswrong/core@0.18.4)(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.37)(yaml@2.9.0)' ··· 299 299 specifier: 4.1.9 300 300 version: 4.1.9(@types/node@26.0.1)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@arethetypeswrong/core@0.18.4)(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.37)(yaml@2.9.0)) 301 301 302 - packages/@luke-ui/docs-tools: 303 - dependencies: 304 - ts-morph: 305 - specifier: 'catalog:' 306 - version: 28.0.0 307 - devDependencies: 308 - '@types/node': 309 - specifier: 'catalog:' 310 - version: 26.0.1 311 - '@types/react': 312 - specifier: 'catalog:' 313 - version: 19.2.17 314 - react-aria-components: 315 - specifier: 'catalog:' 316 - version: 1.19.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 317 - typescript: 318 - specifier: 'catalog:' 319 - version: 6.0.3 320 - vite-plus: 321 - specifier: 'catalog:' 322 - version: 0.2.2(@arethetypeswrong/core@0.18.4)(@types/node@26.0.1)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.37)(vite@8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(yaml@2.9.0) 323 - 324 302 packages/@luke-ui/rainbow-sprinkles: 325 303 dependencies: 326 304 '@vanilla-extract/css': ··· 367 345 '@arethetypeswrong/core': 368 346 specifier: 'catalog:' 369 347 version: 0.18.4 370 - '@luke-ui/docs-tools': 371 - specifier: workspace:* 372 - version: link:../docs-tools 373 348 '@rollup/plugin-babel': 374 349 specifier: 'catalog:' 375 350 version: 7.1.0(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.59.0) ··· 451 426 vitest: 452 427 specifier: 4.1.9 453 428 version: 4.1.9(@types/node@26.0.1)(@vitest/browser-playwright@4.1.9)(@vitest/browser-preview@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@arethetypeswrong/core@0.18.4)(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.37)(yaml@2.9.0)) 454 - zod: 455 - specifier: 'catalog:' 456 - version: 4.4.3 457 429 458 430 packages/turbo-generators: 459 - dependencies: 460 - '@luke-ui/docs-tools': 461 - specifier: workspace:* 462 - version: link:../@luke-ui/docs-tools 463 431 devDependencies: 464 432 '@types/node': 465 433 specifier: 'catalog:' ··· 927 895 '@types/react': 928 896 optional: true 929 897 930 - '@fumadocs/story@1.1.2': 931 - resolution: {integrity: sha512-VhAAo4QrMtjTImo4e15mqlZUmXYRLr/Rh4snOzCHnjaRSurxpis7QPxk/CRxxc7NVUZYeVwvjg2J202pIT9B0g==} 932 - peerDependencies: 933 - '@types/react': '*' 934 - fumadocs-core: ^16.10.0 935 - fumadocs-ui: ^16.10.0 936 - next: ^15.3.0 || ^16.0.0 937 - react: ^19.2.0 938 - react-dom: ^19.2.0 939 - vite: ^8 940 - webpack: ^5 941 - peerDependenciesMeta: 942 - '@types/react': 943 - optional: true 944 - next: 945 - optional: true 946 - vite: 947 - optional: true 948 - webpack: 949 - optional: true 950 - 951 898 '@fumadocs/tailwind@0.0.5': 952 899 resolution: {integrity: sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ==} 953 900 peerDependencies: ··· 957 904 '@tailwindcss/oxide': 958 905 optional: true 959 906 tailwindcss: 960 - optional: true 961 - 962 - '@fumari/stf@1.0.5': 963 - resolution: {integrity: sha512-O9UQIbV15ePV5vUgceCcaMDuBRYfrSuogDEY5E//CmrbIiWJ1Ji5VgGHHH0L0HQuRr5riUJuAEr9lYIvJl9OyQ==} 964 - peerDependencies: 965 - '@types/react': '*' 966 - react: ^19.2.0 967 - react-dom: ^19.2.0 968 - peerDependenciesMeta: 969 - '@types/react': 970 907 optional: true 971 908 972 909 '@humanfs/core@0.19.2': ··· 2471 2408 2472 2409 '@radix-ui/react-scroll-area@1.2.13': 2473 2410 resolution: {integrity: sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw==} 2474 - peerDependencies: 2475 - '@types/react': '*' 2476 - '@types/react-dom': '*' 2477 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 2478 - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 2479 - peerDependenciesMeta: 2480 - '@types/react': 2481 - optional: true 2482 - '@types/react-dom': 2483 - optional: true 2484 - 2485 - '@radix-ui/react-select@2.3.2': 2486 - resolution: {integrity: sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q==} 2487 2411 peerDependencies: 2488 2412 '@types/react': '*' 2489 2413 '@types/react-dom': '*' ··· 4524 4448 rolldown: 4525 4449 optional: true 4526 4450 vite: 4451 + optional: true 4452 + 4453 + fumadocs-typescript@5.2.7: 4454 + resolution: {integrity: sha512-VORVIdVOC+CQX1bM96YswIk+/1fjeG/zeN4zEhleIAg3uIDgqhIS7xE/XvRzV6xtKm8upAtnZY3Ya+DNNdfFaw==} 4455 + peerDependencies: 4456 + '@types/estree': '*' 4457 + '@types/hast': '*' 4458 + '@types/mdast': '*' 4459 + '@types/react': '*' 4460 + fumadocs-core: ^16.7.0 4461 + fumadocs-ui: ^16.7.0 4462 + react: ^19.2.0 4463 + react-dom: ^19.2.0 4464 + peerDependenciesMeta: 4465 + '@types/estree': 4466 + optional: true 4467 + '@types/hast': 4468 + optional: true 4469 + '@types/mdast': 4470 + optional: true 4471 + '@types/react': 4472 + optional: true 4473 + fumadocs-ui: 4527 4474 optional: true 4528 4475 4529 4476 fumadocs-ui@16.10.7: ··· 6955 6902 optionalDependencies: 6956 6903 '@types/react': 19.2.17 6957 6904 6958 - '@fumadocs/story@1.1.2(dce24b41fef1c9431d2c90aa388db1d8)': 6959 - dependencies: 6960 - '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 6961 - '@fumari/stf': 1.0.5(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 6962 - '@radix-ui/react-select': 2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 6963 - class-variance-authority: 0.7.1 6964 - cnfast: 0.0.8 6965 - fumadocs-core: 16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) 6966 - fumadocs-ui: 16.10.7(@tailwindcss/oxide@4.3.2)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2) 6967 - lucide-react: 1.22.0(react@19.2.7) 6968 - react: 19.2.7 6969 - react-dom: 19.2.7(react@19.2.7) 6970 - ts-morph: 28.0.0 6971 - optionalDependencies: 6972 - '@types/react': 19.2.17 6973 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@arethetypeswrong/core@0.18.4)(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.37)(yaml@2.9.0)' 6974 - transitivePeerDependencies: 6975 - - '@types/react-dom' 6976 - 6977 6905 '@fumadocs/tailwind@0.0.5(@tailwindcss/oxide@4.3.2)(tailwindcss@4.3.2)': 6978 6906 optionalDependencies: 6979 6907 '@tailwindcss/oxide': 4.3.2 6980 6908 tailwindcss: 4.3.2 6981 - 6982 - '@fumari/stf@1.0.5(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': 6983 - dependencies: 6984 - react: 19.2.7 6985 - react-dom: 19.2.7(react@19.2.7) 6986 - optionalDependencies: 6987 - '@types/react': 19.2.17 6988 6909 6989 6910 '@humanfs/core@0.19.2': 6990 6911 dependencies: ··· 8076 7997 '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) 8077 7998 react: 19.2.7 8078 7999 react-dom: 19.2.7(react@19.2.7) 8079 - optionalDependencies: 8080 - '@types/react': 19.2.17 8081 - '@types/react-dom': 19.2.3(@types/react@19.2.17) 8082 - 8083 - '@radix-ui/react-select@2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': 8084 - dependencies: 8085 - '@radix-ui/number': 1.1.2 8086 - '@radix-ui/primitive': 1.1.4 8087 - '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 8088 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) 8089 - '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) 8090 - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) 8091 - '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 8092 - '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) 8093 - '@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 8094 - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) 8095 - '@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 8096 - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 8097 - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 8098 - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 8099 - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) 8100 - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) 8101 - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) 8102 - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) 8103 - '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) 8104 - '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 8105 - aria-hidden: 1.2.6 8106 - react: 19.2.7 8107 - react-dom: 19.2.7(react@19.2.7) 8108 - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) 8109 8000 optionalDependencies: 8110 8001 '@types/react': 19.2.17 8111 8002 '@types/react-dom': 19.2.3(@types/react@19.2.17) ··· 10090 9981 react: 19.2.7 10091 9982 rolldown: 1.1.3 10092 9983 vite: '@voidzero-dev/vite-plus-core@0.2.2(@arethetypeswrong/core@0.18.4)(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)(unrun@0.2.37)(yaml@2.9.0)' 9984 + transitivePeerDependencies: 9985 + - supports-color 9986 + 9987 + fumadocs-typescript@5.2.7(@types/estree@1.0.9)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(fumadocs-ui@16.10.7(@tailwindcss/oxide@4.3.2)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): 9988 + dependencies: 9989 + estree-util-value-to-estree: 3.5.0 9990 + fumadocs-core: 16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) 9991 + hast-util-to-estree: 3.1.3 9992 + hast-util-to-jsx-runtime: 2.3.6 9993 + react: 19.2.7 9994 + react-dom: 19.2.7(react@19.2.7) 9995 + remark: 15.0.1 9996 + remark-rehype: 11.1.2 9997 + shiki: 4.3.0 9998 + ts-morph: 28.0.0 9999 + unified: 11.0.5 10000 + unist-util-visit: 5.1.0 10001 + optionalDependencies: 10002 + '@types/estree': 1.0.9 10003 + '@types/hast': 3.0.4 10004 + '@types/mdast': 4.0.4 10005 + '@types/react': 19.2.17 10006 + fumadocs-ui: 16.10.7(@tailwindcss/oxide@4.3.2)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.10.7(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.22.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.2) 10093 10007 transitivePeerDependencies: 10094 10008 - supports-color 10095 10009
+1 -2
pnpm-workspace.yaml
··· 12 12 '@capsizecss/metrics': ^4.1.0 13 13 '@capsizecss/vanilla-extract': ^2.0.4 14 14 '@changesets/cli': ^2.31.0 15 - '@fumadocs/story': ^1.1.2 16 15 '@orama/orama': ^3.1.18 17 16 '@react-aria/utils': ^3.34.1 18 17 '@rollup/plugin-babel': ^7.1.0 ··· 47 46 fumadocs-core: ^16.10.7 48 47 fumadocs-mdx: ^15.0.13 49 48 fumadocs-ui: ^16.10.7 49 + fumadocs-typescript: ^5.2.7 50 50 knip: ^6.23.0 51 51 lucide-react: ^1.22.0 52 52 nitro: npm:nitro-nightly@3.0.1-20260128-211656-ae83c97e ··· 59 59 serve: ^14.2.6 60 60 storybook: ^10.4.6 61 61 tailwindcss: ^4.3.2 62 - ts-morph: ^28.0.0 63 62 tsx: ^4.22.4 64 63 turbo: ^2.10.1 65 64 typescript: ^6.0.3
+17 -21
turbo.json
··· 1 1 { 2 2 "$schema": "https://v2-8-10.turborepo.dev/schema.json", 3 3 "tasks": { 4 - "@luke-ui/react#check:docs": { 5 - "dependsOn": ["build"] 6 - }, 7 4 "@luke-ui/react#check:lint": { 8 5 "dependsOn": ["build", "generate"] 9 6 }, 10 7 "@luke-ui/react#check:types": { 11 8 "dependsOn": ["build", "^build"] 12 - }, 13 - "@luke-ui/react#generate:docs": { 14 - "dependsOn": ["build"], 15 - "outputs": ["docs/**", "dist/docs/**"] 16 9 }, 17 10 "//#check:format-root": { 18 11 "cache": true ··· 40 33 "outputs": ["storybook-static/**"] 41 34 }, 42 35 "check": { 43 - "dependsOn": ["check:format", "check:lint", "check:types", "check:docs"] 44 - }, 45 - "check:docs": { 46 - "dependsOn": ["^generate"] 36 + "dependsOn": ["check:format", "check:lint", "check:types"] 47 37 }, 48 38 "check:format": { 49 39 "dependsOn": ["generate"] ··· 68 58 "persistent": true 69 59 }, 70 60 "docs#build": { 71 - "dependsOn": ["generate", "^build", "@luke-ui/react#generate:docs"], 61 + "dependsOn": ["docs#generate", "^build"], 72 62 "env": ["VITE_BASE_URL"], 73 63 "outputs": [".output/**", ".source/**"] 74 64 }, 65 + "docs#check:format": { 66 + "dependsOn": ["docs#generate"] 67 + }, 68 + "docs#check:lint": { 69 + "dependsOn": ["docs#generate", "^build"] 70 + }, 71 + "docs#check:types": { 72 + "dependsOn": ["docs#generate", "^build"] 73 + }, 75 74 "docs#dev": { 76 75 "cache": false, 77 - "dependsOn": ["generate", "^build", "@luke-ui/react#generate:docs"], 76 + "dependsOn": ["docs#generate", "^build"], 78 77 "persistent": true 78 + }, 79 + "docs#generate": { 80 + "outputs": ["src/routeTree.gen.ts"], 81 + "cache": true 79 82 }, 80 83 "fix": { 81 84 "cache": false, ··· 97 100 "dependsOn": ["^generate"], 98 101 "inputs": [ 99 102 "$TURBO_DEFAULT$", 100 - "!docs/**", 101 103 "!dist/**", 102 104 "!.generated/**", 103 105 "!storybook-static/**", ··· 105 107 "!**/*.test.tsx", 106 108 "!**/*.stories.tsx" 107 109 ], 108 - "outputs": [ 109 - ".generated/**", 110 - "dist/spritesheet.svg", 111 - "dist/docs/**", 112 - "src/routeTree.gen.ts", 113 - "docs/**" 114 - ] 110 + "outputs": [".generated/**", "dist/spritesheet.svg", "src/routeTree.gen.ts"] 115 111 }, 116 112 "test": { 117 113 "dependsOn": ["^build", "generate"],
+24 -30
docs/CONVENTIONS.md
··· 39 39 `Icon`, `Heading`, `Numeral`, `Emoji`, or `LoadingSpinner`. Atoms get hosted docs pages. 40 40 - **Composed**: an app-developer-facing pattern built from atoms or primitives, such as `Button`, 41 41 `IconButton`, `TextField`, or `ComboboxField`. Composed components get hosted docs pages. 42 - - **Primitive**: a building block for library authors. Primitives are documented in package docs, 43 - but not in hosted docs. A primitive may be a single file or a multi-file kit. 42 + - **Primitive**: a building block for library authors. Primitives are documented in the hosted docs 43 + app under a de-emphasised "Library authors" section, but not in the primary navigation. A 44 + primitive may be a single file or a multi-file kit. 44 45 45 46 ## Package paths 46 47 ··· 60 61 61 62 ## Docs 62 63 63 - `.docs.md` files follow the structure in [ADR-0006](adr/0006-docs-md-structure-standard.md). Humans 64 - and coding agents both read these docs, so optimise for clarity, not only brevity. 64 + Component docs pages follow the structure in 65 + [ADR-0006](../docs/adr/0006-docs-md-structure-standard.md). ADR-0007 supersedes that ADR, but the 66 + section ordering still applies. Humans and coding agents both read these docs, so optimise for 67 + clarity, not only brevity. 65 68 66 69 Headings use sentence case: capitalise only the first word and proper nouns. 67 70 ··· 75 78 76 79 Docs must stay factually accurate: no doc should state something false about the code — a path, 77 80 command, script, export, type, code snippet, or cross-reference that has since changed or been 78 - removed. This covers everything a human writes (comments, JSDoc, `.docs.md` prose, `README.md`, 79 - `docs/*.md`), not generated output; when generated docs are wrong, fix the authored source, not the 80 - generated file. 81 + removed. This covers everything a human writes (comments, JSDoc, MDX prose in 82 + `apps/docs/content/docs/`, `README.md`, `docs/*.md`), not generated output; when generated docs are 83 + wrong, fix the authored source, not the generated file. 81 84 82 85 - Update or delete the docs that describe code in the same change as the code. Most rot is a doc 83 86 that outlived the change that should have touched it. ··· 89 92 - Reference stable things. Don't pin prose to volatile details like line numbers, generated class 90 93 names, or exact command output; reference the durable path, command, or heading instead. 91 94 92 - ## Fumadocs stories 95 + ## Fumadocs examples 93 96 94 - Each component's interactive hosted-docs demo lives in one file: 95 - `apps/docs/src/<component>/<component>.story.tsx`. 97 + Each component's interactive hosted-docs demo lives in example modules under 98 + `apps/docs/src/examples/<component>/`. An example module exports `meta` with a title and 99 + description, and a default component that renders the example. 96 100 97 - The story uses `@fumadocs/story/vite/client`, which is registered as a Vite plugin in 98 - `apps/docs/vite.config.ts`. 101 + Use the `<Example>` component in an MDX page: 99 102 100 - Define a narrow `<Component>StoryProps` type with `Pick<<Component>Props, 'a' | 'b'>`. Include only 101 - props that make useful hosted controls. Drop event handlers, refs, escape hatches such as 102 - `className` and `style`, and obscure ARIA props. 103 + ```tsx 104 + <Example component="button" name="tones" /> 105 + ``` 103 106 104 - Control order follows the key order inside `Pick`, not the declaration order in `<Component>Props`. 105 - `@fumadocs/story` reads the resolved type with `ts-morph` and preserves the pick order, so list keys 106 - in the order they should appear in the panel. 107 + `<Example>` renders a live preview and a "Code" tab with the example source. 107 108 108 - Render the real component through a small `<Component>Playground` wrapper inside the shared 109 - `StoryWrapper` from `../lib/story-wrapper`. Pass it directly to 110 - `defineStory({ Component, args: { initial } })`. 109 + Keep example content aligned with the MDX feature sections. If you add a feature section for a prop 110 + or pattern, add or update an example in the same change. 111 111 112 - Generic components should fix the generic to one concrete sample type inside the playground. See 113 - `combobox-field.story.tsx`. 112 + Initial values should be short and legible. Do not use lorem ipsum. 114 113 115 - Keep story props aligned with `.docs.md` feature sections. If you add a feature section for a prop, 116 - add that prop to the story `Pick` in the same change. 117 - 118 - Initial values should be short and legible, like the `.docs.md` examples. Do not use lorem ipsum. 119 - 120 - Hosted `.mdx` files are wiring only: frontmatter, an interactive demo, and an `<include>` for 121 - generated package docs. Do not add hand-authored component prose there. 114 + Hosted `.mdx` files contain the component prose, frontmatter, `<Example>` components, and an 115 + `<auto-type-table>` for the API reference. Do not add generated package docs.
+1 -1
.github/workflows/check.yml
··· 45 45 TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} 46 46 TURBO_SCM_HEAD: ${{ github.event.pull_request.head.sha }} 47 47 run: | 48 - corepack pnpm exec turbo run check:format-root check:format check:lint check:types check:docs ${{ github.event_name == 'pull_request' && '--affected' || '' }} 48 + corepack pnpm exec turbo run check:format-root check:format check:lint check:types ${{ github.event_name == 'pull_request' && '--affected' || '' }} 49 49 # knip is repo-wide, but --affected only selects packages whose own files 50 50 # changed, so it must run unscoped. Running it via turbo (rather than the 51 51 # bare `knip` script) pulls in its generate dependencies first.
+4 -3
apps/docs/package.json
··· 22 22 "test": "vp test run --config vitest.config.ts" 23 23 }, 24 24 "dependencies": { 25 - "@fumadocs/story": "catalog:", 26 - "@luke-ui/docs-tools": "workspace:*", 27 25 "@luke-ui/react": "workspace:*", 28 26 "@orama/orama": "catalog:", 27 + "@react-aria/utils": "catalog:", 29 28 "@tanstack/react-router": "catalog:", 30 29 "@tanstack/react-start": "catalog:", 31 30 "@tanstack/start-static-server-functions": "catalog:", 31 + "@vanilla-extract/recipes": "catalog:", 32 32 "fumadocs-core": "catalog:", 33 33 "fumadocs-mdx": "catalog:", 34 + "fumadocs-typescript": "catalog:", 34 35 "fumadocs-ui": "catalog:", 35 36 "lucide-react": "catalog:", 36 37 "react": "catalog:", 38 + "react-aria-components": "catalog:", 37 39 "react-dom": "catalog:", 38 - "ts-morph": "catalog:", 39 40 "vite": "catalog:", 40 41 "zod": "catalog:" 41 42 },
+14 -34
apps/docs/source.config.ts
··· 1 + import { fileURLToPath } from 'node:url'; 1 2 import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; 3 + import { 4 + remarkAutoTypeTable, 5 + createGenerator, 6 + createFileSystemGeneratorCache, 7 + } from 'fumadocs-typescript'; 2 8 3 9 export const docs = defineDocs({ 4 10 dir: 'content/docs', ··· 9 15 }, 10 16 }); 11 17 12 - // Strip the leading H1 + blockquote emitted by the package-doc generator so 13 - // they don't duplicate DocsTitle and DocsDescription. Fumadocs's `<include>` 14 - // wraps the included content in a nested `root` node, so we recurse. 15 - // Safe because no hosted MDX file authors an inline `# H1` or leading 16 - // blockquote — titles and descriptions always come from frontmatter. 17 - interface MdastNode { 18 - children?: Array<MdastNode>; 19 - depth?: number; 20 - type?: string; 21 - } 18 + const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); 22 19 23 - function stripIncludedHeader() { 24 - return (tree: MdastNode) => { 25 - stripFrom(tree); 26 - }; 27 - } 28 - 29 - function stripFrom(node: MdastNode): boolean { 30 - const children = node.children; 31 - if (!children) return false; 32 - for (let i = 0; i < children.length; i++) { 33 - const child = children[i]; 34 - if (!child) continue; 35 - if (child.type === 'heading' && child.depth === 1) { 36 - let removeCount = 1; 37 - const next = children[i + 1]; 38 - if (next?.type === 'blockquote') removeCount = 2; 39 - children.splice(i, removeCount); 40 - return true; 41 - } 42 - if (child.type === 'root' && stripFrom(child)) return true; 43 - } 44 - return false; 45 - } 20 + const generator = createGenerator({ 21 + cache: createFileSystemGeneratorCache('.source/fumadocs-typescript'), 22 + }); 46 23 47 24 export default defineConfig({ 48 25 mdxOptions: { 49 - remarkPlugins: (v) => [...v, stripIncludedHeader], 26 + remarkPlugins: (v) => [ 27 + ...v, 28 + [remarkAutoTypeTable, { generator, options: { basePath: repoRoot } }], 29 + ], 50 30 }, 51 31 });
+62 -256
apps/docs/vite.config.ts
··· 1 - import { spawn } from 'node:child_process'; 2 1 import { join, relative, sep } from 'node:path'; 3 2 import { fileURLToPath } from 'node:url'; 4 - import story from '@fumadocs/story/vite'; 5 - import type { 6 - PackageDocsCatalogEntry, 7 - PackageDocsCatalogMetadata, 8 - } from '@luke-ui/docs-tools/package-docs-catalog'; 9 - import { resolvePackageDocsCatalog } from '@luke-ui/docs-tools/package-docs-catalog'; 10 3 import tailwindcss from '@tailwindcss/vite'; 11 4 import { tanstackStart } from '@tanstack/react-start/plugin/vite'; 12 5 import react from '@vitejs/plugin-react'; 13 6 import mdx from 'fumadocs-mdx/vite'; 14 7 import { nitro } from 'nitro/vite'; 15 - import { readdir, readFile } from 'node:fs/promises'; 8 + import { readdir } from 'node:fs/promises'; 16 9 import type { Plugin } from 'vite-plus'; 17 10 import { defineConfig, lazyPlugins } from 'vite-plus'; 18 - import * as z from 'zod'; 19 - import packageJson from '../../packages/@luke-ui/react/package.json' with { type: 'json' }; 20 - import { mapPublicToInternal, toInternal, toPublic } from './src/lib/markdown-url'; 21 11 22 12 // staticFunctionMiddleware hardcodes `/__tsr/staticServerFnCache/...` for the 23 13 // client fetch URL with no base-path support. When deployed under a sub-path ··· 38 28 }; 39 29 } 40 30 41 - function markdownRewritePlugin(): Plugin { 42 - return { 43 - configurePreviewServer(server) { 44 - server.middlewares.use((req, _res, next) => { 45 - const rewritten = mapPublicToInternal(req.url, server.config.base); 46 - if (rewritten) req.url = rewritten; 47 - next(); 48 - }); 49 - }, 50 - configureServer(server) { 51 - server.middlewares.use((req, _res, next) => { 52 - const rewritten = mapPublicToInternal(req.url, server.config.base); 53 - if (rewritten) req.url = rewritten; 54 - next(); 55 - }); 56 - }, 57 - name: 'markdown-rewrite', 58 - }; 59 - } 60 - 61 31 const contentDocsDir = fileURLToPath(new URL('./content/docs/', import.meta.url)); 62 - const packageRootDir = fileURLToPath(new URL('../../packages/@luke-ui/react/', import.meta.url)); 63 - const packageSrcDir = fileURLToPath(new URL('../../packages/@luke-ui/react/src/', import.meta.url)); 64 - const packageDocsDir = fileURLToPath( 65 - new URL('../../packages/@luke-ui/react/docs/', import.meta.url), 66 - ); 67 32 68 - // Expose generated package docs as `virtual:package-docs`. The `/markdown/$` 69 - // routes serve these to AI agents fetching public `.md` URLs. We can't read them via 70 - // `import.meta.glob` because `fumadocs-mdx/vite` compiles any `.md`/`.mdx` it 71 - // sees into a React component, even with the `?raw` query. 72 - const packageJsonPath = join(packageRootDir, 'package.json'); 73 - const packageJsonSchema = z.object({ 74 - exports: z.record(z.string(), z.string()), 75 - }); 33 + async function getMarkdownPrerenderPages(): Promise<Array<{ path: string }>> { 34 + const files: Array<string> = []; 76 35 77 - async function readPackageDocsCatalog(): Promise<Array<PackageDocsCatalogEntry>> { 78 - const currentPackageJson = packageJsonSchema.parse( 79 - JSON.parse(await readFile(packageJsonPath, 'utf8')), 80 - ); 81 - return resolvePackageDocsCatalog({ 82 - exportsField: currentPackageJson.exports, 83 - packageRoot: packageRootDir, 84 - }); 85 - } 86 - 87 - function packageDocsPlugin(): Plugin { 88 - const id = 'virtual:package-docs'; 89 - const resolved = `\0${id}`; 90 - return { 91 - configureServer(server) { 92 - server.watcher.add(packageDocsDir); 93 - server.watcher.add(packageJsonPath); 94 - const handle = (filePath: string) => { 95 - const isPackageDoc = filePath.endsWith('.md') && filePath.startsWith(packageDocsDir); 96 - const isPackageJson = filePath === packageJsonPath; 97 - if (!isPackageDoc && !isPackageJson) return; 98 - const mod = server.moduleGraph.getModuleById(resolved); 99 - if (mod) { 100 - server.moduleGraph.invalidateModule(mod); 101 - server.ws.send({ type: 'full-reload' }); 102 - } 103 - }; 104 - server.watcher.on('add', handle); 105 - server.watcher.on('change', handle); 106 - server.watcher.on('unlink', handle); 107 - }, 108 - enforce: 'pre', 109 - async load(loadId) { 110 - if (loadId !== resolved) return null; 111 - const catalog = await readPackageDocsCatalog(); 112 - const metadata: Array<PackageDocsCatalogMetadata> = catalog.map((entry) => ({ 113 - description: entry.description, 114 - pageKind: entry.pageKind, 115 - path: entry.path, 116 - shape: entry.shape, 117 - slug: entry.slug, 118 - target: entry.target, 119 - tier: entry.tier, 120 - title: entry.title, 121 - })); 122 - const entries = await Promise.all( 123 - catalog 124 - .filter((entry) => entry.pageKind !== 'asset') 125 - .map( 126 - async (entry) => 127 - [ 128 - entry.slug, 129 - await readFile(join(packageDocsDir, `${entry.slug}.md`), 'utf8'), 130 - ] as const, 131 - ), 132 - ); 133 - return [ 134 - `export const packageDocsCatalog = ${JSON.stringify(metadata)};`, 135 - `export const packageDocs = ${JSON.stringify(Object.fromEntries(entries))};`, 136 - ].join('\n'); 137 - }, 138 - name: 'package-docs', 139 - resolveId(source) { 140 - return source === id ? resolved : null; 141 - }, 142 - }; 143 - } 144 - 145 - async function getMarkdownPrerenderPages( 146 - catalog: Array<PackageDocsCatalogEntry>, 147 - ): Promise<Array<{ path: string; prerender: { outputPath: string } }>> { 148 - const contentPages = (await findMdxFiles(contentDocsDir)).map((filePath) => { 149 - const publicPath = getPublicMarkdownPath(filePath); 150 - const internalPath = toInternal(publicPath); 151 - if (!internalPath) throw new Error(`Could not map ${publicPath} to an internal markdown URL.`); 152 - return { 153 - path: internalPath, 154 - prerender: { outputPath: publicPath }, 155 - }; 156 - }); 157 - const packagePages = catalog.flatMap((entry) => { 158 - if (entry.shape !== 'barrel') return []; 159 - return [ 160 - { 161 - path: toInternal(toPublic(entry.slug)) ?? '', 162 - prerender: { outputPath: toPublic(entry.slug) }, 163 - }, 164 - ]; 165 - }); 166 - return [...contentPages, ...packagePages]; 167 - } 168 - 169 - async function findMdxFiles(dir: string): Promise<Array<string>> { 170 - const entries = await readdir(dir, { withFileTypes: true }); 171 - const files = await Promise.all( 172 - entries.map(async (entry) => { 173 - const path = join(dir, entry.name); 174 - if (entry.isDirectory()) return findMdxFiles(path); 175 - return entry.isFile() && entry.name.endsWith('.mdx') ? [path] : []; 176 - }), 177 - ); 178 - return files.flat(); 179 - } 180 - 181 - function getPublicMarkdownPath(filePath: string): string { 182 - const relativePath = relative(contentDocsDir, filePath).split(sep).join('/'); 183 - const withoutExtension = relativePath.slice(0, -'.mdx'.length); 184 - return toPublic(withoutExtension === 'index' ? 'index' : withoutExtension); 185 - } 186 - 187 - /** 188 - * Watch @luke-ui/react source files and regenerate `docs/*.md` when JSDoc or 189 - * prose changes. fumadocs-mdx tracks `<include>` dependencies, so once the 190 - * generated file changes the parent MDX page reloads automatically. 191 - * 192 - * Dev-only (`apply: 'serve'`); production builds rely on turbo's 193 - * `dev`→`generate` / `build`→`generate` dependency for initial generation. 194 - */ 195 - function packageSourceWatcherPlugin(): Plugin { 196 - const debounceMs = 300; 197 - let timer: NodeJS.Timeout | undefined; 198 - let inFlight: ReturnType<typeof spawn> | undefined; 199 - let pendingRun = false; 200 - 201 - function shouldTrigger(filePath: string): boolean { 202 - if (filePath === packageJsonPath) return true; 203 - if (!filePath.startsWith(packageSrcDir)) return false; 204 - return filePath.endsWith('.tsx') || filePath.endsWith('.ts') || filePath.endsWith('.docs.md'); 36 + async function collect(dir: string): Promise<void> { 37 + const entries = await readdir(dir, { withFileTypes: true }); 38 + await Promise.all( 39 + entries.map(async (entry) => { 40 + const path = join(dir, entry.name); 41 + if (entry.isDirectory()) return collect(path); 42 + if (entry.isFile() && entry.name.endsWith('.mdx')) files.push(path); 43 + }), 44 + ); 205 45 } 206 46 207 - function runGenerator(logger: { 208 - info: (msg: string) => void; 209 - error: (msg: string) => void; 210 - }): void { 211 - if (inFlight) { 212 - pendingRun = true; 213 - return; 214 - } 215 - logger.info('[package-docs] regenerating @luke-ui/react docs...'); 216 - const child = spawn('pnpm', ['--filter', '@luke-ui/react', 'generate:docs'], { 217 - cwd: packageRootDir, 218 - shell: false, 219 - stdio: ['ignore', 'pipe', 'pipe'], 220 - }); 221 - inFlight = child; 47 + await collect(contentDocsDir); 222 48 223 - let stderr = ''; 224 - child.stderr?.on('data', (chunk: Buffer) => { 225 - stderr += chunk.toString(); 226 - }); 227 - child.stdout?.on('data', () => { 228 - // Swallow stdout — fumadocs-mdx HMR announces the resulting reload. 229 - }); 230 - child.on('error', (err) => { 231 - logger.error(`[package-docs] failed to spawn generator: ${err.message}`); 232 - inFlight = undefined; 233 - }); 234 - child.on('exit', (code) => { 235 - inFlight = undefined; 236 - if (code !== 0) { 237 - logger.error( 238 - `[package-docs] generator exited with code ${code}.${stderr ? `\n${stderr.trim()}` : ''}`, 239 - ); 240 - } else { 241 - logger.info('[package-docs] regenerated.'); 242 - } 243 - if (pendingRun) { 244 - pendingRun = false; 245 - runGenerator(logger); 246 - } 247 - }); 248 - } 249 - 250 - function schedule(logger: { info: (msg: string) => void; error: (msg: string) => void }): void { 251 - if (timer) clearTimeout(timer); 252 - timer = setTimeout(() => { 253 - timer = undefined; 254 - runGenerator(logger); 255 - }, debounceMs); 256 - } 257 - 258 - return { 259 - apply: 'serve', 260 - configureServer(server) { 261 - server.watcher.add(packageSrcDir); 262 - server.watcher.add(packageJsonPath); 263 - const logger = { 264 - error: (msg: string) => server.config.logger.error(msg, { timestamp: true }), 265 - info: (msg: string) => server.config.logger.info(msg, { timestamp: true }), 266 - }; 267 - const handleChange = (filePath: string) => { 268 - if (!shouldTrigger(filePath)) return; 269 - schedule(logger); 270 - }; 271 - server.watcher.on('add', handleChange); 272 - server.watcher.on('change', handleChange); 273 - server.watcher.on('unlink', handleChange); 274 - }, 275 - name: 'package-source-watcher', 276 - }; 49 + return files.map((filePath) => { 50 + const relativePath = relative(contentDocsDir, filePath).split(sep).join('/'); 51 + const withoutExtension = relativePath.slice(0, -'.mdx'.length); 52 + return { path: `/docs/${withoutExtension}.md` }; 53 + }); 277 54 } 278 55 279 56 export default defineConfig(async () => { 280 - const packageDocsCatalog = resolvePackageDocsCatalog({ 281 - exportsField: packageJson.exports, 282 - packageRoot: packageRootDir, 283 - }); 284 - const markdownPrerenderPages = await getMarkdownPrerenderPages(packageDocsCatalog); 57 + const markdownPrerenderPages = await getMarkdownPrerenderPages(); 285 58 const baseUrl = process.env.VITE_BASE_URL ?? '/'; 286 59 // Storybook is copied into <base>/storybook/ by the Pages deploy workflow after this 287 60 // build, so the link crawler must not try to fetch it from the preview server. ··· 311 84 outDir: 'public', 312 85 }, 313 86 }, 87 + ssr: { 88 + build: { 89 + rolldownOptions: { 90 + external: ['env', 'wasi_snapshot_preview1'], 91 + }, 92 + }, 93 + }, 314 94 }, 315 95 optimizeDeps: { 96 + // @luke-ui/react is a workspace package excluded from pre-bundling so its 97 + // source hot-reloads directly. Its runtime npm dependencies are listed 98 + // explicitly here so Vite discovers them at cold start instead of lazily 99 + // when a doc page first renders a component that needs them — a lazy 100 + // discovery mid-navigation forces a dependency re-optimize + full reload, 101 + // which corrupts the in-flight React render with an "Invalid hook call". 316 102 exclude: ['@luke-ui/react'], 103 + include: [ 104 + '@react-aria/utils', 105 + '@vanilla-extract/recipes', 106 + '@vanilla-extract/recipes/createRuntimeFn', 107 + 'react-aria-components/Breadcrumbs', 108 + 'react-aria-components/Button', 109 + 'react-aria-components/Collection', 110 + 'react-aria-components/ComboBox', 111 + 'react-aria-components/composeRenderProps', 112 + 'react-aria-components/FieldError', 113 + 'react-aria-components/Form', 114 + 'react-aria-components/Group', 115 + 'react-aria-components/Header', 116 + 'react-aria-components/I18nProvider', 117 + 'react-aria-components/Input', 118 + 'react-aria-components/Label', 119 + 'react-aria-components/Link', 120 + 'react-aria-components/ListBox', 121 + 'react-aria-components/slots', 122 + 'react-aria-components/Text', 123 + 'react-aria-components/TextField', 124 + 'react-aria-components/useAsyncList', 125 + ], 317 126 }, 318 127 plugins: lazyPlugins(async () => [ 319 128 staticFunctionBasePathPlugin(), 320 - markdownRewritePlugin(), 321 - packageDocsPlugin(), 322 - packageSourceWatcherPlugin(), 323 129 mdx(await import('./source.config')), 324 - story({ tsconfigPath: fileURLToPath(new URL('./tsconfig.json', import.meta.url)) }), 325 130 tailwindcss(), 326 131 tanstackStart({ 327 132 pages: [ ··· 333 138 prerender: { 334 139 crawlLinks: true, 335 140 enabled: true, 336 - filter: (page) => 337 - !page.path.endsWith('.mdx') && 338 - !page.path.endsWith('.md') && 339 - !page.path.startsWith(storybookPath), 141 + filter: (page) => !page.path.startsWith(storybookPath), 340 142 }, 341 143 }), 342 144 react(), 343 - nitro(), 145 + nitro({ 146 + rolldownConfig: { 147 + external: ['env', 'wasi_snapshot_preview1'], 148 + output: {}, 149 + }, 150 + }), 344 151 ]), 345 152 resolve: { 346 - external: ['ts-morph'], 347 153 tsconfigPaths: true, 348 154 }, 349 155 server: {
+6 -1
docs/adr/0001-component-tier-taxonomy.md
··· 1 1 # Component tier taxonomy: Atom, Composed, Primitive 2 2 3 + > **Note:** The docs location detail is superseded by [ADR-0007](0007-docs-moved-to-hosted-app.md). 4 + > Primitives are mentioned in prose on their parent component's hosted docs page (see, for example, 5 + > the "Primitive kit" section of the Combobox Field docs), but do not yet have their own generated 6 + > API reference. 7 + 3 8 Luke UI classifies components into three tiers: **Atom**, **Composed**, and **Primitive**. The tier 4 - decides whether the component appears in hosted docs and how it is described in source and package 9 + decides whether the component appears in hosted docs and how it is described in source and hosted 5 10 docs. 6 11 7 12 ## Decision
+3
docs/adr/0003-package-docs-surface.md
··· 1 1 # Package docs are a separate AI-native surface 2 2 3 + > **Superseded by [ADR-0007](0007-docs-moved-to-hosted-app.md).** Package docs are no longer shipped 4 + > on npm. The hosted docs app is the primary docs surface. 5 + 3 6 `@luke-ui/react` ships per-export documentation under `packages/@luke-ui/react/docs/`. Those docs 4 7 are generated from JSDoc, TypeScript types, and authored prose. They are separate from the hosted 5 8 Fumadocs site in `apps/docs`.
+3
docs/adr/0006-docs-md-structure-standard.md
··· 1 1 # Standard structure for `.docs.md` prose files 2 2 3 + > **Superseded by [ADR-0007](0007-docs-moved-to-hosted-app.md).** Component prose now lives in 4 + > `apps/docs/content/docs/**/*.mdx`. `.docs.md` files are no longer used. 5 + 3 6 Every `src/<component>/<component>.docs.md` file follows the same section order. Readers can then 4 7 predict where to find usage, props, accessibility notes, and cross-references regardless of which 5 8 component they are reading.
+38
docs/adr/0007-docs-moved-to-hosted-app.md
··· 1 + # Docs moved to the hosted docs app 2 + 3 + Component documentation has moved out of `@luke-ui/react` and into the hosted docs app in 4 + `apps/docs`. The hosted docs app is now the primary docs surface. The package README links to it, 5 + and package docs are no longer shipped on npm. 6 + 7 + This supersedes [ADR-0003](0003-package-docs-surface.md), which established a separate package-docs 8 + surface, and [ADR-0006](0006-docs-md-structure-standard.md), which standardised the `.docs.md` prose 9 + format. 10 + 11 + ## Decision 12 + 13 + - All component prose lives in `apps/docs/content/docs/**/*.mdx`. 14 + - Component MDX pages are the authoritative docs for app developers and library authors. 15 + - API reference tables are generated from TypeScript types with `fumadocs-typescript`'s 16 + `remarkAutoTypeTable` plugin. 17 + - Interactive examples live in `apps/docs/src/examples/<component>/` and render through the 18 + `<Example>` component. 19 + - `llms.txt`, `llms-full.txt`, and `.md` per-page routes are provided by Fumadocs built-ins. 20 + - The `@luke-ui/react` package README links to the hosted docs. 21 + - The package no longer ships generated docs under `packages/@luke-ui/react/docs/` or includes them 22 + in the npm `files` allowlist. 23 + 24 + ## Rejected options 25 + 26 + We rejected keeping the two-surface split (hosted docs + package docs). Maintaining the package docs 27 + generator, virtual modules, and a separate prose source in `src/<component>/<component>.docs.md` 28 + created duplication and tooling overhead without reaching a meaningfully different audience. The 29 + same readers can use the hosted docs, the `llms.txt` index, and the per-page `.md` routes. 30 + 31 + ## Consequences 32 + 33 + - Prose is co-located with the docs app instead of the component source. 34 + - Component docs no longer need a `.docs.md` file or an `<include>` of generated package docs. 35 + - The `generate:docs` and `check:docs` scripts in `@luke-ui/react` are removed. 36 + - The `@luke-ui/docs-tools` package and package-docs Vite plugins are removed. 37 + - The hosted docs app type-checks and builds the full docs surface. 38 + - ADR-0003 and ADR-0006 are superseded.
+2 -1
packages/turbo-generators/config.ts
··· 1 - import { DOC_GROUPS } from '@luke-ui/docs-tools/package-docs-catalog'; 2 1 import type { PlopTypes } from '@turbo/gen'; 3 2 import * as z from 'zod'; 4 3 import { applyComponentCreationPlan } from './src/apply-component-creation-plan.js'; ··· 7 6 const COMPONENT_NAME_RE = /^[A-Za-z][A-Za-z0-9-]*$/; 8 7 const COMPONENT_TIERS = ['atom', 'composed'] as const; 9 8 const COMPONENT_STYLING = ['none', 'recipe'] as const; 9 + // Mirrors apps/docs/content/docs/components/*/meta.json — the pages listed there. 10 + const DOC_GROUPS = ['actions', 'feedback', 'forms', 'typography', 'visuals'] as const; 10 11 11 12 const componentAnswersSchema = z.object({ 12 13 docsGroup: z.enum(DOC_GROUPS),
+1 -3
packages/turbo-generators/package.json
··· 10 10 "generate:component": "tsx scripts/generate-component.ts", 11 11 "test": "vp test run" 12 12 }, 13 - "dependencies": { 14 - "@luke-ui/docs-tools": "workspace:*" 15 - }, 13 + "dependencies": {}, 16 14 "devDependencies": { 17 15 "@types/node": "catalog:", 18 16 "tsx": "catalog:",
-6
apps/docs/src/package-docs.d.ts
··· 1 - declare module 'virtual:package-docs' { 2 - import type { PackageDocsCatalogMetadata } from '@luke-ui/docs-tools/package-docs-catalog'; 3 - 4 - export const packageDocsCatalog: Array<PackageDocsCatalogMetadata>; 5 - export const packageDocs: Record<string, string>; 6 - }
-33
packages/@luke-ui/docs-tools/package.json
··· 1 - { 2 - "name": "@luke-ui/docs-tools", 3 - "version": "0.0.0", 4 - "private": true, 5 - "type": "module", 6 - "exports": { 7 - "./discover-exports": "./src/discover-exports.ts", 8 - "./package-docs-catalog": "./src/package-docs-catalog.ts", 9 - "./parse-types": "./src/parse-types.ts", 10 - "./render-index": "./src/render-index.ts", 11 - "./render-llms-full": "./src/render-llms-full.ts", 12 - "./render-page": "./src/render-page.ts", 13 - "./title": "./src/title.ts" 14 - }, 15 - "scripts": { 16 - "check:format": "vp fmt . --check", 17 - "check:lint": "vp lint . --type-aware", 18 - "check:types": "tsc --noEmit -p tsconfig.json", 19 - "fix:format": "vp fmt . --write", 20 - "fix:lint": "vp lint . --type-aware --fix", 21 - "test": "vp test run" 22 - }, 23 - "dependencies": { 24 - "ts-morph": "catalog:" 25 - }, 26 - "devDependencies": { 27 - "@types/node": "catalog:", 28 - "@types/react": "catalog:", 29 - "react-aria-components": "catalog:", 30 - "typescript": "catalog:", 31 - "vite-plus": "catalog:" 32 - } 33 - }
-19
packages/@luke-ui/docs-tools/tsconfig.json
··· 1 - { 2 - "compilerOptions": { 3 - "allowJs": true, 4 - "forceConsistentCasingInFileNames": true, 5 - "isolatedModules": true, 6 - "lib": ["ES2022"], 7 - "module": "NodeNext", 8 - "moduleDetection": "force", 9 - "moduleResolution": "NodeNext", 10 - "noEmit": true, 11 - "noUncheckedIndexedAccess": true, 12 - "resolveJsonModule": true, 13 - "skipLibCheck": true, 14 - "strict": true, 15 - "target": "ES2022", 16 - "types": ["node"] 17 - }, 18 - "include": ["src/**/*.ts", "vite.config.ts"] 19 - }
-8
packages/@luke-ui/docs-tools/vite.config.ts
··· 1 - import { defineConfig } from 'vite-plus'; 2 - 3 - export default defineConfig({ 4 - test: { 5 - environment: 'node', 6 - include: ['src/**/*.test.ts'], 7 - }, 8 - });
-1
packages/@luke-ui/react/.gitattributes
··· 1 - dist/docs/** linguist-generated=true
+4 -33
packages/@luke-ui/react/AGENTS.md
··· 13 13 14 14 A component directory contains: 15 15 16 - - `[component].docs.md`: authored usage guidance consumed by the docs generator 17 16 - `[component].stories.tsx`: Storybook stories that also serve as tests 18 17 - `index.tsx`: component implementation 19 18 - `primitive/`: optional primitive exports ··· 25 24 26 25 Primitives exported from `*/primitive/` are building blocks for library authors. They are not 27 26 promoted in beginner app-developer navigation, but they are public API and should have enough 28 - generated documentation for power users and agents. 27 + documentation in the hosted docs app for power users and agents. 29 28 30 - ## Documentation generation 29 + ## Documentation 31 30 32 - JSDoc and TypeScript types drive generated docs under `docs/`. 31 + JSDoc and TypeScript types drive the docs app. 33 32 34 33 When adding or modifying a component: 35 34 ··· 43 42 `isDisabled?: RACButtonProps['isDisabled']`. 44 43 - Do not re-declare every React Aria Components prop. Re-declare only props an app developer is 45 44 likely to reach for. 46 - - Long-tail inherited props are covered by the generated "Extends" pointer. 47 - 48 - ## Generated docs 49 - 50 - Generated docs are ignored by Git. After changing JSDoc or `.docs.md` prose, run: 51 - 52 - ```sh 53 - pnpm --filter @luke-ui/react generate:docs 54 - ``` 55 - 56 - Use `pnpm --filter @luke-ui/react check:docs` as a smoke test that the generator is healthy. It is 57 - not a stale-file check because generated output is ignored. 58 - 59 - ## Dev loop 60 - 61 - During `pnpm dev` or `turbo dev`, the docs app does two things: 62 - 63 - 1. Watches generated `docs/*.md` files and hot-reloads pages when they change. 64 - 2. Watches `src/**/*.{ts,tsx,docs.md}` in the package and re-runs `generate:docs` on change, 65 - debounced by about 300ms. 66 - 67 - The loop is the same for prose and JSDoc edits: 68 - 69 - 1. Edit `[component].docs.md`, `index.tsx` JSDoc, or prop types. 70 - 2. Save. 71 - 3. The generator re-runs, `docs/*.md` updates, and the page reloads. 72 - 73 - If the generator fails during a mid-edit syntax error, the dev server logs the error and keeps 74 - running. The next successful save regenerates the docs. 45 + - Long-tail inherited props are covered by the docs app's "Extends" pointer.
+8 -2
packages/@luke-ui/react/README.md
··· 23 23 24 24 ## Components and docs 25 25 26 - This package ships per-export documentation under `docs/`. The full index is in 27 - [`docs/llms.txt`](./docs/llms.txt), which is readable by humans and AI agents. 26 + Full component documentation, interactive examples, and API reference are at 27 + [lukebennett88.github.io/luke-ui/docs](https://lukebennett88.github.io/luke-ui/docs). 28 + 29 + AI agents can fetch documentation at: 30 + 31 + - [llms.txt](https://lukebennett88.github.io/luke-ui/llms.txt) — component index 32 + - [llms-full.txt](https://lukebennett88.github.io/luke-ui/llms-full.txt) — full docs 33 + - Append `.md` to any docs URL for per-page markdown 28 34 29 35 Components follow the 30 36 [three-tier taxonomy](https://github.com/lukebennett88/luke-ui/blob/main/docs/adr/0001-component-tier-taxonomy.md):
+2 -7
packages/@luke-ui/react/package.json
··· 4 4 "license": "MIT", 5 5 "files": [ 6 6 "dist", 7 - "docs", 8 7 "README.md", 9 8 "LICENSE" 10 9 ], ··· 48 47 "build": "pnpm run build:tsdown", 49 48 "build:storybook": "storybook build", 50 49 "build:tsdown": "vp pack", 51 - "check:docs": "pnpm run generate:docs", 52 50 "check:format": "vp fmt . --check", 53 51 "check:lint": "vp lint . --type-aware", 54 52 "check:types": "pnpm run check:types:storybook && pnpm run check:types:browser && pnpm run check:types:node", 55 53 "check:types:browser": "tsc --noEmit -p tsconfig.json", 56 54 "check:types:node": "tsc --noEmit -p scripts/tsconfig.json", 57 55 "check:types:storybook": "tsc --noEmit -p tsconfig.storybook.json", 58 - "clean": "rm -rf .turbo .generated dist docs storybook-static", 56 + "clean": "rm -rf .turbo .generated dist storybook-static", 59 57 "dev": "pnpm run dev:tsdown", 60 58 "dev:storybook": "rm -rf node_modules/.cache/storybook node_modules/.vite-storybook && storybook dev -p 6006", 61 59 "dev:tsdown": "vp pack --watch", ··· 65 63 "generate": "pnpm run generate:assets", 66 64 "generate:assets": "pnpm run generate:icons && pnpm run generate:color-tokens", 67 65 "generate:color-tokens": "node scripts/generate-color-tokens.ts && vp fmt .generated/color-tokens.generated.ts --write", 68 - "generate:docs": "tsx scripts/generate-docs.ts && vp fmt docs/ dist/docs/ --write", 69 66 "generate:icons": "tsx scripts/build-icons.ts", 70 67 "test": "pnpm run test:unit && pnpm run test:browser && pnpm run test:storybook && pnpm run test:visual", 71 68 "test:browser": "vp test run --project=browser", ··· 86 83 }, 87 84 "devDependencies": { 88 85 "@arethetypeswrong/core": "catalog:", 89 - "@luke-ui/docs-tools": "workspace:*", 90 86 "@rollup/plugin-babel": "catalog:", 91 87 "@storybook/addon-a11y": "catalog:", 92 88 "@storybook/addon-docs": "catalog:", ··· 113 109 "typescript": "catalog:", 114 110 "vite": "catalog:", 115 111 "vite-plus": "catalog:", 116 - "vitest": "catalog:", 117 - "zod": "catalog:" 112 + "vitest": "catalog:" 118 113 }, 119 114 "peerDependencies": { 120 115 "react": "catalog:",
-26
apps/docs/src/button/button.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { ButtonProps } from '@luke-ui/react/button'; 3 - import { Button } from '@luke-ui/react/button'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type ButtonStoryProps = Pick< 9 - ButtonProps, 10 - 'children' | 'tone' | 'size' | 'isBlock' | 'isDisabled' | 'isPending' 11 - >; 12 - 13 - function ButtonPlayground(props: ButtonStoryProps) { 14 - return ( 15 - <StoryWrapper> 16 - <Button {...props} /> 17 - </StoryWrapper> 18 - ); 19 - } 20 - 21 - export const story = defineStory({ 22 - args: { 23 - initial: { children: 'Button' }, 24 - }, 25 - Component: ButtonPlayground, 26 - });
-38
apps/docs/src/combobox-field/combobox-field.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { ComboboxFieldProps } from '@luke-ui/react/combobox-field'; 3 - import { ComboboxField } from '@luke-ui/react/combobox-field'; 4 - import { ComboboxItem } from '@luke-ui/react/combobox-field/primitive'; 5 - import { StoryWrapper } from '../lib/story-wrapper'; 6 - 7 - const { defineStory } = defineStoryFactory(); 8 - 9 - type Item = { id: string; label: string }; 10 - 11 - const items: Array<Item> = [ 12 - { id: 'au', label: 'Australia' }, 13 - { id: 'ca', label: 'Canada' }, 14 - { id: 'nz', label: 'New Zealand' }, 15 - { id: 'us', label: 'United States' }, 16 - ]; 17 - 18 - type ComboboxFieldStoryProps = Pick< 19 - ComboboxFieldProps<Item>, 20 - 'label' | 'name' | 'placeholder' | 'description' | 'isRequired' | 'isDisabled' | 'size' 21 - >; 22 - 23 - function ComboboxFieldPlayground(props: ComboboxFieldStoryProps) { 24 - return ( 25 - <StoryWrapper> 26 - <ComboboxField defaultItems={items} {...props}> 27 - {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 28 - </ComboboxField> 29 - </StoryWrapper> 30 - ); 31 - } 32 - 33 - export const story = defineStory({ 34 - args: { 35 - initial: { label: 'Country', name: 'country', placeholder: 'Select a country...' }, 36 - }, 37 - Component: ComboboxFieldPlayground, 38 - });
+141
apps/docs/src/components/example-block.tsx
··· 1 + import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock'; 2 + import { buttonVariants } from 'fumadocs-ui/components/ui/button'; 3 + import { CodeIcon } from 'lucide-react'; 4 + import { Suspense, use, useId, useState } from 'react'; 5 + import type { ComponentType, JSX } from 'react'; 6 + import { StoryWrapper } from '../lib/story-wrapper'; 7 + 8 + type ExampleProps = { 9 + component: string; 10 + description: string; 11 + name: string; 12 + title: string; 13 + }; 14 + 15 + export function ExampleBlock(props: ExampleProps): JSX.Element { 16 + return ( 17 + <Suspense 18 + fallback={<div className="rounded-lg border border-fd-border p-4">Loading example…</div>} 19 + > 20 + <ExampleContent {...props} /> 21 + </Suspense> 22 + ); 23 + } 24 + 25 + const _modules = import.meta.glob<ComponentType>('../examples/*/*.tsx', { 26 + eager: false, 27 + import: 'default', 28 + }); 29 + 30 + function ExampleContent({ component, name, title }: ExampleProps): JSX.Element { 31 + const result = use(loadExample(component, name)); 32 + const [showCode, setShowCode] = useState(false); 33 + const codeId = useId(); 34 + 35 + if (!result.ok) { 36 + return ( 37 + <div className="rounded-lg border border-fd-destructive p-4 text-fd-destructive"> 38 + Failed to load example {component}/{name}: {result.error.message} 39 + </div> 40 + ); 41 + } 42 + 43 + const [PreviewComponent, source] = result.data; 44 + 45 + return ( 46 + <div className="not-prose overflow-hidden rounded-lg border border-fd-border"> 47 + <div className="flex items-center justify-between gap-2 border-fd-border border-b bg-fd-card px-4 py-2"> 48 + <span className="text-fd-muted-foreground text-sm">{title}</span> 49 + <button 50 + aria-controls={codeId} 51 + aria-expanded={showCode} 52 + className={buttonVariants({ size: 'sm', variant: 'ghost' })} 53 + onClick={() => setShowCode((previous) => !previous)} 54 + type="button" 55 + > 56 + <CodeIcon className="size-4" /> 57 + {showCode ? 'Hide code' : 'Show code'} 58 + </button> 59 + </div> 60 + <StoryWrapper> 61 + <PreviewComponent /> 62 + </StoryWrapper> 63 + {showCode ? ( 64 + <div id={codeId}> 65 + <DynamicCodeBlock 66 + code={source.trim()} 67 + codeblock={{ className: 'my-0 rounded-none border-x-0 border-b-0 shadow-none' }} 68 + lang="tsx" 69 + /> 70 + </div> 71 + ) : null} 72 + </div> 73 + ); 74 + } 75 + 76 + const _sources = import.meta.glob<string>('../examples/*/*.tsx', { 77 + eager: false, 78 + query: '?raw', 79 + import: 'default', 80 + }); 81 + 82 + type ExampleTuple = [ComponentType, string]; 83 + 84 + type ExampleResult = 85 + | { 86 + ok: true; 87 + data: ExampleTuple; 88 + } 89 + | { 90 + ok: false; 91 + error: Error; 92 + }; 93 + 94 + const exampleCache = new Map<string, Promise<ExampleResult>>(); 95 + 96 + function findExample( 97 + component: string, 98 + name: string, 99 + ): [() => Promise<ComponentType>, () => Promise<string>] | null { 100 + const key = `../examples/${component}/${name}.tsx`; 101 + const loadModule = _modules[key]; 102 + const loadSource = _sources[key]; 103 + 104 + if (!loadModule || !loadSource) return null; 105 + 106 + return [loadModule, loadSource]; 107 + } 108 + 109 + function loadExample(component: string, name: string): Promise<ExampleResult> { 110 + const key = `${component}/${name}`; 111 + const cached = exampleCache.get(key); 112 + if (cached) return cached; 113 + 114 + const match = findExample(component, name); 115 + if (!match) { 116 + const promise = Promise.resolve({ 117 + ok: false, 118 + error: new Error(`Example not found: ${component}/${name}`), 119 + } satisfies ExampleResult); 120 + exampleCache.set(key, promise); 121 + return promise; 122 + } 123 + 124 + const [loadModule, loadSource] = match; 125 + const promise = Promise.all([loadModule(), loadSource()]) 126 + .then( 127 + ([loadedComponent, loadedSource]): ExampleResult => ({ 128 + ok: true, 129 + data: [loadedComponent, loadedSource], 130 + }), 131 + ) 132 + .catch( 133 + (err): ExampleResult => ({ 134 + ok: false, 135 + error: err instanceof Error ? err : new Error(String(err)), 136 + }), 137 + ); 138 + 139 + exampleCache.set(key, promise); 140 + return promise; 141 + }
-23
apps/docs/src/emoji/emoji.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { EmojiProps } from '@luke-ui/react/emoji'; 3 - import { Emoji } from '@luke-ui/react/emoji'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type EmojiStoryProps = Pick<EmojiProps, 'emoji' | 'label'>; 9 - 10 - function EmojiPlayground(props: EmojiStoryProps) { 11 - return ( 12 - <StoryWrapper> 13 - <Emoji {...props} /> 14 - </StoryWrapper> 15 - ); 16 - } 17 - 18 - export const story = defineStory({ 19 - args: { 20 - initial: { emoji: '🎉', label: 'Celebration' }, 21 - }, 22 - Component: EmojiPlayground, 23 - });
-26
apps/docs/src/heading/heading.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { HeadingProps } from '@luke-ui/react/heading'; 3 - import { Heading } from '@luke-ui/react/heading'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type HeadingStoryProps = Pick< 9 - HeadingProps, 10 - 'children' | 'elementType' | 'level' | 'color' | 'fontWeight' 11 - >; 12 - 13 - function HeadingPlayground(props: HeadingStoryProps) { 14 - return ( 15 - <StoryWrapper> 16 - <Heading {...props} /> 17 - </StoryWrapper> 18 - ); 19 - } 20 - 21 - export const story = defineStory({ 22 - args: { 23 - initial: { children: 'Heading text' }, 24 - }, 25 - Component: HeadingPlayground, 26 - });
-26
apps/docs/src/icon-button/icon-button.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { IconButtonProps } from '@luke-ui/react/icon-button'; 3 - import { IconButton } from '@luke-ui/react/icon-button'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type IconButtonStoryProps = Pick< 9 - IconButtonProps, 10 - 'icon' | 'aria-label' | 'tone' | 'size' | 'isDisabled' 11 - >; 12 - 13 - function IconButtonPlayground(props: IconButtonStoryProps) { 14 - return ( 15 - <StoryWrapper> 16 - <IconButton {...props} /> 17 - </StoryWrapper> 18 - ); 19 - } 20 - 21 - export const story = defineStory({ 22 - args: { 23 - initial: { 'aria-label': 'Add', icon: 'add' }, 24 - }, 25 - Component: IconButtonPlayground, 26 - });
-23
apps/docs/src/icon/icon.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { IconProps } from '@luke-ui/react/icon'; 3 - import { Icon } from '@luke-ui/react/icon'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type IconStoryProps = Pick<IconProps, 'name' | 'size' | 'title'>; 9 - 10 - function IconPlayground(props: IconStoryProps) { 11 - return ( 12 - <StoryWrapper> 13 - <Icon {...props} /> 14 - </StoryWrapper> 15 - ); 16 - } 17 - 18 - export const story = defineStory({ 19 - args: { 20 - initial: { name: 'add', title: 'Add' }, 21 - }, 22 - Component: IconPlayground, 23 - });
-16
apps/docs/src/lib/markdown-url.test.ts
··· 1 - import { describe, expect, it } from 'vite-plus/test'; 2 - import { mapPublicToInternal, withBasePath } from './markdown-url'; 3 - 4 - describe('markdown-url', () => { 5 - it('keeps docs markdown URLs on the same public route under a base path', () => { 6 - expect(withBasePath('/docs/components/actions/button.md', '/luke-ui/')).toBe( 7 - '/luke-ui/docs/components/actions/button.md', 8 - ); 9 - }); 10 - 11 - it('rewrites base-prefixed docs markdown URLs to the internal renderer route', () => { 12 - expect(mapPublicToInternal('/luke-ui/docs/components/actions/button.md', '/luke-ui/')).toBe( 13 - '/markdown/components/actions/button', 14 - ); 15 - }); 16 - });
-45
apps/docs/src/lib/markdown-url.ts
··· 1 - const DOCS_MARKDOWN_PREFIX = '/docs/'; 2 - const DOCS_MARKDOWN_SUFFIX = '.md'; 3 - const INTERNAL_MARKDOWN_PREFIX = '/markdown/'; 4 - 5 - export function toPublic(slug: string): string { 6 - return slug === 'index' ? '/docs.md' : `${DOCS_MARKDOWN_PREFIX}${slug}${DOCS_MARKDOWN_SUFFIX}`; 7 - } 8 - 9 - export function withBasePath(publicPath: string, basePath: string): string { 10 - if (basePath === '/' || basePath === '') return publicPath; 11 - const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`; 12 - return `${normalizedBase}${publicPath.replace(/^\//, '')}`; 13 - } 14 - 15 - export function toInternal(publicPath: string, basePath = '/'): string | null { 16 - const slug = publicToSlug(publicPath, basePath); 17 - return slug ? `${INTERNAL_MARKDOWN_PREFIX}${slug}` : null; 18 - } 19 - 20 - export function mapPublicToInternal(url: string | undefined, basePath = '/'): string | null { 21 - if (!url) return null; 22 - const [pathname, suffix = ''] = url.split(/(?=[?#])/); 23 - const internalPath = toInternal(pathname ?? '', basePath); 24 - return internalPath ? `${internalPath}${suffix}` : null; 25 - } 26 - 27 - export function internalSegmentsToSourceSlugs(segments: Array<string>): Array<string> { 28 - return segments.length === 1 && segments[0] === 'index' ? [] : segments; 29 - } 30 - 31 - function publicToSlug(publicPath: string, basePath: string): string | null { 32 - const path = stripBasePath(publicPath, basePath); 33 - if (path === '/docs.md') return 'index'; 34 - if (!path.startsWith(DOCS_MARKDOWN_PREFIX)) return null; 35 - if (!path.endsWith(DOCS_MARKDOWN_SUFFIX)) return null; 36 - return path.slice(DOCS_MARKDOWN_PREFIX.length, -DOCS_MARKDOWN_SUFFIX.length); 37 - } 38 - 39 - function stripBasePath(path: string, basePath: string): string { 40 - if (basePath === '/' || basePath === '') return path; 41 - const normalizedBase = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; 42 - if (path === normalizedBase) return '/'; 43 - if (!path.startsWith(`${normalizedBase}/`)) return path; 44 - return path.slice(normalizedBase.length); 45 - }
+6 -2
apps/docs/src/lib/storybook.ts
··· 1 - import { withBasePath } from './markdown-url'; 2 - 3 1 const STORYBOOK_DEV_URL = 'http://localhost:6006'; 4 2 5 3 const COMPONENT_DOC_PATH = /^components\/([^/]+)\/([^/]+)\.mdx$/; 4 + 5 + export function withBasePath(publicPath: string, basePath: string): string { 6 + if (basePath === '/' || basePath === '') return publicPath; 7 + const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`; 8 + return `${normalizedBase}${publicPath.replace(/^\//, '')}`; 9 + } 6 10 7 11 export function getStorybookBaseUrl(basePath: string): string { 8 12 if (import.meta.env.DEV) return STORYBOOK_DEV_URL;
-23
apps/docs/src/link/link.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { LinkProps } from '@luke-ui/react/link'; 3 - import { Link } from '@luke-ui/react/link'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type LinkStoryProps = Pick<LinkProps, 'children' | 'href' | 'tone' | 'isStandalone' | 'isDisabled'>; 9 - 10 - function LinkPlayground(props: LinkStoryProps) { 11 - return ( 12 - <StoryWrapper> 13 - <Link {...props} /> 14 - </StoryWrapper> 15 - ); 16 - } 17 - 18 - export const story = defineStory({ 19 - args: { 20 - initial: { children: 'Link', href: '#' }, 21 - }, 22 - Component: LinkPlayground, 23 - });
-33
apps/docs/src/loading-skeleton/loading-skeleton.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { LoadingSkeletonProps } from '@luke-ui/react/loading-skeleton'; 3 - import { LoadingSkeleton } from '@luke-ui/react/loading-skeleton'; 4 - import { Text } from '@luke-ui/react/text'; 5 - import { StoryWrapper } from '../lib/story-wrapper'; 6 - 7 - const { defineStory } = defineStoryFactory(); 8 - 9 - type LoadingSkeletonStoryProps = Pick< 10 - LoadingSkeletonProps, 11 - 'children' | 'isLoading' | 'as' | 'borderRadius' 12 - >; 13 - 14 - function LoadingSkeletonPlayground(props: LoadingSkeletonStoryProps) { 15 - return ( 16 - <StoryWrapper> 17 - <div style={{ maxWidth: '30ch' }}> 18 - <Text> 19 - <LoadingSkeleton {...props} /> 20 - </Text> 21 - </div> 22 - </StoryWrapper> 23 - ); 24 - } 25 - 26 - export const story = defineStory({ 27 - args: { 28 - initial: { 29 - children: 'A short paragraph of placeholder copy that wraps across two lines.', 30 - }, 31 - }, 32 - Component: LoadingSkeletonPlayground, 33 - });
-26
apps/docs/src/loading-spinner/loading-spinner.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { LoadingSpinnerProps } from '@luke-ui/react/loading-spinner'; 3 - import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type LoadingSpinnerStoryProps = Pick< 9 - LoadingSpinnerProps, 10 - 'aria-label' | 'value' | 'size' | 'color' 11 - >; 12 - 13 - function LoadingSpinnerPlayground(props: LoadingSpinnerStoryProps) { 14 - return ( 15 - <StoryWrapper> 16 - <LoadingSpinner {...props} /> 17 - </StoryWrapper> 18 - ); 19 - } 20 - 21 - export const story = defineStory({ 22 - args: { 23 - initial: { 'aria-label': 'Loading' }, 24 - }, 25 - Component: LoadingSpinnerPlayground, 26 - });
-26
apps/docs/src/numeral/numeral.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { NumeralProps } from '@luke-ui/react/numeral'; 3 - import { Numeral } from '@luke-ui/react/numeral'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type NumeralStoryProps = Pick< 9 - NumeralProps, 10 - 'value' | 'format' | 'currency' | 'unit' | 'precision' | 'abbreviate' 11 - >; 12 - 13 - function NumeralPlayground(props: NumeralStoryProps) { 14 - return ( 15 - <StoryWrapper> 16 - <Numeral {...props} /> 17 - </StoryWrapper> 18 - ); 19 - } 20 - 21 - export const story = defineStory({ 22 - args: { 23 - initial: { value: 12345.67 }, 24 - }, 25 - Component: NumeralPlayground, 26 - });
+2 -22
apps/docs/src/routes/llms[.]txt.ts
··· 1 - import { packageDocsCatalog } from 'virtual:package-docs'; 2 - import { renderIndex } from '@luke-ui/docs-tools/render-index'; 3 1 import { createFileRoute } from '@tanstack/react-router'; 4 - import packageJson from '../../../../packages/@luke-ui/react/package.json' with { type: 'json' }; 5 - import { toPublic } from '../lib/markdown-url'; 2 + import { llms } from 'fumadocs-core/source'; 6 3 import { source } from '../lib/source'; 7 4 8 5 export const Route = createFileRoute('/llms.txt')({ 9 6 server: { 10 7 handlers: { 11 8 GET: () => { 12 - const pageHrefBySlug = new Map( 13 - source.getPages().map((page) => [page.slugs.at(-1), `.${page.url}.md`]), 14 - ); 15 - const entriesWithHref = packageDocsCatalog.map((entry) => { 16 - return Object.assign({}, entry, { 17 - href: 18 - entry.shape === 'barrel' 19 - ? `.${toPublic(entry.slug)}` 20 - : (pageHrefBySlug.get(entry.slug) ?? `./${entry.slug}.md`), 21 - }); 22 - }); 23 - 24 - const txt = renderIndex({ 25 - entries: entriesWithHref, 26 - includeLibraryAuthors: false, 27 - packageName: packageJson.name, 28 - }); 29 - return new Response(txt, { 9 + return new Response(llms(source).index(), { 30 10 headers: { 'Content-Type': 'text/plain; charset=utf-8' }, 31 11 }); 32 12 },
-1
apps/docs/src/styles/app.css
··· 4 4 @import 'tailwindcss'; 5 5 @import 'fumadocs-ui/css/neutral.css'; 6 6 @import 'fumadocs-ui/css/preset.css'; 7 - @import '@fumadocs/story/css/preset.css';
-26
apps/docs/src/text-field/text-field.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { TextFieldProps } from '@luke-ui/react/text-field'; 3 - import { TextField } from '@luke-ui/react/text-field'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type TextFieldStoryProps = Pick< 9 - TextFieldProps, 10 - 'name' | 'label' | 'description' | 'placeholder' | 'size' | 'isRequired' 11 - >; 12 - 13 - function TextFieldPlayground(props: TextFieldStoryProps) { 14 - return ( 15 - <StoryWrapper> 16 - <TextField {...props} /> 17 - </StoryWrapper> 18 - ); 19 - } 20 - 21 - export const story = defineStory({ 22 - args: { 23 - initial: { label: 'Email', name: 'email' }, 24 - }, 25 - Component: TextFieldPlayground, 26 - });
-40
apps/docs/src/text/text.story.tsx
··· 1 - import { defineStoryFactory } from '@fumadocs/story/vite/client'; 2 - import type { TextProps } from '@luke-ui/react/text'; 3 - import { Text } from '@luke-ui/react/text'; 4 - import { StoryWrapper } from '../lib/story-wrapper'; 5 - 6 - const { defineStory } = defineStoryFactory(); 7 - 8 - type TextStoryProps = Pick< 9 - TextProps, 10 - | 'children' 11 - | 'elementType' 12 - | 'fontSize' 13 - | 'lineHeight' 14 - | 'color' 15 - | 'fontFamily' 16 - | 'fontVariantNumeric' 17 - | 'fontWeight' 18 - | 'textTransform' 19 - | 'textDecoration' 20 - | 'lineClamp' 21 - | 'shouldDisableTrim' 22 - | 'textAlign' 23 - >; 24 - 25 - function TextPlayground(props: TextStoryProps) { 26 - return ( 27 - <StoryWrapper> 28 - <Text {...props} style={{ display: 'block', inlineSize: '100%' }} /> 29 - </StoryWrapper> 30 - ); 31 - } 32 - 33 - export const story = defineStory({ 34 - args: { 35 - initial: { 36 - children: 'The quick brown fox jumps over the lazy dog.', 37 - }, 38 - }, 39 - Component: TextPlayground, 40 - });
-87
packages/@luke-ui/docs-tools/src/discover-exports.test.ts
··· 1 - import { fileURLToPath } from 'node:url'; 2 - import { describe, expect, it } from 'vite-plus/test'; 3 - import { discoverExports } from './discover-exports.js'; 4 - 5 - const packageRoot = fileURLToPath(new URL('./fixtures/sample-package/', import.meta.url)); 6 - 7 - describe('discoverExports', () => { 8 - it('classifies component-shaped exports as component shape', () => { 9 - const exports = { 10 - './button': './dist/button/index.js', 11 - './icon-button': './dist/icon-button/index.js', 12 - }; 13 - const result = discoverExports(exports); 14 - expect(result.find((e) => e.path === './button')?.shape).toBe('component'); 15 - expect(result.find((e) => e.path === './button')?.pageKind).toBe('component'); 16 - }); 17 - 18 - it('classifies primitive paths as component shape with primitive tier', () => { 19 - const exports = { './button/primitive': './dist/button/primitive/index.js' }; 20 - const result = discoverExports(exports); 21 - const entry = result.find((e) => e.path === './button/primitive'); 22 - expect(entry?.shape).toBe('component'); 23 - expect(entry?.pageKind).toBe('component'); 24 - expect(entry?.tier).toBe('primitive'); 25 - }); 26 - 27 - it('classifies single-export primitive sources as component pages', () => { 28 - const exports = { './single/primitive': './dist/single/primitive/index.js' }; 29 - const result = discoverExports(exports, { packageRoot }); 30 - const entry = result.find((e) => e.path === './single/primitive'); 31 - expect(entry?.shape).toBe('component'); 32 - expect(entry?.pageKind).toBe('component'); 33 - expect(entry?.sourcePath).toContain('/src/single/primitive/index.tsx'); 34 - }); 35 - 36 - it('classifies multi-export primitive sources as barrel pages', () => { 37 - const exports = { './kit/primitive': './dist/kit/primitive/index.js' }; 38 - const result = discoverExports(exports, { packageRoot }); 39 - const entry = result.find((e) => e.path === './kit/primitive'); 40 - expect(entry?.shape).toBe('component'); 41 - expect(entry?.pageKind).toBe('barrel'); 42 - expect(entry?.tier).toBe('primitive'); 43 - }); 44 - 45 - it('classifies multi-export foundations as barrel', () => { 46 - const exports = { 47 - './heading-context': './dist/heading-context/index.js', 48 - './icon-size-context': './dist/icon-size-context/index.js', 49 - './recipes': './dist/recipes/index.js', 50 - './theme': './dist/theme/index.js', 51 - './tokens': './dist/tokens/index.js', 52 - './utils': './dist/utils/index.js', 53 - }; 54 - const result = discoverExports(exports); 55 - expect(result.every((e) => e.shape === 'barrel')).toBe(true); 56 - expect(result.every((e) => e.pageKind === 'barrel')).toBe(true); 57 - }); 58 - 59 - it('classifies styles as foundation barrel API', () => { 60 - const exports = { './styles': './dist/styles/index.js' }; 61 - 62 - const result = discoverExports(exports); 63 - 64 - expect(result.find((e) => e.path === './styles')).toMatchObject({ 65 - pageKind: 'barrel', 66 - shape: 'barrel', 67 - }); 68 - }); 69 - 70 - it('classifies stylesheet/spritesheet/package.json as asset', () => { 71 - const exports = { 72 - './package.json': './package.json', 73 - './spritesheet.svg': './dist/spritesheet.svg', 74 - './stylesheet.css': './dist/stylesheet.css', 75 - }; 76 - const result = discoverExports(exports); 77 - expect(result.every((e) => e.shape === 'asset')).toBe(true); 78 - expect(result.every((e) => e.pageKind === 'asset')).toBe(true); 79 - }); 80 - 81 - it('skips ./package.json from the page-emitting list (asset)', () => { 82 - const exports = { './package.json': './package.json' }; 83 - const result = discoverExports(exports); 84 - const entry = result.find((e) => e.path === './package.json'); 85 - expect(entry?.shape).toBe('asset'); 86 - }); 87 - });
-119
packages/@luke-ui/docs-tools/src/discover-exports.ts
··· 1 - import { existsSync, readFileSync } from 'node:fs'; 2 - import { join } from 'node:path'; 3 - 4 - export type ExportShape = 'component' | 'barrel' | 'asset'; 5 - export type ExportPageKind = 'component' | 'barrel' | 'asset'; 6 - export type ExportTier = 'atom' | 'composed' | 'primitive' | 'n/a'; 7 - 8 - export interface DiscoveredExport { 9 - pageKind: ExportPageKind; 10 - /** The export specifier from package.json#exports, e.g. './button'. */ 11 - path: string; 12 - shape: ExportShape; 13 - /** The slug used for the generated .md filename, e.g. 'button' or 'button-primitive'. */ 14 - slug: string; 15 - /** Absolute path to the source `index.tsx` for components/barrels; undefined for assets. */ 16 - sourcePath?: string; 17 - /** The dist target path (right-hand side of #exports). */ 18 - target: string; 19 - tier: ExportTier; 20 - } 21 - 22 - export interface DiscoverExportsOptions { 23 - /** 24 - * Export specifiers to classify as `barrel` (multi-export foundations). 25 - * Defaults to {@link DEFAULT_BARREL_PATHS}, which is coupled to `@luke-ui/react`. 26 - */ 27 - barrelPaths?: Iterable<string>; 28 - packageRoot?: string; 29 - } 30 - 31 - /** 32 - * Default barrel paths. Some of these (e.g. `./styles`) have only one runtime 33 - * export, so barrel vs. component can't be told apart mechanically and this 34 - * list has to be maintained by hand. 35 - * Other consumers of `discoverExports` should pass their own via {@link DiscoverExportsOptions.barrelPaths}. 36 - */ 37 - export const DEFAULT_BARREL_PATHS: ReadonlyArray<string> = [ 38 - './heading-context', 39 - './icon-size-context', 40 - './recipes', 41 - './styles', 42 - './theme', 43 - './tokens', 44 - './utils', 45 - ]; 46 - 47 - const LEADING_DOT_SLASH = /^\.\//; 48 - const SLASH = /\//g; 49 - const FILE_EXTENSION = /\.[^.]+$/; 50 - const JS_TARGET = /\.js$/; 51 - 52 - export function discoverExports( 53 - exportsField: Record<string, string>, 54 - options: DiscoverExportsOptions = {}, 55 - ): Array<DiscoveredExport> { 56 - const barrelPaths = new Set(options.barrelPaths ?? DEFAULT_BARREL_PATHS); 57 - const result: Array<DiscoveredExport> = []; 58 - for (const [path, target] of Object.entries(exportsField)) { 59 - const { shape, tier } = ((): { shape: ExportShape; tier: ExportTier } => { 60 - if (!JS_TARGET.test(target)) return { shape: 'asset', tier: 'n/a' }; 61 - if (barrelPaths.has(path)) return { shape: 'barrel', tier: 'n/a' }; 62 - return { 63 - shape: 'component', 64 - tier: path.endsWith('/primitive') ? 'primitive' : 'composed', 65 - }; 66 - })(); 67 - const sourcePath = 68 - shape === 'asset' ? undefined : sourceFromExport(options.packageRoot, target); 69 - const pageKind = pageKindFor({ shape, sourcePath, tier }); 70 - const slug = path 71 - .replace(LEADING_DOT_SLASH, '') 72 - .replace(SLASH, '-') 73 - .replace(FILE_EXTENSION, ''); 74 - result.push({ pageKind, path, shape, slug, sourcePath, target, tier }); 75 - } 76 - return result; 77 - } 78 - 79 - function pageKindFor(input: { 80 - shape: ExportShape; 81 - tier: ExportTier; 82 - sourcePath: string | undefined; 83 - }): ExportPageKind { 84 - if (input.shape !== 'component') return input.shape; 85 - if (input.tier !== 'primitive' || !input.sourcePath) return 'component'; 86 - 87 - return countRuntimeExports(input.sourcePath) > 1 ? 'barrel' : 'component'; 88 - } 89 - 90 - function sourceFromExport(root: string | undefined, distTarget: string): string | undefined { 91 - if (!root) return; 92 - 93 - const distRel = distTarget.replace(/^\.\/dist\//, '').replace(/\/index\.js$/, ''); 94 - const tsxPath = join(root, 'src', distRel, 'index.tsx'); 95 - const tsPath = join(root, 'src', distRel, 'index.ts'); 96 - return existsSync(tsxPath) ? tsxPath : tsPath; 97 - } 98 - 99 - function countRuntimeExports(sourcePath: string): number { 100 - const source = readFileSync(sourcePath, 'utf8'); 101 - const names = new Set<string>(); 102 - 103 - for (const match of source.matchAll( 104 - /^export\s+(?:async\s+)?(?:function|class|const|let|var|enum)\s+([A-Za-z_$][\w$]*)/gm, 105 - )) { 106 - const name = match[1]; 107 - if (name) names.add(name); 108 - } 109 - 110 - for (const match of source.matchAll(/^export\s*\{([^}]+)\}(?:\s+from\s+['"][^'"]+['"])?/gm)) { 111 - for (const specifier of (match[1] ?? '').split(',')) { 112 - const name = specifier.trim(); 113 - if (!name || name.startsWith('type ')) continue; 114 - names.add(name); 115 - } 116 - } 117 - 118 - return names.size; 119 - }
-71
packages/@luke-ui/docs-tools/src/package-docs-catalog.test.ts
··· 1 - import { fileURLToPath } from 'node:url'; 2 - import { describe, expect, it } from 'vite-plus/test'; 3 - import { resolvePackageDocsCatalog } from './package-docs-catalog.js'; 4 - 5 - const packageRoot = fileURLToPath(new URL('./fixtures/sample-package/', import.meta.url)); 6 - 7 - describe('resolvePackageDocsCatalog', () => { 8 - it('resolves final metadata and parsed data for every export', () => { 9 - const catalog = resolvePackageDocsCatalog({ 10 - exportsField: { 11 - './kit/primitive': './dist/kit/primitive/index.js', 12 - './sample': './dist/sample/index.js', 13 - './stylesheet.css': './dist/stylesheet.css', 14 - }, 15 - packageRoot, 16 - }); 17 - 18 - const component = catalog.find((entry) => entry.path === './sample'); 19 - expect(component).toMatchObject({ 20 - description: 'Sample atom description.', 21 - pageKind: 'component', 22 - parsed: { 23 - componentName: 'Sample', 24 - }, 25 - path: './sample', 26 - shape: 'component', 27 - sourcePath: expect.stringContaining('/src/sample/index.tsx'), 28 - tier: 'atom', 29 - title: 'Sample', 30 - }); 31 - 32 - const primitiveKit = catalog.find((entry) => entry.path === './kit/primitive'); 33 - expect(primitiveKit).toMatchObject({ 34 - pageKind: 'barrel', 35 - parsed: { 36 - exports: expect.arrayContaining([ 37 - expect.objectContaining({ name: 'KitControl' }), 38 - expect.objectContaining({ name: 'KitItem' }), 39 - ]), 40 - }, 41 - shape: 'component', 42 - sourcePath: expect.stringContaining('/src/kit/primitive/index.tsx'), 43 - tier: 'primitive', 44 - title: 'Kit (primitive)', 45 - }); 46 - 47 - const asset = catalog.find((entry) => entry.path === './stylesheet.css'); 48 - expect(asset).toMatchObject({ 49 - description: '', 50 - pageKind: 'asset', 51 - shape: 'asset', 52 - tier: 'n/a', 53 - title: 'Stylesheet', 54 - }); 55 - expect(asset).not.toHaveProperty('sourcePath'); 56 - expect(asset).not.toHaveProperty('parsed'); 57 - }); 58 - 59 - it('throws with the export path and target when a non-asset source is missing', () => { 60 - expect(() => 61 - resolvePackageDocsCatalog({ 62 - exportsField: { 63 - './missing': './dist/missing/index.js', 64 - }, 65 - packageRoot, 66 - }), 67 - ).toThrow( 68 - 'Could not resolve source for package export "./missing" targeting "./dist/missing/index.js".', 69 - ); 70 - }); 71 - });
-134
packages/@luke-ui/docs-tools/src/package-docs-catalog.ts
··· 1 - /** 2 - * `resolvePackageDocsCatalog` is the package interface catalog: the single place 3 - * that resolves `package.json#exports` into classified, source-backed entries 4 - * (tier, shape, description). Other tooling that needs export/tier/shape data 5 - * should consume this rather than re-deriving it from `package.json` directly. 6 - */ 7 - import { existsSync } from 'node:fs'; 8 - import type { 9 - DiscoveredExport, 10 - ExportPageKind, 11 - ExportShape, 12 - ExportTier, 13 - } from '@luke-ui/docs-tools/discover-exports'; 14 - import { discoverExports } from '@luke-ui/docs-tools/discover-exports'; 15 - import type { ParsedBarrel, ParsedComponent } from '@luke-ui/docs-tools/parse-types'; 16 - import { parseBarrel, parseComponent } from '@luke-ui/docs-tools/parse-types'; 17 - import { slugToTitle } from '@luke-ui/docs-tools/title'; 18 - 19 - interface PackageDocsCatalogEntryBase { 20 - description: string; 21 - pageKind: ExportPageKind; 22 - path: string; 23 - shape: ExportShape; 24 - slug: string; 25 - target: string; 26 - tier: ExportTier; 27 - title: string; 28 - } 29 - 30 - export interface PackageDocsAssetEntry extends PackageDocsCatalogEntryBase { 31 - pageKind: 'asset'; 32 - shape: 'asset'; 33 - tier: 'n/a'; 34 - } 35 - 36 - export interface PackageDocsComponentEntry extends PackageDocsCatalogEntryBase { 37 - pageKind: 'component'; 38 - parsed: ParsedComponent; 39 - sourcePath: string; 40 - } 41 - 42 - export interface PackageDocsBarrelEntry extends PackageDocsCatalogEntryBase { 43 - pageKind: 'barrel'; 44 - parsed: ParsedBarrel; 45 - sourcePath: string; 46 - } 47 - 48 - export type PackageDocsCatalogEntry = 49 - | PackageDocsAssetEntry 50 - | PackageDocsComponentEntry 51 - | PackageDocsBarrelEntry; 52 - 53 - export const DOC_GROUPS = ['actions', 'feedback', 'forms', 'typography', 'visuals'] as const; 54 - export type DocGroup = (typeof DOC_GROUPS)[number]; 55 - 56 - export type TierBucket = 'atom' | 'composed' | 'primitive' | 'barrel' | 'asset'; 57 - 58 - export function tierBucket(entry: { shape: string; tier: string }): TierBucket { 59 - if (entry.shape === 'asset' || entry.shape === 'barrel') return entry.shape; 60 - if (entry.tier === 'atom') return 'atom'; 61 - if (entry.tier === 'primitive') return 'primitive'; 62 - return 'composed'; 63 - } 64 - 65 - export type PackageDocsCatalogMetadata = Pick< 66 - PackageDocsCatalogEntry, 67 - 'path' | 'target' | 'slug' | 'shape' | 'pageKind' | 'tier' | 'title' | 'description' 68 - >; 69 - 70 - export interface ResolvePackageDocsCatalogOptions { 71 - exportsField: Record<string, string>; 72 - packageRoot: string; 73 - } 74 - 75 - export function resolvePackageDocsCatalog( 76 - options: ResolvePackageDocsCatalogOptions, 77 - ): Array<PackageDocsCatalogEntry> { 78 - const discovered = discoverExports(options.exportsField, { 79 - packageRoot: options.packageRoot, 80 - }); 81 - 82 - return discovered.map(resolveEntry); 83 - } 84 - 85 - function resolveEntry(entry: DiscoveredExport): PackageDocsCatalogEntry { 86 - if (entry.pageKind === 'asset') { 87 - return { 88 - description: '', 89 - pageKind: 'asset', 90 - path: entry.path, 91 - shape: 'asset', 92 - slug: entry.slug, 93 - target: entry.target, 94 - tier: 'n/a', 95 - title: slugToTitle(entry.slug, entry.tier), 96 - }; 97 - } 98 - 99 - if (!entry.sourcePath || !existsSync(entry.sourcePath)) { 100 - throw new Error( 101 - `Could not resolve source for package export "${entry.path}" targeting "${entry.target}".`, 102 - ); 103 - } 104 - 105 - if (entry.pageKind === 'barrel') { 106 - const parsed = parseBarrel(entry.sourcePath); 107 - return { 108 - ...entry, 109 - description: parsed.description, 110 - pageKind: 'barrel', 111 - parsed, 112 - sourcePath: entry.sourcePath, 113 - title: slugToTitle(entry.slug, entry.tier), 114 - }; 115 - } 116 - 117 - const parsed = parseComponent(entry.sourcePath); 118 - if (!parsed.tier) { 119 - throw new Error( 120 - `Export "${entry.path}" is missing a @tier JSDoc tag. ` + 121 - `Add /** @tier atom */ or /** @tier composed */ to its props interface.`, 122 - ); 123 - } 124 - const tier = parsed.tier; 125 - return { 126 - ...entry, 127 - description: parsed.description, 128 - pageKind: 'component', 129 - parsed, 130 - sourcePath: entry.sourcePath, 131 - tier, 132 - title: slugToTitle(entry.slug, tier), 133 - }; 134 - }
-108
packages/@luke-ui/docs-tools/src/parse-types.test.ts
··· 1 - import { fileURLToPath } from 'node:url'; 2 - import { describe, expect, it } from 'vite-plus/test'; 3 - import { parseBarrel, parseComponent } from './parse-types.js'; 4 - 5 - const fixturePath = fileURLToPath(new URL('./fixtures/sample-component.ts', import.meta.url)); 6 - const typeAliasFixturePath = fileURLToPath( 7 - new URL('./fixtures/sample-type-alias.ts', import.meta.url), 8 - ); 9 - const wrapperFixturePath = fileURLToPath(new URL('./fixtures/sample-wrapper.ts', import.meta.url)); 10 - 11 - describe('parseComponent', () => { 12 - it('extracts the function description', () => { 13 - const parsed = parseComponent(fixturePath); 14 - expect(parsed.description).toContain('Sample composed button with size and tone variants'); 15 - }); 16 - 17 - it('extracts @tier JSDoc tag from the props interface', () => { 18 - const parsed = parseComponent(fixturePath); 19 - expect(parsed.tier).toBe('composed'); 20 - }); 21 - 22 - it('exposes the exported function name as componentName', () => { 23 - const parsed = parseComponent(fixturePath); 24 - expect(parsed.componentName).toBe('Sample'); 25 - }); 26 - 27 - it('extracts own props (declared on SampleStyleProps) with JSDoc and defaults', () => { 28 - const parsed = parseComponent(fixturePath); 29 - const sizeProp = parsed.propsInterface?.members.find((p) => p.name === 'size'); 30 - expect(sizeProp).toMatchObject({ 31 - default: "'medium'", 32 - description: expect.stringContaining('Controls the button size'), 33 - name: 'size', 34 - optional: true, 35 - type: "'small' | 'medium'", 36 - }); 37 - }); 38 - 39 - it('detects external extends (react-aria-components)', () => { 40 - const parsed = parseComponent(fixturePath); 41 - const externalExtends = parsed.propsInterface?.extends.find((e) => e.from === 'external'); 42 - expect(externalExtends?.module).toBe('react-aria-components'); 43 - expect(externalExtends?.typeName).toContain('ButtonProps'); 44 - }); 45 - 46 - it('extracts local members from exported props type aliases', () => { 47 - const parsed = parseComponent(typeAliasFixturePath); 48 - expect(parsed.propsInterface?.name).toBe('AliasProps'); 49 - expect(parsed.tier).toBe('atom'); 50 - expect(parsed.propsInterface?.members).toEqual( 51 - expect.arrayContaining([ 52 - expect.objectContaining({ 53 - default: "'medium'", 54 - description: 'Sets the visual size.', 55 - name: 'size', 56 - }), 57 - expect.objectContaining({ 58 - description: 'Accessible label.', 59 - name: 'label', 60 - optional: false, 61 - }), 62 - ]), 63 - ); 64 - }); 65 - 66 - it('selects the component props declaration and unwraps Omit around local props', () => { 67 - const parsed = parseComponent(wrapperFixturePath); 68 - expect(parsed.propsInterface?.name).toBe('WrapperProps'); 69 - expect(parsed.propsInterface?.members.map((prop) => prop.name)).toEqual(['size', 'onPress']); 70 - expect(parsed.propsInterface?.members[0]).toMatchObject({ 71 - default: "'wrapper'", 72 - description: 'Wrapper size.', 73 - name: 'size', 74 - }); 75 - expect(parsed.propsInterface?.members[1]).toMatchObject({ 76 - description: 'Wrapper press handler.', 77 - name: 'onPress', 78 - }); 79 - }); 80 - }); 81 - 82 - const barrelFixturePath = fileURLToPath(new URL('./fixtures/sample-barrel.ts', import.meta.url)); 83 - 84 - describe('parseBarrel', () => { 85 - it('reads the file-level JSDoc as the barrel description', () => { 86 - const parsed = parseBarrel(barrelFixturePath); 87 - expect(parsed.description).toContain('Sample barrel that re-exports multiple named components'); 88 - }); 89 - 90 - it('returns all runtime exports with their names', () => { 91 - const parsed = parseBarrel(barrelFixturePath); 92 - const names = parsed.exports.map((e) => e.name); 93 - expect(names).toContain('SampleWidget'); 94 - expect(names).toContain('SamplePanel'); 95 - }); 96 - 97 - it('reads JSDoc descriptions for each export', () => { 98 - const parsed = parseBarrel(barrelFixturePath); 99 - const widget = parsed.exports.find((e) => e.name === 'SampleWidget'); 100 - expect(widget?.description).toContain('A sample widget component'); 101 - }); 102 - 103 - it('includes a function type signature', () => { 104 - const parsed = parseBarrel(barrelFixturePath); 105 - const panel = parsed.exports.find((e) => e.name === 'SamplePanel'); 106 - expect(panel?.type).toMatch(/^function SamplePanel/); 107 - }); 108 - });
-740
packages/@luke-ui/docs-tools/src/parse-types.ts
··· 1 - import type { 2 - ExpressionWithTypeArguments, 3 - FunctionDeclaration, 4 - InterfaceDeclaration, 5 - PropertySignature, 6 - SourceFile, 7 - TypeAliasDeclaration, 8 - TypeNode, 9 - } from 'ts-morph'; 10 - import { Node, Project, SyntaxKind } from 'ts-morph'; 11 - import type { ExportTier } from './discover-exports.js'; 12 - 13 - export interface ParsedProp { 14 - default?: string; 15 - description: string; 16 - name: string; 17 - optional: boolean; 18 - type: string; 19 - } 20 - 21 - export interface ParsedExtends { 22 - from: 'package' | 'external'; 23 - module?: string; 24 - typeName: string; 25 - } 26 - 27 - export interface ParsedComponent { 28 - /** Name of the exported function (e.g. `'TextInput'`), if one was found. */ 29 - componentName?: string; 30 - description: string; 31 - propsInterface?: { 32 - name: string; 33 - extends: Array<ParsedExtends>; 34 - members: Array<ParsedProp>; 35 - }; 36 - tier?: ExportTier; 37 - } 38 - 39 - type PropsDeclaration = InterfaceDeclaration | TypeAliasDeclaration; 40 - interface PropsLookup { 41 - all: Array<PropsDeclaration>; 42 - byName: Map<string, PropsDeclaration>; 43 - } 44 - 45 - /** 46 - * Build a fresh ts-morph `Project` configured for parsing a single source file 47 - * in isolation: no tsconfig, no implicit file discovery, JSX enabled. 48 - */ 49 - function createProject(): Project { 50 - return new Project({ 51 - compilerOptions: { allowJs: false, jsx: 4 /* React */ }, 52 - skipAddingFilesFromTsConfig: true, 53 - tsConfigFilePath: undefined, 54 - }); 55 - } 56 - 57 - const project = createProject(); 58 - 59 - function getSourceFile(sourcePath: string): SourceFile { 60 - return project.getSourceFile(sourcePath) ?? project.addSourceFileAtPath(sourcePath); 61 - } 62 - 63 - export function parseComponent(sourcePath: string): ParsedComponent { 64 - const sourceFile = getSourceFile(sourcePath); 65 - const propsLookup = createPropsLookup(sourceFile); 66 - 67 - const exportedFns = sourceFile.getFunctions(); 68 - let exportedFn: FunctionDeclaration | undefined; 69 - let documentedExportedFn: FunctionDeclaration | undefined; 70 - for (const fn of exportedFns) { 71 - if (!fn.isExported()) continue; 72 - if (documentedExportedFn === undefined && fn.getJsDocs().length > 0) { 73 - documentedExportedFn = fn; 74 - } 75 - const name = fn.getName(); 76 - if (!name) continue; 77 - const props = propsLookup.byName.get(`${name}Props`); 78 - if (props && readTierTag(props) !== undefined) { 79 - exportedFn = fn; 80 - break; 81 - } 82 - } 83 - exportedFn ??= documentedExportedFn; 84 - 85 - const description = exportedFn?.getJsDocs()[0]?.getDescription().trim() ?? ''; 86 - const componentName = exportedFn?.getName(); 87 - 88 - const propsInterface = findPropsDeclaration(propsLookup, componentName); 89 - 90 - if (!propsInterface) { 91 - return { componentName, description }; 92 - } 93 - 94 - const tier = readTierTag(propsInterface); 95 - const extendsList = Node.isInterfaceDeclaration(propsInterface) 96 - ? readExtends(propsInterface) 97 - : []; 98 - const members = collectMembers(propsInterface); 99 - 100 - return { 101 - componentName, 102 - description, 103 - propsInterface: { 104 - extends: extendsList, 105 - members, 106 - name: propsInterface.getName(), 107 - }, 108 - tier, 109 - }; 110 - } 111 - 112 - function createPropsLookup(sourceFile: SourceFile): PropsLookup { 113 - const all: Array<PropsDeclaration> = []; 114 - const byName = new Map<string, PropsDeclaration>(); 115 - appendPropsDeclarations(sourceFile.getInterfaces(), all, byName); 116 - appendPropsDeclarations(sourceFile.getTypeAliases(), all, byName); 117 - return { all, byName }; 118 - } 119 - 120 - function appendPropsDeclarations( 121 - declarations: Array<PropsDeclaration>, 122 - all: Array<PropsDeclaration>, 123 - byName: Map<string, PropsDeclaration>, 124 - ): void { 125 - for (let i = 0; i < declarations.length; i++) { 126 - const decl = declarations[i]; 127 - if (!decl?.isExported()) continue; 128 - 129 - const name = decl.getName(); 130 - if (!name.endsWith('Props')) continue; 131 - 132 - all.push(decl); 133 - byName.set(name, decl); 134 - } 135 - } 136 - 137 - function findPropsDeclaration( 138 - lookup: PropsLookup, 139 - componentName: string | undefined, 140 - ): PropsDeclaration | undefined { 141 - if (componentName) { 142 - const exact = lookup.byName.get(`${componentName}Props`); 143 - if (exact) return exact; 144 - } 145 - return lookup.all[0]; 146 - } 147 - 148 - function readTierTag(decl: PropsDeclaration): ExportTier | undefined { 149 - for (const jsDoc of decl.getJsDocs()) { 150 - for (const tag of jsDoc.getTags()) { 151 - if (tag.getTagName() === 'tier') { 152 - const value = tag.getCommentText()?.trim().toLowerCase(); 153 - if (value === 'atom' || value === 'composed' || value === 'primitive') return value; 154 - } 155 - } 156 - } 157 - return; 158 - } 159 - 160 - function moduleFromPath(filePath: string): string | undefined { 161 - const markerIndex = filePath.lastIndexOf('/node_modules/'); 162 - if (markerIndex === -1) return; 163 - const start = markerIndex + '/node_modules/'.length; 164 - if (start >= filePath.length) return; 165 - 166 - const firstSlash = filePath.indexOf('/', start); 167 - if (filePath.charCodeAt(start) !== 64 /* @ */) { 168 - return firstSlash === -1 ? filePath.slice(start) : filePath.slice(start, firstSlash); 169 - } 170 - 171 - if (firstSlash === -1) return filePath.slice(start); 172 - const secondSlash = filePath.indexOf('/', firstSlash + 1); 173 - return secondSlash === -1 ? filePath.slice(start) : filePath.slice(start, secondSlash); 174 - } 175 - 176 - /** True when a path points to TypeScript's own built-in lib files (e.g. lib.es5.d.ts). */ 177 - function isTsBuiltinPath(filePath: string): boolean { 178 - return filePath.includes('/node_modules/typescript/lib/'); 179 - } 180 - 181 - /** 182 - * Resolve the external module info from a heritage clause's type arguments. 183 - * Used when the expression itself is a TypeScript utility type (Omit, Pick, etc.) 184 - * that resolves to TypeScript's own lib rather than a user package. 185 - */ 186 - function resolveExternalFromTypeArgs( 187 - heritage: ExpressionWithTypeArguments, 188 - ): { module: string | undefined; typeName: string } | undefined { 189 - for (const typeArg of heritage.getTypeArguments()) { 190 - const argType = typeArg.getType(); 191 - const argSym = argType.getSymbol() ?? argType.getAliasSymbol(); 192 - const argDecls = argSym?.getDeclarations() ?? []; 193 - for (const argDecl of argDecls) { 194 - const argPath = argDecl.getSourceFile().getFilePath(); 195 - if (argPath.includes('/node_modules/') && !isTsBuiltinPath(argPath)) { 196 - return { 197 - module: moduleFromPath(argPath), 198 - typeName: argSym?.getName() ?? typeArg.getText(), 199 - }; 200 - } 201 - } 202 - } 203 - return; 204 - } 205 - 206 - function readExtends(decl: InterfaceDeclaration): Array<ParsedExtends> { 207 - const result: Array<ParsedExtends> = []; 208 - for (const heritage of decl.getExtends()) { 209 - const expr = heritage.getExpression(); 210 - const typeName = expr.getText(); 211 - const symbol = expr.getSymbol(); 212 - const declarations = symbol?.getDeclarations() ?? []; 213 - 214 - if (declarations.length === 0) { 215 - // No declarations — check type arguments for external references 216 - const resolved = resolveExternalFromTypeArgs(heritage); 217 - if (resolved) { 218 - result.push({ from: 'external', module: resolved.module, typeName: resolved.typeName }); 219 - } else { 220 - result.push({ from: 'external', typeName }); 221 - } 222 - continue; 223 - } 224 - 225 - const firstDecl = declarations[0]; 226 - const sourceFilePath = firstDecl?.getSourceFile().getFilePath() ?? ''; 227 - 228 - if (sourceFilePath.includes('/node_modules/') && !isTsBuiltinPath(sourceFilePath)) { 229 - result.push({ from: 'external', module: moduleFromPath(sourceFilePath), typeName }); 230 - continue; 231 - } 232 - 233 - if (isTsBuiltinPath(sourceFilePath)) { 234 - const resolved = resolveExternalFromTypeArgs(heritage); 235 - if (resolved) { 236 - result.push({ from: 'external', module: resolved.module, typeName: resolved.typeName }); 237 - } 238 - continue; 239 - } 240 - 241 - result.push({ from: 'package', typeName }); 242 - } 243 - return result; 244 - } 245 - 246 - function collectMembers(decl: PropsDeclaration): Array<ParsedProp> { 247 - const out: Array<ParsedProp> = []; 248 - collectMembersFromDeclaration(decl, undefined, undefined, new Set(), out, new Set()); 249 - return out; 250 - } 251 - 252 - function collectMembersFromHeritage( 253 - heritage: ExpressionWithTypeArguments, 254 - omit: Set<string> | undefined, 255 - include: Set<string> | undefined, 256 - seen: Set<string>, 257 - out: Array<ParsedProp>, 258 - names: Set<string>, 259 - ): void { 260 - const typeArgs = heritage.getTypeArguments(); 261 - const expressionText = heritage.getExpression().getText(); 262 - if (typeArgs.length > 0 && isUtilityTypeName(expressionText)) { 263 - collectUtilityTypeMembers(expressionText, typeArgs, omit, include, seen, out, names); 264 - return; 265 - } 266 - 267 - const target = findLocalPropsDeclaration(heritage); 268 - if (target) collectMembersFromDeclaration(target, omit, include, seen, out, names); 269 - } 270 - 271 - function collectMembersFromTypeNode( 272 - node: TypeNode, 273 - omit: Set<string> | undefined, 274 - include: Set<string> | undefined, 275 - seen: Set<string>, 276 - out: Array<ParsedProp>, 277 - names: Set<string>, 278 - ): void { 279 - if (Node.isIntersectionTypeNode(node)) { 280 - for (const child of node.getTypeNodes()) { 281 - collectMembersFromTypeNode(child, omit, include, seen, out, names); 282 - } 283 - return; 284 - } 285 - 286 - if (Node.isTypeLiteral(node)) { 287 - const members = node.getMembers(); 288 - for (let i = 0; i < members.length; i++) { 289 - const member = members[i]; 290 - if (!member || !Node.isPropertySignature(member)) continue; 291 - appendParsedProp(member, omit, include, out, names); 292 - } 293 - return; 294 - } 295 - 296 - if (Node.isTypeReference(node) && isUtilityTypeName(node.getTypeName().getText())) { 297 - collectUtilityTypeMembers( 298 - node.getTypeName().getText(), 299 - node.getTypeArguments(), 300 - omit, 301 - include, 302 - seen, 303 - out, 304 - names, 305 - ); 306 - return; 307 - } 308 - 309 - const target = findLocalPropsDeclaration(node); 310 - if (target) collectMembersFromDeclaration(target, omit, include, seen, out, names); 311 - } 312 - 313 - function collectUtilityTypeMembers( 314 - name: string, 315 - typeArgs: Array<TypeNode>, 316 - omit: Set<string> | undefined, 317 - include: Set<string> | undefined, 318 - seen: Set<string>, 319 - out: Array<ParsedProp>, 320 - names: Set<string>, 321 - ): void { 322 - const target = typeArgs[0]; 323 - if (!target) return; 324 - 325 - if (name === 'Pick') { 326 - const picked = readLiteralNames(typeArgs[1]); 327 - collectMembersFromTypeNode(target, omit, mergeIncludeSets(include, picked), seen, out, names); 328 - return; 329 - } 330 - 331 - const omitted = readLiteralNames(typeArgs[1]); 332 - collectMembersFromTypeNode(target, mergeOmitSets(omit, omitted), include, seen, out, names); 333 - } 334 - 335 - function mergeOmitSets( 336 - outer: Set<string> | undefined, 337 - inner: Set<string>, 338 - ): Set<string> | undefined { 339 - if (inner.size === 0) return outer; 340 - if (!outer || outer.size === 0) return inner; 341 - 342 - const merged = new Set(outer); 343 - for (const name of inner) merged.add(name); 344 - return merged; 345 - } 346 - 347 - function mergeIncludeSets( 348 - outer: Set<string> | undefined, 349 - inner: Set<string>, 350 - ): Set<string> | undefined { 351 - if (inner.size === 0) return outer; 352 - if (!outer || outer.size === 0) return inner; 353 - 354 - const merged = new Set<string>(); 355 - for (const name of inner) { 356 - if (outer.has(name)) merged.add(name); 357 - } 358 - return merged; 359 - } 360 - 361 - function collectMembersFromDeclaration( 362 - decl: PropsDeclaration, 363 - omit: Set<string> | undefined, 364 - include: Set<string> | undefined, 365 - seen: Set<string>, 366 - out: Array<ParsedProp>, 367 - names: Set<string>, 368 - ): void { 369 - const key = `${decl.getSourceFile().getFilePath()}:${decl.getName()}`; 370 - if (seen.has(key)) return; 371 - seen.add(key); 372 - 373 - if (Node.isTypeAliasDeclaration(decl)) { 374 - const node = decl.getTypeNode(); 375 - if (node) collectMembersFromTypeNode(node, omit, include, seen, out, names); 376 - return; 377 - } 378 - 379 - const properties = decl.getProperties(); 380 - for (let i = 0; i < properties.length; i++) { 381 - const prop = properties[i]; 382 - if (prop) appendParsedProp(prop, omit, include, out, names); 383 - } 384 - 385 - const heritageClauses = decl.getExtends(); 386 - for (let i = 0; i < heritageClauses.length; i++) { 387 - const heritage = heritageClauses[i]; 388 - if (heritage) collectMembersFromHeritage(heritage, omit, include, seen, out, names); 389 - } 390 - } 391 - 392 - function appendParsedProp( 393 - prop: PropertySignature, 394 - omit: Set<string> | undefined, 395 - include: Set<string> | undefined, 396 - out: Array<ParsedProp>, 397 - names: Set<string>, 398 - ): void { 399 - const name = prop.getName(); 400 - if (omit?.has(name) || (include && !include.has(name)) || names.has(name)) return; 401 - 402 - out.push(toParsedProp(prop)); 403 - names.add(name); 404 - } 405 - 406 - function findLocalPropsDeclaration( 407 - node: TypeNode | ExpressionWithTypeArguments, 408 - ): PropsDeclaration | undefined { 409 - const symbol = node.getType().getSymbol() ?? node.getType().getAliasSymbol(); 410 - const declarations = symbol?.getDeclarations() ?? []; 411 - return declarations.find( 412 - (decl): decl is PropsDeclaration => 413 - (Node.isInterfaceDeclaration(decl) || Node.isTypeAliasDeclaration(decl)) && 414 - !decl.getSourceFile().getFilePath().includes('/node_modules/'), 415 - ); 416 - } 417 - 418 - function isUtilityTypeName(name: string): boolean { 419 - return name === 'Omit' || name === 'DistributiveOmit' || name === 'Pick'; 420 - } 421 - 422 - function readLiteralNames(node: TypeNode | undefined): Set<string> { 423 - const out = new Set<string>(); 424 - if (!node) return out; 425 - if (Node.isLiteralTypeNode(node)) { 426 - const literal = node.getLiteral(); 427 - if (Node.isStringLiteral(literal)) out.add(literal.getLiteralText()); 428 - return out; 429 - } 430 - if (Node.isTypeOperatorTypeNode(node) && node.getOperator() === SyntaxKind.KeyOfKeyword) { 431 - appendPropNamesFromTypeNode(node.getTypeNode(), out, new Set()); 432 - return out; 433 - } 434 - if (Node.isUnionTypeNode(node)) { 435 - for (const child of node.getTypeNodes()) { 436 - for (const name of readLiteralNames(child)) out.add(name); 437 - } 438 - } 439 - return out; 440 - } 441 - 442 - function appendPropNamesFromTypeNode(node: TypeNode, out: Set<string>, seen: Set<string>): void { 443 - if (Node.isTypeLiteral(node)) { 444 - const members = node.getMembers(); 445 - for (let i = 0; i < members.length; i++) { 446 - const member = members[i]; 447 - if (member && Node.isPropertySignature(member)) out.add(member.getName()); 448 - } 449 - return; 450 - } 451 - 452 - if (Node.isIntersectionTypeNode(node)) { 453 - for (const child of node.getTypeNodes()) { 454 - appendPropNamesFromTypeNode(child, out, seen); 455 - } 456 - return; 457 - } 458 - 459 - const target = findLocalPropsDeclaration(node); 460 - if (target) appendPropNamesFromDeclaration(target, out, seen); 461 - } 462 - 463 - function appendPropNamesFromDeclaration( 464 - decl: PropsDeclaration, 465 - out: Set<string>, 466 - seen: Set<string>, 467 - ): void { 468 - const key = `${decl.getSourceFile().getFilePath()}:${decl.getName()}`; 469 - if (seen.has(key)) return; 470 - seen.add(key); 471 - 472 - if (Node.isTypeAliasDeclaration(decl)) { 473 - const node = decl.getTypeNode(); 474 - if (node) appendPropNamesFromTypeNode(node, out, seen); 475 - return; 476 - } 477 - 478 - const properties = decl.getProperties(); 479 - for (let i = 0; i < properties.length; i++) { 480 - const prop = properties[i]; 481 - if (prop) out.add(prop.getName()); 482 - } 483 - 484 - const heritageClauses = decl.getExtends(); 485 - for (let i = 0; i < heritageClauses.length; i++) { 486 - const heritage = heritageClauses[i]; 487 - const target = heritage ? findLocalPropsDeclaration(heritage) : undefined; 488 - if (target) appendPropNamesFromDeclaration(target, out, seen); 489 - } 490 - } 491 - 492 - function toParsedProp(prop: PropertySignature): ParsedProp { 493 - const jsDoc = prop.getJsDocs()[0]; 494 - const description = jsDoc?.getDescription().trim() ?? ''; 495 - const defaultValue = (jsDoc?.getTags() ?? []) 496 - .find((tag) => tag.getTagName() === 'default') 497 - ?.getCommentText() 498 - ?.trim(); 499 - return { 500 - default: defaultValue, 501 - description, 502 - name: prop.getName(), 503 - optional: prop.hasQuestionToken(), 504 - type: resolvePropType(prop), 505 - }; 506 - } 507 - 508 - /** 509 - * For indexed-access types (e.g. `Foo['bar']`) the AST text is opaque, 510 - * so resolve to the literal underlying type. For direct unions or primitives 511 - * the AST text already reads cleanly and preserves the author's quote style. 512 - * 513 - * If the resolved type is unwieldy (e.g. a React `children` union), fall back 514 - * to the AST text so the docs table stays readable. 515 - */ 516 - function resolvePropType(prop: PropertySignature): string { 517 - const node = prop.getTypeNode(); 518 - const astText = node?.getText() ?? prop.getType().getText(prop); 519 - if (node?.getKind() !== SyntaxKind.IndexedAccessType) return astText; 520 - const resolved = stripImportPaths(prop.getType().getNonNullableType().getText(prop)); 521 - return resolved.length > UNWIELDY_TYPE_THRESHOLD ? astText : resolved; 522 - } 523 - 524 - const UNWIELDY_TYPE_THRESHOLD = 100; 525 - 526 - /** Strip `import("specifier").` qualifiers TypeScript inserts when a type is not in scope. */ 527 - function stripImportPaths(type: string): string { 528 - return type.replace(/import\("[^"]+"\)\./g, ''); 529 - } 530 - 531 - // --------------------------------------------------------------------------- 532 - // Barrel parser 533 - // --------------------------------------------------------------------------- 534 - 535 - export interface ParsedBarrelExport { 536 - description: string; 537 - name: string; 538 - type?: string; 539 - } 540 - 541 - export interface ParsedBarrel { 542 - description: string; 543 - exports: Array<ParsedBarrelExport>; 544 - } 545 - 546 - /** 547 - * Walk a source file's top-level exports and return their names, JSDoc 548 - * descriptions, and (when compact) type signatures. 549 - */ 550 - export function parseBarrel(sourcePath: string): ParsedBarrel { 551 - const sourceFile = getSourceFile(sourcePath); 552 - 553 - // File-level JSDoc: ts-morph does not expose getJsDocs() on SourceFile. 554 - // The file-level doc block appears as a leading comment on the first statement. 555 - const description = readFileLevelJsDoc(sourceFile); 556 - 557 - // Build a map from export name → JSDoc description extracted from the 558 - // re-export statement's leading comment. Used as a fallback when the resolved 559 - // declaration (possibly in another file) carries no JSDoc of its own. 560 - const reExportDescriptions = buildReExportDescriptionMap(sourceFile); 561 - 562 - // Collect all exported declarations keyed by name. 563 - const exportedDecls = sourceFile.getExportedDeclarations(); 564 - 565 - const exports: Array<ParsedBarrelExport> = []; 566 - 567 - for (const [name, decls] of exportedDecls.entries()) { 568 - // Skip type-only exports (Props interfaces, etc.) — they only appear as 569 - // companions to their runtime counterpart and we list them in the type 570 - // field there. Heuristic: names ending with "Props" or "Size" that are 571 - // pure interfaces / type aliases. 572 - const firstDecl = decls[0]; 573 - if (!firstDecl) continue; 574 - 575 - const kind = firstDecl.getKind(); 576 - 577 - // Resolve description with fallback to the re-export line's JSDoc. 578 - const jsDoc = getJsDoc(firstDecl); 579 - const declDesc = jsDoc?.getDescription().trim() ?? ''; 580 - const desc = declDesc || (reExportDescriptions.get(name) ?? ''); 581 - 582 - if (kind === SyntaxKind.InterfaceDeclaration || kind === SyntaxKind.TypeAliasDeclaration) { 583 - if (isCompanionTypeExport(name, desc)) continue; 584 - // Standalone type alias with a description (e.g. ComboboxSize) — include it briefly. 585 - exports.push({ description: desc, name, type: getTypeSignature(firstDecl, name) }); 586 - continue; 587 - } 588 - 589 - // Runtime export (function, class, arrow function, etc.) 590 - const type = getTypeSignature(firstDecl, name); 591 - exports.push({ description: desc, name, type }); 592 - } 593 - 594 - // Sort alphabetically for stable output. 595 - exports.sort((a, b) => a.name.localeCompare(b.name)); 596 - 597 - return { description, exports }; 598 - } 599 - 600 - /** 601 - * Decide whether a pure type export (interface or type alias) from a barrel is 602 - * a "companion" type — i.e. paired with a runtime export and surfaced via its 603 - * `type` field rather than listed as its own entry. 604 - * 605 - * Heuristic: skip pure type exports whose name ends in `Props` or whose 606 - * description (declaration JSDoc, falling back to the re-export line JSDoc) is 607 - * empty. Standalone public types (e.g. `ComboboxSize` with a description) 608 - * still appear in the barrel's export list. 609 - */ 610 - function isCompanionTypeExport(name: string, description: string): boolean { 611 - return name.endsWith('Props') || !description; 612 - } 613 - 614 - /** 615 - * Build a map from export name → JSDoc description extracted from the leading 616 - * `/** ... *​/` comment on each `export { ... } from '...'` statement. 617 - * 618 - * ts-morph's `getExportedDeclarations()` resolves re-exports to the original 619 - * declaration site, which may be in a different file and may carry no JSDoc. 620 - * This function captures the JSDoc authored on the re-export line itself so it 621 - * can serve as a fallback description. 622 - */ 623 - function buildReExportDescriptionMap(sourceFile: SourceFile): Map<string, string> { 624 - const map = new Map<string, string>(); 625 - for (const exportDecl of sourceFile.getExportDeclarations()) { 626 - const ranges = exportDecl.getLeadingCommentRanges(); 627 - if (ranges.length === 0) continue; 628 - // The last leading range is the one immediately before the declaration. 629 - const lastRange = ranges[ranges.length - 1]; 630 - if (!lastRange?.getText().startsWith('/**')) continue; 631 - const desc = stripJsDocMarkers(lastRange.getText()); 632 - if (!desc) continue; 633 - // Apply this description to every named export specifier in this declaration. 634 - for (const specifier of exportDecl.getNamedExports()) { 635 - // Use the aliased name (e.g. `spinner as loadingSpinner` → `loadingSpinner`) 636 - const exportedName = specifier.getAliasNode()?.getText() ?? specifier.getName(); 637 - map.set(exportedName, desc); 638 - } 639 - } 640 - return map; 641 - } 642 - 643 - /** 644 - * Read the file-level JSDoc block from a source file. 645 - * ts-morph does not expose `getJsDocs()` on `SourceFile`. The block appears 646 - * as the *first* leading comment on the first statement. 647 - * 648 - * When the first statement also has its own JSDoc (e.g. `/** Allowed… *\/`), 649 - * that appears as a second leading comment range. We only want range[0]. 650 - */ 651 - function readFileLevelJsDoc(sourceFile: SourceFile): string { 652 - const stmts = sourceFile.getStatements(); 653 - const first = stmts[0]; 654 - if (!first) return ''; 655 - const ranges = first.getLeadingCommentRanges(); 656 - // There may be multiple JSDoc blocks. A file-level block is distinguished 657 - // by being the first one AND being separated from the statement by a blank 658 - // line (i.e. the following range is the statement's own JSDoc). 659 - // Heuristic: if there are 2+ ranges, the first is file-level. 660 - // If only 1 range, it belongs to the statement itself (no file-level doc). 661 - if (ranges.length < 2) return ''; 662 - const jsDocRange = ranges[0]; 663 - if (!jsDocRange?.getText().startsWith('/**')) return ''; 664 - // Strip /** ... */ markers and leading * on each line. 665 - return stripJsDocMarkers(jsDocRange.getText()); 666 - } 667 - 668 - /** 669 - * Strip `/** … *​/` markers and leading `*` on each line. 670 - * Single-pass: avoids intermediate array allocations from `.split().map().join()`. 671 - */ 672 - function stripJsDocMarkers(raw: string): string { 673 - let start = raw.startsWith('/**') ? 3 : 0; 674 - let end = raw.endsWith('*/') ? raw.length - 2 : raw.length; 675 - while (start < end && raw.charCodeAt(start) <= 32) start++; 676 - while (end > start && raw.charCodeAt(end - 1) <= 32) end--; 677 - 678 - const out: Array<string> = []; 679 - let i = start; 680 - while (i < end) { 681 - const nl = raw.indexOf('\n', i); 682 - const lineEnd = nl === -1 ? end : nl; 683 - let j = i; 684 - while (j < lineEnd && raw.charCodeAt(j) <= 32) j++; 685 - if (j < lineEnd && raw.charCodeAt(j) === 42 /* * */) { 686 - j++; 687 - if (j < lineEnd && raw.charCodeAt(j) === 32 /* space */) j++; 688 - } 689 - out.push(raw.slice(j, lineEnd)); 690 - i = lineEnd + 1; 691 - } 692 - return out.join('\n').trim(); 693 - } 694 - 695 - /** Return the JSDoc attached to a node, if any. */ 696 - function getJsDoc(node: Node) { 697 - // ts-morph exposes getJsDocs() on many node types via mixin. 698 - type JsDocLike = { getDescription(): string }; 699 - type JsDocHost = Node & { getJsDocs: () => Array<JsDocLike> }; 700 - const hasJsDocs = (n: Node): n is JsDocHost => 701 - 'getJsDocs' in n && typeof n.getJsDocs === 'function'; 702 - const tryGet = (n: Node) => (hasJsDocs(n) ? (n.getJsDocs()[0] ?? null) : null); 703 - // VariableDeclaration nodes don't carry JsDocs — they live on the parent 704 - // VariableStatement. Walk up one level to find them. 705 - if (node.getKind() === SyntaxKind.VariableDeclaration) { 706 - const parent = node.getParent(); // VariableDeclarationList 707 - const grandParent = parent?.getParent(); // VariableStatement 708 - const doc = grandParent ? tryGet(grandParent) : null; 709 - if (doc) return doc; 710 - } 711 - return tryGet(node); 712 - } 713 - 714 - /** 715 - * Produce a short, readable type signature for the node. 716 - * Returns undefined when the signature would be too verbose. 717 - */ 718 - function getTypeSignature(node: Node, name: string): string | undefined { 719 - if (Node.isFunctionDeclaration(node)) { 720 - const params = node 721 - .getParameters() 722 - .map((p) => `${p.getName()}: ${p.getType().getText(p)}`) 723 - .join(', '); 724 - const returnType = node.getReturnType().getText(node); 725 - // Skip massive return types. 726 - if (returnType.length > 80) return; 727 - const cleanParams = stripImportPaths(params); 728 - const cleanReturn = stripImportPaths(returnType); 729 - return `function ${name}(${cleanParams}): ${cleanReturn}`; 730 - } 731 - 732 - if (Node.isTypeAliasDeclaration(node)) { 733 - const text = node.getTypeNode()?.getText() ?? ''; 734 - // Skip large union types. 735 - if (text.length > 120) return; 736 - return `type ${name} = ${text}`; 737 - } 738 - 739 - return; 740 - }
-110
packages/@luke-ui/docs-tools/src/render-index.test.ts
··· 1 - import { describe, expect, it } from 'vite-plus/test'; 2 - import type { PackageDocsCatalogMetadata } from './package-docs-catalog.js'; 3 - import { renderIndex } from './render-index.js'; 4 - 5 - const sampleEntries: Array<PackageDocsCatalogMetadata> = [ 6 - { 7 - description: 'Composed button.', 8 - pageKind: 'component', 9 - path: './button', 10 - shape: 'component', 11 - slug: 'button', 12 - target: '', 13 - tier: 'composed', 14 - title: 'Button', 15 - }, 16 - { 17 - description: 'Bare button.', 18 - pageKind: 'component', 19 - path: './button/primitive', 20 - shape: 'component', 21 - slug: 'button-primitive', 22 - target: '', 23 - tier: 'primitive', 24 - title: 'Button (primitive)', 25 - }, 26 - { 27 - description: 'Design tokens.', 28 - pageKind: 'barrel', 29 - path: './tokens', 30 - shape: 'barrel', 31 - slug: 'tokens', 32 - target: '', 33 - tier: 'n/a', 34 - title: 'Tokens', 35 - }, 36 - { 37 - description: '', 38 - pageKind: 'asset', 39 - path: './stylesheet.css', 40 - shape: 'asset', 41 - slug: 'stylesheet', 42 - target: '', 43 - tier: 'n/a', 44 - title: 'Stylesheet', 45 - }, 46 - ]; 47 - 48 - describe('renderIndex', () => { 49 - it('uses resolved catalog titles directly', () => { 50 - const out = renderIndex({ 51 - entries: sampleEntries.map((entry) => 52 - entry.slug === 'button' ? { ...entry, title: 'Resolved Button' } : entry, 53 - ), 54 - includeLibraryAuthors: false, 55 - packageName: '@luke-ui/react', 56 - }); 57 - expect(out).toMatch(/- \[Resolved Button\]\(\.\/button\.md\)/); 58 - }); 59 - 60 - it('lists composed components in the primary section', () => { 61 - const out = renderIndex({ 62 - entries: sampleEntries, 63 - includeLibraryAuthors: false, 64 - packageName: '@luke-ui/react', 65 - }); 66 - expect(out).toMatch(/- \[Button\]\(\.\/button\.md\)/); 67 - }); 68 - 69 - it('omits primitives when includeLibraryAuthors is false', () => { 70 - const out = renderIndex({ 71 - entries: sampleEntries, 72 - includeLibraryAuthors: false, 73 - packageName: '@luke-ui/react', 74 - }); 75 - expect(out).not.toMatch(/Button \(primitive\)/); 76 - expect(out).not.toMatch(/## Library authors/); 77 - }); 78 - 79 - it('includes primitives in a Library authors section when flag is true', () => { 80 - const out = renderIndex({ 81 - entries: sampleEntries, 82 - includeLibraryAuthors: true, 83 - packageName: '@luke-ui/react', 84 - }); 85 - expect(out).toMatch(/## Library authors/); 86 - expect(out).toMatch(/- \[Button \(primitive\)\]\(\.\/button-primitive\.md\)/); 87 - }); 88 - 89 - it('lists assets inline (no link) under Assets section', () => { 90 - const out = renderIndex({ 91 - entries: sampleEntries, 92 - includeLibraryAuthors: true, 93 - packageName: '@luke-ui/react', 94 - }); 95 - expect(out).toMatch(/## Assets/); 96 - expect(out).toMatch(/stylesheet\.css.*import '@luke-ui\/react\/stylesheet\.css'/); 97 - }); 98 - 99 - it('uses entry href when provided', () => { 100 - const out = renderIndex({ 101 - entries: sampleEntries.map((entry) => 102 - entry.slug === 'button' ? { ...entry, href: './docs/components/actions/button.md' } : entry, 103 - ), 104 - includeLibraryAuthors: false, 105 - packageName: '@luke-ui/react', 106 - }); 107 - expect(out).toMatch(/\[Button\]\(\.\/docs\/components\/actions\/button\.md\)/); 108 - expect(out).toMatch(/\[Tokens\]\(\.\/tokens\.md\)/); 109 - }); 110 - });
-92
packages/@luke-ui/docs-tools/src/render-index.ts
··· 1 - import type { PackageDocsCatalogMetadata } from './package-docs-catalog.js'; 2 - import { tierBucket } from './package-docs-catalog.js'; 3 - 4 - export interface IndexEntry extends PackageDocsCatalogMetadata { 5 - href?: string; 6 - } 7 - 8 - export interface RenderIndexInput { 9 - entries: Array<IndexEntry>; 10 - includeLibraryAuthors: boolean; 11 - packageName: string; 12 - } 13 - 14 - const ASSET_NOTES: Record<string, string> = { 15 - './package.json': 'package manifest export, used by tooling — no docs.', 16 - './spritesheet.svg': 17 - 'sprite sheet referenced by the `Icon` atom; bundlers resolve this automatically.', 18 - './stylesheet.css': "required side-effect import: `import '@luke-ui/react/stylesheet.css'`", 19 - }; 20 - 21 - export function renderIndex(input: RenderIndexInput): string { 22 - const { packageName, entries, includeLibraryAuthors } = input; 23 - const lines: Array<string> = []; 24 - lines.push(`# ${packageName}`); 25 - lines.push(''); 26 - 27 - const primary: Array<IndexEntry> = []; 28 - const primitives: Array<IndexEntry> = []; 29 - const barrels: Array<IndexEntry> = []; 30 - const assets: Array<IndexEntry> = []; 31 - 32 - for (const e of entries) { 33 - switch (tierBucket(e)) { 34 - case 'primitive': 35 - primitives.push(e); 36 - break; 37 - case 'barrel': 38 - barrels.push(e); 39 - break; 40 - case 'asset': 41 - assets.push(e); 42 - break; 43 - default: 44 - primary.push(e); 45 - break; 46 - } 47 - } 48 - 49 - lines.push('## Components'); 50 - lines.push(''); 51 - for (const e of primary) { 52 - lines.push(formatLink(e)); 53 - } 54 - lines.push(''); 55 - 56 - if (barrels.length > 0) { 57 - lines.push('## Foundations'); 58 - lines.push(''); 59 - for (const e of barrels) { 60 - lines.push(formatLink(e)); 61 - } 62 - lines.push(''); 63 - } 64 - 65 - if (includeLibraryAuthors && primitives.length > 0) { 66 - lines.push('## Library authors'); 67 - lines.push(''); 68 - for (const e of primitives) { 69 - lines.push(formatLink(e)); 70 - } 71 - lines.push(''); 72 - } 73 - 74 - if (assets.length > 0) { 75 - lines.push('## Assets'); 76 - lines.push(''); 77 - for (const e of assets) { 78 - const note = ASSET_NOTES[e.path] ?? ''; 79 - const path = e.path.startsWith('./') ? e.path.slice(2) : e.path; 80 - lines.push(`- \`${path}\` — ${note}`); 81 - } 82 - lines.push(''); 83 - } 84 - 85 - return lines.join('\n'); 86 - } 87 - 88 - function formatLink(e: IndexEntry): string { 89 - const desc = e.description ? `: ${e.description}` : ''; 90 - const href = e.href ?? `./${e.slug}.md`; 91 - return `- [${e.title}](${href})${desc}`; 92 - }
-148
packages/@luke-ui/docs-tools/src/render-llms-full.test.ts
··· 1 - import { describe, expect, it } from 'vite-plus/test'; 2 - import type { PackageDocsCatalogMetadata } from './package-docs-catalog.js'; 3 - import { renderLlmsFull, sortLlmsFullEntries } from './render-llms-full.js'; 4 - 5 - type LlmsFullFixture = Pick<PackageDocsCatalogMetadata, 'slug' | 'shape' | 'tier'> & { 6 - md: string; 7 - }; 8 - 9 - const md = (slug: string): string => `# ${slug}\nbody-${slug}`; 10 - 11 - describe('sortLlmsFullEntries', () => { 12 - it('orders atoms → composed → barrels → primitives, each alphabetical by slug', () => { 13 - const entries: Array<LlmsFullFixture> = [ 14 - // Deliberately mixed and non-alphabetic insertion order. 15 - { 16 - md: md('combobox-field-primitive'), 17 - shape: 'component', 18 - slug: 'combobox-field-primitive', 19 - tier: 'primitive', 20 - }, 21 - { md: md('tokens'), shape: 'barrel', slug: 'tokens', tier: 'n/a' }, 22 - { md: md('button'), shape: 'component', slug: 'button', tier: 'composed' }, 23 - { md: md('icon'), shape: 'component', slug: 'icon', tier: 'atom' }, 24 - { 25 - md: md('button-primitive'), 26 - shape: 'component', 27 - slug: 'button-primitive', 28 - tier: 'primitive', 29 - }, 30 - { md: md('recipes'), shape: 'barrel', slug: 'recipes', tier: 'n/a' }, 31 - { md: md('avatar'), shape: 'component', slug: 'avatar', tier: 'composed' }, 32 - { md: md('emoji'), shape: 'component', slug: 'emoji', tier: 'atom' }, 33 - ]; 34 - const sorted = sortLlmsFullEntries(entries).map((e) => e.slug); 35 - expect(sorted).toEqual([ 36 - // atoms 37 - 'emoji', 38 - 'icon', 39 - // composed 40 - 'avatar', 41 - 'button', 42 - // barrels 43 - 'recipes', 44 - 'tokens', 45 - // primitives 46 - 'button-primitive', 47 - 'combobox-field-primitive', 48 - ]); 49 - }); 50 - 51 - it('drops entries that do not belong to a bucket (assets, unknown tiers)', () => { 52 - const entries: Array<LlmsFullFixture> = [ 53 - { md: md('stylesheet'), shape: 'asset', slug: 'stylesheet', tier: 'n/a' }, 54 - { md: md('button'), shape: 'component', slug: 'button', tier: 'composed' }, 55 - // component with tier 'n/a' should not slip through as composed. 56 - { md: md('mystery'), shape: 'component', slug: 'mystery', tier: 'n/a' }, 57 - ]; 58 - const sorted = sortLlmsFullEntries(entries).map((e) => e.slug); 59 - expect(sorted).toEqual(['button']); 60 - }); 61 - 62 - it('treats atoms before composed regardless of slug ordering', () => { 63 - const entries: Array<LlmsFullFixture> = [ 64 - { md: md('aardvark'), shape: 'component', slug: 'aardvark', tier: 'composed' }, 65 - { md: md('zebra'), shape: 'component', slug: 'zebra', tier: 'atom' }, 66 - ]; 67 - const sorted = sortLlmsFullEntries(entries).map((e) => e.slug); 68 - expect(sorted).toEqual(['zebra', 'aardvark']); 69 - }); 70 - 71 - it('places barrels before primitives even when primitive slugs sort earlier', () => { 72 - const entries: Array<LlmsFullFixture> = [ 73 - { 74 - md: md('aardvark-primitive'), 75 - shape: 'component', 76 - slug: 'aardvark-primitive', 77 - tier: 'primitive', 78 - }, 79 - { md: md('tokens'), shape: 'barrel', slug: 'tokens', tier: 'n/a' }, 80 - ]; 81 - const sorted = sortLlmsFullEntries(entries).map((e) => e.slug); 82 - expect(sorted).toEqual(['tokens', 'aardvark-primitive']); 83 - }); 84 - 85 - it('does not mutate the input array', () => { 86 - const entries: Array<LlmsFullFixture> = [ 87 - { md: md('b'), shape: 'component', slug: 'b', tier: 'composed' }, 88 - { md: md('a'), shape: 'component', slug: 'a', tier: 'composed' }, 89 - ]; 90 - const snapshot = entries.map((e) => e.slug); 91 - sortLlmsFullEntries(entries); 92 - expect(entries.map((e) => e.slug)).toEqual(snapshot); 93 - }); 94 - }); 95 - 96 - describe('renderLlmsFull', () => { 97 - it('concatenates markdown in canonical (atom → composed → barrel → primitive) order', () => { 98 - // Insertion order is intentionally jumbled. 99 - const entries: Array<LlmsFullFixture> = [ 100 - { 101 - md: md('combobox-field-primitive'), 102 - shape: 'component', 103 - slug: 'combobox-field-primitive', 104 - tier: 'primitive', 105 - }, 106 - { md: md('tokens'), shape: 'barrel', slug: 'tokens', tier: 'n/a' }, 107 - { md: md('button'), shape: 'component', slug: 'button', tier: 'composed' }, 108 - { md: md('icon'), shape: 'component', slug: 'icon', tier: 'atom' }, 109 - { 110 - md: md('button-primitive'), 111 - shape: 'component', 112 - slug: 'button-primitive', 113 - tier: 'primitive', 114 - }, 115 - { md: md('recipes'), shape: 'barrel', slug: 'recipes', tier: 'n/a' }, 116 - { md: md('avatar'), shape: 'component', slug: 'avatar', tier: 'composed' }, 117 - { md: md('emoji'), shape: 'component', slug: 'emoji', tier: 'atom' }, 118 - ]; 119 - 120 - const output = renderLlmsFull(entries); 121 - 122 - const positions = [ 123 - '# emoji', 124 - '# icon', 125 - '# avatar', 126 - '# button', 127 - '# recipes', 128 - '# tokens', 129 - '# button-primitive', 130 - '# combobox-field-primitive', 131 - ].map((needle) => output.indexOf(needle)); 132 - 133 - expect(positions.every((p) => p > -1)).toBe(true); 134 - for (let i = 1; i < positions.length; i++) { 135 - expect(positions[i]).toBeGreaterThan(positions[i - 1]!); 136 - } 137 - 138 - expect(output).toMatch(/^# Luke UI — full documentation/); 139 - }); 140 - 141 - it('emits only the header when no entries fall into a bucket', () => { 142 - const output = renderLlmsFull([ 143 - { md: md('stylesheet'), shape: 'asset', slug: 'stylesheet', tier: 'n/a' }, 144 - ]); 145 - expect(output).toMatch(/^# Luke UI — full documentation/); 146 - expect(output).not.toContain('---'); 147 - }); 148 - });
-50
packages/@luke-ui/docs-tools/src/render-llms-full.ts
··· 1 - import type { PackageDocsCatalogMetadata } from './package-docs-catalog.js'; 2 - import { tierBucket } from './package-docs-catalog.js'; 3 - 4 - export interface LlmsFullEntry extends Pick<PackageDocsCatalogMetadata, 'slug' | 'shape' | 'tier'> { 5 - md: string; 6 - } 7 - 8 - /** 9 - * Aggregates per-export markdown into a single `llms-full.md`-style document. 10 - * 11 - * Entries are sorted into a stable semantic order so that the aggregated output is 12 - * independent of `package.json#exports` insertion order: 13 - * 14 - * 1. atoms (alphabetical by slug) 15 - * 2. composed (alphabetical by slug) 16 - * 3. barrels (alphabetical by slug) 17 - * 4. primitives (alphabetical by slug) 18 - * 19 - * Entries that don't fall into one of those buckets (e.g. assets) are skipped. 20 - */ 21 - export function renderLlmsFull(entries: Array<LlmsFullEntry>): string { 22 - const header = 23 - '# Luke UI — full documentation\n\n> Concatenated per-export documentation for AI consumption.\n'; 24 - const ordered = sortLlmsFullEntries(entries); 25 - const contents = ordered.map((entry) => entry.md); 26 - return [header, ...contents].join('\n\n---\n\n'); 27 - } 28 - 29 - /** 30 - * Returns a new array of entries sorted into the canonical llms-full order: 31 - * atoms → composed → barrels → primitives, each group alphabetical by slug. 32 - * Entries that don't classify into a bucket are dropped. 33 - */ 34 - export function sortLlmsFullEntries<T extends LlmsFullEntry>(entries: Array<T>): Array<T> { 35 - const buckets: Record<string, Array<T>> = {}; 36 - for (const entry of entries) { 37 - if (entry.shape !== 'component' && entry.shape !== 'barrel') continue; 38 - if (entry.shape === 'component' && !entry.tier) continue; 39 - 40 - const bucket = tierBucket(entry); 41 - (buckets[bucket] ??= []).push(entry); 42 - } 43 - const bySlug = (a: T, b: T) => a.slug.localeCompare(b.slug); 44 - return [ 45 - ...(buckets.atom ?? []).sort(bySlug), 46 - ...(buckets.composed ?? []).sort(bySlug), 47 - ...(buckets.barrel ?? []).sort(bySlug), 48 - ...(buckets.primitive ?? []).sort(bySlug), 49 - ]; 50 - }
-174
packages/@luke-ui/docs-tools/src/render-page.test.ts
··· 1 - import { describe, expect, it } from 'vite-plus/test'; 2 - import type { PackageDocsBarrelEntry, PackageDocsComponentEntry } from './package-docs-catalog.js'; 3 - import type { ParsedComponent } from './parse-types.js'; 4 - import { renderPage } from './render-page.js'; 5 - 6 - const sampleParsed: ParsedComponent = { 7 - description: 'Sample composed button.', 8 - propsInterface: { 9 - extends: [{ from: 'external', module: 'react-aria-components', typeName: 'ButtonProps' }], 10 - members: [ 11 - { 12 - default: "'medium'", 13 - description: 'Sets the size.', 14 - name: 'size', 15 - optional: true, 16 - type: "'small' | 'medium'", 17 - }, 18 - ], 19 - name: 'SampleProps', 20 - }, 21 - tier: 'composed', 22 - }; 23 - 24 - function componentEntry( 25 - overrides: Partial<PackageDocsComponentEntry> = {}, 26 - ): PackageDocsComponentEntry { 27 - return { 28 - description: 'Sample composed button.', 29 - pageKind: 'component', 30 - parsed: sampleParsed, 31 - path: './button', 32 - shape: 'component', 33 - slug: 'button', 34 - sourcePath: '/src/button/index.tsx', 35 - target: './dist/button/index.js', 36 - tier: 'composed', 37 - title: 'Button', 38 - ...overrides, 39 - }; 40 - } 41 - 42 - function renderComponent( 43 - entryOverrides: Partial<PackageDocsComponentEntry> = {}, 44 - proseMarkdown = '', 45 - ): string { 46 - return renderPage({ 47 - entry: componentEntry(entryOverrides), 48 - importPath: '@luke-ui/react/button', 49 - proseMarkdown, 50 - }); 51 - } 52 - 53 - const sampleExports = [ 54 - { 55 - description: 'The main control.', 56 - name: 'WidgetControl', 57 - type: 'function WidgetControl(props: WidgetControlProps): JSX.Element', 58 - }, 59 - { description: 'A single item.', name: 'WidgetItem', type: undefined }, 60 - ]; 61 - 62 - function barrelEntry(overrides: Partial<PackageDocsBarrelEntry> = {}): PackageDocsBarrelEntry { 63 - return { 64 - description: '', 65 - pageKind: 'barrel', 66 - parsed: { description: '', exports: sampleExports }, 67 - path: './combobox-field/primitive', 68 - shape: 'component', 69 - slug: 'combobox-field-primitive', 70 - sourcePath: '/src/combobox-field/primitive/index.tsx', 71 - target: './dist/combobox-field/primitive/index.js', 72 - tier: 'primitive', 73 - title: 'Combobox Field (primitive)', 74 - ...overrides, 75 - }; 76 - } 77 - 78 - function renderBarrel(entryOverrides: Partial<PackageDocsBarrelEntry> = {}): string { 79 - return renderPage({ 80 - entry: barrelEntry(entryOverrides), 81 - importPath: '@luke-ui/react/combobox-field/primitive', 82 - proseMarkdown: undefined, 83 - }); 84 - } 85 - 86 - describe('renderPage component body', () => { 87 - it('uses resolved catalog metadata for the header', () => { 88 - const page = renderComponent({ 89 - description: 'Resolved description.', 90 - title: 'Resolved Button', 91 - }); 92 - expect(page).toMatch(/^# Resolved Button\n/); 93 - expect(page).toContain('> Resolved description.'); 94 - }); 95 - 96 - it('renders the import block', () => { 97 - expect(renderComponent()).toContain("import { Button } from '@luke-ui/react/button';"); 98 - }); 99 - 100 - it('uses parsed.componentName for the import identifier when the export is renamed', () => { 101 - const page = renderPage({ 102 - entry: componentEntry({ 103 - parsed: { ...sampleParsed, componentName: 'TextInput' }, 104 - path: './text-field/primitive', 105 - slug: 'text-field-primitive', 106 - tier: 'primitive', 107 - title: 'Text Field (primitive)', 108 - }), 109 - importPath: '@luke-ui/react/text-field/primitive', 110 - proseMarkdown: undefined, 111 - }); 112 - expect(page).toContain("import { TextInput } from '@luke-ui/react/text-field/primitive';"); 113 - }); 114 - 115 - it('renders the props table with own props only', () => { 116 - expect(renderComponent()).toMatch( 117 - /\| `size` \| `'small' \\\| 'medium'` \| `'medium'` \| Sets the size\. \|/, 118 - ); 119 - }); 120 - 121 - it('emits an extends pointer for external types', () => { 122 - expect(renderComponent()).toContain('Extends [`react-aria-components` `ButtonProps`]'); 123 - }); 124 - 125 - it('omits Usage section for primitives', () => { 126 - expect( 127 - renderComponent({ tier: 'primitive', title: 'Button (primitive)' }, '<authored usage>'), 128 - ).not.toContain('## Usage'); 129 - }); 130 - }); 131 - 132 - describe('renderPage barrel body', () => { 133 - it('uses the resolved title', () => { 134 - expect(renderBarrel()).toMatch(/^# Combobox Field \(primitive\)\n/); 135 - }); 136 - 137 - it('renders an Import block listing all exports', () => { 138 - expect(renderBarrel()).toContain( 139 - "import { WidgetControl, WidgetItem } from '@luke-ui/react/combobox-field/primitive';", 140 - ); 141 - }); 142 - 143 - it('marks type-only exports in the import block', () => { 144 - const page = renderBarrel({ 145 - parsed: { 146 - description: '', 147 - exports: [ 148 - { 149 - description: 'Allowed levels.', 150 - name: 'HeadingLevel', 151 - type: 'type HeadingLevel = 1', 152 - }, 153 - { description: 'Provider.', name: 'HeadingLevels', type: undefined }, 154 - ], 155 - }, 156 - }); 157 - expect(page).toContain('import { type HeadingLevel, HeadingLevels }'); 158 - }); 159 - 160 - it('renders each export with descriptions and type signatures', () => { 161 - const page = renderBarrel(); 162 - expect(page).toContain('## Exports'); 163 - expect(page).toContain('### `WidgetControl`'); 164 - expect(page).toContain('### `WidgetItem`'); 165 - expect(page).toContain('The main control.'); 166 - expect(page).toContain('function WidgetControl(props: WidgetControlProps): JSX.Element'); 167 - }); 168 - 169 - it('renders the resolved description as a blockquote', () => { 170 - expect(renderBarrel({ description: 'A barrel of field primitives.' })).toContain( 171 - '> A barrel of field primitives.', 172 - ); 173 - }); 174 - });
-117
packages/@luke-ui/docs-tools/src/render-page.ts
··· 1 - import type { PackageDocsBarrelEntry, PackageDocsComponentEntry } from './package-docs-catalog.js'; 2 - 3 - const RAC_DOCS_BASE = 'https://react-spectrum.adobe.com/react-aria'; 4 - 5 - interface RenderPageInput { 6 - entry: PackageDocsComponentEntry | PackageDocsBarrelEntry; 7 - importPath: string; 8 - /** Authored Markdown prose for the Usage section. Undefined for primitives. */ 9 - proseMarkdown: string | undefined; 10 - } 11 - export function renderPage(input: RenderPageInput): string { 12 - const lines = renderHeader(input); 13 - if (input.entry.pageKind === 'component') { 14 - renderComponentBody(lines, input.entry, input.proseMarkdown); 15 - } else { 16 - renderBarrelBody(lines, input.entry); 17 - } 18 - return lines.join('\n'); 19 - } 20 - 21 - function renderHeader(input: RenderPageInput): Array<string> { 22 - const lines: Array<string> = []; 23 - 24 - lines.push(`# ${input.entry.title}`); 25 - lines.push(''); 26 - if (input.entry.description) { 27 - lines.push(`> ${input.entry.description.replace(/\n/g, ' ')}`); 28 - lines.push(''); 29 - } 30 - 31 - lines.push('## Import'); 32 - lines.push(''); 33 - lines.push('```ts'); 34 - lines.push(importStatement(input)); 35 - lines.push('```'); 36 - lines.push(''); 37 - 38 - return lines; 39 - } 40 - 41 - function importStatement(input: RenderPageInput): string { 42 - if (input.entry.pageKind === 'barrel') { 43 - const importNames = input.entry.parsed.exports.map((e) => 44 - e.type?.startsWith('type ') ? `type ${e.name}` : e.name, 45 - ); 46 - return `import { ${importNames.join(', ')} } from '${input.importPath}';`; 47 - } 48 - 49 - const importIdent = input.entry.parsed.componentName ?? exportIdentifier(input.entry.slug); 50 - return `import { ${importIdent} } from '${input.importPath}';`; 51 - } 52 - 53 - function renderComponentBody( 54 - lines: Array<string>, 55 - entry: PackageDocsComponentEntry, 56 - proseMarkdown: string | undefined, 57 - ): void { 58 - const { tier, parsed } = entry; 59 - 60 - if (tier !== 'primitive' && proseMarkdown) { 61 - lines.push('## Usage'); 62 - lines.push(''); 63 - lines.push(proseMarkdown.trim()); 64 - lines.push(''); 65 - } 66 - 67 - if (parsed.propsInterface) { 68 - lines.push('## Props'); 69 - lines.push(''); 70 - const externalExtends = parsed.propsInterface.extends.find((e) => e.from === 'external'); 71 - if (externalExtends?.module === 'react-aria-components') { 72 - const componentName = externalExtends.typeName.replace(/Props$/, ''); 73 - lines.push( 74 - `Extends [\`react-aria-components\` \`${externalExtends.typeName}\`](${RAC_DOCS_BASE}/${componentName}.html).`, 75 - ); 76 - lines.push(''); 77 - } 78 - lines.push('| Prop | Type | Default | Description |'); 79 - lines.push('| --- | --- | --- | --- |'); 80 - for (const prop of parsed.propsInterface.members) { 81 - const type = prop.type.replace(/\|/g, '\\|'); 82 - const def = prop.default ? `\`${prop.default}\`` : '—'; 83 - lines.push( 84 - `| \`${prop.name}\` | \`${type}\` | ${def} | ${prop.description.replace(/\n/g, ' ')} |`, 85 - ); 86 - } 87 - lines.push(''); 88 - } 89 - } 90 - 91 - function renderBarrelBody(lines: Array<string>, entry: PackageDocsBarrelEntry): void { 92 - lines.push('## Exports'); 93 - lines.push(''); 94 - 95 - for (const e of entry.parsed.exports) { 96 - lines.push(`### \`${e.name}\``); 97 - lines.push(''); 98 - if (e.type) { 99 - lines.push('```ts'); 100 - lines.push(e.type); 101 - lines.push('```'); 102 - lines.push(''); 103 - } 104 - if (e.description) { 105 - lines.push(e.description); 106 - lines.push(''); 107 - } 108 - } 109 - } 110 - 111 - function exportIdentifier(slug: string): string { 112 - const stripped = slug.endsWith('-primitive') ? slug.slice(0, -'-primitive'.length) : slug; 113 - return stripped 114 - .split('-') 115 - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) 116 - .join(''); 117 - }
-11
packages/@luke-ui/docs-tools/src/title.ts
··· 1 - import type { ExportTier } from './discover-exports.js'; 2 - 3 - export function slugToTitle(slug: string, tier: ExportTier): string { 4 - const isPrimitive = slug.endsWith('-primitive'); 5 - const base = (isPrimitive ? slug.slice(0, -'-primitive'.length) : slug) 6 - .split('-') 7 - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) 8 - .join(' '); 9 - if (tier === 'primitive') return `${base} (primitive)`; 10 - return base; 11 - }
-12
packages/@luke-ui/react/scripts/generate-docs.test.ts
··· 1 - import { DEFAULT_BARREL_PATHS } from '@luke-ui/docs-tools/discover-exports'; 2 - import { describe, expect, it } from 'vite-plus/test'; 3 - import packageJson from '../package.json' with { type: 'json' }; 4 - 5 - describe('generate-docs package interface conformance', () => { 6 - it('keeps DEFAULT_BARREL_PATHS in sync with the real package exports', () => { 7 - const realExportPaths = new Set(Object.keys(packageJson.exports)); 8 - for (const barrelPath of DEFAULT_BARREL_PATHS) { 9 - expect(realExportPaths.has(barrelPath)).toBe(true); 10 - } 11 - }); 12 - });
-78
packages/@luke-ui/react/scripts/generate-docs.ts
··· 1 - import { join } from 'node:path'; 2 - import { fileURLToPath } from 'node:url'; 3 - import { resolvePackageDocsCatalog } from '@luke-ui/docs-tools/package-docs-catalog'; 4 - import { renderIndex } from '@luke-ui/docs-tools/render-index'; 5 - import { renderLlmsFull } from '@luke-ui/docs-tools/render-llms-full'; 6 - import { renderPage } from '@luke-ui/docs-tools/render-page'; 7 - import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; 8 - import packageJson from '../package.json' with { type: 'json' }; 9 - 10 - const packageRoot = fileURLToPath(new URL('../', import.meta.url)); 11 - const docsDir = join(packageRoot, 'docs'); 12 - 13 - const catalog = resolvePackageDocsCatalog({ 14 - exportsField: packageJson.exports, 15 - packageRoot, 16 - }); 17 - 18 - await mkdir(docsDir, { recursive: true }); 19 - await removeGeneratedMarkdown(docsDir); 20 - 21 - const pages = await Promise.all( 22 - catalog.map(async (entry) => { 23 - if (entry.pageKind === 'asset') return; 24 - 25 - const proseMarkdown = 26 - entry.pageKind === 'component' 27 - ? await readProse(packageRoot, entry).catch(() => undefined) 28 - : undefined; 29 - const md = renderPage({ 30 - entry, 31 - importPath: `${packageJson.name}${entry.path.replace(/^\./, '')}`, 32 - proseMarkdown, 33 - }); 34 - return { md, shape: entry.shape, slug: entry.slug, tier: entry.tier }; 35 - }), 36 - ); 37 - 38 - await Promise.all( 39 - pages.flatMap((page) => 40 - page ? [writeFile(join(docsDir, `${page.slug}.md`), page.md, 'utf8')] : [], 41 - ), 42 - ); 43 - 44 - const llmsTxt = renderIndex({ 45 - entries: catalog, 46 - includeLibraryAuthors: true, 47 - packageName: packageJson.name, 48 - }); 49 - 50 - await writeFile(join(docsDir, 'llms.txt'), llmsTxt, 'utf8'); 51 - 52 - const distDocsDir = join(packageRoot, 'dist', 'docs'); 53 - await mkdir(distDocsDir, { recursive: true }); 54 - await removeGeneratedMarkdown(distDocsDir); 55 - const llmsFull = renderLlmsFull(pages.filter((page) => page != null)); 56 - await writeFile(join(distDocsDir, 'llms-full.md'), llmsFull, 'utf8'); 57 - 58 - // eslint-disable-next-line no-console -- build script progress 59 - console.log( 60 - `generate-docs: wrote ${pages.filter(Boolean).length} pages + llms.txt + dist/docs/llms-full.md`, 61 - ); 62 - 63 - async function readProse(root: string, entry: { path: string }): Promise<string | undefined> { 64 - if (entry.path.endsWith('/primitive')) return; 65 - const dirSlug = entry.path.replace(/^\.\//, ''); 66 - const prosePath = join(root, 'src', dirSlug, `${dirSlug}.docs.md`); 67 - return readFile(prosePath, 'utf8'); 68 - } 69 - 70 - async function removeGeneratedMarkdown(dir: string): Promise<void> { 71 - const files = await readdir(dir); 72 - 73 - await Promise.all( 74 - files 75 - .filter((file) => file.endsWith('.md')) 76 - .map((file) => rm(join(dir, file), { force: true })), 77 - ); 78 - }
+10
apps/docs/src/examples/button/pending.tsx
··· 1 + import { Button } from '@luke-ui/react/button'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Pending(): JSX.Element { 5 + return ( 6 + <div style={{ alignItems: 'center', display: 'flex', gap: '1rem' }}> 7 + <Button isPending>Saving</Button> 8 + </div> 9 + ); 10 + }
+11
apps/docs/src/examples/button/sizes.tsx
··· 1 + import { Button } from '@luke-ui/react/button'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Sizes(): JSX.Element { 5 + return ( 6 + <div style={{ alignItems: 'center', display: 'flex', gap: '1rem' }}> 7 + <Button size="small">Small</Button> 8 + <Button size="medium">Medium</Button> 9 + </div> 10 + ); 11 + }
+13
apps/docs/src/examples/button/tones.tsx
··· 1 + import { Button } from '@luke-ui/react/button'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Tones(): JSX.Element { 5 + return ( 6 + <div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: '1rem' }}> 7 + <Button tone="primary">Primary</Button> 8 + <Button tone="critical">Critical</Button> 9 + <Button tone="ghost">Ghost</Button> 10 + <Button tone="neutral">Neutral</Button> 11 + </div> 12 + ); 13 + }
+25
apps/docs/src/examples/combobox-field/basic.tsx
··· 1 + import { ComboboxField } from '@luke-ui/react/combobox-field'; 2 + import { ComboboxItem } from '@luke-ui/react/combobox-field/primitive'; 3 + import type { JSX } from 'react'; 4 + 5 + type Country = { id: string; label: string }; 6 + 7 + const countries: Array<Country> = [ 8 + { id: 'au', label: 'Australia' }, 9 + { id: 'ca', label: 'Canada' }, 10 + { id: 'nz', label: 'New Zealand' }, 11 + { id: 'us', label: 'United States' }, 12 + ]; 13 + 14 + export default function Basic(): JSX.Element { 15 + return ( 16 + <ComboboxField 17 + label="Country" 18 + name="country" 19 + placeholder="Select a country..." 20 + defaultItems={countries} 21 + > 22 + {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 23 + </ComboboxField> 24 + ); 25 + }
+16
apps/docs/src/examples/combobox-field/grouped.tsx
··· 1 + import { ComboboxField } from '@luke-ui/react/combobox-field'; 2 + import { ComboboxItem, ComboboxSection } from '@luke-ui/react/combobox-field/primitive'; 3 + import type { JSX } from 'react'; 4 + 5 + export default function Grouped(): JSX.Element { 6 + return ( 7 + <ComboboxField label="Country" name="country"> 8 + <ComboboxSection title="Northern hemisphere"> 9 + <ComboboxItem id="ca">Canada</ComboboxItem> 10 + </ComboboxSection> 11 + <ComboboxSection title="Southern hemisphere"> 12 + <ComboboxItem id="au">Australia</ComboboxItem> 13 + </ComboboxSection> 14 + </ComboboxField> 15 + ); 16 + }
+24
apps/docs/src/examples/combobox-field/required.tsx
··· 1 + import { ComboboxField } from '@luke-ui/react/combobox-field'; 2 + import { ComboboxItem } from '@luke-ui/react/combobox-field/primitive'; 3 + import type { JSX } from 'react'; 4 + 5 + const items = [ 6 + { id: 'au', label: 'Australia' }, 7 + { id: 'ca', label: 'Canada' }, 8 + { id: 'nz', label: 'New Zealand' }, 9 + { id: 'us', label: 'United States' }, 10 + ]; 11 + 12 + export default function Required(): JSX.Element { 13 + return ( 14 + <ComboboxField 15 + isRequired 16 + label="Country" 17 + name="country" 18 + necessityIndicator="icon" 19 + defaultItems={items} 20 + > 21 + {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 22 + </ComboboxField> 23 + ); 24 + }
+11
apps/docs/src/examples/emoji/basic.tsx
··· 1 + import { Emoji } from '@luke-ui/react/emoji'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Basic(): JSX.Element { 5 + return ( 6 + <div style={{ alignItems: 'center', display: 'flex', gap: '1rem' }}> 7 + <Emoji emoji="🎉" label="Celebration" /> 8 + <Emoji emoji="🚀" label="Rocket" fontSize="large" /> 9 + </div> 10 + ); 11 + }
+17
apps/docs/src/examples/heading/automatic-leveling.tsx
··· 1 + import { Heading } from '@luke-ui/react/heading'; 2 + import { HeadingLevels } from '@luke-ui/react/heading-context'; 3 + import type { JSX } from 'react'; 4 + 5 + export default function AutomaticLeveling(): JSX.Element { 6 + return ( 7 + <HeadingLevels base={1}> 8 + <Heading>h1</Heading> 9 + <HeadingLevels> 10 + <Heading>h2 nested automatically</Heading> 11 + <HeadingLevels> 12 + <Heading>h3 nested again</Heading> 13 + </HeadingLevels> 14 + </HeadingLevels> 15 + </HeadingLevels> 16 + ); 17 + }
+15
apps/docs/src/examples/heading/typography.tsx
··· 1 + import { Heading } from '@luke-ui/react/heading'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Typography(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> 7 + <Heading level={3} color="informative"> 8 + Informative heading 9 + </Heading> 10 + <Heading level={4} fontWeight="regular"> 11 + Light-weight heading 12 + </Heading> 13 + </div> 14 + ); 15 + }
+11
apps/docs/src/examples/icon-button/size.tsx
··· 1 + import { IconButton } from '@luke-ui/react/icon-button'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Size(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}> 7 + <IconButton icon="delete" aria-label="Delete" size="small" /> 8 + <IconButton icon="delete" aria-label="Delete" /> 9 + </div> 10 + ); 11 + }
+11
apps/docs/src/examples/icon-button/tones.tsx
··· 1 + import { IconButton } from '@luke-ui/react/icon-button'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Tone(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', gap: '1rem' }}> 7 + <IconButton icon="close" aria-label="Close" tone="ghost" /> 8 + <IconButton icon="delete" aria-label="Delete" tone="critical" /> 9 + </div> 10 + ); 11 + }
+11
apps/docs/src/examples/icon/basic.tsx
··· 1 + import { Icon } from '@luke-ui/react/icon'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Basic(): JSX.Element { 5 + return ( 6 + <div style={{ alignItems: 'center', display: 'flex', gap: '1rem' }}> 7 + <Icon name="add" title="Add" size="xsmall" /> 8 + <Icon name="close" aria-hidden size="medium" /> 9 + </div> 10 + ); 11 + }
+12
apps/docs/src/examples/icon/custom.tsx
··· 1 + import { createIcon } from '@luke-ui/react/icon'; 2 + import type { JSX } from 'react'; 3 + 4 + const HeartIcon = createIcon({ 5 + path: ( 6 + <path d="M12 21a1 1 0 0 1-.7-.3L5 14.5a5 5 0 1 1 7-6 5 5 0 1 1 7 6l-6.3 6.2a1 1 0 0 1-.7.3Z" /> 7 + ), 8 + }); 9 + 10 + export default function Custom(): JSX.Element { 11 + return <HeartIcon title="Favorite" size="small" />; 12 + }
+10
apps/docs/src/examples/link/disabled.tsx
··· 1 + import { Link } from '@luke-ui/react/link'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Disabled(): JSX.Element { 5 + return ( 6 + <Link href="/archived" isDisabled> 7 + Archived report 8 + </Link> 9 + ); 10 + }
+15
apps/docs/src/examples/link/standalone.tsx
··· 1 + import { Link } from '@luke-ui/react/link'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Standalone(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> 7 + <Link href="#" isStandalone> 8 + Standalone link 9 + </Link> 10 + <p> 11 + This is an <Link href="#">inline link</Link> inside a sentence. 12 + </p> 13 + </div> 14 + ); 15 + }
+18
apps/docs/src/examples/link/tones.tsx
··· 1 + import { Link } from '@luke-ui/react/link'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Tones(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> 7 + <Link href="#">Brand</Link> 8 + <Link href="#" tone="neutral"> 9 + Neutral 10 + </Link> 11 + <div style={{ background: 'black', padding: '1rem' }}> 12 + <Link href="#" tone="inverted"> 13 + Inverted 14 + </Link> 15 + </div> 16 + </div> 17 + ); 18 + }
+24
apps/docs/src/examples/loading-skeleton/border-radius.tsx
··· 1 + import { LoadingSkeleton } from '@luke-ui/react/loading-skeleton'; 2 + import { TextField } from '@luke-ui/react/text-field'; 3 + import type { JSX } from 'react'; 4 + import { useState } from 'react'; 5 + 6 + export default function BorderRadius(): JSX.Element { 7 + const [isLoading, setIsLoading] = useState(true); 8 + 9 + return ( 10 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxInlineSize: '20rem' }}> 11 + <LoadingSkeleton borderRadius="0.25rem" isLoading={isLoading}> 12 + <TextField label="Email" name="email" /> 13 + </LoadingSkeleton> 14 + <label style={{ alignItems: 'center', display: 'flex', gap: '0.5rem' }}> 15 + <input 16 + checked={isLoading} 17 + onChange={(event) => setIsLoading(event.target.checked)} 18 + type="checkbox" 19 + /> 20 + Loading 21 + </label> 22 + </div> 23 + ); 24 + }
+25
apps/docs/src/examples/loading-skeleton/custom-dimensions.tsx
··· 1 + import { LoadingSkeleton } from '@luke-ui/react/loading-skeleton'; 2 + import type { JSX } from 'react'; 3 + import { useState } from 'react'; 4 + 5 + export default function CustomDimensions(): JSX.Element { 6 + const [isLoading, setIsLoading] = useState(true); 7 + 8 + return ( 9 + <div 10 + style={{ alignItems: 'flex-start', display: 'flex', flexDirection: 'column', gap: '1rem' }} 11 + > 12 + <LoadingSkeleton isLoading={isLoading}> 13 + <div style={{ borderRadius: '9999px', height: '3rem', width: '3rem' }} /> 14 + </LoadingSkeleton> 15 + <label style={{ alignItems: 'center', display: 'flex', gap: '0.5rem' }}> 16 + <input 17 + checked={isLoading} 18 + onChange={(event) => setIsLoading(event.target.checked)} 19 + type="checkbox" 20 + /> 21 + Loading 22 + </label> 23 + </div> 24 + ); 25 + }
+27
apps/docs/src/examples/loading-skeleton/element.tsx
··· 1 + import { LoadingSkeleton } from '@luke-ui/react/loading-skeleton'; 2 + import type { JSX } from 'react'; 3 + import { useState } from 'react'; 4 + 5 + export default function ElementSkeleton(): JSX.Element { 6 + const [isLoading, setIsLoading] = useState(true); 7 + 8 + return ( 9 + <div 10 + style={{ alignItems: 'flex-start', display: 'flex', flexDirection: 'column', gap: '1rem' }} 11 + > 12 + <ul> 13 + <LoadingSkeleton as="li" isLoading={isLoading}> 14 + List item placeholder 15 + </LoadingSkeleton> 16 + </ul> 17 + <label style={{ alignItems: 'center', display: 'flex', gap: '0.5rem' }}> 18 + <input 19 + checked={isLoading} 20 + onChange={(event) => setIsLoading(event.target.checked)} 21 + type="checkbox" 22 + /> 23 + Loading 24 + </label> 25 + </div> 26 + ); 27 + }
+30
apps/docs/src/examples/loading-skeleton/provider.tsx
··· 1 + import { Button } from '@luke-ui/react/button'; 2 + import { LoadingSkeleton, LoadingSkeletonProvider } from '@luke-ui/react/loading-skeleton'; 3 + import { Text } from '@luke-ui/react/text'; 4 + import type { JSX } from 'react'; 5 + import { useState } from 'react'; 6 + 7 + export default function ProviderSkeleton(): JSX.Element { 8 + const [isLoading, setIsLoading] = useState(true); 9 + 10 + return ( 11 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxInlineSize: '20rem' }}> 12 + <LoadingSkeletonProvider isLoading={isLoading}> 13 + <Text> 14 + <LoadingSkeleton isLoading={false}>Ada Lovelace</LoadingSkeleton> 15 + </Text> 16 + <LoadingSkeleton isLoading={false}> 17 + <Button>Edit profile</Button> 18 + </LoadingSkeleton> 19 + </LoadingSkeletonProvider> 20 + <label style={{ alignItems: 'center', display: 'flex', gap: '0.5rem' }}> 21 + <input 22 + checked={isLoading} 23 + onChange={(event) => setIsLoading(event.target.checked)} 24 + type="checkbox" 25 + /> 26 + Loading 27 + </label> 28 + </div> 29 + ); 30 + }
+23
apps/docs/src/examples/loading-skeleton/text.tsx
··· 1 + import { LoadingSkeleton } from '@luke-ui/react/loading-skeleton'; 2 + import type { JSX } from 'react'; 3 + import { useState } from 'react'; 4 + 5 + export default function TextSkeleton(): JSX.Element { 6 + const [isLoading, setIsLoading] = useState(true); 7 + 8 + return ( 9 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxInlineSize: '20rem' }}> 10 + <LoadingSkeleton isLoading={isLoading}> 11 + A short paragraph of placeholder copy that wraps across two lines. 12 + </LoadingSkeleton> 13 + <label style={{ alignItems: 'center', display: 'flex', gap: '0.5rem' }}> 14 + <input 15 + checked={isLoading} 16 + onChange={(event) => setIsLoading(event.target.checked)} 17 + type="checkbox" 18 + /> 19 + Loading 20 + </label> 21 + </div> 22 + ); 23 + }
+6
apps/docs/src/examples/loading-spinner/basic.tsx
··· 1 + import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Basic(): JSX.Element { 5 + return <LoadingSpinner aria-label="Loading" />; 6 + }
+11
apps/docs/src/examples/loading-spinner/colors.tsx
··· 1 + import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Colors(): JSX.Element { 5 + return ( 6 + <div style={{ alignItems: 'center', display: 'flex', gap: '1rem' }}> 7 + <LoadingSpinner aria-label="Syncing" color="informative" /> 8 + <LoadingSpinner aria-label="Syncing" color="critical" /> 9 + </div> 10 + ); 11 + }
+11
apps/docs/src/examples/loading-spinner/progress.tsx
··· 1 + import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Progress(): JSX.Element { 5 + return ( 6 + <div style={{ alignItems: 'center', display: 'flex', gap: '1rem' }}> 7 + <LoadingSpinner aria-label="Loading" /> 8 + <LoadingSpinner aria-label="Loading profile" value={66} /> 9 + </div> 10 + ); 11 + }
+11
apps/docs/src/examples/loading-spinner/sizes.tsx
··· 1 + import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Sizes(): JSX.Element { 5 + return ( 6 + <div style={{ alignItems: 'center', display: 'flex', gap: '1rem' }}> 7 + <LoadingSpinner aria-label="Syncing" size="small" /> 8 + <LoadingSpinner aria-label="Syncing" size="medium" /> 9 + </div> 10 + ); 11 + }
+6
apps/docs/src/examples/numeral/basic.tsx
··· 1 + import { Numeral } from '@luke-ui/react/numeral'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Basic(): JSX.Element { 5 + return <Numeral value={12_345.67} />; 6 + }
+11
apps/docs/src/examples/numeral/compact.tsx
··· 1 + import { Numeral } from '@luke-ui/react/numeral'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Compact(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> 7 + <Numeral value={12_345} abbreviate /> 8 + <Numeral value={12_345} abbreviate="long" /> 9 + </div> 10 + ); 11 + }
+13
apps/docs/src/examples/numeral/formats.tsx
··· 1 + import { Numeral } from '@luke-ui/react/numeral'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Formats(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> 7 + <Numeral value={3_500} format="percent" /> 8 + <Numeral value={98.76} currency="AUD" /> 9 + <Numeral value={98} unit="kilometer-per-hour" /> 10 + <Numeral value={12_345} format="decimal" /> 11 + </div> 12 + ); 13 + }
+11
apps/docs/src/examples/numeral/precision.tsx
··· 1 + import { Numeral } from '@luke-ui/react/numeral'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Precision(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> 7 + <Numeral value={98.7654} precision={2} /> 8 + <Numeral value={1_234.5678} precision={[0, 2]} /> 9 + </div> 10 + ); 11 + }
+17
apps/docs/src/examples/text-field/adornments.tsx
··· 1 + import { Icon } from '@luke-ui/react/icon'; 2 + import { TextField } from '@luke-ui/react/text-field'; 3 + import type { JSX } from 'react'; 4 + 5 + export default function Adornments(): JSX.Element { 6 + return ( 7 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxInlineSize: '20rem' }}> 8 + <TextField 9 + name="search" 10 + label="Search" 11 + adornmentStart={<Icon name="search" aria-hidden size="small" />} 12 + /> 13 + <TextField name="url" label="URL" adornmentStart="https://" /> 14 + <TextField name="price" label="Price" adornmentEnd="AUD" /> 15 + </div> 16 + ); 17 + }
+13
apps/docs/src/examples/text-field/basic.tsx
··· 1 + import { TextField } from '@luke-ui/react/text-field'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Basic(): JSX.Element { 5 + return ( 6 + <TextField 7 + name="email" 8 + label="Email" 9 + description="We'll only use this for account updates." 10 + placeholder="name@example.com" 11 + /> 12 + ); 13 + }
+11
apps/docs/src/examples/text-field/required.tsx
··· 1 + import { TextField } from '@luke-ui/react/text-field'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Required(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxInlineSize: '20rem' }}> 7 + <TextField isRequired name="firstName" label="First name" necessityIndicator="icon" /> 8 + <TextField isRequired name="lastName" label="Last name" necessityIndicator="label" /> 9 + </div> 10 + ); 11 + }
+11
apps/docs/src/examples/text-field/sizes.tsx
··· 1 + import { TextField } from '@luke-ui/react/text-field'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Sizes(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxInlineSize: '20rem' }}> 7 + <TextField name="small" label="Small" size="small" placeholder="Small input" /> 8 + <TextField name="medium" label="Medium" size="medium" placeholder="Medium input" /> 9 + </div> 10 + ); 11 + }
+10
apps/docs/src/examples/text/alignment.tsx
··· 1 + import { Text } from '@luke-ui/react/text'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Alignment(): JSX.Element { 5 + return ( 6 + <Text textAlign="end" fontVariantNumeric="tabular-nums" style={{ display: 'block' }}> 7 + 12121.21 8 + </Text> 9 + ); 10 + }
+10
apps/docs/src/examples/text/transform.tsx
··· 1 + import { Text } from '@luke-ui/react/text'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Transform(): JSX.Element { 5 + return ( 6 + <Text textTransform="uppercase" textDecoration="underline"> 7 + Emphasized text 8 + </Text> 9 + ); 10 + }
+12
apps/docs/src/examples/text/truncation.tsx
··· 1 + import { Text } from '@luke-ui/react/text'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Truncation(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxInlineSize: '20rem' }}> 7 + <Text lineClamp={2}>Long content that should be line clamped to two lines.</Text> 8 + <Text lineClamp>Long content truncated to one line.</Text> 9 + <Text shouldDisableTrim>Untrimmed text spacing</Text> 10 + </div> 11 + ); 12 + }
+15
apps/docs/src/examples/text/typography.tsx
··· 1 + import { Text } from '@luke-ui/react/text'; 2 + import type { JSX } from 'react'; 3 + 4 + export default function Typography(): JSX.Element { 5 + return ( 6 + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> 7 + <Text fontSize="h2" lineHeight="tight"> 8 + Heading-like text 9 + </Text> 10 + <Text color="critical" fontFamily="mono" fontWeight="bold"> 11 + Alert-like inline text 12 + </Text> 13 + </div> 14 + ); 15 + }
+10 -4
apps/docs/src/routes/docs/$.tsx
··· 2 2 import { createServerFn } from '@tanstack/react-start'; 3 3 import { staticFunctionMiddleware } from '@tanstack/start-static-server-functions'; 4 4 import { useFumadocsLoader } from 'fumadocs-core/source/client'; 5 + import { AutoTypeTable } from 'fumadocs-typescript/ui'; 6 + import { TypeTable } from 'fumadocs-ui/components/type-table'; 5 7 import { DocsLayout } from 'fumadocs-ui/layouts/docs'; 6 8 import { DocsBody, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; 7 9 import defaultMdxComponents from 'fumadocs-ui/mdx'; 8 10 import { Suspense } from 'react'; 9 11 import * as z from 'zod'; 10 12 import browserCollections from '../../../.source/browser'; 13 + import { ExampleBlock } from '../../components/example-block'; 11 14 import { PageActions } from '../../components/page-actions'; 12 15 import { baseOptions } from '../../lib/layout.shared'; 13 - import { withBasePath } from '../../lib/markdown-url'; 14 16 import { source } from '../../lib/source'; 15 - import { getStorybookStoryUrl } from '../../lib/storybook'; 17 + import { getStorybookStoryUrl, withBasePath } from '../../lib/storybook'; 16 18 17 19 const GITHUB_DOCS_URL = 'https://github.com/lukebennett88/luke-ui/blob/main/apps/docs/content/docs'; 20 + 21 + const mdxComponents = { ...defaultMdxComponents, AutoTypeTable, TypeTable, ExampleBlock }; 18 22 19 23 export const Route = createFileRoute('/docs/$')({ 20 24 component: Page, ··· 36 40 const page = source.getPage(slugs); 37 41 if (!page) throw notFound(); 38 42 43 + const markdownPath = `${page.url === '/docs' ? '/docs/index' : page.url}.md`; 44 + 39 45 return { 40 46 githubUrl: `${GITHUB_DOCS_URL}/${page.path}`, 41 - markdownUrl: withBasePath(`${page.url}.md`, import.meta.env.BASE_URL), 47 + markdownUrl: withBasePath(markdownPath, import.meta.env.BASE_URL), 42 48 pageTree: await source.serializePageTree(source.getPageTree()), 43 49 path: page.path, 44 50 storybookUrl: getStorybookStoryUrl(page.path, import.meta.env.BASE_URL), ··· 66 72 markdownUrl={markdownUrl} 67 73 storybookUrl={storybookUrl} 68 74 /> 69 - <MDX components={defaultMdxComponents} /> 75 + <MDX components={mdxComponents} /> 70 76 </DocsBody> 71 77 </DocsPage> 72 78 );
+19
apps/docs/src/routes/docs/{$}[.]md.ts
··· 1 + import { createFileRoute, notFound } from '@tanstack/react-router'; 2 + import { getLLMText } from '../../lib/get-llm-text'; 3 + import { source } from '../../lib/source'; 4 + 5 + export const Route = createFileRoute('/docs/{$}.md')({ 6 + server: { 7 + handlers: { 8 + GET: async ({ params }) => { 9 + const slugs = (params._splat ?? '').split('/').filter(Boolean); 10 + const page = source.getPage(slugs); 11 + if (!page) throw notFound(); 12 + 13 + return new Response(await getLLMText(page), { 14 + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, 15 + }); 16 + }, 17 + }, 18 + }, 19 + });
-31
apps/docs/src/routes/markdown/$.ts
··· 1 - import { packageDocs } from 'virtual:package-docs'; 2 - import { createFileRoute, notFound } from '@tanstack/react-router'; 3 - import { getLLMText } from '../../lib/get-llm-text'; 4 - import { internalSegmentsToSourceSlugs } from '../../lib/markdown-url'; 5 - import { source } from '../../lib/source'; 6 - 7 - // Internal route used to serve and prerender the public Markdown files. 8 - export const Route = createFileRoute('/markdown/$')({ 9 - server: { 10 - handlers: { 11 - GET: async ({ params }) => { 12 - const segments = (params._splat ?? '').split('/').filter(Boolean); 13 - const lastSegment = segments.at(-1) ?? ''; 14 - const markdown = packageDocs[lastSegment]; 15 - if (markdown) { 16 - return new Response(markdown, { 17 - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, 18 - }); 19 - } 20 - 21 - const slugs = internalSegmentsToSourceSlugs(segments); 22 - const page = source.getPage(slugs); 23 - if (!page) throw notFound(); 24 - 25 - return new Response(await getLLMText(page), { 26 - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, 27 - }); 28 - }, 29 - }, 30 - }, 31 - });
-16
packages/@luke-ui/docs-tools/src/fixtures/sample-barrel.ts
··· 1 - /** 2 - * Sample barrel that re-exports multiple named components. 3 - */ 4 - 5 - /** Allowed size values. */ 6 - export type SampleWidgetSize = 'small' | 'medium' | 'large'; 7 - 8 - /** A sample widget component. */ 9 - export function SampleWidget(_props: { size?: SampleWidgetSize }): null { 10 - return null; 11 - } 12 - 13 - /** A sample panel component. */ 14 - export function SamplePanel(_props: { label: string }): null { 15 - return null; 16 - }
-20
packages/@luke-ui/docs-tools/src/fixtures/sample-component.ts
··· 1 - import type { ButtonProps as RACButtonProps } from 'react-aria-components'; 2 - 3 - interface SampleStyleProps { 4 - /** Controls the button size. @default 'medium' */ 5 - size?: 'small' | 'medium'; 6 - /** Visual tone variant. @default 'primary' */ 7 - tone?: 'primary' | 'critical'; 8 - } 9 - 10 - /** 11 - * Sample composed button. 12 - * @tier composed 13 - */ 14 - export interface SampleProps 15 - extends Omit<RACButtonProps, keyof SampleStyleProps>, SampleStyleProps {} 16 - 17 - /** Sample composed button with size and tone variants. */ 18 - export function Sample(_props: SampleProps): null { 19 - return null; 20 - }
-18
packages/@luke-ui/docs-tools/src/fixtures/sample-type-alias.ts
··· 1 - interface AliasStyleProps { 2 - /** Sets the visual size. @default 'medium' */ 3 - size?: 'small' | 'medium'; 4 - } 5 - 6 - /** 7 - * Props for `Alias`. 8 - * @tier atom 9 - */ 10 - export type AliasProps = AliasStyleProps & { 11 - /** Accessible label. */ 12 - label: string; 13 - }; 14 - 15 - /** Sample component backed by a props type alias. */ 16 - export function Alias(_props: AliasProps): null { 17 - return null; 18 - }
-43
packages/@luke-ui/docs-tools/src/fixtures/sample-wrapper.ts
··· 1 - interface InnerProps { 2 - /** Hidden implementation prop. */ 3 - icon: string; 4 - /** Inner press handler. */ 5 - onPress?: () => void; 6 - /** Inner size. @default 'inner' */ 7 - size?: 'small' | 'medium'; 8 - } 9 - 10 - interface WrapperStyleProps { 11 - /** Wrapper size. @default 'wrapper' */ 12 - size?: InnerProps['size']; 13 - } 14 - 15 - interface WrapperRedeclaredProps { 16 - /** Wrapper press handler. */ 17 - onPress?: InnerProps['onPress']; 18 - } 19 - 20 - /** 21 - * Props for `Wrapper`. 22 - * @tier composed 23 - */ 24 - export interface WrapperProps 25 - extends 26 - Omit<InnerProps, 'icon' | keyof WrapperStyleProps | keyof WrapperRedeclaredProps>, 27 - WrapperStyleProps, 28 - WrapperRedeclaredProps {} 29 - 30 - interface WrapperProviderProps { 31 - /** Provider value. */ 32 - value: string; 33 - } 34 - 35 - /** Sample provider component. */ 36 - export function WrapperProvider(_props: WrapperProviderProps): null { 37 - return null; 38 - } 39 - 40 - /** Sample wrapper component. */ 41 - export function Wrapper(_props: WrapperProps): null { 42 - return null; 43 - }
-61
packages/@luke-ui/react/src/build/package-scripts.test.ts
··· 1 - import { execFile } from 'node:child_process'; 2 - import { fileURLToPath } from 'node:url'; 3 - import { promisify } from 'node:util'; 4 - import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; 5 - import { describe, expect, it } from 'vite-plus/test'; 6 - import { z } from 'zod'; 7 - 8 - const execFileAsync = promisify(execFile); 9 - const packageJsonPath = fileURLToPath(new URL('../../package.json', import.meta.url)); 10 - const packageRoot = fileURLToPath(new URL('../../', import.meta.url)); 11 - const docsDir = fileURLToPath(new URL('../../docs/', import.meta.url)); 12 - const staleDocsPath = fileURLToPath( 13 - new URL('../../docs/use-synchronize-animations.md', import.meta.url), 14 - ); 15 - const turboJsonPath = fileURLToPath(new URL('../../../../../turbo.json', import.meta.url)); 16 - const packageJsonSchema = z.object({ 17 - scripts: z.record(z.string(), z.string()), 18 - }); 19 - const turboJsonSchema = z.object({ 20 - tasks: z.record( 21 - z.string(), 22 - z.object({ 23 - dependsOn: z.array(z.string()).optional(), 24 - }), 25 - ), 26 - }); 27 - 28 - describe('package scripts', () => { 29 - it('lets Turbo order build before package docs generation', async () => { 30 - const packageJson = packageJsonSchema.parse( 31 - JSON.parse(await readFile(packageJsonPath, 'utf8')), 32 - ); 33 - const turboJson = turboJsonSchema.parse(JSON.parse(await readFile(turboJsonPath, 'utf8'))); 34 - 35 - expect(packageJson.scripts.build).toBe('pnpm run build:tsdown'); 36 - expect(packageJson.scripts.generate).toBe('pnpm run generate:assets'); 37 - expect(turboJson.tasks['@luke-ui/react#generate:docs']?.dependsOn).toContain('build'); 38 - expect(turboJson.tasks['docs#dev']?.dependsOn).toContain('@luke-ui/react#generate:docs'); 39 - }); 40 - 41 - // Runs the real generate-docs script in a subprocess, which needs well over 42 - // the default 5s test timeout. 43 - it( 44 - 'removes stale generated package docs before writing current pages', 45 - { timeout: 60_000 }, 46 - async () => { 47 - await mkdir(docsDir, { recursive: true }); 48 - await writeFile(staleDocsPath, '# stale page\n', 'utf8'); 49 - 50 - try { 51 - await execFileAsync('pnpm', ['exec', 'tsx', 'scripts/generate-docs.ts'], { 52 - cwd: packageRoot, 53 - }); 54 - 55 - await expect(access(staleDocsPath)).rejects.toMatchObject({ code: 'ENOENT' }); 56 - } finally { 57 - await rm(staleDocsPath, { force: true }); 58 - } 59 - }, 60 - ); 61 - });
-96
packages/@luke-ui/react/src/button/button.docs.md
··· 1 - `Button` expects the Luke UI theme class at the app or root level. See 2 - [Getting Started](/docs/getting-started). 3 - 4 - ```tsx 5 - <Button>Save</Button> 6 - ``` 7 - 8 - ## Best practices 9 - 10 - | Guidance | Practices | 11 - | -------- | -------------------------------------------------------------------------------------------------------------- | 12 - | Do | Use one `tone="primary"` button for the main action in a view. Use `neutral` or `ghost` for secondary actions. | 13 - | Do | Write a label that names the action, such as "Save changes" or "Delete account". Avoid vague labels like "OK". | 14 - | Do | Set `isPending` while an action is in flight so the user can see that work is still happening. | 15 - | Don't | Use `Button` for navigation. If the control only moves the user to another page, use `Link`. | 16 - 17 - ## Tone 18 - 19 - `Button` has four tones: `primary` (default), `neutral`, `critical`, and `ghost`. 20 - 21 - ```tsx 22 - <Button tone="neutral">Cancel</Button> 23 - ``` 24 - 25 - ```tsx 26 - <Button tone="critical">Delete</Button> 27 - ``` 28 - 29 - ```tsx 30 - <Button tone="ghost">Dismiss</Button> 31 - ``` 32 - 33 - ## Size 34 - 35 - `Button` has two sizes: `medium` (default) and `small`. 36 - 37 - ```tsx 38 - <Button size="small">Save</Button> 39 - ``` 40 - 41 - ## Icons 42 - 43 - Use `startIcon` and `endIcon` to place an icon before or after the label. The icon inherits the 44 - button size, so the icon does not need its own `size` prop. 45 - 46 - ```tsx 47 - import { Icon } from '@luke-ui/react/icon'; 48 - 49 - <Button startIcon={<Icon name="add" aria-hidden />}>Add item</Button>; 50 - ``` 51 - 52 - ## Disabled 53 - 54 - Disabled buttons cannot be focused or pressed. 55 - 56 - ```tsx 57 - <Button isDisabled>Save</Button> 58 - ``` 59 - 60 - ## Pending 61 - 62 - Set `isPending` while an action is in flight. A spinner overlays the label and the button becomes 63 - non-interactive. 64 - 65 - ```tsx 66 - <Button isPending>Saving</Button> 67 - ``` 68 - 69 - ## Full width 70 - 71 - Set `isBlock` to make the button fill the inline size of its container. 72 - 73 - ```tsx 74 - <Button isBlock>Save</Button> 75 - ``` 76 - 77 - ## Accessibility 78 - 79 - `Button` wraps its children in `Text`, so visible text usually provides the accessible name. You 80 - normally do not need `aria-label`. 81 - 82 - The pending spinner is `aria-hidden` and does not announce busy state to screen readers. If screen 83 - reader users need to hear the pending state, change the label text itself, for example to "Saving", 84 - while `isPending` is set. 85 - 86 - ## Primitive Button 87 - 88 - The lower-level `Button` primitive is available when you need full control over children, such as 89 - custom loading states, render-prop children, or non-standard content. 90 - 91 - ```ts 92 - import { Button } from '@luke-ui/react/button/primitive'; 93 - ``` 94 - 95 - The primitive renders a single `<button>` element with no internal wrapper. Its children are direct 96 - flex items, so you manage layout yourself.
-146
packages/@luke-ui/react/src/combobox-field/combobox-field.docs.md
··· 1 - Use `ComboboxField` for a single-select combobox with label, description, error, popover, listbox, 2 - and optional async loading built in. 3 - 4 - ```tsx 5 - <ComboboxField 6 - label="Country" 7 - name="country" 8 - placeholder="Select a country..." 9 - defaultItems={countries} 10 - > 11 - {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 12 - </ComboboxField> 13 - ``` 14 - 15 - ## Best practices 16 - 17 - | Guidance | Practices | 18 - | -------- | ------------------------------------------------------------------------------------------------------- | 19 - | Do | Use `defaultItems` for a static list. Use `items` with `loadingState` when options load asynchronously. | 20 - | Don't | Use `ComboboxField` for multi-select. It is single-select only in v1. | 21 - 22 - ## API shape 23 - 24 - - Root props follow React Aria `ComboBox` naming. 25 - - Composed convenience props include `label`, `description`, `errorMessage`, `necessityIndicator`, 26 - `size`, and `placeholder`. 27 - - `children` renders items from `items` or `defaultItems`. 28 - - `listBoxProps` and `loadMoreItem` are lower-level escape hatches. 29 - 30 - ## Selection indicators 31 - 32 - The selected option shows a checkmark in the listbox. When the control has a selection, it shows a 33 - clear button before the trigger. Pressing the clear button clears the selection and input text. 34 - 35 - The clear button is omitted when the field is disabled or read-only. 36 - 37 - ```tsx 38 - <ComboboxField defaultValue="ca" defaultItems={countries} label="Country" name="country"> 39 - {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 40 - </ComboboxField> 41 - ``` 42 - 43 - ## Required fields 44 - 45 - Use `isRequired` with `necessityIndicator` to show that the field is mandatory. 46 - 47 - ```tsx 48 - <ComboboxField isRequired label="Country" name="country" necessityIndicator="icon"> 49 - {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 50 - </ComboboxField> 51 - ``` 52 - 53 - ## Validation 54 - 55 - Pass field-level validation through React Aria `Form`. Use `errorMessage` to render the validation 56 - message. 57 - 58 - ```tsx 59 - import { Form } from 'react-aria-components'; 60 - 61 - <Form validationErrors={{ country: 'Please select a country.' }}> 62 - <ComboboxField 63 - name="country" 64 - label="Country" 65 - errorMessage={(validation) => validation.validationErrors.join(' ')} 66 - > 67 - {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 68 - </ComboboxField> 69 - </Form>; 70 - ``` 71 - 72 - ## Grouped options 73 - 74 - Use `ComboboxSection` to group related options inside the listbox. 75 - 76 - ```tsx 77 - <ComboboxField label="Country" name="country"> 78 - <ComboboxSection title="Northern hemisphere"> 79 - <ComboboxItem id="ca">Canada</ComboboxItem> 80 - </ComboboxSection> 81 - <ComboboxSection title="Southern hemisphere"> 82 - <ComboboxItem id="au">Australia</ComboboxItem> 83 - </ComboboxSection> 84 - </ComboboxField> 85 - ``` 86 - 87 - ## Async options 88 - 89 - Pass `loadingState` for built-in loading and empty states. Control the option collection with 90 - `items`. 91 - 92 - ```tsx 93 - <ComboboxField 94 - label="Country" 95 - name="country" 96 - items={results} 97 - loadingState={status} 98 - onInputChange={setQuery} 99 - > 100 - {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 101 - </ComboboxField> 102 - ``` 103 - 104 - ## Infinite scroll 105 - 106 - Use `onLoadMore` for automatic sentinel-based loading, or `loadMoreItem` when you need full control 107 - over the load-more row. 108 - 109 - ```tsx 110 - <ComboboxField 111 - label="Country" 112 - name="country" 113 - items={results} 114 - loadingState={status} 115 - onLoadMore={fetchNextPage} 116 - > 117 - {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 118 - </ComboboxField> 119 - ``` 120 - 121 - ## Primitive kit 122 - 123 - The individual `Combobox*` building blocks are available for library authors who need a custom 124 - combobox layout. 125 - 126 - ```ts 127 - import { 128 - ComboboxInput, 129 - ComboboxControl, 130 - ComboboxTextInput, 131 - ComboboxClearButton, 132 - ComboboxTrigger, 133 - ComboboxPopover, 134 - ComboboxListBox, 135 - ComboboxItem, 136 - ComboboxSection, 137 - } from '@luke-ui/react/combobox-field/primitive'; 138 - ``` 139 - 140 - ### Size propagation 141 - 142 - When you set `size` on `ComboboxInput`, it is inherited by `ComboboxControl`, `ComboboxTextInput`, 143 - `ComboboxClearButton`, `ComboboxTrigger`, `ComboboxItem`, and `ComboboxLoadMoreItem`. 144 - 145 - You can override the inherited size on an individual child by passing that child an explicit `size` 146 - prop.
-20
packages/@luke-ui/react/src/emoji/emoji.docs.md
··· 1 - Use `Emoji` when an emoji needs a reliable accessible label. 2 - 3 - ```tsx 4 - <Emoji emoji="🎉" label="Celebration" /> 5 - ``` 6 - 7 - `Emoji` accepts all `Text` props except `children` and `elementType`, so it inherits typography 8 - controls such as `fontSize` and `color`. 9 - 10 - ## Best practices 11 - 12 - | Guidance | Practices | 13 - | -------- | ---------------------------------------------------------------------------------------------------------- | 14 - | Do | Write a `label` that describes the emoji's meaning in context, such as "Celebration". | 15 - | Don't | Repeat text that already appears in the surrounding sentence. That creates duplicate screen reader output. | 16 - 17 - ## Accessibility 18 - 19 - Both `emoji` and `label` are required. Screen readers announce the label instead of the raw emoji 20 - character, which is not announced consistently across platforms.
-56
packages/@luke-ui/react/src/heading/heading.docs.md
··· 1 - Use `Heading` for section headings. It can infer heading level from `HeadingLevels` context, or you 2 - can pass `level` directly. 3 - 4 - ```tsx 5 - <Heading>Section title</Heading> 6 - ``` 7 - 8 - ```tsx 9 - <Heading level={2}>Explicit h2</Heading> 10 - ``` 11 - 12 - ## Best practices 13 - 14 - | Guidance | Practices | 15 - | -------- | ------------------------------------------------------------------------------------------------------------- | 16 - | Do | Prefer automatic leveling for nested sections instead of hardcoding every `level`. | 17 - | Don't | Skip heading levels, such as an h2 followed directly by an h4. Screen reader users navigate by heading level. | 18 - 19 - ## Automatic leveling 20 - 21 - `Heading` reads its level from `HeadingLevels` context. Set `base` on the root context. Each nested 22 - `HeadingLevels` advances the next heading level. 23 - 24 - ```tsx 25 - import { Heading, HeadingLevels } from '@luke-ui/react/heading'; 26 - 27 - <HeadingLevels base={1}> 28 - <Heading>h1</Heading> 29 - <HeadingLevels> 30 - <Heading>h2 nested automatically</Heading> 31 - <HeadingLevels> 32 - <Heading>h3 nested again</Heading> 33 - </HeadingLevels> 34 - </HeadingLevels> 35 - </HeadingLevels>; 36 - ``` 37 - 38 - The `level` prop overrides context for one heading without changing the nesting depth for siblings 39 - or children. 40 - 41 - ## Typography 42 - 43 - `Heading` accepts all `Text` props except `fontSize`, which is controlled by the heading level. By 44 - default it applies `fontWeight="bold"` and `lineHeight="tight"`. 45 - 46 - ```tsx 47 - <Heading level={3} color="informative"> 48 - Informative heading 49 - </Heading> 50 - ``` 51 - 52 - ```tsx 53 - <Heading level={4} fontWeight="regular"> 54 - Light-weight heading 55 - </Heading> 56 - ```
-45
packages/@luke-ui/react/src/icon-button/icon-button.docs.md
··· 1 - Use `IconButton` for compact actions where an icon can carry the visible UI. Always provide an 2 - accessible label. 3 - 4 - ```tsx 5 - <IconButton icon="add" aria-label="Add item" /> 6 - ``` 7 - 8 - ## Best practices 9 - 10 - | Guidance | Practices | 11 - | -------- | ---------------------------------------------------------------------------- | 12 - | Do | Name the action in `aria-label`, such as "Delete row". Do not name the icon. | 13 - | Do | Use `tone="critical"` for destructive icon actions such as delete. | 14 - 15 - ## Tone 16 - 17 - ```tsx 18 - <IconButton icon="close" aria-label="Close" tone="ghost" /> 19 - ``` 20 - 21 - ```tsx 22 - <IconButton icon="delete" aria-label="Delete" tone="critical" /> 23 - ``` 24 - 25 - ## Size 26 - 27 - ```tsx 28 - <IconButton icon="delete" aria-label="Delete" size="small" /> 29 - ``` 30 - 31 - ## Accessibility 32 - 33 - `IconButton` has no visible text label. Always provide `aria-label` or `aria-labelledby`. Without 34 - one, screen readers have no way to announce the button's purpose. 35 - 36 - ```tsx 37 - <IconButton icon="search" aria-label="Search orders" /> 38 - ``` 39 - 40 - ## When to use vs Button 41 - 42 - Use `IconButton` when the icon alone communicates the action and space is tight, such as in 43 - toolbars, table row actions, or close buttons. 44 - 45 - Use `Button` when you need a visible text label. Labels are clearer for unfamiliar users.
-84
packages/@luke-ui/react/src/icon/icon.docs.md
··· 1 - `Icon` renders a symbol from the Luke UI spritesheet. It requires an `IconSpritesheetProvider` 2 - ancestor. See Setup below. 3 - 4 - ```tsx 5 - <Icon name="add" title="Add" size="xsmall" /> 6 - ``` 7 - 8 - ```tsx 9 - <Icon name="close" aria-hidden size="medium" /> 10 - ``` 11 - 12 - ## Best practices 13 - 14 - | Guidance | Practices | 15 - | -------- | --------------------------------------------------------------------------------------------------------- | 16 - | Do | Pass `title` when the icon conveys meaning without adjacent text, such as a standalone status icon. | 17 - | Don't | Pass both `aria-hidden` and `title`. A titled icon should be exposed to assistive technology, not hidden. | 18 - 19 - ## Setup 20 - 21 - Wrap your app with `IconSpritesheetProvider`. 22 - 23 - - Source asset: `@luke-ui/react/spritesheet.svg`, exported from `./dist/spritesheet.svg` 24 - - Runtime lookup: `<configured-sprite-href>#<icon-name>` 25 - 26 - ```tsx 27 - <IconSpritesheetProvider href="/assets/spritesheet.svg"> 28 - <App /> 29 - </IconSpritesheetProvider> 30 - ``` 31 - 32 - Vite and Storybook should import the spritesheet as a URL. 33 - 34 - ```ts 35 - import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline'; 36 - 37 - <IconSpritesheetProvider href={spriteSheetHref}> 38 - <App /> 39 - </IconSpritesheetProvider>; 40 - ``` 41 - 42 - The `no-inline` query avoids `data:` URLs, which can break `<use href="...#icon-id">` rendering. 43 - 44 - When developing `@luke-ui/react`, generate icons from `packages/@luke-ui/react/icons/*.svg` into 45 - `packages/@luke-ui/react/dist/spritesheet.svg`. 46 - 47 - ```bash 48 - pnpm --dir packages/@luke-ui/react run generate:icons 49 - ``` 50 - 51 - ## How it works 52 - 53 - `Icon` renders an `<svg>` that references a symbol in the generated spritesheet with 54 - `<use href="..." />`. 55 - 56 - - `name` chooses the symbol id. 57 - - The sprite URL comes from `IconSpritesheetProvider`. 58 - - `viewBox` defaults to the generated icon viewBox. 59 - - Icon fill follows `currentColor`. 60 - 61 - ```tsx 62 - <Icon name="add" className="myIcon" style={{ color: 'tomato' }} /> 63 - ``` 64 - 65 - ## Create your own icon 66 - 67 - Use `createIcon` for a one-off icon that is not in the generated spritesheet. 68 - 69 - ```tsx 70 - import { createIcon } from '@luke-ui/react/icon'; 71 - 72 - const HeartIcon = createIcon({ 73 - path: ( 74 - <path d="M12 21a1 1 0 0 1-.7-.3L5 14.5a5 5 0 1 1 7-6 5 5 0 1 1 7 6l-6.3 6.2a1 1 0 0 1-.7.3Z" /> 75 - ), 76 - }); 77 - 78 - <HeartIcon title="Favorite" size="small" />; 79 - ``` 80 - 81 - ## Accessibility 82 - 83 - If `title` is provided, the icon is exposed to assistive technology with `role="img"`. If `title` is 84 - omitted, `aria-hidden` defaults to `true`.
-64
packages/@luke-ui/react/src/link/link.docs.md
··· 1 - `Link` expects the Luke UI theme class at the app or root level. See 2 - [Getting Started](/docs/getting-started). 3 - 4 - ```tsx 5 - <Link href="/help">Help center</Link> 6 - ``` 7 - 8 - ## Best practices 9 - 10 - | Guidance | Practices | 11 - | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | 12 - | Do | Use `tone="inverted"` only on dark backgrounds. On light backgrounds, it is hard to read. | 13 - | Do | Use `isStandalone` for links that are not part of a sentence, such as card links and nav items. Leave it `false` for inline links. | 14 - 15 - ## Tone 16 - 17 - `Link` has three tones: `brand` (default), `neutral`, and `inverted`. 18 - 19 - ```tsx 20 - <Link href="/docs/accessibility" tone="neutral"> 21 - Accessibility docs 22 - </Link> 23 - ``` 24 - 25 - ```tsx 26 - <div style={{ background: 'black', padding: 4 }}> 27 - <Link href="/status" tone="inverted"> 28 - Service status 29 - </Link> 30 - </div> 31 - ``` 32 - 33 - Use `neutral` for a more subtle link. Use `inverted` on dark backgrounds. 34 - 35 - ## Standalone 36 - 37 - Use `isStandalone` when a link stands on its own. Leave it `false` for links inside paragraph text. 38 - 39 - ```tsx 40 - <Link href="/terms" isStandalone> 41 - Terms and conditions 42 - </Link> 43 - ``` 44 - 45 - - `isStandalone={true}`: no underline until hover. 46 - - `isStandalone={false}`: underlined inline link style. 47 - 48 - ## Disabled 49 - 50 - ```tsx 51 - <Link href="/archived" isDisabled> 52 - Archived report 53 - </Link> 54 - ``` 55 - 56 - ## Accessibility 57 - 58 - Screen readers announce a disabled link as unavailable, but not why. Put the reason in nearby 59 - visible text instead of relying on disabled state alone. 60 - 61 - ## When to use vs Button 62 - 63 - Use `Link` to navigate to a new URL or route. Use `Button` for in-page actions, such as saving, 64 - submitting, or opening a dialog.
-100
packages/@luke-ui/react/src/loading-skeleton/loading-skeleton.docs.md
··· 1 - `LoadingSkeleton` expects the Luke UI theme class at the app or root level. See 2 - [Getting Started](/docs/getting-started). 3 - 4 - Use it when loading content should keep the same footprint as the loaded state. Text renders as an 5 - inline skeleton. Elements keep their layout while the skeleton surface is painted over them. 6 - 7 - ```tsx 8 - <LoadingSkeleton isLoading={isLoading}>{user?.name ?? 'Placeholder name'}</LoadingSkeleton> 9 - ``` 10 - 11 - ```tsx 12 - <LoadingSkeleton isLoading={isLoading}> 13 - <Button>Submit</Button> 14 - </LoadingSkeleton> 15 - ``` 16 - 17 - All mounted skeletons use the same pulse timing, even when they mount at different times. 18 - 19 - ## Best practices 20 - 21 - | Guidance | Practices | 22 - | -------- | ------------------------------------------------------------------------------------------------------------------- | 23 - | Do | Wrap real content so the skeleton matches the final size exactly. | 24 - | Don't | Use `LoadingSkeleton` for content whose final size is unknown. Size mismatch causes layout shift when loading ends. | 25 - 26 - ## Loading state 27 - 28 - `isLoading` defaults to `true`. Pass `isLoading={false}` when content is ready. 29 - 30 - ```tsx 31 - <LoadingSkeleton isLoading={false}> 32 - <Button>Submit</Button> 33 - </LoadingSkeleton> 34 - ``` 35 - 36 - ## Multi-line text 37 - 38 - Wrap text directly when the copy spans more than one line. Each line gets its own skeleton shape. 39 - 40 - ```tsx 41 - <div style={{ maxInlineSize: '16rem' }}> 42 - <LoadingSkeleton isLoading={isLoading}> 43 - A short paragraph of placeholder copy that wraps across two lines. 44 - </LoadingSkeleton> 45 - </div> 46 - ``` 47 - 48 - ## Element type 49 - 50 - `LoadingSkeleton` renders a `span` by default. Use `as` when the surrounding markup needs another 51 - element. 52 - 53 - ```tsx 54 - <ul> 55 - <LoadingSkeleton as="li" isLoading={isLoading}> 56 - List item placeholder 57 - </LoadingSkeleton> 58 - </ul> 59 - ``` 60 - 61 - ## LoadingSkeletonProvider 62 - 63 - Use `LoadingSkeletonProvider` when one loading state controls a group of skeletons. The provider 64 - value overrides descendant `isLoading` props. 65 - 66 - ```tsx 67 - <LoadingSkeletonProvider isLoading={isLoading}> 68 - <LoadingSkeleton isLoading={false}>Ada Lovelace</LoadingSkeleton> 69 - <LoadingSkeleton isLoading={false}> 70 - <Button>Edit profile</Button> 71 - </LoadingSkeleton> 72 - </LoadingSkeletonProvider> 73 - ``` 74 - 75 - ## Border radius 76 - 77 - Use `borderRadius` when the wrapped child has no radius, but the visible control inside it does. 78 - 79 - ```tsx 80 - <LoadingSkeleton borderRadius="0.25rem" isLoading={isLoading}> 81 - <TextField label="Email" name="email" /> 82 - </LoadingSkeleton> 83 - ``` 84 - 85 - ## Custom dimensions 86 - 87 - Wrap an element with explicit dimensions when you need a placeholder for a fixed shape, such as an 88 - avatar. 89 - 90 - ```tsx 91 - <LoadingSkeleton isLoading={isLoading}> 92 - <div style={{ borderRadius: '9999px', height: '3rem', width: '3rem' }} /> 93 - </LoadingSkeleton> 94 - ``` 95 - 96 - ## Accessibility 97 - 98 - While loading, skeleton content is hidden from assistive technology and cannot be focused or 99 - clicked. `LoadingSkeleton` sets `aria-hidden`, `inert`, `tabIndex={-1}`, and disables pointer 100 - events.
-34
packages/@luke-ui/react/src/loading-spinner/loading-spinner.docs.md
··· 1 - `LoadingSpinner` expects the Luke UI theme class at the app or root level. See 2 - [Getting Started](/docs/getting-started). 3 - 4 - ```tsx 5 - <LoadingSpinner aria-label="Loading" /> 6 - ``` 7 - 8 - All mounted indeterminate spinners rotate and pulse in sync, even when they mount at different 9 - times. 10 - 11 - ## Progress mode 12 - 13 - Omit `value` for indeterminate progress. Pass `value` for determinate progress. 14 - 15 - ```tsx 16 - <LoadingSpinner aria-label="Loading profile" value={66} /> 17 - ``` 18 - 19 - ## Size 20 - 21 - ```tsx 22 - <LoadingSpinner aria-label="Syncing" size="small" /> 23 - ``` 24 - 25 - ## Color 26 - 27 - ```tsx 28 - <LoadingSpinner aria-label="Syncing" color="informative" /> 29 - ``` 30 - 31 - ## Accessibility 32 - 33 - `aria-label` defaults to `"pending"` when omitted. Override it with what is loading, such as 34 - "Loading profile", for a clearer announcement.
-60
packages/@luke-ui/react/src/numeral/numeral.docs.md
··· 1 - `Numeral` formats numbers with `Intl.NumberFormat`. It respects locale from React Aria's 2 - `I18nProvider`. 3 - 4 - ```tsx 5 - <Numeral value={12_345.67} /> 6 - ``` 7 - 8 - ## Formats 9 - 10 - `Numeral` infers `format` from `currency` or `unit` when omitted. Pass `format` explicitly for 11 - `'percent'` or `'decimal'`, or when you want to be direct about currency and unit formatting. 12 - 13 - ```tsx 14 - <Numeral value={3_500} format="percent" /> 15 - ``` 16 - 17 - ```tsx 18 - <Numeral value={98.76} currency="AUD" /> 19 - ``` 20 - 21 - ```tsx 22 - <Numeral value={98} unit="kilometer-per-hour" /> 23 - ``` 24 - 25 - ```tsx 26 - <Numeral value={12_345} format="decimal" /> 27 - ``` 28 - 29 - ## Compact notation 30 - 31 - Use `abbreviate` for compact notation. 32 - 33 - ```tsx 34 - <Numeral value={12_345} abbreviate /> 35 - ``` 36 - 37 - ```tsx 38 - <Numeral value={12_345} abbreviate="long" /> 39 - ``` 40 - 41 - ## Precision 42 - 43 - Pass a number for fixed fraction digits, or a `[min, max]` tuple for a range. 44 - 45 - ```tsx 46 - <Numeral value={98.7654} precision={2} /> 47 - ``` 48 - 49 - ```tsx 50 - <Numeral value={1_234.5678} precision={[0, 2]} /> 51 - ``` 52 - 53 - ## Constraints 54 - 55 - `Numeral` throws in development when: 56 - 57 - - both `currency` and `unit` are provided. 58 - - `format="currency"` is used without a `currency` code. 59 - - `format="unit"` is used without a `unit` value. 60 - - `precision` is not a non-negative integer or valid `[min, max]` tuple.
-100
packages/@luke-ui/react/src/text-field/text-field.docs.md
··· 1 - Use `TextField` for a single text input with label, description, validation, and optional adornments 2 - built in. 3 - 4 - ```tsx 5 - <TextField 6 - name="email" 7 - label="Email" 8 - description="We'll only use this for account updates." 9 - placeholder="name@example.com" 10 - /> 11 - ``` 12 - 13 - ## Best practices 14 - 15 - | Guidance | Practices | 16 - | -------- | --------------------------------------------------------------------------------------------------------------------------- | 17 - | Do | Use `label` for every field where possible. It works better with assistive technology and autofill than `aria-label` alone. | 18 - | Don't | Use `placeholder` as a label substitute. It disappears after typing and often fails colour contrast requirements. | 19 - 20 - ## Required fields 21 - 22 - Use `isRequired` and `necessityIndicator` to communicate mandatory fields. `'icon'` renders a visual 23 - required marker. `'label'` appends "(required)" to the label text. 24 - 25 - ```tsx 26 - <TextField isRequired name="firstName" label="First name" necessityIndicator="icon" /> 27 - ``` 28 - 29 - ```tsx 30 - <TextField isRequired name="lastName" label="Last name" necessityIndicator="label" /> 31 - ``` 32 - 33 - ## Validation 34 - 35 - Pass field-level validation through React Aria `Form`. Use `errorMessage` to render the validation 36 - message. 37 - 38 - ```tsx 39 - import { Form } from 'react-aria-components'; 40 - 41 - <Form validationErrors={{ username: 'Username is not available.' }}> 42 - <TextField 43 - name="username" 44 - label="Username" 45 - errorMessage={(validation) => validation.validationErrors.join(' ')} 46 - /> 47 - </Form>; 48 - ``` 49 - 50 - ## Adornments 51 - 52 - Use `adornmentStart` and `adornmentEnd` to place non-editable content inside the input chrome. 53 - Adornments accept any `ReactNode`. If an adornment is interactive, you are responsible for its 54 - semantics. 55 - 56 - ```tsx 57 - import { Icon } from '@luke-ui/react/icon'; 58 - 59 - <TextField 60 - name="search" 61 - label="Search" 62 - adornmentStart={<Icon name="search" aria-hidden size="small" />} 63 - />; 64 - ``` 65 - 66 - ```tsx 67 - <TextField name="url" label="URL" adornmentStart="https://" /> 68 - ``` 69 - 70 - ```tsx 71 - <TextField name="price" label="Price" adornmentEnd="AUD" /> 72 - ``` 73 - 74 - ## Size 75 - 76 - `size` controls height and typography. The HTML numeric `<input size>` attribute is intentionally 77 - omitted because `size` is reserved for the design-system variant. 78 - 79 - | Value | Description | 80 - | ---------- | --------------------- | 81 - | `'small'` | Compact input height. | 82 - | `'medium'` | Default input height. | 83 - 84 - ## Accessibility 85 - 86 - When visual context already communicates purpose, you may omit `label` and provide an accessible 87 - name with `aria-label` or `aria-labelledby` on the field. 88 - 89 - ```tsx 90 - <TextField aria-label="Search" name="search" placeholder="Search" /> 91 - ``` 92 - 93 - ## Primitive TextInput 94 - 95 - The lower-level `TextInput` primitive is available when you need the input without the label, 96 - description, or error slots that `Field` provides. 97 - 98 - ```ts 99 - import { TextInput } from '@luke-ui/react/text-field/primitive'; 100 - ```
-101
packages/@luke-ui/react/src/text/text.docs.md
··· 1 - `Text` expects the Luke UI theme class at the app or root level. See 2 - [Getting Started](/docs/getting-started). 3 - 4 - Use `Text` for styled text that should not create heading semantics. 5 - 6 - ```tsx 7 - <Text>The quick brown fox jumps over the lazy dog.</Text> 8 - ``` 9 - 10 - ## Best practices 11 - 12 - | Guidance | Practices | 13 - | -------- | ----------------------------------------------------------------------------------------------------------- | 14 - | Do | Use `fontSize` tokens, such as `'h2'`, instead of arbitrary values so text stays consistent across the app. | 15 - | Don't | Use `Text` for section headings. Use `Heading`, which manages semantic level automatically. | 16 - 17 - ## Typography 18 - 19 - ```tsx 20 - <Text fontSize="h2" lineHeight="tight"> 21 - Heading-like text 22 - </Text> 23 - ``` 24 - 25 - ```tsx 26 - <Text color="critical" fontFamily="mono" fontWeight="bold"> 27 - Alert-like inline text 28 - </Text> 29 - ``` 30 - 31 - See the token reference below for valid `color`, `fontFamily`, `fontSize`, `lineHeight`, and 32 - `fontWeight` values. 33 - 34 - ## Text transform and decoration 35 - 36 - ```tsx 37 - <Text textTransform="uppercase" textDecoration="underline"> 38 - Emphasized text 39 - </Text> 40 - ``` 41 - 42 - ## Truncation 43 - 44 - ```tsx 45 - <Text lineClamp={2}>Long content that should be line clamped.</Text> 46 - ``` 47 - 48 - ```tsx 49 - <Text lineClamp>Long content truncated to one line.</Text> 50 - ``` 51 - 52 - ```tsx 53 - <Text shouldDisableTrim>Untrimmed text spacing</Text> 54 - ``` 55 - 56 - ## Alignment and numeric glyphs 57 - 58 - ```tsx 59 - <Text textAlign="end" fontVariantNumeric="tabular-nums"> 60 - 12121.21 61 - </Text> 62 - ``` 63 - 64 - ## Token reference 65 - 66 - ### `color` tokens 67 - 68 - `neutralSubtle`, `neutralBold`, `neutralDisabled`, `neutralBoldInverted`, `positive`, `informative`, 69 - `caution`, `critical`, `inherit` 70 - 71 - ### `fontFamily` tokens 72 - 73 - `sans`, `mono` 74 - 75 - ### `fontSize` tokens 76 - 77 - `xxsmall`, `xsmall`, `small`, `standard`, `medium`, `large`, `xlarge`, `xxlarge`, `h1`, `h2`, `h3`, 78 - `h4`, `h5`, `h6` 79 - 80 - ### `lineHeight` tokens 81 - 82 - `nospace`, `tight`, `loose` 83 - 84 - ### `fontWeight` tokens 85 - 86 - `regular`, `medium`, `bold`, `inherit` 87 - 88 - ### `textDecoration` values 89 - 90 - `none`, `underline`, `line-through`, `inherit` 91 - 92 - ### `textTransform` values 93 - 94 - `none`, `capitalize`, `uppercase`, `lowercase`, `inherit` 95 - 96 - ## When to use vs Heading 97 - 98 - Use `Heading` for actual section headings because it manages semantic level nesting automatically. 99 - 100 - Use `Text` with a heading-sized `fontSize` token when content should look like a heading but is not 101 - semantically one, such as a large stat number.
+99 -3
apps/docs/content/docs/components/actions/button.mdx
··· 3 3 description: Action button with tone, size, icon, pending, and disabled states. 4 4 --- 5 5 6 - import { story } from '../../../../src/button/button.story'; 6 + `Button` expects the Luke UI theme class at the app or root level. See 7 + [Getting Started](/docs/getting-started). 7 8 8 - <story.WithControl /> 9 + ```tsx 10 + <Button>Save</Button> 11 + ``` 9 12 10 - <include>../../../../../../packages/@luke-ui/react/docs/button.md</include> 13 + ## Best practices 14 + 15 + | Guidance | Practices | 16 + | -------- | -------------------------------------------------------------------------------------------------------------- | 17 + | Do | Use one `tone="primary"` button for the main action in a view. Use `neutral` or `ghost` for secondary actions. | 18 + | Do | Write a label that names the action, such as "Save changes" or "Delete account". Avoid vague labels like "OK". | 19 + | Do | Set `isPending` while an action is in flight so the user can see that work is still happening. | 20 + | Don't | Use `Button` for navigation. If the control only moves the user to another page, use `Link`. | 21 + 22 + ## Tone 23 + 24 + `Button` has four tones: `primary` (default), `neutral`, `critical`, and `ghost`. 25 + 26 + <ExampleBlock 27 + component="button" 28 + name="tones" 29 + title="Button — Tones" 30 + description="Primary, critical, ghost, and neutral tones for different emphasis levels." 31 + /> 32 + 33 + ## Size 34 + 35 + `Button` has two sizes: `medium` (default) and `small`. 36 + 37 + <ExampleBlock 38 + component="button" 39 + name="sizes" 40 + title="Button — Sizes" 41 + description="Small and medium buttons side by side. Use small in dense UIs, medium for most cases." 42 + /> 43 + 44 + ## Icons 45 + 46 + Use `startIcon` and `endIcon` to place an icon before or after the label. The icon inherits the 47 + button size, so the icon does not need its own `size` prop. 48 + 49 + ```tsx 50 + import { Icon } from '@luke-ui/react/icon'; 51 + 52 + <Button startIcon={<Icon name="add" aria-hidden />}>Add item</Button>; 53 + ``` 54 + 55 + ## Disabled 56 + 57 + Disabled buttons cannot be focused or pressed. 58 + 59 + ```tsx 60 + <Button isDisabled>Save</Button> 61 + ``` 62 + 63 + ## Pending 64 + 65 + Set `isPending` while an action is in flight. A spinner overlays the label and the button becomes 66 + non-interactive. 67 + 68 + <ExampleBlock 69 + component="button" 70 + name="pending" 71 + title="Button — Pending" 72 + description="Show a pending state with `isPending` while an async action is in progress." 73 + /> 74 + 75 + ## Full width 76 + 77 + Set `isBlock` to make the button fill the inline size of its container. 78 + 79 + ```tsx 80 + <Button isBlock>Save</Button> 81 + ``` 82 + 83 + ## Accessibility 84 + 85 + `Button` wraps its children in `Text`, so visible text usually provides the accessible name. You 86 + normally do not need `aria-label`. 87 + 88 + The pending spinner is `aria-hidden` and does not announce busy state to screen readers. If screen 89 + reader users need to hear the pending state, change the label text itself, for example to "Saving", 90 + while `isPending` is set. 91 + 92 + ## Primitive Button 93 + 94 + The lower-level `Button` primitive is available when you need full control over children, such as 95 + custom loading states, render-prop children, or non-standard content. 96 + 97 + ```ts 98 + import { Button } from '@luke-ui/react/button/primitive'; 99 + ``` 100 + 101 + The primitive renders a single `<button>` element with no internal wrapper. Its children are direct 102 + flex items, so you manage layout yourself. 103 + 104 + ## Props 105 + 106 + <auto-type-table path="packages/@luke-ui/react/src/button/index.tsx" name="ButtonProps" />
+49 -3
apps/docs/content/docs/components/actions/icon-button.mdx
··· 3 3 description: Compact icon-only action button with an accessible label. 4 4 --- 5 5 6 - import { story } from '../../../../src/icon-button/icon-button.story'; 6 + Use `IconButton` for compact actions where an icon can carry the visible UI. Always provide an 7 + accessible label. 7 8 8 - <story.WithControl /> 9 + ```tsx 10 + <IconButton icon="add" aria-label="Add item" /> 11 + ``` 9 12 10 - <include>../../../../../../packages/@luke-ui/react/docs/icon-button.md</include> 13 + ## Best practices 14 + 15 + | Guidance | Practices | 16 + | -------- | ---------------------------------------------------------------------------- | 17 + | Do | Name the action in `aria-label`, such as "Delete row". Do not name the icon. | 18 + | Do | Use `tone="critical"` for destructive icon actions such as delete. | 19 + 20 + ## Tone 21 + 22 + <ExampleBlock 23 + component="icon-button" 24 + name="tones" 25 + title="Icon Button — Tone" 26 + description="Ghost and critical tones for icon-only actions." 27 + /> 28 + 29 + ## Size 30 + 31 + <ExampleBlock 32 + component="icon-button" 33 + name="size" 34 + title="Icon Button — Size" 35 + description="A small icon button for tighter spaces." 36 + /> 37 + 38 + ## Accessibility 39 + 40 + `IconButton` has no visible text label. Always provide `aria-label` or `aria-labelledby`. Without 41 + one, screen readers have no way to announce the button's purpose. 42 + 43 + ```tsx 44 + <IconButton icon="search" aria-label="Search orders" /> 45 + ``` 46 + 47 + ## When to use vs Button 48 + 49 + Use `IconButton` when the icon alone communicates the action and space is tight, such as in 50 + toolbars, table row actions, or close buttons. 51 + 52 + Use `Button` when you need a visible text label. Labels are clearer for unfamiliar users. 53 + 54 + ## Props 55 + 56 + <auto-type-table path="packages/@luke-ui/react/src/icon-button/index.tsx" name="IconButtonProps" />
+60 -3
apps/docs/content/docs/components/actions/link.mdx
··· 3 3 description: Link component for inline and standalone navigation. 4 4 --- 5 5 6 - import { story } from '../../../../src/link/link.story'; 6 + `Link` expects the Luke UI theme class at the app or root level. See 7 + [Getting Started](/docs/getting-started). 7 8 8 - <story.WithControl /> 9 + ```tsx 10 + <Link href="/help">Help center</Link> 11 + ``` 9 12 10 - <include>../../../../../../packages/@luke-ui/react/docs/link.md</include> 13 + ## Best practices 14 + 15 + | Guidance | Practices | 16 + | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | 17 + | Do | Use `tone="inverted"` only on dark backgrounds. On light backgrounds, it is hard to read. | 18 + | Do | Use `isStandalone` for links that are not part of a sentence, such as card links and nav items. Leave it `false` for inline links. | 19 + 20 + ## Tone 21 + 22 + `Link` has three tones: `brand` (default), `neutral`, and `inverted`. Use `neutral` for a more 23 + subtle link. Use `inverted` on dark backgrounds. 24 + 25 + <ExampleBlock 26 + component="link" 27 + name="tones" 28 + title="Link — Tones" 29 + description="Brand, neutral, and inverted tones for different backgrounds." 30 + /> 31 + 32 + ## Standalone 33 + 34 + Use `isStandalone` when a link stands on its own. Leave it `false` for links inside paragraph text. 35 + 36 + - `isStandalone={true}`: no underline until hover. 37 + - `isStandalone={false}`: underlined inline link style. 38 + 39 + <ExampleBlock 40 + component="link" 41 + name="standalone" 42 + title="Link — Standalone" 43 + description="Use isStandalone for links that stand on their own, or inline links in prose." 44 + /> 45 + 46 + ## Disabled 47 + 48 + <ExampleBlock 49 + component="link" 50 + name="disabled" 51 + title="Link — Disabled" 52 + description="A disabled link cannot be focused or activated." 53 + /> 54 + 55 + ## Accessibility 56 + 57 + Screen readers announce a disabled link as unavailable, but not why. Put the reason in nearby 58 + visible text instead of relying on disabled state alone. 59 + 60 + ## When to use vs Button 61 + 62 + Use `Link` to navigate to a new URL or route. Use `Button` for in-page actions, such as saving, 63 + submitting, or opening a dialog. 64 + 65 + ## Props 66 + 67 + <auto-type-table path="packages/@luke-ui/react/src/link/index.tsx" name="LinkProps" />
+93 -3
apps/docs/content/docs/components/feedback/loading-skeleton.mdx
··· 3 3 description: Loading placeholder that keeps the same footprint as the final content. 4 4 --- 5 5 6 - import { story } from '../../../../src/loading-skeleton/loading-skeleton.story'; 6 + `LoadingSkeleton` expects the Luke UI theme class at the app or root level. See 7 + [Getting Started](/docs/getting-started). 7 8 8 - <story.WithControl /> 9 + Use it when loading content should keep the same footprint as the loaded state. Text renders as an 10 + inline skeleton. Elements keep their layout while the skeleton surface is painted over them. 9 11 10 - <include>../../../../../../packages/@luke-ui/react/docs/loading-skeleton.md</include> 12 + <ExampleBlock 13 + component="loading-skeleton" 14 + name="text" 15 + title="Loading Skeleton — Text" 16 + description="Inline skeleton that mirrors the wrapped text size." 17 + /> 18 + 19 + All mounted skeletons use the same pulse timing, even when they mount at different times. 20 + 21 + ## Best practices 22 + 23 + | Guidance | Practices | 24 + | -------- | ------------------------------------------------------------------------------------------------------------------- | 25 + | Do | Wrap real content so the skeleton matches the final size exactly. | 26 + | Don't | Use `LoadingSkeleton` for content whose final size is unknown. Size mismatch causes layout shift when loading ends. | 27 + 28 + ## Loading state 29 + 30 + `isLoading` defaults to `true`. Pass `isLoading={false}` when content is ready. 31 + 32 + ```tsx 33 + <LoadingSkeleton isLoading={false}> 34 + <Button>Submit</Button> 35 + </LoadingSkeleton> 36 + ``` 37 + 38 + ## Multi-line text 39 + 40 + Wrap text directly when the copy spans more than one line. Each line gets its own skeleton shape. 41 + 42 + ## Element type 43 + 44 + `LoadingSkeleton` renders a `span` by default. Use `as` when the surrounding markup needs another 45 + element. 46 + 47 + <ExampleBlock 48 + component="loading-skeleton" 49 + name="element" 50 + title="Loading Skeleton — Element" 51 + description="Render the skeleton as a different element to match the surrounding markup." 52 + /> 53 + 54 + ## LoadingSkeletonProvider 55 + 56 + Use `LoadingSkeletonProvider` when one loading state controls a group of skeletons. The provider 57 + value overrides descendant `isLoading` props. 58 + 59 + <ExampleBlock 60 + component="loading-skeleton" 61 + name="provider" 62 + title="Loading Skeleton — Provider" 63 + description="One loading state controls a group of skeletons." 64 + /> 65 + 66 + ## Border radius 67 + 68 + Use `borderRadius` when the wrapped child has no radius, but the visible control inside it does. 69 + 70 + <ExampleBlock 71 + component="loading-skeleton" 72 + name="border-radius" 73 + title="Loading Skeleton — Border radius" 74 + description="Override the skeleton corner radius for wrapped controls." 75 + /> 76 + 77 + ## Custom dimensions 78 + 79 + Wrap an element with explicit dimensions when you need a placeholder for a fixed shape, such as an 80 + avatar. 81 + 82 + <ExampleBlock 83 + component="loading-skeleton" 84 + name="custom-dimensions" 85 + title="Loading Skeleton — Custom dimensions" 86 + description="Wrap an element with explicit dimensions for a fixed-shape placeholder." 87 + /> 88 + 89 + ## Accessibility 90 + 91 + While loading, skeleton content is hidden from assistive technology and cannot be focused or 92 + clicked. `LoadingSkeleton` sets `aria-hidden`, `inert`, `tabIndex={-1}`, and disables pointer 93 + events. 94 + 95 + ## Props 96 + 97 + <auto-type-table 98 + path="packages/@luke-ui/react/src/loading-skeleton/index.tsx" 99 + name="LoadingSkeletonProps" 100 + />
+51 -3
apps/docs/content/docs/components/feedback/loading-spinner.mdx
··· 3 3 description: Spinner for indeterminate and determinate progress. 4 4 --- 5 5 6 - import { story } from '../../../../src/loading-spinner/loading-spinner.story'; 6 + `LoadingSpinner` expects the Luke UI theme class at the app or root level. See 7 + [Getting Started](/docs/getting-started). 7 8 8 - <story.WithControl /> 9 + <ExampleBlock 10 + component="loading-spinner" 11 + name="basic" 12 + title="Loading Spinner — Basic" 13 + description="Indeterminate spinner with a default pending label." 14 + /> 9 15 10 - <include>../../../../../../packages/@luke-ui/react/docs/loading-spinner.md</include> 16 + All mounted indeterminate spinners rotate and pulse in sync, even when they mount at different 17 + times. 18 + 19 + ## Progress mode 20 + 21 + Omit `value` for indeterminate progress. Pass `value` for determinate progress. 22 + 23 + <ExampleBlock 24 + component="loading-spinner" 25 + name="progress" 26 + title="Loading Spinner — Progress mode" 27 + description="Indeterminate and determinate progress spinners." 28 + /> 29 + 30 + ## Size 31 + 32 + <ExampleBlock 33 + component="loading-spinner" 34 + name="sizes" 35 + title="Loading Spinner — Sizes" 36 + description="Small and medium spinner sizes." 37 + /> 38 + 39 + ## Color 40 + 41 + <ExampleBlock 42 + component="loading-spinner" 43 + name="colors" 44 + title="Loading Spinner — Colors" 45 + description="Informative and critical spinner colors." 46 + /> 47 + 48 + ## Accessibility 49 + 50 + `aria-label` defaults to `"pending"` when omitted. Override it with what is loading, such as 51 + "Loading profile", for a clearer announcement. 52 + 53 + ## Props 54 + 55 + <auto-type-table 56 + path="packages/@luke-ui/react/src/loading-spinner/index.tsx" 57 + name="LoadingSpinnerProps" 58 + />
+144 -3
apps/docs/content/docs/components/forms/combobox-field.mdx
··· 3 3 description: Single-select combobox field with label, validation, and async options. 4 4 --- 5 5 6 - import { story } from '../../../../src/combobox-field/combobox-field.story'; 6 + Use `ComboboxField` for a single-select combobox with label, description, error, popover, listbox, 7 + and optional async loading built in. 7 8 8 - <story.WithControl /> 9 + <ExampleBlock 10 + component="combobox-field" 11 + name="basic" 12 + title="Combobox Field — Basic" 13 + description="Single-select combobox with a static list of options." 14 + /> 9 15 10 - <include>../../../../../../packages/@luke-ui/react/docs/combobox-field.md</include> 16 + ## Best practices 17 + 18 + | Guidance | Practices | 19 + | -------- | ------------------------------------------------------------------------------------------------------- | 20 + | Do | Use `defaultItems` for a static list. Use `items` with `loadingState` when options load asynchronously. | 21 + | Don't | Use `ComboboxField` for multi-select. It is single-select only in v1. | 22 + 23 + ## API shape 24 + 25 + - Root props follow React Aria `ComboBox` naming. 26 + - Composed convenience props include `label`, `description`, `errorMessage`, `necessityIndicator`, 27 + `size`, and `placeholder`. 28 + - `children` renders items from `items` or `defaultItems`. 29 + - `listBoxProps` and `loadMoreItem` are lower-level escape hatches. 30 + 31 + ## Selection indicators 32 + 33 + The selected option shows a checkmark in the listbox. When the control has a selection, it shows a 34 + clear button before the trigger. Pressing the clear button clears the selection and input text. 35 + 36 + The clear button is omitted when the field is disabled or read-only. 37 + 38 + ```tsx 39 + <ComboboxField defaultValue="ca" defaultItems={countries} label="Country" name="country"> 40 + {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 41 + </ComboboxField> 42 + ``` 43 + 44 + ## Required fields 45 + 46 + Use `isRequired` with `necessityIndicator` to show that the field is mandatory. 47 + 48 + <ExampleBlock 49 + component="combobox-field" 50 + name="required" 51 + title="Combobox Field — Required" 52 + description="Required combobox with a necessity indicator." 53 + /> 54 + 55 + ## Validation 56 + 57 + Pass field-level validation through React Aria `Form`. Use `errorMessage` to render the validation 58 + message. 59 + 60 + ```tsx 61 + import { Form } from 'react-aria-components'; 62 + 63 + <Form validationErrors={{ country: 'Please select a country.' }}> 64 + <ComboboxField 65 + name="country" 66 + label="Country" 67 + errorMessage={(validation) => validation.validationErrors.join(' ')} 68 + > 69 + {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 70 + </ComboboxField> 71 + </Form>; 72 + ``` 73 + 74 + ## Grouped options 75 + 76 + Use `ComboboxSection` to group related options inside the listbox. 77 + 78 + <ExampleBlock 79 + component="combobox-field" 80 + name="grouped" 81 + title="Combobox Field — Grouped options" 82 + description="Group related options inside the listbox with ComboboxSection." 83 + /> 84 + 85 + ## Async options 86 + 87 + Pass `loadingState` for built-in loading and empty states. Control the option collection with 88 + `items`. 89 + 90 + ```tsx 91 + <ComboboxField 92 + label="Country" 93 + name="country" 94 + items={results} 95 + loadingState={status} 96 + onInputChange={setQuery} 97 + > 98 + {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 99 + </ComboboxField> 100 + ``` 101 + 102 + ## Infinite scroll 103 + 104 + Use `onLoadMore` for automatic sentinel-based loading, or `loadMoreItem` when you need full control 105 + over the load-more row. 106 + 107 + ```tsx 108 + <ComboboxField 109 + label="Country" 110 + name="country" 111 + items={results} 112 + loadingState={status} 113 + onLoadMore={fetchNextPage} 114 + > 115 + {(item) => <ComboboxItem>{item.label}</ComboboxItem>} 116 + </ComboboxField> 117 + ``` 118 + 119 + ## Primitive kit 120 + 121 + The individual `Combobox*` building blocks are available for library authors who need a custom 122 + combobox layout. 123 + 124 + ```ts 125 + import { 126 + ComboboxInput, 127 + ComboboxControl, 128 + ComboboxTextInput, 129 + ComboboxClearButton, 130 + ComboboxTrigger, 131 + ComboboxPopover, 132 + ComboboxListBox, 133 + ComboboxItem, 134 + ComboboxSection, 135 + } from '@luke-ui/react/combobox-field/primitive'; 136 + ``` 137 + 138 + ### Size propagation 139 + 140 + When you set `size` on `ComboboxInput`, it is inherited by `ComboboxControl`, `ComboboxTextInput`, 141 + `ComboboxClearButton`, `ComboboxTrigger`, `ComboboxItem`, and `ComboboxLoadMoreItem`. 142 + 143 + You can override the inherited size on an individual child by passing that child an explicit `size` 144 + prop. 145 + 146 + ## Props 147 + 148 + <auto-type-table 149 + path="packages/@luke-ui/react/src/combobox-field/index.tsx" 150 + name="ComboboxFieldProps" 151 + />
+95 -3
apps/docs/content/docs/components/forms/text-field.mdx
··· 3 3 description: Single-line text input with label, validation, and adornments. 4 4 --- 5 5 6 - import { story } from '../../../../src/text-field/text-field.story'; 6 + Use `TextField` for a single text input with label, description, validation, and optional adornments 7 + built in. 7 8 8 - <story.WithControl /> 9 + <ExampleBlock 10 + component="text-field" 11 + name="basic" 12 + title="Text Field — Basic" 13 + description="Single-line text input with label and description." 14 + /> 9 15 10 - <include>../../../../../../packages/@luke-ui/react/docs/text-field.md</include> 16 + ## Best practices 17 + 18 + | Guidance | Practices | 19 + | -------- | --------------------------------------------------------------------------------------------------------------------------- | 20 + | Do | Use `label` for every field where possible. It works better with assistive technology and autofill than `aria-label` alone. | 21 + | Don't | Use `placeholder` as a label substitute. It disappears after typing and often fails colour contrast requirements. | 22 + 23 + ## Required fields 24 + 25 + Use `isRequired` and `necessityIndicator` to communicate mandatory fields. `'icon'` renders a visual 26 + required marker. `'label'` appends "(required)" to the label text. 27 + 28 + <ExampleBlock 29 + component="text-field" 30 + name="required" 31 + title="Text Field — Required" 32 + description="Icon and label necessity indicators for mandatory fields." 33 + /> 34 + 35 + ## Validation 36 + 37 + Pass field-level validation through React Aria `Form`. Use `errorMessage` to render the validation 38 + message. 39 + 40 + ```tsx 41 + import { Form } from 'react-aria-components'; 42 + 43 + <Form validationErrors={{ username: 'Username is not available.' }}> 44 + <TextField 45 + name="username" 46 + label="Username" 47 + errorMessage={(validation) => validation.validationErrors.join(' ')} 48 + /> 49 + </Form>; 50 + ``` 51 + 52 + ## Adornments 53 + 54 + Use `adornmentStart` and `adornmentEnd` to place non-editable content inside the input chrome. 55 + Adornments accept any `ReactNode`. If an adornment is interactive, you are responsible for its 56 + semantics. 57 + 58 + <ExampleBlock 59 + component="text-field" 60 + name="adornments" 61 + title="Text Field — Adornments" 62 + description="Start and end adornments inside the input chrome." 63 + /> 64 + 65 + ## Size 66 + 67 + `size` controls height and typography. The HTML numeric `<input size>` attribute is intentionally 68 + omitted because `size` is reserved for the design-system variant. 69 + 70 + | Value | Description | 71 + | ---------- | --------------------- | 72 + | `'small'` | Compact input height. | 73 + | `'medium'` | Default input height. | 74 + 75 + <ExampleBlock 76 + component="text-field" 77 + name="sizes" 78 + title="Text Field — Sizes" 79 + description="Small and medium input heights." 80 + /> 81 + 82 + ## Accessibility 83 + 84 + When visual context already communicates purpose, you may omit `label` and provide an accessible 85 + name with `aria-label` or `aria-labelledby` on the field. 86 + 87 + ```tsx 88 + <TextField aria-label="Search" name="search" placeholder="Search" /> 89 + ``` 90 + 91 + ## Primitive TextInput 92 + 93 + The lower-level `TextInput` primitive is available when you need the input without the label, 94 + description, or error slots that `Field` provides. 95 + 96 + ```ts 97 + import { TextInput } from '@luke-ui/react/text-field/primitive'; 98 + ``` 99 + 100 + ## Props 101 + 102 + <auto-type-table path="packages/@luke-ui/react/src/text-field/index.tsx" name="TextFieldProps" />
+25 -3
apps/docs/content/docs/components/typography/emoji.mdx
··· 3 3 description: Emoji rendering with a reliable screen reader label. 4 4 --- 5 5 6 - import { story } from '../../../../src/emoji/emoji.story'; 6 + Use `Emoji` when an emoji needs a reliable accessible label. 7 7 8 - <story.WithControl /> 8 + <ExampleBlock 9 + component="emoji" 10 + name="basic" 11 + title="Emoji — Basic" 12 + description="Emoji with accessible label and typography controls." 13 + /> 9 14 10 - <include>../../../../../../packages/@luke-ui/react/docs/emoji.md</include> 15 + `Emoji` accepts all `Text` props except `children` and `elementType`, so it inherits typography 16 + controls such as `fontSize` and `color`. 17 + 18 + ## Best practices 19 + 20 + | Guidance | Practices | 21 + | -------- | ---------------------------------------------------------------------------------------------------------- | 22 + | Do | Write a `label` that describes the emoji's meaning in context, such as "Celebration". | 23 + | Don't | Repeat text that already appears in the surrounding sentence. That creates duplicate screen reader output. | 24 + 25 + ## Accessibility 26 + 27 + Both `emoji` and `label` are required. Screen readers announce the label instead of the raw emoji 28 + character, which is not announced consistently across platforms. 29 + 30 + ## Props 31 + 32 + <auto-type-table path="packages/@luke-ui/react/src/emoji/index.tsx" name="EmojiProps" />
+46 -3
apps/docs/content/docs/components/typography/heading.mdx
··· 3 3 description: Semantic heading with automatic level management. 4 4 --- 5 5 6 - import { story } from '../../../../src/heading/heading.story'; 6 + Use `Heading` for section headings. It can infer heading level from `HeadingLevels` context, or you 7 + can pass `level` directly. 7 8 8 - <story.WithControl /> 9 + ```tsx 10 + <Heading>Section title</Heading> 11 + ``` 9 12 10 - <include>../../../../../../packages/@luke-ui/react/docs/heading.md</include> 13 + ```tsx 14 + <Heading level={2}>Explicit h2</Heading> 15 + ``` 16 + 17 + ## Best practices 18 + 19 + | Guidance | Practices | 20 + | -------- | ------------------------------------------------------------------------------------------------------------- | 21 + | Do | Prefer automatic leveling for nested sections instead of hardcoding every `level`. | 22 + | Don't | Skip heading levels, such as an h2 followed directly by an h4. Screen reader users navigate by heading level. | 23 + 24 + ## Automatic leveling 25 + 26 + `Heading` reads its level from `HeadingLevels` context. Set `base` on the root context. Each nested 27 + `HeadingLevels` advances the next heading level. 28 + 29 + <ExampleBlock 30 + component="heading" 31 + name="automatic-leveling" 32 + title="Heading — Automatic leveling" 33 + description="Heading levels are inferred from nested HeadingLevels context." 34 + /> 35 + 36 + The `level` prop overrides context for one heading without changing the nesting depth for siblings 37 + or children. 38 + 39 + ## Typography 40 + 41 + `Heading` accepts all `Text` props except `fontSize`, which is controlled by the heading level. By 42 + default it applies `fontWeight="bold"` and `lineHeight="tight"`. 43 + 44 + <ExampleBlock 45 + component="heading" 46 + name="typography" 47 + title="Heading — Typography" 48 + description="Heading color and font weight can be customized with Text props." 49 + /> 50 + 51 + ## Props 52 + 53 + <auto-type-table path="packages/@luke-ui/react/src/heading/index.tsx" name="HeadingProps" />
+54 -3
apps/docs/content/docs/components/typography/numeral.mdx
··· 3 3 description: Locale-aware number formatting powered by Intl.NumberFormat. 4 4 --- 5 5 6 - import { story } from '../../../../src/numeral/numeral.story'; 6 + `Numeral` formats numbers with `Intl.NumberFormat`. It respects locale from React Aria's 7 + `I18nProvider`. 7 8 8 - <story.WithControl /> 9 + <ExampleBlock 10 + component="numeral" 11 + name="basic" 12 + title="Numeral — Basic" 13 + description="Default locale-aware decimal formatting." 14 + /> 9 15 10 - <include>../../../../../../packages/@luke-ui/react/docs/numeral.md</include> 16 + ## Formats 17 + 18 + `Numeral` infers `format` from `currency` or `unit` when omitted. Pass `format` explicitly for 19 + `'percent'` or `'decimal'`, or when you want to be direct about currency and unit formatting. 20 + 21 + <ExampleBlock 22 + component="numeral" 23 + name="formats" 24 + title="Numeral — Formats" 25 + description="Percent, currency, unit, and decimal number formats." 26 + /> 27 + 28 + ## Compact notation 29 + 30 + Use `abbreviate` for compact notation. 31 + 32 + <ExampleBlock 33 + component="numeral" 34 + name="compact" 35 + title="Numeral — Compact notation" 36 + description="Short and long compact notation for large numbers." 37 + /> 38 + 39 + ## Precision 40 + 41 + Pass a number for fixed fraction digits, or a `[min, max]` tuple for a range. 42 + 43 + <ExampleBlock 44 + component="numeral" 45 + name="precision" 46 + title="Numeral — Precision" 47 + description="Fixed fraction digits or a minimum and maximum range." 48 + /> 49 + 50 + ## Constraints 51 + 52 + `Numeral` throws in development when: 53 + 54 + - both `currency` and `unit` are provided. 55 + - `format="currency"` is used without a `currency` code. 56 + - `format="unit"` is used without a `unit` value. 57 + - `precision` is not a non-negative integer or valid `[min, max]` tuple. 58 + 59 + ## Props 60 + 61 + <auto-type-table path="packages/@luke-ui/react/src/numeral/index.tsx" name="NumeralProps" />
+95 -3
apps/docs/content/docs/components/typography/text.mdx
··· 3 3 description: Styled text with token-driven typography controls. 4 4 --- 5 5 6 - import { story } from '../../../../src/text/text.story'; 6 + `Text` expects the Luke UI theme class at the app or root level. See 7 + [Getting Started](/docs/getting-started). 7 8 8 - <story.WithControl /> 9 + Use `Text` for styled text that should not create heading semantics. 9 10 10 - <include>../../../../../../packages/@luke-ui/react/docs/text.md</include> 11 + ```tsx 12 + <Text>The quick brown fox jumps over the lazy dog.</Text> 13 + ``` 14 + 15 + ## Best practices 16 + 17 + | Guidance | Practices | 18 + | -------- | ----------------------------------------------------------------------------------------------------------- | 19 + | Do | Use `fontSize` tokens, such as `'h2'`, instead of arbitrary values so text stays consistent across the app. | 20 + | Don't | Use `Text` for section headings. Use `Heading`, which manages semantic level automatically. | 21 + 22 + ## Typography 23 + 24 + <ExampleBlock 25 + component="text" 26 + name="typography" 27 + title="Text — Typography" 28 + description="Use font size, color, font family, and weight tokens to style text." 29 + /> 30 + 31 + See the token reference below for valid `color`, `fontFamily`, `fontSize`, `lineHeight`, and 32 + `fontWeight` values. 33 + 34 + ## Text transform and decoration 35 + 36 + <ExampleBlock 37 + component="text" 38 + name="transform" 39 + title="Text — Transform and decoration" 40 + description="Apply text transform and decoration to emphasize content." 41 + /> 42 + 43 + ## Truncation 44 + 45 + <ExampleBlock 46 + component="text" 47 + name="truncation" 48 + title="Text — Truncation" 49 + description="Clamp lines, truncate to one line, or disable cap-height trim." 50 + /> 51 + 52 + ## Alignment and numeric glyphs 53 + 54 + <ExampleBlock 55 + component="text" 56 + name="alignment" 57 + title="Text — Alignment and numeric glyphs" 58 + description="Align text and set numeric glyph styles such as tabular numerals." 59 + /> 60 + 61 + ## Token reference 62 + 63 + ### `color` tokens 64 + 65 + `neutralSubtle`, `neutralBold`, `neutralDisabled`, `neutralBoldInverted`, `positive`, `informative`, 66 + `caution`, `critical`, `inherit` 67 + 68 + ### `fontFamily` tokens 69 + 70 + `sans`, `mono` 71 + 72 + ### `fontSize` tokens 73 + 74 + `xxsmall`, `xsmall`, `small`, `standard`, `medium`, `large`, `xlarge`, `xxlarge`, `h1`, `h2`, `h3`, 75 + `h4`, `h5`, `h6` 76 + 77 + ### `lineHeight` tokens 78 + 79 + `nospace`, `tight`, `loose` 80 + 81 + ### `fontWeight` tokens 82 + 83 + `regular`, `medium`, `bold`, `inherit` 84 + 85 + ### `textDecoration` values 86 + 87 + `none`, `underline`, `line-through`, `inherit` 88 + 89 + ### `textTransform` values 90 + 91 + `none`, `capitalize`, `uppercase`, `lowercase`, `inherit` 92 + 93 + ## When to use vs Heading 94 + 95 + Use `Heading` for actual section headings because it manages semantic level nesting automatically. 96 + 97 + Use `Text` with a heading-sized `fontSize` token when content should look like a heading but is not 98 + semantically one, such as a large stat number. 99 + 100 + ## Props 101 + 102 + <auto-type-table path="packages/@luke-ui/react/src/text/index.tsx" name="TextProps" />
+80 -3
apps/docs/content/docs/components/visuals/icon.mdx
··· 3 3 description: SVG icon component backed by the generated spritesheet. 4 4 --- 5 5 6 - import { story } from '../../../../src/icon/icon.story'; 6 + `Icon` renders a symbol from the Luke UI spritesheet. It requires an `IconSpritesheetProvider` 7 + ancestor. See Setup below. 7 8 8 - <story.WithControl /> 9 + <ExampleBlock 10 + component="icon" 11 + name="basic" 12 + title="Icon — Basic" 13 + description="Standalone and decorative icons from the spritesheet." 14 + /> 9 15 10 - <include>../../../../../../packages/@luke-ui/react/docs/icon.md</include> 16 + ## Best practices 17 + 18 + | Guidance | Practices | 19 + | -------- | --------------------------------------------------------------------------------------------------------- | 20 + | Do | Pass `title` when the icon conveys meaning without adjacent text, such as a standalone status icon. | 21 + | Don't | Pass both `aria-hidden` and `title`. A titled icon should be exposed to assistive technology, not hidden. | 22 + 23 + ## Setup 24 + 25 + Wrap your app with `IconSpritesheetProvider`. 26 + 27 + - Source asset: `@luke-ui/react/spritesheet.svg`, exported from `./dist/spritesheet.svg` 28 + - Runtime lookup: `<configured-sprite-href>#<icon-name>` 29 + 30 + ```tsx 31 + <IconSpritesheetProvider href="/assets/spritesheet.svg"> 32 + <App /> 33 + </IconSpritesheetProvider> 34 + ``` 35 + 36 + Vite and Storybook should import the spritesheet as a URL. 37 + 38 + ```ts 39 + import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline'; 40 + 41 + <IconSpritesheetProvider href={spriteSheetHref}> 42 + <App /> 43 + </IconSpritesheetProvider>; 44 + ``` 45 + 46 + The `no-inline` query avoids `data:` URLs, which can break `<use href="...#icon-id">` rendering. 47 + 48 + When developing `@luke-ui/react`, generate icons from `packages/@luke-ui/react/icons/*.svg` into 49 + `packages/@luke-ui/react/dist/spritesheet.svg`. 50 + 51 + ```bash 52 + pnpm --dir packages/@luke-ui/react run generate:icons 53 + ``` 54 + 55 + ## How it works 56 + 57 + `Icon` renders an `<svg>` that references a symbol in the generated spritesheet with 58 + `<use href="..." />`. 59 + 60 + - `name` chooses the symbol id. 61 + - The sprite URL comes from `IconSpritesheetProvider`. 62 + - `viewBox` defaults to the generated icon viewBox. 63 + - Icon fill follows `currentColor`. 64 + 65 + ```tsx 66 + <Icon name="add" className="myIcon" style={{ color: 'tomato' }} /> 67 + ``` 68 + 69 + ## Create your own icon 70 + 71 + Use `createIcon` for a one-off icon that is not in the generated spritesheet. 72 + 73 + <ExampleBlock 74 + component="icon" 75 + name="custom" 76 + title="Icon — Custom" 77 + description="Create a one-off icon that is not in the generated spritesheet." 78 + /> 79 + 80 + ## Accessibility 81 + 82 + If `title` is provided, the icon is exposed to assistive technology with `role="img"`. If `title` is 83 + omitted, `aria-hidden` defaults to `true`. 84 + 85 + ## Props 86 + 87 + <auto-type-table path="packages/@luke-ui/react/src/icon/index.tsx" name="IconProps" />
-16
packages/@luke-ui/docs-tools/src/fixtures/sample-package/src/sample/index.tsx
··· 1 - import type { JSX } from 'react'; 2 - 3 - /** 4 - * Props for a sample atom. 5 - * 6 - * @tier atom 7 - */ 8 - export interface SampleProps { 9 - /** Visible label. */ 10 - label: string; 11 - } 12 - 13 - /** Sample atom description. */ 14 - export function Sample(props: SampleProps): JSX.Element { 15 - return <div>{props.label}</div>; 16 - }
-11
packages/@luke-ui/docs-tools/src/fixtures/sample-package/src/kit/primitive/index.tsx
··· 1 - import type { JSX } from 'react'; 2 - 3 - /** First primitive kit export. */ 4 - export function KitControl(): JSX.Element { 5 - return <div />; 6 - } 7 - 8 - /** Second primitive kit export. */ 9 - export function KitItem(): JSX.Element { 10 - return <div />; 11 - }
-16
packages/@luke-ui/docs-tools/src/fixtures/sample-package/src/single/primitive/index.tsx
··· 1 - import type { JSX } from 'react'; 2 - 3 - /** 4 - * Props for a single primitive export. 5 - * 6 - * @tier primitive 7 - */ 8 - export interface SingleProps { 9 - /** Visible label. */ 10 - label: string; 11 - } 12 - 13 - /** Single primitive export. */ 14 - export function Single(props: SingleProps): JSX.Element { 15 - return <div>{props.label}</div>; 16 - }