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.
| @@ -0,0 +1,124 @@ | ||
| 1 | import { coachingModule } from './coaching' | |
| 2 | import { race } from './race' | |
| 3 | import { absolute, siteOrigin } from './site' | |
| 4 | ||
| 5 | /** | |
| 6 | * JSON-LD, for the two things a search engine cannot work out from the page. | |
| 7 | * | |
| 8 | * The site had none at all, so nothing tied its pages to one publisher and | |
| 9 | * nothing described where a nested page sits. | |
| 10 | * | |
| 11 | * Deliberately narrow. Every value here is one the site already prints in | |
| 12 | * visible text - the organisation's details come from the imprint, the | |
| 13 | * profiles from the footer - because markup that claims more than the page | |
| 14 | * shows is what gets a site's rich results turned off. In particular: | |
| 15 | * | |
| 16 | * No Event. race.ts describes the August 2026 edition, which has been run; | |
| 17 | * describing a finished race as an event invites a wrong date in a result. | |
| 18 | * No FAQPage. The questions on /contact are written into that view, and | |
| 19 | * resources/data/faq.ts is a second copy nothing renders, so marking up | |
| 20 | * from it would publish answers that need not match the page. | |
| 21 | * No Product or Offer for coaching. Those prices are the coach's, served | |
| 22 | * from HQ, and are not approved LSU pricing. | |
| 23 | */ | |
| 24 | ||
| 25 | const ORGANIZATION_ID = `${siteOrigin}/#organization` | |
| 26 | const WEBSITE_ID = `${siteOrigin}/#website` | |
| 27 | ||
| 28 | /** | |
| 29 | * The publisher, as the imprint states it. | |
| 30 | * | |
| 31 | * `sameAs` carries the two profiles that belong to the organisation. The | |
| 32 | * footer also links Kim Gottwald's YouTube and Twitch; those are a person's | |
| 33 | * channels, and listing them as the company's own accounts would be a claim | |
| 34 | * the imprint does not make. | |
| 35 | */ | |
| 36 | export function organization(): Record<string, unknown> { | |
| 37 | return { | |
| 38 | '@type': 'Organization', | |
| 39 | '@id': ORGANIZATION_ID, | |
| 40 | 'name': race.organiser, | |
| 41 | 'alternateName': race.name, | |
| 42 | 'url': absolute('/'), | |
| 43 | 'logo': absolute('/assets/lsu/images/lsu-logo.svg'), | |
| 44 | 'email': race.email, | |
| 45 | 'telephone': '+49 176 3122 9027', | |
| 46 | 'address': { | |
| 47 | '@type': 'PostalAddress', | |
| 48 | 'streetAddress': 'Ehrenfeldstr. 14', | |
| 49 | 'postalCode': '44789', | |
| 50 | 'addressLocality': 'Bochum', | |
| 51 | 'addressCountry': 'DE', | |
| 52 | }, | |
| 53 | 'sameAs': [race.instagram, race.livestreams], | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | export function website(): Record<string, unknown> { | |
| 58 | return { | |
| 59 | '@type': 'WebSite', | |
| 60 | '@id': WEBSITE_ID, | |
| 61 | 'url': absolute('/'), | |
| 62 | 'name': race.name, | |
| 63 | 'inLanguage': 'en-GB', | |
| 64 | 'publisher': { '@id': ORGANIZATION_ID }, | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | /** The label a crumb uses for a section, in the site's own words. */ | |
| 69 | const SECTIONS: Record<string, string> = { | |
| 70 | results: 'Results', | |
| 71 | coaching: 'Coaching', | |
| 72 | } | |
| 73 | ||
| 74 | /** The label for the last crumb, which is the page itself. */ | |
| 75 | function leafName(path: string, title: string): string { | |
| 76 | const [, section, slug] = path.split('/') | |
| 77 | if (section === 'coaching' && slug) | |
| 78 | return coachingModule(slug)?.title ?? title | |
| 79 | // A results page is titled for its edition; the part before the first | |
| 80 | // separator is the name without the site's suffix. | |
| 81 | return title.split(' - ')[0]?.split(' | ')[0]?.trim() || title | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * Where a nested page sits. Only for pages that have a parent: a breadcrumb | |
| 86 | * of one item describes nothing, and the top-level pages are all siblings. | |
| 87 | */ | |
| 88 | export function breadcrumbs(path: string, title: string): Record<string, unknown> | null { | |
| 89 | const segments = path.split('/').filter(Boolean) | |
| 90 | if (segments.length < 2) | |
| 91 | return null | |
| 92 | ||
| 93 | const section = segments[0]! | |
| 94 | const sectionName = SECTIONS[section] | |
| 95 | if (!sectionName) | |
| 96 | return null | |
| 97 | ||
| 98 | return { | |
| 99 | '@type': 'BreadcrumbList', | |
| 100 | 'itemListElement': [ | |
| 101 | { '@type': 'ListItem', 'position': 1, 'name': race.name, 'item': absolute('/') }, | |
| 102 | { '@type': 'ListItem', 'position': 2, 'name': sectionName, 'item': absolute(`/${section}`) }, | |
| 103 | { '@type': 'ListItem', 'position': 3, 'name': leafName(path, title) }, | |
| 104 | ], | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | /** Everything one page publishes, as a single graph. */ | |
| 109 | export function structuredData(path: string, title: string): Record<string, unknown> { | |
| 110 | const graph: Record<string, unknown>[] = [organization(), website()] | |
| 111 | const crumbs = breadcrumbs(path, title) | |
| 112 | if (crumbs) | |
| 113 | graph.push(crumbs) | |
| 114 | ||
| 115 | return { '@context': 'https://schema.org', '@graph': graph } | |
| 116 | } | |
| 117 | ||
| 118 | /** | |
| 119 | * Serialised for a script element. `<` is escaped so a value can never close | |
| 120 | * the element it sits in, which is the one way JSON-LD becomes an injection. | |
| 121 | */ | |
| 122 | export function structuredDataJson(path: string, title: string): string { | |
| 123 | return JSON.stringify(structuredData(path, title)).replace(/</g, '\\u003c') | |
| 124 | } | |
| @@ -1,9 +1,13 @@ | ||
| 1 | 1 | <script server> |
| 2 | 2 | import { CARD_HEIGHT, CARD_WIDTH, shareCard } from '../data/share' |
| 3 | 3 | import { absolute } from '../data/site' |
| 4 | import { structuredDataJson } from '../data/structured' | |
| 4 | 5 | const metaPage = typeof currentPage !== 'undefined' && currentPage ? currentPage : { title: 'Last Soul Ultra', description: 'The backyard ultra World Series. There is no finish line.', styles: ['/assets/styles/production/base.css'] } |
| 5 | 6 | const pagePath = typeof canonicalPath !== 'undefined' ? canonicalPath : '/' |
| 6 | 7 | const card = shareCard(pagePath) |
| 8 | // Not on a noindex page: nothing indexes it, so the graph is bytes a reader | |
| 9 | // pays for and no crawler reads. | |
| 10 | const graph = metaPage.noindex ? '' : structuredDataJson(pagePath, metaPage.title) | |
| 7 | 11 | </script> |
| 8 | 12 | <meta charset="utf-8"> |
| 9 | 13 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| @@ -35,3 +39,6 @@ const card = shareCard(pagePath) | ||
| 35 | 39 | <link rel="stylesheet" href=""> |
| 36 | 40 | @endforeach |
| 37 | 41 | <link rel="stylesheet" href="/assets/styles/stx-site.css"> |
| 42 | @if(graph) | |
| 43 | <script type="application/ld+json">{!! graph !!}</script> | |
| 44 | @endif | |
| @@ -0,0 +1,97 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { breadcrumbs, organization, structuredData, structuredDataJson } from '../resources/data/structured' | |
| 3 | ||
| 4 | /** | |
| 5 | * Structured data makes claims on the site's behalf in places nobody on the | |
| 6 | * team will ever look. The rule this suite enforces is that it may only claim | |
| 7 | * what the site already prints in visible text, because markup that outruns | |
| 8 | * the page is what gets a site's rich results withdrawn. | |
| 9 | */ | |
| 10 | ||
| 11 | const imprint = await Bun.file('resources/views/imprint.stx').text() | |
| 12 | const footer = await Bun.file('resources/components/SiteFooter.stx').text() | |
| 13 | ||
| 14 | describe('the organisation', () => { | |
| 15 | it('states nothing the imprint does not', () => { | |
| 16 | const org = organization() as any | |
| 17 | ||
| 18 | expect(imprint).toContain(org.name) | |
| 19 | expect(imprint).toContain(org.email) | |
| 20 | expect(imprint).toContain(org.telephone) | |
| 21 | expect(imprint).toContain(org.address.streetAddress) | |
| 22 | expect(imprint).toContain(org.address.postalCode) | |
| 23 | expect(imprint).toContain(org.address.addressLocality) | |
| 24 | }) | |
| 25 | ||
| 26 | it('claims only the profiles that belong to the company', () => { | |
| 27 | const org = organization() as any | |
| 28 | ||
| 29 | for (const profile of org.sameAs) | |
| 30 | expect(footer).toContain(new URL(profile).host) | |
| 31 | // The footer also links a person's YouTube and Twitch. Listing those as | |
| 32 | // the company's own accounts is a claim the imprint does not make. | |
| 33 | expect(org.sameAs.join(' ')).not.toContain('youtube') | |
| 34 | expect(org.sameAs.join(' ')).not.toContain('twitch') | |
| 35 | }) | |
| 36 | ||
| 37 | it('is one node the rest of the graph points at, not a copy per page', () => { | |
| 38 | const graph = structuredData('/results/last-soul-ultra-2026', 'x') as any | |
| 39 | const ids = graph['@graph'].map((node: any) => node['@id']).filter(Boolean) | |
| 40 | ||
| 41 | expect(new Set(ids).size).toBe(ids.length) | |
| 42 | expect(graph['@graph'][1].publisher['@id']).toBe(graph['@graph'][0]['@id']) | |
| 43 | }) | |
| 44 | }) | |
| 45 | ||
| 46 | describe('breadcrumbs', () => { | |
| 47 | it('describes where a nested page sits', () => { | |
| 48 | const crumbs = breadcrumbs('/results/last-soul-ultra-2025', 'Last Soul Ultra 2025 results | Last Soul Ultra') as any | |
| 49 | ||
| 50 | expect(crumbs.itemListElement.map((item: any) => item.name)) | |
| 51 | .toEqual(['Last Soul Ultra', 'Results', 'Last Soul Ultra 2025 results']) | |
| 52 | expect(crumbs.itemListElement.map((item: any) => item.position)).toEqual([1, 2, 3]) | |
| 53 | // The page itself carries no item: it is the URL being described. | |
| 54 | expect(crumbs.itemListElement[2].item).toBeUndefined() | |
| 55 | }) | |
| 56 | ||
| 57 | it('names a coaching module the way the module pages do', () => { | |
| 58 | const crumbs = breadcrumbs('/coaching/account', 'Billing and renewal - Last Soul Ultra coaching') as any | |
| 59 | expect(crumbs.itemListElement[2].name).toBe('Billing and renewal') | |
| 60 | }) | |
| 61 | ||
| 62 | it('says nothing about a page with no parent', () => { | |
| 63 | for (const path of ['/', '/format', '/coaching', '/results']) | |
| 64 | expect(breadcrumbs(path, 'x')).toBeNull() | |
| 65 | }) | |
| 66 | ||
| 67 | it('says nothing about a section it has no name for', () => { | |
| 68 | expect(breadcrumbs('/some/unknown', 'x')).toBeNull() | |
| 69 | }) | |
| 70 | }) | |
| 71 | ||
| 72 | describe('what is deliberately not published', () => { | |
| 73 | it('describes no event, no FAQ and no offer', () => { | |
| 74 | // The 2026 edition has been run, the FAQ answers live in a view rather | |
| 75 | // than in data, and coaching prices are the coach's and served from HQ. | |
| 76 | const json = structuredDataJson('/coaching/plans', 'What is open - Last Soul Ultra coaching') | |
| 77 | ||
| 78 | for (const type of ['Event', 'SportsEvent', 'FAQPage', 'Offer', 'Product', 'Review']) | |
| 79 | expect(json).not.toContain(`"${type}"`) | |
| 80 | }) | |
| 81 | }) | |
| 82 | ||
| 83 | describe('serialising it', () => { | |
| 84 | it('parses, and declares the vocabulary', () => { | |
| 85 | const parsed = JSON.parse(structuredDataJson('/', 'Last Soul Ultra')) | |
| 86 | expect(parsed['@context']).toBe('https://schema.org') | |
| 87 | expect(Array.isArray(parsed['@graph'])).toBe(true) | |
| 88 | }) | |
| 89 | ||
| 90 | it('cannot close the script element it sits in', () => { | |
| 91 | const json = structuredDataJson('/results/last-soul-ultra-2026', '</script><img src=x onerror=alert(1)>') | |
| 92 | ||
| 93 | expect(json).not.toContain('<') | |
| 94 | expect(json).toContain('\\u003c') | |
| 95 | expect(JSON.parse(json)['@graph'][2].itemListElement[2].name).toContain('</script>') | |
| 96 | }) | |
| 97 | }) | |