hono-svelte 0.4.2 → 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/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
  }
@@ -1,16 +1,38 @@
1
1
  import type { HeadProps } from "./head.js";
2
+ /** Data every error page receives (merged under `opts.data`). */
3
+ export type ErrorPageData = {
4
+ status: number;
5
+ message: string;
6
+ };
2
7
  export type ErrorPageOptions = {
3
8
  /** Entry name. @default "404" (not-found) / "500" (error) */
4
9
  entry?: string;
5
10
  title?: string;
11
+ /** Extra page data, merged over `{ status, message }`. */
6
12
  data?: Record<string, unknown>;
7
13
  head?: string | HeadProps;
14
+ /**
15
+ * Answer `{ message }` JSON with the same status instead of rendering the
16
+ * page — e.g. `(c) => c.req.path.startsWith("/api/")` so API clients never
17
+ * get an HTML document.
18
+ */
19
+ json?: (c: any) => boolean;
8
20
  };
9
21
  /** Hono `notFound` handler: `app.notFound(notFoundHandler())`. */
10
- export declare function notFoundHandler(opts?: ErrorPageOptions): (c: any) => any;
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
- export declare function errorHandler(opts?: ServerErrorHandlerOptions): (err: unknown, c: any) => any;
38
+ export declare function errorHandler(opts?: ServerErrorHandlerOptions): (err: Error, c: any) => Response | Promise<Response>;
package/dist/handlers.js CHANGED
@@ -1,31 +1,74 @@
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 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, {
22
+ title: opts.title,
23
+ data: { ...page, ...opts.data },
24
+ head: opts.head,
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;
44
+ }
3
45
  /** Hono `notFound` handler: `app.notFound(notFoundHandler())`. */
4
46
  export function notFoundHandler(opts = {}) {
5
47
  const entry = opts.entry ?? "404";
6
- return (c) => {
7
- return c.render(entry, {
8
- title: opts.title,
9
- data: opts.data,
10
- head: opts.head,
11
- status: 404,
12
- });
13
- };
48
+ const message = opts.message ?? "Not Found";
49
+ // See the file header for why the context param stays `any`.
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;
14
56
  }
15
57
  /** Hono `onError` handler: `app.onError(errorHandler())`. */
16
58
  export function errorHandler(opts = {}) {
17
59
  const entry = opts.entry ?? "500";
18
60
  const log = opts.log ?? true;
61
+ const fallback = opts.message ?? "Internal Server Error";
62
+ const expose = opts.expose ?? ((_err, status) => status < 500);
19
63
  return (err, c) => {
20
- if (log === true)
21
- console.error(err);
22
- else if (typeof log === "function")
23
- log(err);
24
- return c.render(entry, {
25
- title: opts.title,
26
- data: opts.data,
27
- head: opts.head,
28
- status: 500,
29
- });
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));
30
73
  };
31
74
  }
package/dist/index.d.ts CHANGED
@@ -1,22 +1,43 @@
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";
11
- export type { ErrorPageOptions, ServerErrorHandlerOptions } from "./handlers.js";
12
- export { initFiles, doctorChecks, checkAppLink, versionsMatch, isSafeAppPath } from "./scaffold.js";
6
+ export { errorHandler, notFoundHandler } from "./handlers.js";
7
+ export type { ErrorPageData, ErrorPageOptions, ServerErrorHandlerOptions } from "./handlers.js";
8
+ export { renderHead } from "./head.js";
9
+ export type { HeadMetaItem, HeadProps } from "./head.js";
10
+ export { getIds } from "./ids.js";
11
+ export type { PageInfo } from "./page.js";
12
+ export type { SharedIds } from "./ids.js";
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
  }>;
