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.
- package/dist/babel-plugin.min.js +1 -1
- package/dist/file-router.min.js +13 -3
- package/dist/index.min.js +16 -6
- package/dist/vite-plugin.min.js +16 -6
- package/package.json +2 -2
- package/src/babel-plugin.js +495 -19
- package/src/file-router.js +252 -19
package/src/file-router.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|