@nextwebwg/htmlkit 1.0.0-alpha.53 → 1.0.0-alpha.54

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.
Files changed (54) hide show
  1. package/README.md +9 -6
  2. package/dist/application.d.ts +15 -1
  3. package/dist/application.d.ts.map +1 -1
  4. package/dist/application.js +152 -306
  5. package/dist/application.js.map +1 -1
  6. package/dist/build.d.ts.map +1 -1
  7. package/dist/build.js +116 -18
  8. package/dist/build.js.map +1 -1
  9. package/dist/bundle-worker.js +17 -7
  10. package/dist/bundle-worker.js.map +1 -1
  11. package/dist/bundle.d.ts +7 -1
  12. package/dist/bundle.d.ts.map +1 -1
  13. package/dist/bundle.js +6 -4
  14. package/dist/bundle.js.map +1 -1
  15. package/dist/cli.js +38 -10
  16. package/dist/cli.js.map +1 -1
  17. package/dist/client.js +11 -11
  18. package/dist/client.js.map +1 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +9 -3
  21. package/dist/config.js.map +1 -1
  22. package/dist/document.d.ts +1 -1
  23. package/dist/document.d.ts.map +1 -1
  24. package/dist/document.js.map +1 -1
  25. package/dist/head.d.ts +19 -2
  26. package/dist/head.d.ts.map +1 -1
  27. package/dist/head.js +26 -21
  28. package/dist/head.js.map +1 -1
  29. package/dist/index.d.ts +2 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/links.d.ts +8 -0
  34. package/dist/links.d.ts.map +1 -0
  35. package/dist/links.js +90 -0
  36. package/dist/links.js.map +1 -0
  37. package/dist/resource.d.ts +2 -0
  38. package/dist/resource.d.ts.map +1 -1
  39. package/dist/resource.js +8 -3
  40. package/dist/resource.js.map +1 -1
  41. package/dist/routes.d.ts +12 -3
  42. package/dist/routes.d.ts.map +1 -1
  43. package/dist/routes.js +89 -8
  44. package/dist/routes.js.map +1 -1
  45. package/dist/serve.d.ts.map +1 -1
  46. package/dist/serve.js +25 -77
  47. package/dist/serve.js.map +1 -1
  48. package/dist/server.d.ts +162 -0
  49. package/dist/server.d.ts.map +1 -0
  50. package/dist/server.js +599 -0
  51. package/dist/server.js.map +1 -0
  52. package/dist/types.d.ts +89 -7
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +8 -3
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # HTMLKit
2
2
 
3
3
  HTMLKit (`@nextwebwg/htmlkit`) builds applications with HTML Next components. It provides routes,
4
- layouts, server loaders, browser delivery, and development tools. The first production adapter
5
- generates static pages; [the server-rendering design](./docs/server-rendering.md) preserves a path
6
- to request-time rendering.
4
+ layouts, server loaders, endpoints, middleware, browser delivery, and development tools. A build
5
+ prerenders pages for any static host and writes a request server for pages that render on each
6
+ request, which `htmlkit start` runs on Node and Deno and Bun run through its `fetch`.
7
+ [The server-rendering design](./docs/server-rendering.md) records how requests are answered.
7
8
 
8
9
  Use Node `>=22.22.2 <23 || >=24.15 <25`. Install `@nextwebwg/htmlkit` and add its commands to your
9
10
  `package.json` scripts:
@@ -13,15 +14,17 @@ Use Node `>=22.22.2 <23 || >=24.15 <25`. Install `@nextwebwg/htmlkit` and add it
13
14
  "scripts": {
14
15
  "dev": "htmlkit dev",
15
16
  "build": "htmlkit build",
16
- "preview": "htmlkit preview"
17
+ "preview": "htmlkit preview",
18
+ "start": "htmlkit start"
17
19
  }
18
20
  }
