Sailing marks, course cards, and leg geometry: a versioned data format for the course card a club prints — its racing marks and its numbered courses as mark sequences — plus a small, dependency-free TypeScript library that turns a course number and the race-day positions into the legs actually sailed, each with its distance and true bearing.
The format is the product; the library proves it. A club's card encoded once serves every consumer: a scoring application that needs the legs of a constructed course, a results page rendering the course, and eventually a card-designing tool.
Two file kinds, plain JSON, specified in docs/format.md:
- Marks — the racing marks a club lists: id, name, physical description, and either a fixed position or a note on where the mark is laid per race ("Upwind of Start Line").
- Course card — the courses as ordered mark sequences, each mark with the side it is left on and whether it is a passing mark, and the start line every course begins at, read from the club's sailing instructions and quoted from them.
A card cannot know where the start line was on the day, nor where the marks laid per race went. Those are supplied to the library per race.
// npm install @sailscoring/course-cards
import { courseLegs, destination, parseCourseCardFile, parseMarksFile } from '@sailscoring/course-cards';
const marks = parseMarksFile(JSON.parse(marksJson));
const card = parseCourseCardFile(JSON.parse(cardJson));
const start = { lat: 53.4055, lng: -6.0675 };
const legs = courseLegs(card, marks, '041', {
marks: {
SL: start, // where the line was: the card quotes the SI, not a position
Z: destination(start, 190, 1000), // laid 1,000 m upwind on 190°
F: { lat: 53.4085, lng: -6.0705 },
},
});
// legs[i] = { from, to, distanceNm, bearingDeg }courseLegs walks the course's marks — the first of which is the card's
start line — and returns every leg's great-circle distance and initial true
bearing; a mark it cannot place is an error naming the mark and quoting
where the club says it is laid. What the wind was doing on each leg is the
caller's knowledge, not the library's — though where a card lays its
courses out for a wind, as HYC's do, each course carries it as
windDirectionDeg, so a caller can propose it.
Around that:
courseMarks(card, marks, courseId)— the course's marks resolved, each saying whether the card places it; the ones it does not are what to ask the race officer for.legsFromWaypoints(waypoints)— the leg arithmetic on its own, for a course built by hand from placed marks with no card behind it.parsePosition/formatPosition— positions the way sailors write them: degrees and decimal minutes with or without the symbols, degrees minutes and seconds, decimal degrees; the hemisphere as a letter before or after, or a sign.distanceNm,bearingDeg,destination,METRES_PER_NM,METRES_PER_CABLE— the geometry for the arithmetic around a race.renderCourseSvg(marks, course)— the course as a picture: marks at their real relative positions, legs numbered with bearing and distance, north arrow, scale bar; one inert SVG element with no script, style, id or external resource, so it can go inline anywhere.parseCatalogue— a release'sindex.json, typed.
The package is ESM, and also loadable from CommonJS: the exports map
carries a require condition, and Node has been able to require() an ESM
module since 22.12 (the minimum in engines). So const { courseLegs } = require('@sailscoring/course-cards') works, which matters for consumers
that cannot set "type": "module" at their root. pnpm check:cjs guards it
after a build, in CI and again on the packed artifact before it is published.
Each club's PDFs are kept alongside its JSON, and the JSON is generated
from them by the tools in tools/. A card's PDF is not the whole story:
the club's sailing instructions are what define the start line every
course begins at, say which card is used when, and give the conventions the
card's letters are printed under. Each data set therefore keeps the sailing
instructions that bear on its courses in source/ too — only those; a
club's amendments are kept when they change something a card depends on and
left out when they do not, and the manifest records which and why. Every one
gets a Markdown sidecar (foo.pdf → foo.md, by tools/pdf_markdown.py)
so its text is greppable and diffable without a PDF viewer, following the
practice of the sibling reference-docs store; the PDF stays the source of
truth and the sidecar is regenerated and verified like every other artifact. The same pipeline renders each card
as a self-contained HTML page: the course table as printed, the marks over
an OpenStreetMap + OpenSeaMap chart — pick a course and it is drawn there,
leg by leg, with each leg's true bearing and distance — bearings and
distances between marks, and the notes. No scripts: the picker is a radio
button per course and a CSS rule. Every data set has a README saying how,
and how it was checked.
data/hyc/al-2025/— Howth Yacht Club's Autumn League 2025: the marks from the course card technical sheet, and the offshore and inshore committee-boat-start course cards (180 courses each) with the sheet's notes. The technical sheet is parsed from its text layer; the two cards are pictures, read by a small purpose-built OCR. The two start lines — north and north-west of Ireland's Eye — come from SI 6.1 and 6.2. Published: offshore, inshore; README.data/hyc/al-2026/— Howth Yacht Club's Autumn League 2026, as drafts: the offshore and inshore cards as they stood on 6 September 2026, sent round for comment before the league. The club writes them in Office rather than as PDFs — one a Word table, one an Excel sheet — so they are read straight out of the OOXML, the colour of each letter giving the side. The marks are the 2025 technical sheet's, there being no 2026 one yet, and there is no start line to quote until the 2026 sailing instructions appear. Each course carries the length the card prints for it, andtools/check_hyc_al.pyrecomputes all 144 of them from the marks and the card's own conventions: 132 agree to a fifth of a mile, and the twelve that do not are recorded, course by course, in the manifest. README.data/hyc/brass-monkey-2025/— Howth Yacht Club's Brass Monkey Winter Series 2025: the eight marks and 16 courses from the course card in the last two pages of the sailing instructions, read from the PDF's text layer and checked against the SI's own picture of the marks. The SI gives names and positions only, and misplaces Portmarnock; shapes, colours and that position come from the Autumn League sheet, as the README records. The start line is SI 12.1's, in SI 8.1's race area. README.data/dlcc/regattas-2026/— the Dun Laoghaire Combined Clubs regattas 2026 (DMYC, NYC, RIYC and RStGYC, June–July), whose common sailing instructions carry the cruisers' "Course Card A" in Addendum A as a picture: 16 lettered sections of four courses round DBSC's marks, read by a small purpose-built OCR, with the marks from DBSC's own sheet, which the addendum reproduces, and the start line from Addendum A 3.1. README.data/dbsc/summer-2026/— Dublin Bay Sailing Club's Summer Series 2026: the 26 marks from the club's marks, bearings and distances sheet, and the five keelboat course cards (Saturday committee vessel and hut, Thursday Blue and Red fleets, Tuesday hut; 592 courses), all read from the PDFs' text layers and cross-checked against the club's own machine-readable CSV/GPX files — which agree apart from one course, recorded in the manifest. The start lines come from the sailing instructions supplements: B's committee vessel for cards 1, 3 and 4, and H's fixed transit at the West Pier hut for cards 2 and 5. README.
pnpm data # rewrite the JSON from the PDFs, then the HTML from the JSON
pnpm data:check # verify the committed files are what a fresh run producesThe extraction tools need Python 3, Pillow, and poppler's pdftotext /
pdftoppm / pdfinfo; rendering needs only Node.
Releases are tagged vMAJOR.MINOR.PATCH; the tag is also the version of
the artifacts, and each GitHub Release attaches:
course-cards-vX.Y.Z.zip— every data set: marks and cards as JSON, the rendered HTML pages, the chart image, the clubs' source PDFs and the provenance manifests, plus the format spec.catalogue.json— what the release contains, with the URL of every artifact on the site.sailscoring-course-cards-X.Y.Z.tgz— the library as packed for npm, where it is published as@sailscoring/course-cards.
courses.sailscoring.ie lists the available
course cards and serves every artifact at its own URL — unversioned for the
current release, and under /vX.Y.Z/ for the release it belongs to:
https://courses.sailscoring.ie/hyc/al-2025/offshore.json
https://courses.sailscoring.ie/v0.1.0/hyc/al-2025/offshore.json
https://courses.sailscoring.ie/v0.1.0/course-cards-v0.1.0.zip
https://courses.sailscoring.ie/index.json
A deploy carries one release; earlier releases stay downloadable from
GitHub. The site is built by pnpm site into site/ and deployed by
Vercel on every push to main.
- Bump
versioninpackage.jsonand commit. git tag -a vX.Y.Z -m "…", then pushmainand the tag.- The release workflow tests, checks the tag matches
package.json, attaches the assets to a GitHub Release, and stages the npm publish. - Approve the staged version — see below. Until you do, nothing is installable from npm. A green release workflow is not a finished release.
A re-run of a failed release uses the workflow file as it stood at the tag, so a fix to the workflow only takes effect on a new tag.
Publishing uses npm's trusted publishing with staged publishing, and the two do different jobs.
Trusted publishing removes the credential: the workflow proves its identity to the registry over GitHub's OIDC, so there is no token in the repository, nothing to rotate, no expiry to track, and provenance is attached automatically. It replaced a granular access token in September 2026, when npm began restricting tokens that bypass two-factor authentication — such a token can no longer publish directly, and CI cannot answer an OTP prompt, so no token-based publish from Actions can succeed.
Staged publishing puts a person back in the loop. The trusted publisher is
configured stage-only, so the workflow runs npm stage publish and can do
nothing more: the tarball lands in a staging queue, and a maintainer promotes
it with 2FA. A compromised workflow can therefore stage something, but cannot
put it in front of anyone.
Approving a staged version, from a terminal that can answer a 2FA prompt:
npm stage list # versions awaiting approval
npm stage view <stage-id> # inspect the tarball before promoting it
npm stage approve <stage-id> # promote it; prompts for 2FA
npm stage reject <stage-id> # or discard itapprove and reject need interactive authentication and cannot use an OIDC
token, so they are never automatable — by design. The package page on
npmjs.com does the same job.
The trust is configured on the registry side, at npmjs.com →
@sailscoring/course-cards → Settings → Trusted Publisher → GitHub
Actions:
| Field | Value |
|---|---|
| Organization | sailscoring |
| Repository | course-cards |
| Workflow filename | release.yml |
| Environment | (empty) |
Every field is case-sensitive and matched exactly, so renaming
release.yml, the repository, or the org breaks publishing until the
registry side is updated to match. The symptom is an authentication error or
an OTP prompt on the stage step, not a helpful message about mismatched
configuration.
The job needs id-token: write (it has it) and npm 11.5.1 or newer, which is
why it upgrades npm before publishing rather than trusting whatever the Node
release bundles.
Format-first: the versioned format and leg library are the deliverable, with a course renderer beside them; a club-facing card designer grows here later. Part of the Sail Scoring project, whose app consumes this format to fill in a race's legs by course number.
License: MIT. "Sail Scoring" and its logo are trademarks of Mark McLoughlin and not covered by the code license.