@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 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
- surfaced at deploy), and `throttled` (back off).
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
- ### Link rel in 0.15.0
648
+ ### Place categories in 0.18.0
639
649
 
640
- A link publishes followed unless its author chooses otherwise. A link mark's
641
- `rel` takes space-separated link types (`nofollow`, `sponsored`, `ugc`, `me`,
642
- and the other standard types except `opener`), on any link; `target: "_blank"`
643
- opens a new tab with `noopener noreferrer` added. In Markdown, write
644
- `[Shop](https://shop.example){rel="sponsored nofollow" target="_blank"}`
645
- (either attribute alone works). Other tokens and
646
- targets are refused (`content.link_rel`, `content.link_target`). The editor's
647
- old default, `noopener noreferrer nofollow`, reads as no choice. See
648
- `taproot-site help page free-form`.
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.16.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
- siteId,
1128
- `publishing/readiness${
1129
- query([
1130
- ["stagedPageIds", selection.stagedPageIds],
1131
- ["selectedSettingsTypes", selection.selectedSettingsTypes],
1132
- ["includeNavigation", selection.includeNavigation],
1133
- ["useExactPageSelection", selection.useExactPageSelection],
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(14)} ${verb.summary}`).join("\n")}
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;
@@ -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",
@@ -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,
@@ -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
- name: form.name,
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
  }