chore: initial monorepo scaffold + WDS Phase 1+2 artifacts
- Nx 22.7 monorepo (pnpm 11.1, TypeScript 5.9, Node 24) - apps/api: NestJS 11 (CJS conforme CODING-RULES.md PGD-DB-004) - apps/web: React 19 + Vite 8 (ESM) - libs/shared/api-interface: Zod contract base - Docker Compose dev: Postgres 18, Valkey 8, MinIO, Mailpit - WDS artifacts: - design-artifacts/A-Product-Brief/ (5 docs canônicos + 16 dialogs) - design-artifacts/B-Trigger-Map/ (hub + 4 personas + feature impact) - Stack canon: STACK.md v2.2 + CODING-RULES.md v2.0 + brand.md - AGENTS.md + README.md como entrada para devs/agentes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
_bmad/wds/scripts/README.md
Normal file
155
_bmad/wds/scripts/README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# WDS Scaffold Scripts
|
||||
|
||||
Node.js scripts that enforce deterministic output from AI agents. Agents provide content via CLI flags; scripts produce structure.
|
||||
|
||||
All scripts use only Node.js stdlib (no external dependencies). Run from the project root.
|
||||
|
||||
---
|
||||
|
||||
## Scripts
|
||||
|
||||
### `wds-init-scenario.js` — Initialize a scenario
|
||||
|
||||
Creates the scenario folder and a README index file.
|
||||
|
||||
```bash
|
||||
node src/scripts/wds-init-scenario.js \
|
||||
--scenario "01 New User Onboarding" \
|
||||
--description "New user first visit to account creation"
|
||||
```
|
||||
|
||||
Output: `C-UX-Scenarios/01-new-user-onboarding/README.md`
|
||||
|
||||
---
|
||||
|
||||
### `wds-init-page.js` — Initialize a page spec
|
||||
|
||||
Creates a new page spec file with all required sections pre-filled with placeholders.
|
||||
|
||||
```bash
|
||||
node src/scripts/wds-init-page.js \
|
||||
--page "01 Start" \
|
||||
--scenario "01 New User Onboarding" \
|
||||
--platform "Mobile web" \
|
||||
--visibility "Public"
|
||||
```
|
||||
|
||||
Output:
|
||||
- `C-UX-Scenarios/01-new-user-onboarding/01-start/01-start.md`
|
||||
- `C-UX-Scenarios/01-new-user-onboarding/01-start/sketches/`
|
||||
|
||||
After creating all pages in a scenario, run `wds-nav.js` to wire up navigation links.
|
||||
|
||||
---
|
||||
|
||||
### `wds-nav.js` — Update navigation links
|
||||
|
||||
Scans pages in a scenario (sorted by name) and writes correct prev/next navigation rows into each page spec.
|
||||
|
||||
```bash
|
||||
# One scenario
|
||||
node src/scripts/wds-nav.js --scenario "01 New User Onboarding"
|
||||
|
||||
# All scenarios
|
||||
node src/scripts/wds-nav.js --all
|
||||
```
|
||||
|
||||
Run this after adding or removing pages, or after reordering page numbers.
|
||||
|
||||
---
|
||||
|
||||
### `wds-add-object.js` — Append an object spec
|
||||
|
||||
Appends a structured object spec block to a page spec under a named section.
|
||||
|
||||
```bash
|
||||
node src/scripts/wds-add-object.js \
|
||||
--page "C-UX-Scenarios/01-new-user-onboarding/01-start/01-start.md" \
|
||||
--section "Hero" \
|
||||
--object "Primary Headline" \
|
||||
--component "H1 heading" \
|
||||
--se "Välkommen" \
|
||||
--en "Welcome" \
|
||||
--behavior "Static display"
|
||||
```
|
||||
|
||||
Object ID is auto-derived: `start-hero-primary-headline`
|
||||
|
||||
The section heading (`### Section: Hero`) is created if it doesn't already exist.
|
||||
|
||||
---
|
||||
|
||||
### `wds-add-spacing.js` — Append a spacing object
|
||||
|
||||
Appends a spacing notation entry to the `## Spacing` section of a page spec.
|
||||
|
||||
```bash
|
||||
node src/scripts/wds-add-spacing.js \
|
||||
--page "C-UX-Scenarios/01-new-user-onboarding/01-start/01-start.md" \
|
||||
--direction v \
|
||||
--type space \
|
||||
--size xl \
|
||||
--reason "major section boundary between hero and features"
|
||||
```
|
||||
|
||||
Valid directions: `v` (vertical), `h` (horizontal)
|
||||
Valid types: `space`, `separator`, `line`
|
||||
Valid sizes: `zero`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `flex`
|
||||
|
||||
Spacing ID is auto-derived: `start-v-space-xl`
|
||||
|
||||
---
|
||||
|
||||
### `wds-validate.js` — Validate page specs
|
||||
|
||||
Checks page spec files for structural correctness.
|
||||
|
||||
```bash
|
||||
# Single page
|
||||
node src/scripts/wds-validate.js \
|
||||
--page "C-UX-Scenarios/01-new-user-onboarding/01-start/01-start.md"
|
||||
|
||||
# All pages in a scenario
|
||||
node src/scripts/wds-validate.js --scenario "01 New User Onboarding"
|
||||
|
||||
# All scenarios
|
||||
node src/scripts/wds-validate.js --all
|
||||
```
|
||||
|
||||
Validates:
|
||||
- Required sections present
|
||||
- Object IDs are kebab-case with correct page prefix
|
||||
- No duplicate Object IDs
|
||||
- Navigation rows (3 expected)
|
||||
- Metadata table has all required properties
|
||||
- Sketches folder exists
|
||||
- SE + EN content present for each object
|
||||
|
||||
---
|
||||
|
||||
## How agents use these scripts
|
||||
|
||||
1. Agent calls `wds-init-scenario.js` with scenario name and description
|
||||
2. Agent calls `wds-init-page.js` for each page in the scenario
|
||||
3. Agent calls `wds-nav.js` to wire navigation after all pages exist
|
||||
4. Agent calls `wds-add-object.js` for each UI object, providing Swedish and English content
|
||||
5. Agent calls `wds-add-spacing.js` for each spacing decision
|
||||
6. Agent calls `wds-validate.js` to confirm the spec is structurally correct before handoff
|
||||
|
||||
The agent never writes raw markdown — it only supplies content as flag values. The scripts own all structural decisions.
|
||||
|
||||
---
|
||||
|
||||
## File location convention
|
||||
|
||||
```
|
||||
C-UX-Scenarios/
|
||||
{scenario-slug}/
|
||||
README.md
|
||||
{page-slug}/
|
||||
{page-slug}.md
|
||||
sketches/
|
||||
{page-slug}-concept.jpg
|
||||
```
|
||||
|
||||
Example: `C-UX-Scenarios/01-new-user-onboarding/02-signup/02-signup.md`
|
||||
202
_bmad/wds/scripts/wds-add-object.js
Normal file
202
_bmad/wds/scripts/wds-add-object.js
Normal file
@@ -0,0 +1,202 @@
|
||||
// wds-add-object.js — WDS scaffold: append an object spec block to a page spec file
|
||||
// Usage: node src/scripts/wds-add-object.js --page "C-UX-Scenarios/01-onboarding/01-start/01-start.md" \
|
||||
// --section "Hero" --object "Primary Headline" --component "H1 heading" \
|
||||
// --se "Välkommen" --en "Welcome"
|
||||
|
||||
'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-add-object.js --page <path> --section <name> --object <name> [options]',
|
||||
'',
|
||||
'Required:',
|
||||
' --page Path to the page spec .md file',
|
||||
' --section Section name (e.g. "Hero")',
|
||||
' --object Object name (e.g. "Primary Headline")',
|
||||
'',
|
||||
'Optional:',
|
||||
' --component Component name (default: "—")',
|
||||
' --translation Translation key (auto-derived if omitted)',
|
||||
' --se Swedish text content',
|
||||
' --en English text content',
|
||||
' --behavior Behavior description (e.g. "onClick: submit form")',
|
||||
' --component-path Path to component file (default: "—")',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// Derive page slug from file path: "01-start/01-start.md" -> "01-start"
|
||||
function pageSlugFromPath(filePath) {
|
||||
const base = path.basename(filePath, '.md');
|
||||
return base;
|
||||
}
|
||||
|
||||
// Derive Object ID: pageSlug + sectionSlug + objectSlug
|
||||
// e.g. page=01-start, section=Hero, object=Primary Headline -> 01-start-hero-primary-headline
|
||||
function deriveObjectId(pageSlug, sectionName, objectName) {
|
||||
// Strip leading page number from pageSlug for ID prefix
|
||||
// "01-start" -> "start", "02-signup" -> "signup"
|
||||
const slugParts = pageSlug.split('-');
|
||||
const pagePrefix = slugParts.length > 1 ? slugParts.slice(1).join('-') : pageSlug;
|
||||
const sectionSlug = toSlug(sectionName);
|
||||
const objectSlug = toSlug(objectName);
|
||||
return `${pagePrefix}-${sectionSlug}-${objectSlug}`;
|
||||
}
|
||||
|
||||
function buildObjectBlock({ objectName, objectId, component, componentPath, translationKey, se, en, behavior }) {
|
||||
const compDisplay = componentPath && componentPath !== '—' ? `[${component}](${componentPath})` : component || '—';
|
||||
|
||||
const lines = [
|
||||
`#### ${objectName}`,
|
||||
'',
|
||||
`**OBJECT ID:** \`${objectId}\``,
|
||||
'',
|
||||
'| Property | Value |',
|
||||
'|----------|-------|',
|
||||
`| Component | ${compDisplay} |`,
|
||||
`| Translation Key | \`${translationKey}\` |`,
|
||||
`| SE | "${se || '—'}" |`,
|
||||
`| EN | "${en || '—'}" |`,
|
||||
`| Behavior | ${behavior || '—'} |`,
|
||||
'',
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Insert content after a section heading. Creates the section heading if it doesn't exist.
|
||||
function insertUnderSection(content, sectionHeading, objectBlock) {
|
||||
const lines = content.split('\n');
|
||||
const headingLine = `### Section: ${sectionHeading}`;
|
||||
const headingIdx = lines.findIndex((l) => l.trim() === headingLine);
|
||||
|
||||
if (headingIdx === -1) {
|
||||
// Section doesn't exist — append it before the next ## heading after ## Page Sections
|
||||
const pageSectionsIdx = lines.findIndex((l) => l.trim() === '## Page Sections');
|
||||
if (pageSectionsIdx === -1) {
|
||||
// Just append at end before last nav row
|
||||
return content + `\n${headingLine}\n\n${objectBlock}\n`;
|
||||
}
|
||||
|
||||
// Find end of ## Page Sections block
|
||||
let insertIdx = pageSectionsIdx + 1;
|
||||
while (insertIdx < lines.length) {
|
||||
const t = lines[insertIdx].trim();
|
||||
if (t.startsWith('## ') || t === '---') break;
|
||||
insertIdx++;
|
||||
}
|
||||
|
||||
const before = lines.slice(0, insertIdx);
|
||||
const after = lines.slice(insertIdx);
|
||||
return [...before, '', headingLine, '', objectBlock, ...after].join('\n');
|
||||
} else {
|
||||
// Find the end of this section (next ### or ## or end of file)
|
||||
let insertIdx = headingIdx + 1;
|
||||
// Skip blank lines after heading
|
||||
while (insertIdx < lines.length && lines[insertIdx].trim() === '') insertIdx++;
|
||||
// Skip comment lines
|
||||
while (insertIdx < lines.length && lines[insertIdx].trim().startsWith('<!--')) insertIdx++;
|
||||
|
||||
// Find end of section
|
||||
let endIdx = insertIdx;
|
||||
while (endIdx < lines.length) {
|
||||
const t = lines[endIdx].trim();
|
||||
if (t.startsWith('### ') || t.startsWith('## ') || t === '---') break;
|
||||
endIdx++;
|
||||
}
|
||||
|
||||
// Insert object block before end of section
|
||||
const before = lines.slice(0, endIdx);
|
||||
const after = lines.slice(endIdx);
|
||||
return [...before, '', objectBlock, ...after].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!args.page || !args.section || !args.object) {
|
||||
process.stderr.write('Error: --page, --section, and --object are required.\n\n');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filePath = path.resolve(args.page);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
process.stderr.write(`Error: File not found: ${filePath}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pageSlug = pageSlugFromPath(filePath);
|
||||
const objectId = deriveObjectId(pageSlug, args.section, args.object);
|
||||
|
||||
// Auto-derive translation key from objectId
|
||||
const translationKey = args.translation || objectId.replaceAll('-', '.');
|
||||
|
||||
const objectBlock = buildObjectBlock({
|
||||
objectName: args.object,
|
||||
objectId,
|
||||
component: args.component || '—',
|
||||
componentPath: args['component-path'] || '—',
|
||||
translationKey,
|
||||
se: args.se || '',
|
||||
en: args.en || '',
|
||||
behavior: args.behavior || '—',
|
||||
});
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error reading file: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check for duplicate object ID
|
||||
if (content.includes(`\`${objectId}\``)) {
|
||||
process.stderr.write(`Error: Object ID already exists in file: ${objectId}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updated = insertUnderSection(content, args.section, objectBlock);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, updated, 'utf8');
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error writing file: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`✓ Added object ${objectId}\n`);
|
||||
process.stdout.write(` File: ${filePath}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
158
_bmad/wds/scripts/wds-add-spacing.js
Normal file
158
_bmad/wds/scripts/wds-add-spacing.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// wds-add-spacing.js — WDS scaffold: append a spacing object to a page spec file
|
||||
// Usage: node src/scripts/wds-add-spacing.js --page "C-UX-Scenarios/01-onboarding/01-start/01-start.md" \
|
||||
// --direction v --type space --size xl --reason "major section boundary between hero and features"
|
||||
|
||||
'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 printUsage() {
|
||||
process.stdout.write(
|
||||
[
|
||||
'Usage: node src/scripts/wds-add-spacing.js --page <path> --direction <v|h> --type <type> --size <size> [options]',
|
||||
'',
|
||||
'Required:',
|
||||
' --page Path to the page spec .md file',
|
||||
' --direction v (vertical) or h (horizontal)',
|
||||
' --type space | separator | line',
|
||||
' --size zero | sm | md | lg | xl | 2xl | 3xl | flex',
|
||||
'',
|
||||
'Optional:',
|
||||
' --reason Why this spacing exists',
|
||||
'',
|
||||
'Valid directions: v, h',
|
||||
'Valid types: space, separator, line',
|
||||
'Valid sizes: zero, sm, md, lg, xl, 2xl, 3xl, flex',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const VALID_DIRECTIONS = ['v', 'h'];
|
||||
const VALID_TYPES = ['space', 'separator', 'line'];
|
||||
const VALID_SIZES = ['zero', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', 'flex'];
|
||||
|
||||
// Derive page prefix from slug: "01-start" -> "start"
|
||||
function pagePrefix(slug) {
|
||||
const parts = slug.split('-');
|
||||
return parts.length > 1 ? parts.slice(1).join('-') : slug;
|
||||
}
|
||||
|
||||
function pageSlugFromPath(filePath) {
|
||||
return path.basename(filePath, '.md');
|
||||
}
|
||||
|
||||
function buildSpacingBlock(spacingId, reason) {
|
||||
const icon = '↕';
|
||||
const reasonText = reason ? ` — ${reason}` : '';
|
||||
return `#### ${icon} \`${spacingId}\`${reasonText}\n`;
|
||||
}
|
||||
|
||||
function appendToSpacingSection(content, spacingBlock) {
|
||||
const lines = content.split('\n');
|
||||
const spacingIdx = lines.findIndex((l) => l.trim() === '## Spacing');
|
||||
|
||||
if (spacingIdx === -1) {
|
||||
// No spacing section — append before first ## after metadata
|
||||
return content + `\n## Spacing\n\n${spacingBlock}\n`;
|
||||
}
|
||||
|
||||
// Find end of spacing section (next ## or ---)
|
||||
let endIdx = spacingIdx + 1;
|
||||
while (endIdx < lines.length) {
|
||||
const t = lines[endIdx].trim();
|
||||
if ((t.startsWith('## ') && t !== '## Spacing') || t === '---') break;
|
||||
endIdx++;
|
||||
}
|
||||
|
||||
// Insert just before the end marker
|
||||
const before = lines.slice(0, endIdx);
|
||||
const after = lines.slice(endIdx);
|
||||
return [...before, spacingBlock, ...after].join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!args.page || !args.direction || !args.type || args.size === 0) {
|
||||
process.stderr.write('Error: --page, --direction, --type, and --size are required.\n\n');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!VALID_DIRECTIONS.includes(args.direction)) {
|
||||
process.stderr.write(`Error: Invalid direction "${args.direction}". Must be: ${VALID_DIRECTIONS.join(', ')}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!VALID_TYPES.includes(args.type)) {
|
||||
process.stderr.write(`Error: Invalid type "${args.type}". Must be: ${VALID_TYPES.join(', ')}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!VALID_SIZES.includes(args.size)) {
|
||||
process.stderr.write(`Error: Invalid size "${args.size}". Must be: ${VALID_SIZES.join(', ')}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filePath = path.resolve(args.page);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
process.stderr.write(`Error: File not found: ${filePath}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const slug = pageSlugFromPath(filePath);
|
||||
const prefix = pagePrefix(slug);
|
||||
const spacingId = `${prefix}-${args.direction}-${args.type}-${args.size}`;
|
||||
const reason = args.reason || '';
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error reading file: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check for duplicate spacing ID
|
||||
if (content.includes(`\`${spacingId}\``)) {
|
||||
process.stderr.write(`Error: Spacing ID already exists in file: ${spacingId}\n`);
|
||||
process.stderr.write('Use a different combination of direction/type/size or manually edit the file.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spacingBlock = buildSpacingBlock(spacingId, reason);
|
||||
const updated = appendToSpacingSection(content, spacingBlock);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, updated, 'utf8');
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error writing file: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`✓ Added spacing object ${spacingId}\n`);
|
||||
process.stdout.write(` File: ${filePath}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
229
_bmad/wds/scripts/wds-init-page.js
Normal file
229
_bmad/wds/scripts/wds-init-page.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// wds-init-page.js — WDS scaffold: initialize new page spec
|
||||
// Usage: node src/scripts/wds-init-page.js --page "01 Start" --scenario "01 Onboarding"
|
||||
|
||||
'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-init-page.js --page "01 Start" --scenario "01 Onboarding" [options]',
|
||||
'',
|
||||
'Required:',
|
||||
' --page Page name with number, e.g. "01 Start"',
|
||||
' --scenario Scenario name, e.g. "01 New User Onboarding"',
|
||||
'',
|
||||
'Optional:',
|
||||
' --platform Platform value (default: "Mobile web")',
|
||||
' --visibility Visibility value (default: "Public")',
|
||||
' --output Base path to write to (default: current directory)',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function buildTemplate({ pageSlug, pageName, scenarioSlug, scenarioName, platform, visibility }) {
|
||||
const sketchFile = `sketches/${pageSlug}-concept.jpg`;
|
||||
|
||||
const navEmpty = `← [Previous]() | [Next →]()`;
|
||||
|
||||
const metaTable = [
|
||||
'| Property | Value |',
|
||||
'|----------|-------|',
|
||||
`| Scenario | ${scenarioName} |`,
|
||||
`| Page Number | ${pageName.split(' ')[0]} |`,
|
||||
`| Platform | ${platform} |`,
|
||||
`| Page Type | — |`,
|
||||
`| Viewport | — |`,
|
||||
`| Interaction | — |`,
|
||||
`| Visibility | ${visibility} |`,
|
||||
].join('\n');
|
||||
|
||||
const overviewSection = [
|
||||
'| Property | Value |',
|
||||
'|----------|-------|',
|
||||
'| Purpose | — |',
|
||||
'| User Situation | — |',
|
||||
'| Success Criteria | — |',
|
||||
'| Entry Points | — |',
|
||||
'| Exit Points | — |',
|
||||
].join('\n');
|
||||
|
||||
const spacingTable = [
|
||||
'| Token | Direction | Type | Size | Reason |',
|
||||
'|-------|-----------|------|------|--------|',
|
||||
`| \`${pageSlug}-v-space-md\` | Vertical | space | md | — |`,
|
||||
].join('\n');
|
||||
|
||||
const typographyTable = [
|
||||
'| Element | Semantic | Size | Weight | Typeface |',
|
||||
'|---------|----------|------|--------|----------|',
|
||||
'| Page title | H1 | — | — | — |',
|
||||
].join('\n');
|
||||
|
||||
const statesTable = [
|
||||
'| State | When | Appearance | Actions |',
|
||||
'|-------|------|------------|---------|',
|
||||
'| Default | On load | — | — |',
|
||||
].join('\n');
|
||||
|
||||
return [
|
||||
navEmpty,
|
||||
'',
|
||||
``,
|
||||
'',
|
||||
navEmpty,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
`# ${pageName}`,
|
||||
'',
|
||||
'## Page Metadata',
|
||||
'',
|
||||
metaTable,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Overview',
|
||||
'',
|
||||
overviewSection,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Reference Materials',
|
||||
'',
|
||||
'- —',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Layout Structure',
|
||||
'',
|
||||
'```',
|
||||
'+---------------------------+',
|
||||
'| Header |',
|
||||
'+---------------------------+',
|
||||
'| |',
|
||||
'| Content |',
|
||||
'| |',
|
||||
'+---------------------------+',
|
||||
'| Footer |',
|
||||
'+---------------------------+',
|
||||
'```',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Spacing',
|
||||
'',
|
||||
spacingTable,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Typography',
|
||||
'',
|
||||
typographyTable,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Page Sections',
|
||||
'',
|
||||
`### Section: Main`,
|
||||
'',
|
||||
`<!-- Objects go here. Use wds-add-object.js to append. -->`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Page States',
|
||||
'',
|
||||
statesTable,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Open Questions',
|
||||
'',
|
||||
'- —',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Checklist',
|
||||
'',
|
||||
'- [ ] Navigation links updated',
|
||||
'- [ ] Sketch added',
|
||||
'- [ ] All objects have SE + EN content',
|
||||
'- [ ] All Object IDs use correct prefix',
|
||||
'- [ ] Page States defined',
|
||||
'- [ ] Spacing tokens defined',
|
||||
'',
|
||||
navEmpty,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!args.page || !args.scenario) {
|
||||
process.stderr.write('Error: --page and --scenario are required.\n\n');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pageName = args.page;
|
||||
const scenarioName = args.scenario;
|
||||
const platform = args.platform || 'Mobile web';
|
||||
const visibility = args.visibility || 'Public';
|
||||
const outputBase = args.output || process.cwd();
|
||||
|
||||
const pageSlug = toSlug(pageName);
|
||||
const scenarioSlug = toSlug(scenarioName);
|
||||
|
||||
const pageDir = path.join(outputBase, 'C-UX-Scenarios', scenarioSlug, pageSlug);
|
||||
const sketchesDir = path.join(pageDir, 'sketches');
|
||||
const pageFile = path.join(pageDir, `${pageSlug}.md`);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(pageDir, { recursive: true });
|
||||
fs.mkdirSync(sketchesDir, { recursive: true });
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error creating directories: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = buildTemplate({ pageSlug, pageName, scenarioSlug, scenarioName, platform, visibility });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(pageFile, content, 'utf8');
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error writing file: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`✓ Created ${pageSlug}.md\n`);
|
||||
process.stdout.write(` Path: ${pageFile}\n`);
|
||||
process.stdout.write(` Run wds-nav.js to update navigation links.\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
120
_bmad/wds/scripts/wds-init-scenario.js
Normal file
120
_bmad/wds/scripts/wds-init-scenario.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// wds-init-scenario.js — WDS scaffold: initialize new scenario folder
|
||||
// Usage: node src/scripts/wds-init-scenario.js --scenario "01 Onboarding" --description "New user first visit to account creation"
|
||||
|
||||
'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-init-scenario.js --scenario "01 Onboarding" [options]',
|
||||
'',
|
||||
'Required:',
|
||||
' --scenario Scenario name with number, e.g. "01 New User Onboarding"',
|
||||
'',
|
||||
'Optional:',
|
||||
' --description Short description of the scenario',
|
||||
' --output Base path to write to (default: current directory)',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function buildReadme({ scenarioName, scenarioSlug, description }) {
|
||||
const scenarioNumber = scenarioName.split(' ')[0];
|
||||
const desc = description || '—';
|
||||
|
||||
return [
|
||||
`# Scenario ${scenarioNumber}: ${scenarioName}`,
|
||||
'',
|
||||
`**Description:** ${desc}`,
|
||||
'',
|
||||
'**Trigger Map:** [Link to trigger map]()',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Pages',
|
||||
'',
|
||||
'| # | Page | File | Status |',
|
||||
'|---|------|------|--------|',
|
||||
'| — | (no pages yet) | — | — |',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'- Add pages with: `node src/scripts/wds-init-page.js --scenario "' + scenarioName + '" --page "01 Start"`',
|
||||
'- Update navigation after adding pages: `node src/scripts/wds-nav.js --scenario "' + scenarioName + '"`',
|
||||
'- Validate pages: `node src/scripts/wds-validate.js --scenario "' + scenarioName + '"`',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!args.scenario) {
|
||||
process.stderr.write('Error: --scenario is required.\n\n');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scenarioName = args.scenario;
|
||||
const description = args.description || '';
|
||||
const outputBase = args.output || process.cwd();
|
||||
|
||||
const scenarioSlug = toSlug(scenarioName);
|
||||
const scenarioDir = path.join(outputBase, 'C-UX-Scenarios', scenarioSlug);
|
||||
const readmeFile = path.join(scenarioDir, 'README.md');
|
||||
|
||||
if (fs.existsSync(scenarioDir)) {
|
||||
process.stderr.write(`Error: Scenario folder already exists: ${scenarioDir}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(scenarioDir, { recursive: true });
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error creating directory: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = buildReadme({ scenarioName, scenarioSlug, description });
|
||||
|
||||
try {
|
||||
fs.writeFileSync(readmeFile, content, 'utf8');
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error writing README: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`✓ Created scenario ${scenarioSlug}/\n`);
|
||||
process.stdout.write(` Path: ${scenarioDir}\n`);
|
||||
process.stdout.write(` README: ${readmeFile}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
201
_bmad/wds/scripts/wds-nav.js
Normal file
201
_bmad/wds/scripts/wds-nav.js
Normal file
@@ -0,0 +1,201 @@
|
||||
// wds-nav.js — WDS scaffold: generate/update navigation links across all pages in a scenario
|
||||
// Usage: node src/scripts/wds-nav.js --scenario "01 Onboarding"
|
||||
// node src/scripts/wds-nav.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-nav.js --scenario "01 Onboarding"',
|
||||
' node src/scripts/wds-nav.js --all',
|
||||
'',
|
||||
'Options:',
|
||||
' --scenario Scenario name or slug to update',
|
||||
' --all Update all scenarios',
|
||||
' --output Base path (default: current directory)',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// Build a human-readable page name from the slug for nav labels
|
||||
// e.g. "01-start" -> "01 Start"
|
||||
function slugToLabel(slug) {
|
||||
return slug
|
||||
.split('-')
|
||||
.map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildNavRow(prev, next) {
|
||||
const leftPart = prev ? `← [${slugToLabel(prev.slug)}](../${prev.slug}/${prev.slug}.md)` : '←';
|
||||
const rightPart = next ? `[${slugToLabel(next.slug)} →](../${next.slug}/${next.slug}.md)` : '→';
|
||||
return `${leftPart} | ${rightPart}`;
|
||||
}
|
||||
|
||||
// Replace all 3 occurrences of navigation rows in a page file.
|
||||
// Nav rows are lines matching the pattern: starts with '←' or contains '| [' and ends with '→'
|
||||
// We identify them by a simple pattern: line that starts with "←" (after trim).
|
||||
function updateNavInContent(content, navRow) {
|
||||
const lines = content.split('\n');
|
||||
const result = [];
|
||||
let navCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Match navigation rows: lines that start with "←" (our nav format)
|
||||
if (trimmed.startsWith('←')) {
|
||||
result.push(navRow);
|
||||
navCount++;
|
||||
} else {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return { content: result.join('\n'), navCount };
|
||||
}
|
||||
|
||||
function getPageFolders(scenarioDir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(scenarioDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.filter((name) => {
|
||||
// Must have a matching .md file inside
|
||||
const mdFile = path.join(scenarioDir, name, `${name}.md`);
|
||||
return fs.existsSync(mdFile);
|
||||
})
|
||||
.sort(); // Sort alphabetically — page numbers ensure correct order
|
||||
}
|
||||
|
||||
function processScenario(scenariosBase, scenarioSlug) {
|
||||
const scenarioDir = path.join(scenariosBase, scenarioSlug);
|
||||
|
||||
if (!fs.existsSync(scenarioDir)) {
|
||||
process.stderr.write(`Error: Scenario not found: ${scenarioDir}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const pageSlugs = getPageFolders(scenarioDir);
|
||||
|
||||
if (pageSlugs.length === 0) {
|
||||
process.stdout.write(` ${scenarioSlug}: no pages found, skipping.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
|
||||
for (let i = 0; i < pageSlugs.length; i++) {
|
||||
const slug = pageSlugs[i];
|
||||
const prev = i > 0 ? { slug: pageSlugs[i - 1] } : null;
|
||||
const next = i < pageSlugs.length - 1 ? { slug: pageSlugs[i + 1] } : null;
|
||||
const navRow = buildNavRow(prev, next);
|
||||
|
||||
const mdFile = path.join(scenarioDir, slug, `${slug}.md`);
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(mdFile, 'utf8');
|
||||
} catch (error) {
|
||||
process.stderr.write(` Error reading ${mdFile}: ${error.message}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { content: newContent, navCount } = updateNavInContent(content, navRow);
|
||||
|
||||
if (navCount === 0) {
|
||||
process.stderr.write(` Warning: No navigation rows found in ${slug}.md\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(mdFile, newContent, 'utf8');
|
||||
updated++;
|
||||
} catch (error) {
|
||||
process.stderr.write(` Error writing ${mdFile}: ${error.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`✓ Updated navigation for ${updated} pages in ${scenarioSlug}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!args.scenario && !args.all) {
|
||||
process.stderr.write('Error: --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');
|
||||
|
||||
if (!fs.existsSync(scenariosBase)) {
|
||||
process.stderr.write(`Error: C-UX-Scenarios directory not found at: ${scenariosBase}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.all) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(scenariosBase, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
process.stderr.write(`Error reading C-UX-Scenarios: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scenarios = entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
if (scenarios.length === 0) {
|
||||
process.stdout.write('No scenarios found.\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let allOk = true;
|
||||
for (const slug of scenarios) {
|
||||
if (!processScenario(scenariosBase, slug)) allOk = false;
|
||||
}
|
||||
process.exit(allOk ? 0 : 1);
|
||||
} else {
|
||||
const scenarioSlug = toSlug(args.scenario);
|
||||
const ok = processScenario(scenariosBase, scenarioSlug);
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
301
_bmad/wds/scripts/wds-validate.js
Normal file
301
_bmad/wds/scripts/wds-validate.js
Normal file
@@ -0,0 +1,301 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user