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

clay / claycli / 25473439541

07 May 2026 02:54AM UTC coverage: 86.812% (+0.09%) from 86.718%
25473439541

Pull #248

github

jjpaulino
πŸ• Add Vite CSS site targeting via CLAYCLI_VITE_CSS_SITES.

Allow Vite style compilation to target selected styleguides while always including _default, with tests for scoped, all-sites, and watcher changedFiles behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pull Request #248: πŸ• Add Vite CSS site targeting flag

742 of 922 branches covered (80.48%)

Branch coverage included in aggregate %.

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

52 existing lines in 1 file now uncovered.

1562 of 1732 relevant lines covered (90.18%)

10.23 hits per line

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

71.84
/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';
19✔
7

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

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

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

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

30
const CWD = process.cwd();
19✔
31
const DEST = path.join(CWD, 'public', 'js');
19✔
32
const CLAY_DIR = path.join(CWD, '.clay');
19✔
33

34
exports.VITE_BOOTSTRAP_KEY = VITE_BOOTSTRAP_KEY;
19✔
35
exports.KILN_EDIT_ENTRY_KEY = KILN_EDIT_ENTRY_KEY;
19✔
36

37
// ── Config helpers ──────────────────────────────────────────────────────────
38

39
/**
40
 * Read and apply the bundlerConfig() customizer from claycli.config.js.
41
 *
42
 * bundlerConfig() is the single hook for customizing the Vite build pipeline.
43
 * Plugins use the standard Rollup plugin API (resolveId, load, transform) since
44
 * Vite's production build uses Rollup internally.
45
 *
46
 * Config shape (all keys optional):
47
 *
48
 *   {
49
 *     minify:             false,
50
 *     extraEntries:       [],
51
 *     manualChunksMinSize: undefined, // bytes β€” controls chunk merging threshold.
52
 *                                  // Not set by default: omitting it means no
53
 *                                  // inlining/merging (equivalent to 0).
54
 *                                  // Suggested values for CJS/mixed codebases:
55
 *                                  //   4096  (4 KB) β€” conservative, fewer merges
56
 *                                  //   8192  (8 KB) β€” balanced, recommended starting point
57
 *                                  // In CJS mode (clientFilesESM:false) this feeds
58
 *                                  // viteManualChunksPlugin which inlines small private
59
 *                                  // deps into their owner chunk.
60
 *                                  // In ESM mode (clientFilesESM:true) this maps to
61
 *                                  // Rollup's native experimentalMinChunkSize which
62
 *                                  // merges small chunks across the whole graph.
63
 *     clientFilesESM:     false,  // set true once all client.js files and their deps
64
 *                                  // are native ESM.  Switches the view-mode pass from
65
 *                                  // viteManualChunksPlugin to Rollup's native
66
 *                                  // experimentalMinChunkSize (driven by manualChunksMinSize).
67
 *                                  // Does NOT affect the kiln pass β€” use kilnSplit for that.
68
 *     kilnSplit:          false,  // set true once all model.js/kiln.js are ESM β€”
69
 *                                  // collapses the two-pass build into one graph.
70
 *     define:             {},     // identifier replacements merged on top of built-in
71
 *                                  // defines (process.env.NODE_ENV, __dirname, etc.)
72
 *     alias:              {},     // module path aliases applied at resolution time.
73
 *                                  // Equivalent to Vite's resolve.alias.  Keys are
74
 *                                  // bare specifiers or path prefixes; values are
75
 *                                  // absolute paths or replacement specifiers:
76
 *                                  //   '@sentry/node': '@sentry/browser'
77
 *                                  //   '@utils': path.resolve(__dirname, 'src/utils')
78
 *                                  // For complex patterns (regex, onResolve hooks),
79
 *                                  // add a Rollup plugin via `plugins` instead.
80
 *     sourcemap:          true,   // emit .js.map files alongside every output chunk.
81
 *                                  // Keeps DevTools stack traces symbolicated and lets
82
 *                                  // error monitoring (Sentry) map runtime errors to
83
 *                                  // source lines.  Disable to save build time in CI
84
 *                                  // environments where sourcemaps are not consumed:
85
 *                                  //   config.sourcemap = false;
86
 *     plugins:            [],     // extra Rollup/Vite plugins appended after built-ins
87
 *     commonjsExclude:    [],     // patterns passed to @rollup/plugin-commonjs `exclude`
88
 *                                  // for CJS packages whose internal eval() scope must
89
 *                                  // not be rewritten (e.g. webpack dev bundles like
90
 *                                  // pyxis-frontend that use eval() for modules)
91
 *     browserStubs:       {},     // site-specific Node.js module stubs for the browser
92
 *                                  // bundle.  Keys are module names; values are either
93
 *                                  // null (empty-object stub) or a custom ESM string:
94
 *                                  //   'ioredis': null
95
 *                                  //   'mongodb': 'export default { connect: function() {} };'
96
 *                                  // Site stubs override built-ins when names collide.
97
 *   }
98
 *
99
 * @param {object} [cliOptions]
100
 * @returns {object}
101
 */
102
function getViteConfig(cliOptions = {}) {
×
103
  const config = {
6✔
104
    minify:              cliOptions.minify || false,
12✔
105
    extraEntries:        cliOptions.extraEntries || [],
12✔
106
    manualChunksMinSize: undefined,
107
    clientFilesESM:      false,
108
    kilnSplit:           false,
109
    define:              {},
110
    alias:               {},
111
    sourcemap:           true,
112
    plugins:             [],
113
    commonjsExclude:     [],
114
    browserStubs:        {},
115
    // When true, replace Vite's throwing `__vite-browser-external` proxy with
116
    // an empty ESM module so unresolved Node-only imports behave like
117
    // Browserify (silent undefined) instead of throwing at runtime on the
118
    // first property access. Intended as a migration flag while latent
119
    // server-only imports are tracked down in isomorphic code (cheerio,
120
    // postcss, @google/maps, etc.). Default off so real problems surface.
121
    lenientBrowserExternalize: false,
122
  };
123

124
  // Read bundlerConfig() from claycli.config.js β€” the single hook for
125
  // customizing the Vite build pipeline.
126
  const customizer = getConfigValue('bundlerConfig');
6✔
127

128
  if (typeof customizer === 'function') {
6✔
129
    const customized = customizer(config);
2✔
130

131
    if (customized && typeof customized === 'object') return customized;
2!
132
  }
133

134
  return config;
4✔
135
}
136

137
exports.getViteConfig = getViteConfig;
19✔
138

139
/**
140
 * Build the define map injected into every module at compile time.
141
 *
142
 * Vite uses esbuild under the hood for define substitution, so these are
143
 * identifier-scoped replacements (not substring replacements like sed).
144
 * Only meaningful identifiers are replaced β€” string literals are never touched.
145
 *
146
 * Node globals (process, __filename, etc.) appear in isomorphic Clay services
147
 * that run on both the server and in the browser.  Stubbing them here lets the
148
 * browser bundle compile without a polyfill, while the real Node values are
149
 * used at runtime on the server.
150
 *
151
 * @param {object} userDefines - extra defines from bundlerConfig()
152
 * @returns {object}
153
 */
154
function buildDefines(userDefines = {}) {
×
155
  const NODE_ENV = process.env.NODE_ENV || 'development';
9!
156

157
  return Object.assign(
9✔
158
    {
159
      // Guards like `if (process.env.NODE_ENV === 'production')` tree-shake
160
      // correctly in the browser bundle.
161
      'process.env.NODE_ENV': JSON.stringify(NODE_ENV),
162

163
      // process.browser is a convention used by some isomorphic libraries to
164
      // branch between Node and browser paths.
165
      'process.browser': JSON.stringify(true),
166

167
      // process.version and process.versions are read by clay-log to detect
168
      // the runtime environment. Stub to empty values so the browser branch
169
      // is taken rather than the Node branch.
170
      'process.version':  JSON.stringify(''),
171
      'process.versions': JSON.stringify({}),
172

173
      // __filename and __dirname are used in some server-side Clay utilities.
174
      // In the browser bundle they are never accessed at runtime, but they
175
      // must resolve to something so the module compiles.
176
      __filename: JSON.stringify(''),
177
      __dirname:  JSON.stringify('/'),
178

179
      // global β†’ globalThis. Some older CJS packages reference global instead
180
      // of window. globalThis is universally available in ES2017+ environments.
181
      global: 'globalThis',
182
    },
183
    userDefines
184
  );
185
}
186

187
/**
188
 * Assemble the Vite plugin array for a build pass.
189
 *
190
 * Plugin order matters because each runs in sequence on every resolved module:
191
 *
192
 *   1. browser-compat  β€” intercepts Node built-in imports (fs, path, events …)
193
 *                        and replaces them with browser-safe stubs BEFORE any
194
 *                        other plugin or Vite's resolver sees them.  Runs first
195
 *                        because some node_modules transitively import built-ins
196
 *                        and must be redirected at resolution time.
197
 *
198
 *   2. service-rewrite β€” redirects any import of services/server/* to the
199
 *                        matching services/client/* file.  Clay components use
200
 *                        isomorphic service paths; the browser bundle always gets
201
 *                        the client implementation.
202
 *
203
 *   3. missing-module  β€” stubs unresolvable relative imports with an empty ESM
204
 *                        module instead of erroring.  Browserify silently skipped
205
 *                        missing requires; this plugin preserves that lenient
206
 *                        behaviour while the codebase is still being cleaned up.
207
 *
208
 *   4. vue2            β€” compiles .vue Single File Components using Vue 2's
209
 *                        template compiler.  Must run before Vite's JS pipeline
210
 *                        so that .vue files are converted to plain JS before
211
 *                        @rollup/plugin-commonjs processes any require() calls
212
 *                        in the <script> block.
213
 *
214
 *   5. user plugins    β€” site-specific overrides from bundlerConfig().plugins,
215
 *                        appended last so they can override any of the above.
216
 *
217
 * Note: the client-env collector plugin (clay-client-env) is NOT assembled
218
 * here.  It is created per-build in buildJS/watch via createClientEnvCollector
219
 * and injected as an internalPlugins argument to runViewBuild/runKilnBuild.
220
 * This keeps the collector separate from the user-facing plugin list.
221
 *
222
 * CJS→ESM conversion is handled by Vite's single built-in @rollup/plugin-commonjs
223
 * instance, configured in baseViteConfig() via build.commonjsOptions.  A second
224
 * instance must NOT be added here β€” two commonjs instances each maintain their
225
 * own internal virtual-module state (?commonjs-proxy, ?commonjs-wrapped …) and
226
 * will leave require() calls untransformed in the final bundle.
227
 *
228
 * @param {object[]} [extraPlugins]
229
 * @param {object}   [browserStubs]  site stubs from bundlerConfig().browserStubs
230
 * @param {object}   [opts]
231
 * @param {boolean}  [opts.lenientBrowserExternalize=false]  forwarded to
232
 *   viteBrowserCompatPlugin; see that plugin's JSDoc for semantics.
233
 * @returns {object[]}
234
 */
235
function buildPlugins(extraPlugins = [], browserStubs = {}, opts = {}) {
×
236
  return [
9✔
237
    viteBrowserCompatPlugin(browserStubs, { lenientExternalize: opts.lenientBrowserExternalize === true }),
238
    viteServiceRewritePlugin(),
239
    viteMissingModulePlugin(),
240
    viteVue2Plugin(),
241
    ...extraPlugins,
242
  ];
243
}
244

245
// ── Manifest ────────────────────────────────────────────────────────────────
246

247
/**
248
 * Build _manifest.json from one or two Vite RollupOutput results.
249
 *
250
 * viewOutput  β€” the splitting pass (bootstrap + component chunks)
251
 * kilnOutput  β€” the single-file kiln edit pass (null when kilnSplit is true)
252
 *
253
 * The manifest shape { key: { file, imports } } is the contract between the
254
 * build pipeline and resolve-media.js.  Every pipeline (Browserify included)
255
 * must produce a compatible manifest so the runtime asset injector works
256
 * without knowing which bundler was used.
257
 *
258
 * @param {Object|null} viewOutput
259
 * @param {Object|null} kilnOutput
260
 * @param {string} publicBase
261
 * @returns {object}
262
 */
263
function buildManifest(viewOutput, kilnOutput = null, publicBase = '/js') {
6!
264
  const manifest = {};
6✔
265

266
  for (const output of [viewOutput, kilnOutput]) {
6✔
267
    for (const chunk of output ? output.output : []) {
12✔
268
      if (chunk.type !== 'chunk' || !chunk.isEntry) continue;
8!
269

270
      const facadeId = chunk.facadeModuleId;
8✔
271

272
      if (!facadeId) continue;
8!
273

274
      const cleanFacadeId = facadeId.replace(/\?.*$/, '');
8✔
275
      const entryKey = path.relative(CWD, cleanFacadeId)
8✔
276
        .replace(/\\/g, '/')
277
        .replace(/\.js$/, '');
278

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

282
      manifest[entryKey] = { file: fileUrl, imports: importUrls };
8✔
283
    }
284
  }
285

286
  return manifest;
6✔
287
}
288

289
async function writeManifest(manifest) {
290
  const manifestPath = path.join(DEST, '_manifest.json');
6✔
291

292
  await fs.outputJson(manifestPath, manifest, { spaces: 2 });
6✔
293
}
294

295
// ── Vite build config factory ────────────────────────────────────────────────
296

297
/**
298
 * Return the base Vite config shared by both build passes (view and kiln).
299
 *
300
 * ── Why Vite for production builds ──────────────────────────────────────────
301
 *
302
 * The legacy Browserify pipeline bundled all component JavaScript into a
303
 * handful of monolithic files.  Every page load re-downloaded the full bundle
304
 * even if the user had visited before, and every deploy invalidated the cache
305
 * for all pages simultaneously.
306
 *
307
 * Vite's production build uses Rollup under the hood to emit native ES modules
308
 * with content-hashed filenames.  Components are loaded on demand via
309
 * dynamic import(), so the browser only fetches the code each page actually
310
 * needs.  Unchanged modules keep their hash across deploys and are served
311
 * straight from the browser cache on repeat visits.
312
 *
313
 * ── Why native ESM output ────────────────────────────────────────────────────
314
 *
315
 * Browserify emitted a single synchronous IIFE bundle β€” the browser had to
316
 * parse and evaluate all component code before any component could mount,
317
 * which directly raised Time to Interactive and Total Blocking Time.
318
 *
319
 * Native ESM allows the browser to parse modules in parallel and defer
320
 * evaluation of off-screen components until they are actually needed.
321
 *
322
 * ── Migration doors ─────────────────────────────────────────────────────────
323
 *
324
 * CSS (Lightning CSS):
325
 *   The styles step currently uses PostCSS via buildStyles().
326
 *   When ready to migrate, add:
327
 *     css: { transformer: 'lightningcss', lightningcssOptions: { ... } }
328
 *   to the returned config object and remove the PostCSS step from buildAll().
329
 *   The `lightningcss` package must be added as a dependency.
330
 *   Lightning CSS is faster and handles modern CSS features (nesting, color-mix,
331
 *   etc.) natively without PostCSS plugins.
332
 *
333
 * Vue 3:
334
 *   The vue2 plugin handles .vue files today.  To start writing new components
335
 *   in Vue 3, add @vitejs/plugin-vue to bundlerConfig().plugins in claycli.config.js:
336
 *     import vuePlugin from '@vitejs/plugin-vue';
337
 *     config.plugins.push(vuePlugin());
338
 *   Both plugins can coexist β€” vue2 handles legacy SFCs, @vitejs/plugin-vue
339
 *   handles new ones (differentiate by directory or a file-naming convention).
340
 *   Once all .vue files are migrated to Vue 3, remove viteVue2Plugin().
341
 *
342
 * ESM migration:
343
 *   As source files are converted from require()/module.exports to import/export,
344
 *   the CJS shims shrink automatically:
345
 *     - @rollup/plugin-commonjs (commonjsOptions) becomes a no-op per migrated file
346
 *     - strictRequires entries drop as circular require() cycles are eliminated
347
 *     - transformMixedEsModules can be removed once all .vue scripts use ESM
348
 *     - kilnSplit can be set true once all model.js/kiln.js files are ESM,
349
 *       collapsing the two-pass build into a single faster graph
350
 *   New components should be written as ESM from day one.
351
 *
352
 * @param {object} viteCfg   β€” result of getViteConfig()
353
 * @returns {object}
354
 */
355
function baseViteConfig(viteCfg) {
356
  // The base path must match the URL prefix under which public/js/ is served.
357
  // Vite embeds this into dynamic import() calls inside the bootstrap so that
358
  // chunk URLs resolve to /js/chunks/… rather than just /chunks/….
359
  const publicBase = (viteCfg.publicBase || '/js').replace(/\/$/, '');
9✔
360

361
  return {
9✔
362
    root:       CWD,
363
    base:       publicBase + '/',
364
    configFile: false, // always use this programmatic config; never read vite.config.js
365
    logLevel:   'warn',
366
    plugins:    buildPlugins(viteCfg.plugins, viteCfg.browserStubs, {
367
      lenientBrowserExternalize: viteCfg.lenientBrowserExternalize === true,
368
    }),
369
    define:     buildDefines(viteCfg.define),
370
    resolve: {
371
      browserField: true,
372

373
      // Field resolution order: prefer the browser-specific build, then the
374
      // CommonJS main entry, then ESM via the module field.
375
      //
376
      // `module` is intentionally last: some packages ship both CJS (main) and
377
      // ESM (module) builds; using the CJS build is safer when @rollup/plugin-commonjs
378
      // is active because it applies the same transformation consistently.
379
      //
380
      // ESM migration lever: once the codebase no longer needs @rollup/plugin-commonjs,
381
      // flip this to ['browser', 'module', 'main'] so Rollup can tree-shake ESM
382
      // packages directly.
383
      mainFields: ['browser', 'main', 'module'],
384

385
      // Site-defined module aliases from bundlerConfig().alias.
386
      // This is the first-class alternative to writing a full Rollup plugin just
387
      // to redirect a specifier.  Common use-cases:
388
      //   - swap a server-only package for its browser equivalent at build time
389
      //     (e.g. '@sentry/node' β†’ '@sentry/browser')
390
      //   - point a bare specifier at an absolute path
391
      //     (e.g. '@utils' β†’ path.resolve(__dirname, 'src/utils'))
392
      // For complex patterns (regex, conditional logic), use bundlerConfig().plugins
393
      // with a resolveId hook instead.
394
      alias: viteCfg.alias || {},
9!
395
    },
396

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

409
    build: {
410
      // Target ES2017 (async/await, Object.assign, etc.) β€” supported by all
411
      // browsers we care about.  This lets Rollup emit clean async code without
412
      // transpiling it to generator functions.
413
      //
414
      // ESM migration note: bump to 'es2020' when ready to use optional chaining
415
      // and nullish coalescing natively in the output (Vue 3 recommends es2020+).
416
      target: 'es2017',
417

418
      outDir:      DEST,
419
      emptyOutDir: false, // we write into public/js/ which already exists
420
      // Configurable via bundlerConfig().sourcemap.  Defaults to true so that
421
      // DevTools stack traces and Sentry source mapping work out of the box.
422
      // Set to false in CI pipelines that do not consume source maps to shave
423
      // a few seconds off the build without changing runtime behaviour.
424
      sourcemap:   viteCfg.sourcemap !== false,
425
      minify:      viteCfg.minify ? 'esbuild' : false,
9!
426

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

434
      // We handle CSS extraction ourselves: component .css files go through the
435
      // PostCSS step in buildStyles(), and .vue scoped styles are injected at
436
      // runtime by viteVue2Plugin().  Letting Vite split CSS would generate
437
      // separate .css chunks that nothing requests.
438
      cssCodeSplit: false,
439

440
      // public/js/ lives inside public/ which is also the publicDir.  Vite warns
441
      // when outDir is inside publicDir because it tries to copy publicDir into
442
      // outDir at the end of the build.  We disable that copy entirely β€” static
443
      // assets are managed by copyMedia/copyVendor/buildFonts.
444
      copyPublicDir: false,
445

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

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

456
      // Configure Vite's single built-in @rollup/plugin-commonjs instance.
457
      //
458
      // This is the bridge between the CJS-heavy existing codebase and Rollup's
459
      // native ESM module graph.  Every require() call in source files is
460
      // converted to an ESM import so Rollup can tree-shake and bundle correctly.
461
      // Browserify handled CJS implicitly; here it is explicit and configurable.
462
      //
463
      // IMPORTANT: do not add a second @rollup/plugin-commonjs instance via plugins[].
464
      // Each instance tracks its own set of ?commonjs-* virtual modules, so two
465
      // instances conflict and leave require() calls in the output.
466
      commonjsOptions: {
467
        // Apply to all JS, CJS, and .vue files.  Two checks must pass inside
468
        // @rollup/plugin-commonjs: the `include` regex (createFilter check) and
469
        // the `extensions` list (path.extname check).
470
        include:    /\.(js|cjs|vue)$/,
471
        extensions: ['.js', '.cjs', '.vue'],
472

473
        // Required for .vue files: viteVue2Plugin always appends `export default __sfc__`
474
        // regardless of whether the <script> block used require() or ESM, so every
475
        // compiled .vue file is "mixed" (CJS body + ESM export default).  Without
476
        // this flag, @rollup/plugin-commonjs would skip the require() conversion and
477
        // leave bare require() calls in the browser bundle.
478
        //
479
        // For plain .js files: mixing require() and import/export in the same file
480
        // is prohibited β€” files must be either pure CJS or pure ESM.
481
        transformMixedEsModules: true,
482

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

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

497
        // Sites can exclude specific packages via commonjsExclude in bundlerConfig().
498
        // Use this for packages that use eval() internally in ways that break when
499
        // @rollup/plugin-commonjs rewrites their module scope (e.g. webpack dev-mode
500
        // bundles where eval() strings reference `exports` as a function parameter
501
        // that gets renamed by the CJS transformation).
502
        exclude: viteCfg.commonjsExclude || [],
9!
503
      },
504
    },
505
  };
506
}
507

508
// ── Build passes ────────────────────────────────────────────────────────────
509

510
/**
511
 * Return the output.manualChunks / output.experimentalMinChunkSize entry for
512
 * rollupOptions.output based on whether the codebase is fully ESM.
513
 *
514
 * CJS mode (clientFilesESM:false): @rollup/plugin-commonjs injects \0-prefixed
515
 * virtual proxy modules into the graph which inflate apparent module sizes and
516
 * add phantom importer edges.  Rollup's native experimentalMinChunkSize would
517
 * produce inaccurate merges in this environment.  viteManualChunksPlugin guards
518
 * against virtual modules explicitly and uses info.code.length as an honest size
519
 * proxy for pre-minification CJS source.
520
 *
521
 * ESM mode (clientFilesESM:true): the graph is clean β€” no proxy modules, no CJS
522
 * wrapper boilerplate.  Rollup's native experimentalMinChunkSize is accurate and
523
 * optimal; viteManualChunksPlugin is no longer needed.  manualChunksMinSize maps
524
 * directly to the native threshold (0 = no merging if the site leaves it unset).
525
 *
526
 * @param {object} viteCfg
527
 * @returns {object}
528
 */
529
function buildChunkingOutput(viteCfg) {
530
  const minSize = viteCfg.manualChunksMinSize ?? 0;
5✔
531

532
  if (viteCfg.clientFilesESM) {
5!
UNCOV
533
    return { experimentalMinChunkSize: minSize };
×
534
  }
535

536
  return { manualChunks: viteManualChunksPlugin(minSize, CWD) };
5✔
537
}
538

539

