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
10 changes: 8 additions & 2 deletions .github/workflows/a11y-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:

- name: Diagram pipeline unit tests
working-directory: site
run: node --test test/diagram.test.mjs test/replace-plantuml.test.mjs
run: node --test test/diagram.test.mjs test/replace-plantuml.test.mjs test/schema-versions.test.mjs test/build-sports.test.mjs

- name: Build site
working-directory: site
Expand Down Expand Up @@ -77,6 +77,12 @@ jobs:
http://localhost:4321/docs/rulesets/ \
http://localhost:4321/examples/ \
http://localhost:4321/ecosystem/ \
http://localhost:4321/playground/renderer/ \
http://localhost:4321/use/try-it/ \
http://localhost:4321/learn/sports/ \
http://localhost:4321/learn/sports/basketball/ \
http://localhost:4321/learn/sports/soccer/ \
http://localhost:4321/learn/sports/handball/ \
http://localhost:4321/learn/sports/hockey/ \
http://localhost:4321/learn/sports/futsal/ \
--chromedriver-path "$CHROMEDRIVER_PATH" \
--exit
1,754 changes: 1,754 additions & 0 deletions docs/superpowers/plans/2026-08-28-siteC-tryit-sports.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
"scripts": {
"build:adoc": "node scripts/build-adoc.mjs",
"build:renderer": "node scripts/build-renderer.mjs",
"build": "npm run build:adoc && npm run build:renderer && astro build",
"dev": "npm run build:adoc && npm run build:renderer && astro dev",
"build:sports": "node scripts/build-sports.mjs",
"build": "npm run build:adoc && npm run build:renderer && npm run build:sports && astro build",
"dev": "npm run build:adoc && npm run build:renderer && npm run build:sports && astro dev",
"test:renderer-build": "node scripts/build-renderer.test.mjs",
"test:renderer-playground": "node src/lib/renderer-playground.test.mjs",
"test:diagram": "node --test test/diagram.test.mjs"
"test:diagram": "node --test test/diagram.test.mjs",
"test:schema-versions": "node --test test/schema-versions.test.mjs",
"test:sports": "node --test test/build-sports.test.mjs"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.3",
Expand Down
59 changes: 59 additions & 0 deletions site/scripts/build-sports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';

function titleCase(slug) {
return slug.charAt(0).toUpperCase() + slug.slice(1);
}

// Pure transform: [{ filename, data }] -> normalized, sorted sport entries.
// Basketball is pinned first; the remaining sports are alphabetical.
export function buildSportsIndex(files) {
const entries = files.map(({ data }) => ({
sport: data.sport,
label: titleCase(data.sport),
version: data.version ?? '',
status: data.status ?? 'provisional',
statusLabel: data.status === 'defined' ? 'Full' : 'Reserved',
reserved: data.status !== 'defined',
action_types: Array.isArray(data.action_types) ? data.action_types : [],
variants:
data.variants && typeof data.variants === 'object' && !Array.isArray(data.variants)
? data.variants
: {},
outcomes: Array.isArray(data.outcomes) ? data.outcomes : [],
rulesets: Array.isArray(data.rulesets) ? data.rulesets : [],
}));
entries.sort((a, b) => {
if (a.sport === 'basketball') return -1;
if (b.sport === 'basketball') return 1;
return a.sport.localeCompare(b.sport);
});
return entries;
}

// Only run the filesystem side effects when executed directly, not on import
// (keeps the module unit-testable).
const isMain =
process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);

