@webjsdev/server 0.8.91 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dev-styles.js CHANGED
@@ -61,17 +61,28 @@ export function preloadStyles(root, now, timeoutMs) {
61
61
  kept[key] = { el, url };
62
62
  }
63
63
  let committed = false;
64
- /** @type {Array<{ key: string, old: Element, next: Element, state: string }>} */
64
+ /** @type {Array<{ key: string, file: string, old: Element, next: Element, state: string }>} */
65
65
  const pairs = [];
66
66
  // Drop every other copy of this sheet (the old link, a copy the head merge
67
- // re-appended) so exactly the replacement is left.
67
+ // re-appended, the client router's temporary preload copy) so exactly the
68
+ // replacement is left. The router marks its copy `data-webjs-preload-style`
69
+ // and gives it only the fetch attributes, so it never shares the identity
70
+ // key. Left in place it waits for a twin link this function has just
71
+ // removed, and lingers until the router's timeout. So a copy goes too when
72
+ // it is the same file (the same URL once the dev cache-buster is ignored,
73
+ // so `?theme=dark` stays when `?theme=light` is rebuilt) AND has already
74
+ // loaded: an unloaded copy is one a navigation is still waiting on, and
75
+ // removing it would stall that navigation for the whole timeout.
68
76
  const settleLoaded = function (pair) {
77
+ if (pair.old.parentNode) pair.old.parentNode.removeChild(pair.old);
69
78
  const links = [].slice.call(scope.querySelectorAll('link[rel~="stylesheet"][href]'));
70
79
  for (const el of links) {
71
80
  if (el === pair.next) continue;
72
81
  let url;
73
82
  try { url = new URL(el.getAttribute('href'), location.href); } catch (_) { continue; }
74
- if (url.origin === location.origin && identityKey(el, url) === pair.key && el.parentNode) el.parentNode.removeChild(el);
83
+ if (url.origin !== location.origin || !el.parentNode) continue;
84
+ const routerCopy = el.hasAttribute(ROUTER_PRELOAD_ATTR) && !!el.sheet && withoutBuster(url) === pair.file;
85
+ if (routerCopy || identityKey(el, url) === pair.key) el.parentNode.removeChild(el);
75
86
  }
76
87
  };
77
88
  const waits = [];
@@ -79,13 +90,30 @@ export function preloadStyles(root, now, timeoutMs) {
79
90
  const old = kept[key].el;
80
91
  const url = kept[key].url;
81
92
  if (!old.parentNode) continue;
93
+ const file = withoutBuster(url);
82
94
  url.searchParams.set('__webjs_dev', String(stamp()));
83
95
  const next = /** @type {Element} */ (old.cloneNode(false));
84
96
  next.setAttribute('href', url.pathname + url.search);
85
- const pair = { key, old, next, state: 'pending' };
97
+ // The copy loads under a media query that matches nothing, so it never
98
+ // applies before its load handler has decided whether it should: the
99
+ // client router can switch the link off or on (#1669) while this refresh
100
+ // waits, so that state is read at load, not now.
101
+ const media = authoredMedia(old);
102
+ next.removeAttribute('data-wj-style-media');
103
+ next.setAttribute('media', 'not all');
104
+ const pair = { key, file, old, next, state: 'pending' };
86
105
  pairs.push(pair);
87
106
  waits.push(new Promise(function (resolve) {
88
107
  next.addEventListener('load', function () {
108
+ // A section sheet the router switched off is refreshed too, so going
109
+ // back to the section switches on the edited file, but the copy stays
110
+ // off until then, like the link it replaces. Media first: Firefox and
111
+ // WebKit give the link a new sheet object when its media changes, so
112
+ // disabling before would be lost.
113
+ const off = old.parentNode ? old.hasAttribute('data-wj-style-off') : next.hasAttribute('data-wj-style-off');
114
+ if (off) next.setAttribute('data-wj-style-off', ''); else next.removeAttribute('data-wj-style-off');
115
+ if (media) next.setAttribute('media', media); else next.removeAttribute('media');
116
+ if (next.sheet) next.sheet.disabled = off;
89
117
  pair.state = 'load';
90
118
  if (committed) settleLoaded(pair);
91
119
  resolve(undefined);
@@ -112,6 +140,24 @@ export function preloadStyles(root, now, timeoutMs) {
112
140
  });
113
141
  }
114
142
 
143
+ /**
144
+ * The attribute the client router marks its temporary stylesheet copies with
145
+ * (`PRELOAD_ATTR` in core's `router-client/stylesheets.js`). Duplicated rather
146
+ * than imported, since this module is inlined into the reload client as source.
147
+ */
148
+ const ROUTER_PRELOAD_ATTR = 'data-webjs-preload-style';
149
+
150
+ /**
151
+ * A stylesheet URL with the dev cache-buster removed, which is what names the
152
+ * file: two links that differ only by `__webjs_dev` load the same rules.
153
+ * @param {URL} url
154
+ */
155
+ function withoutBuster(url) {
156
+ const u = new URL(url.href);
157
+ u.searchParams.delete('__webjs_dev');
158
+ return u.pathname + u.search;
159
+ }
160
+
115
161
  /**
116
162
  * A link's identity for de-duping: its path plus every attribute except `href`.
117
163
  * JSON-encoded over a sorted list so no separator can collide with an attribute
@@ -123,9 +169,21 @@ function identityKey(el, url) {
123
169
  const parts = [];
124
170
  const attrs = el.attributes;
125
171
  for (let a = 0; a < attrs.length; a++) {
126
- if (attrs[a].name === 'href') continue;
127
- parts.push([attrs[a].name, attrs[a].value]);
172
+ const name = attrs[a].name;
173
+ // The client router's switch-off state (#1669) is not part of which sheet
174
+ // a link is, and neither is a held `not all` media: the author's is.
175
+ if (name === 'href' || name === 'data-wj-style-off' || name === 'data-wj-style-media') continue;
176
+ parts.push([name, name === 'media' ? authoredMedia(el) : attrs[a].value]);
128
177
  }
129
178
  parts.sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
130
179
  return JSON.stringify([url.pathname, parts]);
131
180
  }
181
+
182
+ /**
183
+ * The media a link applies under: the author's, also while the client router
184
+ * holds it under `not all` (`data-wj-style-media`, owned-styles.js).
185
+ * @param {Element} el
186
+ */
187
+ function authoredMedia(el) {
188
+ return el.hasAttribute('data-wj-style-media') ? el.getAttribute('data-wj-style-media') : el.getAttribute('media');
189
+ }
@@ -8,6 +8,7 @@ import { lookupActionIdentity } from './form-action-identity.js';
8
8
  import { verifyOrigin } from './csrf.js';
9
9
  import { actionMethod, actionConfigFn, actionMiddleware, resolveTags, RESERVED_CONFIG } from './action-config.js';
10
10
  import { runValidate } from './actions.js';
11
+ import { formInput } from './action-input.js';
11
12
  import { runActionChain } from './action-middleware.js';
12
13
  import { runWithActionSignal } from './action-signal.js';
13
14
  import { isStreamable } from './action-stream.js';
@@ -527,10 +528,15 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
527
528
  );
528
529
  }
529
530
 
530
- // A form-bound action always receives the `FormData` (#1155). `validate` is
531
- // the typing seam: it takes the FormData and its transform-return becomes the
532
- // action's typed input, the same `runValidate` contract the RPC path uses.
533
- let args = [formData];
531
+ // A form-bound action receives the submitted fields as ONE object that reads
532
+ // both ways (`action-input.js`): `input.title`, the shape the same action gets
533
+ // over RPC and through `route()`, and `input.get('title')`, the FormData
534
+ // readers. It used to receive the raw FormData, so a validator written for
535
+ // the RPC shape read `undefined` and answered every submission with a 422
536
+ // (#1664). `validate`, middleware, the action and `invalidates` all see it.
537
+ // The raw `formData` local keeps feeding the #1581 submitted-values path.
538
+ const warn = ssrOpts.dev ? (logger && logger.warn ? (m) => logger.warn(m) : (m) => console.warn(m)) : undefined;
539
+ let args = [formInput(formData, { warn, label: fnName })];
534
540
  const validate = actionConfigFn(mod, 'validate');
535
541
  if (typeof validate === 'function') {
536
542
  const v = runValidate(validate, args[0]);
@@ -1,4 +1,4 @@
1
- import { collectHoistedHeadTags, hoistHeadTags, wrapHead } from './head.js';
1
+ import { collectHoistedHeadTags, hoistHeadTags, hoistOwnedStylesheets, stylesheetKeys, wrapHead } from './head.js';
2
2
 
3
3
  /**
4
4
  * Derive a layout's segment path from its file path. The path identifies
@@ -331,19 +331,27 @@ export function buildDocumentParts(body, wrapOpts) {
331
331
  if (shell) {
332
332
  const headInner = buildHeadInner(wrapOpts);
333
333
  const hoist = collectHoistedHeadTags(shell.userBody);
334
- const composedHead = [headInner, shell.userHead.trim(), hoist.tags.join('\n')]
334
+ const rootHead = [headInner, shell.userHead.trim(), hoist.tags.join('\n')]
335
335
  .filter(Boolean)
336
336
  .join('\n');
337
+ // A nested layout's or page's leading stylesheets join the head after the
338
+ // root's, so the cascade reads root to leaf (#1669).
339
+ const owned = hoistOwnedStylesheets(hoist.body, stylesheetKeys(rootHead, wrapOpts.pageUrl), wrapOpts.pageUrl);
340
+ const composedHead = [rootHead, owned.tags.join('\n')].filter(Boolean).join('\n');
337
341
  const prefix =
338
342
  `<!doctype html>\n<html${shell.htmlAttrs}>\n<head${shell.headAttrs}>\n` +
339
343
  composedHead +
340
344
  `\n</head>\n<body${shell.bodyAttrs}>\n`;
341
- return { prefix, streamBody: hoist.body, closer: `\n</body>\n</html>` };
345
+ return { prefix, streamBody: owned.body, closer: `\n</body>\n</html>` };
342
346
  }
343
347
  // No user shell: framework owns the wrapper.
344
348
  const headHtml = wrapHead(wrapOpts);
345
349
  const { head, body: bodyOut } = hoistHeadTags(headHtml, body);
346
- return { prefix: head, streamBody: bodyOut, closer: `\n</body>\n</html>` };
350
+ const owned = hoistOwnedStylesheets(bodyOut, stylesheetKeys(head, wrapOpts.pageUrl), wrapOpts.pageUrl);
351
+ const prefix = owned.tags.length
352
+ ? head.replace('</head>', () => owned.tags.join('\n') + '\n</head>')
353
+ : head;
354
+ return { prefix, streamBody: owned.body, closer: `\n</body>\n</html>` };
347
355
  }
348
356
 
349
357
  /** Re-export for unit testing. */
package/src/ssr/head.js CHANGED
@@ -305,7 +305,7 @@ export function collectHoistedHeadTags(bodyHtml) {
305
305
  * (breaks the ES-module waterfall without a bundler) and any user-declared
306
306
  * `metadata.preload` entries.
307
307
  *
308
- * @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean, streaming: boolean, preloads?: string[], lazyComponents?: Record<string, string>, nonce?: string }} opts
308
+ * @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean, streaming: boolean, preloads?: string[], lazyComponents?: Record<string, string>, nonce?: string, pageUrl?: string }} opts
309
309
  */
