Skip to content

Commit 34e437e

Browse files
madiedgarclaude
andauthored
feat(site): language support and language canon pages [CORE-1929] [CORE-1930] (#199)
* feat(site): language support and language canon pages [CORE-1929] [CORE-1930] Two pages the published datasets need the website to answer for, plus the generated data that keeps the site's claims and the datasets' claims from drifting apart. CORE-1929 — /languages lists every human language Legesher ships a Python language pack for, and the maturity stamp each one carries. All 51 are experimental, and the page says so as a sentence rather than as a table a reader has to total up: presenting a three-rung ladder while every language sits on the bottom rung invites the assumption that some of them do not. The ladder is explained by what each stage asserts and what it withholds, held to the wording the packs themselves document and the dataset card renders. The promotion criteria are described as still being defined with the language communities, because they are — no threshold is stated, since none has been ratified and a number invented for the website would become the number people hold us to. CORE-1930 — /canon covers the vocabulary dataset itself. Two similarly named datasets were published days apart, so the first thing on the page is the difference between them: the corpus is the evidence, the canon is the decision. Both artifacts are linked and each is labelled for who it is for — the canon for developers, the corpus for researchers — so the choice does not require opening two dataset cards. The distinction is worded identically to the dataset card's own answer to the same question; one distinction, stated once. Tier state is generated, not typed. `npm run sync:languages` derives src/data/language-packs.json from the language registry and the pack export's own manifest — the same two inputs the published dataset is built from — and fails if the two disagree about which locales exist or how they are spread across the ladder. Every count and every stamp on both pages reads from that file, so the site cannot advertise a review standard the packs no longer hold. The parquet files are deliberately not read: everything rendered is already in the manifest, and a parquet dependency in a static site build would re-derive numbers the exporter has reconciled. Figures: 51 languages, 255 pack files, 46,512 vocabulary rows across 5 interpreter versions, Apache-2.0. Reconciled against the export rather than quoted — an earlier 52 came from counting a version-index file as a locale. Both pages avoid the bg-card and text-muted-foreground utilities. Those resolve through hsl(var(--card)), which globals.css defines as a hex, so the browser discards the value and paints nothing; the tiles use brand colours directly. Verified: build green, both routes prerendered, sitemap carries both with correct lastmod, canonical and social tags present, 51 rows render with lang and dir set per language so endonyms are announced and laid out correctly, the wide tables scroll inside their own containers rather than the document, no internal identifiers in rendered output, and the sync script is idempotent and fails closed on a stale input pairing. The canon dataset URL resolves once that dataset is published; the site content is intended to land ahead of it. Signed-off-by: Madi (Pfaff) Edgar <madiedgar@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(site): review follow-ups on the language pages [CORE-1929] [CORE-1930] Copy that would have gone stale, a cross-check that rejected a valid manifest, and accessibility gaps on the two tables. Claims that outlive their moment. Three paragraphs asserted the current state of the ladder as flat prose — "nothing has been promoted", "no native speaker has signed off", "no criterion has been exercised". A promotion arrives as a changed tier in the generated data with no prose edit attached, so those sentences would have gone on denying it directly above a table showing otherwise. They are now gated on the tier data, with a tier-agnostic alternative in their place once the languages spread across rungs. Two present-tense publication claims are reworded to survive being read before the canon dataset is published, without a dated caveat that would need removing after. The link to /canon now reads as a page reference rather than as the dataset itself, matching the parallel link in the other direction. The tier cross-check compared undefined to zero, so a manifest declaring a rung explicitly as zero was rejected with the self-contradicting message "registry 0, manifest 0". Both sides now default to 0. The script also validates the shape of each input before using it — a registry with no languages object, a manifest with no locales array or counts, and a manifest listing a locale twice each fail with a sentence naming the field rather than a TypeError. The vocabulary-row count was labelled "terms" on one page and "rows" on the other for the same number. It is rows: roughly 184 source terms per language, resolved across five interpreter versions. Labelled consistently now, so the figure cannot be read as a per-language vocabulary size five times its real value. Accessibility on both tables. Each scroll container is a focusable, named region — it holds no focusable child, so a keyboard-only reader previously could not scroll it at all. Column headers carry scope, and the first cell of each row is a row header, which is what lets a screen reader announce "Telugu, Stage, experimental" instead of reading three unattached cells. Row headers get the cell padding Tailwind Typography applies to td but not th. The lang attribute now carries the registry's BCP 47 tag rather than its key. Five disagree — zh is zh-Hans, no is nb — and it is the tag that tells a screen reader which voice to use. The visible Code column keeps the key, which is the pack identifier. Arabic-script endonyms get the font-arabic utility globals.css already defines; the body font carries no Arabic glyphs, so those three cells were falling back to whatever the reader's system happened to have. Script is detected from the endonym rather than from a list of locales, since the registry records no script field. Also: the canon page's meta description derives its counts instead of hardcoding them, the reviewed rung is described in one wording rather than two, the spread sentence handles an empty tier set explicitly instead of reaching a dead branch, and a sentence reconciles the corpus carrying endorsements with no pack having cleared review — an endorsement attaches to one rendering, the stamp is about the pack as a whole. Verified: build green; both routes prerender; 51 rows with the five corrected language tags and three Arabic-script cells; the verbatim corpus-and-canon sentence and the 51 / 255 / 46,512 figures unchanged; no duplicate ids and no dangling aria-labelledby on either page; no internal identifiers in the built output. The gated paragraphs were exercised by building against a doctored multi-tier data file — the all-experimental copy drops out, the alternative renders, and the spread sentence reads "Of 51 languages: 48 experimental, 2 reviewed and 1 official" — then the real data was restored and confirmed byte-identical on re-sync. The sync script accepts a manifest declaring zero tiers and rejects a duplicated locale, a missing locales array, missing counts, and a file that is not the registry. Signed-off-by: Madi (Pfaff) Edgar <madiedgar@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Signed-off-by: Madi (Pfaff) Edgar <madiedgar@users.noreply.github.com> Co-authored-by: Madi (Pfaff) Edgar <madiedgar@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1f21fd8 commit 34e437e

11 files changed

Lines changed: 1237 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828

2929
### Added
3030

31+
- **Language support page** at [/languages](https://www.legesher.io/languages) listing every human language
32+
Legesher ships a Python language pack for, the maturity stamp each one carries, and what moves a language
33+
up the ladder. Every language is `experimental` today and the page says so plainly rather than presenting
34+
a ladder and letting a reader assume the languages are spread across it. Promotion criteria are described
35+
as still being defined with the language communities, because they are — no threshold is implied.
36+
(CORE-1929)
37+
- **Language canon page** at [/canon](https://www.legesher.io/canon) covering the dataset of vocabulary
38+
Legesher actually ships, and answering "how is this different from the corpus?" at the top of the page
39+
rather than leaving a reader to compare two dataset cards. Links both artifacts, each labelled for who it
40+
is for: the canon for developers, the corpus for researchers. (CORE-1930)
41+
- Language and tier data is **generated, not hand-written**. `npm run sync:languages` derives
42+
`src/data/language-packs.json` from the language registry and the pack export's own manifest — the same
43+
two inputs the published dataset is built from — and fails if the two disagree about which locales exist
44+
or how they are distributed across the ladder. The counts and stamps rendered on both pages are read from
45+
that file, so the site cannot advertise a review standard the packs no longer hold. (CORE-1929)
3146
- **Campaign attribution on newsletter subscriptions.** When someone reaches the site through a
3247
link carrying `utm_source` / `utm_campaign`, those values are recorded with their subscription
3348
as `last_touch_channel` and `last_touch_release`, so an announcement can be credited with the

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"build": "astro build",
2121
"preview": "astro preview",
2222
"astro": "astro",
23-
"contributors:generate": "all-contributors generate"
23+
"contributors:generate": "all-contributors generate",
24+
"sync:languages": "node scripts/sync-language-data.mjs"
2425
},
2526
"dependencies": {
2627
"@astrojs/mdx": "^6.0.3",

scripts/sync-language-data.mjs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env node
2+
// Regenerates src/data/language-packs.json — the site's copy of which human
3+
// languages ship a Legesher language pack, and what tier each one carries.
4+
//
5+
// WHY A GENERATED FILE, AND NOT A HAND-WRITTEN LIST
6+
//
7+
// The tier stamped on a language is a claim about how far that language has
8+
// been reviewed, and the same claim is stamped on the published dataset. A
9+
// hand-typed list on the website is how those two silently stop agreeing: the
10+
// dataset re-exports, the site does not, and the site is then advertising a
11+
// review standard the packs no longer hold. So the site's copy is derived,
12+
// once, from the artifacts that decide the answer, and the derivation is
13+
// re-runnable.
14+
//
15+
// INPUTS — both JSON, both produced by the packs monorepo, never edited here:
16+
//
17+
// 1. The language registry (`libs/i18n/legesher_i18n/languages.json`). Itself
18+
// generated from the translations database; carries each locale's name,
19+
// endonym, direction, and `status` — the tier.
20+
// 2. The canon export manifest (`manifest.json`, written beside the parquet
21+
// files by the pack exporter). Carries the counts that actually ship:
22+
// locales, pack files, vocabulary rows, interpreter versions, and the
23+
// tier spread.
24+
//
25+
// Two inputs rather than one because neither alone is sufficient: the registry
26+
// knows the languages, the manifest knows what was published about them. They
27+
// are cross-checked against each other below, so a stale pairing fails here
28+
// instead of rendering a wrong number.
29+
//
30+
// The parquet files are deliberately not read. Everything the site renders is
31+
// in the manifest, and adding a parquet dependency to a static site build to
32+
// re-derive numbers the exporter already reconciled would buy nothing.
33+
//
34+
// Both paths are required rather than guessed, because the wrong pairing is
35+
// worse than no run at all:
36+
//
37+
// npm run sync:languages -- \
38+
// --registry <monorepo>/libs/i18n/legesher_i18n/languages.json \
39+
// --manifest <monorepo>/build/hf-packs/manifest.json
40+
41+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
42+
import { dirname, resolve } from 'node:path';
43+
import { fileURLToPath } from 'node:url';
44+
45+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
46+
const OUTPUT = resolve(REPO_ROOT, 'src/data/language-packs.json');
47+
48+
const USAGE =
49+
'Usage: npm run sync:languages -- \\\n' +
50+
' --registry <monorepo>/libs/i18n/legesher_i18n/languages.json \\\n' +
51+
' --manifest <monorepo>/build/hf-packs/manifest.json';
52+
53+
function parseArgs(argv) {
54+
const args = { registry: null, manifest: null };
55+
for (let i = 0; i < argv.length; i += 2) {
56+
const flag = argv[i]?.replace(/^--/, '');
57+
if (!(flag in args)) {
58+
throw new Error(`Unknown argument "${argv[i]}".\n${USAGE}`);
59+
}
60+
if (argv[i + 1] === undefined) throw new Error(`--${flag} needs a path.\n${USAGE}`);
61+
args[flag] = argv[i + 1];
62+
}
63+
for (const [flag, value] of Object.entries(args)) {
64+
if (!value) throw new Error(`--${flag} is required.\n${USAGE}`);
65+
}
66+
return args;
67+
}
68+
69+
function readJson(label, path) {
70+
const absolute = resolve(REPO_ROOT, path);
71+
try {
72+
return JSON.parse(readFileSync(absolute, 'utf8'));
73+
} catch (error) {
74+
throw new Error(
75+
`Could not read the ${label} at ${absolute}.\n` +
76+
`Point --${label} at a checkout of the packs monorepo` +
77+
(label === 'manifest' ? ', running the pack export first if needed' : '') +
78+
`.\n ${error.message}\n${USAGE}`
79+
);
80+
}
81+
}
82+
83+
/**
84+
* Fail with a sentence naming the field, not a stack trace about `undefined`.
85+
*
86+
* Both inputs are generated files from another repository, so the realistic
87+
* failure is being handed the wrong file rather than a corrupt one — and the
88+
* useful error says which shape was expected.
89+
*/
90+
function requireShape(condition, message) {
91+
if (!condition) throw new Error(message);
92+
}
93+
94+
function validateInputs(registryFile, manifest) {
95+
requireShape(
96+
registryFile && typeof registryFile === 'object' && registryFile.languages,
97+
'The registry file has no `languages` object. Expected the generated ' +
98+
'languages.json from the packs monorepo, not another JSON file.'
99+
);
100+
requireShape(
101+
Array.isArray(manifest?.locales),
102+
'The manifest has no `locales` array. Expected manifest.json from a pack ' +
103+
'export, not another JSON file.'
104+
);
105+
requireShape(
106+
Number.isInteger(manifest?.counts?.locales),
107+
'The manifest has no `counts.locales` integer. Expected manifest.json from a pack export.'
108+
);
109+
requireShape(
110+
new Set(manifest.locales).size === manifest.locales.length,
111+
'The manifest lists the same locale more than once, so its counts cannot be trusted.'
112+
);
113+
}
114+
115+
/** Fail loudly when the two inputs describe different exports. */
116+
function crossCheck(registry, manifest) {
117+
const registryCodes = Object.keys(registry).sort();
118+
const manifestCodes = [...manifest.locales].sort();
119+
120+
const onlyInRegistry = registryCodes.filter((c) => !manifestCodes.includes(c));
121+
const onlyInManifest = manifestCodes.filter((c) => !registryCodes.includes(c));
122+
if (onlyInRegistry.length || onlyInManifest.length) {
123+
throw new Error(
124+
'Registry and export manifest name different locales — one of them is stale.\n' +
125+
` only in registry: ${onlyInRegistry.join(', ') || '(none)'}\n` +
126+
` only in manifest: ${onlyInManifest.join(', ') || '(none)'}`
127+
);
128+
}
129+
130+
if (manifest.counts.locales !== registryCodes.length) {
131+
throw new Error(
132+
`Manifest counts ${manifest.counts.locales} locales but names ${registryCodes.length}.`
133+
);
134+
}
135+
136+
// The tier a language carries is read from the registry below, while the
137+
// headline "all of them are experimental" line is read from the manifest.
138+
// Proving the two agree here is what lets the pages trust either one.
139+
const derived = {};
140+
for (const code of registryCodes) {
141+
const tier = registry[code].status;
142+
if (!tier) throw new Error(`Language "${code}" carries no status in the registry.`);
143+
derived[tier] = (derived[tier] ?? 0) + 1;
144+
}
145+
const declared = manifest.tier_counts ?? {};
146+
const tiers = [...new Set([...Object.keys(derived), ...Object.keys(declared)])].sort();
147+
// Both sides default to 0 before comparing. A manifest is free to declare a
148+
// rung explicitly as zero, and comparing `undefined` to `0` would reject it
149+
// with the self-contradicting message "registry 0, manifest 0".
150+
const disagreement = tiers.filter((tier) => (derived[tier] ?? 0) !== (declared[tier] ?? 0));
151+
if (disagreement.length) {
152+
throw new Error(
153+
'Tier spread differs between the registry and the export manifest:\n' +
154+
disagreement
155+
.map((t) => ` ${t}: registry ${derived[t] ?? 0}, manifest ${declared[t] ?? 0}`)
156+
.join('\n')
157+
);
158+
}
159+
160+
return derived;
161+
}
162+
163+
function main() {
164+
const args = parseArgs(process.argv.slice(2));
165+
const registryFile = readJson('registry', args.registry);
166+
const manifest = readJson('manifest', args.manifest);
167+
validateInputs(registryFile, manifest);
168+
169+
const registry = registryFile.languages;
170+
const tierCounts = crossCheck(registry, manifest);
171+
172+
const languages = Object.entries(registry)
173+
.map(([code, meta]) => ({
174+
code,
175+
// The registry key and the BCP 47 tag are not the same string for every
176+
// language — `zh` is `zh-Hans`, `no` is `nb` — and they answer different
177+
// questions. The key identifies the pack; the tag tells a browser or a
178+
// screen reader which language the text is in. Both are carried so the
179+
// page can render the key and mark up the tag.
180+
bcp47: meta.bcp47 || code,
181+
name: meta.name,
182+
native: meta.native,
183+
rtl: Boolean(meta.rtl),
184+
tier: meta.status,
185+
}))
186+
// Sorted by English name so the rendered table needs no sort of its own.
187+
.sort((a, b) => a.name.localeCompare(b.name, 'en'));
188+
189+
const data = {
190+
_comment:
191+
'GENERATED FILE — do not edit by hand. Regenerate with `npm run sync:languages`. ' +
192+
'Source: the language registry and the canon export manifest in the packs monorepo.',
193+
artifact: manifest.artifact,
194+
layer: manifest.layer,
195+
license: manifest.declared_license,
196+
counts: {
197+
locales: manifest.counts.locales,
198+
packFiles: manifest.counts.pack_files,
199+
vocabularyRows: manifest.counts.vocabulary_rows,
200+
pythonVersions: manifest.counts.python_versions,
201+
},
202+
tierCounts,
203+
languages,
204+
};
205+
206+
mkdirSync(dirname(OUTPUT), { recursive: true });
207+
writeFileSync(OUTPUT, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
208+
209+
const spread = Object.entries(tierCounts)
210+
.map(([tier, count]) => `${count} ${tier}`)
211+
.join(', ');
212+
console.log(
213+
`Wrote ${OUTPUT}\n` +
214+
` ${data.counts.locales} languages (${spread}), ` +
215+
`${data.counts.packFiles} pack files, ` +
216+
`${data.counts.vocabularyRows.toLocaleString('en-US')} rows`
217+
);
218+
}
219+
220+
try {
221+
main();
222+
} catch (error) {
223+
console.error(error.message);
224+
process.exit(1);
225+
}

src/components/Footer.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ const currentYear = new Date().getFullYear();
4646
</a>
4747
</div>
4848
<div class="text-sm text-brand-gray flex flex-col md:items-end gap-1">
49-
<div class="flex items-center gap-4">
49+
<div class="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 md:justify-end">
50+
<a href="/languages" class="hover:text-brand-cyan transition-colors duration-300">Language Support</a>
51+
<a href="/canon" class="hover:text-brand-cyan transition-colors duration-300">Language Canon</a>
5052
<a href="/privacy" class="hover:text-brand-cyan transition-colors duration-300">Privacy Policy</a>
5153
<a href="/terms" class="hover:text-brand-cyan transition-colors duration-300">Terms of Service</a>
5254
</div>

src/components/Header.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { LINKS } from '@/lib/links';
55
// Single source for both the desktop nav and the mobile menu so the two can't
66
// drift apart as links are added.
77
const navLinks = [
8+
{ href: '/languages', label: 'Languages' },
89
{ href: LINKS.github, label: 'Roadmap' },
910
{ href: LINKS.slack, label: 'Community' },
1011
{ href: LINKS.docs, label: 'Docs' },

0 commit comments

Comments
 (0)