diff --git a/.gitignore b/.gitignore index dbcfb3e5..0af205f6 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,7 @@ packages/cli/.mcp-fixture/ packages/cli/.island-fixture/ packages/cli/.overrides-fixture/ packages/cli/.prerender-fixture/ +packages/cli/.prerender-app-root-fixture/ packages/cli/.mcp-host-fixture/ packages/cli/.roles-script-csp-fixture/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e821e956..311363f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,17 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ## [Unreleased] -Nothing yet. +### Fixed + +- **render / cli:** a stylesheet's surface is read below the app root, never off its absolute path. + Under the scaffold container's `WORKDIR /app` every absolute path starts with an `app/` segment, + so every sheet — `site/` modules, `shared/global.scss`, `@ultimat3/ui`'s — classified as `app`, + `stylesFor('site')` was empty, and every prerendered and `site/` document shipped with no + ``. The same held for any root with `site/`, `api/` or `shared/` above it. + `loadApp(root)` now names the root before it imports a module (`setStylesheetRoot`, new in + `@ultimat3/render/server`; the working directory when unset), sheets registered earlier are + reclassified, and a sheet under `node_modules/` is a package sheet whatever its directories are + called. Route and boundary classification already read root-relative paths and are unchanged. ## 22.2.0 - 2026-09-24 diff --git a/packages/cli/src/app-load.ts b/packages/cli/src/app-load.ts index 852d30d7..24de6140 100644 --- a/packages/cli/src/app-load.ts +++ b/packages/cli/src/app-load.ts @@ -19,8 +19,9 @@ import { isRouteConfig, pageComponentOf, registerRoute, routeEntries } from '@ul // every app module below is loaded by the dynamic `import()` in this file. Before the render // barrel split it came free with the line above; after it, the only other path to `/server` from // here is six hops through `error-contract` → `fix-command` → the command registry, which is an -// accident one refactor away from compiling every app's `.tsx` to `React.createElement`. -import '@ultimat3/render/server'; +// accident one refactor away from compiling every app's `.tsx` to `React.createElement`. The named +// import is a value import, so the side effect holds without the bare line it replaced. +import { setStylesheetRoot } from '@ultimat3/render/server'; import { APP_CONFIG_FILE } from './app-root'; import { collectDeclaredCodes } from './error-contract'; import type { Finding } from './output'; @@ -108,6 +109,10 @@ export function resetAppLoad(): void { } export async function loadApp(root: string): Promise { + // Before any import: a stylesheet's surface is read below the app root, never off its absolute + // path — under the container's `WORKDIR /app` that path's first segment is `app/`, and every + // sheet, the site's included, classified as app CSS. + setStylesheetRoot(root); const files: string[] = []; const findings: Finding[] = []; diff --git a/packages/cli/src/prerender-app-root.test.ts b/packages/cli/src/prerender-app-root.test.ts new file mode 100644 index 00000000..43946b11 --- /dev/null +++ b/packages/cli/src/prerender-app-root.test.ts @@ -0,0 +1,74 @@ +// The static build under a root whose last segment is `app` — the scaffold container's +// `WORKDIR /app`. A real app on disk, loaded the way `x build` loads it, because the defect lived in +// how an IMPORTED stylesheet was classified: registering a sheet by hand would not reach it. + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; // why: Bun has no recursive remove, only a per-file delete. +// why: Bun exposes no path-join primitive; Bun.file and import() take one already joined. +import { join } from 'node:path'; +import { clearRoutes } from '@ultimat3/render'; +import { clearStylesheets, setStylesheetRoot } from '@ultimat3/render/server'; +import { prerenderSite } from './prerender'; + +// Inside the package, so the fixture's `@ultimat3/*` imports resolve; `app` is the point. +const FIXTURE = join(import.meta.dir, '..', '.prerender-app-root-fixture'); +const APP_ROOT = join(FIXTURE, 'app'); + +beforeEach(async () => { + clearRoutes(); + await rm(FIXTURE, { recursive: true, force: true }); +}); + +afterEach(async () => { + clearRoutes(); + // Both registries are process-global, and this file writes into them. + clearStylesheets(); + setStylesheetRoot(undefined); + await rm(FIXTURE, { recursive: true, force: true }); +}); + +// Reproduced in production: the scaffold's Dockerfile builds and serves from `WORKDIR /app`, and the +// stylesheet loader read the surface off the ABSOLUTE path — whose first `app/` segment is the root +// itself. Every sheet classified as `app`, the site bundle was empty, and every prerendered page +// shipped with no ``. A real app on disk, loaded the way `x build` loads it, under a root +// whose last segment is `app`. +describe('an app whose root is a directory named app/', () => { + test('the site page links its own stylesheet, carrying the global layer and not app/ CSS', async () => { + await Bun.write( + join(APP_ROOT, 'package.json'), + JSON.stringify({ name: 'app-root-fixture', version: '1.0.0' }), + ); + await Bun.write(join(APP_ROOT, 'apps/web/shared/global.scss'), ':root{--space-9:9rem}\n'); + await Bun.write(join(APP_ROOT, 'apps/web/site/page.module.scss'), '.hero{margin:3px}\n'); + await Bun.write(join(APP_ROOT, 'apps/web/app/feed/panel.module.scss'), '.panel{margin:7px}\n'); + await Bun.write( + join(APP_ROOT, 'apps/web/site/page.tsx'), + [ + "import { defineRoute } from '@ultimat3/render';", + "import '../shared/global.scss';", + "import styles from './page.module.scss';", + "export const config = defineRoute({ render: 'static', hydrate: 'never', offline: 'precache',", + " meta: () => ({ title: 'Home', description: 'the landing page' }) });", + 'export default function Home() { return
; }', + '', + ].join('\n'), + ); + // An app/ module with CSS of its own, so an empty site bundle is not the only way to fail. + await Bun.write( + join(APP_ROOT, 'apps/web/app/feed/panel.ts'), + "import styles from './panel.module.scss';\nexport const panel = styles.panel;\n", + ); + const out = join(APP_ROOT, 'static'); + + const report = await prerenderSite({ root: APP_ROOT, out, origin: 'https://example.test' }); + + const html = await Bun.file(join(out, 'index.html')).text(); + const href = //.exec(html)?.groups?.['url'] ?? ''; + expect(href).toMatch(/^\/styles\/[0-9a-f]{8}\.css$/); + expect(report.styles).toContain(href); + const css = await Bun.file(join(out, href.slice(1))).text(); + expect(css).toContain('margin:3px'); + expect(css).toContain('--space-9:9rem'); + expect(css).not.toContain('margin:7px'); + }); +}); diff --git a/packages/cli/src/style-bundle.test.ts b/packages/cli/src/style-bundle.test.ts index 11e7b322..810d1c0a 100644 --- a/packages/cli/src/style-bundle.test.ts +++ b/packages/cli/src/style-bundle.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { rm } from 'node:fs/promises'; // why: Bun exposes no path API — nothing native joins a directory to a file. import { join } from 'node:path'; -import { clearStylesheets, loadStylesheet } from '@ultimat3/render/server'; +import { clearStylesheets, loadStylesheet, setStylesheetRoot } from '@ultimat3/render/server'; import { STYLE_BASE_PATH, styleBundle, styleBundleOf, writeStyles } from './style-bundle'; const SITE = '/srv/demo/apps/web/site/page.module.scss'; @@ -24,6 +24,7 @@ beforeEach(() => { afterEach(async () => { clearStylesheets(); + setStylesheetRoot(undefined); await rm(OUT, { recursive: true, force: true }); }); @@ -100,3 +101,48 @@ describe('styleBundle', () => { expect(await Bun.file(join(OUT, url.slice(1))).text()).toBe('.hero{color:red}'); }); }); + +// The container's shape: the app runs from `WORKDIR /app`, so every sheet's absolute path begins +// with an `app/` segment. The fingerprinted pipeline has to hold there exactly as it does in a +// checkout — the site gets a file of its own, and its name moves with its bytes and only with them. +describe('styleBundle, for an app served from /app', () => { + const SITE_SHEET = '/app/apps/web/site/page.module.scss'; + const APP_SHEET = '/app/apps/web/app/feed/page.module.scss'; + const GLOBAL_SHEET = '/app/apps/web/shared/global.scss'; + + beforeEach(() => { + setStylesheetRoot('/app'); + }); + + test('the site gets its own content-hashed file, carrying the global layer and no app CSS', () => { + loadStylesheet(GLOBAL_SHEET, ':root{--space-9:9rem}'); + loadStylesheet(SITE_SHEET, '.hero{color:red}'); + loadStylesheet(APP_SHEET, '.feed{color:blue}'); + const bundle = styleBundle(); + const site = bundle.hrefFor('site') ?? ''; + const app = bundle.hrefFor('app') ?? ''; + + expect(site).toMatch(new RegExp(`^${STYLE_BASE_PATH}/[0-9a-f]{8}\\.css$`)); + expect(site).not.toBe(app); + const css = bundle.chunkAt(site)?.css ?? ''; + expect(css).toContain('--space-9:9rem'); + expect(css).toContain('color:red'); + expect(css).not.toContain('color:blue'); + }); + + test('the site URL is stable while its CSS is, and moves only when its CSS does', () => { + loadStylesheet(SITE_SHEET, '.hero{color:red}'); + loadStylesheet(APP_SHEET, '.feed{color:blue}'); + const first = styleBundle().hrefFor('site'); + + // The same bytes again, and an edit on the OTHER surface: neither is a reason to re-download. + loadStylesheet(SITE_SHEET, '.hero{color:red}'); + loadStylesheet(APP_SHEET, '.feed{color:purple}'); + expect(styleBundle().hrefFor('site')).toBe(first); + + loadStylesheet(SITE_SHEET, '.hero{color:green}'); + const moved = styleBundle().hrefFor('site') ?? ''; + expect(moved).not.toBe(first); + expect(styleBundle().chunkAt(moved)?.css).toContain('color:green'); + }); +}); diff --git a/packages/render/src/module-loader.test.ts b/packages/render/src/module-loader.test.ts index 7c0ab782..af1c99e4 100644 --- a/packages/render/src/module-loader.test.ts +++ b/packages/render/src/module-loader.test.ts @@ -11,6 +11,7 @@ import { JSX_FACTORY_SPECIFIER, loadStylesheet, registeredStylesheets, + setStylesheetRoot, stylesFor, stylesheetsRevision, transformTsx, @@ -27,6 +28,7 @@ const occurrences = (haystack: string, needle: string): number => haystack.split afterEach(() => { clearStylesheets(); + setStylesheetRoot(undefined); }); describe('installRenderLoader', () => { @@ -231,3 +233,53 @@ describe('stylesFor', () => { expect(stylesFor('app').startsWith(GLOBAL_CSS)).toBe(true); }); }); + +// The container runs the app from `WORKDIR /app`, so every absolute path a Bun plugin hands the +// loader starts with an `app/` segment. Classified from the absolute path, the site page, the +// global layer and the UI kit all landed on `app`, and every site document went out unstyled. +describe('an app served from /app', () => { + const ROOT = '/app'; + const SITE_SHEET = '/app/apps/web/site/page.module.scss'; + const APP_SHEET = '/app/apps/web/app/feed/page.module.scss'; + const GLOBAL_SHEET = '/app/apps/web/shared/global.scss'; + const KIT_SHEET = '/app/node_modules/@ultimat3/ui/src/stack.module.scss'; + + test('each sheet is classified by where it sits in the app, not by the root it sits under', () => { + setStylesheetRoot(ROOT); + loadStylesheet(SITE_SHEET, '.hero{color:red}'); + loadStylesheet(APP_SHEET, '.feed{color:blue}'); + loadStylesheet(GLOBAL_SHEET, GLOBAL_CSS); + loadStylesheet(KIT_SHEET, '.stack{display:flex}'); + + const surfaces = Object.fromEntries(registeredStylesheets().map((s) => [s.file, s.surface])); + expect(surfaces).toEqual({ + [SITE_SHEET]: 'site', + [APP_SHEET]: 'app', + [GLOBAL_SHEET]: 'shared', + [KIT_SHEET]: null, + }); + const site = stylesFor('site'); + expect(site).toContain('color:red'); + expect(site).toContain('--color-fg:'); + expect(site).toContain('display:flex'); + expect(site).not.toContain('color:blue'); + }); + + // The order `x build` meets: a sheet can register before `loadApp` names the root, and a + // classification frozen at registration would keep the answer the wrong root gave. + test('naming the root reclassifies what registered before it, and moves the revision', () => { + setStylesheetRoot('/'); + loadStylesheet(SITE_SHEET, '.hero{color:red}'); + expect(stylesFor('site')).toBe(''); + + const before = stylesheetsRevision(); + setStylesheetRoot(ROOT); + expect(stylesFor('site')).toContain('color:red'); + expect(stylesheetsRevision()).toBeGreaterThan(before); + + // Naming the same root again changes no answer, so it mints no new stylesheet URL. + const settled = stylesheetsRevision(); + setStylesheetRoot(ROOT); + expect(stylesheetsRevision()).toBe(settled); + }); +}); diff --git a/packages/render/src/module-loader.ts b/packages/render/src/module-loader.ts index 31bf35cc..c2ea54df 100644 --- a/packages/render/src/module-loader.ts +++ b/packages/render/src/module-loader.ts @@ -8,7 +8,7 @@ import { renderThrowable } from '@ultimat3/core'; import { compileStylesheet, isGlobalStylesheet, stripCharset } from './css-modules'; import { PrerenderFailedError } from './errors'; import type { Surface } from './surfaces'; -import { surfaceOf } from './surfaces'; +import { surfaceUnder } from './surfaces'; /** * Why a transform at all: `tsconfig.json` says `jsx: 'preserve'`, which makes Bun fall back to the @@ -101,6 +101,32 @@ export function registeredStylesheets(): readonly Stylesheet[] { return [...stylesheets.values()]; } +/** + * The directory a sheet's surface is read below. Absent, the process's working directory — which + * is the app root in the container (`WORKDIR /app`) and under every `x` command run from one. + */ +let stylesheetRoot: string | undefined; + +const surfaceOfSheet = (path: string): Surface | null => + surfaceUnder(stylesheetRoot ?? process.cwd(), path); + +/** + * Names the app root the registry classifies against; `loadApp` calls it before importing a module. + * Every sheet already registered is classified again, because a sheet can load before the root is + * named — and a classification frozen then would keep the answer the wrong root gave. The revision + * moves only when an answer does, so naming the same root twice mints no new stylesheet URL. + * `undefined` returns to the working directory. + */ +export function setStylesheetRoot(root: string | undefined): void { + stylesheetRoot = root; + for (const [path, sheet] of stylesheets) { + const surface = surfaceOfSheet(path); + if (surface === sheet.surface) continue; + stylesheets.set(path, { ...sheet, surface }); + revision += 1; + } +} + /** Test seam: the registry is process-global because the module cache it mirrors is too. */ export function clearStylesheets(): void { if (stylesheets.size > 0) revision += 1; @@ -159,7 +185,7 @@ export function loadStylesheet(path: string, source: string): string { if (stylesheets.get(path)?.css !== compiled.css) revision += 1; stylesheets.set(path, { file: path, - surface: surfaceOf(path), + surface: surfaceOfSheet(path), global: isGlobalStylesheet(path), css: compiled.css, }); diff --git a/packages/render/src/server.ts b/packages/render/src/server.ts index 6cb04a48..2a493d76 100644 --- a/packages/render/src/server.ts +++ b/packages/render/src/server.ts @@ -30,6 +30,7 @@ export { installRenderLoader, loadStylesheet, registeredStylesheets, + setStylesheetRoot, stylesFor, stylesheetsRevision, transformTsx, diff --git a/packages/render/src/surfaces.test.ts b/packages/render/src/surfaces.test.ts index ccfce203..37842609 100644 --- a/packages/render/src/surfaces.test.ts +++ b/packages/render/src/surfaces.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from 'bun:test'; import { SurfaceBoundaryError } from './errors'; -import { assertSurfaceBoundary, checkSurfaceBoundary, importGraph, surfaceOf } from './surfaces'; +import { + assertSurfaceBoundary, + checkSurfaceBoundary, + importGraph, + surfaceOf, + surfaceUnder, +} from './surfaces'; describe('surfaceOf', () => { test('reads the surface out of a monorepo path', () => { @@ -11,6 +17,47 @@ describe('surfaceOf', () => { }); }); +// Reproduced in production: the scaffold's Dockerfile runs the app from `WORKDIR /app`, so every +// ABSOLUTE stylesheet path starts `/app/` — and the first `app/` segment won. The site page, the +// shared global layer and every package sheet classified as `app`, `stylesFor('site')` was empty, +// and every prerendered page shipped with no `` at all. +describe('surfaceUnder', () => { + test('an app root named app/ does not make every file an app/ file', () => { + expect(surfaceUnder('/app', '/app/apps/web/site/page.module.scss')).toBe('site'); + expect(surfaceUnder('/app', '/app/apps/web/shared/global.scss')).toBe('shared'); + expect(surfaceUnder('/app', '/app/apps/web/app/feed/page.module.scss')).toBe('app'); + expect(surfaceUnder('/app', '/app/packages/ui/src/card.module.scss')).toBe(null); + }); + + test('nor does a root with site/ somewhere above it', () => { + expect(surfaceUnder('/srv/site/x', '/srv/site/x/apps/web/app/feed/page.module.scss')).toBe( + 'app', + ); + expect(surfaceUnder('/srv/site/x/', '/srv/site/x/apps/web/shared/global.scss')).toBe('shared'); + }); + + // A package sheet is carried by both graphs; `node_modules/lib/app/theme.scss` is not app code + // because the library happens to keep its sheets in a directory called `app`. + test('an installed package sheet has no surface, whatever directories it sits in', () => { + expect(surfaceUnder('/app', '/app/node_modules/@ultimat3/ui/src/card.module.scss')).toBe(null); + expect(surfaceUnder('/app', '/app/node_modules/some-lib/app/theme.scss')).toBe(null); + expect( + surfaceUnder('/app', '/app/apps/web/node_modules/@ultimat3/ui/src/stack.module.scss'), + ).toBe(null); + }); + + test('a file outside the root is read as it always was', () => { + expect(surfaceUnder('/app', '/srv/demo/apps/web/site/page.module.scss')).toBe('site'); + expect(surfaceUnder('/app', '/srv/demo/packages/ui/src/card.module.scss')).toBe(null); + // A sibling whose name merely STARTS with the root is not under it. + expect(surfaceUnder('/app', '/apple/apps/web/site/page.module.scss')).toBe('site'); + }); + + test('Windows separators are read as POSIX ones', () => { + expect(surfaceUnder('C:\\app', 'C:\\app\\apps\\web\\site\\page.module.scss')).toBe('site'); + }); +}); + describe('checkSurfaceBoundary', () => { // The exact failure this prevents: