@taprootio/site-authoring 0.14.1 → 0.16.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 +181 -16
- package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
- package/examples/riverbend-wellness/pages/index.md +1 -17
- package/package.json +2 -2
- package/src/api.js +123 -1
- package/src/authors-contract.js +170 -0
- package/src/cli.js +355 -30
- package/src/constants.js +9 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/link-warnings.js +109 -0
- package/src/content/markdown.js +161 -26
- package/src/content/tiptap-prosemirror.ts +66 -8
- package/src/content/validate-document.js +30 -6
- package/src/content/vocabulary.js +26 -0
- package/src/errors.js +22 -0
- package/src/espalier-shared.js +11 -0
- package/src/fixture-init.js +55 -3
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/page-metadata.js +92 -0
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +157 -41
- package/src/session.js +13 -0
- package/src/staging-check.js +62 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +97 -21
- package/src/typed-pages.js +196 -13
- package/src/verbs/approve.js +63 -14
- package/src/verbs/authors.js +167 -0
- package/src/verbs/deploy.js +5 -28
- package/src/verbs/index.js +22 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +814 -224
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +595 -0
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +309 -217
- package/src/video-prepare.js +48 -12
- package/src/workspace-check.js +199 -0
- package/src/workspace-ledger.js +173 -0
- package/src/workspace.js +105 -10
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTHORS_FILE_NAME,
|
|
3
|
+
authorConflictMessage,
|
|
4
|
+
authorKnown,
|
|
5
|
+
parseAuthorReference,
|
|
6
|
+
readAuthorsDocument,
|
|
7
|
+
requireAuthorReference,
|
|
8
|
+
} from "../authors-contract.js";
|
|
9
|
+
import { VERB_PAGES_META_SET } from "../constants.js";
|
|
10
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
11
|
+
import { descriptionWarnings, PAGE_AUTHOR_UNVERIFIED, requireDescriptionLength } from "../page-metadata.js";
|
|
12
|
+
import { openAnonymousSession, successResult } from "../session.js";
|
|
13
|
+
import { isGeneratedTemplateType, TEMPLATE_GENERATED, typedDocumentFromJson } from "../typed-pages.js";
|
|
14
|
+
import {
|
|
15
|
+
displayPagePath,
|
|
16
|
+
normalizePagePath,
|
|
17
|
+
PAGE_SOURCE_FORMAT_MARKDOWN,
|
|
18
|
+
PAGE_WORKSPACE_MODE_EDITABLE,
|
|
19
|
+
readManifest,
|
|
20
|
+
readWorkspaceFile,
|
|
21
|
+
readWorkspaceJson,
|
|
22
|
+
workspaceFileExists,
|
|
23
|
+
WORKSPACE_LIMITS,
|
|
24
|
+
writeManifest,
|
|
25
|
+
writeWorkspaceJson,
|
|
26
|
+
} from "../workspace.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `pages meta set` — the supported way to change a .pm.json page's title,
|
|
30
|
+
* description or author, which live in the workspace manifest rather than the
|
|
31
|
+
* file (TR01194, TR01196). It edits the workspace only; `pages push` sends the
|
|
32
|
+
* change, since the page's content key covers all three.
|
|
33
|
+
*
|
|
34
|
+
* The author is the one field that can only be filled, never changed: pages are
|
|
35
|
+
* authorless by default and an author already on the page is never replaced.
|
|
36
|
+
*
|
|
37
|
+
* A generated page keeps its own title and description in its source document
|
|
38
|
+
* (`data.customTitle`, `data.customDescription`), because the manifest holds the
|
|
39
|
+
* ones the site reports; an empty value there means "use the generated default"
|
|
40
|
+
* (TR01195).
|
|
41
|
+
*/
|
|
42
|
+
export async function pagesMetaSet(invocation) {
|
|
43
|
+
const { config, onProgress } = await openAnonymousSession({ ...invocation, client: null });
|
|
44
|
+
if (config === undefined) {
|
|
45
|
+
throw new SiteAuthoringError(
|
|
46
|
+
"config.not_found",
|
|
47
|
+
"No taproot-site.json was found. pages meta set edits the workspace it names.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
// The parser refuses this too; a handler called directly must not report
|
|
51
|
+
// "nothing changed" for a request that asked for nothing.
|
|
52
|
+
if (
|
|
53
|
+
invocation.metaTitle === undefined && invocation.metaDescription === undefined
|
|
54
|
+
&& invocation.metaAuthor === undefined
|
|
55
|
+
) {
|
|
56
|
+
throw new SiteAuthoringError(
|
|
57
|
+
"pages.meta_nothing_to_set",
|
|
58
|
+
"Name what to set: --title, --description, --author, or any of them.",
|
|
59
|
+
{ exitCode: 2 },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
// Surrounding whitespace is never meant; the site would keep it otherwise.
|
|
63
|
+
const title = invocation.metaTitle?.trim();
|
|
64
|
+
const description = invocation.metaDescription?.trim();
|
|
65
|
+
// An empty --author clears one that was never sent; any other value is a reference.
|
|
66
|
+
const clearAuthor = invocation.metaAuthor === "";
|
|
67
|
+
const author = invocation.metaAuthor === undefined || clearAuthor
|
|
68
|
+
? undefined
|
|
69
|
+
: requireAuthorReference(invocation.metaAuthor, "author");
|
|
70
|
+
const requested = normalizePagePath(invocation.metaPagePath);
|
|
71
|
+
if (requested === undefined) {
|
|
72
|
+
throw new SiteAuthoringError("pages.meta_path_invalid", "The page path is not usable.", {
|
|
73
|
+
field: "pagePath",
|
|
74
|
+
exitCode: 2,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const manifest = await readManifest(config.workspaceDir, config.siteId);
|
|
78
|
+
const entry = manifest.pages.find((candidate) => normalizePagePath(candidate?.path) === requested);
|
|
79
|
+
const shown = displayPagePath(requested);
|
|
80
|
+
if (entry === undefined) {
|
|
81
|
+
throw new SiteAuthoringError(
|
|
82
|
+
"pages.meta_page_unknown",
|
|
83
|
+
`No page this workspace tracks is at '${shown}'. Run 'taproot-site pull' if it was created elsewhere.`,
|
|
84
|
+
{ field: shown },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (entry.workspaceMode !== PAGE_WORKSPACE_MODE_EDITABLE) {
|
|
88
|
+
throw new SiteAuthoringError(
|
|
89
|
+
"pages.meta_page_readonly",
|
|
90
|
+
`'${shown}' is not an editable page in this workspace, so its title, description and author cannot be set here.`,
|
|
91
|
+
{ field: shown },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (entry.sourceFormat === PAGE_SOURCE_FORMAT_MARKDOWN) {
|
|
95
|
+
throw new SiteAuthoringError(
|
|
96
|
+
"pages.meta_markdown",
|
|
97
|
+
`'${shown}' is authored in ${entry.file}, whose front matter holds its title, description and author; edit them there.`,
|
|
98
|
+
{ field: shown },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
// The manifest entry outlives a deleted source, and pull re-creates the file
|
|
102
|
+
// from the site, which would silently drop an edit made here.
|
|
103
|
+
if (typeof entry.file !== "string" || !await workspaceFileExists(config.workspaceDir, entry.file)) {
|
|
104
|
+
throw new SiteAuthoringError(
|
|
105
|
+
"pages.meta_source_missing",
|
|
106
|
+
`'${shown}' has no source file in this workspace. Run 'taproot-site pull' to restore it first.`,
|
|
107
|
+
{ field: shown },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (description !== undefined) requireDescriptionLength(description);
|
|
111
|
+
// A generated page's custom title and description are in its source, where
|
|
112
|
+
// pull already protects an edit like any other.
|
|
113
|
+
if (isGeneratedTemplateType(entry.templateType)) {
|
|
114
|
+
if (author !== undefined || clearAuthor) {
|
|
115
|
+
throw new SiteAuthoringError(
|
|
116
|
+
"pages.meta_author_generated",
|
|
117
|
+
`'${shown}' is a page Taproot generates, so it has no author to set.`,
|
|
118
|
+
{ field: shown },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return await setGeneratedMetadata({ invocation, config, entry, shown, onProgress });
|
|
122
|
+
}
|
|
123
|
+
// pull tells a metadata edit from the site's values by the content key the
|
|
124
|
+
// last pull or push recorded. Without one the edit could not be protected,
|
|
125
|
+
// and the next pull would quietly overwrite it.
|
|
126
|
+
if (typeof entry.baseline?.sourceHash !== "string" || typeof entry.baseline?.contentKey !== "string") {
|
|
127
|
+
throw new SiteAuthoringError(
|
|
128
|
+
"pages.meta_unreconciled",
|
|
129
|
+
`'${shown}' has not been reconciled with the site yet, so an edit here could be lost to the next pull. Run `
|
|
130
|
+
+ "'taproot-site pull' (or push the page) first.",
|
|
131
|
+
{ field: shown },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (title === "") {
|
|
135
|
+
throw new SiteAuthoringError(
|
|
136
|
+
"pages.meta_title_empty",
|
|
137
|
+
`'${shown}' needs a title. Only a generated page can clear its title, to use the default one.`,
|
|
138
|
+
{ field: "title", exitCode: 2 },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let unverifiedAuthor = false;
|
|
143
|
+
if (clearAuthor) requireClearableAuthor({ entry, shown });
|
|
144
|
+
else if (author !== undefined) {
|
|
145
|
+
unverifiedAuthor = await requireAssignableAuthor({ config, entry, shown, author });
|
|
146
|
+
if (unverifiedAuthor) {
|
|
147
|
+
onProgress(
|
|
148
|
+
`Warning: '${author}' is not in ${AUTHORS_FILE_NAME}; it may have been added since. 'taproot-site authors list' `
|
|
149
|
+
+ "refreshes it, and 'pages push' checks the name against the site before sending.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const next = {
|
|
155
|
+
title: title ?? entry.title,
|
|
156
|
+
description: description ?? entry.description ?? "",
|
|
157
|
+
author: clearAuthor ? "" : author ?? entry.author ?? "",
|
|
158
|
+
};
|
|
159
|
+
const changed = next.title !== entry.title
|
|
160
|
+
|| next.description !== (entry.description ?? "")
|
|
161
|
+
|| next.author !== (entry.author ?? "");
|
|
162
|
+
if (changed) {
|
|
163
|
+
entry.title = next.title;
|
|
164
|
+
entry.description = next.description;
|
|
165
|
+
if (next.author === "") delete entry.author;
|
|
166
|
+
else entry.author = next.author;
|
|
167
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
168
|
+
onProgress(`Set the ${describeFields(invocation)} of '${shown}'. Run 'taproot-site pages push' to send it.`);
|
|
169
|
+
} else {
|
|
170
|
+
onProgress(`'${shown}' already has that ${describeFields(invocation)}; nothing changed.`);
|
|
171
|
+
}
|
|
172
|
+
return successResult(VERB_PAGES_META_SET, config.siteId, {
|
|
173
|
+
page: {
|
|
174
|
+
path: shown,
|
|
175
|
+
file: entry.file,
|
|
176
|
+
title: next.title,
|
|
177
|
+
description: next.description,
|
|
178
|
+
...(next.author === "" ? {} : { author: next.author }),
|
|
179
|
+
},
|
|
180
|
+
changed,
|
|
181
|
+
...descriptionWarnings([{ file: entry.file, pagePath: entry.path, description: next.description }], onProgress),
|
|
182
|
+
...(unverifiedAuthor
|
|
183
|
+
? {
|
|
184
|
+
authorWarnings: {
|
|
185
|
+
total: 1,
|
|
186
|
+
items: [{ code: PAGE_AUTHOR_UNVERIFIED, file: entry.file, path: shown, author: next.author }],
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
...(changed ? { nextStep: "pages push" } : {}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function describeFields(invocation) {
|
|
195
|
+
return [
|
|
196
|
+
invocation.metaTitle !== undefined && "title",
|
|
197
|
+
invocation.metaDescription !== undefined && "description",
|
|
198
|
+
invocation.metaAuthor !== undefined && "author",
|
|
199
|
+
]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join(" and ");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Refuses an author the site would not take: a different one than the site
|
|
206
|
+
* holds (never replaced). One that `authors.json` lacks is only warned about,
|
|
207
|
+
* because this verb is offline and the file is as old as the last `pull` or
|
|
208
|
+
* `authors list`: the person may have been added since, and `pages push` asks
|
|
209
|
+
* the site before it sends anything. Answers whether the name is unverified.
|
|
210
|
+
*/
|
|
211
|
+
async function requireAssignableAuthor({ config, entry, shown, author }) {
|
|
212
|
+
const held = parseAuthorReference(entry.baseline?.author)?.value ?? "";
|
|
213
|
+
if (held !== "" && held !== author) {
|
|
214
|
+
throw new SiteAuthoringError(
|
|
215
|
+
"pages.author_conflict",
|
|
216
|
+
authorConflictMessage({ shown, held, wanted: author, markdown: false, file: entry.file }),
|
|
217
|
+
{ field: shown },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (held === author) return false;
|
|
221
|
+
let recorded;
|
|
222
|
+
try {
|
|
223
|
+
if (await workspaceFileExists(config.workspaceDir, AUTHORS_FILE_NAME)) {
|
|
224
|
+
recorded = readAuthorsDocument(
|
|
225
|
+
await readWorkspaceJson(config.workspaceDir, AUTHORS_FILE_NAME, WORKSPACE_LIMITS.authorsBytes),
|
|
226
|
+
config.siteId,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
recorded = undefined;
|
|
231
|
+
}
|
|
232
|
+
return authorKnown(recorded, author) === false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `--author ""` drops an author this workspace has not sent yet. It never asks
|
|
237
|
+
* the site to remove one: once the site holds an author (the last pull or push
|
|
238
|
+
* recorded it), only the app changes that, so the request is refused rather
|
|
239
|
+
* than quietly leaving the author in place.
|
|
240
|
+
*/
|
|
241
|
+
function requireClearableAuthor({ entry, shown }) {
|
|
242
|
+
const held = parseAuthorReference(entry.baseline?.author)?.value ?? "";
|
|
243
|
+
if (held !== "") {
|
|
244
|
+
throw new SiteAuthoringError(
|
|
245
|
+
"pages.author_conflict",
|
|
246
|
+
`Page '${shown}' already has the author '${held}', and an author is never removed from the CLI. `
|
|
247
|
+
+ "To change or remove it, use the app.",
|
|
248
|
+
{ field: shown },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Sets a generated page's custom title or description in its source document. */
|
|
254
|
+
async function setGeneratedMetadata({ invocation, config, entry, shown, onProgress }) {
|
|
255
|
+
let parsed;
|
|
256
|
+
try {
|
|
257
|
+
const bytes = await readWorkspaceFile(config.workspaceDir, entry.file, WORKSPACE_LIMITS.documentBytes);
|
|
258
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof SiteAuthoringError) throw error;
|
|
261
|
+
throw new SiteAuthoringError("pages.document_shape", `'${entry.file}' is not valid JSON.`, { field: entry.file });
|
|
262
|
+
}
|
|
263
|
+
const document_ = typedDocumentFromJson(parsed, entry.file);
|
|
264
|
+
if (document_.template !== TEMPLATE_GENERATED) {
|
|
265
|
+
throw new SiteAuthoringError(
|
|
266
|
+
"pages.meta_page_readonly",
|
|
267
|
+
`'${entry.file}' is not a generated-page source, so '${shown}' cannot be set from it.`,
|
|
268
|
+
{ field: shown },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const data = {
|
|
272
|
+
...document_.data,
|
|
273
|
+
...(invocation.metaTitle === undefined ? {} : { customTitle: invocation.metaTitle.trim() }),
|
|
274
|
+
...(invocation.metaDescription === undefined ? {} : { customDescription: invocation.metaDescription.trim() }),
|
|
275
|
+
};
|
|
276
|
+
const next = typedDocumentFromJson({ ...document_, data }, entry.file);
|
|
277
|
+
const changed = next.data.customTitle !== document_.data.customTitle
|
|
278
|
+
|| next.data.customDescription !== document_.data.customDescription;
|
|
279
|
+
if (changed) {
|
|
280
|
+
await writeWorkspaceJson(config.workspaceDir, entry.file, next);
|
|
281
|
+
onProgress(
|
|
282
|
+
`Set the ${describeFields(invocation)} of '${shown}' in ${entry.file}. Run 'taproot-site pages push' to send it.`,
|
|
283
|
+
);
|
|
284
|
+
} else {
|
|
285
|
+
onProgress(`'${shown}' already has that ${describeFields(invocation)}; nothing changed.`);
|
|
286
|
+
}
|
|
287
|
+
return successResult(VERB_PAGES_META_SET, config.siteId, {
|
|
288
|
+
// The owner's own values; empty means the page uses its generated default.
|
|
289
|
+
page: {
|
|
290
|
+
path: shown,
|
|
291
|
+
file: entry.file,
|
|
292
|
+
title: next.data.customTitle,
|
|
293
|
+
description: next.data.customDescription,
|
|
294
|
+
generated: true,
|
|
295
|
+
},
|
|
296
|
+
changed,
|
|
297
|
+
...descriptionWarnings(
|
|
298
|
+
[{ file: entry.file, pagePath: entry.path, description: next.data.customDescription }],
|
|
299
|
+
onProgress,
|
|
300
|
+
),
|
|
301
|
+
...(changed ? { nextStep: "pages push" } : {}),
|
|
302
|
+
});
|
|
303
|
+
}
|