hono-svelte 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - **feat(vite): per-page `Data` can use imported types.** The generated dts
6
+ rewrites references to types imported in the page's `<script module>` as
7
+ `import("…")` type queries (named, aliased, default and namespace
8
+ imports; alias/package specifiers kept, relative ones re-resolved from the
9
+ dts). Pages whose data comes from the app's own types — every real app —
10
+ can now use per-page typing instead of being skipped with a warning.
11
+ - **fix(vite): typed `c.render` overloads are actually enforced.** The
12
+ catch-all overload accepted every entry, so a wrong `data` for a typed
13
+ page failed its overload and silently matched the catch-all
14
+ (`Record<string, unknown>`). The catch-all now excludes typed entries.
15
+ Behavior change: rendering through a value typed as the whole `Entry`
16
+ union no longer compiles once a page declares `Data`. New `UntypedEntry`
17
+ (pages without `Data`) and `TypedEntry` types: generic helpers take
18
+ `UntypedEntry`.
19
+ - Imported types inside template literal types (`\`row-${RowId}\``) are
20
+ qualified too; a `//` inside an import specifier (URL) no longer cuts it.
21
+ - New `test/types.test.ts` case runs `tsc` on a page whose `Data` imports
22
+ an app type: correct data compiles, a wrong field type and a missing field
23
+ fail.
24
+
3
25
  ## 0.6.2
4
26
 
5
27
  - **fix(shell): unknown-entry errors never list the app's pages in
package/README.md CHANGED
@@ -360,12 +360,16 @@ declare module "hono" {
360
360
  }
361
361
  ```
362
362
 
363
- Helpers that forward an entry to `c.render` use the `Entry` type exported by the package — the generated union once the `.d.ts` is in the program, `string` without it. No import of the generated file needed:
363
+ The package exports the entry unions — the generated ones once the `.d.ts` is in the program, `string` without it. No import of the generated file needed:
364
+
365
+ - `Entry`: every page.
366
+ - `TypedEntry`: pages that declare a `Data` type (see per-page data typing below).
367
+ - `UntypedEntry`: the others — use it in helpers that forward an entry to `c.render`; typed pages need their own `data` and are rendered directly.
364
368
 
365
369
  ```ts
366
- import type { Entry } from "hono-svelte";
370
+ import type { UntypedEntry } from "hono-svelte";
367
371
 
368
- export const page = (entry: Entry, title: string) => (c: Context) => c.render(entry, { title });
372
+ export const page = (entry: UntypedEntry, title: string) => (c: Context) => c.render(entry, { title });
369
373
  ```
370
374
 
371
375
  `dts: "path/to/entries.d.ts"` sets a custom path (relative to the Vite root). The file regenerates on `buildStart` and via the dev watcher when entries change. Without `dts` (default `false`), declare the renderer manually with a plain `string`:
@@ -416,14 +420,30 @@ declare module "hono" {
416
420
  }
417
421
  ```
418
422
 
419
- The `Data` declaration is inlined verbatim into the dts, so it must be
420
- **self-contained** — inline shapes or built-ins only. References to imported
421
- types can't be resolved from the dts location; such pages keep
422
- `Record<string, unknown>` data with a one-time warning. Both
423
+ `Data` can use types imported in the same `<script module>` — the usual
424
+ case when the data comes from your database layer:
425
+
426
+ ```svelte
427
+ <script module lang="ts">
428
+ import type { Row } from "$lib/db";
429
+ import type { LayoutData } from "../dashboard/layout-data";
430
+ export type Data = LayoutData & { rows: Row[] };
431
+ </script>
432
+ ```
433
+
434
+ The generated dts rewrites each imported reference as an `import("…")` type
435
+ query (`import("$lib/db").Row`). Alias and package specifiers are kept as
436
+ written (your `tsconfig` resolves them anywhere); relative ones are
437
+ re-resolved from the dts location. Types declared locally in the module
438
+ script (not exported from another file) can't be referenced. Both
423
439
  `export type Data = ...` and `export interface Data { ... }` work (including
424
440
  legacy `<script context="module">`). `pages().dataTypes()` exposes the
425
441
  extracted declarations.
426
442
 
443
+ Entries with a `Data` type only accept their own overload: `c.render(entry, …)`
444
+ with an entry typed as the whole `Entry` union no longer compiles — type
445
+ generic helpers with `UntypedEntry`, and render typed pages directly.
446
+
427
447
  ## Production tips
428
448
 
