|
| 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`); |
0 commit comments