Skip to content

Commit 9ab7c93

Browse files
authored
JSON generation: Generate techniques field on SCs from data (#4619)
This restores generation of the `techniques` field for success criteria in `wcag.json`, enabled by the new data representation of associated techniques created in #4509. This had been removed in #4301 in the initial effort to re-enable automatic `wcag.json` generation, and its removal was then raised as an issue in #4393; see "`techniques` vs. `techniquesHtml`" in #4393 (comment) for more background. ## `wcag.json` Changes The most notable breaking change is that `techniquesHtml` is no longer present on success criteria, replaced by `techniques`, which brings `wcag.json` closer to its state prior to #4301. The rest of this section compares the new state of the `techniques` field to its state prior to removal in #4301, except where specified otherwise. ### Breaking - The top-level `sufficient`, `advisory`, and `failure` keys now exist on a single object, rather than array of multiple separate objects each with one key - Each of these keys will only exist if there are corresponding techniques - Technique IDs no longer contain a `TECH:` prefix - Entries that do not link to an existing technique page no longer have an `id` - Previously, these always included an auto-incremented ID which might not be stable, containing the word `future` which was sometimes inaccurate/misleading) - Subsections (e.g. for situations) are now listed directly within the `sufficient` array, i.e. there is no longer an extra child object in between - Top-level entries under `sufficient` will either be all subsections or all techniques, never a mix of both - Each subsection entry contains `title`, `techniques`, and optionally `note`; presence of subsections can be feature-detected by checking for the `techniques` field in one of the entries - This is also used for the subsections in 1.4.8: Visual Presentation which are requirements, not situations; this SC's `sufficient` techniques had not been included at all prior to #4301 - Conjunction entries containing `and` (instead of `id`/`title`) may also contain `using`, e.g. in 1.2.4: Captions (Live) - Titles may contain HTML content ### Additions - Each technique may include `prefix` and/or `suffix`, indicating content to display before/after the technique link/title - May include HTML - Leading/trailing space is not explicitly included in `prefix` or `suffix` values, but spaces are implied in between `prefix`, the link/title, and `suffix` - `suffix` is sometimes used to describe a `using` relationship, i.e. those are now better represented in the serialized format - Techniques which include `id` also include `technology`, allowing their respective URL to be pieced together (e.g. `https://www.w3.org/WAI/WCAG22/Techniques/{technology}/{id}`) - For success criteria with `sufficient`, a `sufficientNote` field may be included alongside, with HTML content to be displayed at the bottom of the Sufficient section - New optional `groups` construct for `sufficient` subsections, currently only used by 1.1.1: Non-text Content - Fields: `id`, `title`, `techniques` - Automatically implies `using` relationship on all `techniques` within the subsection ### Bugfixes since #4301 These issues had impacted `techniquesHtml` in the previous update. They are now resolved for any HTML content within fields under `techniques`. - Relative links to Understanding pages are now properly handled, avoiding broken relative links - WCAG 2.1 only: Absolute URLs to techniques are no longer hard-coded to WCAG22
1 parent 5e44195 commit 9ab7c93

File tree

1 file changed

+186
-103
lines changed

1 file changed

+186
-103
lines changed

11ty/json.ts

Lines changed: 186 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import { load, type CheerioAPI } from "cheerio";
2+
import invert from "lodash-es/invert";
23
import pick from "lodash-es/pick";
34

4-
import { readFile, writeFile } from "fs/promises";
5+
import { writeFile } from "fs/promises";
56
import { join } from "path";
67

7-
import { loadFromFile, type CheerioAnyNode, type CheerioElement } from "./cheerio";
8+
import type {
9+
ResolvedUnderstandingAssociatedTechnique,
10+
UnderstandingAssociatedTechniqueArray,
11+
UnderstandingAssociatedTechniqueEntry,
12+
UnderstandingAssociatedTechniqueParent,
13+
UnderstandingAssociatedTechniqueSection,
14+
} from "understanding/understanding";
15+
import eleventyUnderstanding from "understanding/understanding.11tydata";
16+
17+
import { type CheerioAnyNode } from "./cheerio";
818
import { resolveDecimalVersion } from "./common";
919
import {
1020
type SuccessCriterion,
@@ -14,15 +24,20 @@ import {
1424
getTermsMapForVersion,
1525
assertIsWcagVersion,
1626
getFlatGuidelines,
27+
generateScSlugOverrides,
1728
} from "./guidelines";
1829
import {
30+
expandTechniqueToObject,
1931
getFlatTechniques,
2032
getTechniquesByTechnology,
2133
techniqueAssociationTypes,
2234
type Technique,
2335
type TechniqueAssociationType,
36+
type Technology,
2437
} from "./techniques";
2538

39+
const removeNewlines = (str: string) => str.trim().replace(/\n\s+/g, " ");
40+
2641
const altIds: Record<string, string> = {
2742
"text-alternatives": "text-equiv",
2843
"non-text-content": "text-equiv-all",
@@ -131,7 +146,7 @@ function createDetailsFromSc(sc: SuccessCriterion) {
131146
const $el = $(el);
132147
$el.replaceWith($el.text());
133148
});
134-
return $el.html()!.replace(/\n\s+/g, " ").trim();
149+
return removeNewlines($el.html()!);
135150
}
136151

137152
// Note handling is in a reusable function to handle 1.4.7 edge case inside dd
@@ -197,107 +212,188 @@ function createDetailsFromSc(sc: SuccessCriterion) {
197212
return details;
198213
}
199214

200-
interface TechniquesSituation {
201-
content: string;
215+
interface SerializedTechniqueAssociation {
216+
id?: string;
217+
technology?: Technology;
202218
title: string;
219+
prefix?: string;
220+
suffix?: string;
221+
using?: SerializedTechniqueAssociationArray;
222+
}
223+
224+
interface SerializedTechniqueConjunction {
225+
and: SerializedTechniqueAssociation[];
226+
using?: SerializedTechniqueAssociationArray;
203227
}
204228

205-
type TechniquesHtmlMap = Partial<Record<TechniqueAssociationType, string | TechniquesSituation[]>>;
229+
type SerializedTechniqueAssociationArray = Array<
230+
SerializedTechniqueAssociation | SerializedTechniqueConjunction
231+
>;
232+
233+
type SerializedTechniqueSection = {
234+
title: string;
235+
groups?: {
236+
id: string;
237+
title: string;
238+
techniques: SerializedTechniqueAssociationArray;
239+
}[];
240+
note?: string;
241+
techniques: SerializedTechniqueAssociationArray;
242+
};
243+
244+
interface SerializedTechniques
245+
extends Partial<
246+
Record<
247+
TechniqueAssociationType,
248+
SerializedTechniqueAssociationArray | SerializedTechniqueSection[]
249+
>
250+
> {
251+
sufficientNote?: string;
252+
}
253+
254+
/**
255+
* Converts a technique's using properties to their string representation.
256+
* This is not reused by the techniques-list template due to differing requirements
257+
* (the template operates within HTML context and also handles groups).
258+
*/
259+
function stringifyUsingProps(technique: UnderstandingAssociatedTechniqueParent) {
260+
const { usingConjunction = "using", usingQuantity = "one", usingPrefix } = technique;
261+
const quantityStr = usingQuantity ? `${usingQuantity} of ` : "";
262+
return `${usingPrefix ? `${usingPrefix} ` : ""}${usingConjunction} ${quantityStr}the following techniques:`;
263+
}
264+
265+
/** Removes links; intended for use with notes (consistent with previous JSON output) */
266+
const cleanLinks = (html: string) => html.replace(/<a[^>]*>([^<]*)<\/a>/g, "$1");
267+
268+
/** Resolves relative links against the base folder for the WCAG version */
269+
const resolveLinks = (html: string, version: WcagVersion) =>
270+
html.replace(/href="([^"]*)"/g, (match, href: string) => {
271+
if (/^https?:/.test(href)) return match;
272+
const domain = `https://www.w3.org`;
273+
const baseUrl = `${domain}/WAI/WCAG${version}/Understanding/`;
274+
if (href.startsWith("/")) return `href="${domain}${href}"`;
275+
return `href="${baseUrl}${href}"`;
276+
});
206277

