@taprootio/site-authoring 0.12.6 → 0.13.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 +20 -2
- package/package.json +3 -2
- package/src/api.js +81 -2
- package/src/capabilities.js +1 -1
- package/src/cli.js +51 -3
- package/src/constants.js +6 -0
- package/src/content/components.js +180 -2
- package/src/content/free-form-section-registry.json +16 -1
- package/src/content/markdown.js +12 -2
- package/src/content/media-presentation.js +123 -0
- package/src/content/tiptap-prosemirror.ts +12 -64
- package/src/content/video-embed-url.js +104 -0
- package/src/content/vocabulary.js +9 -32
- package/src/field-schema.json +227 -0
- package/src/field-validation.js +721 -0
- package/src/fixture-contract.js +14 -3
- package/src/fixture-init.js +18 -0
- package/src/forms-api.js +192 -0
- package/src/forms-contract.js +274 -0
- package/src/forms-workspace.js +100 -0
- package/src/image-metadata.js +1 -1
- package/src/reference-help.js +168 -5
- package/src/session.js +22 -1
- package/src/settings-catalog.js +3 -0
- package/src/transport.js +77 -3
- package/src/typed-pages.js +83 -13
- 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/media-upload.js +352 -28
- package/src/verbs/pages-push.js +97 -2
- package/src/verbs/pull.js +8 -0
- package/src/verbs/validate.js +94 -2
- package/src/video-metadata.js +90 -0
- package/src/video-prepare.js +136 -0
- package/src/workspace.js +113 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { withRefusalGuidance } from "../api.js";
|
|
2
|
+
import { VERB_FORMS_PULL, VERB_FORMS_PUSH } from "../constants.js";
|
|
3
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
4
|
+
import {
|
|
5
|
+
createSiteForm,
|
|
6
|
+
getPageIndex,
|
|
7
|
+
getSiteFormByKey,
|
|
8
|
+
saveSiteFormVersion,
|
|
9
|
+
updateSiteFormSettings,
|
|
10
|
+
} from "../forms-api.js";
|
|
11
|
+
import { afterSubmitPage, formFileName, projectFormForWorkspace, storedDefinition } from "../forms-contract.js";
|
|
12
|
+
import { readWorkspaceForms, recordedForm, siteSettingsBaseline, withRecordedForm } from "../forms-workspace.js";
|
|
13
|
+
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
14
|
+
import { ApiError } from "../transport.js";
|
|
15
|
+
import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
|
|
16
|
+
|
|
17
|
+
function changedOnTheSite(key, cause, settingsApplied) {
|
|
18
|
+
return new SiteAuthoringError(
|
|
19
|
+
"forms.concurrent_modification",
|
|
20
|
+
`${formFileName(key)}: the form changed on the site while it was being pushed. Run '${VERB_FORMS_PULL}', `
|
|
21
|
+
+ `reconcile the file, and retry. ${
|
|
22
|
+
settingsApplied ? "Its settings were applied; its definition was not." : "This form was not changed."
|
|
23
|
+
}`,
|
|
24
|
+
{ field: formFileName(key), status: cause.status },
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pullRequired(key, reason) {
|
|
29
|
+
return new SiteAuthoringError(
|
|
30
|
+
"forms.pull_required",
|
|
31
|
+
`${reason} Run '${VERB_FORMS_PULL}', reconcile ${formFileName(key)}, and retry. Nothing was written.`,
|
|
32
|
+
{ field: formFileName(key) },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decides what one form needs, from the file, the site's copy and the
|
|
38
|
+
* baseline the last pull recorded. Throws before anything is written.
|
|
39
|
+
*/
|
|
40
|
+
function planForm(form, remote, baseline, pages) {
|
|
41
|
+
// An omitted retention means "the site's choice": the plan-aware default on
|
|
42
|
+
// creation, and the form's current value on an update.
|
|
43
|
+
const requestedRetention = form.document.retention_days;
|
|
44
|
+
const desired = projectFormForWorkspace({
|
|
45
|
+
...form.document,
|
|
46
|
+
retention_days: requestedRetention ?? remote?.form.retentionDays,
|
|
47
|
+
}, pages?.pathById);
|
|
48
|
+
if (remote === undefined) {
|
|
49
|
+
if (baseline !== undefined) {
|
|
50
|
+
throw pullRequired(form.key, "This workspace recorded the form, but the site no longer has it.");
|
|
51
|
+
}
|
|
52
|
+
return { form, desired, request: { ...desired, retention_days: requestedRetention }, action: "create" };
|
|
53
|
+
}
|
|
54
|
+
if (remote.form.archived) {
|
|
55
|
+
throw new SiteAuthoringError(
|
|
56
|
+
"forms.archived",
|
|
57
|
+
`${formFileName(form.key)}: the site's form '${form.key}' is archived. Restore it on the site's Forms page, `
|
|
58
|
+
+ "delete the file, or name the other keys to push. Nothing was written.",
|
|
59
|
+
{ field: formFileName(form.key) },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (baseline === undefined) {
|
|
63
|
+
throw pullRequired(form.key, "The site already has a form with this key that this workspace never pulled.");
|
|
64
|
+
}
|
|
65
|
+
const siteSettings = siteSettingsBaseline(remote.form);
|
|
66
|
+
if (
|
|
67
|
+
baseline.id !== remote.form.id
|
|
68
|
+
|| baseline.version !== remote.version.version
|
|
69
|
+
// Name, sink and retention change without a new version, so they are
|
|
70
|
+
// fenced separately; a stale file would otherwise revert them.
|
|
71
|
+
|| JSON.stringify(baseline.settings) !== JSON.stringify(siteSettings)
|
|
72
|
+
) {
|
|
73
|
+
throw new SiteAuthoringError(
|
|
74
|
+
"forms.concurrent_modification",
|
|
75
|
+
`${formFileName(form.key)}: the form changed on the site after this workspace read it (recorded version `
|
|
76
|
+
+ `${baseline.version}, site has ${remote.version.version}; its name, sink or retention may also have changed). Run '${VERB_FORMS_PULL}', reconcile the file, `
|
|
77
|
+
+ "and retry. Nothing was written.",
|
|
78
|
+
{ field: formFileName(form.key) },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const current = projectFormForWorkspace({
|
|
82
|
+
key: remote.form.key,
|
|
83
|
+
name: remote.form.name,
|
|
84
|
+
sink: remote.form.sink,
|
|
85
|
+
retention_days: remote.form.retentionDays,
|
|
86
|
+
definition: remote.version.definition,
|
|
87
|
+
}, pages?.pathById);
|
|
88
|
+
const settingsChanged = current.name !== desired.name
|
|
89
|
+
|| current.sink !== desired.sink
|
|
90
|
+
|| current.retention_days !== desired.retention_days;
|
|
91
|
+
const versionChanged = JSON.stringify(current.definition) !== JSON.stringify(desired.definition);
|
|
92
|
+
return {
|
|
93
|
+
form,
|
|
94
|
+
desired,
|
|
95
|
+
remote,
|
|
96
|
+
baseline,
|
|
97
|
+
action: settingsChanged || versionChanged ? "update" : "unchanged",
|
|
98
|
+
settingsChanged,
|
|
99
|
+
versionChanged,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `forms push` — create or update each form file in the workspace.
|
|
105
|
+
*
|
|
106
|
+
* Every file is validated and every form is compared with the site before the
|
|
107
|
+
* first write, so a conflict on the third form refuses the whole push rather
|
|
108
|
+
* than landing two. After that each form is written and its baseline advanced
|
|
109
|
+
* immediately, so a failure partway leaves a workspace that the next push can
|
|
110
|
+
* resume from. Editing a definition appends a version on the site; the
|
|
111
|
+
* published pages keep the version they were built with until the next deploy.
|
|
112
|
+
*/
|
|
113
|
+
export async function formsPush(invocation) {
|
|
114
|
+
const session = await openSession(invocation);
|
|
115
|
+
const { client, config, siteId, onProgress } = session;
|
|
116
|
+
warnIfExternalWritesPaused(session, VERB_FORMS_PUSH);
|
|
117
|
+
const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys);
|
|
118
|
+
onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"}.`);
|
|
119
|
+
let manifest = await readManifest(config.workspaceDir, siteId);
|
|
120
|
+
|
|
121
|
+
// The site names the page after a response by resource id and the file by path, so the
|
|
122
|
+
// page list is read once, and only when a form on either side points at a page.
|
|
123
|
+
let pages;
|
|
124
|
+
const plans = [];
|
|
125
|
+
for (const form of forms) {
|
|
126
|
+
const remote = await withRefusalGuidance(
|
|
127
|
+
onProgress,
|
|
128
|
+
"forms push",
|
|
129
|
+
async () => await getSiteFormByKey(client, siteId, form.key),
|
|
130
|
+
);
|
|
131
|
+
if (pages === undefined && (afterSubmitPage(form.document.definition) || afterSubmitPage(remote?.version.definition))) {
|
|
132
|
+
pages = await withRefusalGuidance(onProgress, "forms push", async () => await getPageIndex(client, siteId, { onProgress }));
|
|
133
|
+
}
|
|
134
|
+
const plan = planForm(form, remote, recordedForm(manifest, form.key), pages);
|
|
135
|
+
// A page_path the site does not have is refused here, with the rest of the checks, not partway
|
|
136
|
+
// through the writes. Only a definition that is going to be sent is resolved: a settings-only
|
|
137
|
+
// update of a form whose page was deleted never sends it.
|
|
138
|
+
plans.push(
|
|
139
|
+
plan.action === "create" || plan.versionChanged
|
|
140
|
+
? { ...plan, wireDefinition: storedDefinition(form.file, (plan.request ?? plan.desired).definition, pages) }
|
|
141
|
+
: plan,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const results = [];
|
|
146
|
+
for (const plan of plans) {
|
|
147
|
+
let { desired } = plan;
|
|
148
|
+
const { form } = plan;
|
|
149
|
+
let formId = plan.remote?.form.id;
|
|
150
|
+
let version = plan.remote?.version.version;
|
|
151
|
+
// The baseline is what the site stored, not what the file said: the site
|
|
152
|
+
// normalizes some values, and the next push is fenced against its copy.
|
|
153
|
+
let settings = plan.remote === undefined ? undefined : siteSettingsBaseline(plan.remote.form);
|
|
154
|
+
let settingsApplied = false;
|
|
155
|
+
try {
|
|
156
|
+
if (plan.action === "create") {
|
|
157
|
+
const created = await withRefusalGuidance(
|
|
158
|
+
onProgress,
|
|
159
|
+
"forms push",
|
|
160
|
+
async () => await createSiteForm(client, siteId, { ...plan.request, definition: plan.wireDefinition }),
|
|
161
|
+
);
|
|
162
|
+
formId = created.form.id;
|
|
163
|
+
version = created.version.version;
|
|
164
|
+
settings = siteSettingsBaseline(created.form);
|
|
165
|
+
// The file is written from what the site stored: it chose the retention
|
|
166
|
+
// and normalized the name.
|
|
167
|
+
desired = projectFormForWorkspace({
|
|
168
|
+
...desired,
|
|
169
|
+
name: created.form.name,
|
|
170
|
+
sink: created.form.sink,
|
|
171
|
+
retention_days: created.form.retentionDays,
|
|
172
|
+
}, pages?.pathById);
|
|
173
|
+
} else if (plan.action === "update") {
|
|
174
|
+
if (plan.settingsChanged) {
|
|
175
|
+
settings = siteSettingsBaseline(
|
|
176
|
+
await withRefusalGuidance(
|
|
177
|
+
onProgress,
|
|
178
|
+
"forms push",
|
|
179
|
+
async () => await updateSiteFormSettings(client, siteId, formId, desired, plan.baseline.settings),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
settingsApplied = true;
|
|
183
|
+
// Recorded at once: if the version save below fails, the next push
|
|
184
|
+
// must not read the settings it just applied as someone else's edit.
|
|
185
|
+
manifest = withRecordedForm(manifest, form.key, { id: formId, version, settings });
|
|
186
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
187
|
+
}
|
|
188
|
+
if (plan.versionChanged) {
|
|
189
|
+
version = (await withRefusalGuidance(
|
|
190
|
+
onProgress,
|
|
191
|
+
"forms push",
|
|
192
|
+
async () => await saveSiteFormVersion(client, siteId, formId, { ...desired, definition: plan.wireDefinition }, plan.baseline.version),
|
|
193
|
+
)).version;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch (caught) {
|
|
197
|
+
// The site checks the recorded version and settings under its own lock,
|
|
198
|
+
// which is what closes the gap between the comparison above and the write.
|
|
199
|
+
const error = caught instanceof ApiError && (caught.hasField("ExpectedVersion") || caught.hasField("ExpectedSettings"))
|
|
200
|
+
? changedOnTheSite(form.key, caught, settingsApplied)
|
|
201
|
+
: caught;
|
|
202
|
+
if (error instanceof SiteAuthoringError) {
|
|
203
|
+
const completed = results.filter((item) => item.action !== "unchanged").map((item) => item.key);
|
|
204
|
+
// The settings write stands even though the version save after it failed.
|
|
205
|
+
if (settingsApplied) completed.push(form.key);
|
|
206
|
+
if (completed.length > 0) throw error.withCompletedWrites(completed);
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
if (plan.action !== "unchanged") {
|
|
211
|
+
// The file is rewritten in canonical form so the workspace and the site
|
|
212
|
+
// agree byte for byte, and the baseline moves with it.
|
|
213
|
+
await writeWorkspaceJson(config.workspaceDir, formFileName(form.key), desired);
|
|
214
|
+
manifest = withRecordedForm(manifest, form.key, { id: formId, version, settings });
|
|
215
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
216
|
+
}
|
|
217
|
+
results.push({ key: form.key, action: plan.action, version });
|
|
218
|
+
onProgress(`${form.key}: ${plan.action}${plan.action === "unchanged" ? "" : `, version ${version}`}.`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const written = results.filter((item) => item.action !== "unchanged").map((item) => item.key);
|
|
222
|
+
return successResult(VERB_FORMS_PUSH, siteId, {
|
|
223
|
+
forms: { count: results.length, items: results },
|
|
224
|
+
written: { items: written, count: written.length },
|
|
225
|
+
...(written.length > 0 ? { nextStep: "deploy --staging" } : {}),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { VERB_FORMS_VALIDATE } from "../constants.js";
|
|
2
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
3
|
+
import { readWorkspaceForms } from "../forms-workspace.js";
|
|
4
|
+
import { openAnonymousSession, successResult } from "../session.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `forms validate` — prove the workspace's form files offline.
|
|
8
|
+
*
|
|
9
|
+
* It runs the same file contract and the same shared field validator
|
|
10
|
+
* `forms push` does, so a file that passes here is not refused for its shape
|
|
11
|
+
* by the push. It cannot prove what only the site knows: whether the key is
|
|
12
|
+
* already taken by a form someone else changed, or whether the caller may
|
|
13
|
+
* write forms.
|
|
14
|
+
*/
|
|
15
|
+
export async function formsValidate(invocation) {
|
|
16
|
+
const { config, onProgress } = await openAnonymousSession({ ...invocation, client: null });
|
|
17
|
+
if (config === undefined) {
|
|
18
|
+
throw new SiteAuthoringError(
|
|
19
|
+
"config.not_found",
|
|
20
|
+
"No taproot-site.json was found. forms validate reads the workspace it names.",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const forms = await readWorkspaceForms(config.workspaceDir, invocation.formKeys);
|
|
24
|
+
onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"}.`);
|
|
25
|
+
return successResult(VERB_FORMS_VALIDATE, undefined, {
|
|
26
|
+
forms: {
|
|
27
|
+
count: forms.length,
|
|
28
|
+
items: forms.map((form) => ({
|
|
29
|
+
key: form.key,
|
|
30
|
+
file: form.file,
|
|
31
|
+
fields: form.document.definition.fields.length,
|
|
32
|
+
sink: form.document.sink ?? "none",
|
|
33
|
+
})),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
package/src/verbs/index.js
CHANGED
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
VERB_DEPLOY,
|
|
5
5
|
VERB_ENV,
|
|
6
6
|
VERB_FOOTER_PUSH,
|
|
7
|
+
VERB_FORMS_PULL,
|
|
8
|
+
VERB_FORMS_PUSH,
|
|
9
|
+
VERB_FORMS_VALIDATE,
|
|
7
10
|
VERB_LOGIN,
|
|
8
11
|
VERB_LOGOUT,
|
|
9
12
|
VERB_MEDIA_UPLOAD,
|
|
@@ -28,6 +31,9 @@ import { deliveryCheck } from "./delivery-check.js";
|
|
|
28
31
|
import { deploy } from "./deploy.js";
|
|
29
32
|
import { env } from "./env.js";
|
|
30
33
|
import { footerPush } from "./footer-push.js";
|
|
34
|
+
import { formsPull } from "./forms-pull.js";
|
|
35
|
+
import { formsPush } from "./forms-push.js";
|
|
36
|
+
import { formsValidate } from "./forms-validate.js";
|
|
31
37
|
import { login } from "./login.js";
|
|
32
38
|
import { logout } from "./logout.js";
|
|
33
39
|
import { mediaUpload } from "./media-upload.js";
|
|
@@ -84,6 +90,8 @@ import { whoami } from "./whoami.js";
|
|
|
84
90
|
* fixturePath, // validate: one offline authoring-fixture directory
|
|
85
91
|
* paths, // media upload: files and/or directories to upload;
|
|
86
92
|
* // with none, the workspace's media/ directory is walked
|
|
93
|
+
* formKeys, // forms validate / push: narrow to these form keys;
|
|
94
|
+
* // with none, every file under forms/
|
|
87
95
|
* pagePaths, // approve: narrow to these page paths; with none, every
|
|
88
96
|
* // draft the workspace manifest tracks is staged
|
|
89
97
|
* pageId, // preview page: the one persisted draft UUID to render
|
|
@@ -124,6 +132,9 @@ export const VERB_HANDLERS = Object.freeze({
|
|
|
124
132
|
[VERB_REDIRECTS_PUSH]: redirectsPush,
|
|
125
133
|
[VERB_THEME_PUSH]: themePush,
|
|
126
134
|
[VERB_FOOTER_PUSH]: footerPush,
|
|
135
|
+
[VERB_FORMS_PULL]: formsPull,
|
|
136
|
+
[VERB_FORMS_PUSH]: formsPush,
|
|
137
|
+
[VERB_FORMS_VALIDATE]: formsValidate,
|
|
127
138
|
[VERB_MEDIA_UPLOAD]: mediaUpload,
|
|
128
139
|
[VERB_APPROVE]: approve,
|
|
129
140
|
[VERB_DEPLOY]: deploy,
|