pour-engine 1.40.0 → 1.41.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/config/project.config.js +1 -1
- package/engine/AUDIT.html +24 -690
- package/engine/COVERAGE.md +4 -2
- package/engine/README.md +9 -4
- package/engine/index.js +34 -8
- package/engine/lib/accessible-name.js +69 -63
- package/engine/lib/contrast.js +111 -10
- package/engine/lib/dom.js +78 -18
- package/engine/lib/roles.js +5 -2
- package/engine/rules/best-practice/fieldset-legend.js +11 -5
- package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
- package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
- package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
- package/engine/rules/wcag/1.3.1-form-label.js +5 -29
- package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
- package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
- package/engine/rules/wcag/1.3.4-orientation.js +117 -48
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
- package/engine/rules/wcag/1.4.10-reflow.js +3 -3
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
- package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
- package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
- package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +8 -2
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
- package/engine/rules/wcag/2.4.2-document-title.js +7 -8
- package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
- package/engine/rules/wcag/2.5.8-target-size.js +161 -45
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
- package/engine/rules/wcag/4.1.2-button-name.js +1 -1
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
- package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
- package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
- package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
- package/engine/wcag22.js +7 -7
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import {
|
|
6
6
|
parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
|
|
7
7
|
backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity, showRatio,
|
|
8
|
+
hasPaintEffects, opacityGroupPaint, splitBackgroundLayers, sampledGradientRange,
|
|
8
9
|
} from '../../lib/contrast.js';
|
|
9
10
|
|
|
10
11
|
export default {
|
|
@@ -56,10 +57,26 @@ export default {
|
|
|
56
57
|
// far less contrast than its declared colours suggest.
|
|
57
58
|
const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
|
|
58
59
|
if (opacity < 0.05) return { status: 'pass' };
|
|
60
|
+
if (hasPaintEffects(element)) {
|
|
61
|
+
return { status: 'incomplete', message: 'A filter or blend mode changes the colours presented inside this field. Check the resulting text contrast by eye.' };
|
|
62
|
+
}
|
|
59
63
|
// The control's own background wins; else whatever shows through it.
|
|
60
64
|
const own = parseColor(style.backgroundColor);
|
|
61
65
|
let background;
|
|
62
|
-
if (
|
|
66
|
+
if (style.backgroundImage !== 'none') {
|
|
67
|
+
const layers = splitBackgroundLayers(style.backgroundImage);
|
|
68
|
+
const range = layers.length === 1 && layers[0].includes('gradient(')
|
|
69
|
+
? sampledGradientRange(layers[0]) : null;
|
|
70
|
+
// A constant opaque gradient has exactly one painted background.
|
|
71
|
+
// Other image paint needs spatial sampling, not its fallback colour.
|
|
72
|
+
const fullBox = /^(auto|auto auto)$/.test(style.backgroundSize)
|
|
73
|
+
&& style.backgroundPosition === '0% 0%'
|
|
74
|
+
&& ['repeat', 'no-repeat'].includes(style.backgroundRepeat);
|
|
75
|
+
if (!range || range.min !== range.max || opacity < 1 || !fullBox) {
|
|
76
|
+
return { status: 'incomplete', message: 'An image or gradient paints this field background. Check its text against the pixels behind it.' };
|
|
77
|
+
}
|
|
78
|
+
background = range.minColor;
|
|
79
|
+
} else if (own && own.a >= 1) {
|
|
63
80
|
background = own;
|
|
64
81
|
} else {
|
|
65
82
|
// An image or gradient painted under a see-through control is a
|
|
@@ -73,18 +90,27 @@ export default {
|
|
|
73
90
|
};
|
|
74
91
|
}
|
|
75
92
|
const behind = effectiveBackground(element);
|
|
76
|
-
background = behind
|
|
93
|
+
background = behind; // effectiveBackground already composites the field fill
|
|
77
94
|
}
|
|
78
95
|
if (!background) {
|
|
79
96
|
return { status: 'incomplete', message: 'The control’s background could not be determined — check its text contrast by eye.' };
|
|
80
97
|
}
|
|
81
98
|
const required = isLargeText(style) ? 3 : 4.5;
|
|
82
99
|
|
|
100
|
+
let unresolvedGroup = false;
|
|
83
101
|
const judge = (color, what, ownOpacity = 1) => {
|
|
84
102
|
const parsed = parseColor(color);
|
|
85
103
|
if (!parsed || parsed.a === 0) return null;
|
|
86
104
|
// Same treatment the main rule gives faded text: what reaches the eye
|
|
87
105
|
// is the declared colour thinned by the opacity it is painted at.
|
|
106
|
+
if (opacity < 1) {
|
|
107
|
+
const group = opacityGroupPaint(element, { ...parsed, a: parsed.a * ownOpacity });
|
|
108
|
+
if (group?.unresolved) { unresolvedGroup = true; return null; }
|
|
109
|
+
if (group) {
|
|
110
|
+
const ratio = contrastRatio(group.foreground, group.background);
|
|
111
|
+
return ratio >= required ? null : { what, ratio };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
88
114
|
const painted = opacity * ownOpacity;
|
|
89
115
|
const faded = painted < 1 ? { ...parsed, a: parsed.a * painted } : parsed;
|
|
90
116
|
const fg = faded.a < 1 ? composite(faded, background) : faded;
|
|
@@ -94,16 +120,18 @@ export default {
|
|
|
94
120
|
};
|
|
95
121
|
|
|
96
122
|
const failures = [];
|
|
97
|
-
const valueVerdict = judge(style.color, 'value text');
|
|
123
|
+
const valueVerdict = judge(style.webkitTextFillColor || style.color, 'value text');
|
|
98
124
|
if (valueVerdict) failures.push(valueVerdict);
|
|
99
125
|
// Placeholder text is real text users must read; browsers expose its
|
|
100
|
-
// computed colour via the ::placeholder pseudo-element.
|
|
101
|
-
|
|
126
|
+
// computed colour via the ::placeholder pseudo-element. Selectors 4
|
|
127
|
+
// defines :placeholder-shown for controls actually showing that text;
|
|
128
|
+
// a populated field's unpainted hint must not create a violation.
|
|
129
|
+
if (element.getAttribute('placeholder')?.trim() && element.matches(':placeholder-shown')) {
|
|
102
130
|
let placeholderColor = null;
|
|
103
131
|
let placeholderOpacity = 1;
|
|
104
132
|
try {
|
|
105
133
|
const placeholderStyle = getComputedStyle(element, '::placeholder');
|
|
106
|
-
placeholderColor = placeholderStyle.color;
|
|
134
|
+
placeholderColor = placeholderStyle.webkitTextFillColor || placeholderStyle.color;
|
|
107
135
|
// ::placeholder { opacity: .3 } is a widespread idiom (framework
|
|
108
136
|
// resets, Firefox's own UA default of 0.54), and the criterion is on
|
|
109
137
|
// the visual presentation of the text, which is the faded colour.
|
|
@@ -117,6 +145,7 @@ export default {
|
|
|
117
145
|
if (verdict) failures.push(verdict);
|
|
118
146
|
}
|
|
119
147
|
}
|
|
148
|
+
if (unresolvedGroup) return { status: 'incomplete', message: 'The field and its text fade together over an unresolved background. Check the presented text contrast by eye.' };
|
|
120
149
|
if (!failures.length) return { status: 'pass' };
|
|
121
150
|
const worst = failures.sort((a, b) => a.ratio - b.ratio)[0];
|
|
122
151
|
return {
|
|
@@ -20,10 +20,15 @@ export default {
|
|
|
20
20
|
const maxScale = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
|
|
21
21
|
const cappedZoom = maxScale && parseFloat(maxScale[1]) < 2;
|
|
22
22
|
if (!disablesZoom && !cappedZoom) return { status: 'pass' };
|
|
23
|
+
// Asserted the way ACT rule b4f0c3 asserts it, under its assumption that
|
|
24
|
+
// the page provides no other way to enlarge the text (technique G178).
|
|
25
|
+
// The message names that assumption so a reviewer who knows the page
|
|
26
|
+
// has its own text-size control can dismiss the finding.
|
|
27
|
+
// https://www.w3.org/WAI/standards-guidelines/act/rules/b4f0c3/
|
|
23
28
|
return {
|
|
24
29
|
status: 'fail',
|
|
25
|
-
message: 'This viewport meta tag stops
|
|
26
|
-
fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag.',
|
|
30
|
+
message: 'This viewport meta tag stops users zooming the page, so text cannot be enlarged with the browser. 1.4.4 is met only if the page provides its own control that enlarges all text to 200% (technique G178); this finding assumes it does not, since the tag cannot show it.',
|
|
31
|
+
fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag. If the page has its own text-size control that reaches 200%, record that and dismiss this finding.',
|
|
27
32
|
};
|
|
28
33
|
},
|
|
29
34
|
};
|
|
@@ -92,13 +92,13 @@ export default {
|
|
|
92
92
|
// An explicit negative tabindex is the author OPTING OUT of the tab
|
|
93
93
|
// order, and the browser honours it: measured in Chrome 151, a scroller
|
|
94
94
|
// with tabindex="-1" is skipped by Tab even though the same scroller
|
|
95
|
-
// without the attribute would be auto-focused.
|
|
96
|
-
//
|
|
97
|
-
//
|
|
95
|
+
// without the attribute would be auto-focused. That does not prove
|
|
96
|
+
// keyboard inoperability: external controls may scroll the region or
|
|
97
|
+
// move focus into it programmatically.
|
|
98
98
|
if (element.hasAttribute('tabindex')) {
|
|
99
99
|
return {
|
|
100
|
-
status: '
|
|
101
|
-
message: 'This region scrolls,
|
|
100
|
+
status: 'incomplete',
|
|
101
|
+
message: 'This region scrolls, has no detected tabbable content and is removed from sequential focus by a negative tabindex. Check whether keyboard-operable controls elsewhere scroll it or move focus into it; without such a mechanism, the overflowed content may be unreachable.',
|
|
102
102
|
fix: 'Change tabindex="-1" to tabindex="0" (plus role="region" and an aria-label describing it), or make something inside it tabbable.',
|
|
103
103
|
};
|
|
104
104
|
}
|
|
@@ -80,10 +80,16 @@ export default {
|
|
|
80
80
|
// 2.2.1 exempts a time limit "longer than 20 hours" — 20 hours exactly
|
|
81
81
|
// is still inside the criterion.
|
|
82
82
|
if (delay > 72000) return { status: 'pass' };
|
|
83
|
+
// Asserted the way ACT rule bc659a asserts it: a meta refresh gives the
|
|
84
|
+
// user no way to turn the limit off, adjust it or extend it, so the only
|
|
85
|
+
// escapes are the real-time and essential exceptions, which the tag
|
|
86
|
+
// cannot show either way. The message names that assumption so a
|
|
87
|
+
// reviewer who knows the page offers a control can dismiss the finding.
|
|
88
|
+
// https://www.w3.org/WAI/standards-guidelines/act/rules/bc659a/
|
|
83
89
|
return {
|
|
84
90
|
status: 'fail',
|
|
85
|
-
message: `The page refreshes
|
|
86
|
-
fix: 'Remove the timed refresh; let users act in their own time.',
|
|
91
|
+
message: `The page refreshes or redirects itself after ${delay}s. Slow readers lose their place, or the whole page, and a meta refresh offers no way to turn that off, adjust it or extend it. 2.2.1 excuses a time limit only where it is part of a real-time event or essential; this finding assumes neither applies, since the tag cannot show it.`,
|
|
92
|
+
fix: 'Remove the timed refresh; let users act in their own time. If the page offers a control that turns the refresh off, or the refresh is essential, record that and dismiss this finding.',
|
|
87
93
|
};
|
|
88
94
|
},
|
|
89
95
|
};
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Two shapes are checkable, and they need different verdicts.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// Legacy markup is not proof of motion: blink has no native animation in
|
|
10
|
+
// modern browsers, and a marquee may be stopped or controlled by script.
|
|
11
11
|
//
|
|
12
12
|
// A CSS animation set to repeat for ever is the modern ticker. Clauses 1
|
|
13
13
|
// and 2 are settled by the CSS itself — it starts with the page and never
|
|
@@ -117,15 +117,15 @@ export default {
|
|
|
117
117
|
if (!elements.length) return [];
|
|
118
118
|
const moving = endlesslyMoving(elements[0].ownerDocument);
|
|
119
119
|
return elements.map((element) => {
|
|
120
|
-
if (element.tagName === 'MARQUEE'
|
|
120
|
+
if (element.tagName === 'MARQUEE') {
|
|
121
121
|
// scrollamount="0" is the documented way to ship a <marquee> that
|
|
122
122
|
// doesn't actually move.
|
|
123
123
|
if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
|
|
124
124
|
return { status: 'pass' };
|
|
125
125
|
}
|
|
126
126
|
return {
|
|
127
|
-
status: '
|
|
128
|
-
message:
|
|
127
|
+
status: 'incomplete',
|
|
128
|
+
message: 'This marquee can scroll content. Check whether it actually starts automatically, continues for more than five seconds alongside other content, and has a working pause, stop or hide mechanism. The element alone does not establish those conditions.',
|
|
129
129
|
fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
|
|
130
130
|
};
|
|
131
131
|
}
|
|
@@ -80,8 +80,8 @@ export default {
|
|
|
80
80
|
const linkCount = roots.reduce((n, root) => n + (root.querySelectorAll?.('a[href], button').length ?? 0), 0);
|
|
81
81
|
if (linkCount < 4) return { status: 'pass' };
|
|
82
82
|
return {
|
|
83
|
-
status: '
|
|
84
|
-
message: `This page has ${linkCount} links and buttons but no landmark, heading, skip link
|
|
83
|
+
status: 'incomplete',
|
|
84
|
+
message: `This page has ${linkCount} links and buttons but no detected landmark, heading, skip link or titled frame. Check whether it contains blocks repeated on other pages and, if so, whether a mechanism lets users bypass them. A single page cannot establish repetition.`,
|
|
85
85
|
fix: 'Add a skip link like <a href="#content">Skip to content</a>, wrap primary content in <main>, or structure the page with headings.',
|
|
86
86
|
};
|
|
87
87
|
},
|
|
@@ -3,18 +3,17 @@
|
|
|
3
3
|
// merely PRESENT satisfies the markup, not the criterion, so the two
|
|
4
4
|
// well-known undescriptive shapes are checked as well.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Familiar template strings are review candidates, not proof of a missing
|
|
7
|
+
// topic: a document can genuinely describe the HTML document title or an
|
|
8
|
+
// artwork named Untitled 1.
|
|
9
9
|
// Tier two, sent to a human: single generic words and framework scaffold
|
|
10
10
|
// defaults. Almost always a leftover, but a gallery page really can be about
|
|
11
11
|
// an artwork called "Untitled", and "Example Domain" really is the subject
|
|
12
12
|
// of example.com. Those are somebody's call to make, not the engine's.
|
|
13
13
|
// "New document", "New page" and "New tab" sit in tier two as well: a
|
|
14
14
|
// document editor's creation screen, a wiki's new-page form or a
|
|
15
|
-
// browser-style app's new-tab page is about exactly that
|
|
16
|
-
//
|
|
17
|
-
// placeholder (2026-08-25 overnight audit).
|
|
15
|
+
// browser-style app's new-tab page is about exactly that. Even numbered
|
|
16
|
+
// editor-style titles remain a question about the actual subject.
|
|
18
17
|
//
|
|
19
18
|
// Deliberately NOT judged: whether a real-looking title matches the page it
|
|
20
19
|
// sits on. That needs someone who can read the page, and guessing at it
|
|
@@ -63,8 +62,8 @@ export default {
|
|
|
63
62
|
const normalized = title.replace(/\s+/g, ' ').toLowerCase();
|
|
64
63
|
if (PLACEHOLDER.has(normalized) || /^(?:untitled|new (?:document|page|tab))[\s-]*\d+$/.test(normalized)) {
|
|
65
64
|
return {
|
|
66
|
-
status: '
|
|
67
|
-
message: `“${title}”
|
|
65
|
+
status: 'incomplete',
|
|
66
|
+
message: `“${title}” resembles a template title. Check whether it describes this page's actual topic or purpose; the same words can also be a legitimate subject or name.`,
|
|
68
67
|
fix: 'Put this page\'s own subject in the title first, then the site name.',
|
|
69
68
|
};
|
|
70
69
|
}
|
|
@@ -13,11 +13,9 @@
|
|
|
13
13
|
// it sits in. A "Read more" under a headline naming the
|
|
14
14
|
// article is a judgment call about whether that context really
|
|
15
15
|
// reaches the user, so this REVIEWS. It never asserts.
|
|
16
|
-
// 2.4.9 (AAA)
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// ambiguous to everyone, which is the exception failing to
|
|
20
|
-
// apply rather than saving it, so this FAILS.
|
|
16
|
+
// 2.4.9 (AAA) requires a mechanism for purpose to be available from link
|
|
17
|
+
// text alone. A page may let users expand its link names,
|
|
18
|
+
// so a generic name in this snapshot still needs review.
|
|
21
19
|
//
|
|
22
20
|
// Judged on the ACCESSIBLE NAME, not the text content: a link written as
|
|
23
21
|
// <a href="…" aria-label="Read more about the budget">Read more</a> is
|
|
@@ -31,6 +29,8 @@
|
|
|
31
29
|
// normalizeName below): apostrophes become spaces, so "plus d informations"
|
|
32
30
|
// is how "plus d'informations" — straight or typographic — must be listed.
|
|
33
31
|
|
|
32
|
+
import { effectiveRole, implicitRole } from '../../lib/roles.js';
|
|
33
|
+
|
|
34
34
|
const GENERIC = new Set([
|
|
35
35
|
// English
|
|
36
36
|
'read more', 'read more about this', 'more', 'much more', 'learn more',
|
|
@@ -83,6 +83,7 @@ export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict
|
|
|
83
83
|
// link-name's finding, not this one.
|
|
84
84
|
selector: 'a[href], [role="link"]',
|
|
85
85
|
evaluate(element, { accessibleName }) {
|
|
86
|
+
if ((effectiveRole(element) ?? implicitRole(element)) !== 'link') return { status: 'pass' };
|
|
86
87
|
const name = accessibleName(element);
|
|
87
88
|
if (!name) return { status: 'pass' }; // nameless: link-name reports it
|
|
88
89
|
const normalized = normalizeName(name);
|
|
@@ -4,28 +4,13 @@
|
|
|
4
4
|
// threshold: the link text ALONE has to identify the purpose, and context
|
|
5
5
|
// is not allowed to make up the difference.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// everyone — which is the exception failing to apply rather than rescuing
|
|
11
|
-
// it. Nothing about the surrounding page can change that reading, so there
|
|
12
|
-
// is nothing here for a human to settle.
|
|
7
|
+
// The criterion allows a mechanism that expands link names. F84 requires
|
|
8
|
+
// checking that no such mechanism exists, which a name-only snapshot cannot
|
|
9
|
+
// prove. Semantic ambiguity and alternative presentation remain review.
|
|
13
10
|
//
|
|
14
|
-
//
|
|
15
|
-
// (
|
|
16
|
-
//
|
|
17
|
-
// term that names its destination exactly (one-sided decoration like
|
|
18
|
-
// python.org's CSS-generated ">>>More" prompt still asserts);
|
|
19
|
-
// · a name that is exactly "go" — every corpus hit was a brand logo
|
|
20
|
-
// ("Go" is the language's name), and a brand name identifies the link.
|
|
21
|
-
//
|
|
22
|
-
// A third, beside "go" (2026-08-25 overnight audit): the French entries
|
|
23
|
-
// "plus" and "suite" are ordinary English words and, more to the point,
|
|
24
|
-
// common English plan and product names (a "Plus" tier, a "Suite"). A
|
|
25
|
-
// pricing link named "Plus" identifies its destination the way the Go logo
|
|
26
|
-
// does, so outside a page declared French they review rather than assert.
|
|
27
|
-
// On a link whose closest lang is French the generic reading ("more",
|
|
28
|
-
// "continued") is the only one and the assertion stands.
|
|
11
|
+
// The review wording distinguishes recognizable proper-name ambiguities
|
|
12
|
+
// (Go, Plus, Suite and quoted code terms) from ordinary generic phrases.
|
|
13
|
+
// Neither branch treats a phrase-list match as semantic proof.
|
|
29
14
|
import { createLinkPurposeRule } from './2.4.4-link-text-generic.js';
|
|
30
15
|
|
|
31
16
|
const FRENCH_ONLY_ENGLISH_NAMES = new Set(['plus', 'suite']);
|
|
@@ -50,8 +35,8 @@ export default createLinkPurposeRule({
|
|
|
50
35
|
fix: 'If the name really is generic here, put the destination in the link text, e.g. "Read more about the 2026 budget".',
|
|
51
36
|
};
|
|
52
37
|
return {
|
|
53
|
-
status: '
|
|
54
|
-
message: `“${name}”
|
|
38
|
+
status: 'incomplete',
|
|
39
|
+
message: `“${name}” appears generic when read alone. Check whether it identifies the destination in this context or whether a mechanism makes the link text descriptive. 2.4.9 permits such a mechanism; the initial link name alone cannot establish a failure.`,
|
|
55
40
|
fix: 'Name the destination in the link text, e.g. "Read more about the 2026 budget".',
|
|
56
41
|
};
|
|
57
42
|
},
|