From 33c1571a4e16d9f473d1fc7fc123ce13969aea98 Mon Sep 17 00:00:00 2001 From: Thor Brink Date: Fri, 3 Jul 2026 08:10:39 +0000 Subject: [PATCH 1/2] fix: button component missing token reference --- build-design-tokens.mjs | 204 ++++++++++-------- component-design-tokens.json | 1 + source/component-schema.json | 1 + .../ComponentSettingsTokenReferencesTest.php | 128 +++++++++++ source/components/Tests/UnusedTokensTest.php | 38 +++- source/components/button/component.json | 1 + .../components/scope/examples/examples.json | 4 + .../scope/examples/nested.blade.php | 16 ++ 8 files changed, 306 insertions(+), 87 deletions(-) create mode 100644 source/components/Tests/ComponentSettingsTokenReferencesTest.php create mode 100644 source/components/scope/examples/nested.blade.php diff --git a/build-design-tokens.mjs b/build-design-tokens.mjs index 01c503729..029fd47ee 100644 --- a/build-design-tokens.mjs +++ b/build-design-tokens.mjs @@ -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 @@ -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`); } diff --git a/component-design-tokens.json b/component-design-tokens.json index d27aeb4b7..c04730ad4 100644 --- a/component-design-tokens.json +++ b/component-design-tokens.json @@ -268,6 +268,7 @@ "color--secondary", "font-weight-medium", "font-family-base", + "color--surface", "color--surface-contrast", "shadow-color", "shadow-amount", diff --git a/source/component-schema.json b/source/component-schema.json index d390177d6..cf2292b8c 100644 --- a/source/component-schema.json +++ b/source/component-schema.json @@ -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": { diff --git a/source/components/Tests/ComponentSettingsTokenReferencesTest.php b/source/components/Tests/ComponentSettingsTokenReferencesTest.php new file mode 100644 index 000000000..ce2bbe05d --- /dev/null +++ b/source/components/Tests/ComponentSettingsTokenReferencesTest.php @@ -0,0 +1,128 @@ + + */ + 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 $componentData + * + * @return array + */ + 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 $componentData + * @param array $declaredTokens + * + * @return array + */ + private static function findMissingTokenReferences(array $componentData, array $declaredTokens): array + { + 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 + */ + 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]; + } + } +} diff --git a/source/components/Tests/UnusedTokensTest.php b/source/components/Tests/UnusedTokensTest.php index d3932f116..fbb1d1a8a 100644 --- a/source/components/Tests/UnusedTokensTest.php +++ b/source/components/Tests/UnusedTokensTest.php @@ -17,10 +17,11 @@ class UnusedTokensTest extends TestCase public function testComponentUtilizeAllTokens(string $component, string $tokenFile, string $componentDir): void { $tokens = self::extractTokensFromTokenFile($tokenFile); + $settingsTokenReferences = self::extractComponentSettingsTokenReferences($tokenFile); $scssContents = self::readAllScssFiles($componentDir); self::assertShadowDependenciesAreDeclared($tokens, $scssContents, $component); - $unusedTokens = self::findUnusedTokens($tokens, $scssContents, self::TOKEN_EXCEPTIONS); + $unusedTokens = self::findUnusedTokens($tokens, $scssContents, self::TOKEN_EXCEPTIONS, $settingsTokenReferences); $errorMessage = sprintf("Component '%s' has declared but unused tokens in %s:%s- %s", $component, $componentDir, PHP_EOL, implode(PHP_EOL . '- ', $unusedTokens)); $this->assertEmpty($unusedTokens, $errorMessage); @@ -79,12 +80,17 @@ private static function assertShadowDependenciesAreDeclared( ); } - private static function findUnusedTokens(array $tokens, string $scssContents, array $excludedTokens): array + private static function findUnusedTokens( + array $tokens, + string $scssContents, + array $excludedTokens, + array $componentSettingsTokenReferences, + ): array { $unusedTokens = []; foreach ($tokens as $token) { - if (in_array($token, $excludedTokens, true)) { + if (in_array($token, $excludedTokens, true) || in_array($token, $componentSettingsTokenReferences, true)) { continue; } @@ -117,6 +123,32 @@ private static function extractTokensFromTokenFile(string $tokenFile): array return $data['tokens'] ?? []; } + private static function extractComponentSettingsTokenReferences(string $tokenFile): array + { + $content = (string) file_get_contents($tokenFile); + $data = json_decode($content, true); + + if (!is_array($data) || !isset($data['componentSettings']) || !is_array($data['componentSettings'])) { + return []; + } + + $references = []; + + foreach ($data['componentSettings'] as $category) { + if (!is_array($category) || !isset($category['settings']) || !is_array($category['settings'])) { + continue; + } + + foreach ($category['settings'] as $setting) { + if (is_array($setting) && is_string($setting['token'] ?? null)) { + $references[] = $setting['token']; + } + } + } + + return array_values(array_unique($references)); + } + public static function componentFilesProvider(): \Generator { $componentsDir = __DIR__ . '/../'; diff --git a/source/components/button/component.json b/source/components/button/component.json index d0324abda..ace956ddb 100644 --- a/source/components/button/component.json +++ b/source/components/button/component.json @@ -13,6 +13,7 @@ "color--secondary", "font-weight-medium", "font-family-base", + "color--surface", "color--surface-contrast", "shadow-color", "shadow-amount", diff --git a/source/components/scope/examples/examples.json b/source/components/scope/examples/examples.json index b6036b936..b05aaa669 100644 --- a/source/components/scope/examples/examples.json +++ b/source/components/scope/examples/examples.json @@ -6,5 +6,9 @@ "list": { "heading": "Scope List", "text": "A list of scopes can also be provided to present multiple scopes within the same element." + }, + "nested": { + "heading": "Multiple components within a scope", + "text": "This example shows how multiple components can be placed within a single scope element. The styles of the components are limited to only the components within its scope." } } diff --git a/source/components/scope/examples/nested.blade.php b/source/components/scope/examples/nested.blade.php new file mode 100644 index 000000000..439588b91 --- /dev/null +++ b/source/components/scope/examples/nested.blade.php @@ -0,0 +1,16 @@ +@scope(['name' => 'nested-components-scope']) + @card([ + 'title' => 'Scope 1 - Card 1', + 'text' => 'This is a card within scope 1.', + 'color' => 'primary', + 'style' => 'filled' + ]) + @button([ + 'text' => 'Scope 1 - Button 1', + 'style' => 'basic', + 'href' => 'https://getmunicipio.com' + + ]) + @endbutton + @endcard +@endscope \ No newline at end of file From 5a6f7119355e218d4449682a48bd8bf6d127a970 Mon Sep 17 00:00:00 2001 From: Thor Brink Date: Fri, 3 Jul 2026 08:49:44 +0000 Subject: [PATCH 2/2] test: update narrow minded component family test --- .../ComponentCustomizerRuntime.test.ts | 67 ++++++++++++------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/source/design-builder/features/component-customizer/ComponentCustomizerRuntime.test.ts b/source/design-builder/features/component-customizer/ComponentCustomizerRuntime.test.ts index 0d5b80875..526cb4685 100644 --- a/source/design-builder/features/component-customizer/ComponentCustomizerRuntime.test.ts +++ b/source/design-builder/features/component-customizer/ComponentCustomizerRuntime.test.ts @@ -1390,16 +1390,14 @@ describe('ComponentCustomizerRuntime pick mode', () => { mount.remove(); }); - it('maps full companion families for the real button component data', () => { - document.body.innerHTML = ` - - - `; + it('does not map full companion families for single variable of variable family', () => { + document.body.innerHTML = `
`; const mount = document.createElement('div'); document.body.appendChild(mount); const hostElement = document.createElement('design-builder') as HTMLElement & { overrideState: ReturnType; }; + hostElement.overrideState = normalizeDesignBuilderOverrideState({ token: { '--color--palette-1': '#0055aa', @@ -1416,29 +1414,48 @@ describe('ComponentCustomizerRuntime pick mode', () => { buildCategoriesForComponent(componentName: string): TokenData['categories']; }; - const categories = runtimeInternals.buildCategoriesForComponent('button'); - const colorCategory = categories.find((category) => category.id === 'colors'); - const primarySetting = colorCategory?.settings.find((setting) => setting.variable === '--c-button--color--primary'); - const defaultSetting = colorCategory?.settings.find((setting) => setting.variable === '--c-button--color--surface'); - const secondaryPrimaryOption = primarySetting?.options?.find((option) => option.label === 'Secondary'); - const backgroundDefaultOption = defaultSetting?.options?.find((option) => option.label === 'Background'); + const categories = runtimeInternals.buildCategoriesForComponent('loader'); + const layoutColorsCategorySettings = categories.find((category) => category.id === 'colors-layout')?.settings ?? []; - expect(secondaryPrimaryOption?.extraValues).toEqual( - expect.objectContaining({ - '--c-button--color--primary-contrast': 'var(--color--secondary-contrast)', - '--c-button--color--primary-border': 'var(--color--secondary-border)', - }), - ); + expect(layoutColorsCategorySettings.length).toEqual(1); + expect(layoutColorsCategorySettings[0].variable).toEqual('--c-loader--color--surface-contrast'); + expect(layoutColorsCategorySettings[0].linkedDefaults).toEqual({}); - expect(backgroundDefaultOption?.extraValues).toBeUndefined(); + hostElement.remove(); + mount.remove(); + }); - const palettePrimaryOption = primarySetting?.options?.find((option) => option.label === 'Palette 1'); - expect(palettePrimaryOption?.extraValues).toEqual( - expect.objectContaining({ - '--c-button--color--primary-contrast': 'var(--color--palette-1-contrast)', - '--c-button--color--primary-border': 'var(--color--palette-1-border)', - }), - ); + it('maps full companion families for the real loader component data', () => { + document.body.innerHTML = `
`; + const mount = document.createElement('div'); + + document.body.appendChild(mount); + const hostElement = document.createElement('design-builder') as HTMLElement & { + overrideState: ReturnType; + }; + + hostElement.overrideState = normalizeDesignBuilderOverrideState({ + token: { + '--color--palette-1': '#0055aa', + '--color--palette-1-contrast': '#ffffff', + }, + }); + document.body.appendChild(hostElement); + + const realComponentData = require('../../../../component-design-tokens.json') as ComponentTokenData; + const realTokenLibrary = require('../../../data/design-tokens.json') as TokenData; + + const runtime = new ComponentCustomizerRuntime(realComponentData, realTokenLibrary, mount, { hostElement: hostElement as RuntimeHostElement }); + const runtimeInternals = runtime as unknown as { + buildCategoriesForComponent(componentName: string): TokenData['categories']; + }; + + const categories = runtimeInternals.buildCategoriesForComponent('loader'); + const brandColorsCategorySettings = categories.find((category) => category.id === 'colors-brand')?.settings ?? []; + const primaryColorSetting = brandColorsCategorySettings.find((setting) => setting.label === 'Primary'); + + expect(primaryColorSetting).toBeDefined(); + expect(primaryColorSetting?.linkedDefaults).not.toEqual({}); hostElement.remove(); mount.remove();