17
- export type RenderProps = {
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;
38
+ export type RenderProps<TData = Record<string, unknown>> = {
18
39
  title?: string;
19
- data?: Record<string, unknown>;
40
+ data?: TData;
20
41
  /** HTTP status of the shell response. @default 200 */
21
42
  status?: number;
22
43
  /** Extra response headers merged into the shell response. */
@@ -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
  /**
@@ -69,6 +99,11 @@ export type ShellOptions = {
69
99
  knownEntries?: string[];
70
100
  /** `false` disables unknown-entry validation. @default true */
71
101
  strict?: boolean;
102
+ /**
103
+ * When SSR of a page throws at request time: log the error and fall back
104
+ * to client rendering (entry script) instead of a 500. @default false
105
+ */
106
+ ssrFallback?: boolean;
72
107
  /** Default status when `c.render(entry)` omits it. @default 200 */
73
108
  status?: number;
74
109
  /** Default headers merged into every shell response. */
@@ -78,6 +113,13 @@ export type ShellOptions = {
78
113
  * (`import.meta.env.PROD`, else `NODE_ENV === "production"`).
79
114
  */
80
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>);
81
123
  };
82
124
  export type ShellContext = {
83
125
  render: (entryName: string, props?: RenderProps) => Response | Promise<Response>;
package/dist/index.js CHANGED
@@ -1,13 +1,14 @@
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
- export { getIds } from "./ids.js";
7
- export { renderHead } from "./head.js";
8
7
  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";
8
+ export { errorHandler, notFoundHandler } from "./handlers.js";
9
+ export { renderHead } from "./head.js";
10
+ export { getIds } from "./ids.js";
11
+ export { checkAppLink, doctorChecks, initFiles, isSafeAppPath, isValidPageName, newPageFile } from "./scaffold.js";
11
12
  function escapeAttr(value) {
12
13
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
13
14
  }
@@ -21,10 +22,46 @@ 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
  }
27
- function serializePageData(data, dataId, dataLimit) {
64
+ function serializePageData(entryName, data, dataId, dataLimit) {
28
65
  if (!data)
29
66
  return { json: "", html: "" };
30
67
  let json;
@@ -36,7 +73,7 @@ function serializePageData(data, dataId, dataLimit) {
36
73
  }
37
74
  if (json === undefined)
38
75
  return { json: "", html: "" };
39
- checkDataLimit(json, dataLimit);
76
+ checkDataLimit(entryName, json, dataLimit);
40
77
  const safe = json.replace(/</g, "\\u003c");
41
78
  const lt = String.fromCharCode(60);
42
79
  return {
@@ -45,19 +82,21 @@ function serializePageData(data, dataId, dataLimit) {
45
82
  };
46
83
  }
47
84
  const warnedDataLimit = new Set();
48
- function checkDataLimit(json, dataLimit) {
85
+ function checkDataLimit(entryName, json, dataLimit) {
49
86
  if (dataLimit === false)
50
87
  return;
51
88
  const bytes = new TextEncoder().encode(json).length;
52
89
  if (bytes <= dataLimit)
53
90
  return;
54
- const key = String(dataLimit);
91
+ // One warning per (page, limit): a second page over the limit must still
92
+ // be reported (the key used to be just the limit — one warn per process).
93
+ const key = entryName + ":" + String(dataLimit);
55
94
  if (warnedDataLimit.has(key))
56
95
  return;
57
96
  warnedDataLimit.add(key);
58
97
  const kb = (bytes / 1024).toFixed(1);
59
98
  const limitKb = (dataLimit / 1024).toFixed(0);
60
- console.warn(`[hono-svelte] page data is ${kb}KB (limit ${limitKb}KB). ` +
99
+ console.warn(`[hono-svelte] page "${entryName}" data is ${kb}KB (limit ${limitKb}KB). ` +
61
100
  `data is inlined in the HTML — move large/sensitive payloads to a typed API (hc<AppType>).`);
62
101
  }
63
102
  /** Visible for tests. */
@@ -84,7 +123,8 @@ function detectProd() {
84
123
  // plain node / vitest: no import.meta.env — bundled server is prod
85
124
  return true;
86
125
  }
87
- return globalThis.process?.env?.NODE_ENV === "production";
126
+ return (globalThis.process?.env?.NODE_ENV ===
127
+ "production");
88
128
  }
89
129
  function isDev() {
90
130
  try {
@@ -95,7 +135,8 @@ function isDev() {
95
135
  // plain node / vitest
96
136
  return false;
97
137
  }
98
- return globalThis.process?.env?.NODE_ENV !== "production";
138
+ return (globalThis.process?.env?.NODE_ENV !==
139
+ "production");
99
140
  }
100
141
  function defaultKnownEntries(ssrPages) {
101
142
  const keys = Object.keys(ssrPages);
@@ -134,18 +175,23 @@ function prefetchHoverScript(nonce, dataId, urls) {
134
175
  if (urls.length === 0)
135
176
  return "";
136
177
  const lt = String.fromCharCode(60);
137
- 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".
138
180
  const code = `(function(){var m=${map};` +
139
- `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);}` +
181
+ `function pre(u){if(document.querySelector('link[rel=\"modulepreload\"][href=\"'+u+'\"]'))return;` +
182
+ `var l=document.createElement('link');l.rel='modulepreload';l.href=u;document.head.appendChild(l);}` +
141
183
  `document.addEventListener('mouseover',function(e){var a=e.target&&e.target.closest?e.target.closest('a[href]'):null;` +
142
- `if(!a)return;try{var p=new URL(a.href,location.origin).pathname.replace(/^\\//,'');` +
143
- `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});})();`;
144
186
  return lt + `script${nonceAttr(nonce)}>${code}` + lt + "/script>";
145
187
  }
146
188
  export function shell(options = {}) {
147
189
  const titleDefault = options.title ?? "App";
148
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
+ };
149
195
  const assetsBase = (options.assetsBase ?? "/static").replace(/\/$/, "");
150
196
  const assetsOpt = options.assets ?? assetsBase;
151
197
  const head = options.head ?? "";
@@ -153,10 +199,18 @@ export function shell(options = {}) {
153
199
  const defaultHeaders = options.headers ?? {};
154
200
  const dataLimit = options.dataLimit ?? 102400;
155
201
  const strict = options.strict ?? true;
202
+ const ssrFallback = options.ssrFallback ?? false;
156
203
  const extraStyles = options.styles ?? [];
157
204
  const resolveStyles = typeof options.stylesHref === "function"
158
205
  ? options.stylesHref
159
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
+ }
160
214
  // Zero config: with pages() active, this module is redirected to the
161
215
  // in-memory manifest by the plugin's resolveId (enforce: "pre").
162
216
  const ssrPages = options.ssrPages ?? autoSsrPages;
@@ -212,13 +266,15 @@ export function shell(options = {}) {
212
266
  try {
213
267
  return options.nonce(c) ?? undefined;
214
268
  }
215
- 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);
216
272
  return undefined;
217
273
  }
218
274
  }
219
275
  return options.nonce;
220
276
  }
221
- async function renderSsrBody(loader, pageData, nonce, layoutChain) {
277
+ async function renderSsrBody(loader, pageData, nonce, layoutChain, page) {
222
278
  const mod = await loader();
223
279
  const { render } = await import("svelte/server");
224
280
  const renderOpts = {};
@@ -226,12 +282,14 @@ export function shell(options = {}) {
226
282
  renderOpts.props = pageData;
227
283
  if (nonce)
228
284
  renderOpts.csp = { nonce };
285
+ renderOpts.context = pageContext(page);
229
286
  const rendered = render(mod.default, renderOpts);
230
287
  let html = rendered.html ?? rendered.body ?? "";
231
288
  let headValue = typeof rendered.head === "function" ? rendered.head() : (rendered.head ?? "");
232
289
  if (layoutChain.length > 0) {
233
290
  const { createRawSnippet } = await import("svelte");
234
- for (const layoutLoader of layoutChain) {
291
+ // The chain is outermost first; wrapping goes inside out.
292
+ for (const layoutLoader of [...layoutChain].reverse()) {
235
293
  const layoutMod = await layoutLoader();
236
294
  const childHtml = html;
237
295
  // The snippet MUST render a single wrapper element: the server
@@ -241,9 +299,9 @@ export function shell(options = {}) {
241
299
  // is hydration-safe (hydrate reuses SSR DOM).
242
300
  const childSnippet = createRawSnippet(() => ({ render: () => `<div>${childHtml}</div>` }));
243
301
  const wrapped = render(layoutMod.default, {
244
- ...(pageData !== undefined ? { props: pageData } : {}),
245
- props: { ...(pageData ?? {}), children: childSnippet },
302
+ props: { ...pageData, children: childSnippet },
246
303
  ...(nonce ? { csp: { nonce } } : {}),
304
+ context: pageContext(page),
247
305
  });
248
306
  html = wrapped.html ?? wrapped.body ?? "";
249
307
  const layoutHead = typeof wrapped.head === "function" ? wrapped.head() : (wrapped.head ?? "");
@@ -252,7 +310,18 @@ export function shell(options = {}) {
252
310
  }
253
311
  return { html, head: headValue };
254
312
  }
255
- async function inner(c, next) {
313
+ // The context param stays `any` ON PURPOSE — validated empirically:
314
+ // typing it (MiddlewareHandler / Context / generics) works with a single
315
+ // hono copy and even with duplicate copies of the SAME version, but breaks
316
+ // when the consumer's resolution picks a hono copy with a different version
317
+ // than the one this package's d.ts resolves to (file:/npm-link/monorepo
318
+ // topologies — e.g. app hono 4.13.7 vs package types 4.13.8 fail with
319
+ // `[GET_MATCH_RESULT]`-symbol mismatches). A d.ts free of hono type
320
+ // references is immune to any copy/version layout. The
321
+ // render/setRenderer boundary needs casts regardless (hono's default
322
+ // Renderer only accepts string content), so `any` here costs no real
323
+ // type safety — the casts carry the correctness burden.
324
+ const inner = async (c, next) => {
256
325
  c.setRenderer(async (entryName, props) => {
257
326
  if (!isValidEntryName(entryName)) {
258
327
  throw new Error(`hono-svelte: invalid entryName: ${JSON.stringify(entryName)}`);
@@ -265,7 +334,7 @@ export function shell(options = {}) {
265
334
  const chain = layoutChainFor(entryName)
266
335
  .map((l) => ssrLayouts[l])
267
336
  .filter((l) => l !== undefined);
268
- const isSsr = loader !== undefined && !hasClientEntry(entryName);
337
+ let isSsr = loader !== undefined && !hasClientEntry(entryName);
269
338
  if (strict && !isSsr) {
270
339
  const known = availableEntries();
271
340
  if (known && !known.includes(entryName)) {
@@ -273,19 +342,29 @@ export function shell(options = {}) {
273
342
  }
274
343
  }
275
344
  const ids = getIds(entryName);
276
- const title = props?.title ?? titleDefault;
345
+ const page = { path: c.req.path, route: c.req.routePath ?? c.req.path };
277
346
  const isProd = options.isProd ?? detectProd();
278
347
  const nonce = resolveNonce(c);
279
348
  const nAttr = nonceAttr(nonce);
280
- const stylesHref = resolveStyles(isProd) ?? (isProd ? "/static/styles.css" : "/src/styles.css");
281
- const { html: dataHtml } = serializePageData(props?.data, ids.dataId, dataLimit);
349
+ const stylesHref = resolveStyles(isProd) ?? defaultStylesHref(isProd);
350
+ const { html: dataHtml } = serializePageData(entryName, props?.data, ids.dataId, dataLimit);
282
351
  const lt = String.fromCharCode(60);
283
352
  let bodyHtml = "";
284
353
  let ssrHead = "";
285
354
  if (isSsr) {
286
- const rendered = await renderSsrBody(loader, props?.data, nonce, chain);
287
- bodyHtml = rendered.html;
288
- ssrHead = rendered.head;
355
+ try {
356
+ const rendered = await renderSsrBody(loader, props?.data, nonce, chain, page);
357
+ bodyHtml = rendered.html;
358
+ ssrHead = rendered.head;
359
+ }
360
+ catch (err) {
361
+ if (!ssrFallback)
362
+ throw err;
363
+ console.error(`[hono-svelte] SSR failed for "${entryName}" — falling back to client rendering:`, err);
364
+ bodyHtml = "";
365
+ ssrHead = "";
366
+ isSsr = false;
367
+ }
289
368
  }
290
369
  const resolveUrl = (entry) => isProd ? resolveAssetUrl(assetsOpt, assetsBase, entry) : devEntryUrl(entry);
291
370
  let scriptHtml = "";
@@ -293,7 +372,8 @@ export function shell(options = {}) {
293
372
  let prefetchHtml = "";
294
373
  if (!isSsr) {
295
374
  const src = resolveUrl(entryName);
296
- scriptHtml = lt + `script type="module" src="${escapeAttr(src)}"${nAttr}>` + lt + "/script>";
375
+ scriptHtml =
376
+ lt + `script type="module" src="${escapeAttr(src)}"${nAttr}>` + lt + "/script>";
297
377
  const shouldPreload = options.preload ?? isProd;
298
378
  const manifest = isProd ? manifestFor(assetsOpt) : undefined;
299
379
  if (shouldPreload) {
@@ -309,20 +389,21 @@ export function shell(options = {}) {
309
389
  .join("");
310
390
  }
311
391
  if (isProd && options.prefetch) {
312
- const known = availableEntries();
392
+ // Only client entries have a JS chunk; static pages are SSR-only.
393
+ const known = availableEntries()?.filter((e) => e !== entryName && (hasClientEntry(e) || ssrPages[e] === undefined));
313
394
  if (options.prefetch === "all" && known) {
314
395
  for (const u of prefetchUrls("all", entryName, known, resolveUrl)) {
315
- prefetchHtml += lt + `link rel="prefetch" href="${escapeAttr(u)}"${nAttr} />`;
396
+ prefetchHtml += lt + `link rel="modulepreload" href="${escapeAttr(u)}"${nAttr} />`;
316
397
  }
317
398
  }
318
399
  else if (options.prefetch === "hover" && known) {
319
- const others = known.filter((e) => e !== entryName && ssrPages[e] === undefined);
400
+ const others = known;
320
401
  prefetchHtml = prefetchHoverScript(nonce, ids.dataId, others.map((e) => ({ entry: e, url: resolveUrl(e) })));
321
402
  }
322
403
  }
323
404
  }
324
405
  const status = props?.status ?? defaultStatus;
325
- const headers = { ...defaultHeaders, ...(props?.headers ?? {}) };
406
+ const headers = { ...defaultHeaders, ...props?.headers };
326
407
  const pageHead = props?.head !== undefined ? renderHead(props.head) : "";
327
408
  const manifestNow = isProd ? manifestFor(assetsOpt) : undefined;
328
409
  const cssLinks = (manifestNow ? manifestCssFor(manifestNow, entryName) : [])
@@ -331,27 +412,31 @@ export function shell(options = {}) {
331
412
  extraStyles
332
413
  .map((href) => lt + `link rel="stylesheet" href="${escapeAttr(href)}"${nAttr} />`)
333
414
  .join("");
334
- return c.html(`<!doctype html><html lang="${escapeAttr(lang)}"><head><title>${escapeAttr(title)}</title>` +
415
+ const resolvedHead = resolveHeadConflicts({
416
+ shellTitle: titleDefault,
417
+ explicitTitle: props?.title,
418
+ shellHead: head,
419
+ ssrHead,
420
+ pageHead,
421
+ });
422
+ return c.html(`<!doctype html><html lang="${escapeAttr(lang)}"${htmlAttributes(c)}><head>${resolvedHead.title}` +
335
423
  `<meta charset="utf-8" /><meta content="width=device-width, initial-scale=1" name="viewport" />` +
336
- (head ? head : "") +
424
+ resolvedHead.shellHead +
337
425
  `<link rel="stylesheet" href="${escapeAttr(stylesHref)}"${nAttr} />` +
338
426
  cssLinks +
339
- ssrHead +
427
+ resolvedHead.ssrHead +
340
428
  pageHead +
341
429
  preloadHtml +
342
430
  prefetchHtml +
343
431
  scriptHtml +
344
432
  `</head>` +
345
- `<body class="bg-base-200 min-h-screen text-base-content"><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>` +
346
434
  dataHtml +
347
435
  `</body></html>`, status, headers);
348
436
  });
349
437
  await next();
350
- }
351
- async function shellMiddleware(c, next) {
352
- return inner(c, next);
353
- }
354
- const mw = shellMiddleware;
438
+ };
439
+ const mw = inner;
355
440
  mw.availableEntries = availableEntries;
356
441
  return mw;
357
442
  }