Skip to content

Commit c7286fc

Browse files
kfranqueirombgower
andauthored
Automate generating techniques changelog (#4515)
This adds: - A script that generates data for the techniques changelog based on git history - A separate page to display the changelog It turns out that in our manual maintenance of the changelog, we had missed roughly half of the entries that belong in it. This makes the changelog rather long, warranting its own page. The script added in this PR properly recognizes obsolete techniques, as well as techniques that only pertain to 2.2 success criteria, so the 2.1 and 2.2 changelogs are now distinct. (The PR preview will only show the changelog for 2.2, as the changelog exists at the same path in each version. [Here's roughly what it'll look like for 2.1](https://gist.githack.com/kfranqueiro/e2bcc1bacb82ce56b26249b7f0a61646/raw/changelog.html), bearing in mind that one of the stylesheets doesn't load in this standalone preview.) Because the script reads git history, it will need to be run _after_ PRs that add or remove techniques are merged. I'll discuss with Kevin whether we want to make this part of the monthly publication process. I also noticed we were incorrectly linking to the old default branch name, so I've updated that in `techniques/index.html` and `understanding/about.html`. @netlify /techniques/changelog --------- Co-authored-by: Mike Gower <[email protected]>
1 parent 275dd0a commit c7286fc

File tree

15 files changed

+1565
-182
lines changed

15 files changed

+1565
-182
lines changed

11ty/CustomLiquid.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { techniqueToUnderstandingLinkSelector } from "./understanding";
1717
const titleSuffix = " | WAI | W3C";
1818

1919
/** Matches index and about pages, traditionally processed differently than individual pages */
20-
const indexPattern = /(techniques|understanding)\/(index|about)\.html$/;
20+
const indexPattern = /(techniques|understanding)\/(index|about|changelog)\.html$/;
2121
const techniquesPattern = /\btechniques\//;
2222
const understandingPattern = /\bunderstanding\//;
2323

11ty/README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,25 @@ Common tasks:
2323
- http://localhost:8080/techniques
2424
- http://localhost:8080/understanding
2525

26-
Maintenance tasks (for working with Eleventy config and supporting files under this subdirectory):
26+
Maintenance tasks (for working with Eleventy config and supporting files used by the build):
2727

2828
- `npm run check` checks for TypeScript errors
2929
- `npm run fmt` formats all TypeScript files
3030

31+
## Publishing to WAI website
32+
33+
The following npm scripts can be used to assist with publishing updates to the WAI website:
34+
35+
- `npm run publish-w3c` to publish 2.2
36+
- `npm run publish-w3c:21` to publish 2.1
37+
38+
Each of these scripts performs the following steps:
39+
40+
1. Updates the data used for the Techniques Change Log page
41+
- Note that this step may result in changes to `techniques/changelog.11tydata.json`, which should be committed to `main`
42+
2. Runs the build for the appropriate WCAG version, generating pages and `wcag.json` under `_site`
43+
3. Copies the built files from `_site` to the CVS checkout (see [`WCAG_CVSDIR`](#wcag_cvsdir))
44+
3145
## Environment Variables
3246

3347
### `WCAG_CVSDIR`

11ty/data-dependencies.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
assertIsWcagVersion,
3+
getFlatGuidelines,
4+
getPrinciples,
5+
getPrinciplesForVersion,
6+
type FlatGuidelinesMap,
7+
} from "./guidelines";
8+
import {
9+
getTechniquesByTechnology,
10+
getFlatTechniques,
11+
getTechniqueAssociations,
12+
isTechniqueObsolete,
13+
type Technology,
14+
} from "./techniques";
15+
import { getUnderstandingDocs, generateUnderstandingNavMap } from "./understanding";
16+
17+
/**
18+
* Central function that loads data necessary for the Eleventy build process,
19+
* exposed for reuse in separate scripts (e.g. techniques changelog generation).
20+
* @param version A version string, e.g. from process.env.WCAG_VERSION; may be undefined
21+
*/
22+
export async function loadDataDependencies(version?: string) {
23+
const definedVersion = version ?? "22";
24+
assertIsWcagVersion(definedVersion);
25+
26+
/** Tree of Principles/Guidelines/SC across all versions (including later than selected) */
27+
const allPrinciples = await getPrinciples();
28+
const allFlatGuidelines = getFlatGuidelines(allPrinciples);
29+
30+
async function resolveRelevantPrinciples() {
31+
// Resolve from local filesystem if input version was unset (e.g. no explicit WCAG_VERSION)
32+
if (!version) return allPrinciples;
33+
// Also resolve from local filesystem if explicit path was given via environment variable
34+
if (process.env.WCAG_FORCE_LOCAL_GUIDELINES)
35+
return await getPrinciples(process.env.WCAG_FORCE_LOCAL_GUIDELINES);
36+
// Otherwise, resolve from published guidelines (requires internet connection)
37+
assertIsWcagVersion(definedVersion);
38+
return await getPrinciplesForVersion(definedVersion);
39+
}
40+
41+
// Note: many of these variables are documented in the return value instead of up-front,
42+
// in order to provide docs to consumers of this function
43+
44+
const principles = await resolveRelevantPrinciples();
45+
const flatGuidelines = getFlatGuidelines(principles);
46+
/** Flattened Principles/Guidelines/SC that only exist in later versions (to filter techniques) */
47+
const futureGuidelines: FlatGuidelinesMap = {};
48+
for (const [key, value] of Object.entries(allFlatGuidelines)) {
49+
if (value.version > definedVersion) futureGuidelines[key] = value;
50+
}
51+
52+
const techniques = await getTechniquesByTechnology(flatGuidelines);
53+
const flatTechniques = getFlatTechniques(techniques);
54+
55+
const techniqueAssociations = await getTechniqueAssociations(flatGuidelines);
56+
const futureTechniqueAssociations = await getTechniqueAssociations(futureGuidelines);
57+
const futureExclusiveTechniqueAssociations: typeof techniqueAssociations = {};
58+
for (const [id, associations] of Object.entries(futureTechniqueAssociations)) {
59+
if (!techniqueAssociations[id]) futureExclusiveTechniqueAssociations[id] = associations;
60+
}
61+
62+
for (const [id, associations] of Object.entries(techniqueAssociations)) {
63+
// Prune associations from non-obsolete techniques to obsolete SCs
64+
techniqueAssociations[id] = associations.filter(
65+
({ criterion }) =>
66+
criterion.level !== "" || isTechniqueObsolete(flatTechniques[id], definedVersion)
67+
);
68+
}
69+
70+
for (const [technology, list] of Object.entries(techniques)) {
71+
// Prune techniques that are obsolete or associated with SCs from later versions
72+
// (only prune hierarchical structure for ToC; keep all in flatTechniques for lookups)
73+
techniques[technology as Technology] = list.filter(
74+
(technique) =>
75+
(!technique.obsoleteSince || technique.obsoleteSince > definedVersion) &&
76+
!futureExclusiveTechniqueAssociations[technique.id]
77+
);
78+
}
79+
80+
const understandingDocs = getUnderstandingDocs(definedVersion);
81+
const understandingNav = generateUnderstandingNavMap(principles, understandingDocs);
82+
83+
return {
84+
/** Tree of Principles/Guidelines/SC relevant to selected version */
85+
principles,
86+
/** Flattened Principles/Guidelines/SC relevant to selected version */
87+
flatGuidelines,
88+
/** Flattened Principles/Guidelines/SC across all versions (including later than selected) */
89+
allFlatGuidelines,
90+
/**
91+
* Techniques organized hierarchically per technology;
92+
* excludes obsolete techniques and techniques that only reference SCs of future WCAG versions
93+
**/
94+
techniques,
95+
/**
96+
* Techniques organized in a flat map indexed by ID;
97+
* _does not_ exclude obsolete/future techniques (so that cross-page links can be resolved)
98+
*/
99+
flatTechniques,
100+
/** Maps technique IDs to SCs found in target version */
101+
techniqueAssociations,
102+
/** Only maps techniques that only reference SCs introduced in future WCAG versions */
103+
futureExclusiveTechniqueAssociations,
104+
/** Information for other top-level understanding pages */
105+
understandingDocs,
106+
/** Contains next/previous/parent information for each understanding page, for rendering nav */
107+
understandingNav,
108+
// Pass back version so the consumer doesn't need to re-assert
109+
version: definedVersion,
110+
};
111+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { exec } from "child_process";
2+
import { access, readFile, writeFile } from "fs/promises";
3+
import { glob } from "glob";
4+
import { basename, join } from "path";
5+
import { promisify } from "util";
6+
7+
import type { WcagVersion } from "./guidelines";
8+
import { technologies, technologyTitles } from "./techniques";
9+
import { loadDataDependencies } from "./data-dependencies";
10+
11+
const execAsync = promisify(exec);
12+
13+
// Only list changes since initial 2.1 rec publication.
14+
// Note: this script intentionally searches the full commit list,
15+
// then later filters by date, rather than truncating the list of commits.
16+
// The latter would cause additional edge cases
17+
// (e.g. due to a few files being re-added after deletion)
18+
// and would only save ~10% of total run time.
19+
const sinceDate = new Date(2018, 5, 6);
20+
21+
interface Entry {
22+
date: Date;
23+
hash: string;
24+
technique: string;
25+
type: "added" | "removed";
26+
}
27+
28+
interface CompoundEntry extends Omit<Entry, "technique"> {
29+
techniques: string[];
30+
}
31+
32+
const excludes = {
33+
G222: true, // Was added then removed, related to a SC that was never published
34+
};
35+
36+
/** Checks for existence and non-obsolescene of technique. */
37+
const checkTechnique = async (path: string) =>
38+
access(path).then(
39+
() => readFile(path, "utf8").then((content) => !/^obsoleteSince:/m.test(content)),
40+
() => false
41+
);
42+
43+
function resolveEarliest(a: EarliestInfo | null, b: EarliestInfo | null) {
44+
if (a && b) {
45+
if (a.date > b.date) return b;
46+
return a;
47+
}
48+
if (a || b) return a || b;
49+
return null;
50+
}
51+
52+
interface EarliestInfo {
53+
date: Date;
54+
hash: string;
55+
}
56+
57+
// Cache getEarliestDate results to save time processing across versions
58+
const earliestCache: Record<string, EarliestInfo | null> = {};
59+
60+
// Common function called by getEarlest...Date functions below
61+
async function getEarliest(path: string, logArgs: string) {
62+
const cacheKey = `${path} ${logArgs}`;
63+
if (!(cacheKey in earliestCache)) {
64+
const command = `git log ${logArgs} --format='%h %ad' --date=iso-strict -- ${path} | tail -1`;
65+
const output = (await execAsync(command)).stdout.trim();
66+
if (output) {
67+
const match = /^(\w+) (.+)$/.exec(output);
68+
if (!match) throw new Error("Unexpected git log output format");
69+
earliestCache[cacheKey] = { date: new Date(match[2]), hash: match[1] };
70+
} else earliestCache[cacheKey] = null;
71+
}
72+
return earliestCache[cacheKey];
73+
}
74+
75+
// --diff-filter=A is NOT used for additions because it misses odd merge commit cases e.g. F99.
76+
// (Not using it seems to have negligible performance difference and causes no other changes.)
77+
const getEarliestAddition = (path: string) => getEarliest(path, "");
78+
const getEarliestRemoval = (path: string) => getEarliest(path, "--diff-filter=D");
79+
const getEarliestObsolescence = (path: string, version: WcagVersion) => {
80+
const valuePattern = `2[0-${version[1]}]`;
81+
return getEarliest(
82+
path,
83+
`-G'${
84+
path.endsWith(".json")
85+
? `"obsoleteSince": "${valuePattern}"`
86+
: `obsoleteSince: ${valuePattern}`
87+
}'`
88+
);
89+
};
90+
91+
async function generateChangelog(version: WcagVersion) {
92+
const { futureExclusiveTechniqueAssociations } = await loadDataDependencies(version);
93+
const entries: Entry[] = [];
94+
95+
for (const technology of technologies) {
96+
const techniquesPath = join("techniques", technology);
97+
const techniquesFiles = (await glob("*.html", { cwd: techniquesPath })).filter((filename) =>
98+
/^[A-Z]+\d+\.html$/.test(filename)
99+
);
100+
if (!techniquesFiles.length) continue;
101+
102+
const code = techniquesFiles[0].replace(/\d+\.html$/, "");
103+
const technologyObsolescence = await getEarliestObsolescence(
104+
join(techniquesPath, `${technology}.11tydata.json`),
105+
version
106+
);
107+
if (technologyObsolescence && technologyObsolescence.date > sinceDate) {
108+
// The above check can handle future cases of marking an entire folder as obsolete;
109+
// for legacy cases (Flash/SL), check the first technique file for removal
110+
const technologyRemoval = await getEarliestRemoval(join(techniquesPath, `${code}1.html`));
111+
entries.push({
112+
date: (technologyRemoval || technologyObsolescence).date,
113+
hash: (technologyRemoval || technologyObsolescence).hash,
114+
technique: `all ${technologyTitles[technology]}`,
115+
type: "removed",
116+
});
117+
continue;
118+
}
119+
120+
const max = techniquesFiles.reduce((currentMax, filename) => {
121+
const num = +basename(filename, ".html").replace(/^[A-Z]+/, "");
122+
return Math.max(num, currentMax);
123+
}, 0);
124+
125+
for (let i = 1; i <= max; i++) {
126+
const techniquePath = join(techniquesPath, `${code}${i}.html`);
127+
const technique = basename(techniquePath, ".html");
128+
if (technique in excludes || technique in futureExclusiveTechniqueAssociations) continue;
129+
130+
const addition = await getEarliestAddition(techniquePath);
131+
if (!addition) continue; // Skip if added prior to start date
132+
if (addition.date > sinceDate)
133+
entries.push({
134+
...addition,
135+
technique,
136+
type: "added",
137+
});
138+
139+
const removal = (await checkTechnique(techniquePath))
140+
? null
141+
: resolveEarliest(
142+
await getEarliestRemoval(techniquePath),
143+
await getEarliestObsolescence(techniquePath, version)
144+
);
145+
if (removal && removal.date > sinceDate)
146+
entries.push({
147+
...removal,
148+
technique,
149+
type: "removed",
150+
});
151+
}
152+
}
153+
154+
// Sort most-recent-first, tie-breaking by additions before removals
155+
entries.sort((a, b) => {
156+
if (a.date > b.date) return -1;
157+
if (a.date < b.date) return 1;
158+
if (a.type < b.type) return -1;
159+
if (a.type > b.type) return 1;
160+
return 0;
161+
});
162+
163+
return entries.reduce((collectedEntries, { date, hash, technique, type }) => {
164+
const previousEntry = collectedEntries[collectedEntries.length - 1];
165+
if (previousEntry && previousEntry.type === type && +previousEntry.date === +date)
166+
previousEntry.techniques.push(technique);
167+
else collectedEntries.push({ date, hash, techniques: [technique], type });
168+
return collectedEntries;
169+
}, [] as CompoundEntry[]);
170+
}
171+
172+
const startTime = Date.now();
173+
await writeFile(
174+
join("techniques", `changelog.11tydata.json`),
175+
JSON.stringify(
176+
{
177+
"//": "DO NOT EDIT THIS FILE; it is automatically generated",
178+
changelog: {
179+
"21": await generateChangelog("21"),
180+
"22": await generateChangelog("22"),
181+
},
182+
},
183+
null,
184+
" "
185+
)
186+
);
187+
console.log(`Wrote changelog in ${Date.now() - startTime}ms`);

11ty/techniques.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ function assertIsTechnology(
4141
if (!(technology in technologyTitles)) throw new Error(`Invalid technology name: ${technology}`);
4242
}
4343

44+
/**
45+
* Returns boolean indicating whether a technique is obsolete for the given version.
46+
* Tolerates undefined for use with hash lookups.
47+
*/
48+
export const isTechniqueObsolete = (technique: Technique | undefined, version: WcagVersion) =>
49+
!!technique?.obsoleteSince && technique.obsoleteSince <= version;
50+
4451
export const techniqueAssociationTypes = ["sufficient", "advisory", "failure"] as const;
4552
export type TechniqueAssociationType = (typeof techniqueAssociationTypes)[number];
4653

11ty/understanding.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const techniqueToUnderstandingLinkSelector = [
1515
* Resolves information for top-level understanding pages;
1616
* ported from generate-structure-xml.xslt
1717
*/
18-
export async function getUnderstandingDocs(version: WcagVersion): Promise<DocNode[]> {
18+
export function getUnderstandingDocs(version: WcagVersion): DocNode[] {
1919
const decimalVersion = resolveDecimalVersion(version);
2020
return [
2121
{

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,8 @@ appended to the "Note" title in applicable versions, and the note will be hidden
219219

220220
### Techniques Change Log
221221

222-
At the time of writing (November 2024), the Change Log in the Techniques index is identical between WCAG 2.1 and 2.2.
223-
These have been split out into separate version-specific includes under `_includes/techniques/changelog/*.html`
224-
for future-proofing in support of building multiple versions of informative documents from the same branch.
222+
Data for the Techniques Change Log is now generated automatically by a script that reads git history;
223+
see [Eleventy Usage](11ty/README.md#usage).
225224

226225
## Working Examples
227226

0 commit comments

Comments
 (0)