@pracht/vite-plugin 0.7.6 → 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.
@@ -0,0 +1,588 @@
1
+ import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, extname, join, relative } from "node:path";
3
+ import { maskCommentsAndStrings } from "@pracht/capabilities/static";
4
+ import { parse } from "@babel/parser";
5
+ import { initSync, parse as parse$1 } from "es-module-lexer";
6
+ //#region src/route-extensions.ts
7
+ const BUILT_IN_ROUTE_EXTENSIONS = [
8
+ ".ts",
9
+ ".tsx",
10
+ ".js",
11
+ ".jsx",
12
+ ".md",
13
+ ".mdx"
14
+ ];
15
+ const LEGACY_BARE_ROUTE_EXTENSIONS = [".tsrx"];
16
+ const DEFAULT_ROUTE_EXTENSIONS = [...BUILT_IN_ROUTE_EXTENSIONS, ...LEGACY_BARE_ROUTE_EXTENSIONS];
17
+ const DEFAULT_SHELL_EXTENSIONS = [
18
+ ".ts",
19
+ ".tsx",
20
+ ".js",
21
+ ".jsx",
22
+ ...LEGACY_BARE_ROUTE_EXTENSIONS
23
+ ];
24
+ const EXTENSION_RE = /^\.[a-z0-9][a-z0-9_-]*$/i;
25
+ function normalizeAdditionalExtensions(extensions) {
26
+ if (extensions === void 0) return [];
27
+ if (!Array.isArray(extensions)) throw new Error("pracht({ additionalExtensions }) expects an array of dot-prefixed extensions.");
28
+ const normalized = extensions.map((extension) => {
29
+ if (typeof extension !== "string" || !EXTENSION_RE.test(extension)) throw new Error(`pracht({ additionalExtensions }) expects dot-prefixed extensions such as ".vue", got ${JSON.stringify(extension)}.`);
30
+ return extension.toLowerCase();
31
+ });
32
+ const defaults = new Set(BUILT_IN_ROUTE_EXTENSIONS);
33
+ return [...new Set(normalized)].filter((extension) => !defaults.has(extension));
34
+ }
35
+ function extensionGlob(extensions) {
36
+ const names = extensions.map((extension) => extension.slice(1));
37
+ return names.length === 1 ? names[0] : `{${names.join(",")}}`;
38
+ }
39
+ function withAdditionalExtensions(defaults, additionalExtensions) {
40
+ return new Set([...defaults, ...additionalExtensions]);
41
+ }
42
+ //#endregion
43
+ //#region src/route-loader-hints.ts
44
+ initSync();
45
+ function namedDeclarationRe(exportName) {
46
+ return new RegExp(`export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+${exportName}\\b`);
47
+ }
48
+ const HEAD_DECLARATION_RE = namedDeclarationRe("head");
49
+ const STATIC_PATHS_DECLARATION_RE = namedDeclarationRe("getStaticPaths");
50
+ const EXPORT_BLOCK_RE = /export\s*\{([^}]*)\}\s*(?:from\s*["'][^"']+["'])?/g;
51
+ const EXPORT_ALL_RE = /export\s+\*\s+from\b/;
52
+ const EXPORT_VARIABLE_DECLARATION_RE = /export\s+(?:const|let|var)\b/g;
53
+ function isExportAllStatement(source) {
54
+ const withoutComments = source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, " ");
55
+ return /^\s*export\s*\*/.test(withoutComments);
56
+ }
57
+ function exportedVariableDeclarationIncludesLoader(source) {
58
+ for (const declaration of source.matchAll(/\bexport\s+(?:const|let|var)\b/g)) {
59
+ let index = (declaration.index ?? 0) + declaration[0].length;
60
+ while (index < source.length) {
61
+ while (/\s/.test(source[index] ?? "")) index += 1;
62
+ const bindingStart = index;
63
+ const opening = source[index];
64
+ if (opening === "{" || opening === "[") {
65
+ const closing = opening === "{" ? "}" : "]";
66
+ let depth = 0;
67
+ do {
68
+ const char = source[index++];
69
+ if (char === opening) depth += 1;
70
+ if (char === closing) depth -= 1;
71
+ } while (index < source.length && depth > 0);
72
+ if (/\bloader\b/.test(source.slice(bindingStart, index))) return true;
73
+ } else {
74
+ const binding = /^[A-Za-z_$][\w$]*/.exec(source.slice(index));
75
+ if (!binding) break;
76
+ if (binding[0] === "loader") return true;
77
+ index += binding[0].length;
78
+ }
79
+ let parentheses = 0;
80
+ let brackets = 0;
81
+ let braces = 0;
82
+ for (; index < source.length; index += 1) {
83
+ const char = source[index];
84
+ if (char === "(") parentheses += 1;
85
+ else if (char === ")") parentheses = Math.max(0, parentheses - 1);
86
+ else if (char === "[") brackets += 1;
87
+ else if (char === "]") brackets = Math.max(0, brackets - 1);
88
+ else if (char === "{") braces += 1;
89
+ else if (char === "}") braces = Math.max(0, braces - 1);
90
+ if (parentheses === 0 && brackets === 0 && braces === 0) {
91
+ if (char === ";") break;
92
+ if (char === ",") {
93
+ index += 1;
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ if (source[index] === ";" || index >= source.length) break;
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+ function detectLoaderExportFallback(source) {
104
+ const masked = maskCommentsAndStrings(source);
105
+ if (/\bexport\s+(?:async\s+)?function\s+loader\b/.test(masked)) return true;
106
+ if (exportedVariableDeclarationIncludesLoader(masked)) return true;
107
+ if (/\bexport\s*\*/.test(masked)) return true;
108
+ for (const match of masked.matchAll(/\bexport\s*\{([^}]*)\}/g)) if (match[1].split(",").map((specifier) => specifier.trim()).filter(Boolean).some((specifier) => {
109
+ const names = /^(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/.exec(specifier);
110
+ if (!names || specifier.startsWith("type ")) return false;
111
+ return (names[2] ?? names[1]) === "loader";
112
+ })) return true;
113
+ return false;
114
+ }
115
+ function topLevelAssignmentIndex(source) {
116
+ let parentheses = 0;
117
+ let brackets = 0;
118
+ let braces = 0;
119
+ for (let index = 0; index < source.length; index += 1) {
120
+ const char = source[index];
121
+ if (char === "(") parentheses += 1;
122
+ else if (char === ")") parentheses = Math.max(0, parentheses - 1);
123
+ else if (char === "[") brackets += 1;
124
+ else if (char === "]") brackets = Math.max(0, brackets - 1);
125
+ else if (char === "{") braces += 1;
126
+ else if (char === "}") braces = Math.max(0, braces - 1);
127
+ else if (char === "=" && parentheses === 0 && brackets === 0 && braces === 0) return index;
128
+ }
129
+ return -1;
130
+ }
131
+ function bindingExportsName(source, exportName) {
132
+ const assignmentIndex = topLevelAssignmentIndex(source);
133
+ const binding = assignmentIndex === -1 ? source : source.slice(0, assignmentIndex);
134
+ return new RegExp(`\\b${exportName}\\b`).test(binding);
135
+ }
136
+ function variableDeclarationExports(source, exportName) {
137
+ for (const match of source.matchAll(EXPORT_VARIABLE_DECLARATION_RE)) {
138
+ let declarationStart = (match.index ?? 0) + match[0].length;
139
+ let parentheses = 0;
140
+ let brackets = 0;
141
+ let braces = 0;
142
+ for (let index = declarationStart; index <= source.length; index += 1) {
143
+ const char = source[index];
144
+ if (char === "(") parentheses += 1;
145
+ else if (char === ")") parentheses = Math.max(0, parentheses - 1);
146
+ else if (char === "[") brackets += 1;
147
+ else if (char === "]") brackets = Math.max(0, brackets - 1);
148
+ else if (char === "{") braces += 1;
149
+ else if (char === "}") braces = Math.max(0, braces - 1);
150
+ if (parentheses === 0 && brackets === 0 && braces === 0 && (char === "," || char === ";" || char === void 0)) {
151
+ if (bindingExportsName(source.slice(declarationStart, index), exportName)) return true;
152
+ if (char !== ",") break;
153
+ declarationStart = index + 1;
154
+ }
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+ function exportSpecifiersInclude(specifiers, exportName) {
160
+ return specifiers.split(",").map((specifier) => specifier.trim()).filter(Boolean).some((specifier) => {
161
+ const match = /^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/.exec(specifier);
162
+ if (!match) return false;
163
+ const [, localName, exportedName] = match;
164
+ return (exportedName ?? localName) === exportName;
165
+ });
166
+ }
167
+ /**
168
+ * Whether `source` exports `exportName`, via a declaration, an export block,
169
+ * or an `export *` re-export (which could expose anything, so it counts).
170
+ *
171
+ * Comments and strings are masked first, so prose or a string literal
172
+ * mentioning the name cannot produce a false positive.
173
+ */
174
+ function detectNamedExport(source, exportName, declarationRe) {
175
+ const analysisSource = maskCommentsAndStrings(source);
176
+ if (declarationRe.test(analysisSource) || variableDeclarationExports(analysisSource, exportName)) return true;
177
+ for (const match of analysisSource.matchAll(EXPORT_BLOCK_RE)) if (exportSpecifiersInclude(match[1], exportName)) return true;
178
+ return EXPORT_ALL_RE.test(analysisSource);
179
+ }
180
+ function detectHeadExport(source) {
181
+ return detectNamedExport(source, "head", HEAD_DECLARATION_RE);
182
+ }
183
+ /**
184
+ * Whether the route module exports `getStaticPaths()`.
185
+ *
186
+ * Only a static export consumes this: it decides whether a dynamic route has
187
+ * any prerendered path at all, and therefore whether the client should ever
188
+ * request a route-state file for it. Unknown answers must stay `true` — the
189
+ * cost of a wrong `true` is the request the client already makes today, while
190
+ * a wrong `false` would drop state the build did write.
191
+ */
192
+ function detectStaticPathsExport(source) {
193
+ return detectNamedExport(source, "getStaticPaths", STATIC_PATHS_DECLARATION_RE);
194
+ }
195
+ function isSyntaxNode(value) {
196
+ return typeof value === "object" && value !== null && typeof value.type === "string";
197
+ }
198
+ function bindingIncludesLoader(node) {
199
+ if (!isSyntaxNode(node)) return false;
200
+ if (node.type === "Identifier") return node.name === "loader";
201
+ if (node.type === "AssignmentPattern") return bindingIncludesLoader(node.left);
202
+ if (node.type === "RestElement") return bindingIncludesLoader(node.argument);
203
+ if (node.type === "ArrayPattern") return Array.isArray(node.elements) && node.elements.some(bindingIncludesLoader);
204
+ if (node.type === "ObjectPattern") return Array.isArray(node.properties) && node.properties.some((property) => {
205
+ if (!isSyntaxNode(property)) return false;
206
+ return property.type === "RestElement" ? bindingIncludesLoader(property.argument) : bindingIncludesLoader(property.value);
207
+ });
208
+ return false;
209
+ }
210
+ function exportedNameIsLoader(node) {
211
+ if (!isSyntaxNode(node)) return false;
212
+ if (node.type === "Identifier") return node.name === "loader";
213
+ if (node.type === "StringLiteral") return node.value === "loader";
214
+ return false;
215
+ }
216
+ function inspectParsedModule(source) {
217
+ for (const plugins of [["typescript", "jsx"], ["typescript"]]) {
218
+ let body;
219
+ try {
220
+ body = parse(source, {
221
+ plugins: [...plugins],
222
+ sourceType: "module"
223
+ }).program.body;
224
+ } catch {
225
+ continue;
226
+ }
227
+ for (const statement of body) {
228
+ if (statement.type === "ExportAllDeclaration") {
229
+ if (statement.exportKind !== "type") return true;
230
+ continue;
231
+ }
232
+ if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type") continue;
233
+ if (Array.isArray(statement.specifiers) && statement.specifiers.some((specifier) => isSyntaxNode(specifier) && specifier.exportKind !== "type" && exportedNameIsLoader(specifier.exported))) return true;
234
+ const declaration = statement.declaration;
235
+ if (!isSyntaxNode(declaration)) continue;
236
+ if (declaration.declare === true || declaration.type.startsWith("TS")) continue;
237
+ if (declaration.type === "VariableDeclaration") {
238
+ if (Array.isArray(declaration.declarations) && declaration.declarations.some((declarator) => isSyntaxNode(declarator) && bindingIncludesLoader(declarator.id))) return true;
239
+ } else if (bindingIncludesLoader(declaration.id)) return true;
240
+ }
241
+ return false;
242
+ }
243
+ }
244
+ function detectLoaderExport(source) {
245
+ const parsedResult = inspectParsedModule(source);
246
+ if (parsedResult !== void 0) return parsedResult;
247
+ try {
248
+ const [imports, exports] = parse$1(source);
249
+ if (exports.some((entry) => entry.n === "loader")) return true;
250
+ for (const entry of imports) if (entry.d === -1 && isExportAllStatement(source.slice(entry.ss, entry.se))) return true;
251
+ } catch {}
252
+ return detectLoaderExportFallback(source);
253
+ }
254
+ function scanRouteFiles(dir, files, extensions) {
255
+ let entries;
256
+ try {
257
+ entries = readdirSync(dir);
258
+ } catch {
259
+ return;
260
+ }
261
+ for (const entry of entries) {
262
+ const abs = join(dir, entry);
263
+ if (statSync(abs).isDirectory()) {
264
+ scanRouteFiles(abs, files, extensions);
265
+ continue;
266
+ }
267
+ if (extensions.has(extname(entry))) files.push(abs);
268
+ }
269
+ }
270
+ function toPosixPath(path) {
271
+ return path.replace(/\\/g, "/");
272
+ }
273
+ function createRouteLoaderHints(routesDir, options = {}) {
274
+ const files = [];
275
+ const hints = {};
276
+ scanRouteFiles(routesDir, files, withAdditionalExtensions(DEFAULT_ROUTE_EXTENSIONS, normalizeAdditionalExtensions(options.additionalExtensions)));
277
+ for (const file of files) {
278
+ const hasLoader = detectLoaderExport(readFileSync(file, "utf-8"));
279
+ const relativeToRoutesDir = toPosixPath(relative(routesDir, file));
280
+ const routeRootPrefix = options.rootRelativePrefix?.replace(/\/$/, "");
281
+ const appFileDir = options.appFileDir;
282
+ const keys = /* @__PURE__ */ new Set();
283
+ if (appFileDir) {
284
+ const relativeToAppFile = toPosixPath(relative(appFileDir, file));
285
+ keys.add(relativeToAppFile.startsWith(".") ? relativeToAppFile : `./${relativeToAppFile}`);
286
+ }
287
+ if (routeRootPrefix) keys.add(`${routeRootPrefix}/${relativeToRoutesDir}`);
288
+ for (const key of keys) hints[key] = hasLoader;
289
+ }
290
+ return hints;
291
+ }
292
+ function createRouteHeadHints(routesDir, options = {}) {
293
+ const files = [];
294
+ const hints = {};
295
+ const additionalExtensions = normalizeAdditionalExtensions(options.additionalExtensions);
296
+ scanRouteFiles(routesDir, files, withAdditionalExtensions(DEFAULT_ROUTE_EXTENSIONS, additionalExtensions));
297
+ for (const file of files) {
298
+ const extension = extname(file);
299
+ const hasHead = extension === ".md" || extension === ".mdx" || additionalExtensions.includes(extension) || detectHeadExport(readFileSync(file, "utf-8"));
300
+ const relativeToRoutesDir = toPosixPath(relative(routesDir, file));
301
+ const routeRootPrefix = options.rootRelativePrefix?.replace(/\/$/, "");
302
+ const keys = /* @__PURE__ */ new Set();
303
+ if (options.appFileDir) {
304
+ const relativeToAppFile = toPosixPath(relative(options.appFileDir, file));
305
+ keys.add(relativeToAppFile.startsWith(".") ? relativeToAppFile : `./${relativeToAppFile}`);
306
+ }
307
+ if (routeRootPrefix) keys.add(`${routeRootPrefix}/${relativeToRoutesDir}`);
308
+ for (const key of keys) hints[key] = hasHead;
309
+ }
310
+ return hints;
311
+ }
312
+ /**
313
+ * Per-route-file `getStaticPaths()` presence, keyed the same way as the loader
314
+ * and head hints.
315
+ *
316
+ * Formats compiled by a companion Vite plugin are reported as `true`: raw
317
+ * source scanning cannot prove such a module has no `getStaticPaths`, and the
318
+ * conservative answer keeps today's behavior.
319
+ */
320
+ function createRouteStaticPathsHints(routesDir, options = {}) {
321
+ const files = [];
322
+ const hints = {};
323
+ const additionalExtensions = normalizeAdditionalExtensions(options.additionalExtensions);
324
+ scanRouteFiles(routesDir, files, withAdditionalExtensions(DEFAULT_ROUTE_EXTENSIONS, additionalExtensions));
325
+ for (const file of files) {
326
+ const extension = extname(file);
327
+ const hasStaticPaths = additionalExtensions.includes(extension) || detectStaticPathsExport(readFileSync(file, "utf-8"));
328
+ const relativeToRoutesDir = toPosixPath(relative(routesDir, file));
329
+ const routeRootPrefix = options.rootRelativePrefix?.replace(/\/$/, "");
330
+ const keys = /* @__PURE__ */ new Set();
331
+ if (options.appFileDir) {
332
+ const relativeToAppFile = toPosixPath(relative(options.appFileDir, file));
333
+ keys.add(relativeToAppFile.startsWith(".") ? relativeToAppFile : `./${relativeToAppFile}`);
334
+ }
335
+ if (routeRootPrefix) keys.add(`${routeRootPrefix}/${relativeToRoutesDir}`);
336
+ for (const key of keys) hints[key] = hasStaticPaths;
337
+ }
338
+ return hints;
339
+ }
340
+ //#endregion
341
+ //#region src/pages-router.ts
342
+ function scanPagesDirectory(pagesDir, additionalExtensions = []) {
343
+ const normalizedExtensions = normalizeAdditionalExtensions(additionalExtensions);
344
+ const pageExtensions = withAdditionalExtensions(DEFAULT_ROUTE_EXTENSIONS, normalizedExtensions);
345
+ const shellExtensions = withAdditionalExtensions(DEFAULT_SHELL_EXTENSIONS, normalizedExtensions);
346
+ const pages = [];
347
+ scan(pagesDir, pagesDir, pages, pageExtensions, shellExtensions, new Set(normalizedExtensions));
348
+ const appShell = pages.find((page) => page.routePath === "__shell__");
349
+ if (appShell?.hasRevalidateExport) throw new Error(`[pracht] Pages app shell ${JSON.stringify(appShell.relativePath)} exports REVALIDATE, but app shells are not ISG routes. Declare the policy on each ISG page instead.`);
350
+ return sortRoutes(pages);
351
+ }
352
+ function scan(dir, root, pages, pageExtensions, shellExtensions, additionalExtensions) {
353
+ let entries;
354
+ try {
355
+ entries = readdirSync(dir);
356
+ } catch {
357
+ return;
358
+ }
359
+ for (const entry of entries) {
360
+ const abs = join(dir, entry);
361
+ if (statSync(abs).isDirectory()) {
362
+ scan(abs, root, pages, pageExtensions, shellExtensions, additionalExtensions);
363
+ continue;
364
+ }
365
+ const ext = extname(entry);
366
+ if (!pageExtensions.has(ext)) continue;
367
+ const name = basename(entry, ext);
368
+ if (name === "_app" && !shellExtensions.has(ext)) continue;
369
+ if (name.startsWith("_") && name !== "_app") continue;
370
+ const rel = relative(root, abs);
371
+ const routePath = filePathToRoutePath(rel);
372
+ const analysisSource = maskMarkdownFences(readFileSync(abs, "utf-8"), rel);
373
+ const renderMode = extractQuotedPageExport(analysisSource, "RENDER_MODE", rel);
374
+ const hydrationMode = extractQuotedPageExport(analysisSource, "HYDRATION", rel);
375
+ const revalidate = extractRevalidateSeconds(analysisSource, rel);
376
+ const hasLoader = detectLoaderExport(analysisSource);
377
+ const hasHead = ext === ".md" || ext === ".mdx" || additionalExtensions.has(ext) || detectHeadExport(analysisSource);
378
+ pages.push({
379
+ absolutePath: abs,
380
+ relativePath: rel,
381
+ routePath,
382
+ isIndex: name === "index",
383
+ isCatchAll: routePath.split("/").includes("*"),
384
+ isDynamic: routePath.split("/").some((segment) => segment.startsWith(":")),
385
+ renderMode,
386
+ hydrationMode,
387
+ revalidateSeconds: revalidate.seconds,
388
+ hasRevalidateExport: revalidate.present,
389
+ hasLoader,
390
+ hasHead
391
+ });
392
+ }
393
+ }
394
+ function filePathToRoutePath(relativePath) {
395
+ const extension = extname(relativePath);
396
+ let route = extension ? relativePath.slice(0, -extension.length) : relativePath;
397
+ route = route.replace(/\\/g, "/");
398
+ if (route === "_app" || route.endsWith("/_app")) return "__shell__";
399
+ if (route === "index") return "/";
400
+ route = route.replace(/\/index$/, "");
401
+ route = route.replace(/\[([^\].]+)\]/g, ":$1");
402
+ route = route.replace(/\[\.\.\.([^\]]+)\]/g, "*");
403
+ return `/${route}`;
404
+ }
405
+ function sortRoutes(pages) {
406
+ return [...pages].filter((p) => p.routePath !== "__shell__").sort(comparePagesBySpecificity);
407
+ }
408
+ function comparePagesBySpecificity(left, right) {
409
+ const leftSegments = splitRoutePath(left.routePath);
410
+ const rightSegments = splitRoutePath(right.routePath);
411
+ const length = Math.max(leftSegments.length, rightSegments.length);
412
+ for (let index = 0; index < length; index += 1) {
413
+ const leftSegment = leftSegments[index];
414
+ const rightSegment = rightSegments[index];
415
+ if (!leftSegment) return -1;
416
+ if (!rightSegment) return 1;
417
+ const leftScore = getRouteSegmentSpecificity(leftSegment);
418
+ const rightScore = getRouteSegmentSpecificity(rightSegment);
419
+ if (leftScore !== rightScore) return rightScore - leftScore;
420
+ if (leftScore === 3 && leftSegment !== rightSegment) return leftSegment.localeCompare(rightSegment);
421
+ }
422
+ return left.routePath.localeCompare(right.routePath);
423
+ }
424
+ function splitRoutePath(routePath) {
425
+ return routePath.split("/").filter(Boolean);
426
+ }
427
+ function getRouteSegmentSpecificity(segment) {
428
+ if (segment === "*") return 1;
429
+ if (segment.startsWith(":")) return 2;
430
+ return 3;
431
+ }
432
+ function extractQuotedPageExport(source, name, relativePath) {
433
+ const declarations = [...maskCommentsAndStrings(source).matchAll(new RegExp(`export\\s+const\\s+${name}\\s*=`, "g"))];
434
+ if (declarations.length === 0) return void 0;
435
+ if (declarations.length > 1) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} exports ${name} more than once.`);
436
+ const declaration = declarations[0];
437
+ const valueStart = (declaration.index ?? 0) + declaration[0].length;
438
+ return source.slice(valueStart).trimStart().match(/^["'](\w+)["']/)?.[1];
439
+ }
440
+ const REVALIDATE_RE = /export\s+const\s+REVALIDATE\s*=\s*([^;\n]+)/;
441
+ function extractRevalidateSeconds(source, relativePath) {
442
+ const matches = [...maskCommentsAndStrings(source).matchAll(new RegExp(REVALIDATE_RE, "g"))];
443
+ if (matches.length === 0) return { present: false };
444
+ if (matches.length > 1) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} exports REVALIDATE more than once.`);
445
+ const expression = matches[0][1].trim().replace(/\s+as\s+const$/, "");
446
+ if (!/^\d(?:_?\d)*$/.test(expression)) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} must export REVALIDATE as a positive integer literal number of seconds (for example, \`export const REVALIDATE = 60\`).`);
447
+ const seconds = Number(expression.replaceAll("_", ""));
448
+ if (!Number.isSafeInteger(seconds) || seconds <= 0) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} must export REVALIDATE as a positive integer literal number of seconds within JavaScript's safe integer range.`);
449
+ return {
450
+ present: true,
451
+ seconds
452
+ };
453
+ }
454
+ /** Mask Markdown fenced examples while preserving source offsets and top-level MDX exports. */
455
+ function maskMarkdownFences(source, relativePath) {
456
+ if (!/\.mdx?$/.test(relativePath)) return source;
457
+ const chars = source.split("");
458
+ let activeFence = null;
459
+ for (const line of source.matchAll(/.*(?:\r?\n|$)/g)) {
460
+ if (line[0] === "") continue;
461
+ const lineStart = line.index ?? 0;
462
+ const stripped = stripMarkdownContainerPrefix(line[0].replace(/\r?\n$/, ""));
463
+ const fenceContent = activeFence && stripped.content.startsWith(" ".repeat(activeFence.continuationIndent)) ? stripped.content.slice(activeFence.continuationIndent) : stripped.content;
464
+ const opening = activeFence ? null : /^ {0,3}(`{3,}|~{3,})/.exec(fenceContent);
465
+ const closing = activeFence ? new RegExp(`^ {0,3}\\${activeFence.character}{${activeFence.length},}[ \\t]*$`).test(fenceContent) : false;
466
+ if (activeFence || opening) for (let offset = 0; offset < line[0].length; offset += 1) {
467
+ const index = lineStart + offset;
468
+ if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
469
+ }
470
+ if (closing) activeFence = null;
471
+ else if (opening) activeFence = {
472
+ character: opening[1][0],
473
+ continuationIndent: stripped.continuationIndent,
474
+ length: opening[1].length
475
+ };
476
+ }
477
+ return chars.join("");
478
+ }
479
+ function stripMarkdownContainerPrefix(line) {
480
+ let content = line;
481
+ let continuationIndent = 0;
482
+ while (true) {
483
+ const quote = /^ {0,3}> ?/.exec(content);
484
+ if (quote) {
485
+ content = content.slice(quote[0].length);
486
+ continue;
487
+ }
488
+ const list = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+/.exec(content);
489
+ if (!list) return {
490
+ content,
491
+ continuationIndent
492
+ };
493
+ continuationIndent += list[0].length;
494
+ content = content.slice(list[0].length);
495
+ }
496
+ }
497
+ function generatePagesManifestSource(pages, options) {
498
+ const pagesDir = options.pagesDir;
499
+ const defaultRender = options.pagesDefaultRender ?? "ssr";
500
+ const prefix = options.pagesDirPrefix;
501
+ const useImport = options.useImportSyntax ?? false;
502
+ const shellExtensions = withAdditionalExtensions(DEFAULT_SHELL_EXTENSIONS, normalizeAdditionalExtensions(options.additionalExtensions));
503
+ const appFile = scanAllFiles(pagesDir).find((f) => basename(f, extname(f)) === "_app" && shellExtensions.has(extname(f)));
504
+ const lines = [`import { ${pages.some((page) => page.revalidateSeconds !== void 0) ? "defineApp, group, route, timeRevalidate" : "defineApp, group, route"} } from "@pracht/core/manifest";`, ""];
505
+ const routeEntries = [];
506
+ const notFoundPage = pages.find((page) => page.routePath === "/404");
507
+ if (notFoundPage?.hasRevalidateExport) throw new Error(`[pracht] Pages not-found module ${JSON.stringify(notFoundPage.relativePath)} exports REVALIDATE, but not-found responses are never ISG routes.`);
508
+ for (const page of pages) {
509
+ if (page === notFoundPage) continue;
510
+ const render = page.renderMode ?? defaultRender;
511
+ if (render === "isg" && page.revalidateSeconds === void 0) throw new Error(`[pracht] Pages route ${JSON.stringify(page.relativePath)} uses render mode "isg" but does not export a revalidation policy. Add \`export const REVALIDATE = 60\` with a positive integer number of seconds, or use another render mode.`);
512
+ if (render !== "isg" && page.hasRevalidateExport) throw new Error(`[pracht] Pages route ${JSON.stringify(page.relativePath)} exports REVALIDATE but its effective render mode is ${JSON.stringify(render)}. REVALIDATE is only valid with \`RENDER_MODE = "isg"\` (or \`pagesDefaultRender: "isg"\`).`);
513
+ const filePath = prefix ? `${prefix}/${page.relativePath.replace(/\\/g, "/")}` : `./${page.relativePath.replace(/\\/g, "/")}`;
514
+ const fileRef = useImport ? `() => import(${JSON.stringify(filePath)})` : JSON.stringify(filePath);
515
+ const metaParts = [
516
+ `render: ${JSON.stringify(render)}`,
517
+ `hasLoader: ${page.hasLoader ? "true" : "false"}`,
518
+ `hasHead: ${page.hasHead ? "true" : "false"}`
519
+ ];
520
+ if (page.hydrationMode) metaParts.push(`hydration: ${JSON.stringify(page.hydrationMode)}`);
521
+ if (page.revalidateSeconds !== void 0) metaParts.push(`revalidate: timeRevalidate(${page.revalidateSeconds})`);
522
+ routeEntries.push(` route(${JSON.stringify(page.routePath)}, ${fileRef}, { ${metaParts.join(", ")} })`);
523
+ }
524
+ const notFoundEntry = notFoundPage ? buildNotFoundEntry(notFoundPage, {
525
+ prefix,
526
+ useImport,
527
+ withShell: !!appFile
528
+ }) : null;
529
+ if (appFile) {
530
+ const appPath = prefix ? `${prefix}/_app.${extname(appFile).slice(1)}` : `./${relative(join(pagesDir, ".."), appFile).replace(/\\/g, "/")}`;
531
+ const shellRef = useImport ? `() => import(${JSON.stringify(appPath)})` : JSON.stringify(appPath);
532
+ lines.push("const app = defineApp({");
533
+ lines.push(" shells: {");
534
+ lines.push(` pages: ${shellRef},`);
535
+ lines.push(" },");
536
+ lines.push(" routes: [");
537
+ lines.push(` group({ shell: "pages" }, [`);
538
+ lines.push(routeEntries.join(",\n"));
539
+ lines.push(" ]),");
540
+ lines.push(" ],");
541
+ if (notFoundEntry) lines.push(notFoundEntry);
542
+ lines.push("});");
543
+ } else {
544
+ lines.push("const app = defineApp({");
545
+ lines.push(" routes: [");
546
+ lines.push(routeEntries.join(",\n"));
547
+ lines.push(" ],");
548
+ if (notFoundEntry) lines.push(notFoundEntry);
549
+ lines.push("});");
550
+ }
551
+ lines.push("");
552
+ return lines.join("\n");
553
+ }
554
+ function buildNotFoundEntry(page, options) {
555
+ const filePath = options.prefix ? `${options.prefix}/${page.relativePath.replace(/\\/g, "/")}` : `./${page.relativePath.replace(/\\/g, "/")}`;
556
+ const configParts = [`component: ${options.useImport ? `() => import(${JSON.stringify(filePath)})` : JSON.stringify(filePath)}`];
557
+ if (options.withShell) configParts.push("shell: \"pages\"");
558
+ if (page.hydrationMode) configParts.push(`hydration: ${JSON.stringify(page.hydrationMode)}`);
559
+ return ` notFound: { ${configParts.join(", ")} },`;
560
+ }
561
+ function scanAllFiles(dir) {
562
+ const results = [];
563
+ let entries;
564
+ try {
565
+ entries = readdirSync(dir);
566
+ } catch {
567
+ return results;
568
+ }
569
+ for (const entry of entries) {
570
+ const abs = join(dir, entry);
571
+ if (statSync(abs).isDirectory()) results.push(...scanAllFiles(abs));
572
+ else results.push(abs);
573
+ }
574
+ return results;
575
+ }
576
+ function generateRoutesFile(pagesDir, outputPath, options) {
577
+ writeFileSync(outputPath, [
578
+ "// Auto-generated from pages/ directory by @pracht/vite-plugin.",
579
+ "// Customize this file and remove `pagesDir` from pracht config to use it directly.",
580
+ "",
581
+ generatePagesManifestSource(scanPagesDirectory(pagesDir, options.additionalExtensions), {
582
+ ...options,
583
+ useImportSyntax: true
584
+ }).replace("const app = defineApp(", "export const app = defineApp(")
585
+ ].join("\n"), "utf-8");
586
+ }
587
+ //#endregion
588
+ export { sortRoutes as a, createRouteStaticPathsHints as c, extensionGlob as d, normalizeAdditionalExtensions as f, scanPagesDirectory as i, DEFAULT_ROUTE_EXTENSIONS as l, generatePagesManifestSource as n, createRouteHeadHints as o, withAdditionalExtensions as p, generateRoutesFile as r, createRouteLoaderHints as s, filePathToRoutePath as t, LEGACY_BARE_ROUTE_EXTENSIONS as u };
@@ -8,13 +8,17 @@ interface ScannedPage {
8
8
  isDynamic: boolean;
9
9
  renderMode?: string;
10
10
  hydrationMode?: string;
11
+ revalidateSeconds?: number;
12
+ hasRevalidateExport?: boolean;
11
13
  hasLoader?: boolean;
14
+ hasHead?: boolean;
12
15
  }
13
16
  interface PagesRouterOptions {
14
17
  pagesDir: string;
15
18
  pagesDefaultRender?: string;
19
+ additionalExtensions?: readonly string[];
16
20
  }
17
- declare function scanPagesDirectory(pagesDir: string): ScannedPage[];
21
+ declare function scanPagesDirectory(pagesDir: string, additionalExtensions?: readonly string[]): ScannedPage[];
18
22
  declare function filePathToRoutePath(relativePath: string): string;
19
23
  declare function sortRoutes(pages: ScannedPage[]): ScannedPage[];
20
24
  declare function generatePagesManifestSource(pages: ScannedPage[], options: PagesRouterOptions & {
@@ -1,2 +1,2 @@
1
- import { a as sortRoutes, i as scanPagesDirectory, n as generatePagesManifestSource, r as generateRoutesFile, t as filePathToRoutePath } from "./pages-router-CStJY2Zp.mjs";
1
+ import { a as sortRoutes, i as scanPagesDirectory, n as generatePagesManifestSource, r as generateRoutesFile, t as filePathToRoutePath } from "./pages-router-KcCRkhnf.mjs";
2
2
  export { filePathToRoutePath, generatePagesManifestSource, generateRoutesFile, scanPagesDirectory, sortRoutes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/vite-plugin",
3
- "version": "0.7.6",
3
+ "version": "0.9.0",
4
4
  "description": "Vite plugin for Pracht apps with virtual modules, dev SSR, prerendering, route inspection, and multi-adapter builds.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -42,12 +42,14 @@
42
42
  "provenance": true
43
43
  },
44
44
  "dependencies": {
45
+ "@babel/parser": "^7.29.2",
45
46
  "@preact/preset-vite": "^2.10.5",
46
47
  "@prefresh/vite": "^2.0.0",
47
- "@pracht/adapter-node": "0.3.8",
48
- "@pracht/core": "0.12.0",
49
- "@pracht/preact-ssr-precompile": "0.1.2",
50
- "@pracht/capabilities": "0.1.1"
48
+ "es-module-lexer": "^1.7.0",
49
+ "@pracht/adapter-node": "0.4.0",
50
+ "@pracht/capabilities": "0.2.0",
51
+ "@pracht/preact-ssr-precompile": "0.1.3",
52
+ "@pracht/core": "0.14.0"
51
53
  },
52
54
  "peerDependencies": {
53
55
  "vite": "^8.0.0"