@volpe/astro-svelte-spa 0.1.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.
Files changed (4) hide show
  1. package/README.md +61 -0
  2. package/index.d.ts +37 -0
  3. package/index.js +444 -0
  4. package/package.json +31 -0
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @volpe/astro-svelte-spa
2
+
3
+ Vite plugin for file-based routing with svelte5-router in Astro.
4
+
5
+ ## Features
6
+
7
+ - File-based routing with `page.svelte` and `layout.svelte`
8
+ - Auto-generates `App.svelte`, `Link.svelte`, `[...path].astro`, `page.svelte`, and `404.svelte`
9
+ - Virtual module `virtual:svelte-routes` with typed routes
10
+ - `SvelteAppRoutes` type for autocomplete
11
+ - 404 handling with `StatusCode.NotFound`
12
+ - HMR support with full reload on changes
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @volpe/astro-svelte-spa @mateothegreat/svelte5-router
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```js
23
+ // astro.config.mjs
24
+ import { defineConfig } from "astro/config"
25
+ import svelte from "@astrojs/svelte"
26
+ import { astroSvelteSpa } from "@volpe/astro-svelte-spa"
27
+
28
+ export default defineConfig({
29
+ integrations: [svelte()],
30
+ vite: {
31
+ plugins: [astroSvelteSpa({ basePath: "/app" })]
32
+ }
33
+ })
34
+ ```
35
+
36
+ ## File Structure
37
+
38
+ ```
39
+ src/
40
+ pages/
41
+ svelte-app/ # or your custom basePath
42
+ page.svelte # -> /svelte-app
43
+ 404.svelte # -> catch-all 404
44
+ [...path].astro # Astro catch-all
45
+ about/
46
+ page.svelte # -> /svelte-app/about
47
+ settings/
48
+ page.svelte # -> /svelte-app/settings
49
+ layout.svelte # wraps settings pages
50
+ components/
51
+ App.svelte # auto-generated
52
+ Link.svelte # auto-generated
53
+ ```
54
+
55
+ ## Options
56
+
57
+ - `basePath` (string, default: `"/svelte-app"`): Base path for all routes
58
+
59
+ ## License
60
+
61
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { Plugin } from "vite";
2
+
3
+ export interface AstroSvelteSpaOptions {
4
+ /**
5
+ * Base path for all routes.
6
+ * When set to "/app", the plugin scans `src/pages/app/` and routes start with "/app".
7
+ * @default "/svelte-app"
8
+ */
9
+ basePath?: string;
10
+ }
11
+
12
+ /**
13
+ * Vite plugin for file-based routing with svelte5-router in Astro.
14
+ *
15
+ * Features:
16
+ * - File-based routing with `page.svelte` and `layout.svelte`
17
+ * - Auto-generates `App.svelte`, `Link.svelte`, `[...path].astro`, `page.svelte`, and `404.svelte`
18
+ * - Virtual module `virtual:svelte-routes` with typed routes
19
+ * - `SvelteAppRoutes` type for autocomplete
20
+ * - 404 handling with StatusCode.NotFound
21
+ * - HMR support with full reload on changes
22
+ *
23
+ * @example
24
+ * ```js
25
+ * // astro.config.mjs
26
+ * import { astroSvelteSpa } from "@volpe/astro-svelte-spa"
27
+ *
28
+ * export default defineConfig({
29
+ * vite: {
30
+ * plugins: [astroSvelteSpa({ basePath: "/app" })]
31
+ * }
32
+ * })
33
+ * ```
34
+ */
35
+ export function astroSvelteSpa(options?: AstroSvelteSpaOptions): Plugin;
36
+
37
+ export default astroSvelteSpa;
package/index.js ADDED
@@ -0,0 +1,444 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const VIRTUAL_MODULE_ID = "virtual:svelte-routes";
5
+ const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID;
6
+
7
+ /**
8
+ * @typedef {Object} RouteNode
9
+ * @property {string} path
10
+ * @property {string} [page]
11
+ * @property {string} [layout]
12
+ * @property {RouteNode[]} children
13
+ */
14
+
15
+ /**
16
+ * @param {string} dir
17
+ * @param {string} basePath
18
+ * @returns {RouteNode}
19
+ */
20
+ function scanPages(dir, basePath = "") {
21
+ const node = {
22
+ path: basePath || "/",
23
+ children: [],
24
+ };
25
+
26
+ if (!fs.existsSync(dir)) return node;
27
+
28
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
29
+
30
+ for (const entry of entries) {
31
+ const fullPath = path.join(dir, entry.name);
32
+
33
+ if (entry.isFile()) {
34
+ if (entry.name === "page.svelte") {
35
+ node.page = fullPath;
36
+ } else if (entry.name === "layout.svelte") {
37
+ node.layout = fullPath;
38
+ }
39
+ } else if (entry.isDirectory() && !entry.name.startsWith("[") && !entry.name.startsWith("_")) {
40
+ const childPath = basePath ? `${basePath}/${entry.name}` : `/${entry.name}`;
41
+ const childNode = scanPages(fullPath, childPath);
42
+ if (childNode.page || childNode.layout || childNode.children.length > 0) {
43
+ node.children.push(childNode);
44
+ }
45
+ }
46
+ }
47
+
48
+ return node;
49
+ }
50
+
51
+ /**
52
+ * @param {RouteNode} node
53
+ * @param {string[]} paths
54
+ * @returns {string[]}
55
+ */
56
+ function collectPaths(node, paths = []) {
57
+ if (node.page) {
58
+ paths.push(node.path);
59
+ }
60
+ for (const child of node.children) {
61
+ collectPaths(child, paths);
62
+ }
63
+ return paths;
64
+ }
65
+
66
+ /**
67
+ * @param {RouteNode} node
68
+ * @param {Map<string, string>} imports
69
+ * @param {{ value: number }} counter
70
+ */
71
+ function generateImports(node, imports, counter = { value: 0 }) {
72
+ if (node.layout) {
73
+ const name = `Layout${counter.value++}`;
74
+ imports.set(node.layout, name);
75
+ }
76
+ if (node.page) {
77
+ const name = `Page${counter.value++}`;
78
+ imports.set(node.page, name);
79
+ }
80
+ for (const child of node.children) {
81
+ generateImports(child, imports, counter);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {string} routePath
87
+ * @param {string} basePath
88
+ * @returns {string}
89
+ */
90
+ function applyBasePath(routePath, basePath) {
91
+ if (!basePath) return routePath;
92
+ if (routePath === "/") return basePath;
93
+ return basePath + routePath;
94
+ }
95
+
96
+ /**
97
+ * @param {RouteNode} node
98
+ * @param {Map<string, string>} imports
99
+ * @returns {string[]}
100
+ */
101
+ function generateRoutes(node, imports) {
102
+ const routes = [];
103
+
104
+ // Use relative paths (basePath is handled by Router component)
105
+ const routePath = node.path;
106
+ const layoutName = node.layout ? imports.get(node.layout) : null;
107
+ const pageName = node.page ? imports.get(node.page) : null;
108
+
109
+ // Add page route
110
+ if (pageName) {
111
+ if (layoutName) {
112
+ // With layout - use children
113
+ routes.push(`{
114
+ path: "${routePath}",
115
+ component: ${layoutName},
116
+ children: [
117
+ { path: "${routePath}", component: async () => import("${node.page}") }
118
+ ]
119
+ }`);
120
+ } else {
121
+ // No layout - simple route with async loading
122
+ routes.push(`{
123
+ path: "${routePath}",
124
+ component: async () => import("${node.page}")
125
+ }`);
126
+ }
127
+ }
128
+
129
+ // Add children routes
130
+ for (const child of node.children) {
131
+ routes.push(...generateRoutes(child, imports));
132
+ }
133
+
134
+ return routes;
135
+ }
136
+
137
+ /**
138
+ * @param {string} pagesDir
139
+ * @param {string} basePath
140
+ * @returns {string}
141
+ */
142
+ function generateRoutesModule(pagesDir, basePath) {
143
+ const tree = scanPages(pagesDir);
144
+ const imports = new Map();
145
+ generateImports(tree, imports);
146
+
147
+ // Import layouts (pages are loaded async)
148
+ const layoutImports = Array.from(imports.entries())
149
+ .filter(([filePath]) => filePath.includes("layout.svelte"))
150
+ .map(([filePath, name]) => `import ${name} from "${filePath}"`)
151
+ .join("\n");
152
+
153
+ // Routes use relative paths - basePath is handled by Router component
154
+ const routes = generateRoutes(tree, imports);
155
+
156
+ // Check for 404.svelte for statuses
157
+ const notFoundPath = path.join(pagesDir, "404.svelte");
158
+ const hasNotFound = fs.existsSync(notFoundPath);
159
+ const notFoundImportStatement = hasNotFound
160
+ ? `import NotFoundComponent from "${notFoundPath.replace(/\\/g, "/")}"`
161
+ : "";
162
+ const notFoundExport = hasNotFound
163
+ ? `export const notFoundComponent = NotFoundComponent`
164
+ : `export const notFoundComponent = null`;
165
+
166
+ const allImports = [layoutImports, notFoundImportStatement].filter(Boolean).join("\n");
167
+
168
+ return `${allImports}
169
+
170
+ export const basePath = "${basePath}"
171
+
172
+ export const routes = [
173
+ ${routes.join(",\n ")}
174
+ ]
175
+
176
+ ${notFoundExport}
177
+
178
+ export default routes
179
+ `;
180
+ }
181
+
182
+ /**
183
+ * @param {string} pagesDir
184
+ * @param {string} outputPath
185
+ * @param {string} basePath
186
+ */
187
+ function generateTypesFile(pagesDir, outputPath, basePath) {
188
+ const tree = scanPages(pagesDir);
189
+ const paths = collectPaths(tree).map((p) => applyBasePath(p, basePath));
190
+
191
+ const pathsUnion = paths.map((p) => `"${p}"`).join(" | ") || "string";
192
+
193
+ const content = `// Auto-generated by @volpe/astro-svelte-spa
194
+ // Do not edit manually
195
+
196
+ declare module "virtual:svelte-routes" {
197
+ import type { Component } from "svelte"
198
+
199
+ export type SvelteAppRoutes = ${pathsUnion}
200
+ export const basePath: string
201
+ export const routes: Array<{
202
+ path: string
203
+ component: any
204
+ children?: Array<{ path: string; component: any }>
205
+ }>
206
+ export const notFoundComponent: Component<any> | null
207
+ export default routes
208
+ }
209
+ `;
210
+
211
+ const currentContent = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf-8") : "";
212
+ if (currentContent !== content) {
213
+ fs.writeFileSync(outputPath, content);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * @param {string} rootDir
219
+ * @param {string} pagesDir
220
+ * @param {string} basePath
221
+ */
222
+ function generateComponentFiles(rootDir, pagesDir, basePath) {
223
+ const componentsDir = path.resolve(rootDir, "src/components");
224
+
225
+ if (!fs.existsSync(componentsDir)) {
226
+ fs.mkdirSync(componentsDir, { recursive: true });
227
+ }
228
+
229
+ if (!fs.existsSync(pagesDir)) {
230
+ fs.mkdirSync(pagesDir, { recursive: true });
231
+ }
232
+
233
+ // Generate App.svelte if it doesn't exist
234
+ const appPath = path.resolve(componentsDir, "App.svelte");
235
+ if (!fs.existsSync(appPath)) {
236
+ const appContent = `<script>
237
+ import { Router, route, StatusCode } from "@mateothegreat/svelte5-router"
238
+ import routes, { basePath, notFoundComponent } from "virtual:svelte-routes"
239
+
240
+ // Build statuses config for 404 handling
241
+ const statuses = notFoundComponent ? {
242
+ [StatusCode.NotFound]: { component: notFoundComponent }
243
+ } : undefined
244
+
245
+ // Action that combines route + prefetch on hover
246
+ function link(node) {
247
+ const href = node.getAttribute("href")
248
+
249
+ function preload() {
250
+ const r = routes.find(r => r.path === href)
251
+ if (r?.component && typeof r.component === "function") {
252
+ r.component()
253
+ }
254
+ }
255
+
256
+ node.addEventListener("mouseenter", preload)
257
+ node.addEventListener("touchstart", preload, { passive: true })
258
+
259
+ const routeAction = route(node)
260
+
261
+ return {
262
+ destroy() {
263
+ node.removeEventListener("mouseenter", preload)
264
+ node.removeEventListener("touchstart", preload)
265
+ routeAction?.destroy?.()
266
+ }
267
+ }
268
+ }
269
+
270
+ export { link }
271
+ </script>
272
+
273
+ <Router {routes} {basePath} {statuses} />
274
+ `;
275
+ fs.writeFileSync(appPath, appContent);
276
+ }
277
+
278
+ // Generate Link.svelte if it doesn't exist
279
+ const linkPath = path.resolve(componentsDir, "Link.svelte");
280
+ if (!fs.existsSync(linkPath)) {
281
+ const linkContent = `<script>
282
+ import { route } from "@mateothegreat/svelte5-router"
283
+ import routes from "virtual:svelte-routes"
284
+
285
+ let { href, children, ...rest } = $props()
286
+
287
+ function preload() {
288
+ const r = routes.find(r => r.path === href)
289
+ if (r?.component && typeof r.component === "function") {
290
+ r.component()
291
+ }
292
+ }
293
+ </script>
294
+
295
+ <a
296
+ {href}
297
+ use:route
298
+ onmouseenter={preload}
299
+ ontouchstart={preload}
300
+ {...rest}
301
+ >
302
+ {@render children()}
303
+ </a>
304
+ `;
305
+ fs.writeFileSync(linkPath, linkContent);
306
+ }
307
+
308
+ // Generate [...path].astro if it doesn't exist
309
+ const catchAllPath = path.resolve(pagesDir, "[...path].astro");
310
+ if (!fs.existsSync(catchAllPath)) {
311
+ const catchAllContent = `---
312
+ import App from "~/components/App.svelte"
313
+ ---
314
+
315
+ <App client:only="svelte" />
316
+ `;
317
+ fs.writeFileSync(catchAllPath, catchAllContent);
318
+ }
319
+
320
+ // Generate page.svelte if it doesn't exist
321
+ const pagePath = path.resolve(pagesDir, "page.svelte");
322
+ if (!fs.existsSync(pagePath)) {
323
+ const pageContent = `<h1>Edit me :)</h1>
324
+ `;
325
+ fs.writeFileSync(pagePath, pageContent);
326
+ }
327
+
328
+ // Generate 404.svelte if it doesn't exist
329
+ const notFoundPagePath = path.resolve(pagesDir, "404.svelte");
330
+ if (!fs.existsSync(notFoundPagePath)) {
331
+ const notFoundContent = `<script>
332
+ import { goto } from "@mateothegreat/svelte5-router"
333
+ import { basePath } from "virtual:svelte-routes"
334
+ </script>
335
+
336
+ <div class="not-found">
337
+ <h1>404</h1>
338
+ <p>Page not found</p>
339
+ <button onclick={() => goto(basePath)}>Go Home</button>
340
+ </div>
341
+
342
+ <style>
343
+ .not-found {
344
+ display: flex;
345
+ flex-direction: column;
346
+ align-items: center;
347
+ justify-content: center;
348
+ min-height: 50vh;
349
+ text-align: center;
350
+ }
351
+ h1 {
352
+ font-size: 4rem;
353
+ margin: 0;
354
+ }
355
+ p {
356
+ color: #666;
357
+ margin: 1rem 0;
358
+ }
359
+ button {
360
+ padding: 0.5rem 1rem;
361
+ cursor: pointer;
362
+ }
363
+ </style>
364
+ `;
365
+ fs.writeFileSync(notFoundPagePath, notFoundContent);
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Vite plugin for file-based routing with svelte5-router in Astro
371
+ * @param {Object} options - Plugin options
372
+ * @param {string} [options.basePath] - Base path for all routes (defaults to "/svelte-app")
373
+ * @returns {import('vite').Plugin}
374
+ */
375
+ export function astroSvelteSpa(options = {}) {
376
+ // Normalize basePath, defaults to "/svelte-app"
377
+ const basePath = options.basePath?.replace(/\/$/, "") || "/svelte-app";
378
+ // Directory suffix: "/svelte-app" -> "svelte-app"
379
+ const dirSuffix = basePath.replace(/^\//, "");
380
+
381
+ const rootDir = process.cwd();
382
+ const pagesDir = path.resolve(rootDir, "src/pages", dirSuffix);
383
+ const typesPath = path.resolve(rootDir, "src/svelte-routes.d.ts");
384
+
385
+ generateComponentFiles(rootDir, pagesDir, basePath);
386
+
387
+ return {
388
+ name: "vite-plugin-astro-svelte-spa",
389
+ enforce: "pre",
390
+
391
+ buildStart() {
392
+ generateTypesFile(pagesDir, typesPath, basePath);
393
+ },
394
+
395
+ configureServer(server) {
396
+ generateTypesFile(pagesDir, typesPath, basePath);
397
+
398
+ const watchPath = `/src/pages/${dirSuffix}/`;
399
+ server.watcher.on("all", (event, filePath) => {
400
+ if (
401
+ filePath.includes(watchPath) &&
402
+ (filePath.endsWith("page.svelte") || filePath.endsWith("layout.svelte") || filePath.endsWith("404.svelte"))
403
+ ) {
404
+ generateTypesFile(pagesDir, typesPath, basePath);
405
+
406
+ const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID);
407
+ if (mod) {
408
+ server.moduleGraph.invalidateModule(mod);
409
+ }
410
+
411
+ if (event === "add" || event === "unlink") {
412
+ server.ws.send({ type: "full-reload" });
413
+ }
414
+ }
415
+
416
+ if (
417
+ event === "change" &&
418
+ filePath.endsWith(".svelte") &&
419
+ (filePath.includes("/src/components/") || filePath.includes(watchPath))
420
+ ) {
421
+ server.ws.send({ type: "full-reload" });
422
+ }
423
+ });
424
+ },
425
+
426
+ resolveId(id) {
427
+ if (id === VIRTUAL_MODULE_ID) {
428
+ return RESOLVED_VIRTUAL_MODULE_ID;
429
+ }
430
+ },
431
+
432
+ load(id) {
433
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
434
+ return generateRoutesModule(pagesDir, basePath);
435
+ }
436
+ },
437
+
438
+ handleHotUpdate() {
439
+ return [];
440
+ },
441
+ };
442
+ }
443
+
444
+ export default astroSvelteSpa;
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@volpe/astro-svelte-spa",
3
+ "version": "0.1.0",
4
+ "description": "Vite plugin for file-based routing with svelte5-router in Astro",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
13
+ },
14
+ "keywords": [
15
+ "vite",
16
+ "vite-plugin",
17
+ "astro",
18
+ "svelte",
19
+ "svelte5-router",
20
+ "spa",
21
+ "file-based-routing"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "peerDependencies": {
26
+ "vite": "^5.0.0 || ^6.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
31
+ }