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` ) + } + } + + } + + } +})