diff --git a/modules/@angular/platform-browser/src/browser.ts b/modules/@angular/platform-browser/src/browser.ts index acc086c79c..73a6e9ae75 100644 --- a/modules/@angular/platform-browser/src/browser.ts +++ b/modules/@angular/platform-browser/src/browser.ts @@ -14,6 +14,7 @@ import {WebAnimationsDriver} from '../src/dom/web_animations_driver'; import {BrowserDomAdapter} from './browser/browser_adapter'; import {BrowserPlatformLocation} from './browser/location/browser_platform_location'; +import {Meta} from './browser/meta'; import {BrowserGetTestability} from './browser/testability'; import {Title} from './browser/title'; import {ELEMENT_PROBE_PROVIDERS} from './dom/debug/ng_probe'; @@ -46,7 +47,7 @@ export const BROWSER_SANITIZATION_PROVIDERS: Array = [ /** * @stable */ -export const platformBrowser = +export const platformBrowser: (extraProviders?: Provider[]) => PlatformRef = createPlatformFactory(platformCore, 'browser', INTERNAL_BROWSER_PLATFORM_PROVIDERS); export function initDomAdapter() { @@ -58,6 +59,10 @@ export function errorHandler(): ErrorHandler { return new ErrorHandler(); } +export function meta(): Meta { + return new Meta(getDOM()); +} + export function _document(): any { return getDOM().defaultDoc(); } @@ -76,7 +81,8 @@ export function _resolveDefaultAnimationDriver(): AnimationDriver { */ @NgModule({ providers: [ - BROWSER_SANITIZATION_PROVIDERS, {provide: ErrorHandler, useFactory: errorHandler, deps: []}, + BROWSER_SANITIZATION_PROVIDERS, + {provide: ErrorHandler, useFactory: errorHandler, deps: []}, {provide: DOCUMENT, useFactory: _document, deps: []}, {provide: EVENT_MANAGER_PLUGINS, useClass: DomEventsPlugin, multi: true}, {provide: EVENT_MANAGER_PLUGINS, useClass: KeyEventsPlugin, multi: true}, @@ -85,8 +91,13 @@ export function _resolveDefaultAnimationDriver(): AnimationDriver { {provide: DomRootRenderer, useClass: DomRootRenderer_}, {provide: RootRenderer, useExisting: DomRootRenderer}, {provide: SharedStylesHost, useExisting: DomSharedStylesHost}, - {provide: AnimationDriver, useFactory: _resolveDefaultAnimationDriver}, DomSharedStylesHost, - Testability, EventManager, ELEMENT_PROBE_PROVIDERS, Title + {provide: AnimationDriver, useFactory: _resolveDefaultAnimationDriver}, + {provide: Meta, useFactory: meta}, + DomSharedStylesHost, + Testability, + EventManager, + ELEMENT_PROBE_PROVIDERS, + Title, ], exports: [CommonModule, ApplicationModule] }) diff --git a/modules/@angular/platform-browser/src/browser/meta.ts b/modules/@angular/platform-browser/src/browser/meta.ts new file mode 100644 index 0000000000..30b9d6a15d --- /dev/null +++ b/modules/@angular/platform-browser/src/browser/meta.ts @@ -0,0 +1,114 @@ +/** + * @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 {Injectable} from '@angular/core'; +import {DomAdapter} from '../dom/dom_adapter'; + +/** + * Represents a meta element. + * + * @experimental + */ +export interface MetaDefinition { + charset?: string; + content?: string; + httpEquiv?: string; + id?: string; + itemprop?: string; + name?: string; + property?: string; + scheme?: string; + url?: string; + [prop: string]: string; +} + +/** + * A service that can be used to get and add meta tags. + * + * @experimental + */ +@Injectable() +export class Meta { + constructor(private _dom: DomAdapter) {} + + addTag(tag: MetaDefinition, forceCreation: boolean = false): HTMLMetaElement { + if (!tag) return null; + return this._getOrCreateElement(tag, forceCreation); + } + + 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; + }, []); + } + + getTag(attrSelector: string): HTMLMetaElement { + if (!attrSelector) return null; + return this._dom.query(`meta[${attrSelector}]`); + } + + getTags(attrSelector: string): HTMLMetaElement[] { + if (!attrSelector) return []; + const list /*NodeList*/ = + this._dom.querySelectorAll(this._dom.defaultDoc(), `meta[${attrSelector}]`); + return list ? [].slice.call(list) : []; + } + + updateTag(tag: MetaDefinition, selector?: string): HTMLMetaElement { + if (!tag) return null; + selector = selector || this._parseSelector(tag); + const meta: HTMLMetaElement = this.getTag(selector); + if (meta) { + return this._setMetaElementAttributes(tag, meta); + } + return this._getOrCreateElement(tag, true); + } + + removeTag(attrSelector: string): void { this.removeTagElement(this.getTag(attrSelector)); } + + removeTagElement(meta: HTMLMetaElement): void { + if (meta) { + this._dom.remove(meta); + } + } + + private _getOrCreateElement(meta: MetaDefinition, forceCreation: boolean = false): + HTMLMetaElement { + if (!forceCreation) { + const selector: string = this._parseSelector(meta); + const elem: HTMLMetaElement = this.getTag(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(meta, elem)) return elem; + } + const element: HTMLMetaElement = this._dom.createElement('meta') as HTMLMetaElement; + this._setMetaElementAttributes(meta, element); + const head = this._dom.getElementsByTagName(this._dom.defaultDoc(), 'head')[0]; + this._dom.appendChild(head, element); + return element; + } + + private _setMetaElementAttributes(tag: MetaDefinition, el: HTMLMetaElement): HTMLMetaElement { + Object.keys(tag).forEach((prop: string) => this._dom.setAttribute(el, prop, tag[prop])); + return el; + } + + private _parseSelector(tag: MetaDefinition): string { + const attr: string = tag.name ? 'name' : 'property'; + return `${attr}="${tag[attr]}"`; + } + + private _containsAttributes(tag: MetaDefinition, elem: HTMLMetaElement): boolean { + return Object.keys(tag).every((key: string) => this._dom.getAttribute(elem, key) === tag[key]); + } +} diff --git a/modules/@angular/platform-browser/src/platform-browser.ts b/modules/@angular/platform-browser/src/platform-browser.ts index 6b6f1c4a92..dbdbfd13da 100644 --- a/modules/@angular/platform-browser/src/platform-browser.ts +++ b/modules/@angular/platform-browser/src/platform-browser.ts @@ -7,6 +7,7 @@ */ export {BrowserModule, platformBrowser} from './browser'; +export {Meta, MetaDefinition} from './browser/meta'; export {Title} from './browser/title'; export {disableDebugTools, enableDebugTools} from './browser/tools/tools'; export {AnimationDriver} from './dom/animation_driver'; diff --git a/modules/@angular/platform-browser/test/browser/meta_spec.ts b/modules/@angular/platform-browser/test/browser/meta_spec.ts new file mode 100644 index 0000000000..46858ccb01 --- /dev/null +++ b/modules/@angular/platform-browser/test/browser/meta_spec.ts @@ -0,0 +1,192 @@ +/** + * @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 {Injectable} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {BrowserModule, Meta} from '@angular/platform-browser'; +import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; +import {expect} from '@angular/platform-browser/testing/matchers'; + +export function main() { + describe('Meta service', () => { + + const metaService: Meta = new Meta(getDOM()); + const doc: HTMLDocument = getDOM().defaultDoc(); + let defaultMeta: HTMLMetaElement; + + beforeEach(() => { + defaultMeta = getDOM().createElement('meta', doc) as HTMLMetaElement; + defaultMeta.setAttribute('property', 'fb:app_id'); + defaultMeta.setAttribute('content', '123456789'); + getDOM().getElementsByTagName(doc, 'head')[0].appendChild(defaultMeta); + }); + + afterEach(() => getDOM().remove(defaultMeta)); + + it('should return meta tag matching selector', () => { + const actual: HTMLMetaElement = metaService.getTag('property="fb:app_id"'); + expect(actual).not.toBeNull(); + expect(actual.content).toEqual('123456789'); + }); + + it('should return all meta tags matching selector', () => { + const tag1 = metaService.addTag({name: 'author', content: 'page author'}); + const tag2 = metaService.addTag({name: 'author', content: 'another page author'}); + + const actual: HTMLMetaElement[] = metaService.getTags('name=author'); + expect(actual.length).toEqual(2); + expect(actual[0].content).toEqual('page author'); + expect(actual[1].content).toEqual('another page author'); + + // clean up + metaService.removeTagElement(tag1); + metaService.removeTagElement(tag2); + }); + + it('should return null if meta tag does not exist', () => { + const actual: HTMLMetaElement = metaService.getTag('fake=fake'); + expect(actual).toBeNull(); + }); + + it('should remove meta tag by the given selector', () => { + expect(metaService.getTag('name=author')).toBeNull(); + + metaService.addTag({name: 'author', content: 'page author'}); + + expect(metaService.getTag('name=author')).not.toBeNull(); + + metaService.removeTag('name=author'); + + expect(metaService.getTag('name=author')).toBeNull(); + }); + + it('should remove meta tag by the given element', () => { + expect(metaService.getTag('name=keywords')).toBeNull(); + + metaService.addTags([{name: 'keywords', content: 'meta test'}]); + + const meta = metaService.getTag('name=keywords'); + expect(meta).not.toBeNull(); + + metaService.removeTagElement(meta); + + expect(metaService.getTag('name=keywords')).toBeNull(); + }); + + it('should update meta tag matching the given selector', () => { + metaService.updateTag({content: '4321'}, 'property="fb:app_id"'); + + const actual = metaService.getTag('property="fb:app_id"'); + expect(actual).not.toBeNull(); + expect(actual.content).toEqual('4321'); + }); + + it('should extract selector from the tag definition', () => { + metaService.updateTag({property: 'fb:app_id', content: '666'}); + + const actual = metaService.getTag('property="fb:app_id"'); + expect(actual).not.toBeNull(); + expect(actual.content).toEqual('666'); + }); + + it('should create meta tag if it does not exist', () => { + expect(metaService.getTag('name="twitter:title"')).toBeNull(); + + metaService.updateTag( + {name: 'twitter:title', content: 'Content Title'}, 'name="twitter:title"'); + + const actual = metaService.getTag('name="twitter:title"'); + expect(actual).not.toBeNull(); + expect(actual.content).toEqual('Content Title'); + + // clean up + metaService.removeTagElement(actual); + }); + + it('should add new meta tag', () => { + expect(metaService.getTag('name="og:title"')).toBeNull(); + + metaService.addTag({name: 'og:title', content: 'Content Title'}); + + const actual = metaService.getTag('name="og:title"'); + expect(actual).not.toBeNull(); + expect(actual.content).toEqual('Content Title'); + + // clean up + metaService.removeTagElement(actual); + }); + + it('should add multiple new meta tags', () => { + expect(metaService.getTag('name="twitter:title"')).toBeNull(); + expect(metaService.getTag('property="og:title"')).toBeNull(); + + metaService.addTags([ + {name: 'twitter:title', content: 'Content Title'}, + {property: 'og:title', content: 'Content Title'} + ]); + const twitterMeta = metaService.getTag('name="twitter:title"'); + const fbMeta = metaService.getTag('property="og:title"'); + expect(twitterMeta).not.toBeNull(); + expect(fbMeta).not.toBeNull(); + + // clean up + metaService.removeTagElement(twitterMeta); + metaService.removeTagElement(fbMeta); + }); + + it('should not add meta tag if it is already present on the page and has the same attr', () => { + expect(metaService.getTags('property="fb:app_id"').length).toEqual(1); + + metaService.addTag({property: 'fb:app_id', content: '123456789'}); + + expect(metaService.getTags('property="fb:app_id"').length).toEqual(1); + }); + + it('should add meta tag if it is already present on the page and but has different attr', + () => { + expect(metaService.getTags('property="fb:app_id"').length).toEqual(1); + + const meta = metaService.addTag({property: 'fb:app_id', content: '666'}); + + expect(metaService.getTags('property="fb:app_id"').length).toEqual(2); + + // clean up + metaService.removeTagElement(meta); + }); + + it('should add meta tag if it is already present on the page and force true', () => { + expect(metaService.getTags('property="fb:app_id"').length).toEqual(1); + + const meta = metaService.addTag({property: 'fb:app_id', content: '123456789'}, true); + + expect(metaService.getTags('property="fb:app_id"').length).toEqual(2); + + // clean up + metaService.removeTagElement(meta); + }); + + }); + + describe('integration test', () => { + + @Injectable() + class DependsOnMeta { + constructor(public meta: Meta) {} + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [BrowserModule], + providers: [DependsOnMeta], + }); + }); + + it('should inject Meta service when using BrowserModule', + () => expect(TestBed.get(DependsOnMeta).meta).toBeAnInstanceOf(Meta)); + }); +} diff --git a/tools/public_api_guard/platform-browser/index.d.ts b/tools/public_api_guard/platform-browser/index.d.ts index fde5572326..e66bd0cb56 100644 --- a/tools/public_api_guard/platform-browser/index.d.ts +++ b/tools/public_api_guard/platform-browser/index.d.ts @@ -58,6 +58,32 @@ export declare class HammerGestureConfig { buildHammer(element: HTMLElement): HammerInstance; } +/** @experimental */ +export declare class Meta { + constructor(_dom: DomAdapter); + addTag(tag: MetaDefinition, forceCreation?: boolean): HTMLMetaElement; + addTags(tags: MetaDefinition[], forceCreation?: boolean): HTMLMetaElement[]; + getTag(attrSelector: string): HTMLMetaElement; + getTags(attrSelector: string): HTMLMetaElement[]; + removeTag(attrSelector: string): void; + removeTagElement(meta: HTMLMetaElement): void; + updateTag(tag: MetaDefinition, selector?: string): HTMLMetaElement; +} + +/** @experimental */ +export interface MetaDefinition { + charset?: string; + content?: string; + httpEquiv?: string; + id?: string; + itemprop?: string; + name?: string; + property?: string; + scheme?: string; + url?: string; + [prop: string]: string; +} + /** @deprecated */ export declare class NgProbeToken { name: string;