Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ An Apple system palette in two appearances: neutral greys that step from canvas

## Layout

A two-column shell: a 260px sticky sidebar, one viewport tall and independently scrollable, beside a main column whose content caps at 1320px. The cap is applied as growing horizontal padding (`max(pad, (100% − 1320px) / 2)`), so every child of main shares one column edge. Main padding is 30px top and 40px bottom on desktop.
A two-column shell: a 260px sticky sidebar, one viewport tall and independently scrollable, beside a main column whose content caps at 1320px. The cap is applied as growing horizontal padding (`max(pad, (100% − 1320px) / 2)`), so every child of main shares one column edge. The site footer is a sibling of main in the same column and repeats that edge as margin. Main padding is 30px top on desktop; the 40px bottom space sits on the footer.

Shelves stack with 40px between them; each shelf after the first opens with an 18px-padded hairline above its header. A shelf or section that directly follows the page head sits flush under it, with no hairline. A shelf header holds the heading and optional subtitle on the left, See All and the paired pagers on the right. Tracks are horizontal, snap to start, and use the 24px gap; they show a thin native scrollbar until the pager script runs, then hide it. Lockup shelves show three columns of three rows; spotlight shelves show two-and-a-bit cards; screenshot shelves show four cards (or a 78% / 780px max figure on product pages). Search, category, alternatives and topic results use a three-column lockup grid. The category grid auto-fills 230px-minimum tiles (280px on the Categories page). The Information list is three columns.

Expand Down
14 changes: 14 additions & 0 deletions docs/VERIFICATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Release verification

## SEO audit fixes — 2026-11-07

A site-wide audit (technical, on-page and content, structured data and AI search, performance) read all 2,066 built pages in `dist/` and made about 60 spaced requests to the live site. It found nothing that blocks indexing: every indexable page has a self-referencing canonical that matches the sitemap, redirects are single 301s, and all 2,064 indexable pages carry valid JSON-LD. The code-level findings are fixed here:

- **Search page weight.** `/search/` dropped from 1,891,129 to 1,436,036 bytes; Googlebot reads only the first 2 MB of a page. Cards no longer carry `data-search` and `data-name`; the script reads the card text once on load and only toggles `hidden` when filters change. `bun run check:links` now fails any page over 1,900,000 bytes. At today's card size that leaves room for about 600 more apps before the search page has to be split.
- **Structured data.** Page, app, breadcrumb and list nodes are linked by `@id` (`mainEntity`, `breadcrumb`, `about`, `mainEntityOfPage`). App pages state `datePublished` (added) and `lastReviewed` (verified); `sameAs` no longer repeats a URL. Google's app rich result needs ratings or reviews, which the catalog does not have, so it stays out of reach on purpose.
- **Internal links.** "You Might Also Like" broke ties alphabetically, so apps starting with "a" got about 33 links each against 9 for "v"; a per-page hash now spreads them to about 11–13 for every letter. 1,345 tag chips that a topic page fully covers link to that topic instead of `/search/?tag=`.
- **Copy that had gone stale.** `llms.txt` said "1,000+" apps and How We Verify "More than 1,000"; both were written when the catalog first passed 1,000 and kept that figure after it reached 1,874. They keep the round style but compute it from the catalog (`hundredsBelow` in `src/lib/catalog.ts`): today "1,800+" and "More than 1,800". About no longer says every listing shows what the app looks like (35 of 1,874 have screenshots). Category titles and descriptions read "Open-source utilities for Mac" instead of "utilities apps". The CCleaner page title no longer says "for Mac" twice.
- **Alternatives evidence.** Each alternatives page shows the sources and verification date already stored for the product, and states `lastReviewed`.
- **Loading.** The first screenshot on app pages and the first "See It in Action" card on category pages load eagerly with high priority (they were lazy-loaded LCP images on 41 pages). The footer moved out of `<main>` without a visual change.
- **Legacy links.** `/?category=<name>` now opens the category page instead of a filtered search.

Checked: `bun run validate`, `bun test` (22 pass), `bun run check` (0 errors), `bun run build` (2,066 pages), `bun run check:links` (179,155 links), `bun run check:generated`. In a browser, the search filters, the sort, the empty state and the legacy redirect behave as before; app pages and the 404 page look the same on desktop and mobile.

## Old apps that depend on a web service — 2026-11-07

Eight old apps that call an outside service were traced to the hosts in their source, and each host was probed without credentials; issue trackers were read for recent breakage reports. Two licenses held out earlier were read again.
Expand Down
7 changes: 7 additions & 0 deletions scripts/check-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const base = basePath;
let pages = 0;
let links = 0;
const errors: string[] = [];
// Googlebot indexes only the first 2 MB of a page's HTML; fail with about 5% to spare.
const maxPageBytes = 1_900_000;

