@taprootio/site-authoring 0.27.0 → 0.28.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 CHANGED
@@ -140,7 +140,7 @@ with no credential, no network, and no write: every page source (metadata,
140
140
  Markdown conversion, media references, content vocabulary, section contexts),
141
141
  page paths and templates against the pages the pull recorded, navigation,
142
142
  redirects, forms, and the complete theme, appearance, header, brand, and
143
- footer. Navigation to a generated page (a section, tag or place page) resolves
143
+ footer. Navigation to a generated page (a section or folder page) resolves
144
144
  against the pages the pull recorded. It reports every problem at once in
145
145
  `error.problems`, each with its file, code, and field, instead of one per run.
146
146
  Warnings never refuse: `linkWarnings` names a link whose address is only
@@ -506,12 +506,11 @@ listings only while that person can still be named: a former member's page, or
506
506
  a member who can no longer create pages, reports none, and a push that names
507
507
  someone else on it is refused as a conflict.
508
508
 
509
- Pages Taproot generates from the site's content — a tag, the tags index, a
510
- published year or month, a place archive, a folder — are update-only sources.
511
- `pull` writes each as `pages/<name>.pm.json` with `"template": "generated"`:
512
- `data` holds what the page is (`kind`, `tagId`, `year`, `month`, `countryCode`,
513
- `regionCode`, `citySlug`, `categorySlug`, which you leave as pulled) and the four
514
- things that are yours to author: `customTitle`, `breadcrumbTitle`,
509
+ Pages Taproot generates from the site's content — the published archive, a
510
+ folder — are update-only sources. `pull` writes each as
511
+ `pages/<name>.pm.json` with `"template": "generated"`: `data` holds what the
512
+ page is (`kind`, which you leave as pulled) and the four things
513
+ that are yours to author: `customTitle`, `breadcrumbTitle`,
515
514
  `customDescription` and `introductionBody`. An empty string means "use the
516
515
  generated default"; the description then falls back to the introduction's first
517
516
  paragraph, else the system default. The manifest keeps the title and description
@@ -525,8 +524,8 @@ move one (`pages.generated_move`) or change its kind or identity
525
524
  (`pages.generated_identity`, also when the manifest has no record of what the
526
525
  file was pulled as: run `pull`); there is no Markdown form
527
526
  (`pages.generated_markdown`). A generated page you did not edit never blocks a
528
- push of the whole workspace, even when Taproot moved it since the pull (a tag was
529
- renamed). If the site no longer has the page (its tag, month or folder is gone),
527
+ push of the whole workspace, even when Taproot changed it since the pull. If the
528
+ site no longer has the page (its folder is gone, or Taproot retired its kind),
530
529
  a whole-workspace push reports the file under `pages.staleGeneratedSources` and
531
530
  sends nothing for it, and `pull` says so; delete the file. Naming that path in
532
531
  `pages push <page-path>` is refused with `pages.generated_create`. A description over 160 characters is warned about,
@@ -857,8 +856,8 @@ taproot-site places category clear <placeId>
857
856
  `list` prints the closed list `set` accepts; a name outside it is refused, and
858
857
  capitalization is ignored. The category is the site's alone and applies to every
859
858
  review of that place on the site: the place itself and other sites do not
860
- change. Place-review bylines and the `/places/<category>` pages use it from the
861
- site's next deployment, including reviews nobody has edited. `clear` removes it.
859
+ change. Place-review bylines use it from the site's next deployment, including
860
+ reviews nobody has edited. `clear` removes it.
862
861
  Both need the same page-editing permission as `places select`.
863
862
 
864
863
  ### Pages generated in 0.18.0
@@ -892,7 +891,7 @@ once).
892
891
  page's title, description or author; `pull` keeps the edit until `pages push`
893
892
  sends it. Descriptions are warned about over 160 characters and refused over
894
893
  1000, here and by Taproot.
895
- - Generated pages (tags, archives, places, folders) pull as update-only sources
894
+ - Generated pages (the published archive, the map, folders) pull as update-only sources
896
895
  whose custom title, breadcrumb, description and introduction you can edit.
