diff --git a/.changeset/cli-app-generator-manifest-3827.md b/.changeset/cli-app-generator-manifest-3827.md new file mode 100644 index 0000000000..a875fa9471 --- /dev/null +++ b/.changeset/cli-app-generator-manifest-3827.md @@ -0,0 +1,69 @@ +--- +"@object-ui/cli": patch +--- + +Generated temp apps now declare every package they import, at ranges anchored to this repo + +`objectui dev` / `serve` / `build` write a throwaway app into `/.objectui-tmp`, +and the `package.json` they wrote named neither `lucide-react` nor any of the seven +`@object-ui/plugin-*` packages the generated sources import — while pinning +`@object-ui/react` and `@object-ui/components` at `^0.1.0`, a range that resolves to +nothing at all for packages published at 17.x (the registry has no 0.1.0). Outside +this workspace that manifest could not install; inside it, hoisting to the root +`node_modules` satisfied every missing name, so nothing was ever red. + +**`lucide-react` is now declared** (objectui#3827). Both of its imports in the +generated layout are live — `import * as LucideIcons` feeds a `DynamicIcon` lookup +and four `LucideIcons.*` icons, and the named `{ Moon, Sun }` renders the theme +toggle — so this is the opposite disposition from the sibling generator, where +objectui#3755 removed an equivalent declaration precisely because nothing imported +it. Anchored to `^1.28.0`, the range all 23 in-repo manifests that import lucide +agree on. `commands/dev.ts` had been covering the gap in the consumer, aliasing +`lucide-react` to a path resolved out of `packages/components` "to avoid dependency +not found in temp app" — but only in monorepo mode, leaving every other path with an +unsatisfiable import. The declaration belongs at the producer; the alias is now a +workspace convenience rather than the only thing holding the import up. + +**The seven plugin packages are now declared too**, in both generators. Measuring +the reported defect turned up that `src/App.tsx` side-effect-imports +`@object-ui/plugin-charts`, `-editor`, `-kanban`, `-markdown`, `-form`, `-grid` and +`-view` to register their components, and no manifest ever named them: the +undeclared set was eight packages, not the one the issue reported. + +**`@object-ui/*` ranges are derived from this CLI's own version** instead of being +written out as literals. `.changeset/config.json` puts `@object-ui/cli` in the same +`fixed` group as every platform package a generated app depends on, so they always +publish at one version — which makes `^` both current and guaranteed to +exist on the registry. A literal here is not merely a fossil risk but a fossil +generator: that group re-versions on every release, so any hard-coded range is stale +the next day. This is how `^0.1.0` survived to sit 16 majors behind. + +**The toolchain ranges are anchored to in-repo manifests**, the discipline +objectui#3742/objectui#3754 established: `vite ^5.0.0` → `^8.2.0`, `typescript +~5.7.3` → `^6.0.3`, `@vitejs/plugin-react ^4.2.1` → `^6.0.5`, `react`/`react-dom` +`^18.3.1` → `19.2.8` with `@types/*` to match, `react-router-dom ^7.12.0` → +`^7.18.2`, `postcss ^8.5.6` → `^8.5.26`, `autoprefixer ^10.4.23` → `^10.5.4`. React +quotes the root's installed version rather than the wider `^18 || ^19` the platform +packages accept as a peer: the peer says what can work, the root says what the +generated code has actually run against, and inside this workspace the temp app +resolves React by hoisting to the root. + +`tailwindcss` is deliberately left at `^3.4.19`. This repo is on Tailwind 4 and +`@object-ui/components` peers `^4.2.1`, so the range is not merely behind — it +conflicts. But re-anchoring it is not a version edit: the generated `index.css` uses +v3 directives, the generated `postcss.config.js` names the plugin key v4 moved to +`@tailwindcss/postcss`, and the generated `tailwind.config.js` is a v3 config. Raising +the range without rewriting those three files yields an app that installs and renders +unstyled, which looks fixed and is worse. Filed separately as objectui#3852; kept +internally consistent at v3 until then, and pinned as a deliberate deferral rather +than left to read as drift. + +The generators now build their output as a file map that the writers spill to disk, +so tests assert over the same artifact the CLI writes. Three structural gates port +the ones the sibling generator grew: every bare import must be declared, no versioned +runtime dependency may be declared that nothing imports, and no generated `src/**` +file may be unreachable from `src/main.tsx` — the one module `index.html` loads. Each +is paired with a self-test that plants the defect back. Note for the next port: the +`create-plugin` import scanner matches single-quoted specifiers only, and these +templates mix quote styles, so a verbatim copy would have been blind to +`from "lucide-react"` — one of the two lines this issue reports. diff --git a/packages/cli/src/__tests__/app-generator.test.ts b/packages/cli/src/__tests__/app-generator.test.ts new file mode 100644 index 0000000000..a79b08f4da --- /dev/null +++ b/packages/cli/src/__tests__/app-generator.test.ts @@ -0,0 +1,677 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Pins the manifests the two app generators write (objectui#3827). + * + * `createTempAppWithRouting` generated a layout that imported `lucide-react` + * twice — `import * as LucideIcons` plus a named `{ Moon, Sun }`, both live — + * while the `package.json` beside it declared neither, and the same manifest + * asked for `@object-ui/react`/`@object-ui/components` at `^0.1.0` for packages + * published at 17.x (the registry has no 0.1.0 at all). Measuring it turned up + * five more of the same kind: the seven `@object-ui/plugin-*` side-effect + * imports in `src/App.tsx` were undeclared in BOTH generators. + * + * Nothing was red, because the temp app is created under `` and every + * missing package happened to be hoisted into this repo's root + * `node_modules` — and because `commands/dev.ts` had been papering over the + * lucide half in the consumer, aliasing it to a path resolved out of + * `packages/components`. + * + * The three structural gates below are ports of the ones the sibling generator + * grew (objectui#3733 / objectui#3826). They assert over the SAME file map the + * CLI writes (`buildAppFiles` / `buildRoutedAppFiles`), never over this repo's + * source text: + * + * - every bare import in every generated source must be declared by the + * generated manifest (the objectui#3827 defect, generalised); + * - no versioned runtime dependency may be declared that no generated source + * imports (the reverse direction, from objectui#3755); + * - no generated `src/**` file may be unreachable from `src/main.tsx`, the one + * module `index.html` loads (from objectui#3759). + * + * Each is paired with a self-test that plants the defect back, because a gate + * that is green by producing nothing is not a gate (objectui#3826). Two notes + * where this port differs from its model, both load-bearing: + * + * 1. `create-plugin`'s import scanner matches single-quoted specifiers only — + * every template it guards is single-quoted. These templates are NOT: the + * generated layout writes `from "lucide-react"` and `from "./theme-provider"` + * with double quotes, and `src/theme-provider.tsx` imports `"react"` the same + * way. Copying that regex verbatim would have left the gate blind to one of + * the exact two lines objectui#3827 reports, so `importedPackagesOf` is + * quote-agnostic and a test below pins that it sees both forms. + * 2. Neither the unused-declaration gate nor the reachability gate is vacuous + * here (13 runtime ranges and 6 generated files are really judged), unlike + * `create-plugin` where both passed over empty sets. The self-tests are kept + * anyway — a non-empty input proves the rule ran, not that it has teeth. + */ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { isBuiltin } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + buildAppFiles, + buildAppPackageJson, + buildRoutedAppFiles, + buildRoutedAppPackageJson, + createTempApp, + createTempAppWithRouting, + type AppGeneratorContext, + type RouteInfo +} from '../utils/app-generator.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +/** packages/cli/src/__tests__ -> repo root */ +const REPO_ROOT = resolve(__dirname, '../../../..'); +const CLI_MANIFEST_PATH = resolve(REPO_ROOT, 'packages/cli/package.json'); + +/** + * The context of a generated app that must really install its dependencies. + * + * `isMonorepo: false` is the case the manifests exist FOR. Inside a workspace + * `createTempApp` writes both maps empty and `commands/dev.ts` skips + * `npm install` altogether, so the ranges are inert there — which is precisely + * why they fossilised unnoticed (objectui#3742's second cost: the declared + * version is never the tested one). Every gate below judges the installable + * manifest. + */ +const STANDALONE: AppGeneratorContext = { cwd: '/tmp/objectui-app', isMonorepo: false }; +const IN_WORKSPACE: AppGeneratorContext = { cwd: REPO_ROOT, isMonorepo: true }; + +const SCHEMA = { type: 'page', body: [{ type: 'text', text: 'hello' }] }; + +const ROUTES: RouteInfo[] = [ + { path: '/', filePath: '/app/pages/index.json', schema: SCHEMA, isDynamic: false }, + { + path: '/users/:id', + filePath: '/app/pages/users/[id].json', + schema: { type: 'page', body: [] }, + isDynamic: true, + paramName: 'id' + } +]; + +/** An `app.json` that makes the routed generator emit `src/Layout.tsx`. */ +const APP_CONFIG = { + title: 'Demo', + logo: 'Flame', + menu: [{ label: 'Home', path: '/' }, { label: 'Users', children: [{ label: 'All', path: '/users' }] }] +}; + +type Manifest = { + name?: string; + version?: string; + dependencies?: Record; + devDependencies?: Record; +}; + +function readManifest(path: string): Manifest { + return JSON.parse(readFileSync(path, 'utf-8')) as Manifest; +} + +/** + * Package names a generated source file imports, excluding relative specifiers. + * + * Quote-agnostic on purpose — see note 1 in the file header. Covers the + * side-effect form (`import '@object-ui/plugin-grid';`), which is how all seven + * plugins enter, and folds a subpath back onto its package so + * `react-dom/client` is checked against `react-dom`. Builtins are dropped. + */ +function importedPackagesOf(source: string): string[] { + const packages = new Set(); + for (const match of source.matchAll( + /(?:^|\n)\s*(?:import|export)\s+(?:[^;'"]*?from\s+)?['"]([^'"]+)['"]/g + )) { + const specifier = match[1]; + if (specifier.startsWith('.') || specifier.startsWith('/')) continue; + if (isBuiltin(specifier)) continue; + const segments = specifier.split('/'); + packages.add(specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]); + } + return [...packages].sort(); +} + +const isGeneratedSource = (path: string) => /^src\/.*\.tsx?$/.test(path); + +/** Packages imported by generated sources but absent from the manifest. */ +function undeclaredImports( + manifest: Record, + files: Record +): string[] { + const declared = new Set([ + ...Object.keys((manifest.dependencies ?? {}) as Record), + ...Object.keys((manifest.devDependencies ?? {}) as Record) + ]); + const missing = new Set(); + for (const [path, contents] of Object.entries(files)) { + if (!isGeneratedSource(path)) continue; + for (const pkg of importedPackagesOf(contents)) { + if (!declared.has(pkg)) missing.add(pkg); + } + } + return [...missing].sort(); +} + +/** + * Runtime dependencies pinned to a version that no generated source imports. + * + * The other direction of the gate above, ported from objectui#3755. There are + * no `workspace:*` ranges here to exempt — a temp app is not a workspace + * member — so every runtime declaration is judged. + */ +function unusedVersionedDependencies( + dependencies: Record, + files: Record +): string[] { + const imported = new Set(); + for (const [path, contents] of Object.entries(files)) { + if (!isGeneratedSource(path)) continue; + for (const pkg of importedPackagesOf(contents)) imported.add(pkg); + } + return Object.entries(dependencies) + .filter(([name, range]) => !range.startsWith('workspace:') && !imported.has(name)) + .map(([name]) => name) + .sort(); +} + +/** + * Generated `src/**` files not reachable from `src/main.tsx`. + * + * objectui#3759's criterion, retargeted: `index.html` loads exactly one module + * (`/src/main.tsx`), so that is the app's only entry and anything the entry + * graph does not reach is dead weight shipped into the temp dir. Judged over + * every `src/**` file rather than just modules — a schema JSON written but + * never imported would be a routed page that does not exist. + */ +function unreachableGeneratedFiles(files: Record): string[] { + const resolveRelative = (fromPath: string, specifier: string): string | undefined => { + const fromDir = fromPath.slice(0, fromPath.lastIndexOf('/')); + const stack: string[] = []; + for (const segment of `${fromDir}/${specifier}`.split('/')) { + if (segment === '.' || segment === '') continue; + if (segment === '..') stack.pop(); + else stack.push(segment); + } + const base = stack.join('/'); + return [base, `${base}.tsx`, `${base}.ts`, `${base}/index.tsx`, `${base}/index.ts`].find( + (candidate) => files[candidate] !== undefined + ); + }; + + const reached = new Set(); + const queue = ['src/main.tsx']; + while (queue.length > 0) { + const current = queue.pop() as string; + if (reached.has(current) || files[current] === undefined) continue; + reached.add(current); + for (const match of files[current].matchAll( + /(?:^|\n)\s*(?:import|export)\s+(?:[^;'"]*?from\s+)?['"](\.[^'"]*)['"]/g + )) { + const target = resolveRelative(current, match[1]); + if (target !== undefined) queue.push(target); + } + } + + return Object.keys(files) + .filter((path) => path.startsWith('src/') && !reached.has(path)) + .sort(); +} + +/** Every in-repo manifest: the root plus every direct child of the three groups. */ +function inRepoManifestPaths(): string[] { + const paths = [resolve(REPO_ROOT, 'package.json')]; + for (const group of ['packages', 'apps', 'examples']) { + const dir = resolve(REPO_ROOT, group); + if (!existsSync(dir)) continue; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = join(dir, entry.name, 'package.json'); + if (existsSync(candidate)) paths.push(candidate); + } + } + return paths; +} + +/** The range `name` is declared at in the root manifest, if at all. */ +function rootRangeOf(name: string): string | undefined { + const manifest = readManifest(resolve(REPO_ROOT, 'package.json')); + return manifest.dependencies?.[name] ?? manifest.devDependencies?.[name]; +} + +/** + * Ranges every non-root in-repo manifest declares for `name`, keyed by range. + * + * `peerDependencies` are excluded deliberately: a peer says what a library + * ACCEPTS (`react` at `^18.0.0 || ^19.0.0`, `react-router-dom` at + * `^6.0.0 || ^7.0.0`), which is a different fact from the single version this + * repo installs and tests with — and a generated app has to name the latter. + */ +function inRepoRangesOf(name: string): Record { + const byRange: Record = {}; + const rootPath = resolve(REPO_ROOT, 'package.json'); + for (const path of inRepoManifestPaths()) { + if (path === rootPath) continue; + const manifest = readManifest(path); + const range = manifest.dependencies?.[name] ?? manifest.devDependencies?.[name]; + if (range === undefined) continue; + (byRange[range] ??= []).push(relative(REPO_ROOT, path)); + } + return byRange; +} + +/** + * Where each range in the two generated manifests must come from. + * + * The anchoring discipline objectui#3742/objectui#3754 established: one range + * per dependency in this repo, quoted rather than invented, so bumping an + * in-repo manifest and leaving a generator behind fails a test instead of + * shipping. These literals live in `.ts` source, outside the objectui#3711 + * version-claims gate's scan face, so this map is the only gate they have. + * + * - `root` — the repo root declares it; the generated range must match. + * - `in-repo` — the root does not, but sibling manifests do, unanimously. + * - `cli-version` — derived from this CLI's own version at generation time, not + * a literal at all (see `platformPackageRange` in `app-generator.ts`). + * - `deferred-tailwind-v4` — see `TAILWIND_V3_DEFERRED` below. + */ +const DEPENDENCY_ANCHORS: Record< + string, + 'root' | 'in-repo' | 'cli-version' | 'deferred-tailwind-v4' +> = { + '@object-ui/components': 'cli-version', + '@object-ui/plugin-charts': 'cli-version', + '@object-ui/plugin-editor': 'cli-version', + '@object-ui/plugin-form': 'cli-version', + '@object-ui/plugin-grid': 'cli-version', + '@object-ui/plugin-kanban': 'cli-version', + '@object-ui/plugin-markdown': 'cli-version', + '@object-ui/plugin-view': 'cli-version', + '@object-ui/react': 'cli-version', + '@types/react': 'root', + '@types/react-dom': 'root', + '@vitejs/plugin-react': 'in-repo', + autoprefixer: 'root', + 'lucide-react': 'in-repo', + postcss: 'in-repo', + react: 'root', + 'react-dom': 'root', + 'react-router-dom': 'root', + tailwindcss: 'deferred-tailwind-v4', + typescript: 'root', + vite: 'root' +}; + +/** + * The Tailwind entries this PR deliberately does NOT re-anchor, and why. + * + * This repo is on Tailwind 4 (`^4.3.3` at the root, `@tailwindcss/postcss` in + * every in-repo `postcss.config.js`) and `@object-ui/components` declares + * `tailwindcss: ^4.2.1` as a PEER — so `^3.4.19` here is not merely drift, it + * conflicts with a peer of a package the generated app depends on. + * + * Anchoring it is still not a version edit: the generated `src/index.css` uses + * v3 directives (`@tailwind base;`), the generated `postcss.config.js` names + * the `tailwindcss` plugin key that v4 moved to `@tailwindcss/postcss`, and the + * generated `tailwind.config.js` is a v3 config whose `content` globs became + * `@source` in v4. Bumping the range without rewriting those three files + * produces an app that installs and renders unstyled — a worse failure than the + * honest v3 trio, because it looks fixed. Filed as objectui#3852 with the + * measurements; kept internally consistent at v3 until then. + */ +const TAILWIND_V3_DEFERRED = ['tailwindcss']; + +function dependenciesOf(manifest: Record): Record { + return (manifest.dependencies ?? {}) as Record; +} + +function allRangesOf(manifest: Record): Record { + return { + ...((manifest.dependencies ?? {}) as Record), + ...((manifest.devDependencies ?? {}) as Record) + }; +} + +const plainFiles = () => buildAppFiles(SCHEMA, STANDALONE); +const routedFiles = () => buildRoutedAppFiles(ROUTES, APP_CONFIG, STANDALONE); +const routedFilesNoConfig = () => buildRoutedAppFiles(ROUTES, undefined, STANDALONE); + +describe('generated app manifests', () => { + it('declares every package the generated sources import', () => { + // objectui#3827, generalised over both generators and every generated file. + // + // One assertion over all three shapes rather than three in a row: a failing + // `expect` ends the test, so sequential assertions would report only the + // first shape and hide the rest. Reverting the fix has to name `lucide-react` + // — the reported defect, which lives in the routed layout — and not just + // whichever shape happens to be checked first. + expect({ + plain: undeclaredImports(buildAppPackageJson(STANDALONE), plainFiles()), + routed: undeclaredImports(buildRoutedAppPackageJson(), routedFiles()), + routedWithoutAppConfig: undeclaredImports( + buildRoutedAppPackageJson(), + routedFilesNoConfig() + ) + }).toEqual({ plain: [], routed: [], routedWithoutAppConfig: [] }); + }); + + it('names every dependency the pre-fix routed manifest was missing', () => { + // The reverse verification, direction predicted before running: restoring + // the exact `dependencies` map that shipped before objectui#3827 must make + // the gate RED, naming all eight undeclared packages — `lucide-react` (the + // reported defect, imported twice in `src/Layout.tsx`) plus the seven + // plugin side-effect imports in `src/App.tsx` that the issue had not + // noticed. Eight, not one, is the measured size of the defect. + const preFix = { + dependencies: { + react: '^18.3.1', + 'react-dom': '^18.3.1', + 'react-router-dom': '^7.12.0', + '@object-ui/react': '^0.1.0', + '@object-ui/components': '^0.1.0' + }, + devDependencies: {} + }; + expect(undeclaredImports(preFix, routedFiles())).toEqual([ + '@object-ui/plugin-charts', + '@object-ui/plugin-editor', + '@object-ui/plugin-form', + '@object-ui/plugin-grid', + '@object-ui/plugin-kanban', + '@object-ui/plugin-markdown', + '@object-ui/plugin-view', + 'lucide-react' + ]); + }); + + it('sees double-quoted and side-effect imports, not only the single-quoted form', () => { + // Note 1 in the file header, pinned. `create-plugin`'s scanner is + // single-quote-only because its templates are; the generated layout here + // writes `from "lucide-react"` — one of the two lines objectui#3827 + // reports — and `src/theme-provider.tsx` imports `"react"` the same way. + // A single-quote-only port would have been blind to exactly the defect. + const layout = routedFiles()['src/Layout.tsx']; + expect(layout).toContain(`import * as LucideIcons from 'lucide-react';`); + expect(layout).toContain(`import { Moon, Sun } from "lucide-react"`); + expect(importedPackagesOf(layout)).toContain('lucide-react'); + expect(importedPackagesOf(routedFiles()['src/theme-provider.tsx'])).toEqual(['react']); + // The side-effect form the seven plugins arrive by. + expect(importedPackagesOf(`import '@object-ui/plugin-grid';\n`)).toEqual([ + '@object-ui/plugin-grid' + ]); + }); + + it('declares no versioned runtime dependency the generated sources never import', () => { + // objectui#3755's direction. Not vacuous here: 13 routed runtime ranges and + // 9 plain ones are judged, all of them really imported. + expect(unusedVersionedDependencies(dependenciesOf(buildRoutedAppPackageJson()), routedFiles())) + .toEqual([]); + expect( + unusedVersionedDependencies(dependenciesOf(buildAppPackageJson(STANDALONE)), plainFiles()) + ).toEqual([]); + }); + + it('catches an unused versioned runtime dependency when one is present', () => { + // Self-test. `lucide-react` is live in the ROUTED app (icons in the + // layout) and imported nowhere in the plain one, so planting it into the + // plain manifest is the real shape of the objectui#3755 defect rather than + // an invented one — and it is why the fix here DECLARES lucide instead of + // deleting the import the way the sibling generator did. + const withUnused = { + ...dependenciesOf(buildAppPackageJson(STANDALONE)), + 'lucide-react': '^1.28.0' + }; + expect(unusedVersionedDependencies(withUnused, plainFiles())).toEqual(['lucide-react']); + }); + + it('keeps both generated dependency maps under one anchor table', () => { + // Completeness: a range added to either generator without naming its anchor + // fails here, which is what kept the eight fossils invisible before. + const declared = new Set([ + ...Object.keys(allRangesOf(buildAppPackageJson(STANDALONE))), + ...Object.keys(allRangesOf(buildRoutedAppPackageJson())) + ]); + expect([...declared].sort()).toEqual(Object.keys(DEPENDENCY_ANCHORS).sort()); + }); + + it('sources every range from this repo instead of inventing one', () => { + const routed = allRangesOf(buildRoutedAppPackageJson()); + const plain = allRangesOf(buildAppPackageJson(STANDALONE)); + const cliVersion = readManifest(CLI_MANIFEST_PATH).version as string; + + for (const [name, anchor] of Object.entries(DEPENDENCY_ANCHORS)) { + const generated = routed[name] ?? plain[name]; + expect(generated, `${name} must be declared by at least one generator`).toBeTruthy(); + + if (anchor === 'deferred-tailwind-v4') { + expect(TAILWIND_V3_DEFERRED).toContain(name); + continue; + } + + if (anchor === 'cli-version') { + expect(generated, `${name} must track this CLI's own version`).toBe(`^${cliVersion}`); + continue; + } + + if (anchor === 'root') { + const rootRange = rootRangeOf(name); + expect(rootRange, `${name} must exist in the root manifest`).toBeTruthy(); + expect(generated, `${name} must match the repo root`).toBe(rootRange); + continue; + } + + const byRange = inRepoRangesOf(name); + const ranges = Object.keys(byRange); + expect(ranges.length, `${name} must be declared in-repo to anchor to`).toBeGreaterThan(0); + expect( + ranges.sort(), + `in-repo manifests disagree on ${name}: ${JSON.stringify(byRange)} — settle on one range first` + ).toHaveLength(1); + expect(generated, `${name} must match its in-repo range`).toBe(ranges[0]); + } + }); + + it('keeps the root and in-repo anchors consistent wherever both declare one', () => { + // Makes the anchor CHOICE non-load-bearing, as objectui#3826 did: anything + // declared both places must already agree, so reading one instead of the + // other cannot hide a drift. + for (const [name, anchor] of Object.entries(DEPENDENCY_ANCHORS)) { + if (anchor === 'cli-version' || anchor === 'deferred-tailwind-v4') continue; + const rootRange = rootRangeOf(name); + if (rootRange === undefined) continue; + for (const [range, manifests] of Object.entries(inRepoRangesOf(name))) { + expect( + range, + `${name} is ${rootRange} at the root but ${range} in ${manifests.join(', ')}` + ).toBe(rootRange); + } + } + }); + + it('pins the release lockstep that lets the platform range be derived', () => { + // The premise `cli-version` rests on. `@object-ui/cli` and every platform + // package the generated app declares sit in ONE `fixed` changeset group, so + // they always publish at the same version and `^` is both + // current and guaranteed to exist on the registry. If that group were ever + // split, deriving the range would silently start naming versions that were + // never published — so the premise is asserted, not assumed. + const changesetConfig = JSON.parse( + readFileSync(resolve(REPO_ROOT, '.changeset/config.json'), 'utf-8') + ) as { fixed?: string[][] }; + const cliVersion = readManifest(CLI_MANIFEST_PATH).version as string; + const platformPackages = Object.entries(DEPENDENCY_ANCHORS) + .filter(([, anchor]) => anchor === 'cli-version') + .map(([name]) => name); + + const group = (changesetConfig.fixed ?? []).find((entry) => entry.includes('@object-ui/cli')); + expect(group, '@object-ui/cli must belong to a fixed group').toBeTruthy(); + for (const name of platformPackages) { + expect(group, `${name} must be released in lockstep with the CLI`).toContain(name); + // And the lockstep is real today, not merely configured. + const dir = name.replace('@object-ui/', ''); + expect( + readManifest(resolve(REPO_ROOT, 'packages', dir, 'package.json')).version, + `${name} must currently sit at the CLI's version` + ).toBe(cliVersion); + } + }); + + it('declares the tailwind trio at v3 deliberately, not by drift', () => { + // The deferral is an explicit, reviewed act: the generated CSS pipeline is + // v3 end to end, so the range matches the files beside it. Re-anchoring it + // means migrating those files (objectui#3852). Adding a second deferred + // entry has to edit this list. + expect(TAILWIND_V3_DEFERRED).toEqual(['tailwindcss']); + expect(allRangesOf(buildRoutedAppPackageJson()).tailwindcss).toBe('^3.4.19'); + expect(routedFiles()['src/index.css']).toContain('@tailwind base;'); + expect(routedFiles()['postcss.config.js']).toContain('tailwindcss: {}'); + // And the conflict this leaves standing, named rather than hidden: the + // components package the generated app depends on peers Tailwind 4. + const components = JSON.parse( + readFileSync(resolve(REPO_ROOT, 'packages/components/package.json'), 'utf-8') + ) as { peerDependencies?: Record }; + expect(components.peerDependencies?.tailwindcss).toBe('^4.2.1'); + }); + + it('writes both maps empty inside a workspace, as before', () => { + // Pre-existing behaviour, pinned so it cannot quietly become a partial + // list: a half-declared manifest would be the objectui#3827 defect again. + // Note the routed generator has no such branch — it always writes the full + // manifest, which is why its missing declarations were missing everywhere. + expect(buildAppPackageJson(IN_WORKSPACE).dependencies).toEqual({}); + expect(buildAppPackageJson(IN_WORKSPACE).devDependencies).toEqual({}); + expect(dependenciesOf(buildRoutedAppPackageJson())['@object-ui/react']).toBeTruthy(); + }); +}); + +describe('generated app file maps', () => { + it('writes no file unreachable from the entry index.html loads', () => { + expect(unreachableGeneratedFiles(plainFiles())).toEqual([]); + expect(unreachableGeneratedFiles(routedFiles())).toEqual([]); + expect(unreachableGeneratedFiles(routedFilesNoConfig())).toEqual([]); + }); + + it('pins that src/main.tsx is the entry the rule measures from', () => { + // The premise the gate rests on. If `index.html` ever loaded a different + // module, reachability-from-`main.tsx` would stop being the criterion. + for (const files of [plainFiles(), routedFiles()]) { + expect(files['index.html']).toContain(''); + expect(files['src/main.tsx']).toBeTruthy(); + } + }); + + it('reports the layout as unreachable when App.tsx stops importing it', () => { + // Self-test for the gate above. `src/Layout.tsx` is written only when an + // `appConfig` is present — exactly when `src/App.tsx` imports it — so + // stripping that import reproduces the objectui#3759 shape here: a file + // written into the temp app that no consumer can reach. + const files = routedFiles(); + expect(files['src/Layout.tsx']).toBeTruthy(); + const strippedApp = files['src/App.tsx'].replace(`import AppLayout from './Layout';\n`, ''); + expect(strippedApp).not.toContain(`from './Layout'`); + expect(unreachableGeneratedFiles({ ...files, 'src/App.tsx': strippedApp })).toEqual([ + 'src/Layout.tsx' + ]); + }); + + it('reports an orphaned route schema, not just an orphaned module', () => { + // Why the rule judges every `src/**` file and not only `.tsx?`: a schema + // written without a matching import is a page the generated router never + // serves. Nothing produces that today; the rule has to be able to see it. + const files = routedFiles(); + expect( + unreachableGeneratedFiles({ ...files, 'src/schemas/page9.json': '{}' }) + ).toEqual(['src/schemas/page9.json']); + }); + + it('keeps every generated path inside the temp app directory', () => { + // The generator joins these onto a tmpdir, so a `..` segment would escape. + for (const files of [plainFiles(), routedFiles()]) { + for (const path of Object.keys(files)) { + expect(path.split('/')).not.toContain('..'); + expect(path.startsWith('/')).toBe(false); + } + } + }); +}); + +describe('generation onto disk', () => { + /** + * Runs the real `createTempApp*` entry points into a throwaway directory. + * + * The builders above are only worth asserting over if the writers really + * write them, so this closes that gap: every file on disk must be + * byte-identical to the map, with nothing extra. It deliberately does NOT + * install anything — inside this workspace hoisting satisfies a missing + * declaration, so a successful install would prove nothing about the + * manifest, which is the whole lesson of objectui#3827. + */ + function withTempDir(run: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), 'objectui-appgen-3827-')); + try { + run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + function filesOnDisk(dir: string): Record { + const out: Record = {}; + const walk = (current: string) => { + for (const entry of readdirSync(current)) { + const full = join(current, entry); + if (statSync(full).isDirectory()) walk(full); + else out[relative(dir, full).split('\\').join('/')] = readFileSync(full, 'utf-8'); + } + }; + walk(dir); + return out; + } + + const contextOfCurrentProcess = (): AppGeneratorContext => ({ + cwd: process.cwd(), + isMonorepo: existsSync(join(process.cwd(), 'pnpm-workspace.yaml')) + }); + + it('writes exactly the plain app file map, byte for byte', () => { + withTempDir((dir) => { + createTempApp(dir, SCHEMA); + expect(filesOnDisk(dir)).toEqual(buildAppFiles(SCHEMA, contextOfCurrentProcess())); + }); + }); + + it('writes exactly the routed app file map, byte for byte', () => { + withTempDir((dir) => { + createTempAppWithRouting(dir, ROUTES, APP_CONFIG); + const onDisk = filesOnDisk(dir); + expect(onDisk).toEqual(buildRoutedAppFiles(ROUTES, APP_CONFIG, contextOfCurrentProcess())); + // The nested schema directory really lands, rather than being flattened. + expect(Object.keys(onDisk)).toContain('src/schemas/page0.json'); + expect(JSON.parse(onDisk['src/schemas/page0.json'])).toEqual(SCHEMA); + }); + }); + + it('writes a manifest whose @object-ui ranges name this CLI version', () => { + // The end-to-end form of the objectui#3827 fossil: `^0.1.0` for packages + // published at 17.x resolved to nothing at all. + withTempDir((dir) => { + createTempAppWithRouting(dir, ROUTES, APP_CONFIG); + const manifest = readManifest(join(dir, 'package.json')); + const cliVersion = readManifest(CLI_MANIFEST_PATH).version as string; + for (const [name, range] of Object.entries(manifest.dependencies ?? {})) { + if (!name.startsWith('@object-ui/')) continue; + expect(range, `${name} in the written manifest`).toBe(`^${cliVersion}`); + } + expect(manifest.dependencies?.['lucide-react']).toBe('^1.28.0'); + }); + }); +}); diff --git a/packages/cli/src/utils/app-generator.ts b/packages/cli/src/utils/app-generator.ts index bdb748baae..02c332522b 100644 --- a/packages/cli/src/utils/app-generator.ts +++ b/packages/cli/src/utils/app-generator.ts @@ -7,7 +7,8 @@ */ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from 'fs'; -import { join } from 'path'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; import chalk from 'chalk'; import * as yaml from 'js-yaml'; @@ -110,7 +111,353 @@ export function scanPagesDirectory(pagesDir: string): RouteInfo[] { return routes; } +/** This package's own name, used to locate its manifest for the platform range. */ +const CLI_PACKAGE_NAME = '@object-ui/cli'; + +/** + * `@object-ui/*` packages the generated apps IMPORT, and therefore must declare. + * + * Every entry is imported by a generated `src/App.tsx` — the two platform + * packages by name, the seven plugins as side-effect imports that register + * their components with the registry. Until objectui#3827 only the first two + * were declared, so a generated app asked npm for nine packages having named + * two of them; the seven plugins resolved in this workspace purely because the + * temp app is created under `` and hoisting reached the root + * `node_modules`. + * + * `@object-ui/core` and `@object-ui/types` are deliberately absent: the + * generated sources never import them (only `commands/dev.ts` aliases them for + * Vite), and declaring a versioned dependency nothing imports is the defect + * objectui#3755 removed from the sibling generator. `app-generator.test.ts` + * gates both directions. + */ +const PLATFORM_RUNTIME_PACKAGES = [ + '@object-ui/react', + '@object-ui/components', + '@object-ui/plugin-charts', + '@object-ui/plugin-editor', + '@object-ui/plugin-kanban', + '@object-ui/plugin-markdown', + '@object-ui/plugin-form', + '@object-ui/plugin-grid', + '@object-ui/plugin-view' +] as const; + +/** Memoised so the manifest walk happens at most once per process. */ +let cachedCliVersion: string | undefined; + +/** + * This CLI's own version, read from its own `package.json`. + * + * Walks up from this module rather than joining a fixed `../package.json`, + * because the same source sits at `src/utils/` in the repo and is bundled into + * `dist/` when published — a relative depth that is correct in one is wrong in + * the other. + */ +function cliVersion(): string { + if (cachedCliVersion !== undefined) return cachedCliVersion; + + let dir = dirname(fileURLToPath(import.meta.url)); + for (;;) { + const candidate = join(dir, 'package.json'); + if (existsSync(candidate)) { + const manifest = JSON.parse(readFileSync(candidate, 'utf-8')) as { + name?: string; + version?: string; + }; + if (manifest.name === CLI_PACKAGE_NAME && typeof manifest.version === 'string') { + cachedCliVersion = manifest.version; + return cachedCliVersion; + } + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + + throw new Error( + `Could not locate the ${CLI_PACKAGE_NAME} manifest to version the generated app's ` + + `platform dependencies against. Refusing to write a package.json with a guessed range.` + ); +} + +/** + * The range the generated apps declare for every `@object-ui/*` dependency. + * + * Derived from this CLI's own version instead of being written out as a + * literal, because `.changeset/config.json` puts `@object-ui/cli` in the SAME + * `fixed` group as every package in `PLATFORM_RUNTIME_PACKAGES`: they are + * released together and always carry the identical version. So `^` is both current and guaranteed to exist on the registry — whatever + * CLI version a user is running was published alongside its siblings. + * + * The literal it replaces was `^0.1.0` for packages published at 17.x, which + * never resolved to any published version at all (objectui#3827; the registry + * has no 0.1.0 for `@object-ui/react`). A literal here is not merely a fossil + * risk, it is a fossil generator: the fixed group re-versions on every release, + * so any hard-coded range is stale the next day. Deriving it removes the class. + */ +function platformPackageRange(): string { + return `^${cliVersion()}`; +} + +/** + * React's range in both generated manifests. + * + * Quotes the repo root verbatim (an exact pin there) rather than the wider + * `^18.0.0 || ^19.0.0` the platform packages accept as a peer. The peer range + * says what CAN work; this says what is actually exercised — inside this + * workspace the temp app resolves React by hoisting to the root, so the root's + * version is the only one the generated code has ever run against. Declaring + * `^18.3.1`, as it did until objectui#3827, named a major nothing here tests. + */ +const REACT_RANGE = '19.2.8'; + +/** + * `dependencies` for an app generated WITHOUT routing (`createTempApp`). + * + * Exactly the packages `src/main.tsx` and `src/App.tsx` import. No + * `react-router-dom` and no `lucide-react`: this variant generates neither a + * router nor a layout. + */ +function buildAppDependencies(): Record { + const range = platformPackageRange(); + return { + react: REACT_RANGE, + 'react-dom': REACT_RANGE, + ...Object.fromEntries(PLATFORM_RUNTIME_PACKAGES.map((name) => [name, range])) + }; +} + +/** + * `dependencies` for a routed app (`createTempAppWithRouting`). + * + * Adds the two packages the routed variant's own sources import and the plain + * one's do not: `react-router-dom` (router in `src/App.tsx`) and `lucide-react` + * (icons in `src/Layout.tsx`). + * + * `lucide-react` was imported and never declared until objectui#3827 — twice + * over, `import * as LucideIcons` plus a named `{ Moon, Sun }`, both live in + * the generated layout. `commands/dev.ts` had been papering over it in the + * consumer, aliasing `lucide-react` to a path resolved out of + * `packages/components` with the comment "avoid dependency not found in temp + * app"; that alias only runs in monorepo mode, so every other path was left + * with an unsatisfiable import. Declaring it at the producer is the fix — the + * alias becomes a workspace convenience rather than the only thing holding the + * import up. + */ +function buildRoutedAppDependencies(): Record { + const range = platformPackageRange(); + return { + react: REACT_RANGE, + 'react-dom': REACT_RANGE, + 'react-router-dom': '^7.18.2', + 'lucide-react': '^1.28.0', + ...Object.fromEntries(PLATFORM_RUNTIME_PACKAGES.map((name) => [name, range])) + }; +} + + +/** + * `devDependencies` shared by both generated apps (identical in both today). + * + * Every range is anchored to an in-repo manifest, and + * `app-generator.test.ts`'s `DEV_DEPENDENCY_ANCHORS` names the anchor for each + * one and fails on an unanchored addition. The three Tailwind-side entries are + * deliberately NOT anchored to the repo's Tailwind 4 — see + * `TAILWIND_V3_DEFERRED` in that test file, and objectui#3852. + */ +const APP_DEV_DEPENDENCIES: Record = { + '@types/react': '19.2.18', + '@types/react-dom': '19.2.4', + '@vitejs/plugin-react': '^6.0.5', + autoprefixer: '^10.5.4', + postcss: '^8.5.26', + tailwindcss: '^3.4.19', + typescript: '^6.0.3', + vite: '^8.2.0' +}; + +/** The generated `tsconfig.json`, identical for both generators. */ +const APP_TSCONFIG = { + compilerOptions: { + target: 'ES2020', + useDefineForClassFields: true, + lib: ['ES2020', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + skipLibCheck: true, + moduleResolution: 'bundler', + allowImportingTsExtensions: true, + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noFallthroughCasesInSwitch: true + }, + include: ['src'] +}; + +/** The generated `postcss.config.js`, identical for both generators. */ +const APP_POSTCSS_CONFIG = `export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +};`; + +/** + * Where the generator is running, as far as the generated files are concerned. + * + * Passed in rather than read from `process.cwd()` inside the builders so the + * file map is a pure function of its inputs and can be asserted over directly. + */ +export interface AppGeneratorContext { + /** The directory `objectui` was invoked from. */ + cwd: string; + /** Whether `cwd` is a pnpm workspace root (root `node_modules` is reachable). */ + isMonorepo: boolean; +} + +/** The context the CLI commands themselves generate under. */ +function currentContext(): AppGeneratorContext { + const cwd = process.cwd(); + return { cwd, isMonorepo: existsSync(join(cwd, 'pnpm-workspace.yaml')) }; +} + +/** + * The generated `package.json` for an app without routing. + * + * In a monorepo both maps are written EMPTY, which is pre-existing behaviour: + * the temp app lives under `` and resolves everything by hoisting, and + * `commands/dev.ts` skips `npm install` entirely there. The consequence worth + * naming is that the ranges below are never the ranges exercised in this repo + * (objectui#3742's second cost), which is exactly why the tests anchor them to + * in-repo manifests instead of trusting a green command. + */ +export function buildAppPackageJson(context: Pick): Record { + return { + name: 'objectui-temp-app', + private: true, + type: 'module', + // In monorepo, we use root node_modules, so we don't need dependencies here + dependencies: context.isMonorepo ? {} : buildAppDependencies(), + devDependencies: context.isMonorepo ? {} : { ...APP_DEV_DEPENDENCIES } + }; +} + +/** + * The generated `package.json` for a routed app. + * + * Unlike the variant above this one has no monorepo branch — it always writes + * the full manifest, so a missing declaration here is missing everywhere and + * cannot be explained away by hoisting. + */ +export function buildRoutedAppPackageJson(): Record { + return { + name: 'objectui-temp-app', + private: true, + type: 'module', + dependencies: buildRoutedAppDependencies(), + devDependencies: { ...APP_DEV_DEPENDENCIES } + }; +} + +/** The generated `tailwind.config.js`; content globs widen inside a monorepo. */ +function buildTailwindConfig(context: AppGeneratorContext): string { + // Define Tailwind Content Paths + // Include JSON files specifically + const contentPaths = ["'./index.html'", "'./src/**/*.{js,ts,jsx,tsx,json}'"]; + if (context.isMonorepo) { + const componentsPath = join(context.cwd, 'packages/components/src/**/*.{ts,tsx}'); + const pluginsPath = join(context.cwd, 'packages/plugin-*/src/**/*.{ts,tsx}'); + contentPaths.push(`'${componentsPath}'`); + contentPaths.push(`'${pluginsPath}'`); + } + + return `/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ['class'], + content: [${contentPaths.join(', ')}], + theme: { + extend: { + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + 1: 'hsl(var(--chart-1))', + 2: 'hsl(var(--chart-2))', + 3: 'hsl(var(--chart-3))', + 4: 'hsl(var(--chart-4))', + 5: 'hsl(var(--chart-5))', + }, + }, + }, + }, + plugins: [], +};`; +} + +/** Writes a generated file map onto `tmpDir`, creating nested directories. */ +function writeGeneratedFiles(tmpDir: string, files: Record): void { + for (const [relativePath, contents] of Object.entries(files)) { + const target = join(tmpDir, relativePath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + } +} + export function createTempApp(tmpDir: string, schema: unknown) { + writeGeneratedFiles(tmpDir, buildAppFiles(schema, currentContext())); +} + +/** + * Every file `createTempApp` writes, as `relative path -> contents`. + * + * Exported so the tests assert over the SAME artifact the CLI writes rather + * than over this file's source text — the shape objectui#3733/#3826 settled on + * for the sibling generator, and the only way a structural gate (imports vs + * declarations, entry reachability) can be written at all. + */ +export function buildAppFiles(schema: unknown, context: AppGeneratorContext): Record { // Create index.html const html = ` @@ -125,12 +472,6 @@ export function createTempApp(tmpDir: string, schema: unknown) { `; - writeFileSync(join(tmpDir, 'index.html'), html); - - // Create src directory - const srcDir = join(tmpDir, 'src'); - mkdirSync(srcDir, { recursive: true }); - // Create main.tsx const mainTsx = `import React from 'react'; import ReactDOM from 'react-dom/client'; @@ -143,8 +484,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render( );`; - writeFileSync(join(srcDir, 'main.tsx'), mainTsx); - // Create App.tsx const appTsx = `import { SchemaRenderer } from '@object-ui/react'; import '@object-ui/components'; @@ -164,8 +503,6 @@ function App() { export default App;`; - writeFileSync(join(srcDir, 'App.tsx'), appTsx); - // Create index.css const indexCss = `@tailwind base; @tailwind components; @@ -237,207 +574,39 @@ export default App;`; } }`; - writeFileSync(join(srcDir, 'index.css'), indexCss); - - // Create tailwind.config.js - const tailwindConfig = `/** @type {import('tailwindcss').Config} */ -export default { - darkMode: ['class'], - content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], - theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - 1: 'hsl(var(--chart-1))', - 2: 'hsl(var(--chart-2))', - 3: 'hsl(var(--chart-3))', - 4: 'hsl(var(--chart-4))', - 5: 'hsl(var(--chart-5))', - }, - }, - }, - }, - plugins: [], -};`; - - const cwd = process.cwd(); - const isMonorepo = existsSync(join(cwd, 'pnpm-workspace.yaml')); - - // Define Tailwind Content Paths - // Include JSON files specifically - const contentPaths = ["'./index.html'", "'./src/**/*.{js,ts,jsx,tsx,json}'"]; - if (isMonorepo) { - const componentsPath = join(cwd, 'packages/components/src/**/*.{ts,tsx}'); - const pluginsPath = join(cwd, 'packages/plugin-*/src/**/*.{ts,tsx}'); - contentPaths.push(`'${componentsPath}'`); - contentPaths.push(`'${pluginsPath}'`); - } - - // Create tailwind.config.js - const finalTailwindConfig = `/** @type {import('tailwindcss').Config} */ -export default { - darkMode: ['class'], - content: [${contentPaths.join(', ')}], - theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - 1: 'hsl(var(--chart-1))', - 2: 'hsl(var(--chart-2))', - 3: 'hsl(var(--chart-3))', - 4: 'hsl(var(--chart-4))', - 5: 'hsl(var(--chart-5))', - }, - }, - }, - }, - plugins: [], -};`; - - writeFileSync(join(tmpDir, 'tailwind.config.js'), finalTailwindConfig); - - // Create postcss.config.js - const finalPostcssConfig = `export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -};`; - - writeFileSync(join(tmpDir, 'postcss.config.js'), finalPostcssConfig); - - // Create package.json - const baseDependencies = { - react: '^18.3.1', - 'react-dom': '^18.3.1', - '@object-ui/react': '^0.1.0', - '@object-ui/components': '^0.1.0', - }; - - const baseDevDependencies = { - '@types/react': '^18.3.12', - '@types/react-dom': '^18.3.1', - '@vitejs/plugin-react': '^4.2.1', - autoprefixer: '^10.4.23', - postcss: '^8.5.6', - tailwindcss: '^3.4.19', - typescript: '~5.7.3', - vite: '^5.0.0', - }; - - const packageJson = { - name: 'objectui-temp-app', - private: true, - type: 'module', - // In monorepo, we use root node_modules, so we don't need dependencies here - dependencies: isMonorepo ? {} : baseDependencies, - devDependencies: isMonorepo ? {} : baseDevDependencies, - }; - - writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(packageJson, null, 2)); - - // Create tsconfig.json - const tsconfig = { - compilerOptions: { - target: 'ES2020', - useDefineForClassFields: true, - lib: ['ES2020', 'DOM', 'DOM.Iterable'], - module: 'ESNext', - skipLibCheck: true, - moduleResolution: 'bundler', - allowImportingTsExtensions: true, - resolveJsonModule: true, - isolatedModules: true, - noEmit: true, - jsx: 'react-jsx', - strict: true, - noUnusedLocals: true, - noUnusedParameters: true, - noFallthroughCasesInSwitch: true, - }, - include: ['src'], + return { + 'index.html': html, + 'src/main.tsx': mainTsx, + 'src/App.tsx': appTsx, + 'src/index.css': indexCss, + 'tailwind.config.js': buildTailwindConfig(context), + 'postcss.config.js': APP_POSTCSS_CONFIG, + 'package.json': JSON.stringify(buildAppPackageJson(context), null, 2), + 'tsconfig.json': JSON.stringify(APP_TSCONFIG, null, 2) }; - - writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); } export function createTempAppWithRouting(tmpDir: string, routes: RouteInfo[], appConfig?: unknown) { + writeGeneratedFiles(tmpDir, buildRoutedAppFiles(routes, appConfig, currentContext())); +} + +/** + * Every file `createTempAppWithRouting` writes, as `relative path -> contents`. + * + * `src/main.tsx` is the entry — `index.html` loads exactly that one module — so + * every other generated `src/**` module has to be reachable from it, which is + * the reachability gate objectui#3826 established for the sibling generator and + * `app-generator.test.ts` ports here. `src/Layout.tsx` is written only when + * `appConfig` is present, precisely because that is the only case in which + * `src/App.tsx` imports it. + */ +export function buildRoutedAppFiles( + routes: RouteInfo[], + appConfig: unknown, + context: AppGeneratorContext +): Record { + const files: Record = {}; + // Create index.html const html = ` @@ -452,32 +621,21 @@ export function createTempAppWithRouting(tmpDir: string, routes: RouteInfo[], ap `; - writeFileSync(join(tmpDir, 'index.html'), html); + files['index.html'] = html; - // Create src directory - const srcDir = join(tmpDir, 'src'); - mkdirSync(srcDir, { recursive: true }); - - // Create schemas directory and copy all schemas - const schemasDir = join(srcDir, 'schemas'); - mkdirSync(schemasDir, { recursive: true }); - const schemaImports: string[] = []; const routeComponents: string[] = []; - + routes.forEach((route, index) => { const schemaVarName = `schema${index}`; const schemaFileName = `page${index}.json`; - - // Write schema to schemas directory - writeFileSync( - join(schemasDir, schemaFileName), - JSON.stringify(route.schema, null, 2) - ); - + + // Add schema to the schemas directory + files[`src/schemas/${schemaFileName}`] = JSON.stringify(route.schema, null, 2); + // Add import statement schemaImports.push(`import ${schemaVarName} from './schemas/${schemaFileName}';`); - + // Add route component routeComponents.push(` } />`); }); @@ -556,7 +714,7 @@ export const useTheme = () => { return context }`; - writeFileSync(join(srcDir, 'theme-provider.tsx'), themeProviderTsx); + files['src/theme-provider.tsx'] = themeProviderTsx; // Create main.tsx const mainTsx = `import React from 'react'; @@ -573,7 +731,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( );`; - writeFileSync(join(srcDir, 'main.tsx'), mainTsx); + files['src/main.tsx'] = mainTsx; // Generate Layout Code if appConfig is present let layoutImport = ''; @@ -766,7 +924,7 @@ const AppLayout = ({ app, children }) => { export default AppLayout; `; - writeFileSync(join(srcDir, 'Layout.tsx'), layoutCode); + files['src/Layout.tsx'] = layoutCode; layoutImport = `import AppLayout from './Layout';\nconst appConfig = ${JSON.stringify(appConfig)};`; layoutWrapperStart = ``; @@ -801,7 +959,7 @@ ${routeComponents.join('\n')} export default App;`; - writeFileSync(join(srcDir, 'App.tsx'), appTsx); + files['src/App.tsx'] = appTsx; // Create index.css with Tailwind const indexCss = `@tailwind base; @@ -874,139 +1032,12 @@ export default App;`; } }`; - writeFileSync(join(srcDir, 'index.css'), indexCss); - - const cwd = process.cwd(); - const isMonorepo = existsSync(join(cwd, 'pnpm-workspace.yaml')); - - // Define Tailwind Content Paths - // Include JSON files specifically - const contentPaths = ["'./index.html'", "'./src/**/*.{js,ts,jsx,tsx,json}'"]; - if (isMonorepo) { - const componentsPath = join(cwd, 'packages/components/src/**/*.{ts,tsx}'); - const pluginsPath = join(cwd, 'packages/plugin-*/src/**/*.{ts,tsx}'); - contentPaths.push(`'${componentsPath}'`); - contentPaths.push(`'${pluginsPath}'`); - } - - // Create tailwind.config.js - const tailwindConfig = `/** @type {import('tailwindcss').Config} */ -export default { - darkMode: ['class'], - content: [${contentPaths.join(', ')}], - theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - 1: 'hsl(var(--chart-1))', - 2: 'hsl(var(--chart-2))', - 3: 'hsl(var(--chart-3))', - 4: 'hsl(var(--chart-4))', - 5: 'hsl(var(--chart-5))', - }, - }, - }, - }, - plugins: [], -};`; - - writeFileSync(join(tmpDir, 'tailwind.config.js'), tailwindConfig); - - // Create postcss.config.js - const postcssConfig = `export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -};`; - - writeFileSync(join(tmpDir, 'postcss.config.js'), postcssConfig); - - // Create package.json with react-router-dom - const packageJson = { - name: 'objectui-temp-app', - private: true, - type: 'module', - dependencies: { - react: '^18.3.1', - 'react-dom': '^18.3.1', - 'react-router-dom': '^7.12.0', - '@object-ui/react': '^0.1.0', - '@object-ui/components': '^0.1.0', - }, - devDependencies: { - '@types/react': '^18.3.12', - '@types/react-dom': '^18.3.1', - '@vitejs/plugin-react': '^4.2.1', - autoprefixer: '^10.4.23', - postcss: '^8.5.6', - tailwindcss: '^3.4.19', - typescript: '~5.7.3', - vite: '^5.0.0', - }, - }; + files['src/index.css'] = indexCss; - writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(packageJson, null, 2)); - - // Create tsconfig.json - const tsconfig = { - compilerOptions: { - target: 'ES2020', - useDefineForClassFields: true, - lib: ['ES2020', 'DOM', 'DOM.Iterable'], - module: 'ESNext', - skipLibCheck: true, - moduleResolution: 'bundler', - allowImportingTsExtensions: true, - resolveJsonModule: true, - isolatedModules: true, - noEmit: true, - jsx: 'react-jsx', - strict: true, - noUnusedLocals: true, - noUnusedParameters: true, - noFallthroughCasesInSwitch: true, - }, - include: ['src'], - }; + files['tailwind.config.js'] = buildTailwindConfig(context); + files['postcss.config.js'] = APP_POSTCSS_CONFIG; + files['package.json'] = JSON.stringify(buildRoutedAppPackageJson(), null, 2); + files['tsconfig.json'] = JSON.stringify(APP_TSCONFIG, null, 2); - writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); + return files; }