@taprootio/site-authoring 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/package.json +1 -1
- package/src/cli.js +6 -2
- package/src/content/components.js +8 -5
- package/src/field-schema.json +5 -0
- package/src/field-validation.js +5 -1
- package/src/form-references.js +103 -0
- package/src/forms-api.js +8 -1
- package/src/forms-contract.js +29 -6
- package/src/forms-workspace.js +47 -14
- package/src/reference-help.js +16 -7
- package/src/typed-pages.js +1 -1
- package/src/verbs/forms-pull.js +1 -3
- package/src/verbs/forms-push.js +76 -37
- package/src/verbs/pages-push.js +45 -1
- package/src/verbs/plan.js +4 -2
- package/src/verbs/pull.js +12 -2
package/README.md
CHANGED
|
@@ -524,6 +524,13 @@ Submissions are never pulled. `help forms` states the file shape and every
|
|
|
524
524
|
refusal, and placing a form on a page is the free-form `form`
|
|
525
525
|
component.
|
|
526
526
|
|
|
527
|
+
The optional top-level `notify_owner` boolean opts a form into hourly,
|
|
528
|
+
counts-only owner email. New forms default to false; omitting it on an
|
|
529
|
+
existing form preserves the site's preference. It is independent of the sink
|
|
530
|
+
and takes effect without publishing or a new form version. Delivery requires
|
|
531
|
+
Taproot's rollout and email provider readiness; opt-in does not enable either
|
|
532
|
+
or backfill earlier submissions. The CLI never pulls visitor answers.
|
|
533
|
+
|
|
527
534
|
`preview page <page-path-or-id>` renders one persisted draft behind the
|
|
528
535
|
staging gate and mints a browser handoff URL, reported only in the final JSON
|
|
529
536
|
result. **Each handoff is single-use.** Opening the URL consumes it; a reused,
|
|
@@ -638,6 +645,24 @@ Other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal `<br>` tags are rejecte
|
|
|
638
645
|
with the exact fact field path. A label is optional and stays outside the value's link. See
|
|
639
646
|
`taproot-site help page free-form` and `taproot-site help component cta`.
|
|
640
647
|
|
|
648
|
+
### Forms by id in 0.20.0
|
|
649
|
+
|
|
650
|
+
A page stores the form it places by the form's id, so renaming a form never
|
|
651
|
+
breaks a page, but a workspace still names a form by its key: `forms/<key>.json`
|
|
652
|
+
and the `formKey` of a `component:form` block are unchanged, and the same files
|
|
653
|
+
still push to a fresh site whose ids all differ. `pages push` looks each key up
|
|
654
|
+
on the site, sends the form's id, and refuses a key the site does not have
|
|
655
|
+
(`pages.form_unknown`) or has archived (`pages.form_archived`) before it writes
|
|
656
|
+
anything; `plan` reports the same. `pages pull` writes the key back. An id the
|
|
657
|
+
site no longer has stays an id in the pulled file, and a push of it is refused
|
|
658
|
+
until you choose a form. Form names are now unique on a site, ignoring case.
|
|
659
|
+
The deploy that introduces this rewrites the stored page bodies, which changes the
|
|
660
|
+
body revision of every page that places a form, so the first `pages pull` after it
|
|
661
|
+
can report `pages.pull_conflict` for such a Markdown page even though its content
|
|
662
|
+
is the same. Run `taproot-site pages push <path>` for that page, or delete the file
|
|
663
|
+
and pull again. `forms validate` and `forms push` also refuse two forms with the
|
|
664
|
+
same name (`forms.name_duplicate`, `forms.name_taken`).
|
|
665
|
+
|
|
641
666
|
### Place categories in 0.18.0
|
|
642
667
|
|
|
643
668
|
A place has one category for every site, taken from Google's types, and a place
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
package/src/cli.js
CHANGED
|
@@ -239,7 +239,9 @@ const VERBS = Object.freeze([
|
|
|
239
239
|
+ "and announced on the human channel. New downloads and compared bodies are excluded. "
|
|
240
240
|
+ "A page Taproot generates (a tag, an archive, a place, a folder) is pulled as an update-only '.pm.json' "
|
|
241
241
|
+ "(template 'generated'): you edit its custom title, breadcrumb title, custom description and introduction, "
|
|
242
|
-
+ "and the manifest keeps the title and description the site reports."
|
|
242
|
+
+ "and the manifest keeps the title and description the site reports. "
|
|
243
|
+
+ "A form a page places is written back by its key, the name 'forms/<key>.json' uses, though the site stores "
|
|
244
|
+
+ "the form's id.",
|
|
243
245
|
},
|
|
244
246
|
{
|
|
245
247
|
name: VERB_PAGES_META_SET,
|
|
@@ -305,6 +307,8 @@ const VERBS = Object.freeze([
|
|
|
305
307
|
positionals: "pagePaths",
|
|
306
308
|
note:
|
|
307
309
|
"Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
|
|
310
|
+
+ "A page names a form by key (formKey); the push looks each key up on the site, sends the form's id, and "
|
|
311
|
+
+ "refuses a key the site does not have or has archived (pages.form_unknown, pages.form_archived) before writing. "
|
|
308
312
|
+ "A link in a page being sent whose address is only http(s):// plus its own text is reported in linkWarnings "
|
|
309
313
|
+ "(content.link_autolinked) without refusing. "
|
|
310
314
|
+ "A page is created without an author unless its source names one ('author:' in the front matter, or 'pages "
|
|
@@ -394,7 +398,7 @@ const VERBS = Object.freeze([
|
|
|
394
398
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
|
|
395
399
|
tokens: ["plan"],
|
|
396
400
|
summary: "Check the whole workspace against the live site and order what is left to send. Writes nothing.",
|
|
397
|
-
note: "Runs every check the pushes run — each page against the site's pages, revisions and
|
|
401
|
+
note: "Runs every check the pushes run — each page against the site's pages, revisions, videos and forms; media that "
|
|
398
402
|
+ "pages reference and media upload has not sent; the theme, appearance and footer colors against the site's "
|
|
399
403
|
+ "presentation revision; the footer against its draft; navigation against the live page list — and lists "
|
|
400
404
|
+ "every problem with its area, file, code and field. It then orders the steps that remain: media upload, "
|
|
@@ -352,11 +352,14 @@ const SPACER = Object.freeze({
|
|
|
352
352
|
|
|
353
353
|
/**
|
|
354
354
|
* A form defined under Site → Forms (or with `forms push`), placed by key. The
|
|
355
|
-
*
|
|
356
|
-
*
|
|
355
|
+
* workspace names a form by its key so the same files push to any site; the site
|
|
356
|
+
* stores the form's id, and `pages push` and `pages pull` translate between the
|
|
357
|
+
* two (TR01216). The key is checked against the site's forms when the page is
|
|
358
|
+
* pushed, not here: this table only knows the shape.
|
|
357
359
|
*/
|
|
358
360
|
const FORM = Object.freeze({
|
|
359
|
-
|
|
361
|
+
// Required: a placement with no form chosen can never publish, so it is refused here, not at deploy.
|
|
362
|
+
formKey: str({ required: true, nonWhitespace: true }),
|
|
360
363
|
});
|
|
361
364
|
|
|
362
365
|
function deepFreeze(value) {
|
|
@@ -648,9 +651,9 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
648
651
|
FORM,
|
|
649
652
|
"Form",
|
|
650
653
|
"A form from Site → Forms that visitors fill in and submit.",
|
|
651
|
-
{
|
|
654
|
+
{},
|
|
652
655
|
[
|
|
653
|
-
"Set formKey to the key of an existing form ('forms push' creates it); an unknown or archived key
|
|
656
|
+
"Set formKey to the key of an existing form ('forms push' creates it); 'pages push' refuses an unknown or archived key, and the site stores the form's id, not the key.",
|
|
654
657
|
"The button text and what happens after a response is sent belong to the form, not the page: set submit_label and after_submit in the form's file (see 'help forms').",
|
|
655
658
|
"The form's field labels are its accessible names; write each so it makes sense out of context.",
|
|
656
659
|
],
|
package/src/field-schema.json
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
"description": "The id of the email field whose value the API copies to subject_email. It must name an email field. Omit it when the form has one email field (that field is the contact); it is required with two or more.",
|
|
18
18
|
"$ref": "#/$defs/id"
|
|
19
19
|
},
|
|
20
|
+
"newsletter_audience_id": {
|
|
21
|
+
"description": "The active manual newsletter audience on the form's site that receives the reader after email confirmation. Omit it for newsletter only. The server checks ownership and availability when saving.",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
24
|
+
},
|
|
20
25
|
"submit_label": {
|
|
21
26
|
"description": "The text on the submit button. Omit it for Taproot's default. Must contain more than whitespace; the validator also rejects a blank label, which a schema cannot express.",
|
|
22
27
|
"type": "string",
|
package/src/field-validation.js
CHANGED
|
@@ -106,7 +106,7 @@ export function contactFieldId(definition) {
|
|
|
106
106
|
const emails = definition.fields.filter((field) => field.type === "email");
|
|
107
107
|
return emails.length === 1 ? emails[0].id : undefined;
|
|
108
108
|
}
|
|
109
|
-
const DEFINITION_KEYS = ["fields", "contact_field", "submit_label", "after_submit"];
|
|
109
|
+
const DEFINITION_KEYS = ["fields", "contact_field", "submit_label", "after_submit", "newsletter_audience_id"];
|
|
110
110
|
const BASE_FIELD_KEYS = ["id", "type", "label", "hint"];
|
|
111
111
|
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
112
|
const isInteger = (value) => typeof value === "number" && Number.isInteger(value);
|
|
@@ -427,6 +427,10 @@ export function validateDefinition(definition) {
|
|
|
427
427
|
}
|
|
428
428
|
/** The button text and the after-submit choice, which sit beside the fields in a definition. */
|
|
429
429
|
function validateFormSettings(definition, errors) {
|
|
430
|
+
const audience = definition.newsletter_audience_id;
|
|
431
|
+
if (audience !== undefined && (typeof audience !== "string" || !isResourceId(audience))) {
|
|
432
|
+
errors.push({ path: "newsletter_audience_id", code: "invalid_newsletter_audience" });
|
|
433
|
+
}
|
|
430
434
|
const label = definition.submit_label;
|
|
431
435
|
if (label !== undefined && (typeof label !== "string" || isBlank(label) || label.length > MAX_SUBMIT_LABEL_LENGTH)) {
|
|
432
436
|
errors.push({ path: "submit_label", code: "invalid_submit_label" });
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { listSiteForms } from "./forms-api.js";
|
|
2
|
+
import { contentDocuments, parseComponentData } from "./typed-pages.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where the workspace and the site spell a placed form differently (TR01216).
|
|
6
|
+
*
|
|
7
|
+
* A workspace names a form by its key, as `forms/<key>.json` and the Markdown
|
|
8
|
+
* `form` fence do, so the same workspace can be pushed to a fresh site whose
|
|
9
|
+
* ids all differ. A page stored on the site names the form by its id. `pages
|
|
10
|
+
* push` turns keys into ids on the way out and `pages pull` turns ids back
|
|
11
|
+
* into keys, the way a form's `page_path` and `page_resource_id` already
|
|
12
|
+
* translate. The translation is block-scoped: a `formKey` anywhere but a `form`
|
|
13
|
+
* component block is author prose.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FORM_COMPONENT = "form";
|
|
17
|
+
|
|
18
|
+
/** Each `form` component block in the document's content documents, with its parsed data. */
|
|
19
|
+
function formBlocks(document_) {
|
|
20
|
+
const found = [];
|
|
21
|
+
const visit = (value) => {
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
value.forEach(visit);
|
|
24
|
+
} else if (value !== null && typeof value === "object") {
|
|
25
|
+
if (value.type === "componentBlock" && value.attrs?.componentType === FORM_COMPONENT) {
|
|
26
|
+
const data = typeof value.attrs.componentData === "string"
|
|
27
|
+
? parseComponentData(value.attrs.componentData)
|
|
28
|
+
: undefined;
|
|
29
|
+
if (data !== null && typeof data === "object" && !Array.isArray(data)) found.push({ attrs: value.attrs, data });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
Object.values(value).forEach(visit);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
for (const { doc } of contentDocuments(document_)) visit(doc);
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The distinct form keys a workspace document places, in document order. */
|
|
40
|
+
export function placedFormKeys(document_) {
|
|
41
|
+
const keys = [];
|
|
42
|
+
for (const { data } of formBlocks(document_)) {
|
|
43
|
+
if (typeof data.formKey === "string" && data.formKey !== "" && !keys.includes(data.formKey)) keys.push(data.formKey);
|
|
44
|
+
}
|
|
45
|
+
return keys;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether a document read from the site places any form by id. */
|
|
49
|
+
export function placesFormIds(document_) {
|
|
50
|
+
return formBlocks(document_).some(({ data }) => typeof data.formId === "string");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A copy of the document with every placed form named by id, for the wire. A key
|
|
55
|
+
* the index does not know is left as it is: `placedFormProblems` has already
|
|
56
|
+
* refused the push for it, so nothing sends it.
|
|
57
|
+
*/
|
|
58
|
+
export function withFormIds(document_, idByKey) {
|
|
59
|
+
return translate(document_, (data) => {
|
|
60
|
+
const id = typeof data.formKey === "string" ? idByKey.get(data.formKey) : undefined;
|
|
61
|
+
if (id === undefined) return undefined;
|
|
62
|
+
const rest = { ...data };
|
|
63
|
+
delete rest.formKey;
|
|
64
|
+
return { ...rest, formId: id };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A copy of a document read from the site with every placed form named by key,
|
|
70
|
+
* for the workspace. An id the site no longer has stays an id: pull must not hide
|
|
71
|
+
* what the site holds, and a push of that file is refused until the author picks a form.
|
|
72
|
+
*/
|
|
73
|
+
export function withFormKeys(document_, keyById) {
|
|
74
|
+
return translate(document_, (data) => {
|
|
75
|
+
const key = typeof data.formId === "string" ? keyById.get(data.formId) : undefined;
|
|
76
|
+
if (key === undefined) return undefined;
|
|
77
|
+
const rest = { ...data };
|
|
78
|
+
delete rest.formId;
|
|
79
|
+
return { ...rest, formKey: key };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function translate(document_, convert) {
|
|
84
|
+
const copy = structuredClone(document_);
|
|
85
|
+
for (const { attrs, data } of formBlocks(copy)) {
|
|
86
|
+
const converted = convert(data);
|
|
87
|
+
if (converted !== undefined) attrs.componentData = JSON.stringify(converted);
|
|
88
|
+
}
|
|
89
|
+
return copy;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The site's forms by key and by id, archived ones marked. One request serves a whole push or pull. */
|
|
93
|
+
export async function readFormIndex(client, siteId) {
|
|
94
|
+
const idByKey = new Map();
|
|
95
|
+
const keyById = new Map();
|
|
96
|
+
const archivedKeys = new Set();
|
|
97
|
+
for (const form of await listSiteForms(client, siteId)) {
|
|
98
|
+
keyById.set(form.id, form.key);
|
|
99
|
+
if (form.archived) archivedKeys.add(form.key);
|
|
100
|
+
else idByKey.set(form.key, form.id);
|
|
101
|
+
}
|
|
102
|
+
return { idByKey, keyById, archivedKeys };
|
|
103
|
+
}
|
package/src/forms-api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { listSitePages, sitePath } from "./api.js";
|
|
2
2
|
import { HTTP_NOT_FOUND } from "./constants.js";
|
|
3
3
|
import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
|
|
4
|
-
import { FORM_DEFAULT_SINK, FORM_SINKS, isFormKey, normalizePagePath } from "./forms-contract.js";
|
|
4
|
+
import { FORM_DEFAULT_NOTIFY_OWNER, FORM_DEFAULT_SINK, FORM_SINKS, isFormKey, normalizePagePath } from "./forms-contract.js";
|
|
5
5
|
import { ApiError } from "./transport.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -38,12 +38,16 @@ function normalizeForm(value) {
|
|
|
38
38
|
if (!isCanonicalUuid(value.id) || !isFormKey(value.key)) {
|
|
39
39
|
throw contract("Taproot returned a form with no usable id or a key this workspace cannot store.", "form");
|
|
40
40
|
}
|
|
41
|
+
if (Object.hasOwn(value, "notifyOwner") && typeof value.notifyOwner !== "boolean") {
|
|
42
|
+
throw contract("Taproot returned an owner notification setting that is not a boolean.", "notifyOwner");
|
|
43
|
+
}
|
|
41
44
|
return {
|
|
42
45
|
id: value.id,
|
|
43
46
|
key: value.key,
|
|
44
47
|
name: typeof value.name === "string" ? value.name : "",
|
|
45
48
|
sink: sinkFromWire(value.sink),
|
|
46
49
|
retentionDays: Number.isSafeInteger(value.retentionDays) ? value.retentionDays : undefined,
|
|
50
|
+
notifyOwner: value.notifyOwner ?? FORM_DEFAULT_NOTIFY_OWNER,
|
|
47
51
|
archived: value.status === STATUS_ARCHIVED_WIRE,
|
|
48
52
|
currentVersion: Number.isSafeInteger(value.currentVersion) ? value.currentVersion : 0,
|
|
49
53
|
};
|
|
@@ -140,6 +144,7 @@ export async function createSiteForm(client, siteId, form) {
|
|
|
140
144
|
name: form.name,
|
|
141
145
|
sink: SINK_WIRE[form.sink],
|
|
142
146
|
retentionDays: form.retention_days,
|
|
147
|
+
notifyOwner: form.notify_owner,
|
|
143
148
|
definition: JSON.stringify(form.definition),
|
|
144
149
|
},
|
|
145
150
|
maximumResponseBytes: FORMS_RESPONSE_BYTES,
|
|
@@ -160,12 +165,14 @@ export async function updateSiteFormSettings(client, siteId, formId, form, expec
|
|
|
160
165
|
name: form.name,
|
|
161
166
|
sink: SINK_WIRE[form.sink],
|
|
162
167
|
retentionDays: form.retention_days,
|
|
168
|
+
notifyOwner: form.notify_owner,
|
|
163
169
|
...(expected === undefined
|
|
164
170
|
? {}
|
|
165
171
|
: {
|
|
166
172
|
expectedName: expected.name,
|
|
167
173
|
expectedSink: SINK_WIRE[expected.sink],
|
|
168
174
|
expectedRetentionDays: expected.retention_days,
|
|
175
|
+
expectedNotifyOwner: expected.notify_owner,
|
|
169
176
|
}),
|
|
170
177
|
},
|
|
171
178
|
maximumResponseBytes: FORMS_RESPONSE_BYTES,
|
package/src/forms-contract.js
CHANGED
|
@@ -5,7 +5,7 @@ import { contactFieldId, validateDefinition } from "./field-validation.js";
|
|
|
5
5
|
* The workspace contract for a form (TR01061).
|
|
6
6
|
*
|
|
7
7
|
* A form is one JSON file, `forms/<key>.json`, holding what an author decides:
|
|
8
|
-
* its key, name, sink, retention and definition. The definition holds the fields
|
|
8
|
+
* its key, name, sink, retention, owner-notification preference and definition. The definition holds the fields
|
|
9
9
|
* and the form's own settings (`contact_field`, `submit_label`, `after_submit`),
|
|
10
10
|
* exactly as the site stores it, so one validator reads one shape. The
|
|
11
11
|
* definition's own rules belong to `shared/field-validation.ts` and are run
|
|
@@ -27,6 +27,7 @@ export const FORM_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
|
27
27
|
export const FORM_KEY_MAXIMUM_LENGTH = 64;
|
|
28
28
|
export const FORM_SINKS = Object.freeze(["none", "newsletter"]);
|
|
29
29
|
export const FORM_DEFAULT_SINK = "none";
|
|
30
|
+
export const FORM_DEFAULT_NOTIFY_OWNER = false;
|
|
30
31
|
export const FORM_RETENTION_DAYS = Object.freeze({ minimum: 30, maximum: 730, default: 365 });
|
|
31
32
|
export const FORM_LIMITS = Object.freeze({
|
|
32
33
|
nameCharacters: 120,
|
|
@@ -35,7 +36,7 @@ export const FORM_LIMITS = Object.freeze({
|
|
|
35
36
|
fileBytes: 256 * 1024,
|
|
36
37
|
});
|
|
37
38
|
|
|
38
|
-
const FORM_FILE_KEYS = ["key", "name", "sink", "retention_days", "definition"];
|
|
39
|
+
const FORM_FILE_KEYS = ["key", "name", "sink", "retention_days", "notify_owner", "definition"];
|
|
39
40
|
// The order `forms pull` writes, which `forms push` writes back after a save.
|
|
40
41
|
// Fixed rather than inherited from the response so the same form is always the
|
|
41
42
|
// same bytes.
|
|
@@ -101,14 +102,24 @@ function describeDefinitionErrors(errors) {
|
|
|
101
102
|
return `${shown.join("; ")}${more}`;
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/** Checks newsletter admission after the definition has passed shared validation. */
|
|
106
|
+
export function validateNewsletterDefinition(file, definition) {
|
|
107
|
+
const fields = definition.fields;
|
|
108
|
+
const contact = fields.find((field) => field.id === contactFieldId(definition));
|
|
109
|
+
if (contact?.type !== "email" || contact.required !== true || !fields.some((field) => field.type === "consent")) {
|
|
110
|
+
throw invalid(file, "a newsletter sink requires a required email contact field and at least one consent checkbox.", "definition");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
104
114
|
/**
|
|
105
115
|
* Validates one parsed form file and returns it unchanged in meaning.
|
|
106
116
|
*
|
|
107
117
|
* `expectedKey` is the key the file's name promises; a mismatch is refused
|
|
108
118
|
* because a rename on disk would otherwise push a different form than the one
|
|
109
119
|
* the author thinks they are editing.
|
|
120
|
+
* Only `forms push` defers newsletter admission until it compares the site's baseline.
|
|
110
121
|
*/
|
|
111
|
-
export function validateFormDocument(document_, expectedKey) {
|
|
122
|
+
export function validateFormDocument(document_, expectedKey, { deferNewsletterSemantics = false } = {}) {
|
|
112
123
|
const file = formFileName(expectedKey);
|
|
113
124
|
if (!isPlainObject(document_)) throw invalid(file, "the file must contain one JSON object.", "$");
|
|
114
125
|
for (const name of Object.keys(document_)) {
|
|
@@ -138,6 +149,9 @@ export function validateFormDocument(document_, expectedKey) {
|
|
|
138
149
|
if (document_.sink !== undefined && !FORM_SINKS.includes(document_.sink)) {
|
|
139
150
|
throw invalid(file, `sink must be one of: ${FORM_SINKS.join(", ")}.`, "sink");
|
|
140
151
|
}
|
|
152
|
+
if (Object.hasOwn(document_, "notify_owner") && typeof document_.notify_owner !== "boolean") {
|
|
153
|
+
throw invalid(file, "notify_owner must be true or false.", "notify_owner");
|
|
154
|
+
}
|
|
141
155
|
const days = document_.retention_days;
|
|
142
156
|
if (
|
|
143
157
|
days !== undefined
|
|
@@ -156,6 +170,7 @@ export function validateFormDocument(document_, expectedKey) {
|
|
|
156
170
|
if (errors.length > 0) {
|
|
157
171
|
throw invalid(file, `the definition is invalid: ${describeDefinitionErrors(errors)}`, "definition");
|
|
158
172
|
}
|
|
173
|
+
if (document_.sink === "newsletter" && !deferNewsletterSemantics) validateNewsletterDefinition(file, document_.definition);
|
|
159
174
|
if (anyUnstorableString(document_.definition)) {
|
|
160
175
|
throw invalid(file, "the definition cannot contain NUL or unpaired surrogate characters.", "definition");
|
|
161
176
|
}
|
|
@@ -238,6 +253,16 @@ function rejectNonTarget(file, pages, id, field, name) {
|
|
|
238
253
|
);
|
|
239
254
|
}
|
|
240
255
|
|
|
256
|
+
/** Operational settings in the same order for workspace files and saved baselines. */
|
|
257
|
+
export function projectFormSettings(form) {
|
|
258
|
+
return {
|
|
259
|
+
name: form.name,
|
|
260
|
+
sink: form.sink ?? FORM_DEFAULT_SINK,
|
|
261
|
+
retention_days: form.retention_days ?? FORM_RETENTION_DAYS.default,
|
|
262
|
+
notify_owner: form.notify_owner ?? FORM_DEFAULT_NOTIFY_OWNER,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
241
266
|
function canonicalField(field) {
|
|
242
267
|
const ordered = {};
|
|
243
268
|
for (const name of FIELD_KEY_ORDER) if (field[name] !== undefined) ordered[name] = field[name];
|
|
@@ -266,9 +291,7 @@ export function projectFormForWorkspace(form, pathById) {
|
|
|
266
291
|
}
|
|
267
292
|
return {
|
|
268
293
|
key: form.key,
|
|
269
|
-
|
|
270
|
-
sink: form.sink ?? FORM_DEFAULT_SINK,
|
|
271
|
-
retention_days: form.retention_days ?? FORM_RETENTION_DAYS.default,
|
|
294
|
+
...projectFormSettings(form),
|
|
272
295
|
definition,
|
|
273
296
|
};
|
|
274
297
|
}
|
package/src/forms-workspace.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { SiteAuthoringError } from "./errors.js";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
FORM_DEFAULT_NOTIFY_OWNER,
|
|
4
4
|
FORM_FILE_EXTENSION,
|
|
5
|
-
FORM_RETENTION_DAYS,
|
|
6
5
|
FORM_LIMITS,
|
|
7
6
|
FORMS_DIRECTORY,
|
|
8
7
|
formFileName,
|
|
9
8
|
isFormKey,
|
|
9
|
+
projectFormSettings,
|
|
10
10
|
validateFormDocument,
|
|
11
11
|
} from "./forms-contract.js";
|
|
12
12
|
import { readWorkspaceJson, walkWorkspaceFiles } from "./workspace.js";
|
|
@@ -18,8 +18,9 @@ import { readWorkspaceJson, walkWorkspaceFiles } from "./workspace.js";
|
|
|
18
18
|
* typo never reads as a successful empty push. With none, every file under
|
|
19
19
|
* `forms/` is read. A file whose name is not a form key is refused rather than
|
|
20
20
|
* skipped: skipping it would report success with one form left behind.
|
|
21
|
+
* Newsletter admission is strict by default; `forms push` defers it for baseline comparison.
|
|
21
22
|
*/
|
|
22
|
-
export async function readWorkspaceForms(workspaceDir, keys) {
|
|
23
|
+
export async function readWorkspaceForms(workspaceDir, keys, validationOptions = {}) {
|
|
23
24
|
const files = await walkWorkspaceFiles(workspaceDir, FORMS_DIRECTORY, [FORM_FILE_EXTENSION]);
|
|
24
25
|
const known = new Map();
|
|
25
26
|
for (const file of files) {
|
|
@@ -46,18 +47,41 @@ export async function readWorkspaceForms(workspaceDir, keys) {
|
|
|
46
47
|
);
|
|
47
48
|
}
|
|
48
49
|
const document_ = await readWorkspaceJson(workspaceDir, file, FORM_LIMITS.fileBytes);
|
|
49
|
-
forms.push({ key, file, document: validateFormDocument(document_, key) });
|
|
50
|
+
forms.push({ key, file, document: validateFormDocument(document_, key, validationOptions) });
|
|
50
51
|
}
|
|
52
|
+
requireDistinctNames(forms);
|
|
51
53
|
return forms;
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/** A form name as the site compares it: trimmed, ignoring case (TR01216). */
|
|
57
|
+
export function comparableFormName(name) {
|
|
58
|
+
return String(name).trim().toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Names are unique per site, so two files in one push cannot share one. */
|
|
62
|
+
function requireDistinctNames(forms) {
|
|
63
|
+
const seen = new Map();
|
|
64
|
+
for (const form of forms) {
|
|
65
|
+
const name = comparableFormName(form.document.name);
|
|
66
|
+
const other = seen.get(name);
|
|
67
|
+
if (other !== undefined) {
|
|
68
|
+
throw new SiteAuthoringError(
|
|
69
|
+
"forms.name_duplicate",
|
|
70
|
+
`${form.file} and ${other} both name the form '${form.document.name}'. A form name is unique on a site, `
|
|
71
|
+
+ "ignoring case; rename one of them.",
|
|
72
|
+
{ field: `${form.file}:name` },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
seen.set(name, form.file);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
/**
|
|
55
80
|
* The forms baseline in the pull manifest (TR01061).
|
|
56
81
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* the push refuse toward pull instead of overwriting it.
|
|
82
|
+
* A browser edit since the last pull or push makes the next push refuse rather
|
|
83
|
+
* than overwrite it. Each mutation also sends this baseline so the site can
|
|
84
|
+
* refuse an edit made after the CLI compared them.
|
|
61
85
|
*/
|
|
62
86
|
export const FORMS_MANIFEST_KEY = "forms";
|
|
63
87
|
|
|
@@ -67,15 +91,16 @@ function isObject(value) {
|
|
|
67
91
|
|
|
68
92
|
/**
|
|
69
93
|
* The settings half of a baseline, from the form exactly as the site returned
|
|
70
|
-
* it.
|
|
94
|
+
* it. Operational settings change without appending a version, so the
|
|
71
95
|
* version alone cannot say they moved.
|
|
72
96
|
*/
|
|
73
97
|
export function siteSettingsBaseline(siteForm) {
|
|
74
|
-
return {
|
|
98
|
+
return projectFormSettings({
|
|
75
99
|
name: siteForm.name,
|
|
76
|
-
sink: siteForm.sink
|
|
77
|
-
retention_days: siteForm.retentionDays
|
|
78
|
-
|
|
100
|
+
sink: siteForm.sink,
|
|
101
|
+
retention_days: siteForm.retentionDays,
|
|
102
|
+
notify_owner: siteForm.notifyOwner,
|
|
103
|
+
});
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
/** The recorded baseline for one key, or undefined when this workspace never saw that form. */
|
|
@@ -85,7 +110,15 @@ export function recordedForm(manifest, key) {
|
|
|
85
110
|
const entry = items[key];
|
|
86
111
|
return isObject(entry) && typeof entry.id === "string" && Number.isSafeInteger(entry.version)
|
|
87
112
|
&& isObject(entry.settings)
|
|
88
|
-
? {
|
|
113
|
+
? {
|
|
114
|
+
id: entry.id,
|
|
115
|
+
version: entry.version,
|
|
116
|
+
// Old baselines can prove only the default opt-out, never a remote opt-in.
|
|
117
|
+
settings: {
|
|
118
|
+
...entry.settings,
|
|
119
|
+
notify_owner: Object.hasOwn(entry.settings, "notify_owner") ? entry.settings.notify_owner : FORM_DEFAULT_NOTIFY_OWNER,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
89
122
|
: undefined;
|
|
90
123
|
}
|
|
91
124
|
|
package/src/reference-help.js
CHANGED
|
@@ -86,7 +86,7 @@ import {
|
|
|
86
86
|
|
|
87
87
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
88
88
|
|
|
89
|
-
export const REFERENCE_VERSION =
|
|
89
|
+
export const REFERENCE_VERSION = 37;
|
|
90
90
|
|
|
91
91
|
// The date-limit examples `help forms` prints; tests run them through the form validator.
|
|
92
92
|
export const FORM_DATE_WINDOW_FIELD = Object.freeze({
|
|
@@ -773,14 +773,21 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
773
773
|
details: Object.freeze([
|
|
774
774
|
`Run 'pull' once first: 'forms pull' and 'forms push' need the pulled workspace's manifest, which records the `
|
|
775
775
|
+ "version of each form this workspace last saw. 'forms validate' needs only taproot-site.json.",
|
|
776
|
-
`The file is { key, name, sink, retention_days, definition }. key must equal the file's name `
|
|
776
|
+
`The file is { key, name, sink, retention_days, notify_owner, definition }. key must equal the file's name `
|
|
777
777
|
+ `and is lowercase letters, digits, and single hyphens, at most ${FORM_KEY_MAXIMUM_LENGTH} characters. name is `
|
|
778
|
-
+ `1 to ${FORM_LIMITS.nameCharacters} characters
|
|
779
|
-
+ "
|
|
778
|
+
+ `1 to ${FORM_LIMITS.nameCharacters} characters and unique on the site, ignoring case: two files, or a file and another form on the site, with the same name `
|
|
779
|
+
+ "are refused (forms.name_duplicate, forms.name_taken) before anything is written. "
|
|
780
|
+
+ `sink is one of ${FORM_SINKS.join(", ")} and defaults to `
|
|
781
|
+
+ "'none' (store submissions only); 'newsletter' requests double-opt-in confirmation for the contact address. "
|
|
782
|
+
+ "A newsletter sink requires a required email contact field and at least one consent checkbox; every consent checkbox must be checked. "
|
|
780
783
|
+ `retention_days is ${FORM_RETENTION_DAYS.minimum} to ${FORM_RETENTION_DAYS.maximum} (a site's plan may cap it lower): `
|
|
781
784
|
+ "submissions are deleted that many days after they arrive. Leave it out and the site chooses on creation (its "
|
|
782
785
|
+ "plan's default, usually 365) and keeps the current value on an update. Shortening it deletes the submissions that fall outside it, "
|
|
783
786
|
+ "so a key cannot do it; the site refuses it, and an owner or admin must make that change.",
|
|
787
|
+
"notify_owner is an optional top-level boolean, independent of the sink. New forms default to false; omitting it "
|
|
788
|
+
+ "on an existing form preserves the site's preference. It opts into hourly counts-only email to the current site "
|
|
789
|
+
+ "account owner, never visitor answers or addresses. This setting takes effect without a new version or publishing. "
|
|
790
|
+
+ "Delivery also requires Taproot's rollout and email provider readiness; opt-in enables neither, and earlier submissions are not backfilled.",
|
|
784
791
|
`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 `
|
|
785
792
|
+ `field types are ${FIELD_TYPES.join(", ")}. hint is optional helper text of up to ${MAX_HINT_LENGTH} `
|
|
786
793
|
+ "characters, drawn under the field on the published form; it is the only author-defined text printed there, so a required "
|
|
@@ -835,14 +842,15 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
835
842
|
+ "'unchanged' and leaves the bytes as they were. Write your own files in that order and a push rewrites "
|
|
836
843
|
+ "nothing you did not change.",
|
|
837
844
|
"'forms push' validates every file and compares every form with the site before the first write. It refuses "
|
|
838
|
-
+ "forms.concurrent_modification when the site's version moved since this workspace last read it, "
|
|
845
|
+
+ "forms.concurrent_modification when the site's version or operational settings moved since this workspace last read it, "
|
|
839
846
|
+ "forms.pull_required when the site has a form with that key this workspace never pulled, and forms.archived "
|
|
840
847
|
+ "when the form is archived on the site. Reconcile with 'forms pull' and retry; nothing was written.",
|
|
841
848
|
"Submissions are never pulled or pushed. They are visitors' personal data: read, mark, export and delete "
|
|
842
849
|
+ "them on the site's Forms page.",
|
|
843
850
|
"To show a form, place the free-form component 'form' on a page (a component:form block) with formKey set "
|
|
844
|
-
+ "to the form's key ('help component form' lists its properties). Push the form first
|
|
845
|
-
+ "
|
|
851
|
+
+ "to the form's key ('help component form' lists its properties). Push the form first: 'pages push' turns the "
|
|
852
|
+
+ "key into the form's id for the site and refuses a key the site does not have or has archived, and 'pages pull' "
|
|
853
|
+
+ "turns the id back into the key. Then deploy: the release freezes the form it was built with.",
|
|
846
854
|
"The delegation.content capability carries site.forms.manage, which every forms verb needs: it covers "
|
|
847
855
|
+ "definitions, settings and status. Visitors' submissions are in no capability: reading them "
|
|
848
856
|
+ "(site.forms.submissions.read) and finding, marking or deleting them (site.forms.submissions.manage) belong "
|
|
@@ -854,6 +862,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
854
862
|
name: "Contact",
|
|
855
863
|
sink: "none",
|
|
856
864
|
retention_days: 365,
|
|
865
|
+
notify_owner: false,
|
|
857
866
|
definition: Object.freeze({
|
|
858
867
|
fields: Object.freeze([
|
|
859
868
|
Object.freeze({ id: "name", type: "text", label: "Your name", required: true, max_length: 120 }),
|
package/src/typed-pages.js
CHANGED
|
@@ -769,7 +769,7 @@ export const COVER_IMAGE_UNUSED_MESSAGE = "The selected cover image must be used
|
|
|
769
769
|
* component JSON, so a string that only parses once decoded is read the way the
|
|
770
770
|
* server reads it.
|
|
771
771
|
*/
|
|
772
|
-
function parseComponentData(text) {
|
|
772
|
+
export function parseComponentData(text) {
|
|
773
773
|
let decoded = text;
|
|
774
774
|
for (let round = 0; round < 3 && /&(?:quot|amp|#39|lt|gt);/u.test(decoded); round += 1) {
|
|
775
775
|
decoded = decoded.replace(/"/gu, "\"").replace(/'/gu, "'").replace(/</gu, "<").replace(/>/gu, ">")
|
package/src/verbs/forms-pull.js
CHANGED
|
@@ -41,9 +41,7 @@ export async function formsPull(invocation) {
|
|
|
41
41
|
}
|
|
42
42
|
const document_ = projectFormForWorkspace({
|
|
43
43
|
key: form.key,
|
|
44
|
-
|
|
45
|
-
sink: form.sink,
|
|
46
|
-
retention_days: form.retentionDays,
|
|
44
|
+
...siteSettingsBaseline(form),
|
|
47
45
|
definition: version.definition,
|
|
48
46
|
}, pages?.pathById);
|
|
49
47
|
await writeWorkspaceJson(config.workspaceDir, formFileName(form.key), document_);
|
package/src/verbs/forms-push.js
CHANGED
|
@@ -5,22 +5,28 @@ import {
|
|
|
5
5
|
createSiteForm,
|
|
6
6
|
getPageIndex,
|
|
7
7
|
getSiteFormByKey,
|
|
8
|
+
listSiteForms,
|
|
8
9
|
saveSiteFormVersion,
|
|
9
10
|
updateSiteFormSettings,
|
|
10
11
|
} from "../forms-api.js";
|
|
11
|
-
import { afterSubmitPage, formFileName, projectFormForWorkspace, storedDefinition } from "../forms-contract.js";
|
|
12
|
-
import { readWorkspaceForms, recordedForm, siteSettingsBaseline, withRecordedForm } from "../forms-workspace.js";
|
|
12
|
+
import { afterSubmitPage, formFileName, projectFormForWorkspace, storedDefinition, validateNewsletterDefinition } from "../forms-contract.js";
|
|
13
|
+
import { comparableFormName, readWorkspaceForms, recordedForm, siteSettingsBaseline, withRecordedForm } from "../forms-workspace.js";
|
|
13
14
|
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
14
15
|
import { ApiError } from "../transport.js";
|
|
15
16
|
import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
|
|
16
17
|
|
|
17
|
-
function changedOnTheSite(key, cause,
|
|
18
|
+
function changedOnTheSite(key, cause, applied) {
|
|
19
|
+
const outcome = applied.settings && applied.definition
|
|
20
|
+
? "Its settings and definition were applied."
|
|
21
|
+
: applied.settings
|
|
22
|
+
? "Its settings were applied; its definition was not."
|
|
23
|
+
: applied.definition
|
|
24
|
+
? "Its definition was applied; its settings were not."
|
|
25
|
+
: "This form was not changed.";
|
|
18
26
|
return new SiteAuthoringError(
|
|
19
27
|
"forms.concurrent_modification",
|
|
20
28
|
`${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
|
-
}`,
|
|
29
|
+
+ `reconcile the file, and retry. ${outcome}`,
|
|
24
30
|
{ field: formFileName(key), status: cause.status },
|
|
25
31
|
);
|
|
26
32
|
}
|
|
@@ -44,6 +50,7 @@ function planForm(form, remote, baseline, pages) {
|
|
|
44
50
|
const desired = projectFormForWorkspace({
|
|
45
51
|
...form.document,
|
|
46
52
|
retention_days: requestedRetention ?? remote?.form.retentionDays,
|
|
53
|
+
notify_owner: form.document.notify_owner ?? remote?.form.notifyOwner,
|
|
47
54
|
}, pages?.pathById);
|
|
48
55
|
if (remote === undefined) {
|
|
49
56
|
if (baseline !== undefined) {
|
|
@@ -66,28 +73,27 @@ function planForm(form, remote, baseline, pages) {
|
|
|
66
73
|
if (
|
|
67
74
|
baseline.id !== remote.form.id
|
|
68
75
|
|| baseline.version !== remote.version.version
|
|
69
|
-
//
|
|
76
|
+
// Operational settings change without a new version, so they are
|
|
70
77
|
// fenced separately; a stale file would otherwise revert them.
|
|
71
78
|
|| JSON.stringify(baseline.settings) !== JSON.stringify(siteSettings)
|
|
72
79
|
) {
|
|
73
80
|
throw new SiteAuthoringError(
|
|
74
81
|
"forms.concurrent_modification",
|
|
75
82
|
`${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
|
|
83
|
+
+ `${baseline.version}, site has ${remote.version.version}; its settings may also have changed). Run '${VERB_FORMS_PULL}', reconcile the file, `
|
|
77
84
|
+ "and retry. Nothing was written.",
|
|
78
85
|
{ field: formFileName(form.key) },
|
|
79
86
|
);
|
|
80
87
|
}
|
|
81
88
|
const current = projectFormForWorkspace({
|
|
82
89
|
key: remote.form.key,
|
|
83
|
-
|
|
84
|
-
sink: remote.form.sink,
|
|
85
|
-
retention_days: remote.form.retentionDays,
|
|
90
|
+
...siteSettings,
|
|
86
91
|
definition: remote.version.definition,
|
|
87
92
|
}, pages?.pathById);
|
|
88
93
|
const settingsChanged = current.name !== desired.name
|
|
89
94
|
|| current.sink !== desired.sink
|
|
90
|
-
|| current.retention_days !== desired.retention_days
|
|
95
|
+
|| current.retention_days !== desired.retention_days
|
|
96
|
+
|| current.notify_owner !== desired.notify_owner;
|
|
91
97
|
const versionChanged = JSON.stringify(current.definition) !== JSON.stringify(desired.definition);
|
|
92
98
|
return {
|
|
93
99
|
form,
|
|
@@ -114,7 +120,7 @@ export async function formsPush(invocation) {
|
|
|
114
120
|
const session = await openSession(invocation);
|
|
115
121
|
const { client, config, siteId, onProgress } = session;
|
|
116
122
|
warnIfExternalWritesPaused(session, VERB_FORMS_PUSH);
|
|
117
|
-
const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys);
|
|
123
|
+
const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys, { deferNewsletterSemantics: true });
|
|
118
124
|
onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"}.`);
|
|
119
125
|
let manifest = await readManifest(config.workspaceDir, siteId);
|
|
120
126
|
|
|
@@ -132,6 +138,14 @@ export async function formsPush(invocation) {
|
|
|
132
138
|
pages = await withRefusalGuidance(onProgress, "forms push", async () => await getPageIndex(client, siteId, { onProgress }));
|
|
133
139
|
}
|
|
134
140
|
const plan = planForm(form, remote, recordedForm(manifest, form.key), pages);
|
|
141
|
+
// Legacy newsletter definitions may stay only after the baseline matches.
|
|
142
|
+
// New capture or a changed definition must satisfy today's admission rules.
|
|
143
|
+
if (
|
|
144
|
+
plan.desired.sink === "newsletter"
|
|
145
|
+
&& (plan.action === "create" || plan.remote.form.sink !== "newsletter" || plan.versionChanged)
|
|
146
|
+
) {
|
|
147
|
+
validateNewsletterDefinition(form.file, plan.desired.definition);
|
|
148
|
+
}
|
|
135
149
|
// A page_path the site does not have is refused here, with the rest of the checks, not partway
|
|
136
150
|
// through the writes. Only a definition that is going to be sent is resolved: a settings-only
|
|
137
151
|
// update of a form whose page was deleted never sends it.
|
|
@@ -142,6 +156,26 @@ export async function formsPush(invocation) {
|
|
|
142
156
|
);
|
|
143
157
|
}
|
|
144
158
|
|
|
159
|
+
// A form name is unique on the site, ignoring case, so a name another form holds is refused here with
|
|
160
|
+
// the rest of the checks, not by the first write that reaches it.
|
|
161
|
+
const naming = plans.filter((plan) => plan.action === "create" || plan.settingsChanged);
|
|
162
|
+
if (naming.length > 0) {
|
|
163
|
+
const siteForms = await withRefusalGuidance(onProgress, "forms push", async () => await listSiteForms(client, siteId));
|
|
164
|
+
for (const plan of naming) {
|
|
165
|
+
const name = comparableFormName(plan.desired.name);
|
|
166
|
+
const holder = siteForms.find((siteForm) => siteForm.key !== plan.form.key && comparableFormName(siteForm.name) === name);
|
|
167
|
+
if (holder !== undefined) {
|
|
168
|
+
throw new SiteAuthoringError(
|
|
169
|
+
"forms.name_taken",
|
|
170
|
+
`${formFileName(plan.form.key)}: the site's form '${holder.key}'${holder.archived ? " (archived)" : ""} already `
|
|
171
|
+
+ `has the name '${plan.desired.name}'. A form name is unique on a site, ignoring case; rename one of them. `
|
|
172
|
+
+ "Nothing was written.",
|
|
173
|
+
{ field: `${formFileName(plan.form.key)}:name` },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
const results = [];
|
|
146
180
|
for (const plan of plans) {
|
|
147
181
|
let { desired } = plan;
|
|
@@ -151,7 +185,7 @@ export async function formsPush(invocation) {
|
|
|
151
185
|
// The baseline is what the site stored, not what the file said: the site
|
|
152
186
|
// normalizes some values, and the next push is fenced against its copy.
|
|
153
187
|
let settings = plan.remote === undefined ? undefined : siteSettingsBaseline(plan.remote.form);
|
|
154
|
-
|
|
188
|
+
const applied = { settings: false, definition: false };
|
|
155
189
|
try {
|
|
156
190
|
if (plan.action === "create") {
|
|
157
191
|
const created = await withRefusalGuidance(
|
|
@@ -166,43 +200,48 @@ export async function formsPush(invocation) {
|
|
|
166
200
|
// and normalized the name.
|
|
167
201
|
desired = projectFormForWorkspace({
|
|
168
202
|
...desired,
|
|
169
|
-
|
|
170
|
-
sink: created.form.sink,
|
|
171
|
-
retention_days: created.form.retentionDays,
|
|
203
|
+
...settings,
|
|
172
204
|
}, pages?.pathById);
|
|
173
205
|
} else if (plan.action === "update") {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
206
|
+
// Enabling newsletter capture checks the stored definition, so its new
|
|
207
|
+
// email and consent fields must land first. Disabling must do the reverse.
|
|
208
|
+
const definitionFirst = plan.settingsChanged && plan.versionChanged
|
|
209
|
+
&& settings.sink === "none" && desired.sink === "newsletter";
|
|
210
|
+
for (const mutation of definitionFirst ? ["definition", "settings"] : ["settings", "definition"]) {
|
|
211
|
+
if (mutation === "settings") {
|
|
212
|
+
if (!plan.settingsChanged) continue;
|
|
213
|
+
settings = siteSettingsBaseline(
|
|
214
|
+
await withRefusalGuidance(
|
|
215
|
+
onProgress,
|
|
216
|
+
"forms push",
|
|
217
|
+
async () => await updateSiteFormSettings(client, siteId, formId, desired, plan.baseline.settings),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
desired = projectFormForWorkspace({ ...desired, ...settings }, pages?.pathById);
|
|
221
|
+
} else {
|
|
222
|
+
if (!plan.versionChanged) continue;
|
|
223
|
+
version = (await withRefusalGuidance(
|
|
177
224
|
onProgress,
|
|
178
225
|
"forms push",
|
|
179
|
-
async () => await
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
//
|
|
226
|
+
async () => await saveSiteFormVersion(client, siteId, formId, { ...desired, definition: plan.wireDefinition }, plan.baseline.version),
|
|
227
|
+
)).version;
|
|
228
|
+
}
|
|
229
|
+
applied[mutation] = true;
|
|
230
|
+
// A retry must recognize either write as its own, even if the next
|
|
231
|
+
// mutation fails. Keep the other half of the baseline until it lands.
|
|
185
232
|
manifest = withRecordedForm(manifest, form.key, { id: formId, version, settings });
|
|
186
233
|
await writeManifest(config.workspaceDir, manifest);
|
|
187
234
|
}
|
|
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
235
|
}
|
|
196
236
|
} catch (caught) {
|
|
197
237
|
// The site checks the recorded version and settings under its own lock,
|
|
198
238
|
// which is what closes the gap between the comparison above and the write.
|
|
199
239
|
const error = caught instanceof ApiError && (caught.hasField("ExpectedVersion") || caught.hasField("ExpectedSettings"))
|
|
200
|
-
? changedOnTheSite(form.key, caught,
|
|
240
|
+
? changedOnTheSite(form.key, caught, applied)
|
|
201
241
|
: caught;
|
|
202
242
|
if (error instanceof SiteAuthoringError) {
|
|
203
243
|
const completed = results.filter((item) => item.action !== "unchanged").map((item) => item.key);
|
|
204
|
-
|
|
205
|
-
if (settingsApplied) completed.push(form.key);
|
|
244
|
+
if (applied.settings || applied.definition) completed.push(form.key);
|
|
206
245
|
if (completed.length > 0) throw error.withCompletedWrites(completed);
|
|
207
246
|
}
|
|
208
247
|
throw error;
|
|
@@ -222,6 +261,6 @@ export async function formsPush(invocation) {
|
|
|
222
261
|
return successResult(VERB_FORMS_PUSH, siteId, {
|
|
223
262
|
forms: { count: results.length, items: results },
|
|
224
263
|
written: { items: written, count: written.length },
|
|
225
|
-
...(
|
|
264
|
+
...(plans.some((plan) => plan.action === "create" || plan.versionChanged) ? { nextStep: "deploy --staging" } : {}),
|
|
226
265
|
});
|
|
227
266
|
}
|
package/src/verbs/pages-push.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../authors-contract.js";
|
|
22
22
|
import { VERB_PAGES_PUSH } from "../constants.js";
|
|
23
23
|
import { autolinkWarnings } from "../content/link-warnings.js";
|
|
24
|
+
import { placedFormKeys, readFormIndex, withFormIds } from "../form-references.js";
|
|
24
25
|
import { ApiError } from "../transport.js";
|
|
25
26
|
import { waitForProcessing } from "./media-upload.js";
|
|
26
27
|
import { SiteAuthoringError } from "../errors.js";
|
|
@@ -878,6 +879,46 @@ export async function placedVideoProblems(client, siteId, planned) {
|
|
|
878
879
|
return problems;
|
|
879
880
|
}
|
|
880
881
|
|
|
882
|
+
/**
|
|
883
|
+
* The pages that place a form the site does not have, or has archived, and the
|
|
884
|
+
* site's forms by key for the wire (TR01216). A page is stored with the form's id,
|
|
885
|
+
* so a key that names no active form cannot be sent at all. Pages that will not be
|
|
886
|
+
* sent are not asked about, and a push that places no form reads nothing.
|
|
887
|
+
*/
|
|
888
|
+
export async function placedFormProblems(client, siteId, planned) {
|
|
889
|
+
const placements = new Map();
|
|
890
|
+
for (const page of planned) {
|
|
891
|
+
if (page.unchanged) continue;
|
|
892
|
+
for (const key of placedFormKeys(page.document)) {
|
|
893
|
+
if (!placements.has(key)) placements.set(key, page.file);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (placements.size === 0) return { problems: [], idByKey: new Map() };
|
|
897
|
+
|
|
898
|
+
const index = await readFormIndex(client, siteId);
|
|
899
|
+
const problems = [];
|
|
900
|
+
for (const [key, file] of placements) {
|
|
901
|
+
if (index.idByKey.has(key)) continue;
|
|
902
|
+
problems.push(...problemsOf(
|
|
903
|
+
index.archivedKeys.has(key)
|
|
904
|
+
? documentError(
|
|
905
|
+
"pages.form_archived",
|
|
906
|
+
`'${file}' places the form '${key}', which is archived. Restore the form under Site → Forms or remove it `
|
|
907
|
+
+ "from the page.",
|
|
908
|
+
file,
|
|
909
|
+
)
|
|
910
|
+
: documentError(
|
|
911
|
+
"pages.form_unknown",
|
|
912
|
+
`'${file}' places the form '${key}', which this site does not have. Run 'taproot-site forms push' to create `
|
|
913
|
+
+ "it, or fix the key.",
|
|
914
|
+
file,
|
|
915
|
+
),
|
|
916
|
+
{ area: CHECK_AREA.pages, file },
|
|
917
|
+
));
|
|
918
|
+
}
|
|
919
|
+
return { problems, idByKey: index.idByKey };
|
|
920
|
+
}
|
|
921
|
+
|
|
881
922
|
/**
|
|
882
923
|
* Gives each `video-embed` block that has no poster the provider's thumbnail,
|
|
883
924
|
* copied into the site's images by the server (TR01109). One request per provider
|
|
@@ -1565,6 +1606,9 @@ export async function pagesPush(invocation) {
|
|
|
1565
1606
|
// push, before anything is written, so a page cannot be sent that
|
|
1566
1607
|
// publishing would then refuse.
|
|
1567
1608
|
plan.problems.push(...await placedVideoProblems(client, siteId, planned));
|
|
1609
|
+
// Likewise a form: pages store its id, which only the site's own forms can supply.
|
|
1610
|
+
const placedForms = await placedFormProblems(client, siteId, planned);
|
|
1611
|
+
plan.problems.push(...placedForms.problems);
|
|
1568
1612
|
|
|
1569
1613
|
// Under apply, the workspace read ledger has already held every source and
|
|
1570
1614
|
// manifest to the plan. What it cannot see is the site: every page the plan
|
|
@@ -1634,7 +1678,7 @@ export async function pagesPush(invocation) {
|
|
|
1634
1678
|
path: page.pagePath,
|
|
1635
1679
|
title: page.title,
|
|
1636
1680
|
shortDescription: page.description,
|
|
1637
|
-
template: wireTemplate(page.document),
|
|
1681
|
+
template: wireTemplate(withFormIds(page.document, placedForms.idByKey)),
|
|
1638
1682
|
...wirePageFields(page.document),
|
|
1639
1683
|
// Only a name the site has not been told: repeating the author a page
|
|
1640
1684
|
// already has would let a later change of that person's permissions
|
package/src/verbs/plan.js
CHANGED
|
@@ -45,6 +45,7 @@ import { navPush, validateNavigationWorkspaceDocument } from "./nav-push.js";
|
|
|
45
45
|
import {
|
|
46
46
|
liveAuthorsRefresher,
|
|
47
47
|
pagesPush,
|
|
48
|
+
placedFormProblems,
|
|
48
49
|
placedVideoProblems,
|
|
49
50
|
planPages,
|
|
50
51
|
plannedPageItem,
|
|
@@ -283,6 +284,7 @@ async function preflight(session, invocation) {
|
|
|
283
284
|
});
|
|
284
285
|
problems.push(...pages.problems);
|
|
285
286
|
problems.push(...await placedVideoProblems(client, siteId, pages.planned));
|
|
287
|
+
problems.push(...(await placedFormProblems(client, siteId, pages.planned)).problems);
|
|
286
288
|
const sending = pages.planned.filter((page) => !page.unchanged);
|
|
287
289
|
|
|
288
290
|
// An image a page names must be one the site holds; an image this plan
|
|
@@ -433,7 +435,7 @@ function planReport(planned) {
|
|
|
433
435
|
ready: problems.length === 0,
|
|
434
436
|
steps,
|
|
435
437
|
checked: [
|
|
436
|
-
"every page source, against the live site's pages, revisions, and
|
|
438
|
+
"every page source, against the live site's pages, revisions, video library, and the forms it places",
|
|
437
439
|
"media under media/ that pages reference and media upload has not sent",
|
|
438
440
|
"the theme, appearance, and footer colors, against the site's presentation revision",
|
|
439
441
|
"the footer document, against the site's footer draft and the pages it still holds",
|
|
@@ -441,7 +443,7 @@ function planReport(planned) {
|
|
|
441
443
|
"navigation shape and page targets, against the live page list and navigation",
|
|
442
444
|
],
|
|
443
445
|
notChecked: [
|
|
444
|
-
"redirects and forms: run 'redirects push' and 'forms push' on their own",
|
|
446
|
+
"redirects, and the forms files themselves: run 'redirects push' and 'forms push' on their own",
|
|
445
447
|
"rendering: preview pages before approving them",
|
|
446
448
|
"approval, staging, and production, which stay separate commands",
|
|
447
449
|
],
|
package/src/verbs/pull.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
34
34
|
} from "../settings-catalog.js";
|
|
35
35
|
import { ApiError } from "../transport.js";
|
|
36
|
+
import { placesFormIds, readFormIndex, withFormKeys } from "../form-references.js";
|
|
36
37
|
import {
|
|
37
38
|
generatedIdentity,
|
|
38
39
|
isAuthorableTemplateType,
|
|
@@ -643,6 +644,15 @@ async function requireWorkspaceBelongsToSite(workspaceDir, siteId) {
|
|
|
643
644
|
|
|
644
645
|
export async function pull(invocation) {
|
|
645
646
|
const { client, config, siteId, surface, now, onProgress } = await openSession(invocation);
|
|
647
|
+
// A page names its forms by id on the site and by key in the workspace (TR01216). The
|
|
648
|
+
// site's forms are read once, and only when a page body places one.
|
|
649
|
+
let formKeys;
|
|
650
|
+
const workspaceBodyFromPage = async (page, listedTemplateType) => {
|
|
651
|
+
const body = workspaceDocumentFromPage(page, listedTemplateType);
|
|
652
|
+
if (body === undefined || !placesFormIds(body)) return body;
|
|
653
|
+
formKeys ??= readFormIndex(client, siteId).then((index) => index.keyById);
|
|
654
|
+
return withFormKeys(body, await formKeys);
|
|
655
|
+
};
|
|
646
656
|
await ensureWorkspaceRoot(config);
|
|
647
657
|
|
|
648
658
|
await requireWorkspaceBelongsToSite(config.workspaceDir, siteId);
|
|
@@ -891,7 +901,7 @@ export async function pull(invocation) {
|
|
|
891
901
|
if (source === undefined) continue;
|
|
892
902
|
onProgress(`Reading page '${summary.path}'.`);
|
|
893
903
|
const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
|
|
894
|
-
const body =
|
|
904
|
+
const body = await workspaceBodyFromPage(page, summary.templateType);
|
|
895
905
|
const description = typeof page.shortDescription === "string" ? page.shortDescription : "";
|
|
896
906
|
if (body === undefined) {
|
|
897
907
|
// The site's body is unreadable, but the workspace file is still this
|
|
@@ -1261,7 +1271,7 @@ export async function pull(invocation) {
|
|
|
1261
1271
|
}
|
|
1262
1272
|
onProgress(`Reading page '${summary.path}'.`);
|
|
1263
1273
|
const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
|
|
1264
|
-
const body =
|
|
1274
|
+
const body = await workspaceBodyFromPage(page, summary.templateType);
|
|
1265
1275
|
entry.description = typeof page.shortDescription === "string" ? page.shortDescription : "";
|
|
1266
1276
|
entry.title = titleFromRead(page, summary);
|
|
1267
1277
|
entry.path = pathFromRead(page, summary);
|