897
896
  - Pages are authorless by default. `authors list`, `authors add` and
898
897
  `authors.json` name a member who can create pages or a site author without a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.27.0",
3
+ "version": "0.28.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",
@@ -1,5 +1,36 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.28.0",
5
+ "note": "Taproot no longer generates /places, year and month, or tag pages, so a generated page's data drops those fields. One /published page lists every month, a folder page reaches search only with an introduction or three cards, and a review's location byline opens Google Maps.",
6
+ "behavior": [
7
+ "The /published/<year> and /published/<year>/<month> pages are no longer generated. One /published page carries a year and a month picker filled from JSON files the release writes (published/months.json and published/<year>/<month>.json), and a content byline links to /published?year=YYYY&month=M. Those file paths are refused as redirect sources with redirect.source_generated_file.",
8
+ "A generated page's data drops year and month beside the place fields: pull writes kind and the four authored fields, and a source that still carries year or month is refused by pages push with pages.document_shape as an unsupported field: delete the year and month lines from sources pulled before this release (every generated page pulled earlier carries year: 0 and month: 0).",
9
+ "The /places index, country, region, city and category pages are no longer generated, so a site has no generated page of those kinds to pull. On a published review, article or album with a place on a site with no map page, the location byline opens the place's Google Maps listing in a new tab; the review's category byline is plain text.",
10
+ "A generated page's data holds kind and the four authored fields. pull no longer writes countryCode, regionCode, citySlug or categorySlug, and a source that still carries one is refused by pages push with pages.document_shape as an unsupported field: delete the four lines from sources pulled before this release.",
11
+ "/places is no longer a reserved path, so a page may be authored there.",
12
+ "A workspace that pulled a /places page before this release keeps that file: pages push reports it as the source of a generated page Taproot no longer publishes and sends the rest, whether or not the site has synchronized the page away yet; naming its path is refused with pages.generated_create. Delete the file.",
13
+ "Taproot no longer generates tag pages (/tags and /tags/<slug>): tags stay page metadata with no public page, a workspace that pulled one keeps the file and pages push reports it as the source of a page Taproot no longer publishes, and /tags is no longer a reserved path. A generated page's data holds kind and the four authored fields; tagId leaves the identity, and a source that still carries it is refused by pages push with pages.document_shape as an unsupported field: delete the line from sources pulled before this release.",
14
+ "A folder index is a search destination when it has an introduction or renders at least three cards; otherwise it carries noindex and leaves the sitemap. Write the page's introductionBody in its pulled .pm.json source to make a small folder one.",
15
+ "Taproot generates a /map page for a site with a public post that has a location. It is a new generated kind in pulls, /map and every path under it are reserved, and /map/places.json cannot be a redirect source.",
16
+ "The help reference version is 47."
17
+ ],
18
+ "help": [
19
+ "pages",
20
+ "page"
21
+ ]
22
+ },
23
+ {
24
+ "version": "0.27.1",
25
+ "note": "The site checks refuse a page or redirect source named like a search-engine key file, which the platform now publishes at the site root.",
26
+ "behavior": [
27
+ "A redirect source whose first segment is 32 lowercase hexadecimal characters followed by .txt (any capitals, with or without a path beneath it) is refused with redirect.source_generated_file, like sitemap.xml and robots.txt. Taproot writes a file with that name at the root of a site that tells search engines about its releases, so nothing else can sit there. No command changed.",
28
+ "The help reference version is 45."
29
+ ],
30
+ "help": [
31
+ "redirects"
32
+ ]
33
+ },
3
34
  {
4
35
  "version": "0.27.0",
5
36
  "note": "Embedded forms inherit configurable background, border and shadow defaults from the site, with per-form overrides.",
package/src/cli.js CHANGED
@@ -139,7 +139,7 @@ const VERBS = Object.freeze([
139
139
  + "appearance, header, brand, footer, navigation, redirects, and forms are checked with the validators the "
140
140
  + "pushes use, and every problem is reported at once in error.problems (each with its file, code, and field) "
141
141
  + "rather than one per run. A pulled workspace is checked offline against the pages (generated ones included, "
142
- + "so navigation to a section or tag page resolves), images, and baselines its manifests recorded at the pull. "
142
+ + "so navigation to a folder index resolves), images, and baselines its manifests recorded at the pull. "
143
143
  + "A link whose address is only http(s):// plus its own text (left by older editor autolinking) is reported in "
144
144
  + "linkWarnings without refusing; pages push reports the same for the pages it sends. "
145
145
  + "The site checks that can run offline are reported in findings (a rule id, severity, file, and the value "
@@ -287,7 +287,7 @@ const VERBS = Object.freeze([
287
287
  + "and pull again. A locally edited '.pm.json' is kept rather than overwritten for the same reason. "
288
288
  + "First revisions adopted without a completed body comparison are counted in pages.revisionsRecordedWithoutBodyComparison "
289
289
  + "and announced on the human channel. New downloads and compared bodies are excluded. "
290
- + "A page Taproot generates (a tag, an archive, a place, a folder) is pulled as an update-only '.pm.json' "
290
+ + "A page Taproot generates (the published archive, the map or a folder) is pulled as an update-only '.pm.json' "
291
291
  + "(template 'generated'): you edit its custom title, breadcrumb title, custom description and introduction, "
292
292
  + "and the manifest keeps the title and description the site reports. "
293
293
  + "A form a page places is written back by its key, the name 'forms/<key>.json' uses, though the site stores "
@@ -445,7 +445,7 @@ const VERBS = Object.freeze([
445
445
  + "'places category list', which prints the closed list; a name outside it is refused, and capitalization is "
446
446
  + "ignored. Use it when Google's own category for a place is not the one this site wants, such as a park filed "
447
447
  + "under Other. The category is this site's alone and applies to every review of that place here: the place "
448
- + "itself and other sites do not change. Place-review bylines and the /places/<category> pages use it from the "
448
+ + "itself and other sites do not change. Place-review bylines use it from the "
449
449
  + "next deployment, which a changed category alone is enough to start. Undo it with 'places category clear'.",
450
450
  },
451
451
  {
@@ -95,7 +95,7 @@ import {
95
95
 
96
96
  export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
97
97
 
98
- export const REFERENCE_VERSION = 44;
98
+ export const REFERENCE_VERSION = 47;
99
99
 
100
100
  // The date-limit examples `help forms` prints; tests run them through the form validator.
101
101
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -755,7 +755,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
755
755
  + "those away, so '/x/../visit' would be checked as itself and served as '/visit' — past the occupancy, "
756
756
  + "chain, and loop rules that were asked about a different path. Write the resolved path instead.",
757
757
  "origin is reported by the site and ignored on push: 'authored' is what you declared, 'path_history' is "
758
- + "what a page or tag rename recorded. Pull the map, edit it, push the whole thing back — an entry you "
758
+ + "what a page rename recorded. Pull the map, edit it, push the whole thing back — an entry you "
759
759
  + "did not change keeps its origin.",
760
760
  "Refused, offline and again at the site, each naming the entry index: a chain (a target that is itself a "
761
761
  + "source in the same map); a loop (an entry targeting itself); a duplicate path; a gone entry carrying a "
@@ -786,7 +786,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
786
786
  + "check does not see may belong to a release the edge has not switched to yet; deploy --staging waits for "
787
787
  + "staging to serve the new release and reports stagingServed.",
788
788
  "A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
789
- + "'/sitemap.xml', '/robots.txt', '/favicon.ico', or a path under the sitemap or robots names. Nothing under "
789
+ + "'/sitemap.xml', '/robots.txt', '/favicon.ico', the published archive's data files ('/published/months.json' "
790
+ + "and '/published/<year>/<month>.json'), the map page's data file ('/map/places.json'), a path under any of those names, or the "
791
+ + "search-engine key file Taproot publishes when a site tells search engines about its releases (32 "
792
+ + "lowercase hexadecimal characters followed by '.txt', in any capitals). Nothing under "
790
793
  + "'/taproot/<version>/' (for example '/taproot/5.0.62/manifest.json') can be a source either: the platform "
791
794
  + "serves the published-site runtime there before any redirect. The platform "
792
795
  + "already redirects every '…/index.html' address to its directory, so no entry is needed for one. A source "
@@ -868,8 +871,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
868
871
  + "refused as forms.page_not_found, so push the page first. It need not be published to save the form, but "
869
872
  + "a deploy whose release does not publish that page is refused, naming the form and the page. The path in "
870
873
  + "each release is the one the page has in that release, so the published form never asks the site where "
871
- + "to go. The page must be one you created, the home page included: a generated page (folder, tag or "
872
- + "archive index), the 404 page, a legal page (help page legal), a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
874
+ + "to go. The page must be one you created, the home page included: a generated page (a folder index or "
875
+ + "the published archive), the 404 page, a legal page (help page legal), a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
873
876
  + "the 'form' component takes only formKey.",
874
877
  "A form's contact address is the value of one email field, stored with each submission. That is what lets the "
875
878
  + "owner find and delete every row for a visitor who asks. With exactly one email field, that field is the "
@@ -280,6 +280,15 @@ export function normalizeRedirectPath(value) {
280
280
  return trimmed === "" ? "/" : trimmed;
281
281
  }
282
282
  const GENERATED_ROOT_FILES = ["404.html", "sitemap.xml", "robots.txt", "favicon.ico"];
283
+ // The IndexNow key file (TR01304) is named by a pattern, so it cannot join the list above.
284
+ const INDEX_NOW_KEY_FILE = /^[a-f0-9]{32}\.txt$/u;
285
+ // The /published archive's data files (TR01309): months.json and yyyy/mm.json, with anything beneath either;
286
+ // a marker at one of those names fails every publish in the generator's occupancy check. The API mirror is
287
+ // SiteRedirectMap.IsPublishedArchiveDataPath.
288
+ const PUBLISHED_ARCHIVE_ROOT = "published";
289
+ const ARCHIVE_MONTHS_FILE = "months.json";
290
+ const ARCHIVE_YEAR_SEGMENT = /^\d{4}$/u;
291
+ const ARCHIVE_MONTH_FILE = /^\d{2}\.json$/iu;
283
292
  const INTEGRATION_DATA_ROOT = "integrations";
284
293
  const MAX_MARKER_SEGMENT_BYTES = 200;
285
294
  const MAX_OUTPUT_PATH_LENGTH = 1_024;
@@ -288,6 +297,18 @@ function isWindowsDeviceSegment(segment) {
288
297
  const stem = segment.split(".", 1)[0].toLowerCase();
289
298
  return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
290
299
  }
300
+ function isPublishedArchiveDataPath(segments) {
301
+ if (segments.length < 2 || segments[0].toLowerCase() !== PUBLISHED_ARCHIVE_ROOT)
302
+ return false;
303
+ if (segments[1].toLowerCase() === ARCHIVE_MONTHS_FILE)
304
+ return true;
305
+ return segments.length >= 3 && ARCHIVE_YEAR_SEGMENT.test(segments[1]) && ARCHIVE_MONTH_FILE.test(segments[2]);
306
+ }
307
+ // The /map page's data file (TR01321), with anything beneath it, for the same reason.
308
+ // The API mirrors this in SiteRedirectMap.IsPlaceMapDataPath.
309
+ function isPlaceMapDataPath(segments) {
310
+ return segments.length >= 2 && segments[0].toLowerCase() === "map" && segments[1].toLowerCase() === "places.json";
311
+ }
291
312
  function isStorable(decoded) {
292
313
  const output = `${decoded}/index.html`;
293
314
  if (output !== output.normalize("NFC") || output.length > MAX_OUTPUT_PATH_LENGTH || output.startsWith("/")
@@ -317,8 +338,11 @@ export function redirectSourceRule(edgePath) {
317
338
  return refuse(RULE_REDIRECT_SOURCE_INTEGRATION_DATA);
318
339
  if (isRuntimeMirrorPath(decoded))
319
340
  return refuse(RULE_REDIRECT_SOURCE_RUNTIME_MIRROR);
320
- if (GENERATED_ROOT_FILES.includes(first.toLowerCase()))
341
+ if (GENERATED_ROOT_FILES.includes(first.toLowerCase()) || INDEX_NOW_KEY_FILE.test(first.toLowerCase())
342
+ || isPublishedArchiveDataPath(segments)
343
+ || isPlaceMapDataPath(segments)) {
321
344
  return refuse(RULE_REDIRECT_SOURCE_GENERATED_FILE);
345
+ }
322
346
  if (!isStorable(decoded))
323
347
  return refuse(RULE_REDIRECT_SOURCE_NOT_STORABLE);
324
348
  return undefined;
@@ -77,7 +77,7 @@ const FIELD_HELP = Object.freeze({
77
77
  description:
78
78
  "The Taproot place being reviewed. The site fills in its name, address and coordinates from this record. Find "
79
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 "
80
+ + "place's category in the byline; change it for this site with "
81
81
  + "'places category set <placeId> <category>'.",
82
82
  },
83
83
  place: {
@@ -263,7 +263,7 @@ const TYPE_DETAILS = Object.freeze({
263
263
  },
264
264
  [TEMPLATE_PLACE_REVIEW]: {
265
265
  displayName: "Place review",
266
- summary: "A review of a place with a three-step rating, listed on the site's places archive.",
266
+ summary: "A review of a place with a three-step rating; its address card opens the place on Google Maps, and its location byline opens the site's map page when it has one.",
267
267
  body: "Everything after the front matter is the review body.",
268
268
  markdownExample: PLACE_REVIEW_EXAMPLE,
269
269
  documentExample: documentExample(
@@ -284,20 +284,14 @@ const TYPE_DETAILS = Object.freeze({
284
284
  [TEMPLATE_GENERATED]: {
285
285
  displayName: "Generated page",
286
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.",
287
+ "A listing Taproot generates from the site's content: the published archive, the map or a folder. You cannot create "
288
+ + "one; you customize its title, breadcrumb, description and introduction. A folder index is a search "
289
+ + "destination when it has an introduction or renders at least three cards; otherwise it carries noindex.",
289
290
  documentExample: documentExample(
290
291
  TEMPLATE_GENERATED,
291
292
  {},
292
293
  {
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: "",
294
+ kind: "GENERATED_PAGE_KIND_FOLDER_INDEX",
301
295
  customTitle: "Trail notes",
302
296
  breadcrumbTitle: "",
303
297
  customDescription: "Field notes from the trails I walk most often.",
@@ -305,8 +299,7 @@ const TYPE_DETAILS = Object.freeze({
305
299
  },
306
300
  ),
307
301
  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)",
302
+ "kind: what the page is, written by pull. Leave it as pulled; a push that changes it is refused (pages.generated_identity)",
310
303
  "customTitle: string; empty uses the generated title (the page's reader-facing title)",
311
304
  "breadcrumbTitle: string; empty uses the generated label (used on pages beneath this one)",
312
305
  "customDescription: string, at most 1000 characters, about 160 shown in search results; empty uses the "
@@ -32,7 +32,7 @@ export const TEMPLATE_ALBUM = "album";
32
32
  export const TEMPLATE_PLACE_REVIEW = "place-review";
33
33
  export const TEMPLATE_LEGAL = "legal";
34
34
  /**
35
- * A page Taproot generates (a tag, a month, a folder). It is update-only: the
35
+ * A page Taproot generates (the published archive, the map or a folder). It is update-only: the
36
36
  * owner's title, breadcrumb, description and introduction are authored, and
37
37
  * pages push refuses to create one or to change what it is (TR01195).
38
38
  */
@@ -111,9 +111,18 @@ const RECIPE_HEADINGS = Object.freeze(["ingredients", "instructions"]);
111
111
 
112
112
  /** The generated page kinds the site reports, as the API names them (never the unknown kind). */
113
113
  export const GENERATED_PAGE_KINDS = Object.freeze([
114
+ "GENERATED_PAGE_KIND_PUBLISHED_INDEX",
115
+ "GENERATED_PAGE_KIND_FOLDER_INDEX",
116
+ "GENERATED_PAGE_KIND_MAP_INDEX",
117
+ ]);
118
+ /**
119
+ * The kinds Taproot no longer generates (TR01308, TR01309, TR01310), which an older
120
+ * pull may have written. Named, so a typo or the unknown kind is refused as a bad
121
+ * document and is never mistaken for a page Taproot retired.
122
+ */
123
+ export const RETIRED_GENERATED_PAGE_KINDS = Object.freeze([
114
124
  "GENERATED_PAGE_KIND_TAG",
115
125
  "GENERATED_PAGE_KIND_TAGS_INDEX",
116
- "GENERATED_PAGE_KIND_PUBLISHED_INDEX",
117
126
  "GENERATED_PAGE_KIND_PUBLISHED_YEAR",
118
127
  "GENERATED_PAGE_KIND_PUBLISHED_MONTH",
119
128
  "GENERATED_PAGE_KIND_PLACES_INDEX",
@@ -121,18 +130,10 @@ export const GENERATED_PAGE_KINDS = Object.freeze([
121
130
  "GENERATED_PAGE_KIND_PLACES_STATE",
122
131
  "GENERATED_PAGE_KIND_PLACES_CITY",
123
132
  "GENERATED_PAGE_KIND_PLACES_CATEGORY",
124
- "GENERATED_PAGE_KIND_FOLDER_INDEX",
125
133
  ]);
126
134
  /** What a generated page is: system-managed, so a push must send it back unchanged. */
127
135
  export const GENERATED_IDENTITY_KEYS = Object.freeze([
128
136
  "kind",
129
- "tagId",
130
- "year",
131
- "month",
132
- "countryCode",
133
- "regionCode",
134
- "citySlug",
135
- "categorySlug",
136
137
  ]);
137
138
  const GENERATED_KEYS = [
138
139
  ...GENERATED_IDENTITY_KEYS,
@@ -142,17 +143,10 @@ const GENERATED_KEYS = [
142
143
  "introductionBody",
143
144
  ];
144
145
  const GENERATED_TEXT_KEYS = [
145
- "tagId",
146
- "countryCode",
147
- "regionCode",
148
- "citySlug",
149
- "categorySlug",
150
146
  "customTitle",
151
147
  "breadcrumbTitle",
152
148
  "customDescription",
153
149
  ];
154
- const GENERATED_MAXIMUM_YEAR = 9999;
155
- const GENERATED_MAXIMUM_MONTH = 12;
156
150
  const EMPTY_DOCUMENT = Object.freeze({ type: "doc", content: Object.freeze([]) });
157
151
 
158
152
  function fail(code, message, field) {
@@ -368,13 +362,6 @@ const DATA_BUILDERS = Object.freeze({
368
362
  [TEMPLATE_GENERATED]: (raw) =>
369
363
  definedEntries([
370
364
  ["kind", raw.kind],
371
- ["tagId", raw.tagId ?? ""],
372
- ["year", raw.year ?? 0],
373
- ["month", raw.month ?? 0],
374
- ["countryCode", raw.countryCode ?? ""],
375
- ["regionCode", raw.regionCode ?? ""],
376
- ["citySlug", raw.citySlug ?? ""],
377
- ["categorySlug", raw.categorySlug ?? ""],
378
365
  ["customTitle", raw.customTitle ?? ""],
379
366
  ["breadcrumbTitle", raw.breadcrumbTitle ?? ""],
380
367
  ["customDescription", raw.customDescription ?? ""],
@@ -479,9 +466,6 @@ function canonicalSourceData(template, data, file) {
479
466
  for (const key of GENERATED_TEXT_KEYS) {
480
467
  if (data[key] !== undefined) requireString(data[key], file, `${path}.${key}`);
481
468
  }
482
- if (data.tagId !== undefined && data.tagId !== "") requireIdentifier(data.tagId, file, `${path}.tagId`);
483
- if (data.year !== undefined) requireCount(data.year, file, `${path}.year`, GENERATED_MAXIMUM_YEAR);
484
- if (data.month !== undefined) requireCount(data.month, file, `${path}.month`, GENERATED_MAXIMUM_MONTH);
485
469
  // The API trims a description, so the length that counts is the trimmed one.
486
470
  const customDescription = data.customDescription?.trim();
487
471
  requireDescriptionLength(customDescription, `${file}:${path}.customDescription`);
@@ -658,8 +642,6 @@ const PAGE_PROJECTIONS = Object.freeze({
658
642
  ? DATA_BUILDERS[TEMPLATE_GENERATED]({
659
643
  kind: raw.kind,
660
644
  ...Object.fromEntries(GENERATED_TEXT_KEYS.map((key) => [key, typeof raw[key] === "string" ? raw[key] : ""])),
661
- year: Number.isSafeInteger(raw.year) ? raw.year : 0,
662
- month: Number.isSafeInteger(raw.month) ? raw.month : 0,
663
645
  introductionBody: presentIntroduction(raw.introductionBody),
664
646
  })
665
647
  : undefined,
@@ -51,6 +51,7 @@ import {
51
51
  FRONT_MATTER_KEYS,
52
52
  PAGE_TEMPLATES,
53
53
  requireTemplateName,
54
+ RETIRED_GENERATED_PAGE_KINDS,
54
55
  sentDisplayDate,
55
56
  TEMPLATE_FREE_FORM,
56
57
  TEMPLATE_GENERATED,
@@ -486,6 +487,20 @@ function peekJsonTemplate(text) {
486
487
  }
487
488
  }
488
489
 
490
+ /**
491
+ * Whether a generated-page source names a kind Taproot retired (TR01308). The
492
+ * site deletes such a page on its next synchronization, but until then it is still listed,
493
+ * so a pulled source of that kind is judged here, before its document is read.
494
+ */
495
+ function isRetiredGeneratedSource(source) {
496
+ if (source.template !== TEMPLATE_GENERATED || typeof source.text !== "string") return false;
497
+ try {
498
+ return RETIRED_GENERATED_PAGE_KINDS.includes(JSON.parse(source.text)?.data?.kind);
499
+ } catch {
500
+ return false;
501
+ }
502
+ }
503
+
489
504
  /**
490
505
  * Reads one editable page source and resolves the metadata that says *which
491
506
  * page it is* — no Markdown conversion, no media resolution, no document
@@ -1293,7 +1308,6 @@ export async function planPages({
1293
1308
  let observedRecord;
1294
1309
  let legalRevisionGuard;
1295
1310
  requireCompletePageMetadata(source);
1296
- const document_ = await convertWorkspacePageSource({ source, mediaManifest, content });
1297
1311
 
1298
1312
  const target = manifestEntry?.pageId !== undefined
1299
1313
  ? liveById.get(manifestEntry.pageId)
@@ -1307,21 +1321,26 @@ export async function planPages({
1307
1321
  file,
1308
1322
  );
1309
1323
  }
1310
- const generated = documentTemplate(document_) === TEMPLATE_GENERATED;
1311
- if (documentTemplate(document_) === TEMPLATE_LEGAL && target === undefined && manifestEntry?.pageId !== undefined) {
1312
- throw documentError("pages.legal_missing", `'${file}' tracks a legal page that the site no longer lists. Pull and reconcile it before creating another page.`, file);
1313
- }
1314
- if (generated && target === undefined) {
1324
+ // A generated source the site no longer has, or whose kind Taproot retired
1325
+ // (TR01308; the site deletes the page on its next synchronization), is
1326
+ // judged before its document is read: it is reported stale like any
1327
+ // other and never refused as an unknown kind that blocks the push.
1328
+ const generated = source.template === TEMPLATE_GENERATED;
1329
+ const retiredKind = generated && isRetiredGeneratedSource(source);
1330
+ if (generated && (target === undefined || retiredKind)) {
1315
1331
  if (targeted) {
1316
1332
  throw documentError(
1317
1333
  "pages.generated_create",
1318
- `'${file}' is a generated-page source, but the site has no generated page for it. Taproot creates `
1319
- + "generated pages from the site's content; a push cannot create one. If the page no longer exists, "
1320
- + "delete this file and run 'taproot-site pull'.",
1334
+ retiredKind
1335
+ ? `'${file}' is the source of a generated page of a kind Taproot no longer publishes, and a push `
1336
+ + "cannot send one. Delete the file."
1337
+ : `'${file}' is a generated-page source, but the site has no generated page for it. Taproot creates `
1338
+ + "generated pages from the site's content; a push cannot create one. If the page no longer exists, "
1339
+ + "delete this file and run 'taproot-site pull'.",
1321
1340
  file,
1322
1341
  );
1323
1342
  }
1324
- // The site retired this page (its tag, month, place or folder is gone),
1343
+ // The site retired this page (its folder or its kind is gone),
1325
1344
  // which is Taproot's doing, not a mistake in the workspace: it cannot
1326
1345
  // block the pages that do exist.
1327
1346
  staleGenerated.push({ file, path: displayPagePath(pagePath) });
@@ -1331,6 +1350,10 @@ export async function planPages({
1331
1350
  );
1332
1351
  return;
1333
1352
  }
1353
+ const document_ = await convertWorkspacePageSource({ source, mediaManifest, content });
1354
+ if (documentTemplate(document_) === TEMPLATE_LEGAL && target === undefined && manifestEntry?.pageId !== undefined) {
1355
+ throw documentError("pages.legal_missing", `'${file}' tracks a legal page that the site no longer lists. Pull and reconcile it before creating another page.`, file);
1356
+ }
1334
1357
  // The file-keyed entry is gone whenever the file was renamed or replaced —
1335
1358
  // which is exactly what the pull, delete the `.pm.json`, author a `.md`
1336
1359
  // flow does. Falling back through the page's own identity keeps the
@@ -1371,7 +1394,7 @@ export async function planPages({
1371
1394
  && targetEntry.file === file
1372
1395
  && targetEntry.sourceFormat === source.sourceFormat;
1373
1396
  // An untouched generated page is not this push's to judge. Taproot
1374
- // moves its revision and its path on its own (a tag rename, a month that
1397
+ // moves its revision and its path on its own (a folder that
1375
1398
  // gained a post), and refusing the whole push for a page nothing here
1376
1399
  // sends would make one renamed page block every other edit.
1377
1400
  let untouchedGenerated = false;
@@ -83,8 +83,8 @@ export async function placesSelect(invocation) {
83
83
  *
84
84
  * A place's category is shared by every site, and Google's types do not always
85
85
  * give the one an owner wants. The override is the site's alone: its place
86
- * reviews and `/places/<category>` pages use it from the next deployment, and
87
- * the platform-wide place is never changed. The server owns the closed list, so
86
+ * reviews use it from the next deployment, and the platform-wide place is
87
+ * never changed. The server owns the closed list, so
88
88
  * the CLI sends the name as given and lets the server check it.
89
89
  */
90
90
  export async function placesCategorySet(invocation) {
package/src/verbs/pull.js CHANGED
@@ -728,7 +728,7 @@ export async function pull(invocation) {
728
728
  registeredFiles.add(record.file);
729
729
  }
730
730
  const tracked = await resolveTrackedSources(config.workspaceDir, authorable, registry, onProgress);
731
- // A generated page the site retired (its tag, month, place or folder is
731
+ // A generated page the site retired (its folder or its kind is
732
732
  // gone) leaves its pulled file behind: authored content, so pull will not
733
733
  // delete it, and settles it before naming any page's file so that a page
734
734
  // created since at the same path never writes over it. It is recorded