diff --git a/helpers/config.js b/helpers/config.js
new file mode 100644
index 0000000..81774bd
--- /dev/null
+++ b/helpers/config.js
@@ -0,0 +1,171 @@
+import nuxtConfig from '~/nuxt.config.js'
+
+
+export const nuxtHead = nuxtConfig.head
+
+export function makeTitle ( listing ) {
+ return `Does ${ listing.name } work on Apple Silicon? - ${ nuxtHead.title }`
+}
+
+export function makeDescription ( listing ) {
+ return `Latest reported support status of ${ listing.name } on Apple Silicon and Apple M1 Pro and M1 Max Processors.`
+}
+
+function makeTag ( tag, tagName = 'meta' ) {
+
+ const attributes = Object.entries(tag).map( ([ name, value ]) => `${name}="${value}"` ).join(' ')
+
+ return `<${tagName} ${attributes}>`
+}
+
+function mapMetaTag ( tag ) {
+
+ if ( tag.hasOwnProperty('property') ) {
+ return [
+ `property-${tag.property}`,
+ makeTag(tag)
+ ]
+ }
+
+ if ( tag.hasOwnProperty('name') ) {
+ return [
+ `name-${tag.name}`,
+ makeTag(tag)
+ ]
+ }
+
+ if ( tag.hasOwnProperty('charset') ) {
+ return [
+ 'charset',
+ makeTag(tag)
+ ]
+ }
+}
+
+function mapLinkTag ( tag ) {
+ return [
+ `type-${tag.type}`,
+ makeTag(tag, 'link')
+ ]
+}
+
+
+export class PageHead {
+
+ constructor ( options = {} ) {
+ const {
+ title,
+ description,
+ meta = [],
+ link = [],
+ structuredData = null,
+
+ domain,
+ pathname,
+ } = options
+
+ this.title = title
+ this.description = description
+ this.meta = meta
+ this.link = link
+ this.structuredData = structuredData
+
+ this.domain = domain
+ this.pathname = pathname
+ }
+
+ get pageUrl () {
+ const urlInstance = new URL( this.domain )
+
+ urlInstance.pathname = this.pathname
+
+ return urlInstance
+ }
+
+ get pageUrlString () {
+ return this.pageUrl.toString()
+ }
+
+ get defaultMeta () {
+ return nuxtHead.meta
+ }
+
+ get defaultMetaTags () {
+ return Object.fromEntries( nuxtHead.meta.map( mapMetaTag ) )
+ }
+
+ get defaultLinkTags () {
+ return Object.fromEntries( nuxtHead.link.map( mapLinkTag ) )
+ }
+
+ get pageMeta () {
+ // console.log('this.defaultMeta', this.defaultMeta)
+ return [
+ ...this.defaultMeta,
+ ...this.meta
+ ]
+ }
+
+ get metaTags () {
+
+ const metaTags = {
+ // ...this.defaultMeta,
+ // 'property-twitter:url': ``,
+ ...Object.fromEntries( this.pageMeta.map(mapMetaTag) )
+ }
+
+ // Get description from data
+ if ( this.description ) {
+ // Set meta description
+ metaTags['name-description'] = ``
+ // Set twitter description
+ metaTags['property-twitter:description'] = ``
+ }
+
+ // Get title from data
+ if ( this.title ) {
+ // Set twitter title
+ metaTags['property-twitter:title'] = ``
+ }
+
+
+ return metaTags
+ }
+
+
+ get metaMarkup () {
+ return Object.values( this.metaTags ).join('')
+ }
+
+ get linkTags () {
+
+ const linkTags = {
+ ...this.defaultLinkTags,
+ ...Object.fromEntries( this.link.map( mapLinkTag ) )
+ }
+
+ return linkTags
+ }
+
+ get linkMarkup () {
+ return Object.values( this.linkTags ).join('')
+ }
+
+ get metaAndLinkMarkup () {
+ return [
+ this.metaMarkup,
+ this.linkMarkup
+ ].join('')
+ }
+
+ get structuredDataMarkup () {
+
+ if ( structuredData === null ) return ''
+
+ const structuredDataJson = JSON.stringify( structuredData )
+
+ return ``
+ }
+
+
+}
diff --git a/helpers/listing-page.js b/helpers/listing-page.js
index 3f034e9..ca14b13 100644
--- a/helpers/listing-page.js
+++ b/helpers/listing-page.js
@@ -3,8 +3,17 @@
import {
getAppType
} from './app-derived.js'
+import { buildVideoStructuredData } from './structured-data.js'
+import { nuxtHead } from './config.js'
+function makeTitle ( listing ) {
+ return `Does ${ listing.name } work on Apple Silicon? - ${ nuxtHead.title }`
+}
+
+function makeDescription ( listing ) {
+ return `Latest reported support status of ${ listing.name } on Apple Silicon and Apple M1 Pro and M1 Max Processors.`
+}
export class ListingDetails {
constructor ( listing ) {
this.listing = listing
@@ -26,7 +35,32 @@ export class ListingDetails {
return this.listing.text
}
- buildListingDetails ( listing ) {
+ get pageTitle () {
+ return makeTitle( this.listing )
+ }
+ get pageDescription () {
+ return makeDescription( this.listing )
+ }
+
+ get structuredData () {
+ if ( this.type === 'video' ) {
+ return buildVideoStructuredData( this.listing, this.listing.featuredApps, { siteUrl: import.meta.site } )
+ }
+
+ return null
+ }
+
+ get headOptions () {
+ return {
+ title: this.pageTitle,
+ description: this.pageDescription,
+ // meta,
+ // link,
+ structuredData: this.structuredData,
+
+ // domain,
+ pathname: this.listing.endpoint,
+ }
}
}
diff --git a/test/prebuild/listings.js b/test/prebuild/listings.js
new file mode 100644
index 0000000..22c3606
--- /dev/null
+++ b/test/prebuild/listings.js
@@ -0,0 +1,177 @@
+import has from 'just-has'
+import test from 'ava'
+import axios from 'axios'
+import { JSDOM } from 'jsdom'
+
+import {
+ isValidHttpUrl,
+ isValidImageUrl,
+ isNonEmptyString,
+ isPositiveNumberString
+} from '~/helpers/check-types.js'
+import { ListingDetails } from '~/helpers/listing-page.js'
+import { PageHead } from '~/helpers/config.js'
+
+
+
+const listings = [
+
+ // Spotify
+ {
+ endpoint: '/app/spotify'
+ },
+
+ // Electron
+ {
+ endpoint: '/app/electron-framework'
+ }
+]
+
+
+
+test.before(async t => {
+ t.context.listings = await Promise.all(
+ listings.map(async listing => {
+ const { endpoint } = listing
+ const { data } = await axios.get(`${ process.env.PUBLIC_API_DOMAIN }/api${ endpoint }.json`)
+
+ return data
+ })
+ )
+})
+
+
+
+const headPropertyTypes = {
+ 'meta[charset]': {
+ charset: isNonEmptyString
+ },
+
+ //
+ 'meta[name="viewport"]': {
+ content: isNonEmptyString
+ },
+
+ //
+ 'meta[property="og:image"]': {
+ content: isValidImageUrl
+ },
+
+ //
+ 'meta[property="og:image:width"]': {
+ content: isPositiveNumberString
+ },
+
+ //
+ 'meta[property="og:image:height"]': {
+ content: isPositiveNumberString
+ },
+
+ //
+ 'meta[property="og:image:alt"]': {
+ content: isNonEmptyString
+ },
+
+ //
+ 'meta[property="twitter:card"]': {
+ content: isNonEmptyString
+ },
+
+ //
+ 'meta[property="twitter:title"]': {
+ content: isNonEmptyString
+ },
+
+ //
+ 'meta[property="twitter:description"]': {
+ content: isNonEmptyString
+ },
+
+ //
+ 'meta[property="twitter:url"]': {
+ content: isValidHttpUrl
+ },
+
+ //
+ 'meta[property="twitter:image"]': {
+ content: isValidImageUrl,
+ },
+
+ //
+ 'meta[name="description"]': {
+ content: isNonEmptyString
+ },
+
+ //
+ 'meta[property="twitter:title"]': {
+ content: isNonEmptyString
+ },
+
+ //
+ 'link[rel="icon"]': {
+ href: isNonEmptyString
+ },
+
+ //
+ //
+ //
+ //
+ //
+ 'link[rel="preconnect"]': {
+ href: isValidHttpUrl
+ },
+}
+
+function parseHTML ( htmlString ) {
+ const dom = new JSDOM( htmlString )
+
+ return {
+ dom,
+ window: dom.window,
+ document: dom.window.document
+ }
+}
+
+test('Listings have valid headings', async t => {
+ const { listings } = t.context
+
+ for ( const listing of listings ) {
+ // Build listing details
+ const listingDetails = new ListingDetails( listing )
+ const listingPageHead = new PageHead( listingDetails.headOptions )
+
+ // console.log( 'pageMeta', listingPageHead.metaMarkup )
+
+ // Parse into dom
+ // so we can get data via selectors
+ const { document } = parseHTML( listingPageHead.metaAndLinkMarkup )
+
+ for ( const [ selector, checks ] of Object.entries( headPropertyTypes ) ) {
+ const elements = document.querySelectorAll( selector )
+
+ let count = 1
+
+ if ( has( checks, ['count'] ) ) {
+ count = checks.count
+ delete checks.count
+ }
+
+ if ( count !== false ) {
+ // Fail if there's more or less than one element
+ t.is( elements.length, count, `${ selector } count is ${ elements.length } but should be ${ count }` )
+ }
+
+ for( const element of elements ) {
+ for ( const [ check, checkMethod ] of Object.entries( checks ) ) {
+ console.log( `Ckecking ${ selector } ${ check }` )
+
+ const value = element.getAttribute( check )
+
+ t.assert( checkMethod( value ), `${ check } on ${ selector } failed` )
+ }
+ }
+
+ }
+
+ }
+})