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
1 change: 0 additions & 1 deletion goldens/public-api/platform-browser/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@ export enum HydrationFeatureKind {

// @public
export class Meta {
constructor(_doc: any);
addTag(tag: MetaDefinition, forceCreation?: boolean): HTMLMetaElement | null;
addTags(tags: MetaDefinition[], forceCreation?: boolean): HTMLMetaElement[];
getTag(attrSelector: string): HTMLMetaElement | null;
Expand Down
103 changes: 48 additions & 55 deletions packages/platform-browser/src/browser/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {DOCUMENT, ɵDomAdapter as DomAdapter, ɵgetDOM as getDOM} from '@angular/common';
import {Inject, Injectable} from '@angular/core';
import {DOCUMENT, ɵgetDOM as getDOM} from '@angular/common';
import {inject, Service} from '@angular/core';

/**
* Represents the attributes of an HTML `<meta>` element. The element itself is
Expand Down Expand Up @@ -55,12 +55,12 @@ export type MetaDefinition = {
*
* @publicApi
*/
@Injectable({providedIn: 'root'})
@Service()
export class Meta {
private _dom: DomAdapter;
constructor(@Inject(DOCUMENT) private _doc: any) {
this._dom = getDOM();
}
private readonly _doc = inject(DOCUMENT);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Should this also apply to Title since it is similar, or only for the moment to Meta?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'll need to check first if that doesn't break g3 (because it will require an injection context)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, when I tried both back then, we had some breaks within G3.

private readonly _dom = getDOM();
private _cachedHead: HTMLHeadElement | undefined;

/**
* Retrieves or creates a specific `<meta>` tag element in the current HTML document.
* In searching for an existing tag, Angular attempts to match the `name` or `property` attribute
Expand All @@ -85,13 +85,9 @@ export class Meta {
* @returns The matching elements if found, or the new elements.
*/
addTags(tags: MetaDefinition[], forceCreation: boolean = false): HTMLMetaElement[] {
if (!tags) return [];
return tags.reduce((result: HTMLMetaElement[], tag: MetaDefinition) => {
if (tag) {
result.push(this._getOrCreateElement(tag, forceCreation));
}
return result;
}, []);
return tags
.filter((tag): tag is MetaDefinition => !!tag)
.map((tag) => this._getOrCreateElement(tag, forceCreation));
}

/**
Expand All @@ -102,8 +98,8 @@ export class Meta {
*/
getTag(attrSelector: string): HTMLMetaElement | null {
if (!attrSelector) return null;
const meta = this._doc.querySelector(`meta[${attrSelector}]`);
return meta?.nodeName.toLowerCase() === 'meta' ? meta : null;
const meta = this._doc.querySelector<HTMLMetaElement>(buildMetaSelector(attrSelector));
return isMetaTag(meta) ? meta : null;
}

/**
Expand All @@ -114,12 +110,8 @@ export class Meta {
*/
getTags(attrSelector: string): HTMLMetaElement[] {
if (!attrSelector) return [];
const list /*NodeList*/ = this._doc.querySelectorAll(`meta[${attrSelector}]`);
return list
? (([].slice.call(list) as HTMLElement[]).filter(
(elem) => elem.nodeName.toLowerCase() === 'meta',
) as HTMLMetaElement[])
: [];
const list = this._doc.querySelectorAll<HTMLMetaElement>(buildMetaSelector(attrSelector));
return list ? Array.from(list).filter((elem) => isMetaTag(elem)) : [];
}

/**
Expand All @@ -132,11 +124,11 @@ export class Meta {
* @return The modified element.
*/
updateTag(tag: MetaDefinition, selector?: string): HTMLMetaElement | null {
if (!tag) return null;
selector = selector || this._parseSelector(tag);
const meta: HTMLMetaElement = this.getTag(selector)!;
selector ??= parseSelector(tag);
const meta = this.getTag(selector);
if (meta) {
return this._setMetaElementAttributes(tag, meta);
setMetaElementAttributes(tag, meta);
return meta;
}
return this._getOrCreateElement(tag, true);
}
Expand Down Expand Up @@ -165,52 +157,53 @@ export class Meta {
forceCreation: boolean = false,
): HTMLMetaElement {
if (!forceCreation) {
const selector: string = this._parseSelector(meta);
const selector: string = parseSelector(meta);
// It's allowed to have multiple elements with the same name so it's not enough to
// just check that element with the same name already present on the page. We also need to
// check if element has tag attributes
const elem = this.getTags(selector).filter((elem) => this._containsAttributes(meta, elem))[0];
const elem = this.getTags(selector).filter((elem) => containsAttributes(meta, elem))[0];
if (elem !== undefined) return elem;
}
const element: HTMLMetaElement = this._dom.createElement('meta') as HTMLMetaElement;
this._setMetaElementAttributes(meta, element);
setMetaElementAttributes(meta, element);
const head = this._doc.getElementsByTagName('head')[0];
head.appendChild(element);
return element;
}
}

private _setMetaElementAttributes(tag: MetaDefinition, el: HTMLMetaElement): HTMLMetaElement {
Object.keys(tag).forEach((prop: string) =>
el.setAttribute(this._getMetaKeyMap(prop), tag[prop]),
);
return el;
}
function buildMetaSelector(attrSelector: string): string {
return `meta[${attrSelector}]`;
}

private _parseSelector(tag: MetaDefinition): string {
const attr: string = tag.name ? 'name' : 'property';
return `${attr}=${this._escapeSelectorValue(String(tag[attr]))}`;
}
function setMetaElementAttributes(tag: MetaDefinition, el: HTMLMetaElement) {
Object.keys(tag).forEach((prop: string) => el.setAttribute(getMetaKeyMap(prop), tag[prop]));
}

private _escapeSelectorValue(value: string): string {
// Escape backslashes and double quotes to prevent CSS selector injection.
// This securely confines the value inside an attribute selector.
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function parseSelector(tag: MetaDefinition): string {
const attr: string = tag.name ? 'name' : 'property';
return `${attr}=${escapeSelectorValue(String(tag[attr]))}`;
}

private _containsAttributes(tag: MetaDefinition, elem: HTMLMetaElement): boolean {
return Object.keys(tag).every(
(key: string) => elem.getAttribute(this._getMetaKeyMap(key)) === tag[key],
);
}
function escapeSelectorValue(value: string): string {
// Escape backslashes and double quotes to prevent CSS selector injection.
// This securely confines the value inside an attribute selector.
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}

private _getMetaKeyMap(prop: string): string {
return META_KEYS_MAP[prop] || prop;
}
function containsAttributes(tag: MetaDefinition, elem: HTMLMetaElement): boolean {
return Object.keys(tag).every((key) => elem.getAttribute(getMetaKeyMap(key)) === tag[key]);
}

function getMetaKeyMap(prop: string): string {
return META_KEYS_MAP[prop] || prop;
}

function isMetaTag(tag: HTMLElement | null): tag is HTMLMetaElement {
return tag?.nodeName.toLowerCase() === 'meta';
}

/**
* Mapping for MetaDefinition properties with their correct meta attribute names
*/
const META_KEYS_MAP: {[prop: string]: string} = {
httpEquiv: 'http-equiv',
};
const META_KEYS_MAP: {[prop: string]: string} = {httpEquiv: 'http-equiv'};
6 changes: 4 additions & 2 deletions packages/platform-browser/test/browser/meta_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {ɵgetDOM as getDOM} from '@angular/common';
import {Injectable} from '@angular/core';
import {DOCUMENT, Injectable} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {expect} from '@angular/private/testing/matchers';
import {BrowserModule, Meta} from '../../index';
Expand All @@ -19,7 +19,8 @@ describe('Meta service', () => {

beforeEach(() => {
doc = getDOM().createHtmlDocument();
metaService = new Meta(doc);
TestBed.configureTestingModule({providers: [{provide: DOCUMENT, useValue: doc}]});
metaService = TestBed.inject(Meta);
defaultMeta = getDOM().createElement('meta', doc) as HTMLMetaElement;
defaultMeta.setAttribute('property', 'fb:app_id');
defaultMeta.setAttribute('content', '123456789');
Expand Down Expand Up @@ -261,6 +262,7 @@ describe('Meta service', () => {
}

beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [BrowserModule],
providers: [DependsOnMeta],
Expand Down
Loading