hono-svelte 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - **Per-page data typing (opt-in, `pages({ dts: true })`)**: declare
6
+ `export type Data` (or `interface Data`) in a page's `<script module>` and
7
+ the generated `.d.ts` adds a typed overload per page —
8
+ `c.render("dashboard", { data })` is checked by `tsc` (a missing/mistyped
9
+ field fails the typecheck instead of shipping `undefined`). The declaration
10
+ is inlined verbatim, so it must be self-contained; types that reference
11
+ imports are skipped with a one-time warning and keep
12
+ `Record<string, unknown>` data. `RenderProps` is now generic
13
+ (`RenderProps<TData = Record<string, unknown>>`) and `pages().dataTypes()`
14
+ exposes the extractions. The dts also regenerates when a page's `Data`
15
+ type changes (dev watcher + `buildStart`).
16
+ - **Neutral `<body>`**: the shell no longer hardcodes DaisyUI classes
17
+ (`bg-base-200 min-h-screen text-base-content`) — it emits a plain,
18
+ CSS-framework-agnostic document. Style `body`/`html` from your global
19
+ stylesheet instead (`body { background: var(--color-base-200); ... }`, or
20
+ `@apply` with Tailwind — the playground does exactly that in
21
+ `styles.css`). **Breaking** for apps relying on the hardcoded classes.
22
+ - **`shell({ ssrFallback })`**: when SSR of a page throws at request time,
23
+ log the error and fall back to client rendering (entry script) instead of
24
+ a 500. Default `false` keeps the previous behavior.
25
+ - `prefetch: "all"` / `"hover"` now emit `rel="modulepreload"` (prefetch on a
26
+ module URL could double-fetch in some browsers; modulepreload also warms
27
+ the entry's import graph).
28
+ - `dataLimit` warning fires once per **(page, limit)** instead of once per
29
+ process — a second oversized page is reported too, with the entry name in
30
+ the message.
31
+ - **Removed dead stubs**: `versionsMatch()` (always returned `true`), the
32
+ unused `hasClient` export from the ssr-manifest stub/virtual manifest, and
33
+ the no-op `checkPagesDir`. **Breaking: `versionsMatch` is no longer
34
+ exported.**
35
+ - Fully typed handlers/middleware: `notFoundHandler()` / `errorHandler()`
36
+ match Hono's `NotFoundHandler`/`ErrorHandler` signatures and `shell()` no
37
+ longer uses `any` internally.
38
+ - **CLI: `hono-svelte add page <name>`** — scaffolds
39
+ `src/pages/<name>.svelte` (static by default, skip-if-exists, traversal
40
+ refused). `isValidPageName()` / `newPageFile()` exported for tests.
41
+ - Metadata: `engines` now declares `bun >= 1.4` and `node >= 26`, the
42
+ `./package.json` export subpath was added, and TypeScript 7 builds the
43
+ package.
44
+ - **Security floors in `peerDependencies`**: `hono ^4.13.8`, `svelte ^5.57.0`,
45
+ `vite ^8.3.0` — every published advisory sits outside these floors
46
+ (hono < 4.12.12 and 4.12.0–4.12.33, patched in 4.12.34; svelte ≤ 5.55.6,
47
+ patched in 5.55.7; vite ≤ 8.0.15, patched in 8.0.16; transitive `devalue`
48
+ resolves to the patched 5.9.2, which also closes CVE-2026-81176). The
49
+ versions installed in the repo and in the example are the latest published
50
+ and none is affected by a known advisory — the ranges just avoid
51
+ advertising vulnerable versions to consumers and scanners.
52
+ - CI: ubuntu + windows runners (Windows-only regressions like the old
53
+ `isSafeAppPath` bug are caught now), coverage via `@vitest/coverage-v8`
54
+ with thresholds (`npm test` runs it), all on the officially supported
55
+ runtimes (Node 26, TS 7, Vite 8, Bun 1.4+).
56
+ - `hono-svelte init` now scaffolds TypeScript 7 + `@types/node` 26 in the
57
+ generated app.
58
+
3
59
  ## 0.4.2
4
60
 
5
61
  - `hono-svelte init` now scaffolds a complete runnable app (works with
package/README.md CHANGED
@@ -15,10 +15,10 @@ Real sites and dashboards mix simple pages (landing, login, terms) with interact
15
15
 
16
16
  ## Installation
17
17
 
18
- Requires Node 22+, Hono 4, Svelte 5, and Vite 6/7/8.
18
+ Requires Bun 1.4+, Node 26+, Hono 4, Svelte 5, Vite 8, and TypeScript 7.
19
19
 
20
20
  ```sh
21
- npm install hono-svelte
21
+ bun add hono-svelte
22
22
  ```
23
23
 
24
24
  ## Quick start
@@ -126,7 +126,9 @@ Works for static (zero-JS) pages too: the shell passes `data` as SSR props **and
126
126
  app.get("/dashboard", (c) => c.render("dashboard", { data: { plan: "pro" } }));
127
127
  ```
128
128
 
129
- Large payloads trigger a one-time `console.warn` (default limit 100KB, `shell({ dataLimit })`).
129
+ Declare `export type Data` in the page's `<script module>` to type it per page — see [per-page data typing](#per-page-data-typing).
130
+
131
+ Large payloads trigger a `console.warn` — once per page (default limit 100KB, `shell({ dataLimit })`).
130
132
  Anything sensitive stays in the API (`hc<AppType>`, HttpOnly cookie). Never put secrets in `data`.
131
133
 
132
134
  ## Status, headers and per-page head
@@ -180,6 +182,8 @@ c.render("dashboard/page1", { data: { title: "Page 1", description: "..." } });
180
182
 
181
183
  Layouts never become entries (`pages().layouts()` / `layoutChain(entry)` inspect them; `layouts: false` restores legacy ignore).
182
184
 
185
+ > **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
+
183
187
 
184
188
  ## Error pages
185
189
 
@@ -193,24 +197,30 @@ app.onError(errorHandler()); // renders `500.svelte` with status 500
193
197
  ## CLI
194
198
 
195
199
  ```sh
196
- # Start a new app (works with npx, bunx or pnpm dlx — no install needed):
200
+ # Start a new app (no install needed):
197
201
  bunx hono-svelte init # minimal: 2 pages + routes + configs
198
202
  bunx hono-svelte init --full # + auth, dashboard w/ layout, typed RPC
199
203
 
200
- cd my-app && bun install && bun run dev # or: npm install && npm run dev
204
+ cd my-app && bun install && bun run dev
201
205
  ```
202
206
 
203
207
  `init` scaffolds a complete runnable app: `package.json`, `tsconfig.json`,
204
208
  `vite.config.ts` (with `pages({ dts: true })`), `src/pages/` and
205
209
  `src/routes/`. The `--full` flavor adds cookie-session auth, a dashboard with
206
210
  nested layout, and a typed API (`hc<AppType>`) — open `/auth`, sign in, explore
207
- `/dashboard`. Generated scripts run under npm and bun.
211
+ `/dashboard`. (npm/pnpm work too: `npx hono-svelte init`, `npm install`.)
208
212
 
209
213
  ```sh
210
- npx hono-svelte doctor # check dist, link and vite config
211
- npx hono-svelte doctor --app=./my-app # check another app directory
214
+ bunx hono-svelte add page blog/post-1 # create src/pages/blog/post-1.svelte
215
+ bunx hono-svelte doctor # check dist, link and vite config
216
+ bunx hono-svelte doctor --app=./my-app # check another app directory
212
217
  ```
213
218
 
219
+ `add page <name>` writes a static page (no `<script>` → zero JS; add one to
220
+ hydrate on the client). Existing files are skipped, traversal names are
221
+ refused, and the default pagesDir is `src/pages` (a custom one set via
222
+ `pages({ pagesDir })` is not visible to the CLI — adjust the path manually).
223
+
214
224
  ## API
215
225
 
216
226
  ### `shell(options?)`
@@ -229,11 +239,12 @@ Hono middleware that provides `c.render(entry, { title?, data?, status?, headers
229
239
  | `head` | `""` | Extra HTML in `<head>` (fonts, meta tags) |
230
240
  | `nonce` | — | CSP nonce string or `(c) => string` (applied to shell `<script>`/`<link>`) |
231
241
  | `preload` | `true` in prod | `<link rel="modulepreload">` for the entry (+ manifest imports) |
232
- | `prefetch` | `false` | `"all"` prefetches other entries; `"hover"` injects a hover-prefetch script |
242
+ | `prefetch` | `false` | `"all"` emits `modulepreload` for other entries; `"hover"` injects a script that preloads them on link hover |
233
243
  | `dataLimit` | `102400` | Warn once above this many `data` bytes (`false` disables) |
234
244
  | `status` | `200` | Default response status |
235
245
  | `headers` | `{}` | Default response headers (per-render `headers` merge over them) |
236
246
  | `strict` | `true` | Unknown entries throw a dev-friendly error; `false` disables |
247
+ | `ssrFallback` | `false` | SSR failures log and fall back to client rendering instead of a 500 |
237
248
  | `knownEntries` | auto from manifest | Override the page list used by strict mode and prefetch |
238
249
 
239
250
  `shell()` also exposes `availableEntries()` for diagnostics.
@@ -250,7 +261,7 @@ Vite plugin (`hono-svelte/vite`) that discovers pages and generates client entri
250
261
  | `dts` | `false` | Typed `c.render` entries: `true` writes `src/hono-svelte-entries.d.ts`, a string sets a custom path |
251
262
  | `alwaysClient` | `[]` | Pages that always get JS, even without `<script>` |
252
263
 
253
- Handy methods: `input()` (client build entries), `entries()` (all pages), `staticEntries()` (static pages only), `hasClient(entry)`, `layouts()`, `layoutChain(entry)`, `types()` (entry union), `typeDeclarations()` (hono module snippet), `dtsPath()` / `writeDts()` (generated `.d.ts`), `validateConfig()` (fail fast on bad Vite setup).
264
+ Handy methods: `input()` (client build entries), `entries()` (all pages), `staticEntries()` (static pages only), `hasClient(entry)`, `layouts()`, `layoutChain(entry)`, `dataTypes()` (extracted `Data` declarations), `types()` (entry union), `typeDeclarations()` (hono module snippet), `dtsPath()` / `writeDts()` (generated `.d.ts`), `validateConfig()` (fail fast on bad Vite setup).
254
265
 
255
266
  ### Typing `c.render`
256
267
 
@@ -291,13 +302,56 @@ declare module "hono" {
291
302
  }
292
303
  ```
293
304
 
294
- `data` stays `Record<string, unknown>` — per-page payload types are a future feature. Programmatic access: `pages().types()` → `"admin" | "home"`, `pages().typeDeclarations()` for the snippet, `dtsPath()` / `writeDts()` for the file.
305
+ `data` stays `Record<string, unknown>` by default — declare a `Data` type in the page for per-page typing (below). Programmatic access: `pages().types()` → `"admin" | "home"`, `pages().typeDeclarations()` for the snippet, `dtsPath()` / `writeDts()` for the file.
306
+
307
+ ### Per-page data typing
308
+
309
+ Declare the page payload once — the `Data` type in a `<script module>` — and reuse it as the page props:
310
+
311
+ ```svelte
312
+ <!-- src/pages/dashboard.svelte -->
313
+ <script module lang="ts">
314
+ export type Data = { plan: string; seats: number };
315
+ </script>
316
+
317
+ <script lang="ts">
318
+ let { plan, seats }: Data = $props();
319
+ </script>
320
+
321
+ <h1>{plan} — {seats} seats</h1>
322
+ ```
323
+
324
+ With `pages({ dts: true })`, the generated `.d.ts` adds a typed overload per
325
+ page, so `c.render("dashboard", { data: { plan: "pro" } })` is checked by
326
+ `tsc` — a missing `plan` fails the typecheck instead of shipping `undefined`
327
+ to the client:
328
+
329
+ ```ts
330
+ // generated (excerpt)
331
+ type HonoSvelteData_dashboard = { plan: string; seats: number };
332
+
333
+ declare module "hono" {
334
+ interface ContextRenderer {
335
+ (entryName: "dashboard", props?: import("hono-svelte").RenderProps<HonoSvelteData_dashboard>): Response | Promise<Response>;
336
+ (entryName: HonoSvelteEntries, props?: import("hono-svelte").RenderProps): Response | Promise<Response>;
337
+ }
338
+ }
339
+ ```
340
+
341
+ The `Data` declaration is inlined verbatim into the dts, so it must be
342
+ **self-contained** — inline shapes or built-ins only. References to imported
343
+ types can't be resolved from the dts location; such pages keep
344
+ `Record<string, unknown>` data with a one-time warning. Both
345
+ `export type Data = ...` and `export interface Data { ... }` work (including
346
+ legacy `<script context="module">`). `pages().dataTypes()` exposes the
347
+ extracted declarations.
295
348
 
296
349
  ## Production tips
297
350
 
298
351
  - Pass the Vite manifest to `shell({ assets: manifest })` for hashed files; manifest CSS and shared chunks are emitted automatically (`modulepreload`).
299
352
  - Serve hashed files with long cache and `immutable` (`immutableHeaders()` / `CACHE_IMMUTABLE`); the HTML shell itself suits `CACHE_NO_STORE`.
300
353
  - 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).
354
+ - 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.
301
355
  - Run both client and server builds before serving; the example in `examples/playground/` shows the full setup.
302
356
 
303
357
  ## Example
@@ -305,7 +359,7 @@ declare module "hono" {
305
359
  `examples/playground/` is a real Hono app using the package: static landing, login, and a dashboard with typed RPC and cookie session. To run it:
306
360
 
307
361
  ```sh
308
- cd examples/playground && npm install && npm run build
362
+ cd examples/playground && bun install && bun run build
309
363
  ```
310
364
 
311
365
  ## License
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import { doctorChecks, checkAppLink, initFiles, isSafeAppPath, } from "./scaffold.js";
7
+ import { checkAppLink, doctorChecks, initFiles, isSafeAppPath, newPageFile, } from "./scaffold.js";
8
8
  const args = process.argv.slice(2);
9
9
  const command = args[0];
10
10
  function flagValue(name) {
@@ -36,18 +36,48 @@ function init() {
36
36
  }
37
37
  console.log(`[hono-svelte] done (${flavor}). Next:`);
38
38
  if (flavor === "minimal") {
39
- console.log(` 1. npm install (or: bun install)`);
40
- console.log(` 2. npm run dev (or: bun run dev)`);
39
+ console.log(` 1. bun install`);
40
+ console.log(` 2. bun run dev (or: bun --bun run dev)`);
41
41
  console.log(` Tip: --full scaffolds auth + dashboard + layout + typed RPC.`);
42
42
  }
43
43
  else {
44
- console.log(` 1. npm install (or: bun install)`);
45
- console.log(` 2. npm run dev (or: bun run dev) → /auth → /dashboard`);
44
+ console.log(` 1. bun install`);
45
+ console.log(` 2. bun run dev (or: bun --bun run dev) → /auth → /dashboard`);
46
46
  }
47
47
  }
48
48
  function packageRootForCli() {
49
49
  return join(dirname(fileURLToPath(import.meta.url)), "..");
50
50
  }
51
+ function addPage() {
52
+ const name = args[2];
53
+ if (!name) {
54
+ console.error("[hono-svelte] usage: hono-svelte add page <name> (e.g. blog/post-1)");
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ const file = newPageFile(name);
59
+ if (!file) {
60
+ console.error(`[hono-svelte] invalid page name: ${name} — use a relative entry name like "blog/post-1" (no ../, no leading /, not "layout")`);
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ const dest = join(appRoot, file.path);
65
+ if (!isSafeAppPath(appRoot, dest)) {
66
+ console.error(`[hono-svelte] refusing to write outside ${appRoot}: ${file.path}`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ if (existsSync(dest)) {
71
+ console.log(`[hono-svelte] exists, skipping: ${file.path}`);
72
+ return;
73
+ }
74
+ mkdirSync(dirname(dest), { recursive: true });
75
+ writeFileSync(dest, file.content);
76
+ const entryName = file.path.replace(/^src\/pages\//, "").replace(/\.svelte$/, "");
77
+ console.log(`[hono-svelte] wrote ${file.path}`);
78
+ console.log(`[hono-svelte] next: render it from a route — c.render("${entryName}")`);
79
+ console.log(`[hono-svelte] (default pagesDir is src/pages; set a custom one via pages({ pagesDir }))`);
80
+ }
51
81
  function doctor() {
52
82
  const packageRoot = packageRootForCli();
53
83
  const issues = doctorChecks({ packageRoot, appRoot });
@@ -68,8 +98,12 @@ if (command === "init")
68
98
  init();
69
99
  else if (command === "doctor")
70
100
  doctor();
101
+ else if (command === "add" && args[1] === "page")
102
+ addPage();
71
103
  else {
72
- console.log("Usage: hono-svelte <init|doctor> [--app=<dir>] [--full]");
73
- console.log(" init [--full] scaffold a runnable app (minimal by default)");
104
+ console.log("Usage: hono-svelte <init|doctor|add page <name>> [--app=<dir>] [--full]");
105
+ console.log(" init [--full] scaffold a runnable app (minimal by default)");
106
+ console.log(" add page <name> create src/pages/<name>.svelte (static, zero JS)");
107
+ console.log(" doctor [--app=<dir>] check dist, link and vite config");
74
108
  process.exitCode = 1;
75
109
  }
@@ -7,10 +7,10 @@ export type ErrorPageOptions = {
7
7
  head?: string | HeadProps;
8
8
  };
9
9
  /** Hono `notFound` handler: `app.notFound(notFoundHandler())`. */
10
- export declare function notFoundHandler(opts?: ErrorPageOptions): (c: any) => any;
10
+ export declare function notFoundHandler(opts?: ErrorPageOptions): (c: any) => Response | Promise<Response>;
11
11
  export type ServerErrorHandlerOptions = ErrorPageOptions & {
12
12
  /** Log the error. `true` = `console.error`. @default true */
13
13
  log?: boolean | ((err: unknown) => void);
14
14
  };
15
15
  /** Hono `onError` handler: `app.onError(errorHandler())`. */
16
- export declare function errorHandler(opts?: ServerErrorHandlerOptions): (err: unknown, c: any) => any;
16
+ export declare function errorHandler(opts?: ServerErrorHandlerOptions): (err: Error, c: any) => Response | Promise<Response>;
package/dist/handlers.js CHANGED
@@ -1,15 +1,33 @@
1
1
  // Conventional error-page handlers: `404.svelte` / `500.svelte` pages
2
2
  // rendered with the matching HTTP status.
3
+ //
4
+ // The context parameter stays `any` ON PURPOSE — validated empirically:
5
+ // typing it with hono types works with a single hono copy (and even with
6
+ // duplicate copies of the same version), but breaks when the consumer's
7
+ // resolution picks a hono copy with a DIFFERENT version than the one this
8
+ // package's d.ts resolves to (file:/npm-link/monorepo setups — e.g.
9
+ // app hono 4.13.7 vs package types 4.13.8 fail on `[GET_MATCH_RESULT]`
10
+ // symbol members). hono's `Context<any, ...>` default is not a supertype of
11
+ // concrete contexts either. `any` keeps the handlers assignable in every
12
+ // copy/version layout; everything else (return type, the render call) is
13
+ // typed via the narrow cast below.
14
+ function entryRender(c) {
15
+ return c.render;
16
+ }
17
+ function pageProps(opts, status) {
18
+ return {
19
+ title: opts.title,
20
+ data: opts.data,
21
+ head: opts.head,
22
+ status,
23
+ };
24
+ }
3
25
  /** Hono `notFound` handler: `app.notFound(notFoundHandler())`. */
4
26
  export function notFoundHandler(opts = {}) {
5
27
  const entry = opts.entry ?? "404";
28
+ // See the file header for why the context param stays `any`.
6
29
  return (c) => {
7
- return c.render(entry, {
8
- title: opts.title,
9
- data: opts.data,
10
- head: opts.head,
11
- status: 404,
12
- });
30
+ return entryRender(c)(entry, pageProps(opts, 404));
13
31
  };
14
32
  }
15
33
  /** Hono `onError` handler: `app.onError(errorHandler())`. */
@@ -21,11 +39,6 @@ export function errorHandler(opts = {}) {
21
39
  console.error(err);
22
40
  else if (typeof log === "function")
23
41
  log(err);
24
- return c.render(entry, {
25
- title: opts.title,
26
- data: opts.data,
27
- head: opts.head,
28
- status: 500,
29
- });
42
+ return entryRender(c)(entry, pageProps(opts, 500));
30
43
  };
31
44
  }
package/dist/index.d.ts CHANGED
@@ -1,22 +1,22 @@
1
1
  import type { Next } from "hono";
2
2
  import { type AssetsResolver, type ViteManifest } from "./assets.js";
3
3
  import { type HeadProps } from "./head.js";
4
- export { getIds } from "./ids.js";
5
- export type { SharedIds } from "./ids.js";
6
- export { renderHead } from "./head.js";
7
- export type { HeadMetaItem, HeadProps } from "./head.js";
8
4
  export { CACHE_IMMUTABLE, CACHE_NO_STORE, createManifestAssets, immutableHeaders, manifestCssFor, manifestImportsFor, manifestKeysFor, } from "./assets.js";
9
5
  export type { AssetsResolver, ManifestAssetsOptions, ViteManifest, ViteManifestChunk, } from "./assets.js";
10
- export { notFoundHandler, errorHandler } from "./handlers.js";
6
+ export { errorHandler, notFoundHandler } from "./handlers.js";
11
7
  export type { ErrorPageOptions, ServerErrorHandlerOptions } from "./handlers.js";
12
- export { initFiles, doctorChecks, checkAppLink, versionsMatch, isSafeAppPath } from "./scaffold.js";
8
+ export { renderHead } from "./head.js";
9
+ export type { HeadMetaItem, HeadProps } from "./head.js";
10
+ export { getIds } from "./ids.js";
11
+ export type { SharedIds } from "./ids.js";
12
+ export { checkAppLink, doctorChecks, initFiles, isSafeAppPath, isValidPageName, newPageFile } from "./scaffold.js";
13
13
  export type { DoctorIssue, DoctorOptions, InitFile, InitFlavor } from "./scaffold.js";
14
14
  export type SsrPageLoader = () => Promise<{
15
15
  default: unknown;
16
16
  }>;
17
- export type RenderProps = {
17
+ export type RenderProps<TData = Record<string, unknown>> = {
18
18
  title?: string;
19
- data?: Record<string, unknown>;
19
+ data?: TData;
20
20
  /** HTTP status of the shell response. @default 200 */
21
21
  status?: number;
22
22
  /** Extra response headers merged into the shell response. */
@@ -69,6 +69,11 @@ export type ShellOptions = {
69
69
  knownEntries?: string[];
70
70
  /** `false` disables unknown-entry validation. @default true */
71
71
  strict?: boolean;
72
+ /**
73
+ * When SSR of a page throws at request time: log the error and fall back
74
+ * to client rendering (entry script) instead of a 500. @default false
75
+ */
76
+ ssrFallback?: boolean;
72
77
  /** Default status when `c.render(entry)` omits it. @default 200 */
73
78
  status?: number;
74
79
  /** Default headers merged into every shell response. */
package/dist/index.js CHANGED
@@ -3,11 +3,11 @@ import { renderHead } from "./head.js";
3
3
  import { getIds } from "./ids.js";
4
4
  import { allEntries as autoAllEntries, ssrLayouts as autoSsrLayouts, ssrPages as autoSsrPages, } from "./ssr-manifest.js";
5
5
  import { devEntryUrl } from "./virtual.js";
6
- export { getIds } from "./ids.js";
7
- export { renderHead } from "./head.js";
8
6
  export { CACHE_IMMUTABLE, CACHE_NO_STORE, createManifestAssets, immutableHeaders, manifestCssFor, manifestImportsFor, manifestKeysFor, } from "./assets.js";
9
- export { notFoundHandler, errorHandler } from "./handlers.js";
10
- export { initFiles, doctorChecks, checkAppLink, versionsMatch, isSafeAppPath } from "./scaffold.js";
7
+ export { errorHandler, notFoundHandler } from "./handlers.js";
8
+ export { renderHead } from "./head.js";
9
+ export { getIds } from "./ids.js";
10
+ export { checkAppLink, doctorChecks, initFiles, isSafeAppPath, isValidPageName, newPageFile } from "./scaffold.js";
11
11
  function escapeAttr(value) {
12
12
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
13
13
  }
@@ -24,7 +24,7 @@ function isValidEntryName(entryName) {
24
24
  function nonceAttr(nonce) {
25
25
  return nonce ? ` nonce="${escapeAttr(nonce)}"` : "";
26
26
  }
27
- function serializePageData(data, dataId, dataLimit) {
27
+ function serializePageData(entryName, data, dataId, dataLimit) {
28
28
  if (!data)
29
29
  return { json: "", html: "" };
30
30
  let json;
@@ -36,7 +36,7 @@ function serializePageData(data, dataId, dataLimit) {
36
36
  }
37
37
  if (json === undefined)
38
38
  return { json: "", html: "" };
39
- checkDataLimit(json, dataLimit);
39
+ checkDataLimit(entryName, json, dataLimit);
40
40
  const safe = json.replace(/</g, "\\u003c");
41
41
  const lt = String.fromCharCode(60);
42
42
  return {
@@ -45,19 +45,21 @@ function serializePageData(data, dataId, dataLimit) {
45
45
  };
46
46
  }
47
47
  const warnedDataLimit = new Set();
48
- function checkDataLimit(json, dataLimit) {
48
+ function checkDataLimit(entryName, json, dataLimit) {
49
49
  if (dataLimit === false)
50
50
  return;
51
51
  const bytes = new TextEncoder().encode(json).length;
52
52
  if (bytes <= dataLimit)
53
53
  return;
54
- const key = String(dataLimit);
54
+ // One warning per (page, limit): a second page over the limit must still
55
+ // be reported (the key used to be just the limit — one warn per process).
56
+ const key = entryName + ":" + String(dataLimit);
55
57
  if (warnedDataLimit.has(key))
56
58
  return;
57
59
  warnedDataLimit.add(key);
58
60
  const kb = (bytes / 1024).toFixed(1);
59
61
  const limitKb = (dataLimit / 1024).toFixed(0);
60
- console.warn(`[hono-svelte] page data is ${kb}KB (limit ${limitKb}KB). ` +
62
+ console.warn(`[hono-svelte] page "${entryName}" data is ${kb}KB (limit ${limitKb}KB). ` +
61
63
  `data is inlined in the HTML — move large/sensitive payloads to a typed API (hc<AppType>).`);
62
64
  }
63
65
  /** Visible for tests. */
@@ -84,7 +86,8 @@ function detectProd() {
84
86
  // plain node / vitest: no import.meta.env — bundled server is prod
85
87
  return true;
86
88
  }
87
- return globalThis.process?.env?.NODE_ENV === "production";
89
+ return (globalThis.process?.env?.NODE_ENV ===
90
+ "production");
88
91
  }
89
92
  function isDev() {
90
93
  try {
@@ -95,7 +98,8 @@ function isDev() {
95
98
  // plain node / vitest
96
99
  return false;
97
100
  }
98
- return globalThis.process?.env?.NODE_ENV !== "production";
101
+ return (globalThis.process?.env?.NODE_ENV !==
102
+ "production");
99
103
  }
100
104
  function defaultKnownEntries(ssrPages) {
101
105
  const keys = Object.keys(ssrPages);
@@ -137,7 +141,7 @@ function prefetchHoverScript(nonce, dataId, urls) {
137
141
  const map = JSON.stringify(Object.fromEntries(urls.map((u) => [u.entry, u.url])));
138
142
  const code = `(function(){var m=${map};` +
139
143
  `function pre(u){if(document.querySelector('link[rel=\"prefetch\"][href=\"'+u+'\"]'))return;` +
140
- `var l=document.createElement('link');l.rel='prefetch';l.href=u;document.head.appendChild(l);}` +
144
+ `var l=document.createElement('link');l.rel='modulepreload';l.href=u;document.head.appendChild(l);}` +
141
145
  `document.addEventListener('mouseover',function(e){var a=e.target&&e.target.closest?e.target.closest('a[href]'):null;` +
142
146
  `if(!a)return;try{var p=new URL(a.href,location.origin).pathname.replace(/^\\//,'');` +
143
147
  `var u=m[p];if(u)pre(u);}catch(_){}},{passive:true});})();`;
@@ -153,6 +157,7 @@ export function shell(options = {}) {
153
157
  const defaultHeaders = options.headers ?? {};
154
158
  const dataLimit = options.dataLimit ?? 102400;
155
159
  const strict = options.strict ?? true;
160
+ const ssrFallback = options.ssrFallback ?? false;
156
161
  const extraStyles = options.styles ?? [];
157
162
  const resolveStyles = typeof options.stylesHref === "function"
158
163
  ? options.stylesHref
@@ -242,7 +247,7 @@ export function shell(options = {}) {
242
247
  const childSnippet = createRawSnippet(() => ({ render: () => `<div>${childHtml}</div>` }));
243
248
  const wrapped = render(layoutMod.default, {
244
249
  ...(pageData !== undefined ? { props: pageData } : {}),
245
- props: { ...(pageData ?? {}), children: childSnippet },
250
+ props: { ...pageData, children: childSnippet },
246
251
  ...(nonce ? { csp: { nonce } } : {}),
247
252
  });
248
253
  html = wrapped.html ?? wrapped.body ?? "";
@@ -252,7 +257,22 @@ export function shell(options = {}) {
252
257
  }
253
258
  return { html, head: headValue };
254
259
  }
255
- async function inner(c, next) {
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
+ // The context param stays `any` ON PURPOSE — validated empirically:
265
+ // typing it (MiddlewareHandler / Context / generics) works with a single
266
+ // hono copy and even with duplicate copies of the SAME version, but breaks
267
+ // when the consumer's resolution picks a hono copy with a different version
268
+ // than the one this package's d.ts resolves to (file:/npm-link/monorepo
269
+ // topologies — e.g. app hono 4.13.7 vs package types 4.13.8 fail with
270
+ // `[GET_MATCH_RESULT]`-symbol mismatches). A d.ts free of hono type
271
+ // references is immune to any copy/version layout. The
272
+ // render/setRenderer boundary needs casts regardless (hono's default
273
+ // Renderer only accepts string content), so `any` here costs no real
274
+ // type safety — the casts carry the correctness burden.
275
+ const inner = async (c, next) => {
256
276
  c.setRenderer(async (entryName, props) => {
257
277
  if (!isValidEntryName(entryName)) {
258
278
  throw new Error(`hono-svelte: invalid entryName: ${JSON.stringify(entryName)}`);
@@ -265,7 +285,7 @@ export function shell(options = {}) {
265
285
  const chain = layoutChainFor(entryName)
266
286
  .map((l) => ssrLayouts[l])
267
287
  .filter((l) => l !== undefined);
268
- const isSsr = loader !== undefined && !hasClientEntry(entryName);
288
+ let isSsr = loader !== undefined && !hasClientEntry(entryName);
269
289
  if (strict && !isSsr) {
270
290
  const known = availableEntries();
271
291
  if (known && !known.includes(entryName)) {
@@ -278,14 +298,24 @@ export function shell(options = {}) {
278
298
  const nonce = resolveNonce(c);
279
299
  const nAttr = nonceAttr(nonce);
280
300
  const stylesHref = resolveStyles(isProd) ?? (isProd ? "/static/styles.css" : "/src/styles.css");
281
- const { html: dataHtml } = serializePageData(props?.data, ids.dataId, dataLimit);
301
+ const { html: dataHtml } = serializePageData(entryName, props?.data, ids.dataId, dataLimit);
282
302
  const lt = String.fromCharCode(60);
283
303
  let bodyHtml = "";
284
304
  let ssrHead = "";
285
305
  if (isSsr) {
286
- const rendered = await renderSsrBody(loader, props?.data, nonce, chain);
287
- bodyHtml = rendered.html;
288
- ssrHead = rendered.head;
306
+ try {
307
+ const rendered = await renderSsrBody(loader, props?.data, nonce, chain);
308
+ bodyHtml = rendered.html;
309
+ ssrHead = rendered.head;
310
+ }
311
+ catch (err) {
312
+ if (!ssrFallback)
313
+ throw err;
314
+ console.error(`[hono-svelte] SSR failed for "${entryName}" — falling back to client rendering:`, err);
315
+ bodyHtml = "";
316
+ ssrHead = "";
317
+ isSsr = false;
318
+ }
289
319
  }
290
320
  const resolveUrl = (entry) => isProd ? resolveAssetUrl(assetsOpt, assetsBase, entry) : devEntryUrl(entry);
291
321
  let scriptHtml = "";
@@ -293,7 +323,8 @@ export function shell(options = {}) {
293
323
  let prefetchHtml = "";
294
324
  if (!isSsr) {
295
325
  const src = resolveUrl(entryName);
296
- scriptHtml = lt + `script type="module" src="${escapeAttr(src)}"${nAttr}>` + lt + "/script>";
326
+ scriptHtml =
327
+ lt + `script type="module" src="${escapeAttr(src)}"${nAttr}>` + lt + "/script>";
297
328
  const shouldPreload = options.preload ?? isProd;
298
329
  const manifest = isProd ? manifestFor(assetsOpt) : undefined;
299
330
  if (shouldPreload) {
@@ -312,7 +343,7 @@ export function shell(options = {}) {
312
343
  const known = availableEntries();
313
344
  if (options.prefetch === "all" && known) {
314
345
  for (const u of prefetchUrls("all", entryName, known, resolveUrl)) {
315
- prefetchHtml += lt + `link rel="prefetch" href="${escapeAttr(u)}"${nAttr} />`;
346
+ prefetchHtml += lt + `link rel="modulepreload" href="${escapeAttr(u)}"${nAttr} />`;
316
347
  }
317
348
  }
318
349
  else if (options.prefetch === "hover" && known) {
@@ -322,7 +353,7 @@ export function shell(options = {}) {
322
353
  }
323
354
  }
324
355
  const status = props?.status ?? defaultStatus;
325
- const headers = { ...defaultHeaders, ...(props?.headers ?? {}) };
356
+ const headers = { ...defaultHeaders, ...props?.headers };
326
357
  const pageHead = props?.head !== undefined ? renderHead(props.head) : "";
327
358
  const manifestNow = isProd ? manifestFor(assetsOpt) : undefined;
328
359
  const cssLinks = (manifestNow ? manifestCssFor(manifestNow, entryName) : [])
@@ -342,16 +373,13 @@ export function shell(options = {}) {
342
373
  prefetchHtml +
343
374
  scriptHtml +
344
375
  `</head>` +
345
- `<body class="bg-base-200 min-h-screen text-base-content"><div id="${ids.rootId}">${bodyHtml}</div>` +
376
+ `<body><div id="${ids.rootId}">${bodyHtml}</div>` +
346
377
  dataHtml +
347
378
  `</body></html>`, status, headers);
348
379
  });
349
380
  await next();
350
- }
351
- async function shellMiddleware(c, next) {
352
- return inner(c, next);
353
- }
354
- const mw = shellMiddleware;
381
+ };
382
+ const mw = inner;
355
383
  mw.availableEntries = availableEntries;
356
384
  return mw;
357
385
  }
@@ -0,0 +1,7 @@
1
+ export type PageDataExtraction = {
2
+ /** Declaration text (`type Data = ...` / `interface Data { ... }`, `export` stripped). Null when the page declares no `Data`. */
3
+ declaration: string | null;
4
+ /** Imported identifiers referenced by the declaration (unusable for the dts). */
5
+ importedRefs: string[];
6
+ };
7
+ export declare function extractPageDataType(source: string): PageDataExtraction;
@@ -0,0 +1,119 @@
1
+ // Extraction of the per-page `Data` type used by the generated dts:
2
+ // `export type Data = ...` / `export interface Data { ... }` inside a page's
3
+ // `<script module lang="ts">` (legacy `<script context="module">` works too).
4
+ //
5
+ // The declaration is inlined verbatim into the dts, so it must be
6
+ // self-contained: references to imported types can't be resolved from the
7
+ // dts location and are reported through `importedRefs` (the caller skips the
8
+ // typed overload for that page and warns once).
9
+ const MODULE_SCRIPT_RE = /<script\b[^>]*\bmodule\b[^>]*>([\s\S]*?)<\/script>/;
10
+ const DATA_DECL_RE = /(?:export\s+)?(type|interface)\s+Data\b/;
11
+ export function extractPageDataType(source) {
12
+ const none = { declaration: null, importedRefs: [] };
13
+ const script = MODULE_SCRIPT_RE.exec(source)?.[1];
14
+ if (!script)
15
+ return none;
16
+ const m = DATA_DECL_RE.exec(script);
17
+ if (!m)
18
+ return none;
19
+ const isInterface = m[1] === "interface";
20
+ const end = scanDeclarationEnd(script, m.index + m[0].length, isInterface);
21
+ if (end < 0)
22
+ return none;
23
+ const declaration = script
24
+ .slice(m.index, end)
25
+ .replace(/^export\s+/, "")
26
+ .replace(/\s+$/, "");
27
+ if (!declaration)
28
+ return none;
29
+ const importedRefs = importedIdentifiers(script).filter((id) => new RegExp(`\\b${id.replace(/\$/g, "\\$")}\\b`).test(declaration));
30
+ return { declaration, importedRefs };
31
+ }
32
+ /**
33
+ * Scan to the end of the declaration starting right after `Data`.
34
+ * - `type`: the `=` alias value, ended by a `;` at depth 0, a newline after
35
+ * a balanced bracket pair, or the end of the module script.
36
+ * - `interface`: the balanced `{ ... }` body.
37
+ * Returns -1 for malformed declarations.
38
+ */
39
+ function scanDeclarationEnd(text, from, isInterface) {
40
+ let i = from;
41
+ if (!isInterface) {
42
+ // skip to the `=` of the type alias
43
+ while (i < text.length && /\s/.test(text[i]))
44
+ i++;
45
+ if (text[i] !== "=")
46
+ return -1;
47
+ i++;
48
+ }
49
+ let depth = 0;
50
+ let seenBrackets = false;
51
+ for (; i < text.length; i++) {
52
+ const ch = text[i];
53
+ if (ch === "/" && text[i + 1] === "/") {
54
+ while (i < text.length && text[i] !== "\n")
55
+ i++;
56
+ continue;
57
+ }
58
+ if (ch === "/" && text[i + 1] === "*") {
59
+ i += 2;
60
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
61
+ i++;
62
+ i++;
63
+ continue;
64
+ }
65
+ if (ch === '"' || ch === "'" || ch === "`") {
66
+ i++;
67
+ while (i < text.length && text[i] !== ch) {
68
+ if (text[i] === "\\")
69
+ i++;
70
+ i++;
71
+ }
72
+ continue;
73
+ }
74
+ if (ch === "{" || ch === "(" || ch === "[" || ch === "<") {
75
+ depth++;
76
+ seenBrackets = true;
77
+ continue;
78
+ }
79
+ if (ch === "}" || ch === ")" || ch === "]") {
80
+ depth--;
81
+ if (isInterface && depth === 0)
82
+ return i + 1;
83
+ continue;
84
+ }
85
+ if (ch === ">" && text[i - 1] !== "=") {
86
+ // `>` but not the `=>` of a function type
87
+ depth--;
88
+ continue;
89
+ }
90
+ if (depth === 0 && ch === ";")
91
+ return i + 1;
92
+ if (depth === 0 && ch === "\n" && seenBrackets && !isInterface)
93
+ return i;
94
+ }
95
+ // EOF: a `type` alias that never hit `;` but balanced its brackets.
96
+ return !isInterface && seenBrackets ? text.length : -1;
97
+ }
98
+ /** Identifiers brought in by `import ... from "..."` clauses of the script. */
99
+ function importedIdentifiers(script) {
100
+ const ids = new Set();
101
+ for (const m of script.matchAll(/import\s+(?:type\s+)?([\s\S]*?)\s*from\s*["'][^"']+["']/g)) {
102
+ const clause = m[1];
103
+ const def = /^\s*([\w$]+)/.exec(clause);
104
+ if (def)
105
+ ids.add(def[1]);
106
+ const ns = /\*\s+as\s+([\w$]+)/.exec(clause);
107
+ if (ns)
108
+ ids.add(ns[1]);
109
+ for (const named of clause.matchAll(/\{([^}]*)\}/g)) {
110
+ for (const part of named[1].split(",")) {
111
+ const asM = /\bas\s+([\w$]+)/.exec(part);
112
+ const id = asM ? asM[1] : part.trim().split(/\s+/)[0];
113
+ if (/^[\w$]+$/.test(id))
114
+ ids.add(id);
115
+ }
116
+ }
117
+ }
118
+ return [...ids];
119
+ }
@@ -17,7 +17,14 @@ export type DoctorOptions = {
17
17
  /** Root of the app being checked. @default cwd */
18
18
  appRoot?: string;
19
19
  };
20
- export declare function versionsMatch(_linkedVersion: string, _appWants: string): boolean;
21
20
  export declare function doctorChecks(opts?: DoctorOptions): DoctorIssue[];
22
21
  export declare function checkAppLink(appRoot: string): DoctorIssue | null;
23
22
  export declare function isSafeAppPath(appRoot: string, target: string): boolean;
23
+ /**
24
+ * Entry name for `hono-svelte add page`: no traversal, no absolute paths,
25
+ * no null chars/backslashes, and never a `layout` (layouts are convention,
26
+ * not pages). `"blog/post-1"` is valid.
27
+ */
28
+ export declare function isValidPageName(name: string): boolean;
29
+ /** File + content written by `hono-svelte add page <name>` (null on invalid names). */
30
+ export declare function newPageFile(name: string, pagesDir?: string): InitFile | null;
package/dist/scaffold.js CHANGED
@@ -5,10 +5,9 @@
5
5
  // `dist/cli.js`, invoked via the `hono-svelte` bin).
6
6
  //
7
7
  // Runtime note: `init` writes plain text files only — no install, no network.
8
- // It works identically under `npx` (npm), `bunx` (bun) and `pnpm dlx`.
9
- // The generated app is runtime-agnostic too: `bun run dev`, `bun run build`
10
- // and `bun run start` work because every script shells out to `vite` / `node`
11
- // (both runnable from bun).
8
+ // It works identically under `bunx` (bun), `npx` (npm) and `pnpm dlx`.
9
+ // The generated app targets bun first (`bun install`, `bun run dev/build/start`);
10
+ // npm still works since every script shells out to `vite` / `node`.
12
11
  import { existsSync } from "node:fs";
13
12
  import { isAbsolute, join, resolve } from "node:path";
14
13
  const ENV_DTS = `// Entry names are generated by \`pages({ dts: true })\` into
@@ -16,8 +15,14 @@ const ENV_DTS = `// Entry names are generated by \`pages({ dts: true })\` into
16
15
  // accepts those names — a typo fails the typecheck.
17
16
  /// <reference path="./hono-svelte-entries.d.ts" />
18
17
  `;
19
- const VITE_CONFIG = `import build from "@hono/vite-build/node";
18
+ const VITE_CONFIG = `// Bun-first: run everything with bun (bun run dev/build/start).
19
+ // NOTE: @hono/vite-build is ESM-only. Bun loads vite.config.ts natively as
20
+ // ESM, so the "/bun" subpath works. Under plain node, Vite loads the config
21
+ // through require() and "/bun" fails — use vite.config.node.ts + npm scripts
22
+ // (build:node / start:node) for node.
23
+ import build from "@hono/vite-build/bun";
20
24
  import devServer from "@hono/vite-dev-server";
25
+ import bunAdapter from "@hono/vite-dev-server/bun";
21
26
  import { svelte } from "@sveltejs/vite-plugin-svelte";
22
27
  import { pages } from "hono-svelte/vite";
23
28
  import { resolve } from "node:path";
@@ -44,7 +49,11 @@ export default defineConfig(({ command, mode }) => {
44
49
 
45
50
  if (command === "serve") {
46
51
  return {
47
- plugins: [svelte(), appPages, devServer({ entry: "src/routes/index.ts" })],
52
+ plugins: [
53
+ svelte(),
54
+ appPages,
55
+ devServer({ entry: "src/routes/index.ts", adapter: bunAdapter() }),
56
+ ],
48
57
  };
49
58
  }
50
59
 
@@ -57,13 +66,18 @@ const PACKAGE_JSON = `{
57
66
  "name": "my-hono-svelte-app",
58
67
  "type": "module",
59
68
  "private": true,
69
+ "engines": {
70
+ "bun": ">=1.4",
71
+ "node": ">=26"
72
+ },
60
73
  "scripts": {
61
74
  "dev": "vite",
62
- "build": "npm run build:client && npm run build:server",
75
+ "build": "bun run build:client && bun run build:server",
63
76
  "build:client": "vite build --mode client",
64
77
  "build:server": "vite build --mode production",
65
78
  "typecheck": "tsc --noEmit",
66
- "start": "node ./dist/index.js"
79
+ "start": "bun ./dist/index.js",
80
+ "start:node": "node ./dist/index.js"
67
81
  },
68
82
  "dependencies": {
69
83
  "hono": "^4",
@@ -74,8 +88,8 @@ const PACKAGE_JSON = `{
74
88
  "@hono/vite-build": "^1",
75
89
  "@hono/vite-dev-server": "^0",
76
90
  "@sveltejs/vite-plugin-svelte": "^7",
77
- "@types/node": "^22",
78
- "typescript": "^5",
91
+ "@types/node": "^26",
92
+ "typescript": "^7",
79
93
  "vite": "^8"
80
94
  }
81
95
  }
@@ -347,9 +361,6 @@ export default app;
347
361
  },
348
362
  ];
349
363
  }
350
- export function versionsMatch(_linkedVersion, _appWants) {
351
- return true;
352
- }
353
364
  export function doctorChecks(opts = {}) {
354
365
  const packageRoot = opts.packageRoot ? resolve(opts.packageRoot) : process.cwd();
355
366
  const appRoot = opts.appRoot ? resolve(opts.appRoot) : process.cwd();
@@ -400,3 +411,35 @@ export function isSafeAppPath(appRoot, target) {
400
411
  // with the old check; callers only pass file paths.
401
412
  return nAbs === nRoot || nAbs.startsWith(nRoot + "/");
402
413
  }
414
+ /**
415
+ * Entry name for `hono-svelte add page`: no traversal, no absolute paths,
416
+ * no null chars/backslashes, and never a `layout` (layouts are convention,
417
+ * not pages). `"blog/post-1"` is valid.
418
+ */
419
+ export function isValidPageName(name) {
420
+ if (!name || name.length > 200)
421
+ return false;
422
+ if (name.startsWith("/") || name.startsWith("."))
423
+ return false;
424
+ if (name.includes("\\") || name.includes(String.fromCharCode(0)))
425
+ return false;
426
+ const parts = name.split("/");
427
+ if (parts.some((p) => p.length === 0 || p === "." || p === ".."))
428
+ return false;
429
+ if (name === "layout" || name.endsWith("/layout"))
430
+ return false;
431
+ return true;
432
+ }
433
+ /** File + content written by `hono-svelte add page <name>` (null on invalid names). */
434
+ export function newPageFile(name, pagesDir = "src/pages") {
435
+ const clean = name.replace(/\.svelte$/, "");
436
+ if (!isValidPageName(clean))
437
+ return null;
438
+ const title = (clean.split("/").pop() ?? clean).replace(/[-_]/g, " ");
439
+ const content = `<!-- src/pages/${clean}.svelte — created by \`hono-svelte add page\`.\n` +
440
+ ` This page has no script tag, so it ships as plain HTML (zero JS).\n` +
441
+ ` Add a script to hydrate it on the client, or declare \`export type Data\`\n` +
442
+ ` in a module script for typed data. -->\n` +
443
+ `<main>\n <h1>${title}</h1>\n <p>Edit src/pages/${clean}.svelte.</p>\n</main>\n`;
444
+ return { path: `${pagesDir}/${clean}.svelte`, content, skipIfExists: true };
445
+ }
@@ -8,4 +8,3 @@ export declare const allEntries: string[];
8
8
  export declare const layoutEntries: string[];
9
9
  export declare const clientEntries: string[];
10
10
  export declare const staticEntries: string[];
11
- export declare function hasClient(_entryName: string): boolean;
@@ -9,7 +9,3 @@ export const allEntries = [];
9
9
  export const layoutEntries = [];
10
10
  export const clientEntries = [];
11
11
  export const staticEntries = [];
12
- export function hasClient(_entryName) {
13
- return true;
14
- }
15
- void hasClient;
package/dist/vite.d.ts CHANGED
@@ -41,6 +41,8 @@ export type PagesPlugin = Plugin & {
41
41
  writeDts: () => string | null;
42
42
  /** Nested layout entryNames (or [] when `layouts: false`). */
43
43
  layouts: () => string[];
44
+ /** Extracted `Data` declarations per entry (per-page typed data), if any. */
45
+ dataTypes: () => Record<string, string>;
44
46
  /** Layout chain for an entry, outermost first (or [] for none). */
45
47
  layoutChain: (entryName: string) => string[];
46
48
  /**
@@ -55,9 +57,4 @@ export type LayoutSpec = {
55
57
  file: string;
56
58
  absFile: string;
57
59
  };
58
- export type PagesConfigIssue = {
59
- code: string;
60
- message: string;
61
- fix: string;
62
- };
63
60
  export declare function pages(options?: PagesOptions): PagesPlugin;
package/dist/vite.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import { getIds } from "./ids.js";
4
+ import { extractPageDataType } from "./page-data.js";
4
5
  import { ENTRY_PREFIX, ENTRY_RESOLVED_PREFIX, MANIFEST_ID, MANIFEST_RESOLVED, } from "./virtual.js";
5
6
  import { createFilter } from "vite";
6
7
  const DEFAULT_IGNORE = ["**/_*.svelte"];
@@ -13,10 +14,6 @@ function escapeRegExp(s) {
13
14
  function entryNameFromFile(file) {
14
15
  return normalizeSlashes(file).slice(0, -".svelte".length);
15
16
  }
16
- function checkPagesDir(_pagesDir) {
17
- return null;
18
- }
19
- void checkPagesDir;
20
17
  export function pages(options = {}) {
21
18
  const pagesDir = resolve(options.pagesDir ?? "src/pages");
22
19
  const ignore = options.ignore ?? DEFAULT_IGNORE;
@@ -31,6 +28,7 @@ export function pages(options = {}) {
31
28
  let vitePlugins = [];
32
29
  let viteMode = "";
33
30
  let lastWrittenDts = null;
31
+ const warnedDataImports = new Set();
34
32
  function computeLayouts(allFiles) {
35
33
  if (!layoutsEnabled)
36
34
  return [];
@@ -103,13 +101,34 @@ export function pages(options = {}) {
103
101
  }
104
102
  }
105
103
  }
106
- const hasScript = /<script[\s>]/i.test(readFileSync(absFile, "utf8"));
104
+ const source = readFileSync(absFile, "utf8");
105
+ const hasScript = /<script[\s>]/i.test(source);
107
106
  // A page is static when it has no <script> — EVEN with layouts.
108
107
  // The shell SSRs the page and wraps it in the layout chain; the layout
109
108
  // files themselves are compiled into the page's client entry only when
110
109
  // the page needs JS. Layout-wrapped static pages ship zero JS.
111
110
  const isStatic = !hasScript && !alwaysClient.has(entryName);
112
- result.push({ entryName, file, absFile, isStatic, layouts });
111
+ // Per-page typed data: `export type Data` / `interface Data` in a
112
+ // <script module> feeds a typed overload in the generated dts. The
113
+ // declaration is inlined verbatim, so it must be self-contained — a
114
+ // type referencing imports can't be resolved from the dts location.
115
+ const extracted = extractPageDataType(source);
116
+ let dataType;
117
+ if (extracted.declaration) {
118
+ if (extracted.importedRefs.length > 0) {
119
+ if (!warnedDataImports.has(entryName)) {
120
+ warnedDataImports.add(entryName);
121
+ console.warn(`[hono-svelte] page "${entryName}": the Data type references imported ` +
122
+ `identifier(s) [${extracted.importedRefs.join(", ")}]. Per-page data typing ` +
123
+ `needs a self-contained type (inline shapes or built-ins only) — this page ` +
124
+ `keeps Record<string, unknown> data.`);
125
+ }
126
+ }
127
+ else {
128
+ dataType = extracted.declaration;
129
+ }
130
+ }
131
+ result.push({ entryName, file, absFile, isStatic, layouts, dataType });
113
132
  }
114
133
  return result;
115
134
  }
@@ -135,7 +154,7 @@ export function pages(options = {}) {
135
154
  }
136
155
  function stateKey() {
137
156
  return listPages()
138
- .map((p) => (p.isStatic ? "S:" : "C:") + p.entryName)
157
+ .map((p) => (p.isStatic ? "S:" : "C:") + p.entryName + (p.dataType ? "@" + p.dataType : ""))
139
158
  .join("|");
140
159
  }
141
160
  function resolveDtsPath() {
@@ -145,24 +164,62 @@ export function pages(options = {}) {
145
164
  const rel = typeof options.dts === "string" ? options.dts : "src/hono-svelte-entries.d.ts";
146
165
  return isAbsolute(rel) ? rel : resolve(root, rel);
147
166
  }
148
- function dtsSource() {
167
+ function dataTypeNames() {
168
+ const names = new Map();
169
+ const used = new Set();
170
+ for (const p of listPages()) {
171
+ if (!p.dataType)
172
+ continue;
173
+ const base = "HonoSvelteData_" + p.entryName.replace(/[^A-Za-z0-9_$]/g, "_");
174
+ let name = base;
175
+ let n = 2;
176
+ while (used.has(name))
177
+ name = `${base}_${n++}`;
178
+ used.add(name);
179
+ names.set(p.entryName, name);
180
+ }
181
+ return names;
182
+ }
183
+ function typeDeclarations() {
149
184
  const names = listPages().map((p) => p.entryName);
150
185
  const typeName = "HonoSvelteEntries";
151
186
  const unionSrc = names.length === 0 ? "never" : names.map((n) => JSON.stringify(n)).join(" | ");
187
+ const dataNames = dataTypeNames();
188
+ const typed = listPages().filter((p) => p.dataType && dataNames.has(p.entryName));
189
+ const declarationFor = (p) => {
190
+ const decl = p.dataType.replace("Data", dataNames.get(p.entryName));
191
+ return decl.endsWith(";") ? decl : decl + ";";
192
+ };
152
193
  return [
153
- header,
154
- `// Entries: ${names.length === 0 ? "(none)" : names.join(", ")}`,
155
- "",
156
194
  `export type ${typeName} = ${unionSrc};`,
157
195
  "",
196
+ ...(typed.length > 0
197
+ ? [
198
+ "// Per-page data — `export type Data` / `interface Data` in each page's <script module>:",
199
+ ...typed.map((p) => declarationFor(p)),
200
+ "",
201
+ ]
202
+ : []),
158
203
  `declare module "hono" {`,
159
204
  ` interface ContextRenderer {`,
205
+ // Typed overloads come first, then the union catch-all: overload
206
+ // resolution tries them in order, entries without `Data` fall through.
207
+ ...typed.map((p) => ` (entryName: ${JSON.stringify(p.entryName)}, props?: import("hono-svelte").RenderProps<${dataNames.get(p.entryName)}>): Response | Promise<Response>;`),
160
208
  ` (entryName: ${typeName}, props?: import("hono-svelte").RenderProps): Response | Promise<Response>;`,
161
209
  ` }`,
162
210
  `}`,
163
211
  "",
164
212
  ].join("\n");
165
213
  }
214
+ function dtsSource() {
215
+ const names = listPages().map((p) => p.entryName);
216
+ return [
217
+ header,
218
+ `// Entries: ${names.length === 0 ? "(none)" : names.join(", ")}`,
219
+ "",
220
+ typeDeclarations(),
221
+ ].join("\n");
222
+ }
166
223
  function writeDtsFile() {
167
224
  const out = resolveDtsPath();
168
225
  if (!out)
@@ -231,10 +288,6 @@ export function pages(options = {}) {
231
288
  "ssrPages.__layouts = ssrLayouts;",
232
289
  "ssrPages.__clientEntries = clientEntries;",
233
290
  "",
234
- "export function hasClient(entryName) {",
235
- " return !Object.prototype.hasOwnProperty.call(ssrPages, entryName);",
236
- "}",
237
- "",
238
291
  ].join("\n");
239
292
  }
240
293
  function entrySource(page) {
@@ -376,6 +429,14 @@ export function pages(options = {}) {
376
429
  const page = listPages().find((p) => p.entryName === entryName);
377
430
  return page ? [...page.layouts] : [];
378
431
  },
432
+ dataTypes() {
433
+ const out = {};
434
+ for (const p of listPages()) {
435
+ if (p.dataType)
436
+ out[p.entryName] = p.dataType;
437
+ }
438
+ return out;
439
+ },
379
440
  types() {
380
441
  const names = listPages().map((p) => p.entryName);
381
442
  if (names.length === 0)
@@ -389,19 +450,7 @@ export function pages(options = {}) {
389
450
  return writeDtsFile();
390
451
  },
391
452
  typeDeclarations() {
392
- const names = listPages().map((p) => p.entryName);
393
- const typeName = "HonoSvelteEntries";
394
- const unionSrc = names.length === 0 ? "never" : names.map((n) => JSON.stringify(n)).join(" | ");
395
- return [
396
- `export type ${typeName} = ${unionSrc};`,
397
- "",
398
- `declare module "hono" {`,
399
- ` interface ContextRenderer {`,
400
- ` (entryName: ${typeName}, props?: import("hono-svelte").RenderProps): Response | Promise<Response>;`,
401
- ` }`,
402
- `}`,
403
- "",
404
- ].join("\n");
453
+ return typeDeclarations();
405
454
  },
406
455
  validateConfig() {
407
456
  const issues = [];
package/package.json CHANGED
@@ -1,11 +1,33 @@
1
1
  {
2
2
  "name": "hono-svelte",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Server-side shell + multi-entry Svelte pages for Hono apps",
5
- "type": "module",
5
+ "keywords": [
6
+ "hono",
7
+ "middleware",
8
+ "ssr",
9
+ "svelte",
10
+ "vite",
11
+ "zero-js"
12
+ ],
13
+ "homepage": "https://github.com/omarcosr/hono-svelte",
14
+ "license": "MIT",
15
+ "author": "omarcos",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/omarcosr/hono-svelte.git"
19
+ },
6
20
  "bin": {
7
21
  "hono-svelte": "dist/cli.js"
8
22
  },
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "CHANGELOG.md",
27
+ "LICENSE"
28
+ ],
29
+ "type": "module",
30
+ "sideEffects": false,
9
31
  "exports": {
10
32
  ".": {
11
33
  "types": "./dist/index.d.ts",
@@ -14,54 +36,35 @@
14
36
  "./vite": {
15
37
  "types": "./dist/vite.d.ts",
16
38
  "default": "./dist/vite.js"
17
- }
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
18
44
  },
19
- "files": [
20
- "dist",
21
- "README.md",
22
- "CHANGELOG.md",
23
- "LICENSE"
24
- ],
25
45
  "scripts": {
26
46
  "build": "tsc -p tsconfig.json",
27
47
  "typecheck": "tsc --noEmit -p tsconfig.json",
28
48
  "prepublishOnly": "npm run build && npm test && npx publint",
29
- "test": "vitest run"
30
- },
31
- "peerDependencies": {
32
- "hono": "^4",
33
- "svelte": "^5",
34
- "vite": "^6 || ^7 || ^8"
49
+ "test": "vitest run --coverage"
35
50
  },
36
51
  "devDependencies": {
37
52
  "@sveltejs/vite-plugin-svelte": "^7.3.0",
38
- "@types/node": "^22.7.0",
53
+ "@types/node": "^26.6.1",
54
+ "@vitest/coverage-v8": "^5.0.1",
39
55
  "publint": "^0.3.24",
40
56
  "svelte": "^5.57.0",
41
- "typescript": "^5.6.0",
57
+ "typescript": "^7.0.2",
42
58
  "vite": "^8.3.0",
43
59
  "vitest": "^5.0.1"
44
60
  },
45
- "engines": {
46
- "node": ">=22"
47
- },
48
- "license": "MIT",
49
- "sideEffects": false,
50
- "keywords": [
51
- "hono",
52
- "svelte",
53
- "vite",
54
- "ssr",
55
- "zero-js",
56
- "middleware"
57
- ],
58
- "author": "omarcos",
59
- "homepage": "https://github.com/omarcosr/hono-svelte",
60
- "publishConfig": {
61
- "access": "public"
61
+ "peerDependencies": {
62
+ "hono": "^4.13.8",
63
+ "svelte": "^5.57.0",
64
+ "vite": "^8.3.0"
62
65
  },
63
- "repository": {
64
- "type": "git",
65
- "url": "git+https://github.com/omarcosr/hono-svelte.git"
66
+ "engines": {
67
+ "bun": ">=1.4",
68
+ "node": ">=26"
66
69
  }
67
70
  }