if (isMain) {
const siteRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const repoRoot = path.dirname(siteRoot);
const outDir = path.join(siteRoot, 'src', 'generated');
const sportsDir = path.join(repoRoot, 'sports');

mkdirSync(outDir, { recursive: true });

const files = readdirSync(sportsDir)
.filter((name) => name.endsWith('.json'))
.sort()
.map((filename) => ({
filename,
data: JSON.parse(readFileSync(path.join(sportsDir, filename), 'utf-8')),
}));

const index = buildSportsIndex(files);
writeFileSync(path.join(outDir, 'sports.json'), JSON.stringify(index, null, 2), 'utf-8');
console.log(`Generated site/src/generated/sports.json (${index.length} sports)`);
}
50 changes: 50 additions & 0 deletions site/src/lib/schema-versions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Testable helpers for the "Try it" schema-version transparency:
// - isNewerVersion(a, b): true iff semver a > b (numeric core compare).
// - fetchLatestSchemaVersion(fetchImpl): reads `latest` from
// https://opencoachingformat.org/schema/versions.json; fail-soft (null).
// No side effects; fetch is injected so this file is unit-testable offline.

export const SCHEMA_VERSIONS_URL =
"https://opencoachingformat.org/schema/versions.json";

// Parse "v1.4.0-rc.1" -> [1, 4, 0]; the pre-release "-…" suffix is dropped so
// comparisons run on the numeric core. Returns null if no numeric core.
function parseCore(version) {
if (typeof version !== "string") return null;
const core = version.trim().replace(/^v/i, "").split("-")[0].split("+")[0];
if (core === "") return null;
const parts = core.split(".");
const nums = [];
for (const p of parts) {
if (!/^\d+$/.test(p)) return null;
nums.push(Number(p));
}
return nums.length > 0 ? nums : null;
}

export function isNewerVersion(candidate, baseline) {
const a = parseCore(candidate);
const b = parseCore(baseline);
if (a === null || b === null) return false;
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
const ai = a[i] ?? 0;
const bi = b[i] ?? 0;
if (ai > bi) return true;
if (ai < bi) return false;
}
return false;
}

export async function fetchLatestSchemaVersion(fetchImpl) {
const doFetch = fetchImpl ?? fetch;
try {
const res = await doFetch(SCHEMA_VERSIONS_URL);
if (!res || !res.ok) return null;
const data = await res.json();
const latest = data && data.latest;
return typeof latest === "string" && latest !== "" ? latest : null;
} catch {
return null;
}
}
4 changes: 2 additions & 2 deletions site/src/lib/validator-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
// This is a plain .mjs (not .ts) so it can be imported both by Astro pages
// (via Vite) and directly by scripts/build-adoc.mjs, which runs under plain
// `node` with no TypeScript loader configured.
export const OCF_VALIDATOR_VERSION = '0.1.1';
export const OCF_VALIDATOR_VERSION = '0.2.0';

const JSDELIVR_NPM_BASE = `https://cdn.jsdelivr.net/npm/@opencoachingformat/validator@${OCF_VALIDATOR_VERSION}`;

export const OCF_VALIDATOR_BROWSER_URL = `${JSDELIVR_NPM_BASE}/dist/browser/browser.js`;

export const OCF_VALIDATOR_COMMIT = '45a11869cfe170269d53a91890063b9ccc95f5fd';
export const OCF_VALIDATOR_COMMIT = '07361d99f2433f2c715486e0894da5594160a218';

const JSDELIVR_GH_BASE = `https://cdn.jsdelivr.net/gh/opencoachingformat/ocf-validator@${OCF_VALIDATOR_COMMIT}`;

Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const jsonLd = {
<Card
title={sport.title}
description={sport.description}
href="/learn/"
href="/learn/sports/"
status={sport.status}
/>
))}
Expand Down
207 changes: 207 additions & 0 deletions site/src/pages/learn/sports/[sport].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
---
import Base from '../../../layouts/Base.astro';
import sports from '../../../generated/sports.json';

export function getStaticPaths() {
return sports.map((sport) => ({
params: { sport: sport.sport },
props: { sport },
}));
}

