|
| 1 | +import { existsSync, readFileSync, readdirSync } from "node:fs"; |
| 2 | +import path from "node:path"; |
| 3 | + |
| 4 | +function readJson(filePath) { |
| 5 | + return JSON.parse(readFileSync(filePath, "utf8")); |
| 6 | +} |
| 7 | + |
| 8 | +const stationsJson = readJson("src/data/method/stations.json"); |
| 9 | +const linesJson = readJson("src/data/method/lines.json"); |
| 10 | +const resourcesJson = readJson("src/data/method/resources.json"); |
| 11 | +const criteriaJson = readJson("src/data/method/criteria.json"); |
| 12 | +const stakeholdersJson = readJson("src/data/method/stakeholders.json"); |
| 13 | +const canvasDataJson = readJson("src/data/canvas/canvasData.json"); |
| 14 | +const localizedCanvasDataJson = readJson("src/data/canvas/localizedData.json"); |
| 15 | +const knownResourceIds = new Set((resourcesJson.resources || []).map((resource) => resource.id)); |
| 16 | +const stationGroups = [ |
| 17 | + ...(stationsJson["core-stations"]?.items || []).map((station) => ({ ...station, group: "core-stations" })), |
| 18 | + ...(stationsJson["sub-stations"]?.items || []).map((station) => ({ ...station, group: "sub-stations" })) |
| 19 | +]; |
| 20 | +const knownStationIds = new Set(stationGroups.map((station) => station.id)); |
| 21 | +const knownCanvasIds = new Set(Object.keys(canvasDataJson)); |
| 22 | +const localeDirs = readdirSync("src/data/method", { withFileTypes: true }) |
| 23 | + .filter((entry) => entry.isDirectory()) |
| 24 | + .map((entry) => entry.name); |
| 25 | +const findings = []; |
| 26 | + |
| 27 | +function collectMatchingStringValues(node, pattern, results = new Set()) { |
| 28 | + if (typeof node === "string") { |
| 29 | + if (pattern.test(node)) { |
| 30 | + results.add(node); |
| 31 | + } |
| 32 | + return results; |
| 33 | + } |
| 34 | + |
| 35 | + if (Array.isArray(node)) { |
| 36 | + for (const item of node) { |
| 37 | + collectMatchingStringValues(item, pattern, results); |
| 38 | + } |
| 39 | + return results; |
| 40 | + } |
| 41 | + |
| 42 | + if (node && typeof node === "object") { |
| 43 | + for (const value of Object.values(node)) { |
| 44 | + collectMatchingStringValues(value, pattern, results); |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + return results; |
| 49 | +} |
| 50 | + |
| 51 | +function validateLabelFile(locale, filename, expectedKeys, allowedExtras = []) { |
| 52 | + const filePath = path.join("src", "data", "method", locale, filename); |
| 53 | + const labels = readJson(filePath); |
| 54 | + const actualKeys = new Set(Object.keys(labels)); |
| 55 | + const allowedExtrasSet = new Set(allowedExtras); |
| 56 | + |
| 57 | + for (const expectedKey of expectedKeys) { |
| 58 | + if (!actualKeys.has(expectedKey)) { |
| 59 | + findings.push(`Locale ${locale} is missing label "${expectedKey}" in ${filename}.`); |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + for (const actualKey of actualKeys) { |
| 64 | + if (!expectedKeys.has(actualKey) && !allowedExtrasSet.has(actualKey)) { |
| 65 | + findings.push(`Locale ${locale} has unused label "${actualKey}" in ${filename}.`); |
| 66 | + } |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +for (const station of stationGroups) { |
| 71 | + const steps = station.how_it_works || station["how-it-works"] || []; |
| 72 | + const seenStepKeys = new Map(); |
| 73 | + const seenResources = new Map(); |
| 74 | + |
| 75 | + for (const [index, step] of steps.entries()) { |
| 76 | + const stepKey = String(step?.step || "").trim(); |
| 77 | + const resourceId = String(step?.resource || "").trim(); |
| 78 | + |
| 79 | + if (!stepKey) { |
| 80 | + findings.push(`Station ${station.id} has an empty step value at index ${index}.`); |
| 81 | + } else if (seenStepKeys.has(stepKey)) { |
| 82 | + findings.push( |
| 83 | + `Station ${station.id} contains duplicate step key "${stepKey}" at indexes ${seenStepKeys.get(stepKey)} and ${index}.` |
| 84 | + ); |
| 85 | + } else { |
| 86 | + seenStepKeys.set(stepKey, index); |
| 87 | + } |
| 88 | + |
| 89 | + if (!resourceId) { |
| 90 | + continue; |
| 91 | + } |
| 92 | + |
| 93 | + if (seenResources.has(resourceId)) { |
| 94 | + findings.push( |
| 95 | + `Station ${station.id} contains duplicate resource reference "${resourceId}" at indexes ${seenResources.get(resourceId)} and ${index}.` |
| 96 | + ); |
| 97 | + } else { |
| 98 | + seenResources.set(resourceId, index); |
| 99 | + } |
| 100 | + |
| 101 | + if (!knownResourceIds.has(resourceId)) { |
| 102 | + findings.push( |
| 103 | + `Station ${station.id} references unknown resource "${resourceId}" at index ${index}.` |
| 104 | + ); |
| 105 | + } |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +for (const line of linesJson.lines?.items || []) { |
| 110 | + const seenStations = new Map(); |
| 111 | + |
| 112 | + for (const [index, stationId] of (line.stations || []).entries()) { |
| 113 | + if (seenStations.has(stationId)) { |
| 114 | + findings.push( |
| 115 | + `Line ${line.id} contains duplicate station "${stationId}" at indexes ${seenStations.get(stationId)} and ${index}.` |
| 116 | + ); |
| 117 | + } else { |
| 118 | + seenStations.set(stationId, index); |
| 119 | + } |
| 120 | + |
| 121 | + if (!knownStationIds.has(stationId)) { |
| 122 | + findings.push(`Line ${line.id} references unknown station "${stationId}" at index ${index}.`); |
| 123 | + } |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +const stationsUsedInLines = new Set( |
| 128 | + (linesJson.lines?.items || []).flatMap((line) => line.stations || []) |
| 129 | +); |
| 130 | +for (const station of stationGroups) { |
| 131 | + if (!stationsUsedInLines.has(station.id)) { |
| 132 | + findings.push(`Station ${station.id} does not belong to any line.`); |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +for (const resource of resourcesJson.resources || []) { |
| 137 | + if (resource.category === "canvas") { |
| 138 | + if (!resource.canvas) { |
| 139 | + findings.push(`Canvas resource ${resource.id} is missing its canvas id.`); |
| 140 | + } else if (!knownCanvasIds.has(resource.canvas)) { |
| 141 | + findings.push(`Canvas resource ${resource.id} references unknown canvas "${resource.canvas}".`); |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + if (resource.snippet) { |
| 146 | + const snippetPath = path.join("src", "snippets", resource.snippet); |
| 147 | + if (!existsSync(snippetPath)) { |
| 148 | + findings.push(`Resource ${resource.id} references missing snippet "${resource.snippet}".`); |
| 149 | + } |
| 150 | + } |
| 151 | +} |
| 152 | + |
| 153 | +const expectedStationLabels = collectMatchingStringValues(stationsJson, /^(group|station)\./); |
| 154 | +const expectedLineLabels = collectMatchingStringValues(linesJson, /^(lines|line)\./); |
| 155 | +const expectedResourceLabels = collectMatchingStringValues(resourcesJson, /^resource\./); |
| 156 | +const expectedCriteriaLabels = new Set(criteriaJson.map((criterion) => `criterion.${criterion.id}`)); |
| 157 | +const expectedStakeholderLabels = collectMatchingStringValues(stakeholdersJson, /^stakeholder\./); |
| 158 | + |
| 159 | +for (const locale of localeDirs) { |
| 160 | + validateLabelFile(locale, "labels.stations.json", expectedStationLabels); |
| 161 | + validateLabelFile(locale, "labels.lines.json", expectedLineLabels); |
| 162 | + validateLabelFile(locale, "labels.resources.json", expectedResourceLabels); |
| 163 | + validateLabelFile(locale, "labels.criteria.json", expectedCriteriaLabels, ["entry_criteria", "exit_criteria"]); |
| 164 | + validateLabelFile( |
| 165 | + locale, |
| 166 | + "labels.stakeholders.json", |
| 167 | + expectedStakeholderLabels, |
| 168 | + [ |
| 169 | + "stakeholder.involvement.lead", |
| 170 | + "stakeholder.involvement.core", |
| 171 | + "stakeholder.involvement.consulted" |
| 172 | + ] |
| 173 | + ); |
| 174 | +} |
| 175 | + |
| 176 | +for (const locale of Object.keys(localizedCanvasDataJson)) { |
| 177 | + const localizedCanvases = localizedCanvasDataJson[locale] || {}; |
| 178 | + |
| 179 | + for (const [canvasId, canvas] of Object.entries(canvasDataJson)) { |
| 180 | + const localizedCanvas = localizedCanvases[canvasId]; |
| 181 | + if (!localizedCanvas) { |
| 182 | + findings.push(`Locale ${locale} is missing canvas localization for "${canvasId}".`); |
| 183 | + continue; |
| 184 | + } |
| 185 | + |
| 186 | + if (!String(localizedCanvas.title || "").trim()) { |
| 187 | + findings.push(`Locale ${locale} is missing canvas title for "${canvasId}".`); |
| 188 | + } |
| 189 | + if (!String(localizedCanvas.purpose || "").trim()) { |
| 190 | + findings.push(`Locale ${locale} is missing canvas purpose for "${canvasId}".`); |
| 191 | + } |
| 192 | + if (!String(localizedCanvas.howToUse || "").trim()) { |
| 193 | + findings.push(`Locale ${locale} is missing canvas howToUse for "${canvasId}".`); |
| 194 | + } |
| 195 | + |
| 196 | + for (const section of canvas.sections || []) { |
| 197 | + const localizedSection = localizedCanvas.sections?.[section.id]; |
| 198 | + if (!localizedSection) { |
| 199 | + findings.push(`Locale ${locale} is missing section localization for "${canvasId}.${section.id}".`); |
| 200 | + continue; |
| 201 | + } |
| 202 | + |
| 203 | + if (!String(localizedSection.section || "").trim()) { |
| 204 | + findings.push(`Locale ${locale} is missing section title for "${canvasId}.${section.id}".`); |
| 205 | + } |
| 206 | + if (!String(localizedSection.description || "").trim()) { |
| 207 | + findings.push(`Locale ${locale} is missing section description for "${canvasId}.${section.id}".`); |
| 208 | + } |
| 209 | + } |
| 210 | + } |
| 211 | +} |
| 212 | + |
| 213 | +if (findings.length > 0) { |
| 214 | + console.error("Method content validation failed:"); |
| 215 | + for (const finding of findings) { |
| 216 | + console.error(`- ${finding}`); |
| 217 | + } |
| 218 | + process.exit(1); |
| 219 | +} |
| 220 | + |
| 221 | +console.log("Method content validation passed."); |
0 commit comments