@jk2908/solas 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -72,23 +72,21 @@ All Solas options are passed to `solas()` inside `defineConfig`.
72
72
 
73
73
  ### `url`
74
74
 
75
- Use `url` to tell Solas what the public application origin is.
75
+ `url` is optional. If you set it, Solas treats it as the public origin for your app.
76
76
 
77
- `url` is optional.
77
+ Solas resolves it in this order:
78
78
 
79
- This happens inside `solas()` when the plugin builds its validated config object. Solas assigns `config.url` with this lookup order:
80
-
81
- - `config.url`
79
+ - the `url` option passed to `solas()`
82
80
  - `VITE_APP_URL`
83
- - `APP_URL`
84
81
 
85
82
  Current behaviour:
86
83
 
87
84
  - Solas reads that value during plugin configuration.
88
- - Solas injects `APP_URL` and `VITE_APP_URL` into `import.meta.env`.
89
- - The current Solas runtime does not otherwise require `config.url` for routing, build, or prerender to work.
85
+ - Solas exposes the resolved value as `import.meta.env.VITE_APP_URL`.
86
+ - If `url` is set, prerender uses it as the request origin for build-time renders.
87
+ - The runtime router does not otherwise require `config.url` for routing to work.
90
88
 
91
- In practice, that means you do not have to pass `url` unless your application code wants a canonical origin value, or you want to standardise that value in config rather than relying on environment variables.
89
+ In practice, you only need `url` if your app code wants to read the public origin from `import.meta.env.VITE_APP_URL`, or if your prerendered output needs a real public origin.
92
90
 
93
91
  If you do want to set it explicitly, this is the shape:
94
92
 
@@ -102,11 +100,7 @@ export default defineConfig(({ mode }) => ({
102
100
  }))
103
101
  ```
104
102
 
105
- If you prefer environment variables, set one of these instead:
106
-
107
- ```sh
108
- APP_URL=https://example.com
109
- ```
103
+ If you prefer an environment variable, set this instead:
110
104
 
111
105
  ```sh
112
106
  VITE_APP_URL=https://example.com
@@ -270,20 +264,77 @@ In that example, the final page title becomes `Routing - Solas`.
270
264
 
271
265
  ### `trailingSlash`
272
266
 
273
- Use `trailingSlash` when you want generated routes to end with `/`.
267
+ Use `trailingSlash` to set the app-wide URL policy.
274
268
 
275
- Default: `false`
269
+ Default: `never`
270
+
271
+ - `never`: `/about/` redirects to `/about`
272
+ - `always`: `/about` redirects to `/about/`
273
+ - `ignore`: both forms resolve without a canonical redirect
274
+
275
+ This is a global setting in `solas()`. Solas does not read `trailingSlash` from route files.
276
+
277
+ Prerendered output follows the same policy. `always` writes route HTML as `about/index.html`, while `never` and `ignore` write it as `about.html`.
276
278
 
277
279
  ```ts
278
280
  export default defineConfig({
279
281
  plugins: [
280
282
  solas({
281
- trailingSlash: true,
283
+ trailingSlash: 'always',
282
284
  }),
283
285
  ],
284
286
  })
285
287
  ```
286
288
 
289
+ ### `sitemap`
290
+
291
+ Use `sitemap` to generate a `sitemap.xml` at build time.
292
+
293
+ Default: `false`
294
+
295
+ When enabled, Solas writes a sitemap containing all routes with deterministic URLs: static routes, prerendered routes, and dynamic routes resolved via `params`. The origin for each URL comes from `config.url`.
296
+
297
+ ```ts
298
+ export default defineConfig(({ mode }) => ({
299
+ plugins: [
300
+ solas({
301
+ url: mode === 'production' ? 'https://example.com' : 'http://localhost:8787',
302
+ sitemap: true,
303
+ }),
304
+ ],
305
+ }))
306
+ ```
307
+
308
+ Routes with dynamic segments (`[id]`) or catch-all segments (`[...param]`) are only included if they export `params` and `prerender`.
309
+
310
+ To add routes that Solas cannot discover automatically (for example, catch-all routes backed by a CMS), pass an object with a `routes` function. The function receives the auto-discovered routes and returns the final list:
311
+
312
+ ```ts
313
+ export default defineConfig(({ mode }) => ({
314
+ plugins: [
315
+ solas({
316
+ url: mode === 'production' ? 'https://example.com' : 'http://localhost:8787',
317
+ sitemap: {
318
+ async routes(discovered) {
319
+ const posts = await fetchPostSlugs()
320
+ return [...discovered, ...posts.map(s => `/blog/${s}`)]
321
+ },
322
+ },
323
+ }),
324
+ ],
325
+ }))
326
+ ```
327
+
328
+ The `routes` function can be async. The callback also lets you filter routes:
329
+
330
+ ```ts
331
+ sitemap: {
332
+ routes: (r) => r.filter(route => !route.startsWith('/admin')),
333
+ }
334
+ ```
335
+
336
+ The sitemap is written to the build output directory as `sitemap.xml` after prerendering and before precompression.
337
+
287
338
  ### `logger.level`
288
339
 
289
340
  Use `logger.level` to control internal Solas logging.
package/dist/cli.js CHANGED
@@ -7,7 +7,14 @@ import { Logger } from './utils/logger';
7
7
  import { Prerender } from './internal/prerender';
8
8
  const logger = new Logger();
9
9
  const DEFAULT_PREVIEW_PORT = 4173;
