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

clay / claycli / 26581303233

28 May 2026 02:33PM UTC coverage: 87.038% (+0.2%) from 86.85%
26581303233

Pull #252

github

jjpaulino
🍕 Load Vite bootstrap before kiln edit entry.

Ensure edit mode module scripts are ordered so bootstrap/env globals initialize before kiln plugin code executes.

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

757 of 941 branches covered (80.45%)

Branch coverage included in aggregate %.

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

57 existing lines in 1 file now uncovered.

1620 of 1790 relevant lines covered (90.5%)

10.09 hits per line

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

72.97
/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';
21✔
7

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

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

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

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

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

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

181
/**
182
 * Build the define map injected into every module at compile time.
183
 *
184
 * Vite uses esbuild under the hood for define substitution, so these are
185
 * identifier-scoped replacements (not substring replacements like sed).
186
 * Only meaningful identifiers are replaced — string literals are never touched.
187
 *
188
 * Node globals (process, __filename, etc.) appear in isomorphic Clay services
189
 * that run on both the server and in the browser.  Stubbing them here lets the
190
 * browser bundle compile without a polyfill, while the real Node values are
191
 * used at runtime on the server.
192
 *
193
 * @param {object} userDefines - extra defines from bundlerConfig()
194
 * @returns {object}
195
 */
196
function buildDefines(userDefines = {}) {
×
197
  const NODE_ENV = process.env.NODE_ENV || 'development';
9!
198

199
  return Object.assign(
9✔
200
    {
201
      // Guards like `if (process.env.NODE_ENV === 'production')` tree-shake
202
      // correctly in the browser bundle.
203
      'process.env.NODE_ENV': JSON.stringify(NODE_ENV),
204

205
      // process.browser is a convention used by some isomorphic libraries to
206
      // branch between Node and browser paths.
207
      'process.browser': JSON.stringify(true),
208

209
      // process.version and process.versions are read by clay-log to detect
210
      // the runtime environment. Stub to empty values so the browser branch
211
      // is taken rather than the Node branch.
212
      'process.version':  JSON.stringify(''),
213
      'process.versions': JSON.stringify({}),
214

215
      // __filename and __dirname are used in some server-side Clay utilities.
216
      // In the browser bundle they are never accessed at runtime, but they
217
      // must resolve to something so the module compiles.
218
      __filename: JSON.stringify(''),
219
      __dirname:  JSON.stringify('/'),
220

221
      // global → globalThis. Some older CJS packages reference global instead
222
      // of window. globalThis is universally available in ES2017+ environments.
223
      global: 'globalThis',
224
    },
225
    userDefines
226
  );
227
}
228

229
/**
230
 * Assemble the Vite plugin array for a build pass.
231
 *
232
 * Plugin order matters because each runs in sequence on every resolved module:
233
 *
234
 *   1. browser-compat  — intercepts Node built-in imports (fs, path, events …)
235
 *                        and replaces them with browser-safe stubs BEFORE any
236
 *                        other plugin or Vite's resolver sees them.  Runs first
237
 *                        because some node_modules transitively import built-ins
238
 *                        and must be redirected at resolution time.
239
 *
240
 *   2. service-rewrite — redirects any import of services/server/* to the
241
 *                        matching services/client/* file.  Clay components use
242
 *                        isomorphic service paths; the browser bundle always gets
243
 *                        the client implementation.
244
 *
245
 *   3. missing-module  — stubs unresolvable relative imports with an empty ESM
246
 *                        module instead of erroring.  Browserify silently skipped
247
 *                        missing requires; this plugin preserves that lenient
248
 *                        behaviour while the codebase is still being cleaned up.
249
 *
250
 *   4. vue2            — compiles .vue Single File Components using Vue 2's
251
 *                        template compiler.  Must run before Vite's JS pipeline
252
 *                        so that .vue files are converted to plain JS before
253
 *                        @rollup/plugin-commonjs processes any require() calls
254
 *                        in the <script> block.
255
 *
256
 *   5. user plugins    — site-specific overrides from bundlerConfig().plugins,
257
 *                        appended last so they can override any of the above.
258
 *
259
 * Note: the client-env collector plugin (clay-client-env) is NOT assembled
260
 * here.  It is created per-build in buildJS/watch via createClientEnvCollector
261
 * and injected as an internalPlugins argument to runViewBuild/runKilnBuild.
262
 * This keeps the collector separate from the user-facing plugin list.
263
 *
264
 * CJS→ESM conversion is handled by Vite's single built-in @rollup/plugin-commonjs
265
 * instance, configured in baseViteConfig() via build.commonjsOptions.  A second
266
 * instance must NOT be added here — two commonjs instances each maintain their
267
 * own internal virtual-module state (?commonjs-proxy, ?commonjs-wrapped …) and
268
 * will leave require() calls untransformed in the final bundle.
269
 *
270
 * @param {object[]} [extraPlugins]
271
 * @param {object}   [browserStubs]  site stubs from bundlerConfig().browserStubs
272
 * @param {object}   [opts]
273
 * @param {boolean}  [opts.lenientBrowserExternalize=false]  forwarded to
274
 *   viteBrowserCompatPlugin; see that plugin's JSDoc for semantics.
275
 * @returns {object[]}
276
 */
277
function buildPlugins(extraPlugins = [], browserStubs = {}, opts = {}) {
×
278
  return [
9✔
279
    viteBrowserCompatPlugin(browserStubs, { lenientExternalize: opts.lenientBrowserExternalize === true }),
280
    viteServiceRewritePlugin(),
281
    viteMissingModulePlugin(),
282
    viteVue2Plugin(),
283
    ...extraPlugins,
284
  ];
285
}
286

287
// ── Manifest ────────────────────────────────────────────────────────────────
288

289
/**
290
 * Build _manifest.json from one or two Vite RollupOutput results.
291
 *
292
 * viewOutput  — the splitting pass (bootstrap + component chunks)
293
 * kilnOutput  — the single-file kiln edit pass (null when kilnSplit is true)
294
 *
295
 * The manifest shape { key: { file, imports } } is the contract between the
296
 * build pipeline and resolve-media.js.  Every pipeline (Browserify included)
297
 * must produce a compatible manifest so the runtime asset injector works
298
 * without knowing which bundler was used.
299
 *
300
 * @param {Object|null} viewOutput
301
 * @param {Object|null} kilnOutput
302
 * @param {string} publicBase
303
 * @returns {object}
304
 */
305
function buildManifest(viewOutput, kilnOutput = null, publicBase = '/js') {
6!
306
  const manifest = {};
6✔
307

308
  for (const output of [viewOutput, kilnOutput]) {
6✔
309
    for (const chunk of output ? output.output : []) {
12✔
310
      if (chunk.type !== 'chunk' || !chunk.isEntry) continue;
8!
311

312
      const facadeId = chunk.facadeModuleId;
8✔
313

314
      if (!facadeId) continue;
8!
315

316
      const cleanFacadeId = facadeId.replace(/\?.*$/, '');
8✔
317
      const entryKey = path.relative(CWD, cleanFacadeId)
8✔
318
        .replace(/\\/g, '/')
319
        .replace(/\.js$/, '');
320

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

324
      manifest[entryKey] = { file: fileUrl, imports: importUrls };
8✔
325
    }
326
  }
327

328
  return manifest;
6✔
329
}
330