429
449
  - Pass the Vite manifest to `shell({ assets: manifest })` for hashed files — including the global stylesheet: with `styles: resolve("src/styles.css")` in the client input (or `stylesEntry` pointing at another file), its hashed name comes from the manifest, no hand-written `stylesHref`. The shell walks the entry's static import graph: the CSS of shared chunks (layouts, components used by several pages) is linked together with the entry's own CSS, and every reachable chunk gets a `modulepreload`.
package/dist/index.d.ts CHANGED
@@ -35,6 +35,16 @@ export interface Register {
35
35
  export type Entry = Register extends {
36
36
  entries: infer E extends string;
37
37
  } ? E : string;
38
+ /** Entries whose page declares a `Data` type (they only accept their own `data`). */
39
+ export type TypedEntry = Register extends {
40
+ typedEntries: infer E extends string;
41
+ } ? E : never;
42
+ /**
43
+ * Entries without a `Data` type — what a generic helper forwarding an entry
44
+ * to `c.render` should accept: typed entries need their own `data` and have
45
+ * no overload taking an arbitrary entry. `string` without the generated `.d.ts`.
46
+ */
47
+ export type UntypedEntry = Exclude<Entry, TypedEntry>;
38
48
  export type RenderProps<TData = Record<string, unknown>> = {
39
49
  title?: string;
40
50
  data?: TData;
@@ -1,7 +1,26 @@
1
+ /** An import of the module script that the `Data` declaration references. */
2
+ export type DataImport = {
3
+ /** Local name used in the declaration. */
4
+ local: string;
5
+ /** Exported name: an identifier, `"default"` or `"*"` (namespace import). */
6
+ imported: string;
7
+ /** Module specifier as written in the page. */
8
+ specifier: string;
9
+ };
1
10
  export type PageDataExtraction = {
2
11
  /** Declaration text (`type Data = ...` / `interface Data { ... }`, `export` stripped). Null when the page declares no `Data`. */
3
12
  declaration: string | null;
4
- /** Imported identifiers referenced by the declaration (unusable for the dts). */
13
+ /** Imported identifiers referenced by the declaration. */
5
14
  importedRefs: string[];
15
+ /** The imports behind `importedRefs`, used to qualify the declaration for the dts. */
16
+ imports: DataImport[];
6
17
  };
7
18
  export declare function extractPageDataType(source: string): PageDataExtraction;
19
+ /**
20
+ * Rewrites every reference to an imported identifier as an `import("…")`
21
+ * type query, so the declaration resolves from any file (the generated dts):
22
+ * `Row` → `import("$lib/db").Row`, a default import → `.default`, a
23
+ * namespace `ns.Row` → `import("…").Row`. `specifierFor` maps the page's
24
+ * specifier to one valid from the dts location (relative paths move).
25
+ */
26
+ export declare function qualifyImports(declaration: string, imports: DataImport[], specifierFor: (specifier: string) => string): string;
package/dist/page-data.js CHANGED
@@ -2,14 +2,14 @@
2
2
  // `export type Data = ...` / `export interface Data { ... }` inside a page's
3
3
  // `<script module lang="ts">` (legacy `<script context="module">` works too).
4
4
  //
5
- // The declaration is inlined verbatim into the dts, so it must be
6
- // self-contained: references to imported types can't be resolved from the
7
- // dts location and are reported through `importedRefs` (the caller skips the
8
- // typed overload for that page and warns once).
5
+ // The declaration is copied into the dts. Types it imports in the module
6
+ // script (`import type { Row } from "$lib/db"`) are carried along: each
7
+ // reference becomes an `import("…")` type query (`qualifyImports`), with
8
+ // relative specifiers re-resolved from the dts location by the caller.
9
9
  const MODULE_SCRIPT_RE = /<script\b[^>]*\bmodule\b[^>]*>([\s\S]*?)<\/script>/;
10
10
  const DATA_DECL_RE = /(?:export\s+)?(type|interface)\s+Data\b/;
11
11
  export function extractPageDataType(source) {
12
- const none = { declaration: null, importedRefs: [] };
12
+ const none = { declaration: null, importedRefs: [], imports: [] };
13
13
  const script = MODULE_SCRIPT_RE.exec(source)?.[1];
14
14
  if (!script)
15
15
  return none;
@@ -26,8 +26,100 @@ export function extractPageDataType(source) {
26
26
  .replace(/\s+$/, "");
27
27
  if (!declaration)
28
28
  return none;
29
- const importedRefs = importedIdentifiers(script).filter((id) => new RegExp(`\\b${id.replace(/\$/g, "\\$")}\\b`).test(declaration));
30
- return { declaration, importedRefs };
29
+ const imports = scriptImports(script).filter((imp) => findReferences(declaration, imp.local).length > 0);
30
+ return { declaration, importedRefs: imports.map((imp) => imp.local), imports };
31
+ }
32
+ /**
33
+ * Rewrites every reference to an imported identifier as an `import("…")`
34
+ * type query, so the declaration resolves from any file (the generated dts):
35
+ * `Row` → `import("$lib/db").Row`, a default import → `.default`, a
36
+ * namespace `ns.Row` → `import("…").Row`. `specifierFor` maps the page's
37
+ * specifier to one valid from the dts location (relative paths move).
38
+ */
39
+ export function qualifyImports(declaration, imports, specifierFor) {
40
+ const edits = [];
41
+ for (const imp of imports) {
42
+ const target = `import(${JSON.stringify(specifierFor(imp.specifier))})`;
43
+ const text = imp.imported === "*" ? target : imp.imported === "default" ? `${target}.default` : `${target}.${imp.imported}`;
44
+ for (const at of findReferences(declaration, imp.local))
45
+ edits.push({ at, length: imp.local.length, text });
46
+ }
47
+ let out = declaration;
48
+ for (const edit of edits.sort((a, b) => b.at - a.at)) {
49
+ out = out.slice(0, edit.at) + edit.text + out.slice(edit.at + edit.length);
50
+ }
51
+ return out;
52
+ }
53
+ /**
54
+ * Positions where `name` is used as a type reference: a whole identifier,
55
+ * not a property access (`.name`), not an object/interface key
56
+ * (`{ name: …`), outside strings and comments — but inside the `${…}`
57
+ * holes of template literal types (`\`row-${RowId}\``), which are types.
58
+ */
59
+ function findReferences(text, name) {
60
+ const found = [];
61
+ const isId = (ch) => ch !== undefined && /[\w$]/.test(ch);
62
+ for (let i = 0; i < text.length; i++) {
63
+ const ch = text[i];
64
+ if (ch === "/" && text[i + 1] === "/") {
65
+ while (i < text.length && text[i] !== "\n")
66
+ i++;
67
+ continue;
68
+ }
69
+ if (ch === "/" && text[i + 1] === "*") {
70
+ i += 2;
71
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
72
+ i++;
73
+ i++;
74
+ continue;
75
+ }
76
+ if (ch === "`") {
77
+ i++;
78
+ while (i < text.length && text[i] !== "`") {
79
+ if (text[i] === "\\") {
80
+ i += 2;
81
+ continue;
82
+ }
83
+ if (text[i] === "$" && text[i + 1] === "{") {
84
+ const start = i + 2;
85
+ let depth = 1;
86
+ let j = start;
87
+ while (j < text.length && depth > 0) {
88
+ if (text[j] === "{")
89
+ depth++;
90
+ else if (text[j] === "}")
91
+ depth--;
92
+ j++;
93
+ }
94
+ for (const at of findReferences(text.slice(start, j - 1), name))
95
+ found.push(start + at);
96
+ i = j;
97
+ continue;
98
+ }
99
+ i++;
100
+ }
101
+ continue;
102
+ }
103
+ if (ch === '"' || ch === "'") {
104
+ i++;
105
+ while (i < text.length && text[i] !== ch) {
106
+ if (text[i] === "\\")
107
+ i++;
108
+ i++;
109
+ }
110
+ continue;
111
+ }
112
+ if (!text.startsWith(name, i) || isId(text[i - 1]) || isId(text[i + name.length]))
113
+ continue;
114
+ const before = text.slice(0, i).replace(/\s+$/, "");
115
+ const after = text.slice(i + name.length).replace(/^\s+/, "");
116
+ const isPropertyAccess = before.endsWith(".");
117
+ const isKey = (before === "" || /[{;,(]$/.test(before)) && /^\??:/.test(after);
118
+ if (!isPropertyAccess && !isKey)
119
+ found.push(i);
120
+ i += name.length - 1;
121
+ }
122
+ return found;
31
123
  }
32
124
  /**
33
125
  * Scan to the end of the declaration starting right after `Data`.
@@ -95,25 +187,33 @@ function scanDeclarationEnd(text, from, isInterface) {
95
187
  // EOF: a `type` alias that never hit `;` but balanced its brackets.
96
188
  return !isInterface && seenBrackets ? text.length : -1;
97
189
  }
98
- /** Identifiers brought in by `import ... from "..."` clauses of the script. */
99
- function importedIdentifiers(script) {
100
- const ids = new Set();
101
- for (const m of script.matchAll(/import\s+(?:type\s+)?([\s\S]*?)\s*from\s*["'][^"']+["']/g)) {
190
+ /** Bindings created by the `import … from "…"` clauses of the script, in order. */
191
+ function scriptImports(script) {
192
+ const imports = [];
193
+ const add = (imp) => {
194
+ if (!imports.some((other) => other.local === imp.local))
195
+ imports.push(imp);
196
+ };
197
+ const code = script.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/gm, "$1");
198
+ for (const m of code.matchAll(/import\s+(?:type\s+)?([\s\S]*?)\s*from\s*(["'])([^"']+)\2/g)) {
102
199
  const clause = m[1];
200
+ const specifier = m[3];
103
201
  const def = /^\s*([\w$]+)/.exec(clause);
104
202
  if (def)
105
- ids.add(def[1]);
203
+ add({ local: def[1], imported: "default", specifier });
106
204
  const ns = /\*\s+as\s+([\w$]+)/.exec(clause);
107
205
  if (ns)
108
- ids.add(ns[1]);
206
+ add({ local: ns[1], imported: "*", specifier });
109
207
  for (const named of clause.matchAll(/\{([^}]*)\}/g)) {
110
- for (const part of named[1].split(",")) {
111
- const asM = /\bas\s+([\w$]+)/.exec(part);
112
- const id = asM ? asM[1] : part.trim().split(/\s+/)[0];
113
- if (/^[\w$]+$/.test(id))
114
- ids.add(id);
208
+ for (const raw of named[1].split(",")) {
209
+ const part = raw.trim().replace(/^type\s+/, "");
210
+ if (!part)
211
+ continue;
212
+ const [imported, local = imported] = part.split(/\s+as\s+/).map((x) => x.trim());
213
+ if (/^[\w$]+$/.test(imported) && /^[\w$]+$/.test(local))
214
+ add({ local, imported, specifier });
115
215
  }
116
216
  }
117
217
  }
118
- return [...ids];
218
+ return imports;
119
219
  }
package/dist/vite.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from "no
2
2
  import { dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import { getIds } from "./ids.js";
4
4
  import { isLayoutEntry, layoutChain } from "./layouts.js";
5
- import { extractPageDataType } from "./page-data.js";
5
+ import { extractPageDataType, qualifyImports } from "./page-data.js";
6
6
  import { ENTRY_PREFIX, ENTRY_RESOLVED_PREFIX, MANIFEST_ID, MANIFEST_RESOLVED, } from "./virtual.js";
7
7
  import { createFilter } from "vite";
8
8
  const DEFAULT_IGNORE = ["**/_*.svelte"];
@@ -47,7 +47,6 @@ export function pages(options = {}) {
47
47
  let vitePlugins = [];
48
48
  let viteMode = "";
49
49
  let lastWrittenDts = null;
50
- const warnedDataImports = new Set();
51
50
  function computeLayouts(allFiles) {
52
51
  if (!layoutsEnabled)
53
52
  return [];
@@ -115,26 +114,11 @@ export function pages(options = {}) {
115
114
  // the page needs JS. Layout-wrapped static pages ship zero JS.
116
115
  const isStatic = !hasScript && !alwaysClient.has(entryName);
117
116
  // Per-page typed data: `export type Data` / `interface Data` in a
118
- // <script module> feeds a typed overload in the generated dts. The
119
- // declaration is inlined verbatim, so it must be self-contained — a
120
- // type referencing imports can't be resolved from the dts location.
117
+ // <script module> feeds a typed overload in the generated dts. Types it
118
+ // imports are carried along as `import("…")` queries (see dataDeclaration).
121
119
  const extracted = extractPageDataType(source);
122
- let dataType;
123
- if (extracted.declaration) {
124
- if (extracted.importedRefs.length > 0) {
125
- if (!warnedDataImports.has(entryName)) {
126
- warnedDataImports.add(entryName);
127
- console.warn(`[hono-svelte] page "${entryName}": the Data type references imported ` +
128
- `identifier(s) [${extracted.importedRefs.join(", ")}]. Per-page data typing ` +
129
- `needs a self-contained type (inline shapes or built-ins only) — this page ` +
130
- `keeps Record<string, unknown> data.`);
131
- }
132
- }
133
- else {
134
- dataType = extracted.declaration;
135
- }
136
- }
137
- result.push({ entryName, file, absFile, isStatic, layouts, dataType });
120
+ const dataType = extracted.declaration ?? undefined;
121
+ result.push({ entryName, file, absFile, isStatic, layouts, dataType, dataImports: extracted.imports });
138
122
  }
139
123
  return result;
140
124
  }
@@ -160,7 +144,7 @@ export function pages(options = {}) {
160
144
  }
161
145
  function stateKey() {
162
146
  return listPages()
163
- .map((p) => (p.isStatic ? "S:" : "C:") + p.entryName + (p.dataType ? "@" + p.dataType : ""))
147
+ .map((p) => (p.isStatic ? "S:" : "C:") + p.entryName + (p.dataType ? "@" + p.dataType + JSON.stringify(p.dataImports) : ""))
164
148
  .join("|");
165
149
  }
166
150
  function resolveDtsPath() {
@@ -170,6 +154,24 @@ export function pages(options = {}) {
170
154
  const rel = typeof options.dts === "string" ? options.dts : "src/hono-svelte-entries.d.ts";
171
155
  return isAbsolute(rel) ? rel : resolve(root, rel);
172
156
  }
157
+ /**
158
+ * The page's `Data` declaration, valid from the dts location: imported
159
+ * types become `import("…")` queries. Bare/alias specifiers (`$lib/db`,
160
+ * `some-package`) stay as written; relative ones are re-resolved from the
161
+ * page file to the dts file (or the Vite root when `dts` is off).
162
+ */
163
+ function dataDeclaration(page) {
164
+ const declaration = page.dataType;
165
+ if (page.dataImports.length === 0)
166
+ return declaration;
167
+ const fromDir = dirname(resolveDtsPath() ?? resolve(viteRoot ?? process.cwd(), "index.d.ts"));
168
+ return qualifyImports(declaration, page.dataImports, (specifier) => {
169
+ if (!specifier.startsWith("./") && !specifier.startsWith("../"))
170
+ return specifier;
171
+ const target = normalizeSlashes(relative(fromDir, resolve(dirname(page.absFile), specifier)));
172
+ return target.startsWith(".") ? target : "./" + target;
173
+ });
174
+ }
173
175
  function dataTypeNames() {
174
176
  const names = new Map();
175
177
  const used = new Set();
@@ -193,7 +195,7 @@ export function pages(options = {}) {
193
195
  const dataNames = dataTypeNames();
194
196
  const typed = listPages().filter((p) => p.dataType && dataNames.has(p.entryName));
195
197
  const declarationFor = (p) => {
196
- const decl = p.dataType.replace("Data", dataNames.get(p.entryName));
198
+ const decl = dataDeclaration(p).replace("Data", dataNames.get(p.entryName));
197
199
  return decl.endsWith(";") ? decl : decl + ";";
198
200
  };
199
201
  return [
@@ -211,15 +213,18 @@ export function pages(options = {}) {
211
213
  `declare module "hono-svelte" {`,
212
214
  ` interface Register {`,
213
215
  ` entries: ${typeName};`,
216
+ ` typedEntries: ${typed.length > 0 ? typed.map((p) => JSON.stringify(p.entryName)).join(" | ") : "never"};`,
214
217
  ` }`,
215
218
  `}`,
216
219
  "",
217
220
  `declare module "hono" {`,
218
221
  ` interface ContextRenderer {`,
219
- // Typed overloads come first, then the union catch-all: overload
220
- // resolution tries them in order, entries without `Data` fall through.
222
+ // One typed overload per page with `Data`, then a catch-all for the
223
+ // other entries. The catch-all EXCLUDES the typed entries: otherwise a
224
+ // wrong `data` fails the typed overload and silently matches the
225
+ // catch-all (Record<string, unknown>), and nothing is checked.
221
226
  ...typed.map((p) => ` (entryName: ${JSON.stringify(p.entryName)}, props?: import("hono-svelte").RenderProps<${dataNames.get(p.entryName)}>): Response | Promise<Response>;`),
222
- ` (entryName: ${typeName}, props?: import("hono-svelte").RenderProps): Response | Promise<Response>;`,
227
+ ` (entryName: ${typed.length > 0 ? `Exclude<${typeName}, ${typed.map((p) => JSON.stringify(p.entryName)).join(" | ")}>` : typeName}, props?: import("hono-svelte").RenderProps): Response | Promise<Response>;`,
223
228
  ` }`,
224
229
  `}`,
225
230
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-svelte",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Server-side shell + multi-entry Svelte pages for Hono apps",
5
5
  "keywords": [
6
6
  "hono",