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
4 changes: 4 additions & 0 deletions packages/core/src/compiler/config/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/compiler/transformers/_test_/modes.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/declarations/stencil-public-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -566,6 +584,18 @@ export type ValidatedConfig = RequireFields<Config, StrictConfigFields> & {
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`.
Expand Down
91 changes: 90 additions & 1 deletion packages/core/src/utils/_test_/validation.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> & { __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<d.ComponentOptions, 'styleUrls' | 'styles'>,
): ModeValidationError | undefined => {
if (!configModes || configModes.length === 0) {
return undefined;
}

const allowedModes = new Map<string, boolean>();
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
Expand Down
Loading