Skip to content

Commit 1b8981a

Browse files
authored
Eleventy build: Support generating WCAG 2.1 docs from main branch (#4007)
## Background There has long been interest in re-generating updated versions of the informative docs for WCAG 2.1. Previously, this relied upon a separate `WCAG-2.1` branch, but the ideal outcome is to be able to build directly from `main`, altering/pruning content as appropriate. ## Changes This adds more functionality to the Eleventy-based build system to support building content targeting WCAG 2.1 in addition to 2.2, specifically when `WCAG_VERSION=21` is set in environment variables: - Guidelines are pulled from the web so as to not pick up information and alterations from newer WCAG versions (e.g. addition of new SC and removal of 4.1.1) - Parsing/processing is augmented as necessary to support both source and pre-processed guidelines - Acknowledgements are also pulled from the web, pinned to 2.1 - `target-size-enhanced` is output to `target-size` for 2.1 - Detects what techniques only apply to later WCAG versions, and prunes them from associations - Avoids emitting pages for techniques and understanding documents that exclusively pertain to later WCAG versions - Further updates hard-coded version numbers in titles and level-1 headings, as well as throughout pages (especially `refer-to-wcag`) via Liquid expressions - Adds support for setting both `note` and `wcagXY` class on the same element - Conditionalizes a couple of 2.2-specific notes that were not within elements with `wcag22` class - Unchanged but worth noting: Obsolete technique data includes version information, so anything marked obsolete as of 2.2 will not contain an obsolete banner when publishing for 2.1
1 parent 1744c59 commit 1b8981a

24 files changed

+449
-197
lines changed

11ty/CustomLiquid.ts

+26-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { Cheerio, Element } from "cheerio";
21
import { Liquid, type Template } from "liquidjs";
32
import type { RenderOptions } from "liquidjs/dist/liquid-options";
43
import compact from "lodash-es/compact";
@@ -9,9 +8,9 @@ import { basename } from "path";
98
import type { GlobalData } from "eleventy.config";
109

1110
import { biblioPattern, getBiblio } from "./biblio";
12-
import { flattenDom, load } from "./cheerio";
11+
import { flattenDom, load, type CheerioAnyNode } from "./cheerio";
1312
import { generateId } from "./common";
14-
import { getTermsMap } from "./guidelines";
13+
import { getAcknowledgementsForVersion, getTermsMap } from "./guidelines";
1514
import { resolveTechniqueIdFromHref, understandingToTechniqueLinkSelector } from "./techniques";
1615
import { techniqueToUnderstandingLinkSelector } from "./understanding";
1716

@@ -63,7 +62,7 @@ const normalizeTocLabel = (label: string) =>
6362
* expand to a link with the full technique ID and title.
6463
* @param $el a $()-wrapped link element
6564
*/
66-
function expandTechniqueLink($el: Cheerio<Element>) {
65+
function expandTechniqueLink($el: CheerioAnyNode) {
6766
const href = $el.attr("href");
6867
if (!href) throw new Error("expandTechniqueLink: non-link element encountered");
6968
const id = resolveTechniqueIdFromHref(href);
@@ -308,6 +307,14 @@ export class CustomLiquid extends Liquid {
308307
if (indexPattern.test(scope.page.inputPath)) {
309308
// Remove empty list items due to obsolete technique link removal
310309
if (scope.isTechniques) $("ul.toc-wcag-docs li:empty").remove();
310+
311+
// Replace acknowledgements with pinned content for older versions
312+
if (process.env.WCAG_VERSION && $("section#acknowledgements").length) {
313+
const pinnedAcknowledgements = await getAcknowledgementsForVersion(scope.version);
314+
for (const [id, content] of Object.entries(pinnedAcknowledgements)) {
315+
$(`#${id} h3 +`).html(content);
316+
}
317+
}
311318
} else {
312319
const $title = $("title");
313320

@@ -401,7 +408,7 @@ export class CustomLiquid extends Liquid {
401408
// Process defined terms within #render,
402409
// where we have access to global data and the about box's HTML
403410
const $termLinks = $(termLinkSelector);
404-
const extractTermName = ($el: Cheerio<Element>) => {
411+
const extractTermName = ($el: CheerioAnyNode) => {
405412
const name = $el
406413
.text()
407414
.toLowerCase()
@@ -426,7 +433,7 @@ export class CustomLiquid extends Liquid {
426433
});
427434
} else if (scope.isUnderstanding) {
428435
const $termsList = $("section#key-terms dl");
429-
const extractTermNames = ($links: Cheerio<Element>) =>
436+
const extractTermNames = ($links: CheerioAnyNode) =>
430437
compact(uniq($links.toArray().map((el) => extractTermName($(el)))));
431438

432439
if ($termLinks.length) {
@@ -496,15 +503,17 @@ export class CustomLiquid extends Liquid {
496503
// (This is also needed for techniques/about)
497504
$("div.note").each((_, el) => {
498505
const $el = $(el);
499-
$el.replaceWith(`<div class="note">
506+
const classes = el.attribs.class;
507+
$el.replaceWith(`<div class="${classes}">
500508
<p class="note-title marker">Note</p>
501509
<div>${$el.html()}</div>
502510
</div>`);
503511
});
504512
// Handle p variant after div (the reverse would double-process)
505513
$("p.note").each((_, el) => {
506514
const $el = $(el);
507-
$el.replaceWith(`<div class="note">
515+
const classes = el.attribs.class;
516+
$el.replaceWith(`<div class="${classes}">
508517
<p class="note-title marker">Note</p>
509518
<p>${$el.html()}</p>
510519
</div>`);
@@ -522,13 +531,19 @@ export class CustomLiquid extends Liquid {
522531
// Handle new-in-version content
523532
$("[class^='wcag']").each((_, el) => {
524533
// Just like the XSLT process, this naively assumes that version numbers are the same length
525-
const classVersion = +el.attribs.class.replace(/^wcag/, "");
526-
const buildVersion = +scope.version;
534+
const classMatch = el.attribs.class.match(/\bwcag(\d\d)\b/);
535+
if (!classMatch) throw new Error(`Invalid wcagXY class found: ${el.attribs.class}`);
536+
const classVersion = +classMatch[1];
527537
if (isNaN(classVersion)) throw new Error(`Invalid wcagXY class found: ${el.attribs.class}`);
538+
const buildVersion = +scope.version;
539+
528540
if (classVersion > buildVersion) {
529541
$(el).remove();
530542
} else if (classVersion === buildVersion) {
531-
$(el).prepend(`<span class="new-version">New in WCAG ${scope.versionDecimal}: </span>`);
543+
if (/\bnote\b/.test(el.attribs.class))
544+
$(el).find(".marker").append(` (new in WCAG ${scope.versionDecimal})`);
545+
else
546+
$(el).prepend(`<span class="new-version">New in WCAG ${scope.versionDecimal}: </span>`);
532547
}
533548
// Output as-is if content pertains to a version older than what's being built
534549
});

11ty/README.md

+9-4
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,17 @@ but may be useful if you're not seeing what you expect in the output files.
4949

5050
### `WCAG_VERSION`
5151

52-
**Usage context:** currently this should not be changed, pending future improvements to `21` support
52+
**Usage context:** for building older versions of techniques and understanding docs
5353

5454
Indicates WCAG version being built, in `XY` format (i.e. no `.`).
55-
Influences base URLs for links to guidelines, techniques, and understanding pages.
56-
57-
**Default:** `22`
55+
Influences which pages get included, guideline/SC content,
56+
and a few details within pages (e.g. titles/URLs, "New in ..." content).
57+
Also influences base URLs for links to guidelines, techniques, and understanding pages.
58+
Explicitly setting this causes the build to reference guidelines and acknowledgements
59+
published under `w3.org/TR/WCAG{version}`, rather than using the local checkout
60+
(which is effectively the 2.2 Editors' Draft).
61+
62+
Possible values: `22`, `21`
5863

5964
### `WCAG_MODE`
6065

11ty/cheerio.ts

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { dirname, resolve } from "path";
55

66
export { load } from "cheerio";
77

8+
/** Superset of the type returned by any Cheerio $() call. */
9+
export type CheerioAnyNode = ReturnType<ReturnType<typeof load>>;
10+
811
/** Convenience function that combines readFile and load. */
912
export const loadFromFile = async (
1013
inputPath: string,

11ty/guidelines.ts

+116-55
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type { Cheerio, Element } from "cheerio";
1+
import axios from "axios";
2+
import type { CheerioAPI } from "cheerio";
23
import { glob } from "glob";
34

45
import { readFile } from "fs/promises";
56
import { basename } from "path";
67

7-
import { flattenDomFromFile, load } from "./cheerio";
8+
import { flattenDomFromFile, load, type CheerioAnyNode } from "./cheerio";
89
import { generateId } from "./common";
910

1011
export type WcagVersion = "20" | "21" | "22";
@@ -34,40 +35,21 @@ export const actRules = (
3435
)["act-rules"];
3536

3637
/**
37-
* Returns an object with keys for each existing WCAG 2 version,
38-
* each mapping to an array of basenames of HTML files under understanding/<version>
39-
* (Functionally equivalent to "guidelines-versions" target in build.xml)
38+
* Flattened object hash, mapping each WCAG 2 SC slug to the earliest WCAG version it applies to.
39+
* (Functionally equivalent to "guidelines-versions" target in build.xml; structurally inverted)
4040
*/
41-
export async function getGuidelinesVersions() {
41+
const scVersions = await (async function () {
4242
const paths = await glob("*/*.html", { cwd: "understanding" });
43-
const versions: Record<WcagVersion, string[]> = { "20": [], "21": [], "22": [] };
43+
const map: Record<string, WcagVersion> = {};
4444

4545
for (const path of paths) {
46-
const [version, filename] = path.split("/");
47-
assertIsWcagVersion(version);
48-
versions[version].push(basename(filename, ".html"));
46+
const [fileVersion, filename] = path.split("/");
47+
assertIsWcagVersion(fileVersion);
48+
map[basename(filename, ".html")] = fileVersion;
4949
}
5050

51-
for (const version of Object.keys(versions)) {
52-
assertIsWcagVersion(version);
53-
versions[version].sort();
54-
}
55-
return versions;
56-
}
57-
58-
/**
59-
* Like getGuidelinesVersions, but mapping each basename to the version it appears in
60-
*/
61-
export async function getInvertedGuidelinesVersions() {
62-
const versions = await getGuidelinesVersions();
63-
const invertedVersions: Record<string, string> = {};
64-
for (const [version, basenames] of Object.entries(versions)) {
65-
for (const basename of basenames) {
66-
invertedVersions[basename] = version;
67-
}
68-
}
69-
return invertedVersions;
70-
}
51+
return map;
52+
})();
7153

7254
export interface DocNode {
7355
id: string;
@@ -79,15 +61,15 @@ export interface DocNode {
7961
export interface Principle extends DocNode {
8062
content: string;
8163
num: `${number}`; // typed as string for consistency with guidelines/SC
82-
version: "WCAG20";
64+
version: "20";
8365
guidelines: Guideline[];
8466
type: "Principle";
8567
}
8668

8769
export interface Guideline extends DocNode {
8870
content: string;
8971
num: `${Principle["num"]}.${number}`;
90-
version: `WCAG${"20" | "21"}`;
72+
version: "20" | "21";
9173
successCriteria: SuccessCriterion[];
9274
type: "Guideline";
9375
}
@@ -97,50 +79,63 @@ export interface SuccessCriterion extends DocNode {
9779
num: `${Guideline["num"]}.${number}`;
9880
/** Level may be empty for obsolete criteria */
9981
level: "A" | "AA" | "AAA" | "";
100-
version: `WCAG${WcagVersion}`;
82+
version: WcagVersion;
10183
type: "SC";
10284
}
10385

10486
export function isSuccessCriterion(criterion: any): criterion is SuccessCriterion {
10587
return !!(criterion?.type === "SC" && "level" in criterion);
10688
}
10789

90+
/** Version-dependent overrides of SC shortcodes for older versions */
91+
export const scSlugOverrides: Record<string, (version: WcagVersion) => string> = {
92+
"target-size-enhanced": (version) => (version < "22" ? "target-size" : "target-size-enhanced"),
93+
};
94+
95+
/** Selectors ignored when capturing content of each Principle / Guideline / SC */
96+
const contentIgnores = [
97+
"h1, h2, h3, h4, h5, h6",
98+
"section",
99+
".change",
100+
".conformance-level",
101+
// Selectors below are specific to pre-published guidelines (for previous versions)
102+
".header-wrapper",
103+
".doclinks",
104+
];
105+
108106
/**
109-
* Returns HTML content used for Understanding guideline/SC boxes.
107+
* Returns HTML content used for Understanding guideline/SC boxes and term definitions.
110108
* @param $el Cheerio element of the full section from flattened guidelines/index.html
111109
*/
112-
const getContentHtml = ($el: Cheerio<Element>) => {
110+
const getContentHtml = ($el: CheerioAnyNode) => {
113111
// Load HTML into a new instance, remove elements we don't want, then return the remainder
114112
const $ = load($el.html()!, null, false);
115-
$("h1, h2, h3, h4, h5, h6, section, .change, .conformance-level").remove();
116-
return $.html();
113+
$(contentIgnores.join(", ")).remove();
114+
return $.html().trim();
117115
};
118116

119-
/**
120-
* Resolves information from guidelines/index.html;
121-
* comparable to the principles section of wcag.xml from the guidelines-xml Ant task.
122-
*/
123-
export async function getPrinciples() {
124-
const versions = await getInvertedGuidelinesVersions();
125-
const $ = await flattenDomFromFile("guidelines/index.html");
126-
117+
/** Performs processing common across WCAG versions */
118+
function processPrinciples($: CheerioAPI) {
127119
const principles: Principle[] = [];
128120
$(".principle").each((i, el) => {
129121
const guidelines: Guideline[] = [];
130-
$(".guideline", el).each((j, guidelineEl) => {
122+
$("> .guideline", el).each((j, guidelineEl) => {
131123
const successCriteria: SuccessCriterion[] = [];
132-
$(".sc", guidelineEl).each((k, scEl) => {
133-
const resolvedVersion = versions[scEl.attribs.id];
134-
assertIsWcagVersion(resolvedVersion);
135-
124+
// Source uses sc class, published uses guideline class (again)
125+
$("> .guideline, > .sc", guidelineEl).each((k, scEl) => {
126+
const scId = scEl.attribs.id;
136127
successCriteria.push({
137128
content: getContentHtml($(scEl)),
138-
id: scEl.attribs.id,
129+
id: scId,
139130
name: $("h4", scEl).text().trim(),
140131
num: `${i + 1}.${j + 1}.${k + 1}`,
141-
level: $("p.conformance-level", scEl).text().trim() as SuccessCriterion["level"],
132+
// conformance-level contains only letters in source, full (Level ...) in publish
133+
level: $("p.conformance-level", scEl)
134+
.text()
135+
.trim()
136+
.replace(/^\(Level (.*)\)$/, "$1") as SuccessCriterion["level"],
142137
type: "SC",
143-
version: `WCAG${resolvedVersion}`,
138+
version: scVersions[scId],
144139
});
145140
});
146141

@@ -150,7 +145,7 @@ export async function getPrinciples() {
150145
name: $("h3", guidelineEl).text().trim(),
151146
num: `${i + 1}.${j + 1}`,
152147
type: "Guideline",
153-
version: guidelineEl.attribs.id === "input-modalities" ? "WCAG21" : "WCAG20",
148+
version: guidelineEl.attribs.id === "input-modalities" ? "21" : "20",
154149
successCriteria,
155150
});
156151
});
@@ -161,14 +156,21 @@ export async function getPrinciples() {
161156
name: $("h2", el).text().trim(),
162157
num: `${i + 1}`,
163158
type: "Principle",
164-
version: "WCAG20",
159+
version: "20",
165160
guidelines,
166161
});
167162
});
168163

169164
return principles;
170165
}
171166

167+
/**
168+
* Resolves information from guidelines/index.html;
169+
* comparable to the principles section of wcag.xml from the guidelines-xml Ant task.
170+
*/
171+
export const getPrinciples = async () =>
172+
processPrinciples(await flattenDomFromFile("guidelines/index.html"));
173+
172174
/**
173175
* Returns a flattened object hash, mapping shortcodes to each principle/guideline/SC.
174176
*/
@@ -225,3 +227,62 @@ export async function getTermsMap() {
225227

226228
return terms;
227229
}
230+
231+
// Version-specific APIs
232+
233+
const remoteGuidelines$: Partial<Record<WcagVersion, CheerioAPI>> = {};
234+
235+
/** Loads guidelines from TR space for specific version, caching for future calls. */
236+
const loadRemoteGuidelines = async (version: WcagVersion) => {
237+
if (!remoteGuidelines$[version]) {
238+
const $ = load(
239+
(await axios.get(`https://www.w3.org/TR/WCAG${version}/`, { responseType: "text" })).data
240+
);
241+
242+
// Re-collapse definition links and notes, to be processed by this build system
243+
$(".guideline a.internalDFN").removeAttr("class data-link-type id href title");
244+
$(".guideline [role='note'] .marker").remove();
245+
$(".guideline [role='note']").find("> div, > p").addClass("note").unwrap();
246+
247+
// Bibliography references are not processed in Understanding SC boxes
248+
$(".guideline cite:has(a.bibref:only-child)").each((_, el) => {
249+
const $el = $(el);
250+
const $parent = $el.parent();
251+
$el.remove();
252+
// Remove surrounding square brackets (which aren't in a dedicated element)
253+
$parent.html($parent.html()!.replace(/ \[\]/g, ""));
254+
});
255+
256+
// Remove extra markup from headings so they can be parsed for names
257+
$("bdi").remove();
258+
259+
// Remove abbr elements which exist only in TR, not in informative docs
260+
$("#acknowledgements li abbr").each((_, abbrEl) => {
261+
$(abbrEl).replaceWith($(abbrEl).text());
262+
});
263+
264+
remoteGuidelines$[version] = $;
265+
}
266+
return remoteGuidelines$[version]!;
267+
};
268+
269+
/**
270+
* Retrieves heading and content information for acknowledgement subsections,
271+
* for preserving the section in About pages for earlier versions.
272+
*/
273+
export const getAcknowledgementsForVersion = async (version: WcagVersion) => {
274+
const $ = await loadRemoteGuidelines(version);
275+
const subsections: Record<string, string> = {};
276+
277+
$("section#acknowledgements section").each((_, el) => {
278+
subsections[el.attribs.id] = $(".header-wrapper + *", el).html()!;
279+
});
280+
281+
return subsections;
282+
};
283+
284+
/**
285+
* Retrieves and processes a pinned WCAG version using published guidelines.
286+
*/
287+
export const getPrinciplesForVersion = async (version: WcagVersion) =>
288+
processPrinciples(await loadRemoteGuidelines(version));

0 commit comments

Comments
 (0)