Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Eleventy build: Support generating WCAG 2.1 docs from main branch #4007

Merged
merged 24 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f9b0783
WIP Add support for generating WCAG 2.1 from main branch
kfranqueiro Jul 10, 2024
b563318
2.1 support: Pin against published guidelines, fix permalink filter
kfranqueiro Jul 15, 2024
ccb2572
2.1 support: Universally update version in titles/h1s of other docs
kfranqueiro Jul 16, 2024
b3b6042
2.1 support: Replace hard-coded version references in refer-to-wcag
kfranqueiro Jul 16, 2024
9efa25c
2.1 support: Update hard-coded version in understanding-techniques
kfranqueiro Jul 16, 2024
46a610d
Add support for an element being both wcagXY and note
kfranqueiro Jul 16, 2024
2a6353b
2.1: Add wcag22 class to 2.2-specific three-flashes-or-below note
kfranqueiro Jul 16, 2024
d5212fc
2.1: Conditionalize 2.2 SC reference in pointer-gestures
kfranqueiro Jul 16, 2024
9b5df4a
2.1 support: Avoid linking to future-version-only techniques
kfranqueiro Jul 18, 2024
e55d98b
2.1 support: Reference variable for version in index pages
kfranqueiro Jul 19, 2024
cb50cc3
Fix slug-in-version check to account for mapped slugs
kfranqueiro Aug 12, 2024
b335585
Preserve W3C staff contact attribution in < 2.2
kfranqueiro Aug 22, 2024
306cc45
2.1 support: Use acknowledgments content from pinned version
kfranqueiro Aug 22, 2024
49b66a4
2.1 support: Pin ACT mappings to requested version
kfranqueiro Sep 30, 2024
1cf757a
Remove paragraph from target-size-enhanced that duplicates template
kfranqueiro Sep 30, 2024
c96306c
Update Intro and Understanding Techniques to reference specific version
kfranqueiro Oct 1, 2024
d940679
Re-simplify title logic for other understanding docs
kfranqueiro Oct 1, 2024
c2c6487
Revert "2.1 support: Pin ACT mappings to requested version"
kfranqueiro Oct 7, 2024
890c995
Move techniques changelog to version-specific files
kfranqueiro Oct 9, 2024
385d095
Add 2.1 variant of publish-w3c script
kfranqueiro Oct 9, 2024
0ab3f99
Fix Cheerio 1.0.0 type errors
kfranqueiro Oct 9, 2024
cfeadf4
2.1 support: Prune future-version SCs from technique titles
kfranqueiro Nov 8, 2024
1f224cb
Update Eleventy dependency for security fix
kfranqueiro Nov 20, 2024
d9bd774
Further expand README docs in light of 2.1 build support
kfranqueiro Nov 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions 11ty/CustomLiquid.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Cheerio, Element } from "cheerio";
import { Liquid, type Template } from "liquidjs";
import type { RenderOptions } from "liquidjs/dist/liquid-options";
import compact from "lodash-es/compact";
Expand All @@ -9,9 +8,9 @@ import { basename } from "path";
import type { GlobalData } from "eleventy.config";

import { biblioPattern, getBiblio } from "./biblio";
import { flattenDom, load } from "./cheerio";
import { flattenDom, load, type CheerioAnyNode } from "./cheerio";
import { generateId } from "./common";
import { getTermsMap } from "./guidelines";
import { getAcknowledgementsForVersion, getTermsMap } from "./guidelines";
import { resolveTechniqueIdFromHref, understandingToTechniqueLinkSelector } from "./techniques";
import { techniqueToUnderstandingLinkSelector } from "./understanding";

