• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

clay / claycli / 26611081313

29 May 2026 12:45AM UTC coverage: 87.096% (+0.2%) from 86.85%
26611081313

Pull #252

github

jjpaulino
🍕 Inline client process.env vars into Vite bundle at build time

CJS modules that read process.env.X at load time compile to an empty
`var pVt = {}` stub under @rollup/plugin-commonjs, so reads like
process.env.PYXIS_HOST resolve to undefined and never reach the
runtime-hydrated window.process.env. This silently broke every Kiln
plugin / universal service that reads env at module eval (pyxis, agora,
glaze, …) — they worked in the legacy pipeline only because dotenv-webpack
inlined those reads as string literals.

Extend buildDefines() to inline client-facing process.env vars via Vite's
define map, mirroring dotenv-webpack. The var list comes from
client-env.json (the same browser surface amphora-html already forwards to
window.process.env), with a cold-build fallback to all valid env keys so a
fresh CI checkout still inlines. define only substitutes referenced tokens,
so an over-inclusive list never leaks an unreferenced secret into the bundle.

Add focused unit tests for inlining, the cold-build fallback, and the
invalid-identifier filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pull Request #252: 🍕 Add Kiln plugin init diagnostics for Vite

764 of 949 branches covered (80.51%)

Branch coverage included in aggregate %.

43 of 43 new or added lines in 2 files covered. (100.0%)

56 existing lines in 1 file now uncovered.

1632 of 1802 relevant lines covered (90.57%)

13.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.69
/lib/cmd/vite/scripts.js
1
'use strict';
2

3
// claycli is a CommonJS package, so we call Vite via require().
4
// Vite 5 ships a full CJS build alongside its ESM build; this warning only
5
// fires when Vite itself is imported as CJS, not when it *builds* CJS code.
6
process.env.VITE_CJS_IGNORE_WARNING = 'true';
23✔
7

8
const vite = require('vite');
23✔
9
const fs = require('fs-extra');
23✔
10
const path = require('path');
23✔
11

12
const { getConfigValue } = require('../../config-file-helpers');
23✔
13
const viteBrowserCompatPlugin = require('./plugins/browser-compat');
23✔
14
const viteServiceRewritePlugin = require('./plugins/service-rewrite');
23✔
15
const viteMissingModulePlugin = require('./plugins/missing-module');
23✔
16
const viteVue2Plugin = require('./plugins/vue2');
23✔
17
const viteManualChunksPlugin = require('./plugins/manual-chunks');
23✔
18
const { createClientEnvCollector } = require('./plugins/client-env');
23✔
19

20
const { generateViteBootstrap, VITE_BOOTSTRAP_FILE, VITE_BOOTSTRAP_KEY } = require('./generate-bootstrap');
23✔
21
const { generateViteKilnEditEntry, KILN_EDIT_ENTRY_FILE, KILN_EDIT_ENTRY_KEY } = require('./generate-kiln-edit');
23✔
22
const { generateViteGlobalsInit } = require('./generate-globals-init');
23✔
23

24
const { buildStyles, SRC_GLOBS: STYLE_GLOBS } = require('./styles');
23✔
25
const { buildFonts, FONTS_SRC_GLOB } = require('./fonts');
23✔
26
const { buildTemplates, TEMPLATE_GLOB_PATTERN } = require('./templates');
23✔
27
const { copyVendor } = require('./vendor');
23✔
28
const { copyMedia } = require('./media');
23✔
29

30
const CWD = process.cwd();
23✔
31
const DEST = path.join(CWD, 'public', 'js');
23✔
32
const CLAY_DIR = path.join(CWD, '.clay');
23✔
33
const BUILD_STEP_NAMES = new Set(['js', 'styles', 'fonts', 'templates', 'vendor', 'media']);
23✔
34

35
exports.VITE_BOOTSTRAP_KEY = VITE_BOOTSTRAP_KEY;
23✔
36
exports.KILN_EDIT_ENTRY_KEY = KILN_EDIT_ENTRY_KEY;
23✔
37

38
function normalizeRequestedSteps(only) {
39
  if (!only || Array.isArray(only) && only.length === 0) return null;
4✔
40

41
  // Accept either repeated flags (--only js --only styles) or CSV
42
  // (--only js,styles), then normalize to one deduped Set.
43
  const entries = (Array.isArray(only) ? only : [only])
2!
44
    .flatMap(v => String(v).split(','))
2✔
45
    .map(v => v.trim())
2✔
46
    .filter(Boolean);
47

48
  // "all" means "no filtering" so buildAll can reuse its normal path.
49
  if (entries.includes('all')) return null;
2!
50

51
  const requested = entries.reduce((set, name) => {
2✔
52
    if (BUILD_STEP_NAMES.has(name)) set.add(name);
2!
53
    return set;
2✔
54
  }, new Set());
55

56
  return requested.size > 0 ? requested : null;
2!
57
}
58

59
function buildSelectedParallelTasks(step, shouldRun, options) {
60
  // Keep the existing parallel scheduling model; this only filters which
61
  // steps participate in the Promise.all fan-out.
62
  return [
4✔
63
    ['js', () => buildJS(options)],
2✔
64
    ['styles', () => buildStyles(options)],
3✔
65
    ['fonts', () => buildFonts()],
2✔
66
    ['templates', () => buildTemplates(options)],
3✔
67
    ['vendor', () => copyVendor()],
2✔
68
  ]
69
    .filter(([name]) => shouldRun(name))
20✔
70
    .map(([name, runner]) => step(name, runner));
12✔
71
}
72

73
function shouldRunMediaStep(shouldRun) {
74
  // Templates can inline SVG/media files from public/media, so media remains
75
  // a hard prerequisite even when the user requested templates only.
76
  return shouldRun('media') || shouldRun('templates');
4✔
77
}
78

79
// ── Config helpers ──────────────────────────────────────────────────────────
80

81
/**
82
 * Read and apply the bundlerConfig() customizer from claycli.config.js.
83
 *
84
 * bundlerConfig() is the single hook for customizing the Vite build pipeline.
85
 * Plugins use the standard Rollup plugin API (resolveId, load, transform) since
86
 * Vite's production build uses Rollup internally.
87
 *
88
 * Config shape (all keys optional):
89
 *
90
 *   {
91
 *     minify:             false,
92
 *     extraEntries:       [],
93
 *     manualChunksMinSize: undefined, // bytes — controls chunk merging threshold.
94
 *                                  // Not set by default: omitting it means no
95
 *                                  // inlining/merging (equivalent to 0).
96
 *                                  // Suggested values for CJS/mixed codebases:
97
 *                                  //   4096  (4 KB) — conservative, fewer merges
98
 *                                  //   8192  (8 KB) — balanced, recommended starting point
99
 *                                  // In CJS mode (clientFilesESM:false) this feeds
100
 *                                  // viteManualChunksPlugin which inlines small private
101
 *                                  // deps into their owner chunk.
102
 *                                  // In ESM mode (clientFilesESM:true) this maps to
103
 *                                  // Rollup's native experimentalMinChunkSize which
104
 *                                  // merges small chunks across the whole graph.
105
 *     clientFilesESM:     false,  // set true once all client.js files and their deps
106
 *                                  // are native ESM.  Switches the view-mode pass from
107
 *                                  // viteManualChunksPlugin to Rollup's native
108
 *                                  // experimentalMinChunkSize (driven by manualChunksMinSize).
109
 *                                  // Does NOT affect the kiln pass — use kilnSplit for that.
110
 *     kilnSplit:          false,  // set true once all model.js/kiln.js are ESM —
111
 *                                  // collapses the two-pass build into one graph.
112
 *     define:             {},     // identifier replacements merged on top of built-in
113
 *                                  // defines (process.env.NODE_ENV, __dirname, etc.)
114
 *     alias:              {},     // module path aliases applied at resolution time.
115
 *                                  // Equivalent to Vite's resolve.alias.  Keys are
116
 *                                  // bare specifiers or path prefixes; values are
117
 *                                  // absolute paths or replacement specifiers:
118
 *                                  //   '@sentry/node': '@sentry/browser'
119
 *                                  //   '@utils': path.resolve(__dirname, 'src/utils')
120
 *                                  // For complex patterns (regex, onResolve hooks),
121
 *                                  // add a Rollup plugin via `plugins` instead.
122
 *     sourcemap:          true,   // emit .js.map files alongside every output chunk.
123
 *                                  // Keeps DevTools stack traces symbolicated and lets
124
 *                                  // error monitoring (Sentry) map runtime errors to
125
 *                                  // source lines.  Disable to save build time in CI
126
 *                                  // environments where sourcemaps are not consumed:
127
 *                                  //   config.sourcemap = false;
128
 *     plugins:            [],     // extra Rollup/Vite plugins appended after built-ins
129
 *     commonjsExclude:    [],     // patterns passed to @rollup/plugin-commonjs `exclude`
130
 *                                  // for CJS packages whose internal eval() scope must
131
 *                                  // not be rewritten (e.g. webpack dev bundles like
132
 *                                  // pyxis-frontend that use eval() for modules)
133
 *     browserStubs:       {},     // site-specific Node.js module stubs for the browser
134
 *                                  // bundle.  Keys are module names; values are either
135
 *                                  // null (empty-object stub) or a custom ESM string:
136
 *                                  //   'ioredis': null
137
 *                                  //   'mongodb': 'export default { connect: function() {} };'
138
 *                                  // Site stubs override built-ins when names collide.
139
 *   }
140
 *
141
 * @param {object} [cliOptions]
142
 * @returns {object}
143
 */
