@taprootio/site-authoring 0.31.0 → 0.32.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 +19 -0
- package/examples/riverbend-wellness/settings/taproot-styles.json +1 -0
- package/package.json +2 -2
- package/src/appearance-contract.js +11 -0
- package/src/block-flow.js +117 -0
- package/src/changelog.json +26 -0
- package/src/content/components.js +12 -6
- package/src/content/external-link.js +77 -0
- package/src/content/free-form-section-registry.json +7 -1
- package/src/content/free-form-sections.js +18 -1
- package/src/content/tiptap-prosemirror.ts +75 -27
- package/src/presentation-reference.js +9 -0
- package/src/reference-help.js +16 -7
- package/src/setting-reference.js +9 -0
- package/src/settings-catalog.js +1 -0
package/README.md
CHANGED
|
@@ -972,6 +972,25 @@ targets are refused (`content.link_rel`, `content.link_target`). The editor's
|
|
|
972
972
|
old default, `noopener noreferrer nofollow`, reads as no choice. See
|
|
973
973
|
`taproot-site help page free-form`.
|
|
974
974
|
|
|
975
|
+
### External links open in a new tab in 0.32.0
|
|
976
|
+
|
|
977
|
+
A link to another site opens in a new tab with nothing to set. On the next
|
|
978
|
+
render of a page, every absolute `http:` or `https:` address whose host is not
|
|
979
|
+
one of the site's own hosts publishes `target="_blank"` with `noopener
|
|
980
|
+
noreferrer` added after any `rel` tokens the link already carries. This covers
|
|
981
|
+
link marks in page text and tables, and component links: feature-grid,
|
|
982
|
+
image-row, card-grid, product-grid, hero-section, cta, image-banner and
|
|
983
|
+
social-links, plus the footer and header navigation. Each of these links also
|
|
984
|
+
says "(opens in a new tab)" to a screen reader, with no change on screen.
|
|
985
|
+
`mailto:`, `tel:`, relative paths, fragments,
|
|
986
|
+
queries and the site's own addresses stay in the same tab. A subdomain of the
|
|
987
|
+
site is another host, so it opens in a new tab; `www.` is ignored. To keep a
|
|
988
|
+
link mark in the same tab, write `target="_self"` after it in Markdown or set
|
|
989
|
+
`attrs.target` to `"_self"` in `.pm.json`. Component links have no setting for
|
|
990
|
+
this. Existing pages change when a publish renders the whole site, such as a
|
|
991
|
+
settings or navigation change; pages an incremental publish copies forward keep
|
|
992
|
+
their old links until then.
|
|
993
|
+
|
|
975
994
|
### What changed between releases
|
|
976
995
|
|
|
977
996
|
Taproot accepts only the latest CLI, so an upgrade can be forced at any time.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@taprootio/espalier": "6.
|
|
31
|
+
"@taprootio/espalier": "6.9.0",
|
|
32
32
|
"mediabunny": "1.61.1"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BLOCK_SPACINGS, DEFAULT_BLOCK_SPACING } from "./block-flow.js";
|
|
1
2
|
import { hasControlCharacter, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
|
|
2
3
|
import {
|
|
3
4
|
SETTINGS_TYPE_BRAND,
|
|
@@ -26,6 +27,16 @@ export const APPEARANCE_SCALAR_FIELDS = Object.freeze([
|
|
|
26
27
|
values: Object.freeze(["system", "light", "dark"]),
|
|
27
28
|
default: "light",
|
|
28
29
|
}),
|
|
30
|
+
// The names come from the synced block-flow rules, so a spacing the page
|
|
31
|
+
// has no scale for can never pass validation (TR01328).
|
|
32
|
+
Object.freeze({
|
|
33
|
+
settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
|
|
34
|
+
name: "blockSpacing",
|
|
35
|
+
path: "taproot-styles.blockSpacing",
|
|
36
|
+
type: "enum",
|
|
37
|
+
values: Object.freeze(Object.keys(BLOCK_SPACINGS)),
|
|
38
|
+
default: DEFAULT_BLOCK_SPACING,
|
|
39
|
+
}),
|
|
29
40
|
...["lightLogoId", "darkLogoId", "lightCanvasImageId", "darkCanvasImageId"].map((name) =>
|
|
30
41
|
Object.freeze({
|
|
31
42
|
settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Generated from shared/block-flow.ts by scripts/sync-field-validation.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* The one owner of how page blocks space themselves (TR01328): the three
|
|
4
|
+
* site-wide spacing choices and the stylesheet that turns them into margins.
|
|
5
|
+
*
|
|
6
|
+
* Text used to get its space from a browser default and components got none.
|
|
7
|
+
* Now every direct child of a flow container takes one gap, a token that
|
|
8
|
+
* follows the site's text size and the chosen spacing. The published page
|
|
9
|
+
* shell inlines `BLOCK_FLOW_CSS` in its core section and the editor's page
|
|
10
|
+
* preview, which has no shell, applies the same string, so both agree.
|
|
11
|
+
*
|
|
12
|
+
* The generator and the site-authoring CLI cannot import a repo-root file, so
|
|
13
|
+
* `node scripts/sync-field-validation.mjs` writes their copies; edit only this
|
|
14
|
+
* file and re-run it. The editor imports it by relative path.
|
|
15
|
+
*/
|
|
16
|
+
/** The scale each choice puts on the gap and on a heading's top margin. */
|
|
17
|
+
export const BLOCK_SPACINGS = {
|
|
18
|
+
compact: 0.75,
|
|
19
|
+
standard: 1,
|
|
20
|
+
roomy: 1.5,
|
|
21
|
+
};
|
|
22
|
+
/** What a site that never chose gets; it is also the value the page leaves unwritten. */
|
|
23
|
+
export const DEFAULT_BLOCK_SPACING = "standard";
|
|
24
|
+
// A flow container holds a page's blocks as direct children. This is the
|
|
25
|
+
// shell's older list with `.taproot-free-form` swapped for the section
|
|
26
|
+
// content: the free-form element's own children are sections and root bands,
|
|
27
|
+
// which must not gain a margin.
|
|
28
|
+
const FLOW_CONTAINERS = [
|
|
29
|
+
"[data-section-content]",
|
|
30
|
+
"[slot=\"body\"]",
|
|
31
|
+
"[slot=\"introduction\"]",
|
|
32
|
+
".recipe-story",
|
|
33
|
+
".taproot-legal__body",
|
|
34
|
+
].join(", ");
|
|
35
|
+
// The end room needs only the text elements whose bottom margin is 0; the
|
|
36
|
+
// unpadded edge leaves text, lists and rules alone, as they are today.
|
|
37
|
+
const TEXT_AND_LISTS = "p, h1, h2, h3, h4, h5, h6, figure, blockquote, dl, ul, ol, pre, hr";
|
|
38
|
+
const TEXT_WITHOUT_END_MARGIN = "p, h1, h2, h3, h4, h5, h6, figure, blockquote, dl";
|
|
39
|
+
const SCALE_MAPPING = Object.entries(BLOCK_SPACINGS)
|
|
40
|
+
.map(([name, scale]) => `[data-block-spacing="${name}"] {\n --taproot-block-scale: ${scale};\n}`)
|
|
41
|
+
.join("\n");
|
|
42
|
+
/**
|
|
43
|
+
* The block flow rules, compact enough to inline in every page. No CSS comments
|
|
44
|
+
* ship, so each rule's reason is here:
|
|
45
|
+
*
|
|
46
|
+
* - The gap is a token, not em, so a heading and a paragraph resolve the same
|
|
47
|
+
* length and it stays fluid on phones. The 1em fallback holds the browser's
|
|
48
|
+
* text margin when the runtime never ran and set `--esp-type-normal`.
|
|
49
|
+
* - The block rule and its text reset have zero specificity (`:where()`), so any
|
|
50
|
+
* rule a component, a banner or an author writes for its own margin wins with
|
|
51
|
+
* no ordering fight. The reset repeats `esp-reset.css` because the page preview
|
|
52
|
+
* has no reset.
|
|
53
|
+
* - Components load lazily and an undefined element is inline, where a vertical
|
|
54
|
+
* margin does nothing, so the listed hosts match their `:host` from first paint.
|
|
55
|
+
* Add every new lazily loaded component host to that list.
|
|
56
|
+
* - A heading's top margin scales with the setting. At (0,1,0) it beats Espalier's
|
|
57
|
+
* root heading rule and loses to the banner resets, in any section order.
|
|
58
|
+
* - A spacer adds its height to the gap instead of a second margin.
|
|
59
|
+
* - The last text block of a padded section gets room, so a section closes as it
|
|
60
|
+
* opens. The descendant combinator reaches past a surface's `esp-box`.
|
|
61
|
+
* - An unpadded section's first and last component stay flush. Text, lists, rules
|
|
62
|
+
* and anything with a margin rule of its own keep what they have.
|
|
63
|
+
* - Banners stay flush with their neighbors. Those rules live here, not in the
|
|
64
|
+
* banner section, because the page preview has no shell.
|
|
65
|
+
*/
|
|
66
|
+
export const BLOCK_FLOW_CSS = `
|
|
67
|
+
:where(${FLOW_CONTAINERS}) {
|
|
68
|
+
--taproot-block-gap: calc(var(--esp-type-normal, 1em) * var(--taproot-block-scale, 1));
|
|
69
|
+
}
|
|
70
|
+
${SCALE_MAPPING}
|
|
71
|
+
:where(${FLOW_CONTAINERS}) > * {
|
|
72
|
+
margin-block: var(--taproot-block-gap);
|
|
73
|
+
}
|
|
74
|
+
:where(${FLOW_CONTAINERS}) > :where(p, h1, h2, h3, h4, figure, blockquote, dl) {
|
|
75
|
+
margin-block-end: 0;
|
|
76
|
+
}
|
|
77
|
+
taproot-hero,
|
|
78
|
+
taproot-cta,
|
|
79
|
+
taproot-testimonial,
|
|
80
|
+
taproot-feature-grid,
|
|
81
|
+
taproot-latest-posts,
|
|
82
|
+
taproot-card-grid,
|
|
83
|
+
taproot-product-grid {
|
|
84
|
+
display: block;
|
|
85
|
+
}
|
|
86
|
+
:is(${FLOW_CONTAINERS}) > :where(h1, h2, h3, h4, h5) {
|
|
87
|
+
margin-block-start: calc(var(--esp-size-small-to-normal, 0.83em) * var(--taproot-block-scale, 1));
|
|
88
|
+
}
|
|
89
|
+
:where(${FLOW_CONTAINERS}) > taproot-spacer {
|
|
90
|
+
margin-block: 0;
|
|
91
|
+
}
|
|
92
|
+
esp-section[data-content-padding="standard"] [data-section-content] > :is(${TEXT_WITHOUT_END_MARGIN}):last-child {
|
|
93
|
+
margin-block-end: var(--taproot-block-gap);
|
|
94
|
+
}
|
|
95
|
+
:where(esp-section[data-content-padding="none"] [data-section-content]) > :where(:first-child:not(${TEXT_AND_LISTS})) {
|
|
96
|
+
margin-block-start: 0;
|
|
97
|
+
}
|
|
98
|
+
:where(esp-section[data-content-padding="none"] [data-section-content]) > :where(:last-child:not(${TEXT_AND_LISTS})) {
|
|
99
|
+
margin-block-end: 0;
|
|
100
|
+
}
|
|
101
|
+
:is(${FLOW_CONTAINERS}) taproot-image-banner {
|
|
102
|
+
margin: 0;
|
|
103
|
+
}
|
|
104
|
+
:is(${FLOW_CONTAINERS}) esp-image[banner] {
|
|
105
|
+
margin: 0;
|
|
106
|
+
}
|
|
107
|
+
:is(${FLOW_CONTAINERS}) *:has(+ taproot-image-banner) {
|
|
108
|
+
margin-block-end: 0;
|
|
109
|
+
}
|
|
110
|
+
:is(${FLOW_CONTAINERS}) taproot-image-banner + * {
|
|
111
|
+
margin-block-start: 0;
|
|
112
|
+
}
|
|
113
|
+
[data-taproot-section][data-following-spacing="compact"] > [data-section-content] > :first-child,
|
|
114
|
+
[data-taproot-section][data-following-spacing="compact"] > esp-box > [data-section-content] > :first-child {
|
|
115
|
+
margin-block-start: 0;
|
|
116
|
+
}
|
|
117
|
+
`.trim();
|
package/src/changelog.json
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"entries": [
|
|
3
|
+
{
|
|
4
|
+
"version": "0.32.0",
|
|
5
|
+
"note": "Links to other sites open in a new tab on published pages with nothing to set; target=\"_self\" keeps a link mark in the same tab; page blocks get real default spacing with a site-wide setting.",
|
|
6
|
+
"behavior": [
|
|
7
|
+
"An absolute http or https link to a host other than the site's own now publishes target=\"_blank\" and rel with noopener noreferrer added, with nothing to set. This covers link marks in page text and tables, inline facts, and the links of feature-grid, image-row, card-grid, product-grid, hero-section, cta, image-banner and social-links components, and the footer. mailto and tel links, relative paths, fragments, queries and the site's own addresses (including www.) stay in the same tab; a subdomain counts as another host. Component links have no setting to keep them in the tab.",
|
|
8
|
+
"A link mark keeps its own choice: target=\"_blank\" opens a new tab for any link, and target=\"_self\" keeps even an external link in the same tab. In Markdown write {target=\"_self\"} after the link; in .pm.json set attrs.target to \"_self\". A mark with a null target, or with the editor's old default rel, follows the default. See help page free-form.",
|
|
9
|
+
"Header navigation follows the same rule: an item or linked group with an http or https address on another host opens in a new tab. mailto and tel items, page items and the site's own addresses stay in the same tab.",
|
|
10
|
+
"A link that opens a new tab now says \"(opens in a new tab)\" to a screen reader, with no change on screen. This covers component links, link marks, navigation and the footer.",
|
|
11
|
+
"Help for component links and the image-row and product-grid notes no longer say links open in the same tab.",
|
|
12
|
+
"Existing pages change at the site's next whole-site render, such as a settings or navigation change. Pages an incremental publish copies forward keep their old links until then.",
|
|
13
|
+
"Page blocks get real default spacing. Text, tables and every component sit one gap apart, where components used to have no space from the text before them; the last text block of a padded section gets the same gap as room before the section ends; a heading's top margin scales with it. Banners stay flush with their neighbors.",
|
|
14
|
+
"New required setting blockSpacing in settings/taproot-styles.json (Brand & style > Block spacing): compact, standard or roomy scales the gap to 0.75, 1 or 1.5 times, in both color schemes. Standard keeps text where it is today.",
|
|
15
|
+
"spacer blocks add their height to the new gap instead of replacing it, so review hand-placed spacers and remove the ones that only worked around tight spacing.",
|
|
16
|
+
"blockSpacing is required on theme push like every other appearance field. A workspace pulled before this version does not hold it: run pull again before theme push, or add \"blockSpacing\": \"standard\" to settings/taproot-styles.json.",
|
|
17
|
+
"A site shows the new spacing after its next whole-site render, such as a settings or navigation change. Pages an incremental publish copies forward keep their old spacing until then.",
|
|
18
|
+
"A section decoration can be sized by height. Set blockSizePercent (an integer from 10 to 200) for \"auto N%\" in place of inlineSizePercent; the two cannot be combined, and neither set keeps the width default of 40. See help page free-form.",
|
|
19
|
+
"The help reference version is 51."
|
|
20
|
+
],
|
|
21
|
+
"help": [
|
|
22
|
+
"page",
|
|
23
|
+
"components",
|
|
24
|
+
"component",
|
|
25
|
+
"settings",
|
|
26
|
+
"appearance"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
3
29
|
{
|
|
4
30
|
"version": "0.31.0",
|
|
5
31
|
"note": "Image defaults can be listed, read and changed safely through media images; metadata advice uses consistent title and description guidance.",
|
|
@@ -708,7 +708,13 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
708
708
|
"Spacer",
|
|
709
709
|
"Intentional vertical separation with an optional decorative divider.",
|
|
710
710
|
{ height: "medium", showDivider: false, dividerStyle: "solid", dividerWidth: 1 },
|
|
711
|
-
[
|
|
711
|
+
[
|
|
712
|
+
"Do not use spacing alone to communicate document structure; keep headings and landmarks semantic.",
|
|
713
|
+
"Blocks already sit one gap apart, set by the site's blockSpacing (help settings). A spacer adds its height to "
|
|
714
|
+
+ "that gap: after text the gap is the spacer's height plus one gap, and between two components it is one gap, "
|
|
715
|
+
+ "the height, and one gap. Review spacers placed to work around tight spacing, and remove ones that are no "
|
|
716
|
+
+ "longer needed.",
|
|
717
|
+
],
|
|
712
718
|
{ height: "large", showDivider: true, dividerStyle: "solid", dividerWidth: 1 },
|
|
713
719
|
),
|
|
714
720
|
"latest-posts": component(
|
|
@@ -911,7 +917,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
911
917
|
"Every image needs alt text unless its descriptionMode is inherit, in which case the inherited description must resolve to non-blank text before deployment. A row of pictures carries no other text. For a linked image the alt is the link's name, so name the destination or action, such as \"Download on the App Store\"; for an unlinked image, describe the picture.",
|
|
912
918
|
"In Markdown the alt comes from the media manifest entry the image path names; a blank alt is refused.",
|
|
913
919
|
"Each image keeps its own shape at the chosen height and is never cropped or framed; an image wider than the row shrinks to fit. Width and height are required so the row can size the image; media upload records them.",
|
|
914
|
-
"
|
|
920
|
+
"A link to another site opens in a new tab with nothing to set. The site's own addresses, relative paths, mailto: and tel: links open in the same tab.",
|
|
915
921
|
],
|
|
916
922
|
{
|
|
917
923
|
items: [
|
|
@@ -950,7 +956,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
950
956
|
[
|
|
951
957
|
"A button's visible text is its accessible name, and the product's title heads the same list item, so \"Buy now\" on every card reads with its product. Say what the button does, such as \"Buy now\" or \"Book a trial\".",
|
|
952
958
|
PRODUCT_GRID_BUTTON_TEXT_NOTE,
|
|
953
|
-
"buttonText and buttonUrl go together: text without an address and an address without text are both refused.
|
|
959
|
+
"buttonText and buttonUrl go together: text without an address and an address without text are both refused. A button to another site opens in a new tab with nothing to set. The site's own addresses, relative paths, mailto: and tel: links open in the same tab.",
|
|
954
960
|
"price is free text (\"From $30\", \"Free\"); priceNote is shown beside it and refused without a price.",
|
|
955
961
|
"An image is optional and cropped to 16:9. Its alt may be blank when the title already says what the picture shows; in Markdown the alt comes from the media manifest entry.",
|
|
956
962
|
"highlighted draws a border of at least 2px (the borderWidth when that is larger) and the badge in the site's action color to mark one card, such as the most popular plan. The grid displays products only: no cart, payments or inventory.",
|
|
@@ -995,7 +1001,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
995
1001
|
[
|
|
996
1002
|
"A link's text is its accessible name: the label if set, else the platform's name. With showLabels false the text is visually hidden and read by screen readers only, so the name is the same either way.",
|
|
997
1003
|
"Link only to accounts you control; each platform's brand rules apply to its mark. " + UNSUPPORTED_DOMAIN_NOTE,
|
|
998
|
-
"platform other is any website and needs a label, such as \"Book online\". A named platform's link
|
|
1004
|
+
"platform other is any website and needs a label, such as \"Book online\". A link to another site opens in a new tab with nothing to set: a named platform's link publishes rel=\"me noopener noreferrer\", a Website link publishes rel=\"noopener noreferrer\", and a link to the site's own address stays in the same tab.",
|
|
999
1005
|
],
|
|
1000
1006
|
{
|
|
1001
1007
|
items: [
|
|
@@ -1071,7 +1077,7 @@ function referenceSchema(spec) {
|
|
|
1071
1077
|
type: "string",
|
|
1072
1078
|
format: "https-url",
|
|
1073
1079
|
maxLength: SOCIAL_LINK_URL_MAX_LENGTH,
|
|
1074
|
-
description: `An ${HTTPS_URL_DESCRIPTION.slice(3)}. ${UNSUPPORTED_DOMAIN_NOTE}
|
|
1080
|
+
description: `An ${HTTPS_URL_DESCRIPTION.slice(3)}. ${UNSUPPORTED_DOMAIN_NOTE} A link to another site opens in a new tab with nothing to set.`,
|
|
1075
1081
|
};
|
|
1076
1082
|
}
|
|
1077
1083
|
return {
|
|
@@ -1079,7 +1085,7 @@ function referenceSchema(spec) {
|
|
|
1079
1085
|
format: spec.allowEmpty === false ? "safe-url" : "safe-url-or-empty",
|
|
1080
1086
|
description: `${
|
|
1081
1087
|
spec.allowEmpty === false ? "An" : "Empty, or an"
|
|
1082
|
-
} 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.`,
|
|
1088
|
+
} 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. A web address on another site opens in a new tab with nothing to set; the site's own addresses, relative paths, fragments, queries, mailto and tel links do not.`,
|
|
1083
1089
|
};
|
|
1084
1090
|
case "css-length":
|
|
1085
1091
|
return { type: "string", description: `Must be ${describe(spec)}.` };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Generated from shared/external-link.ts by scripts/sync-field-validation.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* The one owner of which links open in a new tab (TR01327).
|
|
4
|
+
*
|
|
5
|
+
* A link opens in a new tab when its address is an absolute http or https URL
|
|
6
|
+
* whose host is not one of the site's own. Everything else (relative paths,
|
|
7
|
+
* fragments, mailto, tel, anything that does not parse) stays in the tab.
|
|
8
|
+
*
|
|
9
|
+
* It is pure and dependency-free. The generator, the editor and the
|
|
10
|
+
* site-authoring CLI cannot import this file across project roots, so
|
|
11
|
+
* `node scripts/sync-field-validation.mjs` writes their copies; edit only this
|
|
12
|
+
* file and re-run it.
|
|
13
|
+
*/
|
|
14
|
+
/** A host as the rule compares it: lowercase, with one leading `www.` dropped. */
|
|
15
|
+
export function normalizeLinkHost(host) {
|
|
16
|
+
const lowered = (host ?? "").trim().toLowerCase();
|
|
17
|
+
return lowered.startsWith("www.") ? lowered.slice(4) : lowered;
|
|
18
|
+
}
|
|
19
|
+
/** The site's own hosts, normalized. Empty and missing hosts are left out. */
|
|
20
|
+
export function siteLinkHosts(hosts) {
|
|
21
|
+
const set = new Set();
|
|
22
|
+
for (const host of hosts) {
|
|
23
|
+
const normalized = normalizeLinkHost(host);
|
|
24
|
+
if (normalized)
|
|
25
|
+
set.add(normalized);
|
|
26
|
+
}
|
|
27
|
+
return set;
|
|
28
|
+
}
|
|
29
|
+
/** True for an absolute http or https URL to a host outside the set. */
|
|
30
|
+
export function isExternalLink(href, siteHosts) {
|
|
31
|
+
if (!href)
|
|
32
|
+
return false;
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = new URL(href.trim());
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
41
|
+
return false;
|
|
42
|
+
return !siteHosts.has(normalizeLinkHost(parsed.hostname));
|
|
43
|
+
}
|
|
44
|
+
/** A rel with `noopener noreferrer` after its existing tokens, without duplicates. */
|
|
45
|
+
export function relWithNewTabTokens(rel) {
|
|
46
|
+
const tokens = new Set((rel ?? "").split(/\s+/u).filter(Boolean));
|
|
47
|
+
tokens.add("noopener");
|
|
48
|
+
tokens.add("noreferrer");
|
|
49
|
+
return [...tokens].join(" ");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The `target` and `rel` attributes for a link that opens in a new tab, as
|
|
53
|
+
* markup with a leading space. The values are tokens, so nothing needs escaping.
|
|
54
|
+
*/
|
|
55
|
+
export function newTabAttributeMarkup(rel) {
|
|
56
|
+
return ` target="_blank" rel="${relWithNewTabTokens(rel)}"`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What a screen reader hears after a link's name when it opens a new tab. These
|
|
60
|
+
* are the words Espalier appends to its own links, so a native anchor and an
|
|
61
|
+
* `esp-button` read alike. `generator/browser-tests/new-tab-cue.spec.ts` holds
|
|
62
|
+
* the two together.
|
|
63
|
+
*/
|
|
64
|
+
export const NEW_TAB_CUE_TEXT = " (opens in a new tab)";
|
|
65
|
+
/**
|
|
66
|
+
* The cue for a native anchor, as its last child. Pagefind indexes raw HTML, so
|
|
67
|
+
* `data-pagefind-ignore` keeps the words out of search content and excerpts.
|
|
68
|
+
* Output without `NEW_TAB_CUE_CSS` would show the words, so only published pages
|
|
69
|
+
* may carry it.
|
|
70
|
+
*/
|
|
71
|
+
export const NEW_TAB_CUE_HTML = `<span class="sr-only" data-pagefind-ignore>${NEW_TAB_CUE_TEXT}</span>`;
|
|
72
|
+
/** Hides the cue from sight only. The page shell and each shadow stylesheet with a cued anchor carry it. */
|
|
73
|
+
export const NEW_TAB_CUE_CSS = ".sr-only{position:absolute;width:0.0625rem;height:0.0625rem;padding:0;margin:-0.0625rem;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}";
|
|
74
|
+
/** Rendered HTML without its cues, for code that reads a link's text back. */
|
|
75
|
+
export function withoutNewTabCue(html) {
|
|
76
|
+
return html.split(NEW_TAB_CUE_HTML).join("");
|
|
77
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version":
|
|
2
|
+
"version": 16,
|
|
3
3
|
|
|
4
4
|
"imageDescription": {
|
|
5
5
|
"fields": {
|
|
@@ -317,6 +317,12 @@
|
|
|
317
317
|
"minimum": 10,
|
|
318
318
|
"maximum": 100
|
|
319
319
|
},
|
|
320
|
+
"blockSizePercent": {
|
|
321
|
+
"type": "integer",
|
|
322
|
+
"required": false,
|
|
323
|
+
"minimum": 10,
|
|
324
|
+
"maximum": 200
|
|
325
|
+
},
|
|
320
326
|
"opacity": {
|
|
321
327
|
"type": "number",
|
|
322
328
|
"required": false,
|
|
@@ -469,8 +469,19 @@ export function normalizeFreeFormSectionDecoration(
|
|
|
469
469
|
validateImageDescription(image, imagePath, "Section decoration image", report);
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
+
// TR01329: width and height sizes are exclusive, so one conflict replaces both range checks.
|
|
473
|
+
const sizeConflict = Object.hasOwn(value, "inlineSizePercent") && Object.hasOwn(value, "blockSizePercent");
|
|
474
|
+
if (sizeConflict) {
|
|
475
|
+
report(
|
|
476
|
+
`${path}/blockSizePercent`,
|
|
477
|
+
CODES.attrInvalid,
|
|
478
|
+
"Section decoration blockSizePercent cannot be combined with inlineSizePercent. Use only one of the two sizes.",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
472
482
|
for (const [name, definition] of Object.entries(fields)) {
|
|
473
483
|
if (name === "image" || !Object.hasOwn(value, name)) continue;
|
|
484
|
+
if (sizeConflict && (name === "inlineSizePercent" || name === "blockSizePercent")) continue;
|
|
474
485
|
const fieldPath = `${path}/${name}`;
|
|
475
486
|
const entry = value[name];
|
|
476
487
|
if (definition.type === "enum") {
|
|
@@ -514,7 +525,13 @@ export function normalizeFreeFormSectionDecoration(
|
|
|
514
525
|
};
|
|
515
526
|
const normalized = { image: Object.freeze(normalizedImage) };
|
|
516
527
|
for (const [name, definition] of Object.entries(fields)) {
|
|
517
|
-
if (name
|
|
528
|
+
if (name === "image") continue;
|
|
529
|
+
// A field with no default stays absent. The width default applies only without a height size.
|
|
530
|
+
const fallback = name === "inlineSizePercent" && Object.hasOwn(value, "blockSizePercent")
|
|
531
|
+
? undefined
|
|
532
|
+
: definition.default;
|
|
533
|
+
const resolved = value[name] ?? fallback;
|
|
534
|
+
if (resolved !== undefined) normalized[name] = resolved;
|
|
518
535
|
}
|
|
519
536
|
return finish(Object.freeze(normalized));
|
|
520
537
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
|
|
11
11
|
import { imageDescriptionIssues } from "./image-description-validation.js";
|
|
12
|
+
import { isExternalLink, NEW_TAB_CUE_HTML, relWithNewTabTokens } from "./external-link.js";
|
|
12
13
|
import {
|
|
13
14
|
explicitImageMaxHeight,
|
|
14
15
|
mediaOverrideStyleParts,
|
|
@@ -101,6 +102,12 @@ export interface RenderProseMirrorOptions {
|
|
|
101
102
|
headingLevel?: (authored: number) => number;
|
|
102
103
|
/** Claims the page's one LCP slot before rendering a section photo. */
|
|
103
104
|
claimSectionBackgroundImage?: () => boolean;
|
|
105
|
+
/**
|
|
106
|
+
* The site's own hosts (TR01327). With it, external links open in a new tab
|
|
107
|
+
* unless the author chose otherwise; without it nothing is decided here, so
|
|
108
|
+
* previews and PDFs render as before.
|
|
109
|
+
*/
|
|
110
|
+
siteHosts?: ReadonlySet<string>;
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
export function createEmptyProseMirrorDocument(): ProseMirrorDocument {
|
|
@@ -264,7 +271,7 @@ function renderNode(
|
|
|
264
271
|
renderNodes(node.content ?? [], options, "nested"),
|
|
265
272
|
);
|
|
266
273
|
case "text":
|
|
267
|
-
return renderTextNode(node);
|
|
274
|
+
return renderTextNode(node, options);
|
|
268
275
|
case "hardBreak":
|
|
269
276
|
return "<br>";
|
|
270
277
|
case "heading":
|
|
@@ -345,15 +352,15 @@ function renderIntegrationPlacement(node: ProseMirrorNode, options: RenderProseM
|
|
|
345
352
|
}, selected.html);
|
|
346
353
|
}
|
|
347
354
|
|
|
348
|
-
function renderTextNode(node: ProseMirrorNode): string {
|
|
355
|
+
function renderTextNode(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
|
|
349
356
|
let rendered = escapeHtml(node.text ?? "");
|
|
350
357
|
for (const mark of node.marks ?? []) {
|
|
351
|
-
rendered = wrapMark(rendered, mark);
|
|
358
|
+
rendered = wrapMark(rendered, mark, options);
|
|
352
359
|
}
|
|
353
360
|
return rendered;
|
|
354
361
|
}
|
|
355
362
|
|
|
356
|
-
function wrapMark(html: string, mark: ProseMirrorMark): string {
|
|
363
|
+
function wrapMark(html: string, mark: ProseMirrorMark, options: RenderProseMirrorOptions): string {
|
|
357
364
|
switch (mark.type) {
|
|
358
365
|
case "bold":
|
|
359
366
|
return `<strong>${html}</strong>`;
|
|
@@ -368,7 +375,7 @@ function wrapMark(html: string, mark: ProseMirrorMark): string {
|
|
|
368
375
|
case "link": {
|
|
369
376
|
const href = stringAttr(mark.attrs?.href);
|
|
370
377
|
if (!href || !isSafeUrl(href)) return html;
|
|
371
|
-
return
|
|
378
|
+
return renderLink(href, mark.attrs, html, options);
|
|
372
379
|
}
|
|
373
380
|
default:
|
|
374
381
|
return html;
|
|
@@ -410,7 +417,7 @@ type NormalizedTable = {
|
|
|
410
417
|
* omit one malformed table rather than reinterpret arbitrary blocks as cells.
|
|
411
418
|
*/
|
|
412
419
|
function renderTable(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
|
|
413
|
-
const table = normalizeTable(node);
|
|
420
|
+
const table = normalizeTable(node, options);
|
|
414
421
|
if (!table) return "";
|
|
415
422
|
|
|
416
423
|
const header = renderElement(
|
|
@@ -456,7 +463,7 @@ function renderTable(node: ProseMirrorNode, options: RenderProseMirrorOptions):
|
|
|
456
463
|
);
|
|
457
464
|
}
|
|
458
465
|
|
|
459
|
-
function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
|
|
466
|
+
function normalizeTable(node: ProseMirrorNode, options: RenderProseMirrorOptions): NormalizedTable | undefined {
|
|
460
467
|
const definition = freeFormSectionRegistry.table;
|
|
461
468
|
if (!hasOnlySerializedKeys(node, ["type", "attrs", "content"])) return undefined;
|
|
462
469
|
const attrs = node.attrs;
|
|
@@ -496,7 +503,7 @@ function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
|
|
|
496
503
|
return undefined;
|
|
497
504
|
}
|
|
498
505
|
|
|
499
|
-
const headerCells = normalizeTableRow(headerRow, definition.nodeTypes.header, true);
|
|
506
|
+
const headerCells = normalizeTableRow(headerRow, definition.nodeTypes.header, true, options);
|
|
500
507
|
if (!headerCells) return undefined;
|
|
501
508
|
const normalizedDataRows: string[][] = [];
|
|
502
509
|
for (const row of dataRows) {
|
|
@@ -508,7 +515,7 @@ function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
|
|
|
508
515
|
|| !Array.isArray(row.content)
|
|
509
516
|
|| row.content.length !== columnCount
|
|
510
517
|
) return undefined;
|
|
511
|
-
const normalized = normalizeTableRow(row, definition.nodeTypes.cell, false);
|
|
518
|
+
const normalized = normalizeTableRow(row, definition.nodeTypes.cell, false, options);
|
|
512
519
|
if (!normalized) return undefined;
|
|
513
520
|
normalizedDataRows.push(normalized);
|
|
514
521
|
}
|
|
@@ -524,6 +531,7 @@ function normalizeTableRow(
|
|
|
524
531
|
row: ProseMirrorNode,
|
|
525
532
|
expectedCellType: string,
|
|
526
533
|
header: boolean,
|
|
534
|
+
options: RenderProseMirrorOptions,
|
|
527
535
|
): string[] | undefined {
|
|
528
536
|
if (!Array.isArray(row.content)) return undefined;
|
|
529
537
|
const cells: string[] = [];
|
|
@@ -534,7 +542,7 @@ function normalizeTableRow(
|
|
|
534
542
|
|| cell.type !== expectedCellType
|
|
535
543
|
|| !hasNoAttrs(cell)
|
|
536
544
|
) return undefined;
|
|
537
|
-
const normalized = normalizeTableCell(cell);
|
|
545
|
+
const normalized = normalizeTableCell(cell, options);
|
|
538
546
|
if (!normalized) return undefined;
|
|
539
547
|
if (header && freeFormSectionRegistry.table.cells.headerNonWhitespace && normalized.text.trim() === "") {
|
|
540
548
|
return undefined;
|
|
@@ -544,7 +552,10 @@ function normalizeTableRow(
|
|
|
544
552
|
return cells;
|
|
545
553
|
}
|
|
546
554
|
|
|
547
|
-
function normalizeTableCell(
|
|
555
|
+
function normalizeTableCell(
|
|
556
|
+
cell: ProseMirrorNode,
|
|
557
|
+
options: RenderProseMirrorOptions,
|
|
558
|
+
): { html: string; text: string } | undefined {
|
|
548
559
|
const paragraphs = cell.content;
|
|
549
560
|
const paragraph = paragraphs?.[0];
|
|
550
561
|
if (
|
|
@@ -574,7 +585,7 @@ function normalizeTableCell(cell: ProseMirrorNode): { html: string; text: string
|
|
|
574
585
|
|| inlineNode.text === ""
|
|
575
586
|
) return undefined;
|
|
576
587
|
text += inlineNode.text;
|
|
577
|
-
html.push(renderTextNode(inlineNode));
|
|
588
|
+
html.push(renderTextNode(inlineNode, options));
|
|
578
589
|
} else if (inlineNode.type === "hardBreak") {
|
|
579
590
|
if (
|
|
580
591
|
!hasOnlySerializedKeys(inlineNode, ["type", "attrs", "marks"])
|
|
@@ -659,7 +670,7 @@ function renderInlineFacts(
|
|
|
659
670
|
if (!facts) return "";
|
|
660
671
|
const items = facts.map((fact) => {
|
|
661
672
|
const value = fact.url
|
|
662
|
-
?
|
|
673
|
+
? renderLink(fact.url, {}, escapeHtml(fact.value), options)
|
|
663
674
|
: escapeHtml(fact.value);
|
|
664
675
|
const label = fact.label === undefined
|
|
665
676
|
? ""
|
|
@@ -1030,7 +1041,7 @@ type NormalizedSectionDecoration = {
|
|
|
1030
1041
|
anchor: string;
|
|
1031
1042
|
inlineOffsetPercent: number;
|
|
1032
1043
|
blockOffsetPercent: number;
|
|
1033
|
-
|
|
1044
|
+
size: string;
|
|
1034
1045
|
opacity: number;
|
|
1035
1046
|
tintToken: string;
|
|
1036
1047
|
};
|
|
@@ -1042,7 +1053,7 @@ function normalizedSectionDecorationStyle(value: unknown): string[] | undefined
|
|
|
1042
1053
|
`--esp-section-decoration-image: url("${escapeCssString(decoration.imageUrl)}")`,
|
|
1043
1054
|
`--esp-section-decoration-color: var(${decoration.tintToken})`,
|
|
1044
1055
|
`--esp-section-decoration-position: ${sectionDecorationPosition(decoration)}`,
|
|
1045
|
-
`--esp-section-decoration-size: ${decoration.
|
|
1056
|
+
`--esp-section-decoration-size: ${decoration.size}`,
|
|
1046
1057
|
`--esp-section-decoration-opacity: ${decoration.opacity}`,
|
|
1047
1058
|
];
|
|
1048
1059
|
}
|
|
@@ -1064,6 +1075,10 @@ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration
|
|
|
1064
1075
|
const anchor = value.anchor ?? fields.anchor.default;
|
|
1065
1076
|
const inlineOffsetPercent = value.inlineOffsetPercent ?? fields.inlineOffsetPercent.default;
|
|
1066
1077
|
const blockOffsetPercent = value.blockOffsetPercent ?? fields.blockOffsetPercent.default;
|
|
1078
|
+
// TR01329: width and height sizes are exclusive. Read the raw keys, because
|
|
1079
|
+
// the width default is filled in below.
|
|
1080
|
+
if (value.inlineSizePercent !== undefined && value.blockSizePercent !== undefined) return undefined;
|
|
1081
|
+
const blockSizePercent = value.blockSizePercent;
|
|
1067
1082
|
const inlineSizePercent = value.inlineSizePercent ?? fields.inlineSizePercent.default;
|
|
1068
1083
|
const opacity = value.opacity ?? fields.opacity.default;
|
|
1069
1084
|
const tint = value.tint ?? fields.tint.default;
|
|
@@ -1074,7 +1089,12 @@ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration
|
|
|
1074
1089
|
if (!isBoundedNumber(blockOffsetPercent, fields.blockOffsetPercent.minimum, fields.blockOffsetPercent.maximum)) {
|
|
1075
1090
|
return undefined;
|
|
1076
1091
|
}
|
|
1077
|
-
if (
|
|
1092
|
+
if (blockSizePercent !== undefined) {
|
|
1093
|
+
if (
|
|
1094
|
+
!isBoundedNumber(blockSizePercent, fields.blockSizePercent.minimum, fields.blockSizePercent.maximum)
|
|
1095
|
+
|| !Number.isInteger(blockSizePercent)
|
|
1096
|
+
) return undefined;
|
|
1097
|
+
} else if (
|
|
1078
1098
|
!isBoundedNumber(inlineSizePercent, fields.inlineSizePercent.minimum, fields.inlineSizePercent.maximum)
|
|
1079
1099
|
|| !Number.isInteger(inlineSizePercent)
|
|
1080
1100
|
) return undefined;
|
|
@@ -1088,7 +1108,7 @@ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration
|
|
|
1088
1108
|
anchor,
|
|
1089
1109
|
inlineOffsetPercent,
|
|
1090
1110
|
blockOffsetPercent,
|
|
1091
|
-
inlineSizePercent
|
|
1111
|
+
size: blockSizePercent !== undefined ? `auto ${blockSizePercent}%` : `${inlineSizePercent}% auto`,
|
|
1092
1112
|
opacity,
|
|
1093
1113
|
tintToken,
|
|
1094
1114
|
};
|
|
@@ -1416,7 +1436,11 @@ export const LINK_REL_TOKENS = [
|
|
|
1416
1436
|
"privacy-policy",
|
|
1417
1437
|
"terms-of-service",
|
|
1418
1438
|
] as const;
|
|
1419
|
-
/**
|
|
1439
|
+
/**
|
|
1440
|
+
* The targets a link may carry. `_blank` opens a new tab and `_self` keeps the
|
|
1441
|
+
* tab; with a site host set, any other value leaves the choice to the external
|
|
1442
|
+
* link rule (TR01327).
|
|
1443
|
+
*/
|
|
1420
1444
|
export const LINK_TARGETS = ["_blank", "_self"] as const;
|
|
1421
1445
|
/**
|
|
1422
1446
|
* The editor stored this rel, with `target="_blank"`, on every link before
|
|
@@ -1445,18 +1469,42 @@ export function chosenLinkRelTokens(rel: unknown): string[] | undefined {
|
|
|
1445
1469
|
/**
|
|
1446
1470
|
* The `rel` and `target` a link publishes with: the author's allowed tokens in
|
|
1447
1471
|
* their order, and noopener and noreferrer whenever it opens a new tab.
|
|
1448
|
-
*
|
|
1472
|
+
* `_blank` opens a new tab and `_self` keeps the tab. Any other target, or a
|
|
1473
|
+
* mark with the old editor rel (whose target was never a choice), follows the
|
|
1474
|
+
* external link rule when a host set is supplied, and otherwise stays in the
|
|
1475
|
+
* tab. Anything else stored is ignored.
|
|
1449
1476
|
*/
|
|
1450
|
-
export function linkRelAttributes(
|
|
1477
|
+
export function linkRelAttributes(
|
|
1478
|
+
attrs: Record<string, unknown> | undefined,
|
|
1479
|
+
href?: string,
|
|
1480
|
+
siteHosts?: ReadonlySet<string>,
|
|
1481
|
+
): { rel?: string; target?: string } {
|
|
1451
1482
|
const stored = chosenLinkRelTokens(attrs?.rel);
|
|
1452
|
-
|
|
1453
|
-
const
|
|
1483
|
+
const target = stringAttr(attrs?.target)?.trim().toLowerCase();
|
|
1484
|
+
const byRule = () => siteHosts !== undefined && isExternalLink(href, siteHosts);
|
|
1485
|
+
const newTab = stored && (target === "_blank" || target === "_self")
|
|
1486
|
+
? target === "_blank"
|
|
1487
|
+
: byRule();
|
|
1454
1488
|
const allowed: readonly string[] = LINK_REL_TOKENS;
|
|
1455
|
-
const
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1489
|
+
const kept = (stored ?? []).filter((token) => allowed.includes(token));
|
|
1490
|
+
const rel = newTab ? relWithNewTabTokens(kept.join(" ")) : [...new Set(kept)].join(" ");
|
|
1491
|
+
return { ...(rel ? { rel } : {}), ...(newTab ? { target: "_blank" } : {}) };
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* An anchor with the attributes `linkRelAttributes` chooses. Only a publish
|
|
1496
|
+
* passes a host set, and only then does a new-tab link carry its hidden cue,
|
|
1497
|
+
* because the editor preview and legal PDFs have no rule to hide it (TR01327).
|
|
1498
|
+
*/
|
|
1499
|
+
function renderLink(
|
|
1500
|
+
href: string,
|
|
1501
|
+
attrs: Record<string, unknown> | undefined,
|
|
1502
|
+
html: string,
|
|
1503
|
+
options: RenderProseMirrorOptions,
|
|
1504
|
+
): string {
|
|
1505
|
+
const linkAttributes = linkRelAttributes(attrs, href, options.siteHosts);
|
|
1506
|
+
const cue = options.siteHosts !== undefined && linkAttributes.target === "_blank" ? NEW_TAB_CUE_HTML : "";
|
|
1507
|
+
return renderElement("a", { href, ...linkAttributes }, `${html}${cue}`);
|
|
1460
1508
|
}
|
|
1461
1509
|
|
|
1462
1510
|
function isSafeUrl(url: string): boolean {
|
|
@@ -520,6 +520,14 @@ export function getAppearanceReference() {
|
|
|
520
520
|
+ "so full-bleed designs usually pair headerWidth 'wide' with headerLayout 'centered-menu', which keeps the "
|
|
521
521
|
+ "brand left, the buttons right, and the navigation centered. validate prints a hint, never a failure, when a "
|
|
522
522
|
+ "root-band page ships with a contained header.",
|
|
523
|
+
blockSpacingContract:
|
|
524
|
+
"Blocks on a page space themselves: text, tables and components all sit one gap apart, and the gap follows the "
|
|
525
|
+
+ "site's text size. blockSpacing scales it for the whole site in both color schemes: 'standard' keeps the gap "
|
|
526
|
+
+ "equal to the text spacing, 'compact' is three quarters of it and 'roomy' is one and a half times it. "
|
|
527
|
+
+ "A heading's top margin scales the same way. The last text block in a padded section gets the gap as extra "
|
|
528
|
+
+ "room before the section ends, and an image banner stays flush with its neighbors. A spacer block adds its "
|
|
529
|
+
+ "own height to the gap, so review spacers placed by hand. A site shows the change after its next whole-site "
|
|
530
|
+
+ "render.",
|
|
523
531
|
menuContract:
|
|
524
532
|
"navDrawerStyle 'full-screen' (the published default) opens the mobile menu over the whole viewport with the "
|
|
525
533
|
+ "brand centered above large centered items and navDrawerTransition choosing how it appears (fade, slide-down, "
|
|
@@ -841,6 +849,7 @@ export function formatPresentationReference(reference) {
|
|
|
841
849
|
+ "\nLogo contrast: " + reference.logoContrastContract
|
|
842
850
|
+ "\nFavicon: " + reference.faviconContract
|
|
843
851
|
+ "\nHeader width: " + reference.headerWidthContract
|
|
852
|
+
+ "\nBlock spacing: " + reference.blockSpacingContract
|
|
844
853
|
+ "\nMobile menu and menu font: " + reference.menuContract
|
|
845
854
|
+ "\nFooter: " + reference.footerBoundary
|
|
846
855
|
+ "\nFooter content guard: " + reference.footerContentGuard + "\n\nAtomic change set (one transaction):\n"
|
package/src/reference-help.js
CHANGED
|
@@ -95,7 +95,7 @@ import {
|
|
|
95
95
|
|
|
96
96
|
export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
|
|
97
97
|
|
|
98
|
-
export const REFERENCE_VERSION =
|
|
98
|
+
export const REFERENCE_VERSION = 51;
|
|
99
99
|
|
|
100
100
|
// The date-limit examples `help forms` prints; tests run them through the form validator.
|
|
101
101
|
export const FORM_DATE_WINDOW_FIELD = Object.freeze({
|
|
@@ -695,7 +695,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
695
695
|
"PAGE uses resourceId from .taproot-site-manifest.json, never pageId or path.",
|
|
696
696
|
"EXTERNAL_URL uses externalUrl; GROUP_HEADER has no target; every kind may carry children.",
|
|
697
697
|
"externalUrl is an absolute credential-free http/https URL, or a mailto:/tel: contact URL such as "
|
|
698
|
-
+ "tel:+15555550123, published exactly as authored."
|
|
698
|
+
+ "tel:+15555550123, published exactly as authored. An http or https address on another host opens in a new tab, "
|
|
699
|
+
+ "and the link says so to a screen reader.",
|
|
699
700
|
]),
|
|
700
701
|
example: Object.freeze({
|
|
701
702
|
siteId: "<site-uuid>",
|
|
@@ -1409,6 +1410,8 @@ function decorationReference() {
|
|
|
1409
1410
|
}\n## Rooted in warmth\n\nMove with confidence.\n:::`,
|
|
1410
1411
|
maskGuidance:
|
|
1411
1412
|
"Use a transparent PNG or WebP whose alpha channel contains only the mark. The active section context supplies the tint in both schemes.",
|
|
1413
|
+
sizeGuidance:
|
|
1414
|
+
"Set inlineSizePercent to size the art by the section's width, or blockSizePercent to size it by the section's height. The two cannot be combined.",
|
|
1412
1415
|
opaqueWarning:
|
|
1413
1416
|
"An opaque rectangle uses its whole alpha channel and will render as a tinted rectangle, not as a cut-out mark.",
|
|
1414
1417
|
});
|
|
@@ -1453,7 +1456,7 @@ function inlineFactsReference() {
|
|
|
1453
1456
|
+ "mix labelled and standalone facts. An empty string is rejected, so omitting a label stays explicit. "
|
|
1454
1457
|
+ "Unicode spaces, tabs, and LF/CRLF line breaks are allowed; other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal <br> tags are rejected. The label follows the value in reading order and is outside its link.",
|
|
1455
1458
|
urlPolicy:
|
|
1456
|
-
"Optional safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL. Empty/whitespace-only values, other schemes, backslashes, and ASCII controls are rejected; tel: remains a native link. Contact URLs require a body and refuse tel:// and mailto:// authority forms or control characters, consistently with navigation and footer links.",
|
|
1459
|
+
"Optional safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL. Empty/whitespace-only values, other schemes, backslashes, and ASCII controls are rejected; tel: remains a native link. Contact URLs require a body and refuse tel:// and mailto:// authority forms or control characters, consistently with navigation and footer links. An absolute http(s) address on another site opens in a new tab with nothing to set; the site's own addresses and all other values keep the same tab.",
|
|
1457
1460
|
markdown: Object.freeze({
|
|
1458
1461
|
fence: INLINE_FACTS_DEFINITION.markdown.fence,
|
|
1459
1462
|
body: INLINE_FACTS_DEFINITION.markdown.body,
|
|
@@ -1819,10 +1822,10 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
1819
1822
|
+ "choice is per link, internal links included.",
|
|
1820
1823
|
markdownRule:
|
|
1821
1824
|
'Write {rel="…" target="…"} directly after the link\'s closing parenthesis: either attribute alone or both, '
|
|
1822
|
-
+ 'each double-quoted once. rel is space-separated; target="_blank" opens a new tab.',
|
|
1825
|
+
+ 'each double-quoted once. rel is space-separated; an external web address opens in a new tab on its own, target="_blank" opens a new tab for any link, and target="_self" keeps a link in the same tab.',
|
|
1823
1826
|
pmJson:
|
|
1824
1827
|
'In .pm.json, a link mark carries attrs.rel (a space-separated string, or null) and attrs.target ("_blank" '
|
|
1825
|
-
+ 'opens a new tab and publishes with noopener noreferrer added, "_self"
|
|
1828
|
+
+ 'opens a new tab and publishes with noopener noreferrer added, "_self" keeps the same tab, and null leaves it to the default: an absolute http(s) link to another site opens in a new tab, and any other link keeps the same tab).',
|
|
1826
1829
|
refusals:
|
|
1827
1830
|
`Any other rel token, opener included, is refused (${CONTENT_ERROR_CODES.linkRel}); so is another target `
|
|
1828
1831
|
+ `(${CONTENT_ERROR_CODES.linkTarget}). The editor's old default, "noopener noreferrer nofollow", reads as no choice.`,
|
|
@@ -2039,9 +2042,15 @@ function formatRegistryField(field, indent = " ") {
|
|
|
2039
2042
|
: `; ${field.minScalars} through ${field.maxScalars} Unicode scalar values${
|
|
2040
2043
|
field.nonWhitespace ? "; non-whitespace" : ""
|
|
2041
2044
|
}`;
|
|
2045
|
+
// TR01329: a field with no default key is absent until an author sets it.
|
|
2046
|
+
const requirement = field.required
|
|
2047
|
+
? "required"
|
|
2048
|
+
: Object.hasOwn(field, "default")
|
|
2049
|
+
? `default ${JSON.stringify(field.default)}`
|
|
2050
|
+
: "optional; no default";
|
|
2042
2051
|
const line = `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} ${type}${
|
|
2043
2052
|
field.nullable ? " | null" : ""
|
|
2044
|
-
}; ${
|
|
2053
|
+
}; ${requirement}${bounds}${itemBounds}${scalarBounds}`;
|
|
2045
2054
|
return [line, ...(field.fields ?? []).map((child) => formatRegistryField(child, `${indent} `))].join("\n");
|
|
2046
2055
|
}
|
|
2047
2056
|
|
|
@@ -2128,7 +2137,7 @@ export function formatReferenceResult(result) {
|
|
|
2128
2137
|
}\n delivery URLs ${page.sections.decoration.deliveryUrlPolicy}\n\nTint mapping:\n${
|
|
2129
2138
|
Object.entries(page.sections.decoration.tintTokens).map(([name, token]) => ` ${name.padEnd(20)} ${token}`)
|
|
2130
2139
|
.join("\n")
|
|
2131
|
-
}\n\nTransparent-mask example:\n${page.sections.decoration.markdownExample}\n\nMask guidance: ${page.sections.decoration.maskGuidance}\nWarning: ${page.sections.decoration.opaqueWarning}\n\nRoot defaults and placement:\n${
|
|
2140
|
+
}\n\nTransparent-mask example:\n${page.sections.decoration.markdownExample}\n\nMask guidance: ${page.sections.decoration.maskGuidance}\nSize guidance: ${page.sections.decoration.sizeGuidance}\nWarning: ${page.sections.decoration.opaqueWarning}\n\nRoot defaults and placement:\n${
|
|
2132
2141
|
page.sections.placementMatrix.map(formatPlacement).join("\n")
|
|
2133
2142
|
}\n\nSemantic tables:\n placement document root or directly inside a top-level section; wide/full-well measure\n rows exactly ${page.tables.structure.headerRows} header row, then ${page.tables.limits.dataRows.min} through ${page.tables.limits.dataRows.max} data rows\n columns ${page.tables.limits.columns.min} through ${page.tables.limits.columns.max}; delimiter and every row must match the header width\n caption optional non-empty plain text; maximum ${page.tables.limits.captionScalars.max} Unicode scalar values; syntax ${page.tables.markdown.captionSyntax}\n caption guidance ${page.tables.captionGuidance}\n cells exactly ${page.tables.structure.paragraphChildrenPerCell} paragraph; headers need non-whitespace text; data cells may be empty; maximum ${page.tables.limits.cellTextScalars.max} text scalars\n inline ${
|
|
2134
2143
|
page.tables.structure.inlineNodes.join(", ")
|
package/src/setting-reference.js
CHANGED
|
@@ -83,6 +83,15 @@ const TAPROOT_STYLES_ENTRIES = {
|
|
|
83
83
|
"The color scheme a first-time visitor sees: light, dark, or system to follow the visitor's device.",
|
|
84
84
|
"The starting scheme of every published page. A visitor who uses the theme toggle keeps their own choice.",
|
|
85
85
|
),
|
|
86
|
+
blockSpacing: entry(
|
|
87
|
+
`${BRAND} > Block spacing`,
|
|
88
|
+
"The space between blocks on every page: compact, standard or roomy. Standard keeps the gap equal to the text "
|
|
89
|
+
+ "spacing; compact scales it to three quarters and roomy to one and a half times. Spacer blocks add their "
|
|
90
|
+
+ "height on top.",
|
|
91
|
+
"Both color schemes, on every published page. A site shows the change after its next whole-site render, which "
|
|
92
|
+
+ "happens when any settings group or navigation is selected for a stage or deploy. Pages copied forward from "
|
|
93
|
+
+ "an earlier deploy keep their old spacing until then.",
|
|
94
|
+
),
|
|
86
95
|
lightLogoId: entry(
|
|
87
96
|
`${BRAND} > Logo`,
|
|
88
97
|
"The site-owned image used as the brand logo in the light scheme.",
|