what-compiler 0.12.2 → 0.12.4

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.
@@ -176,36 +176,269 @@ function routeWeight(path) {
176
176
  return 0; // Static first
177
177
  }
178
178
 
179
+ /** Locates `export const page = {`. Non-global so .exec carries no lastIndex. */
180
+ const PAGE_EXPORT_RE = /export\s+const\s+page\s*=\s*\{/;
181
+
179
182
  /**
180
183
  * Extract `export const page = { ... }` from a file's source code.
181
- * Uses simple regex — doesn't need a full parser for this.
184
+ *
185
+ * This used to be four chained regexes over the matched text (swap ' for ",
186
+ * quote bare keys, drop trailing commas, strip comments). Every one of them was
187
+ * blind to string boundaries, so any config whose VALUES contained a
188
+ * parser-significant character was corrupted into invalid JSON and thrown away:
189
+ *
190
+ * vary: ['cookie:theme'] → the key regex rewrote it to ["cookie":theme"]
191
+ * canonical: 'https://x.com/a' → the comment strip ate the rest of the line
192
+ * title: "What's new" → the quote swap produced "What"s new"
193
+ * meta: { title: 'Docs' } → /\{[^}]*\}/ stopped at the inner brace
194
+ * revalidate: 60, // seconds → commas were collapsed BEFORE comments were
195
+ * stripped, so ",\s*}" never matched
196
+ *
197
+ * So we tokenize instead. A single left-to-right scan knows when it is inside a
198
+ * string, so keys are only quoted outside strings, comments are removed before
199
+ * anything else looks at the text, and brace matching finds the real end of the
200
+ * literal. It is still not a JS parser: only JSON-shaped values are supported
201
+ * (strings, numbers, booleans, null, arrays, nested objects). Anything else is
202
+ * a parse failure, which is now REPORTED rather than swallowed.
203
+ *
204
+ * @param {string} source Page module source.
205
+ * @param {{ filePath?: string }} [options] filePath is used in the warning.
206
+ * @returns {{ mode: string, [key: string]: unknown }}
182
207
  */
183
- export function extractPageConfig(source) {
184
- // Match: export const page = { ... }
185
- // Handles single-line and simple multi-line objects
186
- const match = source.match(
187
- /export\s+const\s+page\s*=\s*(\{[^}]*\})/s
188
- );
208
+ export function extractPageConfig(source, options = {}) {
209
+ const match = PAGE_EXPORT_RE.exec(source);
189
210
 
211
+ // No `export const page = { ... }` at all. This is the common case (most pages
212
+ // declare nothing), so it stays silent.
190
213
  if (!match) {
191
214
  return { mode: 'client' }; // Default
192
215
  }
193
216
 
217
+ // Index of the `{` that opens the literal (last char of the matched prefix).
218
+ const open = match.index + match[0].length - 1;
219
+
194
220
  try {
195
- // Simple evaluation of the object literal
196
- // Only supports string/boolean/number literals for safety
197
- const obj = match[1]
198
- .replace(/'/g, '"')
199
- .replace(/(\w+)\s*:/g, '"$1":')
200
- .replace(/,\s*}/g, '}')
201
- .replace(/\/\/[^\n]*/g, ''); // Strip comments
202
-
203
- return { mode: 'client', ...JSON.parse(obj) };
204
- } catch {
221
+ return { mode: 'client', ...JSON.parse(objectLiteralToJson(source, open)) };
222
+ } catch (err) {
223
+ // The config was PRESENT and we could not read it. Falling back to
224
+ // { mode: 'client' } silently is the difference between "this page declared
225
+ // nothing" and "this page's declaration was thrown away", and the second one
226
+ // silently drops the route out of static generation. Say so.
227
+ warnUnparseablePageConfig(err, options.filePath);
205
228
  return { mode: 'client' };
206
229
  }
207
230
  }
208
231
 
232
+ /**
233
+ * Warn that a declared page config was discarded. Build-time diagnostic: it must
234
+ * name the file, because the failure is otherwise invisible until someone
235
+ * notices a route was never pre-rendered.
236
+ */
237
+ function warnUnparseablePageConfig(err, filePath) {
238
+ const where = filePath ? ` in ${filePath}` : '';
239
+ console.warn(
240
+ `[what] Ignoring unparseable \`export const page\` config${where}: ${err.message}\n` +
241
+ ` This page falls back to mode 'client', so static generation will skip it.\n` +
242
+ ` \`page\` must be a plain object literal of JSON-shaped values ` +
243
+ `(strings, numbers, booleans, null, arrays, nested objects).`
244
+ );
245
+ }
246
+
247
+ const isIdentStart = (ch) => /[A-Za-z_$]/.test(ch);
248
+ const isIdentPart = (ch) => /[A-Za-z0-9_$]/.test(ch);
249
+
250
+ /**
251
+ * Convert the JS object literal starting at source[start] (which must be `{`)
252
+ * into JSON text. Single pass, so it always knows whether it is inside a string.
253
+ *
254
+ * Throws a SyntaxError when the literal is unterminated or contains something
255
+ * that cannot be resolved statically (a template interpolation, say). The caller
256
+ * turns that into a warning.
257
+ */
258
+ function objectLiteralToJson(source, start) {
259
+ const out = [];
260
+ // Index in `out` of the most recent comma emitted at "could still be trailing"
261
+ // position, or -1. Whitespace and comments do not clear it, real tokens do,
262
+ // which is what lets us drop `60, // seconds\n}` without a regex that cannot
263
+ // tell a comma inside a string from a structural one.
264
+ let lastComma = -1;
265
+ let depth = 0;
266
+ let i = start;
267
+
268
+ while (i < source.length) {
269
+ const ch = source[i];
270
+
271
+ // --- comments: dropped before anything else can trip over them ---------
272
+ if (ch === '/' && source[i + 1] === '/') {
273
+ const nl = source.indexOf('\n', i);
274
+ i = nl === -1 ? source.length : nl; // leave the newline as whitespace
275
+ out.push(' ');
276
+ continue;
277
+ }
278
+ if (ch === '/' && source[i + 1] === '*') {
279
+ const close = source.indexOf('*/', i + 2);
280
+ if (close === -1) throw new SyntaxError('unterminated block comment');
281
+ i = close + 2;
282
+ out.push(' ');
283
+ continue;
284
+ }
285
+
286
+ // --- strings: decoded, then re-emitted as valid JSON strings -----------
287
+ if (ch === '"' || ch === "'" || ch === '`') {
288
+ const str = readStringLiteral(source, i);
289
+ out.push(JSON.stringify(str.value));
290
+ lastComma = -1;
291
+ i = str.end;
292
+ continue;
293
+ }
294
+
295
+ // --- identifiers: bare keys get quoted, true/false/null pass through ---
296
+ if (isIdentStart(ch)) {
297
+ let j = i + 1;
298
+ while (j < source.length && isIdentPart(source[j])) j++;
299
+ const word = source.slice(i, j);
300
+ out.push(isKeyPosition(source, j) ? JSON.stringify(word) : word);
301
+ lastComma = -1;
302
+ i = j;
303
+ continue;
304
+ }
305
+
306
+ // --- whitespace: copied, and deliberately does NOT clear lastComma -----
307
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
308
+ out.push(ch);
309
+ i++;
310
+ continue;
311
+ }
312
+
313
+ if (ch === ',') {
314
+ out.push(',');
315
+ lastComma = out.length - 1;
316
+ i++;
317
+ continue;
318
+ }
319
+
320
+ if (ch === '}' || ch === ']') {
321
+ if (lastComma !== -1) out[lastComma] = ''; // trailing comma, drop it
322
+ lastComma = -1;
323
+ out.push(ch);
324
+ i++;
325
+ if (ch === '}' && --depth === 0) return out.join('');
326
+ continue;
327
+ }
328
+
329
+ if (ch === '{') depth++;
330
+
331
+ // Everything else (numbers, ':', '[', '-', ...) is copied verbatim. Junk
332
+ // lands in the JSON text and JSON.parse reports it.
333
+ out.push(ch);
334
+ lastComma = -1;
335
+ i++;
336
+ }
337
+
338
+ throw new SyntaxError('unterminated object literal');
339
+ }
340
+
341
+ /**
342
+ * True when the token that ended at index `from` is followed by `:` (skipping
343
+ * whitespace and comments), i.e. it was an object key and needs quoting. Values
344
+ * like true / false / null are never followed by a colon, so they pass through.
345
+ */
346
+ function isKeyPosition(source, from) {
347
+ let i = from;
348
+ while (i < source.length) {
349
+ const ch = source[i];
350
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
351
+ if (ch === '/' && source[i + 1] === '/') {
352
+ const nl = source.indexOf('\n', i);
353
+ if (nl === -1) return false;
354
+ i = nl + 1;
355
+ continue;
356
+ }
357
+ if (ch === '/' && source[i + 1] === '*') {
358
+ const close = source.indexOf('*/', i + 2);
359
+ if (close === -1) return false;
360
+ i = close + 2;
361
+ continue;
362
+ }
363
+ return ch === ':';
364
+ }
365
+ return false;
366
+ }
367
+
368
+ /** JS escape sequences that map to a single character. */
369
+ const STRING_ESCAPES = {
370
+ n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0',
371
+ '\\': '\\', "'": "'", '"': '"', '`': '`', '/': '/',
372
+ };
373
+
374
+ /**
375
+ * Read the JS string literal that starts at source[start] (a quote character)
376
+ * and return its DECODED value plus the index just past the closing quote.
377
+ *
378
+ * Decoding rather than text-swapping is the whole point: `"What's new"` keeps its
379
+ * apostrophe and `'a } b'` keeps its brace, because the caller re-encodes the
380
+ * value with JSON.stringify instead of trusting the original bytes.
381
+ */
382
+ function readStringLiteral(source, start) {
383
+ const quote = source[start];
384
+ let value = '';
385
+ let i = start + 1;
386
+
387
+ while (i < source.length) {
388
+ const ch = source[i];
389
+
390
+ if (ch === '\\') {
391
+ const next = source[i + 1];
392
+ if (next === undefined) break; // unterminated, handled below
393
+
394
+ // Line continuation: backslash + newline contributes nothing.
395
+ if (next === '\n') { i += 2; continue; }
396
+ if (next === '\r') { i += source[i + 2] === '\n' ? 3 : 2; continue; }
397
+
398
+ if (next === 'u') {
399
+ if (source[i + 2] === '{') {
400
+ const close = source.indexOf('}', i + 3);
401
+ if (close === -1) throw new SyntaxError('invalid unicode escape');
402
+ value += String.fromCodePoint(parseInt(source.slice(i + 3, close), 16));
403
+ i = close + 1;
404
+ continue;
405
+ }
406
+ value += String.fromCharCode(parseInt(source.slice(i + 2, i + 6), 16));
407
+ i += 6;
408
+ continue;
409
+ }
410
+ if (next === 'x') {
411
+ value += String.fromCharCode(parseInt(source.slice(i + 2, i + 4), 16));
412
+ i += 4;
413
+ continue;
414
+ }
415
+
416
+ // Known escape, or an unknown one (JS drops the backslash: \q is "q").
417
+ value += Object.prototype.hasOwnProperty.call(STRING_ESCAPES, next)
418
+ ? STRING_ESCAPES[next]
419
+ : next;
420
+ i += 2;
421
+ continue;
422
+ }
423
+
424
+ if (ch === quote) return { value, end: i + 1 };
425
+
426
+ // A template with an interpolation is not a static value, so refuse it
427
+ // loudly instead of guessing.
428
+ if (quote === '`' && ch === '$' && source[i + 1] === '{') {
429
+ throw new SyntaxError('template literal interpolation cannot be resolved at build time');
430
+ }
431
+ // Quoted strings cannot span lines; treat it as unterminated rather than
432
+ // running off into the rest of the module.
433
+ if (quote !== '`' && (ch === '\n' || ch === '\r')) break;
434
+
435
+ value += ch;
436
+ i++;
437
+ }
438
+
439
+ throw new SyntaxError('unterminated string literal');
440
+ }
441
+
209
442
  /**
210
443
  * Detect which named exports a page module declares. Functions (loader,
211
444
  * getStaticPaths) cannot live in `export const page` (JSON-only), so the codegen
@@ -250,7 +483,7 @@ export function generateRoutesModule(pagesDir, rootDir) {
250
483
  let detected = { hasLoader: false, hasGetStaticPaths: false, hasPageConfig: false };
251
484
  try {
252
485
  const source = fs.readFileSync(page.filePath, 'utf-8');
253
- pageConfig = extractPageConfig(source);
486
+ pageConfig = extractPageConfig(source, { filePath: page.filePath });
254
487
  detected = detectPageExports(source);
255
488
  } catch {}
256
489
 
@@ -338,7 +571,7 @@ export function generateServerRoutesModule(pagesDir, rootDir) {
338
571
  let detected = { hasLoader: false, hasGetStaticPaths: false, hasPageConfig: false };
339
572
  try {
340
573
  const source = fs.readFileSync(page.filePath, 'utf-8');
341
- pageConfig = extractPageConfig(source);
574
+ pageConfig = extractPageConfig(source, { filePath: page.filePath });
342
575
  detected = detectPageExports(source);
343
576
  } catch {}
344
577