Expand Down Expand Up @@ -63,7 +62,7 @@ const normalizeTocLabel = (label: string) =>
* expand to a link with the full technique ID and title.
* @param $el a $()-wrapped link element
*/
function expandTechniqueLink($el: Cheerio<Element>) {
function expandTechniqueLink($el: CheerioAnyNode) {
const href = $el.attr("href");
if (!href) throw new Error("expandTechniqueLink: non-link element encountered");
const id = resolveTechniqueIdFromHref(href);
Expand Down Expand Up @@ -308,6 +307,14 @@ export class CustomLiquid extends Liquid {
if (indexPattern.test(scope.page.inputPath)) {
// Remove empty list items due to obsolete technique link removal
if (scope.isTechniques) $("ul.toc-wcag-docs li:empty").remove();

// Replace acknowledgements with pinned content for older versions
if (process.env.WCAG_VERSION && $("section#acknowledgements").length) {
const pinnedAcknowledgements = await getAcknowledgementsForVersion(scope.version);
for (const [id, content] of Object.entries(pinnedAcknowledgements)) {
$(`#${id} h3 +`).html(content);
}
}
} else {
const $title = $("title");

Expand Down Expand Up @@ -401,7 +408,7 @@ export class CustomLiquid extends Liquid {
// Process defined terms within #render,
// where we have access to global data and the about box's HTML
const $termLinks = $(termLinkSelector);
const extractTermName = ($el: Cheerio<Element>) => {
const extractTermName = ($el: CheerioAnyNode) => {
const name = $el
.text()
.toLowerCase()
Expand All @@ -426,7 +433,7 @@ export class CustomLiquid extends Liquid {
});
} else if (scope.isUnderstanding) {
const $termsList = $("section#key-terms dl");
const extractTermNames = ($links: Cheerio<Element>) =>
const extractTermNames = ($links: CheerioAnyNode) =>
compact(uniq($links.toArray().map((el) => extractTermName($(el)))));

if ($termLinks.length) {
Expand Down Expand Up @@ -496,15 +503,17 @@ export class CustomLiquid extends Liquid {
// (This is also needed for techniques/about)
$("div.note").each((_, el) => {
const $el = $(el);
$el.replaceWith(`<div class="note">
const classes = el.attribs.class;
$el.replaceWith(`<div class="${classes}">
<p class="note-title marker">Note</p>
<div>${$el.html()}</div>
</div>`);
});
// Handle p variant after div (the reverse would double-process)
$("p.note").each((_, el) => {
const $el = $(el);
$el.replaceWith(`<div class="note">
const classes = el.attribs.class;
$el.replaceWith(`<div class="${classes}">
<p class="note-title marker">Note</p>
<p>${$el.html()}</p>
</div>`);
Expand All @@ -522,13 +531,19 @@ export class CustomLiquid extends Liquid {
// Handle new-in-version content
$("[class^='wcag']").each((_, el) => {
// Just like the XSLT process, this naively assumes that version numbers are the same length
const classVersion = +el.attribs.class.replace(/^wcag/, "");
const buildVersion = +scope.version;
const classMatch = el.attribs.class.match(/\bwcag(\d\d)\b/);
if (!classMatch) throw new Error(`Invalid wcagXY class found: ${el.attribs.class}`);
const classVersion = +classMatch[1];
if (isNaN(classVersion)) throw new Error(`Invalid wcagXY class found: ${el.attribs.class}`);
const buildVersion = +scope.version;

if (classVersion > buildVersion) {
$(el).remove();
} else if (classVersion === buildVersion) {
$(el).prepend(`<span class="new-version">New in WCAG ${scope.versionDecimal}: </span>`);
if (/\bnote\b/.test(el.attribs.class))
$(el).find(".marker").append(` (new in WCAG ${scope.versionDecimal})`);
else
$(el).prepend(`<span class="new-version">New in WCAG ${scope.versionDecimal}: </span>`);
}
// Output as-is if content pertains to a version older than what's being built
});
Expand Down
13 changes: 9 additions & 4 deletions 11ty/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,17 @@ but may be useful if you're not seeing what you expect in the output files.

### `WCAG_VERSION`

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

Indicates WCAG version being built, in `XY` format (i.e. no `.`).
Influences base URLs for links to guidelines, techniques, and understanding pages.

**Default:** `22`
Influences which pages get included, guideline/SC content,
and a few details within pages (e.g. titles/URLs, "New in ..." content).
Also influences base URLs for links to guidelines, techniques, and understanding pages.
Explicitly setting this causes the build to reference guidelines and acknowledgements
published under `w3.org/TR/WCAG{version}`, rather than using the local checkout
(which is effectively the 2.2 Editors' Draft).

Possible values: `22`, `21`

### `WCAG_MODE`

Expand Down
3 changes: 3 additions & 0 deletions 11ty/cheerio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { dirname, resolve } from "path";

export { load } from "cheerio";

/** Superset of the type returned by any Cheerio $() call. */
export type CheerioAnyNode = ReturnType<ReturnType<typeof load>>;

/** Convenience function that combines readFile and load. */
export const loadFromFile = async (
inputPath: string,
Expand Down
171 changes: 116 additions & 55 deletions 11ty/guidelines.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Cheerio, Element } from "cheerio";
import axios from "axios";
import type { CheerioAPI } from "cheerio";
import { glob } from "glob";

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

import { flattenDomFromFile, load } from "./cheerio";
import { flattenDomFromFile, load, type CheerioAnyNode } from "./cheerio";
import { generateId } from "./common";

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

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

for (const path of paths) {
const [version, filename] = path.split("/");
assertIsWcagVersion(version);
versions[version].push(basename(filename, ".html"));
const [fileVersion, filename] = path.split("/");
assertIsWcagVersion(fileVersion);
map[basename(filename, ".html")] = fileVersion;
}

for (const version of Object.keys(versions)) {
assertIsWcagVersion(version);
versions[version].sort();
}
return versions;
}

/**
* Like getGuidelinesVersions, but mapping each basename to the version it appears in
*/
export async function getInvertedGuidelinesVersions() {
const versions = await getGuidelinesVersions();
const invertedVersions: Record<string, string> = {};
for (const [version, basenames] of Object.entries(versions)) {
for (const basename of basenames) {
invertedVersions[basename] = version;
}
}
return invertedVersions;
}
return map;
})();

export interface DocNode {
id: string;
Expand All @@ -79,15 +61,15 @@ export interface DocNode {
export interface Principle extends DocNode {
content: string;
num: `${number}`; // typed as string for consistency with guidelines/SC
version: "WCAG20";
version: "20";
guidelines: Guideline[];
type: "Principle";
}

export interface Guideline extends DocNode {
content: string;
num: `${Principle["num"]}.${number}`;
version: `WCAG${"20" | "21"}`;
version: "20" | "21";
successCriteria: SuccessCriterion[];
type: "Guideline";
}
Expand All @@ -97,50 +79,63 @@ export interface SuccessCriterion extends DocNode {
num: `${Guideline["num"]}.${number}`;
/** Level may be empty for obsolete criteria */
level: "A" | "AA" | "AAA" | "";
version: `WCAG${WcagVersion}`;
version: WcagVersion;
type: "SC";
}

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

/** Version-dependent overrides of SC shortcodes for older versions */
export const scSlugOverrides: Record<string, (version: WcagVersion) => string> = {
"target-size-enhanced": (version) => (version < "22" ? "target-size" : "target-size-enhanced"),
};

/** Selectors ignored when capturing content of each Principle / Guideline / SC */
const contentIgnores = [
"h1, h2, h3, h4, h5, h6",
"section",
".change",
".conformance-level",
// Selectors below are specific to pre-published guidelines (for previous versions)
".header-wrapper",
".doclinks",
];

/**
* Returns HTML content used for Understanding guideline/SC boxes.
* Returns HTML content used for Understanding guideline/SC boxes and term definitions.
* @param $el Cheerio element of the full section from flattened guidelines/index.html
*/
const getContentHtml = ($el: Cheerio<Element>) => {
const getContentHtml = ($el: CheerioAnyNode) => {
// Load HTML into a new instance, remove elements we don't want, then return the remainder
const $ = load($el.html()!, null, false);
$("h1, h2, h3, h4, h5, h6, section, .change, .conformance-level").remove();
return $.html();
$(contentIgnores.join(", ")).remove();
return $.html().trim();
};

/**
* Resolves information from guidelines/index.html;
* comparable to the principles section of wcag.xml from the guidelines-xml Ant task.
*/
export async function getPrinciples() {
const versions = await getInvertedGuidelinesVersions();
const $ = await flattenDomFromFile("guidelines/index.html");

/** Performs processing common across WCAG versions */
function processPrinciples($: CheerioAPI) {
const principles: Principle[] = [];
$(".principle").each((i, el) => {
const guidelines: Guideline[] = [];
$(".guideline", el).each((j, guidelineEl) => {
$("> .guideline", el).each((j, guidelineEl) => {
const successCriteria: SuccessCriterion[] = [];
$(".sc", guidelineEl).each((k, scEl) => {
const resolvedVersion = versions[scEl.attribs.id];
assertIsWcagVersion(resolvedVersion);

// Source uses sc class, published uses guideline class (again)
$("> .guideline, > .sc", guidelineEl).each((k, scEl) => {
const scId = scEl.attribs.id;
successCriteria.push({
content: getContentHtml($(scEl)),
id: scEl.attribs.id,
id: scId,
name: $("h4", scEl).text().trim(),
num: `${i + 1}.${j + 1}.${k + 1}`,
level: $("p.conformance-level", scEl).text().trim() as SuccessCriterion["level"],
// conformance-level contains only letters in source, full (Level ...) in publish
level: $("p.conformance-level", scEl)
.text()
.trim()
.replace(/^\(Level (.*)\)$/, "$1") as SuccessCriterion["level"],
type: "SC",
version: `WCAG${resolvedVersion}`,
version: scVersions[scId],
});
});

Expand All @@ -150,7 +145,7 @@ export async function getPrinciples() {
name: $("h3", guidelineEl).text().trim(),
num: `${i + 1}.${j + 1}`,
type: "Guideline",
version: guidelineEl.attribs.id === "input-modalities" ? "WCAG21" : "WCAG20",
version: guidelineEl.attribs.id === "input-modalities" ? "21" : "20",
successCriteria,
});
});
Expand All @@ -161,14 +156,21 @@ export async function getPrinciples() {
name: $("h2", el).text().trim(),
num: `${i + 1}`,
type: "Principle",
version: "WCAG20",
version: "20",
guidelines,
});
});

return principles;
}

/**
* Resolves information from guidelines/index.html;
* comparable to the principles section of wcag.xml from the guidelines-xml Ant task.
*/
export const getPrinciples = async () =>
processPrinciples(await flattenDomFromFile("guidelines/index.html"));

/**
* Returns a flattened object hash, mapping shortcodes to each principle/guideline/SC.
*/
Expand Down Expand Up @@ -225,3 +227,62 @@ export async function getTermsMap() {

return terms;
}

// Version-specific APIs

const remoteGuidelines$: Partial<Record<WcagVersion, CheerioAPI>> = {};

/** Loads guidelines from TR space for specific version, caching for future calls. */
const loadRemoteGuidelines = async (version: WcagVersion) => {
if (!remoteGuidelines$[version]) {
const $ = load(
(await axios.get(`https://www.w3.org/TR/WCAG${version}/`, { responseType: "text" })).data
);

// Re-collapse definition links and notes, to be processed by this build system
$(".guideline a.internalDFN").removeAttr("class data-link-type id href title");
$(".guideline [role='note'] .marker").remove();
$(".guideline [role='note']").find("> div, > p").addClass("note").unwrap();

// Bibliography references are not processed in Understanding SC boxes
$(".guideline cite:has(a.bibref:only-child)").each((_, el) => {
const $el = $(el);
const $parent = $el.parent();
$el.remove();
// Remove surrounding square brackets (which aren't in a dedicated element)
$parent.html($parent.html()!.replace(/ \[\]/g, ""));
});

// Remove extra markup from headings so they can be parsed for names
$("bdi").remove();

// Remove abbr elements which exist only in TR, not in informative docs
$("#acknowledgements li abbr").each((_, abbrEl) => {
$(abbrEl).replaceWith($(abbrEl).text());
});

remoteGuidelines$[version] = $;
}
return remoteGuidelines$[version]!;
};

/**
* Retrieves heading and content information for acknowledgement subsections,
* for preserving the section in About pages for earlier versions.
*/
export const getAcknowledgementsForVersion = async (version: WcagVersion) => {
const $ = await loadRemoteGuidelines(version);
const subsections: Record<string, string> = {};

$("section#acknowledgements section").each((_, el) => {
subsections[el.attribs.id] = $(".header-wrapper + *", el).html()!;
});

return subsections;
};

/**
* Retrieves and processes a pinned WCAG version using published guidelines.
*/
export const getPrinciplesForVersion = async (version: WcagVersion) =>
processPrinciples(await loadRemoteGuidelines(version));
Loading