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 +134 -0
- package/README.md +89 -4
- package/dist/assets.d.ts +12 -2
- package/dist/assets.js +45 -14
- package/dist/handlers.d.ts +23 -1
- package/dist/handlers.js +43 -13
- package/dist/index.d.ts +38 -1
- package/dist/index.js +78 -21
- package/dist/page.d.ts +23 -0
- package/dist/page.js +24 -0
- package/dist/scaffold.js +23 -5
- package/dist/vite.d.ts +9 -0
- package/dist/vite.js +89 -37
- package/package.json +7 -2
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
|
|
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` |
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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";
|
package/dist/handlers.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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=\"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
260
|
+
function rootImportPath(absFile) {
|
|
236
261
|
const root = viteRoot ?? process.cwd();
|
|
237
|
-
const rel = relative(root,
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 + "
|
|
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 : [" //
|
|
320
|
+
...(loaders.length > 0 ? loaders : [" // no static pages"]),
|
|
281
321
|
"};",
|
|
282
322
|
"",
|
|
283
323
|
"export const ssrLayouts = {",
|
|
284
|
-
...(layoutLoaders.length > 0 ? layoutLoaders : [" //
|
|
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
|
-
//
|
|
296
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
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
|
|
368
|
+
" const slot = (C, p) => createRawSnippet(() => ({",
|
|
325
369
|
" render: () => \"<div></div>\",",
|
|
326
|
-
" setup: (el) => {
|
|
370
|
+
" setup: (el) => { mount(C, { target: el, props: p, context }); },",
|
|
327
371
|
" }));",
|
|
328
|
-
"
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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.
|
|
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",
|