@flamingo-stack/openframe-frontend-core 0.0.834 → 0.0.836

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,8 +68,9 @@ function withInlineStyles(nextConfig) {
68
68
 
69
69
  /**
70
70
  * `withInlineStyles`, and per-route stylesheets when the build is `next-styles build` (`./route-styles.cjs`): the
71
- * server bundle carries `.next-styles/` (filled after the build) and knows its build, and the build records the
72
- * settings the route step reads. Any other build, and `next dev`, is `withInlineStyles` alone.
71
+ * build runs the route step as its build adapter (`./route-styles-adapter.cjs`, which wraps the adapter the build
72
+ * would have had: the config's own `adapterPath`, else the host's `NEXT_ADAPTER_PATH`), knows its build, and records
73
+ * the settings the route step reads. Any other build, and `next dev`, is `withInlineStyles` alone.
73
74
  */
74
75
  function withRouteStyles(nextConfig) {
75
76
  return async (phase, ...rest) => {
@@ -86,13 +87,18 @@ function withRouteStyles(nextConfig) {
86
87
  output: inline.output ?? null,
87
88
  assetPrefix: inline.assetPrefix ?? '',
88
89
  basePath: inline.basePath ?? '',
90
+ inner: inline.adapterPath ?? process.env.NEXT_ADAPTER_PATH ?? null,
91
+ options: JSON.parse(process.env.NEXT_STYLES_OPTIONS || '{}'),
89
92
  };
90
93
  writeFileSync(infoFile, `${JSON.stringify(info, null, 2)}\n`);
91
- // Every route: the files are read at run time by `RouteStyles`, which no static trace can follow.
94
+ // Every route: the files are read at run time by `RouteStyles`, which no static trace can follow (the adapter
95
+ // adds the sheets it writes after the trace to the server functions it hands on).
92
96
  const includes = { ...inline.outputFileTracingIncludes };
93
97
  for (const route of ['/', '/*', '/*/**']) includes[route] = [...(includes[route] ?? []), `./${STYLES_DIR}/**/*`];
94
98
  return {
95
99
  ...inline,
100
+ // A path, never a require: the config must not reach the build tool (see `loadRouteStyles`).
101
+ adapterPath: join(__dirname, 'route-styles-adapter.cjs'),
96
102
  env: { ...inline.env, NEXT_STYLES_BUILD: buildId, NEXT_STYLES_FULL_URL: fullSheetUrl(info) },
97
103
  outputFileTracingIncludes: includes,
98
104
  };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The route step of `next-styles build` as a Next.js build adapter (`adapterPath`), set by `withRouteStyles`.
3
+ *
4
+ * WHY. A step that runs after `next build` changes files nobody serves any more on a host whose own build adapter
5
+ * packages the output when `next build` ends: on Vercel ("Applying modifyConfig from Vercel" in the build log) the
6
+ * pages, functions and static files are collected by the adapter's `onBuildComplete`, so the placeholders filled
7
+ * afterwards reached production unfilled and a page rendered per request found no `.next-styles/` (2026-11-01,
8
+ * hub #1753, rolled back). An adapter's `onBuildComplete` is the one hook Next runs after every page is prerendered
9
+ * and before anything is packaged (before the standalone copy too), so the step runs there.
10
+ *
11
+ * HOW. Next loads one adapter, so this one WRAPS the adapter the build would have had (`build.json`'s `inner`: the
12
+ * app's own `adapterPath`, else `NEXT_ADAPTER_PATH`, which the host sets): its `modifyConfig` is the inner one's,
13
+ * and its `onBuildComplete` writes the route sheets, fills the prerendered pages, adds what it wrote to the outputs
14
+ * the inner adapter packages (`.next-styles/` to every server function, the complete sheet to the static files),
15
+ * and then hands the outputs on. With no inner adapter (a build on this machine, a Docker image) the build goes on
16
+ * as Next's own.
17
+ */
18
+ const crypto = require('node:crypto');
19
+ const fs = require('node:fs');
20
+ const { join, relative, resolve } = require('node:path');
21
+ const { pathToFileURL } = require('node:url');
22
+
23
+ const { STYLES_DIR, BUILD_INFO } = require('./route-styles-markers.cjs');
24
+
25
+ const readInfo = root => JSON.parse(fs.readFileSync(join(root, STYLES_DIR, BUILD_INFO), 'utf8'));
26
+
27
+ /** The adapter this one wraps (`null` for none), loaded once. */
28
+ let innerAdapter;
29
+ async function inner(root) {
30
+ if (innerAdapter !== undefined) return innerAdapter;
31
+ const path = readInfo(root).inner;
32
+ if (!path) return (innerAdapter = null);
33
+ const mod = await import(pathToFileURL(require.resolve(path, { paths: [root] })).href);
34
+ return (innerAdapter = mod.default ?? mod);
35
+ }
36
+
37
+ /** Every file under `dir`. */
38
+ function filesUnder(dir, out = []) {
39
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
40
+ const path = join(dir, entry.name);
41
+ if (entry.isDirectory()) filesUnder(path, out);
42
+ else out.push(path);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** A file's content hash as Next's adapter computes it (`hashFile` in next/dist/build/adapter/build-complete). */
48
+ function hashFile(salt, file) {
49
+ return crypto.createHash('sha256').update(salt).update('file:').update(fs.readFileSync(file)).digest('hex');
50
+ }
51
+
52
+ /**
53
+ * `outputs` with what the route step wrote: `.next-styles/` in every Node.js server function (read at run time by
54
+ * `RouteStyles`, so no trace finds the sheets written after the trace), and the complete sheet among the static
55
+ * files (collected before this step wrote it).
56
+ */
57
+ function addRouteStyles(ctx) {
58
+ const root = ctx.projectDir;
59
+ const info = readInfo(root);
60
+ const salt = ctx.config.outputHashSalt || '';
61
+ const files = filesUnder(join(root, STYLES_DIR)).map(file => ({
62
+ key: relative(ctx.repoRoot, file).split('\\').join('/'),
63
+ file,
64
+ hash: hashFile(salt, file),
65
+ }));
66
+ const functions = [
67
+ ...ctx.outputs.appPages,
68
+ ...ctx.outputs.appRoutes,
69
+ ...ctx.outputs.pages,
70
+ ...ctx.outputs.pagesApi,
71
+ ].filter(output => output.runtime === 'nodejs');
72
+ for (const output of functions) {
73
+ for (const { key, file, hash } of files) {
74
+ output.assets[key] = file;
75
+ // Next records a hash only for a Turbopack build; where it records any, it records one for every asset.
76
+ if (output.assetsHashes && Object.keys(output.assetsHashes).length) output.assetsHashes[key] = hash;
77
+ }
78
+ }
79
+ const staticDir = join(resolve(root, info.distDir), 'static');
80
+ const sheets = join(staticDir, 'next-styles');
81
+ for (const file of fs.existsSync(sheets) ? filesUnder(sheets) : []) {
82
+ const id = relative(resolve(root, info.distDir), file).split('\\').join('/');
83
+ if (ctx.outputs.staticFiles.some(output => output.id === id)) continue;
84
+ ctx.outputs.staticFiles.push({
85
+ type: 'STATIC_FILE', // AdapterOutputType.STATIC_FILE (next/dist/shared/lib/constants)
86
+ id,
87
+ // Next adds the basePath to every pathname before it calls the adapter.
88
+ pathname: `${ctx.config.basePath || ''}/_next/${id}`,
89
+ filePath: file,
90
+ immutableHash: undefined,
91
+ });
92
+ }
93
+ }
94
+
95
+ module.exports = {
96
+ name: 'next-styles',
97
+
98
+ async modifyConfig(config, ctx) {
99
+ const adapter = await inner(ctx.projectDir);
100
+ return typeof adapter?.modifyConfig === 'function' ? adapter.modifyConfig(config, ctx) : config;
101
+ },
102
+
103
+ async onBuildComplete(ctx) {
104
+ const root = ctx.projectDir;
105
+ const { options } = readInfo(root);
106
+ // The build tool, loaded only here (`./next-styles.cjs` explains why the config must not reach it).
107
+ const { writeRouteStyles } = require(join(__dirname, 'route-styles.cjs'));
108
+ const report = await writeRouteStyles({ root, ...options });
109
+ if (report.over.length) {
110
+ throw new Error(
111
+ `next-styles: ${report.over.length} route sheet(s) over the budget: ${report.over.join(', ')} (listed above)`,
112
+ );
113
+ }
114
+ addRouteStyles(ctx);
115
+ const adapter = await inner(root);
116
+ if (typeof adapter?.onBuildComplete === 'function') await adapter.onBuildComplete(ctx);
117
+ },
118
+ };
@@ -47,9 +47,14 @@ function routePattern(route) {
47
47
  return { route, dynamic, regex: new RegExp(`^${source || '/'}$`) };
48
48
  }
49
49
 
50
+ /**
51
+ * The page's path as a route is written. `/index` is the home page: Next calls it that on the server (Vercel serves it
52
+ * as the `index` function, and a page regenerated there renders with `/index` as its path), and no app route is
53
+ * named `index` because Next answers `/index` itself.
54
+ */
50
55
  function normalize(pathname) {
51
56
  const path = `/${String(pathname || '').replace(/^\/+/, '')}`.replace(/\/+$/, '');
52
- return path === '' ? '/' : path;
57
+ return path === '' || path === '/index' ? '/' : path;
53
58
  }
54
59
 
55
60
  /**
@@ -19,7 +19,9 @@
19
19
  * per route with the app's own Tailwind config, PostCSS plugins and stylesheet. It writes them where the
20
20
  * server reads them (`.next-styles/`, traced into the server bundle) and fills each prerendered page's
21
21
  * placeholder. A page rendered later (on its first request, or again after `revalidate`) reads its route's
22
- * sheet when it renders.
22
+ * sheet when it renders. It runs inside `next build`, as a build adapter (`./route-styles-adapter.cjs`), once
23
+ * every page is prerendered and before anything is packaged: on Vercel, whose own adapter packages the build
24
+ * when `next build` ends, a step run after it reached no deployment.
23
25
  * 3. The complete sheet, built the same way from the full config, is a static file the page loads after its
24
26
  * first paint, for what the page renders later (a menu opened, a panel loaded, a page navigated to).
25
27
  *
@@ -127,22 +129,21 @@ async function stylesheetBuilder({ root, input, config }) {
127
129
  };
128
130
  }
129
131
 
130
- /** Every file under `dir` whose path ends with `suffix` (a name, `.html`, or a path such as `.next-styles/build.json`). */
132
+ /** Every file under `dir` whose name ends with `suffix`. */
131
133
  function filesUnder(dir, suffix, out = []) {
132
134
  if (!fs.existsSync(dir)) return out;
133
135
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
134
136
  const path = join(dir, entry.name);
135
- // A standalone build copies its dependencies under node_modules: no page or marker of the app is there.
136
- if (entry.isDirectory()) {
137
- if (entry.name !== 'node_modules') filesUnder(path, suffix, out);
138
- } else if (path.endsWith(suffix)) out.push(path);
137
+ if (entry.isDirectory()) filesUnder(path, suffix, out);
138
+ else if (entry.name.endsWith(suffix)) out.push(path);
139
139
  }
140
140
  return out;
141
141
  }
142
142
 
143
143
  /**
144
144
  * The prerendered pages of the build, each with the route it belongs to: `{ file, route }`. A server build keeps
145
- * them under `<distDir>/server/app` (and the standalone copy), a static export writes them into `distDir`.
145
+ * them under `<distDir>/server/app` (its static 404 under `<distDir>/server/pages`), a static export writes them
146
+ * into `distDir`.
146
147
  * The route of a page is the prerender manifest's `srcRoute` (`/blog/[slug]` for `/blog/x`).
147
148
  */
148
149
  function prerenderedPages(root, info) {
@@ -151,6 +152,9 @@ function prerenderedPages(root, info) {
151
152
  const routes = fs.existsSync(manifestFile) ? JSON.parse(fs.readFileSync(manifestFile, 'utf8')).routes : {};
152
153
  const routeOf = new Map();
153
154
  for (const [path, entry] of Object.entries(routes)) routeOf.set(path, entry.srcRoute ?? path);
155
+ // Next copies the app's not-found page to the static 404 (`server/pages/404.html`, `404.html` in an export),
156
+ // which no prerender entry names: the host serves it for every missing address.
157
+ if (!routeOf.has('/404')) routeOf.set('/404', '/_not-found');
154
158
  const pathOfFile = (base, file) => {
155
159
  const rel = relative(base, file)
156
160
  .split('\\')
@@ -159,7 +163,8 @@ function prerenderedPages(root, info) {
159
163
  const path = `/${rel}`.replace(/\/index$/, '') || '/';
160
164
  return path === '' ? '/' : path;
161
165
  };
162
- const bases = info.output === 'export' ? [distDir] : [join(distDir, 'server', 'app')];
166
+ const bases =
167
+ info.output === 'export' ? [distDir] : [join(distDir, 'server', 'app'), join(distDir, 'server', 'pages')];
163
168
  const pages = [];
164
169
  for (const base of bases) {
165
170
  for (const file of filesUnder(base, '.html')) {
@@ -173,12 +178,6 @@ function prerenderedPages(root, info) {
173
178
  return pages;
174
179
  }
175
180
 
176
- /** The standalone copies of a server build's files (`<distDir>/standalone/...`), found by the copy of `marker`. */
177
- function standaloneCopies(root, info, marker) {
178
- const standalone = resolve(root, info.distDir, 'standalone');
179
- return filesUnder(standalone, marker).map(file => file.slice(0, -marker.length));
180
- }
181
-
182
181
  /** `html` with its placeholders filled: the route's rules `css` and the complete sheet `full`. */
183
182
  function fillPlaceholder(html, { css, full }) {
184
183
  return html.split(PLACEHOLDER).join(css).split(COMPLETE_PLACEHOLDER).join(full);
@@ -272,8 +271,8 @@ async function crawlRoutes(root, routes, { concurrency = 4, timeoutMs = 60_000 }
272
271
  }
273
272
 
274
273
  /**
275
- * The route sheets of a finished build: built, written, filled into the prerendered pages and checked against
276
- * `budget` (brotli bytes per route). Resolves to the report; sets a failing exit code over the budget.
274
+ * The route sheets of a build whose pages are prerendered: built, written, filled into the prerendered pages and
275
+ * checked against `budget` (brotli bytes per route). Resolves to the manifest and `over`, the routes over budget.
277
276
  */
278
277
  async function writeRouteStyles({ root, input, config, fullConfig, budget = DEFAULT_BUDGET, crawl = true }) {
279
278
  const stylesDir = join(root, STYLES_DIR);
@@ -309,16 +308,10 @@ async function writeRouteStyles({ root, input, config, fullConfig, budget = DEFA
309
308
  }
310
309
  for (const page of [...pages, ...crawled]) routes[page.route].pages += 1;
311
310
 
312
- // The server reads these (traced into its bundle by `withRouteStyles`); the complete sheet is also a static file.
313
- const targets = [
314
- stylesDir,
315
- ...standaloneCopies(root, info, join(STYLES_DIR, BUILD_INFO)).map(dir => join(dir, STYLES_DIR)),
316
- ];
317
- for (const dir of targets) {
318
- fs.mkdirSync(join(dir, 'sheets'), { recursive: true });
319
- for (const [file, css] of sheets) fs.writeFileSync(join(dir, 'sheets', file), css);
320
- fs.writeFileSync(join(dir, 'sheets', fullFile), full);
321
- }
311
+ // The server reads these (added to every server function by the adapter); the complete sheet is also a static file.
312
+ fs.mkdirSync(join(stylesDir, 'sheets'), { recursive: true });
313
+ for (const [file, css] of sheets) fs.writeFileSync(join(stylesDir, 'sheets', file), css);
314
+ fs.writeFileSync(join(stylesDir, 'sheets', fullFile), full);
322
315
  const staticDir =
323
316
  info.output === 'export'
324
317
  ? join(resolve(root, info.distDir), '_next', 'static')
@@ -326,24 +319,12 @@ async function writeRouteStyles({ root, input, config, fullConfig, budget = DEFA
326
319
  fs.mkdirSync(join(staticDir, 'next-styles'), { recursive: true });
327
320
  fs.writeFileSync(join(staticDir, 'next-styles', fullFile), full);
328
321
  const manifest = { version: 1, buildId: info.buildId, full: fullFile, fullUrl, routes };
329
- for (const dir of targets) fs.writeFileSync(join(dir, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
322
+ fs.writeFileSync(join(stylesDir, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
330
323
 
331
- // Fill every prerendered page, the standalone copies included (the server answers those directly).
332
- const copies = standaloneCopies(root, info, join(STYLES_DIR, BUILD_INFO)).map(dir =>
333
- join(dir, relative(root, resolve(root, info.distDir)), 'server', 'app'),
334
- );
335
324
  for (const page of pages) {
336
- const css = sheets.get(routes[page.route].file);
337
- const files = [
338
- page.file,
339
- ...copies.map(dir => join(dir, relative(join(resolve(root, info.distDir), 'server', 'app'), page.file))),
340
- ];
341
- for (const file of files) {
342
- if (!fs.existsSync(file)) continue;
343
- const html = fs.readFileSync(file, 'utf8');
344
- if (html.includes(PLACEHOLDER) || html.includes(COMPLETE_PLACEHOLDER)) {
345
- fs.writeFileSync(file, fillPlaceholder(html, { css, full }));
346
- }
325
+ const html = fs.readFileSync(page.file, 'utf8');
326
+ if (html.includes(PLACEHOLDER) || html.includes(COMPLETE_PLACEHOLDER)) {
327
+ fs.writeFileSync(page.file, fillPlaceholder(html, { css: sheets.get(routes[page.route].file), full }));
347
328
  }
348
329
  }
349
330
 
@@ -352,7 +333,7 @@ async function writeRouteStyles({ root, input, config, fullConfig, budget = DEFA
352
333
  console.log(
353
334
  `[next-styles] complete sheet for ${missed.length} route(s) that answered no page: ${missed.join(', ')}`,
354
335
  );
355
- const over = Object.entries(routes).filter(([, sheet]) => sheet.brotli > budget);
336
+ const over = Object.keys(routes).filter(route => routes[route].brotli > budget);
356
337
  console.log(
357
338
  `[next-styles] complete sheet ${kb(Buffer.byteLength(full))} raw, ${kb(brotli(full))} brotli (after paint)`,
358
339
  );
@@ -366,9 +347,25 @@ async function writeRouteStyles({ root, input, config, fullConfig, budget = DEFA
366
347
  `[next-styles] FAIL: ${over.length} route sheet(s) over ${kb(budget)} brotli. A route's sheet is what its page renders ` +
367
348
  'before it is interactive: render less on the first screen (load the rest when it opens), or raise the budget with the reason.',
368
349
  );
369
- process.exitCode = 1;
370
350
  }
371
- return manifest;
351
+ return { ...manifest, over };
352
+ }
353
+
354
+ /**
355
+ * The `.next-styles/` copies of a standalone build (`output: 'standalone'`, what a Docker image runs). Next copies
356
+ * the files a trace names, and the trace ran before the route step wrote its sheets, so each copy is replaced by
357
+ * the finished folder. The pages in the copy are the filled ones (the adapter ran before Next copied them).
358
+ */
359
+ function standaloneStyleDirs(root, info, dir = resolve(root, info.distDir, 'standalone'), out = []) {
360
+ if (!fs.existsSync(dir)) return out;
361
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
362
+ // A standalone build copies its dependencies under node_modules: no folder of the app is there.
363
+ if (!entry.isDirectory() || entry.name === 'node_modules') continue;
364
+ const path = join(dir, entry.name);
365
+ if (entry.name === STYLES_DIR && fs.existsSync(join(path, BUILD_INFO))) out.push(path);
366
+ else standaloneStyleDirs(root, info, path, out);
367
+ }
368
+ return out;
372
369
  }
373
370
 
374
371
  /** Runs `command args` in `root` with `env` added, resolving on exit 0. */
@@ -383,8 +380,9 @@ function run(command, args, { root, env }) {
383
380
  }
384
381
 
385
382
  /**
386
- * `next build` with per-route sheets: prepares `.next-styles/`, runs the build with `NEXT_STYLES=routes`, then
387
- * writes the route sheets. `nextArgs` are passed to `next build`.
383
+ * `next build` with per-route sheets: prepares `.next-styles/` and runs the build with `NEXT_STYLES=routes` and the
384
+ * route step's `options` (`withRouteStyles` hands both to the adapter, which writes the sheets). `nextArgs` are
385
+ * passed to `next build`.
388
386
  */
389
387
  async function buildWithRouteStyles({ root, nextArgs = [], ...options }) {
390
388
  const stylesDir = join(root, STYLES_DIR);
@@ -393,11 +391,21 @@ async function buildWithRouteStyles({ root, nextArgs = [], ...options }) {
393
391
  // The server bundle traces these files when it is built, so they exist before it is (filled after it).
394
392
  fs.writeFileSync(join(stylesDir, MANIFEST), `${JSON.stringify({ version: 1, pending: true })}\n`);
395
393
  const nextBin = require.resolve('next/dist/bin/next', { paths: [root] });
396
- await run(process.execPath, [nextBin, 'build', ...nextArgs], { root, env: { NEXT_STYLES: 'routes' } });
394
+ await run(process.execPath, [nextBin, 'build', ...nextArgs], {
395
+ root,
396
+ env: { NEXT_STYLES: 'routes', NEXT_STYLES_OPTIONS: JSON.stringify(options) },
397
+ });
397
398
  if (!fs.existsSync(join(stylesDir, BUILD_INFO))) {
398
399
  throw new Error('next-styles: the build did not record its settings: wrap next.config with withRouteStyles');
399
400
  }
400
- return writeRouteStyles({ root, ...options });
401
+ const manifest = JSON.parse(fs.readFileSync(join(stylesDir, MANIFEST), 'utf8'));
402
+ if (manifest.pending) throw new Error('next-styles: the build ran without its route step (the build adapter)');
403
+ const info = JSON.parse(fs.readFileSync(join(stylesDir, BUILD_INFO), 'utf8'));
404
+ for (const copy of standaloneStyleDirs(root, info)) {
405
+ fs.rmSync(copy, { recursive: true, force: true });
406
+ fs.cpSync(stylesDir, copy, { recursive: true });
407
+ }
408
+ return manifest;
401
409
  }
402
410
 
403
411
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.834",
3
+ "version": "0.0.836",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "sideEffects": [