diff --git a/packages/core/src/compiler/config/validate-config.ts b/packages/core/src/compiler/config/validate-config.ts index a15c574acd5..2f172484620 100644 --- a/packages/core/src/compiler/config/validate-config.ts +++ b/packages/core/src/compiler/config/validate-config.ts @@ -238,6 +238,10 @@ export const validateConfig = ( validatedConfig.collections = []; } + if (!Array.isArray(validatedConfig.modes)) { + validatedConfig.modes = []; + } + // validate how many workers we can use validateWorkers(validatedConfig); diff --git a/packages/core/src/compiler/transformers/_test_/modes.spec.ts b/packages/core/src/compiler/transformers/_test_/modes.spec.ts new file mode 100644 index 00000000000..efcce10ba30 --- /dev/null +++ b/packages/core/src/compiler/transformers/_test_/modes.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { transpileModule } from './transpile'; + +describe('config.modes validation', () => { + it('does not error when config.modes is not declared, even with arbitrary mode keys', () => { + expect(() => + transpileModule(` + @Component({ + tag: 'cmp-a', + styleUrls: { anything: 'foo.css', goes: 'bar.css' } + }) + export class CmpA {} + `), + ).not.toThrow(); + }); + + it('throws for a typo in a mode key', () => { + let error: Error | undefined; + try { + transpileModule( + ` + @Component({ + tag: 'cmp-a', + styleUrls: { ios: 'foo.ios.css', tyop: 'foo.tyop.css' } + }) + export class CmpA {} + `, + { modes: ['ios', 'md'] }, + ); + } catch (err: unknown) { + error = err as Error; + } + + expect(error).toBeDefined(); + expect(error.message).toContain('Invalid mode "tyop" in "styleUrls"'); + }); + + it('throws when a required mode is missing', () => { + let error: Error | undefined; + try { + transpileModule( + ` + @Component({ + tag: 'cmp-a', + styleUrls: { md: 'foo.md.css' } + }) + export class CmpA {} + `, + { modes: [{ mode: 'ios', required: true }, 'md'] }, + ); + } catch (err: unknown) { + error = err as Error; + } + + expect(error).toBeDefined(); + expect(error.message).toContain('Missing required mode: ios'); + }); + + it('does not error when all used modes are declared', () => { + expect(() => + transpileModule( + ` + @Component({ + tag: 'cmp-a', + styleUrls: { ios: 'foo.ios.css', md: 'foo.md.css' } + }) + export class CmpA {} + `, + { modes: ['ios', { mode: 'md', required: true }] }, + ), + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/compiler/transformers/decorators-to-static/component-decorator.ts b/packages/core/src/compiler/transformers/decorators-to-static/component-decorator.ts index 5857f709709..ed206c109a2 100644 --- a/packages/core/src/compiler/transformers/decorators-to-static/component-decorator.ts +++ b/packages/core/src/compiler/transformers/decorators-to-static/component-decorator.ts @@ -1,7 +1,12 @@ import ts from 'typescript'; import type * as d from '@stencil/core'; -import { augmentDiagnosticWithNode, buildError, validateComponentTag } from '../../../utils'; +import { + augmentDiagnosticWithNode, + buildError, + validateComponentModes, + validateComponentTag, +} from '../../../utils'; import { convertValueToLiteral, createStaticGetter, @@ -244,6 +249,15 @@ const validateComponent = ( } } + // Validate mode keys used in styleUrls/styles against config.modes, if declared + const modeError = validateComponentModes(config.modes, componentOptions); + if (modeError) { + const err = buildError(diagnostics); + err.messageText = modeError.message; + augmentDiagnosticWithNode(err, findTagNode(modeError.propName, componentDecorator)); + return false; + } + const constructor = cmpNode.members.find(ts.isConstructorDeclaration); if (constructor && constructor.parameters.length > 0) { const err = buildError(diagnostics); diff --git a/packages/core/src/declarations/stencil-public-compiler.ts b/packages/core/src/declarations/stencil-public-compiler.ts index fba53381190..69c2ac5f16c 100644 --- a/packages/core/src/declarations/stencil-public-compiler.ts +++ b/packages/core/src/declarations/stencil-public-compiler.ts @@ -317,6 +317,24 @@ export interface StencilConfig { */ excludeUnusedDependencies?: boolean; + /** + * Declares the set of valid style "modes" (e.g. `ios`, `md`) used by mode-keyed + * `styleUrls`/`styles` in `@Component()`. When set, the compiler validates that + * every mode key used in a component matches one of these entries, catching typos + * at build time. Entries marked `required` must be present on every component that + * defines any mode-keyed styles. + * + * @example + * ```ts + * export const config: Config = { + * modes: ['ios', { mode: 'md', required: true }], + * }; + * ``` + * + * @default [] + */ + modes?: (string | ModeConfig)[]; + /** * Explicitly declare which npm packages are Stencil collections to be re-bundled into this project. * @@ -566,6 +584,18 @@ export type ValidatedConfig = RequireFields & { sourceMap: boolean; }; +export interface ModeConfig { + /** + * The mode name, matched against `styleUrls`/`styles` object keys in `@Component()`. + */ + mode: string; + /** + * When `true`, every component that defines mode-keyed `styleUrls` or `styles` + * must include this mode. Defaults to `false`. + */ + required?: boolean; +} + export interface HydratedFlag { /** * Defaults to `hydrated`. diff --git a/packages/core/src/utils/_test_/validation.spec.ts b/packages/core/src/utils/_test_/validation.spec.ts index fa66bfa3f08..b55ea029b92 100644 --- a/packages/core/src/utils/_test_/validation.spec.ts +++ b/packages/core/src/utils/_test_/validation.spec.ts @@ -1,8 +1,97 @@ import { expect, describe, it } from '@stencil/vitest'; -import { validateComponentTag } from '../validation'; +import { validateComponentModes, validateComponentTag } from '../validation'; describe('validation', () => { + describe('validateComponentModes', () => { + it('returns undefined when config.modes is not declared', () => { + expect(validateComponentModes(undefined, { styleUrls: { typo: 'foo.css' } })).toBeUndefined(); + }); + + it('returns undefined when config.modes is an empty array', () => { + expect(validateComponentModes([], { styleUrls: { typo: 'foo.css' } })).toBeUndefined(); + }); + + it('returns undefined when all used modes are allowed (string entries)', () => { + expect( + validateComponentModes(['ios', 'md'], { + styleUrls: { ios: 'foo.ios.css', md: 'foo.md.css' }, + }), + ).toBeUndefined(); + }); + + it('returns undefined when styleUrls is the plain array form (no mode keys)', () => { + expect(validateComponentModes(['ios', 'md'], { styleUrls: ['foo.css'] })).toBeUndefined(); + }); + + it('errors on an unknown mode key in styleUrls', () => { + expect( + validateComponentModes(['ios', 'md'], { + styleUrls: { ios: 'foo.ios.css', tyop: 'foo.tyop.css' }, + }), + ).toEqual({ + propName: 'styleUrls', + message: 'Invalid mode "tyop" in "styleUrls". Valid modes are: ios, md.', + }); + }); + + it('errors on an unknown mode key in styles', () => { + expect( + validateComponentModes(['ios', 'md'], { + styles: { ios: ':host {}', tyop: ':host {}' }, + }), + ).toEqual({ + propName: 'styles', + message: 'Invalid mode "tyop" in "styles". Valid modes are: ios, md.', + }); + }); + + it('ignores the __identifier wrapper for a single imported style', () => { + expect( + validateComponentModes(['ios', 'md'], { + styles: { __identifier: true, __escapedText: 'styles' } as any, + }), + ).toBeUndefined(); + }); + + it('errors when a required mode is missing', () => { + expect( + validateComponentModes([{ mode: 'ios' }, { mode: 'md', required: true }], { + styleUrls: { ios: 'foo.ios.css' }, + }), + ).toEqual({ + propName: 'styleUrls', + message: 'Missing required mode: md.', + }); + }); + + it('errors listing all missing required modes', () => { + expect( + validateComponentModes( + [{ mode: 'ios', required: true }, { mode: 'md', required: true }, 'web'], + { styleUrls: { web: 'foo.web.css' } }, + ), + ).toEqual({ + propName: 'styleUrls', + message: 'Missing required modes: ios, md.', + }); + }); + + it('does not require a mode when the component uses no mode-keyed styles at all', () => { + expect( + validateComponentModes([{ mode: 'ios', required: true }], { styleUrl: 'foo.css' } as any), + ).toBeUndefined(); + }); + + it('supports mixed string and object entries', () => { + expect( + validateComponentModes(['ios', { mode: 'md', required: true }], { + styleUrls: { ios: 'foo.ios.css', md: 'foo.md.css' }, + }), + ).toBeUndefined(); + }); + }); + describe('validateComponentTag', () => { it('should error on non-string', () => { // @ts-ignore we're checking what happens when we pass an unexpected type (number instead of string) diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts index d6812e38e41..584771ba1ac 100644 --- a/packages/core/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -1,3 +1,85 @@ +import type * as d from '@stencil/core'; + +/** + * The shape of a mode-keyed `styleUrls`/`styles` object once the compiler has + * evaluated the `@Component()` decorator's argument. A single imported style + * (`import styles from './styles.css'`) is wrapped as `{ __identifier: true, ... }`, + * which is not mode-keyed and must be excluded from mode-key extraction. + */ +type PossibleModeObject = Record & { __identifier?: boolean }; + +const getModeKeys = (value: string[] | PossibleModeObject | string | undefined): string[] => { + if (value == null || typeof value !== 'object' || Array.isArray(value) || value.__identifier) { + return []; + } + return Object.keys(value); +}; + +/** + * The result of an invalid `modes` check on a component's `styleUrls`/`styles`. + */ +export interface ModeValidationError { + propName: 'styleUrls' | 'styles'; + message: string; +} + +/** + * Validates the mode keys used in a component's `styleUrls`/`styles` against a + * `config.modes` allowlist, if one is declared. + * @param configModes the `config.modes` allowlist (mixed string/{@link d.ModeConfig} entries) + * @param componentOptions the `@Component()` decorator options for a single component + * @returns a validation error if a used mode is unknown or a required mode is missing, undefined otherwise + */ +export const validateComponentModes = ( + configModes: (string | d.ModeConfig)[] | undefined, + componentOptions: Pick, +): ModeValidationError | undefined => { + if (!configModes || configModes.length === 0) { + return undefined; + } + + const allowedModes = new Map(); + for (const entry of configModes) { + if (typeof entry === 'string') { + allowedModes.set(entry, false); + } else { + allowedModes.set(entry.mode, !!entry.required); + } + } + + const fields: { propName: 'styleUrls' | 'styles'; keys: string[] }[] = [ + { propName: 'styleUrls', keys: getModeKeys(componentOptions.styleUrls) }, + { propName: 'styles', keys: getModeKeys(componentOptions.styles) }, + ]; + + for (const field of fields) { + for (const key of field.keys) { + if (!allowedModes.has(key)) { + return { + propName: field.propName, + message: `Invalid mode "${key}" in "${field.propName}". Valid modes are: ${[...allowedModes.keys()].join(', ')}.`, + }; + } + } + } + + const usedModes = new Set([...fields[0].keys, ...fields[1].keys]); + if (usedModes.size > 0) { + const missingRequired = [...allowedModes.entries()] + .filter(([mode, required]) => required && !usedModes.has(mode)) + .map(([mode]) => mode); + + if (missingRequired.length > 0) { + return { + propName: fields[0].keys.length > 0 ? 'styleUrls' : 'styles', + message: `Missing required mode${missingRequired.length > 1 ? 's' : ''}: ${missingRequired.join(', ')}.`, + }; + } + } + + return undefined; +}; + /** * Validates that a component tag meets required naming conventions to be used for a web component * @param tag the tag to validate