feat(core): support styles and styleUrl as strings (#51715)

Adds support for passing in `@Component.styles` as a string. Also introduces a new `styleUrl` property on `@Component` for providing a single stylesheet. This is more convenient for the most common case where a component only has one stylesheet associated with it.

PR Close #51715
This commit is contained in:
Kristiyan Kostadinov
2023-09-11 13:56:37 +02:00
committed by Andrew Kushnir
parent f9939757d3
commit 59387ee476
12 changed files with 239 additions and 42 deletions
@@ -452,7 +452,8 @@ export function transformDecoratorResources(
// If no external resources are referenced, preserve the original decorator
// for the best source map experience when the decorator is emitted in TS.
if (!component.has('templateUrl') && !component.has('styleUrls') && !component.has('styles')) {
if (!component.has('templateUrl') && !component.has('styleUrls') && !component.has('styleUrl') &&
!component.has('styles')) {
return dec;
}
@@ -464,9 +465,10 @@ export function transformDecoratorResources(
metadata.set('template', ts.factory.createStringLiteral(template.content));
}
if (metadata.has('styleUrls') || metadata.has('styles')) {
if (metadata.has('styleUrls') || metadata.has('styleUrl') || metadata.has('styles')) {
metadata.delete('styles');
metadata.delete('styleUrls');
metadata.delete('styleUrl');
if (styles.length > 0) {
const styleNodes = styles.reduce((result, style) => {
@@ -496,11 +498,35 @@ export function extractComponentStyleUrls(
evaluator: PartialEvaluator,
component: Map<string, ts.Expression>,
): StyleUrlMeta[] {
if (!component.has('styleUrls')) {
return [];
const styleUrlsExpr = component.get('styleUrls');
const styleUrlExpr = component.get('styleUrl');
if (styleUrlsExpr !== undefined && styleUrlExpr !== undefined) {
throw new FatalDiagnosticError(
ErrorCode.COMPONENT_INVALID_STYLE_URLS, styleUrlExpr,
'@Component cannot define both `styleUrl` and `styleUrls`. ' +
'Use `styleUrl` if the component has one stylesheet, or `styleUrls` if it has multiple');
}
return extractStyleUrlsFromExpression(evaluator, component.get('styleUrls')!);
if (styleUrlsExpr !== undefined) {
return extractStyleUrlsFromExpression(evaluator, component.get('styleUrls')!);
}
if (styleUrlExpr !== undefined) {
const styleUrl = evaluator.evaluate(styleUrlExpr);
if (typeof styleUrl !== 'string') {
throw createValueHasWrongTypeError(styleUrlExpr, styleUrl, 'styleUrl must be a string');
}
return [{
url: styleUrl,
source: ResourceTypeForDiagnostics.StylesheetFromDecorator,
nodeForError: styleUrlExpr,
}];
}
return [];
}
function extractStyleUrlsFromExpression(
@@ -543,42 +569,63 @@ function extractStyleUrlsFromExpression(
return styleUrls;
}
export function extractStyleResources(
resourceLoader: ResourceLoader, component: Map<string, ts.Expression>,
containingFile: string): ReadonlySet<Resource> {
const styles = new Set<Resource>();
function stringLiteralElements(array: ts.ArrayLiteralExpression): ts.StringLiteralLike[] {
return array.elements.filter(
(e: ts.Expression): e is ts.StringLiteralLike => ts.isStringLiteralLike(e));
return array.elements.filter((e): e is ts.StringLiteralLike => ts.isStringLiteralLike(e));
}
// If styleUrls is a literal array, process each resource url individually and
// register ones that are string literals.
// If styleUrls is a literal array, process each resource url individually and register ones that
// are string literals. If `styleUrl` is specified, register a single stylesheet. Note that
// `styleUrl` and `styleUrls` are mutually-exclusive. This is validated in
// `extractComponentStyleUrls`.
const styleUrlExpr = component.get('styleUrl');
const styleUrlsExpr = component.get('styleUrls');
if (styleUrlsExpr !== undefined && ts.isArrayLiteralExpression(styleUrlsExpr)) {
for (const expression of stringLiteralElements(styleUrlsExpr)) {
try {
const resourceUrl = resourceLoader.resolve(expression.text, containingFile);
styles.add({path: absoluteFrom(resourceUrl), expression});
} catch {
// Errors in style resource extraction do not need to be handled here. We will produce
// diagnostics for each one that fails in the analysis, after we evaluate the
// `styleUrls` expression to determine _all_ style resources, not just the string
// literals.
const resource = stringLiteralUrlToResource(resourceLoader, expression, containingFile);
if (resource !== null) {
styles.add(resource);
}
}
} else if (styleUrlExpr !== undefined && ts.isStringLiteralLike(styleUrlExpr)) {
const resource = stringLiteralUrlToResource(resourceLoader, styleUrlExpr, containingFile);
if (resource !== null) {
styles.add(resource);
}
}
const stylesExpr = component.get('styles');
if (stylesExpr !== undefined && ts.isArrayLiteralExpression(stylesExpr)) {
for (const expression of stringLiteralElements(stylesExpr)) {
styles.add({path: null, expression});
if (stylesExpr !== undefined) {
if (ts.isArrayLiteralExpression(stylesExpr)) {
for (const expression of stringLiteralElements(stylesExpr)) {
styles.add({path: null, expression});
}
} else if (ts.isStringLiteralLike(stylesExpr)) {
styles.add({path: null, expression: stylesExpr});
}
}
return styles;
}
function stringLiteralUrlToResource(
resourceLoader: ResourceLoader, expression: ts.StringLiteralLike,
containingFile: string): Resource|null {
try {
const resourceUrl = resourceLoader.resolve(expression.text, containingFile);
return {path: absoluteFrom(resourceUrl), expression};
} catch {
// Errors in style resource extraction do not need to be handled here. We will produce
// diagnostics for each one that fails in the analysis, after we evaluate the `styleUrls`
// expression to determine _all_ style resources, not just the string literals.
return null;
}
}
export function _extractTemplateStyleUrls(template: ParsedTemplateWithSource): StyleUrlMeta[] {
if (template.styleUrls === null) {
return [];
@@ -470,7 +470,8 @@ export function parseDirectiveStyles(
return null;
}
const value = evaluator.evaluate(expression);
const evaluated = evaluator.evaluate(expression);
const value = typeof evaluated === 'string' ? [evaluated] : evaluated;
// Create specific error if any string is imported from external file in local compilation mode
if (compilationMode === CompilationMode.LOCAL && Array.isArray(value)) {
@@ -481,7 +482,7 @@ export function parseDirectiveStyles(
const chain: ts.DiagnosticMessageChain = {
messageText: `Unknown identifier used as styles string: ${
entry.node
.getText()} (did you import this string from another file? This is not allowed in local compilation mode. Please either inline it or move it to a separate file and include it using'styleUrls')`,
.getText()} (did you import this string from another file? This is not allowed in local compilation mode. Please either inline it or move it to a separate file and include it using 'styleUrl')`,
category: ts.DiagnosticCategory.Error,
code: 0,
};
@@ -495,7 +496,8 @@ export function parseDirectiveStyles(
if (!isStringArrayOrDie(value, 'styles', expression)) {
throw createValueHasWrongTypeError(
expression, value, `Failed to resolve @Directive.styles to a string array`);
expression, value,
`Failed to resolve @Component.styles to a string or an array of strings`);
}
return value;
@@ -113,6 +113,8 @@ export enum ErrorCode {
*/
CONFLICTING_INPUT_TRANSFORM = 2020,
/** Raised when a component has both `styleUrls` and `styleUrl`. */
COMPONENT_INVALID_STYLE_URLS = 2021,
SYMBOL_NOT_EXPORTED = 3001,
/**
@@ -7436,6 +7436,61 @@ function allTests(os: string) {
// Large string should be called from function definition.
'_c0()]');
});
it('should process `styles` as a string', () => {
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({
template: '',
styles: 'h2 {width: 10px}'
})
export class TestCmp {}
`);
env.driveMain();
const jsContents = env.getContents('test.js');
expect(jsContents).toContain('styles: ["h2[_ngcontent-%COMP%] {width: 10px}"]');
});
it('should process `styleUrl`', () => {
env.write('dir/styles.css', 'h2 {width: 10px}');
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({
selector: 'test-cmp',
styleUrl: 'dir/styles.css',
template: '',
})
export class TestCmp {}
`);
env.driveMain();
const jsContents = env.getContents('test.js');
expect(jsContents).not.toContain('styleUrl');
expect(jsContents).toContain('styles: ["h2[_ngcontent-%COMP%] {width: 10px}"]');
});
it('should produce a diagnostic if both `styleUrls` and `styleUrl` are defined', () => {
env.write('dir/styles.css', 'h2 {width: 10px}');
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({
selector: 'test-cmp',
styleUrl: 'dir/styles.css',
styleUrls: ['dir/styles.css'],
template: '',
})
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toContain('@Component cannot define both `styleUrl` and `styleUrls`');
});
});
describe('empty resources', () => {