diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index cc2207e8..40d9a415 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -48,9 +48,10 @@ notice. ## Changesets -`@luke-ui/react` is unpublished at version `0.0.0`. `apps/docs`, `@luke-ui/rainbow-sprinkles`, and -`@luke-ui/playground-core` are private. Before `1.0.0` no pull request needs a changeset, including -one that moves a runtime or peer dependency. +`@luke-ui/react` is unpublished at version `0.0.0`. `@luke-ui/rainbow-sprinkles` is a publishable +0.x support package used by `@luke-ui/react` at runtime. It is not part of the stable Luke UI 1.x +consumer API. `apps/docs` and `@luke-ui/playground-core` are private. Before `1.0.0` no pull request +needs a changeset, including one that moves a runtime or peer dependency. The `needs-changeset` label in `.github/renovate.json5` is advance notice. It marks packages that will be runtime or peer dependencies of the published package at `1.0.0`. Re-sync that list against diff --git a/docs/STYLING.md b/docs/STYLING.md index ece03d73..19971288 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -340,9 +340,9 @@ return ( ### Styling engine boundary -`@luke-ui/rainbow-sprinkles` is a private implementation package derived from -[Wayfair Rainbow Sprinkles](https://github.com/wayfair/rainbow-sprinkles). Keep it private until -Luke UI's first major release. Reconsider publication at that release. +`@luke-ui/rainbow-sprinkles` is a publishable 0.x support package derived from +[Wayfair Rainbow Sprinkles](https://github.com/wayfair/rainbow-sprinkles). `@luke-ui/react` depends +on it at runtime. It is not part of the stable Luke UI 1.x consumer API. Keep engine authoring and compilation private. If the engine changes, preserve the public contracts: `createSprinkles` passes through own enumerable string-keyed non-utility props, generated diff --git a/knip.config.ts b/knip.config.ts index 47bdf554..3206508f 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -34,6 +34,10 @@ export default { ], project: ['src/**/*.{ts,tsx}'], }, + 'packages/@luke-ui/rainbow-sprinkles': { + entry: ['src/index.ts', 'src/create-runtime-fn.ts', 'vite.config.ts'], + project: ['src/**/*.ts', 'vite.config.ts'], + }, 'packages/@luke-ui/playground-core': { entry: [ 'src/compiler.ts', diff --git a/packages/@luke-ui/rainbow-sprinkles/package.json b/packages/@luke-ui/rainbow-sprinkles/package.json index f46835e6..a1018fbd 100644 --- a/packages/@luke-ui/rainbow-sprinkles/package.json +++ b/packages/@luke-ui/rainbow-sprinkles/package.json @@ -1,17 +1,32 @@ { "name": "@luke-ui/rainbow-sprinkles", "version": "0.0.0", - "private": true, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/lukebennett88/luke-ui.git", + "directory": "packages/@luke-ui/rainbow-sprinkles" + }, + "files": [ + "dist", + "LICENSE" + ], "type": "module", "exports": { - ".": "./src/index.ts", - "./create-runtime-fn": "./src/create-runtime-fn.ts", + ".": "./dist/index.js", + "./create-runtime-fn": "./dist/create-runtime-fn.js", "./package.json": "./package.json" }, + "publishConfig": { + "access": "public" + }, "scripts": { + "build": "vp pack", "check:format": "vp fmt . --check", "check:lint": "vp lint . --type-aware", "check:types": "tsc --noEmit -p tsconfig.json", + "clean": "rm -rf .turbo dist", + "dev": "vp pack --watch", "fix:format": "vp fmt . --write", "fix:lint": "vp lint . --type-aware --fix" }, @@ -22,6 +37,7 @@ "devDependencies": { "@types/node": "catalog:", "csstype": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vite-plus": "catalog:" } } diff --git a/packages/@luke-ui/rainbow-sprinkles/vite.config.ts b/packages/@luke-ui/rainbow-sprinkles/vite.config.ts new file mode 100644 index 00000000..1c2b6775 --- /dev/null +++ b/packages/@luke-ui/rainbow-sprinkles/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite-plus'; +import packageJson from './package.json' with { type: 'json' }; + +export default defineConfig({ + pack: { + deps: { + neverBundle: [...Object.keys(packageJson.dependencies), 'csstype'], + }, + dts: true, + entry: { + index: 'src/index.ts', + 'create-runtime-fn': 'src/create-runtime-fn.ts', + }, + format: ['esm'], + platform: 'neutral', + // Keep modules separate so React's create-runtime-fn import stays a thin dependency. + unbundle: true, + }, +}); diff --git a/packages/@luke-ui/react/src/core/styles/package-exports.test.ts b/packages/@luke-ui/react/src/core/styles/package-exports.test.ts index 78dba674..5a53630b 100644 --- a/packages/@luke-ui/react/src/core/styles/package-exports.test.ts +++ b/packages/@luke-ui/react/src/core/styles/package-exports.test.ts @@ -58,6 +58,24 @@ test('requires react-aria-components as a peer dependency', () => { expect('react-aria-components' in (packageJson.dependencies ?? {})).toBe(false); }); +test('declares @luke-ui/rainbow-sprinkles as a runtime dependency', () => { + expect('@luke-ui/rainbow-sprinkles' in (packageJson.dependencies ?? {})).toBe(true); + expect('@luke-ui/rainbow-sprinkles' in (packageJson.peerDependencies ?? {})).toBe(false); + expect('@luke-ui/rainbow-sprinkles' in (packageJson.devDependencies ?? {})).toBe(false); +}); + +test('sprinkles runtime imports published rainbow-sprinkles create-runtime-fn', async () => { + const source = await readFile(new URL('../../../dist/styles.js', import.meta.url), 'utf8'); + const chunkMatch = /from ["'](\.\/utilities\.css-[^"']+)["']/.exec(source); + expect(chunkMatch?.[1]).toBeTruthy(); + const utilities = await readFile( + new URL(`../../../dist/${chunkMatch![1]!}`, import.meta.url), + 'utf8', + ); + expect(utilities).toContain('@luke-ui/rainbow-sprinkles/create-runtime-fn'); + expect(utilities).not.toContain('#rainbow-sprinkles-runtime'); +}); + test('public JS/TS export declaration closures do not import styling engines', async () => { const entryDeclarations = publicTypeEntryDeclarations(packageJson.exports); expect(entryDeclarations.length).toBeGreaterThan(0); diff --git a/packages/@luke-ui/react/src/core/styles/packed-consumer.test.ts b/packages/@luke-ui/react/src/core/styles/packed-consumer.test.ts index fb8b5035..d1ebe757 100644 --- a/packages/@luke-ui/react/src/core/styles/packed-consumer.test.ts +++ b/packages/@luke-ui/react/src/core/styles/packed-consumer.test.ts @@ -1,119 +1,255 @@ import { execFileSync } from 'node:child_process'; -import { createRequire } from 'node:module'; +import { realpathSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { expect, test } from 'vite-plus/test'; -const require = createRequire(import.meta.url); const packageRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const rainbowPackageRoot = fileURLToPath(new URL('../../../../rainbow-sprinkles', import.meta.url)); +/** + * Packs `@luke-ui/rainbow-sprinkles` and `@luke-ui/react`, then installs both into an isolated + * consumer with npm (no workspace links). Proves SSR, hydration, asset resolution, and that + * sprinkles resolve through the published Rainbow runtime package. + */ test( - 'renders a component from the packed tarball in a plain node process, with no build-time compiler', - { timeout: 60_000 }, + 'installs packed React and Rainbow tarballs with npm and proves SSR, hydration, and assets', + { timeout: 120_000 }, async () => { - const tarballDir = await mkdtemp(path.join(tmpdir(), 'luke-ui-react-pack-')); - const consumerDir = await mkdtemp(path.join(tmpdir(), 'luke-ui-react-consumer-')); + const workDir = await mkdtemp(path.join(tmpdir(), 'luke-ui-react-consumer-')); + const tarballDir = path.join(workDir, 'pack'); + const consumerDir = path.join(workDir, 'consumer'); try { - const tarballName = execFileSync( - 'npm', - ['pack', '--silent', '--pack-destination', tarballDir], - { cwd: packageRoot, encoding: 'utf8' }, - ).trim(); - const tarballPath = path.join(tarballDir, tarballName); - - execFileSync('tar', ['-xzf', tarballPath, '-C', consumerDir]); - const packedPackageRoot = path.join(consumerDir, 'package'); - const packedPackageJson: unknown = JSON.parse( - await readFile(path.join(packedPackageRoot, 'package.json'), 'utf8'), - ); - if ( - !isRecord(packedPackageJson) || - !isRecord(packedPackageJson.dependencies) || - !isRecord(packedPackageJson.peerDependencies) - ) { - throw new Error('Expected packed package.json to define dependency objects.'); + await writeFile(path.join(workDir, 'package.json'), JSON.stringify({ private: true })); + execFileSync('mkdir', ['-p', tarballDir, consumerDir]); + + const rainbowTarballName = packWorkspacePackage(rainbowPackageRoot, tarballDir); + const reactTarballName = packWorkspacePackage(packageRoot, tarballDir); + const rainbowTarballPath = path.join(tarballDir, rainbowTarballName); + const reactTarballPath = path.join(tarballDir, reactTarballName); + + const packedReactJson = readPackedPackageJson(reactTarballPath); + const packedRainbowJson = readPackedPackageJson(rainbowTarballPath); + const reactDependencies = packedReactJson.dependencies; + if (reactDependencies === undefined) { + throw new Error('Expected packed @luke-ui/react to declare dependencies.'); } - const runtimeDependencies = new Set([ - ...Object.keys(packedPackageJson.dependencies), - ...Object.keys(packedPackageJson.peerDependencies), - ]); - const consumerNodeModules = path.join(consumerDir, 'node_modules'); - await mkdir(path.join(consumerNodeModules, '@luke-ui'), { recursive: true }); - await symlink(packedPackageRoot, path.join(consumerNodeModules, '@luke-ui', 'react')); - - await Promise.all( - [...runtimeDependencies].map(async (dependency) => { - const dependencyDir = path.dirname( - require.resolve(`${dependency}/package.json`, { paths: [packageRoot] }), - ); - const linkPath = path.join(consumerNodeModules, dependency); - await mkdir(path.dirname(linkPath), { recursive: true }); - await symlink(dependencyDir, linkPath); - }), - ); + + expect(reactDependencies['@luke-ui/rainbow-sprinkles']).toBeTruthy(); + expect(JSON.stringify(reactDependencies)).not.toContain('catalog:'); + expect(JSON.stringify(reactDependencies)).not.toContain('workspace:'); + expect(JSON.stringify(packedReactJson.peerDependencies ?? {})).not.toContain('catalog:'); + expect(JSON.stringify(packedRainbowJson.dependencies ?? {})).not.toContain('catalog:'); + expect(JSON.stringify(packedRainbowJson.dependencies ?? {})).not.toContain('workspace:'); + expect(packedRainbowJson.private).not.toBe(true); await writeFile( path.join(consumerDir, 'package.json'), - JSON.stringify({ name: 'packed-consumer-fixture', private: true, type: 'module' }), + JSON.stringify( + { + name: 'packed-consumer-fixture', + private: true, + type: 'module', + dependencies: { + '@luke-ui/rainbow-sprinkles': `file:${rainbowTarballPath}`, + '@luke-ui/react': `file:${reactTarballPath}`, + ...packedReactJson.peerDependencies, + jsdom: '^26.1.0', + }, + }, + null, + 2, + ), ); - const renderScript = path.join(consumerDir, 'render.mjs'); - await writeFile( - renderScript, - [ - "import { createElement } from 'react';", - "import { renderToStaticMarkup } from 'react-dom/server';", - "import { Blockquote } from '@luke-ui/react/blockquote';", - "import { breakpoints, createSprinkles } from '@luke-ui/react/styles';", - '', - 'const layout = createSprinkles({ display: "flex", id: "sprinkles-root" });', - 'if (!createSprinkles.properties.has("display")) {', - ' throw new Error("createSprinkles.properties must include display");', - '}', - 'if (layout.id !== "sprinkles-root") {', - ' throw new Error("createSprinkles must pass through non-utility props");', - '}', - 'if (breakpoints.bp768 !== 768) {', - ' throw new Error("breakpoints.bp768 must be 768");', - '}', - '', - "const markup = renderToStaticMarkup(createElement(Blockquote, null, 'Hello world'));", - 'process.stdout.write(JSON.stringify({ markup, layoutId: layout.id, className: layout.className, bp768: breakpoints.bp768 }));', - ].join('\n'), + + execFileSync('npm', ['install', '--ignore-scripts'], { + cwd: consumerDir, + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '', + HOME: process.env.HOME ?? '', + npm_config_cache: process.env.npm_config_cache, + npm_config_registry: process.env.npm_config_registry, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const installedReactRoot = realpathSync( + path.join(consumerDir, 'node_modules', '@luke-ui', 'react'), + ); + const installedRainbowRoot = realpathSync( + path.join(consumerDir, 'node_modules', '@luke-ui', 'rainbow-sprinkles'), + ); + expect(installedReactRoot.startsWith(consumerDir)).toBe(true); + expect(installedRainbowRoot.startsWith(consumerDir)).toBe(true); + expect(installedReactRoot.includes(`${path.sep}packages${path.sep}`)).toBe(false); + expect(installedRainbowRoot.includes(`${path.sep}packages${path.sep}`)).toBe(false); + + const utilitiesSource = await readFile( + path.join(installedReactRoot, 'dist', findUtilitiesChunk(installedReactRoot)), + 'utf8', ); + expect(utilitiesSource).toContain('@luke-ui/rainbow-sprinkles/create-runtime-fn'); + + const probeScript = path.join(consumerDir, 'probe.mjs'); + await writeFile(probeScript, PROBE_SCRIPT); - const output = execFileSync('node', [renderScript], { + const output = execFileSync('node', [probeScript], { cwd: consumerDir, encoding: 'utf8', - // No NODE_PATH or workspace config that could smuggle in a compiler. env: { PATH: process.env.PATH ?? '' }, }); const parsed: unknown = JSON.parse(output); - if ( - !isRecord(parsed) || - typeof parsed.markup !== 'string' || - typeof parsed.layoutId !== 'string' || - typeof parsed.className !== 'string' || - typeof parsed.bp768 !== 'number' - ) { - throw new Error( - 'Expected packed consumer output to include markup, sprinkles, and breakpoint fields.', - ); + if (!isRecord(parsed)) { + throw new Error('Expected packed consumer probe to return a JSON object.'); } - expect(parsed.markup).toContain('
\${markup}\`, { + url: 'http://localhost/', +}); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +globalThis.HTMLElement = dom.window.HTMLElement; +globalThis.SVGElement = dom.window.SVGElement; +globalThis.Node = dom.window.Node; + +const root = document.getElementById('root'); +if (!root) throw new Error('Missing #root'); +const recoverableErrors = []; +hydrateRoot(root, app(), { + onRecoverableError(error) { + recoverableErrors.push(String(error)); + }, +}); +await new Promise((resolve) => setTimeout(resolve, 25)); + +process.stdout.write( + JSON.stringify({ + markup, + layoutId: layout.id, + className: layout.className, + bp768: breakpoints.bp768, + stylesheetBytes: readFileSync(stylesheetPath).byteLength, + spritesheetBytes: readFileSync(spritesheetPath).byteLength, + themeStylesheetBytes: readFileSync(themeStylesheetPath).byteLength, + hydrated: root.innerHTML.includes('Hello world') && root.innerHTML.includes('