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/CHANGELOG.md +190 -0
- package/README.md +155 -16
- package/dist/assets.d.ts +12 -2
- package/dist/assets.js +45 -14
- package/dist/cli.js +41 -7
- package/dist/handlers.d.ts +24 -2
- package/dist/handlers.js +61 -18
- package/dist/index.d.ts +51 -9
- package/dist/index.js +128 -43
- package/dist/page-data.d.ts +7 -0
- package/dist/page-data.js +119 -0
- package/dist/page.d.ts +23 -0
- package/dist/page.js +24 -0
- package/dist/scaffold.d.ts +8 -1
- package/dist/scaffold.js +79 -18
- package/dist/ssr-manifest.d.ts +0 -1
- package/dist/ssr-manifest.js +0 -4
- package/dist/vite.d.ts +11 -5
- package/dist/vite.js +165 -64
- package/package.json +45 -37
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 {
|
|
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.
|
|
40
|
-
console.log(` 2.
|
|
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.
|
|
45
|
-
console.log(` 2.
|
|
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
|
|
73
|
-
console.log(" init [--full]
|
|
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
|
}
|
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
|
-
export declare function errorHandler(opts?: ServerErrorHandlerOptions): (err:
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 {
|
|
11
|
-
export type { ErrorPageOptions, ServerErrorHandlerOptions } from "./handlers.js";
|
|
12
|
-
export {
|
|
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
|
-
|
|
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?:
|
|
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 {
|
|
10
|
-
export {
|
|
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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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
|
-
|
|
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 ===
|
|
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 !==
|
|
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=\"
|
|
140
|
-
`var l=document.createElement('link');l.rel='
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 =
|
|
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
|
-
|
|
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="
|
|
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
|
|
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, ...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|