Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 120 additions & 84 deletions build-design-tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,91 +10,121 @@
* node build-design-tokens.mjs
*/

import { readFileSync, writeFileSync, readdirSync, existsSync, renameSync } from 'fs';
import { resolve, dirname, join } from 'path';
import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

const INPUT = resolve(__dirname, 'source/data/design-tokens.json');
const INPUT = resolve(__dirname, 'source/data/design-tokens.json');
const OUTPUT = resolve(__dirname, 'source/sass/setting/_design-tokens.scss');
const COMPONENT_DIR = resolve(__dirname, 'source/components');
const COMPONENT_OUTPUT = resolve(__dirname, 'component-design-tokens.json');

function writeFileAtomic(path, content) {
const tempPath = `${path}.tmp`;
writeFileSync(tempPath, content, 'utf-8');
renameSync(tempPath, path);
const tempPath = `${path}.tmp`;
writeFileSync(tempPath, content, 'utf-8');
renameSync(tempPath, path);
}

function validateTokenData(data) {
if (!data || !Array.isArray(data.categories)) {
throw new Error('Invalid token source: "categories" must be an array.');
}

for (const category of data.categories) {
if (!Array.isArray(category.settings)) {
throw new Error(`Invalid token source: category "${category?.id || 'unknown'}" is missing a valid "settings" array.`);
}
}
if (!data || !Array.isArray(data.categories)) {
throw new Error('Invalid token source: "categories" must be an array.');
}

for (const category of data.categories) {
if (!Array.isArray(category.settings)) {
throw new Error(`Invalid token source: category "${category?.id || 'unknown'}" is missing a valid "settings" array.`);
}
}
}

function validateComponentTokenReferences(componentName, componentData) {
const declaredTokens = Array.isArray(componentData?.tokens) ? componentData.tokens : [];
const declaredTokensSet = new Set(declaredTokens);
const componentSettings = Array.isArray(componentData?.componentSettings) ? componentData.componentSettings : [];
const invalidReferences = [];

for (const category of componentSettings) {
const settings = Array.isArray(category?.settings) ? category.settings : [];

for (const setting of settings) {
if (typeof setting?.token !== 'string') {
continue;
}

if (!declaredTokensSet.has(setting.token)) {
invalidReferences.push({
categoryId: category?.id || 'unknown',
token: setting.token,
});
}
}
}

if (invalidReferences.length > 0) {
const details = invalidReferences.map(({ categoryId, token }) => `- category "${categoryId}" references token "${token}" that is missing from tokens[]`).join('\n');

throw new Error(`Invalid component token references in source/components/${componentName}/component.json:\n${details}`);
}
}

function buildScss(data) {
const lines = [];

lines.push('// ============================================================================');
lines.push('// DESIGN TOKENS');
lines.push('// ============================================================================');
lines.push('//');
lines.push(`// AUTO-GENERATED from source/data/design-tokens.json (v${data.version || '0.0.0'})`);
lines.push('// DO NOT EDIT THIS FILE MANUALLY — changes will be overwritten.');
lines.push('//');
lines.push('// To modify design tokens, edit source/data/design-tokens.json and run:');
lines.push('// node build-design-tokens.mjs');
lines.push('//');
lines.push('// ============================================================================');
lines.push('');
lines.push(':root {');

for (const category of data.categories) {
lines.push('');
lines.push(' // ' + '='.repeat(72));
lines.push(` // ${category.label}`);

if (category.description) {
lines.push(` // ${category.description}`);
}

lines.push(' // ' + '='.repeat(72));

for (const setting of category.settings) {
const name = setting.variable;
const value = formatValue(setting);

lines.push(` ${name}: ${value};`);
}
}

lines.push('}');
lines.push('');

return lines.join('\n');
const lines = [];

lines.push('// ============================================================================');
lines.push('// DESIGN TOKENS');
lines.push('// ============================================================================');
lines.push('//');
lines.push(`// AUTO-GENERATED from source/data/design-tokens.json (v${data.version || '0.0.0'})`);
lines.push('// DO NOT EDIT THIS FILE MANUALLY — changes will be overwritten.');
lines.push('//');
lines.push('// To modify design tokens, edit source/data/design-tokens.json and run:');
lines.push('// node build-design-tokens.mjs');
lines.push('//');
lines.push('// ============================================================================');
lines.push('');
lines.push(':root {');

for (const category of data.categories) {
lines.push('');
lines.push(' // ' + '='.repeat(72));
lines.push(` // ${category.label}`);

if (category.description) {
lines.push(` // ${category.description}`);
}

lines.push(' // ' + '='.repeat(72));

for (const setting of category.settings) {
const name = setting.variable;
const value = formatValue(setting);

lines.push(` ${name}: ${value};`);
}
}

lines.push('}');
lines.push('');

return lines.join('\n');
}

function formatValue(setting) {
const val = setting.default;
const val = setting.default;

// Strings that are already CSS expressions (var(), calc(), etc.) pass through
if (typeof val !== 'string') {
return String(val);
}
// Strings that are already CSS expressions (var(), calc(), etc.) pass through
if (typeof val !== 'string') {
return String(val);
}

// Font families and other quoted values — keep as-is
if (val.startsWith('var(') || val.startsWith('calc(')) {
return val;
}
// Font families and other quoted values — keep as-is
if (val.startsWith('var(') || val.startsWith('calc(')) {
return val;
}

return val;
return val;
}

// Main
Expand All @@ -111,24 +141,30 @@ console.log(` ${data.categories.length} categories, ${tokenCount} tokens`);

