@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.
- package/README.md +106 -13
- package/examples/riverbend-wellness/manifest.fixture.json +79 -0
- package/examples/riverbend-wellness/nav.json +26 -0
- package/examples/riverbend-wellness/pages/index.md +93 -0
- package/examples/riverbend-wellness/pages/visit.md +33 -0
- package/examples/riverbend-wellness/redirects.json +33 -0
- package/examples/riverbend-wellness/settings/brand.json +7 -0
- package/examples/riverbend-wellness/settings/site-header.json +21 -0
- package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
- package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
- package/package.json +2 -1
- package/src/api.js +203 -3
- package/src/cli-release.js +152 -0
- package/src/cli.js +120 -11
- package/src/constants.js +59 -2
- package/src/contact-url.js +63 -0
- package/src/content/components.js +26 -4
- package/src/content/free-form-section-registry.json +3 -2
- package/src/content/inline-facts.js +5 -1
- package/src/content/tiptap-prosemirror.ts +26 -12
- package/src/credentials.js +39 -4
- package/src/errors.js +25 -0
- package/src/fixture-contract.js +64 -0
- package/src/footer-contract.js +28 -30
- package/src/json-path-diff.js +167 -0
- package/src/output.js +9 -0
- package/src/presentation-reference.js +5 -3
- package/src/redirects-contract.js +444 -0
- package/src/redirects-workspace.js +72 -0
- package/src/reference-help.js +262 -10
- package/src/session.js +161 -7
- package/src/transport.js +111 -6
- package/src/verbs/approve.js +7 -2
- package/src/verbs/deploy.js +29 -2
- package/src/verbs/footer-push.js +7 -2
- package/src/verbs/index.js +7 -1
- package/src/verbs/login.js +11 -1
- package/src/verbs/media-upload.js +7 -2
- package/src/verbs/nav-push.js +17 -11
- package/src/verbs/pages-push.js +93 -8
- package/src/verbs/preview-page.js +7 -2
- package/src/verbs/preview-revoke.js +7 -2
- package/src/verbs/pull.js +357 -22
- package/src/verbs/redirects-pull.js +90 -0
- package/src/verbs/redirects-push.js +100 -0
- package/src/verbs/status.js +90 -3
- package/src/verbs/theme-push.js +7 -2
- package/src/verbs/validate.js +93 -18
- package/src/verbs/whoami.js +79 -1
- package/src/workspace.js +153 -15
package/src/credentials.js
CHANGED
|
@@ -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,
|
|
138
|
-
//
|
|
139
|
-
//
|
|
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
|
+
}
|
package/src/footer-contract.js
CHANGED
|
@@ -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:
|
|
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}
|
|
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
|
-
|
|
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: "
|
|
624
|
-
externalUrl: "
|
|
614
|
+
label: "Call the studio",
|
|
615
|
+
externalUrl: "tel:+15555550123",
|
|
625
616
|
}),
|
|
626
|
-
bottomLinks: Object.freeze([
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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: "
|
|
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: "
|
|
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
|
|
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,
|