diff --git a/packages/shared/src/meta.ts b/packages/shared/src/meta.ts index 13d901b9..833fb576 100644 --- a/packages/shared/src/meta.ts +++ b/packages/shared/src/meta.ts @@ -11,64 +11,323 @@ interface PackingDefinition { } const MetaPackingSchema: Record = { - robots: { + appleItunesApp: { unpack: { - keyValueSeparator: ':', + entrySeparator: ', ', + resolve({ key, value }) { + return `${fixKeyCase(key)}=${value}` + }, }, }, - // Pragma directives + articleAuthor: { + keyValue: 'article:author', + metaKey: 'property', + }, + articleExpirationTime: { + keyValue: 'article:expiration_time', + metaKey: 'property', + }, + articleModifiedTime: { + keyValue: 'article:modified_time', + metaKey: 'property', + }, + articlePublishedTime: { + keyValue: 'article:published_time', + metaKey: 'property', + }, + articleSection: { + keyValue: 'article:section', + metaKey: 'property', + }, + articleTag: { + keyValue: 'article:tag', + metaKey: 'property', + }, + bookAuthor: { + keyValue: 'book:author', + metaKey: 'property', + }, + bookIsbn: { + keyValue: 'book:isbn', + metaKey: 'property', + }, + bookReleaseDate: { + keyValue: 'book:release_date', + metaKey: 'property', + }, + bookTag: { + keyValue: 'book:tag', + metaKey: 'property', + }, + charset: { + metaKey: 'charset', + }, contentSecurityPolicy: { unpack: { - keyValueSeparator: ' ', entrySeparator: '; ', + resolve({ key, value }) { + return `${fixKeyCase(key)} ${value}` + }, }, metaKey: 'http-equiv', }, + contentType: { + metaKey: 'http-equiv', + }, + defaultStyle: { + metaKey: 'http-equiv', + }, fbAppId: { keyValue: 'fb:app_id', metaKey: 'property', }, - ogSiteName: { - keyValue: 'og:site_name', + msapplicationConfig: { + keyValue: 'msapplication-Config', + }, + msapplicationTileColor: { + keyValue: 'msapplication-TileColor', }, msapplicationTileImage: { keyValue: 'msapplication-TileImage', }, - /** - * Tile colour for windows - */ - msapplicationTileColor: { - keyValue: 'msapplication-TileColor', + ogAudioSecureUrl: { + keyValue: 'og:audio:secure_url', + metaKey: 'property', }, - /** - * URL of a config for windows tile. - */ - msapplicationConfig: { - keyValue: 'msapplication-Config', + ogAudioType: { + keyValue: 'og:audio:type', + metaKey: 'property', }, - charset: { - metaKey: 'charset', + ogAudioUrl: { + keyValue: 'og:audio', + metaKey: 'property', }, - contentType: { - metaKey: 'http-equiv', + ogDescription: { + keyValue: 'og:description', + metaKey: 'property', }, - defaultStyle: { - metaKey: 'http-equiv', + ogDeterminer: { + keyValue: 'og:determiner', + metaKey: 'property', }, - xUaCompatible: { - metaKey: 'http-equiv', + ogImage: { + keyValue: 'og:image', + metaKey: 'property', + }, + ogImageAlt: { + keyValue: 'og:image:alt', + metaKey: 'property', + }, + ogImageHeight: { + keyValue: 'og:image:height', + metaKey: 'property', + }, + ogImageSecureUrl: { + keyValue: 'og:image:secure_url', + metaKey: 'property', + }, + ogImageType: { + keyValue: 'og:image:type', + metaKey: 'property', + }, + ogImageUrl: { + keyValue: 'og:image', + metaKey: 'property', + }, + ogImageWidth: { + keyValue: 'og:image:width', + metaKey: 'property', + }, + ogLocale: { + keyValue: 'og:locale', + metaKey: 'property', + }, + ogLocaleAlternate: { + keyValue: 'og:locale:alternate', + metaKey: 'property', + }, + ogSiteName: { + keyValue: 'og:site_name', + metaKey: 'property', + }, + ogTitle: { + keyValue: 'og:title', + metaKey: 'property', + }, + ogType: { + keyValue: 'og:type', + metaKey: 'property', + }, + ogUrl: { + keyValue: 'og:url', + metaKey: 'property', + }, + ogVideo: { + keyValue: 'og:video', + metaKey: 'property', + }, + ogVideoAlt: { + keyValue: 'og:video:alt', + metaKey: 'property', + }, + ogVideoHeight: { + keyValue: 'og:video:height', + metaKey: 'property', + }, + ogVideoSecureUrl: { + keyValue: 'og:video:secure_url', + metaKey: 'property', + }, + ogVideoType: { + keyValue: 'og:video:type', + metaKey: 'property', + }, + ogVideoUrl: { + keyValue: 'og:video', + metaKey: 'property', + }, + ogVideoWidth: { + keyValue: 'og:video:width', + metaKey: 'property', + }, + profileFirstName: { + keyValue: 'profile:first_name', + metaKey: 'property', + }, + profileGender: { + keyValue: 'profile:gender', + metaKey: 'property', + }, + profileLastName: { + keyValue: 'profile:last_name', + metaKey: 'property', + }, + profileUsername: { + keyValue: 'profile:username', + metaKey: 'property', }, refresh: { metaKey: 'http-equiv', + unpack: { + entrySeparator: ';', + keyValueSeparator: '=', + resolve({ key, value }) { + if (key === 'seconds') + return `${value}` + }, + }, + }, + robots: { + unpack: { + entrySeparator: ', ', + resolve({ key, value }) { + if (typeof value === 'boolean') + return `${fixKeyCase(key)}` + else + return `${fixKeyCase(key)}:${value}` + }, + }, + }, + twitterAppIdGoogleplay: { + keyValue: 'twitter:app:id:googleplay', + }, + twitterAppIdIpad: { + keyValue: 'twitter:app:id:ipad', + }, + twitterAppIdIphone: { + keyValue: 'twitter:app:id:iphone', + }, + twitterAppNameGoogleplay: { + keyValue: 'twitter:app:name:googleplay', + }, + twitterAppNameIpad: { + keyValue: 'twitter:app:name:ipad', + }, + twitterAppNameIphone: { + keyValue: 'twitter:app:name:iphone', + }, + twitterAppUrlGoogleplay: { + keyValue: 'twitter:app:url:googleplay', + }, + twitterAppUrlIpad: { + keyValue: 'twitter:app:url:ipad', + }, + twitterAppUrlIphone: { + keyValue: 'twitter:app:url:iphone', + }, + twitterCard: { + keyValue: 'twitter:card', + }, + twitterCreator: { + keyValue: 'twitter:creator', + }, + twitterCreatorId: { + keyValue: 'twitter:creator:id', + }, + twitterData1: { + keyValue: 'twitter:data1', + }, + twitterData2: { + keyValue: 'twitter:data2', + }, + twitterDescription: { + keyValue: 'twitter:description', + }, + twitterImage: { + keyValue: 'twitter:image', + }, + twitterImageAlt: { + keyValue: 'twitter:image:alt', + }, + /*************************************************/ + // not part of Twitter's card specification anymore + twitterImageHeight: { + keyValue: 'twitter:image:height', + }, + twitterImageType: { + keyValue: 'twitter:image:type', + }, + twitterImageUrl: { + keyValue: 'twitter:image', + }, + twitterImageWidth: { + keyValue: 'twitter:image:width', + }, + /**************************************************/ + twitterLabel1: { + keyValue: 'twitter:label1', + }, + twitterLabel2: { + keyValue: 'twitter:label2', + }, + twitterPlayer: { + keyValue: 'twitter:player', + }, + twitterPlayerHeight: { + keyValue: 'twitter:player:height', + }, + twitterPlayerStream: { + keyValue: 'twitter:player:stream', + }, + twitterPlayerWidth: { + keyValue: 'twitter:player:width', + }, + twitterSite: { + keyValue: 'twitter:site', + }, + twitterSiteId: { + keyValue: 'twitter:site:id', + }, + twitterTitle: { + keyValue: 'twitter:title', + }, + xUaCompatible: { + metaKey: 'http-equiv', }, } as const -const ColonPrefixKeys = /^(og|twitter|fb)/ - -const PropertyPrefixKeys = /^(og|fb)/ - export function resolveMetaKeyType(key: string): string { - return PropertyPrefixKeys.test(key) ? 'property' : (MetaPackingSchema[key]?.metaKey || 'name') + return MetaPackingSchema[key]?.metaKey || 'name' } export function resolveMetaKeyValue(key: string): string { @@ -76,13 +335,7 @@ export function resolveMetaKeyValue(key: string): string { } function fixKeyCase(key: string) { - key = key.replace(/([A-Z])/g, '-$1').toLowerCase() - if (ColonPrefixKeys.test(key)) { - key = key - .replace('secure-url', 'secure_url') - .replace(/-/g, ':') - } - return key + return key.replace(/([A-Z])/g, '-$1').toLowerCase() } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint @@ -112,7 +365,6 @@ export function resolvePackedMetaObjectValue(value: string, key: string): string return unpackToString( changeKeyCasingDeep(value), { entrySeparator: ', ', - keyValueSeparator: '=', resolve({ value, key }) { if (value === null) return '' @@ -124,44 +376,64 @@ export function resolvePackedMetaObjectValue(value: string, key: string): string ) } -const OpenGraphInputs = ['og:Image', 'og:Video', 'og:Audio', 'twitter:Image'] - const SimpleArrayUnpackMetas: (keyof MetaFlatInput)[] = ['themeColor'] +function getMeta(key: string, value: string) { + const meta: Record = {} + const metaKeyType = resolveMetaKeyType(key) + + if (metaKeyType === 'charset') { + meta[metaKeyType] = value + } + else { + meta[metaKeyType] = resolveMetaKeyValue(key) + meta.content = value + } + + return meta +} + +function flattenMetaObjects(input: Record, prefix: string = '') { + const extras: Record[] = [] + + for (const [key, value] of Object.entries(input)) { + const fullkey = `${prefix}${prefix === '' ? key : key.charAt(0).toUpperCase() + key.slice(1)}` + const unpacker = MetaPackingSchema[key]?.unpack + + if (unpacker) { + extras.push(getMeta(fullkey, unpackToString(value, unpacker))) + delete input[key] + continue + } + + if (typeof value === 'object') { + const children = Array.isArray(value) ? value : [value] + + for (const child of children) { + if (typeof child === 'object') + extras.push(...flattenMetaObjects(child, fullkey)) + else + extras.push(getMeta(fullkey, child)) + } + + delete input[key] + } + else { + extras.push(getMeta(fullkey, value)) + if (typeof input === 'object') + delete input[key] + } + } + + return extras +} + /** * Converts a flat meta object into an array of meta entries. * @param input */ export function unpackMeta(input: T): Required['meta'] { - const extras: Record[] = [] - - OpenGraphInputs.forEach((key) => { - const propKey = key.toLowerCase() - const inputKey = `${key.replace(':', '')}` as keyof MetaFlatInput - const val = input[inputKey] - if (typeof val === 'object') { - (Array.isArray(val) ? val : [val]) - .forEach((entry) => { - if (!entry) - return - const unpackedEntry = unpackToArray(entry as Record, { - key: key.startsWith('og') ? 'property' : 'name', - value: 'content', - resolveKeyData({ key }) { - return fixKeyCase(`${propKey}${key !== 'url' ? `:${key}` : ''}`) - }, - resolveValueData({ value }) { - return typeof value === 'number' ? value.toString() : value - }, - }) - extras.push( - // need to sort the entry and make sure the `og:image` is first - ...unpackedEntry.sort((a, b) => (a.property === propKey ? -1 : b.property === propKey ? 1 : 0)), - ) - }) - delete input[inputKey] - } - }) + const extras: Record[] = [] SimpleArrayUnpackMetas.forEach((meta: keyof T) => { if (input[meta] && typeof input[meta] !== 'string') { @@ -178,6 +450,22 @@ export function unpackMeta(input: T): Required['m } }) + extras.push( + // need to sort the entry and make sure the `og:image` is first + ...flattenMetaObjects(input).sort((a, b) => { + if (a.property?.startsWith('og:image')) { + if (b.property?.startsWith('og:image')) + return 0 + else + return -1 + } + + if (b.property?.startsWith('og:image')) + return 1 + + return 0 + })) + const meta = unpackToArray((input), { key({ key }) { return resolveMetaKeyType(key) as string diff --git a/test/unhead/ssr/ssr.test.ts b/test/unhead/ssr/ssr.test.ts index c78849e4..3faa6125 100644 --- a/test/unhead/ssr/ssr.test.ts +++ b/test/unhead/ssr/ssr.test.ts @@ -153,10 +153,10 @@ describe('ssr', () => { "bodyAttrs": "", "bodyTags": "", "bodyTagsOpen": "", - "headTags": " + "headTags": " + - ", "htmlAttrs": "", } diff --git a/test/unhead/useSeoMeta.test.ts b/test/unhead/useSeoMeta.test.ts index 0933d3a7..20a82967 100644 --- a/test/unhead/useSeoMeta.test.ts +++ b/test/unhead/useSeoMeta.test.ts @@ -94,4 +94,308 @@ describe('useSeoMeta', () => { } `) }) + + it('generates correct meta tags', async () => { + const head = createHead() + const dateString = new Date(0).toISOString() + + useSeoMeta({ + appleItunesApp: { + appId: 'id', + appArgument: 'https://example.com', + }, + appleMobileWebAppCapable: 'yes', + appleMobileWebAppStatusBarStyle: 'black', + appleMobileWebAppTitle: 'Title', + applicationName: 'Name', + articleAuthor: ['https://example.com/some.html', 'https://example.com/one.html'], + articleExpirationTime: dateString, + articleModifiedTime: dateString, + articlePublishedTime: dateString, + articleSection: 'Technology', + articleTag: ['Apple', 'Steve Jobs'], + author: 'Name', + bookAuthor: ['https://example.com/some.html', 'https://example.com/one.html'], + bookIsbn: '978-3-16-148410-0', + bookReleaseDate: dateString, + bookTag: ['Apple', 'Steve Jobs'], + charset: 'utf-8', + colorScheme: 'normal', + contentSecurityPolicy: { + baseUri: 'https://example.com', + childSrc: '\'none\'', + connectSrc: '\'none\'', + defaultSrc: '\'none\'', + fontSrc: '\'none\'', + formAction: '\'none\'', + frameAncestors: '\'none\'', + imgSrc: '\'none\'', + manifestSrc: '\'none\'', + mediaSrc: '\'none\'', + objectSrc: '\'none\'', + prefetchSrc: '\'none\'', + reportTo: '\'none\'', + reportUri: '\'none\'', + requireSriFor: '\'none\'', + requireTrustedTypesFor: '\'none\'', + sandbox: '\'none\'', + scriptSrc: '\'none\'', + scriptSrcAttr: '\'none\'', + scriptSrcElem: '\'none\'', + styleSrc: '\'none\'', + styleSrcAttr: '\'none\'', + styleSrcElem: '\'none\'', + trustedTypes: '\'none\'', + upgradeInsecureRequests: '\'none\'', + workerSrc: '\'none\'', + }, + contentType: 'text/html; charset=utf-8', + creator: 'Name', + defaultStyle: 'style', + description: 'Description', + fbAppId: 'id', + formatDetection: 'telephone=no', + generator: 'Generator', + google: 'nopagereadaloud', + googlebot: 'notranslate', + googleSiteVerification: 'key', + mobileWebAppCapable: 'yes', + msapplicationConfig: 'config', + msapplicationTileColor: '#fff', + msapplicationTileImage: 'https://example.com', + ogAudio: [{ + secureUrl: 'https://example.com', + type: 'audio/mpeg', + url: 'https://example.com', + }], + ogAudioSecureUrl: 'https://example.com', + ogAudioType: 'audio/mpeg', + ogAudioUrl: 'https://example.com', + ogDescription: 'Description', + ogDeterminer: 'auto', + ogImage: [{ + alt: 'Alt', + height: 1337, + secureUrl: 'https://example.com', + type: 'image/gif', + url: 'https://example.com', + width: 1337, + }], + ogImageAlt: 'Alt', + ogImageHeight: 1337, + ogImageSecureUrl: 'https://example.com', + ogImageType: 'image/gif', + ogImageUrl: 'https://example.com', + ogImageWidth: 1337, + ogLocale: 'en-US', + ogLocaleAlternate: 'de-DE', + ogSiteName: 'Name', + ogTitle: 'Title', + ogType: 'article', + ogUrl: 'https://example.com', + ogVideo: [{ + alt: 'Alt', + height: 1337, + secureUrl: 'https://example.com', + type: 'application/x-shockwave-flash', + url: 'https://example.com', + width: 1337, + }], + ogVideoAlt: 'Alt', + ogVideoHeight: 1337, + ogVideoSecureUrl: 'https://example.com', + ogVideoType: 'application/x-shockwave-flash', + ogVideoUrl: 'https://example.com', + ogVideoWidth: 1337, + profileFirstName: 'Firstname', + profileGender: 'male', + profileLastName: 'Lastname', + profileUsername: 'Username', + publisher: 'Name', + rating: 'adult', + referrer: 'no-referrer', + refresh: { + seconds: 1, + url: 'https://example.com', + }, + robots: { + all: true, + follow: true, + index: true, + indexifembedded: true, + maxImagePreview: 'large', + maxSnippet: 1, + maxVideoPreview: 2, + noarchive: true, + nofollow: true, + noimageindex: true, + noindex: true, + none: true, + nositelinkssearchbox: true, + nosnippet: true, + notranslate: true, + unavailable_after: dateString, + }, + themeColor: '#fff', + title: 'Title', + titleTemplate: '%s', + twitterAppIdGoogleplay: 'id', + twitterAppIdIpad: 'id', + twitterAppIdIphone: 'id', + twitterAppNameGoogleplay: 'name', + twitterAppNameIpad: 'name', + twitterAppNameIphone: 'name', + twitterAppUrlGoogleplay: 'https://example.com', + twitterAppUrlIpad: 'https://example.com', + twitterAppUrlIphone: 'https://example.com', + twitterCard: 'summary_large_image', + twitterCreator: '@username', + twitterCreatorId: 'id', + twitterData1: 'data1', + twitterData2: 'data2', + twitterDescription: 'Description', + twitterImage: 'https://example.com', + twitterImageAlt: 'Alt', + twitterImageHeight: 1337, + twitterImageType: 'image/gif', + twitterImageWidth: 1337, + twitterLabel1: 'label1', + twitterLabel2: 'label2', + twitterPlayer: 'https://example.com', + twitterPlayerHeight: 1337, + twitterPlayerStream: 'https://example.com', + twitterPlayerWidth: 1337, + twitterSite: '@username', + twitterSiteId: 'id', + twitterTitle: 'Title', + viewport: 'width=device-width, initial-scale=1', + xUaCompatible: 'IE=edge', + }) + + expect(await renderSSRHead(head)).toMatchInlineSnapshot(` + { + "bodyAttrs": "", + "bodyTags": "", + "bodyTagsOpen": "", + "headTags": " + + Title + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ", + "htmlAttrs": "", + } + `) + }) })