207-
async function createTechniquesHtmlFromSc(
278+
const associatedTechniques = eleventyUnderstanding({}).associatedTechniques;
279+
function createTechniquesFromSc(
208280
sc: SuccessCriterion,
209-
techniquesMap: Record<string, Technique>
281+
techniquesMap: Record<string, Technique>,
282+
version: WcagVersion
210283
) {
211-
const $ = await loadFromFile(join("_site", "understanding", `${sc.id}.html`));
284+
if (sc.level === "") return {}; // Do not emit techniques for obsolete SC (e.g. 4.1.1)
212285

213-
function cleanHtml($el: CheerioElement) {
214-
// Remove links within notes, which point to definitions or Understanding sections
215-
$el.find(".note a").each((_, aEl) => {
216-
const $aEl = $(aEl);
217-
$aEl.replaceWith($aEl.html()!);
218-
});
219-
// Reduce single-paragraph note markup
220-
$el.find("div.note:has(p.note-title)").each((_, noteEl) => {
221-
const noteParagraphSelector = "p.note-title + div > p, p.note-title + p";
222-
const $noteEl = $(noteEl);
223-
if ($noteEl.find(noteParagraphSelector).length !== 1) return;
224-
const $titleEl = $noteEl.children("p.note-title").eq(0);
225-
// Lift the content out from both the nested p and div (if applicable)
226-
$noteEl.find("p.note-title + div > p").unwrap();
227-
const $pEl = $noteEl.find(noteParagraphSelector).eq(0);
228-
$pEl.replaceWith($pEl.html()!);
229-
$titleEl.replaceWith(`<em>${$titleEl.html()!}:</em>`);
230-
noteEl.tagName = "p";
231-
});
232-
return $el
233-
.html()!
234-
.trim()
235-
.replace(/\n\s*\n/g, "\n");
236-
}
286+
// Since SCs are already remapped for previous versions before calling this function,
287+
// we need to be able to map back to the present to resolve keys in understanding.11tydata.ts
288+
const scSlugMappings = invert(generateScSlugOverrides(version));
237289

238-
const htmlMap: TechniquesHtmlMap = {};
239-
for (const type of techniqueAssociationTypes) {
240-
const $section = $(`section#${type}`);
241-
if (!$section.length) continue;
290+
const scId = scSlugMappings[sc.id] || sc.id;
291+
const associations = associatedTechniques[scId];
292+
if (!associations) throw new Error(`No associatedTechniques found for ${scId}`);
293+
const techniques: SerializedTechniques = {};
242294

243-
$section.children("h3 + p:not(:has(a))").remove();
244-
$section.children("h3").remove();
295+
function resolveAssociatedTechniqueTitle(
296+
technique: ResolvedUnderstandingAssociatedTechnique,
297+
hasGroups?: boolean
298+
) {
299+
const usingContent =
300+
(hasGroups && "using one technique from each group outlined below") ||
301+
("using" in technique && !technique.skipUsingPhrase && stringifyUsingProps(technique));
245302

246-
// Make techniques links absolute
247-
// (this uses a different selector than the build process, to handle pre-built output)
248-
$section.find("[href*='/techniques/' i]").each((_, el) => {
249-
const $el = $(el);
250-
const technique = techniquesMap[$el.attr("href")!.replace(/^.*\//, "")];
251-
$el.attr(
252-
"href",
253-
$el
254-
.attr("href")!
255-
.replace(/^.*\/([\w-]+\/[^\/]+)$/, "https://www.w3.org/WAI/WCAG22/Techniques/$1")
256-
);
257-
$el.removeAttr("class");
258-
// Restore full title (whereas links in build output used truncatedTitle)
259-
$el.html(`${technique.id}: ${technique.title.replace(/\n\s+/g, " ")}`);
260-
});
303+
if ("id" in technique && technique.id)
304+
return {
305+
title: removeNewlines(techniquesMap[technique.id].title),
306+
...(usingContent && { suffix: usingContent }),
307+
};
261308

262-
// Remove superfluous subheadings/subsections, e.g. "CSS Techniques (Advisory)"
263-
$section.find("h4").each((_, el) => {
264-
const $el = $(el);
265-
if (new RegExp(` Techniques(?: \\(${type}\\))?$`, "gi").test($el.text())) {
266-
const $closestSection = $el.closest("section");
267-
$el.remove(); // Remove while $el reference is still valid
268-
if (!$closestSection.is($section)) $closestSection.replaceWith($closestSection.html()!);
269-
}
270-
});
271-
// Merge any consecutive lists (likely due to superfluous subsections)
272-
$section.find("ul + ul").each((_, el) => {
273-
const $el = $(el);
274-
$el.prev().append($el.children());
275-
$el.remove();
276-
});
309+
if ("title" in technique && technique.title)
310+
return {
311+
title: resolveLinks(removeNewlines(technique.title), version),
312+
...(usingContent && { suffix: usingContent }),
313+
};
277314

278-
// Create situations array out of remaining h4s (also used for requirements in 1.4.8)
279-
const situations = $section.find("section:has(h4)").toArray();
280-
if (situations.length) {
281-
htmlMap[type] = situations.map((situationEl) => {
282-
const $situationEl = $(situationEl);
283-
const $h4 = $situationEl.children("h4");
284-
$h4.remove();
315+
if (usingContent) return { title: usingContent };
316+
return null;
317+
}
318+
319+
function mapAssociatedTechniques(
320+
techniques: Array<string | UnderstandingAssociatedTechniqueEntry>
321+
): SerializedTechniqueAssociation[];
322+
function mapAssociatedTechniques(
323+
techniques: UnderstandingAssociatedTechniqueArray,
324+
hasGroups?: boolean
325+
): SerializedTechniqueAssociationArray;
326+
function mapAssociatedTechniques(
327+
techniques:
328+
| Array<string | UnderstandingAssociatedTechniqueEntry>
329+
| UnderstandingAssociatedTechniqueArray,
330+
hasGroups?: boolean
331+
) {
332+
return techniques.map((t) => {
333+
const technique = expandTechniqueToObject(t);
334+
if ("and" in technique) {
285335
return {
286-
title: $h4.text().trim(),
287-
content: cleanHtml($situationEl),
336+
and: mapAssociatedTechniques(technique.and.map(expandTechniqueToObject)),
337+
...("using" in technique &&
338+
technique.using && { using: mapAssociatedTechniques(technique.using) }),
288339
};
289-
});
340+
}
341+
342+
const id = technique.id;
343+
const titleProps = resolveAssociatedTechniqueTitle(technique, hasGroups);
344+
if (!titleProps)
345+
throw new Error(
346+
"Couldn't resolve title for associated technique under " +
347+
`${scId}: ${JSON.stringify(technique)}`
348+
);
349+
350+
return {
351+
...(id && {
352+
id,
353+
technology: techniquesMap[id].technology,
354+
}),
355+
...titleProps,
356+
...("prefix" in technique && technique.prefix && { prefix: technique.prefix }),
357+
...("suffix" in technique && technique.suffix && { suffix: technique.suffix }),
358+
...("using" in technique && { using: mapAssociatedTechniques(technique.using) }),
359+
};
360+
});
361+
}
362+
363+
for (const type of techniqueAssociationTypes) {
364+
const associationsOfType = associations[type];
365+
if (!associationsOfType) continue;
366+
367+
if (typeof associationsOfType[0] !== "string" && "techniques" in associationsOfType[0]) {
368+
techniques[type] = [];
369+
for (const section of associationsOfType as UnderstandingAssociatedTechniqueSection[]) {
370+
techniques[type]!.push({
371+
title: section.title,
372+
techniques: mapAssociatedTechniques(section.techniques, "groups" in section),
373+
...("groups" in section &&
374+
section.groups && {
375+
groups: section.groups.map((group) => ({
376+
id: group.id,
377+
title: group.title,
378+
techniques: mapAssociatedTechniques(group.techniques),
379+
})),
380+
}),
381+
...(section.note && { note: cleanLinks(section.note) }),
382+
} satisfies SerializedTechniqueSection);
383+
}
290384
} else {
291-
// Remove links within notes, which point to definitions or Understanding sections
292-
$section.find(".note a").each((_, el) => {
293-
const $el = $(el);
294-
$el.replaceWith($el.html()!);
295-
});
296-
const html = cleanHtml($section);
297-
if (html) htmlMap[type] = html;
385+
techniques[type] = mapAssociatedTechniques(
386+
associationsOfType as UnderstandingAssociatedTechniqueArray
387+
);
388+
}
389+
390+
if (type === "sufficient" && "sufficientNote" in associations && associations.sufficientNote) {
391+
// Copy sufficient note, with any definitions unlinked
392+
techniques.sufficientNote = cleanLinks(removeNewlines(associations.sufficientNote));
298393
}
299394
}
300-
return htmlMap;
395+
396+
return techniques;
301397
}
302398

303399
function expandVersions(item: WcagItem, maxVersion: WcagVersion) {
@@ -356,7 +452,7 @@ export async function generateWcagJson(version: WcagVersion) {
356452
...spreadCommonProps(sc),
357453
level: sc.level,
358454
details: createDetailsFromSc(sc),
359-
techniquesHtml: await createTechniquesHtmlFromSc(sc, techniquesMap),
455+
techniques: createTechniquesFromSc(sc, techniquesMap, version),
360456
}))
361457
),
362458
}))
@@ -378,21 +474,8 @@ export async function generateWcagJson(version: WcagVersion) {
378474

379475
// Allow running directly, skipping Eleventy build
380476
if (import.meta.filename === process.argv[1]) {
381-
let version: WcagVersion | undefined;
382-
try {
383-
const understandingIndex = await readFile(join("_site", "understanding", "index.html"), "utf8");
384-
const match = /\<title\>Understanding WCAG (\d)\.(\d)/.exec(understandingIndex);
385-
if (match && match[1] && match[2]) {
386-
const parsedVersion = `${match[1]}${match[2]}`;
387-
assertIsWcagVersion(parsedVersion);
388-
version = parsedVersion;
389-
}
390-
} catch (error) {}
391-
if (!version) {
392-
console.error("No _site directory found; run `npm run build` first");
393-
process.exit(1);
394-
}
395-
477+
const version = process.env.WCAG_VERSION || "22";
478+
assertIsWcagVersion(version);
396479
console.log(`Generating wcag.json for version ${resolveDecimalVersion(version)}`);
397480
await writeFile(join("_site", "wcag.json"), await generateWcagJson(version));
398481
}

0 commit comments

Comments
 (0)