@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.
- package/README.md +13 -3
- package/package.json +2 -2
- package/src/capabilities.js +1 -1
- package/src/cli.js +50 -2
- package/src/constants.js +3 -0
- package/src/content/components.js +21 -0
- package/src/content/free-form-section-registry.json +6 -1
- package/src/delivery-check.js +2 -11
- package/src/field-schema.json +217 -0
- package/src/field-validation.js +715 -0
- package/src/fixture-contract.js +1 -1
- package/src/fixture-init.js +15 -0
- package/src/forms-api.js +192 -0
- package/src/forms-contract.js +274 -0
- package/src/forms-workspace.js +100 -0
- package/src/reference-help.js +154 -6
- package/src/settings-catalog.js +3 -0
- package/src/verbs/delivery-check.js +0 -7
- package/src/verbs/forms-pull.js +67 -0
- package/src/verbs/forms-push.js +227 -0
- package/src/verbs/forms-validate.js +36 -0
- package/src/verbs/index.js +11 -0
- package/src/verbs/pull.js +8 -0
- package/src/verbs/validate.js +57 -0
package/src/fixture-contract.js
CHANGED
|
@@ -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(
|
package/src/fixture-init.js
CHANGED
|
@@ -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(),
|
package/src/forms-api.js
ADDED
|
@@ -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
|
+
}
|