Skip to content

Multilingual keyboards #34

Description

@willwade

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:

  1. Add its code to SUPPORTED_LANG_CODES in i18n/languages.ts.
  2. Add translations in i18n/command-labels.ts (or rely on English fallback).
  3. (Optional) Add a vowel set in dynamic-alphabet-chart.ts for improved vowel styling.
  4. (Optional) Add per‑language defaults (rows/cols, fonts) in i18n/languages.ts.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions