htmx-router 1.0.0-alpha.3 → 1.0.0-alpha.5

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/config.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { readFile } from "fs/promises";
2
2
  export async function ReadConfig() {
3
- return JSON.parse(await readFile(process.argv[2] || "./htmx-router.json", "utf-8"));
3
+ return JSON.parse(await readFile(process.argv[2] || "./htmx-config.json", "utf-8"));
4
4
  }
package/bin/cli/index.js CHANGED
@@ -11,10 +11,11 @@ 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
- import { RegisterDynamic } from "htmx-router/bin/util/dynamic";
17
17
  import { GetClientEntryURL } from 'htmx-router/bin/client/entry';
18
+ import { DynamicReference } from "htmx-router/bin/util/dynamic";
18
19
  import { GetMountUrl } from 'htmx-router/bin/client/mount';
19
20
  import { GetSheetUrl } from 'htmx-router/bin/util/css';
20
21
  import { RouteModule } from "htmx-router";
@@ -31,18 +32,12 @@ for (const path in modules) {
31
32
  }
32
33
 
33
34
  export function Dynamic<T extends Record<string, string>>(props: {
34
- params: T,
35
- loader: (params: T, ctx: GenericContext) => Promise<JSX.Element>
35
+ params?: T,
36
+ loader: (ctx: GenericContext, params?: T) => Promise<JSX.Element>
36
37
  children?: JSX.Element
37
38
  }): JSX.Element {
38
- const path = RegisterDynamic(props.loader);
39
-
40
- const query = new URLSearchParams();
41
- for (const key in props.params) query.set(key, props.params[key]);
42
- const url = path + query.toString();
43
-
44
39
  return <div
45
- hx-get={url}
40
+ hx-get={DynamicReference(props.loader, props.params)}
46
41
  hx-trigger="load"
47
42
  hx-swap="outerHTML transition:true"
48
43
  style={{ display: "contents" }}
@@ -18,7 +18,7 @@ export async function GenerateClient(config, force = false) {
18
18
  writeFile(config.source, source
19
19
  + pivot
20
20
  + `// hash: ${hash}\n`
21
- + BuildClientServer(imported)),
21
+ + BuildClientServer(config.adapter, imported)),
22
22
  writeFile(CutString(config.source, ".", -1)[0] + ".manifest.tsx", BuildClientManifest(config.adapter, imported))
23
23
  ]);
24
24
  }
@@ -44,7 +44,13 @@ function ParseImports(source) {
44
44
  }
45
45
  return out;
46
46
  }
47
- function BuildClientServer(imported) {
47
+ function SafeScript(type, script) {
48
+ switch (type) {
49
+ case "react": return `<script dangerouslySetInnerHTML={{__html: ${script}}}></script>`;
50
+ default: return `<script>${script}</script>`;
51
+ }
52
+ }
53
+ function BuildClientServer(type, imported) {
48
54
  const names = new Array();
49
55
  for (const imp of imported) {
50
56
  if (Array.isArray(imp.mapping))
@@ -58,7 +64,7 @@ function BuildClientServer(imported) {
58
64
  + "function mount(name: string, data: string, ssr?: JSX.Element) {\n"
59
65
  + "\treturn (<>\n"
60
66
  + `\t\t<div className={island}>{ssr}</div>\n`
61
- + "\t\t<script>{`Router.mountAboveWith('${name}', ${data})`}</script>\n"
67
+ + `\t\t${SafeScript(type, "`Router.mountAboveWith('${name}', ${data})`")}\n`
62
68
  + "\t</>);\n"
63
69
  + "}\n"
64
70
  + "\n"
@@ -83,6 +89,7 @@ function BuildClientManifest(type, imports) {
83
89
  + " * Generated by htmx-router *\n"
84
90
  + " * Warn: Any changes will be overwritten *\n"
85
91
  + "-------------------------------------------*/\n\n"
92
+ + "/* eslint-disable @typescript-eslint/no-explicit-any */\n"
86
93
  + "const client = {\n";
87
94
  const render = renderer[type];
88
95
  if (!render) {
@@ -3,6 +3,9 @@ import { CutString } from "../helper.js";
3
3
  // this function simply exists so it can be stringified and written into the client js bundle
4
4
  function ClientMounter() {
5
5
  const theme = {
6
+ get: () => {
7
+ return (localStorage.getItem("theme") || theme.infer());
8
+ },
6
9
  infer: () => {
7
10
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
8
11
  const current = prefersDark ? 'dark' : 'light';
@@ -10,16 +13,15 @@ function ClientMounter() {
10
13
  return current;
11
14
  },
12
15
  apply: () => {
13
- const current = localStorage.getItem("theme") || theme.infer();
14
- document.documentElement.setAttribute('data-theme', current);
16
+ document.documentElement.setAttribute('data-theme', theme.get());
15
17
  },
16
18
  toggle: () => {
17
- const current = localStorage.getItem("theme") || theme.infer();
18
- if (current === "dark")
19
+ if (theme.get() === "dark")
19
20
  localStorage.setItem("theme", "light");
20
21
  else
21
22
  localStorage.setItem("theme", "dark");
22
23
  theme.apply();
24
+ return localStorage.getItem("theme");
23
25
  }
24
26
  };
25
27
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
@@ -49,6 +51,45 @@ function ClientMounter() {
49
51
  document.addEventListener("DOMContentLoaded", Mount);
50
52
  if (global.htmx)
51
53
  global.htmx.onLoad(Mount);
54
+ // Track the number of active requests
55
+ let activeRequests = 0;
56
+ const updateLoadingAttribute = () => {
57
+ if (activeRequests > 0)
58
+ document.body.setAttribute('data-loading', 'true');
59
+ else
60
+ document.body.removeAttribute('data-loading');
61
+ };
62
+ const originalXHROpen = XMLHttpRequest.prototype.open;
63
+ const originalXHRSend = XMLHttpRequest.prototype.send;
64
+ // @ts-ignore
65
+ XMLHttpRequest.prototype.open = function (...args) {
66
+ this.addEventListener('loadstart', () => {
67
+ activeRequests++;
68
+ updateLoadingAttribute();
69
+ });
70
+ this.addEventListener('loadend', () => {
71
+ activeRequests--;
72
+ updateLoadingAttribute();
73
+ });
74
+ originalXHROpen.apply(this, args);
75
+ };
76
+ XMLHttpRequest.prototype.send = function (...args) {
77
+ originalXHRSend.apply(this, args);
78
+ };
79
+ // Override fetch
80
+ const originalFetch = window.fetch;
81
+ window.fetch = async (...args) => {
82
+ activeRequests++;
83
+ updateLoadingAttribute();
84
+ try {
85
+ const response = await originalFetch(...args);
86
+ return response;
87
+ }
88
+ finally {
89
+ activeRequests--;
90
+ updateLoadingAttribute();
91
+ }
92
+ };
52
93
  return {
53
94
  mountAboveWith: RequestMount,
54
95
  theme
package/bin/index.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { RouteModule, CatchFunction, RenderFunction } from './types.js';
2
2
  import { RouteContext, GenericContext } from "./router.js";
3
3
  import { createRequestHandler } from './request/index.js';
4
+ import { MetaDescriptor, RenderMetaDescriptor, ShellOptions, ApplyMetaDescriptorDefaults, LdJsonObject, OpenGraph, OpenGraphImage, OpenGraphVideo, OpenGraphAudio, InferShellOptions } from './util/shell.js';
5
+ import { redirect, refresh, revalidate, text, json } from './util/response.js';
4
6
  import { Cookies, CookieOptions } from "./util/cookies.js";
5
7
  import { EventSourceConnection } from "./util/event-source.js";
8
+ import { DynamicReference } from './util/dynamic.js';
6
9
  import { StyleClass } from './util/css.js';
7
10
  import { Endpoint } from './util/endpoint.js';
8
- export { CatchFunction, CookieOptions, Cookies, createRequestHandler, Endpoint, EventSourceConnection, GenericContext, RenderFunction, RouteContext, RouteModule, StyleClass, };
11
+ export { createRequestHandler, CatchFunction, RenderFunction, RouteContext, RouteModule, GenericContext, Cookies, CookieOptions, Endpoint, DynamicReference, StyleClass, EventSourceConnection, redirect, refresh, revalidate, text, json, MetaDescriptor, RenderMetaDescriptor, ShellOptions, ApplyMetaDescriptorDefaults, LdJsonObject, OpenGraph, OpenGraphImage, OpenGraphVideo, OpenGraphAudio, InferShellOptions };
package/bin/index.js CHANGED
@@ -1,7 +1,18 @@
1
1
  import { RouteContext, GenericContext } from "./router.js";
2
2
  import { createRequestHandler } from './request/index.js';
3
+ import { RenderMetaDescriptor, ApplyMetaDescriptorDefaults } from './util/shell.js';
4
+ import { redirect, refresh, revalidate, text, json } from './util/response.js';
3
5
  import { Cookies } from "./util/cookies.js";
4
6
  import { EventSourceConnection } from "./util/event-source.js";
7
+ import { DynamicReference } from './util/dynamic.js';
5
8
  import { StyleClass } from './util/css.js';
6
9
  import { Endpoint } from './util/endpoint.js';
7
- export { Cookies, createRequestHandler, Endpoint, EventSourceConnection, GenericContext, RouteContext, StyleClass, };
10
+ export { createRequestHandler, RouteContext, GenericContext,
11
+ // Request helpers
12
+ Cookies, Endpoint, DynamicReference,
13
+ // CSS Helper
14
+ StyleClass,
15
+ // SSE helper
16
+ EventSourceConnection,
17
+ // Response helpers
18
+ redirect, refresh, revalidate, text, json, RenderMetaDescriptor, ApplyMetaDescriptorDefaults };
@@ -26,17 +26,17 @@ 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
29
+ response = new Response("No Route Found", { status: 404, statusText: "Not Found", headers: ctx.headers });
30
+ // Override with context headers
31
31
  if (response.headers !== ctx.headers) {
32
32
  for (const [key, value] of ctx.headers) {
33
- if (response.headers.has(key))
33
+ if (ctx.headers.has(key))
34
34
  continue;
35
35
  response.headers.set(key, value);
36
36
  }
37
37
  }
38
38
  // Merge cookie changes
39
- const headers = Object.fromEntries(ctx.headers);
39
+ const headers = Object.fromEntries(response.headers);
40
40
  const cookies = ctx.cookie.export();
41
41
  if (cookies.length > 0) {
42
42
  headers['set-cookie'] = cookies;
@@ -0,0 +1,9 @@
1
+ export declare function text(text: string, init?: ResponseInit): Response;
2
+ export declare function json(data: unknown, init?: ResponseInit): Response;
3
+ export declare function redirect(url: string, init?: ResponseInit & {
4
+ client?: boolean;
5
+ }): Response;
6
+ export declare function revalidate(init?: ResponseInit): Response;
7
+ export declare function refresh(init?: ResponseInit & {
8
+ client?: boolean;
9
+ }): Response;
@@ -0,0 +1,46 @@
1
+ export function text(text, init) {
2
+ init ||= {};
3
+ init.statusText ||= "ok";
4
+ init.status = 200;
5
+ const res = new Response(text, init);
6
+ res.headers.set("Content-Type", "text/plain");
7
+ res.headers.set("X-Caught", "true");
8
+ return res;
9
+ }
10
+ export function json(data, init) {
11
+ init ||= {};
12
+ init.statusText ||= "ok";
13
+ init.status = 200;
14
+ const res = new Response(JSON.stringify(data), init);
15
+ res.headers.set("Content-Type", "application/json");
16
+ res.headers.set("X-Caught", "true");
17
+ return res;
18
+ }
19
+ export function redirect(url, init) {
20
+ init ||= {};
21
+ init.statusText ||= "Temporary Redirect";
22
+ init.status = 307;
23
+ const res = new Response("", init);
24
+ if (!init?.client)
25
+ res.headers.set("Location", url);
26
+ res.headers.set("HX-Location", url); // use hx-boost if applicable
27
+ return res;
28
+ }
29
+ export function revalidate(init) {
30
+ init ||= {};
31
+ init.statusText ||= "ok";
32
+ init.status = 200;
33
+ const res = new Response("", init);
34
+ res.headers.set("HX-Location", "");
35
+ return res;
36
+ }
37
+ export function refresh(init) {
38
+ init ||= {};
39
+ init.statusText ||= "ok";
40
+ init.status = 200;
41
+ const res = new Response("", init);
42
+ if (!init?.client)
43
+ res.headers.set("Refresh", "0"); // fallback
44
+ res.headers.set("HX-Refresh", "true");
45
+ return res;
46
+ }
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
+ return 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;
@@ -2,7 +2,7 @@
2
2
  * This whole file is only for internal use but the generated router for the <Dynamic> component
3
3
  */
4
4
  import { GenericContext } from "../router.js";
5
- export declare function RegisterDynamic<T>(load: Loader<T>): string;
6
- type Loader<T> = (params: T, ctx: GenericContext) => Promise<JSX.Element>;
5
+ export declare function DynamicReference<T extends Record<string, string>>(loader: Loader<T>, params?: T): string;
6
+ type Loader<T> = (ctx: GenericContext, params: T) => Promise<JSX.Element>;
7
7
  export declare function _resolve(fragments: string[], ctx: GenericContext): Promise<Response | null>;
8
8
  export {};
@@ -4,17 +4,28 @@
4
4
  import { QuickHash } from "../util/hash.js";
5
5
  const registry = new Map();
6
6
  const index = new Map();
7
- export function RegisterDynamic(load) {
7
+ function Register(load) {
8
8
  const existing = index.get(load);
9
9
  if (existing)
10
10
  return existing;
11
11
  const hash = QuickHash(String(load));
12
12
  const name = `${encodeURIComponent(load.name)}-${hash}`;
13
13
  registry.set(name, load);
14
- const url = `/_/dynamic/${name}?`;
14
+ const url = `/_/dynamic/${name}`;
15
15
  index.set(load, url);
16
16
  return url;
17
17
  }
18
+ export function DynamicReference(loader, params) {
19
+ let url = Register(loader);
20
+ if (params) {
21
+ const query = new URLSearchParams();
22
+ if (params)
23
+ for (const key in params)
24
+ query.set(key, params[key]);
25
+ url += "?" + query.toString();
26
+ }
27
+ return url;
28
+ }
18
29
  export async function _resolve(fragments, ctx) {
19
30
  if (!fragments[2])
20
31
  return null;
@@ -25,5 +36,5 @@ export async function _resolve(fragments, ctx) {
25
36
  for (const [key, value] of ctx.url.searchParams)
26
37
  props[key] = value;
27
38
  ctx.headers.set("X-Partial", "true");
28
- return ctx.render(await endpoint(props, ctx));
39
+ return ctx.render(await endpoint(ctx, props));
29
40
  }
@@ -0,0 +1,11 @@
1
+ export declare function text(text: string, init?: ResponseInit): Response;
2
+ export declare function json<T>(data: T, init?: ResponseInit): Omit<Response, "json"> & {
3
+ json(): Promise<T>;
4
+ };
5
+ export declare function redirect(url: string, init?: ResponseInit & {
6
+ clientOnly?: boolean;
7
+ }): Response;
8
+ export declare function revalidate(init?: ResponseInit): Response;
9
+ export declare function refresh(init?: ResponseInit & {
10
+ clientOnly?: boolean;
11
+ }): Response;
@@ -0,0 +1,46 @@
1
+ export function text(text, init) {
2
+ init ||= {};
3
+ init.statusText ||= "ok";
4
+ init.status = 200;
5
+ const res = new Response(text, init);
6
+ res.headers.set("Content-Type", "text/plain");
7
+ res.headers.set("X-Caught", "true");
8
+ return res;
9
+ }
10
+ export function json(data, init) {
11
+ init ||= {};
12
+ init.statusText ||= "ok";
13
+ init.status = 200;
14
+ const res = new Response(JSON.stringify(data), init);
15
+ res.headers.set("Content-Type", "application/json");
16
+ res.headers.set("X-Caught", "true");
17
+ return res;
18
+ }
19
+ export function redirect(url, init) {
20
+ init ||= {};
21
+ init.statusText ||= "Temporary Redirect";
22
+ init.status = 307;
23
+ const res = new Response("", init);
24
+ if (!init?.clientOnly)
25
+ res.headers.set("Location", url);
26
+ res.headers.set("HX-Location", url); // use hx-boost if applicable
27
+ return res;
28
+ }
29
+ export function revalidate(init) {
30
+ init ||= {};
31
+ init.statusText ||= "ok";
32
+ init.status = 200;
33
+ const res = new Response("", init);
34
+ res.headers.set("HX-Location", "");
35
+ return res;
36
+ }
37
+ export function refresh(init) {
38
+ init ||= {};
39
+ init.statusText ||= "ok";
40
+ init.status = 200;
41
+ const res = new Response("", init);
42
+ if (!init?.clientOnly)
43
+ res.headers.set("Refresh", "0"); // fallback
44
+ res.headers.set("HX-Refresh", "true");
45
+ return res;
46
+ }
@@ -1,32 +1,120 @@
1
- /**
2
- * These types are just helpers which could be useful
3
- * But the goal is to add a feature in the future to help will shells merging meta data
4
- * Currently I want more experience using the slug-shell pattern before I build it out
5
- */
1
+ export type ShellOptions<D = {}> = D & MetaDescriptor;
2
+ export declare function ApplyMetaDescriptorDefaults(options: ShellOptions, defaults: Readonly<Partial<ShellOptions>>): void;
3
+ export type InferShellOptions<F> = F extends (jsx: any, options: infer U) => any ? U : never;
6
4
  export type MetaDescriptor = {
7
- charSet: "utf-8";
8
- } | {
9
- title: string;
10
- } | {
11
- name: string;
12
- content: string;
13
- } | {
14
- property: string;
15
- content: string;
16
- } | {
17
- httpEquiv: string;
18
- content: string;
19
- } | {
20
- "script:ld+json": LdJsonObject;
21
- } | {
22
- tagName: "meta" | "link";
23
- [name: string]: string;
24
- } | {
25
- [name: string]: unknown;
5
+ title?: string;
6
+ description?: string;
7
+ meta?: Record<string, string>;
8
+ og?: OpenGraph<string>;
9
+ jsonLD?: LdJsonObject[];
26
10
  };
11
+ export declare function RenderMetaDescriptor<T>(options: ShellOptions<T>): string;
27
12
  export type LdJsonObject = {
28
13
  [Key in string]?: LdJsonValue | undefined;
29
14
  };
30
- export type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[];
31
- export type LdJsonPrimitive = string | number | boolean | null;
32
- export type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray;
15
+ type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[];
16
+ type LdJsonPrimitive = string | number | boolean | null;
17
+ type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray;
18
+ export type OpenGraphType = "website" | "article" | "book" | "profile" | "music.song" | "music.album" | "music.playlist" | "music.radio_station" | "video.movie" | "video.episode" | "video.tv_show" | "video.other" | string;
19
+ export type OpenGraph<T extends OpenGraphType = string> = {
20
+ type?: T;
21
+ title?: string;
22
+ description?: string;
23
+ determiner?: string;
24
+ url?: string;
25
+ secure_url?: string;
26
+ locale?: string | {
27
+ base: string;
28
+ alternative: string[];
29
+ };
30
+ image?: OpenGraphImage[];
31
+ video?: OpenGraphVideo[];
32
+ audio?: OpenGraphAudio[];
33
+ } & (T extends "music.song" ? OpenGraphSong : T extends "music.album" ? OpenGraphAlbum : T extends "music.playlist" ? OpenGraphPlaylist : T extends "music.radio_station" ? OpenGraphRadioStation : T extends "video.movie" ? OpenGraphMovie : T extends "video.episode" ? OpenGraphEpisode : T extends "video.tv_show" ? OpenGraphTvShow : T extends "video.other" ? OpenGraphVideoOther : T extends "article" ? OpenGraphArticle : T extends "book" ? OpenGraphBook : T extends "profile" ? OpenGraphProfile : {});
34
+ export type OpenGraphImage = {
35
+ url: string;
36
+ secure_url?: string;
37
+ type?: string;
38
+ width?: number;
39
+ height?: number;
40
+ alt?: string;
41
+ };
42
+ export type OpenGraphVideo = {
43
+ url: string;
44
+ type?: string;
45
+ secure_url?: string;
46
+ width?: number;
47
+ height?: number;
48
+ alt?: string;
49
+ };
50
+ export type OpenGraphAudio = {
51
+ url: string;
52
+ type?: string;
53
+ secure_url?: string;
54
+ };
55
+ type OpenGraphSong = {
56
+ duration?: number;
57
+ album?: Array<string | {
58
+ url: string;
59
+ disc?: number;
60
+ track?: number;
61
+ }>;
62
+ musician?: string[];
63
+ };
64
+ type OpenGraphAlbum = {
65
+ songs?: Array<string | {
66
+ url: string;
67
+ disc?: number;
68
+ track?: number;
69
+ }>;
70
+ musician?: string[];
71
+ release_date?: Date;
72
+ };
73
+ type OpenGraphPlaylist = {
74
+ songs?: Array<string | {
75
+ url: string;
76
+ disc?: number;
77
+ track?: number;
78
+ }>;
79
+ creator?: string[];
80
+ };
81
+ type OpenGraphRadioStation = {
82
+ creator?: string[];
83
+ };
84
+ type OpenGraphMovie = {
85
+ actors?: Array<string | {
86
+ url: string;
87
+ role: string;
88
+ }>;
89
+ directors?: string[];
90
+ writers?: string[];
91
+ duration?: number;
92
+ release_date?: Date;
93
+ tag: string[];
94
+ };
95
+ type OpenGraphEpisode = OpenGraphMovie & {
96
+ series?: string;
97
+ };
98
+ type OpenGraphTvShow = OpenGraphMovie;
99
+ type OpenGraphVideoOther = OpenGraphMovie;
100
+ type OpenGraphArticle = {
101
+ published_time?: Date;
102
+ modified_time?: Date;
103
+ expiration_time?: Date;
104
+ authors?: string[];
105
+ section?: string;
106
+ tag?: string;
107
+ };
108
+ type OpenGraphBook = {
109
+ authors?: string[];
110
+ isbn?: string;
111
+ release_date?: Date;
112
+ tag?: string;
113
+ };
114
+ type OpenGraphProfile = {
115
+ first_name?: string;
116
+ last_name?: string;
117
+ username?: string;
118
+ gender?: "male" | "female";
119
+ };
120
+ export {};
package/bin/util/shell.js CHANGED
@@ -1,8 +1,251 @@
1
- /**
2
- * These types are just helpers which could be useful
3
- * But the goal is to add a feature in the future to help will shells merging meta data
4
- * Currently I want more experience using the slug-shell pattern before I build it out
5
- */
6
- export {};
7
- // export type ShellOptions = { meta?: Array<MetaDescriptor> } | undefined;
8
- // export type ShellProps<T> = T & ShellOptions;
1
+ export function ApplyMetaDescriptorDefaults(options, defaults) {
2
+ if (defaults.title && !options.title)
3
+ options.title = defaults.title;
4
+ if (defaults.description && !options.description)
5
+ options.description = defaults.description;
6
+ if (defaults.meta && !options.meta)
7
+ options.meta = defaults.meta;
8
+ if (defaults.og && !options.og)
9
+ options.og = defaults.og;
10
+ if (defaults.jsonLD && !options.jsonLD)
11
+ options.jsonLD = defaults.jsonLD;
12
+ }
13
+ export function RenderMetaDescriptor(options) {
14
+ let out = "";
15
+ if (options.title)
16
+ out += `<title>${EscapeHTML(options.title)}</title>`;
17
+ if (options.description)
18
+ out += `<meta name="description" content="${EscapeHTML(options.description)}">\n`;
19
+ if (options.meta)
20
+ for (const key in options.meta) {
21
+ out += `<meta name="${EscapeHTML(key)}" content="${EscapeHTML(options.meta[key])}">\n`;
22
+ }
23
+ if (options.jsonLD)
24
+ for (const json of options.jsonLD) {
25
+ out += `<script>${EscapeHTML(JSON.stringify(json))}</script>\n`;
26
+ }
27
+ // Auto apply og:title + og:description if not present
28
+ if (options.title && !options.og?.title)
29
+ out += `<meta property="og:title" content="${EscapeHTML(options.title)}">\n`;
30
+ if (options.description && !options.og?.description)
31
+ out += `<meta property="og:description" content="${EscapeHTML(options.description)}">\n`;
32
+ // Apply open graphs
33
+ if (options.og)
34
+ out += RenderOpenGraph(options.og);
35
+ return out;
36
+ }
37
+ function RenderOpenGraph(og) {
38
+ // Manually encoding everything rather than using a loop to ensure they are in the correct order
39
+ // And to ensure extra values can't leak in creating unsafe og tags
40
+ const type = og.type || "website";
41
+ let out = RenderProperty("og:type", type);
42
+ if (og.title)
43
+ out += RenderProperty("og:title", og.title);
44
+ if (og.description)
45
+ out += RenderProperty("og:description", og.description);
46
+ if (og.determiner)
47
+ out += RenderProperty("og:determiner", og.determiner);
48
+ if (og.url)
49
+ out += RenderProperty("og:url", og.url);
50
+ if (og.secure_url)
51
+ out += RenderProperty("og:secure_url", og.secure_url);
52
+ if (og.locale) {
53
+ if (typeof og.locale === "string")
54
+ out += RenderProperty("og:locale", og.locale);
55
+ else {
56
+ out += RenderProperty("og:locale", og.locale.base);
57
+ for (const l of og.locale.alternative)
58
+ out += RenderProperty("og:locale:alternative", l);
59
+ }
60
+ }
61
+ if (og.image)
62
+ for (const img of og.image) {
63
+ out += RenderProperty("og:image", img.url);
64
+ if (img.secure_url)
65
+ out += RenderProperty("og:image:secure_url", img.secure_url);
66
+ if (img.type)
67
+ out += RenderProperty("og:image:type", img.type);
68
+ if (img.width)
69
+ out += RenderProperty("og:image:width", img.width.toString());
70
+ if (img.height)
71
+ out += RenderProperty("og:image:height", img.height.toString());
72
+ if (img.alt)
73
+ out += RenderProperty("og:image:alt", img.alt);
74
+ }
75
+ if (og.video)
76
+ for (const vid of og.video) {
77
+ out += RenderProperty("og:video", vid.url);
78
+ if (vid.secure_url)
79
+ out += RenderProperty("og:video:secure_url", vid.secure_url);
80
+ if (vid.type)
81
+ out += RenderProperty("og:video:type", vid.type);
82
+ if (vid.width)
83
+ out += RenderProperty("og:video:width", vid.width.toString());
84
+ if (vid.height)
85
+ out += RenderProperty("og:video:height", vid.height.toString());
86
+ if (vid.alt)
87
+ out += RenderProperty("og:video:alt", vid.alt);
88
+ }
89
+ if (og.audio)
90
+ for (const audio of og.audio) {
91
+ out += RenderProperty("og:audio", audio.url);
92
+ if (audio.secure_url)
93
+ out += RenderProperty("og:audio:secure_url", audio.secure_url);
94
+ if (audio.type)
95
+ out += RenderProperty("og:audio:type", audio.type);
96
+ }
97
+ return out + RenderOpenGraphExtras(og);
98
+ }
99
+ function RenderProperty(name, value) {
100
+ return `<meta property="${name}" content="${EscapeHTML(value)}">\n`;
101
+ }
102
+ function RenderOpenGraphExtras(og) {
103
+ let out = "";
104
+ if (og.type === "music.song") {
105
+ const g = og;
106
+ if (g.duration)
107
+ out += RenderProperty("og:music:duration", g.duration.toString());
108
+ if (g.album)
109
+ for (const album of g.album) {
110
+ if (typeof album === "string")
111
+ out += RenderProperty("og:music:album", album);
112
+ else {
113
+ out += RenderProperty("og:music:album", album.url);
114
+ if (album.disc)
115
+ out += RenderProperty("og:music:album:disc", album.disc.toString());
116
+ if (album.track)
117
+ out += RenderProperty("og:music:album:track", album.track.toString());
118
+ }
119
+ }
120
+ if (g.musician)
121
+ for (const profile of g.musician)
122
+ out += RenderProperty("og:music:musician", profile);
123
+ return out;
124
+ }
125
+ if (og.type === "music.album") {
126
+ const g = og;
127
+ if (g.songs)
128
+ for (const song of g.songs) {
129
+ if (typeof song === "string")
130
+ out += RenderProperty("og:music:song", song);
131
+ else {
132
+ out += RenderProperty("og:music:song", song.url);
133
+ if (song.disc)
134
+ out += RenderProperty("og:music:song:disc", song.disc.toString());
135
+ if (song.track)
136
+ out += RenderProperty("og:music:song:track", song.track.toString());
137
+ }
138
+ }
139
+ if (g.musician)
140
+ for (const profile of g.musician)
141
+ out += RenderProperty("og:music:musician", profile);
142
+ if (g.release_date)
143
+ out += RenderProperty("og:music:release_date", g.release_date.toISOString());
144
+ return out;
145
+ }
146
+ if (og.type === "music.playlist") {
147
+ const g = og;
148
+ if (g.songs)
149
+ for (const song of g.songs) {
150
+ if (typeof song === "string")
151
+ out += RenderProperty("og:music:song", song);
152
+ else {
153
+ out += RenderProperty("og:music:song", song.url);
154
+ if (song.disc)
155
+ out += RenderProperty("og:music:song:disc", song.disc.toString());
156
+ if (song.track)
157
+ out += RenderProperty("og:music:song:track", song.track.toString());
158
+ }
159
+ }
160
+ if (g.creator)
161
+ for (const profile of g.creator)
162
+ out += RenderProperty("og:music:creator", profile);
163
+ return out;
164
+ }
165
+ if (og.type === "music.radio_station") {
166
+ const g = og;
167
+ if (g.creator)
168
+ for (const profile of g.creator)
169
+ out += RenderProperty("og:music:creator", profile);
170
+ return out;
171
+ }
172
+ if (og.type === "video.movie" || og.type === "video.episode" || og.type === "video.tv_show" || og.type === "video.other") {
173
+ const g = og;
174
+ if (g.actors)
175
+ for (const actor of g.actors) {
176
+ if (typeof actor === "string")
177
+ out += RenderProperty("og:video:actor", actor);
178
+ else {
179
+ out += RenderProperty("og:video:actor", actor.url);
180
+ out += RenderProperty("og:video:actor:role", actor.role);
181
+ }
182
+ }
183
+ if (g.directors)
184
+ for (const profile of g.directors)
185
+ out += RenderProperty("og:video:director", profile);
186
+ if (g.writers)
187
+ for (const profile of g.writers)
188
+ out += RenderProperty("og:video:writer", profile);
189
+ if (g.duration)
190
+ out += RenderProperty("og:video:duration", g.duration.toString());
191
+ if (g.release_date)
192
+ out += RenderProperty("og:video:release_date", g.release_date.toISOString());
193
+ if (g.tag)
194
+ for (const tag of g.tag)
195
+ out += RenderProperty("og:video:tag", tag);
196
+ if (g.series)
197
+ out += RenderProperty("og:video:series", g.series);
198
+ }
199
+ if (og.type === "article") {
200
+ const g = og;
201
+ if (g.published_time)
202
+ out += RenderProperty("og:article:published_time", g.published_time.toISOString());
203
+ if (g.modified_time)
204
+ out += RenderProperty("og:article:modified_time", g.modified_time.toISOString());
205
+ if (g.expiration_time)
206
+ out += RenderProperty("og:article:expiration_time", g.expiration_time.toISOString());
207
+ if (g.authors)
208
+ for (const profile of g.authors)
209
+ out += RenderProperty("og:article:author", profile);
210
+ if (g.section)
211
+ out += RenderProperty("og:article:section", g.section);
212
+ if (g.tag)
213
+ for (const tag of g.tag)
214
+ out += RenderProperty("og:video:tag", tag);
215
+ }
216
+ if (og.type === "book") {
217
+ const g = og;
218
+ if (g.authors)
219
+ for (const profile of g.authors)
220
+ out += RenderProperty("og:article:author", profile);
221
+ if (g.isbn)
222
+ out += RenderProperty("og:book:isbn", g.isbn);
223
+ if (g.release_date)
224
+ out += RenderProperty("og:book:release_date", g.release_date.toISOString());
225
+ if (g.tag)
226
+ for (const tag of g.tag)
227
+ out += RenderProperty("og:video:tag", tag);
228
+ }
229
+ if (og.type === "profile") {
230
+ const g = og;
231
+ if (g.first_name)
232
+ out += RenderProperty("og:profile:first_name", g.first_name);
233
+ if (g.last_name)
234
+ out += RenderProperty("og:profile:last_name", g.last_name);
235
+ if (g.username)
236
+ out += RenderProperty("og:profile:username", g.username);
237
+ if (g.gender)
238
+ out += RenderProperty("og:profile:gender", g.gender);
239
+ }
240
+ return "";
241
+ }
242
+ const escapeTo = {
243
+ "&": "&amp;",
244
+ "<": "&lt;",
245
+ ">": "&gt;",
246
+ "\"": "&quot;",
247
+ "'": "&#39;",
248
+ };
249
+ function EscapeHTML(str) {
250
+ return str.replace(/[&<>"']/g, (match) => escapeTo[match] || match);
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmx-router",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.5",
4
4
  "description": "A simple SSR framework with dynamic+client islands",
5
5
  "keywords": ["htmx", "router", ""],
6
6
  "main": "./bin/index.js",