@blastlauncher/web 0.0.0-stage → 3.0.0-alpha.2
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/LICENSE +7 -0
- package/README.md +85 -2
- package/assets/almostnode.mjs +735 -0
- package/assets/blast-sources.json +1 -0
- package/assets/blast-web.js +35 -0
- package/assets/esbuild.mjs +2695 -0
- package/assets/esbuild.wasm +0 -0
- package/dist/browser-bundle.d.ts +54 -0
- package/dist/browser-bundle.js +441 -0
- package/dist/browser-install.d.ts +66 -0
- package/dist/browser-install.js +326 -0
- package/dist/bundle-config.d.ts +10 -0
- package/dist/bundle-config.js +31 -0
- package/dist/bundles.d.ts +29 -0
- package/dist/bundles.js +126 -0
- package/dist/capabilities.d.ts +66 -0
- package/dist/capabilities.js +288 -0
- package/dist/custom-element.d.ts +6 -0
- package/dist/custom-element.js +36 -0
- package/dist/default-renderer.d.ts +27 -0
- package/dist/default-renderer.js +145 -0
- package/dist/default-styles.d.ts +2 -0
- package/dist/default-styles.js +28 -0
- package/dist/extension-files.d.ts +21 -0
- package/dist/extension-files.js +86 -0
- package/dist/handlers.d.ts +18 -0
- package/dist/handlers.js +22 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/launch.d.ts +11 -0
- package/dist/launch.js +29 -0
- package/dist/prebuilt.d.ts +24 -0
- package/dist/prebuilt.js +112 -0
- package/dist/render.d.ts +62 -0
- package/dist/render.js +288 -0
- package/dist/runtime-port.d.ts +5 -0
- package/dist/runtime-port.js +8 -0
- package/dist/sandbox.d.ts +25 -0
- package/dist/sandbox.js +71 -0
- package/dist/session.d.ts +77 -0
- package/dist/session.js +520 -0
- package/dist/source.d.ts +16 -0
- package/dist/source.js +42 -0
- package/package.json +33 -4
- package/sandbox/almostnode-full.mjs +19713 -0
- package/sandbox/index.html +1 -0
package/dist/handlers.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** localStorage, the Clipboard API (the browser asks the user) and a new tab for URLs. */
|
|
2
|
+
export function createDefaultHandlers() {
|
|
3
|
+
let storage;
|
|
4
|
+
try {
|
|
5
|
+
storage = globalThis.localStorage;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
// Blocked storage (private windows, sandboxed frames): capabilities fall back to memory.
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
...(storage ? { storage } : {}),
|
|
12
|
+
clipboard: {
|
|
13
|
+
readText: () => navigator.clipboard.readText(),
|
|
14
|
+
writeText: (text) => navigator.clipboard.writeText(text),
|
|
15
|
+
},
|
|
16
|
+
openUrl: (url) => {
|
|
17
|
+
// Only web URLs: an extension must not open javascript: or file: links.
|
|
18
|
+
if (/^https?:\/\//i.test(url))
|
|
19
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type WebMount, type WebMountOptions } from "./session.js";
|
|
2
|
+
export * from "./browser-install.js";
|
|
3
|
+
export * from "./browser-bundle.js";
|
|
4
|
+
export { bundleKey, bundleMatches, parseBundleIndex, sha256Hex } from "./bundles.js";
|
|
5
|
+
export { createDefaultHandlers } from "./handlers.js";
|
|
6
|
+
export { defineBlastExtension } from "./custom-element.js";
|
|
7
|
+
export { prewarm } from "./sandbox.js";
|
|
8
|
+
export type { WebExit, WebFrame, WebHandlers, WebMount, WebMountOptions, WebPreferenceRequest, WebStatus, WebView, } from "./session.js";
|
|
9
|
+
/**
|
|
10
|
+
* Runs a Raycast extension inside `element` (or hands its views to
|
|
11
|
+
* `options.render`). The extension executes in the cross-origin sandbox at
|
|
12
|
+
* `options.sandboxUrl`; it never touches the embedding page.
|
|
13
|
+
*/
|
|
14
|
+
export declare function mount(element: HTMLElement, options: WebMountOptions): WebMount;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { startSession } from "./session.js";
|
|
2
|
+
export * from "./browser-install.js";
|
|
3
|
+
export * from "./browser-bundle.js";
|
|
4
|
+
export { bundleKey, bundleMatches, parseBundleIndex, sha256Hex } from "./bundles.js";
|
|
5
|
+
export { createDefaultHandlers } from "./handlers.js";
|
|
6
|
+
export { defineBlastExtension } from "./custom-element.js";
|
|
7
|
+
export { prewarm } from "./sandbox.js";
|
|
8
|
+
/**
|
|
9
|
+
* Runs a Raycast extension inside `element` (or hands its views to
|
|
10
|
+
* `options.render`). The extension executes in the cross-origin sandbox at
|
|
11
|
+
* `options.sandboxUrl`; it never touches the embedding page.
|
|
12
|
+
*/
|
|
13
|
+
export function mount(element, options) {
|
|
14
|
+
if (!element)
|
|
15
|
+
throw new TypeError("mount requires an element");
|
|
16
|
+
return startSession(element, options);
|
|
17
|
+
}
|
package/dist/launch.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class BrowserLaunchTimeoutError extends Error {
|
|
2
|
+
readonly code = "BROWSER_LAUNCH_TIMEOUT";
|
|
3
|
+
readonly stage: string;
|
|
4
|
+
constructor(timeoutMs: number, stage: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function withLaunchTimeout<T>(promise: Promise<T>, timeoutMs: number, stage?: string): Promise<T>;
|
|
7
|
+
export declare function createLaunchGeneration(): {
|
|
8
|
+
next: () => number;
|
|
9
|
+
current: () => number;
|
|
10
|
+
isCurrent: (token: number) => boolean;
|
|
11
|
+
};
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class BrowserLaunchTimeoutError extends Error {
|
|
2
|
+
code = "BROWSER_LAUNCH_TIMEOUT";
|
|
3
|
+
stage;
|
|
4
|
+
constructor(timeoutMs, stage) {
|
|
5
|
+
super(`Command did not start (${stage}) after ${timeoutMs} ms`);
|
|
6
|
+
this.name = "BrowserLaunchTimeoutError";
|
|
7
|
+
this.stage = stage;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function withLaunchTimeout(promise, timeoutMs, stage = "waiting for runtime port") {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const timer = setTimeout(() => reject(new BrowserLaunchTimeoutError(timeoutMs, stage)), timeoutMs);
|
|
13
|
+
promise.then((value) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
resolve(value);
|
|
16
|
+
}, (error) => {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
reject(error);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function createLaunchGeneration() {
|
|
23
|
+
let generation = 0;
|
|
24
|
+
return {
|
|
25
|
+
next: () => ++generation,
|
|
26
|
+
current: () => generation,
|
|
27
|
+
isCurrent: (token) => token === generation,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BrowserBundleFile } from "./browser-bundle.js";
|
|
2
|
+
import { type BundleIndex, type CatalogManifest } from "./bundles.js";
|
|
3
|
+
/** Where a deployment serves its prebuilt bundles (see `WebMountOptions.bundlesUrl`). */
|
|
4
|
+
export type BundleLocation = {
|
|
5
|
+
bundlesUrl: string;
|
|
6
|
+
buildUrl?: string | undefined;
|
|
7
|
+
};
|
|
8
|
+
export type PrebuiltCommand = {
|
|
9
|
+
code: string;
|
|
10
|
+
files: Map<string, BrowserBundleFile>;
|
|
11
|
+
manifest: CatalogManifest;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The validated index, fetched once per location. A `buildUrl` whose `sha`
|
|
15
|
+
* differs from the index's `buildSha` means the index came from another
|
|
16
|
+
* deploy (a stale cache), so it is ignored.
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadBundleIndex(location: BundleLocation): Promise<BundleIndex | undefined>;
|
|
19
|
+
/** The shared runtime the prebuilt bundles import from, when the index has one. */
|
|
20
|
+
export declare function loadPrebuiltRuntime(location: BundleLocation): Promise<string | undefined>;
|
|
21
|
+
/** Bundles are keyed by Store name, so only `raycast/extensions` sources have one. */
|
|
22
|
+
export declare function storeExtensionName(source: string): string | undefined;
|
|
23
|
+
/** A verified prebuilt command, or undefined to fall back to fetching and bundling sources. */
|
|
24
|
+
export declare function loadPrebuiltCommand(location: BundleLocation, source: string, command: string): Promise<PrebuiltCommand | undefined>;
|
package/dist/prebuilt.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { bundleKey, bundleMatches, parseBundleIndex, sha256Hex, } from "./bundles.js";
|
|
2
|
+
import { resolveExtensionSource } from "./extension-files.js";
|
|
3
|
+
import { isTextExtension, parseExtensionSource } from "./source.js";
|
|
4
|
+
/** Relative URLs resolve against the page; tests pass absolute ones. */
|
|
5
|
+
const page = () => globalThis.location?.href;
|
|
6
|
+
const indexes = new Map();
|
|
7
|
+
const runtimes = new Map();
|
|
8
|
+
const locationKey = (location) => `${location.bundlesUrl}\n${location.buildUrl ?? ""}`;
|
|
9
|
+
const urlFor = (path, location) => new URL(path, new URL(location.bundlesUrl, page()));
|
|
10
|
+
/** Fetches a file the index lists; undefined unless its size and digest match. */
|
|
11
|
+
async function verified(entry, location) {
|
|
12
|
+
const response = await fetch(urlFor(entry.path, location));
|
|
13
|
+
if (!response.ok)
|
|
14
|
+
return undefined;
|
|
15
|
+
const bytes = await response.arrayBuffer();
|
|
16
|
+
if (bytes.byteLength !== entry.bytes || (await sha256Hex(bytes)) !== entry.sha256)
|
|
17
|
+
return undefined;
|
|
18
|
+
return bytes;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The validated index, fetched once per location. A `buildUrl` whose `sha`
|
|
22
|
+
* differs from the index's `buildSha` means the index came from another
|
|
23
|
+
* deploy (a stale cache), so it is ignored.
|
|
24
|
+
*/
|
|
25
|
+
export function loadBundleIndex(location) {
|
|
26
|
+
const key = locationKey(location);
|
|
27
|
+
let pending = indexes.get(key);
|
|
28
|
+
if (!pending) {
|
|
29
|
+
pending = (async () => {
|
|
30
|
+
const [indexResponse, buildResponse] = await Promise.all([
|
|
31
|
+
fetch(new URL(location.bundlesUrl, page())),
|
|
32
|
+
location.buildUrl ? fetch(new URL(location.buildUrl, page())) : null,
|
|
33
|
+
]);
|
|
34
|
+
if (!indexResponse.ok || (buildResponse && !buildResponse.ok))
|
|
35
|
+
return undefined;
|
|
36
|
+
const index = parseBundleIndex(await indexResponse.json());
|
|
37
|
+
if (buildResponse) {
|
|
38
|
+
const build = (await buildResponse.json());
|
|
39
|
+
if (build.sha !== index.buildSha)
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return index;
|
|
43
|
+
})().catch(() => undefined);
|
|
44
|
+
// Only a usable index is kept; a failed fetch is retried by the next launch.
|
|
45
|
+
void pending.then((index) => index ?? indexes.delete(key));
|
|
46
|
+
indexes.set(key, pending);
|
|
47
|
+
}
|
|
48
|
+
return pending;
|
|
49
|
+
}
|
|
50
|
+
/** The shared runtime the prebuilt bundles import from, when the index has one. */
|
|
51
|
+
export function loadPrebuiltRuntime(location) {
|
|
52
|
+
const key = locationKey(location);
|
|
53
|
+
let pending = runtimes.get(key);
|
|
54
|
+
if (!pending) {
|
|
55
|
+
pending = (async () => {
|
|
56
|
+
const index = await loadBundleIndex(location);
|
|
57
|
+
const bytes = index?.runtime ? await verified(index.runtime, location) : undefined;
|
|
58
|
+
return bytes ? new TextDecoder().decode(bytes) : undefined;
|
|
59
|
+
})().catch(() => undefined);
|
|
60
|
+
void pending.then((code) => code ?? runtimes.delete(key));
|
|
61
|
+
runtimes.set(key, pending);
|
|
62
|
+
}
|
|
63
|
+
return pending;
|
|
64
|
+
}
|
|
65
|
+
/** Bundles are keyed by Store name, so only `raycast/extensions` sources have one. */
|
|
66
|
+
export function storeExtensionName(source) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = parseExtensionSource(resolveExtensionSource(source));
|
|
69
|
+
const [folder, name, ...rest] = parsed.path.split("/");
|
|
70
|
+
return parsed.owner === "raycast" &&
|
|
71
|
+
parsed.repo === "extensions" &&
|
|
72
|
+
folder === "extensions" &&
|
|
73
|
+
name &&
|
|
74
|
+
rest.length === 0
|
|
75
|
+
? name
|
|
76
|
+
: undefined;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** A verified prebuilt command, or undefined to fall back to fetching and bundling sources. */
|
|
83
|
+
export async function loadPrebuiltCommand(location, source, command) {
|
|
84
|
+
try {
|
|
85
|
+
const extension = storeExtensionName(source);
|
|
86
|
+
const index = extension ? await loadBundleIndex(location) : undefined;
|
|
87
|
+
if (!extension || !index)
|
|
88
|
+
return undefined;
|
|
89
|
+
const key = bundleKey(extension, command);
|
|
90
|
+
const entry = index.bundles[key];
|
|
91
|
+
// An entry without manifest data predates 0133; the source path has the full package.json.
|
|
92
|
+
if (!entry?.manifest)
|
|
93
|
+
return undefined;
|
|
94
|
+
const response = await fetch(urlFor(entry.path, location));
|
|
95
|
+
if (!response.ok)
|
|
96
|
+
return undefined;
|
|
97
|
+
const bytes = await response.arrayBuffer();
|
|
98
|
+
if (!(await bundleMatches(index, index.buildSha, key, bytes)))
|
|
99
|
+
return undefined;
|
|
100
|
+
const files = new Map();
|
|
101
|
+
for (const [name, file] of Object.entries(entry.files ?? {})) {
|
|
102
|
+
const fileBytes = await verified(file, location);
|
|
103
|
+
if (!fileBytes)
|
|
104
|
+
return undefined;
|
|
105
|
+
files.set(`/${name}`, isTextExtension(name) ? new TextDecoder().decode(fileBytes) : new Uint8Array(fileBytes));
|
|
106
|
+
}
|
|
107
|
+
return { code: new TextDecoder().decode(bytes), files, manifest: entry.manifest };
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** @param {unknown} value */
|
|
2
|
+
export declare function escapeHtml(value: unknown): string;
|
|
3
|
+
/**
|
|
4
|
+
* A shortcut keycap. A combo the browser keeps for itself is drawn muted with
|
|
5
|
+
* the hint as its tooltip, so it never looks live when it is not.
|
|
6
|
+
* @param {"small" | "kbd"} tag @param {string} label @param {(label: string) => string} [hint]
|
|
7
|
+
*/
|
|
8
|
+
export declare const keycap: (tag: "small" | "kbd", label: string, hint?: (label: string) => string) => string;
|
|
9
|
+
/** Render the deliberately small, safe markdown subset used by Detail. @param {unknown} markdown */
|
|
10
|
+
export declare function renderMarkdown(markdown: unknown): string;
|
|
11
|
+
export type FooterStatus = {
|
|
12
|
+
text: string;
|
|
13
|
+
error?: boolean;
|
|
14
|
+
progress?: boolean;
|
|
15
|
+
stage?: string;
|
|
16
|
+
};
|
|
17
|
+
export type ExtensionContext = {
|
|
18
|
+
extensionTitle: string;
|
|
19
|
+
commandTitle: string;
|
|
20
|
+
icon?: string;
|
|
21
|
+
iconUrl?: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Toast, HUD and alert: the feedback a command shows over whatever it draws.
|
|
25
|
+
* @param {import("@blastlauncher/client-core").ControllerView} view
|
|
26
|
+
* @param {{reservedHint?: (label: string) => string}} [options]
|
|
27
|
+
*/
|
|
28
|
+
export declare function feedbackMarkup(view: import("@blastlauncher/client-core").ControllerView, options?: {
|
|
29
|
+
reservedHint?: (label: string) => string;
|
|
30
|
+
}): string;
|
|
31
|
+
/**
|
|
32
|
+
* `actionsKey` labels the Actions footer (`⌘K`, or `Ctrl+K` off macOS);
|
|
33
|
+
* `reservedHint` returns a tooltip for a shortcut label the browser reserves.
|
|
34
|
+
* @param {import("@blastlauncher/client-core").ControllerView} view
|
|
35
|
+
* @param {{status?: FooterStatus, context?: ExtensionContext, actionsKey?: string, reservedHint?: (label: string) => string}} [options]
|
|
36
|
+
*/
|
|
37
|
+
export declare function render(view: import("@blastlauncher/client-core").ControllerView, options?: {
|
|
38
|
+
status?: FooterStatus;
|
|
39
|
+
context?: ExtensionContext;
|
|
40
|
+
actionsKey?: string;
|
|
41
|
+
reservedHint?: (label: string) => string;
|
|
42
|
+
}): string;
|
|
43
|
+
/** @param {{title: string, subtitle: string, icon: string, iconUrl?: string, accessory: string}[]} rows @param {number} selected @param {string} query @param {{open?: boolean, query?: string, actions?: {title: string, shortcut?: string}[], selected?: number, toast?: string, status?: FooterStatus}} [panel] */
|
|
44
|
+
export declare function renderRoot(rows: {
|
|
45
|
+
title: string;
|
|
46
|
+
subtitle: string;
|
|
47
|
+
icon: string;
|
|
48
|
+
iconUrl?: string;
|
|
49
|
+
accessory: string;
|
|
50
|
+
}[], selected: number, query: string, panel?: {
|
|
51
|
+
open?: boolean;
|
|
52
|
+
query?: string;
|
|
53
|
+
actions?: {
|
|
54
|
+
title: string;
|
|
55
|
+
shortcut?: string;
|
|
56
|
+
}[];
|
|
57
|
+
selected?: number;
|
|
58
|
+
toast?: string;
|
|
59
|
+
status?: FooterStatus;
|
|
60
|
+
}): string;
|
|
61
|
+
/** @param {import("@blastlauncher/protocol/preference-fields").Preference[]} fields @param {Record<string, unknown>} values @param {string} title @param {string[]} unavailable */
|
|
62
|
+
export declare function renderPreferences(fields: import("@blastlauncher/protocol/preference-fields").Preference[], values: Record<string, unknown>, title: string, unavailable?: string[]): string;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @param {unknown} value */
|
|
3
|
+
export function escapeHtml(value) {
|
|
4
|
+
return String(value ?? "")
|
|
5
|
+
.replaceAll("&", "&")
|
|
6
|
+
.replaceAll("<", "<")
|
|
7
|
+
.replaceAll(">", ">")
|
|
8
|
+
.replaceAll('"', """)
|
|
9
|
+
.replaceAll("'", "'");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A footer hint is a real button: clicking it does what its key does. The
|
|
13
|
+
* label is visible text and the key renders as a keycap.
|
|
14
|
+
* @param {string} label
|
|
15
|
+
* @param {string} key
|
|
16
|
+
* @param {"primary" | "actions" | "back"} action
|
|
17
|
+
*/
|
|
18
|
+
const footerButton = (label, key, action) => `<button type="button" class="footer-key" data-footer="${action}"><span>${escapeHtml(label)}</span><kbd>${escapeHtml(key)}</kbd></button>`;
|
|
19
|
+
/**
|
|
20
|
+
* A shortcut keycap. A combo the browser keeps for itself is drawn muted with
|
|
21
|
+
* the hint as its tooltip, so it never looks live when it is not.
|
|
22
|
+
* @param {"small" | "kbd"} tag @param {string} label @param {(label: string) => string} [hint]
|
|
23
|
+
*/
|
|
24
|
+
export const keycap = (tag, label, hint) => {
|
|
25
|
+
if (!label)
|
|
26
|
+
return "";
|
|
27
|
+
const reserved = hint?.(label);
|
|
28
|
+
return reserved
|
|
29
|
+
? `<${tag} class="reserved" title="${escapeHtml(reserved)}" aria-label="${escapeHtml(`${label}, ${reserved}`)}">${escapeHtml(label)}</${tag}>`
|
|
30
|
+
: `<${tag}>${escapeHtml(label)}</${tag}>`;
|
|
31
|
+
};
|
|
32
|
+
/** @param {string} label @param {string} action @param {boolean} [selected] */
|
|
33
|
+
const button = (label, action, selected = false) => `<button data-input="${escapeHtml(action)}"${selected ? ' class="selected"' : ""}>${escapeHtml(label)}</button>`;
|
|
34
|
+
/**
|
|
35
|
+
* Links and images only carry https URLs; images may also inline `data:image`.
|
|
36
|
+
* Anything else (javascript:, http:, relative) keeps just its visible text.
|
|
37
|
+
* @param {string} raw @param {boolean} image
|
|
38
|
+
*/
|
|
39
|
+
const safeUrl = (raw, image) => {
|
|
40
|
+
if (image && /^data:image\/[a-z0-9.+-]+[;,]\S*$/iu.test(raw))
|
|
41
|
+
return raw;
|
|
42
|
+
try {
|
|
43
|
+
return new URL(raw).protocol === "https:" ? raw : undefined;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/** Emphasis only ever sees escaped text segments, never generated tags or URLs. @param {string} text */
|
|
50
|
+
const emphasis = (text) => escapeHtml(text)
|
|
51
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
52
|
+
.replace(/(^|[^\w])__([^_]+)__(?!\w)/g, "$1<strong>$2</strong>")
|
|
53
|
+
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
|
54
|
+
.replace(/(^|[^\w])_([^_]+)_(?!\w)/g, "$1<em>$2</em>");
|
|
55
|
+
/** Tokenize code, links and images first so emphasis cannot rewrite inside them. @param {string} value */
|
|
56
|
+
const renderMarkdownInline = (value) => {
|
|
57
|
+
let out = "";
|
|
58
|
+
let last = 0;
|
|
59
|
+
for (const match of value.matchAll(/`([^`]+)`|(!?)\[([^\]]*)\]\(([^\s)]+)\)/gu)) {
|
|
60
|
+
out += emphasis(value.slice(last, match.index));
|
|
61
|
+
last = match.index + match[0].length;
|
|
62
|
+
const [, code, bang, label = "", url = ""] = match;
|
|
63
|
+
if (code !== undefined)
|
|
64
|
+
out += `<code>${escapeHtml(code)}</code>`;
|
|
65
|
+
else if (bang) {
|
|
66
|
+
const src = safeUrl(url, true);
|
|
67
|
+
out += src ? `<img src="${escapeHtml(src)}" alt="${escapeHtml(label)}">` : escapeHtml(label);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const href = safeUrl(url, false);
|
|
71
|
+
out += href
|
|
72
|
+
? `<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">${emphasis(label)}</a>`
|
|
73
|
+
: emphasis(label);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out + emphasis(value.slice(last));
|
|
77
|
+
};
|
|
78
|
+
/** Render the deliberately small, safe markdown subset used by Detail. @param {unknown} markdown */
|
|
79
|
+
export function renderMarkdown(markdown) {
|
|
80
|
+
const lines = String(markdown ?? "").split(/\r?\n/);
|
|
81
|
+
const result = [];
|
|
82
|
+
let list = false;
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const item = line.match(/^\s*[-*]\s+(.+)$/);
|
|
85
|
+
if (item) {
|
|
86
|
+
if (!list) {
|
|
87
|
+
result.push("<ul>");
|
|
88
|
+
list = true;
|
|
89
|
+
}
|
|
90
|
+
result.push(`<li>${renderMarkdownInline(item[1] ?? "")}</li>`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (list) {
|
|
94
|
+
result.push("</ul>");
|
|
95
|
+
list = false;
|
|
96
|
+
}
|
|
97
|
+
const heading = line.match(/^\s*(#{1,3})\s+(.+)$/);
|
|
98
|
+
if (heading) {
|
|
99
|
+
const level = heading[1]?.length ?? 1;
|
|
100
|
+
result.push(`<h${level}>${renderMarkdownInline(heading[2] ?? "")}</h${level}>`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!line.trim())
|
|
104
|
+
continue;
|
|
105
|
+
result.push(`<p>${renderMarkdownInline(line)}</p>`);
|
|
106
|
+
}
|
|
107
|
+
if (list)
|
|
108
|
+
result.push("</ul>");
|
|
109
|
+
return result.join("");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {{text: string, error?: boolean, progress?: boolean, stage?: string}} FooterStatus
|
|
113
|
+
* @typedef {{extensionTitle: string, commandTitle: string, icon?: string, iconUrl?: string}} ExtensionContext
|
|
114
|
+
*/
|
|
115
|
+
/** @param {FooterStatus} status */
|
|
116
|
+
function statusMarkup(status) {
|
|
117
|
+
return `<span id="status" role="status" data-error="${status.error ? "true" : "false"}"${status.stage ? ` data-stage="${escapeHtml(status.stage)}"` : ""}>${status.progress ? '<span class="status-spinner" aria-hidden="true"></span>' : ""}${escapeHtml(status.text)}</span>`;
|
|
118
|
+
}
|
|
119
|
+
/** @param {ExtensionContext | undefined} context */
|
|
120
|
+
function breadcrumbMarkup(context) {
|
|
121
|
+
if (!context)
|
|
122
|
+
return "";
|
|
123
|
+
const icon = context.iconUrl
|
|
124
|
+
? `<img src="${escapeHtml(context.iconUrl)}" alt="" width="20" height="20" onerror="this.hidden=true">`
|
|
125
|
+
: escapeHtml(context.icon ?? "⌘");
|
|
126
|
+
return `<div class="breadcrumb"><span class="breadcrumb-icon" aria-hidden="true">${icon}</span><span>${escapeHtml(context.extensionTitle)}</span><span class="breadcrumb-separator">›</span><span>${escapeHtml(context.commandTitle)}</span></div>`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The footer names where the user is, as Raycast does: the extension's icon and
|
|
130
|
+
* the view's navigation title. It repeats the h1 and breadcrumb, so it is hidden
|
|
131
|
+
* from assistive tech.
|
|
132
|
+
* @param {ExtensionContext | undefined} context @param {string} title
|
|
133
|
+
*/
|
|
134
|
+
function footerContextMarkup(context, title) {
|
|
135
|
+
if (!context)
|
|
136
|
+
return "";
|
|
137
|
+
const icon = context.iconUrl
|
|
138
|
+
? `<img src="${escapeHtml(context.iconUrl)}" alt="" width="20" height="20" onerror="this.hidden=true">`
|
|
139
|
+
: escapeHtml(context.icon ?? "⌘");
|
|
140
|
+
return `<span class="footer-context" aria-hidden="true"><span class="footer-icon">${icon}</span><span class="footer-title">${escapeHtml(title || context.commandTitle)}</span></span>`;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Toast, HUD and alert: the feedback a command shows over whatever it draws.
|
|
144
|
+
* @param {import("@blastlauncher/client-core").ControllerView} view
|
|
145
|
+
* @param {{reservedHint?: (label: string) => string}} [options]
|
|
146
|
+
*/
|
|
147
|
+
export function feedbackMarkup(view, options = {}) {
|
|
148
|
+
const toast = view.feedback.toast
|
|
149
|
+
? `<aside class="toast" data-style="${escapeHtml(view.feedback.toast.style)}"><strong>${escapeHtml(view.feedback.toast.title)}</strong> ${escapeHtml(view.feedback.toast.message)}${view.feedback.toast.actions.map((action, index) => `<button type="button" data-toast-action="${index}">${escapeHtml(action.title)} ${keycap("small", action.shortcut, options.reservedHint)}</button>`).join("")}</aside>`
|
|
150
|
+
: "";
|
|
151
|
+
const hud = view.feedback.hud
|
|
152
|
+
? `<aside class="hud" role="status">${escapeHtml(view.feedback.hud.title)}</aside>`
|
|
153
|
+
: "";
|
|
154
|
+
const alert = view.feedback.alert
|
|
155
|
+
? `<section class="alert-backdrop"><dialog open class="alert" role="alertdialog" aria-modal="true" aria-labelledby="alert-title"><h2 id="alert-title">${escapeHtml(view.feedback.alert.title)}</h2><p>${escapeHtml(view.feedback.alert.message)}</p>${view.feedback.alert.rememberUserChoice ? `<label><input id="alert-remember" type="checkbox"${view.feedback.alert.remember ? " checked" : ""}> Remember my choice</label>` : ""}<footer>${view.feedback.alert.dismiss ? `<button type="button" data-alert="dismiss">${escapeHtml(view.feedback.alert.dismiss.title)}</button>` : ""}${view.feedback.alert.primary ? `<button type="button" data-alert="confirm">${escapeHtml(view.feedback.alert.primary.title)}</button>` : ""}</footer></dialog></section>`
|
|
156
|
+
: "";
|
|
157
|
+
return `${toast}${hud}${alert}`;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* `actionsKey` labels the Actions footer (`⌘K`, or `Ctrl+K` off macOS);
|
|
161
|
+
* `reservedHint` returns a tooltip for a shortcut label the browser reserves.
|
|
162
|
+
* @param {import("@blastlauncher/client-core").ControllerView} view
|
|
163
|
+
* @param {{status?: FooterStatus, context?: ExtensionContext, actionsKey?: string, reservedHint?: (label: string) => string}} [options]
|
|
164
|
+
*/
|
|
165
|
+
export function render(view, options = {}) {
|
|
166
|
+
const status = options.status ?? { text: "Ready" };
|
|
167
|
+
const search = `<input id="search" value="${escapeHtml(view.search.text)}" placeholder="${escapeHtml(view.search.placeholder)}" aria-label="Search">${view.dropdown ? `<button type="button" class="dropdown-trigger" data-dropdown-open aria-label="${escapeHtml(view.dropdown.tooltip || view.dropdown.label)}">${escapeHtml(view.dropdown.label || "Filter")}⌄</button>` : ""}`;
|
|
168
|
+
const rows = view.rows
|
|
169
|
+
.map((row) => row.kind === "section"
|
|
170
|
+
? `<li class="section-header" data-id="${escapeHtml(row.id)}"><strong>${escapeHtml(row.title)}</strong>${row.count === null ? "" : `<small>${row.count}</small>`}</li>`
|
|
171
|
+
: `<li${row.selected ? ' class="selected"' : ""} data-id="${escapeHtml(row.id)}"><span class="root-icon"${row.iconTint ? ` style="color:${escapeHtml(row.iconTint)}"` : ""}>${escapeHtml(row.iconLabel)}</span><span class="root-copy"><strong>${escapeHtml(row.title)}</strong>${row.subtitle ? `<small>${escapeHtml(row.subtitle)}</small>` : ""}</span>${row.accessories ? `<span class="root-accessory">${escapeHtml(row.accessories)}</span>` : ""}</li>`)
|
|
172
|
+
.join("");
|
|
173
|
+
const grid = (view.grid ?? [])
|
|
174
|
+
.map((row) => `${row.header ? `<li class="grid-header">${escapeHtml(row.header)}</li>` : ""}<li class="grid-row">${row.cells.map((cell) => `<button type="button" class="grid-cell${cell.selected ? " selected" : ""}" data-id="${escapeHtml(cell.id)}"><span class="grid-content">${escapeHtml(cell.content)}</span><strong>${escapeHtml(cell.title)}</strong><small>${escapeHtml(cell.subtitle)}</small></button>`).join("")}</li>`)
|
|
175
|
+
.join("");
|
|
176
|
+
const dropdown = view.dropdown?.open
|
|
177
|
+
? `<section class="dropdown-panel" role="dialog" aria-label="${escapeHtml(view.dropdown.label)}"><input id="dropdown-search" data-dropdown-search value="${escapeHtml(view.dropdown.query)}" aria-label="Filter options"><ul>${view.dropdown.options.map((option) => `<li><button type="button" data-dropdown-value="${escapeHtml(option.value)}"${option.selected ? ' class="selected"' : ""}>${escapeHtml(option.title)}${option.current ? " ✓" : ""}</button></li>`).join("")}</ul></section>`
|
|
178
|
+
: "";
|
|
179
|
+
const actions = view.actions.open
|
|
180
|
+
? `<section class="actions"><strong>${escapeHtml(view.actions.title)}</strong><div class="breadcrumb">${view.actions.breadcrumb.map(escapeHtml).join(" / ")}</div><input id="action-search" value="${escapeHtml(view.actions.query)}" placeholder="Search actions…" aria-label="Action search"><ul>${view.actions.rows.map((row, index) => `<li${row.selected ? ' class="selected"' : ""} data-action-index="${index}">${escapeHtml(row.title)} ${keycap("small", row.shortcut, options.reservedHint)}</li>`).join("")}</ul></section>`
|
|
181
|
+
: "";
|
|
182
|
+
const formView = view.form;
|
|
183
|
+
const form = formView
|
|
184
|
+
? `<form class="extension-form">${formView.items
|
|
185
|
+
.map((item) => {
|
|
186
|
+
if (item.kind === "separator")
|
|
187
|
+
return `<hr class="form-separator">`;
|
|
188
|
+
if (item.kind === "description")
|
|
189
|
+
return `<p class="form-description">${escapeHtml(item.title)}${item.text ? `: ${escapeHtml(item.text)}` : ""}</p>`;
|
|
190
|
+
const field = formView.fields.find((candidate) => candidate.id === item.id);
|
|
191
|
+
if (!field)
|
|
192
|
+
return "";
|
|
193
|
+
const label = escapeHtml(field.title || field.label || field.id);
|
|
194
|
+
const common = `id="field-${escapeHtml(field.id)}" data-field="${escapeHtml(field.id)}"`;
|
|
195
|
+
const error = field.error
|
|
196
|
+
? `<small class="form-error" role="alert">${escapeHtml(field.error)}</small>`
|
|
197
|
+
: "";
|
|
198
|
+
if (field.type === "Form.Checkbox")
|
|
199
|
+
return `<label class="form-field checkbox"><span>${label}</span><input ${common} type="checkbox"${field.checked ? " checked" : ""}></label>`;
|
|
200
|
+
if (field.type === "Form.Dropdown" || field.type === "Form.TagPicker")
|
|
201
|
+
return `<label class="form-field"><span>${label}</span><select ${common} ${field.type === "Form.TagPicker" ? "multiple" : ""}>${field.options.map((option) => `<option value="${escapeHtml(option.value)}"${field.selected.some((selected) => selected.value === option.value) ? " selected" : ""}>${escapeHtml(option.title)}</option>`).join("")}</select>${error}</label>`;
|
|
202
|
+
if (field.type === "Form.DatePicker")
|
|
203
|
+
return `<label class="form-field"><span>${label}</span><input ${common} type="date" value="${escapeHtml(field.text.slice(0, 10))}">${error}</label>`;
|
|
204
|
+
if (field.type === "Form.FilePicker")
|
|
205
|
+
return `<p class="form-unavailable">${label}: not available in the browser</p>`;
|
|
206
|
+
const area = field.type === "Form.TextArea";
|
|
207
|
+
return `<label class="form-field"><span>${label}</span>${area ? `<textarea ${common} placeholder="${escapeHtml(field.placeholder)}">${escapeHtml(field.text)}</textarea>` : `<input ${common} type="${field.type === "Form.PasswordField" ? "password" : "text"}"${field.hasValue ? " data-has-value" : ""} value="${escapeHtml(field.text)}" placeholder="${escapeHtml(field.placeholder)}">`}${error}</label>`;
|
|
208
|
+
})
|
|
209
|
+
.join("")}${button("Submit", "primary")}</form>`
|
|
210
|
+
: "";
|
|
211
|
+
const detail = view.kind === "detail" || (view.kind === "list" && view.detail)
|
|
212
|
+
? `<article class="detail">${renderMarkdown(view.detail)}</article>`
|
|
213
|
+
: "";
|
|
214
|
+
const noRows = view.kind === "grid"
|
|
215
|
+
? (view.grid ?? []).every((row) => row.cells.length === 0)
|
|
216
|
+
: view.rows.length === 0;
|
|
217
|
+
const empty = view.empty
|
|
218
|
+
? `<p class="empty"><strong>${escapeHtml(view.empty.title)}</strong> ${escapeHtml(view.empty.description)}</p>`
|
|
219
|
+
: noRows && (view.kind === "list" || view.kind === "grid")
|
|
220
|
+
? `<p class="empty">${escapeHtml(view.emptyText || "No results")}</p>`
|
|
221
|
+
: "";
|
|
222
|
+
const menu = view.menuBar
|
|
223
|
+
? `<nav class="menu-bar" aria-label="${escapeHtml(view.menuBar.tooltip || view.menuBar.title || "Menu")}"><h2>${escapeHtml(view.menuBar.title || "Menu")}</h2><ul>${view.menuBar.rows
|
|
224
|
+
.map((row) => row.kind === "section"
|
|
225
|
+
? `<li class="menu-section"><strong>${escapeHtml(row.title)}</strong></li>`
|
|
226
|
+
: `<li><button type="button" data-menu-id="${escapeHtml(row.id)}"${row.selected ? ' class="selected"' : ""}${row.disabled ? " disabled" : ""}><span class="menu-icon"${row.iconTint ? ` style="color:${escapeHtml(row.iconTint)}"` : ""}>${escapeHtml(row.iconLabel || "")}</span><span>${escapeHtml(row.title)}</span>${row.subtitle ? `<small>${escapeHtml(row.subtitle)}</small>` : ""}${keycap("kbd", row.shortcut, options.reservedHint)}${row.kind === "submenu" ? " ›" : ""}</button></li>`)
|
|
227
|
+
.join("")}</ul></nav>`
|
|
228
|
+
: "";
|
|
229
|
+
const body = view.kind === "menu-bar"
|
|
230
|
+
? menu
|
|
231
|
+
: view.kind === "form"
|
|
232
|
+
? form
|
|
233
|
+
: view.kind === "detail"
|
|
234
|
+
? detail
|
|
235
|
+
: view.kind === "grid"
|
|
236
|
+
? `<ul class="grid">${grid}</ul>${empty}`
|
|
237
|
+
: detail
|
|
238
|
+
? // `isShowingDetail`: the selected item's detail sits beside the rows, as in Raycast.
|
|
239
|
+
`<div class="list-split"><div class="list-pane"><ul class="rows">${rows}</ul>${empty}</div>${detail}</div>`
|
|
240
|
+
: `<ul class="rows">${rows}</ul>${empty}`;
|
|
241
|
+
const footer = `<footer>${footerContextMarkup(options.context, view.title)}${statusMarkup(status)}<span class="footer-hints">${view.primary.enabled ? footerButton(view.primary.title, view.primary.shortcut || "↵", "primary") : ""}${footerButton("Actions", options.actionsKey ?? "⌘K", "actions")}</span></footer>`;
|
|
242
|
+
// Back sits left of the search field, where Raycast puts it in pushed views.
|
|
243
|
+
const back = `<button type="button" class="back-key" data-footer="back" title="Back (Esc)"><span aria-hidden="true">←</span><span class="visually-hidden">Back</span></button>`;
|
|
244
|
+
return `<main data-kind="${escapeHtml(view.kind)}" data-loading="${view.loading ? "true" : "false"}" aria-busy="${view.loading ? "true" : "false"}"><header><h1>${escapeHtml(view.title)}</h1><div class="search-line">${back}${breadcrumbMarkup(options.context)}${search}</div></header>${body}${actions}${dropdown}${footer}${feedbackMarkup(view, options)}</main>`;
|
|
245
|
+
}
|
|
246
|
+
/** @param {{title: string, subtitle: string, icon: string, iconUrl?: string, accessory: string}[]} rows @param {number} selected @param {string} query @param {{open?: boolean, query?: string, actions?: {title: string, shortcut?: string}[], selected?: number, toast?: string, status?: FooterStatus}} [panel] */
|
|
247
|
+
export function renderRoot(rows, selected, query, panel = {}) {
|
|
248
|
+
const options = rows
|
|
249
|
+
.map((row, index) => `<li id="root-option-${index}" role="option" aria-selected="${index === selected}" data-root-index="${index}"${index === selected ? ' class="selected"' : ""}><span class="root-icon" aria-hidden="true">${row.iconUrl ? `<img src="${escapeHtml(row.iconUrl)}" alt="" width="24" height="24" onerror="this.hidden=true;this.nextElementSibling.hidden=false">` : ""}<span${row.iconUrl ? " hidden" : ""}>${escapeHtml(row.icon)}</span></span><span class="root-copy"><strong>${escapeHtml(row.title)}</strong><small>${escapeHtml(row.subtitle)}</small></span><span class="root-accessory">${escapeHtml(row.accessory)}</span></li>`)
|
|
250
|
+
.join("");
|
|
251
|
+
const actions = panel.open
|
|
252
|
+
? (panel.actions ?? [])
|
|
253
|
+
.map((action, index) => `<li id="root-action-${index}" role="option" aria-selected="${index === panel.selected}" data-root-action-index="${index}"${index === panel.selected ? ' class="selected"' : ""}>${escapeHtml(action.title)}${action.shortcut ? ` <small>${escapeHtml(action.shortcut)}</small>` : ""}</li>`)
|
|
254
|
+
.join("")
|
|
255
|
+
: "";
|
|
256
|
+
const actionPanel = panel.open
|
|
257
|
+
? `<section class="actions" role="dialog" aria-label="Actions"><strong>Actions</strong><input id="root-action-search" value="${escapeHtml(panel.query ?? "")}" placeholder="Search actions…" aria-label="Search actions" role="combobox" aria-controls="root-actions" aria-activedescendant="${panel.selected !== undefined && panel.selected >= 0 ? `root-action-${panel.selected}` : ""}" autocomplete="off" autofocus><ul id="root-actions" role="listbox" aria-label="Command actions">${actions}</ul></section>`
|
|
258
|
+
: "";
|
|
259
|
+
const toast = panel.toast
|
|
260
|
+
? `<span role="status" class="root-toast">${escapeHtml(panel.toast)}</span>`
|
|
261
|
+
: "";
|
|
262
|
+
const status = panel.status ?? { text: "Ready" };
|
|
263
|
+
return `<main class="root" data-kind="root" data-loading="false"><header><h1>Blast</h1><input id="root-search" value="${escapeHtml(query)}" placeholder="Search extension commands…" aria-label="Search extension commands" role="combobox" aria-controls="root-options" aria-activedescendant="${selected >= 0 ? `root-option-${selected}` : ""}" autocomplete="off" autofocus></header><ul id="root-options" class="rows root-options" role="listbox" aria-label="Extension commands">${options}</ul>${rows.length === 0 ? '<p class="empty">No matching commands</p>' : ""}<footer>${statusMarkup(status)}<span class="footer-hints">${footerButton("Run Command", "↵", "primary")}${footerButton("Actions", "⌘K", "actions")}${status.error ? footerButton("Back", "Esc", "back") : ""}</span>${toast}</footer>${actionPanel}</main>`;
|
|
264
|
+
}
|
|
265
|
+
/** @param {import("@blastlauncher/protocol/preference-fields").Preference[]} fields @param {Record<string, unknown>} values @param {string} title @param {string[]} unavailable */
|
|
266
|
+
export function renderPreferences(fields, values, title, unavailable = []) {
|
|
267
|
+
const rows = fields
|
|
268
|
+
.map((field) => {
|
|
269
|
+
const value = values[field.name] ?? field.default ?? (field.type === "checkbox" ? false : "");
|
|
270
|
+
const required = field.required ? " *" : "";
|
|
271
|
+
const description = field.description
|
|
272
|
+
? `<small class="preference-description">${escapeHtml(field.description)}</small>`
|
|
273
|
+
: "";
|
|
274
|
+
if (field.type === "checkbox")
|
|
275
|
+
return `<label class="preference-row"><span>${escapeHtml(field.title)}${required}</span><input name="${escapeHtml(field.name)}" type="checkbox" data-preference="${escapeHtml(field.name)}"${value ? " checked" : ""}>${description}</label>`;
|
|
276
|
+
if (field.type === "dropdown")
|
|
277
|
+
return `<label class="preference-row"><span>${escapeHtml(field.title)}${required}</span><select name="${escapeHtml(field.name)}" data-preference="${escapeHtml(field.name)}">${field.data.map((option) => `<option value="${escapeHtml(option.value)}"${String(value) === option.value ? " selected" : ""}>${escapeHtml(option.title)}</option>`).join("")}</select>${description}</label>`;
|
|
278
|
+
if (field.type === "file" || field.type === "directory" || field.type === "appPicker")
|
|
279
|
+
return `<div class="preference-row unavailable"><strong>${escapeHtml(field.title)}${required}</strong><span>Needs a file on your computer; this is not available in the browser.</span>${description}</div>`;
|
|
280
|
+
return `<label class="preference-row"><span>${escapeHtml(field.title)}${required}</span><input name="${escapeHtml(field.name)}" type="${field.type === "password" ? "password" : "text"}" data-preference="${escapeHtml(field.name)}" value="${escapeHtml(String(value))}" placeholder="${escapeHtml(field.placeholder ?? "")}">${description}</label>`;
|
|
281
|
+
})
|
|
282
|
+
.join("");
|
|
283
|
+
const disabled = unavailable.length > 0 ||
|
|
284
|
+
fields.some((field) => field.required &&
|
|
285
|
+
field.default === undefined &&
|
|
286
|
+
(values[field.name] === undefined || values[field.name] === ""));
|
|
287
|
+
return `<main class="preferences" data-kind="preferences"><header><h1>${escapeHtml(title)}</h1><p class="preference-note">These values stay in this browser only.</p></header><form id="preferences-form"><div class="preference-fields">${rows}</div><footer><span id="preference-error" role="alert"></span><span class="footer-hints"><button type="submit" class="footer-key"${disabled ? " disabled" : ""}><span>Run</span><kbd>↵</kbd></button><button type="button" class="footer-key" data-preference-back><span>Back</span><kbd>Esc</kbd></button></span></footer></form></main>`;
|
|
288
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type RuntimePortRequest = {
|
|
2
|
+
type: "blast-port-request";
|
|
3
|
+
};
|
|
4
|
+
export declare function isRuntimePortRequest(value: unknown): value is RuntimePortRequest;
|
|
5
|
+
export declare function acceptsRuntimePortRequest(source: unknown, expectedSource: unknown, transferred: boolean, data: unknown): boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function isRuntimePortRequest(value) {
|
|
2
|
+
return (typeof value === "object" &&
|
|
3
|
+
value !== null &&
|
|
4
|
+
value.type === "blast-port-request");
|
|
5
|
+
}
|
|
6
|
+
export function acceptsRuntimePortRequest(source, expectedSource, transferred, data) {
|
|
7
|
+
return !transferred && source === expectedSource && isRuntimePortRequest(data);
|
|
8
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type SandboxRuntime = {
|
|
2
|
+
execute(code: string, filename?: string): Promise<unknown>;
|
|
3
|
+
getVFS(): {
|
|
4
|
+
mkdirSync(path: string, options?: {
|
|
5
|
+
recursive?: boolean;
|
|
6
|
+
}): void;
|
|
7
|
+
writeFileSync(path: string, data: string | Uint8Array): void;
|
|
8
|
+
};
|
|
9
|
+
iframe?: HTMLIFrameElement;
|
|
10
|
+
terminate?: () => void;
|
|
11
|
+
};
|
|
12
|
+
/** What decides which sandbox a session can adopt: the same page, assets and bundles. */
|
|
13
|
+
export type SandboxOptions = {
|
|
14
|
+
sandboxUrl: string;
|
|
15
|
+
assetsUrl?: string | undefined;
|
|
16
|
+
bundlesUrl?: string | undefined;
|
|
17
|
+
buildUrl?: string | undefined;
|
|
18
|
+
};
|
|
19
|
+
/** `path` under `assetsUrl`, which defaults to the package's own `assets/` directory. */
|
|
20
|
+
export declare const assetUrl: (options: Pick<SandboxOptions, "assetsUrl">, path: string) => string;
|
|
21
|
+
export declare function disposeSandbox(runtime: SandboxRuntime | undefined): void;
|
|
22
|
+
/** Starts a sandbox now so the next `mount()` with the same options skips that wait. */
|
|
23
|
+
export declare function prewarm(options: SandboxOptions): void;
|
|
24
|
+
/** The warm sandbox for these options if there is one, otherwise a new one. */
|
|
25
|
+
export declare function acquireSandbox(options: SandboxOptions): Promise<SandboxRuntime>;
|