331
async function writeManifest(manifest) {
332
  const manifestPath = path.join(DEST, '_manifest.json');
6✔
333

334
  await fs.outputJson(manifestPath, manifest, { spaces: 2 });
6✔
335
}
336

337
// ── Vite build config factory ────────────────────────────────────────────────
338

339
/**
340
 * Return the base Vite config shared by both build passes (view and kiln).
341
 *
342
 * ── Why Vite for production builds ──────────────────────────────────────────
343
 *
344
 * The legacy Browserify pipeline bundled all component JavaScript into a
345
 * handful of monolithic files.  Every page load re-downloaded the full bundle
346
 * even if the user had visited before, and every deploy invalidated the cache
347
 * for all pages simultaneously.
348
 *
349
 * Vite's production build uses Rollup under the hood to emit native ES modules
350
 * with content-hashed filenames.  Components are loaded on demand via
351
 * dynamic import(), so the browser only fetches the code each page actually
352
 * needs.  Unchanged modules keep their hash across deploys and are served
353
 * straight from the browser cache on repeat visits.
354
 *
355
 * ── Why native ESM output ────────────────────────────────────────────────────
356
 *
357
 * Browserify emitted a single synchronous IIFE bundle — the browser had to
358
 * parse and evaluate all component code before any component could mount,
359
 * which directly raised Time to Interactive and Total Blocking Time.
360
 *
361
 * Native ESM allows the browser to parse modules in parallel and defer
362
 * evaluation of off-screen components until they are actually needed.
363
 *
364
 * ── Migration doors ─────────────────────────────────────────────────────────
365
 *
366
 * CSS (Lightning CSS):
367
 *   The styles step currently uses PostCSS via buildStyles().
368
 *   When ready to migrate, add:
369
 *     css: { transformer: 'lightningcss', lightningcssOptions: { ... } }
370
 *   to the returned config object and remove the PostCSS step from buildAll().
371
 *   The `lightningcss` package must be added as a dependency.
372
 *   Lightning CSS is faster and handles modern CSS features (nesting, color-mix,
373
 *   etc.) natively without PostCSS plugins.
374
 *
375
 * Vue 3:
376
 *   The vue2 plugin handles .vue files today.  To start writing new components
377
 *   in Vue 3, add @vitejs/plugin-vue to bundlerConfig().plugins in claycli.config.js:
378
 *     import vuePlugin from '@vitejs/plugin-vue';
379
 *     config.plugins.push(vuePlugin());
380
 *   Both plugins can coexist — vue2 handles legacy SFCs, @vitejs/plugin-vue
381
 *   handles new ones (differentiate by directory or a file-naming convention).
382
 *   Once all .vue files are migrated to Vue 3, remove viteVue2Plugin().
383
 *
384
 * ESM migration:
385
 *   As source files are converted from require()/module.exports to import/export,
386
 *   the CJS shims shrink automatically:
387
 *     - @rollup/plugin-commonjs (commonjsOptions) becomes a no-op per migrated file
388
 *     - strictRequires entries drop as circular require() cycles are eliminated
389
 *     - transformMixedEsModules can be removed once all .vue scripts use ESM
390
 *     - kilnSplit can be set true once all model.js/kiln.js files are ESM,
391
 *       collapsing the two-pass build into a single faster graph
392
 *   New components should be written as ESM from day one.
393
 *
394
 * @param {object} viteCfg   — result of getViteConfig()
395
 * @returns {object}
396
 */
397
function baseViteConfig(viteCfg) {
398
  // The base path must match the URL prefix under which public/js/ is served.
399
  // Vite embeds this into dynamic import() calls inside the bootstrap so that
400
  // chunk URLs resolve to /js/chunks/… rather than just /chunks/….
401
  const publicBase = (viteCfg.publicBase || '/js').replace(/\/$/, '');
9✔
402

403
  return {
9✔
404
    root:       CWD,
405
    base:       publicBase + '/',
406
    configFile: false, // always use this programmatic config; never read vite.config.js
407
    logLevel:   'warn',
408
    plugins:    buildPlugins(viteCfg.plugins, viteCfg.browserStubs, {
409
      lenientBrowserExternalize: viteCfg.lenientBrowserExternalize === true,
410
    }),
411
    define:     buildDefines(viteCfg.define),
412
    resolve: {
413
      browserField: true,
414

415
      // Field resolution order: prefer the browser-specific build, then the
416
      // CommonJS main entry, then ESM via the module field.
417
      //
418
      // `module` is intentionally last: some packages ship both CJS (main) and
419
      // ESM (module) builds; using the CJS build is safer when @rollup/plugin-commonjs
420
      // is active because it applies the same transformation consistently.
421
      //
422
      // ESM migration lever: once the codebase no longer needs @rollup/plugin-commonjs,
423
      // flip this to ['browser', 'module', 'main'] so Rollup can tree-shake ESM
424
      // packages directly.
425
      mainFields: ['browser', 'main', 'module'],
426

427
      // Site-defined module aliases from bundlerConfig().alias.
428
      // This is the first-class alternative to writing a full Rollup plugin just
429
      // to redirect a specifier.  Common use-cases:
430
      //   - swap a server-only package for its browser equivalent at build time
431
      //     (e.g. '@sentry/node' → '@sentry/browser')
432
      //   - point a bare specifier at an absolute path
433
      //     (e.g. '@utils' → path.resolve(__dirname, 'src/utils'))
434
      // For complex patterns (regex, conditional logic), use bundlerConfig().plugins
435
      // with a resolveId hook instead.
436
      alias: viteCfg.alias || {},
9!
437
    },
438

439
    // optimizeDeps is Vite's pre-bundler that converts CJS node_modules to ESM using
440
    // esbuild before Rollup processes them.  We use Vite exclusively for production builds
441
    // (vite build) — the Vite dev server and HMR are not used.  Clay runs a server-rendered
442
    // architecture (Amphora) where watch mode uses Rollup's own incremental rebuild, not a
443
    // Vite dev server in the request path.
444
    //
445
    // optimizeDeps does NOT run during `vite build` — production builds go straight through
446
    // Rollup, which handles CJS via @rollup/plugin-commonjs (configured below).
447
    // Setting noDiscovery:true prevents accidental dep scanning that can add latency
448
    // to build startup in some Vite versions.
449
    optimizeDeps: { noDiscovery: true, include: [] },
450

451
    build: {
452
      // Target ES2017 (async/await, Object.assign, etc.) — supported by all
453
      // browsers we care about.  This lets Rollup emit clean async code without
454
      // transpiling it to generator functions.
455
      //
456
      // ESM migration note: bump to 'es2020' when ready to use optional chaining
457
      // and nullish coalescing natively in the output (Vue 3 recommends es2020+).
458
      target: 'es2017',
459

460
      outDir:      DEST,
461
      emptyOutDir: false, // we write into public/js/ which already exists
462
      // Configurable via bundlerConfig().sourcemap.  Defaults to true so that
463
      // DevTools stack traces and Sentry source mapping work out of the box.
464
      // Set to false in CI pipelines that do not consume source maps to shave
465
      // a few seconds off the build without changing runtime behaviour.
466
      sourcemap:   viteCfg.sourcemap !== false,
467
      minify:      viteCfg.minify ? 'esbuild' : false,
9!
468

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

476
      // We handle CSS extraction ourselves: component .css files go through the
477
      // PostCSS step in buildStyles(), and .vue scoped styles are injected at
478
      // runtime by viteVue2Plugin().  Letting Vite split CSS would generate
479
      // separate .css chunks that nothing requests.
480
      cssCodeSplit: false,
481

482
      // public/js/ lives inside public/ which is also the publicDir.  Vite warns
483
      // when outDir is inside publicDir because it tries to copy publicDir into
484
      // outDir at the end of the build.  We disable that copy entirely — static
485
      // assets are managed by copyMedia/copyVendor/buildFonts.
486
      copyPublicDir: false,
487

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

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

498
      // Configure Vite's single built-in @rollup/plugin-commonjs instance.
499
      //
500
      // This is the bridge between the CJS-heavy existing codebase and Rollup's
501
      // native ESM module graph.  Every require() call in source files is
502
      // converted to an ESM import so Rollup can tree-shake and bundle correctly.
503
      // Browserify handled CJS implicitly; here it is explicit and configurable.
504
      //
505
      // IMPORTANT: do not add a second @rollup/plugin-commonjs instance via plugins[].
506
      // Each instance tracks its own set of ?commonjs-* virtual modules, so two
507
      // instances conflict and leave require() calls in the output.
508
      commonjsOptions: {
509
        // Apply to all JS, CJS, and .vue files.  Two checks must pass inside
510
        // @rollup/plugin-commonjs: the `include` regex (createFilter check) and
511
        // the `extensions` list (path.extname check).
512
        include:    /\.(js|cjs|vue)$/,
513
        extensions: ['.js', '.cjs', '.vue'],
514

515
        // Required for .vue files: viteVue2Plugin always appends `export default __sfc__`
516
        // regardless of whether the <script> block used require() or ESM, so every
517
        // compiled .vue file is "mixed" (CJS body + ESM export default).  Without
518
        // this flag, @rollup/plugin-commonjs would skip the require() conversion and
519
        // leave bare require() calls in the browser bundle.
520
        //
521
        // For plain .js files: mixing require() and import/export in the same file
522
        // is prohibited — files must be either pure CJS or pure ESM.
523
        transformMixedEsModules: true,
524

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

531
        // strictRequires: 'auto' — for modules involved in circular require() chains
532
        // (e.g. services/client/auth.js ↔ services/client/gtm.js), wrap the require()
533
        // calls in lazy getters so both modules can fully initialize before either
534
        // reads the other's exports.  'auto' applies this only to modules that
535
        // @rollup/plugin-commonjs detects as part of a cycle, leaving all other
536
        // require() calls as direct synchronous calls (no overhead).
537
        strictRequires: 'auto',
538

539
        // Sites can exclude specific packages via commonjsExclude in bundlerConfig().
540
        // Use this for packages that use eval() internally in ways that break when
541
        // @rollup/plugin-commonjs rewrites their module scope (e.g. webpack dev-mode
542
        // bundles where eval() strings reference `exports` as a function parameter
543
        // that gets renamed by the CJS transformation).
544
        exclude: viteCfg.commonjsExclude || [],
9!
545
      },
546
    },
547
  };
548
}
549