19
21
  ```
20
22
 
21
- Then run `pnpm dev`, `pnpm build`, or `pnpm preview` (`npm run dev` and the others work too).
23
+ Then run `pnpm dev`, `pnpm build`, `pnpm preview`, or `pnpm start` (`npm run dev` and the others work too).
22
24
 
23
25
  **Guide:** [nextwebwg.org/htmlkit](https://nextwebwg.org/htmlkit/) covers pages and routing,
24
- loading data, navigation, moving between pages, and configuration. Its source is
26
+ loading data, endpoints and middleware, navigation, moving between pages, deployment, and
27
+ configuration. Its source is
25
28
  [`docs/guide`](./docs/guide/) in this package.
26
29
  [The client-navigation design](./docs/client-navigation.md) records how pages swap in place.
27
30
 
@@ -1,12 +1,26 @@
1
+ import { type ComponentDefinition } from "@nextwebwg/html-next";
1
2
  import { type ViteDevServer } from "vite";
2
3
  import { type PageAssets } from "./document.js";
3
- import type { Application, ApplicationOptions, RenderedHead, RenderedPage } from "./types.js";
4
+ import { type RenderLayer } from "./server.js";
5
+ import type { Application, ApplicationOptions, ApplicationRoute, BrowserDefinition, RenderedHead, RenderedPage } from "./types.js";
4
6
  export declare function headScripts(options: ApplicationOptions, root: string): Promise<readonly string[]>;
5
7
  export declare function notFoundPage(scripts: readonly string[]): {
6
8
  head: RenderedHead;
7
9
  body: string;
8
10
  html: string;
9
11
  };
12
+ /** A route's components, ready to render: its layers, every component they use, and their browser delivery. */
13
+ export interface RouteGraph {
14
+ readonly layers: readonly RenderLayer[];
15
+ readonly definitions: readonly ComponentDefinition[];
16
+ readonly components: readonly BrowserDefinition[];
17
+ }
18
+ /** The application, plus what a build reads from it: each route's component graph and the concrete page URLs. */
19
+ export declare function createApplicationInternals(input?: ApplicationOptions, moduleServer?: ViteDevServer, assets?: (page: RenderedPage) => PageAssets): Promise<{
20
+ application: Application;
21
+ graph: (route: ApplicationRoute) => Promise<RouteGraph>;
22
+ catalog: () => Promise<ReadonlyMap<string, ApplicationRoute>>;
23
+ }>;
10
24
  /** assets supplies a serving adapter's browser modules and stylesheets for fetch(). */
11
25
  export declare function createApplication(input?: ApplicationOptions, moduleServer?: ViteDevServer, assets?: (page: RenderedPage) => PageAssets): Promise<Application>;
12
26
  //# sourceMappingURL=application.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"application.d.ts","sourceRoot":"","sources":["../src/application.ts"],"names":[],"mappings":"AAQA,OAAO,EAA0C,KAAK,aAAa,EAAE,MAAM,MAAM,CAAC;AAGlF,OAAO,EAAoE,KAAK,UAAU,EAAE,MAAM,eAAe,CAAC;AAIlH,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAkI,YAAY,EAAiB,YAAY,EAA4B,MAAM,YAAY,CAAC;AAgDvQ,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAYvG;AAGD,wBAAgB,YAAY,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE;;;;EAItD;AAED,uFAAuF;AACvF,wBAAsB,iBAAiB,CAAC,KAAK,GAAE,kBAAuB,EAAE,YAAY,CAAC,EAAE,aAAa,EAClG,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAoUnE"}
1
+ {"version":3,"file":"application.d.ts","sourceRoot":"","sources":["../src/application.ts"],"names":[],"mappings":"AAKA,OAAO,EAA0D,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAExH,OAAO,EAA0C,KAAK,aAAa,EAAE,MAAM,MAAM,CAAC;AAGlF,OAAO,EAA8B,KAAK,UAAU,EAAE,MAAM,eAAe,CAAC;AAG5E,OAAO,EAAiH,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAC9J,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,iBAAiB,EAA8C,YAAY,EAAE,YAAY,EAC9H,MAAM,YAAY,CAAC;AAoB/C,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAYvG;AAGD,wBAAgB,YAAY,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE;;;;EAItD;AAED,+GAA+G;AAC/G,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;IACxC,QAAQ,CAAC,WAAW,EAAE,SAAS,mBAAmB,EAAE,CAAC;IACrD,QAAQ,CAAC,UAAU,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACnD;AAED,iHAAiH;AACjH,wBAAsB,0BAA0B,CAAC,KAAK,GAAE,kBAAuB,EAAE,YAAY,CAAC,EAAE,aAAa,EAC3G,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,UAAU;;mBAyEf,gBAAgB,KAAG,OAAO,CAAC,UAAU,CAAC;;GAiJnE;AAED,uFAAuF;AACvF,wBAAsB,iBAAiB,CAAC,KAAK,GAAE,kBAAuB,EAAE,YAAY,CAAC,EAAE,aAAa,EAClG,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAEnE"}
@@ -2,47 +2,14 @@ import { dirname, join } from "node:path";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
3
  import { existsSync } from "node:fs";
4
4
  import { readFile } from "node:fs/promises";
5
- import { compileComponentStylesForBuild, parseComponentResource, serializePropTarget, serializeTypedValue } from "@nextwebwg/html-next";
5
+ import { compileComponentStylesForBuild, parseComponentResource } from "@nextwebwg/html-next";
6
6
  import { loadNodeComponents } from "@nextwebwg/html-next/node-loader";
7
- import { renderComponents } from "@nextwebwg/html-next/server";
8
7
  import { createServer, isRunnableDevEnvironment } from "vite";
9
8
  import { configure, HtmlKitError, rootAlias, withPlugins } from "./config.js";
10
- import { documentHTML, escapeHTML, pagePayload, payloadPath, redirectPage } from "./document.js";
11
- import { breadth, discover, matchRoute, parameter, redirectLocation, redirectStatuses, redirectTable, validSegment } from "./routes.js";
9
+ import { documentHTML, redirectPage } from "./document.js";
10
+ import { breadth, discover, discoverServerFiles, matchRoute, parameter, redirectTable, validSegment } from "./routes.js";
12
11
  import { applicationResource, pageDefinition } from "./resource.js";
13
- import { renderHead } from "./head.js";
14
- /** What a loader's redirect() throws, so render answers with the redirect instead of the page. */
15
- class Redirect {
16
- location;
17
- status;
18
- constructor(location, status) {
19
- this.location = location;
20
- this.status = status;
21
- }
22
- }
23
- /** A loader's props as invocation attributes, in their HTML form, defaults included. */
24
- function invocationAttributes(definition, result) {
25
- const props = result.props ?? {};
26
- for (const name of Object.keys(props)) {
27
- if (!Object.hasOwn(definition.contract.props, name))
28
- throw new HtmlKitError(`Undeclared prop ${name}.`, definition.source.file);
29
- }
30
- const attributes = {};
31
- for (const [name, contract] of Object.entries(definition.contract.props)) {
32
- serializePropTarget(contract, props[name], name, definition.contract.tag);
33
- // The contract target describes lowering onto native DOM. Invocation attributes carry
34
- // the public prop name; writing the target name here silently loses remapped props.
35
- const provided = props[name] === undefined ? contract.default : props[name];
36
- const value = provided == null ? null : serializeTypedValue(provided, contract.type);
37
- if (value !== null)
38
- attributes[name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()] = value;
39
- }
40
- return attributes;
41
- }
42
- /** The browser builds the same invocation from a page payload (client.ts). */
43
- function invocation(tag, attributes, id, child, nested) {
44
- return `<${tag} id="${id}"${nested ? ' slot="page"' : ""}${Object.entries(attributes).map(([name, value]) => ` ${name}="${escapeHTML(value)}"`).join("")}>${child}</${tag}>`;
45
- }
12
+ import { createNavigation, failurePage, pageResponse, renderRoute, respond, serverFailure } from "./server.js";
46
13
  // Built-in components need no link: a resource that uses one gets it added after its content, so
47
14
  // diagnostics keep their line numbers. The import map below resolves it from this package.
48
15
  const builtins = [["hk-nav", "@nextwebwg/htmlkit/components/nav.html"], ["hk-breadcrumbs", "@nextwebwg/htmlkit/components/breadcrumbs.html"],
@@ -79,12 +46,15 @@ export function notFoundPage(scripts) {
79
46
  const body = "<main><h1>Page not found</h1></main>";
80
47
  return { head, body, html: documentHTML(body, head) };
81
48
  }
82
- /** assets supplies a serving adapter's browser modules and stylesheets for fetch(). */
83
- export async function createApplication(input = {}, moduleServer, assets) {
49
+ /** The application, plus what a build reads from it: each route's component graph and the concrete page URLs. */
50
+ export async function createApplicationInternals(input = {}, moduleServer, assets) {
84
51
  const options = await withPlugins(input);
85
52
  const config = configure(options);
86
53
  const redirects = redirectTable(config.base, options.redirects);
87
- const { routes, notFound: notFoundRoute, sources, files, watched } = await discover(config.root, { ...options, base: config.base });
54
+ const { routes, notFound: notFoundRoute, error: errorRoute, sources, files, watched } = await discover(config.root, { ...options, base: config.base });
55
+ // Alias pages name their own URL canonical, absolute when the site sets its origin.
56
+ const canonicalOrigin = options.origin === undefined ? "" : config.origin;
57
+ const { endpoints, middleware } = await discoverServerFiles(config.root);
88
58
  // A dynamic page's entries are checked the same way when they are listed.
89
59
  const shadowed = routes.find(route => route.params.length === 0 && redirects.has(config.base + route.pattern.slice(1)));
90
60
  if (shadowed !== undefined)
@@ -112,8 +82,10 @@ export async function createApplication(input = {}, moduleServer, assets) {
112
82
  const paths = new Map();
113
83
  for (const route of routes) {
114
84
  const loaded = await module(route);
85
+ // A server-rendered dynamic page renders whatever its URL names, so it needs no entries().
115
86
  if (route.params.length > 0 && typeof loaded.entries !== "function") {
116
- unenumerated.push(route);
87
+ if (route.render !== "server")
88
+ unenumerated.push(route);
117
89
  continue;
118
90
  }
119
91
  const entries = loaded.entries === undefined ? [{}] : await loaded.entries();
@@ -152,299 +124,173 @@ export async function createApplication(input = {}, moduleServer, assets) {
152
124
  }
153
125
  return paths;
154
126
  })();
155
- const checkedFrom = (from = "/") => {
156
- if (!/^\/(?:[^/]+\/)*$/.test(from) || from.includes("[") || from.includes("?") || from.includes("#")) {
157
- throw new HtmlKitError("Navigation from requires a concrete application-relative directory prefix.", from);
158
- }
159
- return from;
160
- };
161
- const item = (href, route, depth, current) => Object.freeze({
162
- href, label: route.label ?? decodeURIComponent(href.slice(config.base.length).split("/").filter(Boolean).at(-1) ?? "Home"),
163
- current: href === current ? "page" : "false", depth, pageName: route.pageName,
164
- ...(route.groups === undefined ? {} : { group: route.groups.at(-1).label })
165
- });
166
- // A page's place in the file tree: each URL segment, with its (group) folders where they sit.
167
- // depth is the URL segments above the element: a segment's index, or a group's depth.
168
- const treePath = (href, route) => {
169
- const path = href.slice(config.base.length).split("/").filter(Boolean)
170
- .map((name, i) => ({ name, rank: route.order?.[i] ?? null, depth: i }));
171
- // Deepest first, so each insertion index is still a count of URL segments.
172
- for (const group of (route.groups ?? []).toReversed())
173
- path.splice(group.depth, 0, { name: group.label, rank: group.order, depth: group.depth, group: true });
174
- return path;
175
- };
176
- const navigation = async (query = {}) => {
177
- const from = checkedFrom(query.from);
178
- const prefix = config.base + from.slice(1);
179
- const depth = from.split("/").filter(Boolean).length;
180
- // Hidden pages and alias routes stay routable but out of navigation.
181
- const items = [...await concreteRoutes()].filter(([path, route]) => path.startsWith(prefix) && !route.hidden && route.canonical === undefined);
182
- const paths = new Map(items.map(([href, route]) => [href, treePath(href, route)]));
183
- items.sort(([a], [b]) => {
184
- const aParts = paths.get(a);
185
- const bParts = paths.get(b);
186
- for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
187
- const aRank = aParts[i].rank;
188
- const bRank = bParts[i].rank;
189
- if (aRank !== bRank) {
190
- if (aRank === null)
191
- return 1;
192
- if (bRank === null)
193
- return -1;
194
- const difference = BigInt(aRank) - BigInt(bRank);
195
- if (difference !== 0n)
196
- return difference < 0n ? -1 : 1;
127
+ const navigation = createNavigation(config.base, routes, concreteRoutes);
128
+ // Vite owns module caching and dependency invalidation. Per-render results never enter
129
+ // a shared cache; HTML Next owns the DOM worker and declarative component semantics.
130
+ const graph = async (route) => {
131
+ const layers = [...route.layouts, route];
132
+ const packages = new Map();
133
+ const resources = new Map();
134
+ const layerURLs = new Set(layers.map(layer => pathToFileURL(layer.component).href));
135
+ const resolveCSS = server.config.createResolver({ extensions: [".css"], mainFields: ["style"], conditions: ["style", "development|production"], preferRelative: true, tryIndex: false });
136
+ const loaded = await loadNodeComponents(layers.map(layer => pathToFileURL(layer.component).href), {
137
+ readStylesheet: async (url) => ({ url, source: await readFile(fileURLToPath(url), "utf8") }),
138
+ resolveStylesheet: async (specifier, parentURL) => {
139
+ const resolved = await resolveCSS(specifier, fileURLToPath(parentURL));
140
+ return resolved === undefined ? undefined
141
+ : pathToFileURL(resolved.replace(/[?#].*$/, "")).href + (/[?#].*$/.exec(resolved)?.[0] ?? "");
142
+ },
143
+ stylesheetAssetURL: url => url.startsWith("file:") ? `/@fs/${fileURLToPath(url)}${new URL(url).search}${new URL(url).hash}` : url,
144
+ // The graph resolver is synchronous. Prepare its bare imports while asynchronously
145
+ // reading each carrier, using Vite's ESM resolution from the consuming application.
146
+ importMap: map,
147
+ readComponent: async (url) => {
148
+ let source = sources.get(fileURLToPath(url)) ?? await readFile(fileURLToPath(url), "utf8");
149
+ for (const [tag, href] of builtins)
150
+ if (new RegExp(`<${tag}[\\s/>]`).test(source))
151
+ source += `\n<link rel="component" href="${href}">`;
152
+ const parsed = layerURLs.has(url) ? applicationResource(source, url) : parseComponentResource(source, url);
153
+ if ("configuration" in parsed)
154
+ resources.set(url, parsed);
155
+ const imports = [...parsed.dependencies, ...parsed.definitions.flatMap(definition => definition.controller === undefined ? [] : [definition.controller])];
156
+ for (const specifier of imports) {
157
+ if (/^(?:[A-Za-z][A-Za-z\d+.-]*:|\/|\.\.?\/)/.test(specifier) || Object.keys(map.imports).some(key => specifier.startsWith(key)))
158
+ continue;
159
+ const resolved = await server.environments.client.pluginContainer.resolveId(specifier, fileURLToPath(url));
160
+ if (resolved === null || resolved.external)
161
+ throw new HtmlKitError(`Cannot resolve component import ${specifier}.`, url);
162
+ packages.set(`${url}\0${specifier}`, packageResource(resolved.id));
197
163
  }
198
- const difference = aParts[i].name.localeCompare(bParts[i].name);
199
- if (difference !== 0)
200
- return difference;
201
- }
202
- return aParts.length - bParts.length || a.localeCompare(b);
164
+ return { url, source };
165
+ },
166
+ resolvePackage: (specifier, parentURL) => {
167
+ const resolved = packages.get(`${parentURL}\0${specifier}`);
168
+ if (resolved === undefined)
169
+ throw new HtmlKitError(`Cannot resolve component import ${specifier}.`, parentURL);
170
+ return resolved;
171
+ },
203
172
  });
204
- return Object.freeze(items.map(([href, route]) => item(href, route, href.slice(config.base.length).split("/").filter(Boolean).length - depth, query.current)));
205
- };
206
- // Each existing page from `from` down to the current one, by URL; the current page is included even when hidden.
207
- const breadcrumbs = async (query = {}) => {
208
- const from = checkedFrom(query.from);
209
- const current = query.current;
210
- if (current === undefined || !current.startsWith(config.base + from.slice(1)))
211
- return Object.freeze([]);
212
- const paths = await concreteRoutes();
213
- const parts = current.slice(config.base.length).split("/").filter(Boolean);
214
- const start = from.split("/").filter(Boolean).length;
215
- const trail = [];
216
- for (let i = start; i <= parts.length; i++) {
217
- const href = config.base + parts.slice(0, i).map(part => part + "/").join("");
218
- // The page being rendered exists even when no entries() lists it, as for a dynamic page at request time.
219
- const route = paths.get(href) ?? (i === parts.length ? matchRoute(routes, href, config.base)?.route : undefined);
220
- if (route !== undefined && route.canonical === undefined)
221
- trail.push(item(href, route, i - start, current));
173
+ const definitions = layers.map(layer => {
174
+ if (layer === route)
175
+ return pageDefinition(resources.get(pathToFileURL(layer.component).href), layer.component);
176
+ const roots = loaded.roots.filter(id => loaded.nodes.get(id).url === pathToFileURL(layer.component).href);
177
+ if (roots.length !== 1)
178
+ throw new HtmlKitError("A page or layout must declare exactly one root component.", layer.component);
179
+ return loaded.nodes.get(roots[0]).definition;
180
+ });
181
+ if (definitions.at(-1).contract.tag !== route.pageName) {
182
+ throw new HtmlKitError("Page component name changed; rediscover routes before rendering.", route.component);
183
+ }
184
+ for (const definition of definitions.slice(0, -1)) {
185
+ if (!definition.slots?.some(slot => slot.name === "page"))
186
+ throw new HtmlKitError('Layout requires a slot named "page".', definition.source.file);
222
187
  }
223
- return Object.freeze(trail);
188
+ const components = [...loaded.nodes.values()].map(node => ({ definition: node.definition,
189
+ styles: compileComponentStylesForBuild(node.definition.css, node.definition),
190
+ ...(node.controller === undefined ? {} : { controller: fileURLToPath(node.controller.url) }) }));
191
+ return { components, definitions: components.map(component => component.definition), layers: layers.map((layer, i) => ({ definition: definitions[i],
192
+ metadata: resources.get(pathToFileURL(layer.component).href).components.get(definitions[i].contract.tag), module: () => module(layer), origin: origin(layer) })) };
224
193
  };
225
- // navigation()'s pages, which come in tree order, nested under the folders and groups below `from`.
226
- const navigationTree = async (query = {}) => {
227
- const items = await navigation(query);
228
- const paths = await concreteRoutes();
229
- const fromDepth = checkedFrom(query.from).split("/").filter(Boolean).length;
230
- const top = [];
231
- const nodes = new Map();
232
- for (const item of items) {
233
- const route = paths.get(item.href);
234
- let siblings = top;
235
- let key = "";
236
- let node;
237
- // The page's own node is the last element below `from`; `from`'s index page is a top node of its own.
238
- for (const element of treePath(item.href, route).filter(element => element.depth >= fromDepth)) {
239
- key += `${element.group ? "(" : "/"}${element.name}\0`;
240
- node = nodes.get(key);
241
- if (node === undefined) {
242
- node = { label: element.group ? element.name : decodeURIComponent(element.name), order: element.rank, children: [] };
243
- nodes.set(key, node);
244
- siblings.push(node);
245
- }
246
- siblings = node.children;
247
- }
248
- if (node === undefined) {
249
- node = { label: item.label, order: null, children: [] };
250
- top.unshift(node);
251
- }
252
- node.item = item;
253
- node.route = route;
194
+ const builtinNotFound = (pathname) => ({ status: 404, pathname, ...notFoundPage(scripts), css: "", components: [], layers: [] });
195
+ const redirected = (pathname, location, status, headers) => ({ status, pathname, location, ...redirectPage(location), css: "", components: [], layers: [], ...(headers === undefined ? {} : { headers }) });
196
+ const builtinFailure = (pathname) => ({ pathname, ...failurePage(serverFailure, scripts), css: "", components: [], layers: [] });
197
+ // A matched route; without one, the not-found page, or for a failure the error page, through its layouts.
198
+ // request makes it a request-time render.
199
+ const renderPage = async (matched, url, request, signal, failure) => {
200
+ const fallback = failure === undefined ? notFoundRoute : errorRoute;
201
+ if (matched === undefined && fallback === undefined)
202
+ return failure === undefined ? builtinNotFound(url.pathname) : builtinFailure(url.pathname);
203
+ const { route, params } = matched ?? { route: fallback, params: {} };
204
+ const { layers, definitions, components } = await graph(route);
205
+ const rendered = await renderRoute({ base: config.base, canonicalOrigin, route, params, url, ...(request === undefined ? {} : { request }), signal, layers,
206
+ definitions, scripts, navigation, ...(failure === undefined ? {} : { failure }) });
207
+ if (rendered.kind === "redirect")
208
+ return redirected(url.pathname, rendered.location, rendered.status, rendered.headers);
209
+ if (rendered.kind === "notFound") {
210
+ // The not-found and error pages' own notFound() falls back to the built-in page.
211
+ const page = matched === undefined ? builtinNotFound(url.pathname) : await renderPage(undefined, url, undefined, signal);
212
+ return { ...page, headers: rendered.headers };
254
213
  }
255
- const finish = (draft, depth) => {
256
- const children = draft.children.map(child => finish(child, depth + 1));
257
- const { item, route } = draft;
258
- // A folder's index page labels the folder, by hk:label, then its <title>.
259
- const label = item === undefined ? draft.label : children.length > 0 && route.label === undefined ? route.title ?? draft.label : item.label;
260
- const current = item?.current ?? "false";
261
- return Object.freeze({ label, ...(item === undefined ? {} : { href: item.href, pageName: item.pageName }), current,
262
- open: current === "page" || children.some(child => child.open), depth, order: draft.order, children: Object.freeze(children) });
263
- };
264
- return Object.freeze(top.map(draft => finish(draft, 0)));
214
+ return { status: failure?.status ?? (matched === undefined ? 404 : rendered.status), pathname: url.pathname, body: rendered.body, css: rendered.css, head: rendered.head,
215
+ html: documentHTML(rendered.body, rendered.head), components, layers: rendered.layers, headers: rendered.headers };
265
216
  };
266
- // The neighbours of the current page in navigation order.
267
- const pager = async (query = {}) => {
268
- const items = await navigation(query);
269
- const index = items.findIndex(entry => entry.current === "page");
270
- if (index < 0)
271
- return Object.freeze({});
272
- return Object.freeze({ ...(index > 0 ? { previous: items[index - 1] } : {}), ...(index < items.length - 1 ? { next: items[index + 1] } : {}) });
217
+ /**
218
+ * A failure answers with app/error.html and a safe message; the error itself goes to the console. The
219
+ * development server, which supplies the module server, shows the error instead.
220
+ */
221
+ const failed = async (error, request) => {
222
+ const report = (error) => { if (!ownsServer || request.signal.aborted)
223
+ throw error; console.error(error); };
224
+ report(error);
225
+ const { pathname } = new URL(request.url);
226
+ // A failed page has no payload, so the browser loads its document, the error page.
227
+ if (pathname.startsWith(config.base + "_htmlkit/pages/"))
228
+ return new Response(null, { status: 500 });
229
+ // The error page shares the site's layouts, so a failing layout fails it too; the built-in page answers then.
230
+ const page = pathname.startsWith("//") ? builtinFailure(pathname) : await renderPage(undefined, new URL(pathname, config.origin), undefined, request.signal, serverFailure)
231
+ .catch((again) => { report(again); return builtinFailure(pathname); });
232
+ return pageResponse(page, request, false, assets?.(page));
233
+ };
234
+ const pipeline = {
235
+ base: config.base, routes, endpoints,
236
+ redirects: new Map([...redirects].map(([from, location]) => [from, { location, status: 308 }])),
237
+ endpoint: async (route) => await environment.runner.import(route.file),
238
+ ...(middleware === undefined ? {} : { middleware: async () => {
239
+ const handler = (await environment.runner.import(middleware)).default;
240
+ if (typeof handler !== "function")
241
+ throw new HtmlKitError("Middleware must export a default function.", middleware);
242
+ return handler;
243
+ } }),
244
+ async page(route, params, request, payload) {
245
+ const url = new URL(request.url);
246
+ // A prerendered page renders as the build renders it: at its canonical URL, without the request.
247
+ const server = route.render === "server";
248
+ const page = await renderPage({ route, params }, server ? url : new URL(url.pathname, config.origin), server ? request : undefined, request.signal);
249
+ return pageResponse(page, request, payload, assets?.(page));
250
+ },
251
+ async notFound(request, payload) {
252
+ const { pathname } = new URL(request.url);
253
+ if (payload)
254
+ return new Response(null, { status: 404 });
255
+ const page = pathname.startsWith("//") ? builtinNotFound(pathname) : await renderPage(undefined, new URL(pathname, config.origin), undefined, request.signal);
256
+ return pageResponse(page, request, false, assets?.(page));
257
+ },
273
258
  };
274
259
  const application = {
275
- ...config, routes, ...(notFoundRoute === undefined ? {} : { notFound: notFoundRoute }), navigation, navigationTree, files, watchFiles: watched,
260
+ ...config, routes, ...(notFoundRoute === undefined ? {} : { notFound: notFoundRoute }), ...(errorRoute === undefined ? {} : { error: errorRoute }),
261
+ navigation: navigation.navigation, navigationTree: navigation.navigationTree,
262
+ files, watchFiles: watched, endpoints, ...(middleware === undefined ? {} : { middleware }),
276
263
  async entries() {
277
264
  const paths = await concreteRoutes();
278
265
  const missing = unenumerated[0];
279
266
  if (missing !== undefined)
280
267
  throw new HtmlKitError(`Static entries() must enumerate parameters: ${missing.params.join(", ")}.`, origin(missing));
281
- return [...paths.keys()].sort();
268
+ // Server-rendered pages render on request, so a build lists only prerendered ones.
269
+ return [...paths].filter(([, route]) => route.render !== "server").map(([path]) => path).sort();
282
270
  },
283
271
  async render(pathname, signal = new AbortController().signal) {
284
272
  signal.throwIfAborted();
285
- const notFound = () => ({ status: 404, pathname, ...notFoundPage(scripts), css: "", components: [], layers: [] });
286
273
  // A path starting with // would name another origin; no page lives there, for documents or payloads.
287
274
  if (pathname.startsWith("//"))
288
- return notFound();
275
+ return builtinNotFound(pathname);
289
276
  const url = new URL(pathname, config.origin);
290
277
  if (url.origin !== config.origin || url.search || url.hash)
291
278
  throw new HtmlKitError("Static rendering requires an application pathname without a query or fragment.");
292
- const redirected = (location, status) => ({ status, pathname: url.pathname, location, ...redirectPage(location), css: "", components: [], layers: [] });
293
279
  // The redirects option precedes pages, with or without the trailing slash.
294
280
  const configured = redirects.get(url.pathname.endsWith("/") ? url.pathname : url.pathname + "/");
295
281
  if (configured !== undefined)
296
- return redirected(configured, 308);
297
- const matched = matchRoute(routes, url.pathname, config.base);
298
- // Unmatched paths render app/pages/404.html through its layouts, or the built-in page.
299
- if (matched === undefined && notFoundRoute === undefined)
300
- return notFound();
301
- const { route, params } = matched ?? { route: notFoundRoute, params: {} };
302
- const status = matched === undefined ? 404 : 200;
303
- const layers = [...route.layouts, route];
304
- // Vite owns module caching and dependency invalidation. Per-render results never enter
305
- // a shared cache; HTML Next owns the DOM worker and declarative component semantics.
306
- const packages = new Map();
307
- const resources = new Map();
308
- const layerURLs = new Set(layers.map(layer => pathToFileURL(layer.component).href));
309
- const resolveCSS = server.config.createResolver({ extensions: [".css"], mainFields: ["style"], conditions: ["style", "development|production"], preferRelative: true, tryIndex: false });
310
- const graph = await loadNodeComponents(layers.map(layer => pathToFileURL(layer.component).href), {
311
- readStylesheet: async (url) => ({ url, source: await readFile(fileURLToPath(url), "utf8") }),
312
- resolveStylesheet: async (specifier, parentURL) => {
313
- const resolved = await resolveCSS(specifier, fileURLToPath(parentURL));
314
- return resolved === undefined ? undefined
315
- : pathToFileURL(resolved.replace(/[?#].*$/, "")).href + (/[?#].*$/.exec(resolved)?.[0] ?? "");
316
- },
317
- stylesheetAssetURL: url => url.startsWith("file:") ? `/@fs/${fileURLToPath(url)}${new URL(url).search}${new URL(url).hash}` : url,
318
- // The graph resolver is synchronous. Prepare its bare imports while asynchronously
319
- // reading each carrier, using Vite's ESM resolution from the consuming application.
320
- importMap: map,
321
- readComponent: async (url) => {
322
- let source = sources.get(fileURLToPath(url)) ?? await readFile(fileURLToPath(url), "utf8");
323
- for (const [tag, href] of builtins)
324
- if (new RegExp(`<${tag}[\\s/>]`).test(source))
325
- source += `\n<link rel="component" href="${href}">`;
326
- const parsed = layerURLs.has(url) ? applicationResource(source, url) : parseComponentResource(source, url);
327
- if ("configuration" in parsed)
328
- resources.set(url, parsed);
329
- const imports = [...parsed.dependencies, ...parsed.definitions.flatMap(definition => definition.controller === undefined ? [] : [definition.controller])];
330
- for (const specifier of imports) {
331
- if (/^(?:[A-Za-z][A-Za-z\d+.-]*:|\/|\.\.?\/)/.test(specifier) || Object.keys(map.imports).some(key => specifier.startsWith(key)))
332
- continue;
333
- const resolved = await server.environments.client.pluginContainer.resolveId(specifier, fileURLToPath(url));
334
- if (resolved === null || resolved.external)
335
- throw new HtmlKitError(`Cannot resolve component import ${specifier}.`, url);
336
- packages.set(`${url}\0${specifier}`, packageResource(resolved.id));
337
- }
338
- return { url, source };
339
- },
340
- resolvePackage: (specifier, parentURL) => {
341
- const resolved = packages.get(`${parentURL}\0${specifier}`);
342
- if (resolved === undefined)
343
- throw new HtmlKitError(`Cannot resolve component import ${specifier}.`, parentURL);
344
- return resolved;
345
- },
346
- });
347
- const layerDefinitions = layers.map(layer => {
348
- if (layer === route)
349
- return pageDefinition(resources.get(pathToFileURL(layer.component).href), layer.component);
350
- const roots = graph.roots.filter(id => graph.nodes.get(id).url === pathToFileURL(layer.component).href);
351
- if (roots.length !== 1)
352
- throw new HtmlKitError("A page or layout must declare exactly one root component.", layer.component);
353
- return graph.nodes.get(roots[0]).definition;
354
- });
355
- if (layerDefinitions.at(-1).contract.tag !== route.pageName) {
356
- throw new HtmlKitError("Page component name changed; rediscover routes before rendering.", route.component);
357
- }
358
- for (const definition of layerDefinitions.slice(0, -1)) {
359
- if (!definition.slots?.some(slot => slot.name === "page"))
360
- throw new HtmlKitError('Layout requires a slot named "page".', definition.source.file);
361
- }
362
- const results = [];
363
- const attributes = [];
364
- let parent = Object.freeze({});
365
- let head = {};
366
- // An alias marks its page's own entry current.
367
- const current = route.canonical === undefined ? url.pathname : config.base + route.canonical.slice(1);
368
- for (const layer of layers) {
369
- signal.throwIfAborted();
370
- const loaded = await module(layer);
371
- const context = { phase: "prerender", url: new URL(url), base: config.base, params, parent,
372
- propNames: Object.freeze(Object.keys(layerDefinitions[results.length].contract.props)),
373
- fetch: globalThis.fetch, signal,
374
- navigation: (query) => navigation({ current, ...query }),
375
- navigationTree: (query) => navigationTree({ current, ...query }),
376
- breadcrumbs: (query) => breadcrumbs({ current, ...query }),
377
- pager: (query) => pager({ current, ...query }),
378
- redirect(location, status = 303) {
379
- if (!redirectStatuses.includes(status))
380
- throw new HtmlKitError(`Redirect status ${String(status)} must be 301, 302, 303, 307, or 308.`, origin(layer));
381
- throw new Redirect(redirectLocation(config.base, location, origin(layer)), status);
382
- },
383
- get request() { throw new HtmlKitError("request is unavailable during static generation.", origin(layer)); } };
384
- let result;
385
- try {
386
- result = loaded.load === undefined ? {} : await loaded.load(context);
387
- }
388
- catch (error) {
389
- if (error instanceof Redirect)
390
- return redirected(error.location, error.status);
391
- throw error;
392
- }
393
- if (result === null || typeof result !== "object" || Array.isArray(result))
394
- throw new HtmlKitError("load() must return a LoaderResult object.", origin(layer));
395
- results.push(result);
396
- parent = Object.freeze({ ...parent, ...result.data });
397
- const index = results.length - 1;
398
- // Checked against the layer's own component, which a prop's diagnostic names; its head reuses them.
399
- attributes.push(invocationAttributes(layerDefinitions[index], result));
400
- head = await renderHead(resources.get(pathToFileURL(layer.component).href), layerDefinitions[index], result, head, url.href, definition => invocation(definition.contract.tag, attributes[index], "hk-head", "", false));
401
- }
402
- if (scripts.length > 0)
403
- head = { ...head, scripts };
404
- const state = {};
405
- const rendered = layers.map((_layer, i) => ({ component: layerDefinitions[i].contract.tag,
406
- attributes: attributes[i], ...(results[i].state === undefined ? {} : { state: results[i].state }) }));
407
- let body = "";
408
- for (let i = layers.length - 1; i >= 0; i--) {
409
- const id = `hk-layer-${i}`;
410
- const layer = rendered[i];
411
- if (layer.state !== undefined)
412
- state[`#${id}`] = layer.state;
413
- body = invocation(layer.component, layer.attributes, id, body, i > 0);
414
- }
415
- const components = [...graph.nodes.values()].map(node => ({ definition: node.definition,
416
- styles: compileComponentStylesForBuild(node.definition.css, node.definition),
417
- ...(node.controller === undefined ? {} : { controller: fileURLToPath(node.controller.url) }) }));
418
- const output = await renderComponents(body, { definitions: components.map(value => value.definition), url: url.href, state });
419
- signal.throwIfAborted();
420
- return { status, pathname: url.pathname, body: output.html, css: output.css, head,
421
- html: documentHTML(output.html, head), components, layers: rendered };
282
+ return redirected(url.pathname, configured, 308);
283
+ return renderPage(matchRoute(routes, url.pathname, config.base), url, undefined, signal);
422
284
  },
