From 56137b5c7cb535540958a232f5242b8201fc794a Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Sat, 9 May 2026 18:42:32 +0000 Subject: [PATCH] feat(platform-browser): add Link service for managing link tags Adds a new Link service to @angular/platform-browser for managing HTML link tags. The API is modeled after the existing Meta service and supports adding, retrieving, updating, and removing link tags. Includes runtime tests, duplicate prevention logic, and DI integration tests for BrowserModule support. Closes #68220 --- packages/platform-browser/src/browser/link.ts | 201 +++++++++++++++ .../platform-browser/src/platform-browser.ts | 1 + .../test/browser/link_spec.ts | 244 ++++++++++++++++++ 3 files changed, 446 insertions(+) create mode 100644 packages/platform-browser/src/browser/link.ts create mode 100644 packages/platform-browser/test/browser/link_spec.ts 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)); + }); +});