@taprootio/site-authoring 0.20.0 → 0.21.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.
@@ -0,0 +1,337 @@
1
+ // Generated from shared/site-checks.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * The site checks the CLI can evaluate offline (TR01085).
4
+ *
5
+ * `shared/site-check-rules.json` is the catalogue of rules; this file is the
6
+ * TypeScript interpretation of the ones that need only a page body or a redirect
7
+ * source. The API carries a second interpretation in C#
8
+ * (`Taproot.Domain/SiteChecks`), and both must pass
9
+ * `shared/site-check-corpus.json`. Results carry rule ids and values, never
10
+ * wording: each surface writes its own words.
11
+ *
12
+ * It is dependency-free so the published site-authoring package can carry a
13
+ * compiled copy. The package cannot import this file, so
14
+ * `node scripts/sync-field-validation.mjs` writes that copy; edit only this file
15
+ * and re-run it.
16
+ */
17
+ export const RULE_LINK_PAGE_MISSING = "link.page_missing";
18
+ export const RULE_REDIRECT_SOURCE_INVALID = "redirect.source_invalid";
19
+ export const RULE_REDIRECT_SOURCE_INDEX_HTML = "redirect.source_index_html";
20
+ export const RULE_REDIRECT_SOURCE_INTEGRATION_DATA = "redirect.source_integration_data";
21
+ export const RULE_REDIRECT_SOURCE_SHARE_LINK = "redirect.source_share_link";
22
+ export const RULE_REDIRECT_SOURCE_RUNTIME_MIRROR = "redirect.source_runtime_mirror";
23
+ export const RULE_REDIRECT_SOURCE_GENERATED_FILE = "redirect.source_generated_file";
24
+ export const RULE_REDIRECT_SOURCE_NOT_STORABLE = "redirect.source_not_storable";
25
+ const NON_PAGE_EXTENSIONS = new Set([
26
+ ".avif", ".css", ".csv", ".doc", ".docx", ".gif", ".ico",
27
+ ".jpeg", ".jpg", ".js", ".json", ".m4a", ".mov", ".mp3",
28
+ ".mp4", ".pdf", ".png", ".svg", ".txt", ".wav", ".webm",
29
+ ".webp", ".xls", ".xlsx", ".xml", ".zip",
30
+ ]);
31
+ // The HTML 4 named character references, the table .NET's WebUtility.HtmlDecode (the server's decoder) uses,
32
+ // as name:code point pairs, with apos added.
33
+ const NAMED_ENTITY_CODES = "AElig:198 Aacute:193 Acirc:194 Agrave:192 Alpha:913 Aring:197 Atilde:195 Auml:196 Beta:914 Ccedil:199 " +
34
+ "Chi:935 Dagger:8225 Delta:916 ETH:208 Eacute:201 Ecirc:202 Egrave:200 Epsilon:917 Eta:919 Euml:203 Gamma:915 " +
35
+ "Iacute:205 Icirc:206 Igrave:204 Iota:921 Iuml:207 Kappa:922 Lambda:923 Mu:924 Ntilde:209 Nu:925 OElig:338 " +
36
+ "Oacute:211 Ocirc:212 Ograve:210 Omega:937 Omicron:927 Oslash:216 Otilde:213 Ouml:214 Phi:934 Pi:928 " +
37
+ "Prime:8243 Psi:936 Rho:929 Scaron:352 Sigma:931 THORN:222 Tau:932 Theta:920 Uacute:218 Ucirc:219 Ugrave:217 " +
38
+ "Upsilon:933 Uuml:220 Xi:926 Yacute:221 Yuml:376 Zeta:918 aacute:225 acirc:226 acute:180 aelig:230 agrave:224 " +
39
+ "alefsym:8501 alpha:945 amp:38 and:8743 ang:8736 aring:229 asymp:8776 atilde:227 auml:228 bdquo:8222 beta:946 " +
40
+ "brvbar:166 bull:8226 cap:8745 ccedil:231 cedil:184 cent:162 chi:967 circ:710 clubs:9827 cong:8773 copy:169 " +
41
+ "crarr:8629 cup:8746 curren:164 dArr:8659 dagger:8224 darr:8595 deg:176 delta:948 diams:9830 divide:247 " +
42
+ "eacute:233 ecirc:234 egrave:232 empty:8709 emsp:8195 ensp:8194 epsilon:949 equiv:8801 eta:951 eth:240 " +
43
+ "euml:235 euro:8364 exist:8707 fnof:402 forall:8704 frac12:189 frac14:188 frac34:190 frasl:8260 gamma:947 " +
44
+ "ge:8805 gt:62 hArr:8660 harr:8596 hearts:9829 hellip:8230 iacute:237 icirc:238 iexcl:161 igrave:236 " +
45
+ "image:8465 infin:8734 int:8747 iota:953 iquest:191 isin:8712 iuml:239 kappa:954 lArr:8656 lambda:955 " +
46
+ "lang:9001 laquo:171 larr:8592 lceil:8968 ldquo:8220 le:8804 lfloor:8970 lowast:8727 loz:9674 lrm:8206 " +
47
+ "lsaquo:8249 lsquo:8216 lt:60 macr:175 mdash:8212 micro:181 middot:183 minus:8722 mu:956 nabla:8711 nbsp:160 " +
48
+ "ndash:8211 ne:8800 ni:8715 not:172 notin:8713 nsub:8836 ntilde:241 nu:957 oacute:243 ocirc:244 oelig:339 " +
49
+ "ograve:242 oline:8254 omega:969 omicron:959 oplus:8853 or:8744 ordf:170 ordm:186 oslash:248 otilde:245 " +
50
+ "otimes:8855 ouml:246 para:182 part:8706 permil:8240 perp:8869 phi:966 pi:960 piv:982 plusmn:177 pound:163 " +
51
+ "prime:8242 prod:8719 prop:8733 psi:968 quot:34 rArr:8658 radic:8730 rang:9002 raquo:187 rarr:8594 rceil:8969 " +
52
+ "rdquo:8221 real:8476 reg:174 rfloor:8971 rho:961 rlm:8207 rsaquo:8250 rsquo:8217 sbquo:8218 scaron:353 " +
53
+ "sdot:8901 sect:167 shy:173 sigma:963 sigmaf:962 sim:8764 spades:9824 sub:8834 sube:8838 sum:8721 sup:8835 " +
54
+ "sup1:185 sup2:178 sup3:179 supe:8839 szlig:223 tau:964 there4:8756 theta:952 thetasym:977 thinsp:8201 " +
55
+ "thorn:254 tilde:732 times:215 trade:8482 uArr:8657 uacute:250 uarr:8593 ucirc:251 ugrave:249 uml:168 " +
56
+ "upsih:978 upsilon:965 uuml:252 weierp:8472 xi:958 yacute:253 yen:165 yuml:255 zeta:950 zwj:8205 zwnj:8204";
57
+ const NAMED_ENTITIES = {
58
+ apos: "'",
59
+ ...Object.fromEntries(NAMED_ENTITY_CODES.split(" ").map((pair) => {
60
+ const [name, code] = pair.split(":");
61
+ return [name, String.fromCodePoint(Number(code))];
62
+ })),
63
+ };
64
+ function htmlDecode(value) {
65
+ return value.replace(/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));/gu, (match, decimal, hex, name) => {
66
+ if (name !== undefined)
67
+ return NAMED_ENTITIES[name] ?? match;
68
+ const code = decimal !== undefined ? Number.parseInt(decimal, 10) : Number.parseInt(hex, 16);
69
+ return Number.isInteger(code) && code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff)
70
+ ? String.fromCodePoint(code)
71
+ : match;
72
+ });
73
+ }
74
+ function htmlDecodeRepeatedly(value) {
75
+ let decoded = value;
76
+ for (let index = 0; index < 3; index++) {
77
+ const next = htmlDecode(decoded);
78
+ if (next === decoded)
79
+ break;
80
+ decoded = next;
81
+ }
82
+ return decoded;
83
+ }
84
+ /** Percent-decoding that leaves a malformed run as written, the way .NET's does. */
85
+ function unescapeData(value) {
86
+ return value.replace(/(?:%[0-9a-fA-F]{2})+/gu, (run) => {
87
+ try {
88
+ return decodeURIComponent(run);
89
+ }
90
+ catch {
91
+ return run;
92
+ }
93
+ });
94
+ }
95
+ /** The stored spelling of a page path: slashes trimmed, backslashes forward. */
96
+ export function normalizeStoredPath(path) {
97
+ const normalized = (path ?? "").trim().replaceAll("\\", "/").replace(/^\/+|\/+$/gu, "");
98
+ return normalized === "." ? "" : normalized;
99
+ }
100
+ function normalizeSegments(path) {
101
+ const segments = [];
102
+ for (const segment of path.split("/")) {
103
+ if (segment === "" || segment === ".")
104
+ continue;
105
+ if (segment === "..") {
106
+ if (segments.length === 0)
107
+ return null;
108
+ segments.pop();
109
+ continue;
110
+ }
111
+ segments.push(segment);
112
+ }
113
+ return segments.join("/").replace(/\/+$/gu, "");
114
+ }
115
+ function resolveRelativePath(currentPagePath, relative) {
116
+ const current = normalizeStoredPath(currentPagePath);
117
+ const slash = current.lastIndexOf("/");
118
+ const directory = slash >= 0 ? current.slice(0, slash) : "";
119
+ return normalizeSegments(directory.length === 0 ? relative : `${directory}/${relative}`);
120
+ }
121
+ function isNonPageAsset(path) {
122
+ const fileName = path.slice(path.lastIndexOf("/") + 1);
123
+ const extensionIndex = fileName.lastIndexOf(".");
124
+ return extensionIndex >= 0 && NON_PAGE_EXTENSIONS.has(fileName.slice(extensionIndex).toLowerCase());
125
+ }
126
+ function addCandidate(links, rawValue, currentPagePath) {
127
+ let value = htmlDecode(rawValue).trim();
128
+ if (value.length === 0 || value.startsWith("#") || value.startsWith("?") || value.startsWith("//")
129
+ || value.includes("\\"))
130
+ return;
131
+ const isRootRelative = value.startsWith("/");
132
+ if (!isRootRelative && /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value))
133
+ return;
134
+ let delimiter = value.search(/[?#]/u);
135
+ if (delimiter >= 0)
136
+ value = value.slice(0, delimiter);
137
+ if (value.length === 0)
138
+ return;
139
+ value = unescapeData(value);
140
+ // Encoded query and fragment delimiters become visible only after decoding.
141
+ delimiter = value.search(/[?#]/u);
142
+ if (delimiter >= 0)
143
+ value = value.slice(0, delimiter);
144
+ if (value.length === 0)
145
+ return;
146
+ const target = isRootRelative ? normalizeSegments(value) : resolveRelativePath(currentPagePath, value);
147
+ if (target === null || isNonPageAsset(target))
148
+ return;
149
+ const link = target.length === 0 ? "/" : `/${target}`;
150
+ const key = link.toUpperCase();
151
+ if (!links.has(key))
152
+ links.set(key, link);
153
+ }
154
+ const HREF_PATTERN = /\bhref\s*=\s*(?:["']([^"']*)["']|([^\s"'=<>`]+))/giu;
155
+ function addHtmlLinks(links, html, currentPagePath) {
156
+ for (const match of htmlDecodeRepeatedly(html).matchAll(HREF_PATTERN)) {
157
+ addCandidate(links, match[1] ?? match[2] ?? "", currentPagePath);
158
+ }
159
+ }
160
+ function isUrlProperty(name) {
161
+ const lower = name.toLowerCase();
162
+ return lower === "href" || lower === "url" || lower === "link" || lower === "pagepath";
163
+ }
164
+ function addEncodedComponentLinks(links, value, currentPagePath) {
165
+ const decoded = htmlDecodeRepeatedly(value);
166
+ let parsed;
167
+ try {
168
+ parsed = JSON.parse(decoded);
169
+ }
170
+ catch {
171
+ if (/<a/iu.test(decoded))
172
+ addHtmlLinks(links, decoded, currentPagePath);
173
+ return;
174
+ }
175
+ addLinks(links, parsed, currentPagePath);
176
+ }
177
+ function addLinks(links, element, currentPagePath) {
178
+ if (Array.isArray(element)) {
179
+ for (const item of element)
180
+ addLinks(links, item, currentPagePath);
181
+ return;
182
+ }
183
+ if (element === null || typeof element !== "object")
184
+ return;
185
+ for (const [name, value] of Object.entries(element)) {
186
+ if (typeof value === "string") {
187
+ if (isUrlProperty(name))
188
+ addCandidate(links, value, currentPagePath);
189
+ if (/<a/iu.test(value))
190
+ addHtmlLinks(links, value, currentPagePath);
191
+ if (name.toLowerCase() === "componentdata")
192
+ addEncodedComponentLinks(links, value, currentPagePath);
193
+ }
194
+ else {
195
+ addLinks(links, value, currentPagePath);
196
+ }
197
+ }
198
+ }
199
+ /**
200
+ * The page-shaped internal link targets a page body holds, as `/path`, in
201
+ * case-insensitive order. It reads rich-text hrefs, HTML anchors and URL-bearing
202
+ * component properties, and skips external URLs, fragments, queries and asset
203
+ * extensions. `templateData` is the parsed body.
204
+ */
205
+ export function extractPageLinks(templateData, currentPagePath) {
206
+ const links = new Map();
207
+ addLinks(links, templateData, currentPagePath);
208
+ return [...links.entries()]
209
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
210
+ .map(([, link]) => link);
211
+ }
212
+ /** The set `missingPageLinkFindings` looks links up in: page paths in the stored spelling, lower-cased. Build it once per site. */
213
+ export function normalizedPathSet(pagePaths) {
214
+ return new Set([...pagePaths].map((path) => normalizeStoredPath(path).toLowerCase()));
215
+ }
216
+ /**
217
+ * The `link.page_missing` findings for one page: every internal link whose
218
+ * target is not in `knownPaths` (see `normalizedPathSet`).
219
+ */
220
+ export function missingPageLinkFindings(templateData, currentPagePath, knownPaths) {
221
+ return extractPageLinks(templateData, currentPagePath)
222
+ .filter((link) => !knownPaths.has(normalizeStoredPath(link).toLowerCase()))
223
+ .map((link) => ({ ruleId: RULE_LINK_PAGE_MISSING, severity: "warning", subject: link, detail: "" }));
224
+ }
225
+ // ---------------------------------------------------------------- redirects
226
+ const RUNTIME_MIRROR_PATH = /^taproot\/[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\/|$)/iu;
227
+ /** Whether a site-relative path (no leading slash) is `taproot/<semver>` or under it. */
228
+ export function isRuntimeMirrorPath(path) {
229
+ return RUNTIME_MIRROR_PATH.test(path);
230
+ }
231
+ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/u;
232
+ // A spelling no browser would send as a pathname: anything outside printable ASCII, a character a URL
233
+ // parser escapes on sight, or a '%' that is not an escape. No `i` flag: Unicode case folding would let
234
+ // U+212A and U+017F through `[^!-~]`.
235
+ const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
236
+ const RESOLVABLE_SPELLING = /\/\/|%2e|%2f/iu;
237
+ /** Whether a path is written the way a browser sends a pathname. */
238
+ export function isRequestSpelling(value) {
239
+ return !UNSENDABLE_SPELLING.test(value);
240
+ }
241
+ /**
242
+ * Whether a path names one thing to this pass and another wherever it is resolved: an empty interior
243
+ * segment, a dot segment, or a dot or slash hidden behind percent-encoding.
244
+ */
245
+ export function resolvesAway(value) {
246
+ if (RESOLVABLE_SPELLING.test(value))
247
+ return true;
248
+ return value.split("/").some((segment) => segment === "." || segment === "..");
249
+ }
250
+ /**
251
+ * Normalizes a source path as the published-site Worker's `normalizePathname`
252
+ * does: require the leading slash, strip trailing slashes, change nothing else.
253
+ * Returns undefined for anything that is not an absolute site path, or that some
254
+ * other layer would resolve away or no browser would send.
255
+ */
256
+ export function normalizeRedirectPath(value) {
257
+ if (typeof value !== "string")
258
+ return undefined;
259
+ const candidate = value.trim();
260
+ if (candidate === "" || /[?#\\]/u.test(candidate) || !isRequestSpelling(candidate) || resolvesAway(candidate)) {
261
+ return undefined;
262
+ }
263
+ const withSlash = candidate.startsWith("/") ? candidate : `/${candidate}`;
264
+ const trimmed = withSlash.replace(/\/+$/u, "");
265
+ return trimmed === "" ? "/" : trimmed;
266
+ }
267
+ const GENERATED_ROOT_FILES = ["404.html", "sitemap.xml", "robots.txt", "favicon.ico"];
268
+ const INTEGRATION_DATA_ROOT = "integrations";
269
+ const SHARE_LINK_ROOT = "s";
270
+ const MAX_MARKER_SEGMENT_BYTES = 200;
271
+ const MAX_OUTPUT_PATH_LENGTH = 1_024;
272
+ const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
273
+ function isWindowsDeviceSegment(segment) {
274
+ const stem = segment.split(".", 1)[0].toLowerCase();
275
+ return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
276
+ }
277
+ function isStorable(decoded) {
278
+ const output = `${decoded}/index.html`;
279
+ if (output !== output.normalize("NFC") || output.length > MAX_OUTPUT_PATH_LENGTH || output.startsWith("/")
280
+ || /[\\%?#]/u.test(output) || CONTROL_CHARACTERS.test(output))
281
+ return false;
282
+ const segments = decoded.split("/");
283
+ return !decoded.includes("..") && !segments.some((segment) => segment === "" || segment === "." || segment.startsWith(".") || isWindowsDeviceSegment(segment)
284
+ || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES);
285
+ }
286
+ /**
287
+ * Which redirect source rule an edge-form source path breaks, or undefined when
288
+ * it can be a redirect. `detail` is the first decoded path segment, which two of
289
+ * the refusals name. The site root is left to the occupancy rule.
290
+ */
291
+ export function redirectSourceRule(edgePath) {
292
+ if (edgePath === "/")
293
+ return undefined;
294
+ // Every rule reads the decoded spelling, the name the marker is stored under.
295
+ const decoded = unescapeData(edgePath.replace(/^\/+/u, "").replace(/\/+$/u, ""));
296
+ const segments = decoded.split("/");
297
+ const first = segments[0];
298
+ const refuse = (ruleId) => ({ ruleId, detail: first });
299
+ if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
300
+ return refuse(RULE_REDIRECT_SOURCE_INDEX_HTML);
301
+ }
302
+ if (first.toLowerCase() === INTEGRATION_DATA_ROOT)
303
+ return refuse(RULE_REDIRECT_SOURCE_INTEGRATION_DATA);
304
+ const shareSegments = decoded.replace(/^\/+|\/+$/gu, "").split("/");
305
+ if (shareSegments.length > 1 && shareSegments[1].length > 0 && shareSegments[0].toLowerCase() === SHARE_LINK_ROOT) {
306
+ return refuse(RULE_REDIRECT_SOURCE_SHARE_LINK);
307
+ }
308
+ if (isRuntimeMirrorPath(decoded))
309
+ return refuse(RULE_REDIRECT_SOURCE_RUNTIME_MIRROR);
310
+ if (GENERATED_ROOT_FILES.includes(first.toLowerCase()))
311
+ return refuse(RULE_REDIRECT_SOURCE_GENERATED_FILE);
312
+ if (!isStorable(decoded))
313
+ return refuse(RULE_REDIRECT_SOURCE_NOT_STORABLE);
314
+ return undefined;
315
+ }
316
+ /**
317
+ * The normalized source and the finding for what a caller submitted. A source
318
+ * that normalizes to nothing is `redirect.source_invalid`; otherwise the first
319
+ * source rule it breaks. `finding` is undefined when the source is acceptable.
320
+ */
321
+ export function checkRedirectSource(submitted) {
322
+ const path = normalizeRedirectPath(submitted);
323
+ if (path === undefined) {
324
+ return {
325
+ finding: {
326
+ ruleId: RULE_REDIRECT_SOURCE_INVALID,
327
+ severity: "error",
328
+ subject: typeof submitted === "string" ? submitted : "",
329
+ detail: "",
330
+ },
331
+ };
332
+ }
333
+ const refusal = redirectSourceRule(path);
334
+ return refusal === undefined
335
+ ? { path }
336
+ : { path, finding: { ruleId: refusal.ruleId, severity: "error", subject: path, detail: refusal.detail } };
337
+ }
@@ -1,4 +1,5 @@
1
1
  import { getSiteRedirectMap, mintStagingPreviewHandoff } from "./api.js";
2
+ import { CLI_BINARY_NAME } from "./constants.js";
2
3
  import { sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
3
4
 
4
5
  const COOKIE = "__Host-taproot_staging_preview";
@@ -6,8 +7,8 @@ const CHECK = "__taproot_preview_check";
6
7
  const TIMEOUT = 5_000;
7
8
  const REPORT_BYTES = 28_000;
8
9
 
9
- async function request(client, url, cookie, signal) {
10
- const response = await client.fetch(url, {
10
+ async function send(client, url, cookie, signal, userAgent) {
11
+ return await client.fetch(url, {
11
12
  method: "GET",
12
13
  redirect: "manual",
13
14
  signal: AbortSignal.any([
@@ -17,10 +18,19 @@ async function request(client, url, cookie, signal) {
17
18
  ]),
18
19
  headers: {
19
20
  accept: "text/html",
20
- "user-agent": "taproot-site/staging-redirect-check",
21
+ "user-agent": userAgent,
21
22
  ...(cookie ? { cookie } : {}),
22
23
  },
23
24
  });
25
+ }
26
+
27
+ /** One GET on the staging host with the checker's cookie; the caller owns the body. */
28
+ export async function fetchStagingPage(client, url, cookie, signal) {
29
+ return await send(client, url, cookie, signal, "taproot-site/staging-served-check");
30
+ }
31
+
32
+ async function request(client, url, cookie, signal) {
33
+ const response = await send(client, url, cookie, signal, "taproot-site/staging-redirect-check");
24
34
  await response.body?.cancel().catch(() => {});
25
35
  return response;
26
36
  }
@@ -154,11 +164,36 @@ export async function checkStagingRedirects(client, siteId, onProgress, now) {
154
164
  }
155
165
 
156
166
  /**
157
- * The staging redirect check and a fresh preview handoff, as redirects check and
158
- * deploy --staging report them. A check that cannot finish keeps the same shape,
159
- * with the reason.
167
+ * What deploy --staging and redirects check report in place of a review URL
168
+ * when no handoff was asked for (TR01233). The URL is a live single-use
169
+ * credential, so it stays out of the JSON that agents save to logs unless the
170
+ * caller passes --handoff. The staging origin is not secret and is kept.
171
+ */
172
+ export function handoffNotRequested(stagingUrl) {
173
+ return {
174
+ url: "",
175
+ ...(stagingUrl ? { stagingUrl } : {}),
176
+ reason: "staging.handoff_not_requested",
177
+ recovery: `Run '${CLI_BINARY_NAME} staging review' to mint a single-use review URL (add --path <page> to land on `
178
+ + "a page), or rerun this command with --handoff.",
179
+ };
180
+ }
181
+
182
+ export const HANDOFF_NOT_REQUESTED_PROGRESS =
183
+ `No review URL was minted. Run '${CLI_BINARY_NAME} staging review' when you want one, or pass --handoff.`;
184
+
185
+ /**
186
+ * The staging redirect check and, only when `handoff` is true, a fresh preview
187
+ * handoff, as redirects check and deploy --staging report them. A check that
188
+ * cannot finish keeps the same shape, with the reason.
160
189
  */
161
- export async function inspectStagingPreview(client, siteId, onProgress, now) {
190
+ export async function inspectStagingPreview(
191
+ client,
192
+ siteId,
193
+ onProgress,
194
+ now,
195
+ { handoff = false, returnPath = "/" } = {},
196
+ ) {
162
197
  let routeCheck;
163
198
  let redirects;
164
199
  let stagingUrl;
@@ -184,9 +219,13 @@ export async function inspectStagingPreview(client, siteId, onProgress, now) {
184
219
  error: redirectCheckError(error),
185
220
  };
186
221
  }
222
+ if (!handoff) {
223
+ onProgress(HANDOFF_NOT_REQUESTED_PROGRESS);
224
+ return { routeCheck, redirects, stagingPreview: handoffNotRequested(stagingUrl) };
225
+ }
187
226
  try {
188
227
  // Mint after checks so the final single-use URL has never been consumed.
189
- const stagingPreview = await mintStagingPreviewHandoff(client, siteId, { now });
228
+ const stagingPreview = await mintStagingPreviewHandoff(client, siteId, { now, returnPath });
190
229
  return { routeCheck, redirects, stagingPreview };
191
230
  } catch {
192
231
  onProgress("Warning: a staging handoff could not be minted.");
@@ -196,7 +235,7 @@ export async function inspectStagingPreview(client, siteId, onProgress, now) {
196
235
  stagingPreview: {
197
236
  url: "",
198
237
  ...(stagingUrl ? { stagingUrl } : {}),
199
- warning: "Staging handoff unavailable; run redirects check to retry.",
238
+ warning: `Staging handoff unavailable; run '${CLI_BINARY_NAME} staging review' to mint one.`,
200
239
  },
201
240
  };
202
241
  }