// Process component design tokens
if (existsSync(COMPONENT_DIR)) {
const componentTokens = {};
const componentDirs = readdirSync(COMPONENT_DIR, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);

for (const componentName of componentDirs) {
const tokenFile = join(COMPONENT_DIR, componentName, `component.json`);
if (existsSync(tokenFile)) {
try {
const tokenData = JSON.parse(readFileSync(tokenFile, 'utf-8'));
componentTokens[componentName] = tokenData;
} catch (error) {
console.warn(`Warning: Failed to parse ${tokenFile}: ${error.message}`);
}
}
}

writeFileAtomic(COMPONENT_OUTPUT, JSON.stringify(componentTokens, null, 2));
console.log(`Generated ${COMPONENT_OUTPUT}`);
console.log(` ${Object.keys(componentTokens).length} components processed`);
const componentTokens = {};
const componentErrors = [];
const componentDirs = readdirSync(COMPONENT_DIR, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);

for (const componentName of componentDirs) {
const tokenFile = join(COMPONENT_DIR, componentName, `component.json`);
if (existsSync(tokenFile)) {
try {
const tokenData = JSON.parse(readFileSync(tokenFile, 'utf-8'));
validateComponentTokenReferences(componentName, tokenData);
componentTokens[componentName] = tokenData;
} catch (error) {
componentErrors.push(`Failed to parse ${tokenFile}: ${error.message}`);
}
}
}

if (componentErrors.length > 0) {
throw new Error(componentErrors.join('\n'));
}

writeFileAtomic(COMPONENT_OUTPUT, JSON.stringify(componentTokens, null, 2));
console.log(`Generated ${COMPONENT_OUTPUT}`);
console.log(` ${Object.keys(componentTokens).length} components processed`);
}
1 change: 1 addition & 0 deletions component-design-tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@
"color--secondary",
"font-weight-medium",
"font-family-base",
"color--surface",
"color--surface-contrast",
"shadow-color",
"shadow-amount",
Expand Down
1 change: 1 addition & 0 deletions source/component-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
"type": "object",
"properties": {
"token": {
"description": "Design token name. Must be a valid token and must also be declared in this component's top-level tokens array.",
"$ref": "./design-tokens-schema.json#/$defs/validToken"
},
"label": {
Expand Down
128 changes: 128 additions & 0 deletions source/components/Tests/ComponentSettingsTokenReferencesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

namespace MunicipioStyleGuide\Components\Tests;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;

/**
* Ensures token references in component settings are declared in tokens.
*/
class ComponentSettingsTokenReferencesTest extends TestCase
{
/**
* Verifies that every setting token is present in the component token list.
*/
#[TestDox('component "$component" only references declared tokens in componentSettings')]
#[DataProvider('componentFilesProvider')]
public function testComponentSettingsOnlyReferenceDeclaredTokens(string $component, string $componentFile): void
{
$componentData = self::readComponentFile($componentFile);
$declaredTokens = self::extractDeclaredTokens($componentData);
$missingTokenReferences = self::findMissingTokenReferences($componentData, $declaredTokens);

self::assertEmpty(
$missingTokenReferences,
sprintf(
"Component '%s' contains token references that are missing from tokens[]:%s- %s",
$component,
PHP_EOL,
implode(PHP_EOL . '- ', $missingTokenReferences),
),
);
}

/**
* Reads and decodes a component definition file.
*
* @return array<string, mixed>
*/
private static function readComponentFile(string $componentFile): array
{
$content = (string) file_get_contents($componentFile);
$decoded = json_decode($content, true);

return is_array($decoded) ? $decoded : [];
}

/**
* Extracts top-level declared tokens from a component definition.
*
* @param array<string, mixed> $componentData
*
* @return array<int, string>
*/
private static function extractDeclaredTokens(array $componentData): array
{
if (!isset($componentData['tokens']) || !is_array($componentData['tokens'])) {
return [];
}

return array_values(array_filter($componentData['tokens'], fn ($token) => is_string($token)));
}

/**
* Finds token references used in componentSettings that are not declared in tokens.
*
* @param array<string, mixed> $componentData
* @param array<int, string> $declaredTokens
*
* @return array<int, string>
*/
private static function findMissingTokenReferences(array $componentData, array $declaredTokens): array

Check failure on line 73 in source/components/Tests/ComponentSettingsTokenReferencesTest.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_styleguide&issues=AZ8nCMkXlcb12dMFd7Gh&open=AZ8nCMkXlcb12dMFd7Gh&pullRequest=1263
{
if (!isset($componentData['componentSettings']) || !is_array($componentData['componentSettings'])) {
return [];
}

$declaredTokenLookup = array_fill_keys($declaredTokens, true);
$missingReferences = [];

foreach ($componentData['componentSettings'] as $category) {
if (!is_array($category) || !isset($category['settings']) || !is_array($category['settings'])) {
continue;
}

$categoryId = is_string($category['id'] ?? null) ? $category['id'] : 'unknown';

foreach ($category['settings'] as $setting) {
if (!is_array($setting) || !is_string($setting['token'] ?? null)) {
continue;
}

$token = $setting['token'];

if (!isset($declaredTokenLookup[$token])) {
$missingReferences[] = sprintf('category "%s" token "%s"', $categoryId, $token);
}
}
}

return $missingReferences;
}

/**
* Provides component definition files for validation.
*
* @return \Generator<string, array{0: string, 1: string}>
*/
public static function componentFilesProvider(): \Generator
{
$componentsDir = __DIR__ . '/../';
$componentExceptions = ['Tests'];
$components = array_filter(scandir($componentsDir), function ($item) use ($componentsDir, $componentExceptions) {
return is_dir($componentsDir . $item) && !in_array($item, ['.', '..', ...$componentExceptions], true);
});

foreach ($components as $component) {
$componentFile = $componentsDir . $component . '/component.json';

if (!file_exists($componentFile)) {
continue;
}

yield $component => [$component, $componentFile];
}
}
}
Loading
Loading