@taprootio/site-authoring 0.15.0 → 0.18.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.
@@ -4,6 +4,7 @@ import {
4
4
  PLACE_REVIEW_RATINGS,
5
5
  TEMPLATE_ALBUM,
6
6
  TEMPLATE_ARTICLE,
7
+ TEMPLATE_GENERATED,
7
8
  TEMPLATE_PLACE_REVIEW,
8
9
  TEMPLATE_RECIPE,
9
10
  } from "./typed-pages.js";
@@ -35,6 +36,14 @@ const FIELD_HELP = Object.freeze({
35
36
  required: true,
36
37
  description: "Selects the template. A page's template never changes after it is created.",
37
38
  },
39
+ author: {
40
+ type: "string",
41
+ required: false,
42
+ description:
43
+ "Credits the page to a site author by handle (jane-doe) or to a site member who can create pages by email "
44
+ + "address. Pages are authorless unless you name one, and an author already on the page is never replaced. "
45
+ + "'authors list' shows who can be named; 'authors add' creates a site author without an account.",
46
+ },
38
47
  displayDate: {
39
48
  type: "YYYY-MM-DD",
40
49
  required: false,
@@ -67,7 +76,9 @@ const FIELD_HELP = Object.freeze({
67
76
  required: true,
68
77
  description:
69
78
  "The Taproot place being reviewed. The site fills in its name, address and coordinates from this record. Find "
70
- + "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'.",
79
+ + "it with 'places search <name and city>' and 'places select <googlePlaceId> <sessionToken>'. The site publishes the "
80
+ + "place's category in the byline and files the review under /places/<category>; change it for this site with "
81
+ + "'places category set <placeId> <category>'.",
71
82
  },
72
83
  place: {
73
84
  type: "UUID",
@@ -270,48 +281,106 @@ const TYPE_DETAILS = Object.freeze({
270
281
  "body: ProseMirror document (required, not empty)",
271
282
  ],
272
283
  },
284
+ [TEMPLATE_GENERATED]: {
285
+ displayName: "Generated page",
286
+ summary:
287
+ "A listing Taproot generates from the site's content: a tag, the tags index, a published year or month, a place "
288
+ + "archive, or a folder. You cannot create one; you customize its title, breadcrumb, description and introduction.",
289
+ documentExample: documentExample(
290
+ TEMPLATE_GENERATED,
291
+ {},
292
+ {
293
+ kind: "GENERATED_PAGE_KIND_TAG",
294
+ tagId: "0198a3f2-7c4e-4a10-9b2d-3f6e5d4c3b2a",
295
+ year: 0,
296
+ month: 0,
297
+ countryCode: "",
298
+ regionCode: "",
299
+ citySlug: "",
300
+ categorySlug: "",
301
+ customTitle: "Trail notes",
302
+ breadcrumbTitle: "",
303
+ customDescription: "Field notes from the trails I walk most often.",
304
+ introductionBody: PARAGRAPH("Everything I have written about walking."),
305
+ },
306
+ ),
307
+ dataFields: [
308
+ "kind and the identity fields (tagId, year, month, countryCode, regionCode, citySlug, categorySlug): what the "
309
+ + "page is, written by pull. Leave them as pulled; a push that changes them is refused (pages.generated_identity)",
310
+ "customTitle: string; empty uses the generated title (the page's reader-facing title)",
311
+ "breadcrumbTitle: string; empty uses the generated label (used on pages beneath this one)",
312
+ "customDescription: string, at most 1000 characters, about 160 shown in search results; empty uses the "
313
+ + "introduction's first paragraph, else the generated description",
314
+ "introductionBody: ProseMirror document (optional), shown above the generated listing",
315
+ ],
316
+ note:
317
+ "The manifest's title and description for a generated page are what the site reports, recorded for reference; "
318
+ + "edit customTitle and customDescription here (or with 'pages meta set'), not the manifest. "
319
+ + "pages push updates the page and refuses to create one (pages.generated_create), move it (pages.generated_move) "
320
+ + "or change its identity (pages.generated_identity). The site derives the page's title and description from "
321
+ + "customTitle, customDescription and introductionBody, so what a push sends for them is ignored. A source the site "
322
+ + "no longer has a page for is reported and skipped by a whole-workspace push.",
323
+ },
273
324
  });
274
325
 
275
326
  export const TYPED_PAGE_TYPES = Object.freeze(Object.keys(TYPE_DETAILS));
327
+ /** The templates a Markdown source can declare: every typed page but the update-only generated one. */
328
+ export const MARKDOWN_PAGE_TYPES = Object.freeze(TYPED_PAGE_TYPES.filter((type) => type !== TEMPLATE_GENERATED));
276
329
 
277
330
  function typedReference(type) {
278
331
  const details = TYPE_DETAILS[type];
332
+ const generated = type === TEMPLATE_GENERATED;
279
333
  return Object.freeze({
280
334
  type,
281
335
  displayName: details.displayName,
282
336
  summary: details.summary,
283
337
  workspace: Object.freeze({
284
338
  directory: `${PAGES_DIRECTORY}/`,
285
- sourceRule:
286
- `One source file per page, exactly as for a free-form page: see '${CLI_BINARY_NAME} help page free-form'. `
287
- + "The template is declared in the source (front matter 'template', or the .pm.json 'template' field) and is "
288
- + "immutable after creation: pages push refuses a source whose template differs from the live page's "
289
- + "(pages.template_immutable).",
290
- pull:
291
- "pull writes each page of this template as a .pm.json document, byte-stable across pulls. A page you authored as "
292
- + "Markdown keeps its Markdown source, and a change made on the site since the last pull is reported as a conflict.",
339
+ sourceRule: generated
340
+ ? `One source file per page, exactly as for a free-form page: see '${CLI_BINARY_NAME} help page free-form'. `
341
+ + "A generated page exists only because the site's content produces it, so its source exists only after "
342
+ + "'pull' and is never created by hand."
343
+ : `One source file per page, exactly as for a free-form page: see '${CLI_BINARY_NAME} help page free-form'. `
344
+ + "The template is declared in the source (front matter 'template', or the .pm.json 'template' field) and is "
345
+ + "immutable after creation: pages push refuses a source whose template differs from the live page's "
346
+ + "(pages.template_immutable).",
347
+ pull: generated
348
+ ? "pull writes each generated page as a .pm.json document, byte-stable across pulls, and keeps your edits to it "
349
+ + "when the site's copy has not changed. A change made on the site since the last pull is reported as a conflict."
350
+ : "pull writes each page of this template as a .pm.json document, byte-stable across pulls. A page you authored as "
351
+ + "Markdown keeps its Markdown source, and a change made on the site since the last pull is reported as a conflict.",
293
352
  formats: Object.freeze([
294
- Object.freeze({
353
+ ...(generated ? [] : [Object.freeze({
295
354
  extension: ".md",
296
355
  purpose: "Author the page as Markdown with front matter.",
297
356
  metadata: Object.freeze(
298
357
  frontMatterKeysFor(type).map((name) => Object.freeze({ name, ...FIELD_HELP[name] })),
299
358
  ),
300
- }),
359
+ })]),
301
360
  Object.freeze({
302
361
  extension: ".pm.json",
303
- purpose:
304
- "The pulled form: { template, displayDate?, coverImageId?, data }. Title, path and description come from the manifest.",
362
+ purpose: generated
363
+ ? "The pulled form, and the only one: { template, data }. Path comes from the manifest; title and description "
364
+ + "are the custom ones in data."
365
+ : "The pulled form: { template, displayDate?, coverImageId?, data }. Title, path and description come from the manifest.",
305
366
  dataFields: Object.freeze(details.dataFields),
306
367
  }),
307
368
  ]),
308
369
  }),
309
- markdown: Object.freeze({ body: details.body, example: details.markdownExample }),
370
+ ...(generated ? {} : { markdown: Object.freeze({ body: details.body, example: details.markdownExample }) }),
310
371
  document: Object.freeze({ example: details.documentExample, ...(details.note ? { note: details.note } : {}) }),
311
372
  workflow: Object.freeze([
312
- Object.freeze({ command: `${CLI_BINARY_NAME} media upload`, result: "Upload cover and album images first." }),
373
+ ...(generated
374
+ ? [Object.freeze({
375
+ command: `${CLI_BINARY_NAME} pull, then pages meta set <path> --title/--description`,
376
+ result: "Pull the page, then set its custom title or description (or edit the .pm.json).",
377
+ })]
378
+ : [Object.freeze({ command: `${CLI_BINARY_NAME} media upload`, result: "Upload cover and album images first." })]),
313
379
  Object.freeze({ command: `${CLI_BINARY_NAME} validate`, result: "Check the workspace before sending anything." }),
314
- Object.freeze({ command: `${CLI_BINARY_NAME} pages push`, result: "Create or update an unapproved draft." }),
380
+ Object.freeze({
381
+ command: `${CLI_BINARY_NAME} pages push`,
382
+ result: generated ? "Update the generated page's unapproved draft." : "Create or update an unapproved draft.",
383
+ }),
315
384
  Object.freeze({
316
385
  command: `${CLI_BINARY_NAME} approve, deploy --staging, deploy --production`,
317
386
  result: "Stage, review and promote, as for any page.",
@@ -335,9 +404,10 @@ function formatField(field) {
335
404
  export function formatTypedPageReference(page) {
336
405
  const markdown = page.workspace.formats.find((format) => format.extension === ".md");
337
406
  const json = page.workspace.formats.find((format) => format.extension === ".pm.json");
338
- return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n pull ${page.workspace.pull}\n\nMarkdown front matter:\n${
339
- markdown.metadata.map(formatField).join("\n")
340
- }\n\nMarkdown body: ${page.markdown.body}\n\nMarkdown example:\n${page.markdown.example}\n.pm.json (${json.purpose})\n data:\n${
407
+ const markdownSection = markdown === undefined
408
+ ? ""
409
+ : `Markdown front matter:\n${markdown.metadata.map(formatField).join("\n")}\n\nMarkdown body: ${page.markdown.body}\n\nMarkdown example:\n${page.markdown.example}\n`;
410
+ return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n pull ${page.workspace.pull}\n\n${markdownSection}.pm.json (${json.purpose})\n data:\n${
341
411
  json.dataFields.map((field) => ` ${field}`).join("\n")
342
412
  }\n${page.document.note === undefined ? "" : ` ${page.document.note}\n`}\n.pm.json example:\n${
343
413
  JSON.stringify(page.document.example, null, 2)
@@ -1,6 +1,7 @@
1
1
  import { FREE_FORM_TEMPLATE_VERSION, TEMPLATE_TYPE_FREE_FORM } from "./api.js";
2
2
  import { isVideoEmbedId } from "./content/video-embed-url.js";
3
3
  import { SiteAuthoringError } from "./errors.js";
4
+ import { requireDescriptionLength } from "./page-metadata.js";
4
5
 
5
6
  /**
6
7
  * The authored page templates (TR00893).
@@ -29,6 +30,12 @@ export const TEMPLATE_ARTICLE = "article";
29
30
  export const TEMPLATE_RECIPE = "recipe";
30
31
  export const TEMPLATE_ALBUM = "album";
31
32
  export const TEMPLATE_PLACE_REVIEW = "place-review";
33
+ /**
34
+ * A page Taproot generates (a tag, a month, a folder). It is update-only: the
35
+ * owner's title, breadcrumb, description and introduction are authored, and
36
+ * pages push refuses to create one or to change what it is (TR01195).
37
+ */
38
+ export const TEMPLATE_GENERATED = "generated";
32
39
 
33
40
  /** Wire identity and the `PageTemplate` member that carries each template's data. */
34
41
  export const PAGE_TEMPLATES = Object.freeze({
@@ -37,12 +44,13 @@ export const PAGE_TEMPLATES = Object.freeze({
37
44
  [TEMPLATE_RECIPE]: Object.freeze({ wireType: "TEMPLATE_TYPE_RECIPE", dataKey: "recipeData" }),
38
45
  [TEMPLATE_ALBUM]: Object.freeze({ wireType: "TEMPLATE_TYPE_ALBUM", dataKey: "albumData" }),
39
46
  [TEMPLATE_PLACE_REVIEW]: Object.freeze({ wireType: "TEMPLATE_TYPE_PLACE_REVIEW", dataKey: "placeReviewData" }),
47
+ [TEMPLATE_GENERATED]: Object.freeze({ wireType: "TEMPLATE_TYPE_GENERATED", dataKey: "generatedPageData" }),
40
48
  });
41
49
 
42
50
  export const PAGE_TEMPLATE_NAMES = Object.freeze(Object.keys(PAGE_TEMPLATES));
43
51
 
44
52
  /** Front-matter fields every template accepts. */
45
- export const SHARED_FRONT_MATTER_KEYS = Object.freeze(["title", "path", "description", "template"]);
53
+ export const SHARED_FRONT_MATTER_KEYS = Object.freeze(["title", "path", "description", "template", "author"]);
46
54
 
47
55
  /**
48
56
  * The page-level fields the other templates add. A free-form page's source is
@@ -67,6 +75,8 @@ const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
67
75
  ]),
68
76
  [TEMPLATE_ALBUM]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "seamless", "borderWidth", "place"]),
69
77
  [TEMPLATE_PLACE_REVIEW]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "placeId", "rating"]),
78
+ // Generated pages have no Markdown form.
79
+ [TEMPLATE_GENERATED]: Object.freeze([]),
70
80
  });
71
81
 
72
82
  export const FRONT_MATTER_KEYS = Object.freeze(
@@ -96,6 +106,52 @@ const DISPLAY_DATE = /^\d{4}-\d{2}-\d{2}$/u;
96
106
  const ALBUM_IMAGES_HEADING = "images";
97
107
  const RECIPE_HEADINGS = Object.freeze(["ingredients", "instructions"]);
98
108
 
109
+ /** The generated page kinds the site reports, as the API names them (never the unknown kind). */
110
+ export const GENERATED_PAGE_KINDS = Object.freeze([
111
+ "GENERATED_PAGE_KIND_TAG",
112
+ "GENERATED_PAGE_KIND_TAGS_INDEX",
113
+ "GENERATED_PAGE_KIND_PUBLISHED_INDEX",
114
+ "GENERATED_PAGE_KIND_PUBLISHED_YEAR",
115
+ "GENERATED_PAGE_KIND_PUBLISHED_MONTH",
116
+ "GENERATED_PAGE_KIND_PLACES_INDEX",
117
+ "GENERATED_PAGE_KIND_PLACES_COUNTRY",
118
+ "GENERATED_PAGE_KIND_PLACES_STATE",
119
+ "GENERATED_PAGE_KIND_PLACES_CITY",
120
+ "GENERATED_PAGE_KIND_PLACES_CATEGORY",
121
+ "GENERATED_PAGE_KIND_FOLDER_INDEX",
122
+ ]);
123
+ /** What a generated page is: system-managed, so a push must send it back unchanged. */
124
+ export const GENERATED_IDENTITY_KEYS = Object.freeze([
125
+ "kind",
126
+ "tagId",
127
+ "year",
128
+ "month",
129
+ "countryCode",
130
+ "regionCode",
131
+ "citySlug",
132
+ "categorySlug",
133
+ ]);
134
+ const GENERATED_KEYS = [
135
+ ...GENERATED_IDENTITY_KEYS,
136
+ "customTitle",
137
+ "breadcrumbTitle",
138
+ "customDescription",
139
+ "introductionBody",
140
+ ];
141
+ const GENERATED_TEXT_KEYS = [
142
+ "tagId",
143
+ "countryCode",
144
+ "regionCode",
145
+ "citySlug",
146
+ "categorySlug",
147
+ "customTitle",
148
+ "breadcrumbTitle",
149
+ "customDescription",
150
+ ];
151
+ const GENERATED_MAXIMUM_YEAR = 9999;
152
+ const GENERATED_MAXIMUM_MONTH = 12;
153
+ const EMPTY_DOCUMENT = Object.freeze({ type: "doc", content: Object.freeze([]) });
154
+
99
155
  function fail(code, message, field) {
100
156
  return new SiteAuthoringError(code, message, { field });
101
157
  }
@@ -116,6 +172,32 @@ export function isAuthorableTemplateType(wireType) {
116
172
  return templateNameForWireType(wireType) !== undefined;
117
173
  }
118
174
 
175
+ /** Whether a wire `templateType` is a generated page. */
176
+ export function isGeneratedTemplateType(wireType) {
177
+ return wireType === PAGE_TEMPLATES[TEMPLATE_GENERATED].wireType;
178
+ }
179
+
180
+ /** The system-managed fields of a generated page's `data`, which the manifest records and a push must not change. */
181
+ export function generatedIdentity(data) {
182
+ return Object.fromEntries(GENERATED_IDENTITY_KEYS.map((key) => [key, data[key]]));
183
+ }
184
+
185
+ /** The names of the identity fields that differ, for a message. */
186
+ export function changedGeneratedIdentity(recorded, data) {
187
+ return GENERATED_IDENTITY_KEYS.filter((key) => recorded[key] !== data[key]);
188
+ }
189
+
190
+ /** The title a generated page is sent with: the owner's, else the one the site last reported. */
191
+ export function generatedPageTitle(data, reportedTitle) {
192
+ // Trimmed as Taproot trims it, or a padded title would never match the key a pull recorded.
193
+ return data.customTitle.trim() || reportedTitle;
194
+ }
195
+
196
+ /** The description a generated page is sent with: the owner's, else the one the site last reported. */
197
+ export function generatedPageDescription(data, reportedDescription) {
198
+ return data.customDescription.trim() === "" ? reportedDescription : data.customDescription.trim();
199
+ }
200
+
119
201
  /** The template a canonical workspace document represents. */
120
202
  export function documentTemplate(document_) {
121
203
  return isPlainObject(document_) && typeof document_.template === "string" && document_.type === undefined
@@ -262,6 +344,22 @@ function canonicalIngredient(value) {
262
344
  const DATA_BUILDERS = Object.freeze({
263
345
  [TEMPLATE_ARTICLE]: (raw) => definedEntries([["body", raw.body], ["place", raw.place]]),
264
346
  [TEMPLATE_PLACE_REVIEW]: (raw) => ({ placeId: raw.placeId, rating: raw.rating, body: raw.body }),
347
+ // Defaults are written out, so a page with no custom title reads the same on every pull.
348
+ [TEMPLATE_GENERATED]: (raw) =>
349
+ definedEntries([
350
+ ["kind", raw.kind],
351
+ ["tagId", raw.tagId ?? ""],
352
+ ["year", raw.year ?? 0],
353
+ ["month", raw.month ?? 0],
354
+ ["countryCode", raw.countryCode ?? ""],
355
+ ["regionCode", raw.regionCode ?? ""],
356
+ ["citySlug", raw.citySlug ?? ""],
357
+ ["categorySlug", raw.categorySlug ?? ""],
358
+ ["customTitle", raw.customTitle ?? ""],
359
+ ["breadcrumbTitle", raw.breadcrumbTitle ?? ""],
360
+ ["customDescription", raw.customDescription ?? ""],
361
+ ["introductionBody", raw.introductionBody],
362
+ ]),
265
363
  [TEMPLATE_ALBUM]: (raw) =>
266
364
  definedEntries([
267
365
  ["introductionBody", raw.introductionBody],
@@ -339,6 +437,30 @@ function canonicalSourceData(template, data, file) {
339
437
  body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }),
340
438
  });
341
439
  }
440
+ if (template === TEMPLATE_GENERATED) {
441
+ requireKeys(data, GENERATED_KEYS, file, path);
442
+ if (!GENERATED_PAGE_KINDS.includes(data.kind)) {
443
+ throw fail(
444
+ "pages.document_shape",
445
+ `'${file}' ${path}.kind must be one of ${GENERATED_PAGE_KINDS.join(", ")}.`,
446
+ file,
447
+ );
448
+ }
449
+ for (const key of GENERATED_TEXT_KEYS) {
450
+ if (data[key] !== undefined) requireString(data[key], file, `${path}.${key}`);
451
+ }
452
+ if (data.tagId !== undefined && data.tagId !== "") requireIdentifier(data.tagId, file, `${path}.tagId`);
453
+ if (data.year !== undefined) requireCount(data.year, file, `${path}.year`, GENERATED_MAXIMUM_YEAR);
454
+ if (data.month !== undefined) requireCount(data.month, file, `${path}.month`, GENERATED_MAXIMUM_MONTH);
455
+ // The API trims a description, so the length that counts is the trimmed one.
456
+ const customDescription = data.customDescription?.trim();
457
+ requireDescriptionLength(customDescription, `${file}:${path}.customDescription`);
458
+ return DATA_BUILDERS[template]({
459
+ ...data,
460
+ customDescription,
461
+ introductionBody: OPTIONAL_INTRODUCTION(data.introductionBody, file, `${path}.introductionBody`),
462
+ });
463
+ }
342
464
  if (template === TEMPLATE_ALBUM) {
343
465
  requireKeys(data, ALBUM_KEYS, file, path);
344
466
  const images = requireArray(data.images, file, `${path}.images`);
@@ -442,6 +564,14 @@ export function typedDocumentFromJson(parsed, file) {
442
564
  file,
443
565
  );
444
566
  }
567
+ if (template === TEMPLATE_GENERATED && (parsed.displayDate !== undefined || parsed.coverImageId !== undefined)) {
568
+ throw fail(
569
+ "pages.document_shape",
570
+ `'${file}' is a generated page, which has no displayDate or coverImageId of its own: its document is `
571
+ + "{ template, data }.",
572
+ file,
573
+ );
574
+ }
445
575
  const displayDate = parsed.displayDate === undefined ? undefined : requireDisplayDate(parsed.displayDate, file);
446
576
  let coverImageId;
447
577
  if (parsed.coverImageId !== undefined) {
@@ -482,6 +612,17 @@ const PAGE_PROJECTIONS = Object.freeze({
482
612
  if (body === undefined || rating === undefined || typeof raw.placeId !== "string") return undefined;
483
613
  return DATA_BUILDERS[TEMPLATE_PLACE_REVIEW]({ placeId: raw.placeId, rating, body });
484
614
  },
615
+ // A kind this package does not know cannot be pushed back safely, so it is not readable here.
616
+ [TEMPLATE_GENERATED]: (raw) =>
617
+ GENERATED_PAGE_KINDS.includes(raw.kind)
618
+ ? DATA_BUILDERS[TEMPLATE_GENERATED]({
619
+ kind: raw.kind,
620
+ ...Object.fromEntries(GENERATED_TEXT_KEYS.map((key) => [key, typeof raw[key] === "string" ? raw[key] : ""])),
621
+ year: Number.isSafeInteger(raw.year) ? raw.year : 0,
622
+ month: Number.isSafeInteger(raw.month) ? raw.month : 0,
623
+ introductionBody: presentIntroduction(raw.introductionBody),
624
+ })
625
+ : undefined,
485
626
  [TEMPLATE_ALBUM]: (raw) =>
486
627
  DATA_BUILDERS[TEMPLATE_ALBUM]({
487
628
  introductionBody: presentIntroduction(raw.introductionBody),
@@ -524,9 +665,15 @@ export function workspaceDocumentFromPage(page, listedTemplateType) {
524
665
  if (name === TEMPLATE_FREE_FORM) return isPlainObject(raw.body) ? raw.body : undefined;
525
666
  const data = PAGE_PROJECTIONS[name](raw);
526
667
  if (data === undefined) return undefined;
668
+ // A generated page's date and cover are not part of its source: a push leaves them as the site holds them.
669
+ const generated = name === TEMPLATE_GENERATED;
527
670
  return typedDocument(name, {
528
- displayDate: typeof page.displayDate === "string" && page.displayDate !== "" ? page.displayDate : undefined,
529
- coverImageId: typeof page.coverImageId === "string" && page.coverImageId !== "" ? page.coverImageId : undefined,
671
+ displayDate: !generated && typeof page.displayDate === "string" && page.displayDate !== ""
672
+ ? page.displayDate
673
+ : undefined,
674
+ coverImageId: !generated && typeof page.coverImageId === "string" && page.coverImageId !== ""
675
+ ? page.coverImageId
676
+ : undefined,
530
677
  data,
531
678
  });
532
679
  }
@@ -558,7 +705,7 @@ export function contentDocuments(document_) {
558
705
  /** Whether a push sends `displayDate` for pages of this wire template type (every template but free-form). */
559
706
  export function sentDisplayDate(wireType) {
560
707
  const name = templateNameForWireType(wireType);
561
- return name !== undefined && name !== TEMPLATE_FREE_FORM;
708
+ return name !== undefined && name !== TEMPLATE_FREE_FORM && name !== TEMPLATE_GENERATED;
562
709
  }
563
710
 
564
711
  // The nodes the API accepts in an album or recipe introduction and in a recipe
@@ -742,6 +889,9 @@ export function wireTemplate(document_) {
742
889
  ? { body: document_ }
743
890
  : template === TEMPLATE_PLACE_REVIEW
744
891
  ? { ...document_.data, rating: PLACE_REVIEW_RATINGS[document_.data.rating] }
892
+ // The API reads a missing introduction as no document at all, so the empty one is sent.
893
+ : template === TEMPLATE_GENERATED
894
+ ? { ...document_.data, introductionBody: document_.data.introductionBody ?? EMPTY_DOCUMENT }
745
895
  : document_.data.place === undefined
746
896
  ? document_.data
747
897
  : { ...document_.data, place: { placeId: document_.data.place } };
@@ -960,6 +1110,16 @@ function parseFlag(fields, key, file) {
960
1110
  return value === "true";
961
1111
  }
962
1112
 
1113
+ /** Generated pages are edited in their .pm.json source only. */
1114
+ export function generatedMarkdownFault(file) {
1115
+ return fail(
1116
+ "pages.generated_markdown",
1117
+ `'${file}' declares the generated template, which has no Markdown form: a generated page is pulled as a `
1118
+ + ".pm.json document and edited there.",
1119
+ file,
1120
+ );
1121
+ }
1122
+
963
1123
  /**
964
1124
  * Front-matter fields the declared template does not accept.
965
1125
  *
@@ -988,6 +1148,7 @@ export function templateFrontMatterFault(template, fieldNames, file) {
988
1148
  * module stays pure.
989
1149
  */
990
1150
  export async function typedDocumentFromMarkdown({ template, fields, markdown, file, convert, resolveImage }) {
1151
+ if (template === TEMPLATE_GENERATED) throw generatedMarkdownFault(file);
991
1152
  const displayDate = fields.has("displayDate") ? requireDisplayDate(fields.get("displayDate"), file) : undefined;
992
1153
  let coverImageId;
993
1154
  if (fields.has("coverImage")) {
@@ -1,8 +1,14 @@
1
- import { listSitePages, PAGE_STATUS_DELETED, publishDrafts, withRefusalGuidance } from "../api.js";
1
+ import {
2
+ listSitePages,
3
+ PAGE_STATUS_APPROVED,
4
+ PAGE_STATUS_DELETED,
5
+ publishDrafts,
6
+ withRefusalGuidance,
7
+ } from "../api.js";
2
8
  import { VERB_APPROVE } from "../constants.js";
3
9
  import { SiteAuthoringError } from "../errors.js";
4
- import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
5
- import { normalizePagePath, readManifest, writeManifest } from "../workspace.js";
10
+ import { boundedByBytes, boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
11
+ import { displayPagePath, normalizePagePath, readManifest, writeManifest } from "../workspace.js";
6
12
 
7
13
  /**
8
14
  * `approve` — publish the site's drafts.
@@ -19,6 +25,8 @@ import { normalizePagePath, readManifest, writeManifest } from "../workspace.js"
19
25
  */
20
26
 
21
27
  const MAXIMUM_REPORTED = 200;
28
+ // Room for the unknown-path list inside the 64 KiB result, beside the envelope.
29
+ const ALTERNATIVES_BYTES = 16 * 1024;
22
30
  // `PublishDrafts` resolves the credential from the first page id in the batch,
23
31
  // so batches stay one-site and bounded rather than unbounded and clever.
24
32
  const MAXIMUM_BATCH = 100;
@@ -71,31 +79,71 @@ export async function approve(invocation) {
71
79
  { field: "pages" },
72
80
  );
73
81
  }
82
+ const pathOf = (summary) => normalizePagePath(summary.path) ?? summary.path;
74
83
  const candidates = pages.filter((summary) =>
75
84
  summary.status !== PAGE_STATUS_DELETED
76
85
  && summary.hasDraft
77
86
  && manifestByPageId.has(summary.pageId)
78
- && (requestedPaths === undefined || requestedPaths.has(normalizePagePath(summary.path) ?? summary.path)));
87
+ && (requestedPaths === undefined || requestedPaths.has(pathOf(summary))));
79
88
 
89
+ // A requested page with nothing to approve is reported, not refused, so
90
+ // one already-approved path does not stop the rest of the batch. A path
91
+ // that names no page this workspace tracks is still refused by name.
92
+ const skipped = [];
80
93
  if (requestedPaths !== undefined) {
81
- const matched = new Set(candidates.map((summary) => normalizePagePath(summary.path) ?? summary.path));
82
- const missing = [...requestedPaths].filter((value) => !matched.has(value)).sort();
83
- if (missing.length > 0) {
84
- // The homepage normalizes to the empty path, which the result
85
- // emitters drop as falsy; name it by its documented '/' spelling so
86
- // the stable error contract keeps a field for every unknown path.
94
+ const matched = new Set(candidates.map(pathOf));
95
+ const unknown = [];
96
+ for (const requested of [...requestedPaths].filter((value) => !matched.has(value)).sort()) {
97
+ const summary = pages.find((page) => page.status !== PAGE_STATUS_DELETED && pathOf(page) === requested);
98
+ if (summary === undefined || !manifestByPageId.has(summary.pageId)) {
99
+ unknown.push(displayPagePath(requested));
100
+ continue;
101
+ }
102
+ skipped.push({
103
+ pageId: summary.pageId,
104
+ path: displayPagePath(requested),
105
+ status: summary.status,
106
+ reason: summary.status === PAGE_STATUS_APPROVED ? "already_approved" : "no_pending_draft",
107
+ });
108
+ }
109
+ if (unknown.length > 0) {
110
+ // Every path is named on progress, since the message and the
111
+ // alternatives list are both bounded.
112
+ for (const value of unknown) onProgress(`No page this workspace tracks is at '${value}'.`);
113
+ const shown = unknown.slice(0, 5).map((value) => `'${value}'`).join(", ");
114
+ const listed = boundedByBytes(unknown.slice(0, 100), ALTERNATIVES_BYTES).items;
87
115
  throw new SiteAuthoringError(
88
116
  "approve.page_not_found",
89
- `No approvable draft was found for page path '${missing[0] || "/"}'.`,
90
- { field: missing[0] || "/" },
117
+ `${unknown.length} requested path(s) name no page this workspace tracks. The first ${listed.length} are `
118
+ + "in alternatives and every one is on progress output. Nothing was approved. Run 'taproot-site pull' if "
119
+ + `a page was created elsewhere. ${shown}${unknown.length > 5 ? ", …" : ""}`,
120
+ { field: unknown[0], alternatives: listed },
91
121
  );
92
122
  }
123
+ for (const item of skipped) {
124
+ onProgress(`${item.path} has no pending draft (${item.reason.replaceAll("_", " ")}); skipping it.`);
125
+ }
93
126
  }
127
+ const reportedSkipped = boundedList(skipped, MAXIMUM_REPORTED);
128
+ const skippedResult = skipped.length > 0
129
+ ? {
130
+ skipped: {
131
+ total: skipped.length,
132
+ items: reportedSkipped.items,
133
+ ...(reportedSkipped.truncated ? { itemsTruncated: true } : {}),
134
+ },
135
+ }
136
+ : {};
94
137
 
95
138
  if (candidates.length === 0) {
96
- onProgress("No page in this workspace is carrying a draft; there is nothing to approve.");
139
+ onProgress(
140
+ skipped.length > 0
141
+ ? "Nothing to approve: the requested page(s) have no pending draft."
142
+ : "No page in this workspace is carrying a draft; there is nothing to approve.",
143
+ );
97
144
  return successResult(VERB_APPROVE, siteId, {
98
145
  approved: { total: 0, items: [] },
146
+ ...skippedResult,
99
147
  stagedNotDeployed: true,
100
148
  nextStep: "deploy --staging",
101
149
  });
@@ -110,7 +158,7 @@ export async function approve(invocation) {
110
158
  const summaries = await publishDrafts(client, batch.map((summary) => summary.pageId));
111
159
  manifestDirty = true;
112
160
  for (const summary of summaries) {
113
- approved.push({ pageId: summary.pageId, path: summary.path, status: summary.status });
161
+ approved.push({ pageId: summary.pageId, path: displayPagePath(summary.path), status: summary.status });
114
162
  const entry = manifestByPageId.get(summary.pageId);
115
163
  if (entry !== undefined) {
116
164
  entry.status = summary.status;
@@ -131,6 +179,7 @@ export async function approve(invocation) {
131
179
  items: reported.items,
132
180
  ...(reported.truncated ? { itemsTruncated: true } : {}),
133
181
  },
182
+ ...skippedResult,
134
183
  // Said explicitly so a caller cannot read "approve" as "live".
135
184
  stagedNotDeployed: true,
136
185
  nextStep: "deploy --staging",