diff --git a/packages/platform-browser/src/browser/link.ts b/packages/platform-browser/src/browser/link.ts new file mode 100644 index 000000000000..ed8085d40110 --- /dev/null +++ b/packages/platform-browser/src/browser/link.ts @@ -0,0 +1,201 @@ +/** + * @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 {DOCUMENT} from '@angular/common'; +import {Inject, Injectable} from '@angular/core'; + +/** + * Represents the attributes of an HTML `` element. + * + * @see [HTML link element](https://developer.mozilla.org/docs/Web/HTML/Element/link) + * @see {@link Link} + * + * @publicApi + */ +export type LinkDefinition = { + as?: string; + blocking?: string; + charset?: string; + crossorigin?: string; + disabled?: string; + fetchpriority?: string; + href?: string; + hreflang?: string; + imagesizes?: string; + imagesrcset?: string; + integrity?: string; + media?: string; + nonce?: string; + referrerpolicy?: string; + rel?: string; + rev?: string; + sizes?: string; + target?: string; + title?: string; + type?: string; +} & { + [prop: string]: string | undefined; +}; + +/** + * A service for managing HTML `` tags. + * + * Properties of the `LinkDefinition` object match the attributes of the + * HTML `` tag. These tags are used for canonical URLs, stylesheets, + * preload and prefetch hints, Web App Manifest references, and more. + * + * To identify specific `` tags in a document, use an attribute selection + * string in the format `"tag_attribute='value string'"`. + * For example, an `attrSelector` value of `"rel='canonical'"` matches a tag + * whose `rel` attribute has the value `"canonical"`. + * Selectors are used with the `querySelector()` Document method, + * in the format `link[{attrSelector}]`. + * + * @see [HTML link element](https://developer.mozilla.org/docs/Web/HTML/Element/link) + * @see [Document.querySelector()](https://developer.mozilla.org/docs/Web/API/Document/querySelector) + * + * @publicApi + */ +@Injectable({providedIn: 'root'}) +export class Link { + constructor(@Inject(DOCUMENT) private _doc: any) {} + + /** + * Retrieves or creates a specific `` tag element in the current HTML document. + */ + addTag(tag: LinkDefinition, forceCreation: boolean = false): HTMLLinkElement | null { + if (!tag) return null; + + return this._getOrCreateElement(tag, forceCreation); + } + + /** + * Retrieves or creates a set of `` tag elements in the current HTML document. + */ + addTags(tags: LinkDefinition[], forceCreation: boolean = false): HTMLLinkElement[] { + if (!tags) return []; + + return tags.reduce((result: HTMLLinkElement[], tag: LinkDefinition) => { + if (tag) { + result.push(this._getOrCreateElement(tag, forceCreation)); + } + + return result; + }, []); + } + + /** + * Retrieves a `` tag element in the current HTML document. + */ + getTag(attrSelector: string): HTMLLinkElement | null { + if (!attrSelector) return null; + + return this._doc.querySelector(`link[${attrSelector}]`) || null; + } + + /** + * Retrieves a set of `` tag elements in the current HTML document. + */ + getTags(attrSelector: string): HTMLLinkElement[] { + if (!attrSelector) return []; + + const list = this._doc.querySelectorAll(`link[${attrSelector}]`); + + return list ? [].slice.call(list) : []; + } + + /** + * Modifies an existing `` tag element in the current HTML document. + */ + updateTag(tag: LinkDefinition, selector?: string): HTMLLinkElement | null { + if (!tag) return null; + + selector = selector || this._parseSelector(tag); + + const link = this.getTag(selector); + + if (link) { + return this._setLinkElementAttributes(tag, link); + } + + return this._getOrCreateElement(tag, true); + } + + /** + * Removes an existing `` tag element from the current HTML document. + */ + removeTag(attrSelector: string): void { + this.removeTagElement(this.getTag(attrSelector)!); + } + + /** + * Removes a specific `` tag element from the document. + */ + removeTagElement(link: HTMLLinkElement): void { + if (link && link.parentNode) { + link.parentNode.removeChild(link); + } + } + + private _getOrCreateElement( + link: LinkDefinition, + forceCreation: boolean = false, + ): HTMLLinkElement { + if (!forceCreation) { + const selector = this._parseSelector(link); + + if (selector) { + const existing = this.getTags(selector).filter((elem) => + this._containsAttributes(link, elem), + )[0]; + + if (existing !== undefined) { + return existing; + } + } + } + + const element = this._doc.createElement('link') as HTMLLinkElement; + + this._setLinkElementAttributes(link, element); + + this._doc.head.appendChild(element); + + return element; + } + + private _setLinkElementAttributes(tag: LinkDefinition, el: HTMLLinkElement): HTMLLinkElement { + Object.keys(tag).forEach((prop: string) => { + const value = tag[prop]; + + if (value !== undefined) { + (el as unknown as Record)[prop] = value; + } + }); + + return el; + } + + private _parseSelector(tag: LinkDefinition): string { + const selectors: string[] = []; + + if (tag.rel) { + selectors.push(`rel="${tag.rel}"`); + } + + if (tag.href) { + selectors.push(`href="${tag.href}"`); + } + + return selectors.join(']['); + } + + private _containsAttributes(tag: LinkDefinition, elem: HTMLLinkElement): boolean { + return Object.keys(tag).every((key: string) => elem.getAttribute(key) === tag[key]); + } +} diff --git a/packages/platform-browser/src/platform-browser.ts b/packages/platform-browser/src/platform-browser.ts index 8e3b27de3f51..b50b4e092880 100644 --- a/packages/platform-browser/src/platform-browser.ts +++ b/packages/platform-browser/src/platform-browser.ts @@ -14,6 +14,7 @@ export { platformBrowser, provideProtractorTestingSupport, } from './browser'; +export {Link, LinkDefinition} from './browser/link'; export {Meta, MetaDefinition} from './browser/meta'; export {Title} from './browser/title'; export {disableDebugTools, enableDebugTools} from './browser/tools/tools'; diff --git a/packages/platform-browser/test/browser/link_spec.ts b/packages/platform-browser/test/browser/link_spec.ts new file mode 100644 index 000000000000..cca15eafc128 --- /dev/null +++ b/packages/platform-browser/test/browser/link_spec.ts @@ -0,0 +1,244 @@ +/** + * @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 {ɵgetDOM as getDOM} from '@angular/common'; +import {Injectable} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {expect} from '@angular/private/testing/matchers'; + +import {BrowserModule, Link} from '../../index'; + +describe('Link service', () => { + let doc: Document; + let linkService: Link; + let defaultLink: HTMLLinkElement; + + beforeEach(() => { + doc = getDOM().createHtmlDocument(); + + linkService = new Link(doc); + + defaultLink = getDOM().createElement('link', doc) as HTMLLinkElement; + defaultLink.setAttribute('rel', 'canonical'); + defaultLink.setAttribute('href', 'https://angular.dev'); + + doc.getElementsByTagName('head')[0].appendChild(defaultLink); + }); + + afterEach(() => { + if (defaultLink.parentNode) { + defaultLink.parentNode.removeChild(defaultLink); + } + }); + + it('should return link tag matching selector', () => { + const actual = linkService.getTag('rel="canonical"')!; + + expect(actual).not.toBeNull(); + expect(actual.getAttribute('href')).toEqual('https://angular.dev'); + }); + + it('should return all link tags matching selector', () => { + const link1 = linkService.addTag({ + rel: 'preload', + href: '/font1.woff2', + })!; + + const link2 = linkService.addTag({ + rel: 'preload', + href: '/font2.woff2', + })!; + + const actual = linkService.getTags('rel="preload"'); + + expect(actual.length).toEqual(2); + + expect(actual[0].getAttribute('href')).toEqual('/font1.woff2'); + expect(actual[1].getAttribute('href')).toEqual('/font2.woff2'); + + linkService.removeTagElement(link1); + linkService.removeTagElement(link2); + }); + + it('should return null if link tag does not exist', () => { + const actual = linkService.getTag('rel="fake"'); + + expect(actual).toBeNull(); + }); + + it('should remove link tag by the given selector', () => { + const selector = 'rel="manifest"'; + + expect(linkService.getTag(selector)).toBeNull(); + + linkService.addTag({ + rel: 'manifest', + href: '/manifest.webmanifest', + }); + + expect(linkService.getTag(selector)).not.toBeNull(); + + linkService.removeTag(selector); + + expect(linkService.getTag(selector)).toBeNull(); + }); + + it('should remove link tag by the given element', () => { + const selector = 'rel="stylesheet"'; + + linkService.addTag({ + rel: 'stylesheet', + href: '/styles.css', + }); + + const link = linkService.getTag(selector)!; + + expect(link).not.toBeNull(); + + linkService.removeTagElement(link); + + expect(linkService.getTag(selector)).toBeNull(); + }); + + it('should update link tag matching the given selector', () => { + const selector = 'rel="canonical"'; + + linkService.updateTag( + { + rel: 'canonical', + href: 'https://next.angular.dev', + }, + selector, + ); + + const actual = linkService.getTag(selector)!; + + expect(actual).not.toBeNull(); + + expect(actual.getAttribute('href')).toEqual('https://next.angular.dev'); + }); + + it('should create link tag if it does not exist', () => { + const selector = 'rel="manifest"'; + + linkService.updateTag( + { + rel: 'manifest', + href: '/manifest.webmanifest', + }, + selector, + ); + + const actual = linkService.getTag(selector)!; + + expect(actual).not.toBeNull(); + + expect(actual.getAttribute('href')).toEqual('/manifest.webmanifest'); + + linkService.removeTagElement(actual); + }); + + it('should add new link tag', () => { + const selector = 'rel="modulepreload"'; + + expect(linkService.getTag(selector)).toBeNull(); + + linkService.addTag({ + rel: 'modulepreload', + href: '/main.js', + }); + + const actual = linkService.getTag(selector)!; + + expect(actual).not.toBeNull(); + + expect(actual.getAttribute('href')).toEqual('/main.js'); + + linkService.removeTagElement(actual); + }); + + it('should add multiple new link tags', () => { + linkService.addTags([ + { + rel: 'preconnect', + href: 'https://fonts.googleapis.com', + }, + { + rel: 'dns-prefetch', + href: 'https://cdn.example.com', + }, + ]); + + expect(linkService.getTag('href="https://fonts.googleapis.com"')).not.toBeNull(); + + expect(linkService.getTag('href="https://cdn.example.com"')).not.toBeNull(); + }); + + it('should not add link tag if it is already present on the page and has the same attributes', () => { + const selector = 'rel="canonical"'; + + expect(linkService.getTags(selector).length).toEqual(1); + + linkService.addTag({ + rel: 'canonical', + href: 'https://angular.dev', + }); + + expect(linkService.getTags(selector).length).toEqual(1); + }); + + it('should add link tag if it has different attributes', () => { + const selector = 'rel="canonical"'; + + expect(linkService.getTags(selector).length).toEqual(1); + + const link = linkService.addTag({ + rel: 'canonical', + href: 'https://next.angular.dev', + })!; + + expect(linkService.getTags(selector).length).toEqual(2); + + linkService.removeTagElement(link); + }); + + it('should add link tag if forceCreation is true', () => { + const selector = 'rel="canonical"'; + + expect(linkService.getTags(selector).length).toEqual(1); + + const link = linkService.addTag( + { + rel: 'canonical', + href: 'https://angular.dev', + }, + true, + )!; + + expect(linkService.getTags(selector).length).toEqual(2); + + linkService.removeTagElement(link); + }); + + describe('integration test', () => { + @Injectable() + class DependsOnLink { + constructor(public link: Link) {} + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [BrowserModule], + providers: [DependsOnLink], + }); + }); + + it('should inject Link service when using BrowserModule', () => + expect(TestBed.inject(DependsOnLink).link).toBeInstanceOf(Link)); + }); +});