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

clay / claycli / 26614528197

29 May 2026 02:34AM UTC coverage: 86.957% (+0.1%) from 86.85%
26614528197

Pull #252

github

jjpaulino
πŸ• Match Browserify env handling in Vite: inline build vars + runtime fallback

buildClientEnvDefines now reproduces the legacy dotenv-webpack
({ path: './.env', systemvars: true }) behaviour and adds a runtime safety
net so the Vite pipeline stops regressing where the build env is bare:

- Layer 1 (Browserify parity): overlay ./.env with the build process env and
  inline every NON-EMPTY process.env.<VAR> as a string literal β€” exactly what
  the old pack pipeline shipped (a local container build started with env_file
  bakes the real values in).
- Layer 2 (runtime catch-all): define process.env -> window.process.env so any
  var the build can't see (the bare Docker image build) resolves to the object
  .clay/_env-init.js hydrates from window.kiln.preloadData._envVars instead of a
  pVt={} stub.

Fixes the Vite-only image/product picker break: the prior build-time inline
baked "" for client vars (the Docker build has no PYXIS_HOST/AGORA_HOST), and
master's `window.process.env.X = process.env.X` then clobbered the hydrated
value with "". Empty/absent vars now fall through to the runtime read, turning
that line into a harmless self-assign. process.env.NODE_ENV stays a literal so
guards still tree-shake.

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

766 of 951 branches covered (80.55%)

Branch coverage included in aggregate %.

54 of 59 new or added lines in 2 files covered. (91.53%)

57 existing lines in 1 file now uncovered.

1634 of 1809 relevant lines covered (90.33%)

14.9 hits per line

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

73.27
/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';
24✔
7

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

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

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

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

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

35
exports.VITE_BOOTSTRAP_KEY = VITE_BOOTSTRAP_KEY;
24✔
36
exports.KILN_EDIT_ENTRY_KEY = KILN_EDIT_ENTRY_KEY;
24✔
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;
24✔
180

181
/**
182
 * Minimal dependency-free `.env` reader (KEY=VALUE, one per line).
183
 *
184
 * Mirrors dotenv for the simple single-line values client code relies on
185
 * (hosts, keys, flags).  Multi-line / quoted-newline values are not client
186
 * surface and are irrelevant here.  Returns {} when the file is absent β€” the
187
 * normal case inside the Docker image build, where .env is git/dockerignored.
188
 *
189
 * @param {string} filePath  absolute path to a .env file
190
 * @returns {object} parsed KEY→value map
191
 */
