hono-svelte 0.5.0 → 0.6.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,139 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+
5
+ - **fix(shell): nested layouts nest outermost first.** With a root
6
+ `layout.svelte` plus `<dir>/layout.svelte`, the server wrapped the page in
7
+ the root layout first, so the directory layout ended up outside.
8
+ - **fix(vite): client pages mount the whole layout chain.** The client entry
9
+ imported every layout but only mounted `Layout0`; inner layouts vanished.
10
+ Client entries are never server-rendered, so the chain is now mounted
11
+ (no hydration of an empty root).
12
+ - **fix(vite): components imported by static pages and layouts keep their
13
+ `<style>`.** Only the page/layout files were compiled with
14
+ `css: "injected"` for the server; a layout's nav or a component-library
15
+ component lost its CSS on zero-JS pages.
16
+ - **fix(handlers): `errorHandler` keeps the headers of `HTTPException`
17
+ responses** — `basicAuth`/`bearerAuth` 401s now carry `WWW-Authenticate`.
18
+ - **fix(shell): prefetch.** `prefetch: "all"` no longer preloads static pages
19
+ (no JS chunk → 404); `"hover"` dedupes its links (it looked for
20
+ `rel="prefetch"` but created `modulepreload`) and maps `/` → `index` and
21
+ `/dir` → `dir/index`.
22
+ - **fix(shell):** the global stylesheet fallback follows `assetsBase`; a
23
+ throwing `nonce()` is logged instead of silently dropping the CSP nonce.
24
+ - **fix(vite):** `pagesDir` outside the Vite root (including another drive
25
+ on Windows) fails with a clear English error.
26
+ - **test:** `test/regressions.test.ts` (one test per fix) and a Playwright
27
+ contract suite in `e2e/` that runs the same browser tests against
28
+ hono-svelte on Node, on Bun, and against SvelteKit as the reference. CI
29
+ adds the e2e job and a peer-dependency matrix (declared minimums and
30
+ latest within the majors).
31
+
32
+ ## 0.6.0
33
+
34
+ - **feat(shell): `htmlAttributes` option.** Only `lang` could be set on
35
+ `<html>`, so themes driven by a root attribute (DaisyUI `data-theme`,
36
+ `class="dark"`) needed CSS workarounds. `htmlAttributes` takes a record
37
+ or `(c) => record` (per-request, e.g. from a cookie); values are escaped,
38
+ invalid names (and `lang`, which has its own option) throw.
39
+
40
+ - **feat(handlers): error pages get `{ status, message }`; JSON for API
41
+ paths.** `notFoundHandler`/`errorHandler` rendered the page without any
42
+ data, always with 404/500, and had no way to keep API routes on JSON.
43
+ Now: pages receive `{ status, message }` (merged under `opts.data`);
44
+ `errorHandler` uses the status carried by the error (`HTTPException`
45
+ and friends), exposes the error message only for 4xx (`expose` to
46
+ customize, `message` for the generic text) and logs only 5xx; new
47
+ `json: (c) => boolean` answers `{ message }` JSON with the same status.
48
+ `notFoundHandler` takes a `message`. `ErrorPageData` type exported.
49
+
50
+ - **feat: `getPage()` from `hono-svelte/page`.** Layouts had no way to
51
+ know which route rendered them (active-link logic needed the route
52
+ threaded through every page's `data`). `getPage()` returns `{ path,
53
+ route }` (request pathname + Hono route pattern, e.g. `/users/:id`) in
54
+ server-rendered components and client pages alike: the shell provides it
55
+ as Svelte context on SSR and writes `data-hs-path`/`data-hs-route` on the
56
+ root element, which the generated client entry passes as `context` to
57
+ `mount`/`hydrate`. New `./page` export; `PageInfo` type exported from the
58
+ main entry.
59
+
60
+ - **feat(shell): global stylesheet resolved from the manifest
61
+ (`stylesEntry`).** With `assets: manifest`, the global CSS kept pointing at
62
+ the unhashed `/static/styles.css`, so hashed client builds needed a
63
+ hand-written `stylesHref: "/" + manifest["src/styles.css"].file`. The
64
+ shell now resolves it from the manifest. New `stylesEntry` option
65
+ (default `"src/styles.css"`) names the source file for both modes: dev
66
+ links `/<stylesEntry>`, prod the hashed file (fallback
67
+ `/static/styles.css`). `stylesHref` still wins when set.
68
+
69
+ - **feat(types): `Entry` type exported from `hono-svelte`.** The generated
70
+ `.d.ts` now also augments the package (`interface Register { entries }`),
71
+ so helpers that forward an entry to `c.render` can be typed with
72
+ `import type { Entry } from "hono-svelte"` instead of importing the
73
+ generated file by relative path. Without the `.d.ts`, `Entry` is
74
+ `string`. A new `test/types.test.ts` runs `tsc` against the generated
75
+ declarations.
76
+
77
+ - **fix(shell): the page's `<svelte:head>` title and description win.**
78
+ The shell always emitted its default `<title>` first and browsers use the
79
+ first one, so a static page's `<svelte:head><title>` was ignored (and
80
+ `shell({ head })` descriptions were duplicated). The document now keeps a
81
+ single title — `c.render(entry, { title })` > page/layout `<svelte:head>`
82
+ > `shell({ title })` — and a page description (`<svelte:head>` or
83
+ `head.description`) removes the one from `shell({ head })`.
84
+
85
+ - **fix(assets): CSS of shared chunks is linked in production.** Vite
86
+ attaches a module's CSS to the chunk that owns it, so a layout or
87
+ component shared by several pages gets its own chunk + CSS file.
88
+ `manifestCssFor`/`manifestImportsFor` (and therefore `shell({ assets:
89
+ manifest })`) only read the entry chunk: pages rendered without their
90
+ layout's styles. Both now walk the static `imports` graph (shared chunks
91
+ first, cycles handled, `dynamicImports` skipped). `ViteManifestChunk`
92
+ gains the optional `src`, `name` and `dynamicImports` fields.
93
+ - **fix(vite): static pages and layouts keep their `<style>` after `vite
94
+ build`.** Server-rendered components have no client bundle, but with
95
+ vite-plugin-svelte's default `emitCss: true` the compiler emits their CSS
96
+ as an import that SSR drops — zero-JS pages shipped unstyled. `pages()`
97
+ now loads static pages and layouts for the server with
98
+ `css: "injected"` (merged into an existing `<svelte:options>`, explicit
99
+ `css` respected), so `render()` returns the styles in `head`. New
100
+ `test/build.test.ts` exercises the real `vite build --ssr` pipeline.
101
+
102
+ - **fix(scaffold): `isSafeAppPath` normalizes absolute targets.** An
103
+ absolute path such as `/app/../evil.ts` passed the in-root prefix check
104
+ on POSIX (only relative targets went through `resolve()`). Every target is
105
+ now resolved before the comparison.
106
+ - **CI green on main**: the Windows-only `isSafeAppPath` assertions run only
107
+ on win32 (drive letters are relative segments on POSIX); the nested-layout
108
+ test uses its temp dir as the Vite root (it only passed on Windows because
109
+ tmpdir sits on another drive); the `example` job installs Bun, which its
110
+ build script calls.
111
+
112
+ ## 0.5.1
113
+
114
+ - **Scaffold hardening** (`hono-svelte init`):
115
+ - the generated `package.json` pins the security floors
116
+ (`hono ^4.13.8`, `svelte ^5.57.0`, `vite ^8.3.0`) — the previous
117
+ `^4`/`^5`/`^8` ranges resolved versions with known advisories; a
118
+ regression test keeps the floors in sync with the package peers.
119
+ - `init` writes an initial `src/hono-svelte-entries.d.ts` stub
120
+ (plain-string renderer): `tsc` right after `init` used to fail on
121
+ `c.render(entry, props)` (2 args vs hono's 1-arg default renderer)
122
+ because the typed dts only exists after the first vite run;
123
+ `pages({ dts: true })` replaces the stub with the typed union on the
124
+ first `dev`/`build`.
125
+ - **`c.render()` coexistence contract**: 6 tests pin the shell against
126
+ hono's renderer semantics — `c.render(string)` (default renderer) is
127
+ reinterpreted as an entry name under the shell (strict 500); renderer
128
+ middlewares on the same path pattern are last-registered-wins; and
129
+ **path-scoped coexistence works**: default-render routes and
130
+ jsxRenderer-style middlewares keep working next to a `shell()` mounted on
131
+ its own path (`app.use("/pages/*", shell())`). Documented in the README
132
+ ("Coexistence with other renderers").
133
+ - **CI**: new `scaffold` job smoke-tests `init --full` end to end (install
134
+ with the local package via `file:` → typecheck → build), guarding the
135
+ inline templates against drift from the (unpublished) playground example.
136
+
3
137
  ## 0.5.0
4
138
 
5
139
  - **Per-page data typing (opt-in, `pages({ dts: true })`)**: declare
package/README.md CHANGED
@@ -115,6 +115,8 @@ app.get("/dashboard", (c) => c.render("dashboard", { data: { plan: "pro" } }));
115
115
 
116
116
  The rule is simple: **if the `.svelte` file has no `<script>`, the page ships as plain HTML. If it has one, it hydrates on the client** with initial data available via `$props()`.
117
117
 
118
+ Server-rendered components (static pages and the layouts wrapping them) have no client bundle, so `pages()` compiles them for the server with `css: "injected"`: their `<style>` blocks arrive inline in the `<head>`. An explicit `<svelte:options css="...">` in the component wins.
119
+
118
120
  Files starting with `_` and `layout.svelte` are ignored (convention for partials and layouts).
119
121
 
120
122
  ## Passing data to the page
@@ -148,7 +150,7 @@ app.get("/post", (c) =>
148
150
  );
149
151
  ```
150
152
 
151
- `<svelte:head>` inside pages also works (shell forwards SSR `head`). Page `head` never emits executable inline scripts (CSP-safe; `extra` is for trusted raw HTML like fonts or JSON-LD).
153
+ `<svelte:head>` inside pages also works (shell forwards SSR `head`). The document keeps a single `<title>` and `<meta name="description">`: `c.render(entry, { title })` > the page/layout `<svelte:head>` > `shell({ title })`, and a page description (`<svelte:head>` or `head.description`) replaces one set in `shell({ head })`. Page `head` never emits executable inline scripts (CSP-safe; `extra` is for trusted raw HTML like fonts or JSON-LD).
152
154
 
153
155
  ## Nested layouts
154
156
 
@@ -185,6 +187,26 @@ Layouts never become entries (`pages().layouts()` / `layoutChain(entry)` inspect
185
187
  > **Hydration wrapper:** each layout level wraps the page's SSR output in one `<div>` (a Svelte 5 raw-snippet requirement — an empty snippet render breaks hydration). The wrapper is hydration-safe, but it is part of the DOM: account for it in CSS selectors (e.g. `:global(> div)`).
186
188
 
187
189
 
190
+ ## Current page (`getPage`)
191
+
192
+ Pages and layouts can read the request path and the Hono route pattern that rendered them — e.g. to highlight the active link in a layout nav, ignoring params:
193
+
194
+ ```svelte
195
+ <!-- src/pages/dashboard/layout.svelte -->
196
+ <script lang="ts">
197
+ import type { Snippet } from "svelte";
198
+ import { getPage } from "hono-svelte/page";
199
+
200
+ let { children }: { children: Snippet } = $props();
201
+ const page = getPage(); // { path: "/dashboard/users/7", route: "/dashboard/users/:id" }
202
+ </script>
203
+
204
+ <a href="/dashboard/users" aria-current={page.route.startsWith("/dashboard/users") ? "page" : undefined}>Users</a>
205
+ {@render children()}
206
+ ```
207
+
208
+ It works in server-rendered components (static pages + layouts) and in client pages: the shell passes it as Svelte context on the server and writes it on the root element for the client entry. Call it during component initialisation, like `getContext`.
209
+
188
210
  ## Error pages
189
211
 
190
212
  ```ts
@@ -194,6 +216,25 @@ app.notFound(notFoundHandler()); // renders `404.svelte` with status 404
194
216
  app.onError(errorHandler()); // renders `500.svelte` with status 500
195
217
  ```
196
218
 
219
+ Both pages receive `{ status, message }` as props (merged under `data`). `errorHandler` takes the status from errors that carry one (hono's `HTTPException(410, { message })` → 410) and only exposes the error's own message for 4xx — 5xx get `message` (default `"Internal Server Error"`) and are the only ones logged. `json` answers `{ message }` JSON instead of the page, e.g. for API routes:
220
+
221
+ ```ts
222
+ const isApi = (c: Context) => c.req.path.startsWith("/api/");
223
+
224
+ app.notFound(notFoundHandler({ json: isApi, message: "Page not found" }));
225
+ app.onError(errorHandler({ json: isApi, expose: (err, status) => status < 500 }));
226
+ ```
227
+
228
+ ```svelte
229
+ <!-- src/pages/500.svelte -->
230
+ <script lang="ts">
231
+ let { status, message }: { status: number; message: string } = $props();
232
+ </script>
233
+
234
+ <h1>{status}</h1>
235
+ <p>{message}</p>
236
+ ```
237
+
197
238
  ## CLI
198
239
 
199
240
  ```sh
@@ -229,12 +270,14 @@ Hono middleware that provides `c.render(entry, { title?, data?, status?, headers
229
270
 
230
271
  | Option | Default | Description |
231
272
  |---|---|---|
232
- | `title` | `"App"` | Title used when the route doesn't provide one |
273
+ | `title` | `"App"` | Title used when neither `c.render(entry, { title })` nor the page's `<svelte:head>` provides one |
233
274
  | `lang` | `"en"` | `<html>` `lang` attribute |
275
+ | `htmlAttributes` | — | Extra `<html>` attributes (`{ "data-theme": "dark" }`) or `(c) => attrs` per request; values escaped |
234
276
  | `assetsBase` | `"/static"` | Prefix for JS files in production (legacy; prefer `assets`) |
235
277
  | `assets` | `"/static"` | String prefix, Vite manifest object, or resolver `(entry) => url` (hashed files) |
236
278
  | `isProd` | auto | Force prod/dev asset behavior |
237
- | `stylesHref` | `"/static/styles.css"` in prod, `"/src/styles.css"` in dev | Global stylesheet (or an `(isProd) => string` function) |
279
+ | `stylesHref` | from `stylesEntry` | Global stylesheet href (or an `(isProd) => string` function); overrides `stylesEntry` |
280
+ | `stylesEntry` | `"src/styles.css"` | Source of the global stylesheet: dev links `/<stylesEntry>`, prod resolves the hashed file from the manifest in `assets` (fallback `/static/styles.css`) |
238
281
  | `styles` | `[]` | Extra CSS hrefs emitted per response |
239
282
  | `head` | `""` | Extra HTML in `<head>` (fonts, meta tags) |
240
283
  | `nonce` | — | CSP nonce string or `(c) => string` (applied to shell `<script>`/`<link>`) |
@@ -249,6 +292,27 @@ Hono middleware that provides `c.render(entry, { title?, data?, status?, headers
249
292
 
250
293
  `shell()` also exposes `availableEntries()` for diagnostics.
251
294
 
295
+ #### Coexistence with other renderers
296
+
297
+ `c.render(entry)` rides on hono's renderer mechanism — one renderer per
298
+ request, **last-registered wins** (the same contract as hono's own
299
+ `hono/jsx` `jsx-renderer`). In practice:
300
+
301
+ - **Path-scoped coexistence works**: mount the shell only where pages live
302
+ (`app.use("/pages/*", shell())`, registered after broader renderer
303
+ middlewares) — existing routes using hono's default `c.render(string)` or
304
+ `jsxRenderer()` keep working on their own paths, each renderer serves its
305
+ routes (pinned by `test/render-conflicts.test.ts`).
306
+ - Two renderer middlewares on the **same pattern** (e.g. two shells on `/*`,
307
+ or shell + `jsxRenderer` on `/*`): the last one registered silently
308
+ replaces the earlier one — keep one renderer per path pattern.
309
+ - With more than one `shell()` on nested paths, register the **more
310
+ specific one last** — it then overrides the catch-all for its paths.
311
+ - `c.render("some string")` (hono's default string renderer) is reinterpreted
312
+ by the shell as an entry name — under `strict` it fails loudly with the
313
+ available pages list. Use `c.html(...)` for raw string responses, or scope
314
+ the shell away from those routes.
315
+
252
316
  ### `pages(options?)`
253
317
 
254
318
  Vite plugin (`hono-svelte/vite`) that discovers pages and generates client entries.
@@ -283,6 +347,12 @@ const appPages = pages({ dts: true }); // writes src/hono-svelte-entries.d.ts
283
347
  // generated src/hono-svelte-entries.d.ts (commit it — CI needs no Vite run)
284
348
  export type HonoSvelteEntries = "auth" | "dashboard/index" | "index";
285
349
 
350
+ declare module "hono-svelte" {
351
+ interface Register {
352
+ entries: HonoSvelteEntries;
353
+ }
354
+ }
355
+
286
356
  declare module "hono" {
287
357
  interface ContextRenderer {
288
358
  (entryName: HonoSvelteEntries, props?: import("hono-svelte").RenderProps): Response | Promise<Response>;
@@ -290,6 +360,14 @@ declare module "hono" {
290
360
  }
291
361
  ```
292
362
 
363
+ Helpers that forward an entry to `c.render` use the `Entry` type exported by the package — the generated union once the `.d.ts` is in the program, `string` without it. No import of the generated file needed:
364
+
365
+ ```ts
366
+ import type { Entry } from "hono-svelte";
367
+
368
+ export const page = (entry: Entry, title: string) => (c: Context) => c.render(entry, { title });
369
+ ```
370
+
293
371
  `dts: "path/to/entries.d.ts"` sets a custom path (relative to the Vite root). The file regenerates on `buildStart` and via the dev watcher when entries change. Without `dts` (default `false`), declare the renderer manually with a plain `string`:
294
372
 
295
373
  ```ts
@@ -348,9 +426,10 @@ extracted declarations.
348
426
 
349
427
  ## Production tips
350
428
 
351
- - Pass the Vite manifest to `shell({ assets: manifest })` for hashed files; manifest CSS and shared chunks are emitted automatically (`modulepreload`).
429
+ - Pass the Vite manifest to `shell({ assets: manifest })` for hashed files — including the global stylesheet: with `styles: resolve("src/styles.css")` in the client input (or `stylesEntry` pointing at another file), its hashed name comes from the manifest, no hand-written `stylesHref`. The shell walks the entry's static import graph: the CSS of shared chunks (layouts, components used by several pages) is linked together with the entry's own CSS, and every reachable chunk gets a `modulepreload`.
352
430
  - Serve hashed files with long cache and `immutable` (`immutableHeaders()` / `CACHE_IMMUTABLE`); the HTML shell itself suits `CACHE_NO_STORE`.
353
431
  - CSP: pass `shell({ nonce })` (string or from `secureHeaders()` context) — shell tags carry the nonce. Without a nonce, static pages keep working with `script-src 'self'` (no executable inline script, except the opt-in `prefetch: "hover"` helper).
432
+ - Theme attributes on the root element: `shell({ htmlAttributes: { "data-theme": "dark" } })` (DaisyUI), or a function reading a cookie for per-user themes.
354
433
  - The shell emits a neutral `<body>` (no framework classes) — style `body`/`html` from your global stylesheet (`stylesHref`), e.g. `body { @apply bg-base-200 min-h-screen text-base-content; }` with Tailwind/DaisyUI.
355
434
  - Run both client and server builds before serving; the example in `examples/playground/` shows the full setup.
356
435
 
@@ -362,6 +441,12 @@ extracted declarations.
362
441
  cd examples/playground && bun install && bun run build
363
442
  ```
364
443
 
444
+ ## Testing
445
+
446
+ - `npm test` — unit + integration suite (vitest, real `vite build --ssr` included), with coverage thresholds.
447
+ - `npm run test:e2e` — Playwright contract suite in [`e2e/`](./e2e/README.md): the same browser tests run against hono-svelte on Node, on Bun, and against an equivalent SvelteKit app used as the reference implementation (layout nesting, `<title>` precedence, scoped CSS, interactivity, console errors, broken assets).
448
+ - CI also runs the suite against the minimum and the latest peer versions (`hono`, `svelte`, `vite`).
449
+
365
450
  ## License
366
451
 
367
452
  MIT — see [LICENSE](./LICENSE). Changelog in [CHANGELOG.md](./CHANGELOG.md).
package/dist/assets.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export type ViteManifestChunk = {
2
2
  file: string;
3
+ src?: string;
4
+ name?: string;
3
5
  css?: string[];
4
6
  imports?: string[];
7
+ dynamicImports?: string[];
5
8
  isEntry?: boolean;
6
9
  };
7
10
  export type ViteManifest = Record<string, ViteManifestChunk>;
@@ -15,9 +18,16 @@ export type ManifestAssetsOptions = {
15
18
  /** Manifest keys tried for an entry (virtual ids first, bare entry last). */
16
19
  export declare function manifestKeysFor(entryName: string): string[];
17
20
  export declare function createManifestAssets(manifest: ViteManifest, opts?: ManifestAssetsOptions): AssetsResolver;
18
- /** Hashed CSS files for an entry, via the Vite manifest. */
21
+ /**
22
+ * Hashed CSS files for an entry, via the Vite manifest — including the CSS
23
+ * of every statically imported chunk (shared layouts, components), shared
24
+ * chunks first so the entry's own rules come last.
25
+ */
19
26
  export declare function manifestCssFor(manifest: ViteManifest, entryName: string, prefix?: string): string[];
20
- /** Shared-chunk URLs (for `modulepreload`), via the Vite manifest. */
27
+ /**
28
+ * Shared-chunk URLs (for `modulepreload`), via the Vite manifest: every chunk
29
+ * the entry reaches through static imports, not only the direct ones.
30
+ */
21
31
  export declare function manifestImportsFor(manifest: ViteManifest, entryName: string, prefix?: string): string[];
22
32
  /** Long-term caching for hashed `/static` files. */
23
33
  export declare const CACHE_IMMUTABLE = "public, max-age=31536000, immutable";
package/dist/assets.js CHANGED
@@ -33,23 +33,54 @@ export function createManifestAssets(manifest, opts = {}) {
33
33
  return `${fallbackBase}/${entryName}.js`;
34
34
  };
35
35
  }
36
- /** Hashed CSS files for an entry, via the Vite manifest. */
36
+ /**
37
+ * The entry chunk plus every chunk it reaches through static `imports`,
38
+ * dependencies first (the order the browser evaluates them). Vite attaches
39
+ * a module's CSS to the chunk that owns the module — a layout or component
40
+ * shared by several pages lives in its own chunk, so reading only the entry
41
+ * chunk misses its styles. `dynamicImports` are not followed: they load
42
+ * later, with their own CSS handling.
43
+ */
44
+ function staticChunkGraph(manifest, entryName) {
45
+ const entry = findChunk(manifest, entryName);
46
+ if (!entry)
47
+ return [];
48
+ const ordered = [];
49
+ const seen = new Set();
50
+ const visit = (chunk) => {
51
+ if (seen.has(chunk))
52
+ return;
53
+ seen.add(chunk);
54
+ for (const key of chunk.imports ?? []) {
55
+ const dep = manifest[key];
56
+ if (dep)
57
+ visit(dep);
58
+ }
59
+ ordered.push(chunk);
60
+ };
61
+ visit(entry);
62
+ return ordered;
63
+ }
64
+ function unique(values) {
65
+ return [...new Set(values)];
66
+ }
67
+ /**
68
+ * Hashed CSS files for an entry, via the Vite manifest — including the CSS
69
+ * of every statically imported chunk (shared layouts, components), shared
70
+ * chunks first so the entry's own rules come last.
71
+ */
37
72
  export function manifestCssFor(manifest, entryName, prefix = "/") {
38
- const chunk = findChunk(manifest, entryName);
39
- return (chunk?.css ?? []).map((f) => joinPrefix(prefix, f));
73
+ return unique(staticChunkGraph(manifest, entryName).flatMap((chunk) => (chunk.css ?? []).map((f) => joinPrefix(prefix, f))));
40
74
  }
41
- /** Shared-chunk URLs (for `modulepreload`), via the Vite manifest. */
75
+ /**
76
+ * Shared-chunk URLs (for `modulepreload`), via the Vite manifest: every chunk
77
+ * the entry reaches through static imports, not only the direct ones.
78
+ */
42
79
  export function manifestImportsFor(manifest, entryName, prefix = "/") {
43
- const chunk = findChunk(manifest, entryName);
44
- if (!chunk)
45
- return [];
46
- const out = [];
47
- for (const imp of chunk.imports ?? []) {
48
- const dep = manifest[imp];
49
- if (dep?.file)
50
- out.push(joinPrefix(prefix, dep.file));
51
- }
52
- return out;
80
+ const entry = findChunk(manifest, entryName);
81
+ return unique(staticChunkGraph(manifest, entryName)
82
+ .filter((chunk) => chunk !== entry && chunk.file)
83
+ .map((chunk) => joinPrefix(prefix, chunk.file)));
53
84
  }
54
85
  /** Long-term caching for hashed `/static` files. */
55
86
  export const CACHE_IMMUTABLE = "public, max-age=31536000, immutable";
@@ -1,16 +1,38 @@
1
1
  import type { HeadProps } from "./head.js";
2
+ /** Data every error page receives (merged under `opts.data`). */
3
+ export type ErrorPageData = {
4
+ status: number;
5
+ message: string;
6
+ };
2
7
  export type ErrorPageOptions = {
3
8
  /** Entry name. @default "404" (not-found) / "500" (error) */
4
9
  entry?: string;
5
10
  title?: string;
11
+ /** Extra page data, merged over `{ status, message }`. */
6
12
  data?: Record<string, unknown>;
7
13
  head?: string | HeadProps;
14
+ /**
15
+ * Answer `{ message }` JSON with the same status instead of rendering the
16
+ * page — e.g. `(c) => c.req.path.startsWith("/api/")` so API clients never
17
+ * get an HTML document.
18
+ */
19
+ json?: (c: any) => boolean;
8
20
  };
9
21
  /** Hono `notFound` handler: `app.notFound(notFoundHandler())`. */
10
- export declare function notFoundHandler(opts?: ErrorPageOptions): (c: any) => Response | Promise<Response>;
22
+ export declare function notFoundHandler(opts?: ErrorPageOptions & {
23
+ message?: string;
24
+ }): (c: any) => Response | Promise<Response>;
11
25
  export type ServerErrorHandlerOptions = ErrorPageOptions & {
12
26
  /** Log the error. `true` = `console.error`. @default true */
13
27
  log?: boolean | ((err: unknown) => void);
28
+ /** Message for errors whose own message is not exposed. @default "Internal Server Error" */
29
+ message?: string;
30
+ /**
31
+ * Whether the error's own message reaches the page/JSON. Default: only for
32
+ * errors carrying a 4xx `status` (e.g. hono's `HTTPException(404, …)`) —
33
+ * 5xx and unknown errors get `message`, never internals.
34
+ */
35
+ expose?: (err: Error, status: number) => boolean;
14
36
  };
15
37
  /** Hono `onError` handler: `app.onError(errorHandler())`. */
16
38
  export declare function errorHandler(opts?: ServerErrorHandlerOptions): (err: Error, c: any) => Response | Promise<Response>;
package/dist/handlers.js CHANGED
@@ -14,31 +14,61 @@
14
14
  function entryRender(c) {
15
15
  return c.render;
16
16
  }
17
- function pageProps(opts, status) {
18
- return {
17
+ function respond(c, opts, entry, page, headers) {
18
+ if (opts.json?.(c)) {
19
+ return c.json({ message: page.message }, page.status, headers);
20
+ }
21
+ return entryRender(c)(entry, {
19
22
  title: opts.title,
20
- data: opts.data,
23
+ data: { ...page, ...opts.data },
21
24
  head: opts.head,
22
- status,
23
- };
25
+ status: page.status,
26
+ ...(headers ? { headers } : {}),
27
+ });
28
+ }
29
+ /**
30
+ * Headers of the response carried by the error — hono's `HTTPException(401, { res })`
31
+ * (`basicAuth`, `bearerAuth`) carries `WWW-Authenticate` there. Body headers are
32
+ * dropped: the page/JSON replaces the body.
33
+ */
34
+ function headersOf(err) {
35
+ const res = err.res;
36
+ if (!(res instanceof Response))
37
+ return undefined;
38
+ const out = {};
39
+ res.headers.forEach((value, name) => {
40
+ if (name !== "content-type" && name !== "content-length")
41
+ out[name] = value;
42
+ });
43
+ return Object.keys(out).length > 0 ? out : undefined;
24
44
  }
25
45
  /** Hono `notFound` handler: `app.notFound(notFoundHandler())`. */
26
46
  export function notFoundHandler(opts = {}) {
27
47
  const entry = opts.entry ?? "404";
48
+ const message = opts.message ?? "Not Found";
28
49
  // See the file header for why the context param stays `any`.
29
- return (c) => {
30
- return entryRender(c)(entry, pageProps(opts, 404));
31
- };
50
+ return (c) => respond(c, opts, entry, { status: 404, message });
51
+ }
52
+ /** HTTP status carried by the error (hono's `HTTPException` and friends), else 500. */
53
+ function statusOf(err) {
54
+ const status = err.status;
55
+ return typeof status === "number" && Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500;
32
56
  }
33
57
  /** Hono `onError` handler: `app.onError(errorHandler())`. */
34
58
  export function errorHandler(opts = {}) {
35
59
  const entry = opts.entry ?? "500";
36
60
  const log = opts.log ?? true;
61
+ const fallback = opts.message ?? "Internal Server Error";
62
+ const expose = opts.expose ?? ((_err, status) => status < 500);
37
63
  return (err, c) => {
38
- if (log === true)
39
- console.error(err);
40
- else if (typeof log === "function")
41
- log(err);
42
- return entryRender(c)(entry, pageProps(opts, 500));
64
+ const status = statusOf(err);
65
+ if (status >= 500) {
66
+ if (log === true)
67
+ console.error(err);
68
+ else if (typeof log === "function")
69
+ log(err);
70
+ }
71
+ const message = expose(err, status) && err.message ? err.message : fallback;
72
+ return respond(c, opts, entry, { status, message }, headersOf(err));
43
73
  };
44
74
  }
package/dist/index.d.ts CHANGED
@@ -4,16 +4,37 @@ import { type HeadProps } from "./head.js";
4
4
  export { CACHE_IMMUTABLE, CACHE_NO_STORE, createManifestAssets, immutableHeaders, manifestCssFor, manifestImportsFor, manifestKeysFor, } from "./assets.js";
5
5
  export type { AssetsResolver, ManifestAssetsOptions, ViteManifest, ViteManifestChunk, } from "./assets.js";
6
6
  export { errorHandler, notFoundHandler } from "./handlers.js";
7
- export type { ErrorPageOptions, ServerErrorHandlerOptions } from "./handlers.js";
7
+ export type { ErrorPageData, ErrorPageOptions, ServerErrorHandlerOptions } from "./handlers.js";
8
8
  export { renderHead } from "./head.js";
9
9
  export type { HeadMetaItem, HeadProps } from "./head.js";
10
10
  export { getIds } from "./ids.js";
11
+ export type { PageInfo } from "./page.js";
11
12
  export type { SharedIds } from "./ids.js";
12
13
  export { checkAppLink, doctorChecks, initFiles, isSafeAppPath, isValidPageName, newPageFile } from "./scaffold.js";
13
14
  export type { DoctorIssue, DoctorOptions, InitFile, InitFlavor } from "./scaffold.js";
14
15
  export type SsrPageLoader = () => Promise<{
15
16
  default: unknown;
16
17
  }>;
18
+ /**
19
+ * Type registry filled by the generated `.d.ts` (`pages({ dts: true })`):
20
+ *
21
+ * ```ts
22
+ * declare module "hono-svelte" {
23
+ * interface Register { entries: "index" | "dashboard/index" }
24
+ * }
25
+ * ```
26
+ */
27
+ export interface Register {
28
+ }
29
+ /**
30
+ * Page entry names accepted by `c.render`. The union from the generated
31
+ * `.d.ts` once it is part of the program; plain `string` without it.
32
+ * Use it to type helpers that forward an entry to `c.render`:
33
+ * `function page(entry: Entry) { … }`.
34
+ */
35
+ export type Entry = Register extends {
36
+ entries: infer E extends string;
37
+ } ? E : string;
17
38
  export type RenderProps<TData = Record<string, unknown>> = {
18
39
  title?: string;
19
40
  data?: TData;
@@ -39,6 +60,15 @@ export type ShellOptions = {
39
60
  /** Extra CSS links per HTML response. Strings are hrefs verbatim. */
40
61
  styles?: string[];
41
62
  stylesHref?: string | ((isProd: boolean) => string);
63
+ /**
64
+ * Source path of the global stylesheet, relative to the Vite root — the
65
+ * same file passed to the client build input. Dev links `/<stylesEntry>`
66
+ * (served by Vite); prod resolves the hashed file from the manifest given
67
+ * in `assets`, falling back to `/static/styles.css`. `stylesHref` wins
68
+ * when set.
69
+ * @default "src/styles.css"
70
+ */
71
+ stylesEntry?: string;
42
72
  /** Global head HTML (fonts, meta) — page `head` prop is appended after it. */
43
73
  head?: string;
44
74
  /**
@@ -83,6 +113,13 @@ export type ShellOptions = {
83
113
  * (`import.meta.env.PROD`, else `NODE_ENV === "production"`).
84
114
  */
85
115
  isProd?: boolean;
116
+ /**
117
+ * Extra attributes on `<html>` (`lang` comes from the `lang` option), e.g.
118
+ * `{ "data-theme": "dark" }` for DaisyUI themes. A function receives the
119
+ * Hono context (per-request theme from a cookie). Values are escaped;
120
+ * invalid attribute names throw.
121
+ */
122
+ htmlAttributes?: Record<string, string> | ((c: unknown) => Record<string, string>);
86
123
  };
87
124
  export type ShellContext = {
88
125
  render: (entryName: string, props?: RenderProps) => Response | Promise<Response>;
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createManifestAssets, manifestCssFor, manifestImportsFor, } from "./assets.js";
2
2
  import { renderHead } from "./head.js";
3
3
  import { getIds } from "./ids.js";
4
+ import { PAGE_ATTRIBUTES, pageContext } from "./page.js";
4
5
  import { allEntries as autoAllEntries, ssrLayouts as autoSsrLayouts, ssrPages as autoSsrPages, } from "./ssr-manifest.js";
5
6
  import { devEntryUrl } from "./virtual.js";
6
7
  export { CACHE_IMMUTABLE, CACHE_NO_STORE, createManifestAssets, immutableHeaders, manifestCssFor, manifestImportsFor, manifestKeysFor, } from "./assets.js";
@@ -21,6 +22,42 @@ function isValidEntryName(entryName) {
21
22
  const parts = entryName.split("/");
22
23
  return parts.every((p) => p.length > 0 && p !== "." && p !== ".." && !p.includes(String.fromCharCode(0)));
23
24
  }
25
+ const TITLE_TAG = /<title\b[^>]*>[\s\S]*?<\/title>/gi;
26
+ const DESCRIPTION_META = /<meta\b(?=[^>]*\bname=["']description["'])[^>]*>/gi;
27
+ /**
28
+ * One `<title>` / `<meta name="description">` per document, page first.
29
+ * Browsers use the FIRST `<title>`, so the shell default used to beat the
30
+ * page's `<svelte:head>`. Precedence: `c.render(entry, { title })` > the
31
+ * page/layout `<svelte:head>` > `shell({ title })`; for the description,
32
+ * the page (`<svelte:head>` or `props.head`) > `shell({ head })`.
33
+ */
34
+ function resolveHeadConflicts(input) {
35
+ let { shellHead, ssrHead } = input;
36
+ const pageHasTitle = new RegExp(TITLE_TAG.source, "i").test(ssrHead);
37
+ let title = "";
38
+ if (input.explicitTitle !== undefined) {
39
+ title = `<title>${escapeAttr(input.explicitTitle)}</title>`;
40
+ ssrHead = ssrHead.replace(TITLE_TAG, "");
41
+ }
42
+ else if (!pageHasTitle) {
43
+ title = `<title>${escapeAttr(input.shellTitle)}</title>`;
44
+ }
45
+ if (new RegExp(DESCRIPTION_META.source, "i").test(ssrHead + input.pageHead)) {
46
+ shellHead = shellHead.replace(DESCRIPTION_META, "");
47
+ }
48
+ return { title, shellHead, ssrHead };
49
+ }
50
+ const ATTRIBUTE_NAME = /^[A-Za-z_:][-A-Za-z0-9_:.]*$/;
51
+ function renderAttributes(attributes) {
52
+ let out = "";
53
+ for (const [name, value] of Object.entries(attributes)) {
54
+ if (!ATTRIBUTE_NAME.test(name) || name.toLowerCase() === "lang") {
55
+ throw new Error(`hono-svelte: invalid htmlAttributes name: ${JSON.stringify(name)} (use the lang option for lang)`);
56
+ }
57
+ out += ` ${name}="${escapeAttr(String(value))}"`;
58
+ }
59
+ return out;
60
+ }
24
61
  function nonceAttr(nonce) {
25
62
  return nonce ? ` nonce="${escapeAttr(nonce)}"` : "";
26
63
  }
@@ -138,18 +175,23 @@ function prefetchHoverScript(nonce, dataId, urls) {
138
175
  if (urls.length === 0)
139
176
  return "";
140
177
  const lt = String.fromCharCode(60);
141
- const map = JSON.stringify(Object.fromEntries(urls.map((u) => [u.entry, u.url])));
178
+ const map = JSON.stringify(Object.fromEntries(urls.map((u) => [u.entry, u.url]))).replace(/</g, "\\u003c");
179
+ // Links map to entries by path: "/" -> "index", "/a" -> "a" or "a/index".
142
180
  const code = `(function(){var m=${map};` +
143
- `function pre(u){if(document.querySelector('link[rel=\"prefetch\"][href=\"'+u+'\"]'))return;` +
181
+ `function pre(u){if(document.querySelector('link[rel=\"modulepreload\"][href=\"'+u+'\"]'))return;` +
144
182
  `var l=document.createElement('link');l.rel='modulepreload';l.href=u;document.head.appendChild(l);}` +
145
183
  `document.addEventListener('mouseover',function(e){var a=e.target&&e.target.closest?e.target.closest('a[href]'):null;` +
146
- `if(!a)return;try{var p=new URL(a.href,location.origin).pathname.replace(/^\\//,'');` +
147
- `var u=m[p];if(u)pre(u);}catch(_){}},{passive:true});})();`;
184
+ `if(!a)return;try{var p=new URL(a.href,location.origin).pathname.replace(/^\\/+|\\/+$/g,'');` +
185
+ `var u=m[p]||m[p?p+'/index':'index'];if(u)pre(u);}catch(_){}},{passive:true});})();`;
148
186
  return lt + `script${nonceAttr(nonce)}>${code}` + lt + "/script>";
149
187
  }
150
188
  export function shell(options = {}) {
151
189
  const titleDefault = options.title ?? "App";
152
190
  const lang = options.lang ?? "en";
191
+ const htmlAttributes = (c) => {
192
+ const attrs = typeof options.htmlAttributes === "function" ? options.htmlAttributes(c) : options.htmlAttributes;
193
+ return attrs ? renderAttributes(attrs) : "";
194
+ };
153
195
  const assetsBase = (options.assetsBase ?? "/static").replace(/\/$/, "");
154
196
  const assetsOpt = options.assets ?? assetsBase;
155
197
  const head = options.head ?? "";
@@ -162,6 +204,13 @@ export function shell(options = {}) {
162
204
  const resolveStyles = typeof options.stylesHref === "function"
163
205
  ? options.stylesHref
164
206
  : () => options.stylesHref;
207
+ const stylesEntry = (options.stylesEntry ?? "src/styles.css").replace(/^\/+/, "");
208
+ function defaultStylesHref(isProd) {
209
+ if (!isProd)
210
+ return "/" + stylesEntry;
211
+ const file = manifestFor(assetsOpt)?.[stylesEntry]?.file;
212
+ return file ? "/" + file.replace(/^\/+/, "") : `${assetsBase}/styles.css`;
213
+ }
165
214
  // Zero config: with pages() active, this module is redirected to the
166
215
  // in-memory manifest by the plugin's resolveId (enforce: "pre").
167
216
  const ssrPages = options.ssrPages ?? autoSsrPages;
@@ -217,13 +266,15 @@ export function shell(options = {}) {
217
266
  try {
218
267
  return options.nonce(c) ?? undefined;
219
268
  }
220
- catch {
269
+ catch (err) {
270
+ // Without a nonce the CSP blocks every shell script — never fail silently.
271
+ console.error("[hono-svelte] nonce() threw — rendering without a nonce:", err);
221
272
  return undefined;
222
273
  }
223
274
  }
224
275
  return options.nonce;
225
276
  }
226
- async function renderSsrBody(loader, pageData, nonce, layoutChain) {
277
+ async function renderSsrBody(loader, pageData, nonce, layoutChain, page) {
227
278
  const mod = await loader();
228
279
  const { render } = await import("svelte/server");
229
280
  const renderOpts = {};
@@ -231,12 +282,14 @@ export function shell(options = {}) {
231
282
  renderOpts.props = pageData;
232
283
  if (nonce)
233
284
  renderOpts.csp = { nonce };
285
+ renderOpts.context = pageContext(page);
234
286
  const rendered = render(mod.default, renderOpts);
235
287
  let html = rendered.html ?? rendered.body ?? "";
236
288
  let headValue = typeof rendered.head === "function" ? rendered.head() : (rendered.head ?? "");
237
289
  if (layoutChain.length > 0) {
238
290
  const { createRawSnippet } = await import("svelte");
239
- for (const layoutLoader of layoutChain) {
291
+ // The chain is outermost first; wrapping goes inside out.
292
+ for (const layoutLoader of [...layoutChain].reverse()) {
240
293
  const layoutMod = await layoutLoader();
241
294
  const childHtml = html;
242
295
  // The snippet MUST render a single wrapper element: the server
@@ -246,9 +299,9 @@ export function shell(options = {}) {
246
299
  // is hydration-safe (hydrate reuses SSR DOM).
247
300
  const childSnippet = createRawSnippet(() => ({ render: () => `<div>${childHtml}</div>` }));
248
301
  const wrapped = render(layoutMod.default, {
249
- ...(pageData !== undefined ? { props: pageData } : {}),
250
302
  props: { ...pageData, children: childSnippet },
251
303
  ...(nonce ? { csp: { nonce } } : {}),
304
+ context: pageContext(page),
252
305
  });
253
306
  html = wrapped.html ?? wrapped.body ?? "";
254
307
  const layoutHead = typeof wrapped.head === "function" ? wrapped.head() : (wrapped.head ?? "");
@@ -257,10 +310,6 @@ export function shell(options = {}) {
257
310
  }
258
311
  return { html, head: headValue };
259
312
  }
260
- // The context param stays `any` ON PURPOSE (see src/handlers.ts header for
261
- // the full rationale: hono's unique symbols + `Context<any>` not being a
262
- // supertype of concrete contexts). Everything the shell touches is typed
263
- // through the casts below — no `any` leaks into the rendered response.
264
313
  // The context param stays `any` ON PURPOSE — validated empirically:
265
314
  // typing it (MiddlewareHandler / Context / generics) works with a single
266
315
  // hono copy and even with duplicate copies of the SAME version, but breaks
@@ -293,18 +342,18 @@ export function shell(options = {}) {
293
342
  }
294
343
  }
295
344
  const ids = getIds(entryName);
296
- const title = props?.title ?? titleDefault;
345
+ const page = { path: c.req.path, route: c.req.routePath ?? c.req.path };
297
346
  const isProd = options.isProd ?? detectProd();
298
347
  const nonce = resolveNonce(c);
299
348
  const nAttr = nonceAttr(nonce);
300
- const stylesHref = resolveStyles(isProd) ?? (isProd ? "/static/styles.css" : "/src/styles.css");
349
+ const stylesHref = resolveStyles(isProd) ?? defaultStylesHref(isProd);
301
350
  const { html: dataHtml } = serializePageData(entryName, props?.data, ids.dataId, dataLimit);
302
351
  const lt = String.fromCharCode(60);
303
352
  let bodyHtml = "";
304
353
  let ssrHead = "";
305
354
  if (isSsr) {
306
355
  try {
307
- const rendered = await renderSsrBody(loader, props?.data, nonce, chain);
356
+ const rendered = await renderSsrBody(loader, props?.data, nonce, chain, page);
308
357
  bodyHtml = rendered.html;
309
358
  ssrHead = rendered.head;
310
359
  }
@@ -340,14 +389,15 @@ export function shell(options = {}) {
340
389
  .join("");
341
390
  }
342
391
  if (isProd && options.prefetch) {
343
- const known = availableEntries();
392
+ // Only client entries have a JS chunk; static pages are SSR-only.
393
+ const known = availableEntries()?.filter((e) => e !== entryName && (hasClientEntry(e) || ssrPages[e] === undefined));
344
394
  if (options.prefetch === "all" && known) {
345
395
  for (const u of prefetchUrls("all", entryName, known, resolveUrl)) {
346
396
  prefetchHtml += lt + `link rel="modulepreload" href="${escapeAttr(u)}"${nAttr} />`;
347
397
  }
348
398
  }
349
399
  else if (options.prefetch === "hover" && known) {
350
- const others = known.filter((e) => e !== entryName && ssrPages[e] === undefined);
400
+ const others = known;
351
401
  prefetchHtml = prefetchHoverScript(nonce, ids.dataId, others.map((e) => ({ entry: e, url: resolveUrl(e) })));
352
402
  }
353
403
  }
@@ -362,18 +412,25 @@ export function shell(options = {}) {
362
412
  extraStyles
363
413
  .map((href) => lt + `link rel="stylesheet" href="${escapeAttr(href)}"${nAttr} />`)
364
414
  .join("");
365
- return c.html(`<!doctype html><html lang="${escapeAttr(lang)}"><head><title>${escapeAttr(title)}</title>` +
415
+ const resolvedHead = resolveHeadConflicts({
416
+ shellTitle: titleDefault,
417
+ explicitTitle: props?.title,
418
+ shellHead: head,
419
+ ssrHead,
420
+ pageHead,
421
+ });
422
+ return c.html(`<!doctype html><html lang="${escapeAttr(lang)}"${htmlAttributes(c)}><head>${resolvedHead.title}` +
366
423
  `<meta charset="utf-8" /><meta content="width=device-width, initial-scale=1" name="viewport" />` +
367
- (head ? head : "") +
424
+ resolvedHead.shellHead +
368
425
  `<link rel="stylesheet" href="${escapeAttr(stylesHref)}"${nAttr} />` +
369
426
  cssLinks +
370
- ssrHead +
427
+ resolvedHead.ssrHead +
371
428
  pageHead +
372
429
  preloadHtml +
373
430
  prefetchHtml +
374
431
  scriptHtml +
375
432
  `</head>` +
376
- `<body><div id="${ids.rootId}">${bodyHtml}</div>` +
433
+ `<body><div id="${ids.rootId}" ${PAGE_ATTRIBUTES.path}="${escapeAttr(page.path)}" ${PAGE_ATTRIBUTES.route}="${escapeAttr(page.route)}">${bodyHtml}</div>` +
377
434
  dataHtml +
378
435
  `</body></html>`, status, headers);
379
436
  });
package/dist/page.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ export type PageInfo = {
2
+ /** Request pathname, e.g. `/dashboard/settings`. */
3
+ path: string;
4
+ /**
5
+ * Hono route pattern that rendered the page, e.g. `/users/:id` — handy
6
+ * for "active link" logic that must ignore params.
7
+ */
8
+ route: string;
9
+ };
10
+ /** Context key; `Symbol.for` so duplicated module copies still agree. */
11
+ export declare const PAGE_CONTEXT_KEY: unique symbol;
12
+ /** Root element attributes the shell writes and the client entry reads. */
13
+ export declare const PAGE_ATTRIBUTES: {
14
+ readonly path: "data-hs-path";
15
+ readonly route: "data-hs-route";
16
+ };
17
+ /**
18
+ * Page info for the current render. Call during component initialisation
19
+ * (like any `getContext`). Throws outside a page rendered by the shell.
20
+ */
21
+ export declare function getPage(): PageInfo;
22
+ /** Svelte context map carrying the page info (`render`/`mount`/`hydrate` `context` option). */
23
+ export declare function pageContext(page: PageInfo): Map<symbol, PageInfo>;
package/dist/page.js ADDED
@@ -0,0 +1,24 @@
1
+ // Current request info for pages and layouts — the hono-svelte counterpart
2
+ // of SvelteKit's `page`. The shell provides it as Svelte context on the
3
+ // server (SSR of static pages and layouts) and the generated client entry
4
+ // provides it on mount/hydrate, so the same `getPage()` works in both.
5
+ import { getContext } from "svelte";
6
+ /** Context key; `Symbol.for` so duplicated module copies still agree. */
7
+ export const PAGE_CONTEXT_KEY = Symbol.for("hono-svelte:page");
8
+ /** Root element attributes the shell writes and the client entry reads. */
9
+ export const PAGE_ATTRIBUTES = { path: "data-hs-path", route: "data-hs-route" };
10
+ /**
11
+ * Page info for the current render. Call during component initialisation
12
+ * (like any `getContext`). Throws outside a page rendered by the shell.
13
+ */
14
+ export function getPage() {
15
+ const page = getContext(PAGE_CONTEXT_KEY);
16
+ if (!page) {
17
+ throw new Error("hono-svelte: getPage() must be called inside a page or layout rendered by shell()");
18
+ }
19
+ return page;
20
+ }
21
+ /** Svelte context map carrying the page info (`render`/`mount`/`hydrate` `context` option). */
22
+ export function pageContext(page) {
23
+ return new Map([[PAGE_CONTEXT_KEY, page]]);
24
+ }
package/dist/scaffold.js CHANGED
@@ -9,12 +9,27 @@
9
9
  // The generated app targets bun first (`bun install`, `bun run dev/build/start`);
10
10
  // npm still works since every script shells out to `vite` / `node`.
11
11
  import { existsSync } from "node:fs";
12
- import { isAbsolute, join, resolve } from "node:path";
12
+ import { join, resolve } from "node:path";
13
13
  const ENV_DTS = `// Entry names are generated by \`pages({ dts: true })\` into
14
14
  // \`src/hono-svelte-entries.d.ts\` (see vite.config.ts). \`c.render()\` only
15
15
  // accepts those names — a typo fails the typecheck.
16
16
  /// <reference path="./hono-svelte-entries.d.ts" />
17
17
  `;
18
+ // Initial stub so `npm run typecheck` is green right after init (routes call
19
+ // c.render(entry, props) with 2 args — hono's default renderer takes 1).
20
+ // `pages({ dts: true })` overwrites it with the typed entry union on the
21
+ // first dev/build run.
22
+ const ENTRIES_DTS_STUB = `// Initial stub — regenerated with the typed entry union by
23
+ // pages({ dts: true }) on the first dev/build run. Commit the
24
+ // regenerated file once it exists.
25
+ export type HonoSvelteEntries = string;
26
+
27
+ declare module "hono" {
28
+ interface ContextRenderer {
29
+ (entryName: string, props?: import("hono-svelte").RenderProps): Response | Promise<Response>;
30
+ }
31
+ }
32
+ `;
18
33
  const VITE_CONFIG = `// Bun-first: run everything with bun (bun run dev/build/start).
19
34
  // NOTE: @hono/vite-build is ESM-only. Bun loads vite.config.ts natively as
20
35
  // ESM, so the "/bun" subpath works. Under plain node, Vite loads the config
@@ -80,9 +95,9 @@ const PACKAGE_JSON = `{
80
95
  "start:node": "node ./dist/index.js"
81
96
  },
82
97
  "dependencies": {
83
- "hono": "^4",
98
+ "hono": "^4.13.8",
84
99
  "hono-svelte": "latest",
85
- "svelte": "^5"
100
+ "svelte": "^5.57.0"
86
101
  },
87
102
  "devDependencies": {
88
103
  "@hono/vite-build": "^1",
@@ -90,7 +105,7 @@ const PACKAGE_JSON = `{
90
105
  "@sveltejs/vite-plugin-svelte": "^7",
91
106
  "@types/node": "^26",
92
107
  "typescript": "^7",
93
- "vite": "^8"
108
+ "vite": "^8.3.0"
94
109
  }
95
110
  }
96
111
  `;
@@ -232,6 +247,7 @@ export function initFiles(flavor = "minimal") {
232
247
  { path: "tsconfig.json", content: TSCONFIG_JSON, skipIfExists: true },
233
248
  { path: ".gitignore", content: GITIGNORE_TEXT, skipIfExists: true },
234
249
  { path: "src/env.d.ts", content: ENV_DTS, skipIfExists: true },
250
+ { path: "src/hono-svelte-entries.d.ts", content: ENTRIES_DTS_STUB, skipIfExists: true },
235
251
  { path: "vite.config.ts", content: VITE_CONFIG, skipIfExists: true },
236
252
  { path: "src/styles.css", content: STYLES_CSS, skipIfExists: true },
237
253
  { path: "src/pages/index.svelte", content: INDEX_SVELTE, skipIfExists: true },
@@ -402,7 +418,9 @@ export function isSafeAppPath(appRoot, target) {
402
418
  // so checking `startsWith(root + "/")` always failed on win32 and `init`
403
419
  // refused every file.
404
420
  const root = resolve(appRoot);
405
- const abs = isAbsolute(target) ? target : resolve(root, target);
421
+ // resolve() also normalizes absolute targets: `/app/../evil.ts` must not
422
+ // pass the prefix check below as if it were inside `/app`.
423
+ const abs = resolve(root, target);
406
424
  const norm = (p) => p.replace(/\\/g, "/");
407
425
  const lower = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
408
426
  const nRoot = lower(norm(root));
package/dist/vite.d.ts CHANGED
@@ -57,4 +57,13 @@ export type LayoutSpec = {
57
57
  file: string;
58
58
  absFile: string;
59
59
  };
60
+ /**
61
+ * Server-rendered components (static pages and layouts) have no client
62
+ * bundle, so their `<style>` must travel inside the SSR output. With
63
+ * vite-plugin-svelte's default `emitCss: true` the compiler runs with
64
+ * `css: "external"` and the styles become a CSS import that an SSR build
65
+ * drops. Forcing `css: "injected"` makes `render()` return them in `head`.
66
+ * An explicit `css` option on the component is respected.
67
+ */
68
+ export declare function withInjectedCss(source: string): string;
60
69
  export declare function pages(options?: PagesOptions): PagesPlugin;
package/dist/vite.js CHANGED
@@ -11,6 +11,24 @@ function normalizeSlashes(p) {
11
11
  function escapeRegExp(s) {
12
12
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
13
  }
14
+ /**
15
+ * Server-rendered components (static pages and layouts) have no client
16
+ * bundle, so their `<style>` must travel inside the SSR output. With
17
+ * vite-plugin-svelte's default `emitCss: true` the compiler runs with
18
+ * `css: "external"` and the styles become a CSS import that an SSR build
19
+ * drops. Forcing `css: "injected"` makes `render()` return them in `head`.
20
+ * An explicit `css` option on the component is respected.
21
+ */
22
+ export function withInjectedCss(source) {
23
+ const options = /<svelte:options\b([^>]*?)(\s*\/?)>/.exec(source);
24
+ if (!options)
25
+ return source + '\n<svelte:options css="injected" />\n';
26
+ if (/\bcss\s*=/.test(options[1]))
27
+ return source;
28
+ return (source.slice(0, options.index) +
29
+ `<svelte:options${options[1]} css="injected"${options[2]}>` +
30
+ source.slice(options.index + options[0].length));
31
+ }
14
32
  function entryNameFromFile(file) {
15
33
  return normalizeSlashes(file).slice(0, -".svelte".length);
16
34
  }
@@ -82,11 +100,11 @@ export function pages(options = {}) {
82
100
  // static+layouts => SSR page, then wrap in layout(s).
83
101
  const layouts = chainFor(entryName, cachedLayouts);
84
102
  // Single source of truth: when layouts are enabled, the shell composes
85
- // Page inside LayoutN...Layout0 automatically (SSR + hydrate). A page
103
+ // Page inside LayoutN...Layout0 automatically (SSR or client mount). A page
86
104
  // that ALSO imports its layout manually would render it twice
87
105
  // (<main><div><main>). Fail fast with a clear message instead.
106
+ const source = readFileSync(absFile, "utf8");
88
107
  if (layoutsEnabled && layouts.length > 0) {
89
- const source = readFileSync(absFile, "utf8");
90
108
  for (const layout of layouts) {
91
109
  const layoutFile = layout.endsWith("/layout")
92
110
  ? layout.slice(0, -"/layout".length) + "/layout.svelte"
@@ -101,7 +119,6 @@ export function pages(options = {}) {
101
119
  }
102
120
  }
103
121
  }
104
- const source = readFileSync(absFile, "utf8");
105
122
  const hasScript = /<script[\s>]/i.test(source);
106
123
  // A page is static when it has no <script> — EVEN with layouts.
107
124
  // The shell SSRs the page and wraps it in the layout chain; the layout
@@ -200,6 +217,14 @@ export function pages(options = {}) {
200
217
  "",
201
218
  ]
202
219
  : []),
220
+ // Registers the union on the package itself so apps can write
221
+ // `import type { Entry } from "hono-svelte"` instead of importing this file.
222
+ `declare module "hono-svelte" {`,
223
+ ` interface Register {`,
224
+ ` entries: ${typeName};`,
225
+ ` }`,
226
+ `}`,
227
+ "",
203
228
  `declare module "hono" {`,
204
229
  ` interface ContextRenderer {`,
205
230
  // Typed overloads come first, then the union catch-all: overload
@@ -232,14 +257,18 @@ export function pages(options = {}) {
232
257
  lastWrittenDts = content;
233
258
  return out;
234
259
  }
235
- function pageImportPath(page) {
260
+ function rootImportPath(absFile) {
236
261
  const root = viteRoot ?? process.cwd();
237
- const rel = relative(root, page.absFile);
238
- if (rel.startsWith("..")) {
239
- throw new Error("hono-svelte: pagesDir precisa estar dentro do root do Vite");
262
+ const rel = relative(root, absFile);
263
+ // Another drive on Windows makes relative() return an absolute path.
264
+ if (rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel)) {
265
+ throw new Error(`hono-svelte: pagesDir must be inside the Vite root (${root}): ${absFile}`);
240
266
  }
241
267
  return "/" + normalizeSlashes(rel);
242
268
  }
269
+ function pageImportPath(page) {
270
+ return rootImportPath(page.absFile);
271
+ }
243
272
  function layoutByName(entryName) {
244
273
  const found = cachedLayouts.find((l) => l.entryName === entryName);
245
274
  if (!found)
@@ -247,12 +276,23 @@ export function pages(options = {}) {
247
276
  return found;
248
277
  }
249
278
  function layoutImportPath(layout) {
250
- const root = viteRoot ?? process.cwd();
251
- const rel = relative(root, layout.absFile);
252
- if (rel.startsWith("..")) {
253
- throw new Error("hono-svelte: pagesDir precisa estar dentro do root do Vite");
254
- }
255
- return "/" + normalizeSlashes(rel);
279
+ return rootImportPath(layout.absFile);
280
+ }
281
+ /** Absolute path when `id` is a static page or a layout (the components the shell SSRs). */
282
+ /**
283
+ * Absolute path when `id` is a component the shell may render on the server:
284
+ * static pages, layouts AND every component they import (a layout's nav, a
285
+ * component library). Client pages are excluded — the shell never SSRs them.
286
+ */
287
+ function ssrRenderedFile(id) {
288
+ if (!id.endsWith(".svelte") || !existsSync(id))
289
+ return null;
290
+ // Windows paths are case-insensitive and Vite may report a different drive-letter case.
291
+ const key = (p) => (process.platform === "win32" ? normalizeSlashes(p).toLowerCase() : normalizeSlashes(p));
292
+ const target = key(id);
293
+ if (listPages().some((p) => !p.isStatic && key(p.absFile) === target))
294
+ return null;
295
+ return id;
256
296
  }
257
297
  function manifestSource() {
258
298
  const all = listPages();
@@ -266,7 +306,7 @@ export function pages(options = {}) {
266
306
  const loaders = staticPages.map((p) => " " + JSON.stringify(p.entryName) + ": () => import(" + JSON.stringify(pageImportPath(p)) + "),");
267
307
  const layoutLoaders = cachedLayouts.map((l) => " " + JSON.stringify(l.entryName) + ": () => import(" + JSON.stringify(layoutImportPath(l)) + "),");
268
308
  return [
269
- header + " gerado em memoria pelo plugin - nao editar.",
309
+ header + " in-memory manifest generated by the plugin.",
270
310
  "",
271
311
  "export const clientEntries = " + JSON.stringify(client) + ";",
272
312
  "",
@@ -277,11 +317,11 @@ export function pages(options = {}) {
277
317
  "export const layoutEntries = " + JSON.stringify(cachedLayouts.map((l) => l.entryName)) + ";",
278
318
  "",
279
319
  "export const ssrPages = {",
280
- ...(loaders.length > 0 ? loaders : [" // nenhuma pagina estatica detectada"]),
320
+ ...(loaders.length > 0 ? loaders : [" // no static pages"]),
281
321
  "};",
282
322
  "",
283
323
  "export const ssrLayouts = {",
284
- ...(layoutLoaders.length > 0 ? layoutLoaders : [" // nenhum layout detectado"]),
324
+ ...(layoutLoaders.length > 0 ? layoutLoaders : [" // no layouts"]),
285
325
  "};",
286
326
  "",
287
327
  "ssrPages.__allEntries = allEntries;",
@@ -292,48 +332,51 @@ export function pages(options = {}) {
292
332
  }
293
333
  function entrySource(page) {
294
334
  const ids = getIds(page.entryName);
295
- // Layouts are composed on the SERVER (SSR): the entry's client script
296
- // hydrates the same tree — hydrate() the layout with a children snippet
297
- // that hydrates the page. Static import (no `await import`) so the
335
+ // Client entries mount the layout chain around the page (see
336
+ // entryMountSource). Static import (no `await import`) so the
298
337
  // bundler keeps one copy of the svelte runtime in the entry chunk.
299
338
  const layoutImports = page.layouts
300
339
  .map((l, i) => `import Layout${i} from ${JSON.stringify(layoutImportPath(layoutByName(l)))};`)
301
340
  .join("\n");
302
341
  return (header + " Source: " + page.file + "\n" +
303
342
  (page.layouts.length > 0
304
- ? 'import { createRawSnippet, hydrate, mount } from "svelte";\n'
343
+ ? 'import { createRawSnippet, mount } from "svelte";\n'
305
344
  : 'import { mount } from "svelte";\n') +
306
345
  "import Page from " + JSON.stringify(pageImportPath(page)) + ";\n" +
307
346
  (page.layouts.length > 0 ? layoutImports + "\n" : "") +
308
347
  "const target = document.getElementById(" + JSON.stringify(ids.rootId) + ");\n" +
309
348
  "const raw = document.getElementById(" + JSON.stringify(ids.dataId) + ")?.textContent;\n" +
310
349
  "const props = raw ? JSON.parse(raw) : {};\n" +
350
+ // Same context the shell gives server-rendered components (see src/page.ts).
351
+ "const context = new Map([[Symbol.for(\"hono-svelte:page\"), {" +
352
+ " path: target?.getAttribute(\"data-hs-path\") ?? location.pathname," +
353
+ " route: target?.getAttribute(\"data-hs-route\") ?? location.pathname }]]);\n" +
311
354
  entryMountSource(page) +
312
355
  "\n");
313
356
  }
314
357
  function entryMountSource(page) {
315
358
  if (page.layouts.length === 0)
316
- return "if (target) mount(Page, { target, props });";
359
+ return "if (target) mount(Page, { target, props, context });";
360
+ // Client entries are never server-rendered (the shell only SSRs static
361
+ // pages), so the chain is MOUNTED, outermost first: Layout0 > … > Page.
317
362
  // NOTE: createRawSnippet's snippet MUST render one wrapper element
318
- // (client impl takes get_first_child(fragment) and calls setup(element)).
319
- // An empty render ("") yields a null element -> "Cannot set properties
320
- // of null" inside svelte internals. The wrapper div is hydration-safe:
321
- // hydrate() reuses the SSR DOM instead of creating new nodes.
322
- return [
363
+ // (client impl takes get_first_child(fragment) and calls setup(element));
364
+ // an empty render yields a null element. The SSR side (src/index.ts)
365
+ // renders the same <div> wrappers.
366
+ const lines = [
323
367
  "if (target) {",
324
- " const __kids = createRawSnippet(() => ({",
368
+ " const slot = (C, p) => createRawSnippet(() => ({",
325
369
  " render: () => \"<div></div>\",",
326
- " setup: (el) => { hydrate(Page, { target: el, props }); },",
370
+ " setup: (el) => { mount(C, { target: el, props: p, context }); },",
327
371
  " }));",
328
- " hydrate(Layout0, { target, props: { ...props, children: __kids } });",
329
- "}",
330
- ].join("\n");
331
- }
332
- function wrapperSource(page) {
333
- void page;
334
- throw new Error("hono-svelte: wrappers are compiled at build time (unreachable)");
372
+ " let children = slot(Page, props);",
373
+ ];
374
+ for (let i = page.layouts.length - 1; i >= 1; i--) {
375
+ lines.push(` children = slot(Layout${i}, { ...props, children });`);
376
+ }
377
+ lines.push(" mount(Layout0, { target, props: { ...props, children }, context });", "}");
378
+ return lines.join("\n");
335
379
  }
336
- void wrapperSource;
337
380
  const plugin = {
338
381
  name: "hono-svelte-pages",
339
382
  // pre: must run BEFORE vite:resolve to intercept the shell's
@@ -365,7 +408,6 @@ export function pages(options = {}) {
365
408
  load(id, options) {
366
409
  if (id === MANIFEST_RESOLVED)
367
410
  return manifestSource();
368
- void options;
369
411
  if (id.startsWith(ENTRY_RESOLVED_PREFIX)) {
370
412
  const entryName = id.slice(ENTRY_RESOLVED_PREFIX.length);
371
413
  const page = listPages().find((p) => p.entryName === entryName);
@@ -374,6 +416,16 @@ export function pages(options = {}) {
374
416
  }
375
417
  return entrySource(page);
376
418
  }
419
+ // Vite 8 passes the environment on the plugin context; `options.ssr` covers older call sites.
420
+ const context = this;
421
+ const isSsr = options?.ssr === true || context?.environment?.config?.consumer === "server";
422
+ if (isSsr && !id.includes("?")) {
423
+ const file = ssrRenderedFile(id);
424
+ if (file) {
425
+ context?.addWatchFile?.(file);
426
+ return withInjectedCss(readFileSync(file, "utf8"));
427
+ }
428
+ }
377
429
  return null;
378
430
  },
379
431
  configureServer(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-svelte",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Server-side shell + multi-entry Svelte pages for Hono apps",
5
5
  "keywords": [
6
6
  "hono",
@@ -33,6 +33,10 @@
33
33
  "types": "./dist/index.d.ts",
34
34
  "default": "./dist/index.js"
35
35
  },
36
+ "./page": {
37
+ "types": "./dist/page.d.ts",
38
+ "default": "./dist/page.js"
39
+ },
36
40
  "./vite": {
37
41
  "types": "./dist/vite.d.ts",
38
42
  "default": "./dist/vite.js"
@@ -46,7 +50,8 @@
46
50
  "build": "tsc -p tsconfig.json",
47
51
  "typecheck": "tsc --noEmit -p tsconfig.json",
48
52
  "prepublishOnly": "npm run build && npm test && npx publint",
49
- "test": "vitest run --coverage"
53
+ "test": "vitest run --coverage",
54
+ "test:e2e": "npm run build && cd e2e && npm run build && npm test"
50
55
  },
51
56
  "devDependencies": {
52
57
  "@sveltejs/vite-plugin-svelte": "^7.3.0",