@pracht/vite-plugin 0.8.0 → 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.
@@ -1,319 +0,0 @@
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
- //#region src/route-loader-hints.ts
5
- const ROUTE_EXTENSIONS = new Set([
6
- ".tsx",
7
- ".ts",
8
- ".jsx",
9
- ".js",
10
- ".md",
11
- ".mdx"
12
- ]);
13
- const LOADER_DECLARATION_RE = /export\s+(?:async\s+)?(?:function|const|let|var)\s+loader\b/;
14
- const EXPORT_BLOCK_RE = /export\s*\{([^}]*)\}\s*(?:from\s*["'][^"']+["'])?/g;
15
- const EXPORT_ALL_RE = /export\s+\*\s+from\s*["'][^"']+["']/;
16
- function exportSpecifiersIncludeLoader(specifiers) {
17
- return specifiers.split(",").map((specifier) => specifier.trim()).filter(Boolean).some((specifier) => {
18
- const match = /^(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/.exec(specifier);
19
- if (!match) return false;
20
- const [, localName, exportedName] = match;
21
- return (exportedName ?? localName) === "loader";
22
- });
23
- }
24
- function detectLoaderExport(source) {
25
- if (LOADER_DECLARATION_RE.test(source)) return true;
26
- for (const match of source.matchAll(EXPORT_BLOCK_RE)) if (exportSpecifiersIncludeLoader(match[1])) return true;
27
- return EXPORT_ALL_RE.test(source);
28
- }
29
- function scanRouteFiles(dir, files) {
30
- let entries;
31
- try {
32
- entries = readdirSync(dir);
33
- } catch {
34
- return;
35
- }
36
- for (const entry of entries) {
37
- const abs = join(dir, entry);
38
- if (statSync(abs).isDirectory()) {
39
- scanRouteFiles(abs, files);
40
- continue;
41
- }
42
- if (ROUTE_EXTENSIONS.has(extname(entry))) files.push(abs);
43
- }
44
- }
45
- function toPosixPath(path) {
46
- return path.replace(/\\/g, "/");
47
- }
48
- function createRouteLoaderHints(routesDir, options = {}) {
49
- const files = [];
50
- const hints = {};
51
- scanRouteFiles(routesDir, files);
52
- for (const file of files) {
53
- const hasLoader = detectLoaderExport(readFileSync(file, "utf-8"));
54
- const relativeToRoutesDir = toPosixPath(relative(routesDir, file));
55
- const routeRootPrefix = options.rootRelativePrefix?.replace(/\/$/, "");
56
- const appFileDir = options.appFileDir;
57
- const keys = /* @__PURE__ */ new Set();
58
- if (appFileDir) {
59
- const relativeToAppFile = toPosixPath(relative(appFileDir, file));
60
- keys.add(relativeToAppFile.startsWith(".") ? relativeToAppFile : `./${relativeToAppFile}`);
61
- }
62
- if (routeRootPrefix) keys.add(`${routeRootPrefix}/${relativeToRoutesDir}`);
63
- for (const key of keys) hints[key] = hasLoader;
64
- }
65
- return hints;
66
- }
67
- //#endregion
68
- //#region src/pages-router.ts
69
- const PAGE_EXTENSIONS = new Set([
70
- ".tsx",
71
- ".tsrx",
72
- ".ts",
73
- ".jsx",
74
- ".js",
75
- ".md",
76
- ".mdx"
77
- ]);
78
- const SHELL_EXTENSIONS = new Set([
79
- ".tsx",
80
- ".tsrx",
81
- ".ts",
82
- ".jsx",
83
- ".js"
84
- ]);
85
- function scanPagesDirectory(pagesDir) {
86
- const pages = [];
87
- scan(pagesDir, pagesDir, pages);
88
- const appShell = pages.find((page) => page.routePath === "__shell__");
89
- 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.`);
90
- return sortRoutes(pages);
91
- }
92
- function scan(dir, root, pages) {
93
- let entries;
94
- try {
95
- entries = readdirSync(dir);
96
- } catch {
97
- return;
98
- }
99
- for (const entry of entries) {
100
- const abs = join(dir, entry);
101
- if (statSync(abs).isDirectory()) {
102
- scan(abs, root, pages);
103
- continue;
104
- }
105
- const ext = extname(entry);
106
- if (!PAGE_EXTENSIONS.has(ext)) continue;
107
- const name = basename(entry, ext);
108
- if (name.startsWith("_") && name !== "_app") continue;
109
- const rel = relative(root, abs);
110
- const routePath = filePathToRoutePath(rel);
111
- const analysisSource = maskMarkdownFences(readFileSync(abs, "utf-8"), rel);
112
- const renderMode = extractQuotedPageExport(analysisSource, "RENDER_MODE", rel);
113
- const hydrationMode = extractQuotedPageExport(analysisSource, "HYDRATION", rel);
114
- const revalidate = extractRevalidateSeconds(analysisSource, rel);
115
- const hasLoader = detectLoaderExport(analysisSource);
116
- pages.push({
117
- absolutePath: abs,
118
- relativePath: rel,
119
- routePath,
120
- isIndex: name === "index",
121
- isCatchAll: routePath.split("/").includes("*"),
122
- isDynamic: routePath.split("/").some((segment) => segment.startsWith(":")),
123
- renderMode,
124
- hydrationMode,
125
- revalidateSeconds: revalidate.seconds,
126
- hasRevalidateExport: revalidate.present,
127
- hasLoader
128
- });
129
- }
130
- }
131
- function filePathToRoutePath(relativePath) {
132
- let route = relativePath.replace(/\.(tsx?|tsrx|jsx?|mdx?)$/, "");
133
- route = route.replace(/\\/g, "/");
134
- if (route === "_app" || route.endsWith("/_app")) return "__shell__";
135
- if (route === "index") return "/";
136
- route = route.replace(/\/index$/, "");
137
- route = route.replace(/\[([^\].]+)\]/g, ":$1");
138
- route = route.replace(/\[\.\.\.([^\]]+)\]/g, "*");
139
- return `/${route}`;
140
- }
141
- function sortRoutes(pages) {
142
- return [...pages].filter((p) => p.routePath !== "__shell__").sort(comparePagesBySpecificity);
143
- }
144
- function comparePagesBySpecificity(left, right) {
145
- const leftSegments = splitRoutePath(left.routePath);
146
- const rightSegments = splitRoutePath(right.routePath);
147
- const length = Math.max(leftSegments.length, rightSegments.length);
148
- for (let index = 0; index < length; index += 1) {
149
- const leftSegment = leftSegments[index];
150
- const rightSegment = rightSegments[index];
151
- if (!leftSegment) return -1;
152
- if (!rightSegment) return 1;
153
- const leftScore = getRouteSegmentSpecificity(leftSegment);
154
- const rightScore = getRouteSegmentSpecificity(rightSegment);
155
- if (leftScore !== rightScore) return rightScore - leftScore;
156
- if (leftScore === 3 && leftSegment !== rightSegment) return leftSegment.localeCompare(rightSegment);
157
- }
158
- return left.routePath.localeCompare(right.routePath);
159
- }
160
- function splitRoutePath(routePath) {
161
- return routePath.split("/").filter(Boolean);
162
- }
163
- function getRouteSegmentSpecificity(segment) {
164
- if (segment === "*") return 1;
165
- if (segment.startsWith(":")) return 2;
166
- return 3;
167
- }
168
- function extractQuotedPageExport(source, name, relativePath) {
169
- const declarations = [...maskCommentsAndStrings(source).matchAll(new RegExp(`export\\s+const\\s+${name}\\s*=`, "g"))];
170
- if (declarations.length === 0) return void 0;
171
- if (declarations.length > 1) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} exports ${name} more than once.`);
172
- const declaration = declarations[0];
173
- const valueStart = (declaration.index ?? 0) + declaration[0].length;
174
- return source.slice(valueStart).trimStart().match(/^["'](\w+)["']/)?.[1];
175
- }
176
- const REVALIDATE_RE = /export\s+const\s+REVALIDATE\s*=\s*([^;\n]+)/;
177
- function extractRevalidateSeconds(source, relativePath) {
178
- const matches = [...maskCommentsAndStrings(source).matchAll(new RegExp(REVALIDATE_RE, "g"))];
179
- if (matches.length === 0) return { present: false };
180
- if (matches.length > 1) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} exports REVALIDATE more than once.`);
181
- const expression = matches[0][1].trim().replace(/\s+as\s+const$/, "");
182
- 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\`).`);
183
- const seconds = Number(expression.replaceAll("_", ""));
184
- 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.`);
185
- return {
186
- present: true,
187
- seconds
188
- };
189
- }
190
- /** Mask Markdown fenced examples while preserving source offsets and top-level MDX exports. */
191
- function maskMarkdownFences(source, relativePath) {
192
- if (!/\.mdx?$/.test(relativePath)) return source;
193
- const chars = source.split("");
194
- let activeFence = null;
195
- for (const line of source.matchAll(/.*(?:\r?\n|$)/g)) {
196
- if (line[0] === "") continue;
197
- const lineStart = line.index ?? 0;
198
- const stripped = stripMarkdownContainerPrefix(line[0].replace(/\r?\n$/, ""));
199
- const fenceContent = activeFence && stripped.content.startsWith(" ".repeat(activeFence.continuationIndent)) ? stripped.content.slice(activeFence.continuationIndent) : stripped.content;
200
- const opening = activeFence ? null : /^ {0,3}(`{3,}|~{3,})/.exec(fenceContent);
201
- const closing = activeFence ? new RegExp(`^ {0,3}\\${activeFence.character}{${activeFence.length},}[ \\t]*$`).test(fenceContent) : false;
202
- if (activeFence || opening) for (let offset = 0; offset < line[0].length; offset += 1) {
203
- const index = lineStart + offset;
204
- if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
205
- }
206
- if (closing) activeFence = null;
207
- else if (opening) activeFence = {
208
- character: opening[1][0],
209
- continuationIndent: stripped.continuationIndent,
210
- length: opening[1].length
211
- };
212
- }
213
- return chars.join("");
214
- }
215
- function stripMarkdownContainerPrefix(line) {
216
- let content = line;
217
- let continuationIndent = 0;
218
- while (true) {
219
- const quote = /^ {0,3}> ?/.exec(content);
220
- if (quote) {
221
- content = content.slice(quote[0].length);
222
- continue;
223
- }
224
- const list = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+/.exec(content);
225
- if (!list) return {
226
- content,
227
- continuationIndent
228
- };
229
- continuationIndent += list[0].length;
230
- content = content.slice(list[0].length);
231
- }
232
- }
233
- function generatePagesManifestSource(pages, options) {
234
- const pagesDir = options.pagesDir;
235
- const defaultRender = options.pagesDefaultRender ?? "ssr";
236
- const prefix = options.pagesDirPrefix;
237
- const useImport = options.useImportSyntax ?? false;
238
- const appFile = scanAllFiles(pagesDir).find((f) => basename(f, extname(f)) === "_app" && SHELL_EXTENSIONS.has(extname(f)));
239
- const lines = [`import { ${pages.some((page) => page.revalidateSeconds !== void 0) ? "defineApp, group, route, timeRevalidate" : "defineApp, group, route"} } from "@pracht/core/manifest";`, ""];
240
- const routeEntries = [];
241
- const notFoundPage = pages.find((page) => page.routePath === "/404");
242
- 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.`);
243
- for (const page of pages) {
244
- if (page === notFoundPage) continue;
245
- const render = page.renderMode ?? defaultRender;
246
- 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.`);
247
- 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"\`).`);
248
- const filePath = prefix ? `${prefix}/${page.relativePath.replace(/\\/g, "/")}` : `./${page.relativePath.replace(/\\/g, "/")}`;
249
- const fileRef = useImport ? `() => import(${JSON.stringify(filePath)})` : JSON.stringify(filePath);
250
- const metaParts = [`render: ${JSON.stringify(render)}`, `hasLoader: ${page.hasLoader ? "true" : "false"}`];
251
- if (page.hydrationMode) metaParts.push(`hydration: ${JSON.stringify(page.hydrationMode)}`);
252
- if (page.revalidateSeconds !== void 0) metaParts.push(`revalidate: timeRevalidate(${page.revalidateSeconds})`);
253
- routeEntries.push(` route(${JSON.stringify(page.routePath)}, ${fileRef}, { ${metaParts.join(", ")} })`);
254
- }
255
- const notFoundEntry = notFoundPage ? buildNotFoundEntry(notFoundPage, {
256
- prefix,
257
- useImport,
258
- withShell: !!appFile
259
- }) : null;
260
- if (appFile) {
261
- const appPath = prefix ? `${prefix}/_app.${extname(appFile).slice(1)}` : `./${relative(join(pagesDir, ".."), appFile).replace(/\\/g, "/")}`;
262
- const shellRef = useImport ? `() => import(${JSON.stringify(appPath)})` : JSON.stringify(appPath);
263
- lines.push("const app = defineApp({");
264
- lines.push(" shells: {");
265
- lines.push(` pages: ${shellRef},`);
266
- lines.push(" },");
267
- lines.push(" routes: [");
268
- lines.push(` group({ shell: "pages" }, [`);
269
- lines.push(routeEntries.join(",\n"));
270
- lines.push(" ]),");
271
- lines.push(" ],");
272
- if (notFoundEntry) lines.push(notFoundEntry);
273
- lines.push("});");
274
- } else {
275
- lines.push("const app = defineApp({");
276
- lines.push(" routes: [");
277
- lines.push(routeEntries.join(",\n"));
278
- lines.push(" ],");
279
- if (notFoundEntry) lines.push(notFoundEntry);
280
- lines.push("});");
281
- }
282
- lines.push("");
283
- return lines.join("\n");
284
- }
285
- function buildNotFoundEntry(page, options) {
286
- const filePath = options.prefix ? `${options.prefix}/${page.relativePath.replace(/\\/g, "/")}` : `./${page.relativePath.replace(/\\/g, "/")}`;
287
- const configParts = [`component: ${options.useImport ? `() => import(${JSON.stringify(filePath)})` : JSON.stringify(filePath)}`];
288
- if (options.withShell) configParts.push("shell: \"pages\"");
289
- if (page.hydrationMode) configParts.push(`hydration: ${JSON.stringify(page.hydrationMode)}`);
290
- return ` notFound: { ${configParts.join(", ")} },`;
291
- }
292
- function scanAllFiles(dir) {
293
- const results = [];
294
- let entries;
295
- try {
296
- entries = readdirSync(dir);
297
- } catch {
298
- return results;
299
- }
300
- for (const entry of entries) {
301
- const abs = join(dir, entry);
302
- if (statSync(abs).isDirectory()) results.push(...scanAllFiles(abs));
303
- else results.push(abs);
304
- }
305
- return results;
306
- }
307
- function generateRoutesFile(pagesDir, outputPath, options) {
308
- writeFileSync(outputPath, [
309
- "// Auto-generated from pages/ directory by @pracht/vite-plugin.",
310
- "// Customize this file and remove `pagesDir` from pracht config to use it directly.",
311
- "",
312
- generatePagesManifestSource(scanPagesDirectory(pagesDir), {
313
- ...options,
314
- useImportSyntax: true
315
- }).replace("const app = defineApp(", "export const app = defineApp(")
316
- ].join("\n"), "utf-8");
317
- }
318
- //#endregion
319
- export { sortRoutes as a, scanPagesDirectory as i, generatePagesManifestSource as n, createRouteLoaderHints as o, generateRoutesFile as r, filePathToRoutePath as t };