10
+ /**
11
+ * The build command does more than just run vite build - it also handles prerendering and
12
+ * precompressing assets. This is because prerendering needs to run against the built
13
+ * server entry to ensure the same code paths as preview, and precompressing needs
14
+ * to include the prerendered html and json files
15
+ */
10
16
  async function build() {
17
+ // build and prerender should both run in production mode
11
18
  process.env.NODE_ENV = 'production';
12
19
  const cwd = process.cwd();
13
20
  const manifestPath = path.join(cwd, Solas.Config.GENERATED_DIR, 'build.json');
@@ -40,23 +47,24 @@ async function build() {
40
47
  // do not keep stale metadata from a previous build
41
48
  await fs.rm(artifactRoot, { recursive: true, force: true });
42
49
  // prerender routes
43
- if (manifest.prerenderedRoutes.length > 0) {
50
+ if (manifest.prerenderRoutes.length > 0) {
44
51
  const timeout = Prerender.Build.getTimeout();
45
52
  const concurrency = Prerender.Build.getConcurrency();
53
+ // track the extra prerender files we write for preview
46
54
  const artifactManifestRoutes = {};
47
- logger.info('[prerender]', `prerendering ${manifest.prerenderedRoutes.length} routes (timeout: ${timeout}ms, concurrency: ${concurrency})...`);
48
- // ensure production mode for React
49
- process.env.NODE_ENV = 'production';
55
+ logger.info('[prerender]', `prerendering ${manifest.prerenderRoutes.length} routes (timeout: ${timeout}ms, concurrency: ${concurrency})...`);
56
+ // load the built server entry and render each prerendered route through it
50
57
  const rscEntry = path.join(rscDir, 'index.js');
51
58
  const { default: app } = await import(/* @vite-ignore */ rscEntry);
52
- for await (const result of Prerender.Build.run(app, manifest.prerenderedRoutes, {
59
+ // run prerender through the built app so build output uses the same path as preview
60
+ for await (const result of Prerender.Build.run(app, manifest.prerenderRoutes, {
53
61
  timeout,
54
62
  concurrency,
63
+ origin: manifest.url,
55
64
  })) {
56
65
  const route = result.route;
57
66
  try {
58
- const routeDir = route === '/' ? '' : route.replace(/^\//, '');
59
- // folder for this route's build notes/files
67
+ // store prerender metadata for this route under the framework folder
60
68
  const artifactDir = Prerender.Artifact.getPath(outDir, route);
61
69
  if ('error' in result)
62
70
  throw result.error;
@@ -66,7 +74,7 @@ async function build() {
66
74
  }
67
75
  const artifact = result.artifact;
68
76
  if (artifact.mode === 'ppr') {
69
- // for ppr we save the shell now, and the delayed part for later
77
+ // for ppr save the shell now and keep the postponed state for later
70
78
  await fs.mkdir(artifactDir, { recursive: true });
71
79
  const writes = [
72
80
  Bun.write(path.join(artifactDir, 'prelude.html'), artifact.html),
@@ -103,7 +111,7 @@ async function build() {
103
111
  continue;
104
112
  }
105
113
  // @todo: hash files
106
- // even for full pages, write metadata so preview/runtime knows to serve built html
114
+ // full prerender still keeps metadata so preview knows to serve saved html
107
115
  await fs.mkdir(artifactDir, { recursive: true });
108
116
  await Bun.write(path.join(artifactDir, 'metadata.json'), JSON.stringify({
109
117
  schema: artifact.schema,
@@ -111,9 +119,29 @@ async function build() {
111
119
  createdAt: artifact.createdAt,
112
120
  mode: artifact.mode,
113
121
  }));
122
+ const routePath = route.replace(/^\//, '').replace(/\/$/, '');
114
123
  const outPath = route === '/'
115
124
  ? path.join(outDir, 'index.html')
116
- : path.join(outDir, routeDir, 'index.html');
125
+ : manifest.trailingSlash === 'always'
126
+ ? path.join(outDir, routePath, 'index.html')
127
+ : path.join(outDir, `${routePath}.html`);
128
+ // remove the old file shape for this route so switching trailingSlash mode does not leave
129
+ // both variants behind. we have to do this before writing the new file so that if the
130
+ // route shape changes, we still remove the old one instead of leaving in the output
131
+ const alternateOutPath = route === '/'
132
+ ? null
133
+ : manifest.trailingSlash === 'always'
134
+ ? path.join(outDir, `${routePath}.html`)
135
+ : path.join(outDir, routePath, 'index.html');
136
+ if (alternateOutPath) {
137
+ // remove the old file shape so switching trailingSlash mode
138
+ // does not leave both variants behind
139
+ await Promise.all([
140
+ fs.rm(alternateOutPath, { force: true }),
141
+ fs.rm(`${alternateOutPath}.br`, { force: true }),
142
+ ]);
143
+ await fs.rmdir(path.dirname(alternateOutPath)).catch(() => { });
144
+ }
117
145
  await fs.mkdir(path.dirname(outPath), { recursive: true });
118
146
  await Bun.write(outPath, artifact.html);
119
147
  artifactManifestRoutes[route] = {
@@ -127,15 +155,32 @@ async function build() {
127
155
  logger.error('[prerender]', `failed ${route}: ${err}. This often means unresolved async work (for example external fetches or dynamic rendering in full mode).`);
128
156
  }
129
157
  }
158
+ // write one manifest for the saved prerender files after all routes finish
130
159
  await fs.mkdir(artifactRoot, { recursive: true });
131
160
  await Bun.write(Prerender.Artifact.getManifestPath(outDir), JSON.stringify({
132
161
  generatedAt: Date.now(),
133
162
  routes: artifactManifestRoutes,
134
163
  }));
135
164
  }
165
+ // sitemap
166
+ if (manifest.sitemapRoutes.length > 0 && manifest.url) {
167
+ const origin = manifest.url.replace(/\/$/, '');
168
+ const urls = manifest.sitemapRoutes
169
+ .map(route => ` <url><loc>${origin}${route}</loc></url>`)
170
+ .join('\n');
171
+ const sitemap = [
172
+ '<?xml version="1.0" encoding="UTF-8"?>',
173
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
174
+ urls,
175
+ '</urlset>',
176
+ ].join('\n');
177
+ await Bun.write(path.join(outDir, 'sitemap.xml'), sitemap);
178
+ logger.info('[sitemap]', `generated ${manifest.sitemapRoutes.length} urls`);
179
+ }
136
180
  // precompress
137
181
  if (manifest.precompress) {
138
182
  logger.info('[precompress]', 'compressing assets...');
183
+ // compress after prerender so generated html and json are included too
139
184
  for await (const { input, compressed } of Compress.run(outDir, {
140
185
  filter: f => /\.(js|css|html|svg|json|txt)$/.test(f),
141
186
  })) {
@@ -144,6 +189,7 @@ async function build() {
144
189
  }
145
190
  }
146
191
  // cleanup
192
+ // this file is only needed while the build command is running
147
193
  await fs.unlink(manifestPath).catch(() => { });
148
194
  logger.info('[build]', 'done');
149
195
  }
@@ -158,6 +204,7 @@ async function dev() {
158
204
  await proc.exited;
159
205
  }
160
206
  async function preview() {
207
+ // preview should behave like production, not like vite dev
161
208
  process.env.NODE_ENV = 'production';
162
209
  const cwd = process.cwd();
163
210
  const outDir = path.resolve(cwd, Solas.Config.OUT_DIR);
@@ -167,11 +214,12 @@ async function preview() {
167
214
  const parsedPort = portFlagIndex >= 0 && args[portFlagIndex + 1]
168
215
  ? Number(args[portFlagIndex + 1])
169
216
  : DEFAULT_PREVIEW_PORT;
217
+ // fail fast if the port is invalid
170
218
  if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
171
219
  logger.error(`[preview] invalid port: ${args[portFlagIndex + 1] ?? 'undefined'}`);
172
220
  process.exit(1);
173
221
  }
174
- // import RSC server (handles prerendered HTML, static assets, and ssr)
222
+ // the built server entry handles routing, prerendered html, and ssr here
175
223
  try {
176
224
  await fs.access(rscEntry);
177
225
  }
@@ -181,6 +229,7 @@ async function preview() {
181
229
  }
182
230
  const { default: app } = await import(/* @vite-ignore */ rscEntry);
183
231
  try {
232
+ // keep the preview server thin and let the app handle requests
184
233
  Bun.serve({
185
234
  port: parsedPort,
186
235
  fetch: app.fetch,
@@ -191,10 +240,10 @@ async function preview() {
191
240
  process.exit(1);
192
241
  }
193
242
  logger.info('[preview]', `server running at http://localhost:${parsedPort}`);
194
- // keep alive
243
+ // keep the process running after the server starts
195
244
  await new Promise(() => { });
196
245
  }
197
- // cli entry point
246
+ // read the subcommand once and dispatch below
198
247
  const [, , command, ...args] = process.argv;
199
248
  switch (command) {
200
249
  case 'build':
package/dist/index.js CHANGED
@@ -15,20 +15,21 @@ import { writeMaps } from './internal/codegen/maps';
15
15
  const DEFAULT_CONFIG = {
16
16
  precompress: true,
17
17
  prerender: false,
18
- trailingSlash: false,
18
+ trailingSlash: 'never',
19
19
  };
20
20
  function solas(c) {
21
21
  const config = Solas.Config.validate({
22
22
  ...DEFAULT_CONFIG,
23
23
  ...c,
24
- url: c.url ?? process.env.VITE_APP_URL?.toString() ?? process.env.APP_URL?.toString(),
24
+ url: c.url ?? process.env.VITE_APP_URL?.toString(),
25
25
  });
26
26
  if (config.logger?.level)
27
27
  Logger.defaultLevel = config.logger.level;
28
28
  const logger = new Logger();
29
29
  const exportReader = new ExportReader();
30
30
  const buildContext = {
31
- prerenderedRoutes: new Set(),
31
+ prerenderRoutes: new Set(),
32
+ knownRoutes: new Set(),
32
33
  exportReader,
33
34
  };
34
35
  // cache for file contents to avoid unnecessary readFile invocations
@@ -87,9 +88,9 @@ function solas(c) {
87
88
  fs.mkdir(generatedDir, { recursive: true }),
88
89
  ]);
89
90
  const processor = new Build.Finder(buildContext, config);
90
- const { manifest, prerenderedRoutes, imports, modules } = await processor.run();
91
- // set prerenderable routes in context for use in closeBundle
92
- buildContext.prerenderedRoutes = prerenderedRoutes;
91
+ const { manifest, prerenderRoutes, knownRoutes, imports, modules } = await processor.run();
92
+ buildContext.prerenderRoutes = prerenderRoutes;
93
+ buildContext.knownRoutes = knownRoutes;
93
94
  const files = [
94
95
  ['config.ts', writeConfig(config)],
95
96
  ['manifest.ts', writeManifest(manifest)],
@@ -183,8 +184,7 @@ function solas(c) {
183
184
  viteConfig.server ??= {};
184
185
  viteConfig.server.port = config.port ?? viteConfig.server.port ?? 8787;
185
186
  viteConfig.define ??= {};
186
- viteConfig.define['import.meta.env.APP_URL'] = JSON.stringify(process.env.APP_URL);
187
- viteConfig.define['import.meta.env.VITE_APP_URL'] = JSON.stringify(process.env.VITE_APP_URL);
187
+ viteConfig.define['import.meta.env.VITE_APP_URL'] = JSON.stringify(config.url);
188
188
  viteConfig.define['import.meta.env.SOLAS_VERSION'] = JSON.stringify(pkg.version);
189
189
  viteConfig.resolve ??= {};
190
190
  viteConfig.resolve.alias = {
@@ -211,11 +211,25 @@ function solas(c) {
211
211
  async closeBundle() {
212
212
  if (process.env.NODE_ENV === 'development')
213
213
  return;
214
+ // resolve sitemap routes
215
+ let sitemapRoutes = [];
216
+ if (config.sitemap && config.url) {
217
+ const auto = [...new Set([...buildContext.knownRoutes, ...buildContext.prerenderRoutes])];
218
+ if (typeof config.sitemap === 'object' && config.sitemap.routes) {
219
+ sitemapRoutes = await config.sitemap.routes(auto);
220
+ }
221
+ else {
222
+ sitemapRoutes = auto;
223
+ }
224
+ }
214
225
  // write build manifest
215
226
  const generatedDir = path.join(process.cwd(), Solas.Config.GENERATED_DIR);
216
227
  await Bun.write(path.join(generatedDir, 'build.json'), JSON.stringify({
217
- prerenderedRoutes: Array.from(buildContext.prerenderedRoutes),
228
+ prerenderRoutes: Array.from(buildContext.prerenderRoutes),
229
+ sitemapRoutes,
218
230
  precompress: config.precompress,
231
+ trailingSlash: config.trailingSlash,
232
+ url: config.url,
219
233
  }));
220
234
  logger.info('[closeBundle]', 'vite build complete');
221
235
  },
@@ -89,7 +89,8 @@ export declare namespace Build {
89
89
  manifest: Record<string, (Endpoint | Segment)[] | Endpoint | Segment>;
90
90
  imports: Imports;
91
91
  modules: Modules;
92
- prerenderedRoutes: Set<string>;
92
+ prerenderRoutes: Set<string>;
93
+ knownRoutes: Set<string>;
93
94
  }>;
94
95
  /**
95
96
  * Process the scanned route data
@@ -98,7 +99,8 @@ export declare namespace Build {
98
99
  manifest: Record<string, (Endpoint | Segment)[] | Endpoint | Segment>;
99
100
  imports: Imports;
100
101
  modules: Modules;
101
- prerenderedRoutes: Set<string>;
102
+ prerenderRoutes: Set<string>;
103
+ knownRoutes: Set<string>;
102
104
  }>;
103
105
  }
104
106
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { Solas } from '../solas';
4
4
  import { Logger } from '../utils/logger';
5
+ import { normalisePathname } from './router/utils';
5
6
  import { Prerender } from './prerender';
6
7
  export { Build };
7
8
  var Build;
@@ -317,7 +318,9 @@ var Build;
317
318
  */
318
319
  async process(res) {
319
320
  const processed = new Set();
320
- const prerenderedRoutes = new Set();
321
+ const prerenderRoutes = new Set();
322
+ const knownRoutes = new Set();
323
+ const trailingSlash = this.config?.trailingSlash ?? 'never';
321
324
  const manifest = {};
322
325
  // imports for endpoints and components
323
326
  const imports = {
@@ -485,15 +488,18 @@ var Build;
485
488
  : false;
486
489
  if (shouldPrerender) {
487
490
  if (!isDynamic && !isWildcard) {
488
- prerenderedRoutes.add(route);
491
+ prerenderRoutes.add(normalisePathname(route, trailingSlash));
489
492
  }
490
493
  else if (pagePath) {
491
494
  const staticParams = await Prerender.Build.getStaticParams(pagePath, this.buildContext);
492
495
  for (const r of Prerender.Build.getDynamicRouteList(route, params, staticParams)) {
493
- prerenderedRoutes.add(r);
496
+ prerenderRoutes.add(normalisePathname(r, trailingSlash));
494
497
  }
495
498
  }
496
499
  }
500
+ if (!isDynamic && !isWildcard) {
501
+ knownRoutes.add(normalisePathname(route, trailingSlash));
502
+ }
497
503
  const entry = {
498
504
  __id: entryId,
499
505
  __path: route,
@@ -626,7 +632,7 @@ var Build;
626
632
  logger.error('[Build:Finder:process]: failed to process route', err);
627
633
  }
628
634
  }
629
- return { manifest, imports, modules, prerenderedRoutes };
635
+ return { manifest, imports, modules, prerenderRoutes, knownRoutes };
630
636
  }
631
637
  }
632
638
  Build.Finder = Finder;
@@ -1,7 +1,9 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import path from 'node:path';
2
3
  import { createTemporaryReferenceSet, decodeAction, decodeFormState, decodeReply, loadServerAction, renderToReadableStream, } from '@vitejs/plugin-rsc/rsc';
3
4
  import { Solas } from '../../solas';
4
5
  import { Logger } from '../../utils/logger';
6
+ import { normalisePathname } from '../router/utils';
5
7
  import { getKnownDigest, isKnownError } from './utils';
6
8
  import { Metadata } from '../metadata';
7
9
  import { HttpException, isHttpException } from '../navigation/http-exception';
@@ -214,10 +216,11 @@ export async function maybeActionWithParsedFormData(req) {
214
216
  * with a fetch method that handles requests
215
217
  */
216
218
  export function createHandler(config, manifest, importMap) {
219
+ const prerenderPathMode = config.trailingSlash === 'always' ? 'always' : 'never';
217
220
  const fullyPrerenderedRoutes = new Set(Object.values(manifest)
218
221
  .flat()
219
222
  .filter(entry => 'prerender' in entry && String(entry.prerender) === 'full')
220
- .map(entry => entry.__path));
223
+ .map(entry => normalisePathname(entry.__path, prerenderPathMode)));
221
224
  /**
222
225
  * Create the HTTP response for a single incoming request. Runs actions when needed,
223
226
  * converts the payload into component, HTML, or prerender artifact responses, and
@@ -245,6 +248,7 @@ export function createHandler(config, manifest, importMap) {
245
248
  }
246
249
  const mod = await import.meta.viteRsc.loadModule('ssr', 'index');
247
250
  const pathname = new URL(req.url).pathname;
251
+ const lookupPath = normalisePathname(pathname, prerenderPathMode);
248
252
  const runtimePpr = !import.meta.env.DEV && ppr;
249
253
  // prerender artifact requests bypass the normal document path so the cli
250
254
  // gets structured JSON instead of a rendered html response
@@ -267,20 +271,20 @@ export function createHandler(config, manifest, importMap) {
267
271
  const artifactManifest = runtimePpr
268
272
  ? await Prerender.Artifact.loadManifest(Solas.Config.OUT_DIR)
269
273
  : null;
270
- const artifactManifestEntry = artifactManifest?.routes[pathname] ?? null;
274
+ const artifactManifestEntry = artifactManifest?.routes[lookupPath] ?? null;
271
275
  let tryPrelude = false;
272
276
  if (artifactManifestEntry) {
273
277
  tryPrelude = artifactManifestEntry.mode === 'ppr';
274
278
  }
275
279
  else if (runtimePpr) {
276
- const artifactMetadata = await Prerender.Artifact.loadMetadata(Solas.Config.OUT_DIR, pathname);
280
+ const artifactMetadata = await Prerender.Artifact.loadMetadata(Solas.Config.OUT_DIR, lookupPath);
277
281
  tryPrelude =
278
282
  !!artifactMetadata &&
279
- Prerender.Artifact.isCompatible(artifactMetadata, pathname, 'ppr');
283
+ Prerender.Artifact.isCompatible(artifactMetadata, lookupPath, 'ppr');
280
284
  }
281
285
  if (tryPrelude) {
282
- const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR, pathname);
283
- const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR, pathname);
286
+ const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR, lookupPath);
287
+ const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR, lookupPath);
284
288
  // resumable ppr responses splice fresh streamed content into the cached
285
289
  // prelude when postponed state is available for this route
286
290
  if (postponedState) {
@@ -319,37 +323,45 @@ export function createHandler(config, manifest, importMap) {
319
323
  async fetch(req) {
320
324
  const url = new URL(req.url);
321
325
  const accept = req.headers.get('accept') ?? '';
326
+ const method = req.method.toUpperCase();
327
+ const canonicalPath = config.trailingSlash === 'ignore'
328
+ ? url.pathname
329
+ : normalisePathname(url.pathname, config.trailingSlash);
330
+ if ((method === 'GET' || method === 'HEAD') &&
331
+ config.trailingSlash !== 'ignore' &&
332
+ canonicalPath !== url.pathname) {
333
+ url.pathname = canonicalPath;
334
+ return Response.redirect(url.toString(), 308);
335
+ }
322
336
  // fully prerendered html can be served straight from disk for normal
323
337
  // document requests, but artifact generation must still hit the runtime path
324
338
  if (!import.meta.env.DEV &&
325
339
  accept.includes('text/html') &&
326
340
  req.headers.get(`x-${Solas.Config.SLUG}-prerender-artifact`) !== '1') {
327
- const pathname = url.pathname;
341
+ const pathname = canonicalPath;
342
+ const lookupPath = normalisePathname(pathname, prerenderPathMode);
343
+ const routePath = lookupPath.replace(/^\//, '').replace(/\/$/, '');
328
344
  let prerenderPath = null;
329
345
  const artifactManifest = await Prerender.Artifact.loadManifest(Solas.Config.OUT_DIR);
330
- const artifactManifestEntry = artifactManifest?.routes[pathname] ?? null;
331
- if (fullyPrerenderedRoutes.has(pathname)) {
332
- prerenderPath =
333
- pathname === '/'
334
- ? Solas.Config.OUT_DIR + '/index.html'
335
- : Solas.Config.OUT_DIR + pathname + '/index.html';
346
+ const artifactManifestEntry = artifactManifest?.routes[lookupPath] ?? null;
347
+ const fullHtmlPath = lookupPath === '/'
348
+ ? path.join(Solas.Config.OUT_DIR, 'index.html')
349
+ : config.trailingSlash === 'always'
350
+ ? path.join(Solas.Config.OUT_DIR, routePath, 'index.html')
351
+ : path.join(Solas.Config.OUT_DIR, `${routePath}.html`);
352
+ if (fullyPrerenderedRoutes.has(lookupPath)) {
353
+ prerenderPath = fullHtmlPath;
336
354
  }
337
355
  else if (artifactManifestEntry) {
338
356
  if (artifactManifestEntry.mode === 'full') {
339
- prerenderPath =
340
- pathname === '/'
341
- ? Solas.Config.OUT_DIR + '/index.html'
342
- : Solas.Config.OUT_DIR + pathname + '/index.html';
357
+ prerenderPath = fullHtmlPath;
343
358
  }
344
359
  }
345
360
  else {
346
- const artifactMetadata = await Prerender.Artifact.loadMetadata(Solas.Config.OUT_DIR, pathname);
361
+ const artifactMetadata = await Prerender.Artifact.loadMetadata(Solas.Config.OUT_DIR, lookupPath);
347
362
  if (artifactMetadata &&
348
- Prerender.Artifact.isCompatible(artifactMetadata, pathname, 'full')) {
349
- prerenderPath =
350
- pathname === '/'
351
- ? Solas.Config.OUT_DIR + '/index.html'
352
- : Solas.Config.OUT_DIR + pathname + '/index.html';
363
+ Prerender.Artifact.isCompatible(artifactMetadata, lookupPath, 'full')) {
364
+ prerenderPath = fullHtmlPath;
353
365
  }
354
366
  }
355
367
  if (prerenderPath) {
@@ -27,9 +27,9 @@ export function HttpExceptionBoundary({ components, children, }) {
27
27
  if (!isHttpException(err))
28
28
  throw err;
29
29
  if ('digest' in err && typeof err.digest === 'string') {
30
- const [type, ...rest] = err.digest.split(':');
30
+ // split with care, the message part may contain colons
31
+ const [type, code] = err.digest.split(':');
31
32
  if (type === HTTP_EXCEPTION_DIGEST_PREFIX) {
32
- const [code] = rest;
33
33
  const status = Number(code);
34
34
  if (!isSupportedStatusCode(status))
35
35
  throw err;
@@ -5,7 +5,7 @@ type Props = {
5
5
  /**
6
6
  * A link component that navigates to a given href
7
7
  * @param href - the href to navigate to
8
- * @param prefetch - when to prefetch the linked page, defaults to 'intent'
8
+ * @param prefetch - when to prefetch the linked page, defaults to 'none'
9
9
  * @param rest - other props to pass to the underlying anchor element
10
10
  * @returns a link element that navigates to the given href
11
11
  */
@@ -1,17 +1,32 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useRef } from 'react';
3
+ import { useEffect, useRef } from 'react';
4
4
  import { useRouter } from '../router/use-router';
5
+ function guard(path, prefetcher) {
6
+ const connection = window.navigator.connection;
7
+ if (document.visibilityState === 'hidden')
8
+ return;
9
+ if (connection?.saveData)
10
+ return;
11
+ if (['2g', 'slow-2g'].includes(connection?.effectiveType ?? ''))
12
+ return;
13
+ prefetcher(path);
14
+ }
5
15
  /**
6
16
  * A link component that navigates to a given href
7
17
  * @param href - the href to navigate to
8
- * @param prefetch - when to prefetch the linked page, defaults to 'intent'
18
+ * @param prefetch - when to prefetch the linked page, defaults to 'none'
9
19
  * @param rest - other props to pass to the underlying anchor element
10
20
  * @returns a link element that navigates to the given href
11
21
  */
12
- export function Link({ children, href, prefetch = 'intent', ...rest }) {
22
+ export function Link({ children, href, prefetch = 'none', ...rest }) {
13
23
  const { go, prefetch: prefetcher } = useRouter();
14
24
  const timer = useRef(null);
25
+ // clear any pending hover-prefetch timer on unmount
26
+ useEffect(() => () => {
27
+ if (timer.current)
28
+ clearTimeout(timer.current);
29
+ }, []);
15
30
  return (_jsx("a", { ...rest, href: href, onClick: e => {
16
31
  rest.onClick?.(e);
17
32
  if (e.defaultPrevented)
@@ -36,14 +51,14 @@ export function Link({ children, href, prefetch = 'intent', ...rest }) {
36
51
  return;
37
52
  if (prefetch !== 'intent')
38
53
  return;
39
- prefetcher(href);
54
+ guard(href, prefetcher);
40
55
  }, onTouchStart: e => {
41
56
  rest.onTouchStart?.(e);
42
57
  if (e.defaultPrevented)
43
58
  return;
44
59
  if (prefetch !== 'intent')
45
60
  return;
46
- prefetcher(href);
61
+ guard(href, prefetcher);
47
62
  }, onMouseEnter: e => {
48
63
  rest.onMouseEnter?.(e);
49
64
  if (e.defaultPrevented)
@@ -51,7 +66,7 @@ export function Link({ children, href, prefetch = 'intent', ...rest }) {
51
66
  if (prefetch !== 'hover')
52
67
  return;
53
68
  timer.current = setTimeout(() => {
54
- prefetcher(href);
69
+ guard(href, prefetcher);
55
70
  }, 100);
56
71
  }, onMouseLeave: e => {
57
72
  rest.onMouseLeave?.(e);
@@ -28,10 +28,12 @@ export function RedirectBoundary({ children }) {
28
28
  if (!isRedirect(err))
29
29
  throw err;
30
30
  if ('digest' in err && typeof err.digest === 'string') {
31
- const [type, ...rest] = err.digest.split(':');
31
+ // rejoin after status so urls with colons (https://...) stay intact
32
+ const [type, , ...parts] = err.digest.split(':');
32
33
  if (type === REDIRECT_DIGEST_PREFIX) {
33
- const [, url] = rest;
34
- return _jsx("meta", { httpEquiv: "refresh", content: `0;url=${url}` });
34
+ const url = parts.join(':');
35
+ if (url)
36
+ return _jsx("meta", { httpEquiv: "refresh", content: `0;url=${url}` });
35
37
  }
36
38
  }
37
39
  return null;
@@ -7,6 +7,7 @@ export class Redirect extends Error {
7
7
  status;
8
8
  digest;
9
9
  constructor(url, status = 307) {
10
+ validate(url);
10
11
  super(`Redirecting to ${url} with status ${status}`);
11
12
  this.url = url;
12
13
  this.status = status;
@@ -58,6 +59,5 @@ export function isRedirect(err) {
58
59
  * @param status - the HTTP status code for the redirect, defaults to 307
59
60
  */
60
61
  export function redirect(url, status = 307) {
61
- validate(url);
62
62
  throw new Redirect(url, status);
63
63
  }
@@ -3,7 +3,7 @@ import { compile } from 'path-to-regexp';
3
3
  import { Solas } from '../solas';
4
4
  import { Logger } from '../utils/logger';
5
5
  import { Time } from '../utils/time';
6
- import { toPathPattern } from './router/pattern';
6
+ import { toPathPattern } from './router/utils';
7
7
  const logger = new Logger();
8
8
  export { Prerender };
9
9
  var Prerender;
@@ -211,20 +211,22 @@ var Prerender;
211
211
  artifactMetadata.mode === mode);
212
212
  }
213
213
  Artifact.isCompatible = isCompatible;
214
+ // shared instances, both are stateless so one per module is fine
215
+ const encoder = new TextEncoder();
216
+ const decoder = new TextDecoder();
214
217
  /**
215
218
  * Compose the prelude HTML and the resume stream into a single HTML stream, by injecting the resume stream
216
219
  * into the prelude at the appropriate location (before </body> or </html>)
217
220
  */
218
221
  function composePreludeAndResume(prelude, resumeStream) {
219
- const lower = prelude.toLowerCase();
220
- const bodyClose = lower.lastIndexOf('</body>');
221
- const htmlClose = lower.lastIndexOf('</html>');
222
- const splitAt = bodyClose >= 0 && htmlClose > bodyClose ? bodyClose : prelude.length;
222
+ // search both cases to avoid duplicating the full string with toLowerCase
223
+ const bodyClose = Math.max(prelude.lastIndexOf('</body>'), prelude.lastIndexOf('</BODY>'));
224
+ const htmlClose = Math.max(prelude.lastIndexOf('</html>'), prelude.lastIndexOf('</HTML>'));
225
+ const splitAt = bodyClose >= 0 ? bodyClose : htmlClose >= 0 ? htmlClose : prelude.length;
223
226
  return new ReadableStream({
224
227
  async start(controller) {
225
- const encoder = new TextEncoder();
226
- const decoder = new TextDecoder();
227
- controller.enqueue(new TextEncoder().encode(prelude.slice(0, splitAt)));
228
+ // send everything before the closing tags so the resume stream can be injected
229
+ controller.enqueue(encoder.encode(prelude.slice(0, splitAt)));
228
230
  const reader = resumeStream.getReader();
229
231
  let strippedLeadingClose = false;
230
232
  try {
@@ -12,7 +12,8 @@ export class Prefetcher {
12
12
  */
13
13
  static key(path, base) {
14
14
  const url = new URL(path, base);
15
- return url.pathname + url.search + url.hash;
15
+ // hash is client-only and never sent to the server, so exclude it
16
+ return url.pathname + url.search;
16
17
  }
17
18
  /**
18
19
  * Evicts the oldest entry from the cache
@@ -47,6 +47,21 @@ export class Resolver {
47
47
  }
48
48
  return 200;
49
49
  }
50
+ /**
51
+ * Find a manifest entry by path, trying both with and without a trailing slash
52
+ */
53
+ static #getEntryByPath(manifest, path) {
54
+ const direct = manifest[path];
55
+ if (direct)
56
+ return direct;
57
+ if (path !== '/' && path.endsWith('/')) {
58
+ return manifest[path.slice(0, -1)];
59
+ }
60
+ return manifest[`${path}/`];
61
+ }
62
+ /**
63
+ * Merge the cached enhanced match with the params and error from this request's match
64
+ */
50
65
  static #withRequestState(cached, match) {
51
66
  // the cached match only stores route structure, while params and errors
52
67
  // still belong to this request so merge them back in here
@@ -111,7 +126,7 @@ export class Resolver {
111
126
  */
112
127
  reconcile(path, match, error) {
113
128
  if (match) {
114
- const entry = Resolver.narrow(this.#manifest[match.route.path]);
129
+ const entry = Resolver.narrow(Resolver.#getEntryByPath(this.#manifest, match.route.path));
115
130
  if (entry) {
116
131
  // normal case, the router matched a page route so just attach request state
117
132
  return {
@@ -54,21 +54,31 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
54
54
  // and return early
55
55
  if (navigationId !== id.current)
56
56
  return path;
57
- const res = await createFromFetch(promise);
57
+ // we need both the parsed payload and the final response url because
58
+ // redirects can change the canonical path we should store in history
59
+ const [res, payload] = await Promise.all([
60
+ promise,
61
+ createFromFetch(promise),
62
+ ]);
63
+ // use the final response url so client history matches server redirects
64
+ const resolvedPath = Prefetcher.key(res.url, window.location.origin);
58
65
  // check again if another navigation has started while we were awaiting
59
66
  // the response
60
67
  if (navigationId !== id.current)
61
- return path;
68
+ return resolvedPath;
62
69
  // this state update is already wrapped in a
63
70
  // transition before being passed as props
64
- setPayload?.(res);
71
+ setPayload?.(payload);
65
72
  if (replace) {
66
- window.history.replaceState(null, '', path);
73
+ window.history.replaceState(null, '', resolvedPath);
67
74
  }
68
75
  else {
69
- window.history.pushState(null, '', path);
76
+ window.history.pushState(null, '', resolvedPath);
70
77
  }
71
- window.dispatchEvent(new CustomEvent(Solas.Events.names.NAVIGATION, { detail: { path } }));
78
+ window.dispatchEvent(new CustomEvent(Solas.Events.names.NAVIGATION, {
79
+ detail: { path: resolvedPath },
80
+ }));
81
+ return resolvedPath;
72
82
  }
73
83
  catch (err) {
74
84
  if (err instanceof Error && err.name === 'AbortError') {
@@ -96,18 +106,10 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
96
106
  return path;
97
107
  }, [setPayload]);
98
108
  /**
99
- * Prefetch a route's assets by fetching the RSC payload
109
+ * Prefetch a route's RSC payload
100
110
  * @param path the route path to prefetch (absolute or relative to origin)
101
- * @returns void
102
111
  */
103
112
  const prefetch = useCallback((path) => {
104
- const connection = window.navigator.connection;
105
- if (document.visibilityState === 'hidden')
106
- return;
107
- if (connection?.saveData)
108
- return;
109
- if (['2g', 'slow-2g'].includes(connection?.effectiveType ?? ''))
110
- return;
111
113
  const key = Prefetcher.key(path, window.location.origin);
112
114
  if (prefetcher.has(key))
113
115
  return;
@@ -23,7 +23,7 @@ export declare namespace Router {
23
23
  params: Params;
24
24
  };
25
25
  type Options = {
26
- trailingSlash?: boolean;
26
+ trailingSlash?: NonNullable<PluginConfig['trailingSlash']>;
27
27
  };
28
28
  type Registry = {
29
29
  static: Map<string, Route>;
@@ -1,9 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { match as createMatch } from 'path-to-regexp';
3
3
  import { Solas } from '../../solas';
4
+ import { getAlternatePathname, normalisePathname, toPathPattern } from './utils';
4
5
  import { maybeActionWithParsedFormData } from '../env/rsc';
5
6
  import { HttpException } from '../navigation/http-exception';
6
- import { toPathPattern } from './pattern';
7
7
  /**
8
8
  * Handle routing and matching for server requests
9
9
  */
@@ -49,7 +49,12 @@ export class Router {
49
49
  * Register a route handler
50
50
  */
51
51
  add(path, method, handler, params, middleware = []) {
52
- const segments = Router.#split(path);
52
+ // normalise static routes up front so trailingSlash matching
53
+ // uses the same pathname shape
54
+ const routePath = !path.includes(':') && !path.includes('*')
55
+ ? normalisePathname(path, this.opts.trailingSlash ?? 'never')
56
+ : path;
57
+ const segments = Router.#split(routePath);
53
58
  const tokens = [];
54
59
  let score = 0;
55
60
  let wildcard = false;
@@ -70,7 +75,7 @@ export class Router {
70
75
  score += 2;
71
76
  }
72
77
  const route = {
73
- path,
78
+ path: routePath,
74
79
  method: method.toUpperCase(),
75
80
  handler,
76
81
  middleware: [...middleware],
@@ -81,7 +86,7 @@ export class Router {
81
86
  };
82
87
  // static route, easy map set
83
88
  if (!path.includes(':') && !path.includes('*')) {
84
- this.#routes.static.set(`${route.method}:${path}`, route);
89
+ this.#routes.static.set(`${route.method}:${route.path}`, route);
85
90
  return this;
86
91
  }
87
92
  // wildcard route, push to end of list
@@ -117,15 +122,15 @@ export class Router {
117
122
  * Match a path and method, returning params and route
118
123
  */
119
124
  match(path, method) {
120
- const direct = this.#routes.static.get(`${method}:${path}`);
121
- // direct match - quick return
122
- if (direct)
123
- return { route: direct, params: {} };
124
- // HEAD falls back to GET when HEAD is not explicitly defined
125
- if (method === 'HEAD') {
126
- const directGet = this.#routes.static.get(`GET:${path}`);
127
- if (directGet)
128
- return { route: directGet, params: {} };
125
+ for (const candidate of Router.#candidates(path)) {
126
+ const direct = this.#routes.static.get(`${method}:${candidate}`);
127
+ if (direct)
128
+ return { route: direct, params: {} };
129
+ if (method === 'HEAD') {
130
+ const directGet = this.#routes.static.get(`GET:${candidate}`);
131
+ if (directGet)
132
+ return { route: directGet, params: {} };
133
+ }
129
134
  }
130
135
  // else dynamic/wildcard match
131
136
  const segments = Router.#split(path);
@@ -157,10 +162,18 @@ export class Router {
157
162
  */
158
163
  async fetch(req) {
159
164
  const url = new URL(req.url);
160
- const path = Router.#normalise(url.pathname, this.opts.trailingSlash);
165
+ const trailingSlash = this.opts.trailingSlash ?? 'never';
166
+ const path = trailingSlash === 'ignore'
167
+ ? url.pathname
168
+ : normalisePathname(url.pathname, trailingSlash);
161
169
  let match = null;
162
170
  let action = false;
163
171
  try {
172
+ const method = req.method.toUpperCase();
173
+ if ((method === 'GET' || method === 'HEAD') && path !== url.pathname) {
174
+ url.pathname = path;
175
+ return Response.redirect(url.toString(), 308);
176
+ }
164
177
  if (path !== url.pathname) {
165
178
  // rebuild the request with the canonical pathname so downstream code
166
179
  // sees the same url the router matched against
@@ -169,7 +182,6 @@ export class Router {
169
182
  }
170
183
  const { action: isAction, formData: parsedFormData } = await maybeActionWithParsedFormData(req);
171
184
  action = isAction;
172
- const method = req.method.toUpperCase();
173
185
  // action requests stay on the same pathname only the method is
174
186
  // normalised to GET this lets page/layout routes match for
175
187
  // rerender action execution still reads POST body and
@@ -218,7 +230,17 @@ export class Router {
218
230
  for (let i = stack.length - 1; i >= 0; i -= 1) {
219
231
  const handler = stack[i];
220
232
  const prev = run;
221
- run = () => Promise.resolve(handler(req, prev));
233
+ run = () => {
234
+ let called = false;
235
+ return Promise.resolve(handler(req, () => {
236
+ // guard against double invocation so handlers/inner middleware
237
+ // only execute once per request
238
+ if (called)
239
+ throw new Error('next() called more than once');
240
+ called = true;
241
+ return prev();
242
+ }));
243
+ };
222
244
  }
223
245
  // run composed middleware stack
224
246
  return run();
@@ -292,15 +314,10 @@ export class Router {
292
314
  /**
293
315
  * Normalise a path based on router options
294
316
  */
295
- static #normalise(path, trailingSlash = true) {
296
- if (!trailingSlash) {
297
- // collapse non-root trailing slashes when the router runs in slashless mode
298
- return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
299
- }
317
+ static #candidates(path) {
300
318
  if (path === '/')
301
- return path;
302
- // otherwise make non-root paths canonical with a trailing slash
303
- return path.endsWith('/') ? path : `${path}/`;
319
+ return ['/'];
320
+ return [path, getAlternatePathname(path)];
304
321
  }
305
322
  /**
306
323
  * Split a path into segments
@@ -0,0 +1,21 @@
1
+ import type { Route } from '../../types';
2
+ export type PathPattern = {
3
+ path: string;
4
+ wildcardNames: Set<string>;
5
+ };
6
+ /**
7
+ * Convert an internal route string into a path-to-regexp pattern and collect
8
+ * the wildcard param names used in that pattern
9
+ */
10
+ export declare function toPathPattern(route: string, paramNames?: string[]): {
11
+ path: string;
12
+ wildcardNames: Set<string>;
13
+ };
14
+ /**
15
+ * Apply the configured trailing-slash policy to a pathname
16
+ */
17
+ export declare function normalisePathname(pathname: string, trailingSlash?: Route.TrailingSlash): string;
18
+ /**
19
+ * Return the other pathname shape for a non-root route
20
+ */
21
+ export declare function getAlternatePathname(pathname: string): string;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Escape a literal path segment so it can be used safely in a
3
+ * path-to-regexp pattern
4
+ */
5
+ function escapePathSegment(value) {
6
+ return value.replace(/[\\.+*?^${}()[\]|!:]/g, '\\$&');
7
+ }
8
+ /**
9
+ * Convert an internal route string into a path-to-regexp pattern and collect
10
+ * the wildcard param names used in that pattern
11
+ */
12
+ export function toPathPattern(route, paramNames = []) {
13
+ if (route === '/') {
14
+ return { path: route, wildcardNames: new Set() };
15
+ }
16
+ let paramIndex = 0;
17
+ let wildcardIndex = 0;
18
+ const wildcardNames = new Set();
19
+ const path = route
20
+ .split('/')
21
+ .filter(Boolean)
22
+ .map(segment => {
23
+ if (segment.startsWith(':')) {
24
+ paramIndex += 1;
25
+ return `/${segment}`;
26
+ }
27
+ if (segment === '*') {
28
+ // reuse the discovered param name when we have one so wildcard params
29
+ // line up with the generated route pattern
30
+ const value = paramNames[paramIndex];
31
+ const name = value && value !== '*' ? value : `wildcard${wildcardIndex}`;
32
+ paramIndex += 1;
33
+ wildcardIndex += 1;
34
+ wildcardNames.add(name);
35
+ return `/*${name}`;
36
+ }
37
+ return `/${escapePathSegment(segment)}`;
38
+ })
39
+ .join('');
40
+ return { path: path || '/', wildcardNames };
41
+ }
42
+ /**
43
+ * Apply the configured trailing-slash policy to a pathname
44
+ */
45
+ export function normalisePathname(pathname, trailingSlash = 'never') {
46
+ if (pathname === '/')
47
+ return pathname;
48
+ // ignore mode keeps the incoming pathname shape as-is
49
+ if (trailingSlash === 'ignore')
50
+ return pathname;
51
+ if (trailingSlash === 'always')
52
+ return pathname.endsWith('/') ? pathname : `${pathname}/`;
53
+ return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
54
+ }
55
+ /**
56
+ * Return the other pathname shape for a non-root route
57
+ */
58
+ export function getAlternatePathname(pathname) {
59
+ if (pathname === '/')
60
+ return pathname;
61
+ return pathname.endsWith('/') ? pathname.slice(0, -1) : `${pathname}/`;
62
+ }
@@ -7,10 +7,11 @@ import { dynamic } from './dynamic';
7
7
  export function url() {
8
8
  dynamic();
9
9
  const { req, cache } = RequestContext.use();
10
- // use request cache if possible
10
+ // always return a clone so consumers can mutate (e.g. searchParams.set)
11
+ // without corrupting the cached instance shared across the request
11
12
  if (cache.url)
12
- return cache.url;
13
+ return new URL(cache.url);
13
14
  const parsed = new URL(req.url);
14
15
  cache.url = parsed;
15
- return parsed;
16
+ return new URL(parsed);
16
17
  }
@@ -2,5 +2,5 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  export default function Err({ error }) {
3
3
  const title = 'status' in error ? `${error.status} - ${error.message}` : error.message;
4
4
  return (_jsxs(_Fragment, { children: [
5
- _jsx("meta", { name: "robots", content: "noindex,nofollow" }), _jsx("title", { children: title }), _jsx("h1", { children: title }), _jsx("p", { children: error.message }), error?.stack && _jsx("pre", { children: error.stack })] }));
5
+ _jsx("meta", { name: "robots", content: "noindex,nofollow" }), _jsx("title", { children: title }), _jsx("h1", { children: title }), _jsx("p", { children: error.message }), process.env.NODE_ENV === 'development' && error?.stack && (_jsx("pre", { children: error.stack }))] }));
6
6
  }
package/dist/solas.d.ts CHANGED
@@ -15,6 +15,7 @@ export declare namespace Solas {
15
15
  const REQUEST_META: string;
16
16
  const LOG_LEVELS: readonly ["debug", "info", "warn", "error", "fatal"];
17
17
  const PRERENDER_MODES: readonly ["full", "ppr", false];
18
+ const TRAILING_SLASH_MODES: readonly ["always", "never", "ignore"];
18
19
  /**
19
20
  * Validate the plugin configuration object, throwing an error if invalid
20
21
  * @param input - the unvalidated configuration object
package/dist/solas.js CHANGED
@@ -17,11 +17,13 @@ var Solas;
17
17
  Config.REQUEST_META = `__${Config.SLUG.toUpperCase()}__`;
18
18
  Config.LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'fatal'];
19
19
  Config.PRERENDER_MODES = ['full', 'ppr', false];
20
+ Config.TRAILING_SLASH_MODES = ['always', 'never', 'ignore'];
20
21
  const CONFIG_KEYS = new Set([
21
22
  'logger',
22
23
  'metadata',
23
24
  'precompress',
24
25
  'prerender',
26
+ 'sitemap',
25
27
  'trailingSlash',
26
28
  'url',
27
29
  ]);
@@ -64,14 +66,28 @@ var Solas;
64
66
  errors.push('config.precompress must be a boolean');
65
67
  }
66
68
  }
69
+ if ('sitemap' in input && input.sitemap !== undefined && input.sitemap !== false) {
70
+ if (typeof input.sitemap !== 'boolean' && typeof input.sitemap !== 'object') {
71
+ errors.push('config.sitemap must be a boolean or an object with a routes function');
72
+ }
73
+ if (typeof input.sitemap === 'object' &&
74
+ input.sitemap !== null &&
75
+ typeof input.sitemap.routes !== 'function') {
76
+ errors.push('config.sitemap.routes must be a function');
77
+ }
78
+ if (!input.url) {
79
+ errors.push('config.url is required when sitemap is enabled');
80
+ }
81
+ }
67
82
  if ('prerender' in input && input.prerender !== undefined) {
68
83
  if (!new Set(Config.PRERENDER_MODES).has(input.prerender)) {
69
84
  errors.push("config.prerender must be 'full', 'ppr', or false");
70
85
  }
71
86
  }
72
87
  if ('trailingSlash' in input && input.trailingSlash !== undefined) {
73
- if (typeof input.trailingSlash !== 'boolean') {
74
- errors.push('config.trailingSlash must be a boolean');
88
+ if (typeof input.trailingSlash !== 'string' ||
89
+ !new Set(Config.TRAILING_SLASH_MODES).has(input.trailingSlash)) {
90
+ errors.push("config.trailingSlash must be 'always', 'never', or 'ignore'");
75
91
  }
76
92
  }
77
93
  if ('metadata' in input &&
package/dist/types.d.ts CHANGED
@@ -8,23 +8,32 @@ import type { Metadata } from './internal/metadata';
8
8
  import type { HttpException } from './internal/navigation/http-exception';
9
9
  import type { Router } from './internal/router/router';
10
10
  export type LogLevel = (typeof Solas.Config.LOG_LEVELS)[number];
11
- export type PluginConfig = {
12
- url?: `http://${string}` | `https://${string}`;
11
+ type PluginConfigBase = {
13
12
  port?: number;
14
13
  precompress?: boolean;
15
14
  prerender?: Route.Prerender;
16
15
  metadata?: Metadata.Item;
17
- trailingSlash?: boolean;
16
+ trailingSlash?: Route.TrailingSlash;
18
17
  readonly logger?: {
19
18
  level?: LogLevel;
20
19
  };
21
20
  };
21
+ export type PluginConfig = PluginConfigBase & ({
22
+ url: `http://${string}` | `https://${string}`;
23
+ sitemap: true | {
24
+ routes: (existing: string[]) => string[] | Promise<string[]>;
25
+ };
26
+ } | {
27
+ url?: `http://${string}` | `https://${string}`;
28
+ sitemap?: false;
29
+ });
22
30
  export type RuntimeConfig = PluginConfig & {
23
31
  precompress: NonNullable<PluginConfig['precompress']>;
24
32
  trailingSlash: NonNullable<PluginConfig['trailingSlash']>;
25
33
  };
26
34
  export type BuildContext = {
27
- prerenderedRoutes: Set<string>;
35
+ prerenderRoutes: Set<string>;
36
+ knownRoutes: Set<string>;
28
37
  exportReader: ExportReader;
29
38
  };
30
39
  export type SolasRequest = Request & {};
@@ -80,12 +89,16 @@ export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' |
80
89
  export type Primitive = string | number | boolean | bigint | symbol | null | undefined;
81
90
  export type LooseNumber<T extends number> = T | (number & {});
82
91
  export type BuildManifest = {
83
- prerenderedRoutes: string[];
92
+ prerenderRoutes: string[];
93
+ sitemapRoutes: string[];
84
94
  precompress: boolean;
95
+ trailingSlash: Route.TrailingSlash;
96
+ url?: PluginConfig['url'];
85
97
  };
86
98
  export declare namespace Route {
87
99
  type Metadata = Metadata.Item | ((input: Metadata.Input<Router.Params>) => Promise<Metadata.Item> | Metadata.Item);
88
100
  type Prerender = (typeof Solas.Config.PRERENDER_MODES)[number];
101
+ type TrailingSlash = (typeof Solas.Config.TRAILING_SLASH_MODES)[number];
89
102
  }
90
103
  export type BoundaryError = Error & {
91
104
  digest?: string;
@@ -35,8 +35,17 @@ export class Logger {
35
35
  if (err instanceof Error || err instanceof HttpException) {
36
36
  return err.message + (err.stack ? `\n${err.stack}` : '');
37
37
  }
38
+ // for plain objects, attempt to stringify with indentation
39
+ // for readability
38
40
  if (typeof err === 'object' && err !== null) {
39
- return JSON.stringify(err, null, 2);
41
+ try {
42
+ return JSON.stringify(err, null, 2);
43
+ }
44
+ catch {
45
+ // if stringify fails (e.g. circular reference), fall back
46
+ // to basic string conversion
47
+ return String(err);
48
+ }
40
49
  }
41
50
  return String(err);
42
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A React Server Components meta-framework powered by Vite",
5
5
  "keywords": [
6
6
  "framework",
@@ -61,6 +61,10 @@
61
61
  "types": "./dist/server.d.ts",
62
62
  "import": "./dist/server.js"
63
63
  },
64
+ "./utils/logger": {
65
+ "types": "./dist/utils/logger.d.ts",
66
+ "import": "./dist/utils/logger.js"
67
+ },
64
68
  "./error-boundary": {
65
69
  "types": "./dist/error-boundary.d.ts",
66
70
  "import": "./dist/error-boundary.js"
@@ -1,8 +0,0 @@
1
- export type PathPattern = {
2
- path: string;
3
- wildcardNames: Set<string>;
4
- };
5
- export declare function toPathPattern(route: string, paramNames?: string[]): {
6
- path: string;
7
- wildcardNames: Set<string>;
8
- };
@@ -1,31 +0,0 @@
1
- function escapePathSegment(value) {
2
- return value.replace(/[\\.+*?^${}()[\]|!:]/g, '\\$&');
3
- }
4
- export function toPathPattern(route, paramNames = []) {
5
- if (route === '/') {
6
- return { path: route, wildcardNames: new Set() };
7
- }
8
- let paramIndex = 0;
9
- let wildcardIndex = 0;
10
- const wildcardNames = new Set();
11
- const path = route
12
- .split('/')
13
- .filter(Boolean)
14
- .map(segment => {
15
- if (segment.startsWith(':')) {
16
- paramIndex += 1;
17
- return `/${segment}`;
18
- }
19
- if (segment === '*') {
20
- const value = paramNames[paramIndex];
21
- const name = value && value !== '*' ? value : `wildcard${wildcardIndex}`;
22
- paramIndex += 1;
23
- wildcardIndex += 1;
24
- wildcardNames.add(name);
25
- return `/*${name}`;
26
- }
27
- return `/${escapePathSegment(segment)}`;
28
- })
29
- .join('');
30
- return { path: path || '/', wildcardNames };
31
- }