@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.
Files changed (49) hide show
  1. package/README.md +181 -16
  2. package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
  3. package/examples/riverbend-wellness/pages/index.md +1 -17
  4. package/package.json +2 -2
  5. package/src/api.js +123 -1
  6. package/src/authors-contract.js +170 -0
  7. package/src/cli.js +355 -30
  8. package/src/constants.js +9 -0
  9. package/src/content/components.js +64 -6
  10. package/src/content/free-form-sections.js +21 -4
  11. package/src/content/link-warnings.js +109 -0
  12. package/src/content/markdown.js +161 -26
  13. package/src/content/tiptap-prosemirror.ts +66 -8
  14. package/src/content/validate-document.js +30 -6
  15. package/src/content/vocabulary.js +26 -0
  16. package/src/errors.js +22 -0
  17. package/src/espalier-shared.js +11 -0
  18. package/src/fixture-init.js +55 -3
  19. package/src/font-catalog.js +87 -0
  20. package/src/footer-contract.js +1 -1
  21. package/src/output.js +19 -1
  22. package/src/page-metadata.js +92 -0
  23. package/src/presentation-reference.js +21 -7
  24. package/src/problems.js +116 -0
  25. package/src/reference-help.js +157 -41
  26. package/src/session.js +13 -0
  27. package/src/staging-check.js +62 -0
  28. package/src/theme-proposals.js +393 -0
  29. package/src/theme-validation.js +31 -10
  30. package/src/typed-page-reference.js +97 -21
  31. package/src/typed-pages.js +196 -13
  32. package/src/verbs/approve.js +63 -14
  33. package/src/verbs/authors.js +167 -0
  34. package/src/verbs/deploy.js +5 -28
  35. package/src/verbs/index.js +22 -0
  36. package/src/verbs/media-upload.js +127 -30
  37. package/src/verbs/nav-push.js +7 -0
  38. package/src/verbs/pages-meta-set.js +303 -0
  39. package/src/verbs/pages-push.js +814 -224
  40. package/src/verbs/places.js +61 -0
  41. package/src/verbs/plan.js +595 -0
  42. package/src/verbs/pull.js +255 -20
  43. package/src/verbs/redirects-check.js +4 -10
  44. package/src/verbs/theme-push.js +68 -36
  45. package/src/verbs/validate.js +309 -217
  46. package/src/video-prepare.js +48 -12
  47. package/src/workspace-check.js +199 -0
  48. package/src/workspace-ledger.js +173 -0
  49. 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
+ }