Compare commits

..

No commits in common. "c5ec942de048afcefa5ac6598bdd41c8f9f2cd6b" and "e701c48fa8b4f3c1622c75995258c75391cc8699" have entirely different histories.

43 changed files with 3806 additions and 8456 deletions

View file

@ -14,19 +14,12 @@ jobs:
name: Deploy
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@master
- name: Setup PNPM
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v2
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: |

View file

@ -1,8 +1,9 @@
# This workflow validates the Node 24 toolchain on GitHub-hosted runners.
name: Run Node 24 Checks
# 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
on:
workflow_dispatch:
push:
branches: [ master ]
pull_request:
@ -12,26 +13,21 @@ jobs:
build:
runs-on: ubuntu-latest
env:
PUBLIC_URL: https://doesitarm.com
PUBLIC_API_DOMAIN: https://api.doesitarm.com
strategy:
matrix:
node-version: [24.x]
node-version: [14.x, 15.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v6
- name: Setup PNPM
uses: pnpm/action-setup@v4
with:
version: 10.12.1
run_install: false
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm netlify-build
- run: |
touch .env
echo ${{ secrets.GH_ENV }} >> .env
- run: npm ci
- run: npm run generate
- run: npm test

3
.gitignore vendored
View file

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

2
.nvmrc
View file

@ -1 +1 @@
v24
v22

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'
import netlify from '@astrojs/netlify/functions'
// import sitemap from '@astrojs/sitemap'
import partytown from '@astrojs/partytown'

24
ava.config.mjs Normal file
View file

@ -0,0 +1,24 @@
/**
* @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, extname, join } from 'path'
import { dirname, basename } from 'path'
import os from 'os'
import fs from 'fs-extra'
@ -41,12 +41,6 @@ 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'
@ -454,14 +448,6 @@ 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
@ -530,18 +516,14 @@ class BuildLists {
}
// Count saved files
const fileCount = fs.readdirSync( apiListDirectory )
.filter( fileName => extname( fileName ) === '.json' )
.length
const fileCount = fs.readdirSync( apiListDirectory ).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 )
.filter( fileName => extname( fileName ) === '.json' )
.map( fileName => basename(fileName).split('.')[0] )
const fileNames = fs.readdirSync( apiListDirectory ).map( fileName => basename(fileName).split('.')[0] )
logArraysDifference( listSlugs, fileNames )
@ -741,15 +723,9 @@ class BuildLists {
console.log('Building XML Sitemap')
await saveSitemap( sitemapEndpoints.map( ({ route }) => route ) )
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 )
}
// Save stork toml index
console.log('Building Stork toml index')
await writeStorkToml( 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()"
@click="toggleFilter(button.query); queryResults(query)"
>{{ button.label }}</button>
</div>
</div>
@ -151,15 +151,21 @@
</div>
<div
v-if="listing.resultExcerptsMarkup?.length"
v-if="listing.storkResult"
class="text-xs leading-5 font-light"
>
<div
v-for="(excerptMarkup, excerptIndex) in listing.resultExcerptsMarkup"
v-for="(excerpt, excerptIndex) in listing.storkResult.excerpts"
:key="`excerpt-${ excerptIndex }`"
class="result-excerpt space-y-3"
v-html="excerptMarkup"
/>
>
<div
v-for="(range, rangeIndex) in makeHighlightedMarkup( excerpt )"
:key="`range-${ rangeIndex }`"
v-html="range"
/>
</div>
</div>
<!-- listing.lastUpdated: {{ listing.lastUpdated }} -->
<template v-if="listing.lastUpdated">
@ -285,18 +291,9 @@ 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'
@ -307,7 +304,7 @@ import RelativeTime from '~/components/relative-time.vue'
import ListSummary from '~/components/list-summary.vue'
import ListEndButtons from '~/components/list-end-buttons.vue'
const searchProvider = getSearchProvider( import.meta.env.PUBLIC_SEARCH_PROVIDER )
let storkClient = null
export default {
components: {
@ -350,30 +347,15 @@ export default {
hasStartedAnyQuery: false,
listingsResults: [],
waitingForQuery: false,
isSSR: import.meta.env.SSR,
searchClient: null,
searchFilters: null,
lastQueryId: 0
isSSR: import.meta.env.SSR
}
},
computed: {
activeSearchProvider () {
return searchProvider
},
activeQuery () {
storkQuery () {
return [
this.userTextQuery.trim(),
...this.filterQueryList
].filter( Boolean ).join(' ')
},
pagefindResultLimit () {
return Math.max( this.initialList.length, 25 )
},
pagefindFilters () {
const filters = new SearchFilters()
filters.setFromStringArray( this.filterQueryList )
return filters.asPagefindFilters
].join(' ')
},
appList () {
return this.kindPage.items
@ -408,7 +390,7 @@ export default {
return this.baseFilters.length > 0
},
hasSearchInputText () {
return this.userTextQuery.trim().length > 0
return this.userTextQuery.length > 0
},
hasAnyUserFilters () {
return this.userFilters.length > 0
@ -420,7 +402,7 @@ export default {
return !this.hasAnyUserTerms
},
inputTerms () {
return this.userTextQuery.trim().split(' ').filter( Boolean )
return this.userTextQuery.trim().split(' ')
},
userFilters () {
// console.log('filterQueryList', )
@ -460,23 +442,22 @@ export default {
}
},
mounted () {
this.searchClient = this.makeSearchClient()
// Setup stork client
storkClient = new StorkClient()
this.searchFilters = new SearchFilters()
// Store filter instance
this.storkFilters = new StorkFilters()
// Add initial filters
this.storkFilters.setFromStringArray( this.baseFilters )
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 || []
},
@ -485,8 +466,10 @@ export default {
return this.filterQueryList.includes( filter )
},
toggleFilter ( newFilterQuery ) {
this.searchFilters.toggleFilter( newFilterQuery )
this.filterQueryList = this.searchFilters.list
this.storkFilters.toggleFilter( newFilterQuery )
this.filterQueryList = this.storkFilters.list
},
scrollInputToTop () {
scrollIntoView(this.$refs['search-container'], {
@ -494,73 +477,15 @@ 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 = this.userTextQuery ) {
const queryId = ++this.lastQueryId
async queryResults ( rawQuery ) {
if ( this.activeQuery.trim().length === 0 ) {
this.waitingForQuery = false
return
}
console.log( 'query', this.storkQuery )
// If our query is empty
// then bail
if ( this.storkQuery.trim().length === 0 ) return
this.waitingForQuery = true
@ -569,22 +494,36 @@ export default {
// Declare that at least one query has been made
this.hasStartedAnyQuery = true
const results = this.activeSearchProvider === 'stork'
? await this.runStorkQuery()
: await this.runPagefindQuery()
// console.log('rawQuery', rawQuery)
if ( queryId !== this.lastQueryId ) {
const requiredTerms = this.storkQuery.split(' ')
const storkQuery = await storkClient.lazyQuery( this.storkQuery, requiredTerms )
// If the query response is empty
// then return
if ( storkQuery === null ) {
return
}
if ( results === null ) {
this.waitingForQuery = false
return
}
// console.log( 'storkQuery', storkQuery )
this.listingsResults = results
this.listingsResults = storkQuery.results.map( result => {
return {
name: makeHighlightedResultTitle( result ),
endpoint: result.entry.url,
slug: '',
category: {
slug: 'uncategorized'
},
storkResult: result
}
})
// Switch from loading state and reveal the results
this.waitingForQuery = false
// console.log('this.listingsResults', this.listingsResults)
},
handleSearchInput ( event ) {

View file

@ -1,77 +0,0 @@
# 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

@ -1,306 +0,0 @@
# 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

@ -1,118 +0,0 @@
# 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.js'
import 'dotenv/config'
import {
// storkVersion,

View file

@ -2,7 +2,6 @@ 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'
@ -156,7 +155,7 @@ export function makeTitle ( listing ) {
}
export function makeDescription ( listing ) {
return `Latest reported support status of ${ listing.name } on Apple Silicon and ${ publicRuntimeConfig.processorsVerbiage } Processors.`
return `Latest reported support status of ${ listing.name } on Apple Silicon and ${ this.$config.processorsVerbiage } Processors.`
}
function makeTag ( tag, tagName = 'meta' ) {

View file

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

View file

@ -1,238 +0,0 @@
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

@ -1,5 +0,0 @@
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'

View file

@ -1,181 +0,0 @@
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 )
}
}

View file

@ -1,18 +0,0 @@
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
}

View file

@ -1,119 +0,0 @@
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,5 +1,6 @@
import { filterSeparator } from '~/helpers/constants.js'
import { isString } from '~/helpers/check-types.js'
import { SearchFilters } from '~/helpers/search/filters.js'
import {
storkIndexRelativeURL,
@ -275,4 +276,121 @@ export class StorkClient {
}
}
export class StorkFilters extends SearchFilters {}
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 ]
}
}

View file

@ -2,6 +2,7 @@
import fs from 'fs-extra'
import execa from 'execa'
import { isDarwin } from '~/helpers/environment.js'
import {
storkVersion,
storkExecutableName,
@ -10,25 +11,15 @@ 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
export function getStorkExecutableDownloadUrl ( options = {} ) {
const target = getStorkExecutableTarget( options )
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`
return `https://files.stork-search.net/releases/v${ storkVersion }/${ target }`
// 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`
}
// Check if a file is executable
@ -42,7 +33,9 @@ async function isExecutable ( path ) {
// 👩‍💻 Bash Download example - https://github.com/jmooring/hugo-stork/blob/main/build.sh
export async function downloadStorkExecutable () {
const execDownloadUrl = getStorkExecutableDownloadUrl()
const envKey = isDarwin() ? 'darwin' : 'default'
const execDownloadUrl = execDownloadUrls[ envKey ]
// console.log( { execDownloadUrl } )
@ -53,7 +46,6 @@ export async function downloadStorkExecutable () {
// Download the binary
await execa( `curl`, [
'-fsSL',
execDownloadUrl,
// Set filename
@ -68,7 +60,7 @@ export async function downloadStorkExecutable () {
// console.log( 'isExecutable', isExecutable )
if ( !(await isExecutable( storkExecutablePath )) ) throw new Error( `Downloaded binary at ${ storkExecutablePath } is not executable.` )
if ( !isExecutable( storkExecutablePath ) ) throw new Error( `Downloaded binary at ${ storkExecutablePath } is not executable.` )
// Check Stork version
@ -86,7 +78,7 @@ export async function downloadStorkExecutable () {
export async function buildIndex () {
if ( !(await isExecutable( storkExecutablePath )) ) throw new Error( `Binary at ${ storkExecutablePath } is not executable.` )
if ( !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": ">=24",
"node": ">=22",
"yarn": "forbidden, this project uses pnpm",
"npm": "forbidden, this project uses pnpm"
},
@ -29,22 +29,17 @@
"test-listings": "pnpm run with-env vitest ./test/listings",
"test-postbuild-api": "pnpm test-listings",
"test-vitest": "vitest",
"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",
"test": "ava --timeout=1m --verbose",
"dev": "pnpm run dev-astro",
"build": "pnpm run generate-astro",
"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",
"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",
"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": "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",
"build-stork-index-js": "npx vite-node scripts/build-stork-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",
@ -59,10 +54,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": "pnpm exec vite-node scripts/vercel-build.js",
"netlify-prebuild:download-sitemaps": "pnpm exec vite-node scripts/download-sitemaps.js",
"vercel-build": "npx vite-node scripts/vercel-build.js",
"netlify-prebuild:download-sitemaps": "npx 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 build-search-index",
"netlify-prebuild": "pnpm run \"/^netlify-prebuild:.*/\" && pnpm stork-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:.*/\"",
@ -70,9 +65,9 @@
},
"dependencies": {
"7z-wasm": "^1.0.0-beta.5",
"@astrojs/netlify": "^7.0.2",
"@astrojs/partytown": "^2.1.5",
"@astrojs/vue": "^6.0.0",
"@astrojs/netlify": "^2.6.0",
"@astrojs/partytown": "^1.2.3",
"@astrojs/vue": "^2.2.1",
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@fontsource/inter": "^4.0.1",
@ -81,7 +76,7 @@
"@originjs/vite-plugin-commonjs": "^1.0.3",
"@supercharge/promise-pool": "^2.1.0",
"@zip.js/zip.js": "^2.5.25",
"astro": "^6.0.4",
"astro": "^2.10.7",
"axios": "^0.21.0",
"buffer": "^6.0.3",
"can-autoplay": "^3.0.0",
@ -112,7 +107,6 @@
"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",
@ -121,31 +115,32 @@
"sitemap": "^7.1.1",
"slugify": "^1.4.6",
"std-env": "^3.3.2",
"terser": "^5.46.0",
"terser": "^4.8.0",
"uuid": "^8.3.2",
"vite-node": "^5.3.0",
"vue": "^3.5.30",
"vite-node": "^0.34.1",
"vue": "^3.2.30",
"workerpool": "^6.2.1",
"zip-lib": "^0.7.3"
},
"devDependencies": {
"@astrojs/sitemap": "^3.7.1",
"@astrojs/tailwind": "^6.0.2",
"@vitest/web-worker": "^4.1.0",
"@astrojs/sitemap": "^2.0.1",
"@astrojs/tailwind": "^4.0.0",
"@vitest/web-worker": "^0.20.3",
"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",
@ -153,7 +148,7 @@
"tailwindcss": "^3.2.6",
"tsconfig-paths": "^3.14.1",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.0"
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^2.1.8"
}
}

10149
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,42 +0,0 @@
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

@ -1,18 +0,0 @@
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.js'
import 'dotenv/config'
import axios from 'axios'
import {

View file

@ -2,11 +2,11 @@
# Hugo Bash Example https://github.com/jmooring/hugo-stork/blob/main/build.sh
# 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/latest/stork-amazon-linux -o stork-executable
# curl https://files.stork-search.net/releases/v1.4.3/stork-macos-latest -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
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
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,2 +1 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View file

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

View file

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

View file

@ -8,7 +8,6 @@
// 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'
@ -118,8 +117,8 @@ for (const video of allVideos) {
---
<Layout
headOptions={ {
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.`,
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.`,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -8,7 +8,6 @@
// 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'
@ -35,7 +34,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 ${ publicRuntimeConfig.processorsVerbiage } Processors including performance reports and benchmarks`,
description: `List of compatibility apps and games for Apple Silicon and the ${ global.$config.processorsVerbiage } Processors including performance reports and benchmarks`,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -8,7 +8,6 @@
// 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'
@ -32,7 +31,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 ${ publicRuntimeConfig.processorsVerbiage } Processors`,
description: `List of devices for Apple Silicon and the ${ global.$config.processorsVerbiage } Processors`,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -4,7 +4,6 @@
import { DoesItAPI } from '~/helpers/api/client.js'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import { getPathPartsFromAstroRequest } from '~/helpers/url.js'
import {
catchRedirectResponse,
@ -66,7 +65,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 ${ publicRuntimeConfig.processorsVerbiage } Processors. `,
description: `Check the latest reported support status of ${ pageLabel } on Apple Silicon and ${ global.$config.processorsVerbiage } Processors. `,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -8,7 +8,6 @@
// 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'
@ -30,8 +29,8 @@ const allAppsSummary = await DoesItAPI('all-apps-summary').get()
---
<Layout
headOptions={ {
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`,
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`,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -4,7 +4,6 @@
import { DoesItAPI } from '~/helpers/api/client.js'
import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs'
import { getPathPartsFromAstroRequest } from '~/helpers/url.js'
import {
catchRedirectResponse,
@ -91,7 +90,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 ${ publicRuntimeConfig.processorsVerbiage } Processors. `,
description: `Check the latest reported support status of ${ pageLabel } on Apple Silicon and ${ global.$config.processorsVerbiage } Processors. `,
// meta,
// link,
// structuredData: this.structuredData,

View file

@ -1,273 +0,0 @@
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

@ -1,24 +0,0 @@
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,19 +84,5 @@ 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

@ -1,64 +0,0 @@
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

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

View file

@ -1,23 +0,0 @@
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 )