@taprootio/site-authoring 0.11.0 → 0.12.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
@@ -99,6 +99,7 @@ taproot-site help
99
99
  taproot-site help designs
100
100
  taproot-site help design professional-portfolio
101
101
  taproot-site help page free-form
102
+ taproot-site help page recipe
102
103
  taproot-site help component hero-section --json
103
104
  ```
104
105
 
@@ -141,8 +142,8 @@ taproot-site validate "$(npm root --global)/@taprootio/site-authoring/examples/r
141
142
 
142
143
  The fixture is a fictional wellness studio — `example.test` hostnames, a
143
144
  reserved `555-01xx` telephone number, an invented street and town — with two
144
- free-form pages, a three-item navigation tree, and all four settings
145
- documents. Nothing in it describes a real business. Copy the directory
145
+ free-form pages and one each of the article, recipe, album and place-review
146
+ templates, a three-item navigation tree, and all four settings documents. Nothing in it describes a real business. Copy the directory
146
147
  somewhere writable before you edit it; `validate` never writes to the fixture
147
148
  it reads.
148
149
 
@@ -338,6 +339,17 @@ the site's own document is kept as internal state under
338
339
  edited on the site since the last pull is a `pages.pull_conflict` refusal
339
340
  before anything under `pages/` changes, with both recovery paths named.
340
341
 
342
+ Pages are not only free-form. A source chooses its template with `template:` in
343
+ its front matter — `article`, `recipe`, `album` or `place-review`; free-form is
344
+ the default — and `pull` writes the other four as a `.pm.json` wrapper
345
+ (`{ "template", "displayDate", "coverImageId", "data" }`) that `pages push`
346
+ sends back unchanged. Typed pages also carry `displayDate` (so an import keeps
347
+ its original publication date) and `coverImage`, which the site requires to be an
348
+ image the page itself uses (place it in the body too; `pages.cover_image_unused`). A page's template is fixed when
349
+ it is created: a source for another template is refused with
350
+ `pages.template_immutable`. `taproot-site help page <type>` gives each
351
+ template's fields and one worked example.
352
+
341
353
  `pages push [page-path...]` validates and sends the selected pages, or every
342
354
  workspace page when none is named; the homepage is addressed as `/`. It fails
343
355
  closed with `pages.push_conflict` when a page's stored-state revision has
@@ -549,7 +561,7 @@ taproot-site validate ../authoring-fixture
549
561
  The destination must be new, outside the source workspace, and its parent must exist. Use
550
562
  `taproot-site --config path/to/taproot-site.json validate --init ../authoring-fixture`
551
563
  to select another source configuration. Initialization needs no credential or
552
- network. It exports editable free-form pages as ProseMirror with navigation,
564
+ network. It exports editable pages of every template as workspace documents with navigation,
553
565
  redirects, settings, and version-7 appearance/footer metadata, validates the
554
566
  result, and leaves the source unchanged. Excluded metadata-only pages are
555
567
  counted; unresolved references to them fail validation.
@@ -30,6 +30,62 @@
30
30
  "file": "pages/visit.md",
31
31
  "sourceFormat": "markdown",
32
32
  "workspaceMode": "editable"
33
+ },
34
+ {
35
+ "pageId": "a0000000-0000-4000-8000-000000000040",
36
+ "resourceId": "a0000000-0000-4000-8000-000000000041",
37
+ "path": "journal/welcome",
38
+ "title": "Welcome to the Riverbend journal",
39
+ "status": "PAGE_STATUS_DRAFT",
40
+ "templateType": "TEMPLATE_TYPE_ARTICLE",
41
+ "hasDraft": true,
42
+ "isGenerated": false,
43
+ "description": "An example article for the offline authoring fixture.",
44
+ "file": "pages/journal-welcome.md",
45
+ "sourceFormat": "markdown",
46
+ "workspaceMode": "editable"
47
+ },
48
+ {
49
+ "pageId": "a0000000-0000-4000-8000-000000000042",
50
+ "resourceId": "a0000000-0000-4000-8000-000000000043",
51
+ "path": "recipes/green-smoothie",
52
+ "title": "Green morning smoothie",
53
+ "status": "PAGE_STATUS_DRAFT",
54
+ "templateType": "TEMPLATE_TYPE_RECIPE",
55
+ "hasDraft": true,
56
+ "isGenerated": false,
57
+ "description": "An example recipe for the offline authoring fixture.",
58
+ "file": "pages/green-smoothie.md",
59
+ "sourceFormat": "markdown",
60
+ "workspaceMode": "editable"
61
+ },
62
+ {
63
+ "pageId": "a0000000-0000-4000-8000-000000000044",
64
+ "resourceId": "a0000000-0000-4000-8000-000000000045",
65
+ "path": "albums/studio-tour",
66
+ "title": "A tour of the studio",
67
+ "status": "PAGE_STATUS_DRAFT",
68
+ "templateType": "TEMPLATE_TYPE_ALBUM",
69
+ "hasDraft": true,
70
+ "isGenerated": false,
71
+ "description": "An example album for the offline authoring fixture.",
72
+ "file": "pages/studio-tour.pm.json",
73
+ "sourceFormat": "prosemirror",
74
+ "workspaceMode": "editable"
75
+ },
76
+ {
77
+ "pageId": "a0000000-0000-4000-8000-000000000046",
78
+ "resourceId": "a0000000-0000-4000-8000-000000000047",
79
+ "path": "reviews/juice-bar",
80
+ "title": "The corner juice bar",
81
+ "status": "PAGE_STATUS_DRAFT",
82
+ "templateType": "TEMPLATE_TYPE_PLACE_REVIEW",
83
+ "hasDraft": true,
84
+ "isGenerated": false,
85
+ "description": "An example place review for the offline authoring fixture.",
86
+ "file": "pages/juice-bar.md",
87
+ "sourceFormat": "markdown",
88
+ "workspaceMode": "editable"
33
89
  }
34
90
  ],
35
91
  "pagesTruncated": false,
@@ -0,0 +1,28 @@
1
+ ---
2
+ title: Green morning smoothie
3
+ path: recipes/green-smoothie
4
+ description: An example recipe for the offline authoring fixture.
5
+ template: recipe
6
+ prepTimeMinutes: 5
7
+ servings: 2
8
+ recipeCategory: Drink
9
+ ---
10
+
11
+ A fictional studio favorite that takes five minutes.
12
+
13
+ ## Ingredients
14
+
15
+ ### Base
16
+
17
+ - 1 cup spinach
18
+ - 1 banana
19
+ - 1 cup oat milk
20
+
21
+ ### Finish
22
+
23
+ - 1 tablespoon chia seeds
24
+
25
+ ## Instructions
26
+
27
+ 1. Blend the base until smooth.
28
+ 2. Stir in the chia seeds and rest for two minutes.
@@ -0,0 +1,13 @@
1
+ ---
2
+ title: Welcome to the Riverbend journal
3
+ path: journal/welcome
4
+ description: An example article for the offline authoring fixture.
5
+ template: article
6
+ displayDate: 2024-03-01
7
+ ---
8
+
9
+ The journal is where the fictional Riverbend Wellness studio shares notes from
10
+ the studio floor. This page is an **article**: a title, a body, and an
11
+ original publication date kept with `displayDate`.
12
+
13
+ A page's template is chosen once, when it is created, and never changes.
@@ -0,0 +1,11 @@
1
+ ---
2
+ title: The corner juice bar
3
+ path: reviews/juice-bar
4
+ description: An example place review for the offline authoring fixture.
5
+ template: place-review
6
+ placeId: a0000000-0000-4000-8000-0000000000b0
7
+ rating: will-return
8
+ ---
9
+
10
+ An invented juice bar in the fictional town of Elm Harbor. Nothing here
11
+ describes a real business.
@@ -0,0 +1,34 @@
1
+ {
2
+ "template": "album",
3
+ "coverImageId": "a0000000-0000-4000-8000-0000000000a1",
4
+ "data": {
5
+ "introductionBody": {
6
+ "type": "doc",
7
+ "content": [
8
+ {
9
+ "type": "paragraph",
10
+ "content": [
11
+ {
12
+ "type": "text",
13
+ "text": "A fictional tour of the studio. An album keeps its images by id, so its source is a JSON document."
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ },
19
+ "images": [
20
+ {
21
+ "imageId": "a0000000-0000-4000-8000-0000000000a1",
22
+ "caption": "The main room",
23
+ "width": 1920,
24
+ "height": 1080
25
+ },
26
+ {
27
+ "imageId": "a0000000-0000-4000-8000-0000000000a2",
28
+ "caption": "The tea corner",
29
+ "width": 1200,
30
+ "height": 1600
31
+ }
32
+ ]
33
+ }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",
package/src/api.js CHANGED
@@ -490,6 +490,9 @@ export function normalizePageSummary(value) {
490
490
  // at" — and both are carried as `undefined` rather than as a sentinel that
491
491
  // would compare unequal to every recorded baseline and refuse every push.
492
492
  bodyRevision: normalizePageBodyRevision(summary.bodyRevision),
493
+ // The page's calendar date, empty when it has none. It is not part of
494
+ // `bodyRevision`, so a typed page's push guard compares it separately.
495
+ displayDate: typeof summary.displayDate === "string" ? summary.displayDate : "",
493
496
  };
494
497
  }
495
498
 
@@ -526,14 +529,6 @@ export async function getPage(client, pageId, status) {
526
529
  );
527
530
  }
528
531
 
529
- export function freeFormTemplate(body) {
530
- return {
531
- templateType: TEMPLATE_TYPE_FREE_FORM,
532
- templateVersion: FREE_FORM_TEMPLATE_VERSION,
533
- freeFormData: { body },
534
- };
535
- }
536
-
537
532
  export async function createPage(client, body) {
538
533
  return normalizePageSummary(await client.request("v1/pages", { method: "POST", body }));
539
534
  }
@@ -1118,7 +1113,6 @@ export async function listDeployments(
1118
1113
  `deployments${
1119
1114
  query([
1120
1115
  ["pageSize", pageSize],
1121
- ["includeStaging", true],
1122
1116
  ["environment", environment],
1123
1117
  ])
1124
1118
  }`,
package/src/cli.js CHANGED
@@ -705,6 +705,7 @@ function parseReferenceArguments(arguments_) {
705
705
  || topic === "redirects"
706
706
  || topic === "walkthrough"
707
707
  || topic === "delivery"
708
+ || topic === "import"
708
709
  )
709
710
  && (subject !== undefined || extra.length > 0)
710
711
  ) {
@@ -774,6 +775,7 @@ function referenceResult(parsed) {
774
775
  case "fixture":
775
776
  case "walkthrough":
776
777
  case "delivery":
778
+ case "import":
777
779
  return {
778
780
  ...result,
779
781
  topic: "workflow",
package/src/constants.js CHANGED
@@ -1,5 +1,7 @@
1
+ import packageMetadata from "../package.json" with { type: "json" };
2
+
1
3
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.11.0";
4
+ export const CLI_VERSION = packageMetadata.version;
3
5
  export const CLI_BINARY_NAME = "taproot-site";
4
6
  export const RESULT_SCHEMA_VERSION = 1;
5
7
  export const CONFIG_FILE_NAME = "taproot-site.json";
@@ -157,10 +157,6 @@ const CTA = Object.freeze({
157
157
  }),
158
158
  imagePosition: enumOf("top", "left", "right"),
159
159
  mediaImage: image(),
160
- // TR00890: "waitlist" replaces the link button with an email-capture form
161
- // that posts to the platform's public waitlist endpoint. buttonUrl is
162
- // ignored in that mode; buttonText becomes the submit button's label.
163
- formMode: enumOf("none", "waitlist"),
164
160
  });
165
161
 
166
162
  /** `FeatureGridData`. */
@@ -363,12 +359,10 @@ const COMPONENT_DEFINITIONS = Object.freeze({
363
359
  borderWidth: 0,
364
360
  imagePosition: "top",
365
361
  mediaImage: null,
366
- formMode: "none",
367
362
  },
368
363
  [
369
364
  "Button text must describe its action or destination.",
370
365
  "Give meaningful media a descriptive alt value and decorative media an empty alt value.",
371
- "formMode \"waitlist\" renders an email-capture form instead of the link button; leave buttonUrl empty and use buttonText for the submit label.",
372
366
  ],
373
367
  {
374
368
  heading: "Ready to begin?",
@@ -10,6 +10,17 @@
10
10
  import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
11
11
 
12
12
  export const TIPTAP_SITE_DEFAULT = "site-default";
13
+ export const DEFAULT_TIPTAP_IMAGE_MAX_IMAGES_PER_ROW = 2;
14
+ export const TIPTAP_IMAGE_MAX_IMAGES_PER_ROW_OPTIONS = [1, 2, 3] as const;
15
+ export const TIPTAP_IMAGE_NARROW_CONTENT_REM = 40;
16
+
17
+ export function normalizeTiptapImageMaxImagesPerRow(value: unknown): number {
18
+ const count = Number(value);
19
+ return TIPTAP_IMAGE_MAX_IMAGES_PER_ROW_OPTIONS.some(option => option === count)
20
+ ? count
21
+ : DEFAULT_TIPTAP_IMAGE_MAX_IMAGES_PER_ROW;
22
+ }
23
+
13
24
  export const DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH = 60;
14
25
  export const TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS = [45, 60, 75, 90] as const;
15
26
  const SECTION_CONTEXT_PATTERN = new RegExp(
@@ -64,6 +75,7 @@ export interface RenderProseMirrorOptions {
64
75
  /** Normalize free-form root flow into full-bleed Espalier sections. */
65
76
  freeFormSections?: boolean;
66
77
  imageDefaults?: {
78
+ maxImagesPerRow?: number | null;
67
79
  placement?: TiptapImagePresentationPlacement | string | null;
68
80
  /** The page shell emits this default once so rich-text formats inherit it. */
69
81
  maxHeightVh?: TiptapImageMaxHeightVh | number | null;
@@ -71,7 +83,10 @@ export interface RenderProseMirrorOptions {
71
83
  /** Generator-only render hooks for structured nodes that need page context. */
72
84
  nodeRenderers?: {
73
85
  componentBlock?: (node: ProseMirrorNode) => string | undefined;
74
- taprootImage?: (node: ProseMirrorNode) => string | undefined;
86
+ taprootImage?: (
87
+ node: ProseMirrorNode,
88
+ context: { groupColumns?: number; fullWidth: boolean },
89
+ ) => string | undefined;
75
90
  /** Emits an adopted fragment's canonical bytes; without it the node renders nothing. */
76
91
  integrationFragment?: (node: ProseMirrorNode) => string | undefined;
77
92
  };
@@ -151,14 +166,26 @@ function isTextBlock(type: string): boolean {
151
166
  }
152
167
 
153
168
  type RenderPlacement = "root" | "section" | "nested";
169
+ type RenderOptionsWithLayout = RenderProseMirrorOptions & { fullWidthImages?: boolean };
154
170
 
155
171
  function renderNodes(
156
172
  nodes: readonly ProseMirrorNode[],
157
- options: RenderProseMirrorOptions,
173
+ options: RenderOptionsWithLayout,
158
174
  placement: RenderPlacement,
159
175
  ): string {
160
- return nodes.map((node) => {
161
- const html = renderNode(node, options, placement);
176
+ let imageRunEnd = 0;
177
+ let groupColumns: number | undefined;
178
+ return nodes.map((node, index) => {
179
+ const isImage = node.type === "taprootImage";
180
+ if (isImage && index >= imageRunEnd) {
181
+ imageRunEnd = index + 1;
182
+ while (imageRunEnd < nodes.length && nodes[imageRunEnd].type === "taprootImage") imageRunEnd++;
183
+ const count = imageRunEnd - index;
184
+ groupColumns = count > 1
185
+ ? Math.min(count, normalizeTiptapImageMaxImagesPerRow(options.imageDefaults?.maxImagesPerRow))
186
+ : undefined;
187
+ }
188
+ const html = renderNode(node, options, placement, false, isImage ? groupColumns : undefined);
162
189
  options.onRenderedNode?.(node, html);
163
190
  return html;
164
191
  }).join("");
@@ -217,9 +244,10 @@ function isRootBandComponent(node: ProseMirrorNode): boolean {
217
244
 
218
245
  function renderNode(
219
246
  node: ProseMirrorNode,
220
- options: RenderProseMirrorOptions,
247
+ options: RenderOptionsWithLayout,
221
248
  placement: RenderPlacement,
222
249
  compactFollowing = false,
250
+ groupColumns?: number,
223
251
  ): string {
224
252
  switch (node.type) {
225
253
  case "doc":
@@ -263,7 +291,8 @@ function renderNode(
263
291
  case "table":
264
292
  return placement === "root" || placement === "section" ? renderTable(node, options) : "";
265
293
  case "taprootImage":
266
- return options.nodeRenderers?.taprootImage?.(node) ?? renderTaprootImage(node, options);
294
+ return options.nodeRenderers?.taprootImage?.(node, { groupColumns, fullWidth: options.fullWidthImages === true })
295
+ ?? renderTaprootImage(node, options);
267
296
  case "inlineFacts":
268
297
  return renderInlineFacts(node, options, placement);
269
298
  case "componentBlock":
@@ -737,7 +766,7 @@ function renderSection(
737
766
  const content = renderElement(
738
767
  "div",
739
768
  { "data-section-content": true },
740
- renderNodes(nodes, options, contentPlacement),
769
+ renderNodes(nodes, { ...options, fullWidthImages: contentPadding === "none" }, contentPlacement),
741
770
  );
742
771
  const surfaced = surface === "none"
743
772
  ? content
@@ -1181,6 +1210,7 @@ function resolveImagePresentation(
1181
1210
  if (maxHeightOverride) styleParts.push(`--taproot-article-image-max-height: ${maxHeightOverride}`);
1182
1211
  if (borderWidthOverride) styleParts.push(`--esp-image-border: ${imageBorderValue(borderWidthOverride)}`);
1183
1212
  return {
1213
+ "data-tiptap-image": normalizeTiptapImageMaxImagesPerRow(defaults?.maxImagesPerRow),
1184
1214
  // The page shell owns the inherited site default so HTML and structured
1185
1215
  // rich text share it. Serialize only an explicit legacy choice; this
1186
1216
  // includes 60vh when it intentionally overrides a taller site default.
@@ -1,6 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
- import { TEMPLATE_TYPE_FREE_FORM } from "./api.js";
4
3
  import { CLI_BINARY_NAME } from "./constants.js";
5
4
  import {
6
5
  canonicalizeComponentData,
@@ -35,6 +34,8 @@ import {
35
34
  REDIRECTS_FILE_NAME,
36
35
  } from "./redirects-contract.js";
37
36
  import { SETTINGS_GROUPS } from "./settings-catalog.js";
37
+ import { formatTypedPageReference, getTypedPageReference, TYPED_PAGE_TYPES } from "./typed-page-reference.js";
38
+ import { PAGE_TEMPLATES } from "./typed-pages.js";
38
39
  import {
39
40
  INTERNAL_PAGE_BASELINE_DIRECTORY,
40
41
  MANIFEST_VERSION,
@@ -49,8 +50,8 @@ import {
49
50
 
50
51
  export { getAppearanceReference, getFooterReference, getThemeReference };
51
52
 
52
- export const REFERENCE_VERSION = 27;
53
- export const PAGE_TYPES = Object.freeze(["free-form"]);
53
+ export const REFERENCE_VERSION = 28;
54
+ export const PAGE_TYPES = Object.freeze(["free-form", ...TYPED_PAGE_TYPES]);
54
55
 
55
56
  function deepFreeze(value) {
56
57
  if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
@@ -334,7 +335,7 @@ export function getDesignBlueprint(type) {
334
335
  return DESIGN_BLUEPRINTS.find((blueprint) => blueprint.type === type);
335
336
  }
336
337
  export const REFERENCE_TOPICS = Object.freeze([
337
- Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types." }),
338
+ Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types: free-form, article, recipe, album, place-review." }),
338
339
  Object.freeze({
339
340
  name: "page",
340
341
  usage: `${CLI_BINARY_NAME} help page <page-type>`,
@@ -397,6 +398,11 @@ export const REFERENCE_TOPICS = Object.freeze([
397
398
  usage: `${CLI_BINARY_NAME} help footer`,
398
399
  summary: "Author and concurrency-safely push the complete footer document.",
399
400
  }),
401
+ Object.freeze({
402
+ name: "import",
403
+ usage: `${CLI_BINARY_NAME} help import`,
404
+ summary: "Import an existing WordPress site from its export: mapping rules, order of work, and what to report.",
405
+ }),
400
406
  Object.freeze({
401
407
  name: "fixture",
402
408
  usage: `${CLI_BINARY_NAME} help fixture`,
@@ -405,6 +411,60 @@ export const REFERENCE_TOPICS = Object.freeze([
405
411
  ]);
406
412
 
407
413
  const WORKFLOW_REFERENCES = Object.freeze({
414
+ import: Object.freeze({
415
+ title: "Importing a WordPress site",
416
+ summary: "Turn a WordPress export (WXR) into a published Taproot site with the templates, dates, media and redirects preserved, and a written account of everything that could not be mapped.",
417
+ usage: `${CLI_BINARY_NAME} pull | media upload <files...> | pages push | redirects push | nav push | deploy --staging`,
418
+ details: Object.freeze([
419
+ "Source: a WordPress export file (Tools > Export > All content, WXR 1.2). Read the live site only when the owner "
420
+ + "has said you may; the export names every media URL but carries no image bytes, so downloading originals from "
421
+ + "the old site also needs that permission. Work in a fresh workspace: 'use' the site, then 'pull'.",
422
+ "Order of work: 1 parse the export; 2 classify every item; 3 stage media; 4 write page sources; 5 check every "
423
+ + "source; 6 pages push; 7 nav.json and nav push; 8 redirects.json and redirects push; 9 approve, deploy "
424
+ + "--staging, review it; 10 redirects check and delivery check --staging; 11 deploy --production; 12 delivery "
425
+ + "check --production --url <the origin 'sites' reports as primaryDomain>. Do not promote until the account of unmapped content has been read by the owner.",
426
+ "Classify. Published post: article (the default); recipe when it has an Ingredients list and a Steps list "
427
+ + "(help page recipe); album when it is a gallery with little text (help page album). Published page: a free-form "
428
+ + "page at its path, children under their parent's path. Never guess a place review: it needs a Taproot "
429
+ + "placeId this CLI cannot look up, so import the post as an article and list it. Not imported, and always "
430
+ + "listed: drafts, pending, private, scheduled and password-protected items; custom post types; comments; the WordPress front "
431
+ + "page (Taproot seeds its own home page: ask the owner what belongs there).",
432
+ "Paths and dates. A post's path is '<first category slug, ignoring the default Uncategorized>/<post name>' ('journal/' with no category; a post or page whose slug has no ASCII letters gets a name like post-<id>, listed), a "
433
+ + "page's is its post name under its parents. Keep the original publication date in displayDate (the date "
434
+ + "part of wp:post_date). Categories beyond the first, and all tags, have no Taproot equivalent (tags are "
435
+ + "generated per page type): list them. The featured image becomes coverImage on typed pages, and the same image must also appear in the page's own content (put it at the top of the body), or the site refuses the cover; a free-form page "
436
+ + "has none, so list it.",
437
+ "Content. Convert the HTML to the Markdown subset. Keep headings from h2 to h4 (the title is the h1; deeper levels are refused, so make h5 and h6 an h4). Decode HTML entities (&amp;, &#8217;) in titles, excerpts, menu labels and term names; WordPress stores them encoded. An image "
438
+ + "must stand alone in its own block, so split paragraphs around images and lift images out of links; a link "
439
+ + "to the full-size file is dropped, so list it. Tables need a header row and 2 to 12 columns, else write "
440
+ + "a list and report it. Remove Gutenberg block comments and the more-tag marker. In a recipe, times and servings "
441
+ + "go to front matter, text after the steps moves into the introduction with its headings dropped to level 3, and the only "
442
+ + "level-2 headings are Ingredients and Instructions; an album's only level-2 heading is Images.",
443
+ "Report, never silently drop. Shortcodes with no equivalent ([contact-form-7], [adsense], maps, recipe cards "
444
+ + "whose data sits in separate records), iframes and other embeds (a YouTube, Vimeo, X/Twitter, Instagram or TikTok address becomes a plain "
445
+ + "link), scripts, video and audio elements, inline styles, galleries (flattened to sequential images, or an "
446
+ + "album when they are the post), links to content you did not import (keep the absolute old link), and images "
447
+ + "on other hosts. Every item goes in the account with its page and what happened to it.",
448
+ "Media. Save each original in the workspace's media/ directory (PNG, JPEG, GIF or WebP up to 32 MiB each; list SVG, AVIF, HEIC and oversized files, which cannot be uploaded; use the original, not "
449
+ + "the -300x200 thumbnail WordPress also stores), then upload in batches of at most 10 files (conservative: the limit depends on how long the delivery URLs are, and was measured only with short stand-in URLs): 'media upload' "
450
+ + "prints every file's delivery URLs into a result capped at 64 KiB, and a longer batch uploads everything, "
451
+ + "writes .taproot-site-media.json, then exits 1 with output.too_large. If that happens the files are already "
452
+ + "uploaded; run the same paths again (they deduplicate) in smaller batches.",
453
+ "Check before sending. 'pages push' validates every source before it sends anything but stops at the first "
454
+ + "problem. On a large import, validate the sources yourself first so you see every problem at once: run the "
455
+ + "package's validators over each file, or push one page at a time ('pages push <path>').",
456
+ "Navigation. A site can have several menus (header, footer): ask which is primary and list the rest. Rebuild it as nav.json after 'pages push', at most three levels deep with canonical UUID ids, 1000 items and 200-character titles (help nav); a label-only dropdown heading (a WordPress '#' link) is a GROUP_HEADER, and a child of an item you cannot map is listed, not promoted (page items carry the resourceId from the "
457
+ + "manifest; a custom link is an EXTERNAL_URL item; a category or tag item has no target, so list it).",
458
+ "Redirects. At most 2000 entries; never redirect to a page you did not push. Every imported page's old path (from its <link>) becomes a 301 to its new path, unless the two "
459
+ + "are the same. A category archive redirects to its folder when it became one. Query-string URLs (?p=12), "
460
+ + "feeds, tag archives, author archives and attachment pages cannot be redirect sources or have no "
461
+ + "destination: list them and ask the owner whether each is 410 or goes to an index. A source that a live page "
462
+ + "occupies is refused by the site.",
463
+ "Finish with the account: counts of pages by template, media staged, redirects written, then every unmapped "
464
+ + "item grouped by kind with examples, the questions only the owner can answer, and the verification results "
465
+ + "(redirects check, delivery check).",
466
+ ]),
467
+ }),
408
468
  delivery: Object.freeze({
409
469
  title: "Delivery verification",
410
470
  summary: "delivery check reads a completed deployment's target the way a visitor does and reports HTTP delivery, runtime compatibility and (optionally) browser behaviour as separate dimensions.",
@@ -683,8 +743,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
683
743
  `manifestVersion must be ${MANIFEST_VERSION}; fixture.contractVersion must be ${FIXTURE_CONTRACT_VERSION}.`,
684
744
  "siteId, every pageId and resourceId, every settings entityId, and every fixture.imageIds entry is a canonical "
685
745
  + "lowercase UUID. They are deterministic fixture identities, not identities from a live site.",
686
- `Each pages[] entry binds one editable free-form source: pageId, resourceId, path, title, templateType `
687
- + `${TEMPLATE_TYPE_FREE_FORM}, file, sourceFormat, and workspaceMode ${PAGE_WORKSPACE_MODE_EDITABLE}. `
746
+ `Each pages[] entry binds one editable page source: pageId, resourceId, path, title, templateType `
747
+ + `(${Object.values(PAGE_TEMPLATES).map((template) => template.wireType).join(", ")}; the source must be that template), file, `
748
+ + `sourceFormat, and workspaceMode ${PAGE_WORKSPACE_MODE_EDITABLE}. Album images and the cover image must be `
749
+ + "listed in fixture.imageIds. "
688
750
  + "status, hasDraft, isGenerated, and description are recorded by pull and not read: the page key set "
689
751
  + "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string.",
690
752
  `file lives under '${PAGES_DIRECTORY}/' and ends in ${PAGE_SOURCE_EXTENSIONS.join(" or ")}; sourceFormat must be `
@@ -716,7 +778,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
716
778
  "validate reads the fixture and writes nothing to it. Copy the directory somewhere writable before editing it.",
717
779
  "validate --init <new-directory> exports the current pulled workspace as a validated version-7 fixture with "
718
780
  + "appearance and footer metadata. Run from the workspace or its configured project; --config may select the source.",
719
- "Initialization keeps editable free-form pages as ProseMirror sources and reports excluded metadata-only pages. "
781
+ "Initialization keeps editable pages of every template as workspace documents and reports excluded metadata-only pages. "
720
782
  + "References to excluded pages must be resolved before the fixture can validate. It never copies credentials, "
721
783
  + "internal reconciliation state, or deployment receipts. UUIDs and HTTP(S) origins are replaced with deterministic "
722
784
  + "fixture identities and example.test origins; URL credentials, queries and fragments are removed. Authored prose is retained.",
@@ -1302,6 +1364,14 @@ const FREE_FORM_REFERENCE = Object.freeze({
1302
1364
  inheritedForTracked: true,
1303
1365
  defaultForNew: "",
1304
1366
  }),
1367
+ Object.freeze({
1368
+ name: "template",
1369
+ type: "string",
1370
+ requiredForNew: false,
1371
+ inheritedForTracked: false,
1372
+ defaultForNew: "free-form",
1373
+ description: `Other templates: ${TYPED_PAGE_TYPES.join(", ")}; see '${CLI_BINARY_NAME} help pages'.`,
1374
+ }),
1305
1375
  ]),
1306
1376
  }),
1307
1377
  Object.freeze({
@@ -1387,17 +1457,20 @@ const FREE_FORM_REFERENCE = Object.freeze({
1387
1457
  });
1388
1458
 
1389
1459
  export function listPageTypeReferences() {
1390
- return PAGE_TYPES.map((type) => ({
1391
- type,
1392
- displayName: FREE_FORM_REFERENCE.displayName,
1393
- summary: FREE_FORM_REFERENCE.summary,
1394
- helpCommand: `${CLI_BINARY_NAME} help page ${type}`,
1395
- }));
1460
+ return PAGE_TYPES.map((type) => {
1461
+ const reference = type === "free-form" ? FREE_FORM_REFERENCE : getTypedPageReference(type);
1462
+ return {
1463
+ type,
1464
+ displayName: reference.displayName,
1465
+ summary: reference.summary,
1466
+ helpCommand: `${CLI_BINARY_NAME} help page ${type}`,
1467
+ };
1468
+ });
1396
1469
  }
1397
1470
 
1398
1471
  export function getPageTypeReference(type) {
1399
- if (type !== "free-form") return undefined;
1400
- return { ...FREE_FORM_REFERENCE, components: listComponentTypeReferences() };
1472
+ if (type === "free-form") return { ...FREE_FORM_REFERENCE, components: listComponentTypeReferences() };
1473
+ return getTypedPageReference(type);
1401
1474
  }
1402
1475
 
1403
1476
  function schemaLabel(schema) {
@@ -1557,6 +1630,7 @@ export function formatReferenceResult(result) {
1557
1630
  }
1558
1631
  case "page": {
1559
1632
  const page = result.page;
1633
+ if (page.type !== "free-form") return formatTypedPageReference(page);
1560
1634
  const markdownFormat = page.workspace.formats.find((format) => format.extension === ".md");
1561
1635
  const sourceRule = page.workspace.sourceRule;
1562
1636
  return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace:\n${
@@ -125,6 +125,7 @@ export const SETTINGS_GROUPS = Object.freeze([
125
125
  // the same as the site behaves.
126
126
  { name: "hideLightboxComments", wireType: "boolean" },
127
127
  { name: "tiptapImageMaxHeightVh", wireType: "int" },
128
+ { name: "tiptapImageMaxImagesPerRow", wireType: "int" },
128
129
  { name: "tiptapImageBorderWidth", wireType: "int" },
129
130
  { name: "albumBorderWidth", wireType: "int" },
130
131
  { name: "albumMaxImagesPerRow", wireType: "int" },