@lchase/kiln 0.1.0 → 0.4.0
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/README.md +57 -17
- package/assets/serve.css +452 -0
- package/assets/serve.js +361 -0
- package/dist/attention.js +254 -0
- package/dist/board.js +326 -0
- package/dist/cli.js +105 -6
- package/dist/config.js +48 -28
- package/dist/driver.js +499 -34
- package/dist/findings.js +79 -0
- package/dist/intake/jira.js +4 -1
- package/dist/intake/tickets.js +23 -18
- package/dist/jobs.js +6 -1
- package/dist/reconcile.js +78 -21
- package/dist/reliability.js +441 -0
- package/dist/report.js +274 -73
- package/dist/schemas.js +23 -5
- package/dist/serve-page.js +352 -0
- package/dist/serve.js +373 -0
- package/dist/workflow.js +95 -10
- package/package.json +2 -1
- package/prompts/kiln/investigation.md +31 -0
- package/workflows/investigation.yaml +67 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { scheduledRetry } from "./attention.js";
|
|
4
|
+
import { readBoardFiles, stageOf, stagesFor } from "./board.js";
|
|
5
|
+
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
6
|
+
import { packageRoot } from "./paths.js";
|
|
7
|
+
const REVIEWERS = [
|
|
8
|
+
["security", "sec"],
|
|
9
|
+
["ux", "ux"],
|
|
10
|
+
["ui", "ui"],
|
|
11
|
+
["code", "code"],
|
|
12
|
+
];
|
|
13
|
+
const LIVE_SCRIPT = readFileSync(join(packageRoot(), "assets", "serve.js"), "utf8");
|
|
14
|
+
function kilnVersion() {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
|
|
17
|
+
return typeof parsed.version === "string" ? parsed.version : "";
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const VERSION = kilnVersion();
|
|
24
|
+
export function renderConsole(css, attention) {
|
|
25
|
+
const items = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently];
|
|
26
|
+
const stages = stagesFor(items.map((item) => item.job));
|
|
27
|
+
const grouped = new Map();
|
|
28
|
+
for (const item of items) {
|
|
29
|
+
const stage = stageOf(item.job);
|
|
30
|
+
const list = grouped.get(stage) ?? [];
|
|
31
|
+
list.push(item);
|
|
32
|
+
grouped.set(stage, list);
|
|
33
|
+
}
|
|
34
|
+
const tracks = stages
|
|
35
|
+
.map((stage) => ((grouped.get(stage.id)?.length ?? 0) === 0 ? "40px" : "minmax(0, 1fr)"))
|
|
36
|
+
.join(" ");
|
|
37
|
+
const columns = stages.map((stage) => column(stage, grouped.get(stage.id) ?? [])).join("\n");
|
|
38
|
+
const repos = [...new Set(items.map((item) => item.job.repo).filter((repo) => repo.length > 0))].sort();
|
|
39
|
+
const chips = [
|
|
40
|
+
chip("", "all", items.length, true),
|
|
41
|
+
...repos.map((repo) => chip(repo, repo, items.filter((item) => item.job.repo === repo).length, false)),
|
|
42
|
+
].join("");
|
|
43
|
+
const version = VERSION ? `<span class="ver">v${escapeHtml(VERSION)}</span>` : "";
|
|
44
|
+
const body = `<header>
|
|
45
|
+
<div class="brand"><span class="logo" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V6l5-3 5 3v6l-5 3-5-3z"/><path d="M3 6l5 3 5-3M8 9v6"/></svg></span><h1>Kiln</h1>${version}</div>
|
|
46
|
+
<div class="status" id="status">${items.length === 1 ? "1 job" : `${items.length} jobs`}</div>
|
|
47
|
+
</header>
|
|
48
|
+
<nav class="repos" aria-label="Filter by repo">${chips}</nav>
|
|
49
|
+
<main class="board" id="board" style="--cols: ${tracks}">
|
|
50
|
+
${columns}
|
|
51
|
+
</main>
|
|
52
|
+
<nav class="toolbar" aria-label="Kiln commands">
|
|
53
|
+
<button class="tool" type="button" data-act="approve" data-tip="Approve" aria-label="Approve"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button>
|
|
54
|
+
<button class="tool" type="button" data-act="retry" data-tip="Retry" aria-label="Retry"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/></svg></button>
|
|
55
|
+
<button class="tool" type="button" data-act="pr" data-tip="Open pull request" aria-label="Open pull request"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M6 8.5v7M18 15.5V10a3 3 0 0 0-3-3h-3"/></svg></button>
|
|
56
|
+
<button class="tool" type="button" id="notify" data-tip="Notify me" aria-label="Notify me"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 7H3s3 0 3-7"/><path d="M10 19a2 2 0 0 0 4 0"/></svg></button>
|
|
57
|
+
<span class="sep" aria-hidden="true"></span>
|
|
58
|
+
<button class="tool" type="button" id="compact" aria-pressed="true" data-tip="expand empty columns" aria-label="expand empty columns"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h7m0 0-3-3m3 3-3 3M21 12h-7m0 0 3-3m-3 3 3 3M12 4v16"/></svg></button>
|
|
59
|
+
</nav>
|
|
60
|
+
<div class="scrim" data-close></div>
|
|
61
|
+
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="Job">
|
|
62
|
+
${items.map((item) => sheet(item)).join("\n")}
|
|
63
|
+
</aside>`;
|
|
64
|
+
return document({
|
|
65
|
+
css,
|
|
66
|
+
title: `(${attention.needsYouCount}) Kiln`,
|
|
67
|
+
needsYou: attention.needsYouCount,
|
|
68
|
+
revision: attention.revision,
|
|
69
|
+
live: true,
|
|
70
|
+
body,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function renderTextPage(css, title, text) {
|
|
74
|
+
return document({
|
|
75
|
+
css,
|
|
76
|
+
title,
|
|
77
|
+
needsYou: 0,
|
|
78
|
+
revision: "",
|
|
79
|
+
live: false,
|
|
80
|
+
body: `<a class="back" href="/">Console</a>
|
|
81
|
+
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
82
|
+
<pre class="text">${escapeHtml(text)}</pre></div>`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
export function renderMessagePage(css, title, message) {
|
|
86
|
+
return document({
|
|
87
|
+
css,
|
|
88
|
+
title,
|
|
89
|
+
needsYou: 0,
|
|
90
|
+
revision: "",
|
|
91
|
+
live: false,
|
|
92
|
+
body: `<a class="back" href="/">Console</a>
|
|
93
|
+
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
94
|
+
<p class="empty">${escapeHtml(message)}</p></div>`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function column(stage, items) {
|
|
98
|
+
const slim = items.length === 0;
|
|
99
|
+
const tone = stage.tone ? ` ${stage.tone}` : "";
|
|
100
|
+
const cards = items.map((item) => card(item, stage.id)).join("\n");
|
|
101
|
+
const count = items.length ? String(items.length) : "";
|
|
102
|
+
return `<section class="col${slim ? " slim" : ""}${tone}" id="col-${escapeHtml(stage.id)}" data-stage="${escapeHtml(stage.id)}" aria-label="${escapeHtml(stage.label)}, ${items.length}">
|
|
103
|
+
<div class="slim-face"><span class="count">${items.length}</span><div class="v"><h2>${escapeHtml(stage.label)}</h2><span class="who">${escapeHtml(stage.who)}</span></div></div>
|
|
104
|
+
<div class="full"><div class="col-head"><h2>${escapeHtml(stage.label)}</h2><span class="who">${escapeHtml(stage.who)}</span><span class="count">${escapeHtml(count)}</span></div>
|
|
105
|
+
${cards || ""}<div class="empty"${items.length > 0 ? " hidden" : ""}>none</div></div>
|
|
106
|
+
</section>`;
|
|
107
|
+
}
|
|
108
|
+
function card(item, stage) {
|
|
109
|
+
const job = item.job;
|
|
110
|
+
const waiting = scheduledRetry(job);
|
|
111
|
+
const classes = ["card"];
|
|
112
|
+
if (waiting)
|
|
113
|
+
classes.push("retrying");
|
|
114
|
+
else if (stage === "needs-human")
|
|
115
|
+
classes.push("human");
|
|
116
|
+
if (stage === "merged")
|
|
117
|
+
classes.push("merged");
|
|
118
|
+
if (stage === "queued")
|
|
119
|
+
classes.push("queued");
|
|
120
|
+
const deps = job.dependsOn.map((dep) => dep.jobId).join(" ");
|
|
121
|
+
const pulse = job.state === "running" ? '<span class="pulse" aria-label="running"></span>' : "";
|
|
122
|
+
const flag = stage === "needs-human" && !waiting ? '<span class="flag">your call</span>' : "";
|
|
123
|
+
const branch = branchOf(job);
|
|
124
|
+
return `<div role="button" tabindex="0" class="${classes.join(" ")}" id="job-${escapeHtml(job.jobId)}" data-job="${escapeHtml(job.jobId)}" data-deps="${escapeHtml(deps)}" data-repo="${escapeHtml(job.repo)}" data-stage="${escapeHtml(stage)}">
|
|
125
|
+
<div class="card-top">${pulse}<span class="num">${escapeHtml(job.jobId)}</span>${flag}</div>
|
|
126
|
+
<div class="branch repo">${escapeHtml(job.repo)}</div>
|
|
127
|
+
<p class="title">${escapeHtml(job.title || job.jobId)}</p>
|
|
128
|
+
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
129
|
+
${prLink(job)}
|
|
130
|
+
${roundsHtml(job, stage)}
|
|
131
|
+
${attemptHtml(job, stage)}
|
|
132
|
+
${waiting ? "" : noteHtml(item)}
|
|
133
|
+
${dependsHtml(job)}
|
|
134
|
+
</div>`;
|
|
135
|
+
}
|
|
136
|
+
function sheet(item) {
|
|
137
|
+
const job = item.job;
|
|
138
|
+
const files = displayFiles(job);
|
|
139
|
+
const round = lastRound(job);
|
|
140
|
+
const pr = safeUrl(job.prUrl);
|
|
141
|
+
const num = `${job.repo}/${job.jobId} · ${job.workflow} · ${job.state}${round ? ` · round ${round}` : ""}`;
|
|
142
|
+
const branch = branchOf(job);
|
|
143
|
+
return `<section class="sheet" data-job="${escapeHtml(job.jobId)}"${pr ? ` data-pr="${escapeHtml(pr)}"` : ""} hidden>
|
|
144
|
+
<div class="d-head">
|
|
145
|
+
<div class="num">${escapeHtml(num)}</div>
|
|
146
|
+
<h3>${escapeHtml(job.title || job.jobId)}</h3>
|
|
147
|
+
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
148
|
+
${prLink(job)}
|
|
149
|
+
${actionsHtml(item)}
|
|
150
|
+
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="d-body">${filesNav(files, job)}</div>
|
|
153
|
+
</section>`;
|
|
154
|
+
}
|
|
155
|
+
function filesNav(files, job) {
|
|
156
|
+
let group = "";
|
|
157
|
+
const buttons = files
|
|
158
|
+
.map((file, index) => {
|
|
159
|
+
const head = file.group !== group ? `<div class="group">${escapeHtml(file.group)}</div>` : "";
|
|
160
|
+
group = file.group;
|
|
161
|
+
const dot = file.tone ? ` ${file.tone}` : "";
|
|
162
|
+
return `${head}<button type="button" class="file${index === 0 ? " on" : ""}" data-file="${escapeHtml(file.id)}"><span class="dot${dot}"></span>${escapeHtml(file.label)}</button>`;
|
|
163
|
+
})
|
|
164
|
+
.join("");
|
|
165
|
+
const sources = files
|
|
166
|
+
.map((file) => `<pre class="src" data-file="${escapeHtml(file.id)}" hidden>${escapeHtml(file.text)}</pre>`)
|
|
167
|
+
.join("");
|
|
168
|
+
const empty = files.length === 0 ? '<div class="empty">Nothing written yet.</div>' : "";
|
|
169
|
+
return `<nav class="files">${buttons}${routeLinks(job)}${empty}</nav><article class="doc"><div class="md" data-view></div>${sources}</article>`;
|
|
170
|
+
}
|
|
171
|
+
function routeLinks(job) {
|
|
172
|
+
const id = encodeURIComponent(job.jobId);
|
|
173
|
+
const links = [];
|
|
174
|
+
if (job.specPath)
|
|
175
|
+
links.push(`<a class="file" href="/jobs/${id}/spec">spec</a>`);
|
|
176
|
+
if (job.logPath)
|
|
177
|
+
links.push(`<a class="file" href="/jobs/${id}/log">log</a>`);
|
|
178
|
+
if (job.findingsPath)
|
|
179
|
+
links.push(`<a class="file" href="/jobs/${id}/findings">Findings</a>`);
|
|
180
|
+
if (links.length === 0)
|
|
181
|
+
return "";
|
|
182
|
+
return `<div class="group">Open</div>${links.join("")}`;
|
|
183
|
+
}
|
|
184
|
+
function actionsHtml(item) {
|
|
185
|
+
const id = encodeURIComponent(item.job.jobId);
|
|
186
|
+
const forms = [];
|
|
187
|
+
if (item.job.canApprove) {
|
|
188
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/approve" class="act approve"><button type="submit">Approve</button></form>`);
|
|
189
|
+
}
|
|
190
|
+
if (item.job.canReject) {
|
|
191
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject"><button type="submit">Reject</button></form>`);
|
|
192
|
+
}
|
|
193
|
+
if (item.retry) {
|
|
194
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry"><button type="submit">Retry</button></form>`);
|
|
195
|
+
}
|
|
196
|
+
if (forms.length === 0)
|
|
197
|
+
return "";
|
|
198
|
+
return `<div class="d-actions">${forms.join("")}</div>`;
|
|
199
|
+
}
|
|
200
|
+
function roundsHtml(job, stage) {
|
|
201
|
+
const rounds = job.rounds ?? [];
|
|
202
|
+
const last = rounds[rounds.length - 1]?.n;
|
|
203
|
+
const rows = rounds.map((round) => roundRow(round, stage === "review" && job.state === "running" && round.n === last));
|
|
204
|
+
if (job.state === "in-review" || (job.state === "done" && safeUrl(job.prUrl))) {
|
|
205
|
+
const n = (last ?? 0) + 1;
|
|
206
|
+
const tone = job.state === "done" ? " PASS" : "";
|
|
207
|
+
rows.push(`<div class="round"><span class="r">R${n}</span><span class="chip${tone}" style="grid-column: 2 / -1">PR review</span></div>`);
|
|
208
|
+
}
|
|
209
|
+
if (rows.length === 0)
|
|
210
|
+
return "";
|
|
211
|
+
return `<div class="rounds">${rows.join("")}</div>`;
|
|
212
|
+
}
|
|
213
|
+
function roundRow(round, waiting) {
|
|
214
|
+
const cells = REVIEWERS.map(([key, label]) => {
|
|
215
|
+
const verdict = round[key];
|
|
216
|
+
const tone = verdict === "PASS" || verdict === "CHANGES" ? verdict : waiting && !verdict ? "wait" : "";
|
|
217
|
+
return `<span class="chip${tone ? ` ${tone}` : ""}" title="${escapeHtml(`${key}: ${verdict || "pending"}`)}">${label}</span>`;
|
|
218
|
+
}).join("");
|
|
219
|
+
return `<div class="round"><span class="r">R${round.n}</span>${cells}</div>`;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A scheduled retry is one quiet line in the stage column.
|
|
223
|
+
* A dead or non-retryable failure in Needs human keeps the attempt budget.
|
|
224
|
+
*/
|
|
225
|
+
function attemptHtml(job, stage) {
|
|
226
|
+
if (scheduledRetry(job) && job.nextRetryAt) {
|
|
227
|
+
const detail = [`attempt ${job.attemptCount}/${job.maxAttempts}`];
|
|
228
|
+
if (job.lastFailureReason)
|
|
229
|
+
detail.push(job.lastFailureReason);
|
|
230
|
+
return `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time> · ${escapeHtml(detail.join(" · "))}</div>`;
|
|
231
|
+
}
|
|
232
|
+
if (stage !== "needs-human" || (job.state !== "failed" && job.state !== "dead"))
|
|
233
|
+
return "";
|
|
234
|
+
if (job.attemptCount <= 0 && !job.lastFailureReason && !job.nextRetryAt)
|
|
235
|
+
return "";
|
|
236
|
+
const parts = [];
|
|
237
|
+
if (job.attemptCount > 0)
|
|
238
|
+
parts.push(`attempt ${job.attemptCount}/${job.maxAttempts}`);
|
|
239
|
+
if (job.lastFailureReason)
|
|
240
|
+
parts.push(job.lastFailureReason);
|
|
241
|
+
const line = parts.length > 0 ? `<div class="meta">${escapeHtml(parts.join(" · "))}</div>` : "";
|
|
242
|
+
const when = job.nextRetryAt
|
|
243
|
+
? `<div class="meta">next retry <time datetime="${escapeHtml(job.nextRetryAt)}">${escapeHtml(job.nextRetryAt)}</time></div>`
|
|
244
|
+
: "";
|
|
245
|
+
return `${line}${when}`;
|
|
246
|
+
}
|
|
247
|
+
function noteHtml(item) {
|
|
248
|
+
const lines = [noteText(item)];
|
|
249
|
+
if (item.errorTail) {
|
|
250
|
+
for (const line of item.errorTail.split("\n")) {
|
|
251
|
+
const trimmed = line.trim();
|
|
252
|
+
if (trimmed && !lines.some((existing) => existing.includes(trimmed)))
|
|
253
|
+
lines.push(trimmed);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return `<div class="note">${lines.map((line) => escapeHtml(line)).join("<br>")}</div>`;
|
|
257
|
+
}
|
|
258
|
+
function noteText(item) {
|
|
259
|
+
const job = item.job;
|
|
260
|
+
if (job.state === "done")
|
|
261
|
+
return safeUrl(job.prUrl) ? "merged via PR" : "landed locally";
|
|
262
|
+
return item.reason;
|
|
263
|
+
}
|
|
264
|
+
function dependsHtml(job) {
|
|
265
|
+
if (job.dependsOn.length === 0)
|
|
266
|
+
return "";
|
|
267
|
+
const links = job.dependsOn
|
|
268
|
+
.map((dep) => `<a href="#job-${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a>`)
|
|
269
|
+
.join(", ");
|
|
270
|
+
return `<p class="deps">depends on ${links}</p>`;
|
|
271
|
+
}
|
|
272
|
+
function prLink(job) {
|
|
273
|
+
const safe = safeUrl(job.prUrl);
|
|
274
|
+
if (safe) {
|
|
275
|
+
const text = job.prNumber ? `PR #${job.prNumber}` : safe;
|
|
276
|
+
return `<div class="branch"><a href="${escapeHtml(safe)}" target="_blank" rel="noopener">${escapeHtml(text)}</a></div>`;
|
|
277
|
+
}
|
|
278
|
+
if (job.prUrl)
|
|
279
|
+
return `<div class="branch">${escapeHtml(job.prUrl)}</div>`;
|
|
280
|
+
return "";
|
|
281
|
+
}
|
|
282
|
+
function branchOf(job) {
|
|
283
|
+
if (job.branch)
|
|
284
|
+
return job.branch;
|
|
285
|
+
if (job.runId)
|
|
286
|
+
return `yak/${job.runId}`;
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
function lastRound(job) {
|
|
290
|
+
const rounds = job.rounds ?? [];
|
|
291
|
+
return rounds.length > 0 ? rounds[rounds.length - 1]?.n : undefined;
|
|
292
|
+
}
|
|
293
|
+
function displayFiles(job) {
|
|
294
|
+
if (job.files && job.files.length > 0)
|
|
295
|
+
return job.files;
|
|
296
|
+
return readBoardFiles({
|
|
297
|
+
...(job.diffSummary && job.workflow !== INVESTIGATION_WORKFLOW ? { diffSummary: job.diffSummary } : {}),
|
|
298
|
+
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
299
|
+
...(job.workflow ? { workflow: job.workflow } : {}),
|
|
300
|
+
gates: job.gates.map((gate) => ({
|
|
301
|
+
stepId: gate.stepId,
|
|
302
|
+
prompt: gate.prompt,
|
|
303
|
+
...(gate.rendered ? { rendered: gate.rendered } : {}),
|
|
304
|
+
choices: gate.choices,
|
|
305
|
+
artifacts: gate.artifacts,
|
|
306
|
+
reviews: gate.reviews,
|
|
307
|
+
})),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function chip(id, label, count, pressed) {
|
|
311
|
+
return `<button type="button" class="repo-f" aria-pressed="${pressed ? "true" : "false"}" data-repo="${escapeHtml(id)}">${escapeHtml(label)}<span class="n">${count}</span></button>`;
|
|
312
|
+
}
|
|
313
|
+
function safeUrl(url) {
|
|
314
|
+
if (!url)
|
|
315
|
+
return undefined;
|
|
316
|
+
try {
|
|
317
|
+
const parsed = new URL(url);
|
|
318
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
319
|
+
return undefined;
|
|
320
|
+
return url;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function document(options) {
|
|
327
|
+
return `<!DOCTYPE html>
|
|
328
|
+
<html lang="en">
|
|
329
|
+
<head>
|
|
330
|
+
<meta charset="utf-8">
|
|
331
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
332
|
+
<title>${escapeHtml(options.title)}</title>
|
|
333
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono:wght@400;500&display=swap">
|
|
334
|
+
<style>
|
|
335
|
+
${options.css}
|
|
336
|
+
</style>
|
|
337
|
+
</head>
|
|
338
|
+
<body data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}">
|
|
339
|
+
${options.body}
|
|
340
|
+
${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
|
|
341
|
+
</body>
|
|
342
|
+
</html>
|
|
343
|
+
`;
|
|
344
|
+
}
|
|
345
|
+
export function escapeHtml(value) {
|
|
346
|
+
return value
|
|
347
|
+
.replaceAll("&", "&")
|
|
348
|
+
.replaceAll("<", "<")
|
|
349
|
+
.replaceAll(">", ">")
|
|
350
|
+
.replaceAll('"', """)
|
|
351
|
+
.replaceAll("'", "'");
|
|
352
|
+
}
|