@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.
@@ -33,7 +33,7 @@ export const FIXTURE_REQUIRED_ROOT_FIELDS = Object.freeze([
33
33
  * evidence. When appearance/footer are present, validate checks their local
34
34
  * image bindings and footer hashes against the fixture settings.
35
35
  */
36
- export const FIXTURE_OPTIONAL_ROOT_FIELDS = Object.freeze(["pulledAt", "deployments", "appearance", "footer"]);
36
+ export const FIXTURE_OPTIONAL_ROOT_FIELDS = Object.freeze(["pulledAt", "deployments", "appearance", "footer", "forms"]);
37
37
 
38
38
  /** Every root field the manifest may declare. Anything else is refused. */
39
39
  export const FIXTURE_ROOT_FIELDS = Object.freeze(
@@ -6,6 +6,8 @@ import { loadSiteConfig } from "./config.js";
6
6
  import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
7
7
  import { FIXTURE_CONTRACT_VERSION, FIXTURE_MANIFEST_FILE_NAME } from "./fixture-contract.js";
8
8
  import { appearanceManifestEntry, footerManifestEntry } from "./footer-workspace.js";
9
+ import { formFileName } from "./forms-contract.js";
10
+ import { formsManifestEntry, readWorkspaceForms } from "./forms-workspace.js";
9
11
  import { SETTINGS_GROUPS, SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "./settings-catalog.js";
10
12
  import { validateWorkspacePageSource } from "./verbs/pages-push.js";
11
13
  import { validateThemeWorkspace } from "./verbs/theme-push.js";
@@ -194,6 +196,18 @@ export async function initializeFixture(invocation, validate) {
194
196
  // A fixture hash is a local placeholder, not authority to mutate the original site.
195
197
  const revision = createHash("sha256").update(JSON.stringify(redirects.entries)).digest("hex");
196
198
  if (Object.hasOwn(redirects, "revision")) redirects.revision = revision;
199
+ // Forms are optional in a workspace. The ones the pull manifest recorded are
200
+ // carried through the same sanitizer as every other document, so a consent
201
+ // text that links somewhere never leaves a real origin in a shared fixture.
202
+ const formItems = Object.keys(source.forms?.items ?? {}).sort();
203
+ const forms = formItems.length === 0 ? [] : await readWorkspaceForms(workspaceDir, formItems);
204
+ for (const form of forms) add(formFileName(form.key), sanitize(form.document));
205
+ const formsManifest = forms.length === 0
206
+ ? undefined
207
+ : formsManifestEntry(forms.map((form) => [form.key, {
208
+ id: identity(source.forms.items[form.key].id),
209
+ version: source.forms.items[form.key].version,
210
+ }]));
197
211
  const footer = footerManifestEntry(appearanceDocuments[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES].footerSettings);
198
212
  const appearance = appearanceManifestEntry(appearanceDocuments);
199
213
  for (const id of [...footer.imageIds, ...appearance.imageIds]) imageIds.add(id);
@@ -208,6 +222,7 @@ export async function initializeFixture(invocation, validate) {
208
222
  settingsSkipped: [],
209
223
  appearance,
210
224
  footer,
225
+ ...(formsManifest === undefined ? {} : { forms: formsManifest }),
211
226
  fixture: {
212
227
  contractVersion: FIXTURE_CONTRACT_VERSION,
213
228
  imageIds: [...imageIds].sort(),
@@ -0,0 +1,192 @@
1
+ import { listSitePages, sitePath } from "./api.js";
2
+ import { HTTP_NOT_FOUND } from "./constants.js";
3
+ import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
4
+ import { FORM_DEFAULT_SINK, FORM_SINKS, isFormKey, normalizePagePath } from "./forms-contract.js";
5
+ import { ApiError } from "./transport.js";
6
+
7
+ /**
8
+ * The owner-side forms routes `forms pull` and `forms push` call (TR01061).
9
+ *
10
+ * The wire spells a sink and a status as proto enum names; the workspace file
11
+ * uses the lowercase word. The translation lives here so neither side learns
12
+ * the other's vocabulary. A form's definition travels as JSON text and comes
13
+ * back as canonical JSON text; it is parsed on the way in so the verbs handle
14
+ * one shape.
15
+ */
16
+
17
+ const SINK_WIRE = Object.freeze({ none: "SITE_FORM_SINK_NONE", newsletter: "SITE_FORM_SINK_NEWSLETTER" });
18
+ const STATUS_ARCHIVED_WIRE = "SITE_FORM_STATUS_ARCHIVED";
19
+ // The cap is the owner's own forms list, which the site bounds; this is the
20
+ // reply-size bound for the largest form a workspace file may describe.
21
+ const FORMS_RESPONSE_BYTES = 2 * 1024 * 1024;
22
+
23
+ function contract(message, field) {
24
+ return new SiteAuthoringError("api.forms_contract", message, { field });
25
+ }
26
+
27
+ function sinkFromWire(value) {
28
+ if (value === undefined || value === "" || value === "SITE_FORM_SINK_UNSPECIFIED") return FORM_DEFAULT_SINK;
29
+ const sink = FORM_SINKS.find((candidate) => SINK_WIRE[candidate] === value);
30
+ if (sink === undefined) throw contract("Taproot returned an unsupported form sink.", "sink");
31
+ return sink;
32
+ }
33
+
34
+ function normalizeForm(value) {
35
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
36
+ throw contract("Taproot returned a form that is not an object.", "form");
37
+ }
38
+ if (!isCanonicalUuid(value.id) || !isFormKey(value.key)) {
39
+ throw contract("Taproot returned a form with no usable id or a key this workspace cannot store.", "form");
40
+ }
41
+ return {
42
+ id: value.id,
43
+ key: value.key,
44
+ name: typeof value.name === "string" ? value.name : "",
45
+ sink: sinkFromWire(value.sink),
46
+ retentionDays: Number.isSafeInteger(value.retentionDays) ? value.retentionDays : undefined,
47
+ archived: value.status === STATUS_ARCHIVED_WIRE,
48
+ currentVersion: Number.isSafeInteger(value.currentVersion) ? value.currentVersion : 0,
49
+ };
50
+ }
51
+
52
+ function normalizeVersion(value) {
53
+ if (value === null || typeof value !== "object" || typeof value.definition !== "string") {
54
+ throw contract("Taproot returned a form with no current version.", "currentVersion");
55
+ }
56
+ let definition;
57
+ try {
58
+ definition = JSON.parse(value.definition);
59
+ } catch {
60
+ throw contract("Taproot returned a form definition that is not JSON.", "definition");
61
+ }
62
+ return {
63
+ version: Number.isSafeInteger(value.version) ? value.version : 0,
64
+ definition,
65
+ };
66
+ }
67
+
68
+ function normalizeFormResponse(value) {
69
+ const form = normalizeForm(value?.form);
70
+ return { form, version: normalizeVersion(value?.currentVersion) };
71
+ }
72
+
73
+ /**
74
+ * The site's pages by path and by resource id, for the one place a form file and the
75
+ * site spell a page differently. `truncated` says the listing hit its bound, so a
76
+ * path that is not here may still exist and an id that is not here is not proof.
77
+ * `notTargetIds` holds the pages a form may not send visitors to.
78
+ */
79
+ export async function getPageIndex(client, siteId, options) {
80
+ const { pages, truncated } = await listSitePages(client, siteId, options);
81
+ const idByPath = new Map();
82
+ const pathById = new Map();
83
+ const notTargetIds = new Set();
84
+ for (const page of pages) {
85
+ if (page.resourceId === "") continue;
86
+ const path = normalizePagePath(page.path);
87
+ idByPath.set(path, page.resourceId);
88
+ pathById.set(page.resourceId, path);
89
+ if (!isAfterSubmitTarget(page, path)) notTargetIds.add(page.resourceId);
90
+ }
91
+ return { idByPath, pathById, notTargetIds, truncated };
92
+ }
93
+
94
+ // The site's rule for where a form may send visitors: a page an author creates, the home page included.
95
+ // Generated pages, the 404 page, integration-managed pages, legal pages and a profile home are not targets.
96
+ const NOT_TARGET_TEMPLATES = new Set([
97
+ "TEMPLATE_TYPE_GENERATED",
98
+ "TEMPLATE_TYPE_INTEGRATION_MANAGED",
99
+ "TEMPLATE_TYPE_LEGAL",
100
+ "TEMPLATE_TYPE_PROFILE_HOME",
101
+ ]);
102
+ function isAfterSubmitTarget(page, path) {
103
+ return !page.isGenerated && !NOT_TARGET_TEMPLATES.has(page.templateType) && path.toLowerCase() !== "/404";
104
+ }
105
+
106
+ /** Every form on the site, archived ones included. */
107
+ export async function listSiteForms(client, siteId) {
108
+ const response = await client.request(sitePath(siteId, "forms"), { maximumResponseBytes: FORMS_RESPONSE_BYTES });
109
+ return (Array.isArray(response?.forms) ? response.forms : []).map(normalizeForm);
110
+ }
111
+
112
+ export async function getSiteForm(client, siteId, formId) {
113
+ return normalizeFormResponse(
114
+ await client.request(sitePath(siteId, `forms/${encodeURIComponent(formId)}`), {
115
+ maximumResponseBytes: FORMS_RESPONSE_BYTES,
116
+ }),
117
+ );
118
+ }
119
+
120
+ /** The form with this key and its current version, or undefined when the site has none. */
121
+ export async function getSiteFormByKey(client, siteId, key) {
122
+ try {
123
+ return normalizeFormResponse(
124
+ await client.request(sitePath(siteId, `form-keys/${encodeURIComponent(key)}`), {
125
+ maximumResponseBytes: FORMS_RESPONSE_BYTES,
126
+ }),
127
+ );
128
+ } catch (error) {
129
+ if (error instanceof ApiError && error.httpStatus === HTTP_NOT_FOUND) return undefined;
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ export async function createSiteForm(client, siteId, form) {
135
+ return normalizeFormResponse(
136
+ await client.request(sitePath(siteId, "forms"), {
137
+ method: "POST",
138
+ body: {
139
+ key: form.key,
140
+ name: form.name,
141
+ sink: SINK_WIRE[form.sink],
142
+ retentionDays: form.retention_days,
143
+ definition: JSON.stringify(form.definition),
144
+ },
145
+ maximumResponseBytes: FORMS_RESPONSE_BYTES,
146
+ }),
147
+ );
148
+ }
149
+
150
+ /**
151
+ * `expected` is the settings the caller last read; the site refuses the update
152
+ * (field ExpectedSettings) when the form no longer holds them, so a change made
153
+ * by someone else in between is not replaced.
154
+ */
155
+ export async function updateSiteFormSettings(client, siteId, formId, form, expected) {
156
+ return normalizeForm(
157
+ await client.request(sitePath(siteId, `forms/${encodeURIComponent(formId)}/settings`), {
158
+ method: "PUT",
159
+ body: {
160
+ name: form.name,
161
+ sink: SINK_WIRE[form.sink],
162
+ retentionDays: form.retention_days,
163
+ ...(expected === undefined
164
+ ? {}
165
+ : {
166
+ expectedName: expected.name,
167
+ expectedSink: SINK_WIRE[expected.sink],
168
+ expectedRetentionDays: expected.retention_days,
169
+ }),
170
+ },
171
+ maximumResponseBytes: FORMS_RESPONSE_BYTES,
172
+ }),
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Appends a version; the site returns the current one when nothing changed.
178
+ * `expectedVersion` is the version the caller last read; the site refuses the
179
+ * save (field ExpectedVersion) when the form has moved past it.
180
+ */
181
+ export async function saveSiteFormVersion(client, siteId, formId, form, expectedVersion) {
182
+ return normalizeVersion(
183
+ await client.request(sitePath(siteId, `forms/${encodeURIComponent(formId)}/versions`), {
184
+ method: "POST",
185
+ body: {
186
+ definition: JSON.stringify(form.definition),
187
+ ...(expectedVersion === undefined ? {} : { expectedVersion }),
188
+ },
189
+ maximumResponseBytes: FORMS_RESPONSE_BYTES,
190
+ }),
191
+ );
192
+ }
@@ -0,0 +1,274 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+ import { contactFieldId, validateDefinition } from "./field-validation.js";
3
+
4
+ /**
5
+ * The workspace contract for a form (TR01061).
6
+ *
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
9
+ * and the form's own settings (`contact_field`, `submit_label`, `after_submit`),
10
+ * exactly as the site stores it, so one validator reads one shape. The
11
+ * definition's own rules belong to `shared/field-validation.ts` and are run
12
+ * here through the generated copy, so this file never restates them. The one
13
+ * translation is a page: the site names the page after a response by resource
14
+ * id, and the file by `page_path`, which `forms push` resolves and `forms pull`
15
+ * writes back, so an author never handles an id. Everything
16
+ * the site decides (the version, the form id, who edited it last) stays out of
17
+ * the file and in the pull manifest, which is what lets `forms pull` and
18
+ * `forms push` round-trip the file byte for byte.
19
+ */
20
+
21
+ export const FORMS_DIRECTORY = "forms";
22
+ export const FORM_FILE_EXTENSION = ".json";
23
+
24
+ // Mirrors SiteFormContract.IsValidKey on the API: the key is also a file name
25
+ // and an attribute value, so it stays lowercase and URL-safe.
26
+ export const FORM_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
27
+ export const FORM_KEY_MAXIMUM_LENGTH = 64;
28
+ export const FORM_SINKS = Object.freeze(["none", "newsletter"]);
29
+ export const FORM_DEFAULT_SINK = "none";
30
+ export const FORM_RETENTION_DAYS = Object.freeze({ minimum: 30, maximum: 730, default: 365 });
31
+ export const FORM_LIMITS = Object.freeze({
32
+ nameCharacters: 120,
33
+ // The site refuses a definition larger than this once serialized.
34
+ definitionBytes: 64 * 1024,
35
+ fileBytes: 256 * 1024,
36
+ });
37
+
38
+ const FORM_FILE_KEYS = ["key", "name", "sink", "retention_days", "definition"];
39
+ // The order `forms pull` writes, which `forms push` writes back after a save.
40
+ // Fixed rather than inherited from the response so the same form is always the
41
+ // same bytes.
42
+ const FIELD_KEY_ORDER = ["id", "type", "label", "hint", "required", "min_length", "max_length", "min", "max", "format", "choices"];
43
+ const AFTER_SUBMIT_KEY_ORDER = ["show", "message", "page_path", "page_resource_id"];
44
+ // A nil id stands in for a `page_path` while the shared validator checks everything else about the file.
45
+ const PLACEHOLDER_RESOURCE_ID = "00000000-0000-0000-0000-000000000000";
46
+ const MAXIMUM_REPORTED_ERRORS = 20;
47
+
48
+ function isPlainObject(value) {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+
52
+ function invalid(file, message, field) {
53
+ return new SiteAuthoringError("forms.file_invalid", `${file}: ${message}`, { field: `${file}:${field}` });
54
+ }
55
+
56
+ /**
57
+ * A site page path as the file writes it: a leading slash, no trailing one, and none of the
58
+ * characters that would make it a query, a fragment or another origin. `/` is the home page.
59
+ */
60
+ export function isCanonicalPagePath(path) {
61
+ return path === "/" || /^\/[^\s\\?#/]+(?:\/[^\s\\?#/]+)*$/u.test(path);
62
+ }
63
+
64
+ /** The canonical spelling of a path a page summary reports, which may carry or lack slashes at either end. */
65
+ export function normalizePagePath(path) {
66
+ return `/${path.replace(/^\/+|\/+$/gu, "")}`;
67
+ }
68
+
69
+ /** The `after_submit` that sends visitors to a page, or undefined for a form that shows its message. */
70
+ export function afterSubmitPage(definition) {
71
+ const after = definition?.after_submit;
72
+ return isPlainObject(after) && after.show === "page" ? after : undefined;
73
+ }
74
+
75
+ /** The workspace path a form key is stored at. */
76
+ export function formFileName(key) {
77
+ return `${FORMS_DIRECTORY}/${key}${FORM_FILE_EXTENSION}`;
78
+ }
79
+
80
+ export function isFormKey(value) {
81
+ return typeof value === "string" && value.length <= FORM_KEY_MAXIMUM_LENGTH && FORM_KEY_PATTERN.test(value);
82
+ }
83
+
84
+ /** NUL and lone surrogates cannot be stored by the site; it refuses them as invalid_characters. */
85
+ function hasUnstorableCharacters(text) {
86
+ return /\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(text);
87
+ }
88
+
89
+ function anyUnstorableString(value) {
90
+ if (typeof value === "string") return hasUnstorableCharacters(value);
91
+ if (Array.isArray(value)) return value.some(anyUnstorableString);
92
+ if (value !== null && typeof value === "object") {
93
+ return Object.entries(value).some(([key, child]) => hasUnstorableCharacters(key) || anyUnstorableString(child));
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function describeDefinitionErrors(errors) {
99
+ const shown = errors.slice(0, MAXIMUM_REPORTED_ERRORS).map((error) => `${error.path || "definition"}: ${error.code}`);
100
+ const more = errors.length > shown.length ? ` (and ${errors.length - shown.length} more)` : "";
101
+ return `${shown.join("; ")}${more}`;
102
+ }
103
+
104
+ /**
105
+ * Validates one parsed form file and returns it unchanged in meaning.
106
+ *
107
+ * `expectedKey` is the key the file's name promises; a mismatch is refused
108
+ * because a rename on disk would otherwise push a different form than the one
109
+ * the author thinks they are editing.
110
+ */
111
+ export function validateFormDocument(document_, expectedKey) {
112
+ const file = formFileName(expectedKey);
113
+ if (!isPlainObject(document_)) throw invalid(file, "the file must contain one JSON object.", "$");
114
+ for (const name of Object.keys(document_)) {
115
+ if (!FORM_FILE_KEYS.includes(name)) {
116
+ throw invalid(file, `'${name}' is not a form property. Allowed: ${FORM_FILE_KEYS.join(", ")}.`, name);
117
+ }
118
+ }
119
+ if (document_.key !== expectedKey) {
120
+ throw invalid(file, `key must be '${expectedKey}', the name of the file.`, "key");
121
+ }
122
+ if (!isFormKey(expectedKey)) {
123
+ throw invalid(file, `the key must be lowercase letters, digits, and single hyphens between them, at most ${FORM_KEY_MAXIMUM_LENGTH} characters.`, "key");
124
+ }
125
+ // The site stores the trimmed name, so a padded one could never match the baseline pull records.
126
+ if (
127
+ typeof document_.name !== "string"
128
+ || document_.name.trim() === ""
129
+ || document_.name !== document_.name.trim()
130
+ || document_.name.length > FORM_LIMITS.nameCharacters
131
+ ) {
132
+ throw invalid(
133
+ file,
134
+ `name must be 1 to ${FORM_LIMITS.nameCharacters} characters with no space at either end.`,
135
+ "name",
136
+ );
137
+ }
138
+ if (document_.sink !== undefined && !FORM_SINKS.includes(document_.sink)) {
139
+ throw invalid(file, `sink must be one of: ${FORM_SINKS.join(", ")}.`, "sink");
140
+ }
141
+ const days = document_.retention_days;
142
+ if (
143
+ days !== undefined
144
+ && (!Number.isInteger(days) || days < FORM_RETENTION_DAYS.minimum || days > FORM_RETENTION_DAYS.maximum)
145
+ ) {
146
+ throw invalid(
147
+ file,
148
+ `retention_days must be a whole number from ${FORM_RETENTION_DAYS.minimum} to ${FORM_RETENTION_DAYS.maximum}.`,
149
+ "retention_days",
150
+ );
151
+ }
152
+ if (hasUnstorableCharacters(document_.name)) {
153
+ throw invalid(file, "name cannot contain NUL or unpaired surrogate characters.", "name");
154
+ }
155
+ const errors = validateDefinition(shapeCheckedDefinition(file, document_.definition));
156
+ if (errors.length > 0) {
157
+ throw invalid(file, `the definition is invalid: ${describeDefinitionErrors(errors)}`, "definition");
158
+ }
159
+ if (anyUnstorableString(document_.definition)) {
160
+ throw invalid(file, "the definition cannot contain NUL or unpaired surrogate characters.", "definition");
161
+ }
162
+ if (Buffer.byteLength(JSON.stringify(document_.definition), "utf8") > FORM_LIMITS.definitionBytes) {
163
+ throw invalid(file, `the definition is larger than ${FORM_LIMITS.definitionBytes} bytes.`, "definition");
164
+ }
165
+ return document_;
166
+ }
167
+
168
+ /**
169
+ * The definition the shared validator can read: a `page_path` is checked here and
170
+ * replaced by a placeholder id, because the validator only knows the stored shape.
171
+ */
172
+ function shapeCheckedDefinition(file, definition) {
173
+ const after = afterSubmitPage(definition);
174
+ if (after === undefined || !("page_path" in after)) return definition;
175
+ if ("page_resource_id" in after) {
176
+ throw invalid(file, "after_submit names its page twice: use page_path or page_resource_id, not both.", "definition.after_submit");
177
+ }
178
+ if (typeof after.page_path !== "string" || !isCanonicalPagePath(after.page_path)) {
179
+ throw invalid(
180
+ file,
181
+ "after_submit.page_path must be a site path such as '/thanks': a leading slash, no trailing slash, no query or fragment.",
182
+ "definition.after_submit.page_path",
183
+ );
184
+ }
185
+ const { page_path: _path, ...rest } = after;
186
+ return { ...definition, after_submit: { ...rest, page_resource_id: PLACEHOLDER_RESOURCE_ID } };
187
+ }
188
+
189
+ function canonicalAfterSubmit(afterSubmit, pathById) {
190
+ const path = afterSubmit.show === "page" ? pathById?.get(afterSubmit.page_resource_id) : undefined;
191
+ const source = path === undefined ? afterSubmit : { ...afterSubmit, page_resource_id: undefined, page_path: path };
192
+ const ordered = {};
193
+ for (const name of AFTER_SUBMIT_KEY_ORDER) if (source[name] !== undefined) ordered[name] = source[name];
194
+ return ordered;
195
+ }
196
+
197
+ /**
198
+ * The definition as the site stores it: `page_path` replaced by the page's resource id.
199
+ * `pages` is the site's page index, which the caller loads when `afterSubmitPage` says it is needed.
200
+ */
201
+ export function storedDefinition(file, definition, pages) {
202
+ const after = afterSubmitPage(definition);
203
+ if (after === undefined) return definition;
204
+ const { page_path: path, ...rest } = after;
205
+ if (path !== undefined) {
206
+ const id = pages?.idByPath.get(path);
207
+ if (id === undefined) {
208
+ throw new SiteAuthoringError(
209
+ "forms.page_not_found",
210
+ pages?.truncated
211
+ ? `${file}: the site has more pages than this CLI lists, so '${path}' could not be looked up. `
212
+ + "Name the page by page_resource_id instead."
213
+ : `${file}: no page on the site has the path '${path}'. Push the page first, or name a page that exists.`,
214
+ { field: `${file}:definition.after_submit.page_path` },
215
+ );
216
+ }
217
+ rejectNonTarget(file, pages, id, "page_path", path);
218
+ return { ...definition, after_submit: { ...rest, page_resource_id: id } };
219
+ }
220
+ if (pages !== undefined && !pages.truncated && !pages.pathById.has(after.page_resource_id)) {
221
+ throw new SiteAuthoringError(
222
+ "forms.page_not_found",
223
+ `${file}: no page on the site has the resource id '${after.page_resource_id}'. Name the page by its path instead.`,
224
+ { field: `${file}:definition.after_submit.page_resource_id` },
225
+ );
226
+ }
227
+ if (pages !== undefined) rejectNonTarget(file, pages, after.page_resource_id, "page_resource_id", pages.pathById.get(after.page_resource_id));
228
+ return definition;
229
+ }
230
+
231
+ // A form may send visitors only to a page an author created, so this refuses before any write.
232
+ function rejectNonTarget(file, pages, id, field, name) {
233
+ if (!pages.notTargetIds?.has(id)) return;
234
+ throw new SiteAuthoringError(
235
+ "forms.page_not_allowed",
236
+ `${file}: '${name ?? id}' is a generated, system, legal, profile or integration page. A form can send visitors only to a page you created. Name another page.`,
237
+ { field: `${file}:definition.after_submit.${field}` },
238
+ );
239
+ }
240
+
241
+ function canonicalField(field) {
242
+ const ordered = {};
243
+ for (const name of FIELD_KEY_ORDER) if (field[name] !== undefined) ordered[name] = field[name];
244
+ // Anything the site returns that this release does not know is kept after
245
+ // the known names rather than dropped, so an older CLI cannot erase a
246
+ // constraint a newer site added.
247
+ for (const name of Object.keys(field).sort()) if (ordered[name] === undefined) ordered[name] = field[name];
248
+ return ordered;
249
+ }
250
+
251
+ /**
252
+ * The form file for a form as the site holds it. Defaults are written out so a
253
+ * pulled file states the retention and sink it is actually running with. `pathById`
254
+ * maps page resource ids to their paths; a page it knows is written as `page_path`,
255
+ * so the same page is always the same bytes whichever spelling the file used.
256
+ */
257
+ export function projectFormForWorkspace(form, pathById) {
258
+ const definition = { fields: form.definition.fields.map(canonicalField) };
259
+ // The named field stays only when the form's own fields would not pick it, so a form with one email field
260
+ // is written without it and an explicit file still round-trips as unchanged.
261
+ const { contact_field: named } = form.definition;
262
+ if (named !== undefined && contactFieldId({ fields: definition.fields }) !== named) definition.contact_field = named;
263
+ if (form.definition.submit_label !== undefined) definition.submit_label = form.definition.submit_label;
264
+ if (form.definition.after_submit !== undefined) {
265
+ definition.after_submit = canonicalAfterSubmit(form.definition.after_submit, pathById);
266
+ }
267
+ return {
268
+ key: form.key,
269
+ name: form.name,
270
+ sink: form.sink ?? FORM_DEFAULT_SINK,
271
+ retention_days: form.retention_days ?? FORM_RETENTION_DAYS.default,
272
+ definition,
273
+ };
274
+ }
@@ -0,0 +1,100 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+ import {
3
+ FORM_DEFAULT_SINK,
4
+ FORM_FILE_EXTENSION,
5
+ FORM_RETENTION_DAYS,
6
+ FORM_LIMITS,
7
+ FORMS_DIRECTORY,
8
+ formFileName,
9
+ isFormKey,
10
+ validateFormDocument,
11
+ } from "./forms-contract.js";
12
+ import { readWorkspaceJson, walkWorkspaceFiles } from "./workspace.js";
13
+
14
+ /**
15
+ * Reads and validates the workspace's form files (TR01061).
16
+ *
17
+ * `keys` narrows the read to named forms and refuses a name with no file, so a
18
+ * typo never reads as a successful empty push. With none, every file under
19
+ * `forms/` is read. A file whose name is not a form key is refused rather than
20
+ * skipped: skipping it would report success with one form left behind.
21
+ */
22
+ export async function readWorkspaceForms(workspaceDir, keys) {
23
+ const files = await walkWorkspaceFiles(workspaceDir, FORMS_DIRECTORY, [FORM_FILE_EXTENSION]);
24
+ const known = new Map();
25
+ for (const file of files) {
26
+ const key = file.slice(FORMS_DIRECTORY.length + 1, -FORM_FILE_EXTENSION.length);
27
+ if (key.includes("/") || !isFormKey(key)) {
28
+ throw new SiteAuthoringError(
29
+ "forms.file_invalid",
30
+ `${file}: a form file is forms/<key>.json directly under forms/, with a key of lowercase letters, `
31
+ + "digits, and single hyphens between them.",
32
+ { field: file },
33
+ );
34
+ }
35
+ known.set(key, file);
36
+ }
37
+ const selected = keys === undefined ? [...known.keys()].sort() : [...new Set(keys)].sort();
38
+ const forms = [];
39
+ for (const key of selected) {
40
+ const file = known.get(key);
41
+ if (file === undefined) {
42
+ throw new SiteAuthoringError(
43
+ "forms.file_missing",
44
+ `There is no ${formFileName(key)} in the workspace.`,
45
+ { field: formFileName(key) },
46
+ );
47
+ }
48
+ const document_ = await readWorkspaceJson(workspaceDir, file, FORM_LIMITS.fileBytes);
49
+ forms.push({ key, file, document: validateFormDocument(document_, key) });
50
+ }
51
+ return forms;
52
+ }
53
+
54
+ /**
55
+ * The forms baseline in the pull manifest (TR01061).
56
+ *
57
+ * The site has no compare-and-set for a form, so `forms push` fences itself:
58
+ * it writes a form only when the site's current version is the one the last
59
+ * pull or push recorded here. A form edited in the browser since then makes
60
+ * the push refuse toward pull instead of overwriting it.
61
+ */
62
+ export const FORMS_MANIFEST_KEY = "forms";
63
+
64
+ function isObject(value) {
65
+ return value !== null && typeof value === "object" && !Array.isArray(value);
66
+ }
67
+
68
+ /**
69
+ * The settings half of a baseline, from the form exactly as the site returned
70
+ * it. Name, sink and retention change without appending a version, so the
71
+ * version alone cannot say they moved.
72
+ */
73
+ export function siteSettingsBaseline(siteForm) {
74
+ return {
75
+ name: siteForm.name,
76
+ sink: siteForm.sink ?? FORM_DEFAULT_SINK,
77
+ retention_days: siteForm.retentionDays ?? FORM_RETENTION_DAYS.default,
78
+ };
79
+ }
80
+
81
+ /** The recorded baseline for one key, or undefined when this workspace never saw that form. */
82
+ export function recordedForm(manifest, key) {
83
+ const items = manifest[FORMS_MANIFEST_KEY]?.items;
84
+ if (!isObject(items) || !Object.hasOwn(items, key)) return undefined;
85
+ const entry = items[key];
86
+ return isObject(entry) && typeof entry.id === "string" && Number.isSafeInteger(entry.version)
87
+ && isObject(entry.settings)
88
+ ? { id: entry.id, version: entry.version, settings: entry.settings }
89
+ : undefined;
90
+ }
91
+
92
+ export function formsManifestEntry(items) {
93
+ return { items: Object.fromEntries([...items].sort(([left], [right]) => (left < right ? -1 : 1))) };
94
+ }
95
+
96
+ /** Sets one form's baseline and keeps every other entry, in key order. */
97
+ export function withRecordedForm(manifest, key, entry) {
98
+ const current = isObject(manifest[FORMS_MANIFEST_KEY]?.items) ? manifest[FORMS_MANIFEST_KEY].items : {};
99
+ return { ...manifest, [FORMS_MANIFEST_KEY]: formsManifestEntry(Object.entries({ ...current, [key]: entry })) };
100
+ }