| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- // wds-validate.js — WDS scaffold: validate page spec files for correctness
- // Usage: node src/scripts/wds-validate.js --page "C-UX-Scenarios/01-onboarding/01-start/01-start.md"
- // node src/scripts/wds-validate.js --scenario "01 Onboarding"
- // node src/scripts/wds-validate.js --all
- 'use strict';
- const fs = require('node:fs');
- const path = require('node:path');
- function parseArgs(argv) {
- const args = {};
- for (let i = 0; i < argv.length; i++) {
- if (argv[i].startsWith('--')) {
- const key = argv[i].slice(2);
- const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
- args[key] = value;
- if (value !== true) i++;
- }
- }
- return args;
- }
- function toSlug(str) {
- return str.toLowerCase().replaceAll(/\s+/g, '-');
- }
- function printUsage() {
- process.stdout.write(
- [
- 'Usage: node src/scripts/wds-validate.js --page <path>',
- ' node src/scripts/wds-validate.js --scenario "01 Onboarding"',
- ' node src/scripts/wds-validate.js --all',
- '',
- 'Options:',
- ' --page Path to a single page spec .md file',
- ' --scenario Scenario name or slug to validate all pages',
- ' --all Validate all scenarios',
- ' --output Base path (default: current directory)',
- '',
- ].join('\n'),
- );
- }
- const REQUIRED_SECTIONS = [
- '## Overview',
- '## Page Metadata',
- '## Layout Structure',
- '## Spacing',
- '## Typography',
- '## Page Sections',
- '## Page States',
- '## Checklist',
- ];
- const REQUIRED_METADATA_PROPS = ['Scenario', 'Page Number', 'Platform', 'Page Type', 'Viewport', 'Interaction', 'Visibility'];
- const KEBAB_CASE_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
- // Extract all OBJECT IDs from content: lines matching **OBJECT ID:** `...`
- function extractObjectIds(content) {
- const ids = [];
- const re = /\*\*OBJECT ID:\*\*\s+`([^`]+)`/g;
- let m;
- while ((m = re.exec(content)) !== null) {
- ids.push(m[1]);
- }
- return ids;
- }
- // Extract all spacing IDs from content: lines matching #### ↕ `...`
- function extractSpacingIds(content) {
- const ids = [];
- const re = /####\s+[↕↔]\s+`([^`]+)`/g;
- let m;
- while ((m = re.exec(content)) !== null) {
- ids.push(m[1]);
- }
- return ids;
- }
- // Count nav rows: lines starting with '←'
- function countNavRows(content) {
- const lines = content.split('\n');
- return lines.filter((l) => l.trim().startsWith('←')).length;
- }
- // Check Swedish + English content: for each object block, look for SE and EN rows
- function checkObjectContent(content) {
- const missing = [];
- // Split into object blocks by #### heading
- const blocks = content.split(/(?=#### )/);
- for (const block of blocks) {
- const idMatch = block.match(/\*\*OBJECT ID:\*\*\s+`([^`]+)`/);
- if (!idMatch) continue;
- const objectId = idMatch[1];
- // Check for SE row
- if (block.includes('| SE |')) {
- const seMatch = block.match(/\| SE \| "([^"]*)"/);
- if (!seMatch || seMatch[1].trim() === '' || seMatch[1].trim() === '—') {
- missing.push(`Object "${objectId}" has empty SE content`);
- }
- } else {
- missing.push(`Object "${objectId}" missing SE content field`);
- }
- // Check for EN row
- if (block.includes('| EN |')) {
- const enMatch = block.match(/\| EN \| "([^"]*)"/);
- if (!enMatch || enMatch[1].trim() === '' || enMatch[1].trim() === '—') {
- missing.push(`Object "${objectId}" has empty EN content`);
- }
- } else {
- missing.push(`Object "${objectId}" missing EN content field`);
- }
- }
- return missing;
- }
- function validatePage(filePath) {
- const errors = [];
- const warnings = [];
- if (!fs.existsSync(filePath)) {
- return { errors: [`File not found: ${filePath}`], warnings: [], objectCount: 0, spacingCount: 0 };
- }
- let content;
- try {
- content = fs.readFileSync(filePath, 'utf8');
- } catch (error) {
- return { errors: [`Cannot read file: ${error.message}`], warnings: [], objectCount: 0, spacingCount: 0 };
- }
- const pageSlug = path.basename(filePath, '.md');
- // Derive page prefix (strip leading number): "01-start" -> "start"
- const slugParts = pageSlug.split('-');
- const pagePrefix = slugParts.length > 1 ? slugParts.slice(1).join('-') : pageSlug;
- // 1. Required sections
- for (const section of REQUIRED_SECTIONS) {
- if (!content.includes(section)) {
- errors.push(`Missing section: ${section}`);
- }
- }
- // 2. Object IDs — kebab-case and prefix
- const objectIds = extractObjectIds(content);
- const seenIds = new Set();
- for (const id of objectIds) {
- // Kebab-case check
- if (!KEBAB_CASE_RE.test(id)) {
- errors.push(`Object ID not in kebab-case: \`${id}\``);
- }
- // Prefix check
- if (!id.startsWith(pagePrefix + '-')) {
- errors.push(`Object ID missing prefix: \`${id}\` (expected prefix: ${pagePrefix}-)`);
- }
- // Duplicate check
- if (seenIds.has(id)) {
- errors.push(`Duplicate Object ID: \`${id}\``);
- }
- seenIds.add(id);
- }
- // 3. Navigation rows (expect 3)
- const navCount = countNavRows(content);
- if (navCount < 3) {
- errors.push(`Navigation rows: found ${navCount}, expected 3`);
- }
- // 4. Metadata table properties
- for (const prop of REQUIRED_METADATA_PROPS) {
- if (!content.includes(`| ${prop} |`)) {
- errors.push(`Metadata table missing property: ${prop}`);
- }
- }
- // 5. Sketches folder
- const sketchesDir = path.join(path.dirname(filePath), 'sketches');
- if (!fs.existsSync(sketchesDir)) {
- warnings.push('Sketches folder does not exist');
- }
- // 6. Swedish + English content check
- const contentWarnings = checkObjectContent(content);
- for (const w of contentWarnings) {
- warnings.push(w);
- }
- // Spacing IDs
- const spacingIds = extractSpacingIds(content);
- return {
- errors,
- warnings,
- objectCount: objectIds.length,
- spacingCount: spacingIds.length,
- };
- }
- function formatResult(label, result) {
- const { errors, warnings, objectCount, spacingCount } = result;
- if (errors.length === 0 && warnings.length === 0) {
- return `✓ ${label} — valid (${objectCount} objects, ${spacingCount} spacing objects)\n`;
- }
- const lines = [];
- if (errors.length > 0) {
- lines.push(`✗ ${label} — ${errors.length} error(s):`);
- for (const e of errors) lines.push(` - ${e}`);
- }
- if (warnings.length > 0) {
- lines.push(` ${warnings.length} warning(s):`);
- for (const w of warnings) lines.push(` ! ${w}`);
- }
- return lines.join('\n') + '\n';
- }
- function getPageFiles(scenarioDir) {
- let entries;
- try {
- entries = fs.readdirSync(scenarioDir, { withFileTypes: true });
- } catch {
- return [];
- }
- return entries
- .filter((e) => e.isDirectory())
- .map((e) => {
- const mdFile = path.join(scenarioDir, e.name, `${e.name}.md`);
- return fs.existsSync(mdFile) ? mdFile : null;
- })
- .filter(Boolean)
- .sort();
- }
- function main() {
- const args = parseArgs(process.argv.slice(2));
- if (args.help) {
- printUsage();
- process.exit(0);
- }
- if (!args.page && !args.scenario && !args.all) {
- process.stderr.write('Error: --page, --scenario, or --all is required.\n\n');
- printUsage();
- process.exit(1);
- }
- const outputBase = args.output || process.cwd();
- const scenariosBase = path.join(outputBase, 'C-UX-Scenarios');
- let filesToValidate = [];
- if (args.page) {
- filesToValidate = [path.resolve(args.page)];
- } else if (args.scenario) {
- const scenarioSlug = toSlug(args.scenario);
- const scenarioDir = path.join(scenariosBase, scenarioSlug);
- filesToValidate = getPageFiles(scenarioDir);
- if (filesToValidate.length === 0) {
- process.stdout.write(`No pages found in scenario: ${scenarioSlug}\n`);
- process.exit(0);
- }
- } else if (args.all) {
- if (!fs.existsSync(scenariosBase)) {
- process.stderr.write(`Error: C-UX-Scenarios not found at: ${scenariosBase}\n`);
- process.exit(1);
- }
- let entries;
- try {
- entries = fs.readdirSync(scenariosBase, { withFileTypes: true });
- } catch (error) {
- process.stderr.write(`Error reading scenarios: ${error.message}\n`);
- process.exit(1);
- }
- for (const e of entries.filter((e) => e.isDirectory()).sort()) {
- const pages = getPageFiles(path.join(scenariosBase, e.name));
- filesToValidate.push(...pages);
- }
- if (filesToValidate.length === 0) {
- process.stdout.write('No page specs found.\n');
- process.exit(0);
- }
- }
- let hasErrors = false;
- for (const filePath of filesToValidate) {
- const label = path.basename(filePath);
- const result = validatePage(filePath);
- process.stdout.write(formatResult(label, result));
- if (result.errors.length > 0) hasErrors = true;
- }
- process.exit(hasErrors ? 1 : 0);
- }
- main();
|