htmx-router 1.0.0-alpha.2 → 1.0.0-alpha.4

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/bin/cli/index.js CHANGED
@@ -11,6 +11,7 @@ await writeFile(config.router.output, `/*---------------------------------------
11
11
  * Generated by htmx-router *
12
12
  * Warn: Any changes will be overwritten *
13
13
  -------------------------------------------*/
14
+ /* eslint-disable @typescript-eslint/no-explicit-any */
14
15
 
15
16
  import { GenericContext, RouteTree } from "htmx-router/bin/router";
16
17
  import { RegisterDynamic } from "htmx-router/bin/util/dynamic";
@@ -56,9 +57,9 @@ export function Scripts() {
56
57
  if (headCache) return headCache;
57
58
 
58
59
  const res = <>
60
+ <link href={GetSheetUrl()} rel="stylesheet"></link>
59
61
  { isProduction ? "" : <script type="module" src="/@vite/client"></script> }
60
62
  <script type="module" src={clientEntry}></script>
61
- <link href={GetSheetUrl()} rel="stylesheet"></link>
62
63
  <script src={GetMountUrl()}></script>
63
64
  </>;
64
65
 
@@ -13,29 +13,52 @@ export async function GenerateClient(config, force = false) {
13
13
  if (!force && ExtractHash(history) === hash)
14
14
  return;
15
15
  await init;
16
+ const imported = ParseImports(source);
17
+ await Promise.all([
18
+ writeFile(config.source, source
19
+ + pivot
20
+ + `// hash: ${hash}\n`
21
+ + BuildClientServer(imported)),
22
+ writeFile(CutString(config.source, ".", -1)[0] + ".manifest.tsx", BuildClientManifest(config.adapter, imported))
23
+ ]);
24
+ }
25
+ function ParseImports(source) {
16
26
  const parsed = parse(source)[0];
17
- const imports = new Array();
18
- const names = new Array();
19
- for (const imp of parsed) {
20
- if (imp.a !== -1)
27
+ const out = [];
28
+ for (const imported of parsed) {
29
+ if (imported.a !== -1)
21
30
  continue;
22
- if (imp.t !== 1)
31
+ if (imported.t !== 1)
23
32
  continue;
24
- imports.push(source.slice(imp.ss, imp.se));
25
- names.push(...ExtractNames(source.slice(imp.ss, imp.s)));
33
+ const href = source.slice(imported.s, imported.e);
34
+ const front = source.slice(imported.ss, imported.s);
35
+ const start = front.indexOf("{");
36
+ if (start === -1) {
37
+ const middle = CutString(CutString(front, "import")[1], "from", -1)[0];
38
+ out.push({ mapping: ExtractName(middle), href });
39
+ continue;
40
+ }
41
+ const end = front.lastIndexOf("}");
42
+ const segments = front.slice(start + 1, end).split(",");
43
+ out.push({ mapping: segments.map(ExtractName), href });
26
44
  }
27
- await writeFile(config.source, source
28
- + pivot
29
- + `// hash: ${hash}\n`
30
- + BuildClientServer(names));
31
- await writeFile(CutString(config.source, ".", -1)[0] + ".manifest.tsx", BuildClientManifest(config.adapter, names, imports));
45
+ return out;
32
46
  }
33
- function BuildClientServer(names) {
34
- let out = "type FirstArg<T> = T extends (arg: infer U, ...args: any[]) => any ? U : never;\n"
47
+ function BuildClientServer(imported) {
48
+ const names = new Array();
49
+ for (const imp of imported) {
50
+ if (Array.isArray(imp.mapping))
51
+ names.push(...imp.mapping.map(x => x.name));
52
+ else
53
+ names.push(imp.mapping.name);
54
+ }
55
+ let out = `import { StyleClass } from "htmx-router";\n`
56
+ + `const island = new StyleClass("i", ".this{display:contents;}\\n").name;\n\n`
57
+ + "type FirstArg<T> = T extends (arg: infer U, ...args: any[]) => any ? U : never;\n"
35
58
  + "function mount(name: string, data: string, ssr?: JSX.Element) {\n"
36
59
  + "\treturn (<>\n"
37
- + `\t\t<div style={{ display: "contents" }}>{ssr}</div>\n`
38
- + "\t\t<script>{`Router.mountAboveWith(\"${name}\", JSON.parse(\"${data}\"))`}</script>\n"
60
+ + `\t\t<div className={island}>{ssr}</div>\n`
61
+ + "\t\t<script>{`Router.mountAboveWith('${name}', ${data})`}</script>\n"
39
62
  + "\t</>);\n"
40
63
  + "}\n"
41
64
  + "\n"
@@ -47,52 +70,52 @@ function BuildClientServer(names) {
47
70
  + `\t},\n`;
48
71
  }
49
72
  out += "}\nexport default Client;\n\n"
50
- + `if (process.env.NODE_ENV !== "production") {\n`
51
- + `\t(await import( "htmx-router/bin/client/watch.js")).WatchClient();\n`
52
- + `}`;
73
+ + `import { __RebuildClient__ } from "htmx-router/bin/client/watch.js";\n`
74
+ + `__RebuildClient__();`;
53
75
  return out;
54
76
  }
55
- function BuildClientManifest(type, names, imports) {
77
+ const renderer = {
78
+ react: '\t\tconst r = await import("react-dom/client");\n'
79
+ + "\t\tr.createRoot(element).render(<C {...props} />);\n"
80
+ };
81
+ function BuildClientManifest(type, imports) {
56
82
  let out = "/*------------------------------------------\n"
57
83
  + " * Generated by htmx-router *\n"
58
84
  + " * Warn: Any changes will be overwritten *\n"
59
- + "-------------------------------------------*/\n"
60
- + imports.join(";\n") + ";\n";
61
- switch (type) {
62
- case "react":
63
- out += BuildReactClientManifest(names);
64
- break;
65
- default:
66
- console.error(`Unsupported client adapter ${type}`);
67
- process.exit(1);
85
+ + "-------------------------------------------*/\n\n"
86
+ + "/* eslint-disable @typescript-eslint/no-explicit-any */\n"
87
+ + "const client = {\n";
88
+ const render = renderer[type];
89
+ if (!render) {
90
+ console.error(`Unsupported client adapter ${type}`);
91
+ process.exit(1);
68
92
  }
69
- out += "export default client;\n"
93
+ for (const imported of imports) {
94
+ if (Array.isArray(imported.mapping)) {
95
+ for (const map of imported.mapping) {
96
+ out += `\t${map.name}: async (element: HTMLElement, props: any) => {\n`
97
+ + `\t\tconst C = (await import("${imported.href}")).${map.original};\n`
98
+ + render
99
+ + `\t},\n`;
100
+ }
101
+ }
102
+ else {
103
+ out += `\t${imported.mapping.name}: async (element: HTMLElement, props: any) => {\n`
104
+ + `\t\tconst C = (await import("${imported.href}")).default;\n`
105
+ + render
106
+ + `\t},\n`;
107
+ }
108
+ }
109
+ out += "}\nexport default client;\n"
70
110
  + "(window as any).CLIENT = client;";
71
111
  return out;
72
112
  }
73
- function BuildReactClientManifest(names) {
74
- let out = `import ReactDOM from "react-dom/client";\n\n`
75
- + "const client = {\n";
76
- for (const name of names)
77
- out += `\t${name}: (element: HTMLElement, props: any) => ReactDOM.createRoot(element).render(<${name} {...props} />),\n`;
78
- out += "};\n";
79
- return out;
80
- }
81
- function ExtractNames(str) {
82
- const start = str.indexOf("{");
83
- if (start === -1) {
84
- const middle = CutString(CutString(str, "import")[1], "from", -1)[0];
85
- return [ExtractName(middle)];
86
- }
87
- const end = str.lastIndexOf("}");
88
- const segments = str.slice(start + 1, end).split(",");
89
- return segments.map(ExtractName);
90
- }
91
113
  function ExtractName(str) {
92
114
  const parts = CutString(str, "as");
93
- if (parts[1])
94
- return parts[1].trim();
95
- return parts[0].trim();
115
+ if (parts[1].length !== 0)
116
+ return { name: parts[1].trim(), original: parts[0].trim() };
117
+ const name = parts[0].trim();
118
+ return { name, original: name };
96
119
  }
97
120
  function ExtractHash(source) {
98
121
  const regex = /\/\/\s+hash\s*:\s*(\w+)/;
@@ -1 +1 @@
1
- export declare function WatchClient(): Promise<void>;
1
+ export declare function __RebuildClient__(): Promise<void>;
@@ -1,19 +1,11 @@
1
- import { watch } from "fs";
2
1
  import { GenerateClient } from "../client/index.js";
3
2
  import { ReadConfig } from "../cli/config.js";
4
- export async function WatchClient() {
5
- if (process.env.NODE_ENV === "production") {
6
- console.warn("Watching client islands is disabled in production");
3
+ export async function __RebuildClient__() {
4
+ if (process.env.NODE_ENV === "production")
7
5
  return;
8
- }
9
6
  const config = await ReadConfig();
10
7
  const client = config.client;
11
8
  if (!client)
12
9
  return;
13
- const rebuild = () => {
14
- console.info("Building client");
15
- GenerateClient(client, false).catch(console.error); // rebuild only if the hash has changed
16
- };
17
- watch(client.source, rebuild);
18
- rebuild();
10
+ GenerateClient(client, false).catch(console.error);
19
11
  }
package/bin/helper.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export declare function CutString(str: string, pivot: string, offset?: number): [string, string];
2
+ export declare function Singleton<T>(name: string, cb: () => T): T;
package/bin/helper.js CHANGED
@@ -25,3 +25,10 @@ export function CutString(str, pivot, offset = 1) {
25
25
  }
26
26
  return [str, ""];
27
27
  }
28
+ export function Singleton(name, cb) {
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ const g = globalThis;
31
+ g.__singletons ??= {};
32
+ g.__singletons[name] ??= cb();
33
+ return g.__singletons[name];
34
+ }
package/bin/index.d.ts CHANGED
@@ -5,4 +5,5 @@ import { Cookies, CookieOptions } from "./util/cookies.js";
5
5
  import { EventSourceConnection } from "./util/event-source.js";
6
6
  import { StyleClass } from './util/css.js';
7
7
  import { Endpoint } from './util/endpoint.js';
8
- export { CatchFunction, CookieOptions, Cookies, createRequestHandler, Endpoint, EventSourceConnection, GenericContext, RenderFunction, RouteContext, RouteModule, StyleClass, };
8
+ import { redirect, text, json, refresh } from './response.js';
9
+ export { CatchFunction, CookieOptions, Cookies, createRequestHandler, Endpoint, EventSourceConnection, GenericContext, RenderFunction, RouteContext, RouteModule, StyleClass, redirect, text, json, refresh };
package/bin/index.js CHANGED
@@ -4,4 +4,5 @@ import { Cookies } from "./util/cookies.js";
4
4
  import { EventSourceConnection } from "./util/event-source.js";
5
5
  import { StyleClass } from './util/css.js';
6
6
  import { Endpoint } from './util/endpoint.js';
7
- export { Cookies, createRequestHandler, Endpoint, EventSourceConnection, GenericContext, RouteContext, StyleClass, };
7
+ import { redirect, text, json, refresh } from './response.js';
8
+ export { Cookies, createRequestHandler, Endpoint, EventSourceConnection, GenericContext, RouteContext, StyleClass, redirect, text, json, refresh };
@@ -26,15 +26,7 @@ export async function Resolve(request, tree, config) {
26
26
  const fragments = x.split("/").slice(1);
27
27
  let response = await tree.resolve(fragments, ctx);
28
28
  if (response === null)
29
- response = new Response("Not Found", { status: 404, statusText: "Not Found", headers: ctx.headers });
30
- // Merge context headers
31
- if (response.headers !== ctx.headers) {
32
- for (const [key, value] of ctx.headers) {
33
- if (response.headers.has(key))
34
- continue;
35
- response.headers.set(key, value);
36
- }
37
- }
29
+ response = new Response("No Route Found", { status: 404, statusText: "Not Found", headers: ctx.headers });
38
30
  // Merge cookie changes
39
31
  const headers = Object.fromEntries(ctx.headers);
40
32
  const cookies = ctx.cookie.export();
@@ -42,5 +34,17 @@ export async function Resolve(request, tree, config) {
42
34
  headers['set-cookie'] = cookies;
43
35
  response.headers.set("Set-Cookie", cookies[0]); // Response object doesn't support multi-header..[]
44
36
  }
37
+ // Merge context headers
38
+ if (response.headers !== ctx.headers) {
39
+ for (const [key, value] of response.headers) {
40
+ if (!headers[key]) {
41
+ headers[key] = value;
42
+ continue;
43
+ }
44
+ if (!Array.isArray(headers[key]))
45
+ headers[key] = [headers[key]];
46
+ headers[key].push(value);
47
+ }
48
+ }
45
49
  return { response, headers };
46
50
  }
@@ -0,0 +1,4 @@
1
+ export declare function redirect(url: string, init?: ResponseInit): Response;
2
+ export declare function text(text: string, init?: ResponseInit): Response;
3
+ export declare function json(data: unknown, init?: ResponseInit): Response;
4
+ export declare function refresh(init?: ResponseInit): Response;
@@ -0,0 +1,33 @@
1
+ export function redirect(url, init) {
2
+ init ||= {};
3
+ init.statusText ||= "Temporary Redirect";
4
+ init.status = 307;
5
+ const res = new Response("", init);
6
+ res.headers.set("X-Caught", "true");
7
+ res.headers.set("Location", url);
8
+ return res;
9
+ }
10
+ export function text(text, init) {
11
+ init ||= {};
12
+ init.statusText ||= "ok";
13
+ init.status = 200;
14
+ const res = new Response(text, init);
15
+ res.headers.set("Content-Type", "text/plain");
16
+ return res;
17
+ }
18
+ export function json(data, init) {
19
+ init ||= {};
20
+ init.statusText ||= "ok";
21
+ init.status = 200;
22
+ const res = new Response(JSON.stringify(data), init);
23
+ res.headers.set("Content-Type", "application/json");
24
+ return res;
25
+ }
26
+ export function refresh(init) {
27
+ init ||= {};
28
+ init.statusText ||= "ok";
29
+ init.status = 200;
30
+ const res = new Response("", init);
31
+ res.headers.set("HX-Refresh", "true");
32
+ return res;
33
+ }
package/bin/router.d.ts CHANGED
@@ -26,7 +26,7 @@ export declare class RouteLeaf {
26
26
  module: RouteModule<any>;
27
27
  constructor(module: RouteModule<any>);
28
28
  resolve(ctx: GenericContext): Promise<Response | null>;
29
- error(ctx: GenericContext, e: unknown): Promise<Response | null>;
29
+ error(ctx: GenericContext, e: unknown): Promise<Response>;
30
30
  private renderWrapper;
31
31
  }
32
32
  export declare class RouteTree {
@@ -39,6 +39,7 @@ export declare class RouteTree {
39
39
  constructor(root?: boolean);
40
40
  ingest(path: string | string[], module: RouteModule<any>): void;
41
41
  resolve(fragments: string[], ctx: GenericContext): Promise<Response | null>;
42
+ private _resolve;
42
43
  private resolveIndex;
43
44
  private resolveNext;
44
45
  private resolveWild;
package/bin/router.js CHANGED
@@ -55,10 +55,8 @@ export class RouteLeaf {
55
55
  }
56
56
  async error(ctx, e) {
57
57
  if (!this.module.error)
58
- return null;
58
+ throw e;
59
59
  const res = await this.module.error(ctx, e);
60
- if (res === null)
61
- return null;
62
60
  if (res instanceof Response)
63
61
  return res;
64
62
  return ctx.render(res);
@@ -79,10 +77,7 @@ export class RouteLeaf {
79
77
  throw new Response("Method not Allowed", { status: 405, statusText: "Method not Allowed", headers: ctx.headers });
80
78
  }
81
79
  catch (e) {
82
- if (this.module.error)
83
- return await this.module.error(ctx, e);
84
- else
85
- throw e;
80
+ return await this.error(ctx, e);
86
81
  }
87
82
  return null;
88
83
  }
@@ -138,12 +133,29 @@ export class RouteTree {
138
133
  next.ingest(path, module);
139
134
  }
140
135
  async resolve(fragments, ctx) {
136
+ if (!this.slug)
137
+ return await this._resolve(fragments, ctx);
138
+ try {
139
+ return await this._resolve(fragments, ctx);
140
+ }
141
+ catch (e) {
142
+ return this.unwrap(ctx, e);
143
+ }
144
+ }
145
+ async _resolve(fragments, ctx) {
141
146
  let res = await this.resolveNative(fragments, ctx)
142
147
  || await this.resolveIndex(fragments, ctx)
143
148
  || await this.resolveNext(fragments, ctx)
144
149
  || await this.resolveWild(fragments, ctx)
145
150
  || await this.resolveSlug(fragments, ctx);
146
- return this.unwrap(ctx, res);
151
+ if (res instanceof Response) {
152
+ if (100 <= res.status && res.status <= 399)
153
+ return res;
154
+ if (res.headers.has("X-Caught"))
155
+ return res;
156
+ this.unwrap(ctx, res);
157
+ }
158
+ return res;
147
159
  }
148
160
  async resolveIndex(fragments, ctx) {
149
161
  if (fragments.length > 0)
@@ -187,29 +199,13 @@ export class RouteTree {
187
199
  return await ResolveNatively(fragments, ctx);
188
200
  }
189
201
  async unwrap(ctx, res) {
190
- if (!BadResponse(res))
191
- return res;
192
202
  if (!this.slug)
193
- return res;
194
- if (res === null)
195
- res = new Response("Not Found", { status: 404, statusText: "Not Found", headers: ctx.headers });
196
- if (res.headers.has("X-Caught"))
197
- return res;
203
+ throw res;
198
204
  const caught = await this.slug.error(ctx, res);
199
- if (!caught)
200
- return res;
201
205
  caught.headers.set("X-Caught", "true");
202
206
  return caught;
203
207
  }
204
208
  }
205
- function BadResponse(res) {
206
- if (res === null)
207
- return true;
208
- if (res.status < 200)
209
- return true;
210
- if (res.status > 299)
211
- return true;
212
- }
213
209
  async function ResolveNatively(fragments, ctx) {
214
210
  switch (fragments[1]) {
215
211
  case "dynamic": return dynamic._resolve(fragments, ctx);
package/bin/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ParameterShaper } from "./util/parameters.js";
2
2
  import { RouteContext } from "./router.js";
3
- export type CatchFunction<T> = (args: T, err: unknown) => Promise<Response | JSX.Element | null>;
3
+ export type CatchFunction<T> = (args: T, err: unknown) => Promise<Response | JSX.Element>;
4
4
  export type RenderFunction<T> = (args: T) => Promise<Response | JSX.Element | null>;
5
5
  export type RouteModule<T extends ParameterShaper> = {
6
6
  parameters?: T;
package/bin/util/css.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { QuickHash } from "../util/hash.js";
2
2
  const classNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
3
- const registry = new Array();
3
+ const registry = new Map();
4
4
  let cache = null;
5
5
  /**
6
6
  * Create a new css class to be included in the sheet
@@ -17,7 +17,7 @@ export class StyleClass {
17
17
  this.name = `${name}-${this.hash}`;
18
18
  style = style.replaceAll(".this", "." + this.name);
19
19
  this.style = style;
20
- registry.push(this);
20
+ registry.set(this.name, this);
21
21
  cache = null;
22
22
  }
23
23
  toString() {
@@ -43,9 +43,13 @@ export function _resolve(fragments) {
43
43
  return new Response(build.sheet, { headers });
44
44
  }
45
45
  function BuildSheet() {
46
- const key = registry.map(x => x.hash).join("");
47
- const hash = QuickHash(key);
48
- const sheet = registry.map(x => x.style).join("");
46
+ let composite = "";
47
+ let sheet = "";
48
+ for (const [key, def] of registry) {
49
+ composite += key;
50
+ sheet += def.style;
51
+ }
52
+ const hash = QuickHash(composite);
49
53
  cache = { hash, sheet };
50
54
  return cache;
51
55
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "htmx-router",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "A simple SSR framework with dynamic+client islands",
5
+ "keywords": ["htmx", "router", ""],
5
6
  "main": "./bin/index.js",
6
7
  "type": "module",
7
8
  "scripts": {