const { sport } = Astro.props;
const variantEntries = Object.entries(sport.variants ?? {});
---
<Base
title={`${sport.label} — Sports`}
description={sport.reserved
? `${sport.label} is a reserved sport in OCF: the shared structure applies and a provisional action list is stubbed, open for contribution.`
: `${sport.label} is fully specified in OCF: action types, variants, outcomes, and supported rulesets.`}
>
<section class="hero">
<div class="title-row">
<h1>{sport.label}</h1>
<span class={`status-badge ${sport.reserved ? 'status-reserved' : 'status-full'}`}>
{sport.reserved ? 'Reserved' : 'Full'}
</span>
</div>
<p class="tagline">
{sport.reserved
? 'The shared OCF structure — court, entities, frames — already fits this sport. Its action vocabulary is provisional and open for contribution.'
: 'This sport is fully specified: the action vocabulary, outcomes, and supported rulesets below are stable.'}
</p>
</section>

<section>
<h2>Action types</h2>
{sport.action_types.length > 0 ? (
<ul class="chip-list">
{sport.action_types.map((a) => <li class="chip">{a}</li>)}
</ul>
) : (
<p class="empty">No action types defined yet.</p>
)}
</section>

{!sport.reserved && (
<Fragment>
<section>
<h2>Variants</h2>
{variantEntries.length > 0 ? (
<dl class="variant-list">
{variantEntries.map(([action, variants]) => (
<div class="variant-row">
<dt>{action}</dt>
<dd>
<ul class="chip-list">
{variants.map((v) => <li class="chip">{v}</li>)}
</ul>
</dd>
</div>
))}
</dl>
) : (
<p class="empty">No variants defined.</p>
)}
</section>

<section>
<h2>Outcomes</h2>
{sport.outcomes.length > 0 ? (
<ul class="chip-list">
{sport.outcomes.map((o) => <li class="chip">{o}</li>)}
</ul>
) : (
<p class="empty">No outcomes defined.</p>
)}
</section>

<section>
<h2>Rulesets</h2>
{sport.rulesets.length > 0 ? (
<ul class="chip-list">
{sport.rulesets.map((r) => <li class="chip">{r}</li>)}
</ul>
) : (
<p class="empty">No rulesets defined.</p>
)}
</section>
</Fragment>
)}

{sport.reserved && (
<section class="contribute">
<h2>Contribute</h2>
<p>
{sport.label} is reserved: the format is ready, but its action
vocabulary needs the people who know the game. If you coach or play
{` ${sport.label.toLowerCase()}`}, you can help define the actions,
variants, outcomes, and rulesets that make an OCF {sport.label.toLowerCase()}
play precise.
</p>
<ul class="contribute-list">
<li>
<strong>Sport experts wanted.</strong> Propose the action vocabulary
for {sport.label} through the RFC process — no code required to start
the conversation.
</li>
<li>
<strong>Open an RFC or discussion.</strong> See the
{' '}
<a href="https://github.com/opencoachingformat/spec/tree/main/rfcs">RFC process</a>
{' '}in the specification repository.
</li>
<li>
<strong>Browse the repository.</strong> The
{' '}
<a href="https://github.com/opencoachingformat/spec">opencoachingformat/spec</a>
{' '}repo holds the sport registry files under <code>sports/</code>.
</li>
</ul>
</section>
)}

<p class="back"><a href="/learn/sports/">← All sports</a></p>
</Base>

<style>
.hero {
margin-bottom: 2rem;
}
.title-row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.status-badge {
font-size: 0.8rem;
font-weight: 700;
padding: 0.2rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--border);
}
.status-full {
color: #0b1220;
background: var(--accent);
border-color: var(--accent);
}
.status-reserved {
color: var(--text-muted);
background: var(--bg-elevated);
}
.tagline {
font-size: 1.1rem;
color: var(--text-muted);
}
.chip-list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85rem;
padding: 0.25rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-elevated);
}
.variant-list {
margin: 0.5rem 0 0;
}
.variant-row {
margin-bottom: 0.75rem;
}
.variant-row dt {
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85rem;
}
.variant-row dd {
margin: 0.25rem 0 0;
}
.empty {
color: var(--text-muted);
}
.contribute {
border: 1px solid var(--border);
border-left: 4px solid var(--accent);
border-radius: 8px;
padding: 1rem 1.25rem;
background: var(--bg-elevated);
}
.contribute-list {
margin: 0.75rem 0 0;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back {
margin-top: 2rem;
}
</style>
Loading