diff --git a/.gitignore b/.gitignore index 35b7dc8d..52f0ad87 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.log **/__screenshots__/** apps/docs/.story/ +apps/docs/src/generated/ apps/docs/src/routeTree.gen.ts dist/ node_modules/ diff --git a/apps/docs/package.json b/apps/docs/package.json index f49c6878..b3158ecc 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,7 +13,8 @@ "fix:format": "vp fmt . --write", "fix:lint": "vp lint . --type-aware --fix", "fix:unsafe": "vp lint . --type-aware --fix-dangerously", - "generate": "pnpm run routes:generate", + "generate": "pnpm run routes:generate && pnpm run generate:playground", + "generate:playground": "node scripts/generate-playground-scope.ts && node scripts/generate-playground-types.ts && vp pack", "postinstall": "fumadocs-mdx", "preview": "vp preview", "routes:generate": "tsr generate", @@ -22,7 +23,9 @@ "test": "vp test run --config vitest.config.ts" }, "dependencies": { + "@catppuccin/palette": "catalog:", "@luke-ui/react": "workspace:*", + "@monaco-editor/react": "catalog:", "@orama/orama": "catalog:", "@react-aria/utils": "catalog:", "@tanstack/react-router": "catalog:", @@ -34,9 +37,14 @@ "fumadocs-typescript": "catalog:", "fumadocs-ui": "catalog:", "lucide-react": "catalog:", + "lz-string": "catalog:", + "monaco-editor": "catalog:", "react": "catalog:", "react-aria-components": "catalog:", "react-dom": "catalog:", + "react-error-boundary": "catalog:", + "spin-doctor": "catalog:", + "sucrase": "catalog:", "vite": "catalog:", "zod": "catalog:" }, diff --git a/apps/docs/scripts/generate-playground-scope.ts b/apps/docs/scripts/generate-playground-scope.ts new file mode 100644 index 00000000..cee62b97 --- /dev/null +++ b/apps/docs/scripts/generate-playground-scope.ts @@ -0,0 +1,61 @@ +/** + * Generates src/generated/playground-scope.generated.ts — the module map the + * playground preview uses to resolve imports in user code at runtime. + * + * Reads the `exports` map of @luke-ui/react so new component subpaths are + * picked up automatically. Runs via the `generate:playground` script (wired + * into `docs#generate` in turbo.json), so dev and build always regenerate it. + */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { z } from 'zod'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const reactPackageJsonPath = resolve(scriptDir, '../../../packages/@luke-ui/react/package.json'); +const outputPath = resolve(scriptDir, '../src/generated/playground-scope.generated.ts'); + +const packageJsonSchema = z.object({ + exports: z.record(z.string(), z.string()), +}); + +const reactPackageJson = packageJsonSchema.parse( + JSON.parse(readFileSync(reactPackageJsonPath, 'utf8')), +); + +const lukeUiSpecifiers = Object.keys(reactPackageJson.exports) + .flatMap((key) => { + const target = reactPackageJson.exports[key]; + if (key === './package.json' || target === undefined || !target.endsWith('.js')) return []; + return [`@luke-ui/react/${key.slice(2)}`]; + }) + .sort(); + +const baseSpecifiers = ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime']; +const specifiers = [...lukeUiSpecifiers, ...baseSpecifiers]; + +const SPECIFIER_TO_IDENTIFIER_RE = /^@|[^a-zA-Z0-9]+/g; + +function toIdentifier(specifier: string): string { + return specifier.replace(SPECIFIER_TO_IDENTIFIER_RE, (match) => (match === '@' ? '' : '_')); +} + +const importLines = specifiers.map( + (specifier) => `import * as ${toIdentifier(specifier)} from '${specifier}';`, +); +const entryLines = specifiers.map((specifier) => `\t'${specifier}': ${toIdentifier(specifier)},`); + +const output = `// Generated by scripts/generate-playground-scope.ts — do not edit. +${importLines.join('\n')} + +export const playgroundScope: Record = { +${entryLines.join('\n')} +}; +`; + +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, output); +// oxlint-disable-next-line no-console +console.log( + `generate-playground-scope: wrote ${specifiers.length} modules to src/generated/playground-scope.generated.ts`, +); diff --git a/apps/docs/scripts/generate-playground-types.ts b/apps/docs/scripts/generate-playground-types.ts new file mode 100644 index 00000000..7e8021b4 --- /dev/null +++ b/apps/docs/scripts/generate-playground-types.ts @@ -0,0 +1,151 @@ +/** + * Generates src/generated/playground-types.generated.json — a map of virtual + * `file:///node_modules/...` paths to `.d.ts` contents, fed to Monaco's + * TypeScript worker via `addExtraLib` so the playground editor gets real + * IntelliSense for @luke-ui/react and its type dependencies. + * + * The payload is a few MB raw (loaded lazily, only on /playground). If a + * type-dependency package is dropped from the allowlist below, imports from + * it degrade to `any` in hovers — no user-visible errors. + * + * Runs via the `generate:playground` script (wired into `docs#generate` in + * turbo.json). Requires @luke-ui/react to be built first (dist/*.d.ts). + */ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { z } from 'zod'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const docsPackageJsonPath = resolve(scriptDir, '../package.json'); +const reactPackageDir = resolve(scriptDir, '../../../packages/@luke-ui/react'); +const rainbowSprinklesDir = resolve(scriptDir, '../../../packages/@luke-ui/rainbow-sprinkles'); +const outputPath = resolve(scriptDir, '../src/generated/playground-types.generated.json'); + +const files: Record = {}; + +function virtualPath(packageName: string, relativePath: string): string { + return `file:///node_modules/${packageName}/${relativePath.split(sep).join('/')}`; +} + +function addFile(packageName: string, packageDir: string, filePath: string): void { + files[virtualPath(packageName, relative(packageDir, filePath))] = readFileSync(filePath, 'utf8'); +} + +function walk(dir: string, visit: (filePath: string) => void): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules') continue; + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) walk(entryPath, visit); + else if (entry.isFile()) visit(entryPath); + } +} + +/** + * Resolves an installed package's directory from a dependent package.json. + * Falls back to resolving the main entry and walking up, because some + * packages (e.g. @internationalized/date) do not export ./package.json. + */ +function resolvePackageDir(fromPackageJson: string, packageName: string): string { + const require = createRequire(fromPackageJson); + try { + return dirname(require.resolve(`${packageName}/package.json`)); + } catch { + let dir = dirname(require.resolve(packageName)); + while (dir !== dirname(dir)) { + const packageJsonPath = join(dir, 'package.json'); + if (existsSync(packageJsonPath)) { + const { name } = z + .object({ name: z.string().optional() }) + .parse(JSON.parse(readFileSync(packageJsonPath, 'utf8'))); + if (name === packageName) return dir; + } + dir = dirname(dir); + } + throw new Error(`Could not locate package directory for ${packageName}`); + } +} + +function addTypesPackage(packageName: string, packageDir: string): void { + walk(packageDir, (filePath) => { + if (!filePath.endsWith('.d.ts')) return; + addFile(packageName, packageDir, filePath); + }); + addFile(packageName, packageDir, join(packageDir, 'package.json')); +} + +// @luke-ui/react — dist .d.ts files plus a package.json stub so Monaco's +// bundler-mode resolution can follow the subpath exports map. +const reactPackageJsonSchema = z.object({ + name: z.string(), + exports: z.record(z.string(), z.string()), +}); + +const reactPackageJson = reactPackageJsonSchema.parse( + JSON.parse(readFileSync(join(reactPackageDir, 'package.json'), 'utf8')), +); +const reactDistDir = join(reactPackageDir, 'dist'); +if (!existsSync(reactDistDir)) { + // oxlint-disable-next-line no-console + console.error( + 'generate-playground-types: packages/@luke-ui/react/dist is missing — build it first', + ); + process.exit(1); +} +walk(reactDistDir, (filePath) => { + if (!filePath.endsWith('.d.ts')) return; + addFile('@luke-ui/react', reactPackageDir, filePath); +}); +files[virtualPath('@luke-ui/react', 'package.json')] = JSON.stringify({ + exports: reactPackageJson.exports, + name: reactPackageJson.name, +}); + +// @luke-ui/rainbow-sprinkles ships TypeScript sources, which Monaco consumes directly. +walk(join(rainbowSprinklesDir, 'src'), (filePath) => { + if (!filePath.endsWith('.ts')) return; + addFile('@luke-ui/rainbow-sprinkles', rainbowSprinklesDir, filePath); +}); +addFile( + '@luke-ui/rainbow-sprinkles', + rainbowSprinklesDir, + join(rainbowSprinklesDir, 'package.json'), +); + +// External type dependencies reachable from @luke-ui/react's public types. +// Resolution starts from the package that actually depends on each one, so +// pnpm's strict node_modules layout resolves the correct versions. +const typesReactDir = resolvePackageDir(docsPackageJsonPath, '@types/react'); +const recipesDir = resolvePackageDir(docsPackageJsonPath, '@vanilla-extract/recipes'); +const racDir = resolvePackageDir(docsPackageJsonPath, 'react-aria-components'); +const racPackageJsonPath = join(racDir, 'package.json'); +const externalTypePackages: Array<[string, string]> = [ + ['@types/react', typesReactDir], + ['@types/react-dom', resolvePackageDir(docsPackageJsonPath, '@types/react-dom')], + ['csstype', resolvePackageDir(join(typesReactDir, 'package.json'), 'csstype')], + ['@vanilla-extract/recipes', recipesDir], + [ + '@vanilla-extract/css', + resolvePackageDir(join(recipesDir, 'package.json'), '@vanilla-extract/css'), + ], + ['react-aria-components', racDir], + ['react-aria', resolvePackageDir(racPackageJsonPath, 'react-aria')], + ['react-stately', resolvePackageDir(racPackageJsonPath, 'react-stately')], + ['@react-types/shared', resolvePackageDir(racPackageJsonPath, '@react-types/shared')], + ['@internationalized/date', resolvePackageDir(racPackageJsonPath, '@internationalized/date')], + ['@internationalized/number', resolvePackageDir(racPackageJsonPath, '@internationalized/number')], + ['@internationalized/string', resolvePackageDir(racPackageJsonPath, '@internationalized/string')], +]; +for (const [packageName, packageDir] of externalTypePackages) { + addTypesPackage(packageName, packageDir); +} + +const output = JSON.stringify(files); +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, output); +// oxlint-disable-next-line no-console +console.log( + `generate-playground-types: wrote ${Object.keys(files).length} files (${(output.length / 1024 / 1024).toFixed(1)}MB raw) to src/generated/playground-types.generated.json`, +); diff --git a/apps/docs/src/components/example-block.tsx b/apps/docs/src/components/example-block.tsx index c028375b..a3385687 100644 --- a/apps/docs/src/components/example-block.tsx +++ b/apps/docs/src/components/example-block.tsx @@ -1,8 +1,10 @@ +import { Link } from '@tanstack/react-router'; import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock'; import { buttonVariants } from 'fumadocs-ui/components/ui/button'; -import { CodeIcon } from 'lucide-react'; +import { CodeIcon, SquareArrowOutUpRightIcon } from 'lucide-react'; import type { ComponentType, JSX } from 'react'; import { Suspense, use, useId, useState } from 'react'; +import { encodeCodeHash } from '../lib/playground-hash'; import { StoryWrapper } from '../lib/story-wrapper'; type ExampleProps = { @@ -48,16 +50,27 @@ function ExampleContent({ src, title }: ExampleProps): JSX.Element {
{title} - +
+ + + Open in playground + + +
diff --git a/apps/docs/src/components/playground/editor-skeleton-script.ts b/apps/docs/src/components/playground/editor-skeleton-script.ts new file mode 100644 index 00000000..65b3fa93 --- /dev/null +++ b/apps/docs/src/components/playground/editor-skeleton-script.ts @@ -0,0 +1,52 @@ +/** + * Pre-hydration rewrite of the server-rendered editor skeleton. The server can + * only know the default code (the URL hash never reaches it), so this runs + * before first paint and reshapes the skeleton rows to the `shape` hash param + * of shared playground links. + * + * Compiled by `vp pack` (tsdown, IIFE, minified) into + * `src/generated/editor-skeleton-script.iife.js` during `docs#generate`, then + * inlined via a `?raw` import in `editor-skeleton.tsx` and rendered as an + * inline script immediately after the skeleton root, which + * `document.currentScript.previousElementSibling` relies on. The first row is + * cloned as a template so no row markup is duplicated here, and any unexpected + * input bails out, keeping the default skeleton. Hydration then renders the + * same rows from the decoded hash, so React adopts the rewritten DOM without a + * mismatch — which is also why the bar's style attribute is written as the + * exact string React serializes. + */ +rewriteSkeletonToShape(); + +function rewriteSkeletonToShape(): void { + const shape = /(?:^|&)shape=([\d.,]+)/.exec(location.hash.slice(1))?.[1]; + const root = document.currentScript?.previousElementSibling; + if (!shape || !root) return; + + const rows = root.querySelectorAll('[data-line]'); + const template = rows[0]; + if (!template?.querySelector('[data-line-bar]')) return; + + const fragment = document.createDocumentFragment(); + for (const [index, entry] of shape.split(',').entries()) { + const [indentPart, lengthPart] = entry.split('.'); + const indent = Number(indentPart); + const length = Number(lengthPart); + if (!Number.isFinite(indent) || !Number.isFinite(length)) return; + + const row = template.cloneNode(true) as Element; + const lineNumber = row.querySelector('[data-line-number]'); + const bar = row.querySelector('[data-line-bar]'); + if (!lineNumber || !bar) return; + + lineNumber.textContent = String(index + 1); + if (length > 0) { + bar.setAttribute('style', `--indent:${indent}ch;--length:${length}ch`); + } else { + bar.remove(); + } + fragment.appendChild(row); + } + + root.insertBefore(fragment, template); + for (const row of rows) row.remove(); +} diff --git a/apps/docs/src/components/playground/editor-skeleton.tsx b/apps/docs/src/components/playground/editor-skeleton.tsx new file mode 100644 index 00000000..31e11d33 --- /dev/null +++ b/apps/docs/src/components/playground/editor-skeleton.tsx @@ -0,0 +1,79 @@ +import { LoadingSkeleton } from '@luke-ui/react/loading-skeleton'; +import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; +import shapeScript from '../../generated/editor-skeleton-script.iife.js?raw'; +import { toSkeletonLines } from '../../lib/playground-shape'; + +/** + * Placeholder that mirrors the code the editor is about to show: one bar per + * line, matching each line's indentation and length. Metrics (font, line + * height, gutter, padding) are kept in sync with the Monaco options in + * `editor.tsx` so the swap to the real editor lands every line where its bar + * was. `showPill` is owned by the page so the indicator stays stable while the + * skeleton remounts across loading phases (SSR fallback → Suspense → Monaco + * init). + */ +export function EditorSkeleton({ code, showPill }: { code: string; showPill: boolean }) { + return ( +
+ Loading editor + {toSkeletonLines(code).map((line, index) => ( +
+ + {index + 1} + + + {line.length > 0 ? ( + // Sized via custom properties (not style properties) so + // editor-skeleton-script.js can write byte-identical inline + // styles — camelCase style keys would trip hydration diffing. + + ) : null} + +
+ ))} + {showPill ? ( +
+ +
+ ) : null} +
+ ); +} + +/** + * Rewrites the server-rendered skeleton to the shape of the shared code before + * first paint; see `editor-skeleton-script.ts` (compiled by `vp pack` during + * `docs#generate`, inlined via `?raw`). Render it in the pre-hydration + * fallback only, directly after the skeleton — the script finds its skeleton + * as `document.currentScript.previousElementSibling`. + */ +export function EditorSkeletonShapeScript() { + return ( + // oxlint-disable-next-line react/no-danger -- static, fully inlined script; nothing user-controlled beyond digits. +