@taprootio/site-authoring 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +106 -13
  2. package/examples/riverbend-wellness/manifest.fixture.json +79 -0
  3. package/examples/riverbend-wellness/nav.json +26 -0
  4. package/examples/riverbend-wellness/pages/index.md +93 -0
  5. package/examples/riverbend-wellness/pages/visit.md +33 -0
  6. package/examples/riverbend-wellness/redirects.json +33 -0
  7. package/examples/riverbend-wellness/settings/brand.json +7 -0
  8. package/examples/riverbend-wellness/settings/site-header.json +21 -0
  9. package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
  10. package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
  11. package/package.json +2 -1
  12. package/src/api.js +203 -3
  13. package/src/cli-release.js +152 -0
  14. package/src/cli.js +120 -11
  15. package/src/constants.js +59 -2
  16. package/src/contact-url.js +63 -0
  17. package/src/content/components.js +26 -4
  18. package/src/content/free-form-section-registry.json +3 -2
  19. package/src/content/inline-facts.js +5 -1
  20. package/src/content/tiptap-prosemirror.ts +26 -12
  21. package/src/credentials.js +39 -4
  22. package/src/errors.js +25 -0
  23. package/src/fixture-contract.js +64 -0
  24. package/src/footer-contract.js +28 -30
  25. package/src/json-path-diff.js +167 -0
  26. package/src/output.js +9 -0
  27. package/src/presentation-reference.js +5 -3
  28. package/src/redirects-contract.js +444 -0
  29. package/src/redirects-workspace.js +72 -0
  30. package/src/reference-help.js +262 -10
  31. package/src/session.js +161 -7
  32. package/src/transport.js +111 -6
  33. package/src/verbs/approve.js +7 -2
  34. package/src/verbs/deploy.js +29 -2
  35. package/src/verbs/footer-push.js +7 -2
  36. package/src/verbs/index.js +7 -1
  37. package/src/verbs/login.js +11 -1
  38. package/src/verbs/media-upload.js +7 -2
  39. package/src/verbs/nav-push.js +17 -11
  40. package/src/verbs/pages-push.js +93 -8
  41. package/src/verbs/preview-page.js +7 -2
  42. package/src/verbs/preview-revoke.js +7 -2
  43. package/src/verbs/pull.js +357 -22
  44. package/src/verbs/redirects-pull.js +90 -0
  45. package/src/verbs/redirects-push.js +100 -0
  46. package/src/verbs/status.js +90 -3
  47. package/src/verbs/theme-push.js +7 -2
  48. package/src/verbs/validate.js +93 -18
  49. package/src/verbs/whoami.js +79 -1
  50. package/src/workspace.js +153 -15
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { atomicWriteFile } from "./atomic-file.js";
7
7
  import { readBoundedFile } from "./bounded-file.js";
8
8
  import { SITE_AUTHORING_CAPABILITIES } from "./capabilities.js";
9
+ import { parseCliVersion } from "./cli-release.js";
9
10
 
10
11
  import {
11
12
  CANONICAL_TIMESTAMP,
@@ -63,6 +64,11 @@ export const KEY_PREFIX = /^[\w.-]{1,64}$/u;
63
64
  const PROJECTION_UUID = "00000000-0000-4000-8000-000000000000";
64
65
  const PROJECTION_TIMESTAMP = "2000-01-01T00:00:00.000Z";
65
66
  const PROJECTION_KEY_PREFIX_LENGTH = 64;
67
+ // A version string as long as the reader admits (MAXIMUM_VERSION_LENGTH in
68
+ // cli-release.js, 64 characters), for the same reason the prefix above is
69
+ // padded: the projection is an upper bound on the record a first exchange will
70
+ // write (TR00703).
71
+ const PROJECTION_LATEST_CLI_VERSION = `999999.999999.999999-${"a".repeat(43)}`;
66
72
 
67
73
  function usableDirectory(value) {
68
74
  return typeof value === "string"
@@ -134,9 +140,17 @@ function normalizeEntry(entry) {
134
140
  "A stored credential record has an invalid createdAt.",
135
141
  );
136
142
  // What the last exchange produced. Entirely non-secret — a site id, the
137
- // capabilities that exchange asked for, and when the credential it returned
138
- // dies — and the only way `whoami` can answer "what can this actually do"
139
- // without a network round trip. Absent before the first exchange.
143
+ // capabilities that exchange asked for, when the credential it returned dies,
144
+ // when the exchange happened, and what Taproot then said about the
145
+ // platform-wide authoring switch — and the only way `whoami` can answer "what
146
+ // can this actually do" without a network round trip. Absent before the first
147
+ // exchange.
148
+ //
149
+ // `at` and `externalWritesEnabled` are optional on top of that (TR00692), and
150
+ // `latestCliVersion` on top of those (TR00703): a record written by an
151
+ // earlier version carries none of them, and a server that predates either
152
+ // field carries no value for it. Refusing those would make a routine upgrade
153
+ // look like a corrupt store.
140
154
  requireStoreShape(
141
155
  entry.lastExchange === undefined
142
156
  || (entry.lastExchange !== null
@@ -146,7 +160,16 @@ function normalizeEntry(entry) {
146
160
  && Array.isArray(entry.lastExchange.capabilities)
147
161
  && entry.lastExchange.capabilities.every((value) => typeof value === "string")
148
162
  && typeof entry.lastExchange.expiresAt === "string"
149
- && CANONICAL_TIMESTAMP.test(entry.lastExchange.expiresAt)),
163
+ && CANONICAL_TIMESTAMP.test(entry.lastExchange.expiresAt)
164
+ && (entry.lastExchange.at === undefined
165
+ || (typeof entry.lastExchange.at === "string" && CANONICAL_TIMESTAMP.test(entry.lastExchange.at)))
166
+ && (entry.lastExchange.externalWritesEnabled === undefined
167
+ || typeof entry.lastExchange.externalWritesEnabled === "boolean")
168
+ // Only a version this side can compare is admitted, because the value's
169
+ // whole job is to be compared: a record carrying something unparseable
170
+ // could never gate anything, so storing it would be storing noise.
171
+ && (entry.lastExchange.latestCliVersion === undefined
172
+ || parseCliVersion(entry.lastExchange.latestCliVersion) !== undefined)),
150
173
  "A stored credential record has an invalid lastExchange.",
151
174
  );
152
175
  return Object.freeze({
@@ -161,6 +184,13 @@ function normalizeEntry(entry) {
161
184
  siteId: entry.lastExchange.siteId,
162
185
  capabilities: Object.freeze([...entry.lastExchange.capabilities]),
163
186
  expiresAt: entry.lastExchange.expiresAt,
187
+ ...(entry.lastExchange.at === undefined ? {} : { at: entry.lastExchange.at }),
188
+ ...(entry.lastExchange.externalWritesEnabled === undefined
189
+ ? {}
190
+ : { externalWritesEnabled: entry.lastExchange.externalWritesEnabled }),
191
+ ...(entry.lastExchange.latestCliVersion === undefined
192
+ ? {}
193
+ : { latestCliVersion: entry.lastExchange.latestCliVersion }),
164
194
  }),
165
195
  }),
166
196
  createdAt: entry.createdAt,
@@ -469,6 +499,11 @@ export async function assertCredentialCapacity(environment, apiOrigin) {
469
499
  siteId: PROJECTION_UUID,
470
500
  capabilities: [...SITE_AUTHORING_CAPABILITIES],
471
501
  expiresAt: PROJECTION_TIMESTAMP,
502
+ at: PROJECTION_TIMESTAMP,
503
+ // `false` rather than `true`: it serializes one byte longer, and the
504
+ // projection's whole job is to be an upper bound on the real record.
505
+ externalWritesEnabled: false,
506
+ latestCliVersion: PROJECTION_LATEST_CLI_VERSION,
472
507
  },
473
508
  createdAt: PROJECTION_TIMESTAMP,
474
509
  });
package/src/errors.js CHANGED
@@ -112,6 +112,31 @@ export class SiteAuthoringError extends Error {
112
112
  return this;
113
113
  }
114
114
 
115
+ /**
116
+ * Attach the JSON paths at which two documents differ.
117
+ *
118
+ * Separate from `alternatives`, which is the bounded list of other values or
119
+ * targets a caller can act on — the files in a pull conflict, but equally the
120
+ * help topics, page types, or revisions another failure offers. These name
121
+ * locations inside one document, decide nothing, and exist so a conflict can
122
+ * be triaged without re-fetching both sides by hand.
123
+ *
124
+ * A list and no list are different facts, so anything that is not a list
125
+ * leaves the field unset rather than becoming an empty one: `[]` means the
126
+ * documents were compared and found identical, and absent means there was
127
+ * nothing to compare them against. Collapsing the second into the first would
128
+ * tell an agent the body is unchanged for a comparison that never ran.
129
+ */
130
+ withDifferences(paths) {
131
+ this.differences = Array.isArray(paths)
132
+ ? paths
133
+ .filter((value) => typeof value === "string")
134
+ .slice(0, 100)
135
+ .map((value) => sanitizeDiagnostic(value, "path"))
136
+ : undefined;
137
+ return this;
138
+ }
139
+
115
140
  /** Attach the non-secret identity needed to inspect or revoke a created preview. */
