From 4c12e94321233e3c572e60d79bb6c4bad1a72a5b Mon Sep 17 00:00:00 2001 From: Abdulkadir Polat Date: Sat, 7 Nov 2026 23:29:40 +0300 Subject: [PATCH 1/2] SEO audit fixes: lighter search page, linked structured data, fairer related apps, topic tag links, computed counts --- DESIGN.md | 2 +- docs/VERIFICATION.md | 14 +++++ scripts/check-links.ts | 7 +++ src/components/AppLockup.astro | 15 +++-- src/components/ShotCard.astro | 6 +- src/layouts/Layout.astro | 76 ++++++++++++++++------- src/lib/guides.ts | 17 +++++ src/lib/llms.ts | 13 +++- src/lib/related.ts | 44 +++++++++++++ src/pages/about.astro | 5 +- src/pages/alternatives/[slug].astro | 23 ++++++- src/pages/apps/[slug].astro | 83 +++++++++++-------------- src/pages/categories/[category].astro | 30 ++++++--- src/pages/how-we-verify.astro | 4 +- src/pages/index.astro | 11 +++- src/pages/llms-full.txt.ts | 4 +- src/pages/llms.txt.ts | 2 +- src/pages/search.astro | 89 +++++++++++++++++++-------- src/styles/global.css | 28 +++++++-- tests/guides.test.ts | 11 +++- tests/related.test.ts | 41 ++++++++++++ 21 files changed, 397 insertions(+), 128 deletions(-) create mode 100644 src/lib/related.ts create mode 100644 tests/related.test.ts diff --git a/DESIGN.md b/DESIGN.md index 3286d1e0..e87cd983 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index a2b0bf26..32b2108d 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -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 now print the catalog's own count. 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 `
` without a visual change. +- **Legacy links.** `/?category=` 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. diff --git a/scripts/check-links.ts b/scripts/check-links.ts index 66e44702..237f983c 100644 --- a/scripts/check-links.ts +++ b/scripts/check-links.ts @@ -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 })) { @@ -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`, + ); for (const match of html.matchAll( /\b(href|src|srcset)=(?:"([^"]*)"|'([^']*)')|([^<]*)<\/loc>|\]\(([^)\s]+)\)/g, )) { diff --git a/src/components/AppLockup.astro b/src/components/AppLockup.astro index 3c760ca8..c242e138 100644 --- a/src/components/AppLockup.astro +++ b/src/components/AppLockup.astro @@ -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[]; @@ -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, } : {}; --- diff --git a/src/components/ShotCard.astro b/src/components/ShotCard.astro index ac30f69f..147505b7 100644 --- a/src/components/ShotCard.astro +++ b/src/components/ShotCard.astro @@ -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]); @@ -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" diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 563b042c..059854e4 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -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[]; + /** 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, @@ -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 = @@ -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', @@ -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) => ({ @@ -289,26 +323,26 @@ const collectionGlyphs = {
-
+