Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7efc97d0dde0b2a3e729ccf9
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
scripts/check-courses.mjs
// Checks course files for shape and common mistakes before they ship.
// Usage: node scripts/check-courses.mjs [es fr ...] [--coverage]
// --coverage also lists, per lesson, sentence words that no lesson so far has taught.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "functions", "courses");
const args = process.argv.slice(2);
const coverage = args.includes("--coverage");
const ids = args.filter((a) => !a.startsWith("--"));
const UNITS = { es: 8, fr: 8, de: 3, it: 3, pt: 3 };
const list = ids.length ? ids : Object.keys(UNITS).filter((id) => fs.existsSync(path.join(dir, `${id}.json`)));
const norm = (s) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^\p{L}\p{N}' ]/gu, " ").replace(/\s+/g, " ").trim();
const tokens = (s) => s.toLowerCase().replace(/[¿¡?!.,;:«»"“”()]/g, " ").split(/[\s ]+/).filter(Boolean);
let failed = false;
for (const id of list) {
const errors = [];
const warns = [];
let course;
try {
course = JSON.parse(fs.readFileSync(path.join(dir, `${id}.json`), "utf8"));
} catch (e) {
console.log(`${id}: cannot read: ${e.message}`);
failed = true;
continue;
}
const str = (v, where) => { if (typeof v !== "string" || !v.trim() || v !== v.trim() || /\s{2,}/.test(v.replace(/ /g, "x"))) errors.push(`${where}: needs a trimmed, non-empty string, got ${JSON.stringify(v)}`); };
const strs = (v, where) => { if (v === undefined) return; if (!Array.isArray(v)) return errors.push(`${where}: must be a list`); v.forEach((x, i) => str(x, `${where}[${i}]`)); };
for (const k of ["id", "name", "native", "speech", "level", "about"]) str(course[k], k);
if (course.id !== id) errors.push(`id is ${course.id}, file is ${id}`);
if (!Array.isArray(course.units) || course.units.length !== UNITS[id]) errors.push(`expected ${UNITS[id]} units, found ${course.units?.length}`);
const allText = JSON.stringify(course);
if (/\p{Extended_Pictographic}/u.test(allText)) errors.push("contains emoji");
if (/—/.test(allText)) errors.push("contains an em dash");
const seenWords = new Map();
const seenSentences = new Map();
const taught = new Set();
let words = 0, sentences = 0;
(course.units || []).forEach((u, ui) => {
const U = `unit ${ui + 1}`;
if (u.n !== ui + 1) errors.push(`${U}: n is ${u.n}`);
str(u.title, `${U}.title`); str(u.about, `${U}.about`);
if (!u.tip) errors.push(`${U}: no tip`);
else {
str(u.tip.title, `${U}.tip.title`);
if (!Array.isArray(u.tip.body) || u.tip.body.length < 2 || u.tip.body.length > 5) errors.push(`${U}.tip.body: 2 to 5 paragraphs`);
else u.tip.body.forEach((p, i) => str(p, `${U}.tip.body[${i}]`));
if (!Array.isArray(u.tip.examples) || u.tip.examples.length < 3 || u.tip.examples.length > 6) errors.push(`${U}.tip.examples: 3 to 6 pairs`);
else u.tip.examples.forEach((p, i) => { if (!Array.isArray(p) || p.length !== 2) errors.push(`${U}.tip.examples[${i}]: [target, English]`); else p.forEach((x) => str(x, `${U}.tip.examples[${i}]`)); });
}
if (!Array.isArray(u.lessons) || u.lessons.length !== 5) errors.push(`${U}: expected 5 lessons`);
(u.lessons || []).forEach((l, li) => {
const L = `${ui + 1}.${li + 1}`;
if (l.n !== li + 1) errors.push(`lesson ${L}: n is ${l.n}`);
str(l.title, `lesson ${L}.title`);
if (!Array.isArray(l.words) || l.words.length < 5 || l.words.length > 7) errors.push(`lesson ${L}: 5 to 7 words (found ${l.words?.length})`);
if (!Array.isArray(l.sentences) || l.sentences.length < 7 || l.sentences.length > 9) errors.push(`lesson ${L}: 7 to 9 sentences (found ${l.sentences?.length})`);
const glosses = new Set();
(l.words || []).forEach((w, wi) => {
const W = `lesson ${L} word ${wi + 1} (${w.t})`;
str(w.t, `${W}.t`); str(w.e, `${W}.e`); strs(w.ea, `${W}.ea`);
words++;
const g = norm(w.e || "");
if (glosses.has(g)) errors.push(`${W}: English "${w.e}" repeats in this lesson`);
glosses.add(g);
const k = norm(w.t || "");
if (seenWords.has(k)) warns.push(`${W}: also taught in lesson ${seenWords.get(k)}`);
else seenWords.set(k, L);
for (const t of tokens(w.t || "")) taught.add(t);
});
const missing = new Set();
(l.sentences || []).forEach((s, si) => {
const S = `lesson ${L} sentence ${si + 1} (${s.t})`;
str(s.t, `${S}.t`); str(s.e, `${S}.e`); strs(s.ta, `${S}.ta`); strs(s.ea, `${S}.ea`);
sentences++;
const n = tokens(s.t || "").length;
if (n < 2 || n > 10) errors.push(`${S}: ${n} words (keep 2 to 10)`);
const k = norm(s.t || "");
if (seenSentences.has(k)) errors.push(`${S}: duplicate of lesson ${seenSentences.get(k)}`);
else seenSentences.set(k, L);
for (const alt of s.ta || []) if (norm(alt) === k) warns.push(`${S}: ta repeats t ("${alt}")`);
for (const alt of s.ea || []) if (norm(alt) === norm(s.e || "")) warns.push(`${S}: ea repeats e ("${alt}")`);
const all = [s.t, ...(s.ta || [])].filter(Boolean);
if (id === "es") for (const x of all) {
if (x.includes("?") && !x.includes("¿")) errors.push(`${S}: "?" without "¿" in "${x}"`);
if (x.includes("!") && !x.includes("¡")) errors.push(`${S}: "!" without "¡" in "${x}"`);
}
if (id === "fr") for (const x of all) {
if (/[^ ][?!:;]/.test(x)) warns.push(`${S}: French uses a no-break space (\\u00a0) before ? ! : ; in "${x}"`);
}
for (const t of tokens(s.t || "")) if (!taught.has(t)) missing.add(t);
});
if (coverage && missing.size) console.log(` ${id} ${L} not yet taught: ${[...missing].join(", ")}`);
});
});
const exercises = (course.units || []).flatMap((u) => u.lessons || []).map((l) => Math.min(15, 4 + 1 + (l.sentences?.length || 0) + ((l.words?.length || 0) >= 6 ? 1 : 0)));
console.log(`${id}: ${course.units?.length} units, ${exercises.length} lessons, ${words} words, ${sentences} sentences, ${Math.min(...exercises)} to ${Math.max(...exercises)} exercises per lesson`);
for (const w of warns) console.log(` warn: ${w}`);
for (const e of errors) console.log(` error: ${e}`);
if (errors.length) failed = true;
}
process.exit(failed ? 1 : 0);
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync