@taprootio/site-authoring 0.12.5 → 0.12.7

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.
@@ -20,6 +20,27 @@ import {
20
20
  SHIPPED_FIXTURE_NAME,
21
21
  shippedFixtureDirectory,
22
22
  } from "./fixture-contract.js";
23
+ import {
24
+ DATE_ANCHORS,
25
+ FIELD_TYPES,
26
+ MAX_AFTER_SUBMIT_MESSAGE_LENGTH,
27
+ MAX_CONSENT_LABEL_LENGTH,
28
+ MAX_DATE_OFFSET_DAYS,
29
+ MAX_FIELDS,
30
+ MAX_HINT_LENGTH,
31
+ MAX_SUBMIT_LABEL_LENGTH,
32
+ MAX_LABEL_LENGTH,
33
+ MAX_SUBMISSION_BYTES,
34
+ RELATIVE_BOUND_GRACE_DAYS,
35
+ } from "./field-validation.js";
36
+ import {
37
+ FORM_FILE_EXTENSION,
38
+ FORM_KEY_MAXIMUM_LENGTH,
39
+ FORM_LIMITS,
40
+ FORM_RETENTION_DAYS,
41
+ FORM_SINKS,
42
+ FORMS_DIRECTORY,
43
+ } from "./forms-contract.js";
23
44
  import {
24
45
  formatPresentationReference,
25
46
  getAppearanceReference,
@@ -50,7 +71,23 @@ import {
50
71
 
51
72
  export { getAppearanceReference, getFooterReference, getThemeReference };
52
73
 
53
- export const REFERENCE_VERSION = 28;
74
+ export const REFERENCE_VERSION = 31;
75
+
76
+ // The date-limit examples `help forms` prints; tests run them through the form validator.
77
+ export const FORM_DATE_WINDOW_FIELD = Object.freeze({
78
+ id: "visit_day",
79
+ type: "date",
80
+ label: "Day of your visit",
81
+ min: Object.freeze({ from: "today" }),
82
+ max: Object.freeze({ from: "today", offset_days: 30 }),
83
+ });
84
+ export const FORM_NEXT_MONTH_FIELD = Object.freeze({
85
+ id: "start_day",
86
+ type: "date",
87
+ label: "A day next month",
88
+ min: Object.freeze({ from: "start_of_next_month" }),
89
+ max: Object.freeze({ from: "end_of_next_month" }),
90
+ });
54
91
  export const PAGE_TYPES = Object.freeze(["free-form", ...TYPED_PAGE_TYPES]);
55
92
 
56
93
  function deepFreeze(value) {
@@ -363,6 +400,11 @@ export const REFERENCE_TOPICS = Object.freeze([
363
400
  usage: `${CLI_BINARY_NAME} help redirects`,
364
401
  summary: "Author redirects.json: entry shape, normalization, and the refusals.",
365
402
  }),
403
+ Object.freeze({
404
+ name: "forms",
405
+ usage: `${CLI_BINARY_NAME} help forms`,
406
+ summary: "Author a form as forms/<key>.json: file shape, field types, validate, pull, push, and placing it on a page.",
407
+ }),
366
408
  Object.freeze({
367
409
  name: "media",
368
410
  usage: `${CLI_BINARY_NAME} help media`,
@@ -480,8 +522,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
480
522
  + "image fails.",
481
523
  "Runtime: the page's bootstrap pins one runtime version, its entry module and the module each declared "
482
524
  + "capability resolves to. The entry must load as JavaScript and every declared capability must resolve and "
483
- + "load. A page that still names the pre-pinning runtime pointer is reported as unverified, so the verdict is "
484
- + "degraded, until the site is republished.",
525
+ + "load. A page whose bootstrap does not pin a runtime version and entry fails the runtime check.",
485
526
  "Browser: a fresh and a returning (same-context, cached) load are compared against the page's pinned entry when "
486
527
  + "Playwright resolves from the CLI's own install: npm install --global @taprootio/site-authoring@latest "
487
528
  + "playwright && npx playwright install chromium, then rerun through the installed taproot-site command (a "
@@ -505,8 +546,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
505
546
  + "not delivery), local-only references (localhost, *.test, *.local) are reported rather than fetched, a "
506
547
  + "redirect Location that could reflect a credential is withheld, bodies are read up to a fixed bound, "
507
548
  + "member-only content is not exercised, and nothing is purged, republished or rolled back.",
508
- "Verdicts: delivered (no findings), degraded (every route served and the runtime is compatible or a pre-pinning "
509
- + "reference, but an asset, link or browser load did not match or the runtime could not be verified), failed (a route or the runtime did not deliver).",
549
+ "Verdicts: delivered (no findings), degraded (every route served and the runtime is compatible, "
550
+ + "but an asset, link or browser load did not match), failed (a route or the runtime did not deliver).",
510
551
  ]),
511
552
  example: Object.freeze({
512
553
  command: `${CLI_BINARY_NAME} delivery check --production --url https://www.example.com/ --wait 30`,
@@ -527,7 +568,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
527
568
  `Pull the baseline: ${CLI_BINARY_NAME} pull writes settings/taproot-styles.json with both schemes' complete effective `
528
569
  + "themes (the stored theme resolved over the same defaults every consumer renders) beside brand.json, "
529
570
  + "site-header.json and site-publishing-preferences.json. Keep that pull as the baseline you edit; never build "
530
- + "a theme from memory or from an example. semanticMappings holds only authored pins, listed in explicitMappingTokens.",
571
+ + "a theme from memory or from an example. semanticMappings holds only authored pins, listed in explicitMappingTokens. "
572
+ + "site-publishing-preferences.json also carries timeZone, the site's IANA zone (UTC until the owner sets it), "
573
+ + "which decides what \"today\" means for relative form date bounds; it is read-only here, so ask the owner to set "
574
+ + "it in the editor under Site settings.",
531
575
  "Edit both schemes: settings.lightTheme and settings.darkTheme are separate documents. Declare anchors, then roles "
532
576
  + "(help theme lists the role slots and how anchor references resolve), tune typography per scheme, and keep "
533
577
  + "contexts and intents paired across schemes. Set appearance scalars (default scheme, header, logos, favicon) "
@@ -682,6 +726,107 @@ const WORKFLOW_REFERENCES = Object.freeze({
682
726
  ]),
683
727
  }),
684
728
  }),
729
+ forms: Object.freeze({
730
+ title: "Form workspace contract",
731
+ summary: `A form is one JSON file, ${FORMS_DIRECTORY}/<key>${FORM_FILE_EXTENSION}. 'forms validate' proves it offline, `
732
+ + "'forms push' creates or updates it on the site, and 'forms pull' reads the site's copy back.",
733
+ usage: `${CLI_BINARY_NAME} forms validate [key...] | ${CLI_BINARY_NAME} forms pull | ${CLI_BINARY_NAME} forms push [key...]`,
734
+ details: Object.freeze([
735
+ `Run 'pull' once first: 'forms pull' and 'forms push' need the pulled workspace's manifest, which records the `
736
+ + "version of each form this workspace last saw. 'forms validate' needs only taproot-site.json.",
737
+ `The file is { key, name, sink, retention_days, definition }. key must equal the file's name `
738
+ + `and is lowercase letters, digits, and single hyphens, at most ${FORM_KEY_MAXIMUM_LENGTH} characters. name is `
739
+ + `1 to ${FORM_LIMITS.nameCharacters} characters. sink is one of ${FORM_SINKS.join(", ")} and defaults to `
740
+ + "'none' (store submissions only); 'newsletter' also subscribes the contact address to the site's newsletter. "
741
+ + `retention_days is ${FORM_RETENTION_DAYS.minimum} to ${FORM_RETENTION_DAYS.maximum} (a site's plan may cap it lower): `
742
+ + "submissions are deleted that many days after they arrive. Leave it out and the site chooses on creation (its "
743
+ + "plan's default, usually 365) and keeps the current value on an update. Shortening it deletes the submissions that fall outside it, "
744
+ + "so a key cannot do it; the site refuses it, and an owner or admin must make that change.",
745
+ `definition is { fields, contact_field?, submit_label?, after_submit? } (contact_field only with several email fields). Each field is { id, type, label, hint?, ...constraints }; the closed `
746
+ + `field types are ${FIELD_TYPES.join(", ")}. hint is optional helper text of up to ${MAX_HINT_LENGTH} `
747
+ + "characters, drawn under the field on the published form; it is the only author-defined text printed there, so a required "
748
+ + "field is not marked 'Required' and a visitor learns it from the error on submit. "
749
+ + `At most ${MAX_FIELDS} fields and ${MAX_SUBMISSION_BYTES} bytes `
750
+ + "per submission. There are no author-supplied regular expressions: use 'format' from its closed list. "
751
+ + "The exact constraints per type are in shared/field-schema.json, which 'forms validate' enforces by "
752
+ + "running the same validator the published form and the site use. Each refusal names the field path and a "
753
+ + "stable code such as duplicate_id or unknown_constraint.",
754
+ "A date field's min and max are each a fixed 'YYYY-MM-DD' date or a relative limit, { from, offset_days? }. "
755
+ + `from is one of ${DATE_ANCHORS.join(", ")}, the site's calendar date today or that day of this or next month; `
756
+ + `offset_days is a whole number of days from -${MAX_DATE_OFFSET_DAYS} to ${MAX_DATE_OFFSET_DAYS} (default 0; a `
757
+ + "year either way covers a leap year, and further out is a fixed date). There is no expression language. "
758
+ + "'Today' is the site's date in the site's time zone, which is UTC until the owner sets one in the editor's "
759
+ + "Site settings; 'pull' shows it as timeZone in settings/site-publishing-preferences.json, read-only here. "
760
+ + "The published form sets the date picker's limits when the page loads and checks them again when the visitor "
761
+ + `submits; the site lets a relative minimum reach back ${RELATIVE_BOUND_GRACE_DAYS} day so a visitor who loaded `
762
+ + "the page before midnight and submits after it is not refused for a date the page offered (a relative maximum "
763
+ + `and a fixed date are never loosened). Within the next 30 days: ${JSON.stringify(FORM_DATE_WINDOW_FIELD)}. `
764
+ + `A day next month: ${JSON.stringify(FORM_NEXT_MONTH_FIELD)}. A fixed and a relative limit may be mixed. 'forms `
765
+ + "validate' refuses an unknown anchor or an offset out of range as invalid_constraint, and a minimum after the "
766
+ + "maximum as min_exceeds_max only when both are fixed dates or both start from the same anchor; other pairs "
767
+ + "depend on the day and are not compared.",
768
+ `submit_label is the text on the submit button, up to ${MAX_SUBMIT_LABEL_LENGTH} characters; leave it out for "Send". `
769
+ + "after_submit says what the visitor sees once a response is sent. Leave it out, or write "
770
+ + `{ "show": "message" }, for Taproot's own confirmation; { "show": "message", "message": "..." } replaces `
771
+ + `its words (up to ${MAX_AFTER_SUBMIT_MESSAGE_LENGTH} characters, line breaks kept). { "show": "page", `
772
+ + `"page_path": "/thanks" } sends the visitor to that page on this site instead. The page is written as `
773
+ + "its path: a leading slash, no trailing slash, '/' for the home page. 'forms push' looks the page up "
774
+ + "and stores it by its resource id, so the form keeps pointing at the page if it is renamed, and 'forms "
775
+ + "pull' writes the page's current path back. A page_resource_id is accepted in place of page_path, and a "
776
+ + "pull keeps it only for a page that no longer exists. A file whose page_path names no page on the site is "
777
+ + "refused as forms.page_not_found, so push the page first. It need not be published to save the form, but "
778
+ + "a deploy whose release does not publish that page is refused, naming the form and the page. The path in "
779
+ + "each release is the one the page has in that release, so the published form never asks the site where "
780
+ + "to go. The page must be one you created, the home page included: a generated page (folder, tag or "
781
+ + "archive index), the 404 page, a legal page, a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
782
+ + "the 'form' component takes only formKey.",
783
+ "A form's contact address is the value of one email field, stored with each submission. That is what lets the "
784
+ + "owner find and delete every row for a visitor who asks. With exactly one email field, that field is the "
785
+ + "contact address and the file leaves contact_field out (a file that names it anyway is accepted, and a pull "
786
+ + "writes it without). With two or more email fields, contact_field must name one of them; leaving it out is "
787
+ + "refused as contact_field_required. A form with no email field has no contact address, and contact_field "
788
+ + "there is refused as invalid_contact_field.",
789
+ `A consent field's label is the exact words the visitor agrees to, up to ${MAX_CONSENT_LABEL_LENGTH} characters `
790
+ + `(every other field's label is at most ${MAX_LABEL_LENGTH}); a form has no separate consent text. `
791
+ + "Each version freezes the labels, and every submission records the hash of each consent field's label as "
792
+ + "the visitor saw it, so a response shows what each box agreed to.",
793
+ "Editing a definition appends a version on the site; versions are never changed. A page keeps the form "
794
+ + "version it was published with until the next deploy, and a rollback restores the form it shipped with.",
795
+ "Both verbs write the file in one canonical key order, so a pulled file pushed back unchanged reports "
796
+ + "'unchanged' and leaves the bytes as they were. Write your own files in that order and a push rewrites "
797
+ + "nothing you did not change.",
798
+ "'forms push' validates every file and compares every form with the site before the first write. It refuses "
799
+ + "forms.concurrent_modification when the site's version moved since this workspace last read it, "
800
+ + "forms.pull_required when the site has a form with that key this workspace never pulled, and forms.archived "
801
+ + "when the form is archived on the site. Reconcile with 'forms pull' and retry; nothing was written.",
802
+ "Submissions are never pulled or pushed. They are visitors' personal data: read, mark, export and delete "
803
+ + "them on the site's Forms page.",
804
+ "To show a form, place the free-form component 'form' on a page (a component:form block) with formKey set "
805
+ + "to the form's key ('help component form' lists its properties). Push the form first and deploy: the release "
806
+ + "freezes the form it was built with.",
807
+ "The delegation.content capability carries site.forms.manage, which every forms verb needs: it covers "
808
+ + "definitions, settings and status. Visitors' submissions are in no capability: reading them "
809
+ + "(site.forms.submissions.read) and finding, marking or deleting them (site.forms.submissions.manage) belong "
810
+ + "to the site's owners and admins on the site's Forms page, never to a key. A refusal names the missing "
811
+ + "permission.",
812
+ ]),
813
+ example: Object.freeze({
814
+ key: "contact",
815
+ name: "Contact",
816
+ sink: "none",
817
+ retention_days: 365,
818
+ definition: Object.freeze({
819
+ fields: Object.freeze([
820
+ Object.freeze({ id: "name", type: "text", label: "Your name", required: true, max_length: 120 }),
821
+ Object.freeze({ id: "email", type: "email", label: "Email", required: true }),
822
+ Object.freeze({ id: "message", type: "long_text", label: "Message", required: true, max_length: 2000 }),
823
+ Object.freeze({ id: "agree", type: "consent", label: "I agree that you may store this message and reply to me." }),
824
+ ]),
825
+ submit_label: "Send message",
826
+ after_submit: Object.freeze({ show: "page", page_path: "/thanks" }),
827
+ }),
828
+ }),
829
+ }),
685
830
  media: Object.freeze({
686
831
  title: "Media upload contract",
687
832
  summary: "Upload workspace-root-relative raster files and receive component-ready media objects.",
@@ -747,6 +892,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
747
892
  `Required root fields: ${FIXTURE_REQUIRED_ROOT_FIELDS.join(", ")}.`,
748
893
  `Optional root fields: ${FIXTURE_OPTIONAL_ROOT_FIELDS.join(", ")}. Appearance and footer metadata, when present, `
749
894
  + "must match the local settings. Snapshot times and deployments are not live evidence. Other root fields are refused.",
895
+ `The optional forms root field is { items: { <key>: { id, version } } }: 'forms pull' also records a settings baseline, which a fixture does not carry. Each key `
896
+ + `names a ${FORMS_DIRECTORY}/<key>${FORM_FILE_EXTENSION} that must validate as in 'help forms', and no form file `
897
+ + "may sit beside them unbound.",
750
898
  `manifestVersion must be ${MANIFEST_VERSION}; fixture.contractVersion must be ${FIXTURE_CONTRACT_VERSION}.`,
751
899
  "siteId, every pageId and resourceId, every settings entityId, and every fixture.imageIds entry is a canonical "
752
900
  + "lowercase UUID. They are deterministic fixture identities, not identities from a live site.",
@@ -116,6 +116,9 @@ export const SETTINGS_GROUPS = Object.freeze([
116
116
  // exist is worse than one that omits the field.
117
117
  { name: "commentsMode", wireType: "enum", zero: "SITE_COMMENTS_MODE_UNSPECIFIED" },
118
118
  ...stringFields("tiptapImagePlacement"),
119
+ // One IANA zone id (TR01091). The server answers UTC for a site that
120
+ // never set one, so a pulled snapshot is never empty.
121
+ ...stringFields("timeZone"),
119
122
  { name: "allowSearch", wireType: "boolean" },
120
123
  { name: "showFollowButton", wireType: "boolean" },
121
124
  { name: "albumSeamless", wireType: "boolean" },
@@ -156,13 +156,6 @@ export async function deliveryCheck(invocation) {
156
156
  let browser;
157
157
  if (invocation.browser === false) {
158
158
  browser = { status: "unchecked", reason: "disabled", note: "Browser verification was not requested." };
159
- } else if (report.runtime.legacy) {
160
- // There is no pinned entry to compare a browser load against.
161
- browser = {
162
- status: "unchecked",
163
- reason: "legacy_runtime_reference",
164
- note: "The page names a pre-pinning runtime pointer, so no entry is pinned to compare a browser load against; republish the site and rerun.",
165
- };
166
159
  } else {
167
160
  onProgress("Browser dimension: probing with Playwright when it is installed.");
168
161
  browser = await probeDeliveryWithBrowser({
@@ -0,0 +1,67 @@
1
+ import { withRefusalGuidance } from "../api.js";
2
+ import { VERB_FORMS_PULL } from "../constants.js";
3
+ import { getPageIndex, getSiteForm, listSiteForms } from "../forms-api.js";
4
+ import { afterSubmitPage, formFileName, projectFormForWorkspace } from "../forms-contract.js";
5
+ import { formsManifestEntry, siteSettingsBaseline } from "../forms-workspace.js";
6
+ import { boundedList, openSession, successResult } from "../session.js";
7
+ import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
8
+
9
+ const MAXIMUM_REPORTED = 200;
10
+
11
+ /**
12
+ * `forms pull` — write one file per live form and record the versions read.
13
+ *
14
+ * It overwrites a form's file with the site's state, so a local edit that was
15
+ * never pushed is replaced; that is what pull means everywhere else in this
16
+ * CLI. An archived form is reported and not written, because `forms push`
17
+ * refuses an archived form and a file that can never be pushed is noise.
18
+ */
19
+ export async function formsPull(invocation) {
20
+ const { client, config, siteId, onProgress } = await openSession(invocation);
21
+ // The manifest is the pulled workspace this baseline belongs to, the same
22
+ // precondition every push has.
23
+ const manifest = await readManifest(config.workspaceDir, siteId);
24
+
25
+ const listed = await withRefusalGuidance(onProgress, "forms pull", async () => await listSiteForms(client, siteId));
26
+ const live = listed.filter((form) => !form.archived);
27
+ const archived = listed.filter((form) => form.archived).map((form) => form.key).sort();
28
+
29
+ const baselines = [];
30
+ const written = [];
31
+ // A form that sends visitors to a page is written with the page's path, which needs the page list once.
32
+ let pages;
33
+ for (const summary of live) {
34
+ const { form, version } = await withRefusalGuidance(
35
+ onProgress,
36
+ "forms pull",
37
+ async () => await getSiteForm(client, siteId, summary.id),
38
+ );
39
+ if (pages === undefined && afterSubmitPage(version.definition)) {
40
+ pages = await withRefusalGuidance(onProgress, "forms pull", async () => await getPageIndex(client, siteId, { onProgress }));
41
+ }
42
+ const document_ = projectFormForWorkspace({
43
+ key: form.key,
44
+ name: form.name,
45
+ sink: form.sink,
46
+ retention_days: form.retentionDays,
47
+ definition: version.definition,
48
+ }, pages?.pathById);
49
+ await writeWorkspaceJson(config.workspaceDir, formFileName(form.key), document_);
50
+ baselines.push([form.key, { id: form.id, version: version.version, settings: siteSettingsBaseline(form) }]);
51
+ written.push(form.key);
52
+ }
53
+
54
+ manifest.forms = formsManifestEntry(baselines);
55
+ await writeManifest(config.workspaceDir, manifest);
56
+ onProgress(`Wrote ${written.length} form file${written.length === 1 ? "" : "s"} and recorded their versions.`);
57
+
58
+ const reported = boundedList(written.sort(), MAXIMUM_REPORTED);
59
+ return successResult(VERB_FORMS_PULL, siteId, {
60
+ forms: {
61
+ total: written.length,
62
+ items: reported.items.map((key) => ({ key, file: formFileName(key) })),
63
+ ...(reported.truncated ? { itemsTruncated: true } : {}),
64
+ ...(archived.length > 0 ? { archived } : {}),
65
+ },
66
+ });
67
+ }
@@ -0,0 +1,227 @@
1
+ import { withRefusalGuidance } from "../api.js";
2
+ import { VERB_FORMS_PULL, VERB_FORMS_PUSH } from "../constants.js";
3
+ import { SiteAuthoringError } from "../errors.js";
4
+ import {
5
+ createSiteForm,
6
+ getPageIndex,
7
+ getSiteFormByKey,
8
+ saveSiteFormVersion,
9
+ updateSiteFormSettings,
10
+ } from "../forms-api.js";
11
+ import { afterSubmitPage, formFileName, projectFormForWorkspace, storedDefinition } from "../forms-contract.js";
12
+ import { readWorkspaceForms, recordedForm, siteSettingsBaseline, withRecordedForm } from "../forms-workspace.js";
13
+ import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
14
+ import { ApiError } from "../transport.js";
15
+ import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
16
+
17
+ function changedOnTheSite(key, cause, settingsApplied) {
18
+ return new SiteAuthoringError(
19
+ "forms.concurrent_modification",
20
+ `${formFileName(key)}: the form changed on the site while it was being pushed. Run '${VERB_FORMS_PULL}', `
21
+ + `reconcile the file, and retry. ${
22
+ settingsApplied ? "Its settings were applied; its definition was not." : "This form was not changed."
23
+ }`,
24
+ { field: formFileName(key), status: cause.status },
25
+ );
26
+ }
27
+
28
+ function pullRequired(key, reason) {
29
+ return new SiteAuthoringError(
30
+ "forms.pull_required",
31
+ `${reason} Run '${VERB_FORMS_PULL}', reconcile ${formFileName(key)}, and retry. Nothing was written.`,
32
+ { field: formFileName(key) },
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Decides what one form needs, from the file, the site's copy and the
38
+ * baseline the last pull recorded. Throws before anything is written.
39
+ */
40
+ function planForm(form, remote, baseline, pages) {
41
+ // An omitted retention means "the site's choice": the plan-aware default on
42
+ // creation, and the form's current value on an update.
43
+ const requestedRetention = form.document.retention_days;
44
+ const desired = projectFormForWorkspace({
45
+ ...form.document,
46
+ retention_days: requestedRetention ?? remote?.form.retentionDays,
47
+ }, pages?.pathById);
48
+ if (remote === undefined) {
49
+ if (baseline !== undefined) {
50
+ throw pullRequired(form.key, "This workspace recorded the form, but the site no longer has it.");
51
+ }
52
+ return { form, desired, request: { ...desired, retention_days: requestedRetention }, action: "create" };
53
+ }
54
+ if (remote.form.archived) {
55
+ throw new SiteAuthoringError(
56
+ "forms.archived",
57
+ `${formFileName(form.key)}: the site's form '${form.key}' is archived. Restore it on the site's Forms page, `
58
+ + "delete the file, or name the other keys to push. Nothing was written.",
59
+ { field: formFileName(form.key) },
60
+ );
61
+ }
62
+ if (baseline === undefined) {
63
+ throw pullRequired(form.key, "The site already has a form with this key that this workspace never pulled.");
64
+ }
65
+ const siteSettings = siteSettingsBaseline(remote.form);
66
+ if (
67
+ baseline.id !== remote.form.id
68
+ || baseline.version !== remote.version.version
69
+ // Name, sink and retention change without a new version, so they are
70
+ // fenced separately; a stale file would otherwise revert them.
71
+ || JSON.stringify(baseline.settings) !== JSON.stringify(siteSettings)
72
+ ) {
73
+ throw new SiteAuthoringError(
74
+ "forms.concurrent_modification",
75
+ `${formFileName(form.key)}: the form changed on the site after this workspace read it (recorded version `
76
+ + `${baseline.version}, site has ${remote.version.version}; its name, sink or retention may also have changed). Run '${VERB_FORMS_PULL}', reconcile the file, `
77
+ + "and retry. Nothing was written.",
78
+ { field: formFileName(form.key) },
79
+ );
80
+ }
81
+ const current = projectFormForWorkspace({
82
+ key: remote.form.key,
83
+ name: remote.form.name,
84
+ sink: remote.form.sink,
85
+ retention_days: remote.form.retentionDays,
86
+ definition: remote.version.definition,
87
+ }, pages?.pathById);
88
+ const settingsChanged = current.name !== desired.name
89
+ || current.sink !== desired.sink
90
+ || current.retention_days !== desired.retention_days;
91
+ const versionChanged = JSON.stringify(current.definition) !== JSON.stringify(desired.definition);
92
+ return {
93
+ form,
94
+ desired,
95
+ remote,
96
+ baseline,
97
+ action: settingsChanged || versionChanged ? "update" : "unchanged",
98
+ settingsChanged,
99
+ versionChanged,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * `forms push` — create or update each form file in the workspace.
105
+ *
106
+ * Every file is validated and every form is compared with the site before the
107
+ * first write, so a conflict on the third form refuses the whole push rather
108
+ * than landing two. After that each form is written and its baseline advanced
109
+ * immediately, so a failure partway leaves a workspace that the next push can
110
+ * resume from. Editing a definition appends a version on the site; the
111
+ * published pages keep the version they were built with until the next deploy.
112
+ */
113
+ export async function formsPush(invocation) {
114
+ const session = await openSession(invocation);
115
+ const { client, config, siteId, onProgress } = session;
116
+ warnIfExternalWritesPaused(session, VERB_FORMS_PUSH);
117
+ const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys);
118
+ onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"}.`);
119
+ let manifest = await readManifest(config.workspaceDir, siteId);
120
+
121
+ // The site names the page after a response by resource id and the file by path, so the
122
+ // page list is read once, and only when a form on either side points at a page.
123
+ let pages;
124
+ const plans = [];
125
+ for (const form of forms) {
126
+ const remote = await withRefusalGuidance(
127
+ onProgress,
128
+ "forms push",
129
+ async () => await getSiteFormByKey(client, siteId, form.key),
130
+ );
131
+ if (pages === undefined && (afterSubmitPage(form.document.definition) || afterSubmitPage(remote?.version.definition))) {
132
+ pages = await withRefusalGuidance(onProgress, "forms push", async () => await getPageIndex(client, siteId, { onProgress }));
133
+ }
134
+ const plan = planForm(form, remote, recordedForm(manifest, form.key), pages);
135
+ // A page_path the site does not have is refused here, with the rest of the checks, not partway
136
+ // through the writes. Only a definition that is going to be sent is resolved: a settings-only
137
+ // update of a form whose page was deleted never sends it.
138
+ plans.push(
139
+ plan.action === "create" || plan.versionChanged
140
+ ? { ...plan, wireDefinition: storedDefinition(form.file, (plan.request ?? plan.desired).definition, pages) }
141
+ : plan,
142
+ );
143
+ }
144
+
145
+ const results = [];
146
+ for (const plan of plans) {
147
+ let { desired } = plan;
148
+ const { form } = plan;
149
+ let formId = plan.remote?.form.id;
150
+ let version = plan.remote?.version.version;
151
+ // The baseline is what the site stored, not what the file said: the site
152
+ // normalizes some values, and the next push is fenced against its copy.
153
+ let settings = plan.remote === undefined ? undefined : siteSettingsBaseline(plan.remote.form);
154
+ let settingsApplied = false;
155
+ try {
156
+ if (plan.action === "create") {
157
+ const created = await withRefusalGuidance(
158
+ onProgress,
159
+ "forms push",
160
+ async () => await createSiteForm(client, siteId, { ...plan.request, definition: plan.wireDefinition }),
161
+ );
162
+ formId = created.form.id;
163
+ version = created.version.version;
164
+ settings = siteSettingsBaseline(created.form);
165
+ // The file is written from what the site stored: it chose the retention
166
+ // and normalized the name.
167
+ desired = projectFormForWorkspace({
168
+ ...desired,
169
+ name: created.form.name,
170
+ sink: created.form.sink,
171
+ retention_days: created.form.retentionDays,
172
+ }, pages?.pathById);
173
+ } else if (plan.action === "update") {
174
+ if (plan.settingsChanged) {
175
+ settings = siteSettingsBaseline(
176
+ await withRefusalGuidance(
177
+ onProgress,
178
+ "forms push",
179
+ async () => await updateSiteFormSettings(client, siteId, formId, desired, plan.baseline.settings),
180
+ ),
181
+ );
182
+ settingsApplied = true;
183
+ // Recorded at once: if the version save below fails, the next push
184
+ // must not read the settings it just applied as someone else's edit.
185
+ manifest = withRecordedForm(manifest, form.key, { id: formId, version, settings });
186
+ await writeManifest(config.workspaceDir, manifest);
187
+ }
188
+ if (plan.versionChanged) {
189
+ version = (await withRefusalGuidance(
190
+ onProgress,
191
+ "forms push",
192
+ async () => await saveSiteFormVersion(client, siteId, formId, { ...desired, definition: plan.wireDefinition }, plan.baseline.version),
193
+ )).version;
194
+ }
195
+ }
196
+ } catch (caught) {
197
+ // The site checks the recorded version and settings under its own lock,
198
+ // which is what closes the gap between the comparison above and the write.
199
+ const error = caught instanceof ApiError && (caught.hasField("ExpectedVersion") || caught.hasField("ExpectedSettings"))
200
+ ? changedOnTheSite(form.key, caught, settingsApplied)
201
+ : caught;
202
+ if (error instanceof SiteAuthoringError) {
203
+ const completed = results.filter((item) => item.action !== "unchanged").map((item) => item.key);
204
+ // The settings write stands even though the version save after it failed.
205
+ if (settingsApplied) completed.push(form.key);
206
+ if (completed.length > 0) throw error.withCompletedWrites(completed);
207
+ }
208
+ throw error;
209
+ }
210
+ if (plan.action !== "unchanged") {
211
+ // The file is rewritten in canonical form so the workspace and the site
212
+ // agree byte for byte, and the baseline moves with it.
213
+ await writeWorkspaceJson(config.workspaceDir, formFileName(form.key), desired);
214
+ manifest = withRecordedForm(manifest, form.key, { id: formId, version, settings });
215
+ await writeManifest(config.workspaceDir, manifest);
216
+ }
217
+ results.push({ key: form.key, action: plan.action, version });
218
+ onProgress(`${form.key}: ${plan.action}${plan.action === "unchanged" ? "" : `, version ${version}`}.`);
219
+ }
220
+
221
+ const written = results.filter((item) => item.action !== "unchanged").map((item) => item.key);
222
+ return successResult(VERB_FORMS_PUSH, siteId, {
223
+ forms: { count: results.length, items: results },
224
+ written: { items: written, count: written.length },
225
+ ...(written.length > 0 ? { nextStep: "deploy --staging" } : {}),
226
+ });
227
+ }
@@ -0,0 +1,36 @@
1
+ import { VERB_FORMS_VALIDATE } from "../constants.js";
2
+ import { SiteAuthoringError } from "../errors.js";
3
+ import { readWorkspaceForms } from "../forms-workspace.js";
4
+ import { openAnonymousSession, successResult } from "../session.js";
5
+
6
+ /**
7
+ * `forms validate` — prove the workspace's form files offline.
8
+ *
9
+ * It runs the same file contract and the same shared field validator
10
+ * `forms push` does, so a file that passes here is not refused for its shape
11
+ * by the push. It cannot prove what only the site knows: whether the key is
12
+ * already taken by a form someone else changed, or whether the caller may
13
+ * write forms.
14
+ */
15
+ export async function formsValidate(invocation) {
16
+ const { config, onProgress } = await openAnonymousSession({ ...invocation, client: null });
17
+ if (config === undefined) {
18
+ throw new SiteAuthoringError(
19
+ "config.not_found",
20
+ "No taproot-site.json was found. forms validate reads the workspace it names.",
21
+ );
22
+ }
23
+ const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys);
24
+ onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"}.`);
25
+ return successResult(VERB_FORMS_VALIDATE, undefined, {
26
+ forms: {
27
+ count: forms.length,
28
+ items: forms.map((form) => ({
29
+ key: form.key,
30
+ file: form.file,
31
+ fields: form.document.definition.fields.length,
32
+ sink: form.document.sink ?? "none",
33
+ })),
34
+ },
35
+ });
36
+ }
@@ -4,6 +4,9 @@ import {
4
4
  VERB_DEPLOY,
5
5
  VERB_ENV,
6
6
  VERB_FOOTER_PUSH,
7
+ VERB_FORMS_PULL,
8
+ VERB_FORMS_PUSH,
9
+ VERB_FORMS_VALIDATE,
7
10
  VERB_LOGIN,
8
11
  VERB_LOGOUT,
9
12
  VERB_MEDIA_UPLOAD,
@@ -28,6 +31,9 @@ import { deliveryCheck } from "./delivery-check.js";
28
31
  import { deploy } from "./deploy.js";
29
32
  import { env } from "./env.js";
30
33
  import { footerPush } from "./footer-push.js";
34
+ import { formsPull } from "./forms-pull.js";
35
+ import { formsPush } from "./forms-push.js";
36
+ import { formsValidate } from "./forms-validate.js";
31
37
  import { login } from "./login.js";
32
38
  import { logout } from "./logout.js";
33
39
  import { mediaUpload } from "./media-upload.js";
@@ -84,6 +90,8 @@ import { whoami } from "./whoami.js";
84
90
  * fixturePath, // validate: one offline authoring-fixture directory
85
91
  * paths, // media upload: files and/or directories to upload;
86
92
  * // with none, the workspace's media/ directory is walked
93
+ * formKeys, // forms validate / push: narrow to these form keys;
94
+ * // with none, every file under forms/
87
95
  * pagePaths, // approve: narrow to these page paths; with none, every
88
96
  * // draft the workspace manifest tracks is staged
89
97
  * pageId, // preview page: the one persisted draft UUID to render
@@ -124,6 +132,9 @@ export const VERB_HANDLERS = Object.freeze({
124
132
  [VERB_REDIRECTS_PUSH]: redirectsPush,
125
133
  [VERB_THEME_PUSH]: themePush,
126
134
  [VERB_FOOTER_PUSH]: footerPush,
135
+ [VERB_FORMS_PULL]: formsPull,
136
+ [VERB_FORMS_PUSH]: formsPush,
137
+ [VERB_FORMS_VALIDATE]: formsValidate,
127
138
  [VERB_MEDIA_UPLOAD]: mediaUpload,
128
139
  [VERB_APPROVE]: approve,
129
140
  [VERB_DEPLOY]: deploy,
package/src/verbs/pull.js CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  workspaceContentHash,
64
64
  workspaceFileExists,
65
65
  workspaceFileNameForPage,
66
+ readManifest,
66
67
  writeManifest,
67
68
  writeWorkspaceFile,
68
69
  writeWorkspaceJson,
@@ -1103,6 +1104,12 @@ export async function pull(invocation) {
1103
1104
  await writeWorkspaceJson(config.workspaceDir, document.file, document.value);
1104
1105
  }
1105
1106
 
1107
+ // `forms pull` owns the forms baseline, and a plain pull does not read forms.
1108
+ // Carrying it forward keeps the next `forms push` fenced instead of refusing
1109
+ // it as never pulled. An unreadable old manifest has nothing to carry.
1110
+ const formsBaseline = (await readManifest(config.workspaceDir, siteId, { required: false }).catch(() =>
1111
+ undefined
1112
+ ))?.forms;
1106
1113
  const manifest = {
1107
1114
  manifestVersion: MANIFEST_VERSION,
1108
1115
  siteId,
@@ -1123,6 +1130,7 @@ export async function pull(invocation) {
1123
1130
  : {}
1124
1131
  ),
1125
1132
  ...(presentationRevision === undefined ? {} : { presentation: presentationManifestEntry(presentationRevision) }),
1133
+ ...(formsBaseline === undefined ? {} : { forms: formsBaseline }),
1126
1134
  pages: manifestPages,
1127
1135
  };
1128
1136
  await writeManifest(config.workspaceDir, manifest);