@impetusvn/tournament-kit
v0.4.2
Published
Tournament page templates and the tournament/2 data contract, for partners who host the pages in their own app.
Readme
@impetusvn/tournament-kit
The 25 portfolio4u tournament templates as one React package. You keep the data and the app: you check a tournament/2 document with parseTournament, pick a template, and render the tournament's summary page and its sport pages in Vietnamese or English, with a live board of matches from your own feed. Nothing goes through portfolio4u at runtime.
The kit is public on npm under the MIT licence (its fonts under the SIL Open Font License, see dist/fonts/OFL.txt), at 0.x. Read Versions before upgrading.
Contents
- Install
- Entries
- A first page
- The data contract
TournamentPage- The
nowcontract - Next.js (App Router)
- TanStack Start
- Vite and other client-only apps
- Live board
- Theme
- Blocks
- Copy overrides
- Your own views
- Pieces for your own pages
- Languages and formatting
- Versions (0.x)
Install
The package is on the public npm registry: no .npmrc line and no token. If you installed a version before 0.3.2 from GitHub Packages, it was named @impetus-network/tournament-kit: remove the @impetus-network:registry=https://npm.pkg.github.com line and its token line from your .npmrc, replace the dependency with @impetusvn/tournament-kit, change the imports and CSS paths to the new name, and reinstall.
pnpm add @impetusvn/tournament-kit
# or: npm install @impetusvn/tournament-kitPeers: react and react-dom ^19.3.0. Two optional peers: next ^16.3.5 for the /next entry, and @tanstack/react-router ^1.170.0 for TanStack apps (the /tanstack entry takes TanStack's notFound from your code and imports nothing from TanStack itself). A Vite app installs neither (checked with npm and with pnpm 12, which installs no optional peer for a consumer outside the workspace). The kit brings zod as a dependency.
Your app must be bundled. The template and page modules import their own CSS (import "./x.css"), which Next.js and Vite handle and plain Node does not. Only /core and /tanstack load in Node without a bundler.
TypeScript needs the DOM types. The declarations of /core name browser types (Event, RequestInit, for the live connection), so your tsconfig.json lib must include "dom", as Next and Vite templates already do. A server-only project with lib: ["es2024"] fails to type-check against the kit.
Working examples sit in the kit's repository under examples/: a Next.js app (TournamentPage in a server page, createTournamentPage under /adapter, an SSE live source), a TanStack Start app and a Vite app (subscribe live sources), all installing the packed package and drawing the same pages. The code samples below come from them unless they say otherwise.
Entries
| Import | Holds |
|---|---|
| @impetusvn/tournament-kit/core | The data layer, no React: parseTournament, parseMatches, parseTheme, toPromo, phaseOf, liveBoard, liveMatch, boardSports, mergeMatches, connectLive, matchTime, pageMeta, the format functions (money, moneyUnit, num, slotCount, dateRange, shortDate, dayMonth, weekday, daysUntil, phaseName, tierName), PHASES and isPhase, BOARD_WORDS, BLOCK_KEYS, PROMO_CATALOG, PROMO_GROUPS, and the types (TournamentData, Match, MatchRecord, LiveSource, LiveOptions, MatchEvent, KitTheme, BlockKey, Phase, Locale, ...). |
| @impetusvn/tournament-kit/react | TournamentPage, TemplateFrame, Section, Card, MatchRow, AthleteChip, useLiveMatches, and the types KitTemplate, TournamentPageProps, BlockOptions, BlockProps, PageRoutes, PageView, LiveOptions, LiveSource, CopyKey, CopyOverrides. useLiveMatches ships in its own "use client" chunk: a Next server component may import /react, but only a client component can call the hook. |
| @impetusvn/tournament-kit/next | createTournamentPage: an App Router page and its generateMetadata in one call. |
| @impetusvn/tournament-kit/tanstack | loadTournament, a route loader's check of your tournament, and tournamentHead, a route's head() from it. |
| @impetusvn/tournament-kit/templates/<id> | One template object, named by the id in camel case: templates/ngay-hoi exports ngayHoi. Importing it brings only that template's code and CSS. Its copy holds its words in both languages. |
| @impetusvn/tournament-kit/styles/<id>.css | The template's own CSS and @font-face rules. The .css extension is part of the path: styles/championship does not resolve. |
| @impetusvn/tournament-kit/fonts/* | The woff2 files the template CSS points at; you never import them yourself. Beside them, fonts/OFL.txt holds the fonts' licence (SIL Open Font License 1.1) and copyright notices: if you copy or self-host the fonts, keep it with them. |
The 25 ids are listed under Blocks.
A first page
Import the template object and its stylesheet once, in a module every page that draws it imports (from examples/next/lib/templates.ts):
import type { KitTemplate } from "@impetusvn/tournament-kit/react";
import { championship } from "@impetusvn/tournament-kit/templates/championship";
import { dieuLe } from "@impetusvn/tournament-kit/templates/dieu-le";
import { ngayHoi } from "@impetusvn/tournament-kit/templates/ngay-hoi";
import { poster } from "@impetusvn/tournament-kit/templates/poster";
import { street } from "@impetusvn/tournament-kit/templates/street";
import "@impetusvn/tournament-kit/styles/championship.css";
import "@impetusvn/tournament-kit/styles/dieu-le.css";
import "@impetusvn/tournament-kit/styles/ngay-hoi.css";
import "@impetusvn/tournament-kit/styles/poster.css";
import "@impetusvn/tournament-kit/styles/street.css";
import { templateIdOf, type TemplateId } from "../../shared/data";
// Each template is imported from its own entry, with its CSS: the page bundles only these five.
const TEMPLATES: Readonly<Record<TemplateId, KitTemplate>> = { championship, "ngay-hoi": ngayHoi, street, "dieu-le": dieuLe, poster };
/** The template `?template=` names, the first one otherwise. */
export const templateOf = (value: string | string[] | undefined): KitTemplate => TEMPLATES[templateIdOf(value)];About the CSS:
- The
styles/<id>.cssimport is redundant.templates/<id>already imports that same file, after the live board's CSS that it overrides in places. The explicit import adds nothing, and is safe only after the template's JavaScript, as above. Imported earlier (in a root layout, for instance), it puts the template's rules first in the bundle, and the board's defaults win where both have rules of equal weight. - The JavaScript import is never optional. The live board's and
MatchRow's CSS comes with the template's JavaScript; the CSS ofSection,CardandAthleteChipcomes with/react. The stylesheet alone is never the whole set.
Then a page reads your tournament, checks it and renders it (the Vite example, examples/vite/src/pages.tsx):
const parsed = parseTournament(TOURNAMENTS[locale], { locale });
if (!parsed.ok) throw new Error(`Tournament "${slug}": ${parsed.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
if (sport !== undefined && !boardSports(parsed.data).some((s) => s.slug === sport)) return notFound();
const view: PageView = sport === undefined ? { kind: "summary" } : { kind: "sport", slug: sport };<TournamentPage
template={templateOf(search)}
locale={locale}
data={parsed.data}
view={view}
now={NOW}
routes={routesOf(locale, slug)}
live={{ matches: matchesAt(locale, NOW) }}
/>The data contract
parseTournament(input, { locale? }) checks a tournament/2 document and returns { ok: true, data, warnings? } or { ok: false, issues }. It never throws. Each issue has a path (sports.0.startDate, or "" for the whole input), a code (always snake_case) and an English message:
- the schema's own codes (
invalid_type,too_big, ...) for the keystournament/2knows, which are checked strictly, andcustomwith a reason for its rules (a registration fact given at two levels, a window that closes before it opens); blocker: the page cannot be drawn withoutname,startDate,endDateand at least one sport;locale_mismatch: the document'slocalediffers from thelocaleyou passed;unreadable: the input could not be read at all.
Unknown keys are left out, not refused. tournament/2 and match/1 only gain optional keys, so a document written against a later revision of our API still reads on an older kit. Keys the kit does not know, at any depth, are removed from data (your input is not changed) and listed in warnings, each with code unrecognized_keys and the key's path.
No place is a warning, not an issue. A tournament with no city, no venues and no sport venue still parses and draws, without saying where; warnings lists it with code no_place.
Links that are not https:// are left out, not refused. A platform page (the tournament's or a sport's), a document's url, a venue's mapUrl, a broadcast (the whole row) or a match's url that is not an https:// link of at most 500 characters is removed from data and listed in warnings with code link_dropped, so a page renders in an http:// development setup without those links. warnings is there only when something was left out.
TournamentData is branded. Only parseTournament produces it, and TournamentPage and TemplateFrame take nothing else, so a page never renders unchecked data. Parse on the server, in your loader.
What to know about the document:
- One document per language. It is written in the page's language; there are no
{ vi, en }fields. Set its optionallocale("vi"or"en") and pass the page'slocaletoparseTournamentto catch a mix-up there. The page checks it too:TournamentPageandTemplateFramethrow aRangeErrorwhen the document'slocaleis set and differs from theirlocale, so a page is never drawn in two languages. - Dates are
YYYY-MM-DD, money is whole VND, links arehttps://(others are left out, above). - A sport needs
key(one of 23 sport keys, such asbadminton),nameandstartDate. Its page lives at itsslug: the one you give, or else its key (football,football-2for a second one).boardSports(data)lists each sport's{ id, slug, name }. - Give every sport an
id(24 lowercase hex characters, like a MongoDB ObjectId) when matches must name their sport: a match'ssportis that id. - Links to your own pages ride in the data: each match's
url, and theplatformpages (url,scheduleUrl,bracketUrl,liveUrl,resultsUrl) of the tournament and of each sport.platform.namemay stay empty; links are then worded without "on ". The kit'sroutesonly holds the addresses of its own pages.
Registration and fees
Every registration fact sits on the thing one registers for: an event (sports[].events[]), or a sport without events.
entryis what one entry is:"athlete","pair"or"team".slots(places offered, at least 1) andenteredcount entries, andfee(VND) is per entry:{ "name": "Đôi nam", "entry": "pair", "slots": 32, "fee": 300000 }reads "32 cặp", "300.000đ / cặp".- One level each:
entry,fee,slotsandenteredgo on a sport or on its events, never on both (custom, reasongiven-twice, at the sport's key). An event also takeseligibility(one line, adding to the sport's). - A ticket per athlete whatever they enter is the tournament's
fee, allowed only when no sport or event has a fee.feeNoteholds the fine print (discounts, early-bird, how to pay). - The registration window:
registrationOpensandregistrationDeadlineon the tournament, a sport or an event; the most specific one given applies. - The registration status:
registrationStatus("open","closed"or"invite") on the tournament, a sport or an event says what the dates cannot:openwhen registration is open whatever the dates,closedwhen it closed whatever the dates,invitewhen entry is by invitation only. Left out, the dates decide. The most specific one given applies, as for the window, so an event can reopen under a closed tournament; a sport's page readsopenwhen any of its events is open, andclosedorinviteonly when every event resolves to it. Open by the status, a deadline already past is not shown. The summary follows the tournament's own status. A full event needs no status:slotsandenteredalready say it (placesLeftis0). - A waitlist:
waitlist: trueon an event whose places are all taken but which still takes entries on a waitlist. Full stays derived fromslotsandentered; the flag only adds "còn nhận danh sách chờ" / "waitlist open" after the template's own word for full, and the register call stays. The note shows only before the tournament and on events still taking entries; a page drops it elsewhere (openWaitlists(data, phase, now)in/coredoes the same for your own pages). Events only: setregistrationStatus: "open"too when the dates read closed. - Unknown is not zero. A number left out is unknown: the page draws nothing for it.
fee: 0is free and the page says so. After normalising,Sport,SportEventandTournamentcarrynullfor every unknown count and amount, andTotalssums only when every sport gives its part. - On your own pages, read these through
/core:entryFee(sport, t)andfeeText(locale, fee),entries(sport)andentriesText(locale, entries)(which adds the waitlist note),placesLeft,waitlistNote(locale, entries),fullText(locale, entries, word)andwaitlistSentence(locale, entries),sportWindow(sport, t)/eventWindow/tournamentWindow(each with the resolvedstatus),registrationState(window, now)(stateis"unknown","soon","open","closed"or"invite"; a status wins over the dates) andwindowText(locale, state)("Theo thư mời" / "By invitation" for an invitation). Templates show no call to register while the state isclosedorinvite.
Contact and broadcasts
contactis a list of{ channel, value };channelisphone,zalo,email,facebook,messenger,website,youtube,tiktokorinstagram. Send each value as your users typed it: a local phone number, a Zalo number orzalo.me/g/...group, a Facebook page or@handle, a baregiai.vn. The kit reads each per channel (contactLink(row)in/core: a host allowlist and a handle base per channel,httpupgraded tohttps) and draws it as a link with a short label. A row it cannot read is left out ofdatawith a warning (contact_unreadable).venuesare{ name, address?, mapUrl? }.mapUrlis the venue's map link (https, at most 500 characters; any other is left out with alink_droppedwarning, the venue kept). Templates that draw venues draw a "Chỉ đường" / "Directions" link to it, opening in a new tab; after normalising,Venue.mapHrefis the link ornull.broadcasts({ label, url }, https) on the tournament and on a sport are streams of this tournament; templates draw them where visitors look for how to watch. A channel to follow belongs incontact;platform.liveUrlstays the live-scores page.broadcastsOf(t, sport)lists a page's streams.
Moving from 0.1 (tournament/1)
contact: { hotline, email, facebook }becomescontact: [{ channel: "phone", value }, { channel: "email", value }, { channel: "facebook", value }].- A sport whose events give
slotsorenteredmust not give them too: drop the sport's copy (the events' sum replaces it).slots: 0is no longer accepted: leave it out. - A fee left out used to read as free: send
fee: 0where entry is free. cityis optional; with no city and no venue the page draws without a place (no_placewarning).- In code reading normalised data: numbers are
number | null,t.contactis a list of{ channel, href, label },t.placeis where the tournament is, andeventSize,telHref,mailHref,PhoneandEmailare gone (useentriesTextandcontactItems).
The full shape ships as types (TournamentData; Tournament and Sport describe what templates read after normalising).
parseMatches(input, { now? }) does the same for an array of match/1 records (at most 500), unknown keys, dropped links and warnings included, and returns MatchRecords ready for live.matches: a match's updatedAt is kept (an invalid one is an issue), and one without it is stamped with now (default Date.now()). A match is { ref, status: "scheduled" | "live" | "finished", sport?, event?, round?, court?, startsAt?, order?, sides: [side, side], url? }, a side { name, unit?, score?, periods?, won? }; startsAt is ISO with an offset (2026-10-04T14:30:00+07:00). The live board takes MatchRecord: a Match plus updatedAt, epoch milliseconds of its last change, which drives staleness. An updatedAt below 10^11 is read as epoch seconds and converted to milliseconds, so a feed that sends seconds does not empty the board; send milliseconds all the same.
TournamentPage
| Prop | Type | |
|---|---|---|
| template | KitTemplate | The imported template object. |
| locale | "vi" \| "en" | Required. The page's only language. |
| data | TournamentData | From parseTournament. |
| view | PageView | { kind: "summary" } or { kind: "sport", slug }. |
| now | number | Render time, epoch ms. See The now contract. |
| phase | "upcoming" \| "live" \| "done" | Optional. Left out, it follows the dates in Vietnam time (phaseOf). |
| routes | PageRoutes | { summary: string; sport: (slug) => string }: the kit's own pages, for its links between them; optionally your own pages' builders (match, bracket, schedule, results, athletes), see Your own views. |
| live | LiveOptions | Optional. { matches, source?, onError?, onWarning? }, see Live board. |
| schedule | ScheduleInput | Optional. { matches, pageSize? }: every match of the tournament, for the kit's schedule in the schedule place. |
| rootVariables | boolean | Optional. Also lay the template's published variables on <html> while the page is mounted. |
| theme | KitTheme | Optional. Your colours and fonts. |
| blocks | BlockOptions | Optional. { hide?, order?, replace?, custom?, visible? }. |
| copy | CopyOverrides<typeof template> | Optional. Your words for single keys, typed by the template; see Copy overrides. |
| presentation | PromoPresentation | Optional. { palette, sportColors?, unitColors?, documentFiles? } when you keep colours outside the content; the template's own palette otherwise. Checked like theme, see below. |
presentation is checked like the theme. Every colour in palette, sportColors and unitColors must be #rgb or #rrggbb (it comes out as lowercase #rrggbb): an invalid palette colour falls back to the template's, an invalid sport or unit colour to that row's default. A documentFiles[].href must be https:// or relative to the page (not //host); otherwise the entry is left out and the row keeps the content's own url. Invalid entries are dropped without a word, and there is no parsePresentation to list them.
It returns null when view names a sport the tournament does not have; answer 404 then (or check first with boardSports, as the examples do). A tournament with one sport has no separate summary: both views draw that sport's page.
Internal links are plain <a> elements, so moving between the summary and a sport page is a full page load.
The now contract
now is the moment the page is drawn, and the page derives everything time-dependent from it: the phase (when phase is left out), countdowns, "today", which matches the first live board shows and how match times are written.
- Take it on the server and hand the same value to the browser, so hydration draws what the server drew. Never call
Date.now()during render in a component that also renders in the browser. - Keep it stable across renders. A new value is a new page: the live board starts over from
live.matches. - The phase changes on the dates in Vietnam (UTC+7, no daylight saving):
phaseOf("auto", startDate, endDate, now)is what the page uses.
TanStack Start, from examples/tanstack-start/src/routes/live-sim.tsx: the loader takes the time on the server and the component reads it back.
loader: () => {
const parsed = parseTournament(TOURNAMENTS.en, { locale: "en" });
if (!parsed.ok) throw new Error(parsed.issues.map((issue) => issue.message).join("; "));
// The server's time, handed to the browser with the data, so both draw the same first board.
return { data: parsed.data, now: Date.now() };
},In a Next.js server component, Date.now() after await connection() is the request's time, and the server component does not run again in the browser (examples/next/app/live-sim/page.tsx).
Next.js (App Router)
createTournamentPage gives a page and its generateMetadata for a route [slug]/[[...sport]]: the summary at .../<slug>, a sport at .../<slug>/<sport>. From examples/next/app/adapter/[slug]/[[...sport]]/page.tsx:
import { createTournamentPage } from "@impetusvn/tournament-kit/next";
import { championship } from "@impetusvn/tournament-kit/templates/championship";
import "@impetusvn/tournament-kit/styles/championship.css";
import { loadTournament, matchesAt } from "../../../../../shared/data";
const route = createTournamentPage({
template: championship,
locale: "en",
load: async (slug) => {
const tournament = await loadTournament(slug, "en");
return tournament === null ? null : { tournament, locales: ["en", "vi"] };
},
routes: (slug, locale) => ({
summary: locale === "en" ? `/adapter/${slug}` : `/${locale}/t/${slug}`,
sport: (sport) => (locale === "en" ? `/adapter/${slug}/${sport}` : `/${locale}/t/${slug}/${sport}`),
}),
live: () => ({ matches: matchesAt("en", Date.now()) }),
});
export default route.default;
export const generateMetadata = route.generateMetadata;| Option | |
|---|---|
| template, locale | As on TournamentPage. One route draws one language; add a route per language. |
| load(slug) | Your read. Returns { tournament, locales? } or null (404). tournament is the raw document; the page runs parseTournament on it and throws naming the issues when it fails, so a broken document is an error, not a 404. locales lists the languages the tournament exists in. |
| routes(slug, locale) | PageRoutes for a language. Used for links and, when locales has more than one language, for alternates.languages in the metadata. |
| live(slug, view) | Optional. { matches, source? } or null. The source must be plain data (poll, sse, websocket): a function cannot cross from a server component to the browser. |
| theme, blocks, copy | As on TournamentPage. |
- The page renders per request at
Date.now(). A deeper path or a sport the tournament lacks answers 404. - The loader runs once per request for the page and its metadata (React
cache). generateMetadatagives the title (the sport's name first on a sport page), the description (the lede, else dates and place) and, with severallocales,alternates.languages. Paths there needmetadataBasein a layout above the route, ornext buildfails (examples/next/app/adapter/layout.tsx):
export const metadata: Metadata = { metadataBase: new URL(process.env.SITE_URL ?? "http://localhost:3201") };Known limit: Cache Components. With cacheComponents: true in next.config, next build fails on every route that reads params, searchParams or connection() outside a <Suspense> boundary ("blocking-prerender-dynamic"), and the adapter's page reads params. The workaround is export const instant = false in the route's page.tsx, which allows a blocking route (Next docs, route segment config instant). Then the page builds and renders per request, but a notFound() from the adapter answers with status 200 instead of 404, because the response is already streaming. If 404 status codes matter (search engines, monitoring), keep Cache Components off in the app that serves these routes. Without cacheComponents (the default, and the examples' setting) the adapter answers 404 as described.
You can also render TournamentPage yourself in a server component, as examples/next/app/[locale]/t/[slug]/[[...sport]]/page.tsx does, with pageMeta(data, locale, view) for the metadata. Next needs no kit-specific setting (transpilePackages is not needed: the package ships compiled JavaScript and CSS).
TanStack Start
Add the kit to ssr.noExternal. Vite leaves node_modules packages to Node on the server, and Node cannot import the kit's CSS. Without this line the server answers 500 (examples/tanstack-start/vite.config.ts):
export default defineConfig({
server: { port: 3202 },
// Required: the kit's React entries import their CSS, which Node cannot load. Bundling the kit
// into the server build lets Vite handle the CSS, as it does in the browser.
ssr: { noExternal: ["@impetusvn/tournament-kit"] },
// React's plugin comes after Start's.
plugins: [tanstackStart(), viteReact()],
});The loader reads and checks the tournament, head() comes from tournamentHead(data, locale, view?) (title, description and Open Graph tags), and the component renders the page (examples/tanstack-start/src/tournament.tsx):
/** The page of `/<locale>/t/<slug>[/<sport>]`; an unknown language, tournament or sport is a 404. */
export async function loadPage(locale: string, slug: string, sport: string | null): Promise<LoadedPage> {
if (!isLocale(locale)) throw notFound();
const stored = await loadTournament(slug, locale);
if (stored === null) throw notFound();
const parsed = parseTournament(stored, { locale });
if (!parsed.ok) throw new Error(`Tournament "${slug}": ${parsed.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
if (sport !== null && !boardSports(parsed.data).some((s) => s.slug === sport)) throw notFound();
return { locale, slug, data: parsed.data, view: sport === null ? { kind: "summary" } : { kind: "sport", slug: sport } };
}
/** The route's `head()`: title, description and Open Graph tags. */
export const pageHead = (page: LoadedPage | undefined) => (page ? { meta: [...tournamentHead(page.data, page.locale, page.view).meta] } : {});export const Route = createFileRoute("/$locale/t/$slug/")({
validateSearch: templateSearch,
loader: ({ params }) => loadPage(params.locale, params.slug, null),
head: ({ loaderData }) => pageHead(loaderData),
component: Summary,
});loadTournament(raw, { locale, notFound }) from /tanstack does the checking part in one call: pass your raw document and TanStack's notFound (from @tanstack/react-router). It returns the TournamentData, throws notFound() when raw is null or undefined (you have no such tournament), and throws an Error naming the issues when the document does not pass parseTournament, so the route shows its error component. Checking that a sport exists stays yours (boardSports, as above).
loaderData can be undefined in head(). TanStack routes are not server components, so a subscribe source and onError work there.
Vite and other client-only apps
No kit-specific configuration: the kit's JavaScript imports its own CSS (examples/vite/vite.config.ts). Check the data with parseTournament before rendering, as in A first page. The page has no server, so now is the time your app decides the page was drawn.
Vite (and other Rollup-based bundlers) print a MODULE_LEVEL_DIRECTIVE warning for each of the kit's "use client" chunks it bundles. The directive only matters to React Server Components; in a client-only app the warning is harmless.
Live board
Every template places a live board under its hero. On the server it is built from live.matches at now, so the page never loads with an empty board; in the browser it follows live.source. The kit's type, shown without readonly:
type LiveOptions = {
matches: readonly MatchRecord[]; // the first matches, read when the board connects
source?: LiveSource; // where fresh ones come from; left out, the board stays as drawn
onError?: (error: unknown) => void;
onWarning?: (warning: LiveWarning) => void; // a match url that is not https, left out: { path, code: "link_dropped", message }
};When it shows: whenever a match is live, and while the tournament runs if a match is coming up or just finished. During the live phase a page with a source keeps an empty board waiting, so the first match you push appears without a reload. Before and after the tournament the page is its introduction.
What it shows (ADR 0018): every live match (up to 60), then the next 6 and the last 4 results. Within a status, your order (lowest first) ranks matches, then the one nearest to now. A live match not updated for 3 hours, a result not updated for 6 hours and a scheduled match more than an hour past its start drop off; the board re-checks this every 30 seconds without new data. Times are written in Vietnam time. On a sport page the board holds that sport's matches only.
What to send: the records behind the board, not every match. The page hands live.matches to the browser in its HTML, so keep it to what the board can show at now: every live match (up to 60), the next 6 plus a margin of 6 more (12), and the last 4 results. The page itself keeps only those, whatever you pass, so a 300-match tournament costs no more than a 20-match one. A poll answer is a full snapshot each time, so send the same set from your feed; the board rebuilds itself every 30 seconds and the next poll brings the matches that became eligible. The 6 spare "next" matches cover the time until then.
live.matches is checked like a source's events (below), one match at a time: a malformed match is left out, a url that is not https:// is dropped from its match, a match without updatedAt is stamped with now, and at most 500 are kept, the most recently updated. Passing parseMatches output remains the way to see what was wrong.
Sources
| Source | |
|---|---|
| { poll: { url, everyMs? } } | GET url answers { matches }, a full list each time. Every 15 s by default, never more often than 1 s, paused while the tab is hidden. |
| { sse: { url } } | Server-sent events: each message's data is one event, as JSON. |
| { websocket: { url } } | Each message is one event, as JSON text. |
| { subscribe: (emit) => unsubscribe } | Your own client: call emit(event) for each event; return a function that stops it. |
Events (MatchEvent, without readonly):
type MatchEvent =
| { type: "snapshot"; matches: MatchRecord[] } // replaces every match
| { type: "upsert"; matches: MatchRecord[] } // replaces or adds by `ref`
| { type: "remove"; refs: string[] }; // drops by `ref`Rules a source must follow:
- Send a full
snapshotfirst on every connection and every reconnection. The board keeps the matches it had while it was disconnected and only merges what arrives; a match removed or finished while it was away stays until a snapshot replaces the list. The first matches you passed may also be older than your feed. And since the page sends only the board's records plus a margin of 6 (above), a source that only ever sendsupsertcannot refill "next" once those 12 have started: only a snapshot brings the matches after them. Send one on every connect (and whenever your list changes a lot). - Send each match whole, as on
match/1. In the browser every event is checked without zod: an event with a malformed match is dropped as a whole and reported toonError; aurlthat is nothttps://is dropped from its match and reported toonWarning(link_dropped); a match without a numericupdatedAtis stamped with the time it arrived, and one in epoch seconds is read as milliseconds. An event or a poll answer with more than 500 matches is refused. poll,sseandwebsocketare plain data, so a server component can pass them.subscribeandonErrorare functions: use them only where the page is not a server component (TanStack, Vite, or your own client component).- Switching sources. The board reconnects when a plain source's value changes. A
subscribesource is identified by its kind only: a new function on a later render is not called, so a page that moves to a different channel remounts the board with a new Reactkey(for examplekey={channelId}onTournamentPage). - Failures never reach visitors: the last board stays and
onErroris told of each one. A failedpollretries after 15 s, 30 s, 60 s, then every 120 s until it answers, then goes back to its interval. A droppedsseorwebsocketconnection opens again after 1 s, then after 15 s, 30 s, 60 s and every 120 s while it keeps failing; a connection that stayed open at least 15 s counts as healthy, so its drop is a first failure again (a host that ends long streams every minute costs a 1 s gap, and oneonErrorreport, per cycle).
A subscribe source, from examples/shared/data.ts: a snapshot at once, then an upsert every second:
/** A `subscribe` source for the simulation: a snapshot at once, then an event batch every second until unsubscribed. */
export function simSubscribe(emit: (event: ExampleEvent) => void): () => void {
const connectedAt = Date.now();
const startsAt = simStartsAt(connectedAt);
emit(simSnapshot(connectedAt));
let tick = 0;
const timer = setInterval(() => {
tick += 1;
simEvents(tick, Date.now(), startsAt).forEach(emit);
}, SIM_EVERY_MS);
return () => clearInterval(timer);
}live={{ matches: simStart(now), source: { subscribe: simSubscribe } }}An SSE route in Next, from examples/next/app/api/live-sim/route.ts (each connection starts with a snapshot), and the page that follows it:
// A server-sent event stream for `/live-sim`: one `MatchEvent` per message, as JSON. Every connection,
// and so every reconnection, starts with a full `snapshot`.
export function GET(request: Request): Response {
const encoder = new TextEncoder();
let timer: ReturnType<typeof setInterval> | undefined;
let open = true;
const stop = () => {
open = false;
clearInterval(timer);
};
const body = new ReadableStream<Uint8Array>({
start(controller) {
const connectedAt = Date.now();
const startsAt = simStartsAt(connectedAt);
const send = (event: ExampleEvent) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
send(simSnapshot(connectedAt));
let tick = 0;
timer = setInterval(() => {
tick += 1;
simEvents(tick, Date.now(), startsAt).forEach(send);
}, SIM_EVERY_MS);
// The visitor left: end the stream, unless the runtime cancelled it first.
request.signal.addEventListener("abort", () => {
if (!open) return;
stop();
controller.close();
});
},
cancel: stop,
});
return new Response(body, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform" } });
}live={{ matches: simStart(now), source: { sse: { url: "/api/live-sim" } } }}A websocket source
The examples have no websocket server, so this one is a sketch, not copied from them. On your server (Node, with the ws package), send a snapshot to each new connection, then an upsert or remove whenever a match changes:
import { WebSocket, WebSocketServer } from "ws";
const server = new WebSocketServer({ port: 8080 });
// currentMatches() and onMatchChange() stand for your own store.
server.on("connection", (socket) => {
socket.send(JSON.stringify({ type: "snapshot", matches: currentMatches() }));
});
onMatchChange((match) => {
const message = JSON.stringify({ type: "upsert", matches: [match] });
for (const client of server.clients) if (client.readyState === WebSocket.OPEN) client.send(message);
});On the page, the source is plain data, so a server component can pass it:
live={{ matches, source: { websocket: { url: "wss://live.example.com/tournaments/harbour-open" } } }}Matches on your own pages
MatchRow draws one match the way the board does (see Pieces). Turn a MatchRecord into the LiveMatch it takes with liveMatch(record, boardSports(data)), or build a whole board with liveBoard(records, { sports, sport, now }). In a client component, useLiveMatches(options, { sports, sport, now }) follows a source and returns the board ({ live, next, recent, at, updatedAt }); outside React, connectLive(options, onMatches) does the same and returns a function that stops it.
Theme
The example apps keep the templates' own look; this and the next snippets show the props on their own.
theme={{ accent: "#e11d48", accentInk: "#ffffff", fontDisplay: "var(--sx-display)", fontBody: "'Inter', sans-serif" }}| Key | |
|---|---|
| accent, accentInk, accentSoft, second | Colours, #rrggbb (parseTheme also takes #rgb). They replace the tournament's palette before the template computes anything, so they reach the CSS variables (--accent, --accent-ink, --accent-soft, --second) and the colours templates derive in code. |
| fontDisplay, fontBody | A CSS font-family value: comma-separated names, quoted or bare, or var(--name). Each replaces the template font variable that plays that role. |
- Invalid entries are left out without a word, so a theme never breaks a page. Call
parseTheme(theme)to see the issues (invalid_color,invalid_font,unrecognized_keys). - The kit only names fonts; you load them (
@font-face,next/font), asvar(--sx-display)above assumes. - Each template names its display and body variables in
template.tokens(listed below). Where one variable plays both roles,fontDisplaywins when you set both. - Radius, spacing and other per-template CSS are not themeable in
0.x.
Blocks
Every template draws its page as blocks under shared keys:
hero, live, facts, sports, entry, prizes, rules, history, watch, sponsors, contact, documents, and the places for your views: bracket, results, schedule, athletes (BLOCK_KEYS; the last four are VIEW_KEYS, see Your own views).
blocks={{ hide: ["sponsors"], order: ["live", "sports"], replace: { sponsors: MySponsors } }}hideleaves blocks out.herocannot be hidden.orderdraws the listed blocks first, right after the hero, in that order; the rest keep the template's order. A key the template lacks is ignored, inhide,orderandreplacealike.replacedraws your component in place of the block, wherever the template has the key, in every phase and view. The kit cannot know when the template's own block would be empty, so your component decides what to show and may render nothing. Every replacement receives the sameBlockProps:{ data, view, phase, locale, totals, sport, template, theme }(datais the normalised tournament templates read,sportthe page's sport slug or null,templatethe template's id). Where the page renders on the server (Next), a replacement may be an async server component.visiblelimits any block, the template's or yours, to some pages and phases:visible: { bracket: { phases: ["live", "done"] }, athletes: { views: ["sport"] } }. A one-sport tournament's page counts as bothsummaryandsport. The hero ignores it.- A replacement takes over the block's anchor: where the block has one, the replacement sits in
<div id="<anchor>" data-block="<key>">, so the template's navigation and calls to action keep pointing at it. Links into the block's own content (articles, rows) leave with it. The board's own anchor,#truc-tiep, goes whenliveis replaced. - Navigation follows what is shown. A template's in-page navigation and its calls to action list only the blocks drawn: its own and replaced ones. Hidden blocks leave them.
- A block keeps its region's look whatever the order. A block of the template's main column stays in that column (one
<main>per page; a later run of column blocks gets the same look without the landmark), and a full-width block stays full width. - Replacements sit inside the template's frame and inherit its fonts and colours.
How templates map their blocks
What a key covers differs per template. The rules are the same everywhere:
- One key is one position on the page: a block, or several adjacent blocks that belong together. Hiding or replacing the key hides or replaces all of them.
herois the hero plus anything between it and the first block that carries its call to action (bands, ribbons, top bars, a crumb back to the summary on a sport page). Page chrome above the hero (a site or channel bar) is no block and always stays.liveis the live board plus results drawn right after it (this edition's champions) and a phase band beside it (countdown, today, thanks). Replacinglivedrops that band too.watchis when and where to come: how to watch, venues, a standalone schedule of key dates. Dates inside a fact strip stayfacts.- Prize money, ranking points and "what awaits" are
prizes; past editions, the roll of honour and the story arehistory; sponsor packages aresponsors. - A footer holding the contact and the documents is
contact;documentsis its own key only where a template lists the documents in their own section. - A section holding several kinds takes the key of its main content, and the template lacks the other key (a story that holds the prize line is
history; that template has noprizes). - A block that fits no key rides with the group before it (or the group after it, when the one before is
live). - Links to the other sports on a sport page ride with
sportswhen they are drawn right after the sport's own block. Otherwise they are navigation, not a block: they close the run of blocks they follow and leave only when every block of that run is hidden. - On print templates (
poster,thu-moi) the bar above the sheet isheroand the sheet's title and facts arefacts: hiding or replacingfactsthere removes the page's title.
Per-template blocks and anchors
Import templates/<id> and styles/<id>.css. Blocks are listed in the summary's order; where a sport page orders them differently, that order follows. A key not listed is one the template lacks.
| Id | Export | Blocks | Anchors | Font tokens (display, body) |
|---|---|---|---|---|
| championship | championship | hero facts live prizes sports entry rules history watch sponsors contact | prizes #cho-doi, sports #noi-dung, entry #tham-du, rules #dieu-le, history #truyen-thong, watch #theo-doi, sponsors #tai-tro, contact #tai-lieu | --font-ch-sans (both) |
| chien-dich | chienDich | hero live facts sports entry watch history sponsors contact | entry #dang-ky | --font-cd-display, --font-cd-body |
| chu-nha | chuNha | hero live sports watch facts history sponsors contact | none | --font-cn-guide (both) |
| dai-su | daiSu | hero live history sports entry watch sponsors contact (sport page: hero live sports history entry watch sponsors contact) | entry #ds-dang-ky | --font-ds-serif, --font-ds-sans |
| dang-cap | dangCap | hero live prizes facts sports rules entry history sponsors contact | entry #dc-entry, contact #dc-foot | --font-dc-sans (both) |
| di-san | diSan | hero live history facts sports entry sponsors contact (sport page: hero live sports history facts entry sponsors contact) | entry #ds-join | --font-ds-display, --font-ds-text |
| dieu-le | dieuLe | hero live facts rules | none | --font-dl-doc (both) |
| doi-nha | doiNha | hero live prizes sports history entry contact (sport page: hero live sports prizes history entry contact) | entry #dn-join | --font-dn-display, --font-dn-body |
| gay-quy | gayQuy | hero live sports history entry documents watch sponsors contact | entry #tham-gia, contact #lien-he | --font-gq-serif, --font-gq-sans |
| hanh-trinh | hanhTrinh | hero live facts history watch sports entry sponsors contact (sport page: hero live facts history sports watch entry sponsors contact) | entry #ht-len-duong | --font-ht-display, --font-ht-text |
| hoi-khoe | hoiKhoe | hero live sports watch entry history sponsors contact | none | --font-hk-round, --font-hk-sans |
| hoi-lang | hoiLang | hero live history sports prizes watch entry sponsors contact | entry #du-hoi | --font-hl-serif, --font-hl-text |
| ky-niem | kyNiem | hero live history prizes sports entry watch sponsors contact | none | --font-kn-serif, --font-kn-sans |
| linh-vat | linhVat | hero live sports watch history sponsors contact | none | --font-lv-display, --font-lv-body |
| mua-xuan | muaXuan | hero live sports watch history sponsors contact | none | --font-mx-title, --font-mx-text |
| ngay-hoi | ngayHoi | hero live facts sports entry history watch sponsors contact | entry #tham-gia | --font-nh-display, --font-nh-sans |
| phu-song | phuSong | hero live facts sports history sponsors entry watch contact | entry #dang-ky | --font-ps-display, --font-ps-body |
| poster | poster | hero live facts sports sponsors contact | none | --font-po-display (both) |
| resort | resort | hero live sports entry watch rules history sponsors contact | none | --font-rs-serif (both) |
| san-khau | sanKhau | hero live facts history sports sponsors entry watch contact (sport page: hero live facts sports history sponsors entry watch contact) | entry #dang-ky | --font-sk-display (both) |
| sinh-vien | sinhVien | hero live sports history sponsors contact | none | --font-sv-varsity, --font-sv-campus |
| street | street | hero live sports facts history sponsors contact | none | --font-st-wall, --font-st-zine |
| thong-bao | thongBao | hero live facts sports entry prizes watch history documents contact (sport page: hero live facts sports prizes entry watch history documents contact) | sports #tb-mon, entry #tb-dang-ky, prizes #tb-giai-thuong, watch #tb-moc, history #tb-cau-chuyen, contact #tb-lien-he | --font-tb-sans (both) |
| thu-moi | thuMoi | hero live facts sponsors | none | --font-tm-serif (both) |
| vinh-danh | vinhDanh | hero live facts sponsors sports history entry watch contact (sport page: hero live facts sports sponsors history entry watch contact) | entry #dang-ky | --font-vd-engrave, --font-vd-ui |
On thong-bao's sport pages the sports anchor is #tb-noi-dung. In a template without anchors nothing links to a block as a whole, so a replacement there has no wrapper.
Copy overrides
copy={{ register: "Sign up" }}- Where the keys are. A template's words are on its object:
championship.copy.viandchampionship.copy.en(each key an English name of the meaning, such asregisterorhowToWatch). The live board's areBOARD_WORDS.viandBOARD_WORDS.enfrom/core. Single words (strings) can be replaced; phrases (functions, built around a number or a name) cannot. copyis typed by the template. OnTournamentPageandcreateTournamentPageits keys areCopyKey<typeof template>: the template's single words plus the board's. An unknown key or a phrase key is a type error, and keys autocomplete. A template typed only asKitTemplate(picked at runtime) accepts any string key.CopyOverrides<T>is the prop's type.- Overrides apply per key, to every dictionary on the page that has that key: the template's own words and the live board's. Keys collide across dictionaries: a key both have (such as
today, a word in 12 templates and on the board, orfinished,live,liveNow) is replaced in both places on that page. copyhas no language of its own. It applies to the page'slocale, so pass the words of that language and keep one map per language in your app. The kit stores nothing between renders.- An override keeps the spaces the original word had at its edges (a word next to a number keeps its space), and is trimmed itself.
- Keys whose original has no letter (separators such as
/) and phrases are never overridden; at runtime, unknown keys are ignored. - The board's keys:
board,liveNow,today,seeAll,seeMore,upcoming,justFinished,updated,refreshing,live,since,finished,versus,beat,filter,all.MatchRowtakes the same keys in its owncopyprop. - The keys of the places for your views and of the frame's header (
VIEW_WORDS):bracket,results,schedule,athletes(their names in the navigation and the header),seeBracket,seeResults,seeSchedule,seeAthletes,open(the link rows),fullSchedule,fullResults,gold,silver,bronze,court,noTime,search,searchLabel,noMatch,overview,pages.
Your own views
Your platform draws its own bracket, results, schedule, athlete and match views. The kit does not draw them again: it gives them places on the tournament's page and in its frame, and the template's look (ADR 0021).
<TournamentPage
/* template, locale, data, view, now ... */
routes={{
summary: "/t/cup",
sport: (slug) => `/t/cup/${slug}`,
match: ({ ref }) => `/t/cup/matches/${ref}`,
bracket: ({ sport, stage }) => `/t/cup/bracket?sport=${sport ?? ""}${stage ? `&stage=${stage}` : ""}`,
}}
blocks={{
replace: { bracket: MyBracket },
visible: { bracket: { phases: ["live", "done"] } },
custom: [{ key: "gallery", label: { vi: "Hình ảnh", en: "Gallery" }, render: MyGallery, after: "sports" }],
}}
/>Places on the page. bracket, results, schedule and athletes sit right after the template's live board (after the hero in a template without one), in its main column when the board is in it, else in a column of the kit's own that takes the template's surface. Each is drawn only when it has something to draw:
| Key | Your view | Otherwise the kit draws |
|---|---|---|
| bracket | blocks.replace.bracket | a link row to routes.bracket, else to platform.bracketUrl (a sport's own first), while live and once done |
| results | blocks.replace.results | once done, the podium of each event from champions when a row gives silver or bronze, else a link row to routes.results or platform.resultsUrl |
| schedule | blocks.replace.schedule | the schedule from schedule.matches (by day, paged, the page's sport only), else a link row to routes.schedule or platform.scheduleUrl before and during the tournament |
| athletes | blocks.replace.athletes | a searchable list from the sports' athletes, else a link row to routes.athletes |
A link to your page (routes) opens in the same tab; a platform URL in a new one. The phase limits above are the defaults' own: your view is drawn in every phase unless visible says otherwise. The places move with order, leave with hide, and keep the anchors #kit-bracket, #kit-results, #kit-schedule, #kit-athletes; a template's in-page navigation lists them only when they draw something.
Blocks of your own. blocks.custom adds blocks the templates do not have: { key, label: { vi, en }, render?, href?, after? }. The key is lower-case letters, digits and hyphens, and not a built-in key; render draws it, or href draws a link row in the same tab; after names the key it follows (left out, it sits with the places above). A block with a taken key, or with neither render nor href, is left out. Its key works in order, hide and visible, and it joins the navigation under its label, at #kit-<key>.
Builders. Every builder in routes takes one parameter object: sport (a slug) and whatever you pass (stage, event); match also takes ref. The kit never parses or rebuilds the URL you return, so your query parameters stay. createTournamentPage's routes returns the same object.
Matches. With routes.match, the live board's cards and rows (and MatchRow from liveMatch) link to your match page in the same tab; without it they keep match/1.url in a new tab. The kit calls the builder on the server for every match it knows; for a match that arrives later in the browser it calls it once with placeholders and fills the ref in, so put ref and sport in the URL as given (otherwise a later match keeps its url).
Your pages in the frame. TemplateFrame takes the same routes, plus view (the sport the page belongs to), current ("summary", "sport", a view key, "match", or one of your links' keys), params (passed to every builder, so the header's links keep them) and links ({ key, label, href }[], your own pages). With routes it draws a header in the template's fonts and colours: back to the summary (and the sport), then your schedule, bracket, results and athletes pages whose builders are given, then links, the current one marked aria-current="page". Without routes there is no header.
<TemplateFrame template={championship} locale="en" data={data} now={now} routes={routes} view={{ kind: "sport", slug: "badminton" }} current="bracket" params={{ stage: "3" }}>
<MyBracketPage />
</TemplateFrame>Dialogs and other portals. TournamentPage and TemplateFrame add a portal container after the template once mounted ([data-kit-portal]). In a client component, useFramePortal() returns it (null before mount): render dialogs, selects and drawers there (createPortal(ui, container), or your library's container prop) and they wear the template's font and variables. rootVariables also lays the variables on <html> while mounted, for UI that portals to document.body.
The variables. Every template sets these on its root, and the portal container copies them. Read them in your views to wear the template's look; they are a published contract, and renaming one is a breaking change.
| Variable | Holds |
|---|---|
| --kit-font-display, --kit-font-body, --kit-font-tabular | Display, body and score faces |
| --kit-text, --kit-text-muted | Text and secondary text |
| --kit-accent, --kit-accent-ink, --kit-accent-hover, --kit-accent-soft | The tournament accent, text on it, its hover, a tint |
| --kit-surface, --kit-surface-raised, --kit-surface-sunken, --kit-scheme | What the template's text sits on, cards, wells; light or dark |
| --kit-border, --kit-ring, --kit-radius | Lines, focus ring, base corner radius |
| --kit-live, --kit-winner, --kit-warning, --kit-danger | State colours |
| --kit-gold, --kit-silver, --kit-bronze | Medals |
| --kit-frame-width | The template's content width |
KIT_VARIABLES in /core lists them. shadcn: import "@impetusvn/tournament-kit/styles/shadcn.css" maps them onto shadcn's names (--background, --foreground, --primary, --card, --popover, --muted, --border, --input, --ring, --radius and their -foregrounds) inside the frame and the portal container. It needs Tailwind's @theme inline, as shadcn sets it up. --accent stays the tournament's accent inside the frame (templates read it).
Your CSS beside the kit's. The kit's rules are unlayered and scoped to its own classes and data- attributes: they beat a layered base such as Tailwind's preflight inside the kit and touch nothing outside it. A template's element rules (championship sets h1 to h3 white and gives p a margin, thong-bao colours links) stop at your own content: a frame's children and what blocks.replace and blocks.custom draw sit in an element marked data-kit-host, so your preflight and utilities reach your markup there.
Defaults from your data. The results and athletes defaults read two optional keys on a sport: medals beside a champion, champions: [{ event, name, unit?, silver?: { name, unit? }, bronze?: [{ name, unit? }] }] (at most two bronze), and a roster, athletes: [{ id?, name, unit? }] (at most 500). portfolio4u's own studio does not take these keys yet.
Pieces for your own pages
Templates draw the summary, the sport pages and the live board. Detail pages (a match, an athlete, a bracket) are yours, built from pieces that take the chosen template's look:
<TemplateFrame template={championship} locale="en" data={data} now={now}>
<Section title="Quarter-finals">
<Card>
<ol>
<MatchRow match={liveMatch(record, boardSports(data))} locale="en" now={now} />
</ol>
</Card>
<AthleteChip name="Nguyễn Minh Anh" unit="Hải Châu" href="/athletes/minh-anh" />
</Section>
</TemplateFrame>| Piece | Props | |
|---|---|---|
| TemplateFrame | template, locale, data, now, theme?, phase?, presentation?, routes?, view?, current?, params?, links?, rootVariables?, children | The template's root around your content: its fonts, page background, the tournament's colours and the template's variables, with a header when you give routes (see Your own views). theme and presentation are checked as on TournamentPage, and a locale the document contradicts throws a RangeError. |
| Section | title, children | A titled part of the page; the title is an <h2> in the template's display font. |
| Card | children | A surface tinted from the text colour, so it sits on light and dark templates alike. |
| MatchRow | match: LiveMatch, locale, copy?, now?, showSport? | One match as the board draws it: start time, sides (with the score once started), where it is played. It renders an <li>, so put rows in an <ol> or <ul>. With now, a match starting that day shows its clock only. |
| AthleteChip | name, unit?, href? | A name and unit as a pill; a link when you give href. |
- The pieces' CSS and the board's (
live.module.css) come through the JavaScript import of/react; there is no stylesheet to import for them. - The pieces take the frame's font and text colour and mark emphasis with the tournament's accent. Inside a frame, a template's own element rules can reach your content too:
thong-baocolours links,championshipandngay-hoistyleh1toh3. - The example above is a sketch; the examples render template pages only.
Languages and formatting
- A page has exactly one language,
vioren;localeis required wherever text is produced, and a missing one is a type error. - The format functions in
/coretake the locale first:money(locale, vnd)("2 tỉ đồng", "VND 2 billion"),slotCount(locale, n),dateRange(locale, start, end?),shortDate,dayMonth,weekday,num,phaseName(locale, phase),tierName(locale, tier), andmatchTime(locale, ms, at?)for match times. - They use no
Intl, so server and browser write the same text. Times are Vietnam time (UTC+7). pageMeta(data, locale, view?)gives the title and description both adapters use.
Versions (0.x)
- The kit follows semver with the
0.xrule: a minor release (0.1 to 0.2) may break your build or change a page; a patch release never does.^0.1.0already stays below 0.2.0. - Read
CHANGELOG.md(shipped in the package) before moving to the next minor. - A template fix reaches you only when you upgrade.
- Everything exported is the contract:
TournamentPageprops, block keys and anchors, theme keys, the pieces, the live source and its events.tournament/2andmatch/1only gain optional keys; an incompatible change would betournament/3ormatch/2. - The package is MIT on npmjs since 0.3.2; the versions before it were private on GitHub Packages and stay there.
