mirror of
https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git
synced 2026-09-19 18:46:14 +00:00
fix: protect existing Tailwind and token configurations (#501)
Squash-merged by github-maintain cron after maintainer approval (review 2026-09-18) and follow-up gap-closing commit 83ad973. Closes #496.
This commit is contained in:
parent
15de38fb70
commit
de5f12b400
@ -53,6 +53,13 @@ node scripts/sync-brand-to-tokens.cjs
|
||||
node scripts/inject-brand-context.cjs --json | head -20
|
||||
```
|
||||
|
||||
The sync stops when it detects existing token files, `:root` custom properties
|
||||
or Tailwind v4 `@theme` variables in common CSS entry points and their local
|
||||
CSS imports, or Tailwind theme colors and presets. Review the reported source
|
||||
before proceeding. If the detected files are the managed
|
||||
`assets/design-tokens.*` outputs from an earlier sync and replacing them is
|
||||
intentional, re-run with `--force`.
|
||||
|
||||
**Files synced:**
|
||||
- `docs/brand-guidelines.md` → Source of truth
|
||||
- `assets/design-tokens.json` → Token definitions
|
||||
|
||||
@ -49,6 +49,17 @@ Run the sync script:
|
||||
node scripts/sync-brand-to-tokens.cjs
|
||||
```
|
||||
|
||||
If the script reports an existing token source, inspect the named file before
|
||||
continuing. Detection covers `:root` custom properties, Tailwind v4 `@theme`
|
||||
variables in common CSS entry points and their local imports, and Tailwind
|
||||
theme colors or presets. Do not create a parallel token system beside those
|
||||
sources. Use `--force` only when the detected source is the managed
|
||||
`assets/design-tokens.*` output from an earlier sync and replacing it is
|
||||
intentional:
|
||||
```bash
|
||||
node scripts/sync-brand-to-tokens.cjs --force
|
||||
```
|
||||
|
||||
This will:
|
||||
- Update `assets/design-tokens.json` with new color names and values
|
||||
- Regenerate `assets/design-tokens.css` with correct CSS variables
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
* Usage:
|
||||
* node sync-brand-to-tokens.cjs
|
||||
* node sync-brand-to-tokens.cjs --dry-run
|
||||
* node sync-brand-to-tokens.cjs --force
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@ -17,11 +18,97 @@ const { execFileSync } = require('child_process');
|
||||
const BRAND_GUIDELINES = 'docs/brand-guidelines.md';
|
||||
const DESIGN_TOKENS_JSON = 'assets/design-tokens.json';
|
||||
const DESIGN_TOKENS_CSS = 'assets/design-tokens.css';
|
||||
const CSS_TOKEN_SOURCES = [
|
||||
'src/index.css',
|
||||
'src/globals.css',
|
||||
'src/styles/globals.css',
|
||||
'src/styles/tokens.css',
|
||||
'src/app/globals.css',
|
||||
'app/globals.css',
|
||||
'styles/globals.css',
|
||||
'styles/tokens.css'
|
||||
];
|
||||
const TAILWIND_CONFIGS = [
|
||||
'tailwind.config.js',
|
||||
'tailwind.config.cjs',
|
||||
'tailwind.config.mjs',
|
||||
'tailwind.config.ts'
|
||||
];
|
||||
// Sibling sub-skill, resolved from this file's location so it works in every
|
||||
// install context (plugin cache, project or --global CLI install), not only
|
||||
// when the process runs from a project root that contains .claude/skills/.
|
||||
const GENERATE_TOKENS_SCRIPT = path.resolve(__dirname, '..', '..', 'design-system', 'scripts', 'generate-tokens.cjs');
|
||||
|
||||
/**
|
||||
* Find project files that already act as design-token sources.
|
||||
*/
|
||||
function findExistingTokenSources(projectRoot) {
|
||||
const sources = new Set();
|
||||
const addIfPresent = (relativePath) => {
|
||||
if (fs.existsSync(path.resolve(projectRoot, relativePath))) {
|
||||
sources.add(relativePath);
|
||||
}
|
||||
};
|
||||
|
||||
const scanCssSource = (absolutePath, visited = new Set()) => {
|
||||
const normalizedPath = path.resolve(absolutePath);
|
||||
const relativePath = path.relative(projectRoot, normalizedPath);
|
||||
if (
|
||||
visited.has(normalizedPath) ||
|
||||
relativePath.startsWith('..') ||
|
||||
path.isAbsolute(relativePath) ||
|
||||
!fs.existsSync(normalizedPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
visited.add(normalizedPath);
|
||||
const content = fs.readFileSync(normalizedPath, 'utf-8');
|
||||
const uncommented = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const hasRootTokens = /:root\b[^{}]*\{[^}]*--[A-Za-z0-9_-]+\s*:/.test(uncommented);
|
||||
const hasTailwindTheme = /@theme(?:\s+[A-Za-z-]+)?\s*\{[^}]*--[A-Za-z0-9_-]+\s*:/.test(uncommented);
|
||||
if (hasRootTokens || hasTailwindTheme) {
|
||||
sources.add(relativePath.split(path.sep).join('/'));
|
||||
}
|
||||
|
||||
const importPattern = /@import\s+(?:url\(\s*)?(['"])([^'"]+)\1\s*\)?[^;]*;/g;
|
||||
for (const match of uncommented.matchAll(importPattern)) {
|
||||
const importTarget = match[2].split(/[?#]/, 1)[0];
|
||||
let importedPath;
|
||||
if (importTarget.startsWith('.')) {
|
||||
importedPath = path.resolve(path.dirname(normalizedPath), importTarget);
|
||||
} else if (importTarget.startsWith('/')) {
|
||||
importedPath = path.resolve(projectRoot, `.${importTarget}`);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
scanCssSource(importedPath, visited);
|
||||
}
|
||||
};
|
||||
|
||||
addIfPresent(DESIGN_TOKENS_JSON);
|
||||
addIfPresent(DESIGN_TOKENS_CSS);
|
||||
|
||||
for (const relativePath of CSS_TOKEN_SOURCES) {
|
||||
const absolutePath = path.resolve(projectRoot, relativePath);
|
||||
scanCssSource(absolutePath);
|
||||
}
|
||||
|
||||
for (const relativePath of TAILWIND_CONFIGS) {
|
||||
const absolutePath = path.resolve(projectRoot, relativePath);
|
||||
if (!fs.existsSync(absolutePath)) continue;
|
||||
const content = fs.readFileSync(absolutePath, 'utf-8');
|
||||
const hasInlineColors = /\btheme\s*:\s*\{[\s\S]*?\bcolors\s*:/.test(content);
|
||||
const hasPreset = /\bpresets\s*:/.test(content);
|
||||
const hasThemeSpread = /\btheme\s*:\s*\{[\s\S]*?\.\.\.[A-Za-z_$]/.test(content);
|
||||
if (hasInlineColors || hasPreset || hasThemeSpread) {
|
||||
sources.add(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return [...sources];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract color info from brand guidelines markdown
|
||||
*/
|
||||
@ -211,17 +298,33 @@ function updateDesignTokens(tokens, colors) {
|
||||
*/
|
||||
function main() {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const force = process.argv.includes('--force');
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
console.log('🔄 Syncing brand guidelines → design tokens\n');
|
||||
|
||||
// Read brand guidelines
|
||||
const guidelinesPath = path.resolve(process.cwd(), BRAND_GUIDELINES);
|
||||
const guidelinesPath = path.resolve(projectRoot, BRAND_GUIDELINES);
|
||||
if (!fs.existsSync(guidelinesPath)) {
|
||||
console.error(`❌ Brand guidelines not found: ${guidelinesPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const guidelinesContent = fs.readFileSync(guidelinesPath, 'utf-8');
|
||||
|
||||
const existingSources = findExistingTokenSources(projectRoot);
|
||||
if (existingSources.length > 0 && !force) {
|
||||
const details = existingSources.map(source => ` - ${source}`).join('\n');
|
||||
const message =
|
||||
`Existing design-token source${existingSources.length === 1 ? '' : 's'} detected:\n${details}\n` +
|
||||
'Refusing to create or replace token files. Review the detected source and re-run with --force only if replacement is intentional.';
|
||||
if (dryRun) {
|
||||
console.warn(`⚠️ ${message}\n`);
|
||||
} else {
|
||||
console.error(`❌ ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract colors
|
||||
const colors = extractColorsFromMarkdown(guidelinesContent);
|
||||
console.log('📊 Extracted colors:');
|
||||
@ -230,7 +333,7 @@ function main() {
|
||||
console.log(` Accent: ${colors.accent.name} (${colors.accent.base})\n`);
|
||||
|
||||
// Read existing tokens
|
||||
const tokensPath = path.resolve(process.cwd(), DESIGN_TOKENS_JSON);
|
||||
const tokensPath = path.resolve(projectRoot, DESIGN_TOKENS_JSON);
|
||||
let tokens = {};
|
||||
if (fs.existsSync(tokensPath)) {
|
||||
tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
|
||||
@ -247,6 +350,7 @@ function main() {
|
||||
}
|
||||
|
||||
// Write updated tokens
|
||||
fs.mkdirSync(path.dirname(tokensPath), { recursive: true });
|
||||
fs.writeFileSync(tokensPath, JSON.stringify(tokens, null, 2));
|
||||
console.log(`✅ Updated: ${DESIGN_TOKENS_JSON}`);
|
||||
|
||||
|
||||
@ -24,12 +24,12 @@ TOKENS_STARTER = (
|
||||
)
|
||||
|
||||
|
||||
def _run(tmp_path: Path) -> subprocess.CompletedProcess:
|
||||
def _run(tmp_path: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node not available")
|
||||
return subprocess.run(
|
||||
[node, str(SCRIPT)],
|
||||
[node, str(SCRIPT), *args],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@ -50,7 +50,7 @@ def test_sync_parses_bundled_starter_template(tmp_path):
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
shutil.copy(TOKENS_STARTER, tmp_path / "assets" / "design-tokens.json")
|
||||
|
||||
result = _run(tmp_path)
|
||||
result = _run(tmp_path, "--force")
|
||||
|
||||
# Must not crash (the bug raised an unhandled TypeError).
|
||||
assert "TypeError" not in result.stderr, result.stderr
|
||||
@ -92,7 +92,7 @@ def test_dark_base_color_does_not_collapse_shades_to_black(tmp_path):
|
||||
"| Accent Color | #6B8F71 |\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
result = _run(tmp_path, "--force")
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
tokens = json.loads((tmp_path / "assets" / "design-tokens.json").read_text())
|
||||
@ -120,3 +120,198 @@ def test_reports_missing_guidelines_without_breaking_the_harness(tmp_path):
|
||||
assert result.returncode == 1
|
||||
assert result.stderr is not None
|
||||
assert "Brand guidelines not found" in result.stderr
|
||||
|
||||
|
||||
def test_creates_default_output_directory_when_missing(tmp_path):
|
||||
"""A first sync should create assets/ instead of failing with ENOENT."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
assert (tmp_path / "assets" / "design-tokens.css").exists()
|
||||
|
||||
|
||||
def test_refuses_existing_design_tokens_without_force(tmp_path):
|
||||
"""The script must not silently replace its own existing token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "assets").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
tokens_path = tmp_path / "assets" / "design-tokens.json"
|
||||
existing = '{"existing": true}\n'
|
||||
tokens_path.write_text(existing)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "assets/design-tokens.json" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert tokens_path.read_text() == existing
|
||||
|
||||
|
||||
def test_refuses_css_custom_property_source_without_force(tmp_path):
|
||||
"""Common app CSS token sources must be named instead of duplicated."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
":root {\n --primary: #2563eb;\n --foreground: #0f172a;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "src/index.css" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_grouped_root_selector_without_force(tmp_path):
|
||||
"""A :root selector list is still an existing project token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
':root, [data-theme="light"] {\n --primary: #2563eb;\n}\n'
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "src/index.css" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_ignores_commented_root_custom_properties(tmp_path):
|
||||
"""Commented examples must not block a first token sync."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
"/* Example only:\n:root {\n --primary: #2563eb;\n}\n*/\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_ignores_custom_properties_outside_root(tmp_path):
|
||||
"""Component-local variables alone are not a project token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
":root {\n color-scheme: light;\n}\n\n"
|
||||
".progress {\n --progress-value: 50%;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_tailwind_theme_colors_without_force(tmp_path):
|
||||
"""Tailwind theme colors are an existing project token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "assets").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "tailwind.config.js").write_text(
|
||||
"module.exports = { theme: { extend: { colors: { brand: '#2563eb' } } } }\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "tailwind.config.js" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_tailwind_v4_theme_source_without_force(tmp_path):
|
||||
"""Tailwind v4 @theme variables are an existing project token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
"@theme {\n --color-brand-500: #2563eb;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "src/index.css" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_token_source_imported_by_common_css_entry(tmp_path):
|
||||
"""Local CSS imports must be followed to their actual token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src" / "styles").mkdir(parents=True)
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
'@import "./styles/theme.css";\n'
|
||||
)
|
||||
(tmp_path / "src" / "styles" / "theme.css").write_text(
|
||||
"@theme {\n --color-brand-500: #2563eb;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "src/styles/theme.css" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_ignores_external_css_imports(tmp_path):
|
||||
"""Remote and package imports are not project-owned token sources."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
'@import "https://example.com/theme.css";\n'
|
||||
'@import "tailwindcss";\n'
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_tailwind_config_with_sibling_preset_without_force(tmp_path):
|
||||
"""A delegated Tailwind theme must not be treated as token-free."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "tailwind.config.js").write_text(
|
||||
"const preset = require('./tailwind.preset');\n"
|
||||
"module.exports = { presets: [preset] };\n"
|
||||
)
|
||||
(tmp_path / "tailwind.preset.js").write_text(
|
||||
"module.exports = { theme: { colors: { brand: '#2563eb' } } };\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "tailwind.config.js" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_force_allows_sync_with_existing_css_token_source(tmp_path):
|
||||
"""The explicit force flag overrides token-source detection."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
":root {\n --primary: #2563eb;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path, "--force")
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
@ -228,6 +228,14 @@ Generate tailwind.config.js with custom theme:
|
||||
python scripts/tailwind_config_gen.py --colors brand:blue --fonts display:Inter
|
||||
```
|
||||
|
||||
The generator refuses to create or replace a config when any sibling
|
||||
`tailwind.config.js`, `.cjs`, `.mjs`, or `.ts` file already exists. Review the
|
||||
reported config first, then pass `--force` only when the competing output is
|
||||
intentional:
|
||||
```bash
|
||||
python scripts/tailwind_config_gen.py --colors brand:blue --force
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Component Composition**: Build complex UIs from simple, composable primitives
|
||||
|
||||
@ -17,6 +17,12 @@ from typing import Any, Dict, List, Optional
|
||||
# optional subpath. Only allows alphanumeric, hyphens, dots, underscores,
|
||||
# and forward slashes — no quotes, parens, or semicolons.
|
||||
_VALID_PLUGIN_NAME = re.compile(r'^(@[a-zA-Z0-9_-]+/)?[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*$')
|
||||
_TAILWIND_CONFIG_NAMES = (
|
||||
"tailwind.config.js",
|
||||
"tailwind.config.cjs",
|
||||
"tailwind.config.mjs",
|
||||
"tailwind.config.ts",
|
||||
)
|
||||
|
||||
|
||||
class TailwindConfigGenerator:
|
||||
@ -27,6 +33,7 @@ class TailwindConfigGenerator:
|
||||
typescript: bool = True,
|
||||
framework: str = "react",
|
||||
output_path: Optional[Path] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize generator.
|
||||
@ -35,10 +42,12 @@ class TailwindConfigGenerator:
|
||||
typescript: If True, generate .ts config, else .js
|
||||
framework: Framework name (react, vue, svelte, nextjs)
|
||||
output_path: Output file path (default: auto-detect)
|
||||
force: If True, allow replacing an existing output file
|
||||
"""
|
||||
self.typescript = typescript
|
||||
self.framework = framework
|
||||
self.output_path = output_path or self._default_output_path()
|
||||
self.force = force
|
||||
self.config: Dict[str, Any] = self._base_config()
|
||||
|
||||
def _default_output_path(self) -> Path:
|
||||
@ -272,6 +281,25 @@ module.exports = {{
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
existing_paths = []
|
||||
if self.output_path.name in _TAILWIND_CONFIG_NAMES:
|
||||
existing_paths = [
|
||||
self.output_path.parent / name
|
||||
for name in _TAILWIND_CONFIG_NAMES
|
||||
if (self.output_path.parent / name).exists()
|
||||
]
|
||||
elif self.output_path.exists():
|
||||
existing_paths = [self.output_path]
|
||||
|
||||
if existing_paths and not self.force:
|
||||
existing = ", ".join(str(path) for path in existing_paths)
|
||||
return (
|
||||
False,
|
||||
f"Tailwind configuration already exists: {existing}. "
|
||||
"Refusing to create or overwrite a competing config; "
|
||||
"re-run with --force only if this is intentional.",
|
||||
)
|
||||
|
||||
config_content = self.generate_config_string()
|
||||
|
||||
self.output_path.write_text(config_content)
|
||||
@ -382,6 +410,12 @@ Examples:
|
||||
help="Validate config without writing file",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite an existing output file",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Initialize generator
|
||||
@ -389,6 +423,7 @@ Examples:
|
||||
typescript=not args.js,
|
||||
framework=args.framework,
|
||||
output_path=args.output,
|
||||
force=args.force,
|
||||
)
|
||||
|
||||
# Add custom colors
|
||||
@ -465,7 +500,7 @@ Examples:
|
||||
|
||||
# Write config
|
||||
success, message = generator.write_config()
|
||||
print(message)
|
||||
print(message, file=sys.stdout if success else sys.stderr)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
|
||||
@ -278,6 +278,120 @@ class TestTailwindConfigGenerator:
|
||||
assert "import type { Config }" in content
|
||||
assert "brand" in content
|
||||
|
||||
def test_write_config_refuses_to_overwrite_existing_file(self, tmp_path):
|
||||
"""Existing project configuration must be preserved by default."""
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
existing = "// existing project config\nexport default { theme: {} }\n"
|
||||
output_path.write_text(existing)
|
||||
generator = TailwindConfigGenerator(output_path=output_path)
|
||||
|
||||
success, message = generator.write_config()
|
||||
|
||||
assert success is False
|
||||
assert "already exists" in message
|
||||
assert "--force" in message
|
||||
assert output_path.read_text() == existing
|
||||
|
||||
def test_write_config_force_overwrites_existing_file(self, tmp_path):
|
||||
"""An explicit force opt-in permits replacing an existing config."""
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
output_path.write_text("// existing project config\n")
|
||||
generator = TailwindConfigGenerator(output_path=output_path, force=True)
|
||||
generator.add_colors({"brand": "#3b82f6"})
|
||||
|
||||
success, message = generator.write_config()
|
||||
|
||||
assert success is True
|
||||
assert "written to" in message
|
||||
assert "brand" in output_path.read_text()
|
||||
|
||||
def test_cli_refuses_existing_config_without_force(self, tmp_path):
|
||||
"""The CLI must return non-zero and preserve an existing default target."""
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
existing = "// existing project config\n"
|
||||
output_path.write_text(existing)
|
||||
script = Path(__file__).parent.parent / "tailwind_config_gen.py"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--colors", "brand:#3b82f6"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "already exists" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert output_path.read_text() == existing
|
||||
|
||||
def test_cli_refuses_sibling_config_extension_without_force(self, tmp_path):
|
||||
"""A default .ts write must not create a second config beside .js."""
|
||||
existing_path = tmp_path / "tailwind.config.js"
|
||||
existing = "// existing JavaScript project config\nmodule.exports = {}\n"
|
||||
existing_path.write_text(existing)
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
script = Path(__file__).parent.parent / "tailwind_config_gen.py"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--colors", "brand:#3b82f6"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "tailwind.config.js" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert existing_path.read_text() == existing
|
||||
assert not output_path.exists()
|
||||
|
||||
def test_cli_force_allows_target_beside_sibling_config(self, tmp_path):
|
||||
"""The explicit force opt-in also overrides cross-extension detection."""
|
||||
existing_path = tmp_path / "tailwind.config.js"
|
||||
existing = "// existing JavaScript project config\nmodule.exports = {}\n"
|
||||
existing_path.write_text(existing)
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
script = Path(__file__).parent.parent / "tailwind_config_gen.py"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--colors",
|
||||
"brand:#3b82f6",
|
||||
"--force",
|
||||
],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert existing_path.read_text() == existing
|
||||
assert "brand" in output_path.read_text()
|
||||
|
||||
def test_cli_force_overwrites_existing_config(self, tmp_path):
|
||||
"""The CLI must wire --force through to the generator."""
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
output_path.write_text("// existing project config\n")
|
||||
script = Path(__file__).parent.parent / "tailwind_config_gen.py"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--colors",
|
||||
"brand:#3b82f6",
|
||||
"--force",
|
||||
],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "brand" in output_path.read_text()
|
||||
|
||||
def test_write_config_invalid_path(self):
|
||||
"""Test writing config to invalid path."""
|
||||
generator = TailwindConfigGenerator(output_path=Path("/invalid/path/config.ts"))
|
||||
|
||||
@ -53,6 +53,13 @@ node scripts/sync-brand-to-tokens.cjs
|
||||
node scripts/inject-brand-context.cjs --json | head -20
|
||||
```
|
||||
|
||||
The sync stops when it detects existing token files, `:root` custom properties
|
||||
or Tailwind v4 `@theme` variables in common CSS entry points and their local
|
||||
CSS imports, or Tailwind theme colors and presets. Review the reported source
|
||||
before proceeding. If the detected files are the managed
|
||||
`assets/design-tokens.*` outputs from an earlier sync and replacing them is
|
||||
intentional, re-run with `--force`.
|
||||
|
||||
**Files synced:**
|
||||
- `docs/brand-guidelines.md` → Source of truth
|
||||
- `assets/design-tokens.json` → Token definitions
|
||||
|
||||
@ -49,6 +49,17 @@ Run the sync script:
|
||||
node scripts/sync-brand-to-tokens.cjs
|
||||
```
|
||||
|
||||
If the script reports an existing token source, inspect the named file before
|
||||
continuing. Detection covers `:root` custom properties, Tailwind v4 `@theme`
|
||||
variables in common CSS entry points and their local imports, and Tailwind
|
||||
theme colors or presets. Do not create a parallel token system beside those
|
||||
sources. Use `--force` only when the detected source is the managed
|
||||
`assets/design-tokens.*` output from an earlier sync and replacing it is
|
||||
intentional:
|
||||
```bash
|
||||
node scripts/sync-brand-to-tokens.cjs --force
|
||||
```
|
||||
|
||||
This will:
|
||||
- Update `assets/design-tokens.json` with new color names and values
|
||||
- Regenerate `assets/design-tokens.css` with correct CSS variables
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
* Usage:
|
||||
* node sync-brand-to-tokens.cjs
|
||||
* node sync-brand-to-tokens.cjs --dry-run
|
||||
* node sync-brand-to-tokens.cjs --force
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@ -17,11 +18,97 @@ const { execFileSync } = require('child_process');
|
||||
const BRAND_GUIDELINES = 'docs/brand-guidelines.md';
|
||||
const DESIGN_TOKENS_JSON = 'assets/design-tokens.json';
|
||||
const DESIGN_TOKENS_CSS = 'assets/design-tokens.css';
|
||||
const CSS_TOKEN_SOURCES = [
|
||||
'src/index.css',
|
||||
'src/globals.css',
|
||||
'src/styles/globals.css',
|
||||
'src/styles/tokens.css',
|
||||
'src/app/globals.css',
|
||||
'app/globals.css',
|
||||
'styles/globals.css',
|
||||
'styles/tokens.css'
|
||||
];
|
||||
const TAILWIND_CONFIGS = [
|
||||
'tailwind.config.js',
|
||||
'tailwind.config.cjs',
|
||||
'tailwind.config.mjs',
|
||||
'tailwind.config.ts'
|
||||
];
|
||||
// Sibling sub-skill, resolved from this file's location so it works in every
|
||||
// install context (plugin cache, project or --global CLI install), not only
|
||||
// when the process runs from a project root that contains .claude/skills/.
|
||||
const GENERATE_TOKENS_SCRIPT = path.resolve(__dirname, '..', '..', 'design-system', 'scripts', 'generate-tokens.cjs');
|
||||
|
||||
/**
|
||||
* Find project files that already act as design-token sources.
|
||||
*/
|
||||
function findExistingTokenSources(projectRoot) {
|
||||
const sources = new Set();
|
||||
const addIfPresent = (relativePath) => {
|
||||
if (fs.existsSync(path.resolve(projectRoot, relativePath))) {
|
||||
sources.add(relativePath);
|
||||
}
|
||||
};
|
||||
|
||||
const scanCssSource = (absolutePath, visited = new Set()) => {
|
||||
const normalizedPath = path.resolve(absolutePath);
|
||||
const relativePath = path.relative(projectRoot, normalizedPath);
|
||||
if (
|
||||
visited.has(normalizedPath) ||
|
||||
relativePath.startsWith('..') ||
|
||||
path.isAbsolute(relativePath) ||
|
||||
!fs.existsSync(normalizedPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
visited.add(normalizedPath);
|
||||
const content = fs.readFileSync(normalizedPath, 'utf-8');
|
||||
const uncommented = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const hasRootTokens = /:root\b[^{}]*\{[^}]*--[A-Za-z0-9_-]+\s*:/.test(uncommented);
|
||||
const hasTailwindTheme = /@theme(?:\s+[A-Za-z-]+)?\s*\{[^}]*--[A-Za-z0-9_-]+\s*:/.test(uncommented);
|
||||
if (hasRootTokens || hasTailwindTheme) {
|
||||
sources.add(relativePath.split(path.sep).join('/'));
|
||||
}
|
||||
|
||||
const importPattern = /@import\s+(?:url\(\s*)?(['"])([^'"]+)\1\s*\)?[^;]*;/g;
|
||||
for (const match of uncommented.matchAll(importPattern)) {
|
||||
const importTarget = match[2].split(/[?#]/, 1)[0];
|
||||
let importedPath;
|
||||
if (importTarget.startsWith('.')) {
|
||||
importedPath = path.resolve(path.dirname(normalizedPath), importTarget);
|
||||
} else if (importTarget.startsWith('/')) {
|
||||
importedPath = path.resolve(projectRoot, `.${importTarget}`);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
scanCssSource(importedPath, visited);
|
||||
}
|
||||
};
|
||||
|
||||
addIfPresent(DESIGN_TOKENS_JSON);
|
||||
addIfPresent(DESIGN_TOKENS_CSS);
|
||||
|
||||
for (const relativePath of CSS_TOKEN_SOURCES) {
|
||||
const absolutePath = path.resolve(projectRoot, relativePath);
|
||||
scanCssSource(absolutePath);
|
||||
}
|
||||
|
||||
for (const relativePath of TAILWIND_CONFIGS) {
|
||||
const absolutePath = path.resolve(projectRoot, relativePath);
|
||||
if (!fs.existsSync(absolutePath)) continue;
|
||||
const content = fs.readFileSync(absolutePath, 'utf-8');
|
||||
const hasInlineColors = /\btheme\s*:\s*\{[\s\S]*?\bcolors\s*:/.test(content);
|
||||
const hasPreset = /\bpresets\s*:/.test(content);
|
||||
const hasThemeSpread = /\btheme\s*:\s*\{[\s\S]*?\.\.\.[A-Za-z_$]/.test(content);
|
||||
if (hasInlineColors || hasPreset || hasThemeSpread) {
|
||||
sources.add(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return [...sources];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract color info from brand guidelines markdown
|
||||
*/
|
||||
@ -211,17 +298,33 @@ function updateDesignTokens(tokens, colors) {
|
||||
*/
|
||||
function main() {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const force = process.argv.includes('--force');
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
console.log('🔄 Syncing brand guidelines → design tokens\n');
|
||||
|
||||
// Read brand guidelines
|
||||
const guidelinesPath = path.resolve(process.cwd(), BRAND_GUIDELINES);
|
||||
const guidelinesPath = path.resolve(projectRoot, BRAND_GUIDELINES);
|
||||
if (!fs.existsSync(guidelinesPath)) {
|
||||
console.error(`❌ Brand guidelines not found: ${guidelinesPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const guidelinesContent = fs.readFileSync(guidelinesPath, 'utf-8');
|
||||
|
||||
const existingSources = findExistingTokenSources(projectRoot);
|
||||
if (existingSources.length > 0 && !force) {
|
||||
const details = existingSources.map(source => ` - ${source}`).join('\n');
|
||||
const message =
|
||||
`Existing design-token source${existingSources.length === 1 ? '' : 's'} detected:\n${details}\n` +
|
||||
'Refusing to create or replace token files. Review the detected source and re-run with --force only if replacement is intentional.';
|
||||
if (dryRun) {
|
||||
console.warn(`⚠️ ${message}\n`);
|
||||
} else {
|
||||
console.error(`❌ ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract colors
|
||||
const colors = extractColorsFromMarkdown(guidelinesContent);
|
||||
console.log('📊 Extracted colors:');
|
||||
@ -230,7 +333,7 @@ function main() {
|
||||
console.log(` Accent: ${colors.accent.name} (${colors.accent.base})\n`);
|
||||
|
||||
// Read existing tokens
|
||||
const tokensPath = path.resolve(process.cwd(), DESIGN_TOKENS_JSON);
|
||||
const tokensPath = path.resolve(projectRoot, DESIGN_TOKENS_JSON);
|
||||
let tokens = {};
|
||||
if (fs.existsSync(tokensPath)) {
|
||||
tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
|
||||
@ -247,6 +350,7 @@ function main() {
|
||||
}
|
||||
|
||||
// Write updated tokens
|
||||
fs.mkdirSync(path.dirname(tokensPath), { recursive: true });
|
||||
fs.writeFileSync(tokensPath, JSON.stringify(tokens, null, 2));
|
||||
console.log(`✅ Updated: ${DESIGN_TOKENS_JSON}`);
|
||||
|
||||
|
||||
@ -24,12 +24,12 @@ TOKENS_STARTER = (
|
||||
)
|
||||
|
||||
|
||||
def _run(tmp_path: Path) -> subprocess.CompletedProcess:
|
||||
def _run(tmp_path: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node not available")
|
||||
return subprocess.run(
|
||||
[node, str(SCRIPT)],
|
||||
[node, str(SCRIPT), *args],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@ -50,7 +50,7 @@ def test_sync_parses_bundled_starter_template(tmp_path):
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
shutil.copy(TOKENS_STARTER, tmp_path / "assets" / "design-tokens.json")
|
||||
|
||||
result = _run(tmp_path)
|
||||
result = _run(tmp_path, "--force")
|
||||
|
||||
# Must not crash (the bug raised an unhandled TypeError).
|
||||
assert "TypeError" not in result.stderr, result.stderr
|
||||
@ -92,7 +92,7 @@ def test_dark_base_color_does_not_collapse_shades_to_black(tmp_path):
|
||||
"| Accent Color | #6B8F71 |\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
result = _run(tmp_path, "--force")
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
tokens = json.loads((tmp_path / "assets" / "design-tokens.json").read_text())
|
||||
@ -120,3 +120,198 @@ def test_reports_missing_guidelines_without_breaking_the_harness(tmp_path):
|
||||
assert result.returncode == 1
|
||||
assert result.stderr is not None
|
||||
assert "Brand guidelines not found" in result.stderr
|
||||
|
||||
|
||||
def test_creates_default_output_directory_when_missing(tmp_path):
|
||||
"""A first sync should create assets/ instead of failing with ENOENT."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
assert (tmp_path / "assets" / "design-tokens.css").exists()
|
||||
|
||||
|
||||
def test_refuses_existing_design_tokens_without_force(tmp_path):
|
||||
"""The script must not silently replace its own existing token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "assets").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
tokens_path = tmp_path / "assets" / "design-tokens.json"
|
||||
existing = '{"existing": true}\n'
|
||||
tokens_path.write_text(existing)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "assets/design-tokens.json" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert tokens_path.read_text() == existing
|
||||
|
||||
|
||||
def test_refuses_css_custom_property_source_without_force(tmp_path):
|
||||
"""Common app CSS token sources must be named instead of duplicated."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
":root {\n --primary: #2563eb;\n --foreground: #0f172a;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "src/index.css" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_grouped_root_selector_without_force(tmp_path):
|
||||
"""A :root selector list is still an existing project token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
':root, [data-theme="light"] {\n --primary: #2563eb;\n}\n'
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "src/index.css" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_ignores_commented_root_custom_properties(tmp_path):
|
||||
"""Commented examples must not block a first token sync."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
"/* Example only:\n:root {\n --primary: #2563eb;\n}\n*/\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_ignores_custom_properties_outside_root(tmp_path):
|
||||
"""Component-local variables alone are not a project token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
":root {\n color-scheme: light;\n}\n\n"
|
||||
".progress {\n --progress-value: 50%;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_tailwind_theme_colors_without_force(tmp_path):
|
||||
"""Tailwind theme colors are an existing project token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "assets").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "tailwind.config.js").write_text(
|
||||
"module.exports = { theme: { extend: { colors: { brand: '#2563eb' } } } }\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "tailwind.config.js" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_tailwind_v4_theme_source_without_force(tmp_path):
|
||||
"""Tailwind v4 @theme variables are an existing project token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
"@theme {\n --color-brand-500: #2563eb;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "src/index.css" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_token_source_imported_by_common_css_entry(tmp_path):
|
||||
"""Local CSS imports must be followed to their actual token source."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src" / "styles").mkdir(parents=True)
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
'@import "./styles/theme.css";\n'
|
||||
)
|
||||
(tmp_path / "src" / "styles" / "theme.css").write_text(
|
||||
"@theme {\n --color-brand-500: #2563eb;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "src/styles/theme.css" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_ignores_external_css_imports(tmp_path):
|
||||
"""Remote and package imports are not project-owned token sources."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
'@import "https://example.com/theme.css";\n'
|
||||
'@import "tailwindcss";\n'
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_refuses_tailwind_config_with_sibling_preset_without_force(tmp_path):
|
||||
"""A delegated Tailwind theme must not be treated as token-free."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "tailwind.config.js").write_text(
|
||||
"const preset = require('./tailwind.preset');\n"
|
||||
"module.exports = { presets: [preset] };\n"
|
||||
)
|
||||
(tmp_path / "tailwind.preset.js").write_text(
|
||||
"module.exports = { theme: { colors: { brand: '#2563eb' } } };\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "tailwind.config.js" in result.stderr
|
||||
assert not (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
|
||||
def test_force_allows_sync_with_existing_css_token_source(tmp_path):
|
||||
"""The explicit force flag overrides token-source detection."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "src").mkdir()
|
||||
shutil.copy(BRAND_STARTER, tmp_path / "docs" / "brand-guidelines.md")
|
||||
(tmp_path / "src" / "index.css").write_text(
|
||||
":root {\n --primary: #2563eb;\n}\n"
|
||||
)
|
||||
|
||||
result = _run(tmp_path, "--force")
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (tmp_path / "assets" / "design-tokens.json").exists()
|
||||
|
||||
@ -228,6 +228,14 @@ Generate tailwind.config.js with custom theme:
|
||||
python scripts/tailwind_config_gen.py --colors brand:blue --fonts display:Inter
|
||||
```
|
||||
|
||||
The generator refuses to create or replace a config when any sibling
|
||||
`tailwind.config.js`, `.cjs`, `.mjs`, or `.ts` file already exists. Review the
|
||||
reported config first, then pass `--force` only when the competing output is
|
||||
intentional:
|
||||
```bash
|
||||
python scripts/tailwind_config_gen.py --colors brand:blue --force
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Component Composition**: Build complex UIs from simple, composable primitives
|
||||
|
||||
@ -17,6 +17,12 @@ from typing import Any, Dict, List, Optional
|
||||
# optional subpath. Only allows alphanumeric, hyphens, dots, underscores,
|
||||
# and forward slashes — no quotes, parens, or semicolons.
|
||||
_VALID_PLUGIN_NAME = re.compile(r'^(@[a-zA-Z0-9_-]+/)?[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*$')
|
||||
_TAILWIND_CONFIG_NAMES = (
|
||||
"tailwind.config.js",
|
||||
"tailwind.config.cjs",
|
||||
"tailwind.config.mjs",
|
||||
"tailwind.config.ts",
|
||||
)
|
||||
|
||||
|
||||
class TailwindConfigGenerator:
|
||||
@ -27,6 +33,7 @@ class TailwindConfigGenerator:
|
||||
typescript: bool = True,
|
||||
framework: str = "react",
|
||||
output_path: Optional[Path] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize generator.
|
||||
@ -35,10 +42,12 @@ class TailwindConfigGenerator:
|
||||
typescript: If True, generate .ts config, else .js
|
||||
framework: Framework name (react, vue, svelte, nextjs)
|
||||
output_path: Output file path (default: auto-detect)
|
||||
force: If True, allow replacing an existing output file
|
||||
"""
|
||||
self.typescript = typescript
|
||||
self.framework = framework
|
||||
self.output_path = output_path or self._default_output_path()
|
||||
self.force = force
|
||||
self.config: Dict[str, Any] = self._base_config()
|
||||
|
||||
def _default_output_path(self) -> Path:
|
||||
@ -272,6 +281,25 @@ module.exports = {{
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
existing_paths = []
|
||||
if self.output_path.name in _TAILWIND_CONFIG_NAMES:
|
||||
existing_paths = [
|
||||
self.output_path.parent / name
|
||||
for name in _TAILWIND_CONFIG_NAMES
|
||||
if (self.output_path.parent / name).exists()
|
||||
]
|
||||
elif self.output_path.exists():
|
||||
existing_paths = [self.output_path]
|
||||
|
||||
if existing_paths and not self.force:
|
||||
existing = ", ".join(str(path) for path in existing_paths)
|
||||
return (
|
||||
False,
|
||||
f"Tailwind configuration already exists: {existing}. "
|
||||
"Refusing to create or overwrite a competing config; "
|
||||
"re-run with --force only if this is intentional.",
|
||||
)
|
||||
|
||||
config_content = self.generate_config_string()
|
||||
|
||||
self.output_path.write_text(config_content)
|
||||
@ -382,6 +410,12 @@ Examples:
|
||||
help="Validate config without writing file",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite an existing output file",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Initialize generator
|
||||
@ -389,6 +423,7 @@ Examples:
|
||||
typescript=not args.js,
|
||||
framework=args.framework,
|
||||
output_path=args.output,
|
||||
force=args.force,
|
||||
)
|
||||
|
||||
# Add custom colors
|
||||
@ -465,7 +500,7 @@ Examples:
|
||||
|
||||
# Write config
|
||||
success, message = generator.write_config()
|
||||
print(message)
|
||||
print(message, file=sys.stdout if success else sys.stderr)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
|
||||
@ -278,6 +278,120 @@ class TestTailwindConfigGenerator:
|
||||
assert "import type { Config }" in content
|
||||
assert "brand" in content
|
||||
|
||||
def test_write_config_refuses_to_overwrite_existing_file(self, tmp_path):
|
||||
"""Existing project configuration must be preserved by default."""
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
existing = "// existing project config\nexport default { theme: {} }\n"
|
||||
output_path.write_text(existing)
|
||||
generator = TailwindConfigGenerator(output_path=output_path)
|
||||
|
||||
success, message = generator.write_config()
|
||||
|
||||
assert success is False
|
||||
assert "already exists" in message
|
||||
assert "--force" in message
|
||||
assert output_path.read_text() == existing
|
||||
|
||||
def test_write_config_force_overwrites_existing_file(self, tmp_path):
|
||||
"""An explicit force opt-in permits replacing an existing config."""
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
output_path.write_text("// existing project config\n")
|
||||
generator = TailwindConfigGenerator(output_path=output_path, force=True)
|
||||
generator.add_colors({"brand": "#3b82f6"})
|
||||
|
||||
success, message = generator.write_config()
|
||||
|
||||
assert success is True
|
||||
assert "written to" in message
|
||||
assert "brand" in output_path.read_text()
|
||||
|
||||
def test_cli_refuses_existing_config_without_force(self, tmp_path):
|
||||
"""The CLI must return non-zero and preserve an existing default target."""
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
existing = "// existing project config\n"
|
||||
output_path.write_text(existing)
|
||||
script = Path(__file__).parent.parent / "tailwind_config_gen.py"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--colors", "brand:#3b82f6"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "already exists" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert output_path.read_text() == existing
|
||||
|
||||
def test_cli_refuses_sibling_config_extension_without_force(self, tmp_path):
|
||||
"""A default .ts write must not create a second config beside .js."""
|
||||
existing_path = tmp_path / "tailwind.config.js"
|
||||
existing = "// existing JavaScript project config\nmodule.exports = {}\n"
|
||||
existing_path.write_text(existing)
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
script = Path(__file__).parent.parent / "tailwind_config_gen.py"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--colors", "brand:#3b82f6"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "tailwind.config.js" in result.stderr
|
||||
assert "--force" in result.stderr
|
||||
assert existing_path.read_text() == existing
|
||||
assert not output_path.exists()
|
||||
|
||||
def test_cli_force_allows_target_beside_sibling_config(self, tmp_path):
|
||||
"""The explicit force opt-in also overrides cross-extension detection."""
|
||||
existing_path = tmp_path / "tailwind.config.js"
|
||||
existing = "// existing JavaScript project config\nmodule.exports = {}\n"
|
||||
existing_path.write_text(existing)
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
script = Path(__file__).parent.parent / "tailwind_config_gen.py"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--colors",
|
||||
"brand:#3b82f6",
|
||||
"--force",
|
||||
],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert existing_path.read_text() == existing
|
||||
assert "brand" in output_path.read_text()
|
||||
|
||||
def test_cli_force_overwrites_existing_config(self, tmp_path):
|
||||
"""The CLI must wire --force through to the generator."""
|
||||
output_path = tmp_path / "tailwind.config.ts"
|
||||
output_path.write_text("// existing project config\n")
|
||||
script = Path(__file__).parent.parent / "tailwind_config_gen.py"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--colors",
|
||||
"brand:#3b82f6",
|
||||
"--force",
|
||||
],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "brand" in output_path.read_text()
|
||||
|
||||
def test_write_config_invalid_path(self):
|
||||
"""Test writing config to invalid path."""
|
||||
generator = TailwindConfigGenerator(output_path=Path("/invalid/path/config.ts"))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user