We’d like to add support for dynamic alphabet chart generation using worldalphabets.
This allows Launchpad to automatically build alphabet charts for many languages and letter orders (alphabetical, lower/upper case, frequency) rather than maintaining static en‑only grids.
The implementation should integrate into the existing alphabetChart template (colour schemes, casing, commands, fonts, etc.) so all downstream processes (board‑to‑pdf, analytics, OBF export) keep working without breaking changes.
Why
- Broaden coverage: auto‑generate charts for many scripts quickly.
- Customisable: keep all existing knobs (colour scheme, casing, font, commands).
- Maintainable: avoid huge if/else logic; contributors can tweak small i18n files instead.
- Consistent: same JSON shape and template pipeline → PDFs/OBFs/analytics just work.
- Internationalised: command labels (Delete, Space, Start Again) localised per language with a clean fallback to English.
- Explicit scope: a language allow‑list controls which codes we officially generate.
Deliverables
- Shared adapter →
packages/templates/src/shared/worldalphabets-adapter.ts
Wraps worldalphabets (letters + direction).
- Shared util →
packages/templates/src/shared/grid-utils.ts
Auto grid sizing + toGrid() with RTL support.
- Dynamic factory →
packages/templates/src/dynamic-alphabet-chart.ts
Clones alphabetChart and injects dynamic pages + any missing buttons.
- Classifies tokens (number, vowel, consonant, punctuation, command) → reuses existing button styles.
- Fallback: unknown letters styled as consonants.
- Options:
{ lang, order, includeNumbers, includePunctuation, includeCommands, rows, columns }.
- i18n: pulls command label defaults from
i18n/command-labels.ts.
- i18n module (labels) →
packages/templates/src/i18n/command-labels.ts
Provides translated defaults for command labels (delete-text, start-again-text, space-text, and optional start-again-new-line). Missing languages fall back to English.
- Language allow‑list →
packages/templates/src/i18n/languages.ts
SUPPORTED_LANG_CODES (initial set), plus room for optional per‑language defaults.
- GraphQL mutation →
generateAlphabetBoard(options) (additive).
- CLI subcommand →
launchpad gen:alphabet … (additive).
- (Optional) Pre‑generation script → cache
.obf for allow‑listed languages for review/demos.
PR Skeleton
Title
feat: dynamic multilingual alphabet chart + i18n labels + language allow‑list (closes #34)
Description
This PR introduces a dynamic alphabet chart powered by worldalphabets.
It extends the existing alphabetChart template, generating a page layout and ensuring button definitions dynamically for any supported language & order.
It also adds an i18n layer for command labels and a language allow‑list for pre‑gen/dev UX.
No breaking changes to current templates.
File tree (new/changed)
packages/templates/src/shared/worldalphabets-adapter.ts
packages/templates/src/shared/grid-utils.ts
packages/templates/src/i18n/command-labels.ts
packages/templates/src/i18n/languages.ts
packages/templates/src/dynamic-alphabet-chart.ts
packages/graphql/resolvers/alphabet.ts # NEW resolver (generateAlphabetBoard) – stub
packages/cli/commands/gen-alphabet.ts # NEW CLI subcommand – stub
packages/templates/scripts/prebuild-alphabets.ts # OPTIONAL pre-gen script – stub
Code
1) packages/templates/src/shared/worldalphabets-adapter.ts
import {
getAvailableCodes,
getLowercase,
getUppercase,
getFrequency,
getLanguage,
} from "worldalphabets";
export type OrderType = "alpha" | "upper" | "lower" | "freq";
export async function fetchLangDirection(lang: string): Promise<"ltr"|"rtl"> {
const meta = await getLanguage(lang).catch(() => null);
return (meta?.direction === "rtl" ? "rtl" : "ltr");
}
export async function fetchOrderedLetters(lang: string, order: OrderType): Promise<string[]> {
const lower = await getLowercase(lang).catch(() => null);
const upper = await getUppercase(lang).catch(() => null);
const freq = await getFrequency(lang).catch(() => null);
if (order === "upper") return upper ?? [];
if (order === "lower") return lower ?? [];
if (order === "alpha") return (lower?.length ? lower : (upper ?? [])) ?? [];
// freq
const base = (lower?.length ? lower : (upper ?? [])) ?? [];
if (!freq) return base;
return [...base].sort((a, b) => (freq[b] ?? 0) - (freq[a] ?? 0));
}
export async function listAlphabetCodes(): Promise<string[]> {
return getAvailableCodes();
}
2) packages/templates/src/shared/grid-utils.ts
export function autoGrid(n: number, minCols = 8) {
const columns = Math.max(minCols, Math.ceil(Math.sqrt(n)));
const rows = Math.ceil(n / columns);
return { rows, columns };
}
export function toGrid(
tokens: string[],
rows: number,
columns: number,
rtl: boolean
): string[][] {
const out: (string | null)[][] = [];
let i = 0;
for (let r = 0; r < rows; r++) {
const row: (string | null)[] = [];
for (let c = 0; c < columns; c++) row.push(tokens[i++] ?? null);
// Im not sure we actually need to do this. A RTL or LTR language doesnt mean we regorganise the alphabet. We might need to use rtl/ltr to right or left align the text though
out.push(rtl ? row.reverse() : row);
}
const last = out[out.length - 1];
if (last) while (last.length && last[last.length - 1] == null) last.pop();
return out.map((row) => row.map((x) => x ?? "")); // cast to strings
}
3) packages/templates/src/i18n/command-labels.ts
export type CommandLabels = {
delete: string;
startAgain: string;
startAgainNewLine?: string; // optional variant where label wraps across lines
space: string;
};
const EN: CommandLabels = {
delete: "DELETE",
startAgain: "START AGAIN",
startAgainNewLine: "START\nAGAIN",
space: "SPACE",
};
export const COMMAND_LABELS: Record<string, CommandLabels> = {
en: EN,
fr: {
delete: "EFFACER",
startAgain: "RECOMMENCER",
startAgainNewLine: "RE\nCOMMENCER",
space: "ESPACE",
},
es: {
delete: "BORRAR",
startAgain: "EMPEZAR DE NUEVO",
startAgainNewLine: "EMPEZAR\nDE NUEVO",
space: "ESPACIO",
},
de: {
delete: "LÖSCHEN",
startAgain: "NEU STARTEN",
startAgainNewLine: "NEU\nSTARTEN",
space: "LEERTASTE",
},
it: {
delete: "CANCELLA",
startAgain: "RICOMINCIA",
startAgainNewLine: "RICO\nMINCIA",
space: "SPAZIO",
},
he: {
delete: "מחק",
startAgain: "התחל מחדש",
startAgainNewLine: "התחל\nמחדש",
space: "רווח",
},
};
export function getCommandLabels(lang: string): CommandLabels {
return COMMAND_LABELS[lang] ?? EN;
}
4) packages/templates/src/i18n/languages.ts
export const SUPPORTED_LANG_CODES = [
"en", "fr", "es", "de", "it", "he",
// add more via PR
];
// Optional per-language defaults you can consult in the factory if desired.
export const DEFAULTS_BY_LANG: Record<string, Partial<{
includeNumbers: boolean;
includePunctuation: boolean;
includeCommands: boolean;
rows: number;
columns: number;
}>> = {
he: { includeNumbers: true, includePunctuation: true, includeCommands: true },
};
5) packages/templates/src/dynamic-alphabet-chart.ts
import { Template } from "types";
import { alphabetChart } from "./alphabet-chart"; // base template
import {
fetchLangDirection,
fetchOrderedLetters,
OrderType,
} from "./shared/worldalphabets-adapter";
import { autoGrid, toGrid } from "./shared/grid-utils";
import { getCommandLabels } from "./i18n/command-labels";
// Optional vowel sets for nicer styling (unknown -> consonant style)
const VOWELS: Record<string, Set<string>> = {
en: new Set(["a","e","i","o","u"]),
};
type MakeOpts = {
lang: string;
order: OrderType;
includeNumbers?: boolean;
includePunctuation?: boolean;
includeCommands?: boolean;
rows?: number;
columns?: number;
};
const NUMBER_ROW = ["1","2","3","4","5","6","7","8","9","0"];
const PUNCT = [",",".","?","!"];
const COMMAND_IDS = [
"delete","delete","delete",
"space","space","space","space",
"start-again","start-again","start-again",
];
function classifyToken(
lang: string,
token: string
): "number"|"vowel"|"consonant"|"punctuation"|"command"|"empty" {
if (!token) return "empty";
if (/^\d$/.test(token)) return "number";
if (["delete","delete-letter","space","start-again","start-again-new-line","empty-cell-one","empty-cell-two","empty-cell-three","empty-cell-four"].includes(token)) return "command";
if (PUNCT.includes(token)) return "punctuation";
const v = VOWELS[lang];
if (v?.has(token.toLowerCase())) return "vowel";
return "consonant";
}
/** Override template default labels with i18n for the chosen language */
function applyCommandLabelDefaults(base: Template, lang: string): Template {
const labels = getCommandLabels(lang);
const updated = {
...base,
templateVariables: base.templateVariables.map((tv) => {
if (tv.id === "delete-text") return { ...tv, defaultValue: labels.delete };
if (tv.id === "start-again-text") return { ...tv, defaultValue: labels.startAgain };
if (tv.id === "space-text") return { ...tv, defaultValue: labels.space };
return tv;
}),
};
// Update any static 'start-again-new-line' button if present
updated.buttons = updated.buttons.map((b) => {
if (String(b.id) === "start-again-new-line" && typeof b.label === "string") {
return { ...b, label: labels.startAgainNewLine ?? b.label };
}
return b;
});
return updated;
}
/** Ensure each token in the grid has a corresponding button with the right style */
function ensureButtonsForTokens(base: Template, lang: string, tokens: string[]): Template {
const existing = new Set(base.buttons.map((b) => String(b.id)));
const buttons = [...base.buttons];
for (const t of tokens) {
if (!t || existing.has(t)) continue;
const kind = classifyToken(lang, t);
if (kind === "punctuation") {
buttons.push({
id: t, label: t,
border_color: { id: "punctuationBorderColor", type: "TemplateItem" },
background_color: { id: "punctuationBackgroundColor", type: "TemplateItem" },
ext_launchpad_label_color: { id: "punctuationTextColor", type: "TemplateItem" },
ext_launchpad_label_font_size: 30,
ext_launchpad_label_font_style: "bold",
ext_launchpad_label_casing: { type: "TemplateItem", id: "casing" },
ext_launchpad_label_font: { type: "TemplateItem", id: "font" },
});
} else if (kind === "number") {
buttons.push({
id: t, label: t,
border_color: { id: "numberBorderColor", type: "TemplateItem" },
background_color: { id: "numberBackgroundColor", type: "TemplateItem" },
ext_launchpad_label_color: { id: "numberTextColor", type: "TemplateItem" },
ext_launchpad_label_font_size: 30,
ext_launchpad_label_font_style: "bold",
ext_launchpad_label_casing: { type: "TemplateItem", id: "casing" },
ext_launchpad_label_font: { type: "TemplateItem", id: "font" },
});
} else if (kind === "command") {
// Usually present already in base, but keep a fallback:
buttons.push({
id: t, label: t.toUpperCase(),
border_color: { id: "commandBorderColor", type: "TemplateItem" },
background_color: { id: "commandBackgroundColor", type: "TemplateItem" },
ext_launchpad_label_color: { id: "commandTextColor", type: "TemplateItem" },
ext_launchpad_label_font_size: 20,
ext_launchpad_label_font_style: "bold",
ext_launchpad_label_casing: { type: "TemplateItem", id: "command-casing" },
ext_launchpad_label_font: { type: "TemplateItem", id: "font" },
});
} else {
const vowel = kind === "vowel";
buttons.push({
id: t,
label: t.toUpperCase(),
border_color: { id: vowel ? "vowelBorderColor" : "consonantBorderColor", type: "TemplateItem" },
background_color: { id: vowel ? "vowelBackgroundColor" : "consonantBackgroundColor", type: "TemplateItem" },
ext_launchpad_label_color: { id: vowel ? "vowelTextColor" : "consonantTextColor", type: "TemplateItem" },
ext_launchpad_label_font_size: 30,
ext_launchpad_label_font_style: "bold",
ext_launchpad_label_casing: { type: "TemplateItem", id: "casing" },
ext_launchpad_label_font: { type: "TemplateItem", id: "font" },
});
}
existing.add(t);
}
return { ...base, buttons };
}
export async function makeDynamicAlphabetChart(opts: MakeOpts): Promise<Template> {
const {
lang,
order,
includeNumbers = true,
includePunctuation = true,
includeCommands = true,
} = opts;
const direction = await fetchLangDirection(lang);
const letters = await fetchOrderedLetters(lang, order);
const bodyTokens = [...letters];
if (includePunctuation) bodyTokens.push(...PUNCT);
const total = bodyTokens.length + (includeNumbers ? NUMBER_ROW.length : 0);
const { rows, columns } =
opts.rows && opts.columns ? { rows: opts.rows, columns: opts.columns } : autoGrid(total);
const contentRows: string[][] = [];
const rtl = direction === "rtl";
if (includeNumbers) contentRows.push(rtl ? [...NUMBER_ROW].reverse() : NUMBER_ROW);
const bodyRows = Math.max(1, rows - (includeNumbers ? 1 : 0) - (includeCommands ? 1 : 0));
const bodyGrid = toGrid(bodyTokens, bodyRows, columns, rtl);
contentRows.push(...bodyGrid);
if (includeCommands) {
const cmdRow = rtl ? [...COMMAND_IDS].reverse() : COMMAND_IDS;
contentRows.push(cmdRow);
}
// Clone base, apply i18n defaults, ensure buttons exist
let tmpl: Template = JSON.parse(JSON.stringify(alphabetChart));
tmpl = applyCommandLabelDefaults(tmpl, lang);
tmpl = ensureButtonsForTokens(tmpl, lang, contentRows.flat());
return {
...tmpl,
id: `alphabet-chart-dynamic-${lang}-${order}`,
name: `Alphabet Chart (${lang.toUpperCase()} • ${order})`,
locale: lang,
pages: [
{
id: "alphabet-dynamic",
grid: { rows: contentRows.length, columns, order: contentRows },
},
],
};
}
6) packages/graphql/resolvers/alphabet.ts (stub)
// Example GraphQL resolver stub for generateAlphabetBoard
import { makeDynamicAlphabetChart } from "@templates/dynamic-alphabet-chart";
// Reuse your existing generation pipeline (replace with your actual imports/types)
import { generateBoardFromTemplate } from "../services/generateBoard";
type GenerateAlphabetBoardOptions = {
lang: string;
order: "alpha" | "upper" | "lower" | "freq";
includeNumbers?: boolean;
includePunctuation?: boolean;
includeCommands?: boolean;
rows?: number;
columns?: number;
};
export const resolvers = {
Mutation: {
async generateAlphabetBoard(
_: unknown,
{ options }: { options: GenerateAlphabetBoardOptions }
) {
const template = await makeDynamicAlphabetChart(options);
return generateBoardFromTemplate(template);
},
},
};
7) packages/cli/commands/gen-alphabet.ts (stub)
#!/usr/bin/env node
import { Command } from "commander";
import { makeDynamicAlphabetChart } from "@templates/dynamic-alphabet-chart";
import { generateBoardFromTemplateCli } from "../lib/generate"; // adapt path
const program = new Command("gen:alphabet");
program
.requiredOption("--lang <code>", "language code, e.g. en, fr, he")
.requiredOption("--order <type>", "alpha|upper|lower|freq")
.option("--rows <n>", "rows", (v) => parseInt(v, 10))
.option("--cols <n>", "columns", (v) => parseInt(v, 10))
.option("--no-numbers", "exclude numbers row")
.option("--no-punctuation", "exclude punctuation")
.option("--no-commands", "exclude commands row")
.option("--pdf <file>", "output pdf file")
.option("--obf <file>", "output obf file");
program.action(async (opts) => {
const template = await makeDynamicAlphabetChart({
lang: opts.lang,
order: opts.order,
rows: opts.rows,
columns: opts.cols,
includeNumbers: opts.numbers !== false,
includePunctuation: opts.punctuation !== false,
includeCommands: opts.commands !== false,
});
await generateBoardFromTemplateCli(template, { pdf: opts.pdf, obf: opts.obf });
});
program.parseAsync(process.argv);
8) packages/templates/scripts/prebuild-alphabets.ts (optional)
import { promises as fs } from "fs";
import path from "path";
import { makeDynamicAlphabetChart } from "../src/dynamic-alphabet-chart";
import { SUPPORTED_LANG_CODES } from "../src/i18n/languages";
const OUT = path.resolve(process.cwd(), "packages/templates/obf-cache");
async function ensureDir(p: string) { await fs.mkdir(p, { recursive: true }); }
async function main() {
await ensureDir(OUT);
const orders = ["alpha","upper","lower","freq"] as const;
for (const lang of SUPPORTED_LANG_CODES) {
const dir = path.join(OUT, lang);
await ensureDir(dir);
for (const order of orders) {
const tmpl = await makeDynamicAlphabetChart({ lang, order: order as any });
const obf = {
format: tmpl.format,
pages: tmpl.pages,
locale: tmpl.locale,
id: tmpl.id,
name: tmpl.name,
};
const file = path.join(dir, `${order}.obf`);
await fs.writeFile(file, JSON.stringify(obf, null, 2), "utf-8");
}
}
console.log("Prebuilt OBFs at", OUT);
}
main().catch((e) => { console.error(e); process.exit(1); });
Testing Checklist
yarn launchpad gen:alphabet --lang en --order alpha → matches existing alphabet layouts and styles.
- RTL language (e.g.
he) flips row order appropriately.
- Command labels are localised for FR/ES/DE/IT/HE; unknown languages fall back to English.
- All tokens in
pages[0].grid.order have corresponding buttons.
- Punctuation, numbers, and commands toggle correctly.
- GraphQL mutation returns a valid board via the existing pipeline.
- (Optional) Pre‑gen script writes
.obf files only for SUPPORTED_LANG_CODES.
Contributor Notes
To add a language:
- Add its code to
SUPPORTED_LANG_CODES in i18n/languages.ts.
- Add translations in
i18n/command-labels.ts (or rely on English fallback).
- (Optional) Add a vowel set in
dynamic-alphabet-chart.ts for improved vowel styling.
- (Optional) Add per‑language defaults (rows/cols, fonts) in
i18n/languages.ts.
We’d like to add support for dynamic alphabet chart generation using worldalphabets.
This allows Launchpad to automatically build alphabet charts for many languages and letter orders (alphabetical, lower/upper case, frequency) rather than maintaining static
en‑only grids.The implementation should integrate into the existing
alphabetCharttemplate (colour schemes, casing, commands, fonts, etc.) so all downstream processes (board‑to‑pdf, analytics, OBF export) keep working without breaking changes.Why
Deliverables
packages/templates/src/shared/worldalphabets-adapter.tsWraps
worldalphabets(letters + direction).packages/templates/src/shared/grid-utils.tsAuto grid sizing +
toGrid()with RTL support.packages/templates/src/dynamic-alphabet-chart.tsClones
alphabetChartand injects dynamic pages + any missing buttons.{ lang, order, includeNumbers, includePunctuation, includeCommands, rows, columns }.i18n/command-labels.ts.packages/templates/src/i18n/command-labels.tsProvides translated defaults for command labels (
delete-text,start-again-text,space-text, and optionalstart-again-new-line). Missing languages fall back to English.packages/templates/src/i18n/languages.tsSUPPORTED_LANG_CODES(initial set), plus room for optional per‑language defaults.generateAlphabetBoard(options)(additive).launchpad gen:alphabet …(additive)..obffor allow‑listed languages for review/demos.PR Skeleton
Title
Description
This PR introduces a dynamic alphabet chart powered by
worldalphabets.It extends the existing
alphabetCharttemplate, generating a page layout and ensuring button definitions dynamically for any supported language & order.It also adds an i18n layer for command labels and a language allow‑list for pre‑gen/dev UX.
No breaking changes to current templates.
File tree (new/changed)
Code
1)
packages/templates/src/shared/worldalphabets-adapter.ts2)
packages/templates/src/shared/grid-utils.ts3)
packages/templates/src/i18n/command-labels.ts4)
packages/templates/src/i18n/languages.ts5)
packages/templates/src/dynamic-alphabet-chart.ts6)
packages/graphql/resolvers/alphabet.ts(stub)7)
packages/cli/commands/gen-alphabet.ts(stub)8)
packages/templates/scripts/prebuild-alphabets.ts(optional)Testing Checklist
yarn launchpad gen:alphabet --lang en --order alpha→ matches existing alphabet layouts and styles.he) flips row order appropriately.pages[0].grid.orderhave correspondingbuttons..obffiles only forSUPPORTED_LANG_CODES.Contributor Notes
To add a language:
SUPPORTED_LANG_CODESini18n/languages.ts.i18n/command-labels.ts(or rely on English fallback).dynamic-alphabet-chart.tsfor improved vowel styling.i18n/languages.ts.