540
/**
541
 * Pass 1 β€” view mode (splitting pass).
542
 *
543
 * Builds the bootstrap entry point plus any extra entries.  Rollup splits the
544
 * module graph into shared chunks β€” each shared dependency gets its own
545
 * content-hashed file that the browser can cache independently.
546
 *
547
 * Compared to Browserify's single bundle, the browser only downloads the code
548
 * for components present on the current page, and unchanged modules are served
549
 * from cache on subsequent page loads.
550
 *
551
 * When kilnSplit is false (the default while model.js files are still CJS),
552
 * the kiln-edit entry is excluded from this graph to prevent CJS model.js
553
 * dependencies from polluting the view-mode chunk set.  Set kilnSplit:true
554
 * in bundlerConfig() once all model.js/kiln.js files use ESM.
555
 *
556
 * @param {object} viteCfg
557
 * @param {object} internalPlugins
558
 * @returns {Promise<Object>}
559
 */
560
async function runViewBuild(viteCfg, internalPlugins) {
561
  const entryMap = {};
4✔
562

563
  entryMap[VITE_BOOTSTRAP_KEY] = VITE_BOOTSTRAP_FILE;
4✔
564

565
  if (viteCfg.kilnSplit) {
4✔
566
    entryMap[KILN_EDIT_ENTRY_KEY] = KILN_EDIT_ENTRY_FILE;
1✔
567
  }
568

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

573
      entryMap[key] = extraPath;
1✔
574
    }
575
  }
576

577
  const cfg = baseViteConfig(viteCfg);
4✔
578

579
  if (internalPlugins && internalPlugins.length) {
4!
580
    cfg.plugins = cfg.plugins.concat(internalPlugins);
4✔
581
  }
582

583
  cfg.build.rollupOptions = {
4✔
584
    input:  entryMap,
585
    output: {
586
      format:         'esm',
587
      entryFileNames: '[name]-[hash].js',
588
      chunkFileNames: 'chunks/[name]-[hash].js',
589
      // See buildChunkingOutput() for CJS vs ESM branching rationale.
590
      ...buildChunkingOutput(viteCfg),
591
    },
592
    onwarn: suppressWarning,
593
  };
594

595
  const result = await vite.build(cfg);
4✔
596

597
  return Array.isArray(result) ? result[0] : result;
4!
598
}
599

600
// Pre-populates all kiln namespaces with empty objects so that module-level
601
// destructuring patterns like `const { pyxis } = window.kiln.config` in
602
// .vue plugin files don't throw before _initKilnPlugins() runs.
603
// Used by both the production kiln pass (runKilnBuild) and the watch kiln
604
// watcher β€” defined once here to prevent drift.
605
const KILN_BANNER = [
19✔
606
  '(function(){',
607
  '  var k = window.kiln = window.kiln || {};',
608
  '  k.config  = k.config  || {};',
609
  '  k.config.pyxis = k.config.pyxis || {};',
610
  '  k.utils   = k.utils   || {};',
611
  '  k.utils.components = k.utils.components || {};',
612
  '  k.utils.references = k.utils.references || {};',
613
  '  k.utils.componentElements = k.utils.componentElements || {};',
614
  '  k.utils.logger = k.utils.logger || function(){ return { log:function(){}, error:function(){} }; };',
615
  '  k.inputs  = k.inputs  || {};',
616
  '  k.modals  = k.modals  || {};',
617
  '  k.plugins = k.plugins || {};',
618
  '  k.toolbarButtons = k.toolbarButtons || {};',
619
  '  k.navButtons = k.navButtons || {};',
620
  '  k.navContent = k.navContent || {};',
621
  '  k.validators = k.validators || {};',
622
  '  k.transformers = k.transformers || {};',
623
  '  k.kilnInput = k.kilnInput || function(){};',
624
  '})();',
625
].join('\n');
626

627
/**
628
 * Pass 2 β€” kiln edit mode (no-split pass).
629
 *
630
 * The kiln-edit-init entry imports every component's model.js and kiln.js.
631
 * These files are CJS today and cannot be tree-shaken, so their transitive
632
 * dependencies (utility libraries, lodash, etc.) would bleed into the
633
 * view-mode chunk graph if this entry were included in the splitting pass.
634
 * Running it as a separate isolated pass with inlineDynamicImports:true
635
 * produces one self-contained file that is only loaded in edit mode.
636
 *
637
 * Edit mode (kiln) is a separate concern from public page rendering β€” this
638
 * file is never delivered to readers, only to editors inside the CMS.
639
 *
640
 * ESM migration path: once all model.js/kiln.js files are ESM, set
641
 * kilnSplit:true in bundlerConfig().  This adds the kiln entry to the same
642
 * splitting graph as the bootstrap, Rollup tree-shakes across both, and the
643
 * separate kiln pass is no longer needed.
644
 *
645
 * @param {object} viteCfg
646
 * @param {object[]} internalPlugins
647
 * @returns {Promise<Object>}
648
 */
