Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cbc727e
Add rendered UI component examples to docs
brophdawg11 May 13, 2026
fe31427
Cleanups
brophdawg11 May 13, 2026
199973a
Add e2e tests for the docs site
brophdawg11 May 13, 2026
8a084df
Restrict demo discovery to *.demo.tsx inside demos/ folders
brophdawg11 May 13, 2026
e06edd2
Update pnpm lock
brophdawg11 May 13, 2026
fa36f44
Updates
brophdawg11 May 13, 2026
cf26246
Merge branch 'main' into agents/ui-component-demos-implementation
brophdawg11 May 13, 2026
76aa894
Remove docs test command
brophdawg11 May 13, 2026
e398813
Add more demos
brophdawg11 May 14, 2026
31a811a
Improve docs demo infrastructure
brophdawg11 May 14, 2026
df0f593
fix lint
brophdawg11 May 14, 2026
e4cad2a
Merge remote-tracking branch 'origin/main' into agents/ui-component-d…
brophdawg11 May 14, 2026
c8d6d59
Fix merge conflict using old remix fetch router package
brophdawg11 May 14, 2026
599facb
Remove uneeded styles from demos
brophdawg11 May 14, 2026
34d6a83
Fix FOUC when navigating between hydrated client entries
brophdawg11 May 14, 2026
6a10922
refactor: per-subpath UI bundling with code-splitting to avoid name c…
brophdawg11 May 15, 2026
2461a45
refactor: serve demos via @remix-run/assets, drop esbuild pipelines
brophdawg11 May 15, 2026
45052e7
Build before runing dev
brophdawg11 May 15, 2026
ae059eb
Update pnpm lock
brophdawg11 May 15, 2026
dd02040
Revert "Fix FOUC when navigating between hydrated client entries"
brophdawg11 May 15, 2026
19d1aba
Merge branch 'main' into agents/ui-component-demos-implementation
brophdawg11 May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@
"jsdoc/require-returns-description": "error"
}
},
{
"files": ["packages/ui/**/demos/*.demo.tsx"],
"plugins": ["jsdoc"],
"rules": {
"arrow-body-style": "off",
"jsdoc/check-access": "off",
"jsdoc/check-property-names": "off",
"jsdoc/check-tag-names": "off",
"jsdoc/empty-tags": "off",
"jsdoc/implements-on-classes": "off",
"jsdoc/no-defaults": "off",
"jsdoc/require-param": "off",
"jsdoc/require-param-description": "off",
"jsdoc/require-param-name": "off",
"jsdoc/require-returns": "off",
"jsdoc/require-returns-description": "off"
}
},
{
"files": ["packages/**/*.test.ts"],
"plugins": ["jsdoc"],
Expand Down
13 changes: 6 additions & 7 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,16 @@
"@types/hast": "^3.0.4",
"@types/node": "catalog:",
"@types/semver": "^7.5.8",
"esbuild": "^0.25.10",
"shiki": "^3.22.0",
"typedoc": "^0.28.19"
"typedoc": "^0.28.19",
"typescript": "catalog:"
},
"scripts": {
"build": "pnpm run --filter \".\" --parallel \"/^build:/\"",
"build:browser": "esbuild src/client/*.tsx --outbase=src/client --outdir=build/assets --bundle --splitting --format=esm",
"build:public": "mkdir -p build/assets && cp public/* build/assets",
"dev": "NODE_ENV=development pnpm run --filter \".\" --parallel \"/^dev:/\"",
"dev:browser": "pnpm run build:browser --sourcemap --watch",
"dev:server": "tsx --watch src/server/index.ts",
"build:demos": "tsx src/generate/build-demos.ts",
"build:public": "mkdir -p build/public && cp public/* build/public",
"predev": "pnpm run build",
"dev": "NODE_ENV=development tsx --watch src/server/index.ts",
"docs": "node src/generate/index.ts",
"docs:debug": "DEBUG=1 node src/generate/index.ts",
"prerender": "tsx src/server/prerender.ts",
Expand Down
60 changes: 60 additions & 0 deletions docs/src/generate/build-demos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copy `*.demo.tsx` files from `packages/ui/src/components/<comp>/demos/` into
// `docs/build/demos/ui/<comp>/`, rewriting `@remix-run/*` imports to `remix/*`
// (the docs app depends on `remix`, not `@remix-run/ui`). The runtime server
// reads from `docs/build/demos/` — see `src/server/demos.tsx`.

import * as fs from 'node:fs'
import * as path from 'node:path'

const DOCS_DIR = path.resolve(import.meta.dirname, '..', '..')
const REPO_DIR = path.resolve(DOCS_DIR, '..')
const UI_COMPONENTS_DIR = path.join(REPO_DIR, 'packages', 'ui', 'src', 'components')
const DEMO_BUILD_DIR = path.join(DOCS_DIR, 'build', 'demos')

function rewriteImports(source: string): string {
return source.replace(
/(from\s+['"]|import\s*\(\s*['"])@remix-run\//g,
(_match, prefix) => `${prefix}remix/`,
)
}

function* walkDemoSources(dir: string): Generator<string> {
for (let entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
let absolutePath = path.join(dir, entry.name)
if (entry.name === 'demos') {
for (let demoEntry of fs.readdirSync(absolutePath, { withFileTypes: true })) {
if (demoEntry.isFile() && demoEntry.name.endsWith('.demo.tsx')) {
yield path.join(absolutePath, demoEntry.name)
}
}
continue
}
yield* walkDemoSources(absolutePath)
}
}

fs.rmSync(DEMO_BUILD_DIR, { recursive: true, force: true })

let count = 0
for (let sourcePath of walkDemoSources(UI_COMPONENTS_DIR)) {
// packages/ui/src/components/<comp>/demos/<slug>.demo.tsx
let parts = path.relative(REPO_DIR, sourcePath).split(path.sep)
if (
parts.length !== 7 ||
parts[0] !== 'packages' ||
parts[1] !== 'ui' ||
parts[2] !== 'src' ||
parts[3] !== 'components' ||
parts[5] !== 'demos' ||
!parts[6].endsWith('.demo.tsx')
) {
throw new Error(`Invalid demo location: ${sourcePath}`)
}
let outPath = path.join(DEMO_BUILD_DIR, 'ui', parts[4], parts[6])
fs.mkdirSync(path.dirname(outPath), { recursive: true })
fs.writeFileSync(outPath, rewriteImports(fs.readFileSync(sourcePath, 'utf-8')))
count++
}

console.log(`build-demos: wrote ${count} files to ${path.relative(DOCS_DIR, DEMO_BUILD_DIR)}`)
24 changes: 24 additions & 0 deletions docs/src/server/asset-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as path from 'node:path'
import { createAssetServer } from 'remix/assets'

const DOCS_DIR = path.resolve(import.meta.dirname, '..', '..')
const REPO_DIR = path.resolve(DOCS_DIR, '..')
const ENTRY_PATH = path.join(DOCS_DIR, 'src', 'client', 'entry.tsx')

export const assetServer = createAssetServer({
rootDir: REPO_DIR,
basePath: '/assets',
fileMap: {
'/demos/*path': 'docs/build/demos/*path',
'/pkg/*path': 'packages/*path',
'/client/*path': 'docs/src/client/*path',
'/shared/*path': 'docs/src/shared/*path',
},
allow: ['docs/build/demos/**', 'docs/src/client/**', 'docs/src/shared/**', 'packages/**'],
watch: process.env.NODE_ENV !== 'production',
})

// Transitive deps of the client entry. Emitted as <link rel="modulepreload">
// on every page so the prerender spider materializes them into the static
// output (and browsers warm the cache).
export const entryPreloads: readonly string[] = await assetServer.getPreloads(ENTRY_PATH)
62 changes: 0 additions & 62 deletions docs/src/server/components.tsx

This file was deleted.

196 changes: 196 additions & 0 deletions docs/src/server/demos.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as url from 'node:url'
import * as prettier from 'prettier'
import type { RemixNode } from 'remix/ui'
import { codeToHtml } from 'shiki'
import ts from 'typescript'
import { assetServer } from './asset-server.ts'

const DOCS_DIR = path.resolve(import.meta.dirname, '..', '..')
const DEMO_BUILD_DIR = path.join(DOCS_DIR, 'build', 'demos')
const SOURCE_URL_BASE = 'https://github.com/remix-run/remix/blob/main'
const SOURCE_RELATIVE_BASE = 'packages/ui/src/components'

export type DemoDocFile = {
kind: 'demo'
assetHref: string
description: string
importHref: string
name: string
order: number
package: string
path: string
preloads: readonly string[]
relativePath: string
slug: string
source: string
sourceUrl: string
urlPath: string
}

export async function discoverDemoFiles(): Promise<DemoDocFile[]> {
if (!fs.existsSync(DEMO_BUILD_DIR)) {
throw new Error(
`Demo build directory not found: ${DEMO_BUILD_DIR}. Run "pnpm build:demos" first.`,
)
}

let demoPaths = walkBuiltDemos(DEMO_BUILD_DIR).sort()
let demoFiles = await Promise.all(demoPaths.map((demoPath) => getDemoFile(demoPath)))

let seenUrls = new Map<string, string>()
for (let demo of demoFiles) {
let existingPath = seenUrls.get(demo.urlPath)
if (existingPath) {
throw new Error(
`Duplicate demo url path "${demo.urlPath}" for "${existingPath}" and "${demo.relativePath}"`,
)
}
seenUrls.set(demo.urlPath, demo.relativePath)
}

return demoFiles.sort((a, b) => a.urlPath.localeCompare(b.urlPath))
}

export async function loadDemoComponent(
demo: Pick<DemoDocFile, 'importHref' | 'slug'>,
): Promise<() => () => RemixNode> {
let version = fs.statSync(new URL(demo.importHref)).mtimeMs.toString(36)
let mod: unknown = await import(`${demo.importHref}?v=${version}`)

if (!mod || typeof mod !== 'object' || !('default' in mod) || typeof mod.default !== 'function') {
throw new Error(`Demo "${demo.slug}" must default export a component function`)
}

return mod.default as () => () => RemixNode
}

export async function renderDemoSource(source: string): Promise<string> {
return await codeToHtml(source, {
lang: 'tsx',
themes: {
light: 'github-light',
dark: 'github-dark',
},
})
}

async function getDemoFile(filePath: string): Promise<DemoDocFile> {
// build/demos/ui/<comp>/<slug>.demo.tsx, mirrors source layout one-to-one.
let parts = path.relative(DEMO_BUILD_DIR, filePath).split(path.sep)
if (parts.length !== 3 || parts[0] !== 'ui' || !parts[2].endsWith('.demo.tsx')) {
throw new Error(`Invalid built demo location: ${filePath}`)
}
let component = parts[1]
let slug = parts[2].slice(0, -'.demo.tsx'.length)
let packageName = `remix/ui/${component}`
let relativePath = `${SOURCE_RELATIVE_BASE}/${component}/demos/${slug}.demo.tsx`

let source = fs.readFileSync(filePath, 'utf-8')
let { name, description, order, displaySource } = extractDemoMetadata(source, relativePath)
let formattedSource = await formatDemoSource(displaySource, filePath)
let [assetHref, preloads] = await Promise.all([
assetServer.getHref(filePath),
assetServer.getPreloads(filePath),
])
let importHref = url.pathToFileURL(filePath).href

await loadDemoComponent({ importHref, slug })

return {
kind: 'demo',
assetHref,
description,
importHref,
name,
order,
package: packageName,
path: filePath,
preloads,
relativePath,
slug,
source: formattedSource,
sourceUrl: `${SOURCE_URL_BASE}/${relativePath}`,
urlPath: `${packageName}/demos/${slug}`,
}
}

async function formatDemoSource(source: string, filePath: string): Promise<string> {
let config = await prettier.resolveConfig(filePath)
return await prettier.format(source, {
...config,
filepath: filePath,
printWidth: 80,
})
}

function extractDemoMetadata(
source: string,
relativePath: string,
): { name: string; description: string; order: number; displaySource: string } {
let sf = ts.createSourceFile('demo.tsx', source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX)

for (let stmt of sf.statements) {
let jsDocs = (stmt as ts.Node & { jsDoc?: ts.JSDoc[] }).jsDoc
if (!jsDocs) continue

for (let jsDoc of jsDocs) {
let name = readJsdocTag(jsDoc, 'name')
if (!name) continue

let description = readJsdocTag(jsDoc, 'description')
if (!description) {
throw new Error(`Demo "${relativePath}" is missing a required @description tag`)
}

let orderRaw = readJsdocTag(jsDoc, 'order')
let order = orderRaw !== undefined ? parseInt(orderRaw, 10) : Infinity
if (orderRaw !== undefined && !Number.isFinite(order)) {
throw new Error(
`Demo "${relativePath}" has an invalid @order value "${orderRaw}" — must be a number`,
)
}

let stripStart = source.indexOf('/**', jsDoc.pos)
let stripEnd = jsDoc.end
if (source[stripEnd] === '\n') stripEnd++
let displaySource = source.slice(0, stripStart) + source.slice(stripEnd)
return { name, description, order, displaySource }
}
}

throw new Error(
`Demo "${relativePath}" is missing a JSDoc block with @name and @description tags`,
)
}

function readJsdocTag(
jsDoc: ts.JSDoc,
tagName: 'name' | 'description' | 'order',
): string | undefined {
let tag = jsDoc.tags?.find((t) => t.tagName.text === tagName)
if (!tag) return undefined
let comment = tag.comment
let text =
typeof comment === 'string'
? comment
: Array.isArray(comment)
? comment.map((c) => c.text).join('')
: ''
let trimmed = text.trim()
return trimmed.length > 0 ? trimmed : undefined
}

function walkBuiltDemos(directory: string): string[] {
let files: string[] = []
function recurse(dir: string) {
for (let entry of fs.readdirSync(dir, { withFileTypes: true })) {
let p = path.join(dir, entry.name)
if (entry.isDirectory()) recurse(p)
else if (entry.name.endsWith('.demo.tsx')) files.push(p)
}
}
recurse(directory)
return files
}
Loading
Loading