diff --git a/projects/ngx-meta/api-extractor/ngx-meta.api.md b/projects/ngx-meta/api-extractor/ngx-meta.api.md index 8e54e8f1..f86326fb 100644 --- a/projects/ngx-meta/api-extractor/ngx-meta.api.md +++ b/projects/ngx-meta/api-extractor/ngx-meta.api.md @@ -148,6 +148,7 @@ export const makeComposedKeyValMetaDefinition: (names: ReadonlyArray, op export const makeKeyValMetaDefinition: (keyName: string, options?: { keyAttr?: string; valAttr?: string; + extras?: MetaDefinition; }) => NgxMetaMetaDefinition; // @internal (undocumented) @@ -442,6 +443,7 @@ export interface Standard { readonly generator?: true | null; readonly keywords?: ReadonlyArray | null; readonly locale?: GlobalMetadata['locale']; + readonly themeColor?: StandardThemeColorMetadata | null; readonly title?: GlobalMetadata['title']; } @@ -480,6 +482,15 @@ export interface StandardMetadata { standard: Standard; } +// @public +export type StandardThemeColorMetadata = string | ReadonlyArray; + +// @public +export interface StandardThemeColorMetadataObject { + color: string; + media?: string; +} + // @public export const TWITTER_CARD_CARD_METADATA_PROVIDER: FactoryProvider; diff --git a/projects/ngx-meta/docs/content/why/comparison.md b/projects/ngx-meta/docs/content/why/comparison.md index 5a9ba55a..5fe39707 100644 --- a/projects/ngx-meta/docs/content/why/comparison.md +++ b/projects/ngx-meta/docs/content/why/comparison.md @@ -153,6 +153,7 @@ It is certainly a better option than installing a poorly maintained library. But | `#!html ` | ✅ | ✅ | ✅ | ✅ | | `#!html ` | ✅ | ⚙️ | ⚙️ | ⚙️ | | `#!html ` | ✅ | ⚙️ | ⚙️ | ⚙️ | +| `#!html ` | ✅ | ⚙️ | ⚙️ | ⚙️ | | `#!html ` | ✅ | ❌ | ✅ | ❌ | | `#!html ` | ✅ | ❌ | ❌ | ❌ | | `#!html ` | ✅ | ✅ | ✅ | ✅ | diff --git a/projects/ngx-meta/e2e/cypress/fixtures/all-metadata.json b/projects/ngx-meta/e2e/cypress/fixtures/all-metadata.json index e9664daa..fc0acf40 100644 --- a/projects/ngx-meta/e2e/cypress/fixtures/all-metadata.json +++ b/projects/ngx-meta/e2e/cypress/fixtures/all-metadata.json @@ -11,7 +11,8 @@ "standard": { "author": "Mr Bar", "keywords": ["foo", "bar"], - "generator": true + "generator": true, + "themeColor": "blue" }, "openGraph": { "image": { diff --git a/projects/ngx-meta/e2e/cypress/support/metadata/standard.ts b/projects/ngx-meta/e2e/cypress/support/metadata/standard.ts index c0abfd5f..2559ce5f 100644 --- a/projects/ngx-meta/e2e/cypress/support/metadata/standard.ts +++ b/projects/ngx-meta/e2e/cypress/support/metadata/standard.ts @@ -23,6 +23,9 @@ export const shouldContainAllStandardMetadata = () => .should('have.attr', 'href') .and('eq', metadata.canonicalUrl) cy.get('html').should('have.attr', 'lang').and('eq', metadata.locale) + cy.getMeta('theme-color') + .shouldHaveContent() + .and('eq', metadata.standard.themeColor) }, ) }) @@ -45,4 +48,5 @@ export const shouldNotContainAnyStandardMetadata = () => cy.getMeta('application-name').should('not.exist') cy.get('link[rel="canonical"]').should('not.exist') cy.get('html').should('not.have.attr', 'lang') + cy.getMeta('theme-color').should('not.exist') }) diff --git a/projects/ngx-meta/src/core/src/make-key-val-meta-definition.ts b/projects/ngx-meta/src/core/src/make-key-val-meta-definition.ts index 60c4e36a..353f4383 100644 --- a/projects/ngx-meta/src/core/src/make-key-val-meta-definition.ts +++ b/projects/ngx-meta/src/core/src/make-key-val-meta-definition.ts @@ -1,4 +1,5 @@ import { NgxMetaMetaDefinition } from './ngx-meta-meta.service' +import { MetaDefinition } from '@angular/platform-browser' /** * Creates a {@link NgxMetaMetaDefinition} for its use with {@link NgxMetaMetaService} @@ -19,9 +20,10 @@ import { NgxMetaMetaDefinition } from './ngx-meta-meta.service' * * @param keyName - Name of the key in the key/value meta definition * @param options - Specifies HTML attribute defining key, HTML attribute defining - * value. + * value and optional extras to include in definition * `keyAttr` defaults to `name` * `valAttr` defaults to `content` + * `extras` defaults to nothing * * @public */ @@ -30,12 +32,17 @@ export const makeKeyValMetaDefinition = ( options: { keyAttr?: string valAttr?: string + extras?: MetaDefinition } = {}, ): NgxMetaMetaDefinition => { const keyAttr = options.keyAttr ?? _KEY_ATTRIBUTE_NAME const valAttr = options.valAttr ?? _VAL_ATTRIBUTE_CONTENT return { - withContent: (value) => ({ [keyAttr]: keyName, [valAttr]: value }), + withContent: (value) => ({ + [keyAttr]: keyName, + [valAttr]: value, + ...options.extras, + }), attrSelector: `${keyAttr}='${keyName}'`, } } diff --git a/projects/ngx-meta/src/standard/index.ts b/projects/ngx-meta/src/standard/index.ts index c51a0e66..fe572f72 100644 --- a/projects/ngx-meta/src/standard/index.ts +++ b/projects/ngx-meta/src/standard/index.ts @@ -4,6 +4,7 @@ export * from './src/provide-ngx-meta-standard' // Others export * from './src/standard' export * from './src/standard-metadata' +export * from './src/standard-theme-color-metadata' // Specific providers export * from './src/standard-title-metadata-provider' export * from './src/standard-description-metadata-provider' diff --git a/projects/ngx-meta/src/standard/src/provide-ngx-meta-standard.ts b/projects/ngx-meta/src/standard/src/provide-ngx-meta-standard.ts index b9dbea37..40f86b71 100644 --- a/projects/ngx-meta/src/standard/src/provide-ngx-meta-standard.ts +++ b/projects/ngx-meta/src/standard/src/provide-ngx-meta-standard.ts @@ -7,6 +7,7 @@ import { STANDARD_GENERATOR_METADATA_PROVIDER } from './standard-generator-metad import { STANDARD_APPLICATION_NAME_METADATA_PROVIDER } from './standard-application-name-metadata-provider' import { STANDARD_CANONICAL_URL_METADATA_PROVIDER } from './standard-canonical-url-metadata-provider' import { STANDARD_LOCALE_METADATA_PROVIDER } from './standard-locale-metadata-provider' +import { STANDARD_THEME_COLOR_METADATA_PROVIDER } from './standard-theme-color-metadata-provider' /** * Adds {@link https://ngx-meta.dev/built-in-modules/standard/ | standard module} @@ -25,4 +26,5 @@ export const provideNgxMetaStandard = (): Provider[] => [ STANDARD_APPLICATION_NAME_METADATA_PROVIDER, STANDARD_CANONICAL_URL_METADATA_PROVIDER, STANDARD_LOCALE_METADATA_PROVIDER, + STANDARD_THEME_COLOR_METADATA_PROVIDER, ] diff --git a/projects/ngx-meta/src/standard/src/standard-theme-color-metadata-provider.spec.ts b/projects/ngx-meta/src/standard/src/standard-theme-color-metadata-provider.spec.ts new file mode 100644 index 00000000..0066f8b5 --- /dev/null +++ b/projects/ngx-meta/src/standard/src/standard-theme-color-metadata-provider.spec.ts @@ -0,0 +1,100 @@ +import { MetadataSetter, NgxMetaMetaService } from '../../core' +import { Standard } from './standard' +import { TestBed } from '@angular/core/testing' +import { MockProvider } from 'ng-mocks' +import { + __STANDARD_THEME_COLOR_METADATA_SETTER_FACTORY, + _STANDARD_THEME_COLOR_KEY, +} from './standard-theme-color-metadata-provider' +import { enableAutoSpy } from '@/ngx-meta/test/enable-auto-spy' +import { MetaDefinition } from '@angular/platform-browser' +import { StandardThemeColorMetadataObject } from './standard-theme-color-metadata' + +describe('Standard theme color metadata', () => { + enableAutoSpy() + let sut: MetadataSetter + let metaService: jasmine.SpyObj + + const DUMMY_COLOR = 'black' + const DUMMY_MEDIA = '(prefers-color-scheme: dark)' + + beforeEach(() => { + sut = makeSut() + metaService = TestBed.inject( + NgxMetaMetaService, + ) as jasmine.SpyObj + }) + + it('should call the meta service with no value when no value provided', () => { + sut(undefined) + + expect(metaService.set).toHaveBeenCalledOnceWith( + jasmine.anything(), + undefined, + ) + }) + + it('should call the meta service with the specified content when a string value is provided', () => { + sut(DUMMY_COLOR) + + expect(metaService.set).toHaveBeenCalledOnceWith( + jasmine.anything(), + DUMMY_COLOR, + ) + }) + + it('should call the meta service with no value when an empty array is provided', () => { + sut([]) + + expect(metaService.set).toHaveBeenCalledOnceWith( + jasmine.anything(), + undefined, + ) + }) + + it('should call the meta service with the specified content and media when object values are provided', () => { + const firstMediaDefinition = { + color: DUMMY_COLOR, + media: DUMMY_MEDIA, + } satisfies MetaDefinition & StandardThemeColorMetadataObject + const secondMediaDefinition = { + color: 'white', + } satisfies MetaDefinition & StandardThemeColorMetadataObject + sut([firstMediaDefinition, secondMediaDefinition]) + + expect(metaService.set).toHaveBeenCalledWith( + jasmine.anything(), + firstMediaDefinition.color, + ) + expect(metaService.set).toHaveBeenCalledWith( + jasmine.anything(), + secondMediaDefinition.color, + ) + expect( + metaService.set.calls + .argsFor(0)[0] + .withContent(firstMediaDefinition.color), + ).toEqual({ + name: 'theme-color', + content: firstMediaDefinition.color, + media: firstMediaDefinition.media, + }) + expect( + metaService.set.calls + .argsFor(1)[0] + .withContent(secondMediaDefinition.color), + ).toEqual({ + name: 'theme-color', + content: secondMediaDefinition.color, + }) + }) +}) + +function makeSut(): MetadataSetter { + TestBed.configureTestingModule({ + providers: [MockProvider(NgxMetaMetaService)], + }) + return __STANDARD_THEME_COLOR_METADATA_SETTER_FACTORY( + TestBed.inject(NgxMetaMetaService), + ) +} diff --git a/projects/ngx-meta/src/standard/src/standard-theme-color-metadata-provider.ts b/projects/ngx-meta/src/standard/src/standard-theme-color-metadata-provider.ts new file mode 100644 index 00000000..317fe4f9 --- /dev/null +++ b/projects/ngx-meta/src/standard/src/standard-theme-color-metadata-provider.ts @@ -0,0 +1,52 @@ +import { makeStandardMetadataProvider } from './make-standard-metadata-provider' +import { + MetadataSetterFactory, + NgxMetaMetaService, +} from '@davidlj95/ngx-meta/core' +import { Standard } from './standard' +import { makeStandardMetaDefinition } from './make-standard-meta-definition' +import { StandardThemeColorMetadataObject } from './standard-theme-color-metadata' + +/** + * @internal + */ +export const _STANDARD_THEME_COLOR_KEY = 'themeColor' satisfies keyof Standard + +const META_NAME = 'theme-color' + +/** + * @internal + */ +export const __STANDARD_THEME_COLOR_METADATA_SETTER_FACTORY: MetadataSetterFactory< + Standard[typeof _STANDARD_THEME_COLOR_KEY] +> = (ngxMetaMetaService: NgxMetaMetaService) => (value) => { + const isValueAnArray = isStandardThemeColorArray(value) + const baseMetaDefinition = makeStandardMetaDefinition(META_NAME) + if (!value || !isValueAnArray || !value.length) { + ngxMetaMetaService.set( + baseMetaDefinition, + isValueAnArray ? undefined : value, + ) + return + } + for (const { media, color } of value) { + ngxMetaMetaService.set( + makeStandardMetaDefinition(META_NAME, media ? { extras: { media } } : {}), + color, + ) + } +} + +const isStandardThemeColorArray = ( + value: Standard['themeColor'], +): value is ReadonlyArray => + Array.isArray(value) + +/** + * Manages the {@link Standard.themeColor} metadata + * @public + */ +export const STANDARD_THEME_COLOR_METADATA_PROVIDER = + makeStandardMetadataProvider(_STANDARD_THEME_COLOR_KEY, { + s: __STANDARD_THEME_COLOR_METADATA_SETTER_FACTORY, + }) diff --git a/projects/ngx-meta/src/standard/src/standard-theme-color-metadata.ts b/projects/ngx-meta/src/standard/src/standard-theme-color-metadata.ts new file mode 100644 index 00000000..5b2953df --- /dev/null +++ b/projects/ngx-meta/src/standard/src/standard-theme-color-metadata.ts @@ -0,0 +1,22 @@ +/** + * See {@link Standard.themeColor} + * @public + */ +export type StandardThemeColorMetadata = + | string + | ReadonlyArray + +/** + * See {@link Standard.themeColor} + * @public + */ +export interface StandardThemeColorMetadataObject { + /** + * See {@link Standard.themeColor} + */ + color: string + /** + * See {@link Standard.themeColor} + */ + media?: string +} diff --git a/projects/ngx-meta/src/standard/src/standard.ts b/projects/ngx-meta/src/standard/src/standard.ts index c6589756..1e2e3dc1 100644 --- a/projects/ngx-meta/src/standard/src/standard.ts +++ b/projects/ngx-meta/src/standard/src/standard.ts @@ -1,4 +1,5 @@ import { GlobalMetadata } from '@davidlj95/ngx-meta/core' +import { StandardThemeColorMetadata } from './standard-theme-color-metadata' /** * {@link https://ngx-meta.dev/built-in-modules/standard/ | Standard module} @@ -82,4 +83,25 @@ export interface Standard { * @see https://html.spec.whatwg.org/multipage/dom.html#attr-lang */ readonly locale?: GlobalMetadata['locale'] + + /** + * Sets one or more `` HTML elements + * + * If set, colors must specify a valid CSS color. + * + * A `media` attribute can be set to specify a different color depending on + * the context based on a CSS media query. For instance, to provide one color + * for dark mode and another for light mode. + * + * You can use a `string` value to set one theme color as value. No `media` + * attribute will be used. + * + * You can also specify one or more colors & media queries combinations by + * providing an array of objects specifying the color and (optionally) a + * media query + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/theme-color + * @see https://html.spec.whatwg.org/multipage/semantics.html#meta-theme-color + */ + readonly themeColor?: StandardThemeColorMetadata | null }