116
141
  withPreviewRecovery(preview) {
117
142
  const recovery = normalizePreviewRecovery(preview);
@@ -0,0 +1,64 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /**
4
+ * The offline authoring-fixture contract (TR00647).
5
+ *
6
+ * `validate` enforces this; `help fixture` states it. Both read the constants
7
+ * below rather than restating them, so a change to what the verb requires
8
+ * cannot leave the reference describing the previous contract. The one thing
9
+ * this module deliberately does not own is the *page* entry's key set: a
10
+ * fixture page entry is a `pull` manifest entry, and `validate` reads the
11
+ * fields it needs rather than closing the shape, so a fixture written by a
12
+ * newer `pull` is not refused for carrying a field this release ignores.
13
+ */
14
+
15
+ export const FIXTURE_MANIFEST_FILE_NAME = "manifest.fixture.json";
16
+ export const FIXTURE_CONTRACT_VERSION = 1;
17
+
18
+ /** Root fields whose absence or wrong value fails validation. */
19
+ export const FIXTURE_REQUIRED_ROOT_FIELDS = Object.freeze([
20
+ "manifestVersion",
21
+ "siteId",
22
+ "pages",
23
+ "pagesTruncated",
24
+ "navigation",
25
+ "redirects",
26
+ "settings",
27
+ "settingsSkipped",
28
+ "fixture",
29
+ ]);
30
+
31
+ /**
32
+ * Root fields `pull` writes that a fixture may carry unchanged. They are
33
+ * accepted and read for nothing: a fixture proves local structure, and neither
34
+ * the snapshot time nor a recorded deployment can be checked without a server.
35
+ */
36
+ export const FIXTURE_OPTIONAL_ROOT_FIELDS = Object.freeze(["pulledAt", "deployments"]);
37
+
38
+ /** Every root field the manifest may declare. Anything else is refused. */
39
+ export const FIXTURE_ROOT_FIELDS = Object.freeze(
40
+ [...FIXTURE_REQUIRED_ROOT_FIELDS, ...FIXTURE_OPTIONAL_ROOT_FIELDS].sort(),
41
+ );
42
+
43
+ /** Every field the versioned `fixture` block may declare. All are required. */
44
+ export const FIXTURE_METADATA_FIELDS = Object.freeze(["contractVersion", "imageIds", "deliveryOrigins"]);
45
+
46
+ /**
47
+ * Delivery origins are restricted to the reserved example domain rather than
48
+ * merely to HTTPS. A fixture is example data that ships and is copied; a real
49
+ * delivery host in one would be a live reference in every copy of it.
50
+ */
51
+ export const FIXTURE_DELIVERY_ORIGIN_DOMAIN = "example.test";
52
+ export const FIXTURE_MAXIMUM_DELIVERY_ORIGINS = 100;
53
+
54
+ /** The complete example fixture this package ships. */
55
+ export const SHIPPED_FIXTURE_NAME = "riverbend-wellness";
56
+
57
+ /**
58
+ * The absolute path of the shipped fixture, resolved from this module rather
59
+ * than from the caller's directory, so it is correct for a global install as
60
+ * well as a checkout. `help fixture` reports it and `validate` accepts it.
61
+ */
62
+ export function shippedFixtureDirectory() {
63
+ return fileURLToPath(new URL(`../examples/${SHIPPED_FIXTURE_NAME}/`, import.meta.url)).replace(/[\\/]$/u, "");
64
+ }
@@ -1,3 +1,8 @@
1
+ import {
2
+ CONTACT_OR_WEB_URL_MAX_LENGTH,
3
+ CONTACT_OR_WEB_URL_REQUIREMENT,
4
+ isContactOrWebUrl,
5
+ } from "./contact-url.js";
1
6
  import { hasControlCharacter, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
2
7
  import { FOOTER_COLOR_TOKENS, requireAppearanceColor } from "./theme-validation.js";
3
8
 
@@ -15,7 +20,7 @@ export const FOOTER_LIMITS = Object.freeze({
15
20
  linkLabelLength: 120,
16
21
  bottomTextLength: 500,
17
22
  featureImageAltLength: 300,
18
- externalUrlLength: 2_048,
23
+ externalUrlLength: CONTACT_OR_WEB_URL_MAX_LENGTH,
19
24
  backgroundRepeatHeightMinimum: 64,
20
25
  backgroundRepeatHeightMaximum: 512,
21
26
  backgroundRepeatHeightStep: 16,
@@ -198,33 +203,17 @@ function stableId(value, path, seen, label) {
198
203
  return value;
199
204
  }
200
205
 
206
+ // A `tel:` or `mailto:` target is returned exactly as authored: the number's
207
+ // punctuation and the address's plus-tag are content, and `URL.toString()`
208
+ // re-encodes both. Only the whitespace the server would strip is removed.
201
209
  function safeExternalUrl(value, path, tolerateStoredValues) {
202
210
  if (typeof value !== "string") {
203
- fail("footer.url_invalid", `${path} must be a bounded absolute http or https URL.`, path);
211
+ fail("footer.url_invalid", `${path} ${CONTACT_OR_WEB_URL_REQUIREMENT}.`, path);
204
212
  }
205
213
  const normalized = trimLikeServer(value);
206
214
  if (tolerateStoredValues) return normalized;
207
- if (
208
- normalized.length === 0
209
- || normalized.length > FOOTER_LIMITS.externalUrlLength
210
- || hasControlCharacter(normalized)
211
- || normalized.includes("\\")
212
- ) {
213
- fail("footer.url_invalid", `${path} must be a bounded absolute http or https URL.`, path);
214
- }
215
- let parsed;
216
- try {
217
- parsed = new URL(normalized);
218
- } catch {
219
- fail("footer.url_invalid", `${path} must be an absolute http or https URL.`, path);
220
- }
221
- if (
222
- (parsed.protocol !== "http:" && parsed.protocol !== "https:")
223
- || parsed.username !== ""
224
- || parsed.password !== ""
225
- || parsed.hostname === ""
226
- ) {
227
- fail("footer.url_invalid", `${path} must use http or https without embedded credentials.`, path);
215
+ if (!isContactOrWebUrl(normalized, FOOTER_LIMITS.externalUrlLength)) {
216
+ fail("footer.url_invalid", `${path} ${CONTACT_OR_WEB_URL_REQUIREMENT}.`, path);
228
217
  }
229
218
  return normalized;
230
219
  }
@@ -618,16 +607,25 @@ export function footerImageIds(value) {
618
607
  export const FOOTER_EXAMPLE = Object.freeze({
619
608
  enabled: true,
620
609
  showBrand: true,
610
+ // The aside CTA is where a local business puts the thing a visitor came for.
611
+ // A phone number is a `tel:` target, emitted verbatim so it stays dialable.
621
612
  asideCta: Object.freeze({
622
613
  id: "103e726d-3152-49c8-bf14-c947b1bd8a14",
623
- label: "Talk with us",
624
- externalUrl: "https://example.com/contact",
614
+ label: "Call the studio",
615
+ externalUrl: "tel:+15555550123",
625
616
  }),
626
- bottomLinks: Object.freeze([Object.freeze({
627
- id: "28363e08-b341-42fc-bc83-8dd73fa87904",
628
- label: "Privacy",
629
- externalUrl: "https://example.com/privacy",
630
- })]),
617
+ bottomLinks: Object.freeze([
618
+ Object.freeze({
619
+ id: "28363e08-b341-42fc-bc83-8dd73fa87904",
620
+ label: "Privacy",
621
+ externalUrl: "https://example.com/privacy",
622
+ }),
623
+ Object.freeze({
624
+ id: "9e2d1f04-4bb0-4a2d-9d1a-6d3b0d0a4c77",
625
+ label: "Email us",
626
+ externalUrl: "mailto:hello@example.com",
627
+ }),
628
+ ]),
631
629
  light: Object.freeze({
632
630
  backgroundColor: "--esp-color-layer-1",
633
631
  textColor: "--esp-color-text",
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Says *what* differs between two page documents, in JSON paths.
3
+ *
4
+ * A conflict that only says "this page changed on the site" leaves the operator
5
+ * with a choice nobody can make well: re-push identical content, or abandon the
6
+ * local source. On the SHY production home page (2026-09-03) the entire
7
+ * difference was `$.content[0].attrs.decoration.image.src` and the sibling
8
+ * `.urls` — two fields the API re-projects on every read from live image state
9
+ * — and a list of paths would have shown that in one line.
10
+ *
11
+ * This decides nothing. The revision comparison decides; this explains it. So
12
+ * it is deliberately generous about what counts as a difference and strictly
13
+ * bounded in what it will spend finding out: a diagnostic that hangs on a
14
+ * hostile document is worse than one that says "and more".
15
+ */
16
+
17
+ /** How many differing paths one report carries before it says "and more". */
18
+ export const JSON_DIFFERENCE_LIMIT = 20;
19
+
20
+ /**
21
+ * How deep the comparison descends. `canonicalDocumentHash` refuses a document
22
+ * nesting deeper than this, and every document compared here has already been
23
+ * through it, so the bound is unreachable for real content. A subtree at the
24
+ * bound is reported as differing rather than skipped in silence: over-reporting
25
+ * a diagnostic is safe, and quietly not comparing is not.
26
+ */
27
+ const MAXIMUM_DEPTH = 100;
28
+
29
+ /**
30
+ * A ceiling on total work, because both sides are documents this process did
31
+ * not author: one is the site's, the other a file on disk.
32
+ */
33
+ const MAXIMUM_VISITS = 200_000;
34
+
35
+ /** How much of one object key a path segment carries. */
36
+ const MAXIMUM_SEGMENT_SCALARS = 64;
37
+
38
+ /** How long one rendered path may be before its middle is elided. */
39
+ const MAXIMUM_PATH_SCALARS = 200;
40
+
41
+ const PLAIN_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
42
+
43
+ function boundSegment(key) {
44
+ const scalars = [...key];
45
+ return scalars.length <= MAXIMUM_SEGMENT_SCALARS
46
+ ? key
47
+ : `${scalars.slice(0, MAXIMUM_SEGMENT_SCALARS).join("")}…`;
48
+ }
49
+
50
+ function childPath(path, key) {
51
+ const segment = boundSegment(key);
52
+ return PLAIN_KEY.test(segment) ? `${path}.${segment}` : `${path}[${JSON.stringify(segment)}]`;
53
+ }
54
+
55
+ function indexPath(path, index) {
56
+ return `${path}[${index}]`;
57
+ }
58
+
59
+ function boundPath(path) {
60
+ const scalars = [...path];
61
+ if (scalars.length <= MAXIMUM_PATH_SCALARS) return path;
62
+ const head = scalars.slice(0, MAXIMUM_PATH_SCALARS - 20).join("");
63
+ const tail = scalars.slice(-19).join("");
64
+ return `${head}…${tail}`;
65
+ }
66
+
67
+ function isPlainObject(value) {
68
+ return value !== null && typeof value === "object" && !Array.isArray(value);
69
+ }
70
+
71
+ /**
72
+ * The paths at which two parsed JSON values differ.
73
+ *
74
+ * Returns `{ paths, truncated }`. `paths` is empty when the two documents are
75
+ * structurally identical — which, on a page whose revision moved, is the useful
76
+ * answer: the stored state changed in a field this comparison does not cover.
77
+ *
78
+ * `truncated` means the walk stopped early — either more than `limit`
79
+ * differences exist, or the documents are large enough to have exhausted the
80
+ * work ceiling — so the list is a sample rather than the whole story.
81
+ */
82
+ export function describeJsonDifferences(left, right, { limit = JSON_DIFFERENCE_LIMIT } = {}) {
83
+ const bound = Number.isSafeInteger(limit) && limit > 0 ? limit : JSON_DIFFERENCE_LIMIT;
84
+ // One past the reported bound. Stopping *at* the bound could not tell "these
85
+ // are all of them" from "there were more", and a sample presented as a
86
+ // complete list is the kind of diagnostic that sends someone looking in the
87
+ // wrong place.
88
+ const cap = bound + 1;
89
+ const paths = [];
90
+ let exhausted = false;
91
+ let visits = 0;
92
+
93
+ const record = (path) => {
94
+ paths.push(boundPath(path));
95
+ };
96
+
97
+ const walk = (leftValue, rightValue, path, depth) => {
98
+ if (paths.length >= cap || exhausted) return;
99
+ visits += 1;
100
+ if (visits > MAXIMUM_VISITS) {
101
+ exhausted = true;
102
+ return;
103
+ }
104
+ if (depth > MAXIMUM_DEPTH) {
105
+ record(path);
106
+ return;
107
+ }
108
+
109
+ if (Array.isArray(leftValue) && Array.isArray(rightValue)) {
110
+ const shared = Math.min(leftValue.length, rightValue.length);
111
+ for (let index = 0; index < shared; index += 1) {
112
+ walk(leftValue[index], rightValue[index], indexPath(path, index), depth + 1);
113
+ if (paths.length >= cap || exhausted) return;
114
+ }
115
+ // An added or removed element is named by its own index rather than by
116
+ // the array, so "one section was appended" reads differently from "every
117
+ // section moved".
118
+ for (let index = shared; index < Math.max(leftValue.length, rightValue.length); index += 1) {
119
+ record(indexPath(path, index));
120
+ if (paths.length >= cap || exhausted) return;
121
+ }
122
+ return;
123
+ }
124
+
125
+ if (isPlainObject(leftValue) && isPlainObject(rightValue)) {
126
+ // Sorted so two runs over the same pair of documents report the same
127
+ // paths in the same order, whatever order the members arrived in.
128
+ const keys = [...new Set([...Object.keys(leftValue), ...Object.keys(rightValue)])].sort();
129
+ for (const key of keys) {
130
+ const inLeft = Object.hasOwn(leftValue, key);
131
+ const inRight = Object.hasOwn(rightValue, key);
132
+ if (!inLeft || !inRight) {
133
+ record(childPath(path, key));
134
+ } else {
135
+ walk(leftValue[key], rightValue[key], childPath(path, key), depth + 1);
136
+ }
137
+ if (paths.length >= cap || exhausted) return;
138
+ }
139
+ return;
140
+ }
141
+
142
+ // Different kinds, or two scalars. `Object.is` rather than `===` so two
143
+ // NaN bodies do not read as a difference; JSON has no NaN, but these
144
+ // documents reach here through `JSON.parse` of untrusted bytes and a
145
+ // caller may hand over something else.
146
+ if (!Object.is(leftValue, rightValue)) record(path);
147
+ };
148
+
149
+ walk(left, right, "$", 0);
150
+ return { paths: paths.slice(0, bound), truncated: exhausted || paths.length > bound };
151
+ }
152
+
153
+ /**
154
+ * The paths a conflict may claim, or `undefined` when no claim is honest.
155
+ *
156
+ * An empty list is the statement "compared, and the body is identical", which
157
+ * an agent reads as "look at the title, path, or description instead". That
158
+ * statement cannot be made when nothing was compared, and it cannot be made
159
+ * when the walk exhausted its budget before it reached a difference: two
160
+ * documents that agree for the first two hundred thousand nodes and differ
161
+ * afterwards are different, not identical.
162
+ */
163
+ export function reportableDifferencePaths(differences) {
164
+ if (differences === undefined) return undefined;
165
+ if (differences.paths.length === 0 && differences.truncated) return undefined;
166
+ return differences.paths;
167
+ }
package/src/output.js CHANGED
@@ -42,6 +42,15 @@ export function failureResult(error) {
42
42
  if (Array.isArray(error.completedWrites) && error.completedWrites.length > 0) {
43
43
  result.error.completedWrites = error.completedWrites;
44
44
  }
45
+ // Three states, and an agent acts differently on each. Absent: the two
46
+ // documents were never compared, because this workspace kept no copy of the
47
+ // site's previous version. Empty: they were compared and the body is
48
+ // identical, so the change that raised the conflict is in the page's title,
49
+ // path, or description. Non-empty: the JSON paths at which they differ.
50
+ // Suppressing the empty array would collapse the first two into one.
51
+ if (Array.isArray(error.differences)) {
52
+ result.error.differences = error.differences;
53
+ }
45
54
  const previewRecovery = normalizePreviewRecovery(error.previewRecovery);
46
55
  if (previewRecovery) result.error.preview = previewRecovery;
47
56
  const refusal = typeof error.refusalKind === "function" ? error.refusalKind() : undefined;
@@ -412,7 +412,7 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
412
412
  "FooterLink.label": { type: "plain-text", required: true, maximumLength: FOOTER_LIMITS.linkLabelLength },
413
413
  "FooterLink.pageResourceId": { type: "tracked-page-resource-uuid", exclusiveWith: "externalUrl" },
414
414
  "FooterLink.externalUrl": {
415
- type: "absolute-http-or-https-url",
415
+ type: "contact-or-web-url",
416
416
  exclusiveWith: "pageResourceId",
417
417
  maximumLength: FOOTER_LIMITS.externalUrlLength,
418
418
  },
@@ -425,7 +425,7 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
425
425
  "FooterRichTextRun.link": { type: "FooterInlineLink|null", default: null },
426
426
  "FooterInlineLink.pageResourceId": { type: "tracked-page-resource-uuid", exclusiveWith: "externalUrl" },
427
427
  "FooterInlineLink.externalUrl": {
428
- type: "absolute-http-or-https-url",
428
+ type: "contact-or-web-url",
429
429
  exclusiveWith: "pageResourceId",
430
430
  maximumLength: FOOTER_LIMITS.externalUrlLength,
431
431
  },
@@ -507,7 +507,9 @@ export function getFooterReference() {
507
507
  totalGroupMaximum: FOOTER_LIMITS.linkGroups,
508
508
  totalRichTextRunsMaximum: FOOTER_LIMITS.richTextRuns,
509
509
  targetRule:
510
- "Each link has exactly one target: a tracked pageResourceId, or an absolute credential-free http/https externalUrl.",
510
+ "Each link has exactly one target: a tracked pageResourceId, or an externalUrl that is an absolute "
511
+ + "credential-free http/https URL, or a mailto:/tel: contact URL such as tel:+15555550123. A contact URL is "
512
+ + "published exactly as authored, so write the number the way it should be dialled.",
511
513
  imageRule:
512
514
  "Image ids come from pull or media upload. Feature images require alt text; background art is decorative.",
513
515
  readOnlyProjections: FOOTER_READ_ONLY_FIELDS,