ReviewOS

also looking at this

stacks/lastsoulultra

LSU's own coaching surface, and what auditing the site turned up

#12
Merged glennmichael123 wants to merge claude/last-soul-ultra-web-6e0718 into main
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.

resources/data/structured.tsadded+124-0
Changes to resources/data/structured.ts
@@ -0,0 +1,124 @@
1import { coachingModule } from './coaching'
2import { race } from './race'
3import { 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
25const ORGANIZATION_ID = `${siteOrigin}/#organization`
26const 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 */
36export 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
57export 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. */
69const SECTIONS: Record<string, string> = {
70 results: 'Results',
71 coaching: 'Coaching',
72}
73
74/** The label for the last crumb, which is the page itself. */
75function 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 */
88export 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. */
109export 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 */
122export function structuredDataJson(path: string, title: string): string {
123 return JSON.stringify(structuredData(path, title)).replace(/</g, '\\u003c')
124}
resources/partials/head.stxmodified+7-0
Changes to resources/partials/head.stx
@@ -1,9 +1,13 @@
11<script server>
22import { CARD_HEIGHT, CARD_WIDTH, shareCard } from '../data/share'
33import { absolute } from '../data/site'
4import { structuredDataJson } from '../data/structured'
45const 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'] }
56const pagePath = typeof canonicalPath !== 'undefined' ? canonicalPath : '/'
67const 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.
10const graph = metaPage.noindex ? '' : structuredDataJson(pagePath, metaPage.title)
711</script>
812<meta charset="utf-8">
913<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -35,3 +39,6 @@ const card = shareCard(pagePath)
3539<link rel="stylesheet" href="">
3640@endforeach
3741<link rel="stylesheet" href="/assets/styles/stx-site.css">
42@if(graph)
43<script type="application/ld+json">{!! graph !!}</script>
44@endif
tests/structured.test.tsadded+97-0
Changes to tests/structured.test.ts
@@ -0,0 +1,97 @@
1import { describe, expect, it } from 'bun:test'
2import { 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
11const imprint = await Bun.file('resources/views/imprint.stx').text()
12const footer = await Bun.file('resources/components/SiteFooter.stx').text()
13
14describe('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
46describe('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
72describe('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
83describe('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})