649
async function runKilnBuild(viteCfg, internalPlugins) {
650
  const cfg = baseViteConfig(viteCfg);
3✔
651

652
  if (internalPlugins && internalPlugins.length) {
3!
653
    cfg.plugins = cfg.plugins.concat(internalPlugins);
3✔
654
  }
655

656
  cfg.build.rollupOptions = {
3✔
657
    input:  { [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE },
658
    output: {
659
      format:               'esm',
660
      entryFileNames:       '[name]-[hash].js',
661
      inlineDynamicImports: true,
662
      banner:               KILN_BANNER,
663
    },
664
    onwarn: suppressWarning,
665
  };
666

667
  const result = await vite.build(cfg);
3✔
668

669
  return Array.isArray(result) ? result[0] : result;
3!
670
}
671

672
// ── JS build orchestrator ────────────────────────────────────────────────────
673

674
/**
675
 * Generate all entry files, run both build passes, then write _manifest.json.
676
 *
677
 * Entry generation order:
678
 *   1. generateViteGlobalsInit() and generateViteKilnEditEntry() run in parallel
679
 *      (they write to independent files and have no shared dependencies).
680
 *   2. generateViteBootstrap() runs after globals, because it checks whether
681
 *      .clay/_globals-init.js exists to decide whether to import it.
682
 *
683
 * Build pass order:
684
 *   Both passes (view and kiln) run in parallel via Promise.all.  They write to
685
 *   independent file sets (the kiln entry is excluded from the view graph) so
686
 *   there is no ordering constraint between them.
687
 *
688
 * @param {object} [options]
689
 * @returns {Promise<void>}
690
 */
691
async function buildJS(options = {}) {
3✔
692
  // Globals and kiln entry do not depend on each other β€” generate concurrently.
693
  await Promise.all([
5✔
694
    generateViteGlobalsInit(),
695
    generateViteKilnEditEntry(),
696
  ]);
697

698
  // Bootstrap depends on globals existing (it checks pathExists before importing).
699
  await generateViteBootstrap();
5✔
700

701
  if (!fs.existsSync(VITE_BOOTSTRAP_FILE)) {
5✔
702
    throw new Error('clay vite: missing .clay/vite-bootstrap.js after prepare.');
1✔
703
  }
704

705
  await fs.ensureDir(DEST);
4✔
706

707
  const viteCfg = getViteConfig(options);
4✔
708
  const envCollector = createClientEnvCollector(path.join(CWD, 'client-env.json'));
4✔
709
  const envPlugin = envCollector.plugin();
4✔
710

711
  let viewOutput, kilnOutput;
712

713
  if (viteCfg.kilnSplit) {
4✔
714
    // Single pass β€” kiln is in the same Rollup graph as the bootstrap.
715
    // Only safe once all model.js/kiln.js files are native ESM.
716
    viewOutput = await runViewBuild(viteCfg, [envPlugin]);
1✔
717
    kilnOutput = null;
1✔
718
  } else {
719
    // Two passes in parallel β€” kiln is isolated in its own no-split graph.
720
    // This prevents CJS model.js dependencies from creating phantom shared chunks
721
    // in the view-mode graph that would increase page load request counts.
722
    [viewOutput, kilnOutput] = await Promise.all([
3✔
723
      runViewBuild(viteCfg, [envPlugin]),
724
      runKilnBuild(viteCfg, [envPlugin]),
725
    ]);
726
  }
727

728
  const manifest = buildManifest(viewOutput, kilnOutput);
4✔
729

730
  await writeManifest(manifest);
4✔
731
  await envCollector.write();
4✔
732
}
733

734
exports.buildJS = buildJS;
19✔
735

736
// ── Full build (JS + assets in parallel) ────────────────────────────────────
737

738
/**
739
 * Run all build steps: JS + styles + fonts + templates + vendor + media.
740
 *
741
 * media runs first (sequential) so that SVG files are on disk before the
742
 * templates step tries to inline them via {{{ read 'public/media/…' }}}.
743
 * All remaining steps run in parallel after media completes.
744
 *
745
 * @param {object} [options]
746
 * @returns {Promise<void>}
747
 */
748
async function buildAll(options = {}) {
2✔
749
  const isTTY = process.stdout.isTTY;
2✔
750
  const SPINNER = ['β ‹','β ™','β Ή','β Έ','β Ό','β ΄','β ¦','β §','β ‡','⠏'];
2✔
751

752
  const clr = {
2✔
753
    label: s => `\x1b[36m${s}\x1b[0m`,
25✔
754
    done:  s => `\x1b[32m${s}\x1b[0m`,
12✔
755
    fail:  s => `\x1b[31m${s}\x1b[0m`,
2✔
756
    time:  s => `\x1b[90m${s}\x1b[0m`,
13✔
UNCOV
757
    spin:  s => `\x1b[33m${s}\x1b[0m`,
×
758
  };
759

760
  const states    = new Map();
2✔
761
  const totalStart = Date.now();
2✔
762

763
  let spinFrame = 0,
2✔
764
    timer = null,
2✔
765
    progressUp = false;
2✔
766

767
  function clearSummary() {
768
    if (isTTY && progressUp) { process.stdout.write('\r\x1b[2K'); progressUp = false; }
2!
769
  }
770

771
  function writeSummary() {
UNCOV
772
    if (!isTTY) return;
×
UNCOV
773
    const running = [...states.entries()].filter(([, s]) => !s.done);
×
774

UNCOV
775
    if (!running.length) return;
×
776

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

UNCOV
781
    process.stdout.write(`${spin} ${parts.join(' ')} ${clr.time(`(${total}s)`)}`);
×
UNCOV
782
    progressUp = true;
×
783
  }
784

785
  function startStep(label) {
786
    states.set(label, { start: Date.now(), done: false, error: false });
12✔
787
    if (!isTTY) process.stdout.write(`  ${clr.label(`[${label}]`)} starting...\n`);
12!
788
  }
789

790
  function finishStep(label, error = false) {
11✔
791
    const s = states.get(label);
12✔
792

793
    if (s) { s.done = true; s.error = error; s.elapsed = ((Date.now() - s.start) / 1000).toFixed(1); }
12!
794

795
    const icon = error ? clr.fail('βœ—') : clr.done('βœ“');
12✔
796
    const word = error ? 'failed' : 'done  ';
12✔
797

798
    if (isTTY) {
12!
UNCOV
799
      clearSummary();
×
UNCOV
800
      process.stdout.write(`${icon} ${clr.label(`[${label}]`)} ${word} ${clr.time(`(${s ? s.elapsed : '?'}s)`)}\n`);
×
UNCOV
801
      if ([...states.values()].every(v => v.done)) {
×
802
        clearInterval(timer);
×
UNCOV
803
        timer = null;
×
804
      } else {
UNCOV
805
        writeSummary();
×
806
      }
807
    } else {
808
      process.stdout.write(`${icon} ${clr.label(`[${label}]`)} ${word} ${clr.time(`(${s ? s.elapsed : '?'}s)`)}\n`);
12!
809
    }
810
  }
811

812
  // Collect step failures so all steps always run even when one fails, then
813
  // throw at the end so the process exits non-zero in CI.
814
  const stepErrors = [];
2✔
815

816
  function step(label, fn) {
817
    startStep(label);
12✔
818
    return fn()
12✔
819
      .then(() => finishStep(label))
11✔
820
      .catch(e => {
821
        process.stderr.write(`\n${clr.fail('[error]')} ${clr.label(label)}: ${e.message}\n`);
1✔
822
        finishStep(label, true);
1✔
823
        stepErrors.push(e);
1✔
824
      });
825
  }
826

827
  process.stdout.write('\nBuilding assets...\n');
2✔
828

829
  // Media must complete before templates β€” the template step reads SVG files
830
  // from public/media/ via {{{ read 'public/media/…' }}} Handlebars helpers.
831
  await step('media', () => copyMedia());
2✔
832

833
  if (isTTY) {
2!
UNCOV
834
    timer = setInterval(() => { spinFrame++; clearSummary(); writeSummary(); }, 80);
×
835
  }
836

837
  await Promise.all([
2✔
838
    step('js',        () => buildJS(options)),
2✔
839
    step('styles',    () => buildStyles(options)),
2✔
840
    step('fonts',     () => buildFonts()),
2✔
841
    step('templates', () => buildTemplates(options)),
2✔
842
    step('vendor',    () => copyVendor()),
2✔
843
  ]);
844

845
  if (timer) { clearInterval(timer); timer = null; }
2!
846
  clearSummary();
2✔
847

848
  if (stepErrors.length) {
2✔
849
    const names = stepErrors.map(e => e.message).join('; ');
1✔
850

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

854
  const totalSecs = ((Date.now() - totalStart) / 1000).toFixed(1);
1✔
855

856
  process.stdout.write(`\n${clr.done('Build complete')} ${clr.time(`(${totalSecs}s total)`)}\n\n`);
1✔
857
}
858

859
/**
860
 * One-shot production build.
861
 *
862
 * @param {object} [options]
863
 */
864
async function build(options = {}) {
×
UNCOV
865
  return buildAll(options);
×
866
}
867

868
exports.build = build;
19✔
869
exports.buildAll = buildAll;
19✔
870

871
// ── Watch mode ───────────────────────────────────────────────────────────────
872

873
/**
874
 * Start Rollup's incremental watcher for JS plus chokidar watchers for
875
 * CSS, fonts, and templates.
876
 *
877
 * JS watch strategy:
878
 *   Rollup's watch mode rebuilds only the modules that changed, not the whole
879
 *   bundle.  This is significantly faster than Browserify's full-bundle rebuild
880
 *   on every save because the module graph is already resolved; only the dirty
881
 *   sub-graph is re-processed.
882
 *
883
 *   In two-pass mode (kilnSplit:false): both the view bundle and the kiln
884
 *   bundle run as parallel Rollup watchers.  model.js/kiln.js changes are
885
 *   handled incrementally by the kiln watcher; client.js changes are handled
886
 *   by the view watcher.  Add/unlink events regenerate the relevant entry
887
 *   file so the new module is included in the next incremental cycle.
888
 *
889
 * @param {object}   [options]
890
 * @param {boolean}  [options.minify=false]
891
 * @param {string[]} [options.extraEntries=[]]
892
 * @param {function} [options.onRebuild]  called after each JS rebuild with (errors)
893
 * @returns {Promise<{dispose: function}>}
894
 */
895
async function watch(options = {}) {
×
896
  const { onRebuild, onReady } = options;
1✔
897

898
  let isFirstBuild = true;
1✔
899
  const chokidar = require('chokidar');
1✔
900

901
  const chokidarOpts = {
1✔
902
    ignoreInitial: true,
903
    usePolling:    true,
904
    interval:      100,
905
    awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 50 },
906
  };
907

908
  function debounce(fn, ms) {
909
    let t;
910

911
    return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
11✔
912
  }
913

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

916
  const clr = {
1✔
917
    // per-asset prefix colors β€” each asset type gets a unique identity color
918
    js:        s => `\x1b[96m${s}\x1b[0m`,   // bright cyan
2✔
UNCOV
919
    kiln:      s => `\x1b[35m${s}\x1b[0m`,   // magenta
×
920
    styles:    s => `\x1b[34m${s}\x1b[0m`,   // blue
6✔
921
    fonts:     s => `\x1b[93m${s}\x1b[0m`,   // bright yellow
6✔
922
    templates: s => `\x1b[92m${s}\x1b[0m`,   // bright green
6✔
923
    // semantic state colors
924
    rebuilt: s => `\x1b[32m${s}\x1b[0m`,
4✔
925
    file:    s => `\x1b[36m${s}\x1b[0m`,
6✔
926
    error:   s => `\x1b[31m${s}\x1b[0m`,
3✔
927
  };
928

929
  // ── JS watch via Rollup watch mode ──────────────────────────────────────────
930

931
  const viteCfg = getViteConfig(options);
1✔
932

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

940
  // Same parallelization as buildJS: globals + kiln-edit first, then bootstrap.
941
  await Promise.all([
1✔
942
    generateViteGlobalsInit(),
943
    generateViteKilnEditEntry(),
944
  ]);
945
  await generateViteBootstrap();
1✔
946
  await fs.ensureDir(DEST);
1✔
947

948
  // Prints "<coloredPrefix> still building... (Xs)" every 3 s while a rebuild
949
  // is in flight so the terminal never looks frozen.  Returns a stop() function.
950
  // coloredPrefix should be the pre-colored tag, e.g. clr.js('[js]').
951
  function startProgressTick(coloredPrefix) {
952
    const t0 = Date.now();
6✔
953
    const id  = setInterval(() => {
6✔
UNCOV
954
      const s = Math.round((Date.now() - t0) / 1000);
×
955

UNCOV
956
      console.log(`${coloredPrefix} still building... (${s}s)`);
×
957
    }, 3000);
958

959
    return () => clearInterval(id);
6✔
960
  }
961

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

966
  let kilnOutput     = null;
1✔
967

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

970
  let resolveKilnOutput, rejectKilnOutput;
971

972
  function resetKilnOutputPromise() {
973
    return new Promise((res, rej) => { resolveKilnOutput = res; rejectKilnOutput = rej; });
2✔
974
  }
975

976
  let kilnOutputPromise  = resetKilnOutputPromise();
1✔
977

978
  let isFirstKilnBuild   = true;
1✔
979

980
  // KILN_BANNER is defined at module level above runKilnBuild β€” shared constant.
981

982
  let kilnTransformCount = 0;
1✔
983

984
  const captureKilnOutputPlugin = {
1✔
985
    name: 'clay-capture-kiln-output',
986

987
    watchChange(id, { event }) {
UNCOV
988
      console.log(clr.kiln(`[kiln] ${event}: `) + clr.file(path.relative(CWD, id)));
×
989
    },
990

991
    transform() {
UNCOV
992
      kilnTransformCount++;
×
993
    },
994

995
    writeBundle(_opts, bundle) {
996
      resolveKilnOutput({ output: Object.values(bundle) });
1✔
997
    },
998
  };
999

1000
  let kilnWatcher = null;
1✔
1001

1002
  if (!viteCfg.kilnSplit) {
1!
1003
    const kilnWatchCfg = baseViteConfig(viteCfg);
1✔
1004

1005
    kilnWatchCfg.plugins = kilnWatchCfg.plugins.concat([envCollector.plugin(), captureKilnOutputPlugin]);
1✔
1006
    kilnWatchCfg.build.outDir = DEST;
1✔
1007
    kilnWatchCfg.build.watch = {};
1✔
1008
    kilnWatchCfg.build.rollupOptions = {
1✔
1009
      input:  { [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE },
1010
      output: {
1011
        format:               'esm',
1012
        entryFileNames:       '[name]-[hash].js',
1013
        inlineDynamicImports: true,
1014
        banner:               KILN_BANNER,
1015
      },
1016
      onwarn: suppressWarning,
1017
      watch: {
1018
        exclude: [
1019
          path.join(CWD, 'public', '**'),
1020
          path.join(CWD, 'node_modules', '**'),
1021
          path.join(CLAY_DIR, '**'),
1022
        ],
1023
      },
1024
    };
1025

1026
    kilnWatcher = await vite.build(kilnWatchCfg);
1✔
1027

1028
    let stopKilnTick = () => {};
1✔
1029

1030
    async function handleKilnBundleEnd(event) {
1031
      stopKilnTick();
1✔
1032

1033
      const output         = await kilnOutputPromise;
1✔
1034
      const modulesRebuilt = kilnTransformCount;
1✔
1035
      const wasFirst       = isFirstKilnBuild;
1✔
1036

1037
      kilnOutput         = output;
1✔
1038
      kilnOutputPromise  = resetKilnOutputPromise();
1✔
1039
      kilnTransformCount = 0;
1✔
1040
      isFirstKilnBuild   = false;
1✔
1041

1042
      if (lastViewOutput) {
1!
1043
        // Always reconcile the manifest when we have both outputs β€” this fixes
1044
        // a race where the view watcher wrote the manifest before the kiln
1045
        // watcher finished its first build, leaving the kiln entry absent.
1046
        const manifest = buildManifest(lastViewOutput, kilnOutput);
1✔
1047

1048
        await writeManifest(manifest);
1✔
1049
        await envCollector.write();
1✔
1050

1051
        if (!wasFirst) {
1!
1052
          // Only log on incremental rebuilds; suppress the initial startup cycle.
UNCOV
1053
          const moduleLabel = modulesRebuilt === 1 ? '1 module' : `${modulesRebuilt} modules`;
×
UNCOV
1054
          const duration    = event.duration != null ? ` in ${event.duration}ms` : '';
×
1055

UNCOV
1056
          console.log(clr.kiln('[kiln]') + clr.rebuilt(' Rebuilt') + ` (${moduleLabel} transformed${duration})`);
×
1057
        } else if (onReady) {
1!
1058
          // Signal ready only after both view AND kiln have completed their
1059
          // first build β€” the manifest is now complete with both entries.
1060
          onReady();
1✔
1061
        }
1062
      }
1063
      if (event.result && event.result.close) event.result.close();
1!
1064
    }
1065

1066
    kilnWatcher.on('event', async (event) => {
1✔
1067
      if (event.code === 'BUNDLE_START') {
2✔
1068
        if (!isFirstKilnBuild) stopKilnTick = startProgressTick(clr.kiln('[kiln]'));
1!
1069
        else stopKilnTick = () => {};
1✔
1070
      } else if (event.code === 'BUNDLE_END') {
1!
1071
        await handleKilnBundleEnd(event);
1✔
UNCOV
1072
      } else if (event.code === 'ERROR') {
×
UNCOV
1073
        stopKilnTick();
×
UNCOV
1074
        rejectKilnOutput(event.error);
×
UNCOV
1075
        kilnOutputPromise = resetKilnOutputPromise();
×
UNCOV
1076
        console.error(clr.kiln('[kiln]') + clr.error(` Build error: ${event.error.message}`));
×
UNCOV
1077
        if (event.result && event.result.close) event.result.close();
×
1078
      }
1079
    });
1080
  }
1081

1082
  const watchInput = viteCfg.kilnSplit
1!
1083
    ? { [VITE_BOOTSTRAP_KEY]: VITE_BOOTSTRAP_FILE, [KILN_EDIT_ENTRY_KEY]: KILN_EDIT_ENTRY_FILE }
1084
    : { [VITE_BOOTSTRAP_KEY]: VITE_BOOTSTRAP_FILE };
1085

1086
  const watchCfg = baseViteConfig(viteCfg);
1✔
1087

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

1099
  function resetViewOutputPromise() {
1100
    return new Promise((res, rej) => {
2✔
1101
      resolveViewOutput = res;
2✔
1102
      rejectViewOutput  = rej;
2✔
1103
    });
1104
  }
1105

1106
  let viewOutputPromise = resetViewOutputPromise();
1✔
1107

1108
  let transformCount    = 0;
1✔
1109

1110
  const captureOutputPlugin = {
1✔
1111
    name: 'clay-capture-watch-output',
1112

1113
    // watchChange fires (once per file) when Rollup's own watcher detects a
1114
    // change in a file that is actually part of the module graph.  More
1115
    // accurate than the chokidar jsWatcher below: only fires for files Rollup
1116
    // actually tracks, so it won't log a file that changed but isn't imported.
1117
    watchChange(id, { event }) {
1118
      console.log(clr.js(`[js] ${event}: `) + clr.file(path.relative(CWD, id)));
×
1119
    },
1120

1121
    // transform is called only for modules Rollup needs to re-process this
1122
    // cycle.  With the watch-mode module cache, unchanged modules are skipped β€”
1123
    // so this count reflects the actual incremental rebuild scope.
1124
    transform() {
UNCOV
1125
      transformCount++;
×
1126
    },
1127

1128
    // writeBundle fires after every file has been written to disk.
1129
    // `bundle` is a {[fileName]: OutputChunk|OutputAsset} map; convert it to
1130
    // the RollupOutput shape {output:[...]} that buildManifest iterates.
1131
    writeBundle(_opts, bundle) {
1132
      resolveViewOutput({ output: Object.values(bundle) });
1✔
1133
    },
1134
  };
1135

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

1140
  watchCfg.build.outDir = DEST;
1✔
1141
  watchCfg.build.watch = {};
1✔
1142
  watchCfg.build.rollupOptions = {
1✔
1143
    input:  watchInput,
1144
    output: {
1145
      format:         'esm',
1146
      entryFileNames: '[name]-[hash].js',
1147
      chunkFileNames: 'chunks/[name]-[hash].js',
1148
      // See buildChunkingOutput() for CJS vs ESM branching rationale.
1149
      ...buildChunkingOutput(viteCfg),
1150
    },
1151
    onwarn: suppressWarning,
1152
    watch: {
1153
      // Exclude build outputs and dependencies from Rollup's file watcher to
1154
      // prevent feedback loops where writing a chunk triggers another rebuild.
1155
      exclude: [
1156
        path.join(CWD, 'public', '**'),
1157
        path.join(CWD, 'node_modules', '**'),
1158
        path.join(CLAY_DIR, '**'),
1159
      ],
1160
    },
1161
  };
1162

1163
  const watcher = await vite.build(watchCfg);
1✔
1164

1165
  let stopJsTick = () => {};
1✔
1166

1167
  // Extract BUNDLE_END handling so the event callback stays below complexity limit.
1168
  // Signal ready after view's first build.  When a separate kiln watcher
1169
  // exists, defer until handleKilnBundleEnd writes the complete manifest.
1170
  function signalReadyIfFirst() {
1171
    if (!isFirstBuild) return;
1!
1172
    isFirstBuild = false;
1✔
1173
    if (onReady && !kilnWatcher) onReady();
1!
1174
  }
1175

1176
  async function handleBundleEnd(event) {
1177
    stopJsTick();
1✔
1178

1179
    // Await the output captured by captureOutputPlugin.writeBundle(), which
1180
    // fires after files are on disk but before BUNDLE_END reaches us.
1181
    // Reset the promise and transform counter immediately so the next rebuild
1182
    // gets a fresh slot.
1183
    const viewOutput      = await viewOutputPromise;
1✔
1184
    const modulesRebuilt  = transformCount;
1✔
1185

1186
    viewOutputPromise = resetViewOutputPromise();
1✔
1187
    transformCount    = 0;
1✔
1188
    lastViewOutput    = viewOutput; // retained so kiln BUNDLE_END can update the manifest
1✔
1189

1190
    const manifest = buildManifest(viewOutput, kilnOutput);
1✔
1191

1192
    await writeManifest(manifest);
1✔
1193
    await envCollector.write();
1✔
1194
    if (onRebuild) onRebuild([]);
1!
1195

1196
    const moduleLabel = modulesRebuilt === 1 ? '1 module' : `${modulesRebuilt} modules`;
1!
1197
    const duration    = event.duration != null ? ` in ${event.duration}ms` : '';
1!
1198

1199
    console.log(clr.js('[js]') + clr.rebuilt(' Rebuilt successfully') + ` (${moduleLabel} transformed${duration})`);
1✔
1200
    signalReadyIfFirst();
1✔
1201
    if (event.result && event.result.close) event.result.close();
1!
1202
  }
1203

1204
  watcher.on('event', async (event) => {
1✔
1205
    if (event.code === 'BUNDLE_START') {
2✔
1206
      console.log(clr.js('[js]') + ' Rebuilding...');
1✔
1207
      stopJsTick = isFirstBuild ? () => {} : startProgressTick(clr.js('[js]'));
1!
1208
    } else if (event.code === 'BUNDLE_END') {
1!
1209
      await handleBundleEnd(event);
1✔
UNCOV
1210
    } else if (event.code === 'ERROR') {
×
UNCOV
1211
      stopJsTick();
×
1212
      // Reject the output promise so handleBundleEnd doesn't hang if writeBundle
1213
      // was skipped due to the error, then reset for the next rebuild attempt.
UNCOV
1214
      rejectViewOutput(event.error);
×
UNCOV
1215
      viewOutputPromise = resetViewOutputPromise();
×
UNCOV
1216
      console.error(clr.js('[js]') + clr.error(` Build error: ${event.error.message}`));
×
UNCOV
1217
      if (onRebuild) onRebuild([event.error]);
×
1218
    }
1219
  });
1220

1221
  // ── Chokidar: regenerate bootstrap when new client.js files appear ─────────
1222

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

UNCOV
1232
    if (isGlobal) {
×
1233
      // A global/js file was added or removed: regenerate the import list first,
1234
      // then the bootstrap (which may reference the globals entry).
UNCOV
1235
      await generateViteGlobalsInit();
×
UNCOV
1236
      await generateViteBootstrap();
×
UNCOV
1237
    } else if (changedFile.endsWith('client.js')) {
×
UNCOV
1238
      await generateViteBootstrap();
×
UNCOV
1239
    } else if (isKilnFile) {
×
1240
      // The kiln watcher detects the entry file change and rebuilds incrementally.
UNCOV
1241
      await generateViteKilnEditEntry();
×
1242
    }
1243
  }
1244

1245
  const rebuildBootstrap = debounce(async (changedFile, eventType) => {
1✔
1246
    if (!changedFile) return;
1!
1247

1248
    const isChange   = eventType === 'change';
1✔
1249
    const isKilnFile = changedFile.endsWith('model.js') || changedFile.endsWith('kiln.js');
1✔
1250

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

1257
      console.log(pfxFn(`${pfxLabel} ${eventType}: `) + clr.file(rel(changedFile)));
×
1258
      await regenerateEntryFiles(changedFile);
×
1259
    }
1260
    // For change events, the relevant Rollup watcher handles it incrementally.
1261
  }, 200);
1262

1263
  const JS_GLOBS = [
1✔
1264
    path.join(CWD, 'components', '**', '*.js'),
1265
    path.join(CWD, 'components', '**', '*.vue'),
1266
    path.join(CWD, 'layouts', '**', '*.js'),
1267
    path.join(CWD, 'global', '**', '*.js'),
1268
    path.join(CWD, 'services', '**', '*.js'),
1269
  ];
1270

1271
  const jsWatcher = chokidar.watch(JS_GLOBS, {
1✔
1272
    ...chokidarOpts,
1273
    ignored: [path.join(CWD, 'public', '**'), path.join(CWD, 'node_modules', '**')],
1274
  });
1275

1276
  jsWatcher
1✔
1277
    .on('change', f => rebuildBootstrap(f, 'change'))
1✔
1278
    .on('add',    f => rebuildBootstrap(f, 'add'))
3✔
1279
    .on('unlink', f => rebuildBootstrap(f, 'unlink'));
1✔
1280

1281
  // ── CSS watcher ──────────────────────────────────────────────────────────────
1282
  // When a component/layout CSS file changes, only rebuild that component's file
1283
  // across all styleguides (e.g. nav.nymag.css, nav.curbed.css, nav._default.css)
1284
  // rather than recompiling all ~2800 CSS files.
1285
  //
1286
  // Falls back to a full rebuild if the changed file is a shared mixin/variable
1287
  // (i.e. its basename doesn't appear in any standard component/layout glob).
1288
  const { globSync: cssGlobSync } = require('glob');
1✔
1289

1290
  function getChangedStyleFiles(changedFile) {
1291
    if (!changedFile) return null; // null β†’ full rebuild
2!
1292
    const basename = path.basename(changedFile); // e.g. "nav.css"
2✔
1293
    const variants = [
2✔
1294
      ...cssGlobSync(path.join(CWD, 'styleguides', '**', 'components', basename)),
1295
      ...cssGlobSync(path.join(CWD, 'styleguides', '**', 'layouts', basename)),
1296
    ];
1297

1298
    // If no variants found (shared mixin/variable file), do a full rebuild.
1299
    return variants.length > 0 ? variants : null;
2!
1300
  }
1301

1302
  const rebuildStyles = debounce((changedFile) => {
1✔
1303
    if (changedFile) console.log(clr.styles('[styles]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1304
    const changedFiles  = getChangedStyleFiles(changedFile);
2✔
1305
    const buildOpts     = changedFiles ? { ...options, changedFiles } : options;
2!
1306
    const start         = Date.now();
2✔
1307
    const stopStyleTick = startProgressTick(clr.styles('[styles]'));
2✔
1308

1309
    return buildStyles(buildOpts)
2✔
1310
      .then(() => {
1311
        stopStyleTick();
1✔
1312
        console.log(clr.styles('[styles]') + clr.rebuilt(` Rebuilt (${Date.now() - start}ms)`));
1✔
1313
        // Write reload-signal so start.js/nodemon triggers a full server
1314
        // restart.  A restart is necessary because CSS is output with stable
1315
        // filenames (e.g. nav.nymag.css) β€” no content hash β€” so the browser
1316
        // keeps serving its cached copy even after the file changes on disk.
1317
        // A new server process changes the ETag, which forces a re-fetch.
1318
        //
1319
        // TODO: migrate CSS to Lightning CSS with content-hashed output
1320
        // (e.g. nav.nymag-[hash].css) so the manifest carries the new URL and
1321
        // the browser is forced to re-fetch naturally on every CSS change,
1322
        // exactly like JS chunks work today.  Once that lands, this signal and
1323
        // the nodemon restart in start.js can be removed entirely.
1324
        return fs.ensureDir(CLAY_DIR)
1✔
1325
          .then(() => fs.writeFile(path.join(CLAY_DIR, 'reload-signal'), String(Date.now())))
1✔
UNCOV
1326
          .catch(e => console.warn(`[styles] could not write reload-signal: ${e.message}`));
×
1327
      })
1328
      .catch(e => { stopStyleTick(); console.error(clr.styles('[styles]') + clr.error(` rebuild failed: ${e.message}`)); });
1✔
1329
  }, 200);
1330

1331
  const cssWatcher = chokidar.watch(STYLE_GLOBS, chokidarOpts);
1✔
1332

1333
  cssWatcher.on('change', rebuildStyles).on('add', rebuildStyles).on('unlink', rebuildStyles);
1✔
1334

1335
  // ── Font watcher ─────────────────────────────────────────────────────────────
1336
  const rebuildFonts = debounce((changedFile) => {
1✔
1337
    if (changedFile) console.log(clr.fonts('[fonts]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1338
    const start        = Date.now();
2✔
1339
    const stopFontTick = startProgressTick(clr.fonts('[fonts]'));
2✔
1340

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

1346
  const fontWatcher = chokidar.watch(FONTS_SRC_GLOB, chokidarOpts);
1✔
1347

1348
  fontWatcher.on('change', rebuildFonts).on('add', rebuildFonts).on('unlink', rebuildFonts);
1✔
1349

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

1356
  const rebuildTemplates = debounce((changedFile) => {
1✔
1357
    if (changedFile) console.log(clr.templates('[templates]') + ' Changed: ' + clr.file(rel(changedFile)));
2!
1358
    const start            = Date.now();
2✔
1359
    const stopTemplateTick = startProgressTick(clr.templates('[templates]'));
2✔
1360

1361
    return buildTemplates({ ...options, watch: true })
2✔
1362
      .then(() => {
1363
        stopTemplateTick();
1✔
1364
        console.log(clr.templates('[templates]') + clr.rebuilt(` Rebuilt (${Date.now() - start}ms)`));
1✔
1365
        // Write template-signal so renderers.js calls html.init() in-process
1366
        // (~100ms) without a full server restart.
1367
        return fs.ensureDir(CLAY_DIR)
1✔
1368
          .then(() => fs.writeFile(TEMPLATE_SIGNAL, String(Date.now())))
1✔
1369
          .catch(e => console.warn(`[templates] could not write template-signal: ${e.message}`));
×
1370
      })
1371
      .catch(e => { stopTemplateTick(); console.error(clr.templates('[templates]') + clr.error(` rebuild failed: ${e.message}`)); });
1✔
1372
  }, 200);
1373

1374
  const templateGlobs = [
1✔
1375
    path.join(CWD, 'components', '**', TEMPLATE_GLOB_PATTERN),
1376
    path.join(CWD, 'layouts', '**', TEMPLATE_GLOB_PATTERN),
1377
  ];
1378
  const templateWatcher = chokidar.watch(templateGlobs, chokidarOpts);
1✔
1379

1380
  templateWatcher
1✔
1381
    .on('change', rebuildTemplates)
1382
    .on('add', rebuildTemplates)
1383
    .on('unlink', rebuildTemplates);
1384

1385
  const chokidarWatchers = [jsWatcher, cssWatcher, fontWatcher, templateWatcher];
1✔
1386

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

1389
  return {
1✔
1390
    dispose: async () => {
1391
      if (watcher     && watcher.close)     watcher.close();
1!
1392
      if (kilnWatcher && kilnWatcher.close) kilnWatcher.close();
1!
1393
      await Promise.all(chokidarWatchers.map(w => w.close()));
4✔
1394
    },
1395
  };
1396
}
1397

1398
exports.watch = watch;
19✔
1399

1400
// ── Warning suppressor ───────────────────────────────────────────────────────
1401

1402
/**
1403
 * Suppress Rollup warnings that are expected when bundling a CJS-heavy
1404
 * codebase into a native ESM output.
1405
 *
1406
 * These are not real errors β€” they reflect the current state of the codebase
1407
 * (CJS sources, circular dependencies, missing optional modules) and will
1408
 * disappear naturally as files are migrated to ESM.
1409
 *
1410
 * @param {object}   warning
1411
 * @param {function} warn
1412
 */
1413
// Warning codes that are always expected in a mixed CJS/ESM codebase and
1414
// should not surface to the user.  Each code is explained inline.
1415
const SUPPRESSED_WARNING_CODES = new Set([
19✔
1416
  // Circular requires() are common in CJS codebases and handled safely by
1417
  // strictRequires:'auto'.  They become non-issues once files are ESM.
1418
  'CIRCULAR_DEPENDENCY',
1419

1420
  // CJS modules use `this` at the top level (equivalent to `module.exports`).
1421
  // Rollup warns because in ESM `this` is undefined at the top level.
1422
  // @rollup/plugin-commonjs wraps these so the reference is correct.
1423
  'THIS_IS_UNDEFINED',
1424

1425
  // Some globals (e.g. jQuery's $) are expected to be on window and are not
1426
  // imported.  Clay components reference them as free variables.
1427
  'MISSING_GLOBAL_NAME',
1428

1429
  // Missing optional imports are stubbed by viteMissingModulePlugin.  Rollup
1430
  // warns before the plugin catches them; the warning is spurious.
1431
  'UNRESOLVED_IMPORT',
1432

1433
  // CJS modules wrapped by @rollup/plugin-commonjs may not export named
1434
  // bindings.  Named imports from CJS modules get undefined β€” expected.
1435
  'MISSING_EXPORT',
1436
]);
1437

1438
function suppressWarning(warning, warn) {
UNCOV
1439
  if (SUPPRESSED_WARNING_CODES.has(warning.code)) return;
×
1440

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

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