@taprootio/site-authoring 0.14.0 → 0.15.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 +62 -10
- package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
- package/examples/riverbend-wellness/pages/index.md +1 -17
- package/package.json +2 -2
- package/src/api.js +59 -1
- package/src/cli.js +147 -22
- package/src/constants.js +6 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/markdown.js +161 -26
- package/src/content/tiptap-prosemirror.ts +66 -8
- package/src/content/validate-document.js +30 -6
- package/src/content/vocabulary.js +26 -0
- package/src/errors.js +22 -0
- package/src/espalier-shared.js +11 -0
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +100 -34
- package/src/session.js +13 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +11 -3
- package/src/typed-pages.js +31 -9
- package/src/verbs/deploy.js +1 -0
- package/src/verbs/index.js +13 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-push.js +406 -199
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +585 -0
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +264 -217
- package/src/video-prepare.js +48 -12
- package/src/workspace-check.js +199 -0
- package/src/workspace-ledger.js +173 -0
- package/src/workspace.js +47 -4
|
@@ -258,6 +258,10 @@ const IMAGE_BANNER = Object.freeze({
|
|
|
258
258
|
image: image(),
|
|
259
259
|
altText: str(),
|
|
260
260
|
overlayText: str(),
|
|
261
|
+
// One supporting sentence and at most one link under the headline: the copy
|
|
262
|
+
// stack Espalier 6.1 lays out with a bounded measure (TR00819).
|
|
263
|
+
supportingText: boundedStr(280),
|
|
264
|
+
action: Object.freeze({ ...HERO_ACTION, nullable: true }),
|
|
261
265
|
fontSize: enumOf("large", "standard", "small"),
|
|
262
266
|
focus: shape({
|
|
263
267
|
x: num({ minimum: 0, maximum: 1 }),
|
|
@@ -268,7 +272,20 @@ const IMAGE_BANNER = Object.freeze({
|
|
|
268
272
|
// `ratio` keeps the wide/compact shape, capped at the viewport space below
|
|
269
273
|
// the site header; `viewport` fills exactly that space (TR00413).
|
|
270
274
|
heightMode: enumOf("ratio", "viewport"),
|
|
271
|
-
contentPosition: enumOf(
|
|
275
|
+
contentPosition: enumOf(
|
|
276
|
+
"bottom-start",
|
|
277
|
+
"bottom",
|
|
278
|
+
"bottom-end",
|
|
279
|
+
"center",
|
|
280
|
+
"top-start",
|
|
281
|
+
"top",
|
|
282
|
+
"top-end",
|
|
283
|
+
"middle-start",
|
|
284
|
+
"middle-end",
|
|
285
|
+
),
|
|
286
|
+
// Below the compact width the copy stays over the image, or moves into a
|
|
287
|
+
// band under it when the narrow crop has no quiet region.
|
|
288
|
+
compactPlacement: enumOf("overlay", "below"),
|
|
272
289
|
contentGutter: enumOf("frame", "page"),
|
|
273
290
|
followingSpacing: enumOf("standard", "compact"),
|
|
274
291
|
scrim: enumOf("auto", "none", "flat", "top", "bottom", "left", "right", "radial"),
|
|
@@ -574,12 +591,15 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
574
591
|
image: null,
|
|
575
592
|
altText: "",
|
|
576
593
|
overlayText: "",
|
|
594
|
+
supportingText: "",
|
|
595
|
+
action: null,
|
|
577
596
|
fontSize: "large",
|
|
578
597
|
focus: { x: 0.5, y: 0.5 },
|
|
579
598
|
ratio: "3/1",
|
|
580
599
|
compactRatio: "3/2",
|
|
581
600
|
heightMode: "ratio",
|
|
582
601
|
contentPosition: "center",
|
|
602
|
+
compactPlacement: "overlay",
|
|
583
603
|
contentGutter: "frame",
|
|
584
604
|
followingSpacing: "standard",
|
|
585
605
|
scrim: "auto",
|
|
@@ -597,8 +617,10 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
597
617
|
"For a full-bleed photograph with text aligned to the surrounding page well, set contentGutter to page. followingSpacing: compact reduces the top padding of an immediately following section so a bottom banner heading reads with its supporting paragraph. Use a banner-only contentPadding: none section followed by a standard text section; keep their theme contexts consistent. Defaults preserve frame-edge text and standard section spacing.",
|
|
598
618
|
"scrimEdge: flush finishes a directional scrim at full strength where it meets the page, so a full-bleed banner dissolves into the band below instead of ending at a visible line. It requires the arrangement above: a banner-only contentPadding: none section immediately followed by a section in the same theme context. It is inert when scrim is none, flat, or radial, and it stops being seamless when bannerScheme pins a polarity the page scheme does not share, because the scrim ink is then a derived color rather than the adjoining band's own. Default none; add it deliberately, per banner.",
|
|
599
619
|
"Compose around the actual photograph: identify the meaningful subject and a quiet area for copy. Do not cover faces, product details, or high-detail edges. A focal point controls cropping, not text placement; inspect wide and compact crops separately. Keep the subject's face, ears, and other identifying details inside the compact frame, including at both ends of parallax travel. Adjust focus toward the subject rather than reusing a generic center; reduce motion or choose a roomier crop if preserving the subject requires it.",
|
|
600
|
-
"Use contentPosition to place copy in that quiet area, not automatically in the center
|
|
601
|
-
"
|
|
620
|
+
"Use contentPosition to place copy in that quiet area, not automatically in the center: top, middle and bottom rows, each at start, center or end (middle-start and middle-end sit at mid-height against an edge; start and end mirror for right-to-left text). Keep one dominant message; if the photo has no safe text area, use a split hero or a separate text section instead of forcing an overlay.",
|
|
621
|
+
"The copy is a stack: overlayText is the headline, supportingText one optional supporting sentence (280 characters at most) set in the lead type, and action at most one link ({ label, url } with a safe URL, or null). The stack keeps its own spacing and a bounded line length, so long copy does not run the width of a wide banner, and the link is a real keyboard-reachable link with a visible focus ring. A headline alone lays out as before.",
|
|
622
|
+
"compactPlacement: below moves the copy into a band under the image at compact widths, on the banner scheme's own surface, for a crop that keeps its subject but loses the quiet region the copy sat on. Default overlay keeps the copy over the image. The banner grows instead of clipping when enlarged text or long labels need the room, past the height left below the site header if it must, and returns to its shape once the copy fits.",
|
|
623
|
+
"Use the rule of thirds as a composition guide: consider the subject near one third and a compact text group in quiet space on the opposite side. Golden-ratio proportions are optional alternatives, not a proven engagement formula. Neither guide overrides legibility, the actual subject, or the compact crop. contentPosition values are edge, middle and center anchors, not exact third or golden-ratio coordinates; do not invent unsupported positioning properties.",
|
|
602
624
|
"Check contrast behind the actual letters at both motion endpoints and both themes: WCAG AA requires 4.5:1 for ordinary text and 3:1 for qualifying large text. A text shadow alone is not a contrast guarantee. Keep text and actions stationary; recheck at mobile widths and enlarged text.",
|
|
603
625
|
"Prefer parallax-subtle or parallax when motion is useful: motion follows the reader's scrolling and stops when they stop. Infinite ambient loops are an explicit art-direction choice, not the default recommendation.",
|
|
604
626
|
"imageMotion is optional and defaults to none. slow-zoom is a cinematic 18% zoom over 6 seconds, alternating; drift-start and drift-end travel horizontally over 8 seconds with a 14% safety crop.",
|
|
@@ -610,9 +632,12 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
610
632
|
image: null,
|
|
611
633
|
altText: "",
|
|
612
634
|
overlayText: "Train together",
|
|
635
|
+
supportingText: "Small classes, every morning, for every level.",
|
|
636
|
+
action: { label: "See the schedule", url: "/classes" },
|
|
613
637
|
focus: { x: 0.5, y: 0.35 },
|
|
614
638
|
heightMode: "ratio",
|
|
615
|
-
contentPosition: "
|
|
639
|
+
contentPosition: "middle-start",
|
|
640
|
+
compactPlacement: "below",
|
|
616
641
|
scrim: "bottom",
|
|
617
642
|
scrimStrength: "medium",
|
|
618
643
|
texture: "paper",
|
|
@@ -681,6 +706,35 @@ export const COMPONENT_SHAPES = Object.freeze(Object.fromEntries(
|
|
|
681
706
|
Object.entries(COMPONENT_DEFINITIONS).map(([type, definition]) => [type, definition.fields]),
|
|
682
707
|
));
|
|
683
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Replaces each image field in parsed component data with
|
|
711
|
+
* `mapImage(value, path)`, following the component's field table. Markdown
|
|
712
|
+
* authoring turns media paths into stored image records this way, and help
|
|
713
|
+
* builds its Markdown examples the other way (TR01187). A value of the wrong
|
|
714
|
+
* shape is passed through for validation to report.
|
|
715
|
+
*/
|
|
716
|
+
export function mapComponentImages(componentType, data, mapImage) {
|
|
717
|
+
const fields = COMPONENT_SHAPES[componentType];
|
|
718
|
+
return fields !== undefined && isPlainObject(data) ? mapImageFields(fields, data, "", mapImage) : data;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function mapImageFields(fields, value, path, mapImage) {
|
|
722
|
+
const mapped = { ...value };
|
|
723
|
+
for (const [name, spec] of Object.entries(fields)) {
|
|
724
|
+
if (Object.hasOwn(value, name)) mapped[name] = mapImageValue(spec, value[name], `${path}/${name}`, mapImage);
|
|
725
|
+
}
|
|
726
|
+
return mapped;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function mapImageValue(spec, value, path, mapImage) {
|
|
730
|
+
if (spec.kind === "image") return mapImage(value, path);
|
|
731
|
+
if (spec.kind === "object" && isPlainObject(value)) return mapImageFields(spec.fields, value, path, mapImage);
|
|
732
|
+
if (spec.kind === "array" && Array.isArray(value)) {
|
|
733
|
+
return value.map((item, index) => mapImageValue(spec.item, item, `${path}/${index}`, mapImage));
|
|
734
|
+
}
|
|
735
|
+
return value;
|
|
736
|
+
}
|
|
737
|
+
|
|
684
738
|
export function getComponentDefinition(componentType) {
|
|
685
739
|
return typeof componentType === "string" && Object.hasOwn(COMPONENT_DEFINITIONS, componentType)
|
|
686
740
|
? COMPONENT_DEFINITIONS[componentType]
|
|
@@ -749,8 +803,12 @@ function referenceSchema(spec) {
|
|
|
749
803
|
case "image":
|
|
750
804
|
return {
|
|
751
805
|
type: ["object", "null"],
|
|
806
|
+
// What a Markdown component fence takes instead (TR01187).
|
|
807
|
+
markdownType: ["string", "null"],
|
|
752
808
|
description:
|
|
753
|
-
"
|
|
809
|
+
"In Markdown, a media path recorded by media upload (such as \"media/photo.webp\"), or null; pages push "
|
|
810
|
+
+ "stores the image record. A .pm.json document carries that record: keep imageId, src, and urls keys so "
|
|
811
|
+
+ "the server can rewrite delivery URLs.",
|
|
754
812
|
additionalProperties: false,
|
|
755
813
|
properties: referenceProperties(COMPONENT_IMAGE_FIELDS),
|
|
756
814
|
};
|
|
@@ -805,7 +863,7 @@ function describe(spec) {
|
|
|
805
863
|
case "object":
|
|
806
864
|
return "an object";
|
|
807
865
|
case "image":
|
|
808
|
-
return "an image object or null";
|
|
866
|
+
return "a media path in Markdown, an image object in .pm.json, or null";
|
|
809
867
|
case "uuid":
|
|
810
868
|
return "a canonical lowercase UUID";
|
|
811
869
|
case "safe-url":
|
|
@@ -18,6 +18,16 @@ function deepFreeze(value) {
|
|
|
18
18
|
|
|
19
19
|
export const FREE_FORM_SECTION_REGISTRY = deepFreeze(registry);
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* An image converted from a Markdown media path carries `"src": ""` and
|
|
23
|
+
* `"urls": []`, like body and component images: the API fills both from the
|
|
24
|
+
* image id at read time (TR01187). Only that exact pair is unfilled; any other
|
|
25
|
+
* empty value is still refused.
|
|
26
|
+
*/
|
|
27
|
+
function isUnfilledDelivery(image) {
|
|
28
|
+
return image.src === "" && Array.isArray(image.urls) && image.urls.length === 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
function decorationDeliveryUrlIsSafe(value) {
|
|
22
32
|
if (typeof value !== "string" || value === "" || /[\u0000-\u0020\u007f]/u.test(value) || value.includes("\\")) {
|
|
23
33
|
return false;
|
|
@@ -70,10 +80,11 @@ function normalizeProcessedSectionImage(value, definition, path, label, report)
|
|
|
70
80
|
if (Object.hasOwn(value, "imageId") && !isUuid(value.imageId)) {
|
|
71
81
|
reject(`${path}/imageId`, CODES.attrInvalid, `${label} imageId must be a canonical lowercase UUID.`);
|
|
72
82
|
}
|
|
83
|
+
const unfilled = isUnfilledDelivery(value);
|
|
73
84
|
if (Object.hasOwn(value, "src")) {
|
|
74
85
|
if (typeof value.src !== "string") {
|
|
75
86
|
reject(`${path}/src`, CODES.attrInvalid, `${label} src must be a string.`);
|
|
76
|
-
} else if (!decorationDeliveryUrlIsSafe(value.src)) {
|
|
87
|
+
} else if (!unfilled && !decorationDeliveryUrlIsSafe(value.src)) {
|
|
77
88
|
reject(
|
|
78
89
|
`${path}/src`,
|
|
79
90
|
value.src === "" ? CODES.imageKeys : CODES.attrInvalid,
|
|
@@ -88,7 +99,7 @@ function normalizeProcessedSectionImage(value, definition, path, label, report)
|
|
|
88
99
|
if (!Array.isArray(value.urls)) {
|
|
89
100
|
reject(`${path}/urls`, CODES.attrInvalid, `${label} urls must be an array.`);
|
|
90
101
|
} else {
|
|
91
|
-
if (value.urls.length < definition.urls.minItems || value.urls.length > definition.urls.maxItems) {
|
|
102
|
+
if (!unfilled && (value.urls.length < definition.urls.minItems || value.urls.length > definition.urls.maxItems)) {
|
|
92
103
|
reject(
|
|
93
104
|
`${path}/urls`,
|
|
94
105
|
CODES.attrInvalid,
|
|
@@ -138,6 +149,7 @@ function normalizeProcessedSectionImage(value, definition, path, label, report)
|
|
|
138
149
|
}
|
|
139
150
|
if (
|
|
140
151
|
definition.srcMustMatchUrls
|
|
152
|
+
&& !unfilled
|
|
141
153
|
&& typeof value.src === "string"
|
|
142
154
|
&& !value.urls.some((option) => isPlainObject(option) && option.url === value.src)
|
|
143
155
|
) {
|
|
@@ -354,10 +366,11 @@ export function normalizeFreeFormSectionDecoration(
|
|
|
354
366
|
report(`${imagePath}/imageId`, CODES.attrInvalid, "Section decoration imageId must be a canonical lowercase UUID.");
|
|
355
367
|
}
|
|
356
368
|
|
|
369
|
+
const unfilled = isUnfilledDelivery(image);
|
|
357
370
|
if (Object.hasOwn(image, "src")) {
|
|
358
371
|
if (typeof image.src !== "string") {
|
|
359
372
|
report(`${imagePath}/src`, CODES.attrInvalid, "Section decoration image src must be a string.");
|
|
360
|
-
} else if (!decorationDeliveryUrlIsSafe(image.src)) {
|
|
373
|
+
} else if (!unfilled && !decorationDeliveryUrlIsSafe(image.src)) {
|
|
361
374
|
report(
|
|
362
375
|
`${imagePath}/src`,
|
|
363
376
|
image.src === "" ? CODES.imageKeys : CODES.attrInvalid,
|
|
@@ -372,7 +385,10 @@ export function normalizeFreeFormSectionDecoration(
|
|
|
372
385
|
if (!Array.isArray(image.urls)) {
|
|
373
386
|
report(`${imagePath}/urls`, CODES.attrInvalid, "Section decoration image urls must be an array.");
|
|
374
387
|
} else {
|
|
375
|
-
if (
|
|
388
|
+
if (
|
|
389
|
+
!unfilled
|
|
390
|
+
&& (image.urls.length < imageDefinition.urls.minItems || image.urls.length > imageDefinition.urls.maxItems)
|
|
391
|
+
) {
|
|
376
392
|
report(
|
|
377
393
|
`${imagePath}/urls`,
|
|
378
394
|
CODES.attrInvalid,
|
|
@@ -421,6 +437,7 @@ export function normalizeFreeFormSectionDecoration(
|
|
|
421
437
|
}
|
|
422
438
|
if (
|
|
423
439
|
imageDefinition.srcMustMatchUrls
|
|
440
|
+
&& !unfilled
|
|
424
441
|
&& typeof image.src === "string"
|
|
425
442
|
&& !image.urls.some((option) => isPlainObject(option) && option.url === image.src)
|
|
426
443
|
) {
|
package/src/content/markdown.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
canonicalizeComponentData,
|
|
4
4
|
expandComponentAuthoringShorthand,
|
|
5
5
|
isComponentType,
|
|
6
|
+
mapComponentImages,
|
|
6
7
|
validateComponentBlock,
|
|
7
8
|
} from "./components.js";
|
|
8
9
|
import {
|
|
@@ -20,6 +21,8 @@ import {
|
|
|
20
21
|
isPlainObject,
|
|
21
22
|
isSafeUrl,
|
|
22
23
|
isUuid,
|
|
24
|
+
LINK_REL_TOKENS,
|
|
25
|
+
LINK_TARGETS,
|
|
23
26
|
MARK_TYPES,
|
|
24
27
|
} from "./vocabulary.js";
|
|
25
28
|
|
|
@@ -595,7 +598,7 @@ function consumeTable(lines, prelude, context) {
|
|
|
595
598
|
// Top-level section containers
|
|
596
599
|
// ---------------------------------------------------------------------------
|
|
597
600
|
|
|
598
|
-
function sectionHeaderAttrs(text, line) {
|
|
601
|
+
async function sectionHeaderAttrs(text, line, markdownContext) {
|
|
599
602
|
const match = SECTION_HEADER_RE.exec(text);
|
|
600
603
|
if (!match) {
|
|
601
604
|
throw fail(
|
|
@@ -674,6 +677,21 @@ function sectionHeaderAttrs(text, line) {
|
|
|
674
677
|
}
|
|
675
678
|
}
|
|
676
679
|
|
|
680
|
+
for (const [owner, field] of [["decoration", "image"], ["background", "image"], ["background", "portraitImage"]]) {
|
|
681
|
+
if (isPlainObject(parsed[owner]) && Object.hasOwn(parsed[owner], field)) {
|
|
682
|
+
parsed[owner] = {
|
|
683
|
+
...parsed[owner],
|
|
684
|
+
[field]: await authoredImageField(
|
|
685
|
+
parsed[owner][field],
|
|
686
|
+
`/${owner}/${field}`,
|
|
687
|
+
markdownContext,
|
|
688
|
+
line,
|
|
689
|
+
"section header",
|
|
690
|
+
),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
677
695
|
const normalizedDecoration = normalizeFreeFormSectionDecoration(
|
|
678
696
|
parsed.decoration,
|
|
679
697
|
"/attrs/decoration",
|
|
@@ -709,7 +727,7 @@ function fenceClosing(delimiter) {
|
|
|
709
727
|
|
|
710
728
|
async function consumeSection(lines, start, headerText, context, depth) {
|
|
711
729
|
const line = lines[start];
|
|
712
|
-
const attrs = sectionHeaderAttrs(headerText, line);
|
|
730
|
+
const attrs = await sectionHeaderAttrs(headerText, line, context);
|
|
713
731
|
const body = [];
|
|
714
732
|
let index = start + 1;
|
|
715
733
|
let activeFence;
|
|
@@ -773,7 +791,7 @@ function stripClosingHashes(title) {
|
|
|
773
791
|
// Fenced blocks: code, components, and inline facts
|
|
774
792
|
// ---------------------------------------------------------------------------
|
|
775
793
|
|
|
776
|
-
function consumeFence(lines, start, fence, context) {
|
|
794
|
+
async function consumeFence(lines, start, fence, context) {
|
|
777
795
|
const [, indent, delimiter, rawInfo] = fence;
|
|
778
796
|
const info = rawInfo.trim();
|
|
779
797
|
const line = lines[start];
|
|
@@ -801,7 +819,10 @@ function consumeFence(lines, start, fence, context) {
|
|
|
801
819
|
}
|
|
802
820
|
|
|
803
821
|
if (info.startsWith("component:")) {
|
|
804
|
-
return {
|
|
822
|
+
return {
|
|
823
|
+
node: await componentNode(info.slice("component:".length).trim(), body.join("\n"), line, context),
|
|
824
|
+
next: index,
|
|
825
|
+
};
|
|
805
826
|
}
|
|
806
827
|
if (info === INLINE_FACTS_CONTRACT.markdown.fence) {
|
|
807
828
|
if (context.blockPlacement !== "root" && context.blockPlacement !== "section") {
|
|
@@ -865,7 +886,7 @@ function stripIndent(text, columns) {
|
|
|
865
886
|
return text.slice(index);
|
|
866
887
|
}
|
|
867
888
|
|
|
868
|
-
function componentNode(componentType, body, line) {
|
|
889
|
+
async function componentNode(componentType, body, line, context) {
|
|
869
890
|
if (!isComponentType(componentType)) {
|
|
870
891
|
throw fail(
|
|
871
892
|
CODES.componentUnknown,
|
|
@@ -879,7 +900,22 @@ function componentNode(componentType, body, line) {
|
|
|
879
900
|
if (expanded.error !== undefined) {
|
|
880
901
|
throw fail(CODES.componentData, "component block", expanded.error, line.number);
|
|
881
902
|
}
|
|
882
|
-
|
|
903
|
+
let source = expanded.source;
|
|
904
|
+
const parsed = parseJsonOrUndefined(source);
|
|
905
|
+
if (parsed !== undefined) {
|
|
906
|
+
const imageFields = [];
|
|
907
|
+
mapComponentImages(componentType, parsed, (value, path) => {
|
|
908
|
+
imageFields.push([path, value]);
|
|
909
|
+
return value;
|
|
910
|
+
});
|
|
911
|
+
if (imageFields.length > 0) {
|
|
912
|
+
const resolved = new Map();
|
|
913
|
+
for (const [path, value] of imageFields) {
|
|
914
|
+
resolved.set(path, await authoredImageField(value, path, context, line, "component block"));
|
|
915
|
+
}
|
|
916
|
+
source = JSON.stringify(mapComponentImages(componentType, parsed, (_value, path) => resolved.get(path)));
|
|
917
|
+
}
|
|
918
|
+
}
|
|
883
919
|
const errors = validateComponentBlock(componentType, source, "");
|
|
884
920
|
if (errors.length > 0) {
|
|
885
921
|
throw fail(errors[0].code, "component block", errors[0].message, line.number);
|
|
@@ -1476,12 +1512,71 @@ function parseLink(text, index, scan, lineNumber, marks) {
|
|
|
1476
1512
|
);
|
|
1477
1513
|
}
|
|
1478
1514
|
|
|
1515
|
+
const options = parseLinkAttributes(text, destinationEnd + 1, lineNumber);
|
|
1516
|
+
const attrs = { href: destination, ...options?.attrs };
|
|
1479
1517
|
const inner = text.slice(index + 1, textEnd);
|
|
1480
|
-
const nodes = parseInline(inner, scan, lineNumber, [...marks, { type: "link", attrs
|
|
1518
|
+
const nodes = parseInline(inner, scan, lineNumber, [...marks, { type: "link", attrs }]);
|
|
1481
1519
|
if (nodes.length === 0) {
|
|
1482
1520
|
throw fail(CODES.markdownLink, "link", "A link must have text.", lineNumber);
|
|
1483
1521
|
}
|
|
1484
|
-
return { nodes, end: destinationEnd + 1 };
|
|
1522
|
+
return { nodes, end: options ? options.end : destinationEnd + 1 };
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* The optional `{rel="sponsored nofollow" target="_blank"}` right after a
|
|
1527
|
+
* link's destination (TR01190), either attribute alone or both in any order.
|
|
1528
|
+
* Braces that start `{rel=` or `{target=` are attributes and must be well
|
|
1529
|
+
* formed; any other brace after a link stays literal text.
|
|
1530
|
+
*/
|
|
1531
|
+
function parseLinkAttributes(text, from, lineNumber) {
|
|
1532
|
+
const rest = text.slice(from);
|
|
1533
|
+
if (!/^\{\s*(?:rel|target)\s*=/u.test(rest)) return undefined;
|
|
1534
|
+
const match = /^\{((?:\s*(?:rel|target)="[^"\\{}]*")+)\s*\}/u.exec(rest);
|
|
1535
|
+
const pairs = match ? [...match[1].matchAll(/(rel|target)="([^"]*)"/gu)] : [];
|
|
1536
|
+
const names = pairs.map((pair) => pair[1]);
|
|
1537
|
+
if (!match || new Set(names).size !== names.length) {
|
|
1538
|
+
throw fail(
|
|
1539
|
+
CODES.markdownLink,
|
|
1540
|
+
"link attributes",
|
|
1541
|
+
'Write a link\'s attributes right after it as {rel="nofollow" target="_blank"}: each at most once, '
|
|
1542
|
+
+ "double-quoted. Escape a literal brace as \\{.",
|
|
1543
|
+
lineNumber,
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
const attrs = {};
|
|
1547
|
+
for (const [, name, value] of pairs) {
|
|
1548
|
+
if (name === "target") {
|
|
1549
|
+
const target = value.trim().toLowerCase();
|
|
1550
|
+
if (!LINK_TARGETS.includes(target)) {
|
|
1551
|
+
throw fail(
|
|
1552
|
+
CODES.linkTarget,
|
|
1553
|
+
"link target",
|
|
1554
|
+
`A link's target is "_blank" (open in a new tab) or "_self"; "${value}" is not one.`,
|
|
1555
|
+
lineNumber,
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
attrs.target = target;
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
const tokens = value.trim().toLowerCase().split(/\s+/u).filter(Boolean);
|
|
1562
|
+
const unknown = tokens.filter((token) => !LINK_REL_TOKENS.includes(token));
|
|
1563
|
+
if (tokens.length === 0 || unknown.length > 0) {
|
|
1564
|
+
throw fail(
|
|
1565
|
+
CODES.linkRel,
|
|
1566
|
+
"link rel",
|
|
1567
|
+
`A link's rel takes space-separated link types from ${LINK_REL_TOKENS.join(", ")}; ${
|
|
1568
|
+
unknown.length
|
|
1569
|
+
? `${unknown.map((token) => `"${token}"`).join(", ")} ${unknown.length === 1 ? "is" : "are"} not one`
|
|
1570
|
+
: "this one is empty"
|
|
1571
|
+
}.`,
|
|
1572
|
+
lineNumber,
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
// Vocabulary order, so a written rel can never read as the editor's old
|
|
1576
|
+
// default ("noopener noreferrer nofollow"), which means no choice.
|
|
1577
|
+
attrs.rel = LINK_REL_TOKENS.filter((token) => tokens.includes(token)).join(" ");
|
|
1578
|
+
}
|
|
1579
|
+
return { attrs, end: from + match[0].length };
|
|
1485
1580
|
}
|
|
1486
1581
|
|
|
1487
1582
|
// The same unbounded scan as `findLinkTextEnd`, and quadratic for the same
|
|
@@ -1515,19 +1610,34 @@ function unescapePunctuation(text) {
|
|
|
1515
1610
|
}
|
|
1516
1611
|
|
|
1517
1612
|
/**
|
|
1518
|
-
* Builds a `taprootImage` node from ``.
|
|
1519
|
-
*
|
|
1520
|
-
* The node **always** carries `"src": ""` and `"urls": []`, whatever the
|
|
1521
|
-
* resolver reported for delivery URLs.
|
|
1522
|
-
* `PageImageDeliveryRewriter.ReplaceUrlFields` rewrites `src`/`url`/`urls`
|
|
1523
|
-
* only where the key already exists, filling them from the site's signed
|
|
1524
|
-
* delivery URLs at read time — so the key must be there and its value must not
|
|
1525
|
-
* be a URL this CLI invented. An image node missing the keys is rewritten into
|
|
1526
|
-
* nothing and renders blank; one carrying a hand-built URL renders a link the
|
|
1527
|
-
* site cannot sign.
|
|
1613
|
+
* Builds a `taprootImage` node from ``. Like every Markdown
|
|
1614
|
+
* image it carries `"src": ""` and `"urls": []`; see `resolveAuthoredImage`.
|
|
1528
1615
|
*/
|
|
1529
1616
|
async function imageNode(alt, reference, context, line) {
|
|
1530
|
-
const
|
|
1617
|
+
const resolved = await resolveAuthoredImage(unescapePunctuation(reference), context, line);
|
|
1618
|
+
const attrs = {
|
|
1619
|
+
imageId: resolved.imageId,
|
|
1620
|
+
src: "",
|
|
1621
|
+
urls: [],
|
|
1622
|
+
alt: unescapePunctuation(alt) || resolved.alt,
|
|
1623
|
+
};
|
|
1624
|
+
if (resolved.width !== undefined) attrs.width = resolved.width;
|
|
1625
|
+
if (resolved.height !== undefined) attrs.height = resolved.height;
|
|
1626
|
+
return { type: "taprootImage", attrs };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* The one way Markdown turns a media path into an image: body images,
|
|
1631
|
+
* component image fields and section images all resolve here (TR01187).
|
|
1632
|
+
*
|
|
1633
|
+
* The record always carries `"src": ""` and `"urls": []`, whatever the
|
|
1634
|
+
* resolver reported for delivery URLs. `PageImageDeliveryRewriter` fills them
|
|
1635
|
+
* from the image id at read time, but only where the keys already exist, so
|
|
1636
|
+
* they must be present and must not hold a URL this CLI invented: an image
|
|
1637
|
+
* missing the keys renders blank, and a hand-built URL is one the site cannot
|
|
1638
|
+
* sign. Width and height are left out when the media record does not know them.
|
|
1639
|
+
*/
|
|
1640
|
+
async function resolveAuthoredImage(source, context, line, field) {
|
|
1531
1641
|
if (source.length > CONTENT_LIMITS.imageReferenceLength) {
|
|
1532
1642
|
throw fail(CODES.markdownImage, "image", "The image reference is too long.", line.number);
|
|
1533
1643
|
}
|
|
@@ -1541,7 +1651,9 @@ async function imageNode(alt, reference, context, line) {
|
|
|
1541
1651
|
throw fail(
|
|
1542
1652
|
CODES.markdownImage,
|
|
1543
1653
|
"image",
|
|
1544
|
-
`The image '${identifier(source)}' could not be resolved.
|
|
1654
|
+
`The image '${identifier(source)}'${field ? ` at ${field}` : ""} could not be resolved. `
|
|
1655
|
+
+ "Record it in .taproot-site-media.json: 'media upload' does that in a workspace, and a fixture lists it "
|
|
1656
|
+
+ "in its own copy.",
|
|
1545
1657
|
line.number,
|
|
1546
1658
|
error,
|
|
1547
1659
|
);
|
|
@@ -1554,16 +1666,39 @@ async function imageNode(alt, reference, context, line) {
|
|
|
1554
1666
|
line.number,
|
|
1555
1667
|
);
|
|
1556
1668
|
}
|
|
1557
|
-
|
|
1558
|
-
const
|
|
1669
|
+
const dimension = (value) => (Number.isSafeInteger(value) && value > 0 ? value : undefined);
|
|
1670
|
+
const width = dimension(resolved.width);
|
|
1671
|
+
const height = dimension(resolved.height);
|
|
1672
|
+
return {
|
|
1559
1673
|
imageId: resolved.imageId,
|
|
1560
1674
|
src: "",
|
|
1561
1675
|
urls: [],
|
|
1562
|
-
|
|
1676
|
+
...(width === undefined ? {} : { width }),
|
|
1677
|
+
...(height === undefined ? {} : { height }),
|
|
1678
|
+
alt: typeof resolved.alt === "string" ? resolved.alt : "",
|
|
1563
1679
|
};
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** A component or section image field in Markdown: a media path, or null. */
|
|
1683
|
+
async function authoredImageField(value, path, context, line, label) {
|
|
1684
|
+
if (value === null) return null;
|
|
1685
|
+
if (typeof value !== "string" || value === "") {
|
|
1686
|
+
throw fail(
|
|
1687
|
+
CODES.markdownImage,
|
|
1688
|
+
label,
|
|
1689
|
+
`${path} takes a media path such as "media/photo.webp", or null. Markdown does not accept the upload record.`,
|
|
1690
|
+
line.number,
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
return await resolveAuthoredImage(value, context, line, path);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function parseJsonOrUndefined(source) {
|
|
1697
|
+
try {
|
|
1698
|
+
return JSON.parse(source);
|
|
1699
|
+
} catch {
|
|
1700
|
+
return undefined;
|
|
1701
|
+
}
|
|
1567
1702
|
}
|
|
1568
1703
|
|
|
1569
1704
|
// ---------------------------------------------------------------------------
|
|
@@ -367,14 +367,7 @@ function wrapMark(html: string, mark: ProseMirrorMark): string {
|
|
|
367
367
|
case "link": {
|
|
368
368
|
const href = stringAttr(mark.attrs?.href);
|
|
369
369
|
if (!href || !isSafeUrl(href)) return html;
|
|
370
|
-
return renderElement(
|
|
371
|
-
"a",
|
|
372
|
-
{
|
|
373
|
-
href,
|
|
374
|
-
rel: "noopener noreferrer nofollow",
|
|
375
|
-
},
|
|
376
|
-
html,
|
|
377
|
-
);
|
|
370
|
+
return renderElement("a", { href, ...linkRelAttributes(mark.attrs) }, html);
|
|
378
371
|
}
|
|
379
372
|
default:
|
|
380
373
|
return html;
|
|
@@ -1377,6 +1370,71 @@ function serializeAttributes(attrs: Record<string, string | number | boolean | n
|
|
|
1377
1370
|
return serialized.length ? ` ${serialized.join(" ")}` : "";
|
|
1378
1371
|
}
|
|
1379
1372
|
|
|
1373
|
+
/**
|
|
1374
|
+
* The rel tokens a link may carry (TR01190): every HTML link type valid on
|
|
1375
|
+
* `<a>` except `opener`, which hands a new tab a handle on the page.
|
|
1376
|
+
*/
|
|
1377
|
+
export const LINK_REL_TOKENS = [
|
|
1378
|
+
"nofollow",
|
|
1379
|
+
"sponsored",
|
|
1380
|
+
"ugc",
|
|
1381
|
+
"me",
|
|
1382
|
+
"noopener",
|
|
1383
|
+
"noreferrer",
|
|
1384
|
+
"external",
|
|
1385
|
+
"author",
|
|
1386
|
+
"license",
|
|
1387
|
+
"tag",
|
|
1388
|
+
"bookmark",
|
|
1389
|
+
"help",
|
|
1390
|
+
"prev",
|
|
1391
|
+
"next",
|
|
1392
|
+
"privacy-policy",
|
|
1393
|
+
"terms-of-service",
|
|
1394
|
+
] as const;
|
|
1395
|
+
/** The targets a link may carry; only `_blank` changes what renders. */
|
|
1396
|
+
export const LINK_TARGETS = ["_blank", "_self"] as const;
|
|
1397
|
+
/**
|
|
1398
|
+
* The editor stored this rel, with `target="_blank"`, on every link before
|
|
1399
|
+
* authors could choose, so a mark carrying it records no choice of either.
|
|
1400
|
+
*/
|
|
1401
|
+
export const LEGACY_EDITOR_LINK_REL = "noopener noreferrer nofollow";
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* An author's chosen tokens as a stored rel. Those three tokens in the old
|
|
1405
|
+
* default's order would read as no choice, so they are stored in vocabulary
|
|
1406
|
+
* order instead.
|
|
1407
|
+
*/
|
|
1408
|
+
export function spellLinkRel(tokens: readonly string[]): string {
|
|
1409
|
+
const rel = tokens.join(" ");
|
|
1410
|
+
return rel === LEGACY_EDITOR_LINK_REL
|
|
1411
|
+
? LINK_REL_TOKENS.filter((token) => tokens.includes(token)).join(" ")
|
|
1412
|
+
: rel;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** A stored rel as tokens, or undefined when it is the editor's old default (no choice). */
|
|
1416
|
+
export function chosenLinkRelTokens(rel: unknown): string[] | undefined {
|
|
1417
|
+
const tokens = stringAttr(rel)?.trim().toLowerCase().split(/\s+/u).filter(Boolean) ?? [];
|
|
1418
|
+
return tokens.join(" ") === LEGACY_EDITOR_LINK_REL ? undefined : tokens;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* The `rel` and `target` a link publishes with: the author's allowed tokens in
|
|
1423
|
+
* their order, and noopener and noreferrer whenever it opens a new tab.
|
|
1424
|
+
* Anything else stored is ignored.
|
|
1425
|
+
*/
|
|
1426
|
+
export function linkRelAttributes(attrs: Record<string, unknown> | undefined): { rel?: string; target?: string } {
|
|
1427
|
+
const stored = chosenLinkRelTokens(attrs?.rel);
|
|
1428
|
+
if (!stored) return {};
|
|
1429
|
+
const newTab = stringAttr(attrs?.target)?.trim().toLowerCase() === "_blank";
|
|
1430
|
+
const allowed: readonly string[] = LINK_REL_TOKENS;
|
|
1431
|
+
const rel = new Set([
|
|
1432
|
+
...stored.filter((token) => allowed.includes(token)),
|
|
1433
|
+
...(newTab ? ["noopener", "noreferrer"] : []),
|
|
1434
|
+
]);
|
|
1435
|
+
return { ...(rel.size ? { rel: [...rel].join(" ") } : {}), ...(newTab ? { target: "_blank" } : {}) };
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1380
1438
|
function isSafeUrl(url: string): boolean {
|
|
1381
1439
|
if (/[\x00-\x1f\x7f\\]/u.test(url)) return false;
|
|
1382
1440
|
const cleaned = url.trim();
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
isPlainObject,
|
|
19
19
|
isSafeUrl,
|
|
20
20
|
isUuid,
|
|
21
|
+
LINK_REL_TOKENS,
|
|
22
|
+
LINK_TARGETS,
|
|
21
23
|
MARK_TYPE_SET,
|
|
22
24
|
NODE_TYPE_SET,
|
|
23
25
|
normalizeCssLengthOverride,
|
|
@@ -485,12 +487,34 @@ const MARK_RULES = Object.freeze({
|
|
|
485
487
|
{ required: true, code: CODES.linkHref, missingCode: CODES.linkHref },
|
|
486
488
|
),
|
|
487
489
|
// `@tiptap/extension-link` declares these four alongside `href`, and
|
|
488
|
-
// `Mark.toJSON` emits every declared attribute
|
|
489
|
-
// has ever written carries all five
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
490
|
+
// `Mark.toJSON` emits every declared attribute, so every link the editor
|
|
491
|
+
// has ever written carries all five. The renderer publishes `rel` and
|
|
492
|
+
// `target="_blank"` from closed lists (TR01190), so those two are held to
|
|
493
|
+
// them; it ignores `class` and `title`. The editor's old default rel,
|
|
494
|
+
// "noopener noreferrer nofollow", is within the list and renders as no
|
|
495
|
+
// choice.
|
|
496
|
+
target: spec(
|
|
497
|
+
(value) => {
|
|
498
|
+
if (isAbsent(value) || (typeof value === "string" && value.trim() === "")) return null;
|
|
499
|
+
return typeof value === "string" && LINK_TARGETS.includes(value.trim().toLowerCase())
|
|
500
|
+
? null
|
|
501
|
+
: `"_blank" (open in a new tab), "_self", or absent`;
|
|
502
|
+
},
|
|
503
|
+
{ code: CODES.linkTarget },
|
|
504
|
+
),
|
|
505
|
+
rel: spec(
|
|
506
|
+
(value) => {
|
|
507
|
+
if (isAbsent(value)) return null;
|
|
508
|
+
if (typeof value !== "string") return "a string of space-separated link types";
|
|
509
|
+
const unknown = value.trim().toLowerCase().split(/\s+/u)
|
|
510
|
+
.filter((token) => token && !LINK_REL_TOKENS.includes(token));
|
|
511
|
+
if (unknown.length === 0) return null;
|
|
512
|
+
return `space-separated link types from ${LINK_REL_TOKENS.join(", ")}; ${
|
|
513
|
+
unknown.map((token) => `"${token}"`).join(", ")
|
|
514
|
+
} ${unknown.length === 1 ? "is" : "are"} not one`;
|
|
515
|
+
},
|
|
516
|
+
{ code: CODES.linkRel },
|
|
517
|
+
),
|
|
494
518
|
class: optionalString(),
|
|
495
519
|
title: optionalString(),
|
|
496
520
|
}),
|