@taprootio/site-authoring 0.3.0 → 0.5.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/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  CLI_BINARY_NAME,
3
3
  CLI_NAME,
4
+ CLI_UPGRADE_COMMAND,
4
5
  CLI_VERSION,
5
6
  DEFAULT_LOGIN_KEY_NAME,
6
7
  DEPLOY_TARGET_PRODUCTION,
@@ -22,6 +23,8 @@ import {
22
23
  VERB_PREVIEW_PAGE,
23
24
  VERB_PREVIEW_REVOKE,
24
25
  VERB_PULL,
26
+ VERB_REDIRECTS_PULL,
27
+ VERB_REDIRECTS_PUSH,
25
28
  VERB_SITES,
26
29
  VERB_STATUS,
27
30
  VERB_THEME_PUSH,
@@ -56,8 +59,21 @@ import {
56
59
  CAPABILITY_DEPLOYMENTS,
57
60
  CAPABILITY_DESIGN,
58
61
  } from "./capabilities.js";
62
+ import { assertCliCurrent } from "./session.js";
59
63
  import { VERB_HANDLERS } from "./verbs/index.js";
60
64
 
65
+ /**
66
+ * The verbs the local version gate applies to (TR00703).
67
+ *
68
+ * Exactly the three that make no request. Every other verb reaches Taproot,
69
+ * whose own refusal is authoritative and better informed than this recording,
70
+ * so gating them here would only duplicate an answer — and gating `login`,
71
+ * `logout`, or `env` would take away the commands an operator needs while they
72
+ * are outdated. `--help` and `--version` are outside it for the same reason:
73
+ * they are how someone finds out what they have.
74
+ */
75
+ const VERSION_GATED_OFFLINE_VERBS = Object.freeze([VERB_HELP, VERB_VALIDATE, VERB_WHOAMI]);
76
+
61
77
  // One entry per verb, in help order. `tokens` is the exact leading positional
62
78
  // sequence; matching prefers the longest sequence, so a two-token family can
63
79
  // never be shadowed by a one-token verb.
@@ -197,8 +213,9 @@ const VERBS = Object.freeze([
197
213
  + "The homepage is recorded with an empty path, so address it as '/'. "
198
214
  + "A selected path resolves to its one authoritative source from metadata alone, and that page is then validated "
199
215
  + "exactly as a whole push would validate it — site binding, manifest integrity, live create-or-update "
200
- + "resolution, system-page rules, path uniqueness against the live site, media ownership, and two workspace "
201
- + "files claiming one path all still fail closed. What a selection does not do is convert or validate the "
216
+ + "resolution, system-page rules, path uniqueness against the live site, media ownership, a live page whose "
217
+ + "stored-state revision moved since this workspace last reconciled with it (pages.push_conflict), and two "
218
+ + "workspace files claiming one path all still fail closed. What a selection does not do is convert or validate the "
202
219
  + "documents of pages it is not sending: an unrelated page left on an obsolete contract is reported by the "
203
220
  + "whole-workspace push, not used to block this one. The result states the selection and how many sources were "
204
221
  + "discovered and validated. "
@@ -219,6 +236,28 @@ const VERBS = Object.freeze([
219
236
  tokens: ["nav", "push"],
220
237
  summary: "Replace the whole navigation tree from the local workspace.",
221
238
  },
239
+ {
240
+ name: VERB_REDIRECTS_PULL,
241
+ // A redirect is a content path, and both halves of the map — read and
242
+ // replace — are gated on site.pages.edit_any, which only Content carries.
243
+ capabilities: [CAPABILITY_CONTENT],
244
+ tokens: ["redirects", "pull"],
245
+ summary: "Snapshot the site's whole redirect map into redirects.json.",
246
+ note: "Also part of 'pull'. Run it on its own to re-read the map after a page rename, or after a push was "
247
+ + "refused as a conflict, without re-pulling every page. It records the map's revision in the pull "
248
+ + "manifest; 'redirects push' sends that revision so a stale replace is refused rather than deleting an "
249
+ + "entry a rename recorded. See 'taproot-site help redirects'.",
250
+ },
251
+ {
252
+ name: VERB_REDIRECTS_PUSH,
253
+ capabilities: [CAPABILITY_CONTENT],
254
+ tokens: ["redirects", "push"],
255
+ summary: "Validate redirects.json and replace the whole redirect map.",
256
+ note: "Run 'redirects pull' first. The whole file is validated locally by entry index — path normalization, "
257
+ + "targets, statuses, duplicates, chains, and loops — before anything is sent, and the site refuses a "
258
+ + "source a live page occupies. Entries take effect on the next deploy. "
259
+ + "See 'taproot-site help redirects'.",
260
+ },
222
261
  {
223
262
  name: VERB_THEME_PUSH,
224
263
  capabilities: [CAPABILITY_DESIGN],
@@ -304,7 +343,7 @@ const VERBS = Object.freeze([
304
343
  // Content.
305
344
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
306
345
  tokens: ["status"],
307
- summary: "Report deployments, readiness, and image processing.",
346
+ summary: "Report the platform authoring switch, the CLI release, deployments, readiness, and image processing.",
308
347
  note: "Broken references are not included: that read remains"
309
348
  + " session-only on the server, so the result reports it as uncovered"
310
349
  + " rather than pretending an empty list means a clean site.",
@@ -353,6 +392,15 @@ whoami, and env need no configuration and no site. The offline help family is
353
392
  human-readable by default; add --json for stable reference data. Exit codes: 0
354
393
  success, 1 failure, 2 usage fault.
355
394
 
395
+ Troubleshooting: a command that fails with field=CliUpgradeRequired (or
396
+ refusal=cli_outdated) means this CLI is behind the latest published release,
397
+ which is the only release Taproot accepts. Nothing is wrong with the
398
+ credential, the request, or the site, and no retry of this version succeeds.
399
+ Run '${CLI_UPGRADE_COMMAND}' and try again; if the
400
+ release is minutes old, npm may not serve it yet, so wait and retry the
401
+ upgrade. Once a run has recorded a newer release, help, validate, and whoami
402
+ refuse the same way offline. --version and --help always answer.
403
+
356
404
  Verbs:
357
405
  ${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(14)} ${verb.summary}`).join("\n")}
358
406
 
@@ -408,11 +456,22 @@ function verbHelp(verb) {
408
456
  // would have had to gain.
409
457
  const selfContained = verb.offline && !verb.readsLocalState;
410
458
  const prefix = selfContained ? CLI_BINARY_NAME : `${CLI_BINARY_NAME} [--config <path>]`;
459
+ // The offline boundary states the version gate too (TR00703). It is the one
460
+ // thing these verbs read that is not their own input, and a boundary sentence
461
+ // that omitted it would be describing a verb that no longer exists.
462
+ // `env` is offline too and deliberately outside the gate — switching Taproots
463
+ // is one of the things an outdated CLI must still be able to do — so the
464
+ // sentence is attached per verb rather than to offline-ness.
465
+ const upgradeGate = VERSION_GATED_OFFLINE_VERBS.includes(verb.name)
466
+ ? " It refuses only when a previous sign-in exchange recorded a newer published release than this CLI, because "
467
+ + "Taproot accepts only the latest; with nothing recorded it runs."
468
+ : "";
411
469
  const boundary = selfContained
412
- ? "This offline verb reads no configuration or credential and performs no network request or write."
470
+ ? "This offline verb uses no credential and reads no configuration, and performs no network request and no "
471
+ + `write.${upgradeGate}`
413
472
  : verb.offline
414
473
  ? "This verb answers entirely from local state — the stored sign-in and the configuration — and makes no "
415
- + "network request and no write."
474
+ + `network request and no write.${upgradeGate}`
416
475
  : verb.credentialFree
417
476
  ? `This verb reads the configuration but requires no existing credential. `
418
477
  + `${PUBLISH_KEY_ENVIRONMENT_VARIABLE} always takes precedence over the stored credential, so setting it leaves `
@@ -500,6 +559,7 @@ function parseReferenceArguments(arguments_) {
500
559
  || topic === "appearance"
501
560
  || topic === "footer"
502
561
  || topic === "fixture"
562
+ || topic === "redirects"
503
563
  )
504
564
  && (subject !== undefined || extra.length > 0)) {
505
565
  throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
@@ -549,6 +609,7 @@ function referenceResult(parsed) {
549
609
  return { ...result, topic: "component", component };
550
610
  }
551
611
  case "nav":
612
+ case "redirects":
552
613
  case "media":
553
614
  case "preview":
554
615
  case "fixture":
@@ -855,10 +916,14 @@ export async function runCli({
855
916
  return 0;
856
917
  }
857
918
  if (parsed.mode === "reference") {
919
+ await assertCliCurrent(environment);
858
920
  const result = referenceResult(parsed);
859
921
  stdout.write(parsed.json ? `${serializeResult(result)}\n` : formatReferenceResult(result));
860
922
  return 0;
861
923
  }
924
+ if (VERSION_GATED_OFFLINE_VERBS.includes(parsed.verb)) {
925
+ await assertCliCurrent(environment);
926
+ }
862
927
  // Own properties only: a verb name must never resolve through the
863
928
  // prototype chain to something like `constructor`.
864
929
  const handler = Object.hasOwn(handlers, parsed.verb) ? handlers[parsed.verb] : undefined;
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.3.0";
2
+ export const CLI_VERSION = "0.5.0";
3
3
  export const CLI_BINARY_NAME = "taproot-site";
4
4
  export const RESULT_SCHEMA_VERSION = 1;
5
5
  export const CONFIG_FILE_NAME = "taproot-site.json";
@@ -82,6 +82,8 @@ export const VERB_LOGIN = "login";
82
82
  export const VERB_LOGOUT = "logout";
83
83
  export const VERB_PAGES_PUSH = "pages push";
84
84
  export const VERB_NAV_PUSH = "nav push";
85
+ export const VERB_REDIRECTS_PULL = "redirects pull";
86
+ export const VERB_REDIRECTS_PUSH = "redirects push";
85
87
  export const VERB_THEME_PUSH = "theme push";
86
88
  export const VERB_FOOTER_PUSH = "footer push";
87
89
  export const VERB_MEDIA_UPLOAD = "media upload";
@@ -109,6 +111,7 @@ export const REFUSAL_CREDENTIAL_REJECTED = "credential_rejected";
109
111
  export const REFUSAL_PLAN_LIMIT = "plan_limit";
110
112
  export const REFUSAL_THROTTLED = "throttled";
111
113
  export const REFUSAL_CAPABILITY_MISSING = "capability_missing";
114
+ export const REFUSAL_CLI_OUTDATED = "cli_outdated";
112
115
  export const REFUSAL_UNCLASSIFIED = "unclassified";
113
116
 
114
117
  export const REFUSAL_KINDS = Object.freeze([
@@ -117,6 +120,7 @@ export const REFUSAL_KINDS = Object.freeze([
117
120
  REFUSAL_PLAN_LIMIT,
118
121
  REFUSAL_THROTTLED,
119
122
  REFUSAL_CAPABILITY_MISSING,
123
+ REFUSAL_CLI_OUTDATED,
120
124
  REFUSAL_UNCLASSIFIED,
121
125
  ]);
122
126
 
@@ -127,6 +131,34 @@ export const ROLLOUT_REFUSAL_FIELD = "SiteAuthoringRollout";
127
131
  export const CREDENTIAL_REFUSAL_FIELD = "ExternalApiKey";
128
132
  export const PLAN_LIMIT_REFUSAL_FIELD = "UpgradePrompt";
129
133
 
134
+ /**
135
+ * The field Taproot refuses an outdated CLI with (TR00703).
136
+ *
137
+ * Named so the name alone is the instruction, because for every CLI published
138
+ * before this check that is the whole message: an unknown refusal field prints
139
+ * as "Taproot rejected the request field '<name>'." and nothing else.
140
+ */
141
+ export const CLI_UPGRADE_REFUSAL_FIELD = "CliUpgradeRequired";
142
+
143
+ /**
144
+ * How an operator gets current. Composed from {@link CLI_NAME} so the command
145
+ * in a refusal cannot drift from the package that refusal is about.
146
+ */
147
+ export const CLI_UPGRADE_COMMAND = `npm install -g ${CLI_NAME}@latest`;
148
+
149
+ /**
150
+ * The platform switch behind every `platform_paused` refusal, and where the one
151
+ * person who can undo it goes to look (TR00692).
152
+ *
153
+ * Held here rather than written out at each site because four surfaces say it —
154
+ * the pre-write warning, `status`, `whoami`, and the refusal guidance — and a
155
+ * setting key spelled four ways is a key an operator cannot search for. The key
156
+ * is a wire identity and is never reworded; the location is display text.
157
+ */
158
+ export const EXTERNAL_WRITES_SETTING_KEY = "site_authoring.external_writes_enabled";
159
+ export const EXTERNAL_WRITES_SETTING_LOCATION =
160
+ "Admin → Platform settings → Site Authoring (\"External site authoring writes enabled\")";
161
+
130
162
  /**
131
163
  * The `google.rpc.ErrorInfo` reason a key-mode permission denial carries
132
164
  * (TR00691, `SiteAuthoringKeyDenial` on the server). A wire identity, matched
@@ -165,6 +197,10 @@ export const LIMITS = Object.freeze({
165
197
  configDiscoveryParents: 32,
166
198
  workspacePathBytes: 4 * 1024,
167
199
  apiResponseBytes: 1024 * 1024,
200
+ // The redirect map is the one read whose valid size exceeds the ordinary
201
+ // response bound: two thousand entries with long absolute targets. Sized
202
+ // with the workspace file that holds it (WORKSPACE_LIMITS.redirectsBytes).
203
+ redirectMapResponseBytes: 8 * 1024 * 1024,
168
204
  requestMilliseconds: 60_000,
169
205
  uploadMilliseconds: 5 * 60_000,
170
206
  deploymentMilliseconds: 30 * 60_000,
@@ -1,7 +1,9 @@
1
- // Espalier 4.2 exposes this registry value only through its root component,
2
- // whose import also registers `esp-root`. Replace this with the side-effect-free
3
- // shared registry export when Espalier publishes that package subpath.
4
- import { BUILT_IN_IMAGE_TEXTURES } from "@taprootio/espalier/root";
1
+ // Espalier 4.8.0 publishes the texture registry as a side-effect-free subpath
2
+ // (TR00630). Import it from there, never from `@taprootio/espalier/root`: that
3
+ // module registers `esp-root`, so a process already holding another Espalier
4
+ // copy — the generator's Playwright Node process, for one — cannot load this
5
+ // pipeline at all once a component module is in the graph.
6
+ import { BUILT_IN_IMAGE_TEXTURES } from "@taprootio/espalier/shared/image-texture-registry";
5
7
  import { hasAsciiControl } from "../errors.js";
6
8
  import {
7
9
  CONTENT_ERROR_CODES as CODES,
@@ -195,7 +197,8 @@ const TESTIMONIAL = Object.freeze({
195
197
  borderWidth: num({
196
198
  integer: true,
197
199
  minimum: 0,
198
- description: "Pixel border around the whole set of quotations — the grid or the carousel; 0 leaves it unbordered.",
200
+ description:
201
+ "Pixel border around each quotation, as on a card grid; a carousel shows the visible slide's own box and is never framed. 0 boxes nothing.",
199
202
  }),
200
203
  });
201
204
 
@@ -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);
@@ -22,6 +22,7 @@ export const FIXTURE_REQUIRED_ROOT_FIELDS = Object.freeze([
22
22
  "pages",
23
23
  "pagesTruncated",
24
24
  "navigation",
25
+ "redirects",
25
26
  "settings",
26
27
  "settingsSkipped",
27
28
  "fixture",
@@ -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;