@taprootio/site-authoring 0.16.0 → 0.18.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 +60 -13
- package/package.json +1 -1
- package/src/api.js +129 -11
- package/src/cli.js +41 -1
- package/src/constants.js +16 -0
- package/src/reference-help.js +1 -1
- package/src/transport.js +73 -12
- package/src/typed-page-reference.js +3 -1
- package/src/verbs/deploy.js +24 -4
- package/src/verbs/index.js +9 -1
- package/src/verbs/media-upload.js +0 -1
- package/src/verbs/places.js +72 -2
- package/src/verbs/status.js +1 -4
package/README.md
CHANGED
|
@@ -464,7 +464,8 @@ moved since this workspace last reconciled with it, naming both revisions;
|
|
|
464
464
|
names and reports predictions with a session token; `places select
|
|
465
465
|
<googlePlaceId> <sessionToken>` records the match as a Taproot place and
|
|
466
466
|
reports the `placeId` a place review's front matter needs. An article or an
|
|
467
|
-
album can name its place the same way with `place:`.
|
|
467
|
+
album can name its place the same way with `place:`. `places category set
|
|
468
|
+
<placeId> <category>` changes the category this site shows for a place.
|
|
468
469
|
|
|
469
470
|
`plan` checks the whole workspace against the live site — pages, the media
|
|
470
471
|
they reference and the images the settings name, the footer and the pages it
|
|
@@ -567,8 +568,10 @@ paused; retry later), `credential_rejected` (stop and re-issue),
|
|
|
567
568
|
not exchanged for a capability the request needs; the refusal carries
|
|
568
569
|
`field: "GrantedCapabilities"`, and the CLI's human progress names the
|
|
569
570
|
granted and required capabilities, so report the verb's declared set rather
|
|
570
|
-
than re-issuing the key), `plan_limit` (the plan's published-page allowance
|
|
571
|
-
|
|
571
|
+
than re-issuing the key), `plan_limit` (the plan's published-page allowance or
|
|
572
|
+
its pages-generated allowance, or the bandwidth pause on deploys,
|
|
573
|
+
surfaced at deploy; the allowance shows its reset time, while the bandwidth pause shows the earliest
|
|
574
|
+
time it can lift, since it lifts only after a month ends under the allowance or on upgrade), and `throttled` (back off).
|
|
572
575
|
|
|
573
576
|
When `GITHUB_OUTPUT` names the runner's existing output file, every
|
|
574
577
|
operational verb appends the same JSON under `taproot_site_result` through a
|
|
@@ -635,17 +638,49 @@ Other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal `<br>` tags are rejecte
|
|
|
635
638
|
with the exact fact field path. A label is optional and stays outside the value's link. See
|
|
636
639
|
`taproot-site help page free-form` and `taproot-site help component cta`.
|
|
637
640
|
|
|
638
|
-
###
|
|
641
|
+
### Place categories in 0.18.0
|
|
639
642
|
|
|
640
|
-
A
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
643
|
+
A place has one category for every site, taken from Google's types, and a place
|
|
644
|
+
that Taproot could not classify is filed under `Other`. A site can file a place
|
|
645
|
+
under a category of its own:
|
|
646
|
+
|
|
647
|
+
```
|
|
648
|
+
taproot-site places category list
|
|
649
|
+
taproot-site places category set <placeId> park
|
|
650
|
+
taproot-site places category clear <placeId>
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
`list` prints the closed list `set` accepts; a name outside it is refused, and
|
|
654
|
+
capitalization is ignored. The category is the site's alone and applies to every
|
|
655
|
+
review of that place on the site: the place itself and other sites do not
|
|
656
|
+
change. Place-review bylines and the `/places/<category>` pages use it from the
|
|
657
|
+
site's next deployment, including reviews nobody has edited. `clear` removes it.
|
|
658
|
+
Both need the same page-editing permission as `places select`.
|
|
659
|
+
|
|
660
|
+
### Pages generated in 0.18.0
|
|
661
|
+
|
|
662
|
+
A site's licence allows a number of pages generated per billing period, and
|
|
663
|
+
`deploy --staging` is charged by the pages it generates, not by the deployment.
|
|
664
|
+
The deploy asks Taproot to plan the candidate first and reports the plan before
|
|
665
|
+
it sends anything: `This will generate 74 pages · 940 of 1,000 left this period
|
|
666
|
+
(resets 2026-11-01T00:00:00.000Z)`. The same figures are in `readiness.pageEstimate`
|
|
667
|
+
(`plannedPageCount`, `pagesGeneratedLimit`, `pagesGeneratedRemaining`, `periodEnd`
|
|
668
|
+
and `exceedsAllowance`; a limit and what remains are `null` on an unlimited
|
|
669
|
+
licence). The number shown is the number charged. Promoting a staged release to
|
|
670
|
+
production generates nothing and costs no pages.
|
|
671
|
+
|
|
672
|
+
A candidate that does not fit stops with `deploy.page_allowance_exceeded`, naming
|
|
673
|
+
the pages it needs, the pages left, and the reset. A deploy refused by the server
|
|
674
|
+
for the same reason, or for the bandwidth pause, reports `plan_limit` with the
|
|
675
|
+
time it can lift (and `requested_pages` and `remaining_pages` for the allowance):
|
|
676
|
+
the allowance's reset, or for the bandwidth pause the earliest month end that can
|
|
677
|
+
clear it (an upgrade clears it at once).
|
|
678
|
+
|
|
679
|
+
### Deployment allowances in 0.18.0
|
|
680
|
+
|
|
681
|
+
A deploy refused for the bandwidth pause reports `plan_limit` with the time it
|
|
682
|
+
can lift: the earliest month end that can clear it (an upgrade clears it at
|
|
683
|
+
once).
|
|
649
684
|
|
|
650
685
|
### Authoring feedback in 0.16.0
|
|
651
686
|
|
|
@@ -666,6 +701,18 @@ old default, `noopener noreferrer nofollow`, reads as no choice. See
|
|
|
666
701
|
generated pages navigation links to), and `validate` and `pages push` warn on
|
|
667
702
|
links that look made by autolinking (`content.link_autolinked`).
|
|
668
703
|
|
|
704
|
+
### Link rel in 0.15.0
|
|
705
|
+
|
|
706
|
+
A link publishes followed unless its author chooses otherwise. A link mark's
|
|
707
|
+
`rel` takes space-separated link types (`nofollow`, `sponsored`, `ugc`, `me`,
|
|
708
|
+
and the other standard types except `opener`), on any link; `target: "_blank"`
|
|
709
|
+
opens a new tab with `noopener noreferrer` added. In Markdown, write
|
|
710
|
+
`[Shop](https://shop.example){rel="sponsored nofollow" target="_blank"}`
|
|
711
|
+
(either attribute alone works). Other tokens and
|
|
712
|
+
targets are refused (`content.link_rel`, `content.link_target`). The editor's
|
|
713
|
+
old default, `noopener noreferrer nofollow`, reads as no choice. See
|
|
714
|
+
`taproot-site help page free-form`.
|
|
715
|
+
|
|
669
716
|
### Staging verification and deployment evidence
|
|
670
717
|
|
|
671
718
|
`deploy --staging` selects the same changed pages, settings groups and navigation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
package/src/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeLatestCliVersion } from "./cli-release.js";
|
|
2
2
|
import {
|
|
3
|
+
BANDWIDTH_ALLOWANCE_REFUSAL_REASON,
|
|
3
4
|
CANONICAL_TIMESTAMP,
|
|
4
5
|
CAPABILITY_REFUSAL_FIELD,
|
|
5
6
|
CLI_UPGRADE_COMMAND,
|
|
@@ -437,6 +438,34 @@ export function announceRefusal(error, onProgress, action = "request") {
|
|
|
437
438
|
}
|
|
438
439
|
if (kind === REFUSAL_PLAN_LIMIT) {
|
|
439
440
|
onProgress("");
|
|
441
|
+
if (error.planLimit !== undefined) {
|
|
442
|
+
// The allowance and bandwidth refusals ride an ErrorInfo detail, not the
|
|
443
|
+
// UpgradePrompt field, and name when they can next lift on their own.
|
|
444
|
+
const bandwidth = error.planLimit.reason === BANDWIDTH_ALLOWANCE_REFUSAL_REASON;
|
|
445
|
+
onProgress(`PLAN LIMIT (refusal=plan_limit, reason=${error.planLimit.reason})`);
|
|
446
|
+
onProgress(
|
|
447
|
+
bandwidth
|
|
448
|
+
? ` Taproot paused this ${action}: the site's measured bandwidth was over its monthly allowance for two `
|
|
449
|
+
+ "months in a row. The published site keeps serving; only new deployments wait."
|
|
450
|
+
: error.planLimit.requestedPages === undefined
|
|
451
|
+
? ` Taproot refused this ${action}: it generates more pages than the site's allowance for this billing period has left.`
|
|
452
|
+
: ` Taproot refused this ${action}: it generates ${pagesLabel(error.planLimit.requestedPages)} and the site has ${
|
|
453
|
+
(error.planLimit.remainingPages ?? 0).toLocaleString("en-US")
|
|
454
|
+
} left for this billing period.`,
|
|
455
|
+
);
|
|
456
|
+
// The bandwidth pause is not an unconditional reset: it lifts only after
|
|
457
|
+
// a month ends under the allowance (or on upgrade), so the instant is
|
|
458
|
+
// the earliest that can happen.
|
|
459
|
+
onProgress(
|
|
460
|
+
error.planLimit.resetsAt === undefined
|
|
461
|
+
? " Upgrade the site's plan, then run the request again."
|
|
462
|
+
: bandwidth
|
|
463
|
+
? ` Upgrade the site's plan, or deployments resume after a month ends under the allowance (no earlier than ${error.planLimit.resetsAt}).`
|
|
464
|
+
: ` Resets at: ${error.planLimit.resetsAt}. Upgrade the site's plan to continue sooner.`,
|
|
465
|
+
);
|
|
466
|
+
onProgress("");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
440
469
|
onProgress("PLAN LIMIT (refusal=plan_limit, field=UpgradePrompt)");
|
|
441
470
|
onProgress(
|
|
442
471
|
` Taproot refused this ${action} against a plan ceiling rather than a content problem.`,
|
|
@@ -996,6 +1025,45 @@ export async function selectPlace(client, siteId, { googlePlaceId, sessionToken
|
|
|
996
1025
|
};
|
|
997
1026
|
}
|
|
998
1027
|
|
|
1028
|
+
function normalizeSiteCategory(value) {
|
|
1029
|
+
const state = requireObject(value, "api.place_contract", "site place category");
|
|
1030
|
+
return {
|
|
1031
|
+
placeId: requireIdentifier(state.placeId, "api.place_contract", "placeId"),
|
|
1032
|
+
category: text(state.category),
|
|
1033
|
+
placeCategory: text(state.placeCategory),
|
|
1034
|
+
isOverride: state.isOverride === true,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** Sets this site's own category for a place; the place itself and other sites are unchanged. */
|
|
1039
|
+
export async function setPlaceCategory(client, siteId, { placeId, category }) {
|
|
1040
|
+
return normalizeSiteCategory(
|
|
1041
|
+
await client.request(sitePath(siteId, `places/${encodeURIComponent(placeId)}/category`), {
|
|
1042
|
+
method: "PUT",
|
|
1043
|
+
body: { siteId, placeId, category },
|
|
1044
|
+
}),
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** Removes this site's category for a place, so the place's own category applies again. */
|
|
1049
|
+
export async function clearPlaceCategory(client, siteId, { placeId }) {
|
|
1050
|
+
return normalizeSiteCategory(
|
|
1051
|
+
await client.request(sitePath(siteId, `places/${encodeURIComponent(placeId)}/category`), { method: "DELETE" }),
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** The closed list of place categories, in Taproot's order. */
|
|
1056
|
+
export async function listPlaceCategories(client) {
|
|
1057
|
+
const response = requireObject(
|
|
1058
|
+
await client.request(`v1/standard-types${query([["typeGroup", "place_category"], ["maxResults", 1000]])}`),
|
|
1059
|
+
"api.place_contract",
|
|
1060
|
+
"place categories",
|
|
1061
|
+
);
|
|
1062
|
+
return (Array.isArray(response.standardTypes) ? response.standardTypes : [])
|
|
1063
|
+
.map((type) => text(requireObject(type, "api.place_contract", "place category").name))
|
|
1064
|
+
.filter((name) => name !== "");
|
|
1065
|
+
}
|
|
1066
|
+
|
|
999
1067
|
/**
|
|
1000
1068
|
* Processing is observed through `ListSiteImages`; `GetImageById` is
|
|
1001
1069
|
* session-only under TR00602's read list, so a key-authorized client watches
|
|
@@ -1122,18 +1190,20 @@ export async function getDeploySelection(client, siteId) {
|
|
|
1122
1190
|
}
|
|
1123
1191
|
|
|
1124
1192
|
export async function getPublishingReadiness(client, siteId, selection = {}) {
|
|
1193
|
+
// A body, not a query: the exact selection can run past a URL's limit, and the
|
|
1194
|
+
// estimate must plan the pages the deploy will stage (TR01201).
|
|
1125
1195
|
const response = requireObject(
|
|
1126
|
-
await client.request(sitePath(
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
}
|
|
1136
|
-
)
|
|
1196
|
+
await client.request(sitePath(siteId, "publishing/readiness"), {
|
|
1197
|
+
method: "POST",
|
|
1198
|
+
body: {
|
|
1199
|
+
stagedPageIds: selection.stagedPageIds,
|
|
1200
|
+
selectedSettingsTypes: selection.selectedSettingsTypes,
|
|
1201
|
+
includeNavigation: selection.includeNavigation,
|
|
1202
|
+
useExactPageSelection: selection.useExactPageSelection,
|
|
1203
|
+
includePageEstimate: selection.includePageEstimate,
|
|
1204
|
+
allowFailedPreview: selection.allowFailedPreview,
|
|
1205
|
+
},
|
|
1206
|
+
}),
|
|
1137
1207
|
"api.readiness_contract",
|
|
1138
1208
|
"publishing readiness",
|
|
1139
1209
|
);
|
|
@@ -1154,10 +1224,50 @@ export async function getPublishingReadiness(client, siteId, selection = {}) {
|
|
|
1154
1224
|
redirectsChanged: response.redirectsChanged === true,
|
|
1155
1225
|
formsChanged: response.formsChanged === true,
|
|
1156
1226
|
videosChanged: response.videosChanged === true,
|
|
1227
|
+
// The pages staging this candidate would generate, which is the number charged (TR01201).
|
|
1228
|
+
// Absent unless asked for and the candidate could be planned.
|
|
1229
|
+
pageEstimate: pageEstimate(response),
|
|
1157
1230
|
blockers,
|
|
1158
1231
|
};
|
|
1159
1232
|
}
|
|
1160
1233
|
|
|
1234
|
+
/**
|
|
1235
|
+
* The server's plan for a candidate: the pages staging it generates, the
|
|
1236
|
+
* allowance left this period and when it resets. The CLI never computes any of
|
|
1237
|
+
* it; limit, remaining and period end are null for an unlimited licence.
|
|
1238
|
+
*/
|
|
1239
|
+
function pageEstimate(response) {
|
|
1240
|
+
if (!Number.isSafeInteger(response.plannedPageCount) || response.plannedPageCount < 0) return null;
|
|
1241
|
+
const periodEnd = typeof response.pagesGeneratedPeriodEnd === "string"
|
|
1242
|
+
? new Date(response.pagesGeneratedPeriodEnd)
|
|
1243
|
+
: undefined;
|
|
1244
|
+
return {
|
|
1245
|
+
plannedPageCount: response.plannedPageCount,
|
|
1246
|
+
pagesGeneratedLimit: Number.isSafeInteger(response.pagesGeneratedLimit) ? response.pagesGeneratedLimit : null,
|
|
1247
|
+
pagesGeneratedRemaining: Number.isSafeInteger(response.pagesGeneratedRemaining)
|
|
1248
|
+
? response.pagesGeneratedRemaining
|
|
1249
|
+
: null,
|
|
1250
|
+
periodEnd: periodEnd !== undefined && !Number.isNaN(periodEnd.getTime()) ? periodEnd.toISOString() : null,
|
|
1251
|
+
exceedsAllowance: response.plannedPagesExceedAllowance === true,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const pagesLabel = (count) => `${count.toLocaleString("en-US")} ${count === 1 ? "page" : "pages"}`;
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* The one line that states a candidate's plan in pages (TR01201), shared by the
|
|
1259
|
+
* progress output and the refusal so the two never word it differently.
|
|
1260
|
+
*/
|
|
1261
|
+
export function describePageEstimate(estimate) {
|
|
1262
|
+
const generates = `This will generate ${pagesLabel(estimate.plannedPageCount)}`;
|
|
1263
|
+
if (estimate.pagesGeneratedLimit === null || estimate.pagesGeneratedRemaining === null) return generates;
|
|
1264
|
+
const left = `${estimate.pagesGeneratedRemaining.toLocaleString("en-US")} of ${
|
|
1265
|
+
estimate.pagesGeneratedLimit.toLocaleString("en-US")
|
|
1266
|
+
} left this period`;
|
|
1267
|
+
const resets = estimate.periodEnd === null ? "" : ` (resets ${estimate.periodEnd})`;
|
|
1268
|
+
return `${generates} · ${left}${resets}`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1161
1271
|
export async function getStagingPreviewStatus(client, siteId) {
|
|
1162
1272
|
const response = requireObject(
|
|
1163
1273
|
await client.request(sitePath(siteId, "staging-preview/status")),
|
|
@@ -1353,6 +1463,14 @@ export async function deploySite(client, siteId, body) {
|
|
|
1353
1463
|
{ field: "AuthoringPreviewFailed", status: error.status },
|
|
1354
1464
|
);
|
|
1355
1465
|
}
|
|
1466
|
+
if (error instanceof ApiError && error.hasField("ExpectedPageCount")) {
|
|
1467
|
+
throw new SiteAuthoringError(
|
|
1468
|
+
"deploy.page_estimate_changed",
|
|
1469
|
+
`${error.descriptionFor("ExpectedPageCount") ?? "The pages this publish generates have grown since the estimate."} `
|
|
1470
|
+
+ "Nothing was staged; run the command again to confirm the new count.",
|
|
1471
|
+
{ field: "ExpectedPageCount", status: error.status },
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1356
1474
|
throw error;
|
|
1357
1475
|
}
|
|
1358
1476
|
const deployment = normalizeDeployment(response.deployment);
|
package/src/cli.js
CHANGED
|
@@ -34,6 +34,9 @@ import {
|
|
|
34
34
|
VERB_NAV_PUSH,
|
|
35
35
|
VERB_PAGES_META_SET,
|
|
36
36
|
VERB_PAGES_PUSH,
|
|
37
|
+
VERB_PLACES_CATEGORY_CLEAR,
|
|
38
|
+
VERB_PLACES_CATEGORY_LIST,
|
|
39
|
+
VERB_PLACES_CATEGORY_SET,
|
|
37
40
|
VERB_PLACES_SEARCH,
|
|
38
41
|
VERB_PLACES_SELECT,
|
|
39
42
|
VERB_PLAN,
|
|
@@ -350,6 +353,39 @@ const VERBS = Object.freeze([
|
|
|
350
353
|
+ "once for every site, so selecting it again returns the same placeId. Use it as placeId in a place review, "
|
|
351
354
|
+ "or as place in an article or album.",
|
|
352
355
|
},
|
|
356
|
+
{
|
|
357
|
+
name: VERB_PLACES_CATEGORY_SET,
|
|
358
|
+
surface: SURFACE_STANDARD,
|
|
359
|
+
// Changes what the site's place reviews publish, so it needs page editing.
|
|
360
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
361
|
+
tokens: ["places", "category", "set"],
|
|
362
|
+
positionals: "placeCategory",
|
|
363
|
+
summary: "Set this site's category for a place, from the closed category list.",
|
|
364
|
+
note: "Takes the placeId from 'places select' (or a page's front matter), then one category from "
|
|
365
|
+
+ "'places category list', which prints the closed list; a name outside it is refused, and capitalization is "
|
|
366
|
+
+ "ignored. Use it when Google's own category for a place is not the one this site wants, such as a park filed "
|
|
367
|
+
+ "under Other. The category is this site's alone and applies to every review of that place here: the place "
|
|
368
|
+
+ "itself and other sites do not change. Place-review bylines and the /places/<category> pages use it from the "
|
|
369
|
+
+ "next deployment, which a changed category alone is enough to start. Undo it with 'places category clear'.",
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
name: VERB_PLACES_CATEGORY_CLEAR,
|
|
373
|
+
surface: SURFACE_STANDARD,
|
|
374
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
375
|
+
tokens: ["places", "category", "clear"],
|
|
376
|
+
positionals: "placeId",
|
|
377
|
+
summary: "Remove this site's category for a place, so the place's own category applies again.",
|
|
378
|
+
note: "Takes the placeId. Clearing a place this site never set succeeds and changes nothing. Pages use the "
|
|
379
|
+
+ "place's own category from the next deployment.",
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: VERB_PLACES_CATEGORY_LIST,
|
|
383
|
+
surface: SURFACE_STANDARD,
|
|
384
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
385
|
+
tokens: ["places", "category", "list"],
|
|
386
|
+
summary: "List the place categories 'places category set' accepts.",
|
|
387
|
+
note: "The list is closed: a category outside it is refused.",
|
|
388
|
+
},
|
|
353
389
|
{
|
|
354
390
|
name: VERB_PLAN,
|
|
355
391
|
surface: SURFACE_STANDARD,
|
|
@@ -713,7 +749,7 @@ upgrade. Once a run has recorded a newer release, help, validate, and whoami
|
|
|
713
749
|
refuse the same way offline. --version and --help always answer.
|
|
714
750
|
|
|
715
751
|
Verbs:
|
|
716
|
-
${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(
|
|
752
|
+
${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(20)} ${verb.summary}`).join("\n")}
|
|
717
753
|
|
|
718
754
|
Configuration:
|
|
719
755
|
Site verbs read taproot-site.json, found by walking up from the current
|
|
@@ -756,6 +792,10 @@ function verbHelp(verb) {
|
|
|
756
792
|
? " <query...>"
|
|
757
793
|
: verb.positionals === "placeSelection"
|
|
758
794
|
? " <google-place-id> <session-token>"
|
|
795
|
+
: verb.positionals === "placeCategory"
|
|
796
|
+
? " <place-id> <category>"
|
|
797
|
+
: verb.positionals === "placeId"
|
|
798
|
+
? " <place-id>"
|
|
759
799
|
: verb.positionals === "metaPagePath"
|
|
760
800
|
? " <page-path> [--title <text>] [--description <text>] [--author <handle-or-email>]"
|
|
761
801
|
: verb.positionals === "authorHandle"
|
package/src/constants.js
CHANGED
|
@@ -110,6 +110,9 @@ export const VERB_PLAN = "plan";
|
|
|
110
110
|
export const VERB_APPLY = "apply";
|
|
111
111
|
export const VERB_PLACES_SEARCH = "places search";
|
|
112
112
|
export const VERB_PLACES_SELECT = "places select";
|
|
113
|
+
export const VERB_PLACES_CATEGORY_SET = "places category set";
|
|
114
|
+
export const VERB_PLACES_CATEGORY_CLEAR = "places category clear";
|
|
115
|
+
export const VERB_PLACES_CATEGORY_LIST = "places category list";
|
|
113
116
|
/** The identity `plan` reports and `apply --plan` requires. */
|
|
114
117
|
export const PLAN_HASH = /^sha256:[0-9a-f]{64}$/u;
|
|
115
118
|
export const VERB_ENV = "env";
|
|
@@ -204,6 +207,19 @@ export const CAPABILITY_REFUSAL_REASON = "SITE_AUTHORING_CAPABILITY_MISSING";
|
|
|
204
207
|
*/
|
|
205
208
|
export const CAPABILITY_REFUSAL_FIELD = "GrantedCapabilities";
|
|
206
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The `google.rpc.ErrorInfo` reasons a deploy carries when the site has hit a
|
|
212
|
+
* commercial ceiling that no retry can clear (TR00445): the deployments-per-
|
|
213
|
+
* billing-period allowance, or the bandwidth pause. Wire identities matched
|
|
214
|
+
* exactly, within {@link SITE_DEPLOYMENT_REFUSAL_DOMAIN}. The refusal is
|
|
215
|
+
* classified `plan_limit`, the same class as the page-count `UpgradePrompt`
|
|
216
|
+
* field, because the right next move is the same: upgrade, or wait for the
|
|
217
|
+
* reset the server names in `resets_at`.
|
|
218
|
+
*/
|
|
219
|
+
export const DEPLOYMENT_ALLOWANCE_REFUSAL_REASON = "DEPLOYMENT_PERIOD_ALLOWANCE_EXCEEDED";
|
|
220
|
+
export const BANDWIDTH_ALLOWANCE_REFUSAL_REASON = "BANDWIDTH_ALLOWANCE_EXCEEDED";
|
|
221
|
+
export const SITE_DEPLOYMENT_REFUSAL_DOMAIN = "sites.taproot.io";
|
|
222
|
+
|
|
207
223
|
// gRPC `ResourceExhausted`. Transcoded responses carry the numeric code in the
|
|
208
224
|
// body; the HTTP mapping is 429.
|
|
209
225
|
export const GRPC_RESOURCE_EXHAUSTED = 8;
|
package/src/reference-help.js
CHANGED
|
@@ -86,7 +86,7 @@ import {
|
|
|
86
86
|
|
|
87
87
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
88
88
|
|
|
89
|
-
export const REFERENCE_VERSION =
|
|
89
|
+
export const REFERENCE_VERSION = 36;
|
|
90
90
|
|
|
91
91
|
// The date-limit examples `help forms` prints; tests run them through the form validator.
|
|
92
92
|
export const FORM_DATE_WINDOW_FIELD = Object.freeze({
|
package/src/transport.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
+
BANDWIDTH_ALLOWANCE_REFUSAL_REASON,
|
|
2
3
|
CAPABILITY_REFUSAL_FIELD,
|
|
3
4
|
CAPABILITY_REFUSAL_REASON,
|
|
4
5
|
CLI_NAME,
|
|
5
6
|
CLI_UPGRADE_REFUSAL_FIELD,
|
|
6
7
|
CLI_VERSION,
|
|
7
8
|
CREDENTIAL_REFUSAL_FIELD,
|
|
9
|
+
DEPLOYMENT_ALLOWANCE_REFUSAL_REASON,
|
|
8
10
|
GRPC_RESOURCE_EXHAUSTED,
|
|
9
11
|
GRPC_UNAUTHENTICATED,
|
|
10
12
|
HTTP_TOO_MANY_REQUESTS,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
REFUSAL_THROTTLED,
|
|
22
24
|
REFUSAL_UNCLASSIFIED,
|
|
23
25
|
ROLLOUT_REFUSAL_FIELD,
|
|
26
|
+
SITE_DEPLOYMENT_REFUSAL_DOMAIN,
|
|
24
27
|
} from "./constants.js";
|
|
25
28
|
import { Readable } from "node:stream";
|
|
26
29
|
|
|
@@ -409,15 +412,10 @@ function capabilityNames(value) {
|
|
|
409
412
|
* keys on it rather than on `@type`, which a future packing could reshape.
|
|
410
413
|
*/
|
|
411
414
|
export function capabilityRefusal(value) {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
while (queue.length > 0 && work < 1_000) {
|
|
415
|
-
const current = queue.shift();
|
|
416
|
-
work += 1;
|
|
417
|
-
if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
|
|
418
|
-
const metadata = current.value.metadata;
|
|
415
|
+
return findErrorInfo(value, (detail) => {
|
|
416
|
+
const metadata = detail.metadata;
|
|
419
417
|
if (
|
|
420
|
-
|
|
418
|
+
detail.reason === CAPABILITY_REFUSAL_REASON
|
|
421
419
|
&& metadata !== null
|
|
422
420
|
&& typeof metadata === "object"
|
|
423
421
|
&& !Array.isArray(metadata)
|
|
@@ -434,6 +432,61 @@ export function capabilityRefusal(value) {
|
|
|
434
432
|
});
|
|
435
433
|
}
|
|
436
434
|
}
|
|
435
|
+
return undefined;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The pages-generated allowance or bandwidth-pause refusal the deploy API attaches
|
|
441
|
+
* as a `google.rpc.ErrorInfo` detail (TR00445, TR01201), or undefined. Keyed on the
|
|
442
|
+
* namespaced domain and reason, never the message sentence. `resetsAt` is the
|
|
443
|
+
* server's `resets_at` re-serialized as an ISO-8601 UTC instant, or undefined
|
|
444
|
+
* when it is absent (a licence that grants no deployments has no period) or
|
|
445
|
+
* does not parse. `requestedPages` and `remainingPages` are present on the
|
|
446
|
+
* allowance refusal only.
|
|
447
|
+
*/
|
|
448
|
+
export function planLimitRefusal(value) {
|
|
449
|
+
return findErrorInfo(value, (detail) => {
|
|
450
|
+
if (
|
|
451
|
+
detail.domain !== SITE_DEPLOYMENT_REFUSAL_DOMAIN
|
|
452
|
+
|| (detail.reason !== DEPLOYMENT_ALLOWANCE_REFUSAL_REASON && detail.reason !== BANDWIDTH_ALLOWANCE_REFUSAL_REASON)
|
|
453
|
+
) {
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
const metadata = detail.metadata !== null && typeof detail.metadata === "object" ? detail.metadata : {};
|
|
457
|
+
const resetsAt = typeof metadata.resets_at === "string" ? new Date(metadata.resets_at) : undefined;
|
|
458
|
+
return Object.freeze({
|
|
459
|
+
reason: detail.reason,
|
|
460
|
+
resetsAt: resetsAt !== undefined && !Number.isNaN(resetsAt.getTime()) ? resetsAt.toISOString() : undefined,
|
|
461
|
+
// The pages the refused request needed and the period had left (TR01201).
|
|
462
|
+
requestedPages: pageCount(metadata.requested_pages),
|
|
463
|
+
remainingPages: pageCount(metadata.remaining_pages),
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** A page count the server reported as a decimal string, or undefined. */
|
|
469
|
+
function pageCount(value) {
|
|
470
|
+
const count = typeof value === "string" && /^[0-9]{1,9}$/u.test(value) ? Number(value) : undefined;
|
|
471
|
+
return Number.isSafeInteger(count) ? count : undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The first object in `value`, breadth first, for which `match` returns a
|
|
476
|
+
* result. Transcoded `details[]` entries are `Any`-packed and nested, so one
|
|
477
|
+
* pinned path through them would silently empty every extractor on a contract
|
|
478
|
+
* change; the walk is bounded in depth and in work so a hostile body cannot
|
|
479
|
+
* stall it.
|
|
480
|
+
*/
|
|
481
|
+
function findErrorInfo(value, match) {
|
|
482
|
+
const queue = [{ value, depth: 0 }];
|
|
483
|
+
let work = 0;
|
|
484
|
+
while (queue.length > 0 && work < 1_000) {
|
|
485
|
+
const current = queue.shift();
|
|
486
|
+
work += 1;
|
|
487
|
+
if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
|
|
488
|
+
const found = match(current.value);
|
|
489
|
+
if (found !== undefined) return found;
|
|
437
490
|
for (const child of Object.values(current.value)) {
|
|
438
491
|
if (child !== null && typeof child === "object") queue.push({ value: child, depth: current.depth + 1 });
|
|
439
492
|
}
|
|
@@ -459,6 +512,7 @@ export class ApiError extends SiteAuthoringError {
|
|
|
459
512
|
const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
|
|
460
513
|
const fields = fieldViolations(body);
|
|
461
514
|
const capability = capabilityRefusal(body);
|
|
515
|
+
const planLimit = planLimitRefusal(body);
|
|
462
516
|
// Only the known upgrade refusal may supply a human message. Other server
|
|
463
517
|
// errors can contain internal diagnostics and retain their generic output.
|
|
464
518
|
const upgradeMessage = fields.includes(CLI_UPGRADE_REFUSAL_FIELD)
|
|
@@ -472,6 +526,10 @@ export class ApiError extends SiteAuthoringError {
|
|
|
472
526
|
"api.request_rejected",
|
|
473
527
|
upgradeMessage || (fields.length > 0
|
|
474
528
|
? `Taproot rejected the request field '${fields[0]}'.`
|
|
529
|
+
: planLimit !== undefined
|
|
530
|
+
? planLimit.reason === BANDWIDTH_ALLOWANCE_REFUSAL_REASON
|
|
531
|
+
? "Taproot paused deployments: the site used more than its monthly bandwidth for two months in a row."
|
|
532
|
+
: "Taproot refused the request: it generates more pages than the site's allowance for this billing period has left."
|
|
475
533
|
: capability !== undefined
|
|
476
534
|
? `Taproot refused the request: this credential does not carry a capability granting '${
|
|
477
535
|
capability.permission
|
|
@@ -491,6 +549,7 @@ export class ApiError extends SiteAuthoringError {
|
|
|
491
549
|
this.fields = fields;
|
|
492
550
|
this.violationDescriptions = fieldDescriptions(body);
|
|
493
551
|
this.capability = capability;
|
|
552
|
+
this.planLimit = planLimit;
|
|
494
553
|
// The `retry-after` trailer (TR00839's deployment throttle, or any other
|
|
495
554
|
// ResourceExhausted refusal that sets it) transcodes to an ordinary
|
|
496
555
|
// response header. Undefined when absent or unparseable — callers must
|
|
@@ -545,10 +604,12 @@ export class ApiError extends SiteAuthoringError {
|
|
|
545
604
|
* left it unclassified: no `refusal` in the JSON result, and nothing said
|
|
546
605
|
* on the human channel. The field arrives only from the in-transaction
|
|
547
606
|
* re-validation, which does attach `ExternalApiKey`.
|
|
548
|
-
* - `plan_limit` (field `UpgradePrompt
|
|
607
|
+
* - `plan_limit` (field `UpgradePrompt`, or `ErrorInfo` reason
|
|
608
|
+
* `DEPLOYMENT_PERIOD_ALLOWANCE_EXCEEDED` / `BANDWIDTH_ALLOWANCE_EXCEEDED`
|
|
609
|
+
* with the server's `resets_at`) — a commercial ceiling, surfaced at
|
|
549
610
|
* deploy time because no pre-flight read of the published-page quota
|
|
550
|
-
* exists on the contract. **Upgrade** (or publish fewer pages
|
|
551
|
-
* never invents the numeric limit.
|
|
611
|
+
* exists on the contract. **Upgrade** (or publish fewer pages, or wait for
|
|
612
|
+
* the named reset); the CLI never invents the numeric limit.
|
|
552
613
|
* - `throttled` (gRPC `ResourceExhausted` / HTTP 429) — the per-key
|
|
553
614
|
* throttle. **Back off** and retry with delay.
|
|
554
615
|
* - `capability_missing` (`ErrorInfo` reason `SITE_AUTHORING_CAPABILITY_MISSING`)
|
|
@@ -568,7 +629,7 @@ export class ApiError extends SiteAuthoringError {
|
|
|
568
629
|
if (this.hasField(CLI_UPGRADE_REFUSAL_FIELD)) return REFUSAL_CLI_OUTDATED;
|
|
569
630
|
if (this.hasField(ROLLOUT_REFUSAL_FIELD)) return REFUSAL_PLATFORM_PAUSED;
|
|
570
631
|
if (this.hasField(CREDENTIAL_REFUSAL_FIELD)) return REFUSAL_CREDENTIAL_REJECTED;
|
|
571
|
-
if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD)) return REFUSAL_PLAN_LIMIT;
|
|
632
|
+
if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD) || this.planLimit !== undefined) return REFUSAL_PLAN_LIMIT;
|
|
572
633
|
// The named detail ranks with the fields and above the status mappings, for
|
|
573
634
|
// the same reason: it is the specific statement about this refusal.
|
|
574
635
|
if (this.capability !== undefined) return REFUSAL_CAPABILITY_MISSING;
|
|
@@ -76,7 +76,9 @@ const FIELD_HELP = Object.freeze({
|
|
|
76
76
|
required: true,
|
|
77
77
|
description:
|
|
78
78
|
"The Taproot place being reviewed. The site fills in its name, address and coordinates from this record. Find "
|
|
79
|
-
+ "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'."
|
|
79
|
+
+ "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'. The site publishes the "
|
|
80
|
+
+ "place's category in the byline and files the review under /places/<category>; change it for this site with "
|
|
81
|
+
+ "'places category set <placeId> <category>'.",
|
|
80
82
|
},
|
|
81
83
|
place: {
|
|
82
84
|
type: "UUID",
|
package/src/verbs/deploy.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
|
|
5
5
|
DEPLOYMENT_STATUS_COMPLETED,
|
|
6
6
|
deploySite,
|
|
7
|
+
describePageEstimate,
|
|
7
8
|
getDeploySelection,
|
|
8
9
|
getPublishingReadiness,
|
|
9
10
|
listDeployments,
|
|
@@ -67,9 +68,6 @@ const REDIRECT_ACTIVATION_NOTE =
|
|
|
67
68
|
*/
|
|
68
69
|
|
|
69
70
|
const MAXIMUM_REPORTED_BLOCKERS = 50;
|
|
70
|
-
// Staged page ids ride in the readiness query string, so a very large candidate
|
|
71
|
-
// is checked at site level instead of pushing an unbounded URL at the API.
|
|
72
|
-
const MAXIMUM_READINESS_PAGE_IDS = 100;
|
|
73
71
|
|
|
74
72
|
function usageError(code, message, field) {
|
|
75
73
|
return new SiteAuthoringError(code, message, { field, exitCode: 2 });
|
|
@@ -157,6 +155,8 @@ function reportReadiness(readiness) {
|
|
|
157
155
|
redirectsChanged: readiness.redirectsChanged,
|
|
158
156
|
formsChanged: readiness.formsChanged,
|
|
159
157
|
videosChanged: readiness.videosChanged,
|
|
158
|
+
// The pages this deployment generates and the allowance left: what staging is charged (TR01201).
|
|
159
|
+
...(readiness.pageEstimate === null ? {} : { pageEstimate: readiness.pageEstimate }),
|
|
160
160
|
blockers: blockers.items,
|
|
161
161
|
...(blockers.truncated ? { blockersTruncated: true } : {}),
|
|
162
162
|
};
|
|
@@ -341,8 +341,10 @@ export async function deploy(invocation) {
|
|
|
341
341
|
);
|
|
342
342
|
const readiness = await getPublishingReadiness(client, siteId, {
|
|
343
343
|
...candidate,
|
|
344
|
-
stagedPageIds: stagedPageIds.length > MAXIMUM_READINESS_PAGE_IDS ? undefined : stagedPageIds,
|
|
345
344
|
useExactPageSelection: stagedPageIds.length === 0,
|
|
345
|
+
includePageEstimate: true,
|
|
346
|
+
// The estimate must recognize the request it is about to be followed by.
|
|
347
|
+
...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
|
|
346
348
|
});
|
|
347
349
|
if (readiness.blockers.length > 0) {
|
|
348
350
|
throw new SiteAuthoringError(
|
|
@@ -368,11 +370,29 @@ export async function deploy(invocation) {
|
|
|
368
370
|
if (!readiness.hasCandidateChanges) {
|
|
369
371
|
onProgress("Taproot reports this candidate contains no changes; deploying it anyway would be a no-op.");
|
|
370
372
|
}
|
|
373
|
+
// The plan is the charge: the same page count admission uses, shown before anything is sent.
|
|
374
|
+
if (readiness.pageEstimate !== null) {
|
|
375
|
+
onProgress(describePageEstimate(readiness.pageEstimate));
|
|
376
|
+
if (readiness.pageEstimate.exceedsAllowance) {
|
|
377
|
+
const estimate = readiness.pageEstimate;
|
|
378
|
+
throw new SiteAuthoringError(
|
|
379
|
+
"deploy.page_allowance_exceeded",
|
|
380
|
+
`This candidate generates ${estimate.plannedPageCount.toLocaleString("en-US")} ${
|
|
381
|
+
estimate.plannedPageCount === 1 ? "page" : "pages"
|
|
382
|
+
} but the site has ${(estimate.pagesGeneratedRemaining ?? 0).toLocaleString("en-US")} left for this billing `
|
|
383
|
+
+ `period${estimate.periodEnd === null ? "" : ` (it resets ${estimate.periodEnd})`}. Stage a smaller change, `
|
|
384
|
+
+ "or upgrade the site's plan for a larger allowance. Nothing was sent.",
|
|
385
|
+
{ field: "plannedPageCount" },
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
371
389
|
|
|
372
390
|
const created = await deploySite(client, siteId, {
|
|
373
391
|
siteId,
|
|
374
392
|
environment: DEPLOYMENT_ENVIRONMENT_STAGING,
|
|
375
393
|
...candidate,
|
|
394
|
+
// Confirms the number just shown: the server refuses if the plan has grown since.
|
|
395
|
+
...(readiness.pageEstimate === null ? {} : { expectedPageCount: readiness.pageEstimate.plannedPageCount }),
|
|
376
396
|
...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
|
|
377
397
|
});
|
|
378
398
|
onProgress(`Staging deployment ${created.id} accepted; waiting for it to complete.`);
|
package/src/verbs/index.js
CHANGED
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
VERB_NAV_PUSH,
|
|
17
17
|
VERB_PAGES_META_SET,
|
|
18
18
|
VERB_PAGES_PUSH,
|
|
19
|
+
VERB_PLACES_CATEGORY_CLEAR,
|
|
20
|
+
VERB_PLACES_CATEGORY_LIST,
|
|
21
|
+
VERB_PLACES_CATEGORY_SET,
|
|
19
22
|
VERB_PLACES_SEARCH,
|
|
20
23
|
VERB_PLACES_SELECT,
|
|
21
24
|
VERB_PLAN,
|
|
@@ -48,7 +51,7 @@ import { mediaUpload } from "./media-upload.js";
|
|
|
48
51
|
import { navPush } from "./nav-push.js";
|
|
49
52
|
import { pagesMetaSet } from "./pages-meta-set.js";
|
|
50
53
|
import { pagesPush } from "./pages-push.js";
|
|
51
|
-
import { placesSearch, placesSelect } from "./places.js";
|
|
54
|
+
import { placesCategoryClear, placesCategoryList, placesCategorySet, placesSearch, placesSelect } from "./places.js";
|
|
52
55
|
import { apply, plan } from "./plan.js";
|
|
53
56
|
import { previewPage } from "./preview-page.js";
|
|
54
57
|
import { previewRevoke } from "./preview-revoke.js";
|
|
@@ -106,6 +109,8 @@ import { whoami } from "./whoami.js";
|
|
|
106
109
|
* // with none, every file under forms/
|
|
107
110
|
* placeQuery, // places search: the words of the query
|
|
108
111
|
* placeSelection, // places select: [googlePlaceId, sessionToken]
|
|
112
|
+
* placeCategory, // places category set: [placeId, category]
|
|
113
|
+
* placeId, // places category clear: [placeId]
|
|
109
114
|
* pagePaths, // approve: narrow to these page paths; with none, every
|
|
110
115
|
* // draft the workspace manifest tracks is staged
|
|
111
116
|
* authorHandle, // authors add: the handle of the site author to create
|
|
@@ -148,6 +153,9 @@ export const VERB_HANDLERS = Object.freeze({
|
|
|
148
153
|
[VERB_PLAN]: plan,
|
|
149
154
|
[VERB_PLACES_SEARCH]: placesSearch,
|
|
150
155
|
[VERB_PLACES_SELECT]: placesSelect,
|
|
156
|
+
[VERB_PLACES_CATEGORY_SET]: placesCategorySet,
|
|
157
|
+
[VERB_PLACES_CATEGORY_CLEAR]: placesCategoryClear,
|
|
158
|
+
[VERB_PLACES_CATEGORY_LIST]: placesCategoryList,
|
|
151
159
|
[VERB_APPLY]: apply,
|
|
152
160
|
[VERB_REDIRECTS_CHECK]: redirectsCheck,
|
|
153
161
|
[VERB_REDIRECTS_PULL]: redirectsPull,
|
package/src/verbs/places.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
clearPlaceCategory,
|
|
5
|
+
isGooglePlaceId,
|
|
6
|
+
listPlaceCategories,
|
|
7
|
+
searchPlaces,
|
|
8
|
+
selectPlace,
|
|
9
|
+
setPlaceCategory,
|
|
10
|
+
withRefusalGuidance,
|
|
11
|
+
} from "../api.js";
|
|
12
|
+
import {
|
|
13
|
+
VERB_PLACES_CATEGORY_CLEAR,
|
|
14
|
+
VERB_PLACES_CATEGORY_LIST,
|
|
15
|
+
VERB_PLACES_CATEGORY_SET,
|
|
16
|
+
VERB_PLACES_SEARCH,
|
|
17
|
+
VERB_PLACES_SELECT,
|
|
18
|
+
} from "../constants.js";
|
|
5
19
|
import { SiteAuthoringError } from "../errors.js";
|
|
6
20
|
import { openSession, successResult } from "../session.js";
|
|
7
21
|
|
|
@@ -18,6 +32,9 @@ import { openSession, successResult } from "../session.js";
|
|
|
18
32
|
*/
|
|
19
33
|
|
|
20
34
|
const MAXIMUM_QUERY_LENGTH = 200;
|
|
35
|
+
const MAXIMUM_CATEGORY_LENGTH = 100;
|
|
36
|
+
// Any UUID version: the server mints place ids from a sequential-style generator.
|
|
37
|
+
const PLACE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
21
38
|
const SESSION_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
22
39
|
|
|
23
40
|
export async function placesSearch(invocation) {
|
|
@@ -59,3 +76,56 @@ export async function placesSelect(invocation) {
|
|
|
59
76
|
onProgress(`Selected ${place.name}: use placeId ${place.placeId} in the page's front matter.`);
|
|
60
77
|
return successResult(VERB_PLACES_SELECT, siteId, { place });
|
|
61
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `places category set|clear|list` — this site's own category for a place
|
|
82
|
+
* (TR01203).
|
|
83
|
+
*
|
|
84
|
+
* A place's category is shared by every site, and Google's types do not always
|
|
85
|
+
* give the one an owner wants. The override is the site's alone: its place
|
|
86
|
+
* reviews and `/places/<category>` pages use it from the next deployment, and
|
|
87
|
+
* the platform-wide place is never changed. The server owns the closed list, so
|
|
88
|
+
* the CLI sends the name as given and lets the server check it.
|
|
89
|
+
*/
|
|
90
|
+
export async function placesCategorySet(invocation) {
|
|
91
|
+
const [placeId, category, ...extra] = Array.isArray(invocation.placeCategory) ? invocation.placeCategory : [];
|
|
92
|
+
if (!PLACE_ID.test(placeId ?? "") || extra.length > 0 || typeof category !== "string"
|
|
93
|
+
|| category.trim() === "" || category.length > MAXIMUM_CATEGORY_LENGTH) {
|
|
94
|
+
throw new SiteAuthoringError(
|
|
95
|
+
"places.category_invalid",
|
|
96
|
+
"places category set takes the placeId, then one category from 'places category list'.",
|
|
97
|
+
{ field: "category", exitCode: 2 },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const session = await openSession(invocation);
|
|
101
|
+
const { client, siteId, onProgress } = session;
|
|
102
|
+
const state = await withRefusalGuidance(onProgress, "place category", async () =>
|
|
103
|
+
await setPlaceCategory(client, siteId, { placeId, category: category.trim() }));
|
|
104
|
+
onProgress(`This site now files ${placeId} under ${state.category}; deploy for pages to use it.`);
|
|
105
|
+
return successResult(VERB_PLACES_CATEGORY_SET, siteId, { placeCategory: state });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function placesCategoryClear(invocation) {
|
|
109
|
+
const [placeId, ...extra] = Array.isArray(invocation.placeId) ? invocation.placeId : [];
|
|
110
|
+
if (!PLACE_ID.test(placeId ?? "") || extra.length > 0) {
|
|
111
|
+
throw new SiteAuthoringError(
|
|
112
|
+
"places.category_invalid",
|
|
113
|
+
"places category clear takes the placeId of the place whose category this site set.",
|
|
114
|
+
{ field: "placeId", exitCode: 2 },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const session = await openSession(invocation);
|
|
118
|
+
const { client, siteId, onProgress } = session;
|
|
119
|
+
const state = await withRefusalGuidance(onProgress, "place category", async () =>
|
|
120
|
+
await clearPlaceCategory(client, siteId, { placeId }));
|
|
121
|
+
onProgress(`This site files ${placeId} under ${state.category} again; deploy for pages to use it.`);
|
|
122
|
+
return successResult(VERB_PLACES_CATEGORY_CLEAR, siteId, { placeCategory: state });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function placesCategoryList(invocation) {
|
|
126
|
+
const session = await openSession(invocation);
|
|
127
|
+
const { client, siteId, onProgress } = session;
|
|
128
|
+
const categories = await withRefusalGuidance(onProgress, "place categories", async () =>
|
|
129
|
+
await listPlaceCategories(client));
|
|
130
|
+
return successResult(VERB_PLACES_CATEGORY_LIST, siteId, { categories });
|
|
131
|
+
}
|
package/src/verbs/status.js
CHANGED
|
@@ -132,10 +132,7 @@ export async function status(invocation) {
|
|
|
132
132
|
const selection = presentationOnly
|
|
133
133
|
? { ...reviewed, stagedPageIds: [], includeNavigation: false }
|
|
134
134
|
: reviewed;
|
|
135
|
-
const readiness = await getPublishingReadiness(client, siteId,
|
|
136
|
-
...selection,
|
|
137
|
-
stagedPageIds: selection.stagedPageIds.length > 100 ? undefined : selection.stagedPageIds,
|
|
138
|
-
});
|
|
135
|
+
const readiness = await getPublishingReadiness(client, siteId, selection);
|
|
139
136
|
onProgress("Reading the deployment log.");
|
|
140
137
|
const {
|
|
141
138
|
deployments,
|