wds-validate.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. // wds-validate.js — WDS scaffold: validate page spec files for correctness
  2. // Usage: node src/scripts/wds-validate.js --page "C-UX-Scenarios/01-onboarding/01-start/01-start.md"
  3. // node src/scripts/wds-validate.js --scenario "01 Onboarding"
  4. // node src/scripts/wds-validate.js --all
  5. 'use strict';
  6. const fs = require('node:fs');
  7. const path = require('node:path');
  8. function parseArgs(argv) {
  9. const args = {};
  10. for (let i = 0; i < argv.length; i++) {
  11. if (argv[i].startsWith('--')) {
  12. const key = argv[i].slice(2);
  13. const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
  14. args[key] = value;
  15. if (value !== true) i++;
  16. }
  17. }
  18. return args;
  19. }
  20. function toSlug(str) {
  21. return str.toLowerCase().replaceAll(/\s+/g, '-');
  22. }
  23. function printUsage() {
  24. process.stdout.write(
  25. [
  26. 'Usage: node src/scripts/wds-validate.js --page <path>',
  27. ' node src/scripts/wds-validate.js --scenario "01 Onboarding"',
  28. ' node src/scripts/wds-validate.js --all',
  29. '',
  30. 'Options:',
  31. ' --page Path to a single page spec .md file',
  32. ' --scenario Scenario name or slug to validate all pages',
  33. ' --all Validate all scenarios',
  34. ' --output Base path (default: current directory)',
  35. '',
  36. ].join('\n'),
  37. );
  38. }
  39. const REQUIRED_SECTIONS = [
  40. '## Overview',
  41. '## Page Metadata',
  42. '## Layout Structure',
  43. '## Spacing',
  44. '## Typography',
  45. '## Page Sections',
  46. '## Page States',
  47. '## Checklist',
  48. ];
  49. const REQUIRED_METADATA_PROPS = ['Scenario', 'Page Number', 'Platform', 'Page Type', 'Viewport', 'Interaction', 'Visibility'];
  50. const KEBAB_CASE_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
  51. // Extract all OBJECT IDs from content: lines matching **OBJECT ID:** `...`
  52. function extractObjectIds(content) {
  53. const ids = [];
  54. const re = /\*\*OBJECT ID:\*\*\s+`([^`]+)`/g;
  55. let m;
  56. while ((m = re.exec(content)) !== null) {
  57. ids.push(m[1]);
  58. }
  59. return ids;
  60. }
  61. // Extract all spacing IDs from content: lines matching #### ↕ `...`
  62. function extractSpacingIds(content) {
  63. const ids = [];
  64. const re = /####\s+[↕↔]\s+`([^`]+)`/g;
  65. let m;
  66. while ((m = re.exec(content)) !== null) {
  67. ids.push(m[1]);
  68. }
  69. return ids;
  70. }
  71. // Count nav rows: lines starting with '←'
  72. function countNavRows(content) {
  73. const lines = content.split('\n');
  74. return lines.filter((l) => l.trim().startsWith('←')).length;
  75. }
  76. // Check Swedish + English content: for each object block, look for SE and EN rows
  77. function checkObjectContent(content) {
  78. const missing = [];
  79. // Split into object blocks by #### heading
  80. const blocks = content.split(/(?=#### )/);
  81. for (const block of blocks) {
  82. const idMatch = block.match(/\*\*OBJECT ID:\*\*\s+`([^`]+)`/);
  83. if (!idMatch) continue;
  84. const objectId = idMatch[1];
  85. // Check for SE row
  86. if (block.includes('| SE |')) {
  87. const seMatch = block.match(/\| SE \| "([^"]*)"/);
  88. if (!seMatch || seMatch[1].trim() === '' || seMatch[1].trim() === '—') {
  89. missing.push(`Object "${objectId}" has empty SE content`);
  90. }
  91. } else {
  92. missing.push(`Object "${objectId}" missing SE content field`);
  93. }
  94. // Check for EN row
  95. if (block.includes('| EN |')) {
  96. const enMatch = block.match(/\| EN \| "([^"]*)"/);
  97. if (!enMatch || enMatch[1].trim() === '' || enMatch[1].trim() === '—') {
  98. missing.push(`Object "${objectId}" has empty EN content`);
  99. }
  100. } else {
  101. missing.push(`Object "${objectId}" missing EN content field`);
  102. }
  103. }
  104. return missing;
  105. }
  106. function validatePage(filePath) {
  107. const errors = [];
  108. const warnings = [];
  109. if (!fs.existsSync(filePath)) {
  110. return { errors: [`File not found: ${filePath}`], warnings: [], objectCount: 0, spacingCount: 0 };
  111. }
  112. let content;
  113. try {
  114. content = fs.readFileSync(filePath, 'utf8');
  115. } catch (error) {
  116. return { errors: [`Cannot read file: ${error.message}`], warnings: [], objectCount: 0, spacingCount: 0 };
  117. }
  118. const pageSlug = path.basename(filePath, '.md');
  119. // Derive page prefix (strip leading number): "01-start" -> "start"
  120. const slugParts = pageSlug.split('-');
  121. const pagePrefix = slugParts.length > 1 ? slugParts.slice(1).join('-') : pageSlug;
  122. // 1. Required sections
  123. for (const section of REQUIRED_SECTIONS) {
  124. if (!content.includes(section)) {
  125. errors.push(`Missing section: ${section}`);
  126. }
  127. }
  128. // 2. Object IDs — kebab-case and prefix
  129. const objectIds = extractObjectIds(content);
  130. const seenIds = new Set();
  131. for (const id of objectIds) {
  132. // Kebab-case check
  133. if (!KEBAB_CASE_RE.test(id)) {
  134. errors.push(`Object ID not in kebab-case: \`${id}\``);
  135. }
  136. // Prefix check
  137. if (!id.startsWith(pagePrefix + '-')) {
  138. errors.push(`Object ID missing prefix: \`${id}\` (expected prefix: ${pagePrefix}-)`);
  139. }
  140. // Duplicate check
  141. if (seenIds.has(id)) {
  142. errors.push(`Duplicate Object ID: \`${id}\``);
  143. }
  144. seenIds.add(id);
  145. }
  146. // 3. Navigation rows (expect 3)
  147. const navCount = countNavRows(content);
  148. if (navCount < 3) {
  149. errors.push(`Navigation rows: found ${navCount}, expected 3`);
  150. }
  151. // 4. Metadata table properties
  152. for (const prop of REQUIRED_METADATA_PROPS) {
  153. if (!content.includes(`| ${prop} |`)) {
  154. errors.push(`Metadata table missing property: ${prop}`);
  155. }
  156. }
  157. // 5. Sketches folder
  158. const sketchesDir = path.join(path.dirname(filePath), 'sketches');
  159. if (!fs.existsSync(sketchesDir)) {
  160. warnings.push('Sketches folder does not exist');
  161. }
  162. // 6. Swedish + English content check
  163. const contentWarnings = checkObjectContent(content);
  164. for (const w of contentWarnings) {
  165. warnings.push(w);
  166. }
  167. // Spacing IDs
  168. const spacingIds = extractSpacingIds(content);
  169. return {
  170. errors,
  171. warnings,
  172. objectCount: objectIds.length,
  173. spacingCount: spacingIds.length,
  174. };
  175. }
  176. function formatResult(label, result) {
  177. const { errors, warnings, objectCount, spacingCount } = result;
  178. if (errors.length === 0 && warnings.length === 0) {
  179. return `✓ ${label} — valid (${objectCount} objects, ${spacingCount} spacing objects)\n`;
  180. }
  181. const lines = [];
  182. if (errors.length > 0) {
  183. lines.push(`✗ ${label} — ${errors.length} error(s):`);
  184. for (const e of errors) lines.push(` - ${e}`);
  185. }
  186. if (warnings.length > 0) {
  187. lines.push(` ${warnings.length} warning(s):`);
  188. for (const w of warnings) lines.push(` ! ${w}`);
  189. }
  190. return lines.join('\n') + '\n';
  191. }
  192. function getPageFiles(scenarioDir) {
  193. let entries;
  194. try {
  195. entries = fs.readdirSync(scenarioDir, { withFileTypes: true });
  196. } catch {
  197. return [];
  198. }
  199. return entries
  200. .filter((e) => e.isDirectory())
  201. .map((e) => {
  202. const mdFile = path.join(scenarioDir, e.name, `${e.name}.md`);
  203. return fs.existsSync(mdFile) ? mdFile : null;
  204. })
  205. .filter(Boolean)
  206. .sort();
  207. }
  208. function main() {
  209. const args = parseArgs(process.argv.slice(2));
  210. if (args.help) {
  211. printUsage();
  212. process.exit(0);
  213. }
  214. if (!args.page && !args.scenario && !args.all) {
  215. process.stderr.write('Error: --page, --scenario, or --all is required.\n\n');
  216. printUsage();
  217. process.exit(1);
  218. }
  219. const outputBase = args.output || process.cwd();
  220. const scenariosBase = path.join(outputBase, 'C-UX-Scenarios');
  221. let filesToValidate = [];
  222. if (args.page) {
  223. filesToValidate = [path.resolve(args.page)];
  224. } else if (args.scenario) {
  225. const scenarioSlug = toSlug(args.scenario);
  226. const scenarioDir = path.join(scenariosBase, scenarioSlug);
  227. filesToValidate = getPageFiles(scenarioDir);
  228. if (filesToValidate.length === 0) {
  229. process.stdout.write(`No pages found in scenario: ${scenarioSlug}\n`);
  230. process.exit(0);
  231. }
  232. } else if (args.all) {
  233. if (!fs.existsSync(scenariosBase)) {
  234. process.stderr.write(`Error: C-UX-Scenarios not found at: ${scenariosBase}\n`);
  235. process.exit(1);
  236. }
  237. let entries;
  238. try {
  239. entries = fs.readdirSync(scenariosBase, { withFileTypes: true });
  240. } catch (error) {
  241. process.stderr.write(`Error reading scenarios: ${error.message}\n`);
  242. process.exit(1);
  243. }
  244. for (const e of entries.filter((e) => e.isDirectory()).sort()) {
  245. const pages = getPageFiles(path.join(scenariosBase, e.name));
  246. filesToValidate.push(...pages);
  247. }
  248. if (filesToValidate.length === 0) {
  249. process.stdout.write('No page specs found.\n');
  250. process.exit(0);
  251. }
  252. }
  253. let hasErrors = false;
  254. for (const filePath of filesToValidate) {
  255. const label = path.basename(filePath);
  256. const result = validatePage(filePath);
  257. process.stdout.write(formatResult(label, result));
  258. if (result.errors.length > 0) hasErrors = true;
  259. }
  260. process.exit(hasErrors ? 1 : 0);
  261. }
  262. main();