hono-svelte 0.3.0 → 0.4.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 +67 -0
- package/README.md +287 -56
- package/dist/assets.d.ts +26 -0
- package/dist/assets.js +60 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +59 -0
- package/dist/handlers.d.ts +16 -0
- package/dist/handlers.js +31 -0
- package/dist/head.d.ts +19 -0
- package/dist/head.js +42 -0
- package/dist/ids.js +3 -3
- package/dist/index.d.ts +68 -3
- package/dist/index.js +297 -24
- package/dist/scaffold.d.ts +22 -0
- package/dist/scaffold.js +107 -0
- package/dist/ssr-manifest.d.ts +8 -0
- package/dist/ssr-manifest.js +14 -5
- package/dist/virtual.js +5 -5
- package/dist/vite.d.ts +49 -1
- package/dist/vite.js +277 -23
- package/package.json +67 -53
package/dist/head.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Per-page <head> tags (CSP-safe: meta/link tags plus an opt-in raw slot).
|
|
2
|
+
// No executable inline scripts are ever emitted here.
|
|
3
|
+
function escapeAttr(value) {
|
|
4
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
5
|
+
}
|
|
6
|
+
export function renderHead(head) {
|
|
7
|
+
if (!head)
|
|
8
|
+
return "";
|
|
9
|
+
if (typeof head === "string")
|
|
10
|
+
return head;
|
|
11
|
+
const out = [];
|
|
12
|
+
if (head.description !== undefined) {
|
|
13
|
+
out.push(`<meta name="description" content="${escapeAttr(head.description)}" />`);
|
|
14
|
+
}
|
|
15
|
+
if (head.canonical !== undefined) {
|
|
16
|
+
out.push(`<link rel="canonical" href="${escapeAttr(head.canonical)}" />`);
|
|
17
|
+
}
|
|
18
|
+
if (head.themeColor !== undefined) {
|
|
19
|
+
out.push(`<meta name="theme-color" content="${escapeAttr(head.themeColor)}" />`);
|
|
20
|
+
}
|
|
21
|
+
for (const m of head.meta ?? []) {
|
|
22
|
+
const attr = m.name
|
|
23
|
+
? `name="${escapeAttr(m.name)}"`
|
|
24
|
+
: m.property
|
|
25
|
+
? `property="${escapeAttr(m.property)}"`
|
|
26
|
+
: m.httpEquiv
|
|
27
|
+
? `http-equiv="${escapeAttr(m.httpEquiv)}"`
|
|
28
|
+
: null;
|
|
29
|
+
if (!attr)
|
|
30
|
+
continue;
|
|
31
|
+
out.push(`<meta ${attr} content="${escapeAttr(m.content)}" />`);
|
|
32
|
+
}
|
|
33
|
+
for (const [k, v] of Object.entries(head.og ?? {})) {
|
|
34
|
+
out.push(`<meta property="og:${escapeAttr(k)}" content="${escapeAttr(v)}" />`);
|
|
35
|
+
}
|
|
36
|
+
for (const [k, v] of Object.entries(head.twitter ?? {})) {
|
|
37
|
+
out.push(`<meta name="twitter:${escapeAttr(k)}" content="${escapeAttr(v)}" />`);
|
|
38
|
+
}
|
|
39
|
+
if (head.extra)
|
|
40
|
+
out.push(head.extra);
|
|
41
|
+
return out.join("");
|
|
42
|
+
}
|
package/dist/ids.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// IDs
|
|
2
|
-
// (
|
|
3
|
-
//
|
|
1
|
+
// Opaque IDs derived from the entryName. Pure, no runtime dependencies.
|
|
2
|
+
// (works on Node, Workers, Deno, Bun). Not a state secret:
|
|
3
|
+
// it just avoids exposing server internals in the HTML.
|
|
4
4
|
function hashString(input) {
|
|
5
5
|
let h1 = 0xdeadbeef;
|
|
6
6
|
let h2 = 0x41c6ce57;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,24 +1,89 @@
|
|
|
1
1
|
import type { Next } from "hono";
|
|
2
|
+
import { type AssetsResolver, type ViteManifest } from "./assets.js";
|
|
3
|
+
import { type HeadProps } from "./head.js";
|
|
2
4
|
export { getIds } from "./ids.js";
|
|
3
5
|
export type { SharedIds } from "./ids.js";
|
|
6
|
+
export { renderHead } from "./head.js";
|
|
7
|
+
export type { HeadMetaItem, HeadProps } from "./head.js";
|
|
8
|
+
export { CACHE_IMMUTABLE, CACHE_NO_STORE, createManifestAssets, immutableHeaders, manifestCssFor, manifestImportsFor, manifestKeysFor, } from "./assets.js";
|
|
9
|
+
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";
|
|
13
|
+
export type { DoctorIssue, DoctorOptions, InitFile } from "./scaffold.js";
|
|
4
14
|
export type SsrPageLoader = () => Promise<{
|
|
5
15
|
default: unknown;
|
|
6
16
|
}>;
|
|
7
17
|
export type RenderProps = {
|
|
8
18
|
title?: string;
|
|
9
19
|
data?: Record<string, unknown>;
|
|
20
|
+
/** HTTP status of the shell response. @default 200 */
|
|
21
|
+
status?: number;
|
|
22
|
+
/** Extra response headers merged into the shell response. */
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
/** Per-page head tags, merged after the shell `head` option. */
|
|
25
|
+
head?: string | HeadProps;
|
|
10
26
|
};
|
|
27
|
+
export type PrefetchMode = false | "hover" | "all";
|
|
11
28
|
export type ShellOptions = {
|
|
12
29
|
title?: string;
|
|
13
30
|
lang?: string;
|
|
14
31
|
assetsBase?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the client JS URL for an entry.
|
|
34
|
+
* - String: base prefix (`/static` -> `/static/<entry>.js`).
|
|
35
|
+
* - Vite manifest object or resolver function: hashed files.
|
|
36
|
+
* @default "/static"
|
|
37
|
+
*/
|
|
38
|
+
assets?: string | ViteManifest | AssetsResolver;
|
|
39
|
+
/** Extra CSS links per HTML response. Strings are hrefs verbatim. */
|
|
40
|
+
styles?: string[];
|
|
15
41
|
stylesHref?: string | ((isProd: boolean) => string);
|
|
42
|
+
/** Global head HTML (fonts, meta) — page `head` prop is appended after it. */
|
|
16
43
|
head?: string;
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
44
|
+
/**
|
|
45
|
+
* CSP nonce applied to `<script>`, `<link>` and `<style data-hs>` tags
|
|
46
|
+
* emitted by the shell. Pass a per-request nonce string or a function
|
|
47
|
+
* reading it from the Hono context (e.g. set by `secureHeaders()`).
|
|
48
|
+
*/
|
|
49
|
+
nonce?: string | ((c: unknown) => string | undefined);
|
|
50
|
+
/**
|
|
51
|
+
* `<link rel="modulepreload">` for the current entry (+ its manifest
|
|
52
|
+
* imports). @default true in prod, false in dev.
|
|
53
|
+
*/
|
|
54
|
+
preload?: boolean;
|
|
55
|
+
/** Prefetch other client entries. @default false */
|
|
56
|
+
prefetch?: PrefetchMode;
|
|
57
|
+
/**
|
|
58
|
+
* Warn once when serialized `data` exceeds this many bytes.
|
|
59
|
+
* @default 102400 (100KB). Set to `false` to disable.
|
|
60
|
+
*/
|
|
61
|
+
dataLimit?: number | false;
|
|
62
|
+
/** Manual override of the entryName -> loader map (otherwise resolved via ssr-manifest). */
|
|
19
63
|
ssrPages?: Record<string, SsrPageLoader>;
|
|
64
|
+
/**
|
|
65
|
+
* Known entry names. When set (or auto-detected from the manifest),
|
|
66
|
+
* unknown entries throw a dev-friendly error listing available pages.
|
|
67
|
+
* In production the error message is generic.
|
|
68
|
+
*/
|
|
69
|
+
knownEntries?: string[];
|
|
70
|
+
/** `false` disables unknown-entry validation. @default true */
|
|
71
|
+
strict?: boolean;
|
|
72
|
+
/** Default status when `c.render(entry)` omits it. @default 200 */
|
|
73
|
+
status?: number;
|
|
74
|
+
/** Default headers merged into every shell response. */
|
|
75
|
+
headers?: Record<string, string>;
|
|
76
|
+
/**
|
|
77
|
+
* Force prod/dev asset behavior. Defaults to auto-detect
|
|
78
|
+
* (`import.meta.env.PROD`, else `NODE_ENV === "production"`).
|
|
79
|
+
*/
|
|
80
|
+
isProd?: boolean;
|
|
20
81
|
};
|
|
21
82
|
export type ShellContext = {
|
|
22
83
|
render: (entryName: string, props?: RenderProps) => Response | Promise<Response>;
|
|
23
84
|
};
|
|
24
|
-
|
|
85
|
+
/** Visible for tests. */
|
|
86
|
+
export declare function __resetDataLimitWarned(): void;
|
|
87
|
+
export declare function shell(options?: ShellOptions): ((c: any, next: Next) => Promise<void>) & {
|
|
88
|
+
availableEntries: () => string[] | undefined;
|
|
89
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { createManifestAssets, manifestCssFor, manifestImportsFor, } from "./assets.js";
|
|
2
|
+
import { renderHead } from "./head.js";
|
|
1
3
|
import { getIds } from "./ids.js";
|
|
2
|
-
import { ssrPages as autoSsrPages } from "./ssr-manifest.js";
|
|
4
|
+
import { allEntries as autoAllEntries, ssrLayouts as autoSsrLayouts, ssrPages as autoSsrPages, } from "./ssr-manifest.js";
|
|
3
5
|
import { devEntryUrl } from "./virtual.js";
|
|
4
6
|
export { getIds } from "./ids.js";
|
|
7
|
+
export { renderHead } from "./head.js";
|
|
8
|
+
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";
|
|
5
11
|
function escapeAttr(value) {
|
|
6
12
|
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
7
13
|
}
|
|
@@ -15,70 +21,337 @@ function isValidEntryName(entryName) {
|
|
|
15
21
|
const parts = entryName.split("/");
|
|
16
22
|
return parts.every((p) => p.length > 0 && p !== "." && p !== ".." && !p.includes(String.fromCharCode(0)));
|
|
17
23
|
}
|
|
18
|
-
function
|
|
24
|
+
function nonceAttr(nonce) {
|
|
25
|
+
return nonce ? ` nonce="${escapeAttr(nonce)}"` : "";
|
|
26
|
+
}
|
|
27
|
+
function serializePageData(data, dataId, dataLimit) {
|
|
19
28
|
if (!data)
|
|
20
|
-
return "";
|
|
29
|
+
return { json: "", html: "" };
|
|
21
30
|
let json;
|
|
22
31
|
try {
|
|
23
32
|
json = JSON.stringify(data);
|
|
24
33
|
}
|
|
25
34
|
catch {
|
|
26
|
-
throw new Error("hono-svelte: props.data
|
|
35
|
+
throw new Error("hono-svelte: props.data must be JSON-serializable");
|
|
27
36
|
}
|
|
28
37
|
if (json === undefined)
|
|
38
|
+
return { json: "", html: "" };
|
|
39
|
+
checkDataLimit(json, dataLimit);
|
|
40
|
+
const safe = json.replace(/</g, "\\u003c");
|
|
41
|
+
const lt = String.fromCharCode(60);
|
|
42
|
+
return {
|
|
43
|
+
json,
|
|
44
|
+
html: lt + 'script type="application/json" id="' + dataId + '">' + safe + lt + "/script>",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const warnedDataLimit = new Set();
|
|
48
|
+
function checkDataLimit(json, dataLimit) {
|
|
49
|
+
if (dataLimit === false)
|
|
50
|
+
return;
|
|
51
|
+
const bytes = new TextEncoder().encode(json).length;
|
|
52
|
+
if (bytes <= dataLimit)
|
|
53
|
+
return;
|
|
54
|
+
const key = String(dataLimit);
|
|
55
|
+
if (warnedDataLimit.has(key))
|
|
56
|
+
return;
|
|
57
|
+
warnedDataLimit.add(key);
|
|
58
|
+
const kb = (bytes / 1024).toFixed(1);
|
|
59
|
+
const limitKb = (dataLimit / 1024).toFixed(0);
|
|
60
|
+
console.warn(`[hono-svelte] page data is ${kb}KB (limit ${limitKb}KB). ` +
|
|
61
|
+
`data is inlined in the HTML — move large/sensitive payloads to a typed API (hc<AppType>).`);
|
|
62
|
+
}
|
|
63
|
+
/** Visible for tests. */
|
|
64
|
+
export function __resetDataLimitWarned() {
|
|
65
|
+
warnedDataLimit.clear();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build-time flags. `import.meta.env.PROD/DEV` MUST be accessed statically
|
|
69
|
+
* (no `as unknown`, no intermediate variable) so Vite/esbuild can replace
|
|
70
|
+
* them with literals per bundle. In plain node (no Vite define) the access
|
|
71
|
+
* throws at module-eval time, hence the try/catch.
|
|
72
|
+
*
|
|
73
|
+
* NOTE: the server bundle is ALWAYS production (built with
|
|
74
|
+
* `vite build --mode production`), so when Vite replaced the flag the answer
|
|
75
|
+
* is baked in. The NODE_ENV fallback only matters for unbundled runtimes
|
|
76
|
+
* (tests, `vite dev` SSR). `node ./dist/index.js` needs no env prefix.
|
|
77
|
+
*/
|
|
78
|
+
function detectProd() {
|
|
79
|
+
try {
|
|
80
|
+
if (import.meta.env.PROD !== undefined)
|
|
81
|
+
return import.meta.env.PROD;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// plain node / vitest: no import.meta.env — bundled server is prod
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return globalThis.process?.env?.NODE_ENV === "production";
|
|
88
|
+
}
|
|
89
|
+
function isDev() {
|
|
90
|
+
try {
|
|
91
|
+
if (import.meta.env.DEV !== undefined)
|
|
92
|
+
return import.meta.env.DEV;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// plain node / vitest
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return globalThis.process?.env?.NODE_ENV !== "production";
|
|
99
|
+
}
|
|
100
|
+
function defaultKnownEntries(ssrPages) {
|
|
101
|
+
const keys = Object.keys(ssrPages);
|
|
102
|
+
if (ssrPages.__allEntries !== undefined) {
|
|
103
|
+
return ssrPages.__allEntries;
|
|
104
|
+
}
|
|
105
|
+
return keys.length > 0 ? keys : undefined;
|
|
106
|
+
}
|
|
107
|
+
function renderNotFoundHint(available, _prod) {
|
|
108
|
+
if (!available || available.length === 0) {
|
|
109
|
+
return "hono-svelte: unknown entry (check pagesDir and the entryName passed to c.render)";
|
|
110
|
+
}
|
|
111
|
+
const list = [...available].sort().slice(0, 20).join('", "');
|
|
112
|
+
const more = available.length > 20 ? ` (+${available.length - 20} more)` : "";
|
|
113
|
+
return `hono-svelte: unknown entry. Available pages: "${list}"${more}`;
|
|
114
|
+
}
|
|
115
|
+
function resolveAssetUrl(assets, assetsBase, entryName) {
|
|
116
|
+
const resolver = typeof assets === "function"
|
|
117
|
+
? assets
|
|
118
|
+
: typeof assets === "object" && assets !== null
|
|
119
|
+
? createManifestAssets(assets)
|
|
120
|
+
: (entry) => `${assetsBase}/${entry}.js`;
|
|
121
|
+
return resolver(entryName);
|
|
122
|
+
}
|
|
123
|
+
function manifestFor(assets) {
|
|
124
|
+
return typeof assets === "object" && assets !== null ? assets : undefined;
|
|
125
|
+
}
|
|
126
|
+
function prefetchUrls(mode, entryName, allEntries, resolveUrl) {
|
|
127
|
+
if (!mode || !allEntries)
|
|
128
|
+
return [];
|
|
129
|
+
if (mode === "all")
|
|
130
|
+
return allEntries.filter((e) => e !== entryName).map(resolveUrl);
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
function prefetchHoverScript(nonce, dataId, urls) {
|
|
134
|
+
if (urls.length === 0)
|
|
29
135
|
return "";
|
|
30
|
-
json = json.replace(/</g, "\\u003c");
|
|
31
136
|
const lt = String.fromCharCode(60);
|
|
32
|
-
|
|
137
|
+
const map = JSON.stringify(Object.fromEntries(urls.map((u) => [u.entry, u.url])));
|
|
138
|
+
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);}` +
|
|
141
|
+
`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});})();`;
|
|
144
|
+
return lt + `script${nonceAttr(nonce)}>${code}` + lt + "/script>";
|
|
33
145
|
}
|
|
34
146
|
export function shell(options = {}) {
|
|
35
147
|
const titleDefault = options.title ?? "App";
|
|
36
148
|
const lang = options.lang ?? "en";
|
|
37
149
|
const assetsBase = (options.assetsBase ?? "/static").replace(/\/$/, "");
|
|
150
|
+
const assetsOpt = options.assets ?? assetsBase;
|
|
38
151
|
const head = options.head ?? "";
|
|
152
|
+
const defaultStatus = options.status ?? 200;
|
|
153
|
+
const defaultHeaders = options.headers ?? {};
|
|
154
|
+
const dataLimit = options.dataLimit ?? 102400;
|
|
155
|
+
const strict = options.strict ?? true;
|
|
156
|
+
const extraStyles = options.styles ?? [];
|
|
39
157
|
const resolveStyles = typeof options.stylesHref === "function"
|
|
40
158
|
? options.stylesHref
|
|
41
159
|
: () => options.stylesHref;
|
|
42
|
-
// Zero config:
|
|
43
|
-
//
|
|
160
|
+
// Zero config: with pages() active, this module is redirected to the
|
|
161
|
+
// in-memory manifest by the plugin's resolveId (enforce: "pre").
|
|
44
162
|
const ssrPages = options.ssrPages ?? autoSsrPages;
|
|
45
|
-
|
|
163
|
+
// NOTE: `p` in the bundle below is the layouts map. It must come from the
|
|
164
|
+
// VIRTUAL manifest (ssrPages.__layouts), never from the stub — the stub's
|
|
165
|
+
// ssrLayouts is always {}. When the caller passes a manual ssrPages map,
|
|
166
|
+
// __layouts travels on the same object (see vite.ts manifestSource).
|
|
167
|
+
const ssrLayouts = ssrPages.__layouts ??
|
|
168
|
+
autoSsrPages.__layouts ??
|
|
169
|
+
autoSsrLayouts;
|
|
170
|
+
const staticKnown = options.knownEntries ??
|
|
171
|
+
(autoAllEntries.length > 0 ? [...autoAllEntries] : undefined) ??
|
|
172
|
+
defaultKnownEntries(ssrPages);
|
|
173
|
+
function availableEntries() {
|
|
174
|
+
if (staticKnown)
|
|
175
|
+
return staticKnown;
|
|
176
|
+
return defaultKnownEntries(ssrPages);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Client entries have a JS bundle (input() in the plugin). The virtual
|
|
180
|
+
* manifest carries them via `clientEntries`; the static stub has none.
|
|
181
|
+
* Layout-wrapped STATIC pages are SSR (no script branch) — their chain
|
|
182
|
+
* is rendered server-side.
|
|
183
|
+
*/
|
|
184
|
+
function hasClientEntry(entryName) {
|
|
185
|
+
const fromManifest = ssrPages.__clientEntries;
|
|
186
|
+
if (fromManifest)
|
|
187
|
+
return fromManifest.includes(entryName);
|
|
188
|
+
const auto = autoSsrPages.__clientEntries;
|
|
189
|
+
if (auto)
|
|
190
|
+
return auto.includes(entryName);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
function layoutChainFor(entryName) {
|
|
194
|
+
if (entryName === "layout" || entryName.endsWith("/layout"))
|
|
195
|
+
return [];
|
|
196
|
+
const layouts = Object.keys(ssrLayouts);
|
|
197
|
+
if (layouts.length === 0)
|
|
198
|
+
return [];
|
|
199
|
+
const chain = [];
|
|
200
|
+
const parts = entryName.split("/");
|
|
201
|
+
for (let i = 1; i < parts.length; i++) {
|
|
202
|
+
const layout = parts.slice(0, i).join("/") + "/layout";
|
|
203
|
+
if (layouts.includes(layout))
|
|
204
|
+
chain.push(layout);
|
|
205
|
+
}
|
|
206
|
+
if (layouts.includes("layout"))
|
|
207
|
+
chain.unshift("layout");
|
|
208
|
+
return chain;
|
|
209
|
+
}
|
|
210
|
+
function resolveNonce(c) {
|
|
211
|
+
if (typeof options.nonce === "function") {
|
|
212
|
+
try {
|
|
213
|
+
return options.nonce(c) ?? undefined;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return options.nonce;
|
|
220
|
+
}
|
|
221
|
+
async function renderSsrBody(loader, pageData, nonce, layoutChain) {
|
|
222
|
+
const mod = await loader();
|
|
223
|
+
const { render } = await import("svelte/server");
|
|
224
|
+
const renderOpts = {};
|
|
225
|
+
if (pageData !== undefined)
|
|
226
|
+
renderOpts.props = pageData;
|
|
227
|
+
if (nonce)
|
|
228
|
+
renderOpts.csp = { nonce };
|
|
229
|
+
const rendered = render(mod.default, renderOpts);
|
|
230
|
+
let html = rendered.html ?? rendered.body ?? "";
|
|
231
|
+
let headValue = typeof rendered.head === "function" ? rendered.head() : (rendered.head ?? "");
|
|
232
|
+
if (layoutChain.length > 0) {
|
|
233
|
+
const { createRawSnippet } = await import("svelte");
|
|
234
|
+
for (const layoutLoader of layoutChain) {
|
|
235
|
+
const layoutMod = await layoutLoader();
|
|
236
|
+
const childHtml = html;
|
|
237
|
+
// The snippet MUST render a single wrapper element: the server
|
|
238
|
+
// renderer pushes render() output verbatim, and the client
|
|
239
|
+
// createRawSnippet takes get_first_child(fragment). An empty render
|
|
240
|
+
// breaks hydration ("Cannot set properties of null"). The extra div
|
|
241
|
+
// is hydration-safe (hydrate reuses SSR DOM).
|
|
242
|
+
const childSnippet = createRawSnippet(() => ({ render: () => `<div>${childHtml}</div>` }));
|
|
243
|
+
const wrapped = render(layoutMod.default, {
|
|
244
|
+
...(pageData !== undefined ? { props: pageData } : {}),
|
|
245
|
+
props: { ...(pageData ?? {}), children: childSnippet },
|
|
246
|
+
...(nonce ? { csp: { nonce } } : {}),
|
|
247
|
+
});
|
|
248
|
+
html = wrapped.html ?? wrapped.body ?? "";
|
|
249
|
+
const layoutHead = typeof wrapped.head === "function" ? wrapped.head() : (wrapped.head ?? "");
|
|
250
|
+
headValue = layoutHead + headValue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { html, head: headValue };
|
|
254
|
+
}
|
|
255
|
+
async function inner(c, next) {
|
|
46
256
|
c.setRenderer(async (entryName, props) => {
|
|
47
257
|
if (!isValidEntryName(entryName)) {
|
|
48
|
-
throw new Error(`hono-svelte: entryName
|
|
258
|
+
throw new Error(`hono-svelte: invalid entryName: ${JSON.stringify(entryName)}`);
|
|
259
|
+
}
|
|
260
|
+
const loader = ssrPages[entryName];
|
|
261
|
+
// A page is SSR when it is static (no <script>) — LAYOUTS DON'T MATTER
|
|
262
|
+
// here. A static page wrapped in layouts SSRs the page and wraps it in
|
|
263
|
+
// the layout chain (same rule as pages().staticEntries/clientEntries).
|
|
264
|
+
// Client entries (has <script>) always go the script branch.
|
|
265
|
+
const chain = layoutChainFor(entryName)
|
|
266
|
+
.map((l) => ssrLayouts[l])
|
|
267
|
+
.filter((l) => l !== undefined);
|
|
268
|
+
const isSsr = loader !== undefined && !hasClientEntry(entryName);
|
|
269
|
+
if (strict && !isSsr) {
|
|
270
|
+
const known = availableEntries();
|
|
271
|
+
if (known && !known.includes(entryName)) {
|
|
272
|
+
throw new Error(`${renderNotFoundHint(known)} (got ${JSON.stringify(entryName)})`);
|
|
273
|
+
}
|
|
49
274
|
}
|
|
50
275
|
const ids = getIds(entryName);
|
|
51
276
|
const title = props?.title ?? titleDefault;
|
|
52
|
-
const isProd =
|
|
277
|
+
const isProd = options.isProd ?? detectProd();
|
|
278
|
+
const nonce = resolveNonce(c);
|
|
279
|
+
const nAttr = nonceAttr(nonce);
|
|
53
280
|
const stylesHref = resolveStyles(isProd) ?? (isProd ? "/static/styles.css" : "/src/styles.css");
|
|
54
|
-
const dataHtml =
|
|
281
|
+
const { html: dataHtml } = serializePageData(props?.data, ids.dataId, dataLimit);
|
|
55
282
|
const lt = String.fromCharCode(60);
|
|
56
|
-
const loader = ssrPages[entryName];
|
|
57
283
|
let bodyHtml = "";
|
|
58
284
|
let ssrHead = "";
|
|
59
|
-
if (
|
|
60
|
-
const
|
|
61
|
-
const { render } = await import("svelte/server");
|
|
62
|
-
const rendered = render(mod.default);
|
|
285
|
+
if (isSsr) {
|
|
286
|
+
const rendered = await renderSsrBody(loader, props?.data, nonce, chain);
|
|
63
287
|
bodyHtml = rendered.html;
|
|
64
|
-
ssrHead = rendered.head
|
|
288
|
+
ssrHead = rendered.head;
|
|
65
289
|
}
|
|
290
|
+
const resolveUrl = (entry) => isProd ? resolveAssetUrl(assetsOpt, assetsBase, entry) : devEntryUrl(entry);
|
|
66
291
|
let scriptHtml = "";
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
292
|
+
let preloadHtml = "";
|
|
293
|
+
let prefetchHtml = "";
|
|
294
|
+
if (!isSsr) {
|
|
295
|
+
const src = resolveUrl(entryName);
|
|
296
|
+
scriptHtml = lt + `script type="module" src="${escapeAttr(src)}"${nAttr}>` + lt + "/script>";
|
|
297
|
+
const shouldPreload = options.preload ?? isProd;
|
|
298
|
+
const manifest = isProd ? manifestFor(assetsOpt) : undefined;
|
|
299
|
+
if (shouldPreload) {
|
|
300
|
+
const urls = [src];
|
|
301
|
+
if (manifest) {
|
|
302
|
+
for (const imp of manifestImportsFor(manifest, entryName)) {
|
|
303
|
+
if (!urls.includes(imp))
|
|
304
|
+
urls.push(imp);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
preloadHtml = urls
|
|
308
|
+
.map((u) => lt + `link rel="modulepreload" href="${escapeAttr(u)}"${nAttr} />`)
|
|
309
|
+
.join("");
|
|
310
|
+
}
|
|
311
|
+
if (isProd && options.prefetch) {
|
|
312
|
+
const known = availableEntries();
|
|
313
|
+
if (options.prefetch === "all" && known) {
|
|
314
|
+
for (const u of prefetchUrls("all", entryName, known, resolveUrl)) {
|
|
315
|
+
prefetchHtml += lt + `link rel="prefetch" href="${escapeAttr(u)}"${nAttr} />`;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else if (options.prefetch === "hover" && known) {
|
|
319
|
+
const others = known.filter((e) => e !== entryName && ssrPages[e] === undefined);
|
|
320
|
+
prefetchHtml = prefetchHoverScript(nonce, ids.dataId, others.map((e) => ({ entry: e, url: resolveUrl(e) })));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
70
323
|
}
|
|
324
|
+
const status = props?.status ?? defaultStatus;
|
|
325
|
+
const headers = { ...defaultHeaders, ...(props?.headers ?? {}) };
|
|
326
|
+
const pageHead = props?.head !== undefined ? renderHead(props.head) : "";
|
|
327
|
+
const manifestNow = isProd ? manifestFor(assetsOpt) : undefined;
|
|
328
|
+
const cssLinks = (manifestNow ? manifestCssFor(manifestNow, entryName) : [])
|
|
329
|
+
.map((href) => lt + `link rel="stylesheet" href="${escapeAttr(href)}"${nAttr} />`)
|
|
330
|
+
.join("") +
|
|
331
|
+
extraStyles
|
|
332
|
+
.map((href) => lt + `link rel="stylesheet" href="${escapeAttr(href)}"${nAttr} />`)
|
|
333
|
+
.join("");
|
|
71
334
|
return c.html(`<!doctype html><html lang="${escapeAttr(lang)}"><head><title>${escapeAttr(title)}</title>` +
|
|
72
335
|
`<meta charset="utf-8" /><meta content="width=device-width, initial-scale=1" name="viewport" />` +
|
|
73
336
|
(head ? head : "") +
|
|
74
|
-
`<link rel="stylesheet" href="${escapeAttr(stylesHref)}" />` +
|
|
337
|
+
`<link rel="stylesheet" href="${escapeAttr(stylesHref)}"${nAttr} />` +
|
|
338
|
+
cssLinks +
|
|
75
339
|
ssrHead +
|
|
340
|
+
pageHead +
|
|
341
|
+
preloadHtml +
|
|
342
|
+
prefetchHtml +
|
|
76
343
|
scriptHtml +
|
|
77
344
|
`</head>` +
|
|
78
345
|
`<body class="bg-base-200 min-h-screen text-base-content"><div id="${ids.rootId}">${bodyHtml}</div>` +
|
|
79
346
|
dataHtml +
|
|
80
|
-
`</body></html
|
|
347
|
+
`</body></html>`, status, headers);
|
|
81
348
|
});
|
|
82
349
|
await next();
|
|
83
|
-
}
|
|
350
|
+
}
|
|
351
|
+
async function shellMiddleware(c, next) {
|
|
352
|
+
return inner(c, next);
|
|
353
|
+
}
|
|
354
|
+
const mw = shellMiddleware;
|
|
355
|
+
mw.availableEntries = availableEntries;
|
|
356
|
+
return mw;
|
|
84
357
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type InitFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
content: string;
|
|
4
|
+
skipIfExists: boolean;
|
|
5
|
+
};
|
|
6
|
+
export declare function initFiles(): InitFile[];
|
|
7
|
+
export type DoctorIssue = {
|
|
8
|
+
/** Machine-readable code, e.g. "missing-dist". */
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
fix: string;
|
|
12
|
+
};
|
|
13
|
+
export type DoctorOptions = {
|
|
14
|
+
/** Root of the hono-svelte package (where `dist/` lives). @default cwd */
|
|
15
|
+
packageRoot?: string;
|
|
16
|
+
/** Root of the app being checked. @default cwd */
|
|
17
|
+
appRoot?: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function versionsMatch(_linkedVersion: string, _appWants: string): boolean;
|
|
20
|
+
export declare function doctorChecks(opts?: DoctorOptions): DoctorIssue[];
|
|
21
|
+
export declare function checkAppLink(appRoot: string): DoctorIssue | null;
|
|
22
|
+
export declare function isSafeAppPath(appRoot: string, target: string): boolean;
|
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `hono-svelte init` + `hono-svelte doctor` helpers.
|
|
2
|
+
//
|
|
3
|
+
// The package exposes pure helpers (`initFiles`, `doctorChecks`) so they can
|
|
4
|
+
// be unit-tested; the thin CLI wrapper lives in `src/cli.ts` (compiled to
|
|
5
|
+
// `dist/cli.js`, invoked via the `hono-svelte-doctor` bin).
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
const ENV_DTS = `import type { RenderProps } from "hono-svelte";
|
|
9
|
+
|
|
10
|
+
declare module "hono" {
|
|
11
|
+
interface ContextRenderer {
|
|
12
|
+
(entryName: string, props?: RenderProps): Response | Promise<Response>;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
const VITE_CONFIG = `import build from "@hono/vite-build/node";
|
|
17
|
+
import devServer from "@hono/vite-dev-server";
|
|
18
|
+
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
19
|
+
import { pages } from "hono-svelte/vite";
|
|
20
|
+
import { resolve } from "node:path";
|
|
21
|
+
import { defineConfig } from "vite";
|
|
22
|
+
|
|
23
|
+
const appPages = pages();
|
|
24
|
+
|
|
25
|
+
export default defineConfig(({ command, mode }) => {
|
|
26
|
+
if (mode === "client") {
|
|
27
|
+
return {
|
|
28
|
+
plugins: [svelte(), appPages],
|
|
29
|
+
build: {
|
|
30
|
+
rollupOptions: {
|
|
31
|
+
input: { ...appPages.input(), styles: resolve("src/styles.css") },
|
|
32
|
+
output: {
|
|
33
|
+
entryFileNames: "static/[name].js",
|
|
34
|
+
chunkFileNames: "static/chunks/[name]-[hash].js",
|
|
35
|
+
assetFileNames: "static/[name][extname]",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (command === "serve") {
|
|
43
|
+
return {
|
|
44
|
+
plugins: [svelte(), appPages, devServer({ entry: "src/routes/index.ts" })],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
plugins: [svelte(), appPages, build({ entry: "src/routes/index.ts", staticRoot: "./dist" })],
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
`;
|
|
53
|
+
const INDEX_SVELTE = `<main>
|
|
54
|
+
<h1>Welcome</h1>
|
|
55
|
+
<a href="/dashboard">Sign in</a>
|
|
56
|
+
</main>
|
|
57
|
+
`;
|
|
58
|
+
export function initFiles() {
|
|
59
|
+
return [
|
|
60
|
+
{ path: "src/env.d.ts", content: ENV_DTS, skipIfExists: true },
|
|
61
|
+
{ path: "vite.config.ts", content: VITE_CONFIG, skipIfExists: true },
|
|
62
|
+
{ path: "src/pages/index.svelte", content: INDEX_SVELTE, skipIfExists: true },
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
export function versionsMatch(_linkedVersion, _appWants) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
export function doctorChecks(opts = {}) {
|
|
69
|
+
const packageRoot = opts.packageRoot ? resolve(opts.packageRoot) : process.cwd();
|
|
70
|
+
const appRoot = opts.appRoot ? resolve(opts.appRoot) : process.cwd();
|
|
71
|
+
const issues = [];
|
|
72
|
+
for (const file of ["dist/index.js", "dist/vite.js", "dist/index.d.ts", "dist/vite.d.ts"]) {
|
|
73
|
+
if (!existsSync(join(packageRoot, file))) {
|
|
74
|
+
issues.push({
|
|
75
|
+
code: "missing-dist",
|
|
76
|
+
message: `hono-svelte dist is missing (${file} not found). The package was not built.`,
|
|
77
|
+
fix: "Run `npm run build` in the hono-svelte package directory.",
|
|
78
|
+
});
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const viteConfig = join(appRoot, "vite.config.ts");
|
|
83
|
+
if (!existsSync(viteConfig) && !existsSync(join(appRoot, "vite.config.js"))) {
|
|
84
|
+
issues.push({
|
|
85
|
+
code: "missing-vite-config",
|
|
86
|
+
message: "No vite config found in the app.",
|
|
87
|
+
fix: "Run `hono-svelte init` to scaffold one, or copy the README quick-start.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return issues;
|
|
91
|
+
}
|
|
92
|
+
export function checkAppLink(appRoot) {
|
|
93
|
+
const appLink = join(resolve(appRoot), "node_modules", "hono-svelte");
|
|
94
|
+
if (!existsSync(join(appLink, "package.json"))) {
|
|
95
|
+
return {
|
|
96
|
+
code: "missing-link",
|
|
97
|
+
message: `node_modules/hono-svelte is missing or a stale junction in ${appRoot}.`,
|
|
98
|
+
fix: "Run `npm install` inside the app directory to recreate the file: link.",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
export function isSafeAppPath(appRoot, target) {
|
|
104
|
+
const root = resolve(appRoot);
|
|
105
|
+
const abs = isAbsolute(target) ? target : resolve(root, target);
|
|
106
|
+
return abs === root || abs.startsWith(root + "/");
|
|
107
|
+
}
|
package/dist/ssr-manifest.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
export declare const ssrPages: Record<string, () => Promise<{
|
|
2
2
|
default: unknown;
|
|
3
3
|
}>>;
|
|
4
|
+
export declare const ssrLayouts: Record<string, () => Promise<{
|
|
5
|
+
default: unknown;
|
|
6
|
+
}>>;
|
|
7
|
+
export declare const allEntries: string[];
|
|
8
|
+
export declare const layoutEntries: string[];
|
|
9
|
+
export declare const clientEntries: string[];
|
|
10
|
+
export declare const staticEntries: string[];
|
|
11
|
+
export declare function hasClient(_entryName: string): boolean;
|