diff --git a/projects/ngx-meta/api-extractor/ngx-meta.api.md b/projects/ngx-meta/api-extractor/ngx-meta.api.md index 63410828..28ab5e6c 100644 --- a/projects/ngx-meta/api-extractor/ngx-meta.api.md +++ b/projects/ngx-meta/api-extractor/ngx-meta.api.md @@ -199,6 +199,33 @@ export interface NgxMetaCoreModuleForRootOptions { defaults?: MetadataValues; } +// @alpha +export type NgxMetaElementAttributes = Partial<{ + charset: string; + content: string; + 'http-equiv': string; + id: string; + itemprop: string; + name: string; + property: string; + scheme: string; + url: string; + media: string; +}> & { + [key: string]: string; +}; + +// @alpha +export type NgxMetaElementNameAttribute = readonly [name: string, value: string]; + +// @beta +export class NgxMetaElementsService { + constructor(meta: Meta); + // Warning: (ae-incompatible-release-tags) The symbol "set" is marked as @beta, but its signature references "NgxMetaElementNameAttribute" which is marked as @alpha + // Warning: (ae-incompatible-release-tags) The symbol "set" is marked as @beta, but its signature references "NgxMetaElementAttributes" which is marked as @alpha + set(nameAttribute: NgxMetaElementNameAttribute, content: ReadonlyArray | NgxMetaElementAttributes | undefined): void; +} + // @public export class NgxMetaJsonLdModule { } @@ -554,12 +581,24 @@ export type _UrlResolver = (url: URL | string | undefined | null | AngularRouter // @internal export const _urlResolver: _LazyInjectionToken<_UrlResolver>; +// @alpha +export const withContentAttribute: { + (content: null | undefined, extras?: NgxMetaElementAttributes): undefined; + (content: string, extras?: NgxMetaElementAttributes): NgxMetaElementAttributes; +}; + +// @alpha +export const withNameAttribute: (value: string) => readonly ["name", string]; + // @public export const withNgxMetaBaseUrl: (baseUrl: BaseUrl) => CoreFeature; // @public export const withNgxMetaDefaults: (defaults: MetadataValues) => CoreFeature; +// @alpha +export const withPropertyAttribute: (value: string) => readonly ["property", string]; + // (No @packageDocumentation comment for this package) ``` diff --git a/projects/ngx-meta/src/core/src/meta-elements/index.ts b/projects/ngx-meta/src/core/src/meta-elements/index.ts index b520ad3e..67984659 100644 --- a/projects/ngx-meta/src/core/src/meta-elements/index.ts +++ b/projects/ngx-meta/src/core/src/meta-elements/index.ts @@ -9,3 +9,4 @@ export { export { NgxMetaMetaService } from './ngx-meta-meta.service' export { NgxMetaMetaContent } from './ngx-meta-meta-content' export { NgxMetaMetaDefinition } from './ngx-meta-meta-definition' +export * from './v2' diff --git a/projects/ngx-meta/src/core/src/meta-elements/v2/index.ts b/projects/ngx-meta/src/core/src/meta-elements/v2/index.ts new file mode 100644 index 00000000..5d388e5b --- /dev/null +++ b/projects/ngx-meta/src/core/src/meta-elements/v2/index.ts @@ -0,0 +1,6 @@ +export { NgxMetaElementsService } from './ngx-meta-elements.service' +export { NgxMetaElementNameAttribute } from './ngx-meta-element-name-attribute' +export { NgxMetaElementAttributes } from './ngx-meta-element-attributes' +export { withNameAttribute } from './with-name-attribute' +export { withPropertyAttribute } from './with-property-attribute' +export { withContentAttribute } from './with-content-attribute' diff --git a/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-element-attributes.ts b/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-element-attributes.ts new file mode 100644 index 00000000..e288d146 --- /dev/null +++ b/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-element-attributes.ts @@ -0,0 +1,26 @@ +/** + * Models a `` element HTML's attributes as a key / value JSON object. + * + * Almost equivalent to Angular's {@link https://angular.dev/api/platform-browser/MetaDefinition/ | MetaDefinition} + * + * Only difference is `http-equiv` property. In an Angular's + * {@link https://angular.dev/api/platform-browser/MetaDefinition/ | MetaDefinition}, `httpEquiv` would also be + * accepted. This way there's no need to quote the key property. + * But without `httpEquiv` there's no need to map attribute names. So one bit of code less. + * + * @alpha + */ +export type NgxMetaElementAttributes = Partial<{ + charset: string + content: string + 'http-equiv': string + id: string + itemprop: string + name: string + property: string + scheme: string + url: string + media: string +}> & { + [key: string]: string +} diff --git a/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-element-name-attribute.ts b/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-element-name-attribute.ts new file mode 100644 index 00000000..c41cfdbd --- /dev/null +++ b/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-element-name-attribute.ts @@ -0,0 +1,6 @@ +/** + * See {@link NgxMetaElementsService.set} + * + * @alpha + */ +export type NgxMetaElementNameAttribute = readonly [name: string, value: string] diff --git a/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-elements.service.spec.ts b/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-elements.service.spec.ts new file mode 100644 index 00000000..6490e0b0 --- /dev/null +++ b/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-elements.service.spec.ts @@ -0,0 +1,172 @@ +import { TestBed } from '@angular/core/testing' +import { NgxMetaElementsService } from './ngx-meta-elements.service' +import { withNameAttribute } from './with-name-attribute' +import { withContentAttribute } from './with-content-attribute' +import { Meta, MetaDefinition } from '@angular/platform-browser' + +describe('Meta element service', () => { + const dummyMetaNameAttribute = withNameAttribute('dummy') + const dummyMetaNameAttributeKeyValue = { name: 'dummy' } + const dummyMetaContentAttribute = withContentAttribute('dummy') + const dummyMetaAttributes = { + ...dummyMetaNameAttributeKeyValue, + content: 'dummy', + } + + const anotherDummyMetaContentAttribute = withContentAttribute('another-dummy') + const anotherDummyMetaAttributes = { + ...dummyMetaNameAttributeKeyValue, + content: 'another-dummy', + } + + const yetAnotherDummyMetaContentAttribute = + withContentAttribute('yet-another-dummy') + const yetAnotherDummyMetaAttributes = { + ...dummyMetaNameAttributeKeyValue, + content: 'yet-another-dummy', + } + + const getDummyMetaElements = () => + TestBed.inject(Meta).getTags('name="dummy"') + + afterEach(() => { + getDummyMetaElements().forEach((element) => element.remove()) + }) + + describe('when no elements exist', () => { + describe('when no contents are provided', () => { + const TEST_CASES = [ + [[], 'empty array'], + [undefined, 'undefined'], + ] as const + + TEST_CASES.forEach(([testCase, testCaseName]) => { + describe(`like when ${testCaseName}`, () => { + it('should not create any element', () => { + const sut = makeSut() + + sut.set(dummyMetaNameAttribute, testCase) + + expect(getDummyMetaElements()).toHaveSize(0) + }) + }) + }) + }) + + describe('when contents are provided', () => { + describe('when a single content is provided', () => { + it('should create the element', () => { + const sut = makeSut() + sut.set(dummyMetaNameAttribute, dummyMetaContentAttribute) + + const elements = getDummyMetaElements() + expect(elements.length).toBe(1) + const element = elements[0] + expect(htmlAttributesToJson(element.attributes)).toEqual( + dummyMetaAttributes, + ) + }) + }) + + describe('when multiple contents are provided', () => { + it('should create an element for each one', () => { + const sut = makeSut() + sut.set(dummyMetaNameAttribute, [ + dummyMetaContentAttribute, + anotherDummyMetaContentAttribute, + ]) + + const elements = getDummyMetaElements() + expect( + elements.map((e) => e.attributes).map(htmlAttributesToJson), + ).toEqual([dummyMetaAttributes, anotherDummyMetaAttributes]) + }) + }) + }) + }) + + describe('when elements exist', () => { + let sut: NgxMetaElementsService + + beforeEach(() => { + sut = makeSut() + + const dummyNameAttribute = { + [dummyMetaNameAttribute[0]]: dummyMetaNameAttribute[1], + } + TestBed.inject(Meta).addTags([ + { + ...dummyNameAttribute, + content: 'existing-content-1', + }, + { + ...dummyNameAttribute, + content: 'existing-content-2', + }, + ] as MetaDefinition[]) + expect(getDummyMetaElements()) + .withContext('test setup: two elements should exist') + .toHaveSize(2) + }) + + describe('when no contents are provided', () => { + const TEST_CASES = [ + [[], 'empty array'], + [undefined, 'undefined'], + ] as const + + TEST_CASES.forEach(([testCase, testCaseName]) => { + describe(`like when ${testCaseName}`, () => { + it('should remove them all', () => { + sut.set(dummyMetaNameAttribute, testCase) + + expect(getDummyMetaElements()).toHaveSize(0) + }) + }) + }) + }) + + describe('when contents are provided', () => { + describe('when a single content is provided', () => { + it('should remove existing elements and create the new one', () => { + sut.set(dummyMetaNameAttribute, dummyMetaContentAttribute) + + const elements = getDummyMetaElements() + expect(elements.length).toBe(1) + const element = elements[0] + expect(htmlAttributesToJson(element.attributes)).toEqual( + dummyMetaAttributes, + ) + }) + }) + + describe('when multiple contents are provided', () => { + it('should remove existing elements and create new ones', () => { + sut.set(dummyMetaNameAttribute, [ + dummyMetaContentAttribute, + anotherDummyMetaContentAttribute, + yetAnotherDummyMetaContentAttribute, + ]) + + expect( + getDummyMetaElements() + .map((e) => e.attributes) + .map(htmlAttributesToJson), + ).toEqual([ + dummyMetaAttributes, + anotherDummyMetaAttributes, + yetAnotherDummyMetaAttributes, + ]) + }) + }) + }) + }) +}) + +const makeSut = () => TestBed.inject(NgxMetaElementsService) + +export const htmlAttributesToJson = (attributes: NamedNodeMap): object => + [...Array(attributes.length).keys()] + .map((index) => attributes.item(index)) + .map((item) => (item ? { [item.name]: item.value } : {})) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}) diff --git a/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-elements.service.ts b/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-elements.service.ts new file mode 100644 index 00000000..c4b6ea68 --- /dev/null +++ b/projects/ngx-meta/src/core/src/meta-elements/v2/ngx-meta-elements.service.ts @@ -0,0 +1,126 @@ +import { NgxMetaElementAttributes } from './ngx-meta-element-attributes' +import { Meta } from '@angular/platform-browser' +import { Injectable } from '@angular/core' +import { NgxMetaElementNameAttribute } from './ngx-meta-element-name-attribute' + +/** + * Manages `` elements inside `` + * + * API is in alpha state. But appears as beta due a tooling limitation + * + * @beta + */ +@Injectable({ providedIn: 'root' }) +export class NgxMetaElementsService { + constructor(private meta: Meta) {} + + /** + * Creates, updates or removes some kind of `` elements inside `` in a declarative fashion. + * + * API is in alpha state. But appears as beta due a tooling limitation + * + * Kind of `` elements to manage are identified by an HTML attribute providing its metadata name. + * For instance, to manage description metadata elements (``) on the page, the + * `name` attribute with `description` value identifies them. + * + * Then, contents for those can be specified. In the shape of a key/value JSON object declaring each element's + * additional attributes. Mainly `content` named attributes. See {@link NgxMetaElementAttributes}. + * If no contents are provided, all `` elements of that kind will be removed. + * An array of contents may be given to create multiple `` elements with same kind. + * + * @example + * Setting `` + * + * ```typescript + * ngxMetaElementsService.set( + * withNameAttribute('description'), // same as `['name','description']` + * withContent('Cool page'), // same as `{content:'Cool page'}` + * ) + * ``` + * + * Utility functions {@link withNameAttribute} and {@link withContentAttribute} help creating the + * name attribute identifying the kind of meta elements and the contents to provide for it. + * + * {@link withContentAttribute} helps to create the attributes key / value object. + * + * Removing any `` existing elements + * + * ```typescript + * ngxMetaElementsService.set( + * withNameAttribute('description'), // same as `['name','description']` + * undefined, // same as `withContent(undefined)` + * ) + * ``` + * + * Setting many `` elements + * + * ```typescript + * ngxMetaElementsService.set( + * withNameAttribute('theme-color'), // same as `['name','theme-color']` + * [ + * withContent('darkblue', { media: "(prefers-color-scheme: dark)" }), + * withContent('lightblue') // same as `{content:'lightblue'}` + * ] + * ) + * ``` + * + * Removing any `` existing elements + * + * ```typescript + * ngxMetaElementsService.set( + * withNameAttribute('theme-color'), // same as `['name','theme-color']` + * [], // `undefined` is valid too + * ) + * ``` + * + * Attribute name helpers: + * + * - {@link withNameAttribute} + * + * - {@link withPropertyAttribute} + * + * Content helpers: + * + * - {@link withContentAttribute} + * + * @param nameAttribute - Attribute use to identify which kind of `` elements to manage. + * As an array with the attribute name in first position and attribute value in second one. + * Utility functions exist to generate arrays for common name attributes. + * See {@link withNameAttribute} and {@link withPropertyAttribute} helpers to create those + * arrays without repeating attribute names around. + * + * @param content - Content(s) attributes to set for this `` elements kind. + * Or the lack of them to remove all `` elements of this kind. + * See {@link withContentAttribute} helper for creating content objects. + */ + set( + nameAttribute: NgxMetaElementNameAttribute, + content: + | ReadonlyArray + | NgxMetaElementAttributes + | undefined, + ): void { + const [nameAttributeName, nameAttributeValue] = nameAttribute + const attrSelector = `${nameAttributeName}="${nameAttributeValue}"` + this.meta.getTags(attrSelector).forEach((tag) => tag.remove()) + /* istanbul ignore next https://github.com/istanbuljs/istanbuljs/issues/719 */ + if (!content) { + return + } + const contents = (Array.isArray as isContentsArray)(content) + ? content + : [content] + this.meta.addTags( + contents.map((content) => ({ + [nameAttributeName]: nameAttributeValue, + ...content, + })), + ) + } +} + +type isContentsArray = ( + contentOrContents: + | ReadonlyArray + | NgxMetaElementAttributes, +) => contentOrContents is ReadonlyArray diff --git a/projects/ngx-meta/src/core/src/meta-elements/v2/with-content-attribute.spec.ts b/projects/ngx-meta/src/core/src/meta-elements/v2/with-content-attribute.spec.ts new file mode 100644 index 00000000..25bd6f6d --- /dev/null +++ b/projects/ngx-meta/src/core/src/meta-elements/v2/with-content-attribute.spec.ts @@ -0,0 +1,43 @@ +import { withContentAttribute } from './with-content-attribute' +import { NgxMetaElementAttributes } from './ngx-meta-element-attributes' + +describe('with content attribute', () => { + const sut = withContentAttribute + const extras = { dummy: 'dummy' } satisfies NgxMetaElementAttributes + + describe('when no content is provided', () => { + const TEST_CASES = [null, undefined] + + TEST_CASES.forEach((testCase) => { + describe(`like when ${testCase}`, () => { + describe('when not providing extras', () => { + it('should return undefined', () => { + expect(sut(testCase)).toBeUndefined() + }) + }) + + describe('when providing extras', () => { + it('should return undefined', () => { + expect(sut(testCase, extras)).toBeUndefined() + }) + }) + }) + }) + }) + + describe('when content is provided', () => { + const content = 'dummy' + + describe('when not providing extras', () => { + it('should return an object containing the given content in the content key', () => { + expect(sut(content)).toEqual({ content }) + }) + }) + + describe('when providing extras', () => { + it('should return an object containing the given content in the content key plus extras', () => { + expect(sut(content, extras)).toEqual({ content, ...extras }) + }) + }) + }) +}) diff --git a/projects/ngx-meta/src/core/src/meta-elements/v2/with-content-attribute.ts b/projects/ngx-meta/src/core/src/meta-elements/v2/with-content-attribute.ts new file mode 100644 index 00000000..6a2ce790 --- /dev/null +++ b/projects/ngx-meta/src/core/src/meta-elements/v2/with-content-attribute.ts @@ -0,0 +1,22 @@ +import { NgxMetaElementAttributes } from './ngx-meta-element-attributes' + +/** + * Creates an {@link NgxMetaElementAttributes} object specifying the `content` attribute to the + * given `value`. Plus optional `extras`. + * + * Unless given `value` is `null` or `undefined`. In that case, `undefined` is returned. + * + * See {@link NgxMetaElementsService.set} + * + * @param content - Value for the `property` attribute of the `` element + * @param extras - Extra attributes to include in the object if `content` is defined. + * + * @alpha + */ +export const withContentAttribute = (( + content: string | null | undefined, + extras?: NgxMetaElementAttributes, +) => (content ? { content, ...extras } : undefined)) as { + (content: null | undefined, extras?: NgxMetaElementAttributes): undefined + (content: string, extras?: NgxMetaElementAttributes): NgxMetaElementAttributes +} diff --git a/projects/ngx-meta/src/core/src/meta-elements/v2/with-name-attribute.ts b/projects/ngx-meta/src/core/src/meta-elements/v2/with-name-attribute.ts new file mode 100644 index 00000000..03b917b3 --- /dev/null +++ b/projects/ngx-meta/src/core/src/meta-elements/v2/with-name-attribute.ts @@ -0,0 +1,10 @@ +/** + * Creates an attribute name/value identifying a `` element kind. + * + * See {@link NgxMetaElementsService.set}. + * + * @param value - Value for the `name` attribute of the `` element + * + * @alpha + */ +export const withNameAttribute = (value: string) => ['name', value] as const diff --git a/projects/ngx-meta/src/core/src/meta-elements/v2/with-property-attribute.ts b/projects/ngx-meta/src/core/src/meta-elements/v2/with-property-attribute.ts new file mode 100644 index 00000000..28c38251 --- /dev/null +++ b/projects/ngx-meta/src/core/src/meta-elements/v2/with-property-attribute.ts @@ -0,0 +1,12 @@ +/** + * Creates an attribute name/value identifying a `` element kind. + * + * See {@link NgxMetaElementsService.set}. + * + * @param value - Value for the `property` attribute of the `` element + * + * @alpha + */ +/* istanbul ignore next - will be used in next PRs */ +export const withPropertyAttribute = (value: string) => + ['property', value] as const