also looking at this
LSU's own coaching surface, and what auditing the site turned up
#12
86 files
+3943
-1190
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -45,7 +45,12 @@ export function absolute(path: string): string { | ||
| 45 | 45 | export const indexablePaths: string[] = [ |
| 46 | 46 | '/', '/format', '/world-series', '/soul-races', '/sponsorship', '/contact', |
| 47 | 47 | '/lottery', '/results', '/results/last-soul-ultra-2026', '/results/last-soul-ultra-2025', |
| 48 | '/imprint', '/privacy', '/terms', '/coaching', '/faq', | |
| 48 | '/imprint', '/privacy', '/terms', '/coaching', | |
| 49 | // /faq is served, and stays served: it is a URL the previous site published | |
| 50 | // and other people's links still point at. It is not listed, because it | |
| 51 | // renders the contact page and canonicalises to /contact - advertising a | |
| 52 | // URL that tells a crawler it is not the canonical one asks for the page to | |
| 53 | // be ignored and spends the site's crawl budget doing it. | |
| 49 | 54 | // The storefront only. The other seven training modules show a sign-in |
| 50 | 55 | // prompt to anyone without an account, which is not a search result. |
| 51 | 56 | ...indexableModulePaths(), |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'bun:test' |
| 2 | import { readdirSync } from 'node:fs' | |
| 2 | import { readFileSync, readdirSync } from 'node:fs' | |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { coachingPage } from '../resources/data/coaching' |
| 5 | 5 | import { legalPages } from '../resources/data/legal' |
| @@ -37,6 +37,22 @@ describe('seo artefacts', () => { | ||
| 37 | 37 | }) |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | /** | |
| 41 | * The canonical a view declares, read from its source. Views write it as a | |
| 42 | * literal, and the coaching modules build it from their slug. | |
| 43 | */ | |
| 44 | function canonicalOf(path: string): string { | |
| 45 | const file = `resources/views${path === '/' ? '/index' : path}.stx` | |
| 46 | const source = readFileSync(file, 'utf8') | |
| 47 | const literal = /const canonicalPath = ['"]([^'"]+)['"]/.exec(source) | |
| 48 | if (literal) | |
| 49 | return literal[1]! | |
| 50 | const built = /const canonicalPath = modulePath\(['"]([^'"]+)['"]\)/.exec(source) | |
| 51 | if (built) | |
| 52 | return `/coaching/${built[1]}` | |
| 53 | throw new Error(`${file} declares no canonicalPath`) | |
| 54 | } | |
| 55 | ||
| 40 | 56 | /** A page that declares itself noindex is not expected in the sitemap. */ |
| 41 | 57 | function noindex(path: string): boolean { |
| 42 | 58 | if (path === '/404') |
| @@ -49,13 +65,27 @@ describe('seo artefacts', () => { | ||
| 49 | 65 | it('lists every view that is not dynamic, private or noindex', () => { |
| 50 | 66 | // Views on disk are the ground truth for what the site actually serves. |
| 51 | 67 | // Recursive: /results and /coaching both hold pages a flat read missed. |
| 52 | const expected = viewPaths().filter(path => !noindex(path)) | |
| 68 | const expected = viewPaths().filter(path => !noindex(path) && canonicalOf(path) === path) | |
| 53 | 69 | |
| 54 | expect(expected.length).toBeGreaterThan(14) | |
| 70 | expect(expected.length).toBeGreaterThan(13) | |
| 55 | 71 | for (const path of expected) |
| 56 | 72 | expect(indexablePaths).toContain(path) |
| 57 | 73 | }) |
| 58 | 74 | |
| 75 | it('does not list a page that points its canonical somewhere else', () => { | |
| 76 | // /faq renders the contact page and canonicalises to /contact. Listing a | |
| 77 | // URL that tells a crawler it is not the canonical one asks for the page | |
| 78 | // to be ignored, and spends crawl budget doing it. | |
| 79 | const aliases = viewPaths().filter(path => !noindex(path) && canonicalOf(path) !== path) | |
| 80 | ||
| 81 | expect(aliases).toContain('/faq') | |
| 82 | for (const path of aliases) { | |
| 83 | expect(indexablePaths).not.toContain(path) | |
| 84 | // An alias must still point at a page the sitemap does list. | |
| 85 | expect(indexablePaths).toContain(canonicalOf(path)) | |
| 86 | } | |
| 87 | }) | |
| 88 | ||
| 59 | 89 | it('keeps the modules that need an account out of the sitemap', () => { |
| 60 | 90 | const modules = viewPaths().filter(path => path.startsWith('/coaching/')) |
| 61 | 91 | |