144
function getViteConfig(cliOptions = {}) {
×
145
  const config = {
6✔
146
    minify:              cliOptions.minify || false,
12✔
147
    extraEntries:        cliOptions.extraEntries || [],
12✔
148
    manualChunksMinSize: undefined,
149
    clientFilesESM:      false,
150
    kilnSplit:           false,
151
    define:              {},
152
    alias:               {},
153
    sourcemap:           true,
154
    plugins:             [],
155
    commonjsExclude:     [],
156
    browserStubs:        {},
157
    // When true, replace Vite's throwing `__vite-browser-external` proxy with
158
    // an empty ESM module so unresolved Node-only imports behave like
159
    // Browserify (silent undefined) instead of throwing at runtime on the
160
    // first property access. Intended as a migration flag while latent
161
    // server-only imports are tracked down in isomorphic code (cheerio,
162
    // postcss, @google/maps, etc.). Default off so real problems surface.
163
    lenientBrowserExternalize: false,
164
  };
165

166
  // Read bundlerConfig() from claycli.config.js — the single hook for
167
  // customizing the Vite build pipeline.
168
  const customizer = getConfigValue('bundlerConfig');
6✔
169

170
  if (typeof customizer === 'function') {
6✔
171
    const customized = customizer(config);
2✔
172

173
    if (customized && typeof customized === 'object') return customized;
2!
174
  }
175

176
  return config;
4✔
177
}
178

179
exports.getViteConfig = getViteConfig;
23✔
180

181
/**
182
 * Build the `process.env.<VAR>` define map that inlines client-facing env vars
183
 * into the bundle at build time.
184
 *
185
 * ── Why this exists (Vite vs the legacy pipeline) ───────────────────────────
186
 *
187
 * The legacy webpack `pack` pipeline used dotenv-webpack (DotenvPlugin with
188
 * `systemvars: true`), which REPLACED every `process.env.FOO` reference with a
189
 * string literal at build time.  So a module-level `const HOST = process.env.FOO`
190
 * captured a baked value that could never be undefined and was immune to any
191
 * runtime mutation of `window.process.env`.
192
 *
193
 * Vite does not do this out of the box — only NODE_ENV and a few Node stubs are
194
 * defined (see buildDefines).  Every other `process.env.FOO` is left untouched,
195
 * and @rollup/plugin-commonjs then rewrites the bare `process.env` of a CJS
196
 * module into a build-time-empty local stub (literally `var pVt = {}`).  So
197
 * `const HOST = process.env.PYXIS_HOST || 'https://pyxis.nymag.com'` compiles to
198
 * `pVt.PYXIS_HOST || 'https://pyxis.nymag.com'` → always the fallback.  Crucially
199
 * this stub is NOT `window.process.env`, so the runtime hydration in
200
 * .clay/_env-init.js can never reach it — the read is dead at build time.  This
201
 * silently breaks every Kiln plugin / universal service that reads env at load
202
 * time (pyxis.js, glaze/agora helpers, …): they work in the old pipeline but not
203
 * under Vite. Only build-time inlining (define) can reach these reads.
204
 *
205
 * To restore the legacy behaviour we inline the SAME set of vars dotenv-webpack
206
 * would have. The var list is sourced from client-env.json (the exact browser
207
 * surface the clay-client-env collector found in the Rollup graph — also the
208
 * list amphora-html forwards to window.process.env, so no new exposure), with a
209
 * safe fallback for clean/CI builds (see buildClientEnvDefines).  Values are read
210
 * from the build process's own env (matching dotenv-webpack systemvars).
211
 *
212
 * Notes:
213
 * - Only dot-access reads (`process.env.FOO`) are inlined — that covers the
214
 *   plugin/universal `const X = process.env.X` pattern.  Whole-object use
215
 *   (`Object.assign({}, process.env)`) and dynamic keys are left to the existing
216
 *   runtime hydration (_env-init + window.process.env are untouched).
217
 * - define only substitutes a value where the `process.env.FOO` token actually
218
 *   appears in compiled browser code, so an over-inclusive var list never leaks
219
 *   an unreferenced secret into the bundle.
220
 *
221
 * @returns {object} map of `process.env.<VAR>` → JSON-stringified value
222
 */
223
function buildClientEnvDefines() {
224
  const defines = {};
11✔
225

226
  // Only [A-Za-z_][A-Za-z0-9_]* keys are valid as `process.env.<id>` define
227
  // members. Filters out npm-injected junk like `npm_package_dependencies_@babel/core`.
228
  const isValidEnvName = name => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
1,393✔
229

230
  // Source of truth for "which vars are browser-facing":
231
  //
232
  //   1. client-env.json — the exact set the clay-client-env collector found in
233
  //      the Rollup browser graph on the previous build. This is the same list
234
  //      amphora-html forwards to window.process.env, so inlining it adds no new
235
  //      exposure surface. Preferred when present (precise, minimal define map).
236
  //
237
  //   2. Cold build (no client-env.json — e.g. fresh CI checkout): fall back to
238
  //      every valid process.env key. This is SAFE because Vite's `define` only
239
  //      substitutes a value where the `process.env.<VAR>` token actually appears
240
  //      in compiled browser code. A secret only lands in the bundle if browser
241
  //      code already references it — identical to the legacy dotenv-webpack
242
  //      pipeline and to the current runtime-forwarding behaviour. Without this
243
  //      fallback, the first/only build of a clean checkout would inline nothing
244
  //      and every env-reading plugin would break in production.
245
  let names;
246

247
  try {
11✔
248
    const parsed = JSON.parse(fs.readFileSync(path.join(CWD, 'client-env.json'), 'utf8'));
11✔
249

250
    names = Array.isArray(parsed) ? parsed : Object.keys(process.env);
1!
251
  } catch (_) {
252
    names = Object.keys(process.env);
10✔
253
  }
254

255
  for (const name of names) {
11✔
256
    // NODE_ENV is handled explicitly in buildDefines; don't double-define it.
257
    if (name === 'NODE_ENV' || !isValidEnvName(name)) continue;
1,404✔
258

259
    const value = process.env[name];
1,392✔
260

261
    defines[`process.env.${name}`] = JSON.stringify(value === undefined ? '' : value);
1,392!
262
  }
263

264
  return defines;
11✔
265
}
266

267
/**
268
 * Build the define map injected into every module at compile time.
269
 *
270
 * Vite uses esbuild under the hood for define substitution, so these are
271
 * identifier-scoped replacements (not substring replacements like sed).
272
 * Only meaningful identifiers are replaced — string literals are never touched.
273
 *
274
 * Node globals (process, __filename, etc.) appear in isomorphic Clay services
275
 * that run on both the server and in the browser.  Stubbing them here lets the
276
 * browser bundle compile without a polyfill, while the real Node values are
277
 * used at runtime on the server.
278
 *
279
 * @param {object} userDefines - extra defines from bundlerConfig()
280
 * @returns {object}
281
 */
282
function buildDefines(userDefines = {}) {
2✔
283
  const NODE_ENV = process.env.NODE_ENV || 'development';
11!
284

285
  return Object.assign(
11✔
286
    {
287
      // Guards like `if (process.env.NODE_ENV === 'production')` tree-shake
288
      // correctly in the browser bundle.
289
      'process.env.NODE_ENV': JSON.stringify(NODE_ENV),
290

291
      // process.browser is a convention used by some isomorphic libraries to
292
      // branch between Node and browser paths.
293
      'process.browser': JSON.stringify(true),
294

295
      // process.version and process.versions are read by clay-log to detect
296
      // the runtime environment. Stub to empty values so the browser branch
297
      // is taken rather than the Node branch.
298
      'process.version':  JSON.stringify(''),
299
      'process.versions': JSON.stringify({}),
300

301
      // __filename and __dirname are used in some server-side Clay utilities.
302
      // In the browser bundle they are never accessed at runtime, but they
303
      // must resolve to something so the module compiles.
304
      __filename: JSON.stringify(''),
305
      __dirname:  JSON.stringify('/'),
306

307
      // global → globalThis. Some older CJS packages reference global instead
308
      // of window. globalThis is universally available in ES2017+ environments.
309
      global: 'globalThis',
310
    },
311
    // Inline client-facing process.env.<VAR> reads at build time, mirroring the
312
    // legacy dotenv-webpack pipeline.  Must come before userDefines so a site
313
    // can still override a specific var via bundlerConfig().define if needed.
314
    buildClientEnvDefines(),
315
    userDefines
316
  );
317
}
318

319
exports.buildDefines = buildDefines;
23✔
320

321
/**
322
 * Assemble the Vite plugin array for a build pass.
323
 *
324
 * Plugin order matters because each runs in sequence on every resolved module:
325
 *
326
 *   1. browser-compat  — intercepts Node built-in imports (fs, path, events …)
327
 *                        and replaces them with browser-safe stubs BEFORE any
328
 *                        other plugin or Vite's resolver sees them.  Runs first
329
 *                        because some node_modules transitively import built-ins
330
 *                        and must be redirected at resolution time.
331
 *
332
 *   2. service-rewrite — redirects any import of services/server/* to the
333
 *                        matching services/client/* file.  Clay components use
334
 *                        isomorphic service paths; the browser bundle always gets
335
 *                        the client implementation.
336
 *
337
 *   3. missing-module  — stubs unresolvable relative imports with an empty ESM
338
 *                        module instead of erroring.  Browserify silently skipped
339
 *                        missing requires; this plugin preserves that lenient
340
 *                        behaviour while the codebase is still being cleaned up.
341
 *
342
 *   4. vue2            — compiles .vue Single File Components using Vue 2's
343
 *                        template compiler.  Must run before Vite's JS pipeline
344
 *                        so that .vue files are converted to plain JS before
345
 *                        @rollup/plugin-commonjs processes any require() calls
346
 *                        in the <script> block.
347
 *
348
 *   5. user plugins    — site-specific overrides from bundlerConfig().plugins,
349
 *                        appended last so they can override any of the above.
350
 *
351
 * Note: the client-env collector plugin (clay-client-env) is NOT assembled
352
 * here.  It is created per-build in buildJS/watch via createClientEnvCollector
353
 * and injected as an internalPlugins argument to runViewBuild/runKilnBuild.
354
 * This keeps the collector separate from the user-facing plugin list.
355
 *
356
 * CJS→ESM conversion is handled by Vite's single built-in @rollup/plugin-commonjs
357
 * instance, configured in baseViteConfig() via build.commonjsOptions.  A second
358
 * instance must NOT be added here — two commonjs instances each maintain their
359
 * own internal virtual-module state (?commonjs-proxy, ?commonjs-wrapped …) and
360
 * will leave require() calls untransformed in the final bundle.
361
 *
362
 * @param {object[]} [extraPlugins]
363
 * @param {object}   [browserStubs]  site stubs from bundlerConfig().browserStubs
364
 * @param {object}   [opts]
365
 * @param {boolean}  [opts.lenientBrowserExternalize=false]  forwarded to
366
 *   viteBrowserCompatPlugin; see that plugin's JSDoc for semantics.
367
 * @returns {object[]}
368
 */
