feat(server): accept prompt input, shell out, open the editor
The three things the overlay cannot do from inside a page: normalise prompt input before it reaches an agent, run a command, and open a file in the user's editor at the line they picked.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Open a project file in the user's editor.
|
||||
*
|
||||
* Two paths, because neither works alone. The CLI launchers (`code -g`) are the
|
||||
* reliable ones *when they exist* — but `code` is only on `PATH` if the user
|
||||
* ever ran VS Code's "Install 'code' command in PATH", which a fresh machine
|
||||
* has not. The URL schemes always work if the app is installed but cannot carry
|
||||
* a column and are awkward to detect. So: try the binary, fall back to the
|
||||
* scheme.
|
||||
*/
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import { resolve, sep } from "node:path";
|
||||
import type { Editor } from "@airship/protocol";
|
||||
|
||||
export interface OpenRequest {
|
||||
column?: number;
|
||||
editor?: Editor;
|
||||
/** Repo-relative. Resolved against `cwd` and required to stay inside it. */
|
||||
file: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
export interface OpenResult {
|
||||
editor?: string;
|
||||
error?: string;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
interface Launcher {
|
||||
/** Argv for the CLI form, given an absolute `file:line:col` target. */
|
||||
args: (target: string) => string[];
|
||||
bin: string;
|
||||
/** URL scheme host, e.g. `vscode` in `vscode://file/...`. */
|
||||
scheme: string;
|
||||
}
|
||||
|
||||
const LAUNCHERS: Record<Editor, Launcher> = {
|
||||
cursor: { args: (t) => ["-g", t], bin: "cursor", scheme: "cursor" },
|
||||
vscode: { args: (t) => ["-g", t], bin: "code", scheme: "vscode" },
|
||||
windsurf: { args: (t) => ["-g", t], bin: "windsurf", scheme: "windsurf" },
|
||||
// Zed takes the position inline with no flag.
|
||||
zed: { args: (t) => [t], bin: "zed", scheme: "zed" },
|
||||
};
|
||||
|
||||
/** Probe order when nothing is configured. */
|
||||
const PREFERENCE: Editor[] = ["vscode", "cursor", "windsurf", "zed"];
|
||||
|
||||
/** Vite serves out-of-root files under `/@fs/<abs path>`. */
|
||||
const VITE_FS_PREFIX = /^\/@fs(\/.*)$/;
|
||||
const LEADING_SLASHES = /^\/+/;
|
||||
|
||||
export function openInEditor(cwd: string, req: OpenRequest): OpenResult {
|
||||
const root = resolve(cwd);
|
||||
const abs = resolve(root, projectPath(req.file));
|
||||
// This socket is unauthenticated and local, and this handler spawns
|
||||
// processes — without the containment check any page the browser loads could
|
||||
// ask the daemon to open arbitrary files on disk.
|
||||
if (abs !== root && !abs.startsWith(root + sep)) {
|
||||
return { error: "path is outside the project", ok: false };
|
||||
}
|
||||
if (!existsSync(abs)) {
|
||||
return { error: `no such file: ${req.file}`, ok: false };
|
||||
}
|
||||
|
||||
const line = req.line ?? 1;
|
||||
const column = req.column ?? 1;
|
||||
const target = `${abs}:${line}:${column}`;
|
||||
|
||||
for (const editor of candidates(req.editor)) {
|
||||
const launcher = LAUNCHERS[editor];
|
||||
if (!onPath(launcher.bin)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Detached and unref'd: the editor outlives the daemon, and inheriting
|
||||
// stdio would wire its output into ours. Never `shell: true` — argv
|
||||
// arrays mean a path with a space or a quote is just a path.
|
||||
spawn(launcher.bin, launcher.args(target), {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref();
|
||||
return { editor, ok: true };
|
||||
} catch {
|
||||
// Fall through to the next candidate, then to the URL scheme.
|
||||
}
|
||||
}
|
||||
|
||||
const wanted = req.editor ?? PREFERENCE[0];
|
||||
if (openUrl(`${LAUNCHERS[wanted].scheme}://file/${abs}:${line}:${column}`)) {
|
||||
return { editor: wanted, ok: true };
|
||||
}
|
||||
return {
|
||||
error: `could not launch ${wanted} — is it installed?`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path that may have come from the *browser* rather than from a
|
||||
* diff. Source locations are resolved from framework metadata, and what React
|
||||
* and Vite hand back is a dev-server URL path — `/src/App.tsx`, or `/@fs/` +
|
||||
* an absolute path for files outside the Vite root. Both are absolute as far
|
||||
* as `resolve` is concerned, so without this the first one silently escapes
|
||||
* `cwd` and gets rejected as "outside the project".
|
||||
*
|
||||
* Paths that are genuinely absolute and genuinely inside the project still
|
||||
* work: they survive this untouched and the containment check below passes.
|
||||
*/
|
||||
function projectPath(file: string): string {
|
||||
const fs = file.match(VITE_FS_PREFIX);
|
||||
if (fs?.[1]) {
|
||||
return fs[1];
|
||||
}
|
||||
// A real absolute path exists on disk; a URL path like `/src/App.tsx` does
|
||||
// not, and is meant to be read relative to the project root.
|
||||
if (file.startsWith("/") && !existsSync(file)) {
|
||||
return file.replace(LEADING_SLASHES, "");
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function candidates(explicit?: Editor): Editor[] {
|
||||
if (explicit) {
|
||||
return [explicit];
|
||||
}
|
||||
const configured = process.env.AIRSHIP_EDITOR as Editor | undefined;
|
||||
if (configured && configured in LAUNCHERS) {
|
||||
return [configured, ...PREFERENCE.filter((e) => e !== configured)];
|
||||
}
|
||||
return PREFERENCE;
|
||||
}
|
||||
|
||||
/** Cached: a `which` per menu click is pointless, and PATH does not move. */
|
||||
const pathCache = new Map<string, boolean>();
|
||||
|
||||
function onPath(bin: string): boolean {
|
||||
const hit = pathCache.get(bin);
|
||||
if (hit !== undefined) {
|
||||
return hit;
|
||||
}
|
||||
const probe = platform() === "win32" ? "where" : "which";
|
||||
let found = false;
|
||||
try {
|
||||
found = spawnSync(probe, [bin], { stdio: "ignore" }).status === 0;
|
||||
} catch {
|
||||
found = false;
|
||||
}
|
||||
pathCache.set(bin, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
function openUrl(url: string): boolean {
|
||||
const os = platform();
|
||||
let bin = "xdg-open";
|
||||
let args = [url];
|
||||
if (os === "darwin") {
|
||||
bin = "open";
|
||||
} else if (os === "win32") {
|
||||
bin = "cmd";
|
||||
args = ["/c", "start", "", url];
|
||||
}
|
||||
try {
|
||||
spawn(bin, args, { detached: true, stdio: "ignore" }).unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildEditPrompt } from "@airship/core";
|
||||
import type { CreateJobRequest, ElementContext } from "@airship/protocol";
|
||||
import { invalidateTokenCache } from "@airship/source/tokens";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { preparePromptInput } from "./prompt-input";
|
||||
|
||||
/** Build a throwaway project tree and return its root. */
|
||||
function fixture(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "airship-prompt-"));
|
||||
for (const [path, contents] of Object.entries(files)) {
|
||||
const full = join(root, path);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, contents);
|
||||
}
|
||||
invalidateTokenCache();
|
||||
return root;
|
||||
}
|
||||
|
||||
const APP_TSX = `export function App() {
|
||||
return (
|
||||
<main>
|
||||
<button className="btn btn-primary">Get Started</button>
|
||||
<span className="tag">New</span>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const TOKENS_CSS = `:root {
|
||||
--pk-space-sm: 8px;
|
||||
--pk-space-md: 16px;
|
||||
--pk-radius-lg: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
/** A project with one component and one stylesheet, rooted for the scanner. */
|
||||
function project(): string {
|
||||
return fixture({
|
||||
".git": "",
|
||||
"src/App.tsx": APP_TSX,
|
||||
"src/tokens.css": TOKENS_CSS,
|
||||
});
|
||||
}
|
||||
|
||||
function element(overrides: Partial<ElementContext> = {}): ElementContext {
|
||||
return {
|
||||
classes: ["btn", "btn-primary"],
|
||||
displayName: "Button",
|
||||
selector: ".btn.btn-primary",
|
||||
tagName: "button",
|
||||
textPreview: "Get Started",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The location the browser reports: a real file and line, no context yet. */
|
||||
const BUTTON_AT = { file: "src/App.tsx", line: 4 };
|
||||
|
||||
function request(overrides: Partial<CreateJobRequest> = {}): CreateJobRequest {
|
||||
return { prompt: "", ...overrides };
|
||||
}
|
||||
|
||||
describe("preparePromptInput — source backfill", () => {
|
||||
it("attaches surrounding code to a style target", () => {
|
||||
const cwd = project();
|
||||
const input = preparePromptInput(
|
||||
cwd,
|
||||
request({
|
||||
visualChanges: [
|
||||
{
|
||||
changes: [{ from: "12px", property: "padding", to: "16px" }],
|
||||
element: element(),
|
||||
source: BUTTON_AT,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(input.visualChanges?.[0].source?.context).toContain(
|
||||
'className="btn btn-primary"'
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves all three locations on a move, not just the element", () => {
|
||||
// The two easiest to forget: an agent told to relocate JSX needs the new
|
||||
// parent and the anchor sibling as much as it needs the element itself.
|
||||
const cwd = project();
|
||||
const input = preparePromptInput(
|
||||
cwd,
|
||||
request({
|
||||
moveChanges: [
|
||||
{
|
||||
before: element({
|
||||
classes: ["tag"],
|
||||
displayName: "Tag",
|
||||
selector: ".tag",
|
||||
tagName: "span",
|
||||
textPreview: "New",
|
||||
}),
|
||||
beforeSource: { file: "src/App.tsx", line: 5 },
|
||||
element: element(),
|
||||
newParent: element({
|
||||
classes: [],
|
||||
displayName: undefined,
|
||||
selector: "main",
|
||||
tagName: "main",
|
||||
textPreview: "",
|
||||
}),
|
||||
newParentSource: { file: "src/App.tsx", line: 3 },
|
||||
source: BUTTON_AT,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const move = input.moveChanges?.[0];
|
||||
expect(move?.source?.context).toContain("Get Started");
|
||||
expect(move?.newParentSource?.context).toContain("<main>");
|
||||
expect(move?.beforeSource?.context).toContain('className="tag"');
|
||||
});
|
||||
|
||||
it("backfills structural, text and attribute targets too", () => {
|
||||
const cwd = project();
|
||||
const input = preparePromptInput(
|
||||
cwd,
|
||||
request({
|
||||
attrChanges: [
|
||||
{
|
||||
changes: [{ attribute: "disabled", from: null, to: "true" }],
|
||||
element: element(),
|
||||
source: BUTTON_AT,
|
||||
},
|
||||
],
|
||||
structuralChanges: [
|
||||
{ element: element(), op: "duplicate", source: BUTTON_AT },
|
||||
],
|
||||
textChanges: [
|
||||
{
|
||||
element: element(),
|
||||
from: "Get Started",
|
||||
source: BUTTON_AT,
|
||||
to: "Start now",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(input.structuralChanges?.[0].source?.context).toContain(
|
||||
"Get Started"
|
||||
);
|
||||
expect(input.textChanges?.[0].source?.context).toContain("Get Started");
|
||||
expect(input.attrChanges?.[0].source?.context).toContain("Get Started");
|
||||
});
|
||||
|
||||
it("normalizes a root-relative path the dev server reported", () => {
|
||||
// Vite reports `/src/App.tsx`; `resolve(cwd, …)` would read that as
|
||||
// absolute and hand the agent a path outside the project.
|
||||
const cwd = project();
|
||||
const input = preparePromptInput(
|
||||
cwd,
|
||||
request({
|
||||
element: element(),
|
||||
source: { file: "/src/App.tsx", line: 4 },
|
||||
})
|
||||
);
|
||||
expect(input.source?.file).toBe(join("src", "App.tsx"));
|
||||
expect(input.source?.context).toContain("Get Started");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preparePromptInput — the primary element", () => {
|
||||
it("prefers the explicit selection", () => {
|
||||
const cwd = project();
|
||||
const input = preparePromptInput(
|
||||
cwd,
|
||||
request({
|
||||
element: element({ displayName: "Selected" }),
|
||||
source: BUTTON_AT,
|
||||
textChanges: [
|
||||
{
|
||||
element: element({ displayName: "Retyped" }),
|
||||
from: "a",
|
||||
source: BUTTON_AT,
|
||||
to: "b",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(input.element?.displayName).toBe("Selected");
|
||||
});
|
||||
|
||||
it("falls back down the delta chain when nothing is selected", () => {
|
||||
const cwd = project();
|
||||
const fallback = (overrides: Partial<CreateJobRequest>) =>
|
||||
preparePromptInput(cwd, request(overrides)).element?.displayName;
|
||||
|
||||
expect(
|
||||
fallback({
|
||||
moveChanges: [
|
||||
{
|
||||
before: null,
|
||||
beforeSource: null,
|
||||
element: element({ displayName: "Moved" }),
|
||||
newParent: null,
|
||||
newParentSource: null,
|
||||
source: BUTTON_AT,
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe("Moved");
|
||||
expect(
|
||||
fallback({
|
||||
structuralChanges: [
|
||||
{
|
||||
element: element({ displayName: "Deleted" }),
|
||||
op: "delete",
|
||||
source: BUTTON_AT,
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe("Deleted");
|
||||
expect(
|
||||
fallback({
|
||||
attrChanges: [
|
||||
{
|
||||
changes: [{ attribute: "alt", from: null, to: "x" }],
|
||||
element: element({ displayName: "Retagged" }),
|
||||
source: BUTTON_AT,
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe("Retagged");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preparePromptInput — comments and tokens", () => {
|
||||
it("passes comments through untouched", () => {
|
||||
// A comment already carries a repo-relative path and a real line; the
|
||||
// element resolver has nothing to add and would only corrupt it.
|
||||
const cwd = project();
|
||||
const comments = [
|
||||
{
|
||||
body: "tighten this",
|
||||
file: "src/App.tsx",
|
||||
fromLine: 4,
|
||||
jobId: "job-1",
|
||||
snippet: "<button>",
|
||||
toLine: 4,
|
||||
},
|
||||
];
|
||||
expect(preparePromptInput(cwd, request({ comments })).comments).toEqual(
|
||||
comments
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the project's scanned design scale", () => {
|
||||
const cwd = project();
|
||||
const names = preparePromptInput(cwd, request()).tokens?.tokens.map(
|
||||
(t) => t.name
|
||||
);
|
||||
expect(names).toContain("--pk-space-md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rendered prompt", () => {
|
||||
it("carries context and a token legend the browser could not know", () => {
|
||||
// The premise of the whole preview round trip: these two blocks exist only
|
||||
// because the daemon read the project off disk. If this ever passes against
|
||||
// a client-side re-derivation, the server hop was unnecessary.
|
||||
const cwd = project();
|
||||
const text = buildEditPrompt(
|
||||
preparePromptInput(
|
||||
cwd,
|
||||
request({
|
||||
visualChanges: [
|
||||
{
|
||||
changes: [{ from: "12px", property: "padding", to: "16px" }],
|
||||
element: element(),
|
||||
source: BUTTON_AT,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(text).toContain("Source context:");
|
||||
expect(text).toContain("src/App.tsx:4");
|
||||
expect(text).toContain("--pk-space-md");
|
||||
});
|
||||
|
||||
it("routes a comments-only turn to the review prompt", () => {
|
||||
const cwd = project();
|
||||
const text = buildEditPrompt(
|
||||
preparePromptInput(
|
||||
cwd,
|
||||
request({
|
||||
comments: [
|
||||
{
|
||||
body: "tighten this",
|
||||
file: "src/App.tsx",
|
||||
fromLine: 4,
|
||||
jobId: "job-1",
|
||||
snippet: "<button>",
|
||||
toLine: 4,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(text).toContain("The user reviewed the edit you just made");
|
||||
expect(text).toContain("tighten this");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Resolving an edit request against the project on disk, so the prompt can name
|
||||
* files, lines and design tokens the browser has no way to know.
|
||||
*/
|
||||
import type { EditPromptInput } from "@airship/core";
|
||||
import type {
|
||||
CreateJobRequest,
|
||||
ElementContext,
|
||||
SourceLocation,
|
||||
} from "@airship/protocol";
|
||||
import { resolveServerSource } from "@airship/source/server";
|
||||
import { scanProjectTokens } from "@airship/source/tokens";
|
||||
|
||||
/**
|
||||
* Backfill file/line/context for every edit that names a DOM element, the same
|
||||
* way the single selection is resolved — the agent cannot find a JSX node it
|
||||
* has no location for.
|
||||
*/
|
||||
function backfillSources(cwd: string, request: CreateJobRequest) {
|
||||
const locate = (element: ElementContext, source?: SourceLocation | null) =>
|
||||
resolveServerSource(cwd, { element, source: source ?? null });
|
||||
return {
|
||||
attrChanges: request.attrChanges?.map((target) => ({
|
||||
...target,
|
||||
source: locate(target.element, target.source),
|
||||
})),
|
||||
// Each move needs its element, its new parent, and its anchor sibling.
|
||||
moveChanges: request.moveChanges?.map((move) => ({
|
||||
...move,
|
||||
beforeSource: move.before ? locate(move.before, move.beforeSource) : null,
|
||||
newParentSource: move.newParent
|
||||
? locate(move.newParent, move.newParentSource)
|
||||
: null,
|
||||
source: locate(move.element, move.source),
|
||||
})),
|
||||
structuralChanges: request.structuralChanges?.map((edit) => ({
|
||||
...edit,
|
||||
source: locate(edit.element, edit.source),
|
||||
})),
|
||||
textChanges: request.textChanges?.map((edit) => ({
|
||||
...edit,
|
||||
source: locate(edit.element, edit.source),
|
||||
})),
|
||||
visualChanges: request.visualChanges?.map((target) => ({
|
||||
...target,
|
||||
source: locate(target.element, target.source),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The element the turn is *about*, and where it lives. */
|
||||
function primaryLocation(cwd: string, request: CreateJobRequest) {
|
||||
const primaryElement =
|
||||
request.element ??
|
||||
request.visualChanges?.[0]?.element ??
|
||||
request.moveChanges?.[0]?.element ??
|
||||
request.structuralChanges?.[0]?.element ??
|
||||
request.textChanges?.[0]?.element ??
|
||||
request.attrChanges?.[0]?.element;
|
||||
const source = resolveServerSource(cwd, {
|
||||
element: primaryElement,
|
||||
source:
|
||||
request.source ??
|
||||
request.visualChanges?.[0]?.source ??
|
||||
request.moveChanges?.[0]?.source ??
|
||||
request.structuralChanges?.[0]?.source ??
|
||||
request.textChanges?.[0]?.source ??
|
||||
request.attrChanges?.[0]?.source ??
|
||||
null,
|
||||
});
|
||||
return { primaryElement, source };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the prompt is built from, resolved against the project on disk.
|
||||
*
|
||||
* Returns an `EditPromptInput` exactly, and that is the point: `startEdit`
|
||||
* spreads it into `runEdit` (which renders it via `buildEditPrompt`) and the
|
||||
* `prompt` handler renders it directly, so the preview the user reads and the
|
||||
* instruction the agent receives are the same string by construction rather
|
||||
* than by inspection. Anything prompt-relevant assembled outside this function
|
||||
* is a drift bug.
|
||||
*/
|
||||
export function preparePromptInput(
|
||||
cwd: string,
|
||||
request: CreateJobRequest
|
||||
): EditPromptInput {
|
||||
const { primaryElement, source } = primaryLocation(cwd, request);
|
||||
return {
|
||||
...backfillSources(cwd, request),
|
||||
// No `resolveServerSource` backfill: a comment already carries a
|
||||
// repo-relative path and a real source line. That resolver maps DOM
|
||||
// elements onto source, which a comment has already skipped past.
|
||||
comments: request.comments,
|
||||
element: primaryElement,
|
||||
prompt: request.prompt,
|
||||
source,
|
||||
// The project's own design vocabulary, so the prompt can name tokens
|
||||
// instead of advising the agent to go looking for some. Cached after the
|
||||
// first walk, so this is a map lookup on every turn but the first.
|
||||
tokens: scanProjectTokens(cwd),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The canvas shell document.
|
||||
*
|
||||
* In canvas mode the proxy stops serving the app's HTML at the top level and
|
||||
* serves this instead. The app still runs — one full copy per frame, inside
|
||||
* same-origin iframes the shell creates — but it no longer shares a document
|
||||
* with the editor.
|
||||
*
|
||||
* That separation is the point. Until now the overlay lived inside the app and
|
||||
* had to defend itself from it: scoped CSS variables so the app's `:root` didn't
|
||||
* bleed in, a `__airship-` prefix on every class, and a `z-index` of 2147483600
|
||||
* to stay on top. None of the app's CSS reaches this document, so the editor
|
||||
* finally has a clean page of its own.
|
||||
*
|
||||
* Deliberately minimal: no app CSS, no framework, no build step. Fonts resolve
|
||||
* same-origin from `/__airship/fonts/*` (see `serveAirshipAsset`), and every
|
||||
* pixel of UI is drawn by the overlay bundle.
|
||||
*/
|
||||
import type { AirshipWindowConfig } from "@airship/protocol";
|
||||
|
||||
/**
|
||||
* The tab icon: Airship's brand mark, inlined.
|
||||
*
|
||||
* A data URI rather than a route, for the same reason this file has no build
|
||||
* step — the favicon is the first thing a browser asks for, and serving it
|
||||
* would mean a second round trip and another branch in `serveAirshipAsset` for
|
||||
* ~300 bytes. `assets/logo.svg` at the repo root is the geometry of record;
|
||||
* this is the third copy of that path (with the overlay's `logo` glyph), and
|
||||
* all three change together.
|
||||
*
|
||||
* Two differences from the in-UI glyph, both because a favicon is 16 physical
|
||||
* pixels:
|
||||
*
|
||||
* - The viewBox is cropped to `3.6 3.6 16.8 16.8` instead of the full 24 box.
|
||||
* The set's optical inset gives back a third of the tab icon as empty margin,
|
||||
* which matters at 24px and is waste at 16.
|
||||
* - Full opacity, not the set's 0.9. That step exists to seat a glyph among
|
||||
* other glyphs; in a tab strip there is nothing to seat it against.
|
||||
*
|
||||
* The colour comes from a `prefers-color-scheme` rule because the tab strip
|
||||
* follows the *browser's* theme, not this page's — the shell is always dark,
|
||||
* the chrome around it is not. Black is the base so that a renderer which
|
||||
* ignores the query still lands on the readable answer for a default light
|
||||
* tab strip.
|
||||
*/
|
||||
const FAVICON =
|
||||
"data:image/svg+xml," +
|
||||
"%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='3.6 3.6 16.8 16.8'%3E" +
|
||||
"%3Cstyle%3Epath{fill:%23000}" +
|
||||
"@media(prefers-color-scheme:dark){path{fill:%23fff}}%3C/style%3E" +
|
||||
"%3Cpath d='M12 5.1L20 18.9H15.47L9.73 9.01ZM7.47 12.92H12L8.53 18.9H4Z'/%3E" +
|
||||
"%3C/svg%3E";
|
||||
|
||||
/**
|
||||
* `</script>` inside a JSON string would close the tag early; U+2028 and U+2029
|
||||
* are legal in JSON but are line terminators to a JS parser, so both have to be
|
||||
* escaped before the config can be inlined into a `<script>`.
|
||||
*
|
||||
* Exported because the inline injection embeds the same config into the app's
|
||||
* own HTML, and that config now carries a `pathname` taken from the request —
|
||||
* i.e. a string the page's author does not control.
|
||||
*/
|
||||
export function escapeForScript(json: string): string {
|
||||
return json.replace(/[<\u2028\u2029]/g, (c) => {
|
||||
if (c === "<") {
|
||||
return "\\u003c";
|
||||
}
|
||||
return c === "\u2028" ? "\\u2028" : "\\u2029";
|
||||
});
|
||||
}
|
||||
|
||||
export function shellHtml(config: AirshipWindowConfig): string {
|
||||
const json = escapeForScript(JSON.stringify(config));
|
||||
return `<!doctype html>
|
||||
<html lang="en" data-airship-shell>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Airship</title>
|
||||
<link rel="icon" href="${FAVICON}">
|
||||
<style>
|
||||
/* Painted before the bundle runs so the canvas never flashes white. */
|
||||
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#141414;}
|
||||
</style>
|
||||
<script>window.__PIKA__=${json};</script>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/__airship/overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user