Compare commits

...

16 commits

Author SHA1 Message Date
ThatGuySam
c5ec942de0 fix(ci): avoid serializing downloaded sitemap endpoints
Some checks failed
Deploy to Cloudflare Workers with Wrangler / Deploy (push) Has been cancelled
Run Node 24 Checks / build (24.x) (push) Has been cancelled
Keep the Pagefind bootstrap path from rewriting the remote sitemap
payload locally when the static JSON file is absent. GitHub-hosted runs
only need the fetched data in memory, and stringifying the 385 MB payload
was exhausting Node during netlify-build.
2026-03-15 19:33:05 -05:00
ThatGuySam
3dcf7da638 test(search): add url-targetable pagefind browser regression
Cover the Native Support filter with a Playwright-backed Vitest case
that can boot the local dev server or attach to a deployed URL so the
same regression can gate post-deploy verification.
2026-03-15 19:27:25 -05:00
ThatGuySam
a1ae595717 fix(search): prevent pagefind filter hangs
Resolve the Pagefind browser loader in Vite dev and cap filter-only
result hydration so broad filters render promptly instead of stalling
behind thousands of fragment fetches.
2026-03-15 19:25:07 -05:00
ThatGuySam
e1da6eb880 feat(search): add pagefind provider support
Add Pagefind indexing and browser search adapters behind a provider switch.

This lets prebuild generate either Stork or Pagefind search artifacts and lets the existing search UI run against Pagefind while preserving scoped filters, excerpts, and result metadata.
2026-03-15 13:42:07 -05:00
ThatGuySam
727f84e4c2 refactor(search): extract shared filter helper
Share filter parsing and provider mapping logic between Stork and upcoming search adapters.

This keeps the existing Stork API stable while adding Pagefind-oriented filter serialization under test.
2026-03-15 13:41:33 -05:00
ThatGuySam
e5f28b16ee docs(research): add pagefind feature parity memo
Capture user-visible parity requirements for a future Pagefind migration.

This keeps the earlier viability memo focused on engine fit and documents the recommended adapter approach, carry-over patterns, and remaining prototype risks around ranking and title highlighting.
2026-03-15 13:03:57 -05:00
ThatGuySam
2f667357af fix(ci): run netlify build in node 24 check
The generic
> doesitarm@1.0.0 test /Users/athena/Code/doesitarm
> vitest run

Running Astro Config File

 RUN  v4.1.0 /Users/athena/Code/doesitarm

 Test Files  7 passed (7)
      Tests  20 passed | 3 todo (23)
   Start at  13:00:20
   Duration  1.76s (transform 317ms, setup 138ms, import 1.23s, tests 2.55s, environment 2ms) entrypoint assumes artifacts that only exist after the repo's full Netlify-style build. Run the real end-to-end Netlify build in GitHub Actions instead so the hosted Node 24 check matches the supported build surface.
2026-03-15 13:00:21 -05:00
ThatGuySam
edcc260faa fix(ci): repair github node 24 workflows
Install pnpm before enabling setup-node's pnpm cache in the Cloudflare deploy job, and make the Node 24 check workflow self-contained by providing the public build URLs directly.

These fixes address the first GitHub-hosted failures that surfaced after the Node 24 migration push without changing the application runtime.
2026-03-15 12:58:18 -05:00
ThatGuySam
fcda9f0a02 chore(node): move repo tooling to node 24
Align local version markers and GitHub Actions with Node 24, switch the default test entrypoint to the maintained Vitest runner, and replace pnpm-incompatible npm helpers in repo scripts.

This also removes the obsolete AVA plus esm path and excludes disabled test fixtures from generic Vitest discovery so CI reflects the supported test surface.
2026-03-15 12:55:25 -05:00
ThatGuySam
747e564e17 fix(build): prune stale generated api endpoints
Clean list-specific JSON endpoint directories before rewriting them so local and deploy-style API builds do not fail on leftover files from older runs.

Keep the count check, but count only generated JSON outputs so the verification reflects the actual endpoint set.
2026-03-15 12:55:06 -05:00
ThatGuySam
7dfb03bb31 chore(gitignore): ignore astro upgrade artifacts 2026-03-15 12:20:58 -05:00
ThatGuySam
ed9f680505 chore(astro): upgrade to astro 6 2026-03-15 12:20:42 -05:00
ThatGuySam
f378862b23 fix(config): stop relying on global public config 2026-03-15 12:20:19 -05:00
ThatGuySam
d6057857fb docs(research): capture search migration findings
Save the Ubuntu 24 Stork migration notes and the Pagefind viability assessment so the production decision and source trail live in the repo.
2026-03-15 11:58:22 -05:00
ThatGuySam
1c2ce65f7d test(stork): cover node 22 runtime detection
Add focused regression tests for the environment helper and Stork binary target selection so the Ubuntu 24 and Apple Silicon path stays protected.
2026-03-15 11:58:05 -05:00
ThatGuySam
9e48862a5f fix(stork): support netlify ubuntu 24 builds
Switch Stork downloads to artifacts that match current runtimes and fix the runtime detection path that Node 22 changed.

This keeps the existing Stork pipeline working on Netlify's Noble image and on Apple Silicon development machines.
2026-03-15 11:57:41 -05:00
43 changed files with 8462 additions and 3812 deletions

View file

@ -14,13 +14,20 @@ jobs:
name: Deploy
steps:
- name: Checkout
uses: actions/checkout@master
uses: actions/checkout@v6
- name: Setup PNPM
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 10.12.1
run_install: false
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- name: Write Wrangler configs
run: |
echo ${{ secrets.WRANGLER_ENV }} | base64 -d > doesitarm-default/.env

View file

