htmx-router 1.0.0-alpha.4 → 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 +1 -1
- package/bin/cli/index.js +4 -10
- package/bin/client/index.js +9 -3
- package/bin/client/mount.js +45 -4
- package/bin/index.d.ts +4 -2
- package/bin/index.js +12 -2
- package/bin/request/native.js +9 -13
- package/bin/response.d.ts +7 -2
- package/bin/response.js +22 -9
- package/bin/router.js +1 -1
- package/bin/util/dynamic.d.ts +2 -2
- package/bin/util/dynamic.js +14 -3
- package/bin/util/response.d.ts +11 -0
- package/bin/util/response.js +46 -0
- package/bin/util/shell.d.ts +115 -27
- package/bin/util/shell.js +251 -8
- package/package.json +1 -1
package/bin/cli/config.js
CHANGED
package/bin/cli/index.js
CHANGED
|
@@ -14,8 +14,8 @@ await writeFile(config.router.output, `/*---------------------------------------
|
|
|
14
14
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
15
15
|
|
|
16
16
|
import { GenericContext, RouteTree } from "htmx-router/bin/router";
|
|
17
|
-
import { RegisterDynamic } from "htmx-router/bin/util/dynamic";
|
|
18
17
|
import { GetClientEntryURL } from 'htmx-router/bin/client/entry';
|
|
18
|
+
import { DynamicReference } from "htmx-router/bin/util/dynamic";
|
|
19
19
|
import { GetMountUrl } from 'htmx-router/bin/client/mount';
|
|
20
20
|
import { GetSheetUrl } from 'htmx-router/bin/util/css';
|
|
21
21
|
import { RouteModule } from "htmx-router";
|
|
@@ -32,18 +32,12 @@ for (const path in modules) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function Dynamic<T extends Record<string, string>>(props: {
|
|
35
|
-
params
|
|
36
|
-
loader: (
|
|
35
|
+
params?: T,
|
|
36
|
+
loader: (ctx: GenericContext, params?: T) => Promise<JSX.Element>
|
|
37
37
|
children?: JSX.Element
|
|
38
38
|
}): JSX.Element {
|
|
39
|
-
const path = RegisterDynamic(props.loader);
|
|
40
|
-
|
|
41
|
-
const query = new URLSearchParams();
|
|
42
|
-
for (const key in props.params) query.set(key, props.params[key]);
|
|
43
|
-
const url = path + query.toString();
|
|
44
|
-
|
|
45
39
|
return <div
|
|
46
|
-
hx-get={
|
|
40
|
+
hx-get={DynamicReference(props.loader, props.params)}
|
|
47
41
|
hx-trigger="load"
|
|
48
42
|
hx-swap="outerHTML transition:true"
|
|
49
43
|
style={{ display: "contents" }}
|
package/bin/client/index.js
CHANGED
|
@@ -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
|
|
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
|
-
+
|
|
67
|
+
+ `\t\t${SafeScript(type, "`Router.mountAboveWith('${name}', ${data})`")}\n`
|
|
62
68
|
+ "\t</>);\n"
|
|
63
69
|
+ "}\n"
|
|
64
70
|
+ "\n"
|
package/bin/client/mount.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
document.documentElement.setAttribute('data-theme', current);
|
|
16
|
+
document.documentElement.setAttribute('data-theme', theme.get());
|
|
15
17
|
},
|
|
16
18
|
toggle: () => {
|
|
17
|
-
|
|
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,9 +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
|
-
|
|
9
|
-
export { CatchFunction, CookieOptions, Cookies, createRequestHandler, Endpoint, EventSourceConnection, GenericContext, RenderFunction, RouteContext, RouteModule, StyleClass, redirect, text, json, refresh };
|
|
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,8 +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
|
-
|
|
8
|
-
|
|
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 };
|
package/bin/request/native.js
CHANGED
|
@@ -27,24 +27,20 @@ export async function Resolve(request, tree, config) {
|
|
|
27
27
|
let response = await tree.resolve(fragments, ctx);
|
|
28
28
|
if (response === null)
|
|
29
29
|
response = new Response("No Route Found", { status: 404, statusText: "Not Found", headers: ctx.headers });
|
|
30
|
+
// Override with context headers
|
|
31
|
+
if (response.headers !== ctx.headers) {
|
|
32
|
+
for (const [key, value] of ctx.headers) {
|
|
33
|
+
if (ctx.headers.has(key))
|
|
34
|
+
continue;
|
|
35
|
+
response.headers.set(key, value);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
30
38
|
// Merge cookie changes
|
|
31
|
-
const headers = Object.fromEntries(
|
|
39
|
+
const headers = Object.fromEntries(response.headers);
|
|
32
40
|
const cookies = ctx.cookie.export();
|
|
33
41
|
if (cookies.length > 0) {
|
|
34
42
|
headers['set-cookie'] = cookies;
|
|
35
43
|
response.headers.set("Set-Cookie", cookies[0]); // Response object doesn't support multi-header..[]
|
|
36
44
|
}
|
|
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
|
-
}
|
|
49
45
|
return { response, headers };
|
|
50
46
|
}
|
package/bin/response.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export declare function redirect(url: string, init?: ResponseInit): Response;
|
|
2
1
|
export declare function text(text: string, init?: ResponseInit): Response;
|
|
3
2
|
export declare function json(data: unknown, init?: ResponseInit): Response;
|
|
4
|
-
export declare function
|
|
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;
|
package/bin/response.js
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
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
1
|
export function text(text, init) {
|
|
11
2
|
init ||= {};
|
|
12
3
|
init.statusText ||= "ok";
|
|
13
4
|
init.status = 200;
|
|
14
5
|
const res = new Response(text, init);
|
|
15
6
|
res.headers.set("Content-Type", "text/plain");
|
|
7
|
+
res.headers.set("X-Caught", "true");
|
|
16
8
|
return res;
|
|
17
9
|
}
|
|
18
10
|
export function json(data, init) {
|
|
@@ -21,6 +13,25 @@ export function json(data, init) {
|
|
|
21
13
|
init.status = 200;
|
|
22
14
|
const res = new Response(JSON.stringify(data), init);
|
|
23
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", "");
|
|
24
35
|
return res;
|
|
25
36
|
}
|
|
26
37
|
export function refresh(init) {
|
|
@@ -28,6 +39,8 @@ export function refresh(init) {
|
|
|
28
39
|
init.statusText ||= "ok";
|
|
29
40
|
init.status = 200;
|
|
30
41
|
const res = new Response("", init);
|
|
42
|
+
if (!init?.client)
|
|
43
|
+
res.headers.set("Refresh", "0"); // fallback
|
|
31
44
|
res.headers.set("HX-Refresh", "true");
|
|
32
45
|
return res;
|
|
33
46
|
}
|
package/bin/router.js
CHANGED
package/bin/util/dynamic.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
type Loader<T> = (
|
|
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 {};
|
package/bin/util/dynamic.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
+
}
|
package/bin/util/shell.d.ts
CHANGED
|
@@ -1,32 +1,120 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
"&": "&",
|
|
244
|
+
"<": "<",
|
|
245
|
+
">": ">",
|
|
246
|
+
"\"": """,
|
|
247
|
+
"'": "'",
|
|
248
|
+
};
|
|
249
|
+
function EscapeHTML(str) {
|
|
250
|
+
return str.replace(/[&<>"']/g, (match) => escapeTo[match] || match);
|
|
251
|
+
}
|