forked from ChelseaKR/gtfs-scorecard
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtry.js
More file actions
210 lines (197 loc) · 7.97 KB
/
Copy pathtry.js
File metadata and controls
210 lines (197 loc) · 7.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
// @ts-check
/**
* Instant scoring form (infra/instant-score; growth-plans 03-A4). POSTs a
* GTFS Schedule URL to the try endpoint, polls the job until it lands, and
* renders the grade, category scores, and top fixes inline — no GitHub
* account, no wait for an issue comment. Degrades to pointing at the GitHub
* Issue Form below when the endpoint is not configured on this deployment.
*/
import { CATEGORY_LABELS, CATEGORY_ORDER } from "./generated/constants.js";
const TRY_URL = /** @type {any} */ (window).SCORECARD_TRY_URL || null;
const POLL_INTERVAL_MS = 3000;
const POLL_TIMEOUT_MS = 3 * 60 * 1000;
const form = /** @type {HTMLFormElement} */ (document.getElementById("try-form"));
const hosted = /** @type {HTMLElement} */ (document.getElementById("hosted-scorer"));
const status = /** @type {HTMLElement} */ (document.getElementById("try-status"));
const result = /** @type {HTMLElement} */ (document.getElementById("try-result"));
/** @param {string} message @param {"ok"|"err"|"info"} kind */
function setStatus(message, kind) {
status.textContent = message;
status.className = `form-status form-status-${kind}`;
}
/** Safe text escaping via the DOM (same technique as app.js's esc()). @param {unknown} text */
function esc(text) {
const div = document.createElement("div");
div.textContent = String(text);
return div.innerHTML;
}
/** Encode untrusted text for a quoted HTML attribute. `esc` deliberately
* leaves quote characters alone (safe in text content); a value that reaches
* an attribute must also neutralize `"`/`'` or it can close the attribute
* early and add its own (job data is user-submitted). @param {unknown} text */
function escAttr(text) {
return esc(text).replaceAll('"', """).replaceAll("'", "'");
}
/** Return the URL only if it is http(s); otherwise "#". Blocks javascript:/data:
* URLs in the job response from becoming clickable XSS sinks.
* @param {string} url @returns {string} */
function safeUrl(url) {
try {
const u = new URL(url, location.href);
return u.protocol === "http:" || u.protocol === "https:" ? u.href : "#";
} catch {
return "#";
}
}
/** @param {string} [grade] */
function gradeClass(grade) {
const normalized = String(grade || "F").toUpperCase();
const safe = ["A", "B", "C", "D", "F"].includes(normalized) ? normalized : "F";
return `grade-${safe.toLowerCase()}`;
}
if (!TRY_URL) {
// The available GitHub request is already first in the document. Keep an
// unavailable control out of the task flow instead of presenting a disabled
// primary button.
hosted.hidden = true;
} else {
hosted.hidden = false;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form).entries());
const url = String(data.url || "").trim();
const name = String(data.name || "").trim();
const country = String(data.country || "").trim().toUpperCase();
const urlField = /** @type {HTMLInputElement} */ (form.querySelector("#try-url"));
const countryField = /** @type {HTMLInputElement} */ (
form.querySelector("#try-country")
);
urlField.removeAttribute("aria-invalid");
countryField.removeAttribute("aria-invalid");
if (!urlField.validity.valid || !/^https?:\/\/.+/i.test(url)) {
setStatus("The GTFS Schedule URL should start with http:// or https://.", "err");
urlField.setAttribute("aria-invalid", "true");
urlField.focus();
return;
}
if (!countryField.validity.valid || !/^[A-Z]{2}$/.test(country)) {
setStatus("Enter a two-letter ISO country or territory code, like CA or NZ.", "err");
countryField.setAttribute("aria-invalid", "true");
countryField.focus();
return;
}
result.hidden = true;
result.innerHTML = "";
const button = /** @type {HTMLButtonElement} */ (form.querySelector(".submit-button"));
button.disabled = true;
setStatus("Starting the scorer…", "info");
try {
const resp = await fetch(TRY_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, name, country }),
});
const body = await resp.json().catch(() => ({}));
if (resp.status === 429) {
setStatus(body.error || "Too many requests. Try again in a bit.", "err");
return;
}
if (!resp.ok || !body.job_id) {
setStatus(body.error || "Something went wrong. Please try again.", "err");
return;
}
setStatus(
"Downloading your feed and running the validator. This takes about a minute…",
"info"
);
await poll(body.job_id, url, name, country);
} catch {
setStatus("Could not reach the scoring service. Please try again later.", "err");
} finally {
button.disabled = false;
}
});
}
/**
* @param {string} jobId
* @param {string} url
* @param {string} name
* @param {string} country
*/
async function poll(jobId, url, name, country) {
const deadline = Date.now() + POLL_TIMEOUT_MS;
const base = TRY_URL.replace(/\/$/, "");
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
/** @type {any} */
let job;
try {
const resp = await fetch(`${base}/score/${encodeURIComponent(jobId)}`);
job = await resp.json();
} catch {
continue; // a transient network blip; keep polling until the deadline
}
if (job.status === "done") {
setStatus("Done.", "ok");
await showResult(job, url, name, country);
return;
}
if (job.status === "error") {
setStatus(job.message || "We could not score that feed.", "err");
return;
}
}
setStatus(
"This is taking longer than expected. Check back in a minute, or try the GitHub form below.",
"err"
);
}
/**
* @param {any} job
* @param {string} url
* @param {string} name
* @param {string} country
*/
async function showResult(job, url, name, country) {
/** @type {any} */
let artifact = null;
if (job.result_url) {
try {
const resp = await fetch(job.result_url);
if (resp.ok) artifact = await resp.json();
} catch {
/* fall back to the grade-only summary below */
}
}
const trackUrl =
`submit.html?url=${encodeURIComponent(url)}` +
`${name ? `&name=${encodeURIComponent(name)}` : ""}` +
`&country=${encodeURIComponent(country)}`;
if (!artifact) {
result.innerHTML = `
<h2 class="section-title" id="try-result-h" tabindex="-1">Overall grade: ${esc(job.grade || "—")}</h2>
<p><a href="${escAttr(safeUrl(job.result_url || "#"))}">View the full result</a></p>
<p class="field"><a class="submit-button" href="${escAttr(trackUrl)}">Track this feed daily</a></p>`;
} else {
const cats = CATEGORY_ORDER.map((key) => {
const cat = artifact.categories?.[key];
const label = esc(CATEGORY_LABELS[key] || key);
if (!cat || cat.status !== "measured") return `<dt>${label}</dt><dd>not yet measured</dd>`;
return `<dt>${label}</dt><dd>${esc(Math.round(cat.score))}/100</dd>`;
}).join("");
const fixes = (artifact.top_fixes || [])
.slice(0, 3)
.map((f) => `<li>${esc(f.fix)} <span class="hint">(${esc(f.effort)})</span></li>`)
.join("");
result.innerHTML = `
<h2 class="section-title" id="try-result-h" tabindex="-1">Overall grade:
<span class="grade-chip ${gradeClass(artifact.overall?.grade)}">${esc(artifact.overall?.grade || "—")}<span class="visually-hidden"> grade</span></span>
(${esc(String(artifact.overall?.score ?? "—"))}/100)</h2>
<dl>${cats}</dl>
${fixes ? `<h3>Top things to fix</h3><ol>${fixes}</ol>` : "<p>Nothing urgent turned up. This feed passed every check we translate into fixes.</p>"}
<p><a href="${escAttr(safeUrl(job.result_url || "#"))}">View the full JSON result</a></p>
<p class="field"><a class="submit-button" href="${escAttr(trackUrl)}">Track this feed daily</a></p>`;
}
result.hidden = false;
/** @type {HTMLElement | null} */ (result.querySelector("#try-result-h"))?.focus();
}