@botiverse/porthole 0.1.0 → 0.1.1
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/dist/bridge/bridge.js +20 -6
- package/dist/bridge/types.d.ts +19 -3
- package/dist/compile/policy.d.ts +3 -3
- package/dist/compile/policy.js +6 -9
- package/dist/config/schema.d.ts +21 -0
- package/dist/config/schema.js +11 -1
- package/dist/config/validate.d.ts +3 -1
- package/dist/config/validate.js +41 -10
- package/dist/conformance/behavior.js +50 -0
- package/dist/conformance/index.d.ts +11 -5
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/probes.d.ts +26 -2
- package/dist/conformance/probes.js +51 -40
- package/dist/conformance/served.d.ts +7 -0
- package/dist/conformance/served.js +31 -7
- package/dist/guide/guide.js +6 -2
- package/dist/host/create-host.js +46 -2
- package/dist/host/locate.js +6 -1
- package/dist/host/outcome.d.ts +24 -0
- package/dist/host/outcome.js +134 -0
- package/dist/host/types.d.ts +34 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/isolation/served.d.ts +3 -0
- package/dist/isolation/served.js +20 -0
- package/dist/isolation/types.d.ts +9 -0
- package/dist/isolation/types.js +2 -1
- package/dist/platform/types.d.ts +7 -0
- package/dist/platform/web.js +15 -2
- package/dist/presets/presets.d.ts +9 -3
- package/dist/presets/presets.js +16 -1
- package/dist/react/frame.d.ts +16 -0
- package/dist/react/frame.js +44 -10
- package/dist/server/pages.d.ts +11 -6
- package/dist/server/pages.js +78 -12
- package/dist/server/server.js +34 -10
- package/dist/shell/document.d.ts +3 -2
- package/dist/shell/document.js +4 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +1 -0
- package/dist/shell/runtime.js +20 -4
- package/package.json +1 -9
- package/src/bridge/bridge.ts +28 -6
- package/src/bridge/types.ts +21 -3
- package/src/compile/policy.ts +6 -9
- package/src/config/schema.ts +28 -0
- package/src/config/validate.ts +48 -14
- package/src/conformance/behavior.ts +55 -0
- package/src/conformance/index.ts +18 -4
- package/src/conformance/probes.ts +88 -52
- package/src/conformance/served.ts +37 -8
- package/src/guide/guide.ts +8 -2
- package/src/host/create-host.ts +47 -2
- package/src/host/locate.ts +6 -1
- package/src/host/outcome.ts +159 -0
- package/src/host/types.ts +33 -2
- package/src/index.ts +11 -1
- package/src/isolation/served.ts +24 -0
- package/src/isolation/types.ts +15 -1
- package/src/platform/types.ts +7 -0
- package/src/platform/web.ts +15 -2
- package/src/presets/presets.ts +27 -3
- package/src/react/frame.tsx +78 -14
- package/src/server/pages.ts +97 -11
- package/src/server/server.ts +48 -8
- package/src/shell/document.ts +7 -6
- package/src/shell/plan.ts +3 -0
- package/src/shell/runtime.ts +20 -4
package/src/server/pages.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { FRAME_STATUS_METHOD } from "../isolation/types.ts";
|
|
2
|
+
import type { FrameBlock } from "../host/types.ts";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
* The server's own pages on a content origin: why it shows nothing of the content
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
+
* The server's own pages on a content origin: why it shows nothing of the content, and the page a
|
|
6
|
+
* framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
|
|
7
|
+
* the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
|
|
8
|
+
* the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
|
|
9
|
+
* the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
const escape = (text: string): string =>
|
|
@@ -10,22 +15,103 @@ const escape = (text: string): string =>
|
|
|
10
15
|
const STYLE =
|
|
11
16
|
":root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;background:Canvas;color:CanvasText}main{max-width:420px;padding:24px}h1{font-size:17px;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}";
|
|
12
17
|
|
|
18
|
+
/** Where the pages' scripts are served, on every content origin. */
|
|
19
|
+
export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
|
|
20
|
+
export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
|
|
21
|
+
/** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
|
|
22
|
+
export const SESSION_PATH = "/__porthole/session";
|
|
23
|
+
|
|
24
|
+
const say = (): string =>
|
|
25
|
+
`const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
|
|
26
|
+
|
|
27
|
+
/** Tells the host the page is blocked, and why (from the page's data attributes). */
|
|
28
|
+
const STATUS_SCRIPT = `(() => {
|
|
29
|
+
const main = document.querySelector("[data-porthole-status]");
|
|
30
|
+
if (main === null) return;
|
|
31
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
32
|
+
${say()}
|
|
33
|
+
say({ state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) });
|
|
34
|
+
})();`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Enters a framed app: says so, checks that the browser kept the session cookie this page's own
|
|
38
|
+
* answer set (WebKit keeps none in a cross-site frame, P025), then goes on to the app, or says it
|
|
39
|
+
* is blocked and why.
|
|
40
|
+
*/
|
|
41
|
+
const ENTER_SCRIPT = `(async () => {
|
|
42
|
+
const main = document.querySelector("[data-porthole-enter]");
|
|
43
|
+
if (main === null) return;
|
|
44
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
45
|
+
${say()}
|
|
46
|
+
say({ state: "entering" });
|
|
47
|
+
let kept = false;
|
|
48
|
+
try {
|
|
49
|
+
kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
|
|
50
|
+
} catch (_) {}
|
|
51
|
+
if (kept) {
|
|
52
|
+
location.replace(main.dataset.portholeTarget || "/");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
say({ state: "blocked", reason: "cookies", status: 401 });
|
|
56
|
+
main.querySelector("h1").textContent = "Open this app in a new tab";
|
|
57
|
+
main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
|
|
58
|
+
})();`;
|
|
59
|
+
|
|
60
|
+
/** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
|
|
61
|
+
export function pageScript(pathname: string): Response | null {
|
|
62
|
+
const body =
|
|
63
|
+
pathname === STATUS_SCRIPT_PATH
|
|
64
|
+
? STATUS_SCRIPT
|
|
65
|
+
: pathname === ENTER_SCRIPT_PATH
|
|
66
|
+
? ENTER_SCRIPT
|
|
67
|
+
: null;
|
|
68
|
+
return body === null
|
|
69
|
+
? null
|
|
70
|
+
: new Response(body, {
|
|
71
|
+
headers: {
|
|
72
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
73
|
+
"cache-control": "no-store",
|
|
74
|
+
"x-content-type-options": "nosniff",
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const policy = (frameAncestors: string, connect: boolean): string =>
|
|
80
|
+
`default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
|
|
81
|
+
|
|
82
|
+
const pageHeaders = (frameAncestors: string, connect: boolean) => ({
|
|
83
|
+
"content-type": "text/html; charset=utf-8",
|
|
84
|
+
"cache-control": "no-store",
|
|
85
|
+
"content-security-policy": policy(frameAncestors, connect),
|
|
86
|
+
"referrer-policy": "no-referrer",
|
|
87
|
+
"x-content-type-options": "nosniff",
|
|
88
|
+
});
|
|
89
|
+
|
|
13
90
|
export function statusPage(
|
|
14
91
|
status: number,
|
|
15
92
|
title: string,
|
|
16
93
|
detail: string,
|
|
17
94
|
frameAncestors: string,
|
|
18
95
|
method = "GET",
|
|
96
|
+
reason: FrameBlock = "error",
|
|
97
|
+
hostOrigins: readonly string[] = [],
|
|
19
98
|
): Response {
|
|
20
|
-
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main></body></html>`;
|
|
99
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
|
|
21
100
|
return new Response(method === "HEAD" ? null : body, {
|
|
22
101
|
status,
|
|
23
|
-
headers:
|
|
24
|
-
"content-type": "text/html; charset=utf-8",
|
|
25
|
-
"cache-control": "no-store",
|
|
26
|
-
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
|
|
27
|
-
"referrer-policy": "no-referrer",
|
|
28
|
-
"x-content-type-options": "nosniff",
|
|
29
|
-
},
|
|
102
|
+
headers: pageHeaders(frameAncestors, false),
|
|
30
103
|
});
|
|
31
104
|
}
|
|
105
|
+
|
|
106
|
+
/** The page a framed app is entered through (its answer also sets the session cookie). */
|
|
107
|
+
export function enterPage(
|
|
108
|
+
target: string,
|
|
109
|
+
frameAncestors: string,
|
|
110
|
+
hostOrigins: readonly string[],
|
|
111
|
+
cookie: string,
|
|
112
|
+
): Response {
|
|
113
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Opening the app</title><style>${STYLE}</style></head><body><main data-porthole-enter="" data-porthole-target="${escape(target)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>Opening the app…</h1><p></p></main><script src="${ENTER_SCRIPT_PATH}"></script></body></html>`;
|
|
114
|
+
const headers = new Headers(pageHeaders(frameAncestors, true));
|
|
115
|
+
headers.set("set-cookie", cookie);
|
|
116
|
+
return new Response(body, { status: 200, headers });
|
|
117
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { compile, type CompiledConfig } from "../compile/compile.ts";
|
|
2
2
|
import type { DisplayMode, EgressConfig } from "../config/schema.ts";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
hostOriginProblem,
|
|
5
5
|
maybeSameSite,
|
|
6
6
|
originMatchesSource,
|
|
7
7
|
parseOrigin,
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
import type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
|
|
10
10
|
import { sandboxProxyDocument } from "../mcp/proxy.ts";
|
|
11
11
|
import { createKeys, newTicket, ticketId } from "./keys.ts";
|
|
12
|
-
import { statusPage } from "./pages.ts";
|
|
12
|
+
import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
|
|
13
|
+
import type { FrameBlock } from "../host/types.ts";
|
|
13
14
|
import { memoryTicketStore } from "./tickets.ts";
|
|
14
15
|
import type { Access, PortholeServer, ServerOptions, TicketRecord, TicketStore } from "./types.ts";
|
|
15
16
|
|
|
@@ -187,9 +188,14 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
187
188
|
throw new TypeError("hostOrigins must name the origins that embed content");
|
|
188
189
|
}
|
|
189
190
|
for (const origin of options.hostOrigins) {
|
|
190
|
-
const
|
|
191
|
+
const problem = hostOriginProblem(origin);
|
|
192
|
+
if (problem !== null) {
|
|
193
|
+
throw new TypeError(`hostOrigins: ${problem}`);
|
|
194
|
+
}
|
|
195
|
+
// A desktop app's own scheme (ferry://app) is never the same site as web content.
|
|
196
|
+
const parsed = parseOrigin(origin);
|
|
191
197
|
if (parsed === null) {
|
|
192
|
-
|
|
198
|
+
continue;
|
|
193
199
|
}
|
|
194
200
|
if (
|
|
195
201
|
parsed.host === domain ||
|
|
@@ -209,8 +215,15 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
209
215
|
}
|
|
210
216
|
const tickets: TicketStore = options.tickets ?? memoryTicketStore();
|
|
211
217
|
|
|
212
|
-
|
|
213
|
-
|
|
218
|
+
/** A page saying why there is nothing to show, told to the host too (its reason, P040). */
|
|
219
|
+
const page = (
|
|
220
|
+
status: number,
|
|
221
|
+
title: string,
|
|
222
|
+
detail: string,
|
|
223
|
+
method?: string,
|
|
224
|
+
reason: FrameBlock = status === 404 ? "not-found" : status === 502 ? "upstream" : "error",
|
|
225
|
+
): Response =>
|
|
226
|
+
statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins);
|
|
214
227
|
|
|
215
228
|
/** The config a served document is served under, checked to fit it and this server. */
|
|
216
229
|
function configFor(name: string, kind: keyof typeof MODES): CompiledConfig {
|
|
@@ -428,6 +441,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
428
441
|
"This link has been used",
|
|
429
442
|
"Content here opens once per link. Open it again from the app that showed it.",
|
|
430
443
|
method,
|
|
444
|
+
"session",
|
|
431
445
|
);
|
|
432
446
|
}
|
|
433
447
|
const served = await documentOf(access.instance);
|
|
@@ -452,13 +466,27 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
452
466
|
403,
|
|
453
467
|
"This link has been used",
|
|
454
468
|
"A link into an app works once, for a short time. Open it again from the app that showed it.",
|
|
469
|
+
method,
|
|
470
|
+
"session",
|
|
455
471
|
);
|
|
456
472
|
}
|
|
457
473
|
const target = new URL(record.instance.document.url);
|
|
474
|
+
const landing = `${target.pathname}${target.search}${target.hash}`;
|
|
475
|
+
// In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
|
|
476
|
+
// the host), where a redirect would leave a frame without one showing a refusal. On a tab of
|
|
477
|
+
// its own, a redirect.
|
|
478
|
+
if (request.headers.get("sec-fetch-dest") !== "document") {
|
|
479
|
+
return enterPage(
|
|
480
|
+
landing,
|
|
481
|
+
frameAncestors,
|
|
482
|
+
options.hostOrigins,
|
|
483
|
+
sessionCookie(request, record),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
458
486
|
return new Response(null, {
|
|
459
487
|
status: 303,
|
|
460
488
|
headers: {
|
|
461
|
-
location:
|
|
489
|
+
location: landing,
|
|
462
490
|
"set-cookie": sessionCookie(request, record),
|
|
463
491
|
"cache-control": "no-store",
|
|
464
492
|
"referrer-policy": "no-referrer",
|
|
@@ -508,6 +536,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
508
536
|
"Open this from the app",
|
|
509
537
|
"This address shows an app only to the people the app opens it for.",
|
|
510
538
|
method,
|
|
539
|
+
"session",
|
|
511
540
|
);
|
|
512
541
|
}
|
|
513
542
|
if (options.upstream === undefined) {
|
|
@@ -579,7 +608,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
579
608
|
clean.document.kind === "external"
|
|
580
609
|
? `${ENTER_PATH}?ticket=${ticket}`
|
|
581
610
|
: `${PREFIX}d/${key}?ticket=${ticket}`;
|
|
582
|
-
return { url: `${origin}${path}`, origin };
|
|
611
|
+
return { url: `${origin}${path}`, origin, reports: true };
|
|
583
612
|
},
|
|
584
613
|
|
|
585
614
|
async access(request) {
|
|
@@ -601,6 +630,17 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
601
630
|
if (url.pathname === ENTER_PATH) {
|
|
602
631
|
return await enter(request, url, label);
|
|
603
632
|
}
|
|
633
|
+
const script = pageScript(url.pathname);
|
|
634
|
+
if (script !== null) {
|
|
635
|
+
return script;
|
|
636
|
+
}
|
|
637
|
+
if (url.pathname === SESSION_PATH) {
|
|
638
|
+
// Whether this request's cookie opens its origin's app: the entry page asks.
|
|
639
|
+
return new Response(null, {
|
|
640
|
+
status: accessOf(request, label, null) === null ? 401 : 204,
|
|
641
|
+
headers: { "cache-control": "no-store" },
|
|
642
|
+
});
|
|
643
|
+
}
|
|
604
644
|
const document = DOCUMENT_PATH.exec(url.pathname);
|
|
605
645
|
if (document?.[1] !== undefined) {
|
|
606
646
|
return await serveDocument(request, url, label, document[1]);
|
package/src/shell/document.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { hostOriginProblem } from "../isolation/served.ts";
|
|
1
2
|
import { SHELL_RUNTIME } from "./runtime.ts";
|
|
2
3
|
import type { ShellPlan } from "./plan.ts";
|
|
3
4
|
|
|
4
5
|
export interface ShellDocumentOptions {
|
|
5
6
|
/**
|
|
6
7
|
* The origins the shell takes messages from: the embedding page's own (`location.origin`), or
|
|
7
|
-
* every host origin a served document may be embedded by. Exact origins
|
|
8
|
-
* sender must also be the
|
|
8
|
+
* every host origin a served document may be embedded by. Exact origins of any scheme (a
|
|
9
|
+
* desktop app's own, `ferry://app`), no wildcards, never "null". The sender must also be the
|
|
10
|
+
* frame's parent.
|
|
9
11
|
*/
|
|
10
12
|
readonly origins: readonly string[];
|
|
11
13
|
/** The frame document's title (for assistive technology inside the frame). */
|
|
@@ -24,8 +26,6 @@ export interface ShellDocumentOptions {
|
|
|
24
26
|
readonly cspMeta?: boolean;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
const EXACT_ORIGIN = /^(https?:\/\/[a-z0-9.-]+(:\d{1,5})?|https?:\/\/\[::1\](:\d{1,5})?)$/u;
|
|
28
|
-
|
|
29
29
|
const escapeAttribute = (value: string): string =>
|
|
30
30
|
value.replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<");
|
|
31
31
|
|
|
@@ -80,8 +80,9 @@ body {
|
|
|
80
80
|
*/
|
|
81
81
|
export function shellDocument(plan: ShellPlan, csp: string, options: ShellDocumentOptions): string {
|
|
82
82
|
for (const origin of options.origins) {
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const problem = hostOriginProblem(origin);
|
|
84
|
+
if (problem !== null) {
|
|
85
|
+
throw new TypeError(`shellDocument: ${problem}`);
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
const runtime = SHELL_RUNTIME.replace("__PLAN__", () =>
|
package/src/shell/plan.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface ShellPlan {
|
|
|
25
25
|
readonly themeVariables: readonly string[];
|
|
26
26
|
/** The window event fired after a theme change. */
|
|
27
27
|
readonly themeEvent: string;
|
|
28
|
+
/** Whether a whole document takes the person's scheme: when it declares it, or always. */
|
|
29
|
+
readonly themeDocuments: "declared" | "always";
|
|
28
30
|
/** `memory`: an opaque origin has no storage, so a map stands in for the frame's life. */
|
|
29
31
|
readonly storage: "memory" | "native";
|
|
30
32
|
readonly maxErrorReports: number;
|
|
@@ -58,6 +60,7 @@ export function shellPlan(config: PortholeConfig): ShellPlan | null {
|
|
|
58
60
|
globals,
|
|
59
61
|
themeVariables: Object.keys(config.theme.variables),
|
|
60
62
|
themeEvent: config.theme.event,
|
|
63
|
+
themeDocuments: config.theme.documents ?? "declared",
|
|
61
64
|
storage: config.isolation === "opaque" ? "memory" : "native",
|
|
62
65
|
maxErrorReports: config.limits.maxErrorReports,
|
|
63
66
|
maxPromptChars: config.limits.maxPromptChars,
|
package/src/shell/runtime.ts
CHANGED
|
@@ -487,6 +487,21 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
487
487
|
window.dispatchEvent(new Event("load"));
|
|
488
488
|
};
|
|
489
489
|
|
|
490
|
+
// A fragment takes the person's scheme; a whole document only if it declares that it supports
|
|
491
|
+
// both (or the config says always), else it shows as its author styled it (P039).
|
|
492
|
+
let lastContext = null;
|
|
493
|
+
let themed = plan.content !== "document";
|
|
494
|
+
const schemeFollows = () => {
|
|
495
|
+
const root = document.documentElement;
|
|
496
|
+
const theme = lastContext && lastContext.theme;
|
|
497
|
+
if (themed && (theme === "light" || theme === "dark")) {
|
|
498
|
+
root.style.colorScheme = theme;
|
|
499
|
+
root.setAttribute("data-theme", theme);
|
|
500
|
+
} else {
|
|
501
|
+
root.style.removeProperty("color-scheme");
|
|
502
|
+
root.removeAttribute("data-theme");
|
|
503
|
+
}
|
|
504
|
+
};
|
|
490
505
|
// A whole document, parsed inert (no scripts run, no declarative shadow roots built), guarded,
|
|
491
506
|
// imported in place of the shell's, then its scripts run in order. The theme on the root stays.
|
|
492
507
|
const renderDocument = async (html) => {
|
|
@@ -502,6 +517,9 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
502
517
|
document.head.replaceChildren(...Array.from(parsed.head.childNodes, (node) => document.importNode(node, true)));
|
|
503
518
|
document.body.replaceWith(document.importNode(parsed.body, true));
|
|
504
519
|
root = null;
|
|
520
|
+
const declaredScheme = parsed.querySelector('meta[name="color-scheme" i]');
|
|
521
|
+
themed = plan.themeDocuments === "always" || (declaredScheme !== null && /\bdark\b/i.test(declaredScheme.getAttribute("content") || ""));
|
|
522
|
+
schemeFollows();
|
|
505
523
|
await runScripts(scripts);
|
|
506
524
|
announceLoaded();
|
|
507
525
|
};
|
|
@@ -554,10 +572,8 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
554
572
|
}
|
|
555
573
|
}
|
|
556
574
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
document.documentElement.setAttribute("data-theme", context.theme);
|
|
560
|
-
}
|
|
575
|
+
lastContext = context;
|
|
576
|
+
schemeFollows();
|
|
561
577
|
window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
|
|
562
578
|
};
|
|
563
579
|
|