refactor: move nested skills to root + add ui-ux-pro-max + ConsentOS
- Extract 9 nested skills from website-creator/ to root skills/ - Remove duplicate seo-analyzers, seo-geo, seo-multi-channel from website-creator - Add new ui-ux-pro-max skill with full UI/UX data - Update install-skills.sh to sync properly - Remove .DS_Store artifacts Moved skills: - api-and-interface-design - banner-design - brand - design-system - design - frontend-ui-engineering - slides - spec-driven-development - ui-styling
This commit is contained in:
341
skills/brand/scripts/extract-colors.cjs
Executable file
341
skills/brand/scripts/extract-colors.cjs
Executable file
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* extract-colors.cjs
|
||||
*
|
||||
* Extract dominant colors from an image and compare against brand palette.
|
||||
* Uses pure Node.js without external image processing dependencies.
|
||||
*
|
||||
* For full color extraction from images, integrate with ai-multimodal skill
|
||||
* or use ImageMagick via shell commands.
|
||||
*
|
||||
* Usage:
|
||||
* node extract-colors.cjs <image-path>
|
||||
* node extract-colors.cjs <image-path> --brand-file <path>
|
||||
* node extract-colors.cjs --palette # Show brand palette from guidelines
|
||||
*
|
||||
* Integration:
|
||||
* For image color analysis, use: ai-multimodal skill or ImageMagick
|
||||
* magick <image> -colors 10 -depth 8 -format "%c" histogram:info:
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Default brand guidelines path
|
||||
const DEFAULT_GUIDELINES_PATH = "docs/brand-guidelines.md";
|
||||
|
||||
/**
|
||||
* Extract hex colors from markdown content
|
||||
*/
|
||||
function extractHexColors(text) {
|
||||
const hexPattern = /#[0-9A-Fa-f]{6}\b/g;
|
||||
return [...new Set(text.match(hexPattern) || [])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse brand guidelines for color palette
|
||||
*/
|
||||
function parseBrandColors(guidelinesPath) {
|
||||
const resolvedPath = path.isAbsolute(guidelinesPath)
|
||||
? guidelinesPath
|
||||
: path.join(process.cwd(), guidelinesPath);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(resolvedPath, "utf-8");
|
||||
|
||||
const palette = {
|
||||
primary: [],
|
||||
secondary: [],
|
||||
neutral: [],
|
||||
semantic: [],
|
||||
all: [],
|
||||
};
|
||||
|
||||
// Extract colors from different sections
|
||||
const sections = [
|
||||
{ name: "primary", regex: /### Primary[\s\S]*?(?=###|##|$)/i },
|
||||
{ name: "secondary", regex: /### Secondary[\s\S]*?(?=###|##|$)/i },
|
||||
{ name: "neutral", regex: /### Neutral[\s\S]*?(?=###|##|$)/i },
|
||||
{ name: "semantic", regex: /### Semantic[\s\S]*?(?=###|##|$)/i },
|
||||
];
|
||||
|
||||
sections.forEach(({ name, regex }) => {
|
||||
const match = content.match(regex);
|
||||
if (match) {
|
||||
const colors = extractHexColors(match[0]);
|
||||
palette[name] = colors;
|
||||
palette.all.push(...colors);
|
||||
}
|
||||
});
|
||||
|
||||
// Dedupe all
|
||||
palette.all = [...new Set(palette.all)];
|
||||
|
||||
return palette;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex to RGB
|
||||
*/
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB to hex
|
||||
*/
|
||||
function rgbToHex(r, g, b) {
|
||||
return (
|
||||
"#" +
|
||||
[r, g, b]
|
||||
.map((x) => {
|
||||
const hex = Math.round(x).toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
})
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate color distance (Euclidean in RGB space)
|
||||
*/
|
||||
function colorDistance(color1, color2) {
|
||||
const rgb1 = typeof color1 === "string" ? hexToRgb(color1) : color1;
|
||||
const rgb2 = typeof color2 === "string" ? hexToRgb(color2) : color2;
|
||||
|
||||
if (!rgb1 || !rgb2) return Infinity;
|
||||
|
||||
return Math.sqrt(
|
||||
Math.pow(rgb1.r - rgb2.r, 2) +
|
||||
Math.pow(rgb1.g - rgb2.g, 2) +
|
||||
Math.pow(rgb1.b - rgb2.b, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find nearest brand color
|
||||
*/
|
||||
function findNearestBrandColor(color, brandColors) {
|
||||
let nearest = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
brandColors.forEach((brandColor) => {
|
||||
const distance = colorDistance(color, brandColor);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearest = brandColor;
|
||||
}
|
||||
});
|
||||
|
||||
return { color: nearest, distance: minDistance };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate brand compliance percentage
|
||||
* Distance threshold: 50 (out of max ~441 for RGB)
|
||||
*/
|
||||
function calculateCompliance(extractedColors, brandColors, threshold = 50) {
|
||||
if (!extractedColors || extractedColors.length === 0) return 100;
|
||||
if (!brandColors || brandColors.length === 0) return 0;
|
||||
|
||||
let matchCount = 0;
|
||||
|
||||
extractedColors.forEach((color) => {
|
||||
const nearest = findNearestBrandColor(color, brandColors);
|
||||
if (nearest.distance <= threshold) {
|
||||
matchCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return Math.round((matchCount / extractedColors.length) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ImageMagick command for color extraction
|
||||
*/
|
||||
function generateImageMagickCommand(imagePath, numColors = 10) {
|
||||
return `magick "${imagePath}" -colors ${numColors} -depth 8 -format "%c" histogram:info:`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ImageMagick histogram output to extract colors
|
||||
*/
|
||||
function parseImageMagickOutput(output) {
|
||||
const colors = [];
|
||||
const lines = output.trim().split("\n");
|
||||
|
||||
lines.forEach((line) => {
|
||||
// Match pattern like: 12345: (255,128,64) #FF8040 srgb(255,128,64)
|
||||
const hexMatch = line.match(/#([0-9A-Fa-f]{6})/);
|
||||
const countMatch = line.match(/^\s*(\d+):/);
|
||||
|
||||
if (hexMatch) {
|
||||
colors.push({
|
||||
hex: "#" + hexMatch[1].toUpperCase(),
|
||||
count: countMatch ? parseInt(countMatch[1]) : 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by count (most common first)
|
||||
colors.sort((a, b) => b.count - a.count);
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display brand palette
|
||||
*/
|
||||
function displayPalette(palette) {
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("BRAND COLOR PALETTE");
|
||||
console.log("=".repeat(50));
|
||||
|
||||
if (palette.primary.length > 0) {
|
||||
console.log("\nPrimary Colors:");
|
||||
palette.primary.forEach((c) => console.log(` ${c}`));
|
||||
}
|
||||
|
||||
if (palette.secondary.length > 0) {
|
||||
console.log("\nSecondary Colors:");
|
||||
palette.secondary.forEach((c) => console.log(` ${c}`));
|
||||
}
|
||||
|
||||
if (palette.neutral.length > 0) {
|
||||
console.log("\nNeutral Colors:");
|
||||
palette.neutral.forEach((c) => console.log(` ${c}`));
|
||||
}
|
||||
|
||||
if (palette.semantic.length > 0) {
|
||||
console.log("\nSemantic Colors:");
|
||||
palette.semantic.forEach((c) => console.log(` ${c}`));
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log(`Total: ${palette.all.length} colors in brand palette`);
|
||||
console.log("=".repeat(50) + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonOutput = args.includes("--json");
|
||||
const showPalette = args.includes("--palette");
|
||||
const brandFileIdx = args.indexOf("--brand-file");
|
||||
const brandFile =
|
||||
brandFileIdx !== -1 ? args[brandFileIdx + 1] : DEFAULT_GUIDELINES_PATH;
|
||||
const brandFileValue = brandFileIdx !== -1 ? args[brandFileIdx + 1] : null;
|
||||
const imagePath = args.find(
|
||||
(a) => !a.startsWith("--") && a !== brandFileValue
|
||||
);
|
||||
|
||||
// Load brand palette
|
||||
const brandPalette = parseBrandColors(brandFile);
|
||||
|
||||
if (!brandPalette) {
|
||||
console.error(`Brand guidelines not found at: ${brandFile}`);
|
||||
console.error(`Create brand guidelines or specify path with --brand-file`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show palette mode
|
||||
if (showPalette || !imagePath) {
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(brandPalette, null, 2));
|
||||
} else {
|
||||
displayPalette(brandPalette);
|
||||
|
||||
if (!imagePath) {
|
||||
console.log("To extract colors from an image:");
|
||||
console.log(" node extract-colors.cjs <image-path>");
|
||||
console.log("\nOr use ImageMagick directly:");
|
||||
console.log(' magick image.png -colors 10 -depth 8 -format "%c" histogram:info:');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve image path
|
||||
const resolvedPath = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.join(process.cwd(), imagePath);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
console.error(`Image not found: ${resolvedPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate extraction instructions
|
||||
const result = {
|
||||
image: resolvedPath,
|
||||
brandPalette: brandPalette,
|
||||
extractionCommand: generateImageMagickCommand(resolvedPath),
|
||||
instructions: [
|
||||
"1. Run the ImageMagick command to extract colors:",
|
||||
` ${generateImageMagickCommand(resolvedPath)}`,
|
||||
"",
|
||||
"2. Or use the ai-multimodal skill:",
|
||||
` python .claude/skills/ai-multimodal/scripts/gemini_batch_process.py \\`,
|
||||
` --files "${resolvedPath}" \\`,
|
||||
` --task analyze \\`,
|
||||
` --prompt "Extract the 10 most dominant colors as hex values"`,
|
||||
"",
|
||||
"3. Then compare extracted colors against brand palette",
|
||||
],
|
||||
complianceCheck: {
|
||||
threshold: 50,
|
||||
description:
|
||||
"Colors within distance 50 (RGB space) are considered brand-compliant",
|
||||
brandColors: brandPalette.all,
|
||||
},
|
||||
};
|
||||
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("COLOR EXTRACTION HELPER");
|
||||
console.log("=".repeat(60));
|
||||
console.log(`\nImage: ${result.image}`);
|
||||
console.log(`\nBrand Colors: ${brandPalette.all.length} colors loaded`);
|
||||
console.log("\nTo extract colors from this image:\n");
|
||||
result.instructions.forEach((line) => console.log(line));
|
||||
console.log("\n" + "=".repeat(60));
|
||||
|
||||
// Show brand palette for reference
|
||||
console.log("\nBrand Palette Reference:");
|
||||
console.log(` Primary: ${brandPalette.primary.join(", ") || "none"}`);
|
||||
console.log(` Secondary: ${brandPalette.secondary.join(", ") || "none"}`);
|
||||
console.log(` Neutral: ${brandPalette.neutral.join(", ") || "none"}`);
|
||||
console.log("=".repeat(60) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions for use as module
|
||||
module.exports = {
|
||||
parseBrandColors,
|
||||
hexToRgb,
|
||||
rgbToHex,
|
||||
colorDistance,
|
||||
findNearestBrandColor,
|
||||
calculateCompliance,
|
||||
parseImageMagickOutput,
|
||||
};
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
349
skills/brand/scripts/inject-brand-context.cjs
Executable file
349
skills/brand/scripts/inject-brand-context.cjs
Executable file
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* inject-brand-context.cjs
|
||||
*
|
||||
* Extracts brand context from markdown brand guidelines
|
||||
* and outputs a formatted system prompt addition.
|
||||
*
|
||||
* Usage:
|
||||
* node inject-brand-context.cjs [path-to-guidelines]
|
||||
* node inject-brand-context.cjs --json [path-to-guidelines]
|
||||
*
|
||||
* Default path: docs/brand-guidelines.md
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Default brand guidelines path
|
||||
const DEFAULT_GUIDELINES_PATH = "docs/brand-guidelines.md";
|
||||
|
||||
/**
|
||||
* Extract hex colors from text
|
||||
*/
|
||||
function extractHexColors(text) {
|
||||
const hexPattern = /#[0-9A-Fa-f]{6}\b/g;
|
||||
return [...new Set(text.match(hexPattern) || [])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract color data from markdown table
|
||||
*/
|
||||
function extractColorsFromTable(content) {
|
||||
const colors = {
|
||||
primary: [],
|
||||
secondary: [],
|
||||
neutral: [],
|
||||
semantic: [],
|
||||
};
|
||||
|
||||
// Find color tables
|
||||
const primaryMatch = content.match(
|
||||
/### Primary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i
|
||||
);
|
||||
const secondaryMatch = content.match(
|
||||
/### Secondary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i
|
||||
);
|
||||
const neutralMatch = content.match(
|
||||
/### Neutral[\s\S]*?\|[\s\S]*?(?=###|$)/i
|
||||
);
|
||||
const semanticMatch = content.match(
|
||||
/### Semantic[\s\S]*?\|[\s\S]*?(?=###|$)/i
|
||||
);
|
||||
|
||||
if (primaryMatch) colors.primary = extractHexColors(primaryMatch[0]);
|
||||
if (secondaryMatch) colors.secondary = extractHexColors(secondaryMatch[0]);
|
||||
if (neutralMatch) colors.neutral = extractHexColors(neutralMatch[0]);
|
||||
if (semanticMatch) colors.semantic = extractHexColors(semanticMatch[0]);
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract typography info
|
||||
*/
|
||||
function extractTypography(content) {
|
||||
const typography = {
|
||||
heading: null,
|
||||
body: null,
|
||||
mono: null,
|
||||
};
|
||||
|
||||
// Look for font definitions
|
||||
const headingMatch = content.match(/--font-heading:\s*['"]([^'"]+)['"]/);
|
||||
const bodyMatch = content.match(/--font-body:\s*['"]([^'"]+)['"]/);
|
||||
const monoMatch = content.match(/--font-mono:\s*['"]([^'"]+)['"]/);
|
||||
|
||||
// Fallback: look in tables
|
||||
const fontStackMatch = content.match(/### Font Stack[\s\S]*?(?=###|##|$)/i);
|
||||
if (fontStackMatch) {
|
||||
const stackText = fontStackMatch[0];
|
||||
const headingAlt = stackText.match(/heading[^']*['"]([^'"]+)['"]/i);
|
||||
const bodyAlt = stackText.match(/body[^']*['"]([^'"]+)['"]/i);
|
||||
|
||||
if (headingAlt) typography.heading = headingAlt[1];
|
||||
if (bodyAlt) typography.body = bodyAlt[1];
|
||||
}
|
||||
|
||||
if (headingMatch) typography.heading = headingMatch[1];
|
||||
if (bodyMatch) typography.body = bodyMatch[1];
|
||||
if (monoMatch) typography.mono = monoMatch[1];
|
||||
|
||||
return typography;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract voice/tone information
|
||||
*/
|
||||
function extractVoice(content) {
|
||||
const voice = {
|
||||
traits: [],
|
||||
prohibited: [],
|
||||
personality: "",
|
||||
};
|
||||
|
||||
// Extract personality traits from table
|
||||
const personalityMatch = content.match(
|
||||
/### Brand Personality[\s\S]*?\|[\s\S]*?(?=###|##|$)/i
|
||||
);
|
||||
if (personalityMatch) {
|
||||
const traits = personalityMatch[0].match(
|
||||
/\*\*([^*]+)\*\*\s*\|\s*([^|]+)/g
|
||||
);
|
||||
if (traits) {
|
||||
voice.traits = traits.map((t) => {
|
||||
const match = t.match(/\*\*([^*]+)\*\*/);
|
||||
return match ? match[1].trim() : "";
|
||||
}).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract prohibited terms
|
||||
const prohibitedMatch = content.match(
|
||||
/### Prohibited[\s\S]*?(?=###|##|$)/i
|
||||
);
|
||||
if (prohibitedMatch) {
|
||||
const terms = prohibitedMatch[0].match(/\|\s*([^|]+)\s*\|/g);
|
||||
if (terms) {
|
||||
voice.prohibited = terms
|
||||
.map((t) => t.replace(/\|/g, "").trim())
|
||||
.filter((t) => t && !t.includes("Avoid") && !t.includes("---"));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for Forbidden Phrases
|
||||
const forbiddenMatch = content.match(
|
||||
/### Forbidden Phrases[\s\S]*?(?=###|##|$)/i
|
||||
);
|
||||
if (forbiddenMatch && voice.prohibited.length === 0) {
|
||||
const items = forbiddenMatch[0].match(/-\s*["']?([^"'\n(]+)/g);
|
||||
if (items) {
|
||||
voice.prohibited = items
|
||||
.map((item) => item.replace(/^-\s*["']?/, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
voice.personality = voice.traits.join(", ");
|
||||
|
||||
return voice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract core attributes
|
||||
*/
|
||||
function extractCoreAttributes(content) {
|
||||
const attributes = [];
|
||||
|
||||
const attributesMatch = content.match(
|
||||
/### Core Attributes[\s\S]*?\|[\s\S]*?(?=###|##|$)/i
|
||||
);
|
||||
if (attributesMatch) {
|
||||
const rows = attributesMatch[0].match(
|
||||
/\|\s*\*\*([^*]+)\*\*\s*\|\s*([^|]+)\|/g
|
||||
);
|
||||
if (rows) {
|
||||
rows.forEach((row) => {
|
||||
const match = row.match(/\*\*([^*]+)\*\*\s*\|\s*([^|]+)/);
|
||||
if (match) {
|
||||
attributes.push({
|
||||
name: match[1].trim(),
|
||||
description: match[2].trim(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract AI image generation context
|
||||
*/
|
||||
function extractImageStyle(content) {
|
||||
const imageStyle = {
|
||||
basePrompt: "",
|
||||
keywords: [],
|
||||
mood: [],
|
||||
donts: [],
|
||||
examplePrompts: [],
|
||||
};
|
||||
|
||||
// Extract base prompt template (content between ``` blocks after "Base Prompt Template")
|
||||
const basePromptMatch = content.match(
|
||||
/### Base Prompt Template[\s\S]*?```\n?([\s\S]*?)```/i
|
||||
);
|
||||
if (basePromptMatch) {
|
||||
imageStyle.basePrompt = basePromptMatch[1].trim().replace(/\n/g, " ");
|
||||
}
|
||||
|
||||
// Extract style keywords from table
|
||||
const keywordsMatch = content.match(
|
||||
/### Style Keywords[\s\S]*?\|[\s\S]*?(?=###|##|$)/i
|
||||
);
|
||||
if (keywordsMatch) {
|
||||
const keywordRows = keywordsMatch[0].match(/\|\s*\*\*[^*]+\*\*\s*\|\s*([^|]+)\|/g);
|
||||
if (keywordRows) {
|
||||
keywordRows.forEach((row) => {
|
||||
const match = row.match(/\|\s*\*\*[^*]+\*\*\s*\|\s*([^|]+)\|/);
|
||||
if (match) {
|
||||
const keywords = match[1].split(",").map((k) => k.trim()).filter(Boolean);
|
||||
imageStyle.keywords.push(...keywords);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract visual mood descriptors (bullet points)
|
||||
const moodMatch = content.match(
|
||||
/### Visual Mood Descriptors[\s\S]*?(?=###|##|$)/i
|
||||
);
|
||||
if (moodMatch) {
|
||||
const moodItems = moodMatch[0].match(/-\s*([^\n]+)/g);
|
||||
if (moodItems) {
|
||||
imageStyle.mood = moodItems.map((item) => item.replace(/^-\s*/, "").trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract visual don'ts from table
|
||||
const dontsMatch = content.match(
|
||||
/### Visual Don'ts[\s\S]*?\|[\s\S]*?(?=###|##|$)/i
|
||||
);
|
||||
if (dontsMatch) {
|
||||
const dontRows = dontsMatch[0].match(/\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|/g);
|
||||
if (dontRows) {
|
||||
dontRows.forEach((row) => {
|
||||
const match = row.match(/\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|/);
|
||||
if (match && !match[1].includes("Avoid") && !match[1].includes("---")) {
|
||||
imageStyle.donts.push(match[1].trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract example prompts (content between ``` blocks after specific headers)
|
||||
const exampleMatch = content.match(/### Example Prompts[\s\S]*?(?=##|$)/i);
|
||||
if (exampleMatch) {
|
||||
const prompts = exampleMatch[0].match(/\*\*([^*]+)\*\*:\s*```\n?([\s\S]*?)```/g);
|
||||
if (prompts) {
|
||||
prompts.forEach((p) => {
|
||||
const match = p.match(/\*\*([^*]+)\*\*:\s*```\n?([\s\S]*?)```/);
|
||||
if (match) {
|
||||
imageStyle.examplePrompts.push({
|
||||
type: match[1].trim(),
|
||||
prompt: match[2].trim().replace(/\n/g, " "),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return imageStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate system prompt addition
|
||||
*/
|
||||
function generatePromptAddition(brandContext) {
|
||||
const { colors, typography, voice, attributes, imageStyle } = brandContext;
|
||||
|
||||
let prompt = `
|
||||
BRAND CONTEXT:
|
||||
==============
|
||||
|
||||
VISUAL IDENTITY:
|
||||
- Primary Colors: ${colors.primary.join(", ") || "Not specified"}
|
||||
- Secondary Colors: ${colors.secondary.join(", ") || "Not specified"}
|
||||
- Typography: ${typography.heading || typography.body || "System fonts"}
|
||||
|
||||
BRAND VOICE:
|
||||
- Personality: ${voice.personality || "Professional"}
|
||||
- Core Attributes: ${attributes.map((a) => a.name).join(", ") || "Not specified"}
|
||||
|
||||
CONTENT RULES:
|
||||
- Prohibited Terms: ${voice.prohibited.join(", ") || "None specified"}
|
||||
`;
|
||||
|
||||
// Add image style context if available
|
||||
if (imageStyle && imageStyle.basePrompt) {
|
||||
prompt += `
|
||||
IMAGE GENERATION:
|
||||
- Base Prompt: ${imageStyle.basePrompt}
|
||||
- Style Keywords: ${imageStyle.keywords.slice(0, 10).join(", ") || "Not specified"}
|
||||
- Visual Mood: ${imageStyle.mood.slice(0, 5).join("; ") || "Not specified"}
|
||||
- Avoid: ${imageStyle.donts.join(", ") || "None specified"}
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `
|
||||
Apply these brand guidelines to all generated content.
|
||||
Maintain consistent voice, colors, and messaging.
|
||||
`;
|
||||
|
||||
return prompt.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonOutput = args.includes("--json");
|
||||
const guidelinesPath = args.find((a) => !a.startsWith("--")) || DEFAULT_GUIDELINES_PATH;
|
||||
|
||||
// Resolve path
|
||||
const resolvedPath = path.isAbsolute(guidelinesPath)
|
||||
? guidelinesPath
|
||||
: path.join(process.cwd(), guidelinesPath);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
console.error(`Error: Brand guidelines not found at ${resolvedPath}`);
|
||||
console.error(`Create brand guidelines at ${DEFAULT_GUIDELINES_PATH} or specify a path.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read file
|
||||
const content = fs.readFileSync(resolvedPath, "utf-8");
|
||||
|
||||
// Extract brand context
|
||||
const brandContext = {
|
||||
colors: extractColorsFromTable(content),
|
||||
typography: extractTypography(content),
|
||||
voice: extractVoice(content),
|
||||
attributes: extractCoreAttributes(content),
|
||||
imageStyle: extractImageStyle(content),
|
||||
source: resolvedPath,
|
||||
extractedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Output
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(brandContext, null, 2));
|
||||
} else {
|
||||
console.log(generatePromptAddition(brandContext));
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
266
skills/brand/scripts/sync-brand-to-tokens.cjs
Normal file
266
skills/brand/scripts/sync-brand-to-tokens.cjs
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* sync-brand-to-tokens.cjs
|
||||
*
|
||||
* Syncs brand-guidelines.md colors → design-tokens.json → design-tokens.css
|
||||
*
|
||||
* Usage:
|
||||
* node sync-brand-to-tokens.cjs
|
||||
* node sync-brand-to-tokens.cjs --dry-run
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Paths
|
||||
const BRAND_GUIDELINES = 'docs/brand-guidelines.md';
|
||||
const DESIGN_TOKENS_JSON = 'assets/design-tokens.json';
|
||||
const DESIGN_TOKENS_CSS = 'assets/design-tokens.css';
|
||||
const GENERATE_TOKENS_SCRIPT = '.claude/skills/design-system/scripts/generate-tokens.cjs';
|
||||
|
||||
/**
|
||||
* Extract color info from brand guidelines markdown
|
||||
*/
|
||||
function extractColorsFromMarkdown(content) {
|
||||
const colors = {
|
||||
primary: { name: 'primary', shades: {} },
|
||||
secondary: { name: 'secondary', shades: {} },
|
||||
accent: { name: 'accent', shades: {} }
|
||||
};
|
||||
|
||||
// Extract primary color name and hex from Quick Reference table
|
||||
const quickRefMatch = content.match(/Primary Color\s*\|\s*#([A-Fa-f0-9]{6})\s*\(([^)]+)\)/);
|
||||
if (quickRefMatch) {
|
||||
colors.primary.name = quickRefMatch[2].toLowerCase().replace(/\s+/g, '-');
|
||||
colors.primary.base = `#${quickRefMatch[1]}`;
|
||||
}
|
||||
|
||||
const secondaryMatch = content.match(/Secondary Color\s*\|\s*#([A-Fa-f0-9]{6})\s*\(([^)]+)\)/);
|
||||
if (secondaryMatch) {
|
||||
colors.secondary.name = secondaryMatch[2].toLowerCase().replace(/\s+/g, '-');
|
||||
colors.secondary.base = `#${secondaryMatch[1]}`;
|
||||
}
|
||||
|
||||
const accentMatch = content.match(/Accent Color\s*\|\s*#([A-Fa-f0-9]{6})\s*\(([^)]+)\)/);
|
||||
if (accentMatch) {
|
||||
colors.accent.name = accentMatch[2].toLowerCase().replace(/\s+/g, '-');
|
||||
colors.accent.base = `#${accentMatch[1]}`;
|
||||
}
|
||||
|
||||
// Extract all shades from Primary Colors table
|
||||
const primarySection = content.match(/### Primary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i);
|
||||
if (primarySection) {
|
||||
const hexMatches = primarySection[0].matchAll(/\*\*([^*]+)\*\*\s*\|\s*#([A-Fa-f0-9]{6})/g);
|
||||
for (const match of hexMatches) {
|
||||
const name = match[1].trim().toLowerCase();
|
||||
const hex = `#${match[2]}`;
|
||||
if (name.includes('dark')) colors.primary.dark = hex;
|
||||
else if (name.includes('light')) colors.primary.light = hex;
|
||||
else colors.primary.base = hex;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract secondary shades
|
||||
const secondarySection = content.match(/### Secondary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i);
|
||||
if (secondarySection) {
|
||||
const hexMatches = secondarySection[0].matchAll(/\*\*([^*]+)\*\*\s*\|\s*#([A-Fa-f0-9]{6})/g);
|
||||
for (const match of hexMatches) {
|
||||
const name = match[1].trim().toLowerCase();
|
||||
const hex = `#${match[2]}`;
|
||||
if (name.includes('dark')) colors.secondary.dark = hex;
|
||||
else if (name.includes('light')) colors.secondary.light = hex;
|
||||
else colors.secondary.base = hex;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract accent shades
|
||||
const accentSection = content.match(/### Accent Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i);
|
||||
if (accentSection) {
|
||||
const hexMatches = accentSection[0].matchAll(/\*\*([^*]+)\*\*\s*\|\s*#([A-Fa-f0-9]{6})/g);
|
||||
for (const match of hexMatches) {
|
||||
const name = match[1].trim().toLowerCase();
|
||||
const hex = `#${match[2]}`;
|
||||
if (name.includes('dark')) colors.accent.dark = hex;
|
||||
else if (name.includes('light')) colors.accent.light = hex;
|
||||
else colors.accent.base = hex;
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate color scale from base color (simple approach)
|
||||
*/
|
||||
function generateColorScale(baseHex, darkHex, lightHex) {
|
||||
// Use provided shades or generate approximations
|
||||
return {
|
||||
"50": { "$value": lightHex || adjustBrightness(baseHex, 0.9), "$type": "color" },
|
||||
"100": { "$value": lightHex || adjustBrightness(baseHex, 0.8), "$type": "color" },
|
||||
"200": { "$value": adjustBrightness(baseHex, 0.6), "$type": "color" },
|
||||
"300": { "$value": adjustBrightness(baseHex, 0.4), "$type": "color" },
|
||||
"400": { "$value": adjustBrightness(baseHex, 0.2), "$type": "color" },
|
||||
"500": { "$value": baseHex, "$type": "color" },
|
||||
"600": { "$value": darkHex || adjustBrightness(baseHex, -0.15), "$type": "color" },
|
||||
"700": { "$value": adjustBrightness(baseHex, -0.3), "$type": "color" },
|
||||
"800": { "$value": adjustBrightness(baseHex, -0.45), "$type": "color" },
|
||||
"900": { "$value": adjustBrightness(baseHex, -0.6), "$type": "color" }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust hex color brightness
|
||||
*/
|
||||
function adjustBrightness(hex, percent) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = Math.min(255, Math.max(0, (num >> 16) + Math.round(255 * percent)));
|
||||
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + Math.round(255 * percent)));
|
||||
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + Math.round(255 * percent)));
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0').toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update design tokens JSON
|
||||
*/
|
||||
function updateDesignTokens(tokens, colors) {
|
||||
// Update brand name
|
||||
const brandName = `ClaudeKit Marketing - ${colors.primary.name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}`;
|
||||
tokens.brand = brandName;
|
||||
|
||||
// Update primitive colors with new names
|
||||
const primitiveColors = tokens.primitive?.color || {};
|
||||
|
||||
// Remove old color keys, add new ones
|
||||
delete primitiveColors.coral;
|
||||
delete primitiveColors.purple;
|
||||
delete primitiveColors.mint;
|
||||
|
||||
// Add new named colors
|
||||
primitiveColors[colors.primary.name] = generateColorScale(
|
||||
colors.primary.base,
|
||||
colors.primary.dark,
|
||||
colors.primary.light
|
||||
);
|
||||
primitiveColors[colors.secondary.name] = generateColorScale(
|
||||
colors.secondary.base,
|
||||
colors.secondary.dark,
|
||||
colors.secondary.light
|
||||
);
|
||||
primitiveColors[colors.accent.name] = generateColorScale(
|
||||
colors.accent.base,
|
||||
colors.accent.dark,
|
||||
colors.accent.light
|
||||
);
|
||||
|
||||
tokens.primitive.color = primitiveColors;
|
||||
|
||||
// Update ALL semantic color references
|
||||
if (tokens.semantic?.color) {
|
||||
const sem = tokens.semantic.color;
|
||||
const p = colors.primary.name;
|
||||
const s = colors.secondary.name;
|
||||
const a = colors.accent.name;
|
||||
|
||||
// Primary variants
|
||||
sem.primary = { "$value": `{primitive.color.${p}.500}`, "$type": "color" };
|
||||
sem['primary-hover'] = { "$value": `{primitive.color.${p}.600}`, "$type": "color" };
|
||||
sem['primary-active'] = { "$value": `{primitive.color.${p}.700}`, "$type": "color" };
|
||||
sem['primary-light'] = { "$value": `{primitive.color.${p}.400}`, "$type": "color" };
|
||||
sem['primary-lighter'] = { "$value": `{primitive.color.${p}.100}`, "$type": "color" };
|
||||
sem['primary-dark'] = { "$value": `{primitive.color.${p}.600}`, "$type": "color" };
|
||||
|
||||
// Secondary variants
|
||||
sem.secondary = { "$value": `{primitive.color.${s}.500}`, "$type": "color" };
|
||||
sem['secondary-hover'] = { "$value": `{primitive.color.${s}.600}`, "$type": "color" };
|
||||
sem['secondary-light'] = { "$value": `{primitive.color.${s}.300}`, "$type": "color" };
|
||||
sem['secondary-dark'] = { "$value": `{primitive.color.${s}.600}`, "$type": "color" };
|
||||
|
||||
// Accent variants
|
||||
sem.accent = { "$value": `{primitive.color.${a}.500}`, "$type": "color" };
|
||||
sem['accent-hover'] = { "$value": `{primitive.color.${a}.600}`, "$type": "color" };
|
||||
sem['accent-light'] = { "$value": `{primitive.color.${a}.300}`, "$type": "color" };
|
||||
|
||||
// Status colors (use accent for success, primary for error/info)
|
||||
sem.success = { "$value": `{primitive.color.${a}.500}`, "$type": "color" };
|
||||
sem['success-light'] = { "$value": `{primitive.color.${a}.300}`, "$type": "color" };
|
||||
sem.error = { "$value": `{primitive.color.${p}.500}`, "$type": "color" };
|
||||
sem['error-light'] = { "$value": `{primitive.color.${p}.300}`, "$type": "color" };
|
||||
sem.info = { "$value": `{primitive.color.${s}.500}`, "$type": "color" };
|
||||
sem['info-light'] = { "$value": `{primitive.color.${s}.300}`, "$type": "color" };
|
||||
}
|
||||
|
||||
// Update component references (button uses primary color with opacity)
|
||||
if (tokens.component?.button?.secondary) {
|
||||
const primaryBase = colors.primary.base;
|
||||
tokens.component.button.secondary['bg-hover'] = {
|
||||
"$value": `${primaryBase}1A`,
|
||||
"$type": "color"
|
||||
};
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main
|
||||
*/
|
||||
function main() {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
console.log('🔄 Syncing brand guidelines → design tokens\n');
|
||||
|
||||
// Read brand guidelines
|
||||
const guidelinesPath = path.resolve(process.cwd(), BRAND_GUIDELINES);
|
||||
if (!fs.existsSync(guidelinesPath)) {
|
||||
console.error(`❌ Brand guidelines not found: ${guidelinesPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const guidelinesContent = fs.readFileSync(guidelinesPath, 'utf-8');
|
||||
|
||||
// Extract colors
|
||||
const colors = extractColorsFromMarkdown(guidelinesContent);
|
||||
console.log('📊 Extracted colors:');
|
||||
console.log(` Primary: ${colors.primary.name} (${colors.primary.base})`);
|
||||
console.log(` Secondary: ${colors.secondary.name} (${colors.secondary.base})`);
|
||||
console.log(` Accent: ${colors.accent.name} (${colors.accent.base})\n`);
|
||||
|
||||
// Read existing tokens
|
||||
const tokensPath = path.resolve(process.cwd(), DESIGN_TOKENS_JSON);
|
||||
let tokens = {};
|
||||
if (fs.existsSync(tokensPath)) {
|
||||
tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
|
||||
}
|
||||
|
||||
// Update tokens
|
||||
tokens = updateDesignTokens(tokens, colors);
|
||||
|
||||
if (dryRun) {
|
||||
console.log('📋 Would update design-tokens.json:');
|
||||
console.log(JSON.stringify(tokens.primitive.color, null, 2).slice(0, 500) + '...');
|
||||
console.log('\n⏭️ Dry run - no files changed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Write updated tokens
|
||||
fs.writeFileSync(tokensPath, JSON.stringify(tokens, null, 2));
|
||||
console.log(`✅ Updated: ${DESIGN_TOKENS_JSON}`);
|
||||
|
||||
// Regenerate CSS
|
||||
const generateScript = path.resolve(process.cwd(), GENERATE_TOKENS_SCRIPT);
|
||||
if (fs.existsSync(generateScript)) {
|
||||
try {
|
||||
execSync(`node ${generateScript} --config ${DESIGN_TOKENS_JSON} -o ${DESIGN_TOKENS_CSS}`, {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
console.log(`✅ Regenerated: ${DESIGN_TOKENS_CSS}`);
|
||||
} catch (e) {
|
||||
console.error('⚠️ Failed to regenerate CSS:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✨ Brand sync complete!');
|
||||
}
|
||||
|
||||
main();
|
||||
387
skills/brand/scripts/validate-asset.cjs
Executable file
387
skills/brand/scripts/validate-asset.cjs
Executable file
@@ -0,0 +1,387 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user