550
// ── Build passes ────────────────────────────────────────────────────────────
551

552
/**
553
 * Return the output.manualChunks / output.experimentalMinChunkSize entry for
554
 * rollupOptions.output based on whether the codebase is fully ESM.
555
 *
556
 * CJS mode (clientFilesESM:false): @rollup/plugin-commonjs injects \0-prefixed
557
 * virtual proxy modules into the graph which inflate apparent module sizes and
558
 * add phantom importer edges.  Rollup's native experimentalMinChunkSize would
559
 * produce inaccurate merges in this environment.  viteManualChunksPlugin guards
560
 * against virtual modules explicitly and uses info.code.length as an honest size
561
 * proxy for pre-minification CJS source.
562
 *
563
 * ESM mode (clientFilesESM:true): the graph is clean — no proxy modules, no CJS
564
 * wrapper boilerplate.  Rollup's native experimentalMinChunkSize is accurate and
565
 * optimal; viteManualChunksPlugin is no longer needed.  manualChunksMinSize maps
566
 * directly to the native threshold (0 = no merging if the site leaves it unset).
567
 *
568
 * @param {object} viteCfg
569
 * @returns {object}
570
 */
571
function buildChunkingOutput(viteCfg) {
572
  const minSize = viteCfg.manualChunksMinSize ?? 0;
5✔
573

574
  if (viteCfg.clientFilesESM) {
5!
UNCOV
575
    return { experimentalMinChunkSize: minSize };
×
576
  }
577

578
  return { manualChunks: viteManualChunksPlugin(minSize, CWD) };
5✔
579
}
580

581

582
/**
583
 * Pass 1 — view mode (splitting pass).
584
 *
585
 * Builds the bootstrap entry point plus any extra entries.  Rollup splits the
586
 * module graph into shared chunks — each shared dependency gets its own
587
 * content-hashed file that the browser can cache independently.
588
 *
589
 * Compared to Browserify's single bundle, the browser only downloads the code
590
 * for components present on the current page, and unchanged modules are served
591
 * from cache on subsequent page loads.
592
 *
593
 * When kilnSplit is false (the default while model.js files are still CJS),
594
 * the kiln-edit entry is excluded from this graph to prevent CJS model.js
595
 * dependencies from polluting the view-mode chunk set.  Set kilnSplit:true
596
 * in bundlerConfig() once all model.js/kiln.js files use ESM.
597
 *
598
 * @param {object} viteCfg
599
 * @param {object} internalPlugins
600
 * @returns {Promise<Object>}
601
 */
602
async function runViewBuild(viteCfg, internalPlugins) {
603
  const entryMap = {};
4✔
604

605
  entryMap[VITE_BOOTSTRAP_KEY] = VITE_BOOTSTRAP_FILE;
4✔
606

607
  if (viteCfg.kilnSplit) {
4✔
608
    entryMap[KILN_EDIT_ENTRY_KEY] = KILN_EDIT_ENTRY_FILE;
1✔
609
  }
610

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

615
      entryMap[key] = extraPath;
1✔
616
    }
617
  }
618

619
  const cfg = baseViteConfig(viteCfg);
4✔
620

621
  if (internalPlugins && internalPlugins.length) {
4!
622
    cfg.plugins = cfg.plugins.concat(internalPlugins);
4✔
623
  }
624

625
  cfg.build.rollupOptions = {
4✔
626
    input:  entryMap,
627
    output: {
628
      format:         'esm',
629
      entryFileNames: '[name]-[hash].js',
630
      chunkFileNames: 'chunks/[name]-[hash].js',
631
      // See buildChunkingOutput() for CJS vs ESM branching rationale.
632
      ...buildChunkingOutput(viteCfg),
633
    },
634
    onwarn: suppressWarning,
635
  };
636

637
  const result = await vite.build(cfg);
4✔
638

639
  return Array.isArray(result) ? result[0] : result;
4!
640
}
641