192
function readDotenvFile(filePath) {
193
  const out = {};
12✔
194

195
  let raw;
196

197
  try {
12✔
198
    raw = fs.readFileSync(filePath, 'utf8');
12✔
199
  } catch (_) {
200
    return out;
12✔
201
  }
202

NEW
203
  for (const line of raw.split('\n')) {
×
NEW
204
    const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
×
205

NEW
206
    if (!match) continue;
×
207

208
    // Strip a single layer of matching surrounding quotes, mirroring dotenv.
NEW
209
    out[match[1]] = match[2].replace(/^(['"])([\s\S]*)\1$/, '$2');
×
210
  }
211

NEW
212
  return out;
×
213
}
214

215
/**
216
 * Build the `process.env.<VAR>` define map.
217
 *
218
 * Reproduces the legacy webpack `pack` behaviour β€” dotenv-webpack with
219
 * `{ path: './.env', systemvars: true, allowEmptyValues: true }` β€” which INLINED
220
 * every `process.env.FOO` as a string literal at build time, and adds a runtime
221
 * safety net so the Vite pipeline never regresses where the build env is bare.
222
 *
223
 * ── Why this is needed (Vite vs the legacy pipeline) ────────────────────────
224
 *
225
 * Vite does not inline arbitrary env, and @rollup/plugin-commonjs rewrites the
226
 * bare `process.env` of a CJS module into a build-time-empty local stub
227
 * (literally `var pVt = {}`).  So `const HOST = process.env.PYXIS_HOST || '…'`
228
 * compiles to `pVt.PYXIS_HOST || '…'` β†’ always the fallback, and the read is dead
229
 * at build time.  This silently breaks every Kiln plugin / universal service that
230
 * reads env (pyxis.js, glaze/agora helpers, …): they work in Browserify but not
231
 * under Vite.
232
 *
233
 * ── Two layers, in precedence order (most-specific define wins in esbuild) ──
234
 *
235
 *  1. INLINE (Browserify parity): overlay ./.env with the build `process.env`
236
 *     (systemvars) and bake every NON-EMPTY value in as a string literal.  Where
237
 *     the build actually holds a value β€” a local container started with
238
 *     `env_file`, or any build run with .env in cwd β€” the value is inlined exactly
239
 *     as dotenv-webpack did.  NOTE: like dotenv-webpack systemvars, this exposes
240
 *     any referenced var (secrets included) in the browser bundle.
241
 *
242
 *  2. RUNTIME catch-all: `process.env` β†’ `window.process.env`.  Any var NOT
243
 *     inlined (empty at build, or the bare Docker image build) is rewritten to a
244
 *     live read of the object .clay/_env-init.js hydrates from
245
 *     `window.kiln.preloadData._envVars` (amphora-html forwards the server's live
246
 *     env in edit mode).  This also means master's
247
 *     `window.process.env.X = process.env.X` becomes a harmless self-assign
248
 *     instead of clobbering the hydrated value with "".
249
 *
250
 * `process.env.NODE_ENV` is kept as a build-time literal by buildDefines (a
251
 * more-specific key, so it wins over the catch-all) β†’ `process.env.NODE_ENV ===
252
 * 'production'` still tree-shakes.
253
 *
254
 * @returns {object} define map (per-var literals + the `process.env` catch-all)
255
 */
256
function buildClientEnvDefines() {
257
  const isValidEnvName = name => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
1,670✔
258

259
  // Layer 2: runtime catch-all for anything not inlined below.
260
  const defines = { 'process.env': 'window.process.env' };
12✔
261

262
  // Layer 1: dotenv-webpack-style inline (./.env overlaid by systemvars/process.env).
263
  const env = Object.assign(readDotenvFile(path.join(CWD, '.env')), process.env);
12✔
264

265
  for (const name of Object.keys(env)) {
12✔
266
    // NODE_ENV is owned by buildDefines; invalid identifiers would break esbuild's
267
    // define parser (e.g. npm_package_dependencies_@babel/core).
268
    if (name === 'NODE_ENV' || !isValidEnvName(name)) continue;
1,682✔
269

270
    const value = env[name];
1,670✔
271

272
    // Empty/undefined β†’ leave to the runtime catch-all; never inline a clobbering "".
273
    if (value === undefined || value === '') continue;
1,670✔
274

275
    defines[`process.env.${name}`] = JSON.stringify(value);
1,609✔
276
  }
277

278
  return defines;
12✔
279
}
280

281
/**
282
 * Build the define map injected into every module at compile time.
283
 *
284
 * Vite uses esbuild under the hood for define substitution, so these are
285
 * identifier-scoped replacements (not substring replacements like sed).
286
 * Only meaningful identifiers are replaced β€” string literals are never touched.
287
 *
288
 * Node globals (process, __filename, etc.) appear in isomorphic Clay services
289
 * that run on both the server and in the browser.  Stubbing them here lets the
290
 * browser bundle compile without a polyfill, while the real Node values are
291
 * used at runtime on the server.
292
 *
293
 * @param {object} userDefines - extra defines from bundlerConfig()
294
 * @returns {object}
295
 */
296
function buildDefines(userDefines = {}) {
3✔
297
  const NODE_ENV = process.env.NODE_ENV || 'development';
12!
298

299
  return Object.assign(
12✔
300
    {
301
      // Guards like `if (process.env.NODE_ENV === 'production')` tree-shake
302
      // correctly in the browser bundle.
303
      'process.env.NODE_ENV': JSON.stringify(NODE_ENV),
304

305
      // process.browser is a convention used by some isomorphic libraries to
306
      // branch between Node and browser paths.
307
      'process.browser': JSON.stringify(true),
308

309
      // process.version and process.versions are read by clay-log to detect
310
      // the runtime environment. Stub to empty values so the browser branch
311
      // is taken rather than the Node branch.
312
      'process.version':  JSON.stringify(''),
313
      'process.versions': JSON.stringify({}),
314

315
      // __filename and __dirname are used in some server-side Clay utilities.
316
      // In the browser bundle they are never accessed at runtime, but they
317
      // must resolve to something so the module compiles.
318
      __filename: JSON.stringify(''),
319
      __dirname:  JSON.stringify('/'),
320

321
      // global β†’ globalThis. Some older CJS packages reference global instead
322
      // of window. globalThis is universally available in ES2017+ environments.
323
      global: 'globalThis',
324
    },
325
    // Client env (see buildClientEnvDefines): per-var literals inlined from the
326
    // build env (dotenv-webpack parity) + a `process.env` β†’ `window.process.env`
327
    // catch-all for anything the build can't see.  `process.env.NODE_ENV` above is
328
    // more specific and still wins as a literal.  Comes before userDefines so a
329
    // site can still override a specific var via bundlerConfig().define if needed.
330
    buildClientEnvDefines(),
331
    userDefines
332
  );
333
}
334

335
exports.buildDefines = buildDefines;
24✔
336

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

395
// ── Manifest ────────────────────────────────────────────────────────────────
396

397
/**
398
 * Build _manifest.json from one or two Vite RollupOutput results.
399
 *
400
 * viewOutput  β€” the splitting pass (bootstrap + component chunks)
401
 * kilnOutput  β€” the single-file kiln edit pass (null when kilnSplit is true)
402
 *
403
 * The manifest shape { key: { file, imports } } is the contract between the
404
 * build pipeline and resolve-media.js.  Every pipeline (Browserify included)
405
 * must produce a compatible manifest so the runtime asset injector works
406
 * without knowing which bundler was used.
407
 *
408
 * @param {Object|null} viewOutput
409
 * @param {Object|null} kilnOutput
410
 * @param {string} publicBase
411
 * @returns {object}
412
 */
413
function buildManifest(viewOutput, kilnOutput = null, publicBase = '/js') {
6!
414
  const manifest = {};
6✔
415

416
  for (const output of [viewOutput, kilnOutput]) {
6✔
417
    for (const chunk of output ? output.output : []) {
12✔
418
      if (chunk.type !== 'chunk' || !chunk.isEntry) continue;
8!
419

420
      const facadeId = chunk.facadeModuleId;
8✔
421

422
      if (!facadeId) continue;
8!
423

424
      const cleanFacadeId = facadeId.replace(/\?.*$/, '');
8✔
425
      const entryKey = path.relative(CWD, cleanFacadeId)
8✔
426
        .replace(/\\/g, '/')
427
        .replace(/\.js$/, '');
428

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

432
      manifest[entryKey] = { file: fileUrl, imports: importUrls };
8✔
433
    }
434
  }
435

436
  return manifest;
6✔
437
}
438

439
async function writeManifest(manifest) {
440
  const manifestPath = path.join(DEST, '_manifest.json');
6✔
441

442
  await fs.outputJson(manifestPath, manifest, { spaces: 2 });
6✔
443
}
444

445
// ── Vite build config factory ────────────────────────────────────────────────
446

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

511
  return {
9✔
512
    root:       CWD,
513
    base:       publicBase + '/',
514
    configFile: false, // always use this programmatic config; never read vite.config.js
515
    logLevel:   'warn',
516
    plugins:    buildPlugins(viteCfg.plugins, viteCfg.browserStubs, {
517
      lenientBrowserExternalize: viteCfg.lenientBrowserExternalize === true,
518
    }),
519
    define:     buildDefines(viteCfg.define),
520
    resolve: {
521
      browserField: true,
522

523
      // Field resolution order: prefer the browser-specific build, then the
524
      // CommonJS main entry, then ESM via the module field.
525
      //
526
      // `module` is intentionally last: some packages ship both CJS (main) and
527
      // ESM (module) builds; using the CJS build is safer when @rollup/plugin-commonjs
528
      // is active because it applies the same transformation consistently.
529
      //
530
      // ESM migration lever: once the codebase no longer needs @rollup/plugin-commonjs,
531
      // flip this to ['browser', 'module', 'main'] so Rollup can tree-shake ESM
532
      // packages directly.
533
      mainFields: ['browser', 'main', 'module'],
534

535
      // Site-defined module aliases from bundlerConfig().alias.
536
      // This is the first-class alternative to writing a full Rollup plugin just
537
      // to redirect a specifier.  Common use-cases:
538
      //   - swap a server-only package for its browser equivalent at build time
539
      //     (e.g. '@sentry/node' β†’ '@sentry/browser')
540
      //   - point a bare specifier at an absolute path
541
      //     (e.g. '@utils' β†’ path.resolve(__dirname, 'src/utils'))
542
      // For complex patterns (regex, conditional logic), use bundlerConfig().plugins
543
      // with a resolveId hook instead.
544
      alias: viteCfg.alias || {},
9!
545
    },
546

547
    // optimizeDeps is Vite's pre-bundler that converts CJS node_modules to ESM using
548
    // esbuild before Rollup processes them.  We use Vite exclusively for production builds
549
    // (vite build) β€” the Vite dev server and HMR are not used.  Clay runs a server-rendered
550
    // architecture (Amphora) where watch mode uses Rollup's own incremental rebuild, not a
551
    // Vite dev server in the request path.
552
    //
553
    // optimizeDeps does NOT run during `vite build` β€” production builds go straight through
554
    // Rollup, which handles CJS via @rollup/plugin-commonjs (configured below).
555
    // Setting noDiscovery:true prevents accidental dep scanning that can add latency
556
    // to build startup in some Vite versions.
557
    optimizeDeps: { noDiscovery: true, include: [] },
558

559
    build: {
560
      // Target ES2017 (async/await, Object.assign, etc.) β€” supported by all
561
      // browsers we care about.  This lets Rollup emit clean async code without
562
      // transpiling it to generator functions.
563
      //
564
      // ESM migration note: bump to 'es2020' when ready to use optional chaining
565
      // and nullish coalescing natively in the output (Vue 3 recommends es2020+).
566
      target: 'es2017',
567

568
      outDir:      DEST,
569
      emptyOutDir: false, // we write into public/js/ which already exists
570
      // Configurable via bundlerConfig().sourcemap.  Defaults to true so that
571
      // DevTools stack traces and Sentry source mapping work out of the box.
572
      // Set to false in CI pipelines that do not consume source maps to shave
573
      // a few seconds off the build without changing runtime behaviour.
574
      sourcemap:   viteCfg.sourcemap !== false,
575
      minify:      viteCfg.minify ? 'esbuild' : false,
9!
576

577
      // Skips the extra gzip pass Vite performs after bundling just to print the
578
      // compressed sizes in the terminal.  That pass is never free β€” on a large
579
      // codebase it adds 1–3 s.  The uncompressed sizes are enough to catch
580
      // regressions during development; use a dedicated bundle-analysis script
581
      // (scripts/perf/01-bundle-analysis.js) for accurate gzip numbers.
582
      reportCompressedSize: false,
583

584
      // We handle CSS extraction ourselves: component .css files go through the
585
      // PostCSS step in buildStyles(), and .vue scoped styles are injected at
586
      // runtime by viteVue2Plugin().  Letting Vite split CSS would generate
587
      // separate .css chunks that nothing requests.
588
      cssCodeSplit: false,
589

590
      // public/js/ lives inside public/ which is also the publicDir.  Vite warns
591
      // when outDir is inside publicDir because it tries to copy publicDir into
592
      // outDir at the end of the build.  We disable that copy entirely β€” static
593
      // assets are managed by copyMedia/copyVendor/buildFonts.
594
      copyPublicDir: false,
595

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

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

606
      // Configure Vite's single built-in @rollup/plugin-commonjs instance.
607
      //
608
      // This is the bridge between the CJS-heavy existing codebase and Rollup's
609
      // native ESM module graph.  Every require() call in source files is
610
      // converted to an ESM import so Rollup can tree-shake and bundle correctly.
611
      // Browserify handled CJS implicitly; here it is explicit and configurable.
612
      //
613
      // IMPORTANT: do not add a second @rollup/plugin-commonjs instance via plugins[].
614
      // Each instance tracks its own set of ?commonjs-* virtual modules, so two
615
      // instances conflict and leave require() calls in the output.
616
      commonjsOptions: {
617
        // Apply to all JS, CJS, and .vue files.  Two checks must pass inside
618
        // @rollup/plugin-commonjs: the `include` regex (createFilter check) and
619
        // the `extensions` list (path.extname check).
620
        include:    /\.(js|cjs|vue)$/,
621
        extensions: ['.js', '.cjs', '.vue'],
622

623
        // Required for .vue files: viteVue2Plugin always appends `export default __sfc__`
624
        // regardless of whether the <script> block used require() or ESM, so every
625
        // compiled .vue file is "mixed" (CJS body + ESM export default).  Without
626
        // this flag, @rollup/plugin-commonjs would skip the require() conversion and
627
        // leave bare require() calls in the browser bundle.
628
        //
629
        // For plain .js files: mixing require() and import/export in the same file
630
        // is prohibited β€” files must be either pure CJS or pure ESM.
631
        transformMixedEsModules: true,
632

633
        // requireReturnsDefault: 'preferred' β€” when CJS code does `const x = require('y')`,
634
        // return y.default if it exists, otherwise return the whole module object.
635
        // This matches the natural expectation from CJS code that doesn't know about
636
        // ESM default exports, and avoids `.default.method()` call-site surprises.
637
        requireReturnsDefault: 'preferred',
638

639
        // strictRequires: 'auto' β€” for modules involved in circular require() chains
640
        // (e.g. services/client/auth.js ↔ services/client/gtm.js), wrap the require()
641
        // calls in lazy getters so both modules can fully initialize before either
642
        // reads the other's exports.  'auto' applies this only to modules that
643
        // @rollup/plugin-commonjs detects as part of a cycle, leaving all other
644
        // require() calls as direct synchronous calls (no overhead).
645
        strictRequires: 'auto',
646

647
        // Sites can exclude specific packages via commonjsExclude in bundlerConfig().
648
        // Use this for packages that use eval() internally in ways that break when
649
        // @rollup/plugin-commonjs rewrites their module scope (e.g. webpack dev-mode
650
        // bundles where eval() strings reference `exports` as a function parameter
651
        // that gets renamed by the CJS transformation).
652
        exclude: viteCfg.commonjsExclude || [],
9!
653
      },
654
    },
655
  };
656
}
657

658
// ── Build passes ────────────────────────────────────────────────────────────
659

660
/**
661
 * Return the output.manualChunks / output.experimentalMinChunkSize entry for
662
 * rollupOptions.output based on whether the codebase is fully ESM.
663
 *
664
 * CJS mode (clientFilesESM:false): @rollup/plugin-commonjs injects \0-prefixed
665
 * virtual proxy modules into the graph which inflate apparent module sizes and
666
 * add phantom importer edges.  Rollup's native experimentalMinChunkSize would
667
 * produce inaccurate merges in this environment.  viteManualChunksPlugin guards
668
 * against virtual modules explicitly and uses info.code.length as an honest size
669
 * proxy for pre-minification CJS source.
670
 *
671
 * ESM mode (clientFilesESM:true): the graph is clean β€” no proxy modules, no CJS
672
 * wrapper boilerplate.  Rollup's native experimentalMinChunkSize is accurate and
673
 * optimal; viteManualChunksPlugin is no longer needed.  manualChunksMinSize maps
674
 * directly to the native threshold (0 = no merging if the site leaves it unset).
675
 *
676
 * @param {object} viteCfg
677
 * @returns {object}
678
 */
679
function buildChunkingOutput(viteCfg) {
680
  const minSize = viteCfg.manualChunksMinSize ?? 0;
5✔
681

682
  if (viteCfg.clientFilesESM) {
5!
UNCOV
683
    return { experimentalMinChunkSize: minSize };
×
684
  }
685

686
  return { manualChunks: viteManualChunksPlugin(minSize, CWD) };
5✔
687
}
688

689

690
/**
691
 * Pass 1 β€” view mode (splitting pass).
692
 *
693
 * Builds the bootstrap entry point plus any extra entries.  Rollup splits the
694
 * module graph into shared chunks β€” each shared dependency gets its own
695
 * content-hashed file that the browser can cache independently.
696
 *
697
 * Compared to Browserify's single bundle, the browser only downloads the code
698
 * for components present on the current page, and unchanged modules are served
699
 * from cache on subsequent page loads.
700
 *
701
 * When kilnSplit is false (the default while model.js files are still CJS),
702
 * the kiln-edit entry is excluded from this graph to prevent CJS model.js
703
 * dependencies from polluting the view-mode chunk set.  Set kilnSplit:true
704
 * in bundlerConfig() once all model.js/kiln.js files use ESM.
705
 *
706
 * @param {object} viteCfg
707
 * @param {object} internalPlugins
708
 * @returns {Promise<Object>}
709
 */
710
async function runViewBuild(viteCfg, internalPlugins) {
711
  const entryMap = {};
4✔
712

713
  entryMap[VITE_BOOTSTRAP_KEY] = VITE_BOOTSTRAP_FILE;
4✔
714

715
  if (viteCfg.kilnSplit) {
4✔
716
    entryMap[KILN_EDIT_ENTRY_KEY] = KILN_EDIT_ENTRY_FILE;
1✔
717
  }
718

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

723
      entryMap[key] = extraPath;
1✔
724
    }
725
  }
726

727
  const cfg = baseViteConfig(viteCfg);
4✔
728

729
  if (internalPlugins && internalPlugins.length) {
4!
730
    cfg.plugins = cfg.plugins.concat(internalPlugins);
4✔
731
  }
732

733
  cfg.build.rollupOptions = {
4✔
734
    input:  entryMap,
735
    output: {
736
      format:         'esm',
737
      entryFileNames: '[name]-[hash].js',
738
      chunkFileNames: 'chunks/[name]-[hash].js',
739
      // See buildChunkingOutput() for CJS vs ESM branching rationale.
740
      ...buildChunkingOutput(viteCfg),
741
    },
742
    onwarn: suppressWarning,
743
  };
744

745
  const result = await vite.build(cfg);
4✔
746

747
  return Array.isArray(result) ? result[0] : result;
4!
748
}
749