369
function buildPlugins(extraPlugins = [], browserStubs = {}, opts = {}) {
×
370
  return [
9✔
371
    viteBrowserCompatPlugin(browserStubs, { lenientExternalize: opts.lenientBrowserExternalize === true }),
372
    viteServiceRewritePlugin(),
373
    viteMissingModulePlugin(),
374
    viteVue2Plugin(),
375
    ...extraPlugins,
376
  ];
377
}
378

379
// ── Manifest ────────────────────────────────────────────────────────────────
380

381
/**
382
 * Build _manifest.json from one or two Vite RollupOutput results.
383
 *
384
 * viewOutput  — the splitting pass (bootstrap + component chunks)
385
 * kilnOutput  — the single-file kiln edit pass (null when kilnSplit is true)
386
 *
387
 * The manifest shape { key: { file, imports } } is the contract between the
388
 * build pipeline and resolve-media.js.  Every pipeline (Browserify included)
389
 * must produce a compatible manifest so the runtime asset injector works
390
 * without knowing which bundler was used.
391
 *
392
 * @param {Object|null} viewOutput
393
 * @param {Object|null} kilnOutput
394
 * @param {string} publicBase
395
 * @returns {object}
396
 */
397
function buildManifest(viewOutput, kilnOutput = null, publicBase = '/js') {
6!
398
  const manifest = {};
6✔
399

400
  for (const output of [viewOutput, kilnOutput]) {
6✔
401
    for (const chunk of output ? output.output : []) {
12✔
402
      if (chunk.type !== 'chunk' || !chunk.isEntry) continue;
8!
403

404
      const facadeId = chunk.facadeModuleId;
8✔
405

406
      if (!facadeId) continue;
8!
407

408
      const cleanFacadeId = facadeId.replace(/\?.*$/, '');
8✔
409
      const entryKey = path.relative(CWD, cleanFacadeId)
8✔
410
        .replace(/\\/g, '/')
411
        .replace(/\.js$/, '');
412

413
      const fileUrl    = `${publicBase}/${chunk.fileName.replace(/\\/g, '/')}`;
8✔
414
      const importUrls = (chunk.imports || []).map(imp => `${publicBase}/${imp.replace(/\\/g, '/')}`);
8!
415

416
      manifest[entryKey] = { file: fileUrl, imports: importUrls };
8✔
417
    }
418
  }
419

420
  return manifest;
6✔
421
}
422

423
async function writeManifest(manifest) {
424
  const manifestPath = path.join(DEST, '_manifest.json');
6✔
425

426
  await fs.outputJson(manifestPath, manifest, { spaces: 2 });
6✔
427
}
428

429
// ── Vite build config factory ────────────────────────────────────────────────
430

431
/**
432
 * Return the base Vite config shared by both build passes (view and kiln).
433
 *
434
 * ── Why Vite for production builds ──────────────────────────────────────────
435
 *
436
 * The legacy Browserify pipeline bundled all component JavaScript into a
437
 * handful of monolithic files.  Every page load re-downloaded the full bundle
438
 * even if the user had visited before, and every deploy invalidated the cache
439
 * for all pages simultaneously.
440
 *
441
 * Vite's production build uses Rollup under the hood to emit native ES modules
442
 * with content-hashed filenames.  Components are loaded on demand via
443
 * dynamic import(), so the browser only fetches the code each page actually
444
 * needs.  Unchanged modules keep their hash across deploys and are served
445
 * straight from the browser cache on repeat visits.
446
 *
447
 * ── Why native ESM output ────────────────────────────────────────────────────
448
 *
449
 * Browserify emitted a single synchronous IIFE bundle — the browser had to
450
 * parse and evaluate all component code before any component could mount,
451
 * which directly raised Time to Interactive and Total Blocking Time.
452
 *
453
 * Native ESM allows the browser to parse modules in parallel and defer
454
 * evaluation of off-screen components until they are actually needed.
455
 *
456
 * ── Migration doors ─────────────────────────────────────────────────────────
457
 *
458
 * CSS (Lightning CSS):
459
 *   The styles step currently uses PostCSS via buildStyles().
460
 *   When ready to migrate, add:
461
 *     css: { transformer: 'lightningcss', lightningcssOptions: { ... } }
462
 *   to the returned config object and remove the PostCSS step from buildAll().
463
 *   The `lightningcss` package must be added as a dependency.
464
 *   Lightning CSS is faster and handles modern CSS features (nesting, color-mix,
465
 *   etc.) natively without PostCSS plugins.
466
 *
467
 * Vue 3:
468
 *   The vue2 plugin handles .vue files today.  To start writing new components
469
 *   in Vue 3, add @vitejs/plugin-vue to bundlerConfig().plugins in claycli.config.js:
470
 *     import vuePlugin from '@vitejs/plugin-vue';
471
 *     config.plugins.push(vuePlugin());
472
 *   Both plugins can coexist — vue2 handles legacy SFCs, @vitejs/plugin-vue
473
 *   handles new ones (differentiate by directory or a file-naming convention).
474
 *   Once all .vue files are migrated to Vue 3, remove viteVue2Plugin().
475
 *
476
 * ESM migration:
477
 *   As source files are converted from require()/module.exports to import/export,
478
 *   the CJS shims shrink automatically:
479
 *     - @rollup/plugin-commonjs (commonjsOptions) becomes a no-op per migrated file
480
 *     - strictRequires entries drop as circular require() cycles are eliminated
481
 *     - transformMixedEsModules can be removed once all .vue scripts use ESM
482
 *     - kilnSplit can be set true once all model.js/kiln.js files are ESM,
483
 *       collapsing the two-pass build into a single faster graph
484
 *   New components should be written as ESM from day one.
485
 *
486
 * @param {object} viteCfg   — result of getViteConfig()
487
 * @returns {object}
488
 */