function inspect(directory: string) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
Expand All @@ -15,6 +17,11 @@ function inspect(directory: string) {
else if (/\.(?:html|xml)$|\/llms[\w-]*\.txt$/.test(file)) {
if (file.endsWith('.html')) pages++;
const html = fs.readFileSync(file, 'utf8');
const bytes = Buffer.byteLength(html);
if (file.endsWith('.html') && bytes > maxPageBytes)
errors.push(
`${file}: ${bytes.toLocaleString('en-US')} bytes exceeds the ${maxPageBytes.toLocaleString('en-US')}-byte page limit`,
);
// The catalog has no reviews or ratings, so structured data must not claim any.
if (/"aggregateRating"|"@type":\s*"(?:Review|Rating)"/.test(html))
errors.push(`${file}: rating or review markup`);
Expand Down
15 changes: 9 additions & 6 deletions src/components/AppLockup.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { CatalogApp } from '../lib/catalog';
import { licenseLabel } from '../lib/collections';
interface Props {
app: CatalogApp;
/** Adds the data attributes the search page filters on. */
/** Adds the data attributes the search page filters on that the card does not show as text. */
searchable?: boolean;
/** Names of the proprietary products the app replaces; searchable text only. */
replaces?: string[];
Expand All @@ -14,17 +14,20 @@ const base = import.meta.env.BASE_URL;
const unmaintained = app.maintenance === 'unmaintained';
const restricted =
!unmaintained && app.sourceAvailability === 'source-available';
// Search reads the name, summary and license from the card's text; only the
// exceptions are marked for featured apps and restricted licenses.
const filters = searchable
? {
'data-app': '',
'data-name': app.name,
'data-search':
`${app.name} ${app.summary} ${app.category} ${app.tags.join(' ')} ${replaces.join(' ')}`.toLowerCase(),
'data-category': app.category,
'data-tags': app.tags.join(','),
'data-added': app.added,
'data-featured': String(app.featured),
'data-source': app.sourceAvailability,
'data-featured': app.featured ? '' : undefined,
'data-source':
app.sourceAvailability === 'open-source'
? undefined
: app.sourceAvailability,
'data-replaces': replaces.length ? replaces.join(' ') : undefined,
}
: {};
---
Expand Down
6 changes: 4 additions & 2 deletions src/components/ShotCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { appImage } from '../lib/images';
import type { CatalogApp } from '../lib/catalog';
interface Props {
app: CatalogApp;
eager?: boolean;
}
const { app } = Astro.props;
const { app, eager = false } = Astro.props;
const base = import.meta.env.BASE_URL;
const shot = app.screenshots[0];
const image = await appImage(app.slug, shot.src, shot.url, [400, 800]);
Expand All @@ -18,7 +19,8 @@ const image = await appImage(app.slug, shot.src, shot.url, [400, 800]);
srcset={image.srcset}
sizes="(max-width: 900px) 70vw, 320px"
alt={shot.alt}
loading="lazy"
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager ? 'high' : undefined}
decoding="async"
width="640"
height="400"
Expand Down
76 changes: 55 additions & 21 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,16 @@ interface Props {
image?: string;
imageAlt?: string;
noindex?: boolean;
/**
* Extra JSON-LD nodes. The page node links to them by type: a
* SoftwareApplication (else an ItemList) becomes its `mainEntity`, a
* BreadcrumbList its `breadcrumb`; ItemList and BreadcrumbList get ids here.
*/
structuredData?: Record<string, unknown>[];
/** ISO date the page's subject was first listed. */
datePublished?: string;
/** ISO date the page's facts were last checked by hand. */
lastReviewed?: string;
}
const {
title = site.title,
Expand All @@ -41,6 +50,8 @@ const {
imageAlt = site.title,
noindex = false,
structuredData = [],
datePublished,
lastReviewed,
} = Astro.props;
// Search results cut titles near 70 characters; long page names drop the brand suffix first.
const pageTitle =
Expand All @@ -49,6 +60,24 @@ const base = import.meta.env.BASE_URL;
const canonical = new URL(Astro.url.pathname, Astro.site);
const shareImage = absoluteUrl(image || 'social-card.png');
const contributeUrl = `${site.repository}/blob/main/CONTRIBUTING.md`;
const pageId = `${canonical.href}#webpage`;
const nodes = structuredData.map((node) => {
switch (node['@type']) {
case 'BreadcrumbList':
return { '@id': `${canonical.href}#breadcrumb`, ...node };
case 'ItemList':
return { '@id': `${canonical.href}#list`, ...node };
case 'SoftwareApplication':
return { ...node, mainEntityOfPage: { '@id': pageId } };
default:
return node;
}
});
const idOf = (type: string) =>
nodes.find((node) => node['@type'] === type)?.['@id'] as string | undefined;
const app = idOf('SoftwareApplication');
const mainEntity = app ?? idOf('ItemList');
const breadcrumb = idOf('BreadcrumbList');
const graph = [
{
'@type': 'WebSite',
Expand All @@ -66,14 +95,19 @@ const graph = [
},
{
'@type': type,
'@id': `${canonical.href}#webpage`,
'@id': pageId,
url: canonical.href,
name: pageTitle,
description,
isPartOf: { '@id': `${site.url}#website` },
inLanguage: 'en',
...(mainEntity ? { mainEntity: { '@id': mainEntity } } : {}),
...(app ? { about: { '@id': app } } : {}),
...(breadcrumb ? { breadcrumb: { '@id': breadcrumb } } : {}),
...(datePublished ? { datePublished } : {}),
...(lastReviewed ? { lastReviewed } : {}),
},
...structuredData,
...nodes,
];
const path = Astro.url.pathname;
const link = (href: string) => ({
Expand Down Expand Up @@ -289,26 +323,26 @@ const collectionGlyphs = {
</header>
<main id="main" tabindex="-1">
<slot />
<footer class="footer">
<nav aria-label="Footer">
<a href={`${base}about/`}>About {site.name}</a>
<a href={`${base}how-we-verify/`}>How We Verify</a>
<a href={contributeUrl}>Contribute</a>
<a href={`${site.repository}/issues/new?template=app.yml`}>
Suggest an App
</a>
<a href={site.repository}>Source Code</a>
<a href={`${base}catalog.json`}>Catalog JSON</a>
</nav>
<p>
{site.name} is an independent directory. Made by{' '}
<a href="https://koative.com/">koative</a>. Downloads come from each
project’s official source. App names, icons, and screenshots belong
to their owners. Mac and macOS are trademarks of Apple Inc.;{' '}
{site.name} is not affiliated with Apple.
</p>
</footer>
</main>
<footer class="footer">
<nav aria-label="Footer">
<a href={`${base}about/`}>About {site.name}</a>
<a href={`${base}how-we-verify/`}>How We Verify</a>
<a href={contributeUrl}>Contribute</a>
<a href={`${site.repository}/issues/new?template=app.yml`}>
Suggest an App
</a>
<a href={site.repository}>Source Code</a>
<a href={`${base}catalog.json`}>Catalog JSON</a>
</nav>
<p>
{site.name} is an independent directory. Made by{' '}
<a href="https://koative.com/">koative</a>. Downloads come from each
project’s official source. App names, icons, and screenshots belong to
their owners. Mac and macOS are trademarks of Apple Inc.; {site.name}{' '}
is not affiliated with Apple.
</p>
</footer>
<nav class="tabbar" aria-label="Tabs">
<a {...link('')}>
<Star size={22} />
Expand Down
4 changes: 4 additions & 0 deletions src/lib/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,3 +372,7 @@ export const newestFirst = (apps: CatalogApp[]) =>
a.name.localeCompare(b.name) ||
a.slug.localeCompare(b.slug),
);

/** The largest hundred strictly below `n`, for prose like "more than 1,800" or "1,800+" that stays true as the catalog grows. */
export const hundredsBelow = (n: number) =>
n > 100 ? Math.floor((n - 1) / 100) * 100 : n;
17 changes: 17 additions & 0 deletions src/lib/guides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@ export function getTopics(): Topic[] {
return (topicsCache ??= loadTopics(getApps()));
}

/**
* The topic page that lists every `category` app carrying `tag`: one with that
* tag and either no category or this one. Unrestricted topics win, then title order.
*/
export function topicForTag(
topics: Topic[],
tag: string,
category: Topic['category'],
): Topic | undefined {
const covering = topics.filter(
(topic) =>
topic.tags.includes(tag) &&
(!topic.category || topic.category === category),
);
return covering.find((topic) => !topic.category) ?? covering[0];
}

/** schema.org ItemList of app pages, in display order. */
export const appItemList = (apps: CatalogApp[]) => ({
'@type': 'ItemList',
Expand Down
13 changes: 10 additions & 3 deletions src/lib/llms.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { hundredsBelow, type CatalogApp } from './catalog';
import { absoluteUrl, site } from './site';

/** Shared llmstxt.org header for /llms.txt and /llms-full.txt. */
export const llmsHeader = `# ${site.name}
/** Shared llmstxt.org header for /llms.txt and /llms-full.txt; counts come from the catalog. */
export function llmsHeader(apps: readonly CatalogApp[]): string {
const restricted = apps.filter(
(app) => app.sourceAvailability === 'source-available',
).length;
const count = (n: number) => n.toLocaleString('en-US');
return `# ${site.name}

> ${site.name} is an independent, curated directory of 1,000+ open-source Mac apps. Every listing links the app's source code, its license file and its official download, checked by hand. Source-available apps, whose published code carries extra license restrictions, are labelled separately and never called open source. Made by koative; not affiliated with Apple.
> ${site.name} is an independent, curated directory of ${count(hundredsBelow(apps.length - restricted))}+ open-source Mac apps, plus ${count(restricted)} labelled source-available ones. Every listing links the app's source code, its license file and its official download, checked by hand. Source-available apps, whose published code carries extra license restrictions, are labelled separately and never called open source. Made by koative; not affiliated with Apple.

Before an app is listed, a person reads its license file, confirms the project ships a macOS app and links only its official download. Each listing records the date of its last check; that date is not a security audit or an endorsement. The full method is on [How We Verify](${absoluteUrl('how-we-verify/')}).
`;
}
44 changes: 44 additions & 0 deletions src/lib/related.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { CatalogApp } from './catalog';

type Candidate = Pick<CatalogApp, 'slug' | 'category' | 'tags'>;

/** 32-bit FNV-1a: the same number on every machine and build, unlike Math.random. */
function hash(text: string): number {
let value = 0x811c9dc5;
for (let index = 0; index < text.length; index++) {
value ^= text.charCodeAt(index);
value = Math.imul(value, 0x01000193);
}
return value >>> 0;
}

/**
* The "You Might Also Like" shelf for `app`: other apps scored 3 per shared tag
* plus 1 for the same category, best first. Equal scores are ordered by a hash
* of both slugs, so each page takes its own stable sample of a tie instead of
* every page linking the start of the alphabet.
*/
export function relatedApps<T extends Candidate>(
app: Candidate,
apps: readonly T[],
limit = 12,
): T[] {
return apps
.filter((other) => other.slug !== app.slug)
.map((other) => ({
app: other,
score:
(other.category === app.category ? 1 : 0) +
other.tags.filter((tag) => app.tags.includes(tag)).length * 3,
tie: hash(`${app.slug}\n${other.slug}`),
}))
.filter((other) => other.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
a.tie - b.tie ||
a.app.slug.localeCompare(b.app.slug),
)
.slice(0, limit)
.map((other) => other.app);
}
5 changes: 3 additions & 2 deletions src/pages/about.astro
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ const sources = [
We’re building a useful starting point for open-source software on
macOS. Small menu bar helpers sit alongside creative tools, text
editors, and everyday essentials. Each listing brings together what
matters: what it does, what it looks like, its source code, and its
license.
matters: what it does, its source code, its license, and its official
download. Every listing shows the app’s icon, and screenshots appear
where the project publishes ones we can reuse.
</p>
<p>
{site.name} is a community project by{' '}
Expand Down
23 changes: 22 additions & 1 deletion src/pages/alternatives/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,18 @@ const lead = free ? 'Free, open-source' : 'Open-source';
const kind = restricted
? 'Mac apps with published source'
: `${lead.toLowerCase()} Mac apps`;
// "CCleaner for Mac" would read "…CCleaner for Mac alternatives for Mac".
const product = entry.name.replace(/ for Mac$/, '');
const verified = new Date(`${entry.verified}T00:00:00Z`).toLocaleDateString(
'en-US',
{ day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' },
);
---

<Layout
title={
entry.onMac
? `${lead} ${entry.name} alternatives for Mac — ${site.name}`
? `${lead} ${product} alternatives for Mac — ${site.name}`
: `${entry.name} for Mac: ${lead.toLowerCase()} alternatives — ${site.name}`
}
description={describeApps(
Expand All @@ -52,6 +58,7 @@ const kind = restricted
apps,
)}
section="alternatives/"
lastReviewed={entry.verified}
structuredData={[
appItemList(apps),
breadcrumbList([
Expand Down Expand Up @@ -83,6 +90,20 @@ const kind = restricted
<a class="text-link" href={entry.website} rel="nofollow">
{entry.name} website <ArrowUpRight size={14} />
</a>
<details class="evidence">
<summary>
Sources, verified <time datetime={entry.verified}>{verified}</time>
</summary>
<ul>
{entry.sources.map((source) => (
<li>
<a href={source} rel="nofollow">
{source.replace('https://', '')}
</a>
</li>
))}
</ul>
</details>
</div>
<section class="section" aria-labelledby="apps-title">
<header class="shelf-head">
Expand Down
Loading
Loading