@taprootio/site-authoring 0.16.0 → 0.19.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 +67 -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/field-schema.json +5 -0
- package/src/field-validation.js +5 -1
- package/src/forms-api.js +8 -1
- package/src/forms-contract.js +29 -6
- package/src/forms-workspace.js +23 -14
- package/src/reference-help.js +10 -4
- package/src/transport.js +73 -12
- package/src/typed-page-reference.js +3 -1
- package/src/verbs/deploy.js +24 -4
- package/src/verbs/forms-pull.js +1 -3
- package/src/verbs/forms-push.js +54 -36
- 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
|
|
@@ -523,6 +524,13 @@ Submissions are never pulled. `help forms` states the file shape and every
|
|
|
523
524
|
refusal, and placing a form on a page is the free-form `form`
|
|
524
525
|
component.
|
|
525
526
|
|
|
527
|
+
The optional top-level `notify_owner` boolean opts a form into hourly,
|
|
528
|
+
counts-only owner email. New forms default to false; omitting it on an
|
|
529
|
+
existing form preserves the site's preference. It is independent of the sink
|
|
530
|
+
and takes effect without publishing or a new form version. Delivery requires
|
|
531
|
+
Taproot's rollout and email provider readiness; opt-in does not enable either
|
|
532
|
+
or backfill earlier submissions. The CLI never pulls visitor answers.
|
|
533
|
+
|
|
526
534
|
`preview page <page-path-or-id>` renders one persisted draft behind the
|
|
527
535
|
staging gate and mints a browser handoff URL, reported only in the final JSON
|
|
528
536
|
result. **Each handoff is single-use.** Opening the URL consumes it; a reused,
|
|
@@ -567,8 +575,10 @@ paused; retry later), `credential_rejected` (stop and re-issue),
|
|
|
567
575
|
not exchanged for a capability the request needs; the refusal carries
|
|
568
576
|
`field: "GrantedCapabilities"`, and the CLI's human progress names the
|
|
569
577
|
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
|
-
|
|
578
|
+
than re-issuing the key), `plan_limit` (the plan's published-page allowance or
|
|
579
|
+
its pages-generated allowance, or the bandwidth pause on deploys,
|
|
580
|
+
surfaced at deploy; the allowance shows its reset time, while the bandwidth pause shows the earliest
|
|
581
|
+
time it can lift, since it lifts only after a month ends under the allowance or on upgrade), and `throttled` (back off).
|
|
572
582
|
|
|
573
583
|
When `GITHUB_OUTPUT` names the runner's existing output file, every
|
|
574
584
|
operational verb appends the same JSON under `taproot_site_result` through a
|
|
@@ -635,17 +645,49 @@ Other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal `<br>` tags are rejecte
|
|
|
635
645
|
with the exact fact field path. A label is optional and stays outside the value's link. See
|
|
636
646
|
`taproot-site help page free-form` and `taproot-site help component cta`.
|
|
637
647
|
|
|
638
|
-
###
|
|
648
|
+
### Place categories in 0.18.0
|
|
639
649
|
|
|
640
|
-
A
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
650
|
+
A place has one category for every site, taken from Google's types, and a place
|
|
651
|
+
that Taproot could not classify is filed under `Other`. A site can file a place
|
|
652
|
+
under a category of its own:
|
|
653
|
+
|
|
654
|
+
```
|
|
655
|
+
taproot-site places category list
|
|
656
|
+
taproot-site places category set <placeId> park
|
|
657
|
+
taproot-site places category clear <placeId>
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`list` prints the closed list `set` accepts; a name outside it is refused, and
|
|
661
|
+
capitalization is ignored. The category is the site's alone and applies to every
|
|
662
|
+
review of that place on the site: the place itself and other sites do not
|
|
663
|
+
change. Place-review bylines and the `/places/<category>` pages use it from the
|
|
664
|
+
site's next deployment, including reviews nobody has edited. `clear` removes it.
|
|
665
|
+
Both need the same page-editing permission as `places select`.
|
|
666
|
+
|
|
667
|
+
### Pages generated in 0.18.0
|
|
668
|
+
|
|
669
|
+
A site's licence allows a number of pages generated per billing period, and
|
|
670
|
+
`deploy --staging` is charged by the pages it generates, not by the deployment.
|
|
671
|
+
The deploy asks Taproot to plan the candidate first and reports the plan before
|
|
672
|
+
it sends anything: `This will generate 74 pages · 940 of 1,000 left this period
|
|
673
|
+
(resets 2026-11-01T00:00:00.000Z)`. The same figures are in `readiness.pageEstimate`
|
|
674
|
+
(`plannedPageCount`, `pagesGeneratedLimit`, `pagesGeneratedRemaining`, `periodEnd`
|
|
675
|
+
and `exceedsAllowance`; a limit and what remains are `null` on an unlimited
|
|
676
|
+
licence). The number shown is the number charged. Promoting a staged release to
|
|
677
|
+
production generates nothing and costs no pages.
|
|
678
|
+
|
|
679
|
+
A candidate that does not fit stops with `deploy.page_allowance_exceeded`, naming
|
|
680
|
+
the pages it needs, the pages left, and the reset. A deploy refused by the server
|
|
681
|
+
for the same reason, or for the bandwidth pause, reports `plan_limit` with the
|
|
682
|
+
time it can lift (and `requested_pages` and `remaining_pages` for the allowance):
|
|
683
|
+
the allowance's reset, or for the bandwidth pause the earliest month end that can
|
|
684
|
+
clear it (an upgrade clears it at once).
|
|
685
|
+
|
|
686
|
+
### Deployment allowances in 0.18.0
|
|
687
|
+
|
|
688
|
+
A deploy refused for the bandwidth pause reports `plan_limit` with the time it
|
|
689
|
+
can lift: the earliest month end that can clear it (an upgrade clears it at
|
|
690
|
+
once).
|
|
649
691
|
|
|
650
692
|
### Authoring feedback in 0.16.0
|
|
651
693
|
|
|
@@ -666,6 +708,18 @@ old default, `noopener noreferrer nofollow`, reads as no choice. See
|
|
|
666
708
|
generated pages navigation links to), and `validate` and `pages push` warn on
|
|
667
709
|
links that look made by autolinking (`content.link_autolinked`).
|
|
668
710
|
|
|
711
|
+
### Link rel in 0.15.0
|
|
712
|
+
|
|
713
|
+
A link publishes followed unless its author chooses otherwise. A link mark's
|
|
714
|
+
`rel` takes space-separated link types (`nofollow`, `sponsored`, `ugc`, `me`,
|
|
715
|
+
and the other standard types except `opener`), on any link; `target: "_blank"`
|
|
716
|
+
opens a new tab with `noopener noreferrer` added. In Markdown, write
|
|
717
|
+
`[Shop](https://shop.example){rel="sponsored nofollow" target="_blank"}`
|
|
718
|
+
(either attribute alone works). Other tokens and
|
|
719
|
+
targets are refused (`content.link_rel`, `content.link_target`). The editor's
|
|
720
|
+
old default, `noopener noreferrer nofollow`, reads as no choice. See
|
|
721
|
+
`taproot-site help page free-form`.
|
|
722
|
+
|
|
669
723
|
### Staging verification and deployment evidence
|
|
670
724
|
|
|
671
725
|
`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.19.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/field-schema.json
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
"description": "The id of the email field whose value the API copies to subject_email. It must name an email field. Omit it when the form has one email field (that field is the contact); it is required with two or more.",
|
|
18
18
|
"$ref": "#/$defs/id"
|
|
19
19
|
},
|
|
20
|
+
"newsletter_audience_id": {
|
|
21
|
+
"description": "The active manual newsletter audience on the form's site that receives the reader after email confirmation. Omit it for newsletter only. The server checks ownership and availability when saving.",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
24
|
+
},
|
|
20
25
|
"submit_label": {
|
|
21
26
|
"description": "The text on the submit button. Omit it for Taproot's default. Must contain more than whitespace; the validator also rejects a blank label, which a schema cannot express.",
|
|
22
27
|
"type": "string",
|
package/src/field-validation.js
CHANGED
|
@@ -106,7 +106,7 @@ export function contactFieldId(definition) {
|
|
|
106
106
|
const emails = definition.fields.filter((field) => field.type === "email");
|
|
107
107
|
return emails.length === 1 ? emails[0].id : undefined;
|
|
108
108
|
}
|
|
109
|
-
const DEFINITION_KEYS = ["fields", "contact_field", "submit_label", "after_submit"];
|
|
109
|
+
const DEFINITION_KEYS = ["fields", "contact_field", "submit_label", "after_submit", "newsletter_audience_id"];
|
|
110
110
|
const BASE_FIELD_KEYS = ["id", "type", "label", "hint"];
|
|
111
111
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
112
|
const isInteger = (value) => typeof value === "number" && Number.isInteger(value);
|
|
@@ -427,6 +427,10 @@ export function validateDefinition(definition) {
|
|
|
427
427
|
}
|
|
428
428
|
/** The button text and the after-submit choice, which sit beside the fields in a definition. */
|
|
429
429
|
function validateFormSettings(definition, errors) {
|
|
430
|
+
const audience = definition.newsletter_audience_id;
|
|
431
|
+
if (audience !== undefined && (typeof audience !== "string" || !isResourceId(audience))) {
|
|
432
|
+
errors.push({ path: "newsletter_audience_id", code: "invalid_newsletter_audience" });
|
|
433
|
+
}
|
|
430
434
|
const label = definition.submit_label;
|
|
431
435
|
if (label !== undefined && (typeof label !== "string" || isBlank(label) || label.length > MAX_SUBMIT_LABEL_LENGTH)) {
|
|
432
436
|
errors.push({ path: "submit_label", code: "invalid_submit_label" });
|
package/src/forms-api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { listSitePages, sitePath } from "./api.js";
|
|
2
2
|
import { HTTP_NOT_FOUND } from "./constants.js";
|
|
3
3
|
import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
|
|
4
|
-
import { FORM_DEFAULT_SINK, FORM_SINKS, isFormKey, normalizePagePath } from "./forms-contract.js";
|
|
4
|
+
import { FORM_DEFAULT_NOTIFY_OWNER, FORM_DEFAULT_SINK, FORM_SINKS, isFormKey, normalizePagePath } from "./forms-contract.js";
|
|
5
5
|
import { ApiError } from "./transport.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -38,12 +38,16 @@ function normalizeForm(value) {
|
|
|
38
38
|
if (!isCanonicalUuid(value.id) || !isFormKey(value.key)) {
|
|
39
39
|
throw contract("Taproot returned a form with no usable id or a key this workspace cannot store.", "form");
|
|
40
40
|
}
|
|
41
|
+
if (Object.hasOwn(value, "notifyOwner") && typeof value.notifyOwner !== "boolean") {
|
|
42
|
+
throw contract("Taproot returned an owner notification setting that is not a boolean.", "notifyOwner");
|
|
43
|
+
}
|
|
41
44
|
return {
|
|
42
45
|
id: value.id,
|
|
43
46
|
key: value.key,
|
|
44
47
|
name: typeof value.name === "string" ? value.name : "",
|
|
45
48
|
sink: sinkFromWire(value.sink),
|
|
46
49
|
retentionDays: Number.isSafeInteger(value.retentionDays) ? value.retentionDays : undefined,
|
|
50
|
+
notifyOwner: value.notifyOwner ?? FORM_DEFAULT_NOTIFY_OWNER,
|
|
47
51
|
archived: value.status === STATUS_ARCHIVED_WIRE,
|
|
48
52
|
currentVersion: Number.isSafeInteger(value.currentVersion) ? value.currentVersion : 0,
|
|
49
53
|
};
|
|
@@ -140,6 +144,7 @@ export async function createSiteForm(client, siteId, form) {
|
|
|
140
144
|
name: form.name,
|
|
141
145
|
sink: SINK_WIRE[form.sink],
|
|
142
146
|
retentionDays: form.retention_days,
|
|
147
|
+
notifyOwner: form.notify_owner,
|
|
143
148
|
definition: JSON.stringify(form.definition),
|
|
144
149
|
},
|
|
145
150
|
maximumResponseBytes: FORMS_RESPONSE_BYTES,
|
|
@@ -160,12 +165,14 @@ export async function updateSiteFormSettings(client, siteId, formId, form, expec
|
|
|
160
165
|
name: form.name,
|
|
161
166
|
sink: SINK_WIRE[form.sink],
|
|
162
167
|
retentionDays: form.retention_days,
|
|
168
|
+
notifyOwner: form.notify_owner,
|
|
163
169
|
...(expected === undefined
|
|
164
170
|
? {}
|
|
165
171
|
: {
|
|
166
172
|
expectedName: expected.name,
|
|
167
173
|
expectedSink: SINK_WIRE[expected.sink],
|
|
168
174
|
expectedRetentionDays: expected.retention_days,
|
|
175
|
+
expectedNotifyOwner: expected.notify_owner,
|
|
169
176
|
}),
|
|
170
177
|
},
|
|
171
178
|
maximumResponseBytes: FORMS_RESPONSE_BYTES,
|
package/src/forms-contract.js
CHANGED
|
@@ -5,7 +5,7 @@ import { contactFieldId, validateDefinition } from "./field-validation.js";
|
|
|
5
5
|
* The workspace contract for a form (TR01061).
|
|
6
6
|
*
|
|
7
7
|
* A form is one JSON file, `forms/<key>.json`, holding what an author decides:
|
|
8
|
-
* its key, name, sink, retention and definition. The definition holds the fields
|
|
8
|
+
* its key, name, sink, retention, owner-notification preference and definition. The definition holds the fields
|
|
9
9
|
* and the form's own settings (`contact_field`, `submit_label`, `after_submit`),
|
|
10
10
|
* exactly as the site stores it, so one validator reads one shape. The
|
|
11
11
|
* definition's own rules belong to `shared/field-validation.ts` and are run
|
|
@@ -27,6 +27,7 @@ export const FORM_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
|
27
27
|
export const FORM_KEY_MAXIMUM_LENGTH = 64;
|
|
28
28
|
export const FORM_SINKS = Object.freeze(["none", "newsletter"]);
|
|
29
29
|
export const FORM_DEFAULT_SINK = "none";
|
|
30
|
+
export const FORM_DEFAULT_NOTIFY_OWNER = false;
|
|
30
31
|
export const FORM_RETENTION_DAYS = Object.freeze({ minimum: 30, maximum: 730, default: 365 });
|
|
31
32
|
export const FORM_LIMITS = Object.freeze({
|
|
32
33
|
nameCharacters: 120,
|
|
@@ -35,7 +36,7 @@ export const FORM_LIMITS = Object.freeze({
|
|
|
35
36
|
fileBytes: 256 * 1024,
|
|
36
37
|
});
|
|
37
38
|
|
|
38
|
-
const FORM_FILE_KEYS = ["key", "name", "sink", "retention_days", "definition"];
|
|
39
|
+
const FORM_FILE_KEYS = ["key", "name", "sink", "retention_days", "notify_owner", "definition"];
|
|
39
40
|
// The order `forms pull` writes, which `forms push` writes back after a save.
|
|
40
41
|
// Fixed rather than inherited from the response so the same form is always the
|
|
41
42
|
// same bytes.
|
|
@@ -101,14 +102,24 @@ function describeDefinitionErrors(errors) {
|
|
|
101
102
|
return `${shown.join("; ")}${more}`;
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/** Checks newsletter admission after the definition has passed shared validation. */
|
|
106
|
+
export function validateNewsletterDefinition(file, definition) {
|
|
107
|
+
const fields = definition.fields;
|
|
108
|
+
const contact = fields.find((field) => field.id === contactFieldId(definition));
|
|
109
|
+
if (contact?.type !== "email" || contact.required !== true || !fields.some((field) => field.type === "consent")) {
|
|
110
|
+
throw invalid(file, "a newsletter sink requires a required email contact field and at least one consent checkbox.", "definition");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
104
114
|
/**
|
|
105
115
|
* Validates one parsed form file and returns it unchanged in meaning.
|
|
106
116
|
*
|
|
107
117
|
* `expectedKey` is the key the file's name promises; a mismatch is refused
|
|
108
118
|
* because a rename on disk would otherwise push a different form than the one
|
|
109
119
|
* the author thinks they are editing.
|
|
120
|
+
* Only `forms push` defers newsletter admission until it compares the site's baseline.
|
|
110
121
|
*/
|
|
111
|
-
export function validateFormDocument(document_, expectedKey) {
|
|
122
|
+
export function validateFormDocument(document_, expectedKey, { deferNewsletterSemantics = false } = {}) {
|
|
112
123
|
const file = formFileName(expectedKey);
|
|
113
124
|
if (!isPlainObject(document_)) throw invalid(file, "the file must contain one JSON object.", "$");
|
|
114
125
|
for (const name of Object.keys(document_)) {
|
|
@@ -138,6 +149,9 @@ export function validateFormDocument(document_, expectedKey) {
|
|
|
138
149
|
if (document_.sink !== undefined && !FORM_SINKS.includes(document_.sink)) {
|
|
139
150
|
throw invalid(file, `sink must be one of: ${FORM_SINKS.join(", ")}.`, "sink");
|
|
140
151
|
}
|
|
152
|
+
if (Object.hasOwn(document_, "notify_owner") && typeof document_.notify_owner !== "boolean") {
|
|
153
|
+
throw invalid(file, "notify_owner must be true or false.", "notify_owner");
|
|
154
|
+
}
|
|
141
155
|
const days = document_.retention_days;
|
|
142
156
|
if (
|
|
143
157
|
days !== undefined
|
|
@@ -156,6 +170,7 @@ export function validateFormDocument(document_, expectedKey) {
|
|
|
156
170
|
if (errors.length > 0) {
|
|
157
171
|
throw invalid(file, `the definition is invalid: ${describeDefinitionErrors(errors)}`, "definition");
|
|
158
172
|
}
|
|
173
|
+
if (document_.sink === "newsletter" && !deferNewsletterSemantics) validateNewsletterDefinition(file, document_.definition);
|
|
159
174
|
if (anyUnstorableString(document_.definition)) {
|
|
160
175
|
throw invalid(file, "the definition cannot contain NUL or unpaired surrogate characters.", "definition");
|
|
161
176
|
}
|
|
@@ -238,6 +253,16 @@ function rejectNonTarget(file, pages, id, field, name) {
|
|
|
238
253
|
);
|
|
239
254
|
}
|
|
240
255
|
|
|
256
|
+
/** Operational settings in the same order for workspace files and saved baselines. */
|
|
257
|
+
export function projectFormSettings(form) {
|
|
258
|
+
return {
|
|
259
|
+
name: form.name,
|
|
260
|
+
sink: form.sink ?? FORM_DEFAULT_SINK,
|
|
261
|
+
retention_days: form.retention_days ?? FORM_RETENTION_DAYS.default,
|
|
262
|
+
notify_owner: form.notify_owner ?? FORM_DEFAULT_NOTIFY_OWNER,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
241
266
|
function canonicalField(field) {
|
|
242
267
|
const ordered = {};
|
|
243
268
|
for (const name of FIELD_KEY_ORDER) if (field[name] !== undefined) ordered[name] = field[name];
|
|
@@ -266,9 +291,7 @@ export function projectFormForWorkspace(form, pathById) {
|
|
|
266
291
|
}
|
|
267
292
|
return {
|
|
268
293
|
key: form.key,
|
|
269
|
-
|
|
270
|
-
sink: form.sink ?? FORM_DEFAULT_SINK,
|
|
271
|
-
retention_days: form.retention_days ?? FORM_RETENTION_DAYS.default,
|
|
294
|
+
...projectFormSettings(form),
|
|
272
295
|
definition,
|
|
273
296
|
};
|
|
274
297
|
}
|