489
function baseViteConfig(viteCfg) {
490
  // The base path must match the URL prefix under which public/js/ is served.
491
  // Vite embeds this into dynamic import() calls inside the bootstrap so that
492
  // chunk URLs resolve to /js/chunks/… rather than just /chunks/….
493
  const publicBase = (viteCfg.publicBase || '/js').replace(/\/$/, '');
9✔
494

495
  return {
9✔
496
    root:       CWD,
497
    base:       publicBase + '/',
498
    configFile: false, // always use this programmatic config; never read vite.config.js
499
    logLevel:   'warn',
500
    plugins:    buildPlugins(viteCfg.plugins, viteCfg.browserStubs, {
501
      lenientBrowserExternalize: viteCfg.lenientBrowserExternalize === true,
502
    }),
503
    define:     buildDefines(viteCfg.define),
504
    resolve: {
505
      browserField: true,
506

507
      // Field resolution order: prefer the browser-specific build, then the
508
      // CommonJS main entry, then ESM via the module field.
509
      //
510
      // `module` is intentionally last: some packages ship both CJS (main) and
511
      // ESM (module) builds; using the CJS build is safer when @rollup/plugin-commonjs
512
      // is active because it applies the same transformation consistently.
513
      //
514
      // ESM migration lever: once the codebase no longer needs @rollup/plugin-commonjs,
515
      // flip this to ['browser', 'module', 'main'] so Rollup can tree-shake ESM
516
      // packages directly.
517
      mainFields: ['browser', 'main', 'module'],
518

519
      // Site-defined module aliases from bundlerConfig().alias.
520
      // This is the first-class alternative to writing a full Rollup plugin just
521
      // to redirect a specifier.  Common use-cases:
522
      //   - swap a server-only package for its browser equivalent at build time
523
      //     (e.g. '@sentry/node' → '@sentry/browser')
524
      //   - point a bare specifier at an absolute path
525
      //     (e.g. '@utils' → path.resolve(__dirname, 'src/utils'))
526
      // For complex patterns (regex, conditional logic), use bundlerConfig().plugins
527
      // with a resolveId hook instead.
528
      alias: viteCfg.alias || {},
9!
529
    },
530

531
    // optimizeDeps is Vite's pre-bundler that converts CJS node_modules to ESM using
532
    // esbuild before Rollup processes them.  We use Vite exclusively for production builds
533
    // (vite build) — the Vite dev server and HMR are not used.  Clay runs a server-rendered
534
    // architecture (Amphora) where watch mode uses Rollup's own incremental rebuild, not a
535
    // Vite dev server in the request path.
536
    //
537
    // optimizeDeps does NOT run during `vite build` — production builds go straight through
538
    // Rollup, which handles CJS via @rollup/plugin-commonjs (configured below).
539
    // Setting noDiscovery:true prevents accidental dep scanning that can add latency
540
    // to build startup in some Vite versions.
541
    optimizeDeps: { noDiscovery: true, include: [] },
542

543
    build: {
544
      // Target ES2017 (async/await, Object.assign, etc.) — supported by all
545
      // browsers we care about.  This lets Rollup emit clean async code without
546
      // transpiling it to generator functions.
547
      //
548
      // ESM migration note: bump to 'es2020' when ready to use optional chaining
549
      // and nullish coalescing natively in the output (Vue 3 recommends es2020+).
550
      target: 'es2017',
551

552
      outDir:      DEST,
553
      emptyOutDir: false, // we write into public/js/ which already exists
554
      // Configurable via bundlerConfig().sourcemap.  Defaults to true so that
555
      // DevTools stack traces and Sentry source mapping work out of the box.
556
      // Set to false in CI pipelines that do not consume source maps to shave
557
      // a few seconds off the build without changing runtime behaviour.
558
      sourcemap:   viteCfg.sourcemap !== false,
559
      minify:      viteCfg.minify ? 'esbuild' : false,
9!
560

561
      // Skips the extra gzip pass Vite performs after bundling just to print the
562
      // compressed sizes in the terminal.  That pass is never free — on a large
563
      // codebase it adds 1–3 s.  The uncompressed sizes are enough to catch
564
      // regressions during development; use a dedicated bundle-analysis script
565
      // (scripts/perf/01-bundle-analysis.js) for accurate gzip numbers.
566
      reportCompressedSize: false,
567

568
      // We handle CSS extraction ourselves: component .css files go through the
569
      // PostCSS step in buildStyles(), and .vue scoped styles are injected at
570
      // runtime by viteVue2Plugin().  Letting Vite split CSS would generate
571
      // separate .css chunks that nothing requests.
572
      cssCodeSplit: false,
573

574
      // public/js/ lives inside public/ which is also the publicDir.  Vite warns
575
      // when outDir is inside publicDir because it tries to copy publicDir into
576
      // outDir at the end of the build.  We disable that copy entirely — static
577
      // assets are managed by copyMedia/copyVendor/buildFonts.
578
      copyPublicDir: false,
579

580
      // Suppress the default 500 KB chunk size warning.  The kiln edit bundle
581
      // is intentionally large (all component models + kiln plugins in one file)
582
      // because it only loads in edit mode, not on public pages.
583
      chunkSizeWarningLimit: 10000,
584

585
      // Don't inject a modulepreload polyfill.  We target ES2017+ browsers that
586
      // all support <link rel="modulepreload"> natively.  The polyfill is ~2 KB
587
      // of dead code for our audience.
588
      modulePreload: { polyfill: false },
589

590
      // Configure Vite's single built-in @rollup/plugin-commonjs instance.
591
      //
592
      // This is the bridge between the CJS-heavy existing codebase and Rollup's
593
      // native ESM module graph.  Every require() call in source files is
594
      // converted to an ESM import so Rollup can tree-shake and bundle correctly.
595
      // Browserify handled CJS implicitly; here it is explicit and configurable.
596
      //
597
      // IMPORTANT: do not add a second @rollup/plugin-commonjs instance via plugins[].
598
      // Each instance tracks its own set of ?commonjs-* virtual modules, so two
599
      // instances conflict and leave require() calls in the output.
600
      commonjsOptions: {
601
        // Apply to all JS, CJS, and .vue files.  Two checks must pass inside
602
        // @rollup/plugin-commonjs: the `include` regex (createFilter check) and
603
        // the `extensions` list (path.extname check).
604
        include:    /\.(js|cjs|vue)$/,
605
        extensions: ['.js', '.cjs', '.vue'],
606

607
        // Required for .vue files: viteVue2Plugin always appends `export default __sfc__`
608
        // regardless of whether the <script> block used require() or ESM, so every
609
        // compiled .vue file is "mixed" (CJS body + ESM export default).  Without
610
        // this flag, @rollup/plugin-commonjs would skip the require() conversion and
611
        // leave bare require() calls in the browser bundle.
612
        //
613
        // For plain .js files: mixing require() and import/export in the same file
614
        // is prohibited — files must be either pure CJS or pure ESM.
615
        transformMixedEsModules: true,
616

617
        // requireReturnsDefault: 'preferred' — when CJS code does `const x = require('y')`,
618
        // return y.default if it exists, otherwise return the whole module object.
619
        // This matches the natural expectation from CJS code that doesn't know about
620
        // ESM default exports, and avoids `.default.method()` call-site surprises.
621
        requireReturnsDefault: 'preferred',
622

623
        // strictRequires: 'auto' — for modules involved in circular require() chains
624
        // (e.g. services/client/auth.js ↔ services/client/gtm.js), wrap the require()
625
        // calls in lazy getters so both modules can fully initialize before either
626
        // reads the other's exports.  'auto' applies this only to modules that
627
        // @rollup/plugin-commonjs detects as part of a cycle, leaving all other
628
        // require() calls as direct synchronous calls (no overhead).
629
        strictRequires: 'auto',
630

631
        // Sites can exclude specific packages via commonjsExclude in bundlerConfig().
632
        // Use this for packages that use eval() internally in ways that break when
633
        // @rollup/plugin-commonjs rewrites their module scope (e.g. webpack dev-mode
634
        // bundles where eval() strings reference `exports` as a function parameter
635
        // that gets renamed by the CJS transformation).
636
        exclude: viteCfg.commonjsExclude || [],
9!
637
      },
638
    },
639
  };
640
}
641

642
// ── Build passes ────────────────────────────────────────────────────────────
643

644
/**
645
 * Return the output.manualChunks / output.experimentalMinChunkSize entry for
646
 * rollupOptions.output based on whether the codebase is fully ESM.
647
 *
648
 * CJS mode (clientFilesESM:false): @rollup/plugin-commonjs injects \0-prefixed
649
 * virtual proxy modules into the graph which inflate apparent module sizes and
650
 * add phantom importer edges.  Rollup's native experimentalMinChunkSize would
651
 * produce inaccurate merges in this environment.  viteManualChunksPlugin guards
652
 * against virtual modules explicitly and uses info.code.length as an honest size
653
 * proxy for pre-minification CJS source.
654
 *
655
 * ESM mode (clientFilesESM:true): the graph is clean — no proxy modules, no CJS
656
 * wrapper boilerplate.  Rollup's native experimentalMinChunkSize is accurate and
657
 * optimal; viteManualChunksPlugin is no longer needed.  manualChunksMinSize maps
658
 * directly to the native threshold (0 = no merging if the site leaves it unset).
659
 *
660
 * @param {object} viteCfg
661
 * @returns {object}
662
 */
663
function buildChunkingOutput(viteCfg) {
664
  const minSize = viteCfg.manualChunksMinSize ?? 0;
5✔
665

666
  if (viteCfg.clientFilesESM) {
5!
UNCOV
667
    return { experimentalMinChunkSize: minSize };
×
668
  }
669

670
  return { manualChunks: viteManualChunksPlugin(minSize, CWD) };
5✔
671
}
672

673

674
/**
675
 * Pass 1 — view mode (splitting pass).
676
 *
677
 * Builds the bootstrap entry point plus any extra entries.  Rollup splits the
678
 * module graph into shared chunks — each shared dependency gets its own
679
 * content-hashed file that the browser can cache independently.
680
 *
681
 * Compared to Browserify's single bundle, the browser only downloads the code
682
 * for components present on the current page, and unchanged modules are served
683
 * from cache on subsequent page loads.
684
 *
685
 * When kilnSplit is false (the default while model.js files are still CJS),
686
 * the kiln-edit entry is excluded from this graph to prevent CJS model.js
687
 * dependencies from polluting the view-mode chunk set.  Set kilnSplit:true
688
 * in bundlerConfig() once all model.js/kiln.js files use ESM.
689
 *
690
 * @param {object} viteCfg
691
 * @param {object} internalPlugins
692
 * @returns {Promise<Object>}
693
 */
694
async function runViewBuild(viteCfg, internalPlugins) {
695
  const entryMap = {};
4✔
696

697
  entryMap[VITE_BOOTSTRAP_KEY] = VITE_BOOTSTRAP_FILE;
4✔
698

699
  if (viteCfg.kilnSplit) {
4✔
700
    entryMap[KILN_EDIT_ENTRY_KEY] = KILN_EDIT_ENTRY_FILE;
1✔
701
  }
702

703
  for (const extraPath of viteCfg.extraEntries || []) {
4!
704
    if (fs.existsSync(extraPath)) {
1!
705
      const key = path.relative(CWD, extraPath).replace(/\\/g, '/').replace(/\.js$/, '');
1✔
706

707
      entryMap[key] = extraPath;
1✔
708
    }
709
  }
710

711
  const cfg = baseViteConfig(viteCfg);
4✔
712

713
  if (internalPlugins && internalPlugins.length) {
4!
714
    cfg.plugins = cfg.plugins.concat(internalPlugins);
4✔
715
  }
716

717
  cfg.build.rollupOptions = {
4✔
718
    input:  entryMap,
719
    output: {
720
      format:         'esm',
721
      entryFileNames: '[name]-[hash].js',
722
      chunkFileNames: 'chunks/[name]-[hash].js',
723
      // See buildChunkingOutput() for CJS vs ESM branching rationale.
724
      ...buildChunkingOutput(viteCfg),
725
    },
726
    onwarn: suppressWarning,
727
  };
728

729
  const result = await vite.build(cfg);
4✔
730

731
  return Array.isArray(result) ? result[0] : result;
4!
732
}
733

734
// Pre-populates all kiln namespaces with empty objects so that module-level
735
// destructuring patterns like `const { pyxis } = window.kiln.config` in
736
// .vue plugin files don't throw before _initKilnPlugins() runs.
737
// Used by both the production kiln pass (runKilnBuild) and the watch kiln
738
// watcher — defined once here to prevent drift.
739
const KILN_BANNER = [
23✔
740
  '(function(){',
741
  '  var k = window.kiln = window.kiln || {};',
742
  '  k.config  = k.config  || {};',
743
  '  k.config.pyxis = k.config.pyxis || {};',
744
  '  k.utils   = k.utils   || {};',
745
  '  k.utils.components = k.utils.components || {};',
746
  '  k.utils.references = k.utils.references || {};',
747
  '  k.utils.componentElements = k.utils.componentElements || {};',
748
  '  k.utils.logger = k.utils.logger || function(){ return { log:function(){}, error:function(){} }; };',
749
  '  k.inputs  = k.inputs  || {};',
750
  '  k.modals  = k.modals  || {};',
751
  '  k.plugins = k.plugins || {};',
752
  '  k.toolbarButtons = k.toolbarButtons || {};',
753
  '  k.navButtons = k.navButtons || {};',
754
  '  k.navContent = k.navContent || {};',
755
  '  k.validators = k.validators || {};',
756
  '  k.transformers = k.transformers || {};',
757
  '  k.kilnInput = k.kilnInput || function(){};',
758
  '})();',
759
].join('\n');
760