@ -1,9 +1,8 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Run Ava Tests
# This workflow validates the Node 24 toolchain on GitHub-hosted runners.
name: Run Node 24 Checks
on:
workflow_dispatch:
push:
branches: [ master ]
pull_request:
@ -13,21 +12,26 @@ jobs:
build:
runs-on: ubuntu-latest
env:
PUBLIC_URL: https://doesitarm.com
PUBLIC_API_DOMAIN: https://api.doesitarm.com
strategy:
matrix:
node-version: [14.x, 15.x]
node-version: [24.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v6
- name: Setup PNPM
uses: pnpm/action-setup@v4
with:
version: 10.12.1
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
- run: |
touch .env
echo ${{ secrets.GH_ENV }} >> .env
- run: npm ci
- run: npm run generate
- run: npm test
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm netlify-build

3
.gitignore vendored
View file

@ -82,6 +82,8 @@ typings/
# Local Netlify folder
.netlify
.astro/
vitest.config.mjs.timestamp-*
# Build Output
dist
@ -90,6 +92,7 @@ dist
/static/**/*.json
/static/**/*.toml
/static/**/*.st
/static/pagefind/
/commits-data.json
/static/tailwind.css

2
.nvmrc
View file

@ -1 +1 @@
v22
v24

View file

@ -3,7 +3,7 @@ import vue from '@astrojs/vue'
import tailwind from '@astrojs/tailwind'
// Astro Netlify Reference
// https://github.com/withastro/astro/tree/main/packages/integrations/netlify
import netlify from '@astrojs/netlify/functions'
import netlify from '@astrojs/netlify'
// import sitemap from '@astrojs/sitemap'
import partytown from '@astrojs/partytown'

View file

@ -1,24 +0,0 @@
/**
* @type {import('ava').AVAConfig}
*/
export default () => {
return {
require: [
'dotenv/config',
'esm',
'tsconfig-paths/register'
],
// https://github.com/avajs/ava/blob/main/docs/recipes/watch-mode.md
watchMode: {
ignoreChanges: [
'!**/*.{js,vue}',
'./build',
'./dist',
'./.output',
],
},
// tap: true,
// verbose: true,
color: true
}
}

View file

@ -1,4 +1,4 @@
import { dirname, basename } from 'path'
import { dirname, basename, extname, join } from 'path'
import os from 'os'
import fs from 'fs-extra'
@ -41,6 +41,12 @@ import { makeSearchableList } from '~/helpers/searchable-list.js'
import {
writeStorkToml
} from '~/helpers/stork/toml.js'
import {
writePagefindIndex
} from '~/helpers/pagefind/index.js'
import {
getSearchProvider
} from '~/helpers/search/config.js'
import {
KindListMemoized as KindList
} from '~/helpers/api/kind.js'
@ -448,6 +454,14 @@ class BuildLists {
const apiListDirectory = `${ apiDirectory }/${ listOptions.endpointPrefix }`
await fs.ensureDir( apiListDirectory )
for ( const existingFile of await fs.readdir( apiListDirectory ) ) {
if ( extname( existingFile ) !== '.json' ) continue
await fs.remove( join( apiListDirectory, existingFile ) )
}
// const poolSize = 1000
// Store app bundles to memory
@ -516,14 +530,18 @@ class BuildLists {
}
// Count saved files
const fileCount = fs.readdirSync( apiListDirectory ).length
const fileCount = fs.readdirSync( apiListDirectory )
.filter( fileName => extname( fileName ) === '.json' )
.length
console.log( fileCount, 'Files saved in', apiListDirectory )
console.log( this.lists[listOptions.name].size, 'Entries' )
if ( fileCount !== this.lists[listOptions.name].size ) {
const listSlugs = Array.from( this.lists[listOptions.name] ).map( listEntry => listEntry.slug )
const fileNames = fs.readdirSync( apiListDirectory ).map( fileName => basename(fileName).split('.')[0] )
const fileNames = fs.readdirSync( apiListDirectory )
.filter( fileName => extname( fileName ) === '.json' )
.map( fileName => basename(fileName).split('.')[0] )
logArraysDifference( listSlugs, fileNames )
@ -723,9 +741,15 @@ class BuildLists {
console.log('Building XML Sitemap')
await saveSitemap( sitemapEndpoints.map( ({ route }) => route ) )
// Save stork toml index
console.log('Building Stork toml index')
await writeStorkToml( sitemapEndpoints )
const searchProvider = getSearchProvider( process.env.PUBLIC_SEARCH_PROVIDER )
if ( searchProvider === 'stork' ) {
console.log('Building Stork toml index')
await writeStorkToml( sitemapEndpoints )
} else {
console.log('Building Pagefind index')
await writePagefindIndex( sitemapEndpoints )
}
console.log('Total Nuxt Endpoints', this.endpointMaps.nuxt.size )
console.log('Total Eleventy Endpoints', this.endpointMaps.eleventy.size )

View file

@ -39,7 +39,7 @@
hasActiveFilter( button.query ) ? 'border-opacity-50 bg-darkest' : 'border-opacity-0 neumorphic-shadow-inner'
]"
:aria-label="`Filter list by ${button.label}`"
@click="toggleFilter(button.query); queryResults(query)"
@click="toggleFilter(button.query); queryResults()"
>{{ button.label }}</button>
</div>
</div>
@ -151,21 +151,15 @@
</div>
<div
v-if="listing.storkResult"
v-if="listing.resultExcerptsMarkup?.length"
class="text-xs leading-5 font-light"
>
<div
v-for="(excerpt, excerptIndex) in listing.storkResult.excerpts"
v-for="(excerptMarkup, excerptIndex) in listing.resultExcerptsMarkup"
:key="`excerpt-${ excerptIndex }`"
class="result-excerpt space-y-3"
>
<div
v-for="(range, rangeIndex) in makeHighlightedMarkup( excerpt )"
:key="`range-${ rangeIndex }`"
v-html="range"
/>
</div>
v-html="excerptMarkup"
/>
</div>
<!-- listing.lastUpdated: {{ listing.lastUpdated }} -->
<template v-if="listing.lastUpdated">
@ -291,9 +285,18 @@ import {
import {
getIconForListing
} from '~/helpers/app-derived.js'
import {
PagefindClient,
mapPagefindDataToListing
} from '~/helpers/pagefind/browser.js'
import {
getSearchProvider
} from '~/helpers/search/config.js'
import {
SearchFilters
} from '~/helpers/search/filters.js'
import {
StorkClient,
StorkFilters,
makeHighlightedMarkup,
makeHighlightedResultTitle
} from '~/helpers/stork/browser.js'
@ -304,7 +307,7 @@ import RelativeTime from '~/components/relative-time.vue'
import ListSummary from '~/components/list-summary.vue'
import ListEndButtons from '~/components/list-end-buttons.vue'
let storkClient = null
const searchProvider = getSearchProvider( import.meta.env.PUBLIC_SEARCH_PROVIDER )
export default {
components: {
@ -347,15 +350,30 @@ export default {
hasStartedAnyQuery: false,
listingsResults: [],
waitingForQuery: false,
isSSR: import.meta.env.SSR
isSSR: import.meta.env.SSR,
searchClient: null,
searchFilters: null,
lastQueryId: 0
}
},
computed: {
storkQuery () {
activeSearchProvider () {
return searchProvider
},
activeQuery () {
return [
this.userTextQuery.trim(),
...this.filterQueryList
].join(' ')
].filter( Boolean ).join(' ')
},
pagefindResultLimit () {
return Math.max( this.initialList.length, 25 )
},
pagefindFilters () {
const filters = new SearchFilters()
filters.setFromStringArray( this.filterQueryList )
return filters.asPagefindFilters
},
appList () {
return this.kindPage.items
@ -390,7 +408,7 @@ export default {
return this.baseFilters.length > 0
},
hasSearchInputText () {
return this.userTextQuery.length > 0
return this.userTextQuery.trim().length > 0
},
hasAnyUserFilters () {
return this.userFilters.length > 0
@ -402,7 +420,7 @@ export default {
return !this.hasAnyUserTerms
},
inputTerms () {
return this.userTextQuery.trim().split(' ')
return this.userTextQuery.trim().split(' ').filter( Boolean )
},
userFilters () {
// console.log('filterQueryList', )
@ -442,22 +460,23 @@ export default {
}
},
mounted () {
// Setup stork client
storkClient = new StorkClient()
this.searchClient = this.makeSearchClient()
// Store filter instance
this.storkFilters = new StorkFilters()
// Add initial filters
this.storkFilters.setFromStringArray( this.baseFilters )
this.searchFilters = new SearchFilters()
this.searchFilters.setFromStringArray( this.baseFilters )
this.filterQueryList = this.searchFilters.list
},
methods: {
makeHighlightedMarkup,
makeHighlightedResultTitle,
getIconForListing,
makeSearchClient () {
if ( this.activeSearchProvider === 'stork' ) {
return new StorkClient()
}
return new PagefindClient()
},
getSearchLinks (app) {
return app?.searchLinks || []
},
@ -466,10 +485,8 @@ export default {
return this.filterQueryList.includes( filter )
},
toggleFilter ( newFilterQuery ) {
this.storkFilters.toggleFilter( newFilterQuery )
this.filterQueryList = this.storkFilters.list
this.searchFilters.toggleFilter( newFilterQuery )
this.filterQueryList = this.searchFilters.list
},
scrollInputToTop () {
scrollIntoView(this.$refs['search-container'], {
@ -477,15 +494,73 @@ export default {
behavior: 'smooth'
})
},
mapStorkResultToListing ( result ) {
return {
name: makeHighlightedResultTitle( result ),
text: '',
endpoint: result.entry.url,
slug: result.entry.url,
category: {
slug: 'uncategorized'
},
lastUpdated: null,
resultExcerptsMarkup: ( result.excerpts || [] ).flatMap( excerpt => {
return makeHighlightedMarkup( excerpt )
} )
}
},
async runPagefindQuery () {
const pagefindQuery = await this.searchClient.lazyQuery( this.userTextQuery, {
filters: this.pagefindFilters,
sort: this.hasSearchInputText ? {} : {
updated: 'desc'
}
} )
if ( pagefindQuery === null ) {
return null
}
const limitedResults = ( pagefindQuery.results || [] ).slice( 0, this.pagefindResultLimit )
const settledResultData = await Promise.allSettled( limitedResults.map( async result => {
return await result.data()
} ) )
const resultData = settledResultData.flatMap( settledResult => {
if ( settledResult.status === 'fulfilled' ) {
return [ settledResult.value ]
}
console.warn('Failed to load Pagefind result data', settledResult.reason)
return []
} )
return resultData.map( data => {
return mapPagefindDataToListing( data, {
highlightTerms: this.inputTerms
} )
} )
},
async runStorkQuery () {
const storkQuery = await this.searchClient.lazyQuery( this.activeQuery, this.activeQuery.split(' ') )
if ( storkQuery === null ) {
return null
}
return ( storkQuery.results || [] ).map( result => {
return this.mapStorkResultToListing( result )
} )
},
// Called on input and when a filter is toggled
async queryResults ( rawQuery ) {
async queryResults ( rawQuery = this.userTextQuery ) {
const queryId = ++this.lastQueryId
console.log( 'query', this.storkQuery )
// If our query is empty
// then bail
if ( this.storkQuery.trim().length === 0 ) return
if ( this.activeQuery.trim().length === 0 ) {
this.waitingForQuery = false
return
}
this.waitingForQuery = true
@ -494,36 +569,22 @@ export default {
// Declare that at least one query has been made
this.hasStartedAnyQuery = true
// console.log('rawQuery', rawQuery)
const results = this.activeSearchProvider === 'stork'
? await this.runStorkQuery()
: await this.runPagefindQuery()
const requiredTerms = this.storkQuery.split(' ')
const storkQuery = await storkClient.lazyQuery( this.storkQuery, requiredTerms )
// If the query response is empty
// then return
if ( storkQuery === null ) {
if ( queryId !== this.lastQueryId ) {
return
}
// console.log( 'storkQuery', storkQuery )
if ( results === null ) {
this.waitingForQuery = false
return
}
this.listingsResults = storkQuery.results.map( result => {
return {
name: makeHighlightedResultTitle( result ),
endpoint: result.entry.url,
slug: '',
category: {
slug: 'uncategorized'
},
storkResult: result
}
})
this.listingsResults = results
// Switch from loading state and reveal the results
this.waitingForQuery = false
// console.log('this.listingsResults', this.listingsResults)
},
handleSearchInput ( event ) {

View file

@ -0,0 +1,77 @@
# Netlify Ubuntu 24 Stork Migration
Date: 2026-03-15
## Scope
Investigate why `doesitarm` fails on Netlify's Noble/Ubuntu 24 image and
identify whether the fix belongs in this repo or `../doesitarm-functions/`.
## Short Answer
The failing production build is blocked in the Stork setup stage, not in Astro
or the companion functions repo.
The immediate break is that `doesitarm` downloads Stork's
`stork-ubuntu-20-04` binary, which is linked against `libssl.so.1.1`. Netlify's
Noble/Ubuntu 24 image no longer ships that library, so the binary exits before
Astro starts building.
There is also a separate Node 22 regression in this repo: `isBrowserContext()`
uses `navigator` to detect browsers, but Node 22 exposes a global
`navigator`. That causes Node processes on macOS to be misdetected as browser
contexts and pushes local Stork downloads onto the Linux binary path.
## What The Evidence Says
- Confirmed from the user-provided Netlify build log:
`./stork-executable: error while loading shared libraries: libssl.so.1.1`
- Confirmed from the latest Stork release metadata:
`v1.6.0` ships both `stork-ubuntu-20-04` and `stork-ubuntu-22-04`.
- Confirmed from local binary inspection:
`stork-ubuntu-20-04` references `libssl.so.1.1` and `libcrypto.so.1.1`
while `stork-ubuntu-22-04` references `libssl.so.3` and `libcrypto.so.3`.
- Confirmed from Node 22 docs and local runtime checks:
Node 22 exposes a global `navigator`, so `typeof navigator !== 'undefined'`
is no longer a safe browser-only check.
- Confirmed locally on 2026-03-15:
`CI=1 mise exec node@22 -- pnpm run netlify-build` succeeds after switching
the Stork target and fixing runtime detection.
## What Works
- Use Stork's `stork-ubuntu-22-04` binary on Linux/Netlify.
- Use `stork-macos-13-arm` on Apple Silicon Macs.
- Detect browser context with `window` and `document`, not `navigator`.
- Keep the fix in `doesitarm`; `../doesitarm-functions/` is an external API
dependency referenced via `VFUNCTIONS_URL` and `PUBLIC_API_DOMAIN`, not part
of the failing Netlify build path.
## What To Avoid
- Do not keep using `stork-ubuntu-20-04` on Noble/Ubuntu 24.
- Do not use the `stork-amazon-linux` artifact as a Netlify fallback; its
binary references `libssl.so.10`, which is also not a fit for Ubuntu 24.
- Do not use `navigator` as the only browser-runtime signal in Node 22+ code.
## Recommendation
Keep the Stork fix minimal and repo-local:
1. Detect the Stork binary target from `process.platform` and `process.arch`.
2. Use `stork-ubuntu-22-04` for Linux builds.
3. Use `stork-macos-13-arm` for Apple Silicon.
4. Add tests for runtime detection and Stork target selection.
5. Leave `../doesitarm-functions/` unchanged unless its own deployment starts
failing independently.
## Source Links
- Stork latest release metadata:
https://api.github.com/repos/jameslittle230/stork/releases/latest
- Stork install docs:
https://stork-search.net/docs/install
- Stork CI/Netlify guide:
https://stork-search.net/docs/stork-and-netlify
- Node 22 globals docs:
https://nodejs.org/dist/latest-v22.x/docs/api/globals.html

View file

@ -0,0 +1,306 @@
# Pagefind Feature Parity For doesitarm
Date: 2026-03-15
## Scope
Read alongside `docs/research/pagefind-viability-2026-03-15.md`.
Investigate how a Pagefind migration could preserve the current Stork-backed
search UX in `doesitarm`, focusing on user-visible behavior rather than on
whether Pagefind is viable in the abstract.
## Short Answer
Yes, most of the current search experience can be carried over without the user
feeling a major regression, but only if Pagefind is treated as the search core
under a custom Vue adapter.
Recommended parity path:
1. Keep the current server-rendered initial lists, pagination links, summary
block, and page chrome exactly as they are.
2. Replace only the "user has started searching/filtering" path with Pagefind's
JavaScript API.
3. Build the Pagefind index from the existing sitemap/listing data, not from an
HTML crawl.
4. Use Pagefind `filters` for status/category/type scoping.
5. Use Pagefind `meta` only for simple scalar fields needed in result rendering.
6. Reattach richer card UI state such as `searchLinks` from a local
URL-or-slug keyed map instead of trying to force arrays into Pagefind
metadata.
The one place where a prototype may still change the implementation choice is
search quality. If `addCustomRecord()` does not rank app-name and alias matches
well enough, the next-best parity option is to generate virtual HTML records via
`addHTMLFile()` so Pagefind can use `h1` weighting and `data-pagefind-*`
attributes.
## Current UX Contract In The Repo
From `components/search-stork.vue`, `helpers/stork/toml.js`, and the scoped
Astro pages:
- The page initially shows the existing paginated list from the API when the
user has not typed anything yet.
- Search is search-as-you-type, with loading placeholders while results are
pending.
- The UI exposes quick status chips.
- Scoped pages such as `/kind/...` and `/games` inject base filters so the same
component behaves like "search within this slice".
- Empty results on a scoped page show a "Search Everything" escape hatch.
- Query results show highlighted snippets and a detail link.
- Non-query cards can also show timestamps and auxiliary action buttons such as
benchmark/performance links.
- The current Stork index injects synthetic searchable tokens for `status_*`,
category, and route type, in addition to title/content/description/aliases and
tags.
- Stork also post-filters query results so every typed term must be present
somewhere in the returned title/URL/excerpts.
That means parity is not just "can users search", but:
- can they search globally and within a scoped page
- can they click status chips
- can they still get good snippets and stable detail URLs
- can the initial browse mode remain unchanged
## What The Evidence Says
Confirmed from Pagefind docs and repo activity:
- The Node API supports `addCustomRecord()` with `url`, `content`, `language`,
optional flat `meta`, optional flat `filters`, and optional flat `sort`.
- The Node API also supports `addHTMLFile()` for virtual HTML pages and
`writeFiles()` / `getFiles()` for writing the bundle to `/pagefind/`.
- The browser API is intended for custom search interfaces, not just the stock
widget.
- `pagefind.init()` can be called on focus, and `pagefind.preload()` /
`pagefind.debouncedSearch()` exist specifically to reduce first-search
latency.
- `result.data()` returns `url`, `excerpt`, `meta`, and related result data.
The docs explicitly say `excerpt` is safe to use as `innerHTML`, while
`content` and `meta` are raw.
- The JS API supports filter-only browsing by calling
`pagefind.search(null, { filters: ... })`.
- The JS API can also return filter counts via `pagefind.filters()`, plus
remaining-result counts on subsequent searches.
- Filtering defaults to AND semantics, and compound `any` / `all` / `none` /
`not` logic is available.
- Sorting can be applied at search time, but records missing a sort value are
omitted when that sort is active.
- Highlighting on destination pages is supported via `highlightParam` and
`pagefind-highlight.js`.
- Historical GitHub issues `#198` and `#277` asked for direct non-HTML input;
both are now closed, and the current docs document that capability.
- The latest stable release is `v1.4.0`, published on 2025-09-01.
- Issue `#574` about the `npx` wrapper on `ubuntu-latest` is still open as of
2026-03-15, so a pinned dependency or direct binary path is safer than a
casual CLI swap.
Community signal:
- In the main HN discussion for Pagefind's launch, the maintainer explicitly
said multi-word query merging is built in.
- Another HN commenter reported that deploying Pagefind was "pleasingly easy"
and the result was "reasonably nippy".
- Zach Leatherman's `pagefind-search` component is a concrete GitHub example of
treating Pagefind as a customizable UI layer with explicit fallback content
and controlled asset loading.
## Feature Mapping
| Current user-visible feature | Carry-over path with Pagefind | Confidence | Notes |
| --- | --- | --- | --- |
| Search-as-you-type | `pagefind.debouncedSearch()` or manual debounce + `pagefind.preload()` | High | This is native to the JS API. |
| Lazy first-load behavior | `pagefind.init()` on focus, or rely on first search | High | This matches the current deferred Stork load pattern. |
| Scoped search pages | Keep current initial page data, then call `pagefind.search(term-or-null, { filters })` | High | Better fit than the current synthetic token approach. |
| Quick status chips | Map chips to `filters.status` values | High | Pagefind filters are cleaner than indexing `status_native` into content. |
| Empty-state "Search Everything" | Clear base filters and rerun, or keep current link to `/` | High | User-visible behavior is easy to preserve. |
| Highlighted excerpts | Render `result.data().excerpt` | High | Officially documented and safe as `innerHTML`. |
| Highlighted title text | No first-class JS API equivalent was found in the docs | Medium | Likely plain title unless we add client-side emphasis ourselves. |
| Detail links | Use `result.data().url` | High | Direct match. |
| Relative timestamp text | Put timestamp in `meta`, or join from local listing data | High | `meta` is string-only, so store ISO strings if using metadata. |
| Benchmark / Performance buttons | Join from local listing data keyed by URL or slug | High | Inference: better than encoding arrays as metadata strings. |
| Status / category / type scoping | Use Pagefind `filters`, not fake searchable tokens | High | Cleaner and more explicit than the current Stork trick. |
| "Every typed term must match somewhere" behavior | Likely client-side post-filter using returned raw `content` if needed | Medium | Current Stork behavior is explicit; Pagefind query semantics need a parity check in a prototype. |
| Result ordering that favors app names and aliases | Start with `addCustomRecord()` content shaping; fall back to `addHTMLFile()` if needed | Medium | Custom-record metadata appears display-oriented, not ranking-oriented. |
## Options
### 1. Custom Vue adapter over `addCustomRecord()` output
This is the lowest-risk parity path.
Why it works:
- It matches the repo's existing data-first indexing model.
- It preserves the current page shell and only swaps the query engine.
- It uses Pagefind features the way they are documented today:
`meta` for display fields, `filters` for scoping, `sort` for explicit sort
options.
Tradeoffs:
- `meta` is for returned metadata, not clearly for ranking.
- Complex card state such as `searchLinks` does not fit naturally into flat
string metadata.
- The docs do not show title-highlight ranges in the JS API, so exact title
highlighting may need custom client logic.
### 2. Custom Vue adapter over generated virtual HTML via `addHTMLFile()`
This is the "higher parity if ranking is off" option.
Why it might be worth it:
- Pagefind documents default weighting for HTML headings.
- Pagefind documents `data-pagefind-weight`, `data-pagefind-meta`,
`data-pagefind-filter`, and `data-pagefind-sort`.
- If app-name, alias, and status text need finer relevance tuning than a plain
custom record gives, virtual HTML gives more levers.
Tradeoffs:
- More adapter code.
- Harder to justify unless a real query corpus shows ranking problems.
### 3. Replace the current component with the stock Pagefind UI
Not recommended.
Why:
- It discards the current browse-first behavior and scoped-page behavior.
- It loses the current empty-state copy and action-button treatment.
- It would make parity depend on overriding Pagefind's UI instead of preserving
the repo's existing search component contract.
## What Works
- Keep "no query" mode exactly as it is today and switch to Pagefind only after
the user types or toggles a filter.
- Build one Pagefind record per listing/detail route, using the same sitemap
payloads already feeding the Stork pipeline.
- Put searchable text in `content`, starting with the fields users most expect
to match: app name, aliases, support text, description, tags, and any status
phrasing users already see.
- Put render-only scalar fields in `meta`, such as title, slug, status label,
last-updated ISO timestamp, and short display text.
- Use `filters` for `status`, `category`, `kind`, and other scoped-page
constraints.
- Build a parallel local result-decoration map keyed by URL or slug so Pagefind
results can be decorated with the same `searchLinks`, timestamps, or other
card chrome without turning the index into a transport format for the whole
listing object.
- Call `pagefind.filters()` once if you want the chip row to expose counts or
disabled states.
Inference:
For `doesitarm`, this "Pagefind for retrieval + local map for decoration"
split is probably the cleanest way to preserve the current UI without bloating
Pagefind metadata or weakening search semantics.
## What To Avoid
- Do not replace the entire search component with the stock Pagefind UI if the
goal is parity.
- Do not assume `meta` alone is enough for search quality. Metadata is clearly
documented as returned result data; searchable content still needs to live in
`content`.
- Do not try to stuff arrays or nested structures like `searchLinks` into flat
Pagefind metadata if the same information already exists in local page data.
- Do not apply Pagefind sort options to sparse fields unless every record has a
value, because missing sort keys are omitted from sorted results.
- Do not assume the `npx pagefind` wrapper is production-safe on Ubuntu CI
without pinning and testing.
## Recommendation
Recommended implementation order:
1. Keep the current Astro pages and initial list rendering exactly as-is.
2. Build a Pagefind prototype with `addCustomRecord()` from the existing sitemap
payloads.
3. Map the current scoped-page `baseFilters` to real Pagefind filters.
4. Add a thin Pagefind adapter inside the current Vue component rather than
replacing the component.
5. Use a local `listingByUrl` or `listingBySlug` map to reattach rich card UI
fields.
6. Compare a real query set against Stork, especially app-name, alias, and
multi-term searches.
7. Only if ranking quality is weaker than expected, move the prototype from
`addCustomRecord()` to generated `addHTMLFile()` records with weighted
markup.
Why this is the best default:
- It preserves the current user-visible experience in the cheapest way.
- It uses Pagefind features where Pagefind is strongest: retrieval, snippets,
filtering, counts, and static bundle delivery.
- It avoids forcing Pagefind to become the canonical source of every UI field.
## Missing Information
- I did not find a documented JS API for title highlight ranges equivalent to
the Stork title-range behavior.
- I did not find clear documentation on exact multi-term query semantics beyond
Pagefind supporting multi-word queries in practice.
- I did not find a high-signal Stack Overflow thread that added more than the
official docs for this migration.
- The Lobsters URL surfaced during search no longer resolved, so I did not use
it as evidence.
## Recommended Next Inspection Steps
1. Build a small Pagefind prototype against 100-200 representative listings.
2. Test 25-50 real queries from the current site vocabulary:
app names, aliases, status words, category words, and mixed multi-term
queries.
3. Decide whether status chips should stay effectively single-select, matching
current behavior, or become explicit OR filters within the same filter key.
4. Verify whether plain title rendering is acceptable, or whether custom
client-side title emphasis is needed.
5. Measure first-search latency on mobile before removing Stork.
## Source Links
- Pagefind Node API docs:
https://pagefind.app/docs/node-api/
- Pagefind browser API docs:
https://pagefind.app/docs/api/
- Pagefind filtering docs:
https://pagefind.app/docs/filtering/
- Pagefind JS API filtering docs:
https://pagefind.app/docs/js-api-filtering/
- Pagefind sorting docs:
https://pagefind.app/docs/sorts/
- Pagefind JS API sorting docs:
https://pagefind.app/docs/js-api-sorting/
- Pagefind metadata docs:
https://pagefind.app/docs/metadata/
- Pagefind JS API metadata docs:
https://pagefind.app/docs/js-api-metadata/
- Pagefind weighting docs:
https://pagefind.app/docs/weighting/
- Pagefind ranking docs:
https://pagefind.app/docs/ranking/
- Pagefind highlighting docs:
https://pagefind.app/docs/highlighting/
- Pagefind sub-results docs:
https://pagefind.app/docs/sub-results/
- Pagefind latest release `v1.4.0` (published 2025-09-01):
https://github.com/Pagefind/pagefind/releases/tag/v1.4.0
- Pagefind issue `#198` ("Manually defining content, without passing HTML"):
https://github.com/Pagefind/pagefind/issues/198
- Pagefind issue `#277` ("Can pagefind pull its data from a json index file?"):
https://github.com/Pagefind/pagefind/issues/277
- Pagefind issue `#574` (`ubuntu-latest` / `npx` wrapper failure, still open on
2026-03-15):
https://github.com/Pagefind/pagefind/issues/574
- HN discussion for Pagefind launch:
https://news.ycombinator.com/item?id=32290634
- HN item API for the same discussion:
https://hn.algolia.com/api/v1/items/32290634
- Zach Leatherman's `pagefind-search` web component:
https://github.com/zachleat/pagefind-search

View file

@ -0,0 +1,118 @@
# Pagefind Viability For doesitarm
Date: 2026-03-15
## Scope
Investigate whether Pagefind is a good replacement for Stork in `doesitarm`,
given the current Astro 2 + Netlify server build and the existing custom search
pipeline.
## Short Answer
Pagefind is viable for this repo, but not as a drop-in replacement.
The lowest-risk production path today is to keep the Stork fix and ship it.
If `doesitarm` later moves to Pagefind, the right migration path is a
behind-feature-flag prototype using Pagefind's Node API with
`addCustomRecord()`, not a simple `pagefind --site dist` crawl.
## What The Evidence Says
- Current repo shape:
`doesitarm` builds with `output: "server"` in Astro and only prerenders a
small subset of routes (`/`, `/categories`, `/games`). Most searchable
listing pages are SSR routes, not static HTML files in `dist/`.
- Current Stork shape:
[helpers/stork/toml.js](/Users/athena/Code/doesitarm/helpers/stork/toml.js)
generates a structured index from sitemap payloads, and
[components/search-stork.vue](/Users/athena/Code/doesitarm/components/search-stork.vue)
renders a custom search UI over those records.
- Pagefind official docs:
the Node API supports `addDirectory()`, `addHTMLFile()`, and
`addCustomRecord()`. The docs explicitly describe `addCustomRecord()` as a
way to build an index from non-HTML content.
- Pagefind browser API:
supports custom JS search UIs, per-result lazy loading with `result.data()`,
excerpts, filters, and sorting.
- Pagefind latest release:
`v1.4.0`, published 2025-09-01.
- Relevant GitHub issues:
`#163` shows Astro + Netlify usage is workable but can need selector/root
troubleshooting.
`#198` and `#277` show demand for indexing non-HTML/custom data, which is now
covered by the Node API docs.
`#574` remains open for an `npx` wrapper failure on `ubuntu-latest`, which is
a reason to prefer a pinned dependency and explicit integration over a casual
CLI-only swap.
## What Works
- Pagefind can support this repo's filters and sorts.
- Pagefind can support a custom UI instead of the stock widget.
- Pagefind can index structured records directly with `addCustomRecord()`,
which matches `doesitarm` better than crawling built HTML.
- A feature-flagged migration is feasible:
1. build Pagefind assets from the existing sitemap payload data
2. expose them under `/pagefind/`
3. add a Pagefind-backed client alongside the existing Stork component
4. switch between them with a runtime/build flag
## What To Avoid
- Do not treat Pagefind as a trivial `postbuild` swap in this repo.
A plain HTML crawl would miss most of the real searchable surface because the
site is primarily SSR on Netlify.
- Do not attempt the production migration by replacing Stork first and figuring
out the UI later.
- Do not rely on `npx pagefind` alone in CI without pinning and testing the
binary/package path on the target image.
## Recommendation
For production now:
1. Ship the Stork Ubuntu 24 fix.
2. Merge that branch to `master`.
3. verify the Netlify deploy is green.
For a Pagefind migration later:
1. Add `pagefind` as a pinned dependency.
2. Create a build script that maps the same sitemap payloads into
`addCustomRecord()` calls.
3. Write Pagefind output to `static/pagefind/` or `dist/pagefind/`.
4. Add a feature flag that swaps the current Stork client for a Pagefind
adapter in the search UI.
5. Only remove Stork after the Pagefind path has parity on excerpts, filters,
and result URLs.
Inference:
Pagefind is probably the cleaner long-term search engine here, but because this
repo already has a data-first indexing pipeline, the migration cost is more
about adapter work than about search quality.
## Source Links
- Pagefind repo:
https://github.com/Pagefind/pagefind
- Pagefind latest release:
https://github.com/Pagefind/pagefind/releases/tag/v1.4.0
- Pagefind Node API docs:
https://pagefind.app/docs/node-api/
- Pagefind browser API docs:
https://pagefind.app/docs/api/
- Pagefind filtering docs:
https://pagefind.app/docs/filtering/
- Pagefind sorts docs:
https://pagefind.app/docs/sorts/
- Pagefind issue `#163`:
https://github.com/Pagefind/pagefind/issues/163
- Pagefind issue `#198`:
https://github.com/Pagefind/pagefind/issues/198
- Pagefind issue `#277`:
https://github.com/Pagefind/pagefind/issues/277
- Pagefind issue `#574`:
https://github.com/Pagefind/pagefind/issues/574
- HN result set for Pagefind launches:
https://hn.algolia.com/?q=Pagefind

View file

@ -1,6 +1,6 @@
import fs from 'fs-extra'
import axios from 'axios'
import 'dotenv/config'
import 'dotenv/config.js'
import {
// storkVersion,

View file

@ -2,6 +2,7 @@ import TOML from '@iarna/toml'
import fs from 'fs-extra'
import pkg from '~/package.json'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import { getSiteUrl } from '~/helpers/url.js'
import { getRouteType } from '~/helpers/app-derived.js'
@ -155,7 +156,7 @@ export function makeTitle ( listing ) {
}
export function makeDescription ( listing ) {
return `Latest reported support status of ${ listing.name } on Apple Silicon and ${ this.$config.processorsVerbiage } Processors.`
return `Latest reported support status of ${ listing.name } on Apple Silicon and ${ publicRuntimeConfig.processorsVerbiage } Processors.`
}
function makeTag ( tag, tagName = 'meta' ) {

View file

@ -6,7 +6,10 @@ export function isNuxt( VueThis ) {
}
export function isBrowserContext () {
if ( typeof navigator === 'undefined' ) return false
// Node 22 exposes a global navigator, so use window/document instead.
if ( typeof window === 'undefined' ) return false
if ( typeof document === 'undefined' ) return false
return true
}
@ -14,6 +17,8 @@ export function isBrowserContext () {
export function hasProcesGlobal () {
if ( typeof process === 'undefined' ) return false
if ( !process.versions?.node ) return false
return true
}

238
helpers/pagefind/browser.js Normal file
View file

@ -0,0 +1,238 @@
import {
isNonEmptyString
} from '~/helpers/check-types.js'
import {
pagefindBundleRelativeURL,
pagefindScriptURL
} from '~/helpers/pagefind/config.js'
const pagefindGlobalKey = '__doesItArmPagefind'
const pagefindLoaderPromiseKey = '__doesItArmPagefindLoaderPromise'
const pagefindLoaderTimeoutMs = 10 * 1000
function escapeHtml ( text = '' ) {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function escapeRegExp ( value ) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function makeHighlightedPagefindTitle ( title, terms = [] ) {
const highlightedTerms = terms
.filter( term => isNonEmptyString( term ) )
.map( term => term.trim() )
.filter( term => term.length > 0 )
.sort( ( a, b ) => b.length - a.length )
const titleMarkup = escapeHtml( title || '' )
if ( highlightedTerms.length === 0 ) {
return titleMarkup
}
const pattern = highlightedTerms.map( escapeRegExp ).join('|')
return titleMarkup.replace(
new RegExp(`(${ pattern })`, 'gi'),
'<span class="stork-highlighted-text font-bold text-white bg-green-800 rounded px-1">$1</span>'
)
}
export function normalizePagefindExcerptMarkup ( excerptMarkup = '' ) {
if ( !isNonEmptyString( excerptMarkup ) ) return ''
return excerptMarkup
.replace(/<mark[^>]*>/g, '<span class="stork-highlighted-text font-bold text-white bg-green-800 rounded px-1">')
.replace(/<\/mark>/g, '</span>')
}
export function mapPagefindDataToListing ( resultData, {
highlightTerms = []
} = {} ) {
const lastUpdatedTimestamp = Number( resultData.meta?.lastUpdatedTimestamp || 0 )
return {
name: makeHighlightedPagefindTitle( resultData.meta?.title || resultData.url, highlightTerms ),
text: resultData.meta?.text || resultData.url,
endpoint: resultData.url,
slug: resultData.meta?.slug || resultData.url,
category: {
slug: resultData.meta?.categorySlug || 'uncategorized'
},
lastUpdated: lastUpdatedTimestamp > 0 ? {
timestamp: lastUpdatedTimestamp
} : null,
resultExcerptsMarkup: isNonEmptyString( resultData.excerpt ) ? [
normalizePagefindExcerptMarkup( resultData.excerpt )
] : []
}
}
function getPagefindModuleSource () {
return [
`import * as pagefindModule from ${ JSON.stringify( pagefindScriptURL ) }`,
`globalThis.${ pagefindGlobalKey } = pagefindModule.default || pagefindModule`
].join('\n')
}
function waitForPagefindGlobal () {
return new Promise( ( resolve, reject ) => {
if ( globalThis[ pagefindGlobalKey ] ) {
resolve( globalThis[ pagefindGlobalKey ] )
return
}
const startedAt = Date.now()
const timer = setInterval( () => {
if ( globalThis[ pagefindGlobalKey ] ) {
clearInterval( timer )
resolve( globalThis[ pagefindGlobalKey ] )
return
}
if ( Date.now() - startedAt >= pagefindLoaderTimeoutMs ) {
clearInterval( timer )
reject( new Error(`Timed out waiting for Pagefind browser module at ${ pagefindScriptURL }`) )
}
}, 20 )
} )
}
async function loadPagefindBrowserModule () {
if ( typeof document === 'undefined' ) {
throw new Error('PagefindClient can only load in a browser document')
}
if ( globalThis[ pagefindGlobalKey ] ) {
return globalThis[ pagefindGlobalKey ]
}
if ( !globalThis[ pagefindLoaderPromiseKey ] ) {
globalThis[ pagefindLoaderPromiseKey ] = new Promise( async ( resolve, reject ) => {
const script = document.createElement('script')
script.async = true
script.type = 'module'
script.textContent = getPagefindModuleSource()
script.onerror = () => {
delete globalThis[ pagefindLoaderPromiseKey ]
reject( new Error(`Failed to load Pagefind browser module from ${ pagefindScriptURL }`) )
}
document.head.append( script )
try {
resolve( await waitForPagefindGlobal() )
} catch ( err ) {
delete globalThis[ pagefindLoaderPromiseKey ]
reject( err )
}
} )
}
return await globalThis[ pagefindLoaderPromiseKey ]
}
export class PagefindClient {
constructor ( options = {} ) {
this.bundlePath = options.bundlePath || pagefindBundleRelativeURL
this.pagefind = options.pagefind || null
this.debounceMs = options.debounceMs || 100
this.cancelCurrentQuery = null
}
setupState = 'not-setup'
get isSetup () {
return this.setupState === 'complete'
}
waitForSetup () {
return new Promise( resolve => {
if ( this.isSetup ) resolve()
const timer = setInterval( () => {
if ( this.isSetup ) {
clearInterval( timer )
resolve()
}
}, 50 )
})
}
async loadPagefindScript () {
if ( this.pagefind ) return
const pagefindModule = await loadPagefindBrowserModule()
this.pagefind = pagefindModule.default || pagefindModule
this.pagefind.options({
bundlePath: this.bundlePath
})
}
async setup () {
if ( this.setupState !== 'not-setup' ) {
await this.waitForSetup()
return
}
this.setupState = 'pending'
await this.loadPagefindScript()
if ( typeof this.pagefind.init === 'function' ) {
await this.pagefind.init()
}
this.setupState = 'complete'
}
async lazyQuery ( query, {
filters = {},
sort = {}
} = {} ) {
const searchOptions = {}
if ( Object.keys( filters ).length > 0 ) {
searchOptions.filters = filters
}
if ( Object.keys( sort ).length > 0 ) {
searchOptions.sort = sort
}
const trimmedQuery = query.trim()
const result = await new Promise( async ( resolve, reject ) => {
if ( this.cancelCurrentQuery !== null ) {
this.cancelCurrentQuery()
}
this.cancelCurrentQuery = () => { reject({ message: `Cancelled previous query for ${ trimmedQuery }`, canceled: true }) }
if ( !this.isSetup ) await this.setup()
if ( trimmedQuery.length === 0 ) {
resolve( await this.pagefind.search( null, searchOptions ) )
return
}
resolve( await this.pagefind.debouncedSearch( trimmedQuery, searchOptions, this.debounceMs ) )
}).catch( err => {
console.log('Query rejected', err)
return null
})
this.cancelCurrentQuery = null
return result
}
}

View file

@ -0,0 +1,5 @@
export const pagefindLanguage = 'en'
export const pagefindOutputPath = './static/pagefind'
export const pagefindBundleRelativeURL = '/pagefind/'
export const pagefindScriptURL = `${ pagefindBundleRelativeURL }pagefind.js`
export const sitemapEndpointsPath = './static/sitemap-endpoints.json'

181
helpers/pagefind/index.js Normal file
View file

@ -0,0 +1,181 @@
import fs from 'fs-extra'
import * as pagefind from 'pagefind'
import {
isNonEmptyArray,
isNonEmptyString
} from '~/helpers/check-types.js'
import {
getRouteType
} from '~/helpers/app-derived.js'
import {
getAppCategory
} from '~/helpers/categories.js'
import {
pagefindLanguage,
pagefindOutputPath
} from '~/helpers/pagefind/config.js'
function getSearchListing ( sitemapEntry ) {
return sitemapEntry.payload.app || sitemapEntry.payload.listing || sitemapEntry.payload.video || null
}
function pushContentPart ( parts, value ) {
if ( !isNonEmptyString( value ) ) return
parts.push( value.trim() )
}
function pushListContentPart ( parts, values ) {
if ( !isNonEmptyArray( values ) ) return
pushContentPart( parts, values.join(', ') )
}
function normalizeFilterValue ( value ) {
if ( !isNonEmptyString( value ) ) return null
return value.replaceAll('-', '_').trim()
}
export function shouldIndexSitemapEntry ( sitemapEntry ) {
return getSearchListing( sitemapEntry ) !== null
}
export function makePagefindTitle ( sitemapEntry ) {
const listing = getSearchListing( sitemapEntry )
const routeType = getRouteType( sitemapEntry.route )
let title = listing?.name || sitemapEntry.route
if ( routeType === 'benchmarks' ) {
title = `${ title } Benchmarks`
}
return title
}
export function makePagefindContent ( sitemapEntry ) {
const listing = getSearchListing( sitemapEntry )
const routeType = getRouteType( sitemapEntry.route )
const parts = []
pushContentPart( parts, makePagefindTitle( sitemapEntry ) )
pushContentPart( parts, listing?.text )
pushContentPart( parts, listing?.content )
pushContentPart( parts, listing?.description )
pushListContentPart( parts, listing?.aliases )
pushListContentPart( parts, listing?.tags )
pushListContentPart( parts, listing?.timestamps?.map( timestamp => timestamp.fullText ) )
pushListContentPart( parts, listing?.appLinks?.map( appLink => appLink.name ) )
pushContentPart( parts, listing?.category?.label )
pushContentPart( parts, listing?.status )
if ( routeType === 'benchmarks' ) {
pushContentPart( parts, 'Benchmarks')
pushContentPart( parts, 'Apple Silicon App Tested')
}
return parts.join('\n\n')
}
export function makePagefindFilters ( sitemapEntry ) {
const listing = getSearchListing( sitemapEntry )
const routeType = getRouteType( sitemapEntry.route )
const filters = {
type: [ normalizeFilterValue( routeType ) ]
}
const status = normalizeFilterValue( listing?.status )
if ( status !== null ) {
filters.status = [ status ]
}
if ( listing?.category?.slug ) {
filters.category = [ getAppCategory( listing ).snakeSlug ]
}
return filters
}
export function mapSitemapEntryToPagefindRecord ( sitemapEntry ) {
if ( !shouldIndexSitemapEntry( sitemapEntry ) ) return null
const listing = getSearchListing( sitemapEntry )
const routeType = getRouteType( sitemapEntry.route )
const lastUpdatedTimestamp = String( listing?.lastUpdated?.timestamp || 0 )
return {
url: sitemapEntry.route,
content: makePagefindContent( sitemapEntry ),
language: pagefindLanguage,
meta: {
title: makePagefindTitle( sitemapEntry ),
text: listing?.text || '',
slug: listing?.slug || sitemapEntry.route,
categorySlug: listing?.category?.slug || 'uncategorized',
routeType,
lastUpdatedTimestamp
},
filters: makePagefindFilters( sitemapEntry ),
sort: {
updated: lastUpdatedTimestamp
}
}
}
export async function writePagefindIndex ( sitemapEndpoints, {
outputPath = pagefindOutputPath
} = {} ) {
await fs.remove( outputPath )
const {
errors,
index
} = await pagefind.createIndex({
forceLanguage: pagefindLanguage
})
if ( errors.length > 0 ) {
throw new Error(`Pagefind createIndex errors: ${ errors.join(', ') }`)
}
if ( !index ) {
throw new Error('Pagefind index was not created')
}
let recordCount = 0
try {
for ( const sitemapEntry of sitemapEndpoints ) {
const record = mapSitemapEntryToPagefindRecord( sitemapEntry )
if ( record === null ) continue
const response = await index.addCustomRecord( record )
if ( response.errors.length > 0 ) {
throw new Error(`Pagefind addCustomRecord errors for ${ sitemapEntry.route }: ${ response.errors.join(', ') }`)
}
recordCount += 1
}
const writeResponse = await index.writeFiles({
outputPath
})
if ( writeResponse.errors.length > 0 ) {
throw new Error(`Pagefind writeFiles errors: ${ writeResponse.errors.join(', ') }`)
}
return {
outputPath,
recordCount
}
} finally {
await index.deleteIndex().catch( () => null )
await pagefind.close().catch( () => null )
}
}

18
helpers/search/config.js Normal file
View file

@ -0,0 +1,18 @@
export const defaultSearchProvider = 'pagefind'
export const supportedSearchProviders = new Set([
'pagefind',
'stork'
])
export function getSearchProvider ( rawProvider = defaultSearchProvider ) {
const provider = ( rawProvider || defaultSearchProvider ).toLowerCase()
if ( supportedSearchProviders.has( provider ) ) {
return provider
}
console.warn(`Unknown search provider "${ provider }", falling back to "${ defaultSearchProvider }"`)
return defaultSearchProvider
}

119
helpers/search/filters.js Normal file
View file

@ -0,0 +1,119 @@
import { filterSeparator } from '~/helpers/constants.js'
import { isString } from '~/helpers/check-types.js'
export class SearchFilters {
constructor({
initialFilters = {}
} = {}) {
this.initialFilters = initialFilters
this.filters = {
...initialFilters
}
}
get list () {
return Object.entries( this.filters ).map( ([ filterKey, filterValue ]) => {
return `${ filterKey }${ filterSeparator }${ filterValue }`
} )
}
get asQuery () {
return this.list.join(' ')
}
get asPagefindFilters () {
return Object.fromEntries( Object.entries( this.filters ).map( ([ filterKey, filterValue ]) => {
return [ filterKey, [ filterValue ] ]
}) )
}
getByKey ( key ) {
return `${ key }${ filterSeparator }${ this.filters[ key ] }`
}
isQueryValue ( filterNameOrQueryValue ) {
return filterNameOrQueryValue.includes( filterSeparator )
}
getKeyAndValue ( filterQueryValue ) {
const key = filterQueryValue.substring(0, filterQueryValue.indexOf( filterSeparator ))
const value = filterQueryValue.substring(filterQueryValue.indexOf( filterSeparator )+1)
return { key, value }
}
getFilterNameAndValueFromString ( filterNameOrQueryValue ) {
if ( this.isQueryValue( filterNameOrQueryValue ) ) {
return this.getKeyAndValue( filterNameOrQueryValue )
}
return {
key: filterNameOrQueryValue,
value: null
}
}
remove ( filterName ) {
if ( this.isQueryValue( filterName ) ) throw new Error(`${ filterName } is not a valid filter name`)
delete this.filters[ filterName ]
}
setFromStringArray ( filterStringArray ) {
filterStringArray.forEach( filterString => {
const { key, value } = this.getFilterNameAndValueFromString( filterString )
this.filters[ key ] = value
})
}
setFromString ( filterNameOrQueryValue ) {
const {
key,
value = ''
} = this.getFilterNameAndValueFromString( filterNameOrQueryValue )
if ( value.trim().length === 0 ) throw new Error(`${ filterNameOrQueryValue } is not a valid filter value`)
this.set( key, value )
}
set ( filterName, filterValue ) {
if ( this.isQueryValue( filterName ) ) throw new Error(`${ filterName } is not a valid filter name`)
this.filters[ filterName ] = filterValue
}
toggleFilter ( filterNameOrQueryValue, filterValue = null ) {
const fromString = this.getFilterNameAndValueFromString( filterNameOrQueryValue )
const filterName = fromString.key
filterValue = filterValue || fromString.value
if ( this.has( filterNameOrQueryValue ) ) {
this.remove( filterName )
return
}
if ( typeof filterValue !== 'string' ) {
throw new Error(`Filter value must be a string. Got ${ typeof filterValue }`)
}
this.set( filterName, filterValue )
}
has ( filterNameOrQueryValue ) {
const {
key : filterName,
value : filterValue = null
} = this.getFilterNameAndValueFromString( filterNameOrQueryValue )
if ( isString( filterValue ) ) {
return !!this.filters[ filterName ] && this.filters[ filterName ] === filterValue
}
return !!this.filters[ filterName ]
}
}

View file

@ -1,6 +1,5 @@
import { filterSeparator } from '~/helpers/constants.js'
import { isString } from '~/helpers/check-types.js'
import { SearchFilters } from '~/helpers/search/filters.js'
import {
storkIndexRelativeURL,
@ -276,121 +275,4 @@ export class StorkClient {
}
}
export class StorkFilters {
constructor({
initialFilters = {}
} = {}) {
this.initialFilters = initialFilters
this.filters = {
...initialFilters
}
}
get list () {
return Object.entries( this.filters ).map( ([ filterKey, filterValue ]) => {
return `${ filterKey }${ filterSeparator }${ filterValue }`
} )
}
get asQuery () {
return this.list.join(' ')
}
getByKey ( key ) {
return `${ key }${ filterSeparator }${ this.filters[ key ] }`
}
isQueryValue ( filterNameOrQueryValue ) {
return filterNameOrQueryValue.includes( filterSeparator )
}
getKeyAndValue ( filterQueryValue ) {
const key = filterQueryValue.substring(0, filterQueryValue.indexOf( filterSeparator ))
const value = filterQueryValue.substring(filterQueryValue.indexOf( filterSeparator )+1)
return { key, value }
}
getFilterNameAndValueFromString ( filterNameOrQueryValue ) {
if ( this.isQueryValue( filterNameOrQueryValue ) ) {
return this.getKeyAndValue( filterNameOrQueryValue )
}
return {
key: filterNameOrQueryValue,
value: null
}
}
remove ( filterName ) {
// Throw error if it's not a valid filter name
if ( this.isQueryValue( filterName ) ) throw new Error(`${ filterName } is not a valid filter name`)
delete this.filters[ filterName ]
}
setFromStringArray ( filterStringArray ) {
filterStringArray.forEach( filterString => {
const { key, value } = this.getFilterNameAndValueFromString( filterString )
this.filters[ key ] = value
})
}
setFromString ( filterNameOrQueryValue ) {
const {
key,
value = ''
} = this.getFilterNameAndValueFromString( filterNameOrQueryValue )
// Throw for empty values
if ( value.trim().length === 0 ) throw new Error(`${ filterNameOrQueryValue } is not a valid filter value`)
this.set( key, value )
}
set ( filterName, filterValue ) {
// Throw error if it's not a valid filter name
if ( this.isQueryValue( filterName ) ) throw new Error(`${ filterName } is not a valid filter name`)
this.filters[ filterName ] = filterValue
}
toggleFilter ( filterNameOrQueryValue, filterValue = null ) {
const fromString = this.getFilterNameAndValueFromString( filterNameOrQueryValue )
const filterName = fromString.key
filterValue = filterValue || fromString.value
// If the filter is already set, remove it
if ( this.has( filterNameOrQueryValue ) ) {
this.remove( filterName )
return
}
// Throw error if filter value is not a string
if ( typeof filterValue !== 'string' ) {
throw new Error(`Filter value must be a string. Got ${ typeof filterValue }`)
}
this.set( filterName, filterValue )
}
has ( filterNameOrQueryValue ) {
const {
key : filterName,
value : filterValue = null
} = this.getFilterNameAndValueFromString( filterNameOrQueryValue )
// If this filter is a name and value, check if it's set
if ( isString( filterValue ) ) {
return !!this.filters[ filterName ] && this.filters[ filterName ] === filterValue
}
return !!this.filters[ filterName ]
}
}
export class StorkFilters extends SearchFilters {}

View file

@ -2,7 +2,6 @@
import fs from 'fs-extra'
import execa from 'execa'
import { isDarwin } from '~/helpers/environment.js'
import {
storkVersion,
storkExecutableName,
@ -11,15 +10,25 @@ import {
storkIndexPath
} from '~/helpers/stork/config.js'
// Netlify's Ubuntu 24 (Noble) image needs the OpenSSL 3 compatible binary.
export function getStorkExecutableTarget ( {
platform = process.platform,
arch = process.arch
} = {} ) {
if ( platform === 'darwin' ) {
if ( arch === 'arm64' ) return 'stork-macos-13-arm'
return 'stork-macos-10-15'
}
return 'stork-ubuntu-22-04'
}
// https://stork-search.net/docs/install
const execDownloadUrls = {
darwin: `https://files.stork-search.net/releases/v${ storkVersion }/stork-macos-10-15`,
default: `https://files.stork-search.net/releases/v${ storkVersion }/stork-ubuntu-20-04`
export function getStorkExecutableDownloadUrl ( options = {} ) {
const target = getStorkExecutableTarget( options )
// Stork 2.0
// darwin: `https://files.stork-search.net/releases/v${ storkVersion }/stork-macos-12`,
// default: `https://files.stork-search.net/releases/v${ storkVersion }/stork-amazon-linux`
return `https://files.stork-search.net/releases/v${ storkVersion }/${ target }`
}
// Check if a file is executable
@ -33,9 +42,7 @@ async function isExecutable ( path ) {
// 👩‍💻 Bash Download example - https://github.com/jmooring/hugo-stork/blob/main/build.sh
export async function downloadStorkExecutable () {
const envKey = isDarwin() ? 'darwin' : 'default'
const execDownloadUrl = execDownloadUrls[ envKey ]
const execDownloadUrl = getStorkExecutableDownloadUrl()
// console.log( { execDownloadUrl } )
@ -46,6 +53,7 @@ export async function downloadStorkExecutable () {
// Download the binary
await execa( `curl`, [
'-fsSL',
execDownloadUrl,
// Set filename
@ -60,7 +68,7 @@ export async function downloadStorkExecutable () {
// console.log( 'isExecutable', isExecutable )
if ( !isExecutable( storkExecutablePath ) ) throw new Error( `Downloaded binary at ${ storkExecutablePath } is not executable.` )
if ( !(await isExecutable( storkExecutablePath )) ) throw new Error( `Downloaded binary at ${ storkExecutablePath } is not executable.` )
// Check Stork version
@ -78,7 +86,7 @@ export async function downloadStorkExecutable () {
export async function buildIndex () {
if ( !isExecutable( storkExecutablePath ) ) throw new Error( `Binary at ${ storkExecutablePath } is not executable.` )
if ( !(await isExecutable( storkExecutablePath )) ) throw new Error( `Binary at ${ storkExecutablePath } is not executable.` )
// Check Stork version
// so we know our binary is working

View file

@ -7,7 +7,7 @@
"packageManager": "pnpm@10.12.1",
"engines": {
"pnpm": "^10.*",
"node": ">=22",
"node": ">=24",
"yarn": "forbidden, this project uses pnpm",
"npm": "forbidden, this project uses pnpm"
},
@ -29,17 +29,22 @@
"test-listings": "pnpm run with-env vitest ./test/listings",
"test-postbuild-api": "pnpm test-listings",
"test-vitest": "vitest",
"test": "ava --timeout=1m --verbose",
"test": "vitest run",
"test:browser": "vitest run --config vitest.playwright.config.mjs",
"test:browser:pagefind": "vitest run --config vitest.playwright.config.mjs test/playwright/pagefind-native-filter.playwright.js",
"test:browser:pagefind:live": "PLAYWRIGHT_BASE_URL=https://doesitarm.com vitest run --config vitest.playwright.config.mjs test/playwright/pagefind-native-filter.playwright.js",
"dev": "pnpm run dev-astro",
"build": "pnpm run generate-astro",
"build-api": "pnpm run clone-readme && npx vite-node build-lists.js -- --with-api --no-lists",
"build-lists-and-api": "pnpm run clone-readme && npx vite-node build-lists.js -- --with-api",
"generate-dev": "pnpm run generate && npm test",
"download-stork:toml": "npx vite-node scripts/download-stork-toml.js",
"download-stork:executable": "npx vite-node scripts/download-stork-executable.js",
"build-api": "pnpm run clone-readme && pnpm exec vite-node build-lists.js -- --with-api --no-lists",
"build-lists-and-api": "pnpm run clone-readme && pnpm exec vite-node build-lists.js -- --with-api",
"generate-dev": "pnpm build && pnpm test",
"download-stork:toml": "pnpm exec vite-node scripts/download-stork-toml.js",
"download-stork:executable": "pnpm exec vite-node scripts/download-stork-executable.js",
"setup-stork": "pnpm run \"/^download-stork:.*/\" --parallel",
"build-stork-index": "./$npm_package_config_stork_executable build --input $npm_package_config_stork_toml --output $npm_package_config_stork_index",
"build-stork-index-js": "npx vite-node scripts/build-stork-index.js",
"build-stork-index-js": "pnpm exec vite-node scripts/build-stork-index.js",
"build-pagefind-index": "pnpm exec vite-node scripts/build-pagefind-index.js",
"build-search-index": "pnpm exec vite-node scripts/build-search-index.js",
"stork-search": "./$npm_package_config_stork_executable search --index $npm_package_config_stork_index --query $1",
"stork-index": "pnpm setup-stork && pnpm build-stork-index",
"stork-netlify": "chmod +x scripts/stork-netlify.sh && ./scripts/stork-netlify.sh",
@ -54,10 +59,10 @@
"scan-new-apps": "pnpm exec vite-node scripts/scan-new-apps.js",
"with-env": "dotenv -e .env -- ",
"cloudflare-deploy": "pnpm run build-api",
"vercel-build": "npx vite-node scripts/vercel-build.js",
"netlify-prebuild:download-sitemaps": "npx vite-node scripts/download-sitemaps.js",
"vercel-build": "pnpm exec vite-node scripts/vercel-build.js",
"netlify-prebuild:download-sitemaps": "pnpm exec vite-node scripts/download-sitemaps.js",
"netlify-prebuild:test-prebuild-functions": "pnpm test-prebuild && pnpm test-api-client && pnpm test-listings",
"netlify-prebuild": "pnpm run \"/^netlify-prebuild:.*/\" && pnpm stork-index",
"netlify-prebuild": "pnpm run \"/^netlify-prebuild:.*/\" && pnpm build-search-index",
"netlify-postbuild:test-postbuild-functions": "vitest test/main.test.ts",
"netlify-postbuild:test-circular-deps": "madge --circular --extensions js,mjs,ts,vue,astro ./*",
"netlify-build": "pnpm run netlify-prebuild && pnpm generate-astro && pnpm run \"/^netlify-postbuild:.*/\"",
@ -65,9 +70,9 @@
},
"dependencies": {
"7z-wasm": "^1.0.0-beta.5",
"@astrojs/netlify": "^2.6.0",
"@astrojs/partytown": "^1.2.3",
"@astrojs/vue": "^2.2.1",
"@astrojs/netlify": "^7.0.2",
"@astrojs/partytown": "^2.1.5",
"@astrojs/vue": "^6.0.0",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@fontsource/inter": "^4.0.1",
@ -76,7 +81,7 @@
"@originjs/vite-plugin-commonjs": "^1.0.3",
"@supercharge/promise-pool": "^2.1.0",
"@zip.js/zip.js": "^2.5.25",
"astro": "^2.10.7",
"astro": "^6.0.4",
"axios": "^0.21.0",
"buffer": "^6.0.3",
"can-autoplay": "^3.0.0",
@ -107,6 +112,7 @@
"node-html-parser": "^2.0.0",
"observe-element-in-viewport": "0.0.15",
"ofetch": "^1.0.0",
"pagefind": "1.4.0",
"plist": "^3.0.1",
"pretty-bytes": "^5.5.0",
"rollup-plugin-node-polyfills": "^0.2.1",
@ -115,32 +121,31 @@
"sitemap": "^7.1.1",
"slugify": "^1.4.6",
"std-env": "^3.3.2",
"terser": "^4.8.0",
"terser": "^5.46.0",
"uuid": "^8.3.2",
"vite-node": "^0.34.1",
"vue": "^3.2.30",
"vite-node": "^5.3.0",
"vue": "^3.5.30",
"workerpool": "^6.2.1",
"zip-lib": "^0.7.3"
},
"devDependencies": {
"@astrojs/sitemap": "^2.0.1",
"@astrojs/tailwind": "^4.0.0",
"@vitest/web-worker": "^0.20.3",
"@astrojs/sitemap": "^3.7.1",
"@astrojs/tailwind": "^6.0.2",
"@vitest/web-worker": "^4.1.0",
"autoprefixer": "^10.0.2",
"ava": "^6.2.0",
"babel-eslint": "^8.2.1",
"cssnano": "^4.1.10",
"dotenv": "^8.2.0",
"dotenv-cli": "^8.0.0",
"eslint": "^8.14.0",
"eslint-plugin-vue": "^8.7.1",
"esm": "^3.2.25",
"fast-xml-parser": "^3.19.0",
"madge": "^5.0.1",
"msw": "^1.2.3",
"node-fetch": "^2.6.1",
"nodemon": "^1.11.0",
"npm-run-all": "^4.1.5",
"playwright-core": "^1.58.2",
"postcss": "^8.2.4",
"postcss-cli": "^8.3.1",
"replace-css-url": "^1.2.6",
@ -148,7 +153,7 @@
"tailwindcss": "^3.2.6",
"tsconfig-paths": "^3.14.1",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^2.1.8"
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.0"
}
}

10161
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,42 @@
import fs from 'fs-extra'
import axios from 'axios'
import 'dotenv/config.js'
import {
sitemapEndpointsPath
} from '~/helpers/pagefind/config.js'
import {
writePagefindIndex
} from '~/helpers/pagefind/index.js'
async function loadSitemapEndpoints () {
if ( await fs.pathExists( sitemapEndpointsPath ) ) {
return await fs.readJson( sitemapEndpointsPath )
}
if ( !process.env.PUBLIC_API_DOMAIN ) {
throw new Error(`Missing ${ sitemapEndpointsPath } and PUBLIC_API_DOMAIN is not set`)
}
const apiUrl = new URL( process.env.PUBLIC_API_DOMAIN )
apiUrl.pathname = sitemapEndpointsPath.replace(/^\.?\/?static\//, '/')
const response = await axios.get( apiUrl.toString() )
return response.data
}
;(async () => {
const sitemapEndpoints = await loadSitemapEndpoints()
const {
outputPath,
recordCount
} = await writePagefindIndex( sitemapEndpoints )
console.log(`Built Pagefind index with ${ recordCount } records at ${ outputPath }`)
process.exit()
})().catch( error => {
console.error( error )
process.exit(1)
})

View file

@ -0,0 +1,18 @@
import { execSync } from 'child_process'
import 'dotenv/config.js'
import {
getSearchProvider
} from '~/helpers/search/config.js'
const searchProvider = getSearchProvider( process.env.PUBLIC_SEARCH_PROVIDER )
console.log(`Building search index for provider: ${ searchProvider }`)
if ( searchProvider === 'stork' ) {
execSync( 'pnpm stork-index', { stdio: 'inherit' } )
process.exit()
}
execSync( 'pnpm build-pagefind-index', { stdio: 'inherit' } )
process.exit()

View file

@ -1,5 +1,5 @@
import fs from 'fs-extra'
import 'dotenv/config'
import 'dotenv/config.js'
import axios from 'axios'
import {

View file

@ -2,11 +2,11 @@
# Hugo Bash Example https://github.com/jmooring/hugo-stork/blob/main/build.sh
# curl https://files.stork-search.net/releases/latest/stork-amazon-linux -o stork-executable
# curl https://files.stork-search.net/releases/v1.4.3/stork-macos-latest -o stork-executable
# Netlify's Noble/Ubuntu 24 image needs the Ubuntu 22.04 Stork build.
# curl -fsSL https://files.stork-search.net/releases/v1.6.0/stork-macos-13-arm -o stork-executable
curl https://files.stork-search.net/releases/v1.4.2/stork-amazon-linux -o stork-executable
# curl https://files.stork-search.net/releases/v1.4.2/stork-macos-10-15 -o stork-executable
curl -fsSL https://files.stork-search.net/releases/v1.6.0/stork-ubuntu-22-04 -o stork-executable
# curl -fsSL https://files.stork-search.net/releases/v1.6.0/stork-macos-10-15 -o stork-executable
chmod +x stork-executable
./stork-executable build --input static/stork.toml --output static/search-index.st

1
src/env.d.ts vendored
View file

@ -1 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View file

@ -1,5 +1,6 @@
---
// import { getPathPartsFromAstroRequest } from '~/helpers/url.js'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import {
catchRedirectResponse,
applyResponseDefaults
@ -31,7 +32,7 @@ Astro.response.statusText = 'Not found'
<Layout
headOptions={ {
title: `Page is not compatible with Apple Silicon - Does It ARM`,
description: `Check for Apple Silicon compatibility for any of your apps instantly before you buy an ${ global.$config.processorsVerbiage } Mac. `,
description: `Check for Apple Silicon compatibility for any of your apps instantly before you buy an ${ publicRuntimeConfig.processorsVerbiage } Mac. `,
// meta,
// link,
// structuredData: this.structuredData,
@ -75,4 +76,3 @@ Astro.response.statusText = 'Not found'
-->
</Layout>

View file

@ -1,4 +1,5 @@
---
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import {
applyResponseDefaults
} from '~/helpers/astro/request.js'
@ -22,7 +23,7 @@ applyResponseDefaults( Astro )
<Layout
headOptions={ {
title: `Apple Silicon Compatibility Test Online - Does It ARM`,
description: `Check for Apple Silicon compatibility for any of your apps instantly before you buy an ${ global.$config.processorsVerbiage } Mac. `,
description: `Check for Apple Silicon compatibility for any of your apps instantly before you buy an ${ publicRuntimeConfig.processorsVerbiage } Mac. `,
// meta,
// link,
// structuredData: this.structuredData,
@ -33,7 +34,7 @@ applyResponseDefaults( Astro )
>
<AppTestPage
config={ global.$config }
config={ publicRuntimeConfig }
client:load
/>

View file

@ -8,6 +8,7 @@
// https://docs.astro.build/core-concepts/astro-components/
import { DoesItAPI } from '~/helpers/api/client.js'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import {
applyResponseDefaults
} from '~/helpers/astro/request.js'
@ -117,8 +118,8 @@ for (const video of allVideos) {
---
<Layout
headOptions={ {
title: `Benchmarks for ${ global.$config.processorsVerbiage } Processors and Apple Silicon - Does It ARM`,
description: `Apple Silicon benchmark, performance, and compatibility videos for Macs using the ${ global.$config.processorsVerbiage } processors.`,
title: `Benchmarks for ${ publicRuntimeConfig.processorsVerbiage } Processors and Apple Silicon - Does It ARM`,
description: `Apple Silicon benchmark, performance, and compatibility videos for Macs using the ${ publicRuntimeConfig.processorsVerbiage } processors.`,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -8,6 +8,7 @@
// https://docs.astro.build/core-concepts/astro-components/
import { DoesItAPI } from '~/helpers/api/client.js'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import {
applyResponseDefaults
} from '~/helpers/astro/request.js'
@ -34,7 +35,7 @@ const kinds = Object.values( kindIndex ).map( category => {
<Layout
headOptions={ {
title: 'Categories of App Support lists for Apple Silicon',
description: `List of compatibility apps and games for Apple Silicon and the ${ global.$config.processorsVerbiage } Processors including performance reports and benchmarks`,
description: `List of compatibility apps and games for Apple Silicon and the ${ publicRuntimeConfig.processorsVerbiage } Processors including performance reports and benchmarks`,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -8,6 +8,7 @@
// https://docs.astro.build/core-concepts/astro-components/
import { DoesItAPI } from '~/helpers/api/client.js'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import {
applyResponseDefaults
} from '~/helpers/astro/request.js'
@ -31,7 +32,7 @@ const kinds = deviceIndex.items.map( device => {
<Layout
headOptions={ {
title: 'List of Apple Devices for Apple Silicon App Support',
description: `List of devices for Apple Silicon and the ${ global.$config.processorsVerbiage } Processors`,
description: `List of devices for Apple Silicon and the ${ publicRuntimeConfig.processorsVerbiage } Processors`,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -4,6 +4,7 @@
import { DoesItAPI } from '~/helpers/api/client.js'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import { getPathPartsFromAstroRequest } from '~/helpers/url.js'
import {
catchRedirectResponse,
@ -65,7 +66,7 @@ const pageLabel = category?.pluralLabel || category.label
<Layout
headOptions={{
title: `List of ${ pageLabel } that work on Apple Silicon?`,
description: `Check the latest reported support status of ${ pageLabel } on Apple Silicon and ${ global.$config.processorsVerbiage } Processors. `,
description: `Check the latest reported support status of ${ pageLabel } on Apple Silicon and ${ publicRuntimeConfig.processorsVerbiage } Processors. `,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -8,6 +8,7 @@
// https://docs.astro.build/core-concepts/astro-components/
import { DoesItAPI } from '~/helpers/api/client.js'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import {
applyResponseDefaults
} from '~/helpers/astro/request.js'
@ -29,8 +30,8 @@ const allAppsSummary = await DoesItAPI('all-apps-summary').get()
---
<Layout
headOptions={ {
title: `Apple Silicon and ${ global.$config.processorsVerbiage } app and game compatibility list`,
description: `List of compatibility apps and games for Apple Silicon and the ${ global.$config.processorsVerbiage } Processors including performance reports and benchmarks`,
title: `Apple Silicon and ${ publicRuntimeConfig.processorsVerbiage } app and game compatibility list`,
description: `List of compatibility apps and games for Apple Silicon and the ${ publicRuntimeConfig.processorsVerbiage } Processors including performance reports and benchmarks`,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -4,6 +4,7 @@
import { DoesItAPI } from '~/helpers/api/client.js'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import { getPathPartsFromAstroRequest } from '~/helpers/url.js'
import {
catchRedirectResponse,
@ -90,7 +91,7 @@ const adName = (() => {
<Layout
headOptions={{
title: `List of ${ pageLabel } that work on Apple Silicon?`,
description: `Check the latest reported support status of ${ pageLabel } on Apple Silicon and ${ global.$config.processorsVerbiage } Processors. `,
description: `Check the latest reported support status of ${ pageLabel } on Apple Silicon and ${ publicRuntimeConfig.processorsVerbiage } Processors. `,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -0,0 +1,273 @@
import { accessSync, constants } from 'node:fs'
import { spawn } from 'node:child_process'
import net from 'node:net'
import { chromium } from 'playwright-core'
import {
afterAll,
beforeAll,
describe,
expect,
it
} from 'vitest'
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const host = '127.0.0.1'
const configuredBaseUrl = process.env.PLAYWRIGHT_BASE_URL || ''
function canAccessPath ( filePath ) {
try {
accessSync( filePath, constants.X_OK )
return true
} catch {
return false
}
}
function getBrowserExecutablePath () {
const candidatePaths = [
process.env.PLAYWRIGHT_BROWSER_PATH,
process.env.CHROME_BIN,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/opt/homebrew/bin/chromium',
].filter( Boolean )
const executablePath = candidatePaths.find( canAccessPath )
if ( !executablePath ) {
throw new Error(`No browser executable found. Set PLAYWRIGHT_BROWSER_PATH or CHROME_BIN.`)
}
return executablePath
}
function getAvailablePort () {
return new Promise( ( resolve, reject ) => {
const server = net.createServer()
server.unref()
server.on( 'error', reject )
server.listen( 0, host, () => {
const { port } = server.address()
server.close( err => {
if ( err ) {
reject( err )
return
}
resolve( port )
} )
} )
} )
}
async function waitForServer ( url, {
intervalMs = 250,
timeoutMs = 60 * 1000
} = {} ) {
const startedAt = Date.now()
while ( Date.now() - startedAt < timeoutMs ) {
try {
const response = await fetch( url )
if ( response.ok ) {
return
}
} catch {}
await new Promise( resolve => setTimeout( resolve, intervalMs ) )
}
throw new Error(`Timed out waiting for dev server at ${ url }`)
}
function stopProcess ( childProcess ) {
return new Promise( resolve => {
if ( !childProcess ) {
resolve()
return
}
if ( childProcess.killed || childProcess.exitCode !== null ) {
resolve()
return
}
childProcess.once( 'exit', () => resolve() )
childProcess.kill( 'SIGTERM' )
setTimeout( () => {
if ( childProcess.exitCode === null ) {
childProcess.kill( 'SIGKILL' )
}
}, 5 * 1000 ).unref()
} )
}
describe('Pagefind dev search', () => {
let browser
let devServer
let devServerOutput = ''
let baseUrl = ''
beforeAll( async () => {
const executablePath = getBrowserExecutablePath()
if ( configuredBaseUrl.length > 0 ) {
baseUrl = configuredBaseUrl
} else {
const port = await getAvailablePort()
baseUrl = `http://${ host }:${ port }`
devServer = spawn( command, [
'exec',
'astro',
'dev',
'--host',
host,
'--port',
String( port )
], {
cwd: process.cwd(),
env: {
...process.env,
PUBLIC_SEARCH_PROVIDER: 'pagefind'
},
stdio: [ 'ignore', 'pipe', 'pipe' ]
} )
devServer.stdout.on( 'data', chunk => {
devServerOutput += chunk.toString()
} )
devServer.stderr.on( 'data', chunk => {
devServerOutput += chunk.toString()
} )
}
await waitForServer( baseUrl )
browser = await chromium.launch({
executablePath,
headless: true
} )
} )
afterAll( async () => {
await browser?.close()
await stopProcess( devServer )
} )
it('renders visible Pagefind results when Native Support is clicked', async () => {
const page = await browser.newPage()
const consoleErrors = []
const pageErrors = []
const pagefindResponses = []
const failedRequests = []
let fragmentRequests = 0
let failedFragmentRequests = 0
page.on( 'console', message => {
if ( message.type() === 'error' ) {
consoleErrors.push( message.text() )
}
} )
page.on( 'pageerror', error => {
pageErrors.push( error.message )
} )
page.on( 'response', response => {
if ( response.url().includes( '/pagefind/pagefind.js' ) ) {
pagefindResponses.push({
status: response.status(),
url: response.url()
})
}
} )
page.on( 'request', request => {
if ( request.url().includes( '/pagefind/' ) && request.url().includes( 'pf_fragment' ) ) {
fragmentRequests++
}
} )
page.on( 'requestfailed', request => {
if ( request.url().includes( '/pagefind/pagefind.js' ) ) {
failedRequests.push({
errorText: request.failure()?.errorText || 'unknown',
url: request.url()
})
}
if ( request.url().includes( '/pagefind/' ) && request.url().includes( 'pf_fragment' ) ) {
failedFragmentRequests++
}
} )
await page.goto( baseUrl, {
waitUntil: 'domcontentloaded'
} )
await page.waitForTimeout( 3000 )
await Promise.all([
page.waitForResponse( response => {
return response.url().includes( '/pagefind/pagefind.js' )
}, {
timeout: 10 * 1000
} ),
page.getByRole( 'button', {
name: /native support/i
} ).click()
])
await page.waitForFunction( () => {
return [ ...document.querySelectorAll( 'li[data-app-slug] h3' ) ].some( node => {
const text = node.textContent || ''
return text.trim().length > 0 && !/loading/i.test( text )
} )
}, {
timeout: 15 * 1000
} )
const bodyText = await page.locator( 'body' ).textContent()
const renderedResults = await page.evaluate( () => {
const headings = [ ...document.querySelectorAll( 'li[data-app-slug] h3' ) ].map( node => {
return ( node.textContent || '' ).trim()
} )
return {
loadingRows: headings.filter( text => /loading/i.test( text ) ).length,
rows: document.querySelectorAll( 'li[data-app-slug]' ).length,
visibleHeadings: headings.slice( 0, 5 )
}
} )
expect( pagefindResponses.some( response => response.status === 200 ), devServerOutput ).toBe( true )
expect(
pagefindResponses.some( response => response.status >= 400 ),
[
pagefindResponses.map( response => `${ response.status } ${ response.url }` ).join( '\n' ),
failedRequests.map( request => `${ request.errorText } ${ request.url }` ).join( '\n' ),
pageErrors.join( '\n' ),
consoleErrors.join( '\n' )
].join( '\n\n' )
).toBe( false )
expect( fragmentRequests, JSON.stringify( renderedResults ) ).toBeGreaterThan( 0 )
expect( fragmentRequests, JSON.stringify( renderedResults ) ).toBeLessThan( 100 )
expect( failedFragmentRequests, JSON.stringify( renderedResults ) ).toBe( 0 )
expect( renderedResults.rows, JSON.stringify( renderedResults ) ).toBeGreaterThan( 0 )
expect( renderedResults.loadingRows, JSON.stringify( renderedResults ) ).toBe( 0 )
expect( bodyText ).not.toContain( 'Failed to load url /pagefind/pagefind.js' )
expect( bodyText ).not.toContain( 'No apps found for' )
expect( pageErrors.join( '\n' ) ).not.toMatch( /pagefind\/pagefind\.js/ )
expect( pageErrors.join( '\n' ) ).not.toMatch( /Failed to fetch/ )
expect( consoleErrors.join( '\n' ) ).not.toMatch( /pagefind\/pagefind\.js/ )
expect( consoleErrors.join( '\n' ) ).not.toMatch( /ERR_INSUFFICIENT_RESOURCES/ )
await page.close()
} )
} )

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import {
isBrowserContext,
isDarwin,
isLinux
} from '~/helpers/environment.js'
describe( 'environment helpers', () => {
it( 'does not treat Node 22 navigator as a browser runtime', () => {
expect( isBrowserContext() ).toBe( false )
})
it( 'detects darwin directly from process.platform', () => {
expect( isDarwin() ).toBe( process.platform === 'darwin' )
})
it( 'detects linux-like runtimes directly from process.platform', () => {
expect( isLinux() ).toBe([
'linux',
'openbsd'
].includes( process.platform ) )
})
})

View file

@ -84,5 +84,19 @@ describe('StorkFilters', () => {
filters.setFromString('test_works_yes')
expect(filters.asQuery).toBe('test_works_yes')
})
it('should map filters for Pagefind', () => {
const filters = new StorkFilters()
filters.setFromStringArray([
'status_native',
'category_system_tools'
])
expect(filters.asPagefindFilters).toEqual({
status: [ 'native' ],
category: [ 'system_tools' ]
})
})
})
})

View file

@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import {
mapSitemapEntryToPagefindRecord,
shouldIndexSitemapEntry
} from '~/helpers/pagefind/index.js'
describe('Pagefind records', () => {
it('should skip sitemap entries without searchable payloads', () => {
expect( shouldIndexSitemapEntry({
route: '/games',
payload: {}
}) ).toBe( false )
})
it('should map benchmark entries to Pagefind records', () => {
const record = mapSitemapEntryToPagefindRecord({
route: '/app/example/benchmarks',
payload: {
app: {
name: 'Example App',
text: '✅ Native support',
content: 'Runs fast on Apple Silicon',
aliases: [ 'Example' ],
tags: [ 'Utilities' ],
status: 'no-in-progress',
slug: 'example',
category: {
slug: 'system-tools',
label: 'System Tools'
},
lastUpdated: {
timestamp: 1234567890
}
}
}
})
expect( record ).toMatchObject({
url: '/app/example/benchmarks',
language: 'en',
meta: {
title: 'Example App Benchmarks',
text: '✅ Native support',
slug: 'example',
categorySlug: 'system-tools',
routeType: 'benchmarks',
lastUpdatedTimestamp: '1234567890'
},
filters: {
status: [ 'no_in_progress' ],
category: [ 'system_tools' ],
type: [ 'benchmarks' ]
},
sort: {
updated: '1234567890'
}
})
expect( record.content ).toContain('Example App Benchmarks')
expect( record.content ).toContain('Runs fast on Apple Silicon')
expect( record.content ).toContain('Apple Silicon App Tested')
})
})

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
getStorkExecutableTarget,
getStorkExecutableDownloadUrl
} from '~/helpers/stork/executable.js'
describe( 'stork executable selection', () => {
it( 'uses the Ubuntu 22.04 binary for Linux builds', () => {
expect( getStorkExecutableTarget({
platform: 'linux',
arch: 'x64'
}) ).toBe( 'stork-ubuntu-22-04' )
})
it( 'uses the Apple Silicon macOS binary on arm64 Macs', () => {
expect( getStorkExecutableTarget({
platform: 'darwin',
arch: 'arm64'
}) ).toBe( 'stork-macos-13-arm' )
})
it( 'builds the download URL from the selected target', () => {
expect( getStorkExecutableDownloadUrl({
platform: 'linux',
arch: 'x64'
}) ).toContain( '/stork-ubuntu-22-04' )
})
})

View file

@ -1,4 +1,4 @@
import { defineConfig } from 'vitest/config'
import { configDefaults, defineConfig } from 'vitest/config'
import astroConfig from './astro.config.mjs'
@ -12,7 +12,11 @@ const vitestConfig = {
test: {
// testTimeout: 60 * 1000,
setupFiles: 'tsconfig-paths/register'
setupFiles: 'tsconfig-paths/register',
exclude: [
...configDefaults.exclude,
'test/_disabled/**'
]
}
}

View file

@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config'
import astroConfig from './astro.config.mjs'
const vitestConfig = {
...astroConfig,
...astroConfig.vite,
test: {
setupFiles: 'tsconfig-paths/register',
include: [
'test/playwright/**/*.playwright.js'
],
exclude: [
'test/_disabled/**'
],
fileParallelism: false,
hookTimeout: 120 * 1000,
testTimeout: 120 * 1000
}
}
export default defineConfig( vitestConfig )