@taprootio/site-authoring 0.11.1 → 0.12.1

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
@@ -489,7 +501,7 @@ fingerprint supply no gating evidence. Typed preview failure diagnostics contain
489
501
  only error class and known page/node/attribute labels, never renderer messages.
490
502
 
491
503
  Staging deploys check the current redirect map through the real staging edge.
492
- Run `taproot-site redirects check` to repeat after eventual edge propagation.
504
+ Run `taproot-site redirects check` to repeat once the deployment has completed; a redirect is a file inside the release, so it is live exactly when its release is served.
493
505
  The Node CLI consumes a separate handoff, retains its short staging cookie in
494
506
  memory, and requests each map path without following redirects. Results report
495
507
  path, HTTP status and Location; `verified` requires all entries to match and the
@@ -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.1",
3
+ "version": "0.12.1",
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
@@ -251,7 +251,8 @@ const VERBS = Object.freeze([
251
251
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
252
252
  tokens: ["redirects", "check"],
253
253
  summary: "Check the current redirect map through the authenticated staging edge.",
254
- note: "Reports path, HTTP status and Location without following redirects. Re-run after edge propagation. "
254
+ note: "Reports path, HTTP status and Location without following redirects. A redirect is live only with the release "
255
+ + "that carries it, so this reads the staged release. "
255
256
  + "The result includes a fresh single-use staging handoff; keep it private.",
256
257
  },
257
258
  {
@@ -275,7 +276,8 @@ const VERBS = Object.freeze([
275
276
  summary: "Validate redirects.json and replace the whole redirect map.",
276
277
  note: "Run 'redirects pull' first. The whole file is validated locally by entry index — path normalization, "
277
278
  + "targets, statuses, duplicates, chains, and loops — before anything is sent, and the site refuses a "
278
- + "source a live page occupies. Entries take effect on the next deploy. "
279
+ + "source a live page occupies or a file the site generates. An entry is a file inside the next release, so it "
280
+ + "goes live with that deploy: staging first, production when promoted. "
279
281
  + "See 'taproot-site help redirects'.",
280
282
  },
281
283
  {
@@ -705,6 +707,7 @@ function parseReferenceArguments(arguments_) {
705
707
  || topic === "redirects"
706
708
  || topic === "walkthrough"
707
709
  || topic === "delivery"
710
+ || topic === "import"
708
711
  )
709
712
  && (subject !== undefined || extra.length > 0)
710
713
  ) {
@@ -774,6 +777,7 @@ function referenceResult(parsed) {
774
777
  case "fixture":
775
778
  case "walkthrough":
776
779
  case "delivery":
780
+ case "import":
777
781
  return {
778
782
  ...result,
779
783
  topic: "workflow",
@@ -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?",
@@ -42,13 +42,16 @@ export const REDIRECT_STATUSES = Object.freeze([301, 302, 307, 308]);
42
42
 
43
43
  /**
44
44
  * Bounds, mirrored from `SiteRedirectMapContract` on the server. The path bound
45
- * is what fits a Workers KV key beside its `redirect:{environment}:{site}:`
46
- * envelope; the target bound is the edge's own.
45
+ * is the map's own; the target bound is the edge's own, and a target must also
46
+ * fit as metadata on its redirect file once encoded.
47
47
  */
48
48
  export const REDIRECT_LIMITS = Object.freeze({
49
49
  entries: 2_000,
50
50
  pathBytes: 400,
51
51
  targetBytes: 2_048,
52
+ // Once percent-encoded, a target rides on its redirect file as object
53
+ // metadata; the server pins the same number (MaxMarkerTargetEncodedLength).
54
+ targetEncodedCharacters: 1_810,
52
55
  });
53
56
 
54
57
  // C0, DEL, and C1 — every code point the server's own `char.IsControl`
@@ -88,8 +91,7 @@ const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
88
91
  * between the API's .NET and the edge's WHATWG parser, so a spelling this pass
89
92
  * encoded would still not be the one the Worker computes and the layers would
90
93
  * go on comparing different strings. Write what a browser sends and every layer
91
- * compares it unchanged — and the byte bounds then measure the spelling the KV
92
- * key actually costs.
94
+ * compares it unchanged — and the byte bounds then measure that same spelling.
93
95
  */
94
96
  function isRequestSpelling(value) {
95
97
  return !UNSENDABLE_SPELLING.test(value);
@@ -154,6 +156,70 @@ export function normalizeRedirectPath(value) {
154
156
  return trimmed === "" ? "/" : trimmed;
155
157
  }
156
158
 
159
+ // The root segment the published output reserves for integration data; the release
160
+ // inventory leaves everything under it out.
161
+ const RESERVED_INTEGRATION_DATA_SEGMENT = "integrations";
162
+
163
+ // Root-level files the site generates. A redirect is a marker file inside the
164
+ // published site (TR00968), so a source that names one has nowhere to live.
165
+ const GENERATED_ROOT_FILES = Object.freeze(["404.html", "sitemap.xml", "robots.txt", "favicon.ico"]);
166
+ // The longest decoded path segment a marker file name may have; the API rule and the
167
+ // generator use the same 200 bytes.
168
+ const MAX_MARKER_SEGMENT_BYTES = 200;
169
+ const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
170
+
171
+ function isWindowsDeviceSegment(segment) {
172
+ const stem = segment.split(".", 1)[0].toLowerCase();
173
+ return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
174
+ }
175
+
176
+ /**
177
+ * Why a normalized source path cannot be a redirect, or undefined when it can.
178
+ * A mirror of `SiteRedirectMapContract.SourceRefusalReason` on the server (the
179
+ * API is the authority; both run the same test vectors): `…/index.html` is
180
+ * answered by the platform for every page, the generator writes the files
181
+ * named above, and the marker is stored under the decoded spelling, so a path
182
+ * that cannot be stored once decoded (an escaped percent sign, a dot-prefixed
183
+ * segment, a reserved device name) is refused. The site root is left to the
184
+ * occupancy rule.
185
+ */
186
+ export function redirectSourceRefusalReason(path) {
187
+ if (path === "/") return undefined;
188
+ // Every rule reads the decoded spelling: it is the name the marker is stored
189
+ // under, and the name the generator's own files use (`/%69ndex.html` is
190
+ // `/index.html`).
191
+ let decoded;
192
+ try {
193
+ decoded = path.replace(/^\/+|\/+$/gu, "").split("/").map(decodeURIComponent).join("/");
194
+ } catch {
195
+ return "it holds a percent escape that does not decode.";
196
+ }
197
+ const segments = decoded.split("/");
198
+ const first = segments[0].toLowerCase();
199
+ if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
200
+ return "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
201
+ + "a redirect source, and the page or home file there is generated.";
202
+ }
203
+ if (first === RESERVED_INTEGRATION_DATA_SEGMENT) {
204
+ return `'/${segments[0]}' is reserved for integration data, so nothing under it can be a redirect source.`;
205
+ }
206
+ if (GENERATED_ROOT_FILES.includes(first)) {
207
+ return `'/${segments[0]}' is a file the site generates, so neither it nor anything under it can be a redirect source.`;
208
+ }
209
+ if (
210
+ decoded.includes("..")
211
+ || decoded !== decoded.normalize("NFC")
212
+ || segments.some((segment) =>
213
+ segment === "" || segment.startsWith(".") || /[%\\?#\u0000-\u001F\u007F-\u009F]/u.test(segment)
214
+ || isWindowsDeviceSegment(segment) || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES
215
+ )
216
+ ) {
217
+ return "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
218
+ + "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).";
219
+ }
220
+ return undefined;
221
+ }
222
+
157
223
  function looksAbsolute(value) {
158
224
  return !value.startsWith("/") && value.includes("://");
159
225
  }
@@ -235,14 +301,21 @@ function validateEntry(value, index, seen) {
235
301
  `${field}.path`,
236
302
  );
237
303
  }
238
- if (seen.has(path.toLowerCase())) {
304
+ const sourceRefusal = redirectSourceRefusalReason(path);
305
+ if (sourceRefusal !== undefined) {
306
+ throw entryError("redirects.path_unpublishable", `'${path}': ${sourceRefusal}`, `${field}.path`);
307
+ }
308
+ // The marker is stored under the decoded spelling, so `/a%41` and `/aA` are one
309
+ // file, and the second would silently replace the first.
310
+ const markerKey = decodeURIComponent(path).toLowerCase();
311
+ if (seen.has(markerKey)) {
239
312
  throw entryError(
240
313
  "redirects.path_duplicate",
241
314
  `'${path}' appears more than once; one path has one entry.`,
242
315
  `${field}.path`,
243
316
  );
244
317
  }
245
- seen.add(path.toLowerCase());
318
+ seen.add(markerKey);
246
319
 
247
320
  const kind = value.kind === undefined ? REDIRECT_KIND_REDIRECT : value.kind;
248
321
  if (!REDIRECT_KINDS.includes(kind)) {
@@ -296,6 +369,15 @@ function validateEntry(value, index, seen) {
296
369
  );
297
370
  }
298
371
 
372
+ if (encodeURIComponent(rawTarget).length > REDIRECT_LIMITS.targetEncodedCharacters) {
373
+ throw entryError(
374
+ "redirects.target_too_long_to_store",
375
+ "'target' is stored as metadata on the published site's redirect file and is too long to fit "
376
+ + `(${REDIRECT_LIMITS.targetEncodedCharacters} characters once percent-encoded).`,
377
+ `${field}.target`,
378
+ );
379
+ }
380
+
299
381
  let target;
300
382
  if (looksAbsolute(rawTarget)) {
301
383
  if (!isAbsoluteRedirectTarget(rawTarget)) {
@@ -341,13 +423,23 @@ function validateEntry(value, index, seen) {
341
423
  return { path, kind: REDIRECT_KIND_REDIRECT, target, status };
342
424
  }
343
425
 
426
+ // The marker is stored under the decoded spelling, so `/%61` and `/a` are one route
427
+ // and `/%61 -> /a` is a loop. A target's escape may not decode; it then compares raw.
428
+ function routeIdentity(path) {
429
+ try {
430
+ return decodeURIComponent(path).toLowerCase();
431
+ } catch {
432
+ return path.toLowerCase();
433
+ }
434
+ }
435
+
344
436
  function assertNoChains(entries) {
345
- const sources = new Set(entries.map((entry) => entry.path.toLowerCase()));
437
+ const sources = new Set(entries.map((entry) => routeIdentity(entry.path)));
346
438
  entries.forEach((entry, index) => {
347
439
  const targetPath = internalRedirectTargetPath(entry);
348
- if (targetPath === undefined || !sources.has(targetPath.toLowerCase())) return;
440
+ if (targetPath === undefined || !sources.has(routeIdentity(targetPath))) return;
349
441
  const field = `entries[${index}].target`;
350
- if (targetPath.toLowerCase() === entry.path.toLowerCase()) {
442
+ if (routeIdentity(targetPath) === routeIdentity(entry.path)) {
351
443
  throw entryError("redirects.loop", `'${entry.path}' redirects to itself.`, field);
352
444
  }
353
445
  throw entryError(