761
/**
762
 * Pass 2 — kiln edit mode (no-split pass).
763
 *
764
 * The kiln-edit-init entry imports every component's model.js and kiln.js.
765
 * These files are CJS today and cannot be tree-shaken, so their transitive
766
 * dependencies (utility libraries, lodash, etc.) would bleed into the
767
 * view-mode chunk graph if this entry were included in the splitting pass.
768
 * Running it as a separate isolated pass with inlineDynamicImports:true
769
 * produces one self-contained file that is only loaded in edit mode.
770
 *
771
 * Edit mode (kiln) is a separate concern from public page rendering — this
772
 * file is never delivered to readers, only to editors inside the CMS.
773
 *
774
 * ESM migration path: once all model.js/kiln.js files are ESM, set
775
 * kilnSplit:true in bundlerConfig().  This adds the kiln entry to the same
776
 * splitting graph as the bootstrap, Rollup tree-shakes across both, and the
777
 * separate kiln pass is no longer needed.
778
 *
779
 * @param {object} viteCfg
780
 * @param {object[]} internalPlugins
781
 * @returns {Promise<Object>}
782
 */
783
async function runKilnBuild(viteCfg, internalPlugins) {
784
  const cfg = baseViteConfig(viteCfg);
3✔
785

786
  if (internalPlugins && internalPlugins.length) {
3!
787
    cfg.plugins = cfg.plugins.concat(internalPlugins);
3✔
788
  }
789

790
  cfg.build.rollupOptions = {
3✔
791
    input:  { [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE },
792
    output: {
793
      format:               'esm',
794
      entryFileNames:       '[name]-[hash].js',
795
      inlineDynamicImports: true,
796
      banner:               KILN_BANNER,
797
    },
798
    onwarn: suppressWarning,
799
  };
800

801
  const result = await vite.build(cfg);
3✔
802

803
  return Array.isArray(result) ? result[0] : result;
3!
804
}
805

806
// ── JS build orchestrator ────────────────────────────────────────────────────
807

808
/**
809
 * Generate all entry files, run both build passes, then write _manifest.json.
810
 *
811
 * Entry generation order:
812
 *   1. generateViteGlobalsInit() and generateViteKilnEditEntry() run in parallel
813
 *      (they write to independent files and have no shared dependencies).
814
 *   2. generateViteBootstrap() runs after globals, because it checks whether
815
 *      .clay/_globals-init.js exists to decide whether to import it.
816
 *
817
 * Build pass order:
818
 *   Both passes (view and kiln) run in parallel via Promise.all.  They write to
819
 *   independent file sets (the kiln entry is excluded from the view graph) so
820
 *   there is no ordering constraint between them.
821
 *
822
 * @param {object} [options]
823
 * @returns {Promise<void>}
824
 */
825
async function buildJS(options = {}) {
3✔
826
  // Globals and kiln entry do not depend on each other — generate concurrently.
827
  await Promise.all([
5✔
828
    generateViteGlobalsInit(),
829
    generateViteKilnEditEntry(),
830
  ]);
831

832
  // Bootstrap depends on globals existing (it checks pathExists before importing).
833
  await generateViteBootstrap();
5✔
834

835
  if (!fs.existsSync(VITE_BOOTSTRAP_FILE)) {
5✔
836
    throw new Error('clay vite: missing .clay/vite-bootstrap.js after prepare.');
1✔
837
  }
838

839
  await fs.ensureDir(DEST);
4✔
840

841
  const viteCfg = getViteConfig(options);
4✔
842
  const envCollector = createClientEnvCollector(path.join(CWD, 'client-env.json'));
4✔
843
  const envPlugin = envCollector.plugin();
4✔
844

845
  let viewOutput, kilnOutput;
846

847
  if (viteCfg.kilnSplit) {
4✔
848
    // Single pass — kiln is in the same Rollup graph as the bootstrap.
849
    // Only safe once all model.js/kiln.js files are native ESM.
850
    viewOutput = await runViewBuild(viteCfg, [envPlugin]);
1✔
851
    kilnOutput = null;
1✔
852
  } else {
853
    // Two passes in parallel — kiln is isolated in its own no-split graph.
854
    // This prevents CJS model.js dependencies from creating phantom shared chunks
855
    // in the view-mode graph that would increase page load request counts.
856
    [viewOutput, kilnOutput] = await Promise.all([
3✔
857
      runViewBuild(viteCfg, [envPlugin]),
858
      runKilnBuild(viteCfg, [envPlugin]),
859
    ]);
860
  }
861

862
  const manifest = buildManifest(viewOutput, kilnOutput);
4✔
863

864
  await writeManifest(manifest);
4✔
865
  await envCollector.write();
4✔
866
}
867

868
exports.buildJS = buildJS;
23✔
869

870
// ── Full build (JS + assets in parallel) ────────────────────────────────────
871

872
/**
873
 * Run all build steps: JS + styles + fonts + templates + vendor + media.
874
 *
875
 * media runs first (sequential) so that SVG files are on disk before the
876
 * templates step tries to inline them via {{{ read 'public/media/…' }}}.
877
 * All remaining steps run in parallel after media completes.
878
 *
879
 * @param {object} [options]
880
 * @returns {Promise<void>}
881
 */
882
async function buildAll(options = {}) {
2✔
883
  const requested = normalizeRequestedSteps(options.only);
4✔
884
  const shouldRun = step => !requested || requested.has(step);
26✔
885
  const runMedia = shouldRunMediaStep(shouldRun);
4✔
886
  const isTTY = process.stdout.isTTY;
4✔
887
  const SPINNER = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
4✔
888

889
  const clr = {
4✔
890
    label: s => `\x1b[36m${s}\x1b[0m`,
31✔
891
    done:  s => `\x1b[32m${s}\x1b[0m`,
17✔
892
    fail:  s => `\x1b[31m${s}\x1b[0m`,
2✔
893
    time:  s => `\x1b[90m${s}\x1b[0m`,
18✔
UNCOV
894
    spin:  s => `\x1b[33m${s}\x1b[0m`,
×
895
  };
896

897
  const states    = new Map();
4✔
898
  const totalStart = Date.now();
4✔
899

900
  let spinFrame = 0,
4✔
901
    timer = null,
4✔
902
    progressUp = false;
4✔
903

904
  function clearSummary() {
905
    if (isTTY && progressUp) { process.stdout.write('\r\x1b[2K'); progressUp = false; }
4!
906
  }
907

908
  function writeSummary() {
UNCOV
909
    if (!isTTY) return;
×
UNCOV
910
    const running = [...states.entries()].filter(([, s]) => !s.done);
×
911

UNCOV
912
    if (!running.length) return;
×
913

UNCOV
914
    const spin  = clr.spin(SPINNER[spinFrame % SPINNER.length]);
×
UNCOV
915
    const parts = running.map(([l]) => clr.label(`[${l}]`));
×
UNCOV
916
    const total = ((Date.now() - totalStart) / 1000).toFixed(1);
×
917

UNCOV
918
    process.stdout.write(`${spin} ${parts.join(' ')} ${clr.time(`(${total}s)`)}`);
×
UNCOV
919
    progressUp = true;
×
920
  }
921

922
  function startStep(label) {
923
    states.set(label, { start: Date.now(), done: false, error: false });
15✔
924
    if (!isTTY) process.stdout.write(`  ${clr.label(`[${label}]`)} starting...\n`);
15!
925
  }
926

927
  function finishStep(label, error = false) {
14✔
928
    const s = states.get(label);
15✔
929

930
    if (s) { s.done = true; s.error = error; s.elapsed = ((Date.now() - s.start) / 1000).toFixed(1); }
15!
931

932
    const icon = error ? clr.fail('✗') : clr.done('✓');
15✔
933
    const word = error ? 'failed' : 'done  ';
15✔
934

935
    if (isTTY) {
15!
UNCOV
936
      clearSummary();
×
UNCOV
937
      process.stdout.write(`${icon} ${clr.label(`[${label}]`)} ${word} ${clr.time(`(${s ? s.elapsed : '?'}s)`)}\n`);
×
938
      if ([...states.values()].every(v => v.done)) {
×
UNCOV
939
        clearInterval(timer);
×
UNCOV
940
        timer = null;
×
941
      } else {
UNCOV
942
        writeSummary();
×
943
      }
944
    } else {
945
      process.stdout.write(`${icon} ${clr.label(`[${label}]`)} ${word} ${clr.time(`(${s ? s.elapsed : '?'}s)`)}\n`);
15!
946
    }
947
  }
948

949
  // Collect step failures so all steps always run even when one fails, then
950
  // throw at the end so the process exits non-zero in CI.
951
  const stepErrors = [];
4✔
952

953
  function step(label, fn) {
954
    startStep(label);
15✔
955
    return fn()
15✔
956
      .then(() => finishStep(label))
14✔
957
      .catch(e => {
958
        process.stderr.write(`\n${clr.fail('[error]')} ${clr.label(label)}: ${e.message}\n`);
1✔
959
        finishStep(label, true);
1✔
960
        stepErrors.push(e);
1✔
961
      });
962
  }
963

964
  process.stdout.write('\nBuilding assets...\n');
4✔
965

966
  // Media must complete before templates — the template step reads SVG files
967
  // from public/media/ via {{{ read 'public/media/…' }}} Handlebars helpers.
968
  if (runMedia) {
4✔
969
    await step('media', () => copyMedia());
3✔
970
  }
971

972
  if (isTTY) {
4!
UNCOV
973
    timer = setInterval(() => { spinFrame++; clearSummary(); writeSummary(); }, 80);
×
974
  }
975

976
  const tasks = buildSelectedParallelTasks(step, shouldRun, options);
4✔
977

978
  await Promise.all(tasks);
4✔
979

980
  if (timer) { clearInterval(timer); timer = null; }
4!
981
  clearSummary();
4✔
982

983
  if (stepErrors.length) {
4✔
984
    const names = stepErrors.map(e => e.message).join('; ');
1✔
985

986
    throw new Error(`Build failed: ${stepErrors.length} step(s) failed — ${names}`);
1✔
987
  }
988

989
  const totalSecs = ((Date.now() - totalStart) / 1000).toFixed(1);
3✔
990

991
  process.stdout.write(`\n${clr.done('Build complete')} ${clr.time(`(${totalSecs}s total)`)}\n\n`);
3✔
992
}
993

994
/**
995
 * One-shot production build.
996
 *
997
 * @param {object} [options]
998
 */
999
async function build(options = {}) {
×
UNCOV
1000
  return buildAll(options);
×
1001
}
1002

1003
exports.build = build;
23✔
1004
exports.buildAll = buildAll;
23✔
1005

1006
// ── Watch mode ───────────────────────────────────────────────────────────────
1007

1008
/**
1009
 * Start Rollup's incremental watcher for JS plus chokidar watchers for
1010
 * CSS, fonts, and templates.
1011
 *
1012
 * JS watch strategy:
1013
 *   Rollup's watch mode rebuilds only the modules that changed, not the whole
1014
 *   bundle.  This is significantly faster than Browserify's full-bundle rebuild
1015
 *   on every save because the module graph is already resolved; only the dirty
1016
 *   sub-graph is re-processed.
1017
 *
1018
 *   In two-pass mode (kilnSplit:false): both the view bundle and the kiln
1019
 *   bundle run as parallel Rollup watchers.  model.js/kiln.js changes are
1020
 *   handled incrementally by the kiln watcher; client.js changes are handled
1021
 *   by the view watcher.  Add/unlink events regenerate the relevant entry
1022
 *   file so the new module is included in the next incremental cycle.
1023
 *
1024
 * @param {object}   [options]
1025
 * @param {boolean}  [options.minify=false]
1026
 * @param {string[]} [options.extraEntries=[]]
1027
 * @param {function} [options.onRebuild]  called after each JS rebuild with (errors)
1028
 * @returns {Promise<{dispose: function}>}
1029
 */
1030
async function watch(options = {}) {
×
1031
  const { onRebuild, onReady } = options;
1✔
1032

1033
  let isFirstBuild = true;
1✔
1034
  const chokidar = require('chokidar');
1✔
1035

1036
  const chokidarOpts = {
1✔
1037
    ignoreInitial: true,
1038
    usePolling:    true,
1039
    interval:      100,
1040
    awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 50 },
1041
  };
1042

1043
  function debounce(fn, ms) {
1044
    let t;
1045

1046
    return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
11✔
1047
  }
1048

1049
  function rel(p) { return path.relative(CWD, p); }
6✔
1050

1051
  const clr = {
1✔
1052
    // per-asset prefix colors — each asset type gets a unique identity color
1053
    js:        s => `\x1b[96m${s}\x1b[0m`,   // bright cyan
2✔
1054
    kiln:      s => `\x1b[35m${s}\x1b[0m`,   // magenta
×
1055
    styles:    s => `\x1b[34m${s}\x1b[0m`,   // blue
6✔
1056
    fonts:     s => `\x1b[93m${s}\x1b[0m`,   // bright yellow
6✔
1057
    templates: s => `\x1b[92m${s}\x1b[0m`,   // bright green
6✔
1058
    // semantic state colors
1059
    rebuilt: s => `\x1b[32m${s}\x1b[0m`,
4✔
1060
    file:    s => `\x1b[36m${s}\x1b[0m`,
6✔
1061
    error:   s => `\x1b[31m${s}\x1b[0m`,
3✔
1062
  };
1063

1064
  // ── JS watch via Rollup watch mode ──────────────────────────────────────────
1065

1066
  const viteCfg = getViteConfig(options);
1✔
1067

1068
  // Collector accumulates process.env references from both passes across the
1069
  // entire watch session.  The Set is append-only: removing a reference leaves
1070
  // a stale entry until the next full build, which is harmless (the server
1071
  // injects undefined for unused vars).  Missing entries would silently break
1072
  // client-side code, so we intentionally err on the side of inclusion.
1073
  const envCollector = createClientEnvCollector(path.join(CWD, 'client-env.json'));
1✔
1074

1075
  // Same parallelization as buildJS: globals + kiln-edit first, then bootstrap.
1076
  await Promise.all([
1✔
1077
    generateViteGlobalsInit(),
1078
    generateViteKilnEditEntry(),
1079
  ]);
1080
  await generateViteBootstrap();
1✔
1081
  await fs.ensureDir(DEST);
1✔
1082

1083
  // Prints "<coloredPrefix> still building... (Xs)" every 3 s while a rebuild
1084
  // is in flight so the terminal never looks frozen.  Returns a stop() function.
1085
  // coloredPrefix should be the pre-colored tag, e.g. clr.js('[js]').
1086
  function startProgressTick(coloredPrefix) {
1087
    const t0 = Date.now();
6✔
1088
    const id  = setInterval(() => {
6✔
UNCOV
1089
      const s = Math.round((Date.now() - t0) / 1000);
×
1090

UNCOV
1091
      console.log(`${coloredPrefix} still building... (${s}s)`);
×
1092
    }, 3000);
1093

1094
    return () => clearInterval(id);
6✔
1095
  }
1096

1097
  // ── Kiln incremental watcher (two-pass mode only) ───────────────────────────
1098
  // In two-pass mode (kilnSplit:false) the kiln bundle is a separate Rollup
1099
  // watcher so model.js/kiln.js edits are incremental, not full rebuilds.
1100

1101
  let kilnOutput     = null;
1✔
1102

1103
  let lastViewOutput = null; // kept for manifest writes triggered by kiln rebuilds
1✔
1104

1105
  let resolveKilnOutput, rejectKilnOutput;
1106

1107
  function resetKilnOutputPromise() {
1108
    return new Promise((res, rej) => { resolveKilnOutput = res; rejectKilnOutput = rej; });
2✔
1109
  }
1110

1111
  let kilnOutputPromise  = resetKilnOutputPromise();
1✔
1112

1113
  let isFirstKilnBuild   = true;
1✔
1114

1115
  // KILN_BANNER is defined at module level above runKilnBuild — shared constant.
1116

1117
  let kilnTransformCount = 0;
1✔
1118

1119
  const captureKilnOutputPlugin = {
1✔
1120
    name: 'clay-capture-kiln-output',
1121

1122
    watchChange(id, { event }) {
UNCOV
1123
      console.log(clr.kiln(`[kiln] ${event}: `) + clr.file(path.relative(CWD, id)));
×
1124
    },
1125

1126
    transform() {
UNCOV
1127
      kilnTransformCount++;
×
1128
    },
1129

1130
    writeBundle(_opts, bundle) {
1131
      resolveKilnOutput({ output: Object.values(bundle) });
1✔
1132
    },
1133
  };
1134

1135
  let kilnWatcher = null;
1✔
1136

1137
  if (!viteCfg.kilnSplit) {
1!
1138
    const kilnWatchCfg = baseViteConfig(viteCfg);
1✔
1139

1140
    kilnWatchCfg.plugins = kilnWatchCfg.plugins.concat([envCollector.plugin(), captureKilnOutputPlugin]);
1✔
1141
    kilnWatchCfg.build.outDir = DEST;
1✔
1142
    kilnWatchCfg.build.watch = {};
1✔
1143
    kilnWatchCfg.build.rollupOptions = {
1✔
1144
      input:  { [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE },
1145
      output: {
1146
        format:               'esm',
1147
        entryFileNames:       '[name]-[hash].js',
1148
        inlineDynamicImports: true,
1149
        banner:               KILN_BANNER,
1150
      },
1151
      onwarn: suppressWarning,
1152
      watch: {
1153
        exclude: [
1154
          path.join(CWD, 'public', '**'),
1155
          path.join(CWD, 'node_modules', '**'),
1156
          path.join(CLAY_DIR, '**'),
1157
        ],
1158
      },
1159
    };
1160

1161
    kilnWatcher = await vite.build(kilnWatchCfg);
1✔
1162

1163
    let stopKilnTick = () => {};
1✔
1164

1165
    async function handleKilnBundleEnd(event) {
1166
      stopKilnTick();
1✔
1167

1168
      const output         = await kilnOutputPromise;
1✔
1169
      const modulesRebuilt = kilnTransformCount;
1✔
1170
      const wasFirst       = isFirstKilnBuild;
1✔
1171

1172
      kilnOutput         = output;
1✔
1173
      kilnOutputPromise  = resetKilnOutputPromise();
1✔
1174
      kilnTransformCount = 0;
1✔
1175
      isFirstKilnBuild   = false;
1✔
1176

1177
      if (lastViewOutput) {
1!
1178
        // Always reconcile the manifest when we have both outputs — this fixes
1179
        // a race where the view watcher wrote the manifest before the kiln
1180
        // watcher finished its first build, leaving the kiln entry absent.
1181
        const manifest = buildManifest(lastViewOutput, kilnOutput);
1✔
1182

1183
        await writeManifest(manifest);
1✔
1184
        await envCollector.write();
1✔
1185

1186
        if (!wasFirst) {
1!
1187
          // Only log on incremental rebuilds; suppress the initial startup cycle.
UNCOV
1188
          const moduleLabel = modulesRebuilt === 1 ? '1 module' : `${modulesRebuilt} modules`;
×
UNCOV
1189
          const duration    = event.duration != null ? ` in ${event.duration}ms` : '';
×
1190

UNCOV
1191
          console.log(clr.kiln('[kiln]') + clr.rebuilt(' Rebuilt') + ` (${moduleLabel} transformed${duration})`);
×
1192
        } else if (onReady) {
1!
1193
          // Signal ready only after both view AND kiln have completed their
1194
          // first build — the manifest is now complete with both entries.
1195
          onReady();
1✔
1196
        }
1197
      }
1198
      if (event.result && event.result.close) event.result.close();
1!
1199
    }
1200

1201
    kilnWatcher.on('event', async (event) => {
1✔
1202
      if (event.code === 'BUNDLE_START') {
2✔
1203
        if (!isFirstKilnBuild) stopKilnTick = startProgressTick(clr.kiln('[kiln]'));
1!
1204
        else stopKilnTick = () => {};
1✔
1205
      } else if (event.code === 'BUNDLE_END') {
1!
1206
        await handleKilnBundleEnd(event);
1✔
UNCOV
1207
      } else if (event.code === 'ERROR') {
×
UNCOV
1208
        stopKilnTick();
×
UNCOV
1209
        rejectKilnOutput(event.error);
×
UNCOV
1210
        kilnOutputPromise = resetKilnOutputPromise();
×
UNCOV
1211
        console.error(clr.kiln('[kiln]') + clr.error(` Build error: ${event.error.message}`));
×
UNCOV
1212
        if (event.result && event.result.close) event.result.close();
×
1213
      }
1214
    });
1215
  }
1216

1217
  const watchInput = viteCfg.kilnSplit
1!
1218
    ? { [VITE_BOOTSTRAP_KEY]: VITE_BOOTSTRAP_FILE, [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE }
1219
    : { [VITE_BOOTSTRAP_KEY]: VITE_BOOTSTRAP_FILE };
1220

1221
  const watchCfg = baseViteConfig(viteCfg);
1✔
1222

1223
  // Vite closes the internal RollupBuild before emitting BUNDLE_END to external
1224
  // listeners, so event.result.generate() throws "Bundle is already closed".
1225
  // Instead, capture chunk data inside a writeBundle plugin hook which fires
1226
  // after files are written to disk (but before BUNDLE_END reaches us), giving
1227
  // us the same {output:[]} shape that buildManifest expects.
1228
  //
1229
  // We use a resolve/reject pair so handleBundleEnd can *await* the plugin
1230
  // result rather than reading a mutable variable — avoids silent stale data
1231
  // if a build error ever prevents writeBundle from firing.
1232
  let resolveViewOutput, rejectViewOutput;
1233

1234
  function resetViewOutputPromise() {
1235
    return new Promise((res, rej) => {
2✔
1236
      resolveViewOutput = res;
2✔
1237
      rejectViewOutput  = rej;
2✔
1238
    });
1239
  }
1240

1241
  let viewOutputPromise = resetViewOutputPromise();
1✔
1242

1243
  let transformCount    = 0;
1✔
1244

1245
  const captureOutputPlugin = {
1✔
1246
    name: 'clay-capture-watch-output',
1247

1248
    // watchChange fires (once per file) when Rollup's own watcher detects a
1249
    // change in a file that is actually part of the module graph.  More
1250
    // accurate than the chokidar jsWatcher below: only fires for files Rollup
1251
    // actually tracks, so it won't log a file that changed but isn't imported.
1252
    watchChange(id, { event }) {
UNCOV
1253
      console.log(clr.js(`[js] ${event}: `) + clr.file(path.relative(CWD, id)));
×
1254
    },
1255

1256
    // transform is called only for modules Rollup needs to re-process this
1257
    // cycle.  With the watch-mode module cache, unchanged modules are skipped —
1258
    // so this count reflects the actual incremental rebuild scope.
1259
    transform() {
UNCOV
1260
      transformCount++;
×
1261
    },
1262

1263
    // writeBundle fires after every file has been written to disk.
1264
    // `bundle` is a {[fileName]: OutputChunk|OutputAsset} map; convert it to
1265
    // the RollupOutput shape {output:[...]} that buildManifest iterates.
1266
    writeBundle(_opts, bundle) {
1267
      resolveViewOutput({ output: Object.values(bundle) });
1✔
1268
    },
1269
  };
1270

1271
  // Add the env collector to the watch build so Rollup picks up process.env
1272
  // references as it incrementally rebuilds changed modules.
1273
  watchCfg.plugins = watchCfg.plugins.concat([envCollector.plugin(), captureOutputPlugin]);
1✔
1274

1275
  watchCfg.build.outDir = DEST;
1✔
1276
  watchCfg.build.watch = {};
1✔
1277
  watchCfg.build.rollupOptions = {
1✔
1278
    input:  watchInput,
1279
    output: {
1280
      format:         'esm',
1281
      entryFileNames: '[name]-[hash].js',
1282
      chunkFileNames: 'chunks/[name]-[hash].js',
1283
      // See buildChunkingOutput() for CJS vs ESM branching rationale.
1284
      ...buildChunkingOutput(viteCfg),
1285
    },
1286
    onwarn: suppressWarning,
1287
    watch: {
1288
      // Exclude build outputs and dependencies from Rollup's file watcher to
1289
      // prevent feedback loops where writing a chunk triggers another rebuild.
1290
      exclude: [
1291
        path.join(CWD, 'public', '**'),
1292
        path.join(CWD, 'node_modules', '**'),
1293
        path.join(CLAY_DIR, '**'),
1294
      ],
1295
    },
1296
  };
1297

1298
  const watcher = await vite.build(watchCfg);
1✔
1299

1300
  let stopJsTick = () => {};
1✔
1301

1302
  // Extract BUNDLE_END handling so the event callback stays below complexity limit.
1303
  // Signal ready after view's first build.  When a separate kiln watcher
1304
  // exists, defer until handleKilnBundleEnd writes the complete manifest.
1305
  function signalReadyIfFirst() {
1306
    if (!isFirstBuild) return;
1!
1307
    isFirstBuild = false;
1✔
1308
    if (onReady && !kilnWatcher) onReady();
1!
1309
  }
1310

1311
  async function handleBundleEnd(event) {
1312
    stopJsTick();
1✔
1313

1314
    // Await the output captured by captureOutputPlugin.writeBundle(), which
1315
    // fires after files are on disk but before BUNDLE_END reaches us.
1316
    // Reset the promise and transform counter immediately so the next rebuild
1317
    // gets a fresh slot.
1318
    const viewOutput      = await viewOutputPromise;
1✔
1319
    const modulesRebuilt  = transformCount;
1✔
1320

1321
    viewOutputPromise = resetViewOutputPromise();
1✔
1322
    transformCount    = 0;
1✔
1323
    lastViewOutput    = viewOutput; // retained so kiln BUNDLE_END can update the manifest
1✔
1324

1325
    const manifest = buildManifest(viewOutput, kilnOutput);
1✔
1326

1327
    await writeManifest(manifest);
1✔
1328
    await envCollector.write();
1✔
1329
    if (onRebuild) onRebuild([]);
1!
1330

1331
    const moduleLabel = modulesRebuilt === 1 ? '1 module' : `${modulesRebuilt} modules`;
1!
1332
    const duration    = event.duration != null ? ` in ${event.duration}ms` : '';
1!
1333

1334
    console.log(clr.js('[js]') + clr.rebuilt(' Rebuilt successfully') + ` (${moduleLabel} transformed${duration})`);
1✔
1335
    signalReadyIfFirst();
1✔
1336
    if (event.result && event.result.close) event.result.close();
1!
1337
  }
1338

1339
  watcher.on('event', async (event) => {
1✔
1340
    if (event.code === 'BUNDLE_START') {
2✔
1341
      console.log(clr.js('[js]') + ' Rebuilding...');
1✔
1342
      stopJsTick = isFirstBuild ? () => {} : startProgressTick(clr.js('[js]'));
1!
1343
    } else if (event.code === 'BUNDLE_END') {
1!
1344
      await handleBundleEnd(event);
1✔
UNCOV
1345
    } else if (event.code === 'ERROR') {
×
UNCOV
1346
      stopJsTick();
×
1347
      // Reject the output promise so handleBundleEnd doesn't hang if writeBundle
1348
      // was skipped due to the error, then reset for the next rebuild attempt.
UNCOV
1349
      rejectViewOutput(event.error);
×
UNCOV
1350
      viewOutputPromise = resetViewOutputPromise();
×
UNCOV
1351
      console.error(clr.js('[js]') + clr.error(` Build error: ${event.error.message}`));
×
UNCOV
1352
      if (onRebuild) onRebuild([event.error]);
×
1353
    }
1354
  });
1355

1356
  // ── Chokidar: regenerate bootstrap when new client.js files appear ─────────
1357

1358
  // Only regenerate generated entry files when the SET of source files changes
1359
  // (add/unlink).  For plain edits Rollup's incremental watcher already tracks
1360
  // every file in the module graph and rebuilds only the dirty sub-graph —
1361
  // writing the entry file again would touch its mtime and trigger a second
1362
  // unnecessary rebuild.
1363
  async function regenerateEntryFiles(changedFile) {
UNCOV
1364
    const isGlobal   = changedFile.includes(`${path.sep}global${path.sep}`);
×
UNCOV
1365
    const isKilnFile = changedFile.endsWith('model.js') || changedFile.endsWith('kiln.js');
×
1366

UNCOV
1367
    if (isGlobal) {
×
1368
      // A global/js file was added or removed: regenerate the import list first,
1369
      // then the bootstrap (which may reference the globals entry).
UNCOV
1370
      await generateViteGlobalsInit();
×
UNCOV
1371
      await generateViteBootstrap();
×
UNCOV
1372
    } else if (changedFile.endsWith('client.js')) {
×
UNCOV
1373
      await generateViteBootstrap();
×
UNCOV
1374
    } else if (isKilnFile) {
×
1375
      // The kiln watcher detects the entry file change and rebuilds incrementally.
UNCOV
1376
      await generateViteKilnEditEntry();
×
1377
    }
1378
  }
1379

1380
  const rebuildBootstrap = debounce(async (changedFile, eventType) => {
1✔
1381
    if (!changedFile) return;
1!
1382

1383
    const isChange   = eventType === 'change';
1✔
1384
    const isKilnFile = changedFile.endsWith('model.js') || changedFile.endsWith('kiln.js');
1✔
1385

1386
    // For plain edits Rollup's watchChange hook already logs the file — only
1387
    // log here for add/unlink where the file isn't in Rollup's graph yet.
1388
    if (!isChange) {
1!
UNCOV
1389
      const pfxFn = isKilnFile ? clr.kiln : clr.js;
×
UNCOV
1390
      const pfxLabel = isKilnFile ? '[kiln]' : '[js]';
×
1391

UNCOV
1392
      console.log(pfxFn(`${pfxLabel} ${eventType}: `) + clr.file(rel(changedFile)));
×
UNCOV
1393
      await regenerateEntryFiles(changedFile);
×
1394
    }
1395
    // For change events, the relevant Rollup watcher handles it incrementally.
1396
  }, 200);
1397

1398
  const JS_GLOBS = [
1✔
1399
    path.join(CWD, 'components', '**', '*.js'),
1400
    path.join(CWD, 'components', '**', '*.vue'),
1401
    path.join(CWD, 'layouts', '**', '*.js'),
1402
    path.join(CWD, 'global', '**', '*.js'),
1403
    path.join(CWD, 'services', '**', '*.js'),
1404
  ];
1405

1406
  const jsWatcher = chokidar.watch(JS_GLOBS, {
1✔
1407
    ...chokidarOpts,
1408
    ignored: [path.join(CWD, 'public', '**'), path.join(CWD, 'node_modules', '**')],
1409
  });
1410

1411
  jsWatcher
1✔
1412
    .on('change', f => rebuildBootstrap(f, 'change'))
1✔
1413
    .on('add',    f => rebuildBootstrap(f, 'add'))
3✔
1414
    .on('unlink', f => rebuildBootstrap(f, 'unlink'));
1✔
1415

1416
  // ── CSS watcher ──────────────────────────────────────────────────────────────
1417
  // When a component/layout CSS file changes, only rebuild that component's file
1418
  // across all styleguides (e.g. nav.nymag.css, nav.curbed.css, nav._default.css)
1419
  // rather than recompiling all ~2800 CSS files.
1420
  //
1421
  // Falls back to a full rebuild if the changed file is a shared mixin/variable
1422
  // (i.e. its basename doesn't appear in any standard component/layout glob).
1423
  const { globSync: cssGlobSync } = require('glob');
1✔
1424

1425
  function getChangedStyleFiles(changedFile) {
1426
    if (!changedFile) return null; // null → full rebuild
2!
1427
    const basename = path.basename(changedFile); // e.g. "nav.css"
2✔
1428
    const variants = [
2✔
1429
      ...cssGlobSync(path.join(CWD, 'styleguides', '**', 'components', basename)),
1430
      ...cssGlobSync(path.join(CWD, 'styleguides', '**', 'layouts', basename)),
1431
    ];
1432

1433
    // If no variants found (shared mixin/variable file), do a full rebuild.
1434
    return variants.length > 0 ? variants : null;
2!
1435
  }
1436

1437
  const rebuildStyles = debounce((changedFile) => {
1✔
1438
    if (changedFile) console.log(clr.styles('[styles]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1439
    const changedFiles  = getChangedStyleFiles(changedFile);
2✔
1440
    const buildOpts     = changedFiles ? { ...options, changedFiles } : options;
2!
1441
    const start         = Date.now();
2✔
1442
    const stopStyleTick = startProgressTick(clr.styles('[styles]'));
2✔
1443

1444
    return buildStyles(buildOpts)
2✔
1445
      .then(() => {
1446
        stopStyleTick();
1✔
1447
        console.log(clr.styles('[styles]') + clr.rebuilt(` Rebuilt (${Date.now() - start}ms)`));
1✔
1448
        // Write reload-signal so start.js/nodemon triggers a full server
1449
        // restart.  A restart is necessary because CSS is output with stable
1450
        // filenames (e.g. nav.nymag.css) — no content hash — so the browser
1451
        // keeps serving its cached copy even after the file changes on disk.
1452
        // A new server process changes the ETag, which forces a re-fetch.
1453
        //
1454
        // TODO: migrate CSS to Lightning CSS with content-hashed output
1455
        // (e.g. nav.nymag-[hash].css) so the manifest carries the new URL and
1456
        // the browser is forced to re-fetch naturally on every CSS change,
1457
        // exactly like JS chunks work today.  Once that lands, this signal and
1458
        // the nodemon restart in start.js can be removed entirely.
1459
        return fs.ensureDir(CLAY_DIR)
1✔
1460
          .then(() => fs.writeFile(path.join(CLAY_DIR, 'reload-signal'), String(Date.now())))
1✔
UNCOV
1461
          .catch(e => console.warn(`[styles] could not write reload-signal: ${e.message}`));
×
1462
      })
1463
      .catch(e => { stopStyleTick(); console.error(clr.styles('[styles]') + clr.error(` rebuild failed: ${e.message}`)); });
1✔
1464
  }, 200);
1465

1466
  const cssWatcher = chokidar.watch(STYLE_GLOBS, chokidarOpts);
1✔
1467

1468
  cssWatcher.on('change', rebuildStyles).on('add', rebuildStyles).on('unlink', rebuildStyles);
1✔
1469

1470
  // ── Font watcher ─────────────────────────────────────────────────────────────
1471
  const rebuildFonts = debounce((changedFile) => {
1✔
1472
    if (changedFile) console.log(clr.fonts('[fonts]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1473
    const start        = Date.now();
2✔
1474
    const stopFontTick = startProgressTick(clr.fonts('[fonts]'));
2✔
1475

1476
    return buildFonts()
2✔
1477
      .then(() => { stopFontTick(); console.log(clr.fonts('[fonts]') + clr.rebuilt(` Rebuilt (${Date.now() - start}ms)`)); })
1✔
1478
      .catch(e => { stopFontTick(); console.error(clr.fonts('[fonts]') + clr.error(` rebuild failed: ${e.message}`)); });
1✔
1479
  }, 200);
1480

1481
  const fontWatcher = chokidar.watch(FONTS_SRC_GLOB, chokidarOpts);
1✔
1482

1483
  fontWatcher.on('change', rebuildFonts).on('add', rebuildFonts).on('unlink', rebuildFonts);
1✔
1484

1485
  // ── Template watcher ─────────────────────────────────────────────────────────
1486
  // Separate signals so each trigger is handled correctly server-side:
1487
  //   reload-signal    → nodemon.restart() in start.js   (CSS: full restart busts browser cache)
1488
  //   template-signal  → html.init()       in renderers.js (templates: fast in-process re-register)
1489
  const TEMPLATE_SIGNAL = path.join(CLAY_DIR, 'template-signal');
1✔
1490

1491
  const rebuildTemplates = debounce((changedFile) => {
1✔
1492
    if (changedFile) console.log(clr.templates('[templates]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1493
    const start            = Date.now();
2✔
1494
    const stopTemplateTick = startProgressTick(clr.templates('[templates]'));
2✔
1495

1496
    return buildTemplates({ ...options, watch: true })
2✔
1497
      .then(() => {
1498
        stopTemplateTick();
1✔
1499
        console.log(clr.templates('[templates]') + clr.rebuilt(` Rebuilt (${Date.now() - start}ms)`));
1✔
1500
        // Write template-signal so renderers.js calls html.init() in-process
1501
        // (~100ms) without a full server restart.
1502
        return fs.ensureDir(CLAY_DIR)
1✔
1503
          .then(() => fs.writeFile(TEMPLATE_SIGNAL, String(Date.now())))
1✔
UNCOV
1504
          .catch(e => console.warn(`[templates] could not write template-signal: ${e.message}`));
×
1505
      })
1506
      .catch(e => { stopTemplateTick(); console.error(clr.templates('[templates]') + clr.error(` rebuild failed: ${e.message}`)); });
1✔
1507
  }, 200);
1508

1509
  const templateGlobs = [
1✔
1510
    path.join(CWD, 'components', '**', TEMPLATE_GLOB_PATTERN),
1511
    path.join(CWD, 'layouts', '**', TEMPLATE_GLOB_PATTERN),
1512
  ];
1513
  const templateWatcher = chokidar.watch(templateGlobs, chokidarOpts);
1✔
1514

1515
  templateWatcher
1✔
1516
    .on('change', rebuildTemplates)
1517
    .on('add', rebuildTemplates)
1518
    .on('unlink', rebuildTemplates);
1519

1520
  const chokidarWatchers = [jsWatcher, cssWatcher, fontWatcher, templateWatcher];
1✔
1521

1522
  await Promise.all(chokidarWatchers.map(w => new Promise(resolve => w.once('ready', resolve))));
4✔
1523

1524
  return {
1✔
1525
    dispose: async () => {
1526
      if (watcher     && watcher.close)     watcher.close();
1!
1527
      if (kilnWatcher && kilnWatcher.close) kilnWatcher.close();
1!
1528
      await Promise.all(chokidarWatchers.map(w => w.close()));
4✔
1529
    },
1530
  };
1531
}
1532

1533
exports.watch = watch;
23✔
1534

1535
// ── Warning suppressor ───────────────────────────────────────────────────────
1536

1537
/**
1538
 * Suppress Rollup warnings that are expected when bundling a CJS-heavy
1539
 * codebase into a native ESM output.
1540
 *
1541
 * These are not real errors — they reflect the current state of the codebase
1542
 * (CJS sources, circular dependencies, missing optional modules) and will
1543
 * disappear naturally as files are migrated to ESM.
1544
 *
1545
 * @param {object}   warning
1546
 * @param {function} warn
1547
 */
1548
// Warning codes that are always expected in a mixed CJS/ESM codebase and
1549
// should not surface to the user.  Each code is explained inline.
1550
const SUPPRESSED_WARNING_CODES = new Set([
23✔
1551
  // Circular requires() are common in CJS codebases and handled safely by
1552
  // strictRequires:'auto'.  They become non-issues once files are ESM.
1553
  'CIRCULAR_DEPENDENCY',
1554

1555
  // CJS modules use `this` at the top level (equivalent to `module.exports`).
1556
  // Rollup warns because in ESM `this` is undefined at the top level.
1557
  // @rollup/plugin-commonjs wraps these so the reference is correct.
1558
  'THIS_IS_UNDEFINED',
1559

1560
  // Some globals (e.g. jQuery's $) are expected to be on window and are not
1561
  // imported.  Clay components reference them as free variables.
1562
  'MISSING_GLOBAL_NAME',
1563

1564
  // Missing optional imports are stubbed by viteMissingModulePlugin.  Rollup
1565
  // warns before the plugin catches them; the warning is spurious.
1566
  'UNRESOLVED_IMPORT',
1567

1568
  // CJS modules wrapped by @rollup/plugin-commonjs may not export named
1569
  // bindings.  Named imports from CJS modules get undefined — expected.
1570
  'MISSING_EXPORT',
1571
]);
1572

1573
function suppressWarning(warning, warn) {
UNCOV
1574
  if (SUPPRESSED_WARNING_CODES.has(warning.code)) return;
×
1575

1576
  // eval() in node_modules (e.g. pyxis-frontend webpack dev bundle) is
1577
  // intentional and cannot be removed.  Only warn for project source files.
UNCOV
1578
  if (warning.code === 'EVAL' && warning.id && warning.id.includes('/node_modules/')) return;
×
1579

UNCOV
1580
  warn(warning);
×
1581
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc