Skip to content
Closed
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
128 changes: 128 additions & 0 deletions packages/platform-browser/src/browser/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* @license
* Copyright Google Inc. 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.io/license
*/

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

/**
* Represents a link element.
*
* @publicApi
*/
export type LinkDefinition = {
as?: string; crossorigin?: 'anonymous' | 'use-credentials'; disabled?: boolean; href?: string;
hreflang?: string;
importance?: 'auto' | 'high' | 'low';
integrity?: string;
media?: string;
methods?: string;
prefetch?: string;
referrerpolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' |
'origin-when-cross-origin' | 'unsafe-url';
rel?: string;
sizes?: string;
target?: string;
title?: string;
type?: string;
} &
{
[prop: string]: string;
};

/**
* Factory to create Link service.
*/
export function createLink() {
return new Link(ɵɵinject(DOCUMENT));
}

/**
* A service that can be used to get and add link tags.
*
* @publicApi
*/
@Injectable({providedIn: 'root', useFactory: createLink, deps: []})
export class Link {
private _dom: DomAdapter;
constructor(@Inject(DOCUMENT) private _doc: any) { this._dom = getDOM(); }

addLink(link: LinkDefinition, forceCreation: boolean = false): HTMLLinkElement|null {
if (!link) return null;
return this._getOrCreateElement(link, forceCreation);
}

addLinks(links: LinkDefinition[], forceCreation: boolean = false): HTMLLinkElement[] {
if (!links) return [];
return links.reduce((result: HTMLLinkElement[], link: LinkDefinition) => {
if (link) {
result.push(this._getOrCreateElement(link, forceCreation));
}
return result;
}, []);
}

getLink(attrSelector: string): HTMLLinkElement|null {
if (!attrSelector) return null;
return this._doc.querySelector(`link[${attrSelector}]`) || null;
}

getLinks(attrSelector: string): HTMLLinkElement[] {
if (!attrSelector) return [];
const list /*NodeList*/ = this._doc.querySelectorAll(`link[${attrSelector}]`);
return list ? [].slice.call(list) : [];
}

updateLink(link: LinkDefinition, selector?: string): HTMLLinkElement|null {
if (!link) return null;
selector = selector || this._parseSelector(link);
const linkEl: HTMLLinkElement = this.getLink(selector) !;
if (linkEl) {
return this._setLinkElementAttributes(link, linkEl);
}
return this._getOrCreateElement(link, true);
}

removeLink(attrSelector: string): void { this.removeLinkElement(this.getLink(attrSelector) !); }

removeLinkElement(link: HTMLLinkElement): void {
if (link) {
this._dom.remove(link);
}
}

private _getOrCreateElement(link: LinkDefinition, forceCreation: boolean = false):
HTMLLinkElement {
if (!forceCreation) {
const selector: string = this._parseSelector(link);
const elem: HTMLLinkElement = this.getLink(selector) !;
// 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
if (elem && this._containsAttributes(link, elem)) return elem;
}
const element: HTMLLinkElement = this._dom.createElement('link') as HTMLLinkElement;
this._setLinkElementAttributes(link, element);
const head = this._doc.getElementsByTagName('head')[0];
head.appendChild(element);
return element;
}

private _setLinkElementAttributes(link: LinkDefinition, el: HTMLLinkElement): HTMLLinkElement {
Object.keys(link).forEach((prop: string) => el.setAttribute(prop, link[prop]));
return el;
}

private _parseSelector(link: LinkDefinition): string {
const attr: string = link.rel ? 'rel' : 'href';
return `${attr}="${link[attr]}"`;
}

private _containsAttributes(link: LinkDefinition, elem: HTMLLinkElement): boolean {
return Object.keys(link).every((key: string) => elem.getAttribute(key) === link[key]);
}
}
1 change: 1 addition & 0 deletions packages/platform-browser/src/platform-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

export {BrowserModule, platformBrowser} 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';
Expand Down
194 changes: 194 additions & 0 deletions packages/platform-browser/test/browser/link_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* @license
* Copyright Google Inc. 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.io/license
*/

import {ɵgetDOM as getDOM} from '@angular/common';
import {Injectable} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {BrowserModule, Link} from '@angular/platform-browser';
import {expect} from '@angular/platform-browser/testing/src/matchers';

{
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://example.com');
doc.getElementsByTagName('head')[0].appendChild(defaultLink);
});

afterEach(() => getDOM().remove(defaultLink));

it('should return link tag matching selector', () => {
const actual: HTMLLinkElement = linkService.getLink('rel="canonical"') !;
expect(actual).not.toBeNull();
expect(actual.getAttribute('href')).toEqual('https://example.com');
});

it('should return all link tags matching selector', () => {
const tag1 = linkService.addLink({rel: 'stylesheet', href: 'http://foo.bar'}) !;
const tag2 = linkService.addLink({rel: 'stylesheet', href: 'http://bar.baz'}) !;

const actual: HTMLLinkElement[] = linkService.getLinks('rel=stylesheet');
expect(actual.length).toEqual(2);
expect(actual[0].getAttribute('href')).toEqual('http://foo.bar');
expect(actual[1].getAttribute('href')).toEqual('http://bar.baz');

// clean up
linkService.removeLinkElement(tag1);
linkService.removeLinkElement(tag2);
});

it('should return null if link tag does not exist', () => {
const actual: HTMLLinkElement = linkService.getLink('fake=fake') !;
expect(actual).toBeNull();
});

it('should remove link tag by the given selector', () => {
const selector = 'rel=stylesheet';
expect(linkService.getLink(selector)).toBeNull();

linkService.addLink({rel: 'stylesheet', href: 'http://foo.bar'});

expect(linkService.getLink(selector)).not.toBeNull();

linkService.removeLink(selector);

expect(linkService.getLink(selector)).toBeNull();
});

it('should remove link tag by the given element', () => {
const selector = 'rel=stylesheet';
expect(linkService.getLink(selector)).toBeNull();

linkService.addLinks([{rel: 'stylesheet', href: 'http://foo.bar'}]);

const link = linkService.getLink(selector) !;
expect(link).not.toBeNull();

linkService.removeLinkElement(link);

expect(linkService.getLink(selector)).toBeNull();
});

it('should update link tag matching the given selector', () => {
const selector = 'rel="stylesheet"';
linkService.updateLink({rel: 'stylesheet'}, selector);

const actual = linkService.getLink(selector);
expect(actual).not.toBeNull();
expect(actual !.getAttribute('rel')).toEqual('stylesheet');
});

it('should extract selector from the tag definition', () => {
const selector = 'rel="stylesheet"';
linkService.updateLink({rel: 'stylesheet', href: 'http://foo.bar'});

const actual = linkService.getLink(selector);
expect(actual).not.toBeNull();
expect(actual !.getAttribute('href')).toEqual('http://foo.bar');
});

it('should create link tag if it does not exist', () => {
const selector = 'rel="stylesheet"';

linkService.updateLink({rel: 'stylesheet', href: 'http://foo.bar'}, selector);

const actual = linkService.getLink(selector) !;
expect(actual).not.toBeNull();
expect(actual.getAttribute('href')).toEqual('http://foo.bar');

// clean up
linkService.removeLinkElement(actual);
});

it('should add new link tag', () => {
const selector = 'rel="stylesheet"';
expect(linkService.getLink(selector)).toBeNull();

linkService.addLink({rel: 'stylesheet', href: 'http://foo.bar'});

const actual = linkService.getLink(selector) !;
expect(actual).not.toBeNull();
expect(actual.getAttribute('href')).toEqual('http://foo.bar');

// clean up
linkService.removeLinkElement(actual);
});

it('should add multiple new link tags', () => {
const nameSelector = 'rel="stylesheet"';
const propertySelector = 'rel="author"';
expect(linkService.getLink(nameSelector)).toBeNull();
expect(linkService.getLink(propertySelector)).toBeNull();

linkService.addLinks(
[{rel: 'stylesheet', href: 'http://foo.bar'}, {rel: 'author', href: 'http://bar.baz'}]);
const canonicalLink = linkService.getLink(nameSelector) !;
const authorLink = linkService.getLink(propertySelector) !;
expect(canonicalLink).not.toBeNull();
expect(authorLink).not.toBeNull();

// clean up
linkService.removeLinkElement(canonicalLink);
linkService.removeLinkElement(authorLink);
});

it('should not add link tag if it is already present on the page and has the same attr', () => {
const selector = 'rel="canonical"';
expect(linkService.getLinks(selector).length).toEqual(1);

linkService.addLink({rel: 'canonical', href: 'https://example.com'});
expect(linkService.getLinks(selector).length).toEqual(1);
});

it('should add link tag if it is already present on the page and but has different attr',
() => {
const selector = 'rel="canonical"';
expect(linkService.getLinks(selector).length).toEqual(1);

const link = linkService.addLink({rel: 'canonical', href: 'http://bar.baz'}) !;

expect(linkService.getLinks(selector).length).toEqual(2);

// clean up
linkService.removeLinkElement(link);
});

it('should add link tag if it is already present on the page and force true', () => {
const selector = 'rel="canonical"';
expect(linkService.getLinks(selector).length).toEqual(1);

const link = linkService.addLink({rel: 'canonical', href: 'http://bar.baz'}, true) !;

expect(linkService.getLinks(selector).length).toEqual(2);

// clean up
linkService.removeLinkElement(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).toBeAnInstanceOf(Link));
});
}
7 changes: 1 addition & 6 deletions packages/platform-browser/test/browser/meta_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,21 +179,16 @@ import {expect} from '@angular/platform-browser/testing/src/matchers';
// clean up
metaService.removeTagElement(meta);
});

});

describe('integration test', () => {

@Injectable()
class DependsOnMeta {
constructor(public meta: Meta) {}
}

beforeEach(() => {
TestBed.configureTestingModule({
imports: [BrowserModule],
providers: [DependsOnMeta],
});
TestBed.configureTestingModule({imports: [BrowserModule], providers: [DependsOnMeta]});
});

it('should inject Meta service when using BrowserModule',
Expand Down
32 changes: 32 additions & 0 deletions tools/public_api_guard/platform-browser/platform-browser.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,38 @@ export declare type HammerLoader = () => Promise<void>;
export declare class HammerModule {
}

export declare class Link {
constructor(_doc: any);
addLink(link: LinkDefinition, forceCreation?: boolean): HTMLLinkElement | null;
addLinks(links: LinkDefinition[], forceCreation?: boolean): HTMLLinkElement[];
getLink(attrSelector: string): HTMLLinkElement | null;
getLinks(attrSelector: string): HTMLLinkElement[];
removeLink(attrSelector: string): void;
removeLinkElement(link: HTMLLinkElement): void;
updateLink(link: LinkDefinition, selector?: string): HTMLLinkElement | null;
}

export declare type LinkDefinition = {
as?: string;
crossorigin?: 'anonymous' | 'use-credentials';
disabled?: boolean;
href?: string;
hreflang?: string;
importance?: 'auto' | 'high' | 'low';
integrity?: string;
media?: string;
methods?: string;
prefetch?: string;
referrerpolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'unsafe-url';
rel?: string;
sizes?: string;
target?: string;
title?: string;
type?: string;
} & {
[prop: string]: string;
};

export declare function makeStateKey<T = void>(key: string): StateKey<T>;

export declare class Meta {
Expand Down