642
// Pre-populates all kiln namespaces with empty objects so that module-level
643
// destructuring patterns like `const { pyxis } = window.kiln.config` in
644
// .vue plugin files don't throw before _initKilnPlugins() runs.
645
// Used by both the production kiln pass (runKilnBuild) and the watch kiln
646
// watcher — defined once here to prevent drift.
647
const KILN_BANNER = [
21✔
648
  '(function(){',
649
  '  var k = window.kiln = window.kiln || {};',
650
  '  k.config  = k.config  || {};',
651
  '  k.config.pyxis = k.config.pyxis || {};',
652
  '  k.utils   = k.utils   || {};',
653
  '  k.utils.components = k.utils.components || {};',
654
  '  k.utils.references = k.utils.references || {};',
655
  '  k.utils.componentElements = k.utils.componentElements || {};',
656
  '  k.utils.logger = k.utils.logger || function(){ return { log:function(){}, error:function(){} }; };',
657
  '  k.inputs  = k.inputs  || {};',
658
  '  k.modals  = k.modals  || {};',
659
  '  k.plugins = k.plugins || {};',
660
  '  k.toolbarButtons = k.toolbarButtons || {};',
661
  '  k.navButtons = k.navButtons || {};',
662
  '  k.navContent = k.navContent || {};',
663
  '  k.validators = k.validators || {};',
664
  '  k.transformers = k.transformers || {};',
665
  '  k.kilnInput = k.kilnInput || function(){};',
666
  '})();',
667
].join('\n');
668

669
/**
670
 * Pass 2 — kiln edit mode (no-split pass).
671
 *
672
 * The kiln-edit-init entry imports every component's model.js and kiln.js.
673
 * These files are CJS today and cannot be tree-shaken, so their transitive
674
 * dependencies (utility libraries, lodash, etc.) would bleed into the
675
 * view-mode chunk graph if this entry were included in the splitting pass.
676
 * Running it as a separate isolated pass with inlineDynamicImports:true
677
 * produces one self-contained file that is only loaded in edit mode.
678
 *
679
 * Edit mode (kiln) is a separate concern from public page rendering — this
680
 * file is never delivered to readers, only to editors inside the CMS.
681
 *
682
 * ESM migration path: once all model.js/kiln.js files are ESM, set
683
 * kilnSplit:true in bundlerConfig().  This adds the kiln entry to the same
684
 * splitting graph as the bootstrap, Rollup tree-shakes across both, and the
685
 * separate kiln pass is no longer needed.
686
 *
687
 * @param {object} viteCfg
688
 * @param {object[]} internalPlugins
689
 * @returns {Promise<Object>}
690
 */
