mirror of
https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git
synced 2026-08-01 19:06:14 +00:00
* fix(cli): install all 7 skills via uipro init, not just the orchestrator `uipro init` rendered only the orchestrator (ui-ux-pro-max) and never delivered the 6 sibling skills (banner-design, brand, design, design-system, slides, ui-styling), so users got 1 of 7 skills (#362). - sync-assets.mjs: bundle the 6 sub-skills into cli/assets/skills/ as static copies (source of truth: .claude/skills/), with sync + check coverage. Excludes ui-styling/canvas-fonts (~5.8MB of TTF) and __pycache__/.pyc cruft — a skill registers from its SKILL.md, not its fonts — so the bundle adds ~0.9MB, not ~6.6MB. - template.ts: after rendering the orchestrator, install each bundled sub-skill as a sibling. The skills parent is derived from the platform's skillPath (skills/ for most, prompts/ for copilot, steering/ for kiro) rather than hardcoded. - uninstall.ts: remove the sub-skills too. Verified: check:assets in sync, tsc passes, and a per-platform install harness delivers all 7 skills to the correct parent dir with no fonts. Closes #362 * fix(cli): filter excluded files from target side of check:assets check:assets filtered sourceFiles with isExcludedAssetFile but not targetFiles, so a stray cli/assets/scripts/__pycache__/*.pyc (generated by a local Python run) was reported as an "extra asset file" and failed the gate. Apply the same predicate to targetFiles in both the dirsToSync and sub-skill loops. Verified: check:assets now passes with __pycache__/*.pyc present in the target tree; typecheck passes. * fix(cli): uninstall from each platform's real skills dir, not hardcoded skills/ removeSkillDir() hardcoded <folder>/skills/<name>, but the installer places skills under each platform config's skillPath parent — copilot in .github/prompts/, kiro in .kiro/steering/. So uninstall left those platforms' skills (orchestrator + sub-skills) behind. Derive the install parent from loadPlatformConfig(aiType).folderStructure (same source the installer uses), and keep the legacy <folder>/skills/ cleanup (incl. .shared/) for older installs. Deduped via a Set. Verified: typecheck passes; an install+uninstall harness removes all 7 skills with zero leftovers for claude (.claude/skills), copilot (.github/prompts) and kiro (.kiro/steering). * fix(cli): re-sync bundled sub-skills after #385 stripped ckm- names #385 merged to main and removed the ckm- prefix from the six .claude/skills/*/SKILL.md name fields. This branch's bundled copies under cli/assets/skills/ still carried the old ckm- names, so after the PR merges with main the source no longer matched the bundle and the check-asset-sync CI gate failed (stale asset file: skills/*/SKILL.md). Merge main and regenerate the bundle so cli/assets/skills matches the current .claude/skills source of truth. check:assets and typecheck pass.
388 lines
9.9 KiB
JavaScript
388 lines
9.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* validate-asset.cjs
|
|
*
|
|
* Validates marketing assets against brand guidelines.
|
|
* Checks: file naming, dimensions, file size, metadata.
|
|
*
|
|
* Usage:
|
|
* node validate-asset.cjs <asset-path>
|
|
* node validate-asset.cjs <asset-path> --json
|
|
* node validate-asset.cjs <asset-path> --fix
|
|
*
|
|
* For color validation of images, use with extract-colors.cjs
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
// Validation rules
|
|
const RULES = {
|
|
naming: {
|
|
pattern: /^[a-z]+_[a-z0-9-]+_[a-z0-9-]+_\d{8}(_[a-z0-9-]+)?\.[a-z]+$/,
|
|
description:
|
|
"{type}_{campaign}_{description}_{timestamp}_{variant}.{ext}",
|
|
examples: [
|
|
"banner_claude-launch_hero-image_20251209.png",
|
|
"logo_brand-refresh_horizontal_20251209_dark.svg",
|
|
],
|
|
},
|
|
dimensions: {
|
|
banner: { minWidth: 600, minHeight: 300 },
|
|
logo: { minWidth: 100, minHeight: 100 },
|
|
design: { minWidth: 800, minHeight: 600 },
|
|
video: { minWidth: 640, minHeight: 480 },
|
|
default: { minWidth: 100, minHeight: 100 },
|
|
},
|
|
fileSize: {
|
|
image: { max: 5 * 1024 * 1024, recommended: 1 * 1024 * 1024 },
|
|
video: { max: 100 * 1024 * 1024, recommended: 50 * 1024 * 1024 },
|
|
svg: { max: 500 * 1024, recommended: 100 * 1024 },
|
|
},
|
|
formats: {
|
|
image: ["png", "jpg", "jpeg", "webp", "gif"],
|
|
vector: ["svg"],
|
|
video: ["mp4", "mov", "webm"],
|
|
document: ["pdf", "psd", "ai", "fig"],
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Parse asset filename
|
|
*/
|
|
function parseFilename(filename) {
|
|
const parts = filename.replace(/\.[^.]+$/, "").split("_");
|
|
|
|
if (parts.length < 4) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: parts[0],
|
|
campaign: parts[1],
|
|
description: parts[2],
|
|
timestamp: parts[3],
|
|
variant: parts.length > 4 ? parts[4] : null,
|
|
extension: path.extname(filename).slice(1).toLowerCase(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate filename convention
|
|
*/
|
|
function validateFilename(filename) {
|
|
const issues = [];
|
|
const suggestions = [];
|
|
|
|
// Check pattern match
|
|
if (!RULES.naming.pattern.test(filename)) {
|
|
issues.push("Filename does not match naming convention");
|
|
suggestions.push(`Expected format: ${RULES.naming.description}`);
|
|
suggestions.push(`Examples: ${RULES.naming.examples.join(", ")}`);
|
|
}
|
|
|
|
// Parse and check components
|
|
const parsed = parseFilename(filename);
|
|
if (parsed) {
|
|
// Check timestamp format
|
|
if (!/^\d{8}$/.test(parsed.timestamp)) {
|
|
issues.push("Timestamp should be YYYYMMDD format");
|
|
}
|
|
|
|
// Check kebab-case for campaign and description
|
|
if (parsed.campaign && !/^[a-z0-9-]+$/.test(parsed.campaign)) {
|
|
issues.push("Campaign name should be kebab-case");
|
|
}
|
|
|
|
if (parsed.description && !/^[a-z0-9-]+$/.test(parsed.description)) {
|
|
issues.push("Description should be kebab-case");
|
|
}
|
|
|
|
// Check valid type
|
|
const validTypes = [
|
|
"banner",
|
|
"logo",
|
|
"design",
|
|
"video",
|
|
"infographic",
|
|
"icon",
|
|
"photo",
|
|
];
|
|
if (!validTypes.includes(parsed.type)) {
|
|
suggestions.push(`Consider using type: ${validTypes.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
return { valid: issues.length === 0, issues, suggestions, parsed };
|
|
}
|
|
|
|
/**
|
|
* Validate file size
|
|
*/
|
|
function validateFileSize(filepath, extension) {
|
|
const issues = [];
|
|
const warnings = [];
|
|
|
|
const stats = fs.statSync(filepath);
|
|
const size = stats.size;
|
|
|
|
let limits;
|
|
if (RULES.formats.video.includes(extension)) {
|
|
limits = RULES.fileSize.video;
|
|
} else if (extension === "svg") {
|
|
limits = RULES.fileSize.svg;
|
|
} else {
|
|
limits = RULES.fileSize.image;
|
|
}
|
|
|
|
if (size > limits.max) {
|
|
issues.push(
|
|
`File size (${formatBytes(size)}) exceeds maximum (${formatBytes(
|
|
limits.max
|
|
)})`
|
|
);
|
|
} else if (size > limits.recommended) {
|
|
warnings.push(
|
|
`File size (${formatBytes(size)}) exceeds recommended (${formatBytes(
|
|
limits.recommended
|
|
)})`
|
|
);
|
|
}
|
|
|
|
return { valid: issues.length === 0, issues, warnings, size };
|
|
}
|
|
|
|
/**
|
|
* Validate file format
|
|
*/
|
|
function validateFormat(extension) {
|
|
const issues = [];
|
|
const info = { category: null };
|
|
|
|
const allFormats = [
|
|
...RULES.formats.image,
|
|
...RULES.formats.vector,
|
|
...RULES.formats.video,
|
|
...RULES.formats.document,
|
|
];
|
|
|
|
if (!allFormats.includes(extension)) {
|
|
issues.push(`Unsupported file format: .${extension}`);
|
|
return { valid: false, issues, info };
|
|
}
|
|
|
|
// Determine category
|
|
if (RULES.formats.image.includes(extension)) info.category = "image";
|
|
else if (RULES.formats.vector.includes(extension)) info.category = "vector";
|
|
else if (RULES.formats.video.includes(extension)) info.category = "video";
|
|
else if (RULES.formats.document.includes(extension))
|
|
info.category = "document";
|
|
|
|
return { valid: true, issues, info };
|
|
}
|
|
|
|
/**
|
|
* Check if asset exists in manifest
|
|
*/
|
|
function checkManifest(filepath) {
|
|
const manifestPath = path.join(process.cwd(), ".assets", "manifest.json");
|
|
|
|
if (!fs.existsSync(manifestPath)) {
|
|
return { registered: false, message: "Manifest not found" };
|
|
}
|
|
|
|
try {
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
const relativePath = path.relative(process.cwd(), filepath);
|
|
const found = manifest.assets?.find(
|
|
(a) => a.path === relativePath || a.path === filepath
|
|
);
|
|
|
|
return {
|
|
registered: !!found,
|
|
message: found ? "Asset registered in manifest" : "Asset not in manifest",
|
|
asset: found,
|
|
};
|
|
} catch {
|
|
return { registered: false, message: "Error reading manifest" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate suggested filename
|
|
*/
|
|
function suggestFilename(original, parsed) {
|
|
if (!parsed) return null;
|
|
|
|
const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
const type = parsed.type || "asset";
|
|
const campaign = parsed.campaign || "general";
|
|
const description = parsed.description || "untitled";
|
|
const ext = parsed.extension || "png";
|
|
|
|
return `${type}_${campaign}_${description}_${today}.${ext}`;
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human readable
|
|
*/
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return "0 Bytes";
|
|
const k = 1024;
|
|
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* Main validation function
|
|
*/
|
|
function validateAsset(assetPath) {
|
|
const results = {
|
|
path: assetPath,
|
|
filename: path.basename(assetPath),
|
|
valid: true,
|
|
issues: [],
|
|
warnings: [],
|
|
suggestions: [],
|
|
checks: {},
|
|
};
|
|
|
|
// Check file exists
|
|
if (!fs.existsSync(assetPath)) {
|
|
results.valid = false;
|
|
results.issues.push(`File not found: ${assetPath}`);
|
|
return results;
|
|
}
|
|
|
|
const filename = path.basename(assetPath);
|
|
const extension = path.extname(filename).slice(1).toLowerCase();
|
|
|
|
// 1. Validate filename
|
|
const filenameResult = validateFilename(filename);
|
|
results.checks.filename = filenameResult;
|
|
if (!filenameResult.valid) {
|
|
results.issues.push(...filenameResult.issues);
|
|
results.suggestions.push(...filenameResult.suggestions);
|
|
}
|
|
|
|
// 2. Validate format
|
|
const formatResult = validateFormat(extension);
|
|
results.checks.format = formatResult;
|
|
if (!formatResult.valid) {
|
|
results.issues.push(...formatResult.issues);
|
|
}
|
|
|
|
// 3. Validate file size
|
|
const sizeResult = validateFileSize(assetPath, extension);
|
|
results.checks.fileSize = sizeResult;
|
|
if (!sizeResult.valid) {
|
|
results.issues.push(...sizeResult.issues);
|
|
}
|
|
results.warnings.push(...sizeResult.warnings);
|
|
|
|
// 4. Check manifest registration
|
|
const manifestResult = checkManifest(assetPath);
|
|
results.checks.manifest = manifestResult;
|
|
if (!manifestResult.registered) {
|
|
results.warnings.push("Asset not registered in manifest.json");
|
|
results.suggestions.push(
|
|
"Register asset in .assets/manifest.json for tracking"
|
|
);
|
|
}
|
|
|
|
// 5. Suggest corrected filename if needed
|
|
if (!filenameResult.valid && filenameResult.parsed) {
|
|
const suggested = suggestFilename(filename, filenameResult.parsed);
|
|
if (suggested) {
|
|
results.suggestions.push(`Suggested filename: ${suggested}`);
|
|
}
|
|
}
|
|
|
|
// Overall validity
|
|
results.valid = results.issues.length === 0;
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Format output for console
|
|
*/
|
|
function formatOutput(results) {
|
|
const lines = [];
|
|
|
|
lines.push("\n" + "=".repeat(60));
|
|
lines.push(`ASSET VALIDATION: ${results.filename}`);
|
|
lines.push("=".repeat(60));
|
|
|
|
lines.push(`\nStatus: ${results.valid ? "PASS" : "FAIL"}`);
|
|
lines.push(`Path: ${results.path}`);
|
|
|
|
if (results.issues.length > 0) {
|
|
lines.push("\nISSUES:");
|
|
results.issues.forEach((issue) => lines.push(` - ${issue}`));
|
|
}
|
|
|
|
if (results.warnings.length > 0) {
|
|
lines.push("\nWARNINGS:");
|
|
results.warnings.forEach((warning) => lines.push(` - ${warning}`));
|
|
}
|
|
|
|
if (results.suggestions.length > 0) {
|
|
lines.push("\nSUGGESTIONS:");
|
|
results.suggestions.forEach((suggestion) =>
|
|
lines.push(` - ${suggestion}`)
|
|
);
|
|
}
|
|
|
|
// File size info
|
|
if (results.checks.fileSize?.size) {
|
|
lines.push(`\nFile Size: ${formatBytes(results.checks.fileSize.size)}`);
|
|
}
|
|
|
|
lines.push("\n" + "=".repeat(60));
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Main
|
|
*/
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
const jsonOutput = args.includes("--json");
|
|
const assetPath = args.find((a) => !a.startsWith("--"));
|
|
|
|
if (!assetPath) {
|
|
console.error("Usage: node validate-asset.cjs <asset-path> [--json]");
|
|
console.error("\nExamples:");
|
|
console.error(
|
|
" node validate-asset.cjs assets/banners/social-media/banner_launch_hero_20251209.png"
|
|
);
|
|
console.error(
|
|
" node validate-asset.cjs assets/logos/icon-only/logo-icon.svg --json"
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Resolve path
|
|
const resolvedPath = path.isAbsolute(assetPath)
|
|
? assetPath
|
|
: path.join(process.cwd(), assetPath);
|
|
|
|
// Validate
|
|
const results = validateAsset(resolvedPath);
|
|
|
|
// Output
|
|
if (jsonOutput) {
|
|
console.log(JSON.stringify(results, null, 2));
|
|
} else {
|
|
console.log(formatOutput(results));
|
|
}
|
|
|
|
// Exit with appropriate code
|
|
process.exit(results.valid ? 0 : 1);
|
|
}
|
|
|
|
main();
|