750
// Pre-populates all kiln namespaces with empty objects so that module-level
751
// destructuring patterns like `const { pyxis } = window.kiln.config` in
752
// .vue plugin files don't throw before _initKilnPlugins() runs.
753
// Used by both the production kiln pass (runKilnBuild) and the watch kiln
754
// watcher β€” defined once here to prevent drift.
755
const KILN_BANNER = [
24✔
756
  '(function(){',
757
  '  var k = window.kiln = window.kiln || {};',
758
  '  k.config  = k.config  || {};',
759
  '  k.config.pyxis = k.config.pyxis || {};',
760
  '  k.utils   = k.utils   || {};',
761
  '  k.utils.components = k.utils.components || {};',
762
  '  k.utils.references = k.utils.references || {};',
763
  '  k.utils.componentElements = k.utils.componentElements || {};',
764
  '  k.utils.logger = k.utils.logger || function(){ return { log:function(){}, error:function(){} }; };',
765
  '  k.inputs  = k.inputs  || {};',
766
  '  k.modals  = k.modals  || {};',
767
  '  k.plugins = k.plugins || {};',
768
  '  k.toolbarButtons = k.toolbarButtons || {};',
769
  '  k.navButtons = k.navButtons || {};',
770
  '  k.navContent = k.navContent || {};',
771
  '  k.validators = k.validators || {};',
772
  '  k.transformers = k.transformers || {};',
773
  '  k.kilnInput = k.kilnInput || function(){};',
774
  '})();',
775
].join('\n');
776

777
/**
778
 * Pass 2 β€” kiln edit mode (no-split pass).
779
 *
780
 * The kiln-edit-init entry imports every component's model.js and kiln.js.
781
 * These files are CJS today and cannot be tree-shaken, so their transitive
782
 * dependencies (utility libraries, lodash, etc.) would bleed into the
783
 * view-mode chunk graph if this entry were included in the splitting pass.
784
 * Running it as a separate isolated pass with inlineDynamicImports:true
785
 * produces one self-contained file that is only loaded in edit mode.
786
 *
787
 * Edit mode (kiln) is a separate concern from public page rendering β€” this
788
 * file is never delivered to readers, only to editors inside the CMS.
789
 *
790
 * ESM migration path: once all model.js/kiln.js files are ESM, set
791
 * kilnSplit:true in bundlerConfig().  This adds the kiln entry to the same
792
 * splitting graph as the bootstrap, Rollup tree-shakes across both, and the
793
 * separate kiln pass is no longer needed.
794
 *
795
 * @param {object} viteCfg
796
 * @param {object[]} internalPlugins
797
 * @returns {Promise<Object>}
798
 */
799
async function runKilnBuild(viteCfg, internalPlugins) {
800
  const cfg = baseViteConfig(viteCfg);
3✔
801

802
  if (internalPlugins && internalPlugins.length) {
3!
803
    cfg.plugins = cfg.plugins.concat(internalPlugins);
3✔
804
  }
805

806
  cfg.build.rollupOptions = {
3✔
807
    input:  { [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE },
808
    output: {
809
      format:               'esm',
810
      entryFileNames:       '[name]-[hash].js',
811
      inlineDynamicImports: true,
812
      banner:               KILN_BANNER,
813
    },
814
    onwarn: suppressWarning,
815
  };
816

817
  const result = await vite.build(cfg);
3✔
818

819
  return Array.isArray(result) ? result[0] : result;
3!
820
}
821

822
// ── JS build orchestrator ────────────────────────────────────────────────────
823

824
/**
825
 * Generate all entry files, run both build passes, then write _manifest.json.
826
 *
827
 * Entry generation order:
828
 *   1. generateViteGlobalsInit() and generateViteKilnEditEntry() run in parallel
829
 *      (they write to independent files and have no shared dependencies).
830
 *   2. generateViteBootstrap() runs after globals, because it checks whether
831
 *      .clay/_globals-init.js exists to decide whether to import it.
832
 *
833
 * Build pass order:
834
 *   Both passes (view and kiln) run in parallel via Promise.all.  They write to
835
 *   independent file sets (the kiln entry is excluded from the view graph) so
836
 *   there is no ordering constraint between them.
837
 *
838
 * @param {object} [options]
839
 * @returns {Promise<void>}
840
 */
841
async function buildJS(options = {}) {
3✔
842
  // Globals and kiln entry do not depend on each other β€” generate concurrently.
843
  await Promise.all([
5✔
844
    generateViteGlobalsInit(),
845
    generateViteKilnEditEntry(),
846
  ]);
847

848
  // Bootstrap depends on globals existing (it checks pathExists before importing).
849
  await generateViteBootstrap();
5✔
850

851
  if (!fs.existsSync(VITE_BOOTSTRAP_FILE)) {
5✔
852
    throw new Error('clay vite: missing .clay/vite-bootstrap.js after prepare.');
1✔
853
  }
854

855
  await fs.ensureDir(DEST);
4✔
856

857
  const viteCfg = getViteConfig(options);
4✔
858
  const envCollector = createClientEnvCollector(path.join(CWD, 'client-env.json'));
4✔
859
  const envPlugin = envCollector.plugin();
4✔
860

861
  let viewOutput, kilnOutput;
862

863
  if (viteCfg.kilnSplit) {
4✔
864
    // Single pass β€” kiln is in the same Rollup graph as the bootstrap.
865
    // Only safe once all model.js/kiln.js files are native ESM.
866
    viewOutput = await runViewBuild(viteCfg, [envPlugin]);
1✔
867
    kilnOutput = null;
1✔
868
  } else {
869
    // Two passes in parallel β€” kiln is isolated in its own no-split graph.
870
    // This prevents CJS model.js dependencies from creating phantom shared chunks
871
    // in the view-mode graph that would increase page load request counts.
872
    [viewOutput, kilnOutput] = await Promise.all([
3✔
873
      runViewBuild(viteCfg, [envPlugin]),
874
      runKilnBuild(viteCfg, [envPlugin]),
875
    ]);
876
  }
877

878
  const manifest = buildManifest(viewOutput, kilnOutput);
4✔
879

880
  await writeManifest(manifest);
4✔
881
  await envCollector.write();
4✔
882
}
883

884
exports.buildJS = buildJS;
24✔
885

886
// ── Full build (JS + assets in parallel) ────────────────────────────────────
887

888
/**
889
 * Run all build steps: JS + styles + fonts + templates + vendor + media.
890
 *
891
 * media runs first (sequential) so that SVG files are on disk before the
892
 * templates step tries to inline them via {{{ read 'public/media/…' }}}.
893
 * All remaining steps run in parallel after media completes.
894
 *
895
 * @param {object} [options]
896
 * @returns {Promise<void>}
897
 */
898
async function buildAll(options = {}) {
2✔
899
  const requested = normalizeRequestedSteps(options.only);
4✔
900
  const shouldRun = step => !requested || requested.has(step);
26✔
901
  const runMedia = shouldRunMediaStep(shouldRun);
4✔
902
  const isTTY = process.stdout.isTTY;
4✔
903
  const SPINNER = ['β ‹','β ™','β Ή','β Έ','β Ό','β ΄','β ¦','β §','β ‡','⠏'];
4✔
904

905
  const clr = {
4✔
906
    label: s => `\x1b[36m${s}\x1b[0m`,
31✔
907
    done:  s => `\x1b[32m${s}\x1b[0m`,
17✔
908
    fail:  s => `\x1b[31m${s}\x1b[0m`,
2✔
909
    time:  s => `\x1b[90m${s}\x1b[0m`,
18✔
UNCOV
910
    spin:  s => `\x1b[33m${s}\x1b[0m`,
×
911
  };
912

913
  const states    = new Map();
4✔
914
  const totalStart = Date.now();
4✔
915

916
  let spinFrame = 0,
4✔
917
    timer = null,
4✔
918
    progressUp = false;
4✔
919

920
  function clearSummary() {
921
    if (isTTY && progressUp) { process.stdout.write('\r\x1b[2K'); progressUp = false; }
4!
922
  }
923

924
  function writeSummary() {
UNCOV
925
    if (!isTTY) return;
×
UNCOV
926
    const running = [...states.entries()].filter(([, s]) => !s.done);
×
927

UNCOV
928
    if (!running.length) return;
×
929

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

UNCOV
934
    process.stdout.write(`${spin} ${parts.join(' ')} ${clr.time(`(${total}s)`)}`);
×
UNCOV
935
    progressUp = true;
×
936
  }
937

938
  function startStep(label) {
939
    states.set(label, { start: Date.now(), done: false, error: false });
15✔
940
    if (!isTTY) process.stdout.write(`  ${clr.label(`[${label}]`)} starting...\n`);
15!
941
  }
942

943
  function finishStep(label, error = false) {
14✔
944
    const s = states.get(label);
15✔
945

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

948
    const icon = error ? clr.fail('βœ—') : clr.done('βœ“');
15✔
949
    const word = error ? 'failed' : 'done  ';
15✔
950

951
    if (isTTY) {
15!
UNCOV
952
      clearSummary();
×
UNCOV
953
      process.stdout.write(`${icon} ${clr.label(`[${label}]`)} ${word} ${clr.time(`(${s ? s.elapsed : '?'}s)`)}\n`);
×
UNCOV
954
      if ([...states.values()].every(v => v.done)) {
×
UNCOV
955
        clearInterval(timer);
×
UNCOV
956
        timer = null;
×
957
      } else {
UNCOV
958
        writeSummary();
×
959
      }
960
    } else {
961
      process.stdout.write(`${icon} ${clr.label(`[${label}]`)} ${word} ${clr.time(`(${s ? s.elapsed : '?'}s)`)}\n`);
15!
962
    }
963
  }
964

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

969
  function step(label, fn) {
970
    startStep(label);
15✔
971
    return fn()
15✔
972
      .then(() => finishStep(label))
14✔
973
      .catch(e => {
974
        process.stderr.write(`\n${clr.fail('[error]')} ${clr.label(label)}: ${e.message}\n`);
1✔
975
        finishStep(label, true);
1✔
976
        stepErrors.push(e);
1✔
977
      });
978
  }
979

980
  process.stdout.write('\nBuilding assets...\n');
4✔
981

982
  // Media must complete before templates β€” the template step reads SVG files
983
  // from public/media/ via {{{ read 'public/media/…' }}} Handlebars helpers.
984
  if (runMedia) {
4✔
985
    await step('media', () => copyMedia());
3✔
986
  }
987

988
  if (isTTY) {
4!
UNCOV
989
    timer = setInterval(() => { spinFrame++; clearSummary(); writeSummary(); }, 80);
×
990
  }
991

992
  const tasks = buildSelectedParallelTasks(step, shouldRun, options);
4✔
993

994
  await Promise.all(tasks);
4✔
995

996
  if (timer) { clearInterval(timer); timer = null; }
4!
997
  clearSummary();
4✔
998

999
  if (stepErrors.length) {
4✔
1000
    const names = stepErrors.map(e => e.message).join('; ');
1✔
1001

1002
    throw new Error(`Build failed: ${stepErrors.length} step(s) failed β€” ${names}`);
1✔
1003
  }
1004

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

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

1010
/**
1011
 * One-shot production build.
1012
 *
1013
 * @param {object} [options]
1014
 */
1015
async function build(options = {}) {
×
UNCOV
1016
  return buildAll(options);
×
1017
}
1018

1019
exports.build = build;
24✔
1020
exports.buildAll = buildAll;
24✔
1021

1022
// ── Watch mode ───────────────────────────────────────────────────────────────
1023

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

1049
  let isFirstBuild = true;
1✔
1050
  const chokidar = require('chokidar');
1✔
1051

1052
  const chokidarOpts = {
1✔
1053
    ignoreInitial: true,
1054
    usePolling:    true,
1055
    interval:      100,
1056
    awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 50 },
1057
  };
1058

1059
  function debounce(fn, ms) {
1060
    let t;
1061

1062
    return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
11✔
1063
  }
1064

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

1067
  const clr = {
1✔
1068
    // per-asset prefix colors β€” each asset type gets a unique identity color
1069
    js:        s => `\x1b[96m${s}\x1b[0m`,   // bright cyan
2✔
UNCOV
1070
    kiln:      s => `\x1b[35m${s}\x1b[0m`,   // magenta
×
1071
    styles:    s => `\x1b[34m${s}\x1b[0m`,   // blue
6✔
1072
    fonts:     s => `\x1b[93m${s}\x1b[0m`,   // bright yellow
6✔
1073
    templates: s => `\x1b[92m${s}\x1b[0m`,   // bright green
6✔
1074
    // semantic state colors
1075
    rebuilt: s => `\x1b[32m${s}\x1b[0m`,
4✔
1076
    file:    s => `\x1b[36m${s}\x1b[0m`,
6✔
1077
    error:   s => `\x1b[31m${s}\x1b[0m`,
3✔
1078
  };
1079

1080
  // ── JS watch via Rollup watch mode ──────────────────────────────────────────
1081

1082
  const viteCfg = getViteConfig(options);
1✔
1083

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

1091
  // Same parallelization as buildJS: globals + kiln-edit first, then bootstrap.
1092
  await Promise.all([
1✔
1093
    generateViteGlobalsInit(),
1094
    generateViteKilnEditEntry(),
1095
  ]);
1096
  await generateViteBootstrap();
1✔
1097
  await fs.ensureDir(DEST);
1✔
1098

1099
  // Prints "<coloredPrefix> still building... (Xs)" every 3 s while a rebuild
1100
  // is in flight so the terminal never looks frozen.  Returns a stop() function.
1101
  // coloredPrefix should be the pre-colored tag, e.g. clr.js('[js]').
1102
  function startProgressTick(coloredPrefix) {
1103
    const t0 = Date.now();
6✔
1104
    const id  = setInterval(() => {
6✔
UNCOV
1105
      const s = Math.round((Date.now() - t0) / 1000);
×
1106

UNCOV
1107
      console.log(`${coloredPrefix} still building... (${s}s)`);
×
1108
    }, 3000);
1109

1110
    return () => clearInterval(id);
6✔
1111
  }
1112

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

1117
  let kilnOutput     = null;
1✔
1118

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

1121
  let resolveKilnOutput, rejectKilnOutput;
1122

1123
  function resetKilnOutputPromise() {
1124
    return new Promise((res, rej) => { resolveKilnOutput = res; rejectKilnOutput = rej; });
2✔
1125
  }
1126

1127
  let kilnOutputPromise  = resetKilnOutputPromise();
1✔
1128

1129
  let isFirstKilnBuild   = true;
1✔
1130

1131
  // KILN_BANNER is defined at module level above runKilnBuild β€” shared constant.
1132

1133
  let kilnTransformCount = 0;
1✔
1134

1135
  const captureKilnOutputPlugin = {
1✔
1136
    name: 'clay-capture-kiln-output',
1137

1138
    watchChange(id, { event }) {
UNCOV
1139
      console.log(clr.kiln(`[kiln] ${event}: `) + clr.file(path.relative(CWD, id)));
×
1140
    },
1141

1142
    transform() {
UNCOV
1143
      kilnTransformCount++;
×
1144
    },
1145

1146
    writeBundle(_opts, bundle) {
1147
      resolveKilnOutput({ output: Object.values(bundle) });
1✔
1148
    },
1149
  };
1150

1151
  let kilnWatcher = null;
1✔
1152

1153
  if (!viteCfg.kilnSplit) {
1!
1154
    const kilnWatchCfg = baseViteConfig(viteCfg);
1✔
1155

1156
    kilnWatchCfg.plugins = kilnWatchCfg.plugins.concat([envCollector.plugin(), captureKilnOutputPlugin]);
1✔
1157
    kilnWatchCfg.build.outDir = DEST;
1✔
1158
    kilnWatchCfg.build.watch = {};
1✔
1159
    kilnWatchCfg.build.rollupOptions = {
1✔
1160
      input:  { [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE },
1161
      output: {
1162
        format:               'esm',
1163
        entryFileNames:       '[name]-[hash].js',
1164
        inlineDynamicImports: true,
1165
        banner:               KILN_BANNER,
1166
      },
1167
      onwarn: suppressWarning,
1168
      watch: {
1169
        exclude: [
1170
          path.join(CWD, 'public', '**'),
1171
          path.join(CWD, 'node_modules', '**'),
1172
          path.join(CLAY_DIR, '**'),
1173
        ],
1174
      },
1175
    };
1176

1177
    kilnWatcher = await vite.build(kilnWatchCfg);
1✔
1178

1179
    let stopKilnTick = () => {};
1✔
1180

1181
    async function handleKilnBundleEnd(event) {
1182
      stopKilnTick();
1✔
1183

1184
      const output         = await kilnOutputPromise;
1✔
1185
      const modulesRebuilt = kilnTransformCount;
1✔
1186
      const wasFirst       = isFirstKilnBuild;
1✔
1187

1188
      kilnOutput         = output;
1✔
1189
      kilnOutputPromise  = resetKilnOutputPromise();
1✔
1190
      kilnTransformCount = 0;
1✔
1191
      isFirstKilnBuild   = false;
1✔
1192

1193
      if (lastViewOutput) {
1!
1194
        // Always reconcile the manifest when we have both outputs β€” this fixes
1195
        // a race where the view watcher wrote the manifest before the kiln
1196
        // watcher finished its first build, leaving the kiln entry absent.
1197
        const manifest = buildManifest(lastViewOutput, kilnOutput);
1✔
1198

1199
        await writeManifest(manifest);
1✔
1200
        await envCollector.write();
1✔
1201

1202
        if (!wasFirst) {
1!
1203
          // Only log on incremental rebuilds; suppress the initial startup cycle.
UNCOV
1204
          const moduleLabel = modulesRebuilt === 1 ? '1 module' : `${modulesRebuilt} modules`;
×
UNCOV
1205
          const duration    = event.duration != null ? ` in ${event.duration}ms` : '';
×
1206

UNCOV
1207
          console.log(clr.kiln('[kiln]') + clr.rebuilt(' Rebuilt') + ` (${moduleLabel} transformed${duration})`);
×
1208
        } else if (onReady) {
1!
1209
          // Signal ready only after both view AND kiln have completed their
1210
          // first build β€” the manifest is now complete with both entries.
1211
          onReady();
1✔
1212
        }
1213
      }
1214
      if (event.result && event.result.close) event.result.close();
1!
1215
    }
1216

1217
    kilnWatcher.on('event', async (event) => {
1✔
1218
      if (event.code === 'BUNDLE_START') {
2✔
1219
        if (!isFirstKilnBuild) stopKilnTick = startProgressTick(clr.kiln('[kiln]'));
1!
1220
        else stopKilnTick = () => {};
1✔
1221
      } else if (event.code === 'BUNDLE_END') {
1!
1222
        await handleKilnBundleEnd(event);
1✔
UNCOV
1223
      } else if (event.code === 'ERROR') {
×
UNCOV
1224
        stopKilnTick();
×
1225
        rejectKilnOutput(event.error);
×
UNCOV
1226
        kilnOutputPromise = resetKilnOutputPromise();
×
UNCOV
1227
        console.error(clr.kiln('[kiln]') + clr.error(` Build error: ${event.error.message}`));
×
UNCOV
1228
        if (event.result && event.result.close) event.result.close();
×
1229
      }
1230
    });
1231
  }
1232

1233
  const watchInput = viteCfg.kilnSplit
1!
1234
    ? { [VITE_BOOTSTRAP_KEY]: VITE_BOOTSTRAP_FILE, [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE }
1235
    : { [VITE_BOOTSTRAP_KEY]: VITE_BOOTSTRAP_FILE };
1236

1237
  const watchCfg = baseViteConfig(viteCfg);
1✔
1238

1239
  // Vite closes the internal RollupBuild before emitting BUNDLE_END to external
1240
  // listeners, so event.result.generate() throws "Bundle is already closed".
1241
  // Instead, capture chunk data inside a writeBundle plugin hook which fires
1242
  // after files are written to disk (but before BUNDLE_END reaches us), giving
1243
  // us the same {output:[]} shape that buildManifest expects.
1244
  //
1245
  // We use a resolve/reject pair so handleBundleEnd can *await* the plugin
1246
  // result rather than reading a mutable variable β€” avoids silent stale data
1247
  // if a build error ever prevents writeBundle from firing.
1248
  let resolveViewOutput, rejectViewOutput;
1249

1250
  function resetViewOutputPromise() {
1251
    return new Promise((res, rej) => {
2✔
1252
      resolveViewOutput = res;
2✔
1253
      rejectViewOutput  = rej;
2✔
1254
    });
1255
  }
1256

1257
  let viewOutputPromise = resetViewOutputPromise();
1✔
1258

1259
  let transformCount    = 0;
1✔
1260

1261
  const captureOutputPlugin = {
1✔
1262
    name: 'clay-capture-watch-output',
1263

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

1272
    // transform is called only for modules Rollup needs to re-process this
1273
    // cycle.  With the watch-mode module cache, unchanged modules are skipped β€”
1274
    // so this count reflects the actual incremental rebuild scope.
1275
    transform() {
UNCOV
1276
      transformCount++;
×
1277
    },
1278

1279
    // writeBundle fires after every file has been written to disk.
1280
    // `bundle` is a {[fileName]: OutputChunk|OutputAsset} map; convert it to
1281
    // the RollupOutput shape {output:[...]} that buildManifest iterates.
1282
    writeBundle(_opts, bundle) {
1283
      resolveViewOutput({ output: Object.values(bundle) });
1✔
1284
    },
1285
  };
1286

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

1291
  watchCfg.build.outDir = DEST;
1✔
1292
  watchCfg.build.watch = {};
1✔
1293
  watchCfg.build.rollupOptions = {
1✔
1294
    input:  watchInput,
1295
    output: {
1296
      format:         'esm',
1297
      entryFileNames: '[name]-[hash].js',
1298
      chunkFileNames: 'chunks/[name]-[hash].js',
1299
      // See buildChunkingOutput() for CJS vs ESM branching rationale.
1300
      ...buildChunkingOutput(viteCfg),
1301
    },
1302
    onwarn: suppressWarning,
1303
    watch: {
1304
      // Exclude build outputs and dependencies from Rollup's file watcher to
1305
      // prevent feedback loops where writing a chunk triggers another rebuild.
1306
      exclude: [
1307
        path.join(CWD, 'public', '**'),
1308
        path.join(CWD, 'node_modules', '**'),
1309
        path.join(CLAY_DIR, '**'),
1310
      ],
1311
    },
1312
  };
1313

1314
  const watcher = await vite.build(watchCfg);
1✔
1315

1316
  let stopJsTick = () => {};
1✔
1317

1318
  // Extract BUNDLE_END handling so the event callback stays below complexity limit.
1319
  // Signal ready after view's first build.  When a separate kiln watcher
1320
  // exists, defer until handleKilnBundleEnd writes the complete manifest.
1321
  function signalReadyIfFirst() {
1322
    if (!isFirstBuild) return;
1!
1323
    isFirstBuild = false;
1✔
1324
    if (onReady && !kilnWatcher) onReady();
1!
1325
  }
1326

1327
  async function handleBundleEnd(event) {
1328
    stopJsTick();
1✔
1329

1330
    // Await the output captured by captureOutputPlugin.writeBundle(), which
1331
    // fires after files are on disk but before BUNDLE_END reaches us.
1332
    // Reset the promise and transform counter immediately so the next rebuild
1333
    // gets a fresh slot.
1334
    const viewOutput      = await viewOutputPromise;
1✔
1335
    const modulesRebuilt  = transformCount;
1✔
1336

1337
    viewOutputPromise = resetViewOutputPromise();
1✔
1338
    transformCount    = 0;
1✔
1339
    lastViewOutput    = viewOutput; // retained so kiln BUNDLE_END can update the manifest
1✔
1340

1341
    const manifest = buildManifest(viewOutput, kilnOutput);
1✔
1342

1343
    await writeManifest(manifest);
1✔
1344
    await envCollector.write();
1✔
1345
    if (onRebuild) onRebuild([]);
1!
1346

1347
    const moduleLabel = modulesRebuilt === 1 ? '1 module' : `${modulesRebuilt} modules`;
1!
1348
    const duration    = event.duration != null ? ` in ${event.duration}ms` : '';
1!
1349

1350
    console.log(clr.js('[js]') + clr.rebuilt(' Rebuilt successfully') + ` (${moduleLabel} transformed${duration})`);
1✔
1351
    signalReadyIfFirst();
1✔
1352
    if (event.result && event.result.close) event.result.close();
1!
1353
  }
1354

1355
  watcher.on('event', async (event) => {
1✔
1356
    if (event.code === 'BUNDLE_START') {
2✔
1357
      console.log(clr.js('[js]') + ' Rebuilding...');
1✔
1358
      stopJsTick = isFirstBuild ? () => {} : startProgressTick(clr.js('[js]'));
1!
1359
    } else if (event.code === 'BUNDLE_END') {
1!
1360
      await handleBundleEnd(event);
1✔
UNCOV
1361
    } else if (event.code === 'ERROR') {
×
UNCOV
1362
      stopJsTick();
×
1363
      // Reject the output promise so handleBundleEnd doesn't hang if writeBundle
1364
      // was skipped due to the error, then reset for the next rebuild attempt.
UNCOV
1365
      rejectViewOutput(event.error);
×
UNCOV
1366
      viewOutputPromise = resetViewOutputPromise();
×
UNCOV
1367
      console.error(clr.js('[js]') + clr.error(` Build error: ${event.error.message}`));
×
UNCOV
1368
      if (onRebuild) onRebuild([event.error]);
×
1369
    }
1370
  });
1371

1372
  // ── Chokidar: regenerate bootstrap when new client.js files appear ─────────
1373

1374
  // Only regenerate generated entry files when the SET of source files changes
1375
  // (add/unlink).  For plain edits Rollup's incremental watcher already tracks
1376
  // every file in the module graph and rebuilds only the dirty sub-graph β€”
1377
  // writing the entry file again would touch its mtime and trigger a second
1378
  // unnecessary rebuild.
1379
  async function regenerateEntryFiles(changedFile) {
UNCOV
1380
    const isGlobal   = changedFile.includes(`${path.sep}global${path.sep}`);
×
UNCOV
1381
    const isKilnFile = changedFile.endsWith('model.js') || changedFile.endsWith('kiln.js');
×
1382

UNCOV
1383
    if (isGlobal) {
×
1384
      // A global/js file was added or removed: regenerate the import list first,
1385
      // then the bootstrap (which may reference the globals entry).
UNCOV
1386
      await generateViteGlobalsInit();
×
UNCOV
1387
      await generateViteBootstrap();
×
UNCOV
1388
    } else if (changedFile.endsWith('client.js')) {
×
UNCOV
1389
      await generateViteBootstrap();
×
UNCOV
1390
    } else if (isKilnFile) {
×
1391
      // The kiln watcher detects the entry file change and rebuilds incrementally.
UNCOV
1392
      await generateViteKilnEditEntry();
×
1393
    }
1394
  }
1395

1396
  const rebuildBootstrap = debounce(async (changedFile, eventType) => {
1✔
1397
    if (!changedFile) return;
1!
1398

1399
    const isChange   = eventType === 'change';
1✔
1400
    const isKilnFile = changedFile.endsWith('model.js') || changedFile.endsWith('kiln.js');
1✔
1401

1402
    // For plain edits Rollup's watchChange hook already logs the file β€” only
1403
    // log here for add/unlink where the file isn't in Rollup's graph yet.
1404
    if (!isChange) {
1!
UNCOV
1405
      const pfxFn = isKilnFile ? clr.kiln : clr.js;
×
UNCOV
1406
      const pfxLabel = isKilnFile ? '[kiln]' : '[js]';
×
1407

UNCOV
1408
      console.log(pfxFn(`${pfxLabel} ${eventType}: `) + clr.file(rel(changedFile)));
×
UNCOV
1409
      await regenerateEntryFiles(changedFile);
×
1410
    }
1411
    // For change events, the relevant Rollup watcher handles it incrementally.
1412
  }, 200);
1413

1414
  const JS_GLOBS = [
1✔
1415
    path.join(CWD, 'components', '**', '*.js'),
1416
    path.join(CWD, 'components', '**', '*.vue'),
1417
    path.join(CWD, 'layouts', '**', '*.js'),
1418
    path.join(CWD, 'global', '**', '*.js'),
1419
    path.join(CWD, 'services', '**', '*.js'),
1420
  ];
1421

1422
  const jsWatcher = chokidar.watch(JS_GLOBS, {
1✔
1423
    ...chokidarOpts,
1424
    ignored: [path.join(CWD, 'public', '**'), path.join(CWD, 'node_modules', '**')],
1425
  });
1426

1427
  jsWatcher
1✔
1428
    .on('change', f => rebuildBootstrap(f, 'change'))
1✔
1429
    .on('add',    f => rebuildBootstrap(f, 'add'))
3✔
1430
    .on('unlink', f => rebuildBootstrap(f, 'unlink'));
1✔
1431

1432
  // ── CSS watcher ──────────────────────────────────────────────────────────────
1433
  // When a component/layout CSS file changes, only rebuild that component's file
1434
  // across all styleguides (e.g. nav.nymag.css, nav.curbed.css, nav._default.css)
1435
  // rather than recompiling all ~2800 CSS files.
1436
  //
1437
  // Falls back to a full rebuild if the changed file is a shared mixin/variable
1438
  // (i.e. its basename doesn't appear in any standard component/layout glob).
1439
  const { globSync: cssGlobSync } = require('glob');
1✔
1440

1441
  function getChangedStyleFiles(changedFile) {
1442
    if (!changedFile) return null; // null β†’ full rebuild
2!
1443
    const basename = path.basename(changedFile); // e.g. "nav.css"
2✔
1444
    const variants = [
2✔
1445
      ...cssGlobSync(path.join(CWD, 'styleguides', '**', 'components', basename)),
1446
      ...cssGlobSync(path.join(CWD, 'styleguides', '**', 'layouts', basename)),
1447
    ];
1448

1449
    // If no variants found (shared mixin/variable file), do a full rebuild.
1450
    return variants.length > 0 ? variants : null;
2!
1451
  }
1452

1453
  const rebuildStyles = debounce((changedFile) => {
1✔
1454
    if (changedFile) console.log(clr.styles('[styles]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1455
    const changedFiles  = getChangedStyleFiles(changedFile);
2✔
1456
    const buildOpts     = changedFiles ? { ...options, changedFiles } : options;
2!
1457
    const start         = Date.now();
2✔
1458
    const stopStyleTick = startProgressTick(clr.styles('[styles]'));
2✔
1459

1460
    return buildStyles(buildOpts)
2✔
1461
      .then(() => {
1462
        stopStyleTick();
1✔
1463
        console.log(clr.styles('[styles]') + clr.rebuilt(` Rebuilt (${Date.now() - start}ms)`));
1✔
1464
        // Write reload-signal so start.js/nodemon triggers a full server
1465
        // restart.  A restart is necessary because CSS is output with stable
1466
        // filenames (e.g. nav.nymag.css) β€” no content hash β€” so the browser
1467
        // keeps serving its cached copy even after the file changes on disk.
1468
        // A new server process changes the ETag, which forces a re-fetch.
1469
        //
1470
        // TODO: migrate CSS to Lightning CSS with content-hashed output
1471
        // (e.g. nav.nymag-[hash].css) so the manifest carries the new URL and
1472
        // the browser is forced to re-fetch naturally on every CSS change,
1473
        // exactly like JS chunks work today.  Once that lands, this signal and
1474
        // the nodemon restart in start.js can be removed entirely.
1475
        return fs.ensureDir(CLAY_DIR)
1✔
1476
          .then(() => fs.writeFile(path.join(CLAY_DIR, 'reload-signal'), String(Date.now())))
1✔
UNCOV
1477
          .catch(e => console.warn(`[styles] could not write reload-signal: ${e.message}`));
×
1478
      })
1479
      .catch(e => { stopStyleTick(); console.error(clr.styles('[styles]') + clr.error(` rebuild failed: ${e.message}`)); });
1✔
1480
  }, 200);
1481

1482
  const cssWatcher = chokidar.watch(STYLE_GLOBS, chokidarOpts);
1✔
1483

1484
  cssWatcher.on('change', rebuildStyles).on('add', rebuildStyles).on('unlink', rebuildStyles);
1✔
1485

1486
  // ── Font watcher ─────────────────────────────────────────────────────────────
1487
  const rebuildFonts = debounce((changedFile) => {
1✔
1488
    if (changedFile) console.log(clr.fonts('[fonts]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1489
    const start        = Date.now();
2✔
1490
    const stopFontTick = startProgressTick(clr.fonts('[fonts]'));
2✔
1491

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

1497
  const fontWatcher = chokidar.watch(FONTS_SRC_GLOB, chokidarOpts);
1✔
1498

1499
  fontWatcher.on('change', rebuildFonts).on('add', rebuildFonts).on('unlink', rebuildFonts);
1✔
1500

1501
  // ── Template watcher ─────────────────────────────────────────────────────────
1502
  // Separate signals so each trigger is handled correctly server-side:
1503
  //   reload-signal    β†’ nodemon.restart() in start.js   (CSS: full restart busts browser cache)
1504
  //   template-signal  β†’ html.init()       in renderers.js (templates: fast in-process re-register)
1505
  const TEMPLATE_SIGNAL = path.join(CLAY_DIR, 'template-signal');
1✔
1506

1507
  const rebuildTemplates = debounce((changedFile) => {
1✔
1508
    if (changedFile) console.log(clr.templates('[templates]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1509
    const start            = Date.now();
2✔
1510
    const stopTemplateTick = startProgressTick(clr.templates('[templates]'));
2✔
1511

1512
    return buildTemplates({ ...options, watch: true })
2✔
1513
      .then(() => {
1514
        stopTemplateTick();
1✔
1515
        console.log(clr.templates('[templates]') + clr.rebuilt(` Rebuilt (${Date.now() - start}ms)`));
1✔
1516
        // Write template-signal so renderers.js calls html.init() in-process
1517
        // (~100ms) without a full server restart.
1518
        return fs.ensureDir(CLAY_DIR)
1✔
1519
          .then(() => fs.writeFile(TEMPLATE_SIGNAL, String(Date.now())))
1✔
UNCOV
1520
          .catch(e => console.warn(`[templates] could not write template-signal: ${e.message}`));
×
1521
      })
1522
      .catch(e => { stopTemplateTick(); console.error(clr.templates('[templates]') + clr.error(` rebuild failed: ${e.message}`)); });
1✔
1523
  }, 200);
1524

1525
  const templateGlobs = [
1✔
1526
    path.join(CWD, 'components', '**', TEMPLATE_GLOB_PATTERN),
1527
    path.join(CWD, 'layouts', '**', TEMPLATE_GLOB_PATTERN),
1528
  ];
1529
  const templateWatcher = chokidar.watch(templateGlobs, chokidarOpts);
1✔
1530

1531
  templateWatcher
1✔
1532
    .on('change', rebuildTemplates)
1533
    .on('add', rebuildTemplates)
1534
    .on('unlink', rebuildTemplates);
1535

1536
  const chokidarWatchers = [jsWatcher, cssWatcher, fontWatcher, templateWatcher];
1✔
1537

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

1540
  return {
1✔
1541
    dispose: async () => {
1542
      if (watcher     && watcher.close)     watcher.close();
1!
1543
      if (kilnWatcher && kilnWatcher.close) kilnWatcher.close();
1!
1544
      await Promise.all(chokidarWatchers.map(w => w.close()));
4✔
1545
    },
1546
  };
1547
}
1548

1549
exports.watch = watch;
24✔
1550

1551
// ── Warning suppressor ───────────────────────────────────────────────────────
1552

1553
/**
1554
 * Suppress Rollup warnings that are expected when bundling a CJS-heavy
1555
 * codebase into a native ESM output.
1556
 *
1557
 * These are not real errors β€” they reflect the current state of the codebase
1558
 * (CJS sources, circular dependencies, missing optional modules) and will
1559
 * disappear naturally as files are migrated to ESM.
1560
 *
1561
 * @param {object}   warning
1562
 * @param {function} warn
1563
 */
1564
// Warning codes that are always expected in a mixed CJS/ESM codebase and
1565
// should not surface to the user.  Each code is explained inline.
1566
const SUPPRESSED_WARNING_CODES = new Set([
24✔
1567
  // Circular requires() are common in CJS codebases and handled safely by
1568
  // strictRequires:'auto'.  They become non-issues once files are ESM.
1569
  'CIRCULAR_DEPENDENCY',
1570

1571
  // CJS modules use `this` at the top level (equivalent to `module.exports`).
1572
  // Rollup warns because in ESM `this` is undefined at the top level.
1573
  // @rollup/plugin-commonjs wraps these so the reference is correct.
1574
  'THIS_IS_UNDEFINED',
1575

1576
  // Some globals (e.g. jQuery's $) are expected to be on window and are not
1577
  // imported.  Clay components reference them as free variables.
1578
  'MISSING_GLOBAL_NAME',
1579

1580
  // Missing optional imports are stubbed by viteMissingModulePlugin.  Rollup
1581
  // warns before the plugin catches them; the warning is spurious.
1582
  'UNRESOLVED_IMPORT',
1583

1584
  // CJS modules wrapped by @rollup/plugin-commonjs may not export named
1585
  // bindings.  Named imports from CJS modules get undefined β€” expected.
1586
  'MISSING_EXPORT',
1587
]);
1588

1589
function suppressWarning(warning, warn) {
UNCOV
1590
  if (SUPPRESSED_WARNING_CODES.has(warning.code)) return;
×
1591

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

UNCOV
1596
  warn(warning);
×
1597
}
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