@taprootio/site-authoring 0.6.2 → 0.7.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 +103 -10
- package/package.json +2 -2
- package/src/api.js +281 -61
- package/src/cli.js +73 -26
- package/src/constants.js +2 -1
- package/src/content/components.js +7 -33
- package/src/content/free-form-section-registry.json +4 -2
- package/src/content/inline-facts.js +6 -0
- package/src/content/tiptap-prosemirror.ts +22 -16
- package/src/content/vocabulary.js +6 -2
- package/src/errors.js +27 -0
- package/src/fixture-contract.js +4 -4
- package/src/fixture-init.js +231 -0
- package/src/output.js +12 -2
- package/src/reference-help.js +139 -89
- package/src/settings-catalog.js +1 -0
- package/src/staging-check.js +145 -0
- package/src/theme-validation.js +31 -12
- package/src/transport.js +12 -3
- package/src/verbs/deploy.js +35 -101
- package/src/verbs/index.js +4 -1
- package/src/verbs/pull.js +22 -5
- package/src/verbs/redirects-check.js +18 -0
- package/src/verbs/status.js +34 -17
- package/src/verbs/theme-push.js +44 -9
- package/src/verbs/validate.js +51 -12
package/src/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS, CAPABILITY_DESIGN } from "./capabilities.js";
|
|
1
2
|
import {
|
|
2
3
|
CLI_BINARY_NAME,
|
|
3
4
|
CLI_NAME,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
VERB_PREVIEW_PAGE,
|
|
24
25
|
VERB_PREVIEW_REVOKE,
|
|
25
26
|
VERB_PULL,
|
|
27
|
+
VERB_REDIRECTS_CHECK,
|
|
26
28
|
VERB_REDIRECTS_PULL,
|
|
27
29
|
VERB_REDIRECTS_PUSH,
|
|
28
30
|
VERB_SITES,
|
|
@@ -54,11 +56,6 @@ import {
|
|
|
54
56
|
REFERENCE_TOPICS,
|
|
55
57
|
REFERENCE_VERSION,
|
|
56
58
|
} from "./reference-help.js";
|
|
57
|
-
import {
|
|
58
|
-
CAPABILITY_CONTENT,
|
|
59
|
-
CAPABILITY_DEPLOYMENTS,
|
|
60
|
-
CAPABILITY_DESIGN,
|
|
61
|
-
} from "./capabilities.js";
|
|
62
59
|
import { assertCliCurrent } from "./session.js";
|
|
63
60
|
import { VERB_HANDLERS } from "./verbs/index.js";
|
|
64
61
|
|
|
@@ -86,14 +83,16 @@ const VERBS = Object.freeze([
|
|
|
86
83
|
{
|
|
87
84
|
name: VERB_VALIDATE,
|
|
88
85
|
tokens: ["validate"],
|
|
89
|
-
summary: "Validate
|
|
86
|
+
summary: "Validate an offline fixture, or initialize one from a pulled workspace with --init.",
|
|
90
87
|
positionals: "fixturePath",
|
|
91
88
|
offline: true,
|
|
92
|
-
note:
|
|
89
|
+
note:
|
|
90
|
+
"Reads manifest.fixture.json plus the fixture's page, navigation, theme, appearance, header, brand, and footer files. "
|
|
93
91
|
+ "This proves local structure and semantics only. It does not prove authorization, live site ownership, concurrency, "
|
|
94
92
|
+ "persisted round trips, or rendering; run a real pull and authorized preview before deployment. "
|
|
95
93
|
+ `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."
|
|
94
|
+
+ "fixture this package ships, which needs no credential and no pulled site. "
|
|
95
|
+
+ "With --init, the directory is a new destination; the current pulled workspace or --config supplies the source.",
|
|
97
96
|
},
|
|
98
97
|
{
|
|
99
98
|
name: VERB_LOGIN,
|
|
@@ -200,7 +199,9 @@ const VERBS = Object.freeze([
|
|
|
200
199
|
+ "Markdown is deliberately one-way, so a page edited on the site since the last pull cannot be rewritten as "
|
|
201
200
|
+ "Markdown: pull refuses with pages.pull_conflict before changing anything under 'pages/', preserves the "
|
|
202
201
|
+ "site's version under '.taproot-site-state/', and leaves you to either push the local source or delete it "
|
|
203
|
-
+ "and pull again. A locally edited '.pm.json' is kept rather than overwritten for the same reason."
|
|
202
|
+
+ "and pull again. A locally edited '.pm.json' is kept rather than overwritten for the same reason. "
|
|
203
|
+
+ "First revisions adopted without a completed body comparison are counted in pages.revisionsRecordedWithoutBodyComparison "
|
|
204
|
+
+ "and announced on the human channel. New downloads and compared bodies are excluded.",
|
|
204
205
|
},
|
|
205
206
|
{
|
|
206
207
|
name: VERB_PAGES_PUSH,
|
|
@@ -209,7 +210,8 @@ const VERBS = Object.freeze([
|
|
|
209
210
|
summary: "Create and update pages from the local workspace.",
|
|
210
211
|
positionals: "pagePaths",
|
|
211
212
|
allowRawHtml: true,
|
|
212
|
-
note:
|
|
213
|
+
note:
|
|
214
|
+
"Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
|
|
213
215
|
+ "The homepage is recorded with an empty path, so address it as '/'. "
|
|
214
216
|
+ "A selected path resolves to its one authoritative source from metadata alone, and that page is then validated "
|
|
215
217
|
+ "exactly as a whole push would validate it — site binding, manifest integrity, live create-or-update "
|
|
@@ -236,6 +238,14 @@ const VERBS = Object.freeze([
|
|
|
236
238
|
tokens: ["nav", "push"],
|
|
237
239
|
summary: "Replace the whole navigation tree from the local workspace.",
|
|
238
240
|
},
|
|
241
|
+
{
|
|
242
|
+
name: VERB_REDIRECTS_CHECK,
|
|
243
|
+
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
244
|
+
tokens: ["redirects", "check"],
|
|
245
|
+
summary: "Check the current redirect map through the authenticated staging edge.",
|
|
246
|
+
note: "Reports path, HTTP status and Location without following redirects. Re-run after edge propagation. "
|
|
247
|
+
+ "The result includes a fresh single-use staging handoff; keep it private.",
|
|
248
|
+
},
|
|
239
249
|
{
|
|
240
250
|
name: VERB_REDIRECTS_PULL,
|
|
241
251
|
// A redirect is a content path, and both halves of the map — read and
|
|
@@ -297,9 +307,8 @@ const VERBS = Object.freeze([
|
|
|
297
307
|
name: VERB_DEPLOY,
|
|
298
308
|
// Deployments is the only capability this verb *writes* with; the other two
|
|
299
309
|
// are for reads and re-reads the server makes on its behalf (TR00691).
|
|
300
|
-
// Content:
|
|
301
|
-
//
|
|
302
|
-
// list is gated on site.pages.edit_any.
|
|
310
|
+
// Content: staging redirect checks read the current redirect map;
|
|
311
|
+
// production also re-authorizes the stored candidate's pages.
|
|
303
312
|
// Design: --production promotes a completed staging deployment, and the
|
|
304
313
|
// pipeline re-authorizes that deployment's *stored* candidate — every
|
|
305
314
|
// selected settings group and its navigation — against site.theme.manage,
|
|
@@ -340,13 +349,13 @@ const VERBS = Object.freeze([
|
|
|
340
349
|
{
|
|
341
350
|
name: VERB_STATUS,
|
|
342
351
|
// Readiness and the deployment log are Deployments; the image list is
|
|
343
|
-
// Content.
|
|
352
|
+
// Content, as is the broken-reference report.
|
|
344
353
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
345
354
|
tokens: ["status"],
|
|
346
|
-
summary:
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
+ "
|
|
355
|
+
summary:
|
|
356
|
+
"Report the platform authoring switch, CLI release, deployments, readiness, image processing, and broken references.",
|
|
357
|
+
note: "Broken references list pages with missing image IDs or page paths in their latest editable body."
|
|
358
|
+
+ " Content permission is required; a refused or failed read fails status rather than reporting a clean site.",
|
|
350
359
|
},
|
|
351
360
|
]);
|
|
352
361
|
|
|
@@ -436,7 +445,8 @@ function verbHelp(verb) {
|
|
|
436
445
|
: "";
|
|
437
446
|
const targetOption = verb.target
|
|
438
447
|
? `\n --staging Deploy the staged site to staging.
|
|
439
|
-
--production Promote the completed staging deployment to production
|
|
448
|
+
--production Promote the completed staging deployment to production.
|
|
449
|
+
--allow-failed-preview Explicitly override the matching candidate's failed preview.`
|
|
440
450
|
: "";
|
|
441
451
|
const rawHtmlOption = verb.allowRawHtml
|
|
442
452
|
? "\n --allow-raw-html Permit rawHtml nodes. They render verbatim and unsanitized."
|
|
@@ -466,7 +476,10 @@ function verbHelp(verb) {
|
|
|
466
476
|
? " It refuses only when a previous sign-in exchange recorded a newer published release than this CLI, because "
|
|
467
477
|
+ "Taproot accepts only the latest; with nothing recorded it runs."
|
|
468
478
|
: "";
|
|
469
|
-
const boundary =
|
|
479
|
+
const boundary = verb.name === VERB_VALIDATE
|
|
480
|
+
? "Validation uses no credential, configuration, network or write. With --init it reads the pulled workspace "
|
|
481
|
+
+ `or selected configuration and writes a new fixture directory, still without credentials or network.${upgradeGate}`
|
|
482
|
+
: selfContained
|
|
470
483
|
? "This offline verb uses no credential and reads no configuration, and performs no network request and no "
|
|
471
484
|
+ `write.${upgradeGate}`
|
|
472
485
|
: verb.offline
|
|
@@ -478,7 +491,14 @@ function verbHelp(verb) {
|
|
|
478
491
|
+ `existing automation unaffected by login and logout.`
|
|
479
492
|
: `The site-scoped credential is taken from ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} when it is set, and otherwise `
|
|
480
493
|
+ `from the credential '${CLI_BINARY_NAME} ${VERB_LOGIN}' stores outside the repository.`;
|
|
481
|
-
const options =
|
|
494
|
+
const options = verb.name === VERB_VALIDATE
|
|
495
|
+
? `Options:
|
|
496
|
+
--init Export a pulled workspace into the new fixture directory.
|
|
497
|
+
--config <path> Before the verb; source configuration for --init only.
|
|
498
|
+
--quiet Suppress human progress. The JSON result is unchanged.
|
|
499
|
+
--help Show this help.
|
|
500
|
+
--version Show the package version.`
|
|
501
|
+
: selfContained
|
|
482
502
|
? `Options:
|
|
483
503
|
--quiet Suppress human progress. The JSON result is unchanged.
|
|
484
504
|
--help Show this help.
|
|
@@ -561,7 +581,8 @@ function parseReferenceArguments(arguments_) {
|
|
|
561
581
|
|| topic === "fixture"
|
|
562
582
|
|| topic === "redirects"
|
|
563
583
|
)
|
|
564
|
-
&& (subject !== undefined || extra.length > 0)
|
|
584
|
+
&& (subject !== undefined || extra.length > 0)
|
|
585
|
+
) {
|
|
565
586
|
throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
|
|
566
587
|
}
|
|
567
588
|
if ((topic === "page" || topic === "component") && (subject === undefined || extra.length > 0)) {
|
|
@@ -613,7 +634,12 @@ function referenceResult(parsed) {
|
|
|
613
634
|
case "media":
|
|
614
635
|
case "preview":
|
|
615
636
|
case "fixture":
|
|
616
|
-
return {
|
|
637
|
+
return {
|
|
638
|
+
...result,
|
|
639
|
+
topic: "workflow",
|
|
640
|
+
referenceKind: parsed.topic,
|
|
641
|
+
reference: getWorkflowReference(parsed.topic),
|
|
642
|
+
};
|
|
617
643
|
case "theme":
|
|
618
644
|
return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
|
|
619
645
|
case "appearance":
|
|
@@ -699,7 +725,10 @@ function parseArguments(arguments_) {
|
|
|
699
725
|
// origin the sign-in belongs to — so they accept it. whoami is offline and
|
|
700
726
|
// reads both the store and the configuration, so it accepts it too: the test
|
|
701
727
|
// is "reads nothing local", not "makes no request".
|
|
702
|
-
if (
|
|
728
|
+
if (
|
|
729
|
+
verb.offline && !verb.readsLocalState && configPath !== undefined
|
|
730
|
+
&& !(verb.name === VERB_VALIDATE && rest.includes("--init"))
|
|
731
|
+
) {
|
|
703
732
|
throw usageError("cli.config_option", "--config applies only to verbs that read the site configuration.", {
|
|
704
733
|
field: "configPath",
|
|
705
734
|
});
|
|
@@ -707,14 +736,21 @@ function parseArguments(arguments_) {
|
|
|
707
736
|
if (rest.length === 1 && rest[0] === "--help") return { mode: "verb_help", verb };
|
|
708
737
|
if (rest.length === 1 && rest[0] === "--version") return { mode: "version" };
|
|
709
738
|
|
|
739
|
+
let init = false;
|
|
710
740
|
let quiet = false;
|
|
711
741
|
let deployTarget;
|
|
742
|
+
let allowFailedPreview = false;
|
|
712
743
|
let allowRawHtml = false;
|
|
713
744
|
let json = false;
|
|
714
745
|
let keyName;
|
|
715
746
|
const positionals = [];
|
|
716
747
|
for (let index = 0; index < rest.length; index += 1) {
|
|
717
748
|
const argument = rest[index];
|
|
749
|
+
if (verb.name === VERB_VALIDATE && argument === "--init") {
|
|
750
|
+
if (init) throw usageError("cli.duplicate_option", "--init may be supplied only once.");
|
|
751
|
+
init = true;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
718
754
|
if (argument === "--quiet") {
|
|
719
755
|
if (quiet) throw usageError("cli.duplicate_option", "--quiet may be supplied only once.");
|
|
720
756
|
quiet = true;
|
|
@@ -739,6 +775,13 @@ function parseArguments(arguments_) {
|
|
|
739
775
|
deployTarget = candidate;
|
|
740
776
|
continue;
|
|
741
777
|
}
|
|
778
|
+
if (verb.target && argument === "--allow-failed-preview") {
|
|
779
|
+
if (allowFailedPreview) {
|
|
780
|
+
throw usageError("cli.duplicate_option", "--allow-failed-preview may be supplied only once.");
|
|
781
|
+
}
|
|
782
|
+
allowFailedPreview = true;
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
742
785
|
if (verb.allowRawHtml && argument === "--allow-raw-html") {
|
|
743
786
|
if (allowRawHtml) {
|
|
744
787
|
throw usageError("cli.duplicate_option", "--allow-raw-html may be supplied only once.");
|
|
@@ -852,8 +895,8 @@ function parseArguments(arguments_) {
|
|
|
852
895
|
// the server resolves for them, not only the ones the verb's writes name.
|
|
853
896
|
// It reaches the exchange so a content push never holds deploy, and a pull
|
|
854
897
|
// never holds delete (TR00645). Two things count beyond the writes
|
|
855
|
-
// (TR00691):
|
|
856
|
-
//
|
|
898
|
+
// (TR00691): reads (nav push lists pages; deploy checks the redirect map),
|
|
899
|
+
// each gated on Content, and re-authorization
|
|
857
900
|
// of server-held state (deploy --production re-checks the promoted
|
|
858
901
|
// candidate's stored settings and navigation against a Design permission).
|
|
859
902
|
// `VERB_CAPABILITIES` below is what the tests pin against the routes each
|
|
@@ -862,6 +905,8 @@ function parseArguments(arguments_) {
|
|
|
862
905
|
configPath,
|
|
863
906
|
quiet,
|
|
864
907
|
deployTarget,
|
|
908
|
+
allowFailedPreview,
|
|
909
|
+
init,
|
|
865
910
|
allowRawHtml,
|
|
866
911
|
keyName,
|
|
867
912
|
positionals: verb.positionals
|
|
@@ -936,6 +981,8 @@ export async function runCli({
|
|
|
936
981
|
environment,
|
|
937
982
|
configPath: parsed.configPath,
|
|
938
983
|
deployTarget: parsed.deployTarget,
|
|
984
|
+
allowFailedPreview: parsed.allowFailedPreview,
|
|
985
|
+
init: parsed.init,
|
|
939
986
|
quiet: parsed.quiet,
|
|
940
987
|
allowRawHtml: parsed.allowRawHtml,
|
|
941
988
|
keyName: parsed.keyName,
|
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.7.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,7 @@ 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_CHECK = "redirects check";
|
|
85
86
|
export const VERB_REDIRECTS_PULL = "redirects pull";
|
|
86
87
|
export const VERB_REDIRECTS_PUSH = "redirects push";
|
|
87
88
|
export const VERB_THEME_PUSH = "theme push";
|
|
@@ -61,7 +61,6 @@ const list = (item) => Object.freeze({ kind: "array", item });
|
|
|
61
61
|
const shape = (fields, options = {}) => Object.freeze({ kind: "object", fields: Object.freeze(fields), ...options });
|
|
62
62
|
const image = () => Object.freeze({ kind: "image" });
|
|
63
63
|
const safeUrl = (options = {}) => Object.freeze({ kind: "safe-url", ...options });
|
|
64
|
-
const actionUrl = () => Object.freeze({ kind: "action-url" });
|
|
65
64
|
const textureName = () => Object.freeze({ kind: "texture-name", maximumLength: 64 });
|
|
66
65
|
const withOmission = (spec, whenOmitted) => Object.freeze({ ...spec, whenOmitted: Object.freeze(whenOmitted) });
|
|
67
66
|
|
|
@@ -149,7 +148,7 @@ const CTA = Object.freeze({
|
|
|
149
148
|
heading: str(),
|
|
150
149
|
description: str(),
|
|
151
150
|
buttonText: str(),
|
|
152
|
-
buttonUrl:
|
|
151
|
+
buttonUrl: safeUrl(),
|
|
153
152
|
variant: enumOf("primary", "danger"),
|
|
154
153
|
borderWidth: num({
|
|
155
154
|
integer: true,
|
|
@@ -534,15 +533,9 @@ function referenceSchema(spec) {
|
|
|
534
533
|
return {
|
|
535
534
|
type: "string",
|
|
536
535
|
format: spec.allowEmpty === false ? "safe-url" : "safe-url-or-empty",
|
|
537
|
-
description:
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
case "action-url":
|
|
541
|
-
return {
|
|
542
|
-
type: "string",
|
|
543
|
-
format: "action-url-or-empty",
|
|
544
|
-
description:
|
|
545
|
-
"Empty, an explicit HTTP(S) URL, or a root-relative path that does not begin with //. Backslashes and ASCII control characters are rejected.",
|
|
536
|
+
description: `${
|
|
537
|
+
spec.allowEmpty === false ? "An" : "Empty, or an"
|
|
538
|
+
} explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected. Contact URLs require a non-empty body and refuse tel:// and mailto:// authority forms.`,
|
|
546
539
|
};
|
|
547
540
|
case "texture-name":
|
|
548
541
|
return {
|
|
@@ -645,9 +638,9 @@ function describe(spec) {
|
|
|
645
638
|
case "uuid":
|
|
646
639
|
return "a canonical lowercase UUID";
|
|
647
640
|
case "safe-url":
|
|
648
|
-
return `${
|
|
649
|
-
|
|
650
|
-
|
|
641
|
+
return `${
|
|
642
|
+
spec.allowEmpty === false ? "a" : "an empty string or a"
|
|
643
|
+
} safe HTTP(S), mailto, tel, non-protocol-relative root path, fragment, query, or relative URL; backslashes and ASCII control characters are rejected`;
|
|
651
644
|
case "texture-name":
|
|
652
645
|
return "a lowercase hyphenated texture identifier of at most 64 characters";
|
|
653
646
|
default:
|
|
@@ -682,25 +675,6 @@ function validateValue(spec, value, path, errors) {
|
|
|
682
675
|
fail(describe(spec));
|
|
683
676
|
}
|
|
684
677
|
return;
|
|
685
|
-
case "action-url": {
|
|
686
|
-
if (typeof value !== "string") {
|
|
687
|
-
fail(describe(spec));
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
const trimmed = value.trim();
|
|
691
|
-
const lower = trimmed.toLowerCase();
|
|
692
|
-
if (
|
|
693
|
-
value !== ""
|
|
694
|
-
&& (trimmed === ""
|
|
695
|
-
|| value.includes("\\")
|
|
696
|
-
|| hasAsciiControl(value)
|
|
697
|
-
|| trimmed.startsWith("//")
|
|
698
|
-
|| (!trimmed.startsWith("/") && !lower.startsWith("http://") && !lower.startsWith("https://")))
|
|
699
|
-
) {
|
|
700
|
-
fail(describe(spec));
|
|
701
|
-
}
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
678
|
case "texture-name":
|
|
705
679
|
if (
|
|
706
680
|
typeof value !== "string"
|
|
@@ -384,7 +384,8 @@
|
|
|
384
384
|
"required": true,
|
|
385
385
|
"minScalars": 1,
|
|
386
386
|
"maxScalars": 120,
|
|
387
|
-
"nonWhitespace": true
|
|
387
|
+
"nonWhitespace": true,
|
|
388
|
+
"pattern": "^(?![\\s\\S]*<[bB][rR]\\s*/?>)[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f\\u2028\\u2029\\ufeff]*$"
|
|
388
389
|
},
|
|
389
390
|
"label": {
|
|
390
391
|
"type": "plain-text",
|
|
@@ -392,7 +393,8 @@
|
|
|
392
393
|
"default": null,
|
|
393
394
|
"minScalars": 1,
|
|
394
395
|
"maxScalars": 120,
|
|
395
|
-
"nonWhitespace": true
|
|
396
|
+
"nonWhitespace": true,
|
|
397
|
+
"pattern": "^(?![\\s\\S]*<[bB][rR]\\s*/?>)[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f\\u2028\\u2029\\ufeff]*$"
|
|
396
398
|
},
|
|
397
399
|
"url": {
|
|
398
400
|
"type": "safe-url",
|
|
@@ -79,6 +79,12 @@ export function normalizeInlineFactsItems(
|
|
|
79
79
|
CODES.attrInvalid,
|
|
80
80
|
`Inline fact ${name} must contain at most ${field.maxScalars} Unicode scalar values.`,
|
|
81
81
|
);
|
|
82
|
+
} else if (!new RegExp(field.pattern, "u").test(entry)) {
|
|
83
|
+
report(
|
|
84
|
+
`${itemPath}/${name}`,
|
|
85
|
+
CODES.attrInvalid,
|
|
86
|
+
`Inline fact ${name} allows Unicode spaces, tabs, and LF/CRLF line breaks; other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal <br> tags are not supported.`,
|
|
87
|
+
);
|
|
82
88
|
}
|
|
83
89
|
}
|
|
84
90
|
|
|
@@ -52,6 +52,7 @@ export interface RenderProseMirrorOptions {
|
|
|
52
52
|
freeFormSections?: boolean;
|
|
53
53
|
imageDefaults?: {
|
|
54
54
|
placement?: TiptapImagePresentationPlacement | string | null;
|
|
55
|
+
/** The page shell emits this default once so rich-text formats inherit it. */
|
|
55
56
|
maxHeightVh?: TiptapImageMaxHeightVh | number | null;
|
|
56
57
|
};
|
|
57
58
|
}
|
|
@@ -503,8 +504,7 @@ type NormalizedInlineFact = {
|
|
|
503
504
|
/**
|
|
504
505
|
* The row is a list, not a description list: a fact whose value names itself
|
|
505
506
|
* carries no label, and `dl` has no valid shape for a `dd` without a `dt`.
|
|
506
|
-
*
|
|
507
|
-
* lift the value above it stay unchanged for a labelled fact. The list roles
|
|
507
|
+
* Source order matches the visual order: value, then label. The list roles
|
|
508
508
|
* are explicit because `list-style: none` makes WebKit drop them, and the
|
|
509
509
|
* label is separated from the value by a space: search indexers take adjacent
|
|
510
510
|
* inline elements as one word, and a whitespace-only anonymous flex item is
|
|
@@ -524,11 +524,11 @@ function renderInlineFacts(
|
|
|
524
524
|
: escapeHtml(fact.value);
|
|
525
525
|
const label = fact.label === undefined
|
|
526
526
|
? ""
|
|
527
|
-
:
|
|
527
|
+
: ` ${renderElement("span", { class: "taproot-inline-fact__label" }, escapeHtml(fact.label))}`;
|
|
528
528
|
return renderElement(
|
|
529
529
|
"li",
|
|
530
530
|
{ class: "taproot-inline-fact", role: "listitem" },
|
|
531
|
-
`${
|
|
531
|
+
`${renderElement("span", { class: "taproot-inline-fact__value" }, value)}${label}`,
|
|
532
532
|
);
|
|
533
533
|
}).join("");
|
|
534
534
|
return renderElement(
|
|
@@ -1013,7 +1013,7 @@ function resolveImagePresentation(
|
|
|
1013
1013
|
attrs: Record<string, JsonValue | undefined>,
|
|
1014
1014
|
defaults: RenderProseMirrorOptions["imageDefaults"],
|
|
1015
1015
|
): Record<string, string | number | undefined> {
|
|
1016
|
-
const
|
|
1016
|
+
const explicitMaxHeight = explicitImageMaxHeight(attrs.maxHeightVh);
|
|
1017
1017
|
const maxHeightOverride = normalizeCssLengthOverride(attrs.maxHeight, { allowNone: true });
|
|
1018
1018
|
const borderWidthOverride = normalizeCssLengthOverride(attrs.borderWidth);
|
|
1019
1019
|
const placement = resolvePlacement(attrs, defaults?.placement);
|
|
@@ -1022,9 +1022,12 @@ function resolveImagePresentation(
|
|
|
1022
1022
|
if (maxHeightOverride) styleParts.push(`--taproot-article-image-max-height: ${maxHeightOverride}`);
|
|
1023
1023
|
if (borderWidthOverride) styleParts.push(`--esp-image-border: ${imageBorderValue(borderWidthOverride)}`);
|
|
1024
1024
|
return {
|
|
1025
|
+
// The page shell owns the inherited site default so HTML and structured
|
|
1026
|
+
// rich text share it. Serialize only an explicit legacy choice; this
|
|
1027
|
+
// includes 60vh when it intentionally overrides a taller site default.
|
|
1025
1028
|
// An explicit CSS max height supersedes the legacy vh enum entirely.
|
|
1026
|
-
...(maxHeightOverride === null &&
|
|
1027
|
-
? { "data-image-max-height-vh":
|
|
1029
|
+
...(maxHeightOverride === null && explicitMaxHeight !== null
|
|
1030
|
+
? { "data-image-max-height-vh": explicitMaxHeight }
|
|
1028
1031
|
: {}),
|
|
1029
1032
|
...(maxHeightOverride ? { "data-image-max-height": maxHeightOverride } : {}),
|
|
1030
1033
|
...(borderWidthOverride ? { "data-image-border-width": borderWidthOverride } : {}),
|
|
@@ -1034,11 +1037,9 @@ function resolveImagePresentation(
|
|
|
1034
1037
|
};
|
|
1035
1038
|
}
|
|
1036
1039
|
|
|
1037
|
-
function
|
|
1038
|
-
if (value === TIPTAP_SITE_DEFAULT || value === undefined || value === null)
|
|
1039
|
-
|
|
1040
|
-
}
|
|
1041
|
-
return normalizeImageMaxHeight(value);
|
|
1040
|
+
function explicitImageMaxHeight(value: unknown): TiptapImageMaxHeightVh | null {
|
|
1041
|
+
if (value === TIPTAP_SITE_DEFAULT || value === undefined || value === null) return null;
|
|
1042
|
+
return isImageMaxHeightOption(value) ? imageMaxHeightOption(value) : null;
|
|
1042
1043
|
}
|
|
1043
1044
|
|
|
1044
1045
|
function resolvePlacement(
|
|
@@ -1068,10 +1069,15 @@ function isAbsentPresentationValue(value: unknown): boolean {
|
|
|
1068
1069
|
}
|
|
1069
1070
|
|
|
1070
1071
|
export function normalizeImageMaxHeight(value: unknown): TiptapImageMaxHeightVh {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1072
|
+
return isImageMaxHeightOption(value) ? imageMaxHeightOption(value) : DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function isImageMaxHeightOption(value: unknown): boolean {
|
|
1076
|
+
return TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS.includes(imageMaxHeightOption(value));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function imageMaxHeightOption(value: unknown): TiptapImageMaxHeightVh {
|
|
1080
|
+
return (typeof value === "number" ? value : Number(value)) as TiptapImageMaxHeightVh;
|
|
1075
1081
|
}
|
|
1076
1082
|
|
|
1077
1083
|
const CSS_LENGTH_UNITS = "px|em|rem|ch|ex|vw|vh|svh|lvh|dvh|svw|lvw|dvw|vmin|vmax|%";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isContactOrWebUrl } from "../contact-url.js";
|
|
1
2
|
import { sanitizeDiagnostic } from "../errors.js";
|
|
2
3
|
import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
|
|
3
4
|
|
|
@@ -17,10 +18,12 @@ import freeFormSectionRegistry from "./free-form-section-registry.json" with { t
|
|
|
17
18
|
* page that publishes successfully and renders wrong, so this package rejects
|
|
18
19
|
* them before they are sent.
|
|
19
20
|
*
|
|
20
|
-
* The rule for the reimplementations below: match the renderer
|
|
21
|
+
* The rule for the reimplementations below: match the renderer,
|
|
21
22
|
* including the parts that look like bugs. A "safer" copy would reject
|
|
22
23
|
* documents the renderer accepts, or accept documents it silently mangles,
|
|
23
|
-
* and either way the CLI would stop being the schema.
|
|
24
|
+
* and either way the CLI would stop being the schema. The explicit TR00743
|
|
25
|
+
* compatibility exception is contact URLs: new authoring refuses malformed
|
|
26
|
+
* authority forms while the renderer preserves previously stored links.
|
|
24
27
|
*/
|
|
25
28
|
|
|
26
29
|
// ---------------------------------------------------------------------------
|
|
@@ -227,6 +230,7 @@ export function isSafeUrl(url) {
|
|
|
227
230
|
|
|
228
231
|
try {
|
|
229
232
|
const parsed = new URL(cleaned);
|
|
233
|
+
if (["mailto:", "tel:"].includes(parsed.protocol)) return isContactOrWebUrl(cleaned);
|
|
230
234
|
return ["http:", "https:", "mailto:", "tel:"].includes(parsed.protocol);
|
|
231
235
|
} catch {
|
|
232
236
|
return true;
|
package/src/errors.js
CHANGED
|
@@ -54,6 +54,21 @@ export function normalizePreviewRecovery(preview) {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** No renderer messages or content: only stable typed diagnostic tokens. */
|
|
58
|
+
export function normalizePreviewDiagnostic(value) {
|
|
59
|
+
if (
|
|
60
|
+
!value || typeof value !== "object" || !isCanonicalUuid(value.pageId)
|
|
61
|
+
|| typeof value.errorClass !== "string" || !/^[a-z][a-zA-Z0-9_.-]{0,127}$/u.test(value.errorClass)
|
|
62
|
+
) return undefined;
|
|
63
|
+
const result = { errorClass: value.errorClass, pageId: value.pageId };
|
|
64
|
+
for (const field of ["nodeType", "attribute"]) {
|
|
65
|
+
if (value[field] === undefined || value[field] === "") continue;
|
|
66
|
+
if (typeof value[field] !== "string" || !/^[a-z][a-zA-Z0-9_-]{0,127}$/u.test(value[field])) return undefined;
|
|
67
|
+
result[field] = value[field];
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
function isUnsafeDiagnosticCodePoint(codePoint) {
|
|
58
73
|
return codePoint <= 0x1f
|
|
59
74
|
|| (codePoint >= DELETE_CODE_POINT && codePoint <= C1_LAST_CODE_POINT)
|
|
@@ -88,6 +103,18 @@ export class SiteAuthoringError extends Error {
|
|
|
88
103
|
this.code = typeof code === "string" && /^[a-z0-9_.-]+$/u.test(code)
|
|
89
104
|
? code
|
|
90
105
|
: "site.failed";
|
|
106
|
+
this.details = Array.isArray(options.details)
|
|
107
|
+
? options.details.slice(0, 256).filter((item) =>
|
|
108
|
+
item?.code === "theme.setting_missing"
|
|
109
|
+
&& typeof item.field === "string" && typeof item.message === "string"
|
|
110
|
+
)
|
|
111
|
+
.map((item) => ({
|
|
112
|
+
code: item.code,
|
|
113
|
+
field: sanitizeDiagnostic(item.field, "").slice(0, 200),
|
|
114
|
+
message: "is missing (added in a later contract; run taproot-site pull)",
|
|
115
|
+
}))
|
|
116
|
+
: undefined;
|
|
117
|
+
this.previewDiagnostic = normalizePreviewDiagnostic(options.previewDiagnostic);
|
|
91
118
|
this.field = typeof options.field === "string" ? sanitizeDiagnostic(options.field, "") : undefined;
|
|
92
119
|
this.status = typeof options.status === "string" ? sanitizeDiagnostic(options.status, "") : undefined;
|
|
93
120
|
this.alternatives = Array.isArray(options.alternatives)
|
package/src/fixture-contract.js
CHANGED
|
@@ -29,11 +29,11 @@ export const FIXTURE_REQUIRED_ROOT_FIELDS = Object.freeze([
|
|
|
29
29
|
]);
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* Optional pull metadata. Snapshot time and deployment receipts are not live
|
|
33
|
+
* evidence. When appearance/footer are present, validate checks their local
|
|
34
|
+
* image bindings and footer hashes against the fixture settings.
|
|
35
35
|
*/
|
|
36
|
-
export const FIXTURE_OPTIONAL_ROOT_FIELDS = Object.freeze(["pulledAt", "deployments"]);
|
|
36
|
+
export const FIXTURE_OPTIONAL_ROOT_FIELDS = Object.freeze(["pulledAt", "deployments", "appearance", "footer"]);
|
|
37
37
|
|
|
38
38
|
/** Every root field the manifest may declare. Anything else is refused. */
|
|
39
39
|
export const FIXTURE_ROOT_FIELDS = Object.freeze(
|