Skip to content
Open
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
12 changes: 12 additions & 0 deletions goldens/public-api/platform-browser/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ export class By {
// @public
export function createApplication(options?: ApplicationConfig, context?: BootstrapContext): Promise<ApplicationRef>;

// @public
export class CssVarNamespacer {
namespace(name: string): string;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<CssVarNamespacer, never>;
// (undocumented)
static ɵprov: i0.ɵɵInjectableDeclaration<CssVarNamespacer>;
}

// @public
export function disableDebugTools(): void;

Expand Down Expand Up @@ -198,6 +207,9 @@ export const platformBrowser: (extraProviders?: StaticProvider[]) => PlatformRef
// @public
export function provideClientHydration(...features: HydrationFeature<HydrationFeatureKind>[]): EnvironmentProviders;

// @public
export function provideCssVarNamespacing(namespace: string): EnvironmentProviders;

// @public
export function provideProtractorTestingSupport(): Provider[];

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions packages/compiler/src/render3/view/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as core from '../../core';
import {CssSelector} from '../../directive_matching';
import * as o from '../../output/output_ast';
import {ParseError, ParseSourceSpan} from '../../parse_util';
import {ShadowCss} from '../../shadow_css';
import {namespaceCssVariables, ShadowCss} from '../../shadow_css';
import {CompilationJobKind, TemplateCompilationMode} from '../../template/pipeline/src/compilation';
import {emitHostBindingFunction, emitTemplateFn, transform} from '../../template/pipeline/src/emit';
import {ingestComponent, ingestHostBinding} from '../../template/pipeline/src/ingest';
Expand Down Expand Up @@ -300,10 +300,11 @@ export function compileComponentFromMetadata(
let hasStyles = !!meta.externalStyles?.length;
// e.g. `styles: [str1, str2]`
if (meta.styles && meta.styles.length) {
const namespacedStyles = meta.styles.map((s) => namespaceCssVariables(s));
const styleValues =
meta.encapsulation == core.ViewEncapsulation.Emulated
? compileStyles(meta.styles, CONTENT_ATTR, HOST_ATTR)
: meta.styles;
? compileStyles(namespacedStyles, CONTENT_ATTR, HOST_ATTR)
: namespacedStyles;
const styleNodes = styleValues.reduce((result, style) => {
if (style.trim().length > 0) {
result.push(constantPool.getConstLiteral(o.literal(style)));
Expand Down
23 changes: 23 additions & 0 deletions packages/compiler/src/shadow_css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,13 +1130,36 @@ const _cssCommaInPlaceholderReGlobal = new RegExp(COMMA_IN_PLACEHOLDER, 'g');
const _cssSemiInPlaceholderReGlobal = new RegExp(SEMI_IN_PLACEHOLDER, 'g');
const _cssColonInPlaceholderReGlobal = new RegExp(COLON_IN_PLACEHOLDER, 'g');

// Matches `/* DISABLE_NAMESPACING */` optionally followed by any characters
// that are not a semicolon or closing brace (e.g. whitespace or a property name),
// up until it hits a CSS variable `--some-name`.
const _cssNamespaceRe = new RegExp(
String.raw`(?:(/\*\s*DISABLE_NAMESPACING\s*\*/)[^;{}]*?)?(--[a-zA-Z0-9_-]+)`,
'g',
);

export class CssRule {
constructor(
public selector: string,
public content: string,
) {}
}

/**
* Transforms CSS variables within a stylesheet to include a namespace placeholder.
*
* E.g. `--foo: bar;` becomes `--%NS%foo: bar;`
* E.g. `color: var(--foo);` becomes `color: var(--%NS%foo);`
*
* If a declaration or usage is preceded by `/* DISABLE_NAMESPACING *\/`, it is NOT transformed.
*/
export function namespaceCssVariables(cssText: string): string {
return cssText.replace(_cssNamespaceRe, (match, disableComment, varName) => {
if (disableComment) return match;
return `--%NS%${varName.substring('--'.length)}`;
});
}

export function processRules(input: string, ruleCallback: (rule: CssRule) => CssRule): string {
const escaped = escapeInStrings(input);
const inputWithEscapedBlocks = escapeBlocks(escaped, CONTENT_PAIRS, BLOCK_PLACEHOLDER);
Expand Down
88 changes: 88 additions & 0 deletions packages/compiler/test/shadow_css/shadow_css_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {namespaceCssVariables} from '../../src/shadow_css';
import {shim} from './utils';

describe('ShadowCss', () => {
Expand Down Expand Up @@ -397,4 +398,91 @@ describe('ShadowCss', () => {
);
});
});

describe('CSS variable namespacing', () => {
it('should inject `%NS%` placeholder into CSS variable declarations and usages', () => {
const input = `
.foo {
--my-color: red;
color: var(--my-color, blue);
}
`.trim();

const expected = `
.foo {
--%NS%my-color: red;
color: var(--%NS%my-color, blue);
}
`.trim();

expect(namespaceCssVariables(input)).toEqualCss(expected);
});

it('should not inject `%NS%` when `/* DISABLE_NAMESPACING */` comment is present', () => {
const input = `
.foo {
/* DISABLE_NAMESPACING */
--my-color: green;
background: var(
/* DISABLE_NAMESPACING */
--my-color
);
}
`.trim();

const expected = `
.foo {
/* DISABLE_NAMESPACING */
--my-color: green;
background: var(
/* DISABLE_NAMESPACING */
--my-color
);
}
`.trim();

expect(namespaceCssVariables(input)).toEqualCss(expected);
});
it('should not inject `%NS%` when `/* DISABLE_NAMESPACING */` comment is present on the property declaration', () => {
const input = `
.foo {
/* DISABLE_NAMESPACING */
color: var(--my-color, blue);
}
`.trim();

const expected = `
.foo {
/* DISABLE_NAMESPACING */
color: var(--my-color, blue);
}
`.trim();

expect(namespaceCssVariables(input)).toEqualCss(expected);
});

it('should correctly handle multiple `var()` functions with isolated `/* DISABLE_NAMESPACING */` comments', () => {
const input = `
.foo {
border: var(/* DISABLE_NAMESPACING */ --border-size) solid var(--border-color);
box-shadow:
var(--shadow-1),
var(/* DISABLE_NAMESPACING */ --shadow-2),
var(--shadow-3);
}
`.trim();

const expected = `
.foo {
border: var(/* DISABLE_NAMESPACING */ --border-size) solid var(--%NS%border-color);
box-shadow:
var(--%NS%shadow-1),
var(/* DISABLE_NAMESPACING */ --shadow-2),
var(--%NS%shadow-3);
}
`.trim();

expect(namespaceCssVariables(input)).toEqualCss(expected);
});
});
});
2 changes: 1 addition & 1 deletion packages/core/test/acceptance/csp_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('CSP integration', () => {
for (let i = 0; i < styles.length; i++) {
const style = styles[i];
const nonce = style.getAttribute('nonce');
if (nonce && style.textContent?.includes('--csp-test-var')) {
if (nonce && style.textContent?.includes('csp-test-var')) {
nonces.push(nonce);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"CONTENT_ATTR",
"CONTEXT",
"CSP_NONCE",
"CSS_VAR_NAMESPACE",
"ChainedInjector",
"ChangeDetectionScheduler",
"ChangeDetectionSchedulerImpl",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"CONTENT_ATTR",
"CONTEXT",
"CSP_NONCE",
"CSS_VAR_NAMESPACE",
"ChainedInjector",
"ChangeDetectionScheduler",
"ChangeDetectionSchedulerImpl",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"COMPONENT_REGEX",
"COMPONENT_VARIABLE",
"CONTENT_ATTR",
"CSS_VAR_NAMESPACE",
"DefaultDomRenderer2",
"DomAdapter",
"DomEventsPlugin",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"CONTENT_ATTR",
"CONTEXT",
"CSP_NONCE",
"CSS_VAR_NAMESPACE",
"ChainedInjector",
"ChangeDetectionScheduler",
"ChangeDetectionSchedulerImpl",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"CONTENT_ATTR",
"CONTEXT",
"CSP_NONCE",
"CSS_VAR_NAMESPACE",
"ChainedInjector",
"ChangeDetectionScheduler",
"ChangeDetectionSchedulerImpl",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"CONTENT_ATTR",
"CONTEXT",
"CSP_NONCE",
"CSS_VAR_NAMESPACE",
"ChainedInjector",
"ChangeDetectionScheduler",
"ChangeDetectionSchedulerImpl",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"CONTEXT",
"CREATE_VIEW_TRANSITION",
"CSP_NONCE",
"CSS_VAR_NAMESPACE",
"CanActivate",
"CanDeactivate",
"ChainedInjector",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"CONTENT_ATTR",
"CONTEXT",
"CSP_NONCE",
"CSS_VAR_NAMESPACE",
"ChainedInjector",
"ChangeDetectionScheduler",
"ChangeDetectionSchedulerImpl",
Expand Down
47 changes: 47 additions & 0 deletions packages/platform-browser/src/dom/css_var_namespacer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {inject, Injectable} from '@angular/core';
import {CSS_VAR_NAMESPACE} from './dom_renderer';

/**
* A service that can be used to manually namespace CSS variable names at runtime.
* This is useful when reading or setting CSS variables dynamically in JavaScript that
* were transformed by the compiler during the build.
*
* @publicApi
*/
@Injectable({providedIn: 'root'})
export class CssVarNamespacer {
private readonly namespacePrefix = inject(CSS_VAR_NAMESPACE, {optional: true}) ?? '';

/**
* Prepends the namespace prefix to a CSS variable name.
*
* @param name The CSS variable name to namespace, including the leading `--`.
* @returns The namespaced CSS variable name, including the leading `--`. Returns the input
* unchanged if no namespace is configured.
*/
namespace(name: string): string {
// Validate that the whole `--foo` variable is passed in.
if (typeof ngDevMode === 'undefined' || ngDevMode) {
if (!name.startsWith('--')) {
throw new Error(
`CSS variable names passed to \`CssVarNamespacer\` must start with '--', got: '${name}'`,
);
}
}

// We want to support libraries which might be used by applications which do and don't
// namespace variables. Therefore the library always needs to use `CssVarNamespacer`, even
// though the application may not actually be namespacing anything.
if (!this.namespacePrefix) return name;

return `--${this.namespacePrefix}${name.substring('--'.length)}`;
}
}
Loading