691
async function runKilnBuild(viteCfg, internalPlugins) {
692
  const cfg = baseViteConfig(viteCfg);
3✔
693

694
  if (internalPlugins && internalPlugins.length) {
3!
695
    cfg.plugins = cfg.plugins.concat(internalPlugins);
3✔
696
  }
697

698
  cfg.build.rollupOptions = {
3✔
699
    input:  { [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE },
700
    output: {
701
      format:               'esm',
702
      entryFileNames:       '[name]-[hash].js',
703
      inlineDynamicImports: true,
704
      banner:               KILN_BANNER,
705
    },
706
    onwarn: suppressWarning,
707
  };
708

709
  const result = await vite.build(cfg);
3✔
710

711
  return Array.isArray(result) ? result[0] : result;
3!
712
}
713

714
// ── JS build orchestrator ────────────────────────────────────────────────────
715

716
/**
717
 * Generate all entry files, run both build passes, then write _manifest.json.
718
 *
719
 * Entry generation order:
720
 *   1. generateViteGlobalsInit() and generateViteKilnEditEntry() run in parallel
721
 *      (they write to independent files and have no shared dependencies).
722
 *   2. generateViteBootstrap() runs after globals, because it checks whether
723
 *      .clay/_globals-init.js exists to decide whether to import it.
724
 *
725
 * Build pass order:
726
 *   Both passes (view and kiln) run in parallel via Promise.all.  They write to
727
 *   independent file sets (the kiln entry is excluded from the view graph) so
728
 *   there is no ordering constraint between them.
729
 *
730
 * @param {object} [options]
731
 * @returns {Promise<void>}
732
 */
733
async function buildJS(options = {}) {
3✔
734
  // Globals and kiln entry do not depend on each other — generate concurrently.
735
  await Promise.all([
5✔
736
    generateViteGlobalsInit(),
737
    generateViteKilnEditEntry(),
738
  ]);
739

740
  // Bootstrap depends on globals existing (it checks pathExists before importing).
741
  await generateViteBootstrap();
5✔
742

743
  if (!fs.existsSync(VITE_BOOTSTRAP_FILE)) {
5✔
744
    throw new Error('clay vite: missing .clay/vite-bootstrap.js after prepare.');
1✔
745
  }
746

747
  await fs.ensureDir(DEST);
4✔
748

749
  const viteCfg = getViteConfig(options);
4✔
750
  const envCollector = createClientEnvCollector(path.join(CWD, 'client-env.json'));
4✔
751
  const envPlugin = envCollector.plugin();
4✔
752

753
  let viewOutput, kilnOutput;
754

755
  if (viteCfg.kilnSplit) {
4✔
756
    // Single pass — kiln is in the same Rollup graph as the bootstrap.
757
    // Only safe once all model.js/kiln.js files are native ESM.
758
    viewOutput = await runViewBuild(viteCfg, [envPlugin]);
1✔
759
    kilnOutput = null;
1✔
760
  } else {
761
    // Two passes in parallel — kiln is isolated in its own no-split graph.
762
    // This prevents CJS model.js dependencies from creating phantom shared chunks
763
    // in the view-mode graph that would increase page load request counts.
764
    [viewOutput, kilnOutput] = await Promise.all([
3✔
765
      runViewBuild(viteCfg, [envPlugin]),
766
      runKilnBuild(viteCfg, [envPlugin]),
767
    ]);
768
  }
769

770
  const manifest = buildManifest(viewOutput, kilnOutput);
4✔
771

772
  await writeManifest(manifest);
4✔
773
  await envCollector.write();
4✔
774
}
775

776
exports.buildJS = buildJS;
21✔
777

778
// ── Full build (JS + assets in parallel) ────────────────────────────────────
779

780
/**
781
 * Run all build steps: JS + styles + fonts + templates + vendor + media.
782
 *
783
 * media runs first (sequential) so that SVG files are on disk before the
784
 * templates step tries to inline them via {{{ read 'public/media/…' }}}.
785
 * All remaining steps run in parallel after media completes.
786
 *
787
 * @param {object} [options]
788
 * @returns {Promise<void>}
789
 */
790
async function buildAll(options = {}) {
2✔
791
  const requested = normalizeRequestedSteps(options.only);
4✔
792
  const shouldRun = step => !requested || requested.has(step);
26✔
793
  const runMedia = shouldRunMediaStep(shouldRun);
4✔
794
  const isTTY = process.stdout.isTTY;
4✔
795
  const SPINNER = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
4✔
796

797
  const clr = {
4✔
798
    label: s => `\x1b[36m${s}\x1b[0m`,
31✔
799
    done:  s => `\x1b[32m${s}\x1b[0m`,
17✔
800
    fail:  s => `\x1b[31m${s}\x1b[0m`,
2✔
801
    time:  s => `\x1b[90m${s}\x1b[0m`,
18✔
UNCOV
802
    spin:  s => `\x1b[33m${s}\x1b[0m`,
×
803
  };
804

805
  const states    = new Map();
4✔
806
  const totalStart = Date.now();
4✔
807

808
  let spinFrame = 0,
4✔
809
    timer = null,
4✔
810
    progressUp = false;
4✔
811

812
  function clearSummary() {
813
    if (isTTY && progressUp) { process.stdout.write('\r\x1b[2K'); progressUp = false; }
4!
814
  }
815

816
  function writeSummary() {
UNCOV
817
    if (!isTTY) return;
×
UNCOV
818
    const running = [...states.entries()].filter(([, s]) => !s.done);
×
819

UNCOV
820
    if (!running.length) return;
×
821

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

UNCOV
826
    process.stdout.write(`${spin} ${parts.join(' ')} ${clr.time(`(${total}s)`)}`);
×
UNCOV
827
    progressUp = true;
×
828
  }
829

830
  function startStep(label) {
831
    states.set(label, { start: Date.now(), done: false, error: false });
15✔
832
    if (!isTTY) process.stdout.write(`  ${clr.label(`[${label}]`)} starting...\n`);
15!
833
  }
834

835
  function finishStep(label, error = false) {
14✔
836
    const s = states.get(label);
15✔
837

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

840
    const icon = error ? clr.fail('✗') : clr.done('✓');
15✔
841
    const word = error ? 'failed' : 'done  ';
15✔
842

843
    if (isTTY) {
15!
UNCOV
844
      clearSummary();
×
UNCOV
845
      process.stdout.write(`${icon} ${clr.label(`[${label}]`)} ${word} ${clr.time(`(${s ? s.elapsed : '?'}s)`)}\n`);
×
UNCOV
846
      if ([...states.values()].every(v => v.done)) {
×
UNCOV
847
        clearInterval(timer);
×
UNCOV
848
        timer = null;
×
849
      } else {
UNCOV
850
        writeSummary();
×
851
      }
852
    } else {
853
      process.stdout.write(`${icon} ${clr.label(`[${label}]`)} ${word} ${clr.time(`(${s ? s.elapsed : '?'}s)`)}\n`);
15!
854
    }
855
  }
856

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

861
  function step(label, fn) {
862
    startStep(label);
15✔
863
    return fn()
15✔
864
      .then(() => finishStep(label))
14✔
865
      .catch(e => {
866
        process.stderr.write(`\n${clr.fail('[error]')} ${clr.label(label)}: ${e.message}\n`);
1✔
867
        finishStep(label, true);
1✔
868
        stepErrors.push(e);
1✔
869
      });
870
  }
871

872
  process.stdout.write('\nBuilding assets...\n');
4✔
873

874
  // Media must complete before templates — the template step reads SVG files
875
  // from public/media/ via {{{ read 'public/media/…' }}} Handlebars helpers.
876
  if (runMedia) {
4✔
877
    await step('media', () => copyMedia());
3✔
878
  }
879

880
  if (isTTY) {
4!
881
    timer = setInterval(() => { spinFrame++; clearSummary(); writeSummary(); }, 80);
×
882
  }
883

884
  const tasks = buildSelectedParallelTasks(step, shouldRun, options);
4✔
885

886
  await Promise.all(tasks);
4✔
887

888
  if (timer) { clearInterval(timer); timer = null; }
4!
889
  clearSummary();
4✔
890

891
  if (stepErrors.length) {
4✔
892
    const names = stepErrors.map(e => e.message).join('; ');
1✔
893

894
    throw new Error(`Build failed: ${stepErrors.length} step(s) failed — ${names}`);
1✔
895
  }
896

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

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

902
/**
903
 * One-shot production build.
904
 *
905
 * @param {object} [options]
906
 */
907
async function build(options = {}) {
×
UNCOV
908
  return buildAll(options);
×
909
}
910

911
exports.build = build;
21✔
912
exports.buildAll = buildAll;
21✔
913

914
// ── Watch mode ───────────────────────────────────────────────────────────────
915

916
/**
917
 * Start Rollup's incremental watcher for JS plus chokidar watchers for
918
 * CSS, fonts, and templates.
919
 *
920
 * JS watch strategy:
921
 *   Rollup's watch mode rebuilds only the modules that changed, not the whole
922
 *   bundle.  This is significantly faster than Browserify's full-bundle rebuild
923
 *   on every save because the module graph is already resolved; only the dirty
924
 *   sub-graph is re-processed.
925
 *
926
 *   In two-pass mode (kilnSplit:false): both the view bundle and the kiln
927
 *   bundle run as parallel Rollup watchers.  model.js/kiln.js changes are
928
 *   handled incrementally by the kiln watcher; client.js changes are handled
929
 *   by the view watcher.  Add/unlink events regenerate the relevant entry
930
 *   file so the new module is included in the next incremental cycle.
931
 *
932
 * @param {object}   [options]
933
 * @param {boolean}  [options.minify=false]
934
 * @param {string[]} [options.extraEntries=[]]
935
 * @param {function} [options.onRebuild]  called after each JS rebuild with (errors)
936
 * @returns {Promise<{dispose: function}>}
937
 */
938
async function watch(options = {}) {
×
939
  const { onRebuild, onReady } = options;
1✔
940

941
  let isFirstBuild = true;
1✔
942
  const chokidar = require('chokidar');
1✔
943

944
  const chokidarOpts = {
1✔
945
    ignoreInitial: true,
946
    usePolling:    true,
947
    interval:      100,
948
    awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 50 },
949
  };
950

951
  function debounce(fn, ms) {
952
    let t;
953

954
    return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
11✔
955
  }
956

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

959
  const clr = {
1✔
960
    // per-asset prefix colors — each asset type gets a unique identity color
961
    js:        s => `\x1b[96m${s}\x1b[0m`,   // bright cyan
2✔
UNCOV
962
    kiln:      s => `\x1b[35m${s}\x1b[0m`,   // magenta
×
963
    styles:    s => `\x1b[34m${s}\x1b[0m`,   // blue
6✔
964
    fonts:     s => `\x1b[93m${s}\x1b[0m`,   // bright yellow
6✔
965
    templates: s => `\x1b[92m${s}\x1b[0m`,   // bright green
6✔
966
    // semantic state colors
967
    rebuilt: s => `\x1b[32m${s}\x1b[0m`,
4✔
968
    file:    s => `\x1b[36m${s}\x1b[0m`,
6✔
969
    error:   s => `\x1b[31m${s}\x1b[0m`,
3✔
970
  };
971

972
  // ── JS watch via Rollup watch mode ──────────────────────────────────────────
973

974
  const viteCfg = getViteConfig(options);
1✔
975

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

983
  // Same parallelization as buildJS: globals + kiln-edit first, then bootstrap.
984
  await Promise.all([
1✔
985
    generateViteGlobalsInit(),
986
    generateViteKilnEditEntry(),
987
  ]);
988
  await generateViteBootstrap();
1✔
989
  await fs.ensureDir(DEST);
1✔
990

991
  // Prints "<coloredPrefix> still building... (Xs)" every 3 s while a rebuild
992
  // is in flight so the terminal never looks frozen.  Returns a stop() function.
993
  // coloredPrefix should be the pre-colored tag, e.g. clr.js('[js]').
994
  function startProgressTick(coloredPrefix) {
995
    const t0 = Date.now();
6✔
996
    const id  = setInterval(() => {
6✔
UNCOV
997
      const s = Math.round((Date.now() - t0) / 1000);
×
998

UNCOV
999
      console.log(`${coloredPrefix} still building... (${s}s)`);
×
1000
    }, 3000);
1001

1002
    return () => clearInterval(id);
6✔
1003
  }
1004

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

1009
  let kilnOutput     = null;
1✔
1010

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

1013
  let resolveKilnOutput, rejectKilnOutput;
1014

1015
  function resetKilnOutputPromise() {
1016
    return new Promise((res, rej) => { resolveKilnOutput = res; rejectKilnOutput = rej; });
2✔
1017
  }
1018

1019
  let kilnOutputPromise  = resetKilnOutputPromise();
1✔
1020

1021
  let isFirstKilnBuild   = true;
1✔
1022

1023
  // KILN_BANNER is defined at module level above runKilnBuild — shared constant.
1024

1025
  let kilnTransformCount = 0;
1✔
1026

1027
  const captureKilnOutputPlugin = {
1✔
1028
    name: 'clay-capture-kiln-output',
1029

1030
    watchChange(id, { event }) {
UNCOV
1031
      console.log(clr.kiln(`[kiln] ${event}: `) + clr.file(path.relative(CWD, id)));
×
1032
    },
1033

1034
    transform() {
UNCOV
1035
      kilnTransformCount++;
×
1036
    },
1037

1038
    writeBundle(_opts, bundle) {
1039
      resolveKilnOutput({ output: Object.values(bundle) });
1✔
1040
    },
1041
  };
1042

1043
  let kilnWatcher = null;
1✔
1044

1045
  if (!viteCfg.kilnSplit) {
1!
1046
    const kilnWatchCfg = baseViteConfig(viteCfg);
1✔
1047

1048
    kilnWatchCfg.plugins = kilnWatchCfg.plugins.concat([envCollector.plugin(), captureKilnOutputPlugin]);
1✔
1049
    kilnWatchCfg.build.outDir = DEST;
1✔
1050
    kilnWatchCfg.build.watch = {};
1✔
1051
    kilnWatchCfg.build.rollupOptions = {
1✔
1052
      input:  { [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE },
1053
      output: {
1054
        format:               'esm',
1055
        entryFileNames:       '[name]-[hash].js',
1056
        inlineDynamicImports: true,
1057
        banner:               KILN_BANNER,
1058
      },
1059
      onwarn: suppressWarning,
1060
      watch: {
1061
        exclude: [
1062
          path.join(CWD, 'public', '**'),
1063
          path.join(CWD, 'node_modules', '**'),
1064
          path.join(CLAY_DIR, '**'),
1065
        ],
1066
      },
1067
    };
1068

1069
    kilnWatcher = await vite.build(kilnWatchCfg);
1✔
1070

1071
    let stopKilnTick = () => {};
1✔
1072

1073
    async function handleKilnBundleEnd(event) {
1074
      stopKilnTick();
1✔
1075

1076
      const output         = await kilnOutputPromise;
1✔
1077
      const modulesRebuilt = kilnTransformCount;
1✔
1078
      const wasFirst       = isFirstKilnBuild;
1✔
1079

1080
      kilnOutput         = output;
1✔
1081
      kilnOutputPromise  = resetKilnOutputPromise();
1✔
1082
      kilnTransformCount = 0;
1✔
1083
      isFirstKilnBuild   = false;
1✔
1084

1085
      if (lastViewOutput) {
1!
1086
        // Always reconcile the manifest when we have both outputs — this fixes
1087
        // a race where the view watcher wrote the manifest before the kiln
1088
        // watcher finished its first build, leaving the kiln entry absent.
1089
        const manifest = buildManifest(lastViewOutput, kilnOutput);
1✔
1090

1091
        await writeManifest(manifest);
1✔
1092
        await envCollector.write();
1✔
1093

1094
        if (!wasFirst) {
1!
1095
          // Only log on incremental rebuilds; suppress the initial startup cycle.
UNCOV
1096
          const moduleLabel = modulesRebuilt === 1 ? '1 module' : `${modulesRebuilt} modules`;
×
UNCOV
1097
          const duration    = event.duration != null ? ` in ${event.duration}ms` : '';
×
1098

UNCOV
1099
          console.log(clr.kiln('[kiln]') + clr.rebuilt(' Rebuilt') + ` (${moduleLabel} transformed${duration})`);
×
1100
        } else if (onReady) {
1!
1101
          // Signal ready only after both view AND kiln have completed their
1102
          // first build — the manifest is now complete with both entries.
1103
          onReady();
1✔
1104
        }
1105
      }
1106
      if (event.result && event.result.close) event.result.close();
1!
1107
    }
1108

1109
    kilnWatcher.on('event', async (event) => {
1✔
1110
      if (event.code === 'BUNDLE_START') {
2✔
1111
        if (!isFirstKilnBuild) stopKilnTick = startProgressTick(clr.kiln('[kiln]'));
1!
1112
        else stopKilnTick = () => {};
1✔
1113
      } else if (event.code === 'BUNDLE_END') {
1!
1114
        await handleKilnBundleEnd(event);
1✔
UNCOV
1115
      } else if (event.code === 'ERROR') {
×
UNCOV
1116
        stopKilnTick();
×
UNCOV
1117
        rejectKilnOutput(event.error);
×
UNCOV
1118
        kilnOutputPromise = resetKilnOutputPromise();
×
UNCOV
1119
        console.error(clr.kiln('[kiln]') + clr.error(` Build error: ${event.error.message}`));
×
UNCOV
1120
        if (event.result && event.result.close) event.result.close();
×
1121
      }
1122
    });
1123
  }
1124

1125
  const watchInput = viteCfg.kilnSplit
1!
1126
    ? { [VITE_BOOTSTRAP_KEY]: VITE_BOOTSTRAP_FILE, [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE }
1127
    : { [VITE_BOOTSTRAP_KEY]: VITE_BOOTSTRAP_FILE };
1128

1129
  const watchCfg = baseViteConfig(viteCfg);
1✔
1130

1131
  // Vite closes the internal RollupBuild before emitting BUNDLE_END to external
1132
  // listeners, so event.result.generate() throws "Bundle is already closed".
1133
  // Instead, capture chunk data inside a writeBundle plugin hook which fires
1134
  // after files are written to disk (but before BUNDLE_END reaches us), giving
1135
  // us the same {output:[]} shape that buildManifest expects.
1136
  //
1137
  // We use a resolve/reject pair so handleBundleEnd can *await* the plugin
1138
  // result rather than reading a mutable variable — avoids silent stale data
1139
  // if a build error ever prevents writeBundle from firing.
1140
  let resolveViewOutput, rejectViewOutput;
1141

1142
  function resetViewOutputPromise() {
1143
    return new Promise((res, rej) => {
2✔
1144
      resolveViewOutput = res;
2✔
1145
      rejectViewOutput  = rej;
2✔
1146
    });
1147
  }
1148

1149
  let viewOutputPromise = resetViewOutputPromise();
1✔
1150

1151
  let transformCount    = 0;
1✔
1152

1153
  const captureOutputPlugin = {
1✔
1154
    name: 'clay-capture-watch-output',
1155

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

1164
    // transform is called only for modules Rollup needs to re-process this
1165
    // cycle.  With the watch-mode module cache, unchanged modules are skipped —
1166
    // so this count reflects the actual incremental rebuild scope.
1167
    transform() {
UNCOV
1168
      transformCount++;
×
1169
    },
1170

1171
    // writeBundle fires after every file has been written to disk.
1172
    // `bundle` is a {[fileName]: OutputChunk|OutputAsset} map; convert it to
1173
    // the RollupOutput shape {output:[...]} that buildManifest iterates.
1174
    writeBundle(_opts, bundle) {
1175
      resolveViewOutput({ output: Object.values(bundle) });
1✔
1176
    },
1177
  };
1178

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

1183
  watchCfg.build.outDir = DEST;
1✔
1184
  watchCfg.build.watch = {};
1✔
1185
  watchCfg.build.rollupOptions = {
1✔
1186
    input:  watchInput,
1187
    output: {
1188
      format:         'esm',
1189
      entryFileNames: '[name]-[hash].js',
1190
      chunkFileNames: 'chunks/[name]-[hash].js',
1191
      // See buildChunkingOutput() for CJS vs ESM branching rationale.
1192
      ...buildChunkingOutput(viteCfg),
1193
    },
1194
    onwarn: suppressWarning,
1195
    watch: {
1196
      // Exclude build outputs and dependencies from Rollup's file watcher to
1197
      // prevent feedback loops where writing a chunk triggers another rebuild.
1198
      exclude: [
1199
        path.join(CWD, 'public', '**'),
1200
        path.join(CWD, 'node_modules', '**'),
1201
        path.join(CLAY_DIR, '**'),
1202
      ],
1203
    },
1204
  };
1205

1206
  const watcher = await vite.build(watchCfg);
1✔
1207

1208
  let stopJsTick = () => {};
1✔
1209

1210
  // Extract BUNDLE_END handling so the event callback stays below complexity limit.
1211
  // Signal ready after view's first build.  When a separate kiln watcher
1212
  // exists, defer until handleKilnBundleEnd writes the complete manifest.
1213
  function signalReadyIfFirst() {
1214
    if (!isFirstBuild) return;
1!
1215
    isFirstBuild = false;
1✔
1216
    if (onReady && !kilnWatcher) onReady();
1!
1217
  }
1218

1219
  async function handleBundleEnd(event) {
1220
    stopJsTick();
1✔
1221

1222
    // Await the output captured by captureOutputPlugin.writeBundle(), which
1223
    // fires after files are on disk but before BUNDLE_END reaches us.
1224
    // Reset the promise and transform counter immediately so the next rebuild
1225
    // gets a fresh slot.
1226
    const viewOutput      = await viewOutputPromise;
1✔
1227
    const modulesRebuilt  = transformCount;
1✔
1228

1229
    viewOutputPromise = resetViewOutputPromise();
1✔
1230
    transformCount    = 0;
1✔
1231
    lastViewOutput    = viewOutput; // retained so kiln BUNDLE_END can update the manifest
1✔
1232

1233
    const manifest = buildManifest(viewOutput, kilnOutput);
1✔
1234

1235
    await writeManifest(manifest);
1✔
1236
    await envCollector.write();
1✔
1237
    if (onRebuild) onRebuild([]);
1!
1238

1239
    const moduleLabel = modulesRebuilt === 1 ? '1 module' : `${modulesRebuilt} modules`;
1!
1240
    const duration    = event.duration != null ? ` in ${event.duration}ms` : '';
1!
1241

1242
    console.log(clr.js('[js]') + clr.rebuilt(' Rebuilt successfully') + ` (${moduleLabel} transformed${duration})`);
1✔
1243
    signalReadyIfFirst();
1✔
1244
    if (event.result && event.result.close) event.result.close();
1!
1245
  }
1246

1247
  watcher.on('event', async (event) => {
1✔
1248
    if (event.code === 'BUNDLE_START') {
2✔
1249
      console.log(clr.js('[js]') + ' Rebuilding...');
1✔
1250
      stopJsTick = isFirstBuild ? () => {} : startProgressTick(clr.js('[js]'));
1!
1251
    } else if (event.code === 'BUNDLE_END') {
1!
1252
      await handleBundleEnd(event);
1✔
UNCOV
1253
    } else if (event.code === 'ERROR') {
×
UNCOV
1254
      stopJsTick();
×
1255
      // Reject the output promise so handleBundleEnd doesn't hang if writeBundle
1256
      // was skipped due to the error, then reset for the next rebuild attempt.
UNCOV
1257
      rejectViewOutput(event.error);
×
UNCOV
1258
      viewOutputPromise = resetViewOutputPromise();
×
UNCOV
1259
      console.error(clr.js('[js]') + clr.error(` Build error: ${event.error.message}`));
×
UNCOV
1260
      if (onRebuild) onRebuild([event.error]);
×
1261
    }
1262
  });
1263

1264
  // ── Chokidar: regenerate bootstrap when new client.js files appear ─────────
1265

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

UNCOV
1275
    if (isGlobal) {
×
1276
      // A global/js file was added or removed: regenerate the import list first,
1277
      // then the bootstrap (which may reference the globals entry).
UNCOV
1278
      await generateViteGlobalsInit();
×
UNCOV
1279
      await generateViteBootstrap();
×
UNCOV
1280
    } else if (changedFile.endsWith('client.js')) {
×
UNCOV
1281
      await generateViteBootstrap();
×
UNCOV
1282
    } else if (isKilnFile) {
×
1283
      // The kiln watcher detects the entry file change and rebuilds incrementally.
UNCOV
1284
      await generateViteKilnEditEntry();
×
1285
    }
1286
  }
1287

1288
  const rebuildBootstrap = debounce(async (changedFile, eventType) => {
1✔
1289
    if (!changedFile) return;
1!
1290

1291
    const isChange   = eventType === 'change';
1✔
1292
    const isKilnFile = changedFile.endsWith('model.js') || changedFile.endsWith('kiln.js');
1✔
1293

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

UNCOV
1300
      console.log(pfxFn(`${pfxLabel} ${eventType}: `) + clr.file(rel(changedFile)));
×
UNCOV
1301
      await regenerateEntryFiles(changedFile);
×
1302
    }
1303
    // For change events, the relevant Rollup watcher handles it incrementally.
1304
  }, 200);
1305

1306
  const JS_GLOBS = [
1✔
1307
    path.join(CWD, 'components', '**', '*.js'),
1308
    path.join(CWD, 'components', '**', '*.vue'),
1309
    path.join(CWD, 'layouts', '**', '*.js'),
1310
    path.join(CWD, 'global', '**', '*.js'),
1311
    path.join(CWD, 'services', '**', '*.js'),
1312
  ];
1313

1314
  const jsWatcher = chokidar.watch(JS_GLOBS, {
1✔
1315
    ...chokidarOpts,
1316
    ignored: [path.join(CWD, 'public', '**'), path.join(CWD, 'node_modules', '**')],
1317
  });
1318

1319
  jsWatcher
1✔
1320
    .on('change', f => rebuildBootstrap(f, 'change'))
1✔
1321
    .on('add',    f => rebuildBootstrap(f, 'add'))
3✔
1322
    .on('unlink', f => rebuildBootstrap(f, 'unlink'));
1✔
1323

1324
  // ── CSS watcher ──────────────────────────────────────────────────────────────
1325
  // When a component/layout CSS file changes, only rebuild that component's file
1326
  // across all styleguides (e.g. nav.nymag.css, nav.curbed.css, nav._default.css)
1327
  // rather than recompiling all ~2800 CSS files.
1328
  //
1329
  // Falls back to a full rebuild if the changed file is a shared mixin/variable
1330
  // (i.e. its basename doesn't appear in any standard component/layout glob).
1331
  const { globSync: cssGlobSync } = require('glob');
1✔
1332

1333
  function getChangedStyleFiles(changedFile) {
1334
    if (!changedFile) return null; // null → full rebuild
2!
1335
    const basename = path.basename(changedFile); // e.g. "nav.css"
2✔
1336
    const variants = [
2✔
1337
      ...cssGlobSync(path.join(CWD, 'styleguides', '**', 'components', basename)),
1338
      ...cssGlobSync(path.join(CWD, 'styleguides', '**', 'layouts', basename)),
1339
    ];
1340

1341
    // If no variants found (shared mixin/variable file), do a full rebuild.
1342
    return variants.length > 0 ? variants : null;
2!
1343
  }
1344

1345
  const rebuildStyles = debounce((changedFile) => {
1✔
1346
    if (changedFile) console.log(clr.styles('[styles]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1347
    const changedFiles  = getChangedStyleFiles(changedFile);
2✔
1348
    const buildOpts     = changedFiles ? { ...options, changedFiles } : options;
2!
1349
    const start         = Date.now();
2✔
1350
    const stopStyleTick = startProgressTick(clr.styles('[styles]'));
2✔
1351

1352
    return buildStyles(buildOpts)
2✔
1353
      .then(() => {
1354
        stopStyleTick();
1✔
1355
        console.log(clr.styles('[styles]') + clr.rebuilt(` Rebuilt (${Date.now() - start}ms)`));
1✔
1356
        // Write reload-signal so start.js/nodemon triggers a full server
1357
        // restart.  A restart is necessary because CSS is output with stable
1358
        // filenames (e.g. nav.nymag.css) — no content hash — so the browser
1359
        // keeps serving its cached copy even after the file changes on disk.
1360
        // A new server process changes the ETag, which forces a re-fetch.
1361
        //
1362
        // TODO: migrate CSS to Lightning CSS with content-hashed output
1363
        // (e.g. nav.nymag-[hash].css) so the manifest carries the new URL and
1364
        // the browser is forced to re-fetch naturally on every CSS change,
1365
        // exactly like JS chunks work today.  Once that lands, this signal and
1366
        // the nodemon restart in start.js can be removed entirely.
1367
        return fs.ensureDir(CLAY_DIR)
1✔
1368
          .then(() => fs.writeFile(path.join(CLAY_DIR, 'reload-signal'), String(Date.now())))
1✔
UNCOV
1369
          .catch(e => console.warn(`[styles] could not write reload-signal: ${e.message}`));
×
1370
      })
1371
      .catch(e => { stopStyleTick(); console.error(clr.styles('[styles]') + clr.error(` rebuild failed: ${e.message}`)); });
1✔
1372
  }, 200);
1373

1374
  const cssWatcher = chokidar.watch(STYLE_GLOBS, chokidarOpts);
1✔
1375

1376
  cssWatcher.on('change', rebuildStyles).on('add', rebuildStyles).on('unlink', rebuildStyles);
1✔
1377

1378
  // ── Font watcher ─────────────────────────────────────────────────────────────
1379
  const rebuildFonts = debounce((changedFile) => {
1✔
1380
    if (changedFile) console.log(clr.fonts('[fonts]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1381
    const start        = Date.now();
2✔
1382
    const stopFontTick = startProgressTick(clr.fonts('[fonts]'));
2✔
1383

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

1389
  const fontWatcher = chokidar.watch(FONTS_SRC_GLOB, chokidarOpts);
1✔
1390

1391
  fontWatcher.on('change', rebuildFonts).on('add', rebuildFonts).on('unlink', rebuildFonts);
1✔
1392

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

1399
  const rebuildTemplates = debounce((changedFile) => {
1✔
1400
    if (changedFile) console.log(clr.templates('[templates]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1401
    const start            = Date.now();
2✔
1402
    const stopTemplateTick = startProgressTick(clr.templates('[templates]'));
2✔
1403

1404
    return buildTemplates({ ...options, watch: true })
2✔
1405
      .then(() => {
1406
        stopTemplateTick();
1✔
1407
        console.log(clr.templates('[templates]') + clr.rebuilt(` Rebuilt (${Date.now() - start}ms)`));
1✔
1408
        // Write template-signal so renderers.js calls html.init() in-process
1409
        // (~100ms) without a full server restart.
1410
        return fs.ensureDir(CLAY_DIR)
1✔
1411
          .then(() => fs.writeFile(TEMPLATE_SIGNAL, String(Date.now())))
1✔
UNCOV
1412
          .catch(e => console.warn(`[templates] could not write template-signal: ${e.message}`));
×
1413
      })
1414
      .catch(e => { stopTemplateTick(); console.error(clr.templates('[templates]') + clr.error(` rebuild failed: ${e.message}`)); });
1✔
1415
  }, 200);
1416

1417
  const templateGlobs = [
1✔
1418
    path.join(CWD, 'components', '**', TEMPLATE_GLOB_PATTERN),
1419
    path.join(CWD, 'layouts', '**', TEMPLATE_GLOB_PATTERN),
1420
  ];
1421
  const templateWatcher = chokidar.watch(templateGlobs, chokidarOpts);
1✔
1422

1423
  templateWatcher
1✔
1424
    .on('change', rebuildTemplates)
1425
    .on('add', rebuildTemplates)
1426
    .on('unlink', rebuildTemplates);
1427

1428
  const chokidarWatchers = [jsWatcher, cssWatcher, fontWatcher, templateWatcher];
1✔
1429

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

1432
  return {
1✔
1433
    dispose: async () => {
1434
      if (watcher     && watcher.close)     watcher.close();
1!
1435
      if (kilnWatcher && kilnWatcher.close) kilnWatcher.close();
1!
1436
      await Promise.all(chokidarWatchers.map(w => w.close()));
4✔
1437
    },
1438
  };
1439
}
1440

1441
exports.watch = watch;
21✔
1442

1443
// ── Warning suppressor ───────────────────────────────────────────────────────
1444

1445
/**
1446
 * Suppress Rollup warnings that are expected when bundling a CJS-heavy
1447
 * codebase into a native ESM output.
1448
 *
1449
 * These are not real errors — they reflect the current state of the codebase
1450
 * (CJS sources, circular dependencies, missing optional modules) and will
1451
 * disappear naturally as files are migrated to ESM.
1452
 *
1453
 * @param {object}   warning
1454
 * @param {function} warn
1455
 */
1456
// Warning codes that are always expected in a mixed CJS/ESM codebase and
1457
// should not surface to the user.  Each code is explained inline.
1458
const SUPPRESSED_WARNING_CODES = new Set([
21✔
1459
  // Circular requires() are common in CJS codebases and handled safely by
1460
  // strictRequires:'auto'.  They become non-issues once files are ESM.
1461
  'CIRCULAR_DEPENDENCY',
1462

1463
  // CJS modules use `this` at the top level (equivalent to `module.exports`).
1464
  // Rollup warns because in ESM `this` is undefined at the top level.
1465
  // @rollup/plugin-commonjs wraps these so the reference is correct.
1466
  'THIS_IS_UNDEFINED',
1467

1468
  // Some globals (e.g. jQuery's $) are expected to be on window and are not
1469
  // imported.  Clay components reference them as free variables.
1470
  'MISSING_GLOBAL_NAME',
1471

1472
  // Missing optional imports are stubbed by viteMissingModulePlugin.  Rollup
1473
  // warns before the plugin catches them; the warning is spurious.
1474
  'UNRESOLVED_IMPORT',
1475

1476
  // CJS modules wrapped by @rollup/plugin-commonjs may not export named
1477
  // bindings.  Named imports from CJS modules get undefined — expected.
1478
  'MISSING_EXPORT',
1479
]);
1480

1481
function suppressWarning(warning, warn) {
UNCOV
1482
  if (SUPPRESSED_WARNING_CODES.has(warning.code)) return;
×
1483

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

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