423
285
  // Not a method of `this`: runtimes are handed the function itself, as in Deno.serve(application.fetch).
424
- async fetch(request) {
425
- if (request.method !== "GET" && request.method !== "HEAD")
426
- return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
427
- const { pathname } = new URL(request.url);
428
- // A page's payload, through the same render as its document.
429
- const pages = config.base + "_htmlkit/pages/";
430
- const payload = pathname.startsWith(pages) ? /^(.*\/)?payload\.json$/.exec(pathname.slice(pages.length)) : null;
431
- if (payload !== null) {
432
- const page = await application.render(config.base + (payload[1] ?? ""), request.signal);
433
- const data = page.status === 200 && payloadPath(config.base, page.pathname) === pathname
434
- ? pagePayload(page, assets?.(page) ?? { styles: [], modules: [] }) : undefined;
435
- return new Response(data === undefined || request.method === "HEAD" ? null : JSON.stringify(data), { status: data === undefined ? 404 : 200, headers: { "content-type": "application/json" } });
436
- }
437
- const page = await application.render(pathname, request.signal);
438
- if (page.location !== undefined) {
439
- return new Response(request.method === "HEAD" ? null : page.html, { status: page.status, headers: { location: page.location, "content-type": "text/html; charset=utf-8" } });
440
- }
441
- if (page.status === 200 && !pathname.endsWith("/"))
442
- return new Response(null, { status: 308, headers: { location: pathname + "/" } });
443
- return new Response(request.method === "HEAD" ? null : documentHTML(page.body, page.head, assets?.(page)), { status: page.status, headers: { "content-type": "text/html; charset=utf-8" } });
444
- },
286
+ fetch: request => respond(pipeline, request).catch((error) => failed(error, request)),
445
287
  async close() { if (ownsServer)
446
288
  await server.close(); },
447
289
  };
448
- return application;
290
+ return { application, graph, catalog: concreteRoutes };
291
+ }
292
+ /** assets supplies a serving adapter's browser modules and stylesheets for fetch(). */
293
+ export async function createApplication(input = {}, moduleServer, assets) {
294
+ return (await createApplicationInternals(input, moduleServer, assets)).application;
449
295
  }
450
296
  //# sourceMappingURL=application.js.map