310
310
  /**
311
311
  * Extract leading `<script>`, `<style>`, and `<link>` tags from the body
@@ -323,13 +323,161 @@ export function hoistHeadTags(headHtml, bodyHtml) {
323
323
  // streaming path so both hoist identically. See collectHoistedHeadTags.
324
324
  const { tags: hoisted, body: remaining } = collectHoistedHeadTags(bodyHtml);
325
325
  if (!hoisted.length) return { head: headHtml, body: bodyHtml };
326
- const newHead = headHtml.replace('</head>', hoisted.join('\n') + '\n</head>');
326
+ const newHead = headHtml.replace('</head>', () => hoisted.join('\n') + '\n</head>');
327
327
  return { head: newHead, body: remaining };
328
328
  }
329
329
 
330
330
  /** Internal helper re-exported for unit testing. */
331
331
  export const _hoistHeadTags = hoistHeadTags;
332
332
 
333
+ /**
334
+ * The attribute naming the boundary a hoisted stylesheet came from (#1669).
335
+ * Its value is the SEGMENT of the `wj:children` range the link was rendered
336
+ * in: a nested layout's own markup sits inside its parent's range, and a
337
+ * page's inside its own boundary or its innermost layout's. The client router
338
+ * switches the sheet off when it swaps that range and the incoming page no
339
+ * longer links the same file, so a section's stylesheet leaves with the
340
+ * section, and switches it back on when a later page links it again.
341
+ */
342
+ export const STYLE_OWNER_ATTR = 'data-wj-style-owner';
343
+
344
+ const BOUNDARY_OPEN = '<!--wj:children:';
345
+ // One token of a leading run after a boundary marker: a comment that is not a
346
+ // client-router marker, or a `<link>` tag (quote-aware, like the root hoist).
347
+ // Sticky, so it matches at `lastIndex` without copying the rest of the body.
348
+ const OWNED_RUN_TOKEN_RE =
349
+ /\s*(<!--(?!\/?wj:)[\s\S]*?-->|<link\b(?:[^>"']|"[^"]*"|'[^']*')*>)/iy;
350
+ const LINK_TAG_RE = /<link\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
351
+
352
+ // One attribute after the tag name, read in order so a quoted value is
353
+ // consumed whole and never mistaken for another attribute.
354
+ const ATTR_RE = /\s*([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/y;
355
+
356
+ /**
357
+ * A tag's attributes, lowercased names to raw values (the first of a repeated
358
+ * name wins, as in HTML).
359
+ * @param {string} tag
360
+ * @returns {Map<string, string>}
361
+ */
362
+ function tagAttrs(tag) {
363
+ /** @type {Map<string, string>} */
364
+ const out = new Map();
365
+ const start = /^<[a-zA-Z][^\s/>]*/.exec(tag);
366
+ let pos = start ? start[0].length : 0;
367
+ for (;;) {
368
+ ATTR_RE.lastIndex = pos;
369
+ const m = ATTR_RE.exec(tag);
370
+ if (!m || m[0].length === 0) break;
371
+ pos += m[0].length;
372
+ const name = m[1].toLowerCase();
373
+ if (!out.has(name)) out.set(name, m[2] ?? m[3] ?? m[4] ?? '');
374
+ }
375
+ return out;
376
+ }
377
+
378
+ /**
379
+ * The identity of a stylesheet `<link>`, or null when the tag is not an
380
+ * active one (`rel` naming `stylesheet` and not `alternate`, with an href).
381
+ * The URL the href resolves to against the page's own URL (`base`), plus the
382
+ * `media` it applies under, so `./a.css` and `/a.css` on one page are one
383
+ * sheet and a print sheet and a screen sheet of one file are two. The router
384
+ * keys its sheets the same way (owned-styles.js). With no base the href is
385
+ * used as written.
386
+ * @param {string} tag
387
+ * @param {string} [base]
388
+ * @returns {string | null}
389
+ */
390
+ function stylesheetTagKey(tag, base) {
391
+ const attrs = tagAttrs(tag);
392
+ const rel = (attrs.get('rel') || '').toLowerCase().split(/\s+/);
393
+ let href = attrs.get('href');
394
+ if (!rel.includes('stylesheet') || rel.includes('alternate') || !href) return null;
395
+ if (base) { try { href = new URL(href, base).href; } catch { /* keep as written */ } }
396
+ const media = (attrs.get('media') || '').trim();
397
+ return media && media !== 'all' ? `${href} ${media}` : href;
398
+ }
399
+
400
+ /**
401
+ * The keys (`stylesheetTagKey`) of every stylesheet `<link>` in an HTML string.
402
+ * @param {string} html
403
+ * @param {string} [base] The page's URL, which hrefs resolve against.
404
+ * @returns {Set<string>}
405
+ */
406
+ export function stylesheetKeys(html, base) {
407
+ const out = new Set();
408
+ for (const m of html.matchAll(LINK_TAG_RE)) {
409
+ const key = stylesheetTagKey(m[0], base);
410
+ if (key) out.add(key);
411
+ }
412
+ return out;
413
+ }
414
+
415
+ /**
416
+ * Hoist the stylesheet links at the start of each nested layout's and page's
417
+ * output into `<head>` (#1669). The root hoist (`collectHoistedHeadTags`) only
418
+ * reads the leading run of the WHOLE body and stops at the first boundary
419
+ * marker, so a section layout's `<link rel="stylesheet">` stayed in `<body>`,
420
+ * where it is not reliably render-blocking (#406). This reads the leading run
421
+ * after EVERY `<!--wj:children:...-->` open marker instead.
422
+ *
423
+ * Only `rel="stylesheet"` links move, each tagged with `STYLE_OWNER_ATTR` set
424
+ * to the marker's segment. Comments and other `<link>` tags in the run stay
425
+ * where they are and do not end it; anything else does. A stylesheet whose
426
+ * href and media are already in `seen` (the head so far, or an earlier
427
+ * hoist) is dropped rather than hoisted twice, since one URL linked twice is
428
+ * two active sheets the browser matches every rule against. Every framework
429
+ * the WebJs router takes cues from puts a route's sheet in the head and
430
+ * dedupes by href. Attributes are read in order (`tagAttrs`), so a name
431
+ * inside another attribute's quoted value is never mistaken for one.
432
+ *
433
+ * @param {string} bodyHtml
434
+ * @param {Set<string>} [seen] Keys (`stylesheetKeys`) already linked in the head (mutated).
435
+ * @param {string} [base] The page's URL, which hrefs resolve against for the key.
436
+ * @returns {{ tags: string[], body: string }}
437
+ */
438
+ export function hoistOwnedStylesheets(bodyHtml, seen = new Set(), base) {
439
+ if (!bodyHtml.includes(BOUNDARY_OPEN)) return { tags: [], body: bodyHtml };
440
+ /** @type {string[]} */
441
+ const tags = [];
442
+ let out = '';
443
+ let pos = 0;
444
+ for (;;) {
445
+ const open = bodyHtml.indexOf(BOUNDARY_OPEN, pos);
446
+ if (open < 0) break;
447
+ const close = bodyHtml.indexOf('-->', open);
448
+ if (close < 0) break;
449
+ const markerEnd = close + 3;
450
+ out += bodyHtml.slice(pos, markerEnd);
451
+ // The route-key is everything after the LAST ':' (the router's own split,
452
+ // boundaries.js), so the segment is everything before it.
453
+ const rest = bodyHtml.slice(open + BOUNDARY_OPEN.length, close);
454
+ const cut = rest.lastIndexOf(':');
455
+ const segment = cut > 0 ? rest.slice(0, cut) : null;
456
+ pos = markerEnd;
457
+ if (!segment) continue;
458
+ let m;
459
+ for (;;) {
460
+ OWNED_RUN_TOKEN_RE.lastIndex = pos;
461
+ m = OWNED_RUN_TOKEN_RE.exec(bodyHtml);
462
+ if (!m) break;
463
+ const token = m[1];
464
+ const lead = m[0].slice(0, m[0].length - token.length);
465
+ pos += m[0].length;
466
+ const key = token.startsWith('<!--') ? null : stylesheetTagKey(token, base);
467
+ if (!key) {
468
+ out += lead + token;
469
+ continue;
470
+ }
471
+ out += lead;
472
+ if (seen.has(key)) continue;
473
+ seen.add(key);
474
+ const owner = ` ${STYLE_OWNER_ATTR}="${escapeAttr(segment)}">`;
475
+ tags.push(token.replace(/\s*\/?>$/, () => owner));
476
+ }
477
+ }
478
+ return { tags, body: out + bodyHtml.slice(pos) };
479
+ }
480
+
333
481
  /**
334
482
  * The initial-load Suspense resolver: swaps each streamed
335
483
  * `<template data-webjs-resolve>` into its placeholder.
package/src/ssr/render.js CHANGED
@@ -1153,6 +1153,8 @@ export async function ssrPage(route, params, url, opts) {
1153
1153
  vendorPreloads,
1154
1154
  lazyComponents,
1155
1155
  nonce,
1156
+ // The page's own URL, which hoisted stylesheet hrefs are keyed against.
1157
+ pageUrl: url.href,
1156
1158
  };
1157
1159
  // buildDocumentParts picks up a user-supplied <!doctype><html>…</html>
1158
1160
  // shell from the body when present; otherwise auto-emits the framework
@@ -102,6 +102,36 @@ export function assembleBootModules(files, deps) {
102
102
  return { moduleUrls, frontierLazy };
103
103
  }
104
104
 
105
+ /**
106
+ * The 103 Early Hints `Link` values for the boot's modules: a plain
107
+ * `rel=preload; as=script; crossorigin=anonymous` per url, NEVER
108
+ * `rel=modulepreload`.
109
+ *
110
+ * A module hint in a 103 arrives before the document, so it is processed
111
+ * before the page's `<script type="importmap">` can be parsed. Firefox treats
112
+ * a modulepreload as the start of module loading and then refuses the import
113
+ * map ("Import maps are not allowed after a module load or preload has
114
+ * started"), so every bare `@webjsdev/core` import failed and no script ran on
115
+ * a Node-served page. The HTML spec gives modulepreload no early-hint
116
+ * behaviour at all (its "process a link header" steps do nothing), and
117
+ * Chromium acts only on preload and preconnect there, so the modulepreload
118
+ * hint was a no-op in Chromium and fatal in Firefox.
119
+ *
120
+ * A plain preload only puts the bytes in the preload cache: it creates no
121
+ * module script, resolves no specifier and fetches no descendants, so it
122
+ * cannot run ahead of the import map on any engine. `as=script` with
123
+ * `crossorigin=anonymous` is a CORS fetch with same-origin credentials,
124
+ * which is exactly the request a module script makes, so the body's
125
+ * `<link rel="modulepreload">` and the boot's import reuse the response
126
+ * instead of fetching it again.
127
+ *
128
+ * @param {string[]} urls the hinted boot module urls (`hintedBootModules`)
129
+ * @returns {string[]}
130
+ */
131
+ export function earlyHintLinks(urls) {
132
+ return urls.map((u) => `<${u}>; rel=preload; as=script; crossorigin=anonymous`);
133
+ }
134
+
105
135
  /**
106
136
  * The 103 Early Hints url list for `route`: the SAME modules the boot
107
137
  * imports, through `withBasePath` then `withAssetHash`, so the hint warms
package/src/testing.d.ts CHANGED
@@ -51,7 +51,7 @@ export declare function withSessionCookie(init: RequestInit | undefined, session
51
51
  export declare function submitForm(
52
52
  handle: Handle,
53
53
  path: string,
54
- fields?: Record<string, string> | URLSearchParams | FormData,
54
+ fields?: Record<string, string | string[]> | URLSearchParams | FormData,
55
55
  opts?: {
56
56
  index?: number;
57
57
  match?: string | RegExp;
package/src/testing.js CHANGED
@@ -221,7 +221,7 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
221
221
  *
222
222
  * @param {Handle} handle
223
223
  * @param {string} path the page to render AND, by default, to submit back to
224
- * @param {Record<string, string> | URLSearchParams | FormData} [fields]
224
+ * @param {Record<string, string | string[]> | URLSearchParams | FormData} [fields]
225
225
  * @param {{
226
226
  * index?: number,
227
227
  * match?: string | RegExp,
@@ -299,7 +299,12 @@ export async function submitForm(handle, path, fields = {}, opts = {}) {
299
299
  const entries = fields instanceof URLSearchParams || fields instanceof FormData
300
300
  ? fields.entries()
301
301
  : Object.entries(fields);
302
- for (const [k, v] of entries) body.append(k, String(v));
302
+ // An array value posts once per element, the way a checkbox group submits,
303
+ // so a test can exercise the `name[]` convention (#1664).
304
+ for (const [k, v] of entries) {
305
+ if (Array.isArray(v)) for (const item of v) body.append(k, String(item));
306
+ else body.append(k, String(v));
307
+ }
303
308
 
304
309
  const init = {
305
310
  method: 'POST',
@@ -175,7 +175,7 @@
175
175
  "items": { "type": "string" }
176
176
  },
177
177
  "regenerate": {
178
- "description": "On-request build-output regeneration (#967), DEV-ONLY. Each rule rebuilds a stale `output` before the server serves it, instead of relying on a live watcher that can die and serve stale output. The scaffold uses this to keep the static public/tailwind.css fresh (#947) with no `tailwindcss --watch`. Read by the server (readRegenerateRules in dev-regenerate.js).",
178
+ "description": "On-request build-output regeneration (#967). In dev, each rule rebuilds an `output` that does not match its build record (.webjs/regenerate.json, #1672) before the server serves it, and `webjs regenerate` runs the same rules from dev.before / start.before / CI, instead of relying on a live watcher that can die and serve stale output. The scaffold uses this to keep the static public/tailwind.css fresh (#947) with no `tailwindcss --watch`. Read by the server (readRegenerateRules in dev-regenerate.js).",
179
179
  "type": "array",
180
180
  "items": {
181
181
  "type": "object",
@@ -191,7 +191,7 @@
191
191
  "type": "string"
192
192
  },
193
193
  "inputs": {
194
- "description": "Source paths (files or directories) whose newest mtime is compared to `output`'s. If any is newer (or the output is missing), the command runs. For Tailwind: the @source dirs plus public/input.css.",
194
+ "description": "Source paths (files or directories) the output is built from. Every build records a fingerprint of the files under them (path, size, mtime) in .webjs/regenerate.json, and the command runs again when that fingerprint, the command, or the output no longer matches the record, or the output is missing (#1672). For Tailwind: the @source dirs plus public/input.css.",
195
195
  "type": "array",
196
196
  "items": { "type": "string" }
197
197
  }