@taprootio/site-authoring 0.2.0 → 0.3.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 +26 -5
- package/examples/riverbend-wellness/manifest.fixture.json +74 -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/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 +28 -0
- package/src/cli.js +50 -6
- package/src/constants.js +23 -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/fixture-contract.js +63 -0
- package/src/footer-contract.js +28 -30
- package/src/presentation-reference.js +5 -3
- package/src/reference-help.js +141 -6
- package/src/transport.js +89 -2
- package/src/verbs/nav-push.js +10 -9
- package/src/verbs/pull.js +7 -4
- package/src/verbs/validate.js +19 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"bin/",
|
|
25
|
+
"examples/",
|
|
25
26
|
"src/",
|
|
26
27
|
"LICENSE",
|
|
27
28
|
"README.md"
|
package/src/api.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CANONICAL_TIMESTAMP,
|
|
3
|
+
CAPABILITY_REFUSAL_FIELD,
|
|
3
4
|
LIMITS,
|
|
5
|
+
REFUSAL_CAPABILITY_MISSING,
|
|
4
6
|
REFUSAL_CREDENTIAL_REJECTED,
|
|
5
7
|
REFUSAL_PLAN_LIMIT,
|
|
6
8
|
REFUSAL_PLATFORM_PAUSED,
|
|
@@ -329,6 +331,32 @@ const REFUSAL_GUIDANCE = Object.freeze({
|
|
|
329
331
|
export function announceRefusal(error, onProgress, action = "request") {
|
|
330
332
|
if (!(error instanceof ApiError)) return;
|
|
331
333
|
const kind = error.refusalKind();
|
|
334
|
+
if (kind === REFUSAL_CAPABILITY_MISSING) {
|
|
335
|
+
const { permission, granted, required } = error.capability;
|
|
336
|
+
onProgress("");
|
|
337
|
+
onProgress(`CAPABILITY MISSING (refusal=${REFUSAL_CAPABILITY_MISSING}, field=${CAPABILITY_REFUSAL_FIELD})`);
|
|
338
|
+
onProgress(
|
|
339
|
+
` Taproot refused this ${action} because the credential it runs on was not exchanged for a capability `
|
|
340
|
+
+ `that grants '${permission}'.`,
|
|
341
|
+
);
|
|
342
|
+
onProgress(` Granted: ${granted.length > 0 ? granted.join(", ") : "(none)"}.`);
|
|
343
|
+
onProgress(
|
|
344
|
+
required.length > 0
|
|
345
|
+
? ` Carried by: ${required.join(", ")}.`
|
|
346
|
+
: " Carried by: no site-authoring capability — this permission is not reachable by any credential.",
|
|
347
|
+
);
|
|
348
|
+
// The credential is exactly what was asked for, so re-issuing it changes
|
|
349
|
+
// nothing. What is wrong is the verb's declared capability set, and saying
|
|
350
|
+
// so is the whole point of the named refusal: the first agent to meet this
|
|
351
|
+
// had to read the package source to find out.
|
|
352
|
+
onProgress(
|
|
353
|
+
` The credential is not the problem: the exchange minted precisely what the ${action} asked for. `
|
|
354
|
+
+ "The verb's declared capability set in the CLI's verb table is short of the requests it makes; "
|
|
355
|
+
+ "report it rather than minting a wider key.",
|
|
356
|
+
);
|
|
357
|
+
onProgress("");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
332
360
|
if (kind === REFUSAL_PLAN_LIMIT) {
|
|
333
361
|
onProgress("");
|
|
334
362
|
onProgress("PLAN LIMIT (refusal=plan_limit, field=UpgradePrompt)");
|
package/src/cli.js
CHANGED
|
@@ -75,7 +75,9 @@ const VERBS = Object.freeze([
|
|
|
75
75
|
offline: true,
|
|
76
76
|
note: "Reads manifest.fixture.json plus the fixture's page, navigation, theme, appearance, header, brand, and footer files. "
|
|
77
77
|
+ "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."
|
|
78
|
+
+ "persisted round trips, or rendering; run a real pull and authorized preview before deployment. "
|
|
79
|
+
+ `See '${CLI_BINARY_NAME} help fixture' for the manifest contract and for the path of the complete example `
|
|
80
|
+
+ "fixture this package ships, which needs no credential and no pulled site.",
|
|
79
81
|
},
|
|
80
82
|
{
|
|
81
83
|
name: VERB_LOGIN,
|
|
@@ -210,7 +212,10 @@ const VERBS = Object.freeze([
|
|
|
210
212
|
},
|
|
211
213
|
{
|
|
212
214
|
name: VERB_NAV_PUSH,
|
|
213
|
-
|
|
215
|
+
// Content is for the read, not the write: every PAGE nav item is checked
|
|
216
|
+
// against the live page list before the tree is replaced, and that list is
|
|
217
|
+
// gated on site.pages.edit_any (TR00691).
|
|
218
|
+
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
|
|
214
219
|
tokens: ["nav", "push"],
|
|
215
220
|
summary: "Replace the whole navigation tree from the local workspace.",
|
|
216
221
|
},
|
|
@@ -251,7 +256,18 @@ const VERBS = Object.freeze([
|
|
|
251
256
|
},
|
|
252
257
|
{
|
|
253
258
|
name: VERB_DEPLOY,
|
|
254
|
-
|
|
259
|
+
// Deployments is the only capability this verb *writes* with; the other two
|
|
260
|
+
// are for reads and re-reads the server makes on its behalf (TR00691).
|
|
261
|
+
// Content: --staging builds the candidate from the live page list
|
|
262
|
+
// (DeploySiteRequest.staged_page_ids names the ids explicitly), and that
|
|
263
|
+
// list is gated on site.pages.edit_any.
|
|
264
|
+
// Design: --production promotes a completed staging deployment, and the
|
|
265
|
+
// pipeline re-authorizes that deployment's *stored* candidate — every
|
|
266
|
+
// selected settings group and its navigation — against site.theme.manage,
|
|
267
|
+
// which only Design carries. Every candidate this CLI stages from a pulled
|
|
268
|
+
// workspace carries settings, navigation, or both, so a promotion without
|
|
269
|
+
// Design is refused.
|
|
270
|
+
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN, CAPABILITY_DEPLOYMENTS],
|
|
255
271
|
tokens: ["deploy"],
|
|
256
272
|
summary: "Deploy to staging, or promote staging to production.",
|
|
257
273
|
target: true,
|
|
@@ -265,6 +281,8 @@ const VERBS = Object.freeze([
|
|
|
265
281
|
json: true,
|
|
266
282
|
note: "Select the persisted draft by page path (as recorded by pull) or canonical lowercase UUID. "
|
|
267
283
|
+ "The homepage is recorded with an empty path, so address it as '/'. "
|
|
284
|
+
+ "Preview before approving: approve consumes the draft, so a page that has been approved has no draft "
|
|
285
|
+
+ "left to render and answers preview.no_draft. Review it on staging after a deploy instead. "
|
|
268
286
|
+ "The handoff URL in the result is single-use and expires two minutes after it is minted: opening it "
|
|
269
287
|
+ "consumes it, and a reused, shared, or bookmarked preview URL answers Not found. Run preview page again "
|
|
270
288
|
+ "for another. "
|
|
@@ -293,6 +311,23 @@ const VERBS = Object.freeze([
|
|
|
293
311
|
},
|
|
294
312
|
]);
|
|
295
313
|
|
|
314
|
+
/**
|
|
315
|
+
* The shipped verb table's capability declarations, keyed by verb name.
|
|
316
|
+
*
|
|
317
|
+
* Exported so the tests enforce the table rather than restate it. The claim
|
|
318
|
+
* each entry makes — "this is the smallest set this verb's requests need" —
|
|
319
|
+
* went four releases without anything checking it, and it was wrong twice:
|
|
320
|
+
* `nav push` and `deploy` were each short of a read they make on every run, and
|
|
321
|
+
* `deploy` was short again of the promotion re-authorization the server runs on
|
|
322
|
+
* state the request only names (TR00691). A verb with no entry needs no site
|
|
323
|
+
* credential at all.
|
|
324
|
+
*/
|
|
325
|
+
export const VERB_CAPABILITIES = Object.freeze(Object.fromEntries(
|
|
326
|
+
VERBS
|
|
327
|
+
.filter((verb) => verb.capabilities !== undefined)
|
|
328
|
+
.map((verb) => [verb.name, Object.freeze([...verb.capabilities])]),
|
|
329
|
+
));
|
|
330
|
+
|
|
296
331
|
const COMMON_OPTIONS = `Options:
|
|
297
332
|
--config <path> Place before a config-reading verb; bypass parent discovery.
|
|
298
333
|
--quiet Suppress human progress. The JSON result is unchanged.
|
|
@@ -464,6 +499,7 @@ function parseReferenceArguments(arguments_) {
|
|
|
464
499
|
|| topic === "theme"
|
|
465
500
|
|| topic === "appearance"
|
|
466
501
|
|| topic === "footer"
|
|
502
|
+
|| topic === "fixture"
|
|
467
503
|
)
|
|
468
504
|
&& (subject !== undefined || extra.length > 0)) {
|
|
469
505
|
throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
|
|
@@ -515,6 +551,7 @@ function referenceResult(parsed) {
|
|
|
515
551
|
case "nav":
|
|
516
552
|
case "media":
|
|
517
553
|
case "preview":
|
|
554
|
+
case "fixture":
|
|
518
555
|
return { ...result, topic: "workflow", referenceKind: parsed.topic, reference: getWorkflowReference(parsed.topic) };
|
|
519
556
|
case "theme":
|
|
520
557
|
return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
|
|
@@ -750,9 +787,16 @@ function parseArguments(arguments_) {
|
|
|
750
787
|
return {
|
|
751
788
|
mode: "run",
|
|
752
789
|
verb: verb.name,
|
|
753
|
-
// The smallest capability set this verb's requests need
|
|
754
|
-
//
|
|
755
|
-
//
|
|
790
|
+
// The smallest capability set this verb's requests need — every permission
|
|
791
|
+
// the server resolves for them, not only the ones the verb's writes name.
|
|
792
|
+
// It reaches the exchange so a content push never holds deploy, and a pull
|
|
793
|
+
// never holds delete (TR00645). Two things count beyond the writes
|
|
794
|
+
// (TR00691): a read (nav push and deploy each list the site's pages first,
|
|
795
|
+
// and that list is gated on a Content permission), and a re-authorization
|
|
796
|
+
// of server-held state (deploy --production re-checks the promoted
|
|
797
|
+
// candidate's stored settings and navigation against a Design permission).
|
|
798
|
+
// `VERB_CAPABILITIES` below is what the tests pin against the routes each
|
|
799
|
+
// verb actually calls.
|
|
756
800
|
capabilities: verb.capabilities,
|
|
757
801
|
configPath,
|
|
758
802
|
quiet,
|
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.3.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";
|
|
@@ -99,7 +99,7 @@ export const DEPLOY_TARGET_STAGING = "staging";
|
|
|
99
99
|
export const DEPLOY_TARGET_PRODUCTION = "production";
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* The refusal vocabulary TR00602/TR00603 speak, collapsed into the
|
|
102
|
+
* The refusal vocabulary TR00602/TR00603/TR00691 speak, collapsed into the five
|
|
103
103
|
* distinct client behaviors plus an explicit "we do not know" value. The CLI
|
|
104
104
|
* keeps these apart because an agent's correct next move differs for each:
|
|
105
105
|
* see `ApiError.refusalKind()` in transport.js.
|
|
@@ -108,6 +108,7 @@ export const REFUSAL_PLATFORM_PAUSED = "platform_paused";
|
|
|
108
108
|
export const REFUSAL_CREDENTIAL_REJECTED = "credential_rejected";
|
|
109
109
|
export const REFUSAL_PLAN_LIMIT = "plan_limit";
|
|
110
110
|
export const REFUSAL_THROTTLED = "throttled";
|
|
111
|
+
export const REFUSAL_CAPABILITY_MISSING = "capability_missing";
|
|
111
112
|
export const REFUSAL_UNCLASSIFIED = "unclassified";
|
|
112
113
|
|
|
113
114
|
export const REFUSAL_KINDS = Object.freeze([
|
|
@@ -115,6 +116,7 @@ export const REFUSAL_KINDS = Object.freeze([
|
|
|
115
116
|
REFUSAL_CREDENTIAL_REJECTED,
|
|
116
117
|
REFUSAL_PLAN_LIMIT,
|
|
117
118
|
REFUSAL_THROTTLED,
|
|
119
|
+
REFUSAL_CAPABILITY_MISSING,
|
|
118
120
|
REFUSAL_UNCLASSIFIED,
|
|
119
121
|
]);
|
|
120
122
|
|
|
@@ -125,6 +127,25 @@ export const ROLLOUT_REFUSAL_FIELD = "SiteAuthoringRollout";
|
|
|
125
127
|
export const CREDENTIAL_REFUSAL_FIELD = "ExternalApiKey";
|
|
126
128
|
export const PLAN_LIMIT_REFUSAL_FIELD = "UpgradePrompt";
|
|
127
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The `google.rpc.ErrorInfo` reason a key-mode permission denial carries
|
|
132
|
+
* (TR00691, `SiteAuthoringKeyDenial` on the server). A wire identity, matched
|
|
133
|
+
* exactly.
|
|
134
|
+
*/
|
|
135
|
+
export const CAPABILITY_REFUSAL_REASON = "SITE_AUTHORING_CAPABILITY_MISSING";
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The `field` a capability refusal reports on the JSON error.
|
|
139
|
+
*
|
|
140
|
+
* The server sends this refusal as `ErrorInfo`, not as a field violation — the
|
|
141
|
+
* request is well formed and the credential is too narrow, so a `BadRequest`
|
|
142
|
+
* would misstate the status. Automation still wants one shape to branch on, so
|
|
143
|
+
* the CLI names the field itself, exactly as `platform_paused` carries
|
|
144
|
+
* `SiteAuthoringRollout`. It is the CLI's own label rather than a value read
|
|
145
|
+
* off the wire, which is why nothing matches an incoming field against it.
|
|
146
|
+
*/
|
|
147
|
+
export const CAPABILITY_REFUSAL_FIELD = "GrantedCapabilities";
|
|
148
|
+
|
|
128
149
|
// gRPC `ResourceExhausted`. Transcoded responses carry the numeric code in the
|
|
129
150
|
// body; the HTTP mapping is 429.
|
|
130
151
|
export const GRPC_RESOURCE_EXHAUSTED = 8;
|
|
@@ -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;
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
"settings",
|
|
26
|
+
"settingsSkipped",
|
|
27
|
+
"fixture",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Root fields `pull` writes that a fixture may carry unchanged. They are
|
|
32
|
+
* accepted and read for nothing: a fixture proves local structure, and neither
|
|
33
|
+
* the snapshot time nor a recorded deployment can be checked without a server.
|
|
34
|
+
*/
|
|
35
|
+
export const FIXTURE_OPTIONAL_ROOT_FIELDS = Object.freeze(["pulledAt", "deployments"]);
|
|
36
|
+
|
|
37
|
+
/** Every root field the manifest may declare. Anything else is refused. */
|
|
38
|
+
export const FIXTURE_ROOT_FIELDS = Object.freeze(
|
|
39
|
+
[...FIXTURE_REQUIRED_ROOT_FIELDS, ...FIXTURE_OPTIONAL_ROOT_FIELDS].sort(),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** Every field the versioned `fixture` block may declare. All are required. */
|
|
43
|
+
export const FIXTURE_METADATA_FIELDS = Object.freeze(["contractVersion", "imageIds", "deliveryOrigins"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Delivery origins are restricted to the reserved example domain rather than
|
|
47
|
+
* merely to HTTPS. A fixture is example data that ships and is copied; a real
|
|
48
|
+
* delivery host in one would be a live reference in every copy of it.
|
|
49
|
+
*/
|
|
50
|
+
export const FIXTURE_DELIVERY_ORIGIN_DOMAIN = "example.test";
|
|
51
|
+
export const FIXTURE_MAXIMUM_DELIVERY_ORIGINS = 100;
|
|
52
|
+
|
|
53
|
+
/** The complete example fixture this package ships. */
|
|
54
|
+
export const SHIPPED_FIXTURE_NAME = "riverbend-wellness";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The absolute path of the shipped fixture, resolved from this module rather
|
|
58
|
+
* than from the caller's directory, so it is correct for a global install as
|
|
59
|
+
* well as a checkout. `help fixture` reports it and `validate` accepts it.
|
|
60
|
+
*/
|
|
61
|
+
export function shippedFixtureDirectory() {
|
|
62
|
+
return fileURLToPath(new URL(`../examples/${SHIPPED_FIXTURE_NAME}/`, import.meta.url)).replace(/[\\/]$/u, "");
|
|
63
|
+
}
|
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",
|
|
@@ -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,
|