forked from c-fu/arah2
feat(03-03): copy module — UI-SPEC table verbatim (en+ms) + COPY1..6 copy gate
- web/src/i18n/strings.ts: 25-key ShellStrings, pickLocale verbatim from bot, localeFromInitData over the raw initData string (RESEARCH §Security V2) - web/test/copy-gate.test.mjs: parity, content, fallback, no-inline-copy source-introspection (comments stripped), initData locale derivation
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
* strings.ts — the Mini App shell's copy tokens.css (UI-SPEC §Copywriting Contract).
|
||||||
|
* -------------------------------------------------------------------------------
|
||||||
|
* Every user-facing string the shell renders lives here and NOWHERE else —
|
||||||
|
* `web/src/ui/*` and `web/src/main.ts` import from this module, and the copy
|
||||||
|
* gate (web/test/copy-gate.test.mjs COPY1..COPY5) keeps it verbatim against
|
||||||
|
* the approved UI-SPEC table in en + ms. Register labels (`BOOT-01 //`,
|
||||||
|
* `BOOT-02 //`, `GEO // LOCATION`, `SYS // POSITION`) are instrument
|
||||||
|
* vocabulary: identical in both locales by design. Tone is clinical + honest
|
||||||
|
* (D-01): plain statements of what is stored and what is not.
|
||||||
|
*
|
||||||
|
* Locales: English default ("en") + Bahasa Melayu ("ms") — Phase 2 D-05
|
||||||
|
* precedent, same pickLocale ms-prefix rule as bot/src/i18n/strings.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ShellStrings {
|
||||||
|
bootValidatingRegister: string;
|
||||||
|
bootValidatingHeadline: string;
|
||||||
|
bootValidatingBody: string;
|
||||||
|
bootAcquiringRegister: string;
|
||||||
|
bootAcquiringHeadline: string;
|
||||||
|
bootAcquiringBody: string;
|
||||||
|
deniedRegister: string;
|
||||||
|
ctaTryAgain: string;
|
||||||
|
ctaAskViaChat: string;
|
||||||
|
deniedHeadline: string;
|
||||||
|
deniedBody: string;
|
||||||
|
deniedHint: string;
|
||||||
|
lostRegister: string;
|
||||||
|
lostHeadline: string;
|
||||||
|
lostBody: string;
|
||||||
|
lostHint: string;
|
||||||
|
chatFallbackWaiting: string;
|
||||||
|
stripValidating: string;
|
||||||
|
stripAcquiring: string;
|
||||||
|
stripLive: string;
|
||||||
|
stripSignalLost: string;
|
||||||
|
stripLocationOff: string;
|
||||||
|
stripAwaitingPin: string;
|
||||||
|
attribution: string;
|
||||||
|
ariaRecenter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Locale = "en" | "ms";
|
||||||
|
|
||||||
|
export const strings: Record<Locale, ShellStrings> = {
|
||||||
|
en: {
|
||||||
|
bootValidatingRegister: "BOOT-01 // SESSION",
|
||||||
|
bootValidatingHeadline: "Validating session",
|
||||||
|
bootValidatingBody: "Confirming your Telegram identity. Takes a few seconds and stores nothing.",
|
||||||
|
bootAcquiringRegister: "BOOT-02 // LOCATION",
|
||||||
|
bootAcquiringHeadline: "Acquiring location",
|
||||||
|
bootAcquiringBody: "Finding where you are. Your position is never written to storage.",
|
||||||
|
deniedRegister: "GEO // LOCATION",
|
||||||
|
ctaTryAgain: "TRY AGAIN",
|
||||||
|
ctaAskViaChat: "ASK VIA CHAT",
|
||||||
|
deniedHeadline: "Location is switched off",
|
||||||
|
deniedBody: "araharah guides you from where you physically are. Without location there is no route to draw.",
|
||||||
|
deniedHint: "Grant location to Telegram, or send a pin in this chat: attach → Location.",
|
||||||
|
lostRegister: "SYS // POSITION",
|
||||||
|
lostHeadline: "Signal lost",
|
||||||
|
lostBody: "Showing your last known position — marked on the map. Reacquiring automatically.",
|
||||||
|
lostHint: "You can also send a fresh pin in this chat: attach → Location.",
|
||||||
|
chatFallbackWaiting: "Waiting for the pin you send in chat.",
|
||||||
|
stripValidating: "VALIDATING",
|
||||||
|
stripAcquiring: "ACQUIRING",
|
||||||
|
stripLive: "LIVE",
|
||||||
|
stripSignalLost: "SIGNAL LOST",
|
||||||
|
stripLocationOff: "LOCATION OFF",
|
||||||
|
stripAwaitingPin: "AWAITING PIN",
|
||||||
|
attribution: "© OpenStreetMap contributors",
|
||||||
|
ariaRecenter: "Recenter map",
|
||||||
|
},
|
||||||
|
ms: {
|
||||||
|
bootValidatingRegister: "BOOT-01 // SESSION",
|
||||||
|
bootValidatingHeadline: "Menyemak sesi",
|
||||||
|
bootValidatingBody: "Mengesahkan identiti Telegram anda. Hanya beberapa saat, tiada apa-apa disimpan.",
|
||||||
|
bootAcquiringRegister: "BOOT-02 // LOCATION",
|
||||||
|
bootAcquiringHeadline: "Mendapatkan lokasi",
|
||||||
|
bootAcquiringBody: "Mencari kedudukan anda. Lokasi anda tidak pernah disimpan.",
|
||||||
|
deniedRegister: "GEO // LOCATION",
|
||||||
|
ctaTryAgain: "CUBA LAGI",
|
||||||
|
ctaAskViaChat: "MINTA VIA CHAT",
|
||||||
|
deniedHeadline: "Lokasi dimatikan",
|
||||||
|
deniedBody: "araharah memandu dari kedudukan sebenar anda. Tanpa lokasi, tiada laluan boleh dilukis.",
|
||||||
|
deniedHint: "Benarkan lokasi untuk Telegram, atau hantar pin dalam chat ini: lampiran → Lokasi.",
|
||||||
|
lostRegister: "SYS // POSITION",
|
||||||
|
lostHeadline: "Isyarat hilang",
|
||||||
|
lostBody: "Menunjukkan kedudukan terakhir anda — ditanda pada peta. Sedang mencari semula.",
|
||||||
|
lostHint: "Anda juga boleh hantar pin baharu dalam chat ini: lampiran → Lokasi.",
|
||||||
|
chatFallbackWaiting: "Menanti pin yang anda hantar dalam chat.",
|
||||||
|
stripValidating: "MENYEMAK",
|
||||||
|
stripAcquiring: "MENDAPAT",
|
||||||
|
stripLive: "LANGSUNG",
|
||||||
|
stripSignalLost: "ISYARAT HILANG",
|
||||||
|
stripLocationOff: "LOKASI MATI",
|
||||||
|
stripAwaitingPin: "AWAITING PIN",
|
||||||
|
attribution: "© OpenStreetMap contributors",
|
||||||
|
ariaRecenter: "Tetapkan semula peta",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-05 fallback rule (verbatim from bot/src/i18n/strings.ts): a Telegram
|
||||||
|
* `language_code` that starts with the Malay tag selects "ms"; every other
|
||||||
|
* value (including null/undefined and unknown languages) falls back to en.
|
||||||
|
*/
|
||||||
|
export function pickLocale(telegramLanguageCode?: string | null): Locale {
|
||||||
|
const code = telegramLanguageCode ?? "";
|
||||||
|
return code === "ms" || code.startsWith("ms-") ? "ms" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive the locale from the RAW initData string (RESEARCH §Security V2 —
|
||||||
|
* the signed string, never initDataUnsafe). Telegram's wire format is
|
||||||
|
* user=<url-encoded JSON>&auth_date=...&query_id=...&signature=...&hash=...,
|
||||||
|
* so the first query component is `user`. A malformed or absent field
|
||||||
|
* falls back to "en" — never throws, never blocks boot. */
|
||||||
|
export function localeFromInitData(initData: string): Locale {
|
||||||
|
try {
|
||||||
|
const userField = initData.split("&")[0] ?? "";
|
||||||
|
if (!userField.startsWith("user=")) return "en";
|
||||||
|
const user = JSON.parse(decodeURIComponent(userField.slice(5))) as { language_code?: string };
|
||||||
|
return pickLocale(user.language_code);
|
||||||
|
} catch {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* copy-gate.test.mjs — COPY1..COPY6, the UI-SPEC copy gate (mirrors bot CS1-CS3).
|
||||||
|
* --------------------------------------------------------------------------------
|
||||||
|
* COPY1 locale key-set parity · COPY2/COPY3 verbatim-contract content checks ·
|
||||||
|
* COPY4 pickLocale fallback rule · COPY5 no inline copy in shell modules ·
|
||||||
|
* COPY6 localeFromInitData over the raw initData string.
|
||||||
|
*/
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test } from "./harness.mjs";
|
||||||
|
|
||||||
|
const { strings, pickLocale, localeFromInitData } = await import("../src/i18n/strings.ts");
|
||||||
|
|
||||||
|
const UI_DIR = path.join(path.dirname(fileURLToPath(new URL(import.meta.url))), "..", "src", "ui");
|
||||||
|
|
||||||
|
/* ————— COPY1 — en/ms key-set identity (Phase 2 D-05 precedent) ————— */
|
||||||
|
|
||||||
|
test("COPY1 en and ms carry identical key-sets (parity gate, both locales)", () => {
|
||||||
|
const enKeys = Object.keys(strings.en).sort();
|
||||||
|
const msKeys = Object.keys(strings.ms).sort();
|
||||||
|
assert.deepEqual(msKeys, enKeys, "ms missing/duplicated keys vs en (D-05 locale parity)");
|
||||||
|
assert.ok(enKeys.length >= 25, `expected the full ShellStrings surface (25 keys), found ${enKeys.length}`);
|
||||||
|
assert.ok(enKeys.includes("deniedRegister"), "deniedRegister is a keys — cards.ts renders GEO //");
|
||||||
|
assert.ok(enKeys.includes("lostRegister"), "lostRegister is a key — cards.ts renders SYS //");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— COPY2 — denied copy names location ————— */
|
||||||
|
|
||||||
|
test("COPY2 the denied copy names location in both locales", () => {
|
||||||
|
assert.ok(/location/i.test(strings.en.deniedHeadline), "en denied headline names location");
|
||||||
|
assert.ok(/location/i.test(strings.en.deniedBody), "en denied body names location");
|
||||||
|
assert.ok(/lokasi/i.test(strings.ms.deniedHeadline), "ms denied headline names lokasi");
|
||||||
|
assert.ok(/lokasi/i.test(strings.ms.deniedBody), "ms denied body names lokasi");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— COPY3 — the never-persisted fact is on the boot card ————— */
|
||||||
|
|
||||||
|
test("COPY3 the boot-2 body states the never-persisted fact", () => {
|
||||||
|
assert.ok(/never written to storage/i.test(strings.en.bootAcquiringBody), "en boot-2 body carries the never-fact");
|
||||||
|
assert.ok(/tidak pernah disimpan/i.test(strings.ms.bootAcquiringBody), "ms boot-2 body carries the never-fact");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— COPY4 — pickLocale fallback rule ————— */
|
||||||
|
|
||||||
|
test("COPY4 pickLocale: ms and ms-MY select ms; en, en-GB, id, null, and empty string select en", () => {
|
||||||
|
assert.equal(pickLocale("ms"), "ms");
|
||||||
|
assert.equal(pickLocale("ms-MY"), "ms");
|
||||||
|
assert.equal(pickLocale("en"), "en");
|
||||||
|
assert.equal(pickLocale("en-GB"), "en");
|
||||||
|
assert.equal(pickLocale("id"), "en", "unknown/neighbor locale falls back to en");
|
||||||
|
assert.equal(pickLocale(null), "en");
|
||||||
|
assert.equal(pickLocale(""), "en");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— COPY5 — no inline copy in the shell ————— */
|
||||||
|
|
||||||
|
test("COPY5 no shell module inlines copy — source-introspection grep over web/src/ui/*.ts and web/src/main.ts", () => {
|
||||||
|
const banned =
|
||||||
|
/Validating session|Acquiring location|TRY AGAIN|ASK VIA CHAT|Location is switched off|Signal lost|MENYEMAK|MENDAPAT|LANGSUNG|GEO \/\/ LOCATION|SYS \/\/ POSITION|BOOT-01 \/\/|BOOT-02 \/\/|CUBA LAGI|MINTA VIA CHAT|ISYARAT HILANG|LOKASI MATI|AWAITING PIN|OpenStreetMap contributors|Recenter map|Tetapkan semula peta/;
|
||||||
|
const files = existsSync(UI_DIR)
|
||||||
|
? readdirSync(UI_DIR)
|
||||||
|
.filter((f) => f.endsWith(".ts"))
|
||||||
|
.map((f) => path.join(UI_DIR, f))
|
||||||
|
: [];
|
||||||
|
files.push(path.join(UI_DIR, "..", "main.ts")); // the composition root — always scanned
|
||||||
|
assert.ok(files.length >= 1, `scanned a real shell tree (found ${files.length} files)`);
|
||||||
|
for (const file of files) {
|
||||||
|
const src = readFileSync(file, "utf8");
|
||||||
|
// The gate scans code AND prose-in-comments alike? No — comments may cite
|
||||||
|
// the register labels in their own names (e.g. a header example). Strip
|
||||||
|
// block and line comments first: prose is not a call site (GEO3 pattern).
|
||||||
|
const code = src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||||
|
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
|
||||||
|
const hit = banned.exec(code);
|
||||||
|
assert.ok(!hit, `${path.relative(process.cwd(), file)} inlines copy: ${hit && hit[0]} — copy lives only in web/src/i18n/strings.ts`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— COPY6 — localeFromInitData over the raw initData string ————— */
|
||||||
|
|
||||||
|
test("COPY6 localeFromInitData derives the locale from the raw initData string", () => {
|
||||||
|
assert.equal(
|
||||||
|
localeFromInitData('user=%7B%22language_code%22%3A%22ms%22%7D&auth_date=1&hash=x'),
|
||||||
|
"ms",
|
||||||
|
"ms language_code inside the signed user field selects ms",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
localeFromInitData('user=%7B%22language_code%22%3A%22en-GB%22%7D&auth_date=1&hash=x'),
|
||||||
|
"en",
|
||||||
|
"en-GB falls back to en",
|
||||||
|
);
|
||||||
|
assert.equal(localeFromInitData(""), "en", "empty initData falls back to en");
|
||||||
|
assert.equal(localeFromInitData("not-initdata"), "en", "no user= component falls back to en");
|
||||||
|
assert.equal(localeFromInitData("user=%7Bbad-json"), "en", "decode/JSON failure must not throw");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user