@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/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.
|
|
@@ -75,7 +91,9 @@ const VERBS = Object.freeze([
|
|
|
75
91
|
offline: true,
|
|
76
92
|
note: "Reads manifest.fixture.json plus the fixture's page, navigation, theme, appearance, header, brand, and footer files. "
|
|
77
93
|
+ "This proves local structure and semantics only. It does not prove authorization, live site ownership, concurrency, "
|
|
78
|
-
+ "persisted round trips, or rendering; run a real pull and authorized preview before deployment."
|
|
94
|
+
+ "persisted round trips, or rendering; run a real pull and authorized preview before deployment. "
|
|
95
|
+
+ `See '${CLI_BINARY_NAME} help fixture' for the manifest contract and for the path of the complete example `
|
|
96
|
+
+ "fixture this package ships, which needs no credential and no pulled site.",
|
|
79
97
|
},
|
|
80
98
|
{
|
|
81
99
|
name: VERB_LOGIN,
|
|
@@ -195,8 +213,9 @@ const VERBS = Object.freeze([
|
|
|
195
213
|
+ "The homepage is recorded with an empty path, so address it as '/'. "
|
|
196
214
|
+ "A selected path resolves to its one authoritative source from metadata alone, and that page is then validated "
|
|
197
215
|
+ "exactly as a whole push would validate it — site binding, manifest integrity, live create-or-update "
|
|
198
|
-
+ "resolution, system-page rules, path uniqueness against the live site, media ownership,
|
|
199
|
-
+ "
|
|
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 "
|
|
200
219
|
+ "documents of pages it is not sending: an unrelated page left on an obsolete contract is reported by the "
|
|
201
220
|
+ "whole-workspace push, not used to block this one. The result states the selection and how many sources were "
|
|
202
221
|
+ "discovered and validated. "
|
|
@@ -210,10 +229,35 @@ const VERBS = Object.freeze([
|
|
|
210
229
|
},
|
|
211
230
|
{
|
|
212
231
|
name: VERB_NAV_PUSH,
|
|
213
|
-
|
|
232
|
+
// Content is for the read, not the write: every PAGE nav item is checked
|
|
233
|
+
// against the live page list before the tree is replaced, and that list is
|
|
234
|
+
// gated on site.pages.edit_any (TR00691).
|
|
235
|
+
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
|
|
214
236
|
tokens: ["nav", "push"],
|
|
215
237
|
summary: "Replace the whole navigation tree from the local workspace.",
|
|
216
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
|
+
},
|
|
217
261
|
{
|
|
218
262
|
name: VERB_THEME_PUSH,
|
|
219
263
|
capabilities: [CAPABILITY_DESIGN],
|
|
@@ -251,7 +295,18 @@ const VERBS = Object.freeze([
|
|
|
251
295
|
},
|
|
252
296
|
{
|
|
253
297
|
name: VERB_DEPLOY,
|
|
254
|
-
|
|
298
|
+
// Deployments is the only capability this verb *writes* with; the other two
|
|
299
|
+
// are for reads and re-reads the server makes on its behalf (TR00691).
|
|
300
|
+
// Content: --staging builds the candidate from the live page list
|
|
301
|
+
// (DeploySiteRequest.staged_page_ids names the ids explicitly), and that
|
|
302
|
+
// list is gated on site.pages.edit_any.
|
|
303
|
+
// Design: --production promotes a completed staging deployment, and the
|
|
304
|
+
// pipeline re-authorizes that deployment's *stored* candidate — every
|
|
305
|
+
// selected settings group and its navigation — against site.theme.manage,
|
|
306
|
+
// which only Design carries. Every candidate this CLI stages from a pulled
|
|
307
|
+
// workspace carries settings, navigation, or both, so a promotion without
|
|
308
|
+
// Design is refused.
|
|
309
|
+
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN, CAPABILITY_DEPLOYMENTS],
|
|
255
310
|
tokens: ["deploy"],
|
|
256
311
|
summary: "Deploy to staging, or promote staging to production.",
|
|
257
312
|
target: true,
|
|
@@ -265,6 +320,8 @@ const VERBS = Object.freeze([
|
|
|
265
320
|
json: true,
|
|
266
321
|
note: "Select the persisted draft by page path (as recorded by pull) or canonical lowercase UUID. "
|
|
267
322
|
+ "The homepage is recorded with an empty path, so address it as '/'. "
|
|
323
|
+
+ "Preview before approving: approve consumes the draft, so a page that has been approved has no draft "
|
|
324
|
+
+ "left to render and answers preview.no_draft. Review it on staging after a deploy instead. "
|
|
268
325
|
+ "The handoff URL in the result is single-use and expires two minutes after it is minted: opening it "
|
|
269
326
|
+ "consumes it, and a reused, shared, or bookmarked preview URL answers Not found. Run preview page again "
|
|
270
327
|
+ "for another. "
|
|
@@ -286,13 +343,30 @@ const VERBS = Object.freeze([
|
|
|
286
343
|
// Content.
|
|
287
344
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
288
345
|
tokens: ["status"],
|
|
289
|
-
summary: "Report deployments, readiness, and image processing.",
|
|
346
|
+
summary: "Report the platform authoring switch, the CLI release, deployments, readiness, and image processing.",
|
|
290
347
|
note: "Broken references are not included: that read remains"
|
|
291
348
|
+ " session-only on the server, so the result reports it as uncovered"
|
|
292
349
|
+ " rather than pretending an empty list means a clean site.",
|
|
293
350
|
},
|
|
294
351
|
]);
|
|
295
352
|
|
|
353
|
+
/**
|
|
354
|
+
* The shipped verb table's capability declarations, keyed by verb name.
|
|
355
|
+
*
|
|
356
|
+
* Exported so the tests enforce the table rather than restate it. The claim
|
|
357
|
+
* each entry makes — "this is the smallest set this verb's requests need" —
|
|
358
|
+
* went four releases without anything checking it, and it was wrong twice:
|
|
359
|
+
* `nav push` and `deploy` were each short of a read they make on every run, and
|
|
360
|
+
* `deploy` was short again of the promotion re-authorization the server runs on
|
|
361
|
+
* state the request only names (TR00691). A verb with no entry needs no site
|
|
362
|
+
* credential at all.
|
|
363
|
+
*/
|
|
364
|
+
export const VERB_CAPABILITIES = Object.freeze(Object.fromEntries(
|
|
365
|
+
VERBS
|
|
366
|
+
.filter((verb) => verb.capabilities !== undefined)
|
|
367
|
+
.map((verb) => [verb.name, Object.freeze([...verb.capabilities])]),
|
|
368
|
+
));
|
|
369
|
+
|
|
296
370
|
const COMMON_OPTIONS = `Options:
|
|
297
371
|
--config <path> Place before a config-reading verb; bypass parent discovery.
|
|
298
372
|
--quiet Suppress human progress. The JSON result is unchanged.
|
|
@@ -318,6 +392,15 @@ whoami, and env need no configuration and no site. The offline help family is
|
|
|
318
392
|
human-readable by default; add --json for stable reference data. Exit codes: 0
|
|
319
393
|
success, 1 failure, 2 usage fault.
|
|
320
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
|
+
|
|
321
404
|
Verbs:
|
|
322
405
|
${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(14)} ${verb.summary}`).join("\n")}
|
|
323
406
|
|
|
@@ -373,11 +456,22 @@ function verbHelp(verb) {
|
|
|
373
456
|
// would have had to gain.
|
|
374
457
|
const selfContained = verb.offline && !verb.readsLocalState;
|
|
375
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
|
+
: "";
|
|
376
469
|
const boundary = selfContained
|
|
377
|
-
? "This offline verb reads no configuration
|
|
470
|
+
? "This offline verb uses no credential and reads no configuration, and performs no network request and no "
|
|
471
|
+
+ `write.${upgradeGate}`
|
|
378
472
|
: verb.offline
|
|
379
473
|
? "This verb answers entirely from local state — the stored sign-in and the configuration — and makes no "
|
|
380
|
-
+
|
|
474
|
+
+ `network request and no write.${upgradeGate}`
|
|
381
475
|
: verb.credentialFree
|
|
382
476
|
? `This verb reads the configuration but requires no existing credential. `
|
|
383
477
|
+ `${PUBLISH_KEY_ENVIRONMENT_VARIABLE} always takes precedence over the stored credential, so setting it leaves `
|
|
@@ -464,6 +558,8 @@ function parseReferenceArguments(arguments_) {
|
|
|
464
558
|
|| topic === "theme"
|
|
465
559
|
|| topic === "appearance"
|
|
466
560
|
|| topic === "footer"
|
|
561
|
+
|| topic === "fixture"
|
|
562
|
+
|| topic === "redirects"
|
|
467
563
|
)
|
|
468
564
|
&& (subject !== undefined || extra.length > 0)) {
|
|
469
565
|
throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
|
|
@@ -513,8 +609,10 @@ function referenceResult(parsed) {
|
|
|
513
609
|
return { ...result, topic: "component", component };
|
|
514
610
|
}
|
|
515
611
|
case "nav":
|
|
612
|
+
case "redirects":
|
|
516
613
|
case "media":
|
|
517
614
|
case "preview":
|
|
615
|
+
case "fixture":
|
|
518
616
|
return { ...result, topic: "workflow", referenceKind: parsed.topic, reference: getWorkflowReference(parsed.topic) };
|
|
519
617
|
case "theme":
|
|
520
618
|
return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
|
|
@@ -750,9 +848,16 @@ function parseArguments(arguments_) {
|
|
|
750
848
|
return {
|
|
751
849
|
mode: "run",
|
|
752
850
|
verb: verb.name,
|
|
753
|
-
// The smallest capability set this verb's requests need
|
|
754
|
-
//
|
|
755
|
-
//
|
|
851
|
+
// The smallest capability set this verb's requests need — every permission
|
|
852
|
+
// the server resolves for them, not only the ones the verb's writes name.
|
|
853
|
+
// It reaches the exchange so a content push never holds deploy, and a pull
|
|
854
|
+
// never holds delete (TR00645). Two things count beyond the writes
|
|
855
|
+
// (TR00691): a read (nav push and deploy each list the site's pages first,
|
|
856
|
+
// and that list is gated on a Content permission), and a re-authorization
|
|
857
|
+
// of server-held state (deploy --production re-checks the promoted
|
|
858
|
+
// candidate's stored settings and navigation against a Design permission).
|
|
859
|
+
// `VERB_CAPABILITIES` below is what the tests pin against the routes each
|
|
860
|
+
// verb actually calls.
|
|
756
861
|
capabilities: verb.capabilities,
|
|
757
862
|
configPath,
|
|
758
863
|
quiet,
|
|
@@ -811,10 +916,14 @@ export async function runCli({
|
|
|
811
916
|
return 0;
|
|
812
917
|
}
|
|
813
918
|
if (parsed.mode === "reference") {
|
|
919
|
+
await assertCliCurrent(environment);
|
|
814
920
|
const result = referenceResult(parsed);
|
|
815
921
|
stdout.write(parsed.json ? `${serializeResult(result)}\n` : formatReferenceResult(result));
|
|
816
922
|
return 0;
|
|
817
923
|
}
|
|
924
|
+
if (VERSION_GATED_OFFLINE_VERBS.includes(parsed.verb)) {
|
|
925
|
+
await assertCliCurrent(environment);
|
|
926
|
+
}
|
|
818
927
|
// Own properties only: a verb name must never resolve through the
|
|
819
928
|
// prototype chain to something like `constructor`.
|
|
820
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.
|
|
2
|
+
export const CLI_VERSION = "0.4.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";
|
|
@@ -99,7 +101,7 @@ export const DEPLOY_TARGET_STAGING = "staging";
|
|
|
99
101
|
export const DEPLOY_TARGET_PRODUCTION = "production";
|
|
100
102
|
|
|
101
103
|
/**
|
|
102
|
-
* The refusal vocabulary TR00602/TR00603 speak, collapsed into the
|
|
104
|
+
* The refusal vocabulary TR00602/TR00603/TR00691 speak, collapsed into the five
|
|
103
105
|
* distinct client behaviors plus an explicit "we do not know" value. The CLI
|
|
104
106
|
* keeps these apart because an agent's correct next move differs for each:
|
|
105
107
|
* see `ApiError.refusalKind()` in transport.js.
|
|
@@ -108,6 +110,8 @@ export const REFUSAL_PLATFORM_PAUSED = "platform_paused";
|
|
|
108
110
|
export const REFUSAL_CREDENTIAL_REJECTED = "credential_rejected";
|
|
109
111
|
export const REFUSAL_PLAN_LIMIT = "plan_limit";
|
|
110
112
|
export const REFUSAL_THROTTLED = "throttled";
|
|
113
|
+
export const REFUSAL_CAPABILITY_MISSING = "capability_missing";
|
|
114
|
+
export const REFUSAL_CLI_OUTDATED = "cli_outdated";
|
|
111
115
|
export const REFUSAL_UNCLASSIFIED = "unclassified";
|
|
112
116
|
|
|
113
117
|
export const REFUSAL_KINDS = Object.freeze([
|
|
@@ -115,6 +119,8 @@ export const REFUSAL_KINDS = Object.freeze([
|
|
|
115
119
|
REFUSAL_CREDENTIAL_REJECTED,
|
|
116
120
|
REFUSAL_PLAN_LIMIT,
|
|
117
121
|
REFUSAL_THROTTLED,
|
|
122
|
+
REFUSAL_CAPABILITY_MISSING,
|
|
123
|
+
REFUSAL_CLI_OUTDATED,
|
|
118
124
|
REFUSAL_UNCLASSIFIED,
|
|
119
125
|
]);
|
|
120
126
|
|
|
@@ -125,6 +131,53 @@ export const ROLLOUT_REFUSAL_FIELD = "SiteAuthoringRollout";
|
|
|
125
131
|
export const CREDENTIAL_REFUSAL_FIELD = "ExternalApiKey";
|
|
126
132
|
export const PLAN_LIMIT_REFUSAL_FIELD = "UpgradePrompt";
|
|
127
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
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The `google.rpc.ErrorInfo` reason a key-mode permission denial carries
|
|
164
|
+
* (TR00691, `SiteAuthoringKeyDenial` on the server). A wire identity, matched
|
|
165
|
+
* exactly.
|
|
166
|
+
*/
|
|
167
|
+
export const CAPABILITY_REFUSAL_REASON = "SITE_AUTHORING_CAPABILITY_MISSING";
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The `field` a capability refusal reports on the JSON error.
|
|
171
|
+
*
|
|
172
|
+
* The server sends this refusal as `ErrorInfo`, not as a field violation — the
|
|
173
|
+
* request is well formed and the credential is too narrow, so a `BadRequest`
|
|
174
|
+
* would misstate the status. Automation still wants one shape to branch on, so
|
|
175
|
+
* the CLI names the field itself, exactly as `platform_paused` carries
|
|
176
|
+
* `SiteAuthoringRollout`. It is the CLI's own label rather than a value read
|
|
177
|
+
* off the wire, which is why nothing matches an incoming field against it.
|
|
178
|
+
*/
|
|
179
|
+
export const CAPABILITY_REFUSAL_FIELD = "GrantedCapabilities";
|
|
180
|
+
|
|
128
181
|
// gRPC `ResourceExhausted`. Transcoded responses carry the numeric code in the
|
|
129
182
|
// body; the HTTP mapping is 429.
|
|
130
183
|
export const GRPC_RESOURCE_EXHAUSTED = 8;
|
|
@@ -144,6 +197,10 @@ export const LIMITS = Object.freeze({
|
|
|
144
197
|
configDiscoveryParents: 32,
|
|
145
198
|
workspacePathBytes: 4 * 1024,
|
|
146
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,
|
|
147
204
|
requestMilliseconds: 60_000,
|
|
148
205
|
uploadMilliseconds: 5 * 60_000,
|
|
149
206
|
deploymentMilliseconds: 30 * 60_000,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { hasControlCharacter } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one rule for an outbound target on a footer link or a navigation item.
|
|
5
|
+
*
|
|
6
|
+
* A local business's phone number and address are the two things a visitor
|
|
7
|
+
* looks for in a header and a footer, and page links have accepted `tel:` and
|
|
8
|
+
* `mailto:` since D12. This is the same permissive scheme set the published
|
|
9
|
+
* renderer already applies to page content (`generator/src/url-safety.ts`,
|
|
10
|
+
* `shared/tiptap-prosemirror.ts`, `ux/client/src/common/sanitize-html.ts`),
|
|
11
|
+
* narrowed to what a *stored* target may be: absolute, so there is no base to
|
|
12
|
+
* resolve against at publish time, and credential-free, so a footer cannot
|
|
13
|
+
* carry someone's password into published HTML.
|
|
14
|
+
*
|
|
15
|
+
* `tel:` and `mailto:` are opaque URLs whose body is content — a number with
|
|
16
|
+
* its punctuation, an address with its plus-tag. Callers emit the value
|
|
17
|
+
* verbatim after this check rather than round-tripping it through
|
|
18
|
+
* `URL.toString()`, which re-encodes both.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Longest external target a footer link or a navigation item may carry. */
|
|
22
|
+
export const CONTACT_OR_WEB_URL_MAX_LENGTH = 2_048;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The requirement clause every layer says when it refuses a target, so the
|
|
26
|
+
* CLI, the editors, and the server all name the same four schemes.
|
|
27
|
+
*/
|
|
28
|
+
export const CONTACT_OR_WEB_URL_REQUIREMENT =
|
|
29
|
+
"must be an absolute http or https URL without credentials, or a mailto or tel contact URL";
|
|
30
|
+
|
|
31
|
+
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
|
|
32
|
+
const CONTACT_PROTOCOLS = new Set(["mailto:", "tel:"]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether `value` is a target the footer and navigation contracts accept.
|
|
36
|
+
*
|
|
37
|
+
* ASCII controls, C1 controls, and backslashes are refused before the scheme
|
|
38
|
+
* is looked at: browsers normalize them inconsistently, and a line break in a
|
|
39
|
+
* `mailto:` is the header-injection vector.
|
|
40
|
+
*/
|
|
41
|
+
export function isContactOrWebUrl(value, maximumLength = CONTACT_OR_WEB_URL_MAX_LENGTH) {
|
|
42
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maximumLength) return false;
|
|
43
|
+
if (hasControlCharacter(value) || value.includes("\\")) return false;
|
|
44
|
+
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = new URL(value);
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (WEB_PROTOCOLS.has(parsed.protocol)) {
|
|
53
|
+
return parsed.username === "" && parsed.password === "" && parsed.hostname !== "";
|
|
54
|
+
}
|
|
55
|
+
// The body is everything after `tel:` or `mailto:`; an empty one is a link
|
|
56
|
+
// to nothing, which publishes as a dead anchor rather than a refusal. A
|
|
57
|
+
// body that opens with `//` is an authority, which a contact URL has no use
|
|
58
|
+
// for, and the four layers agree on refusing it rather than on how each
|
|
59
|
+
// parser validates one.
|
|
60
|
+
return CONTACT_PROTOCOLS.has(parsed.protocol)
|
|
61
|
+
&& parsed.pathname !== ""
|
|
62
|
+
&& !value.slice(value.indexOf(":") + 1).startsWith("//");
|
|
63
|
+
}
|
|
@@ -149,7 +149,11 @@ const CTA = Object.freeze({
|
|
|
149
149
|
buttonText: str(),
|
|
150
150
|
buttonUrl: actionUrl(),
|
|
151
151
|
variant: enumOf("primary", "danger"),
|
|
152
|
-
borderWidth: num({
|
|
152
|
+
borderWidth: num({
|
|
153
|
+
integer: true,
|
|
154
|
+
minimum: 0,
|
|
155
|
+
description: "Pixel border around the call-to-action panel; 0 leaves it unbordered.",
|
|
156
|
+
}),
|
|
153
157
|
imagePosition: enumOf("top", "left", "right"),
|
|
154
158
|
mediaImage: image(),
|
|
155
159
|
});
|
|
@@ -167,7 +171,11 @@ const FEATURE_GRID = Object.freeze({
|
|
|
167
171
|
),
|
|
168
172
|
columns: num({ values: Object.freeze([2, 3, 4]) }),
|
|
169
173
|
iconSize: enumOf("small", "medium", "large", "xlarge"),
|
|
170
|
-
borderWidth: num({
|
|
174
|
+
borderWidth: num({
|
|
175
|
+
integer: true,
|
|
176
|
+
minimum: 0,
|
|
177
|
+
description: "Pixel border around each item, as on a card grid; the grid itself is never framed. 0 boxes nothing.",
|
|
178
|
+
}),
|
|
171
179
|
});
|
|
172
180
|
|
|
173
181
|
/** `TestimonialData` and its `TestimonialItem`. */
|
|
@@ -184,7 +192,11 @@ const TESTIMONIAL = Object.freeze({
|
|
|
184
192
|
columns: num({ values: Object.freeze([1, 2, 3]) }),
|
|
185
193
|
carousel: bool(),
|
|
186
194
|
interval: num({ integer: true, minimum: 2000 }),
|
|
187
|
-
borderWidth: num({
|
|
195
|
+
borderWidth: num({
|
|
196
|
+
integer: true,
|
|
197
|
+
minimum: 0,
|
|
198
|
+
description: "Pixel border around the whole set of quotations — the grid or the carousel; 0 leaves it unbordered.",
|
|
199
|
+
}),
|
|
188
200
|
});
|
|
189
201
|
|
|
190
202
|
/** `LatestPostsData` — resolved at generation time. */
|
|
@@ -212,7 +224,11 @@ const CARD_GRID = Object.freeze({
|
|
|
212
224
|
{ kind: "value", value: Object.freeze([]) },
|
|
213
225
|
),
|
|
214
226
|
columns: num({ values: Object.freeze([2, 3]) }),
|
|
215
|
-
borderWidth: num({
|
|
227
|
+
borderWidth: num({
|
|
228
|
+
integer: true,
|
|
229
|
+
minimum: 0,
|
|
230
|
+
description: "Pixel border around each card; the grid itself is never framed. 0 boxes nothing.",
|
|
231
|
+
}),
|
|
216
232
|
});
|
|
217
233
|
|
|
218
234
|
/** `ImageBannerData`. */
|
|
@@ -372,6 +388,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
372
388
|
[
|
|
373
389
|
"Each linked item needs a title that remains meaningful as link text.",
|
|
374
390
|
"Icon images need descriptive alt text when they convey information; otherwise use an empty alt value.",
|
|
391
|
+
"Item title and description are plain text: an item's url makes the whole item the link, and no link can be placed inside the text. Put linked prose in a paragraph beside the grid.",
|
|
375
392
|
],
|
|
376
393
|
{
|
|
377
394
|
items: [
|
|
@@ -428,6 +445,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
428
445
|
[
|
|
429
446
|
"Every linked card needs a title that describes its destination.",
|
|
430
447
|
"Card images need descriptive alt text unless they are decorative.",
|
|
448
|
+
"Card title and description are plain text: a card's linkUrl makes the whole card the link, and no link can be placed inside the text. Put linked prose in a paragraph beside the grid.",
|
|
431
449
|
],
|
|
432
450
|
{
|
|
433
451
|
cards: [
|
|
@@ -544,6 +562,10 @@ function referenceSchema(spec) {
|
|
|
544
562
|
if (spec.values) schema.enum = [...spec.values];
|
|
545
563
|
if (spec.minimum !== undefined) schema.minimum = spec.minimum;
|
|
546
564
|
if (spec.maximum !== undefined) schema.maximum = spec.maximum;
|
|
565
|
+
// A numeric field whose units say nothing about what the number does —
|
|
566
|
+
// `borderWidth` is the case — carries its meaning here so `help
|
|
567
|
+
// component` prints it beside the constraints.
|
|
568
|
+
if (spec.description) schema.description = spec.description;
|
|
547
569
|
return schema;
|
|
548
570
|
}
|
|
549
571
|
case "array":
|
|
@@ -370,7 +370,7 @@
|
|
|
370
370
|
"inlineFacts": {
|
|
371
371
|
"nodeType": "inlineFacts",
|
|
372
372
|
"label": "Inline facts",
|
|
373
|
-
"help": "A compact ordered row of
|
|
373
|
+
"help": "A compact ordered row of facts whose values may be native links. A fact that names itself needs no label.",
|
|
374
374
|
"attrs": {
|
|
375
375
|
"items": {
|
|
376
376
|
"type": "array",
|
|
@@ -388,7 +388,8 @@
|
|
|
388
388
|
},
|
|
389
389
|
"label": {
|
|
390
390
|
"type": "plain-text",
|
|
391
|
-
"required":
|
|
391
|
+
"required": false,
|
|
392
|
+
"default": null,
|
|
392
393
|
"minScalars": 1,
|
|
393
394
|
"maxScalars": 120,
|
|
394
395
|
"nonWhitespace": true
|
|
@@ -64,9 +64,13 @@ export function normalizeInlineFactsItems(
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// The value is the fact; the label names it when the value does not name
|
|
68
|
+
// itself. An omitted or null label is the standalone fact, while an empty
|
|
69
|
+
// string stays refused so a half-authored item is still caught.
|
|
67
70
|
for (const name of ["value", "label"]) {
|
|
68
71
|
const field = definition.fields[name];
|
|
69
72
|
const entry = item[name];
|
|
73
|
+
if (!field.required && (entry === undefined || entry === null)) continue;
|
|
70
74
|
if (typeof entry !== "string" || (field.nonWhitespace && entry.trim() === "")) {
|
|
71
75
|
report(`${itemPath}/${name}`, CODES.attrInvalid, `Inline fact ${name} must contain non-whitespace text.`);
|
|
72
76
|
} else if (scalarLength(entry, field.maxScalars) > field.maxScalars) {
|
|
@@ -88,7 +92,7 @@ export function normalizeInlineFactsItems(
|
|
|
88
92
|
|
|
89
93
|
items.push(Object.freeze({
|
|
90
94
|
value: item.value,
|
|
91
|
-
label: item.label,
|
|
95
|
+
...(typeof item.label === "string" ? { label: item.label } : {}),
|
|
92
96
|
...(typeof item.url === "string" ? { url: item.url.trim() } : {}),
|
|
93
97
|
}));
|
|
94
98
|
});
|
|
@@ -496,10 +496,20 @@ function countUnicodeScalars(value: string): number {
|
|
|
496
496
|
|
|
497
497
|
type NormalizedInlineFact = {
|
|
498
498
|
value: string;
|
|
499
|
-
label
|
|
499
|
+
label?: string;
|
|
500
500
|
url?: string;
|
|
501
501
|
};
|
|
502
502
|
|
|
503
|
+
/**
|
|
504
|
+
* The row is a list, not a description list: a fact whose value names itself
|
|
505
|
+
* carries no label, and `dl` has no valid shape for a `dd` without a `dt`.
|
|
506
|
+
* The label keeps its leading source position so the CSS `order` rules that
|
|
507
|
+
* lift the value above it stay unchanged for a labelled fact. The list roles
|
|
508
|
+
* are explicit because `list-style: none` makes WebKit drop them, and the
|
|
509
|
+
* label is separated from the value by a space: search indexers take adjacent
|
|
510
|
+
* inline elements as one word, and a whitespace-only anonymous flex item is
|
|
511
|
+
* not rendered, so the row looks the same.
|
|
512
|
+
*/
|
|
503
513
|
function renderInlineFacts(
|
|
504
514
|
node: ProseMirrorNode,
|
|
505
515
|
options: RenderProseMirrorOptions,
|
|
@@ -512,17 +522,18 @@ function renderInlineFacts(
|
|
|
512
522
|
const value = fact.url
|
|
513
523
|
? renderElement("a", { href: fact.url }, escapeHtml(fact.value))
|
|
514
524
|
: escapeHtml(fact.value);
|
|
525
|
+
const label = fact.label === undefined
|
|
526
|
+
? ""
|
|
527
|
+
: `${renderElement("span", { class: "taproot-inline-fact__label" }, escapeHtml(fact.label))} `;
|
|
515
528
|
return renderElement(
|
|
516
|
-
"
|
|
517
|
-
{ class: "taproot-inline-fact" },
|
|
518
|
-
`${renderElement("
|
|
519
|
-
renderElement("dd", { class: "taproot-inline-fact__value" }, value)
|
|
520
|
-
}`,
|
|
529
|
+
"li",
|
|
530
|
+
{ class: "taproot-inline-fact", role: "listitem" },
|
|
531
|
+
`${label}${renderElement("span", { class: "taproot-inline-fact__value" }, value)}`,
|
|
521
532
|
);
|
|
522
533
|
}).join("");
|
|
523
534
|
return renderElement(
|
|
524
|
-
"
|
|
525
|
-
withProseMeasure({ class: "taproot-inline-facts" }, node.type, options),
|
|
535
|
+
"ul",
|
|
536
|
+
withProseMeasure({ class: "taproot-inline-facts", role: "list" }, node.type, options),
|
|
526
537
|
items,
|
|
527
538
|
);
|
|
528
539
|
}
|
|
@@ -544,14 +555,17 @@ function normalizeInlineFacts(node: ProseMirrorNode): NormalizedInlineFact[] | u
|
|
|
544
555
|
for (const value of node.attrs.items) {
|
|
545
556
|
if (!isRecord(value) || !hasOnlyKeys(value, fieldKeys)) return undefined;
|
|
546
557
|
const factValue = stringAttr(value.value);
|
|
547
|
-
const label = stringAttr(value.label);
|
|
548
558
|
if (
|
|
549
559
|
!factValue
|
|
550
|
-
|| !label
|
|
551
560
|
|| factValue.trim() === ""
|
|
552
|
-
|| label.trim() === ""
|
|
553
561
|
|| countUnicodeScalars(factValue) > definition.fields.value.maxScalars
|
|
554
|
-
|
|
562
|
+
) return undefined;
|
|
563
|
+
// An omitted or null label is the standalone fact; an empty string is a
|
|
564
|
+
// half-authored one and still fails the row closed.
|
|
565
|
+
const label = value.label === undefined || value.label === null ? undefined : stringAttr(value.label);
|
|
566
|
+
if (
|
|
567
|
+
value.label !== undefined && value.label !== null
|
|
568
|
+
&& (!label || label.trim() === "" || countUnicodeScalars(label) > definition.fields.label.maxScalars)
|
|
555
569
|
) return undefined;
|
|
556
570
|
const url = value.url === undefined || value.url === null ? undefined : stringAttr(value.url);
|
|
557
571
|
if (value.url !== undefined && value.url !== null && (!url || !isSafeUrl(url))) return undefined;
|