Перейти к содержимому
FastOffice

Свой язык

Языка нет в наших трёх — его приносит хозяин страницы: подписи переводятся, непереведённое идёт запасной цепочкой, а не пропадает.

языка нет в наших трёх — его приносит хозяин страницы: подписи переводятся, непереведённое идёт запасной цепочкой, а не пропадает
открыть в новой вкладке ↗Работающий пример. Ниже — его код целиком, файл за файлом.

В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.

Страница, которую открывает браузер.

index.html
<!doctype html>
<!--
Свой язык — НАСТОЯЩИЙ МАЛЕНЬКИЙ ПРОЕКТ.
Не «страница витрины», а тот проект, который встраивающий копирует к себе
целиком. Галерея примеров ЗАПУСКАЕТ эти файлы и ПОКАЗЫВАЕТ их же.
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Свой язык — @f1</title>
</head>
<body>
<div data-example="custom-locale" class="flex h-screen w-screen flex-col bg-base-100 text-base-content">
<header class="border-b border-base-300 px-4 py-2">
<div class="text-sm font-semibold">Свой язык</div>
<div class="text-xs opacity-70">
Украинского у нас нет — его приносит ХОЗЯИН СТРАНИЦЫ вызовом
<code>registerLocale({ code: "uk", … })</code>. Переведена горстка подписей; остальные
показываются запасным языком (<code>fallback: "ru"</code>), а не пустотой.
Переключите язык — и посмотрите на полосу подписей ниже: их отдаёт <code>label()</code>,
та же дорога, которой говорит вся поверхность.
</div>
<div class="mt-1 flex items-center gap-2 text-xs" data-locale-picker></div>
</header>
<div class="flex flex-wrap gap-2 border-b border-base-300 px-4 py-2 text-xs" data-locale-strip></div>
<div id="app" class="flex min-h-0 flex-1 flex-col"></div>
<footer class="border-t border-base-300 px-4 py-2 font-mono text-xs" data-locale-coverage>
покрытие считается…
</footer>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

Точка входа: здесь лист создаётся и сюда же приходят события.

src/main.ts
import { createSheetsCore } from "@f1/preset-sheets-core";
import {
applyLocaleAttributes,
label,
subscribeLocale,
availableLocales,
labelCoverage,
localeChain,
localeName,
registerLocale,
setLocale,
surfaceLabelKeys,
} from "@f1/i18n";
import { transport } from "./book.js";
import { hostAsks } from "./host.js";
import { OUR_RUSSIAN_TWEAKED, UKRAINIAN } from "./dictionary.js";
import "./styles.css";
/**
* СВОЙ ЯЗЫК — И ПРАВИЛО ЗА НИМ.
*
* Языков у нас три (ru, en, ar), а у встраивающего их может быть сколько
* угодно. Поэтому язык — точка расширения: словарь объявляет ХОЗЯИН СТРАНИЦЫ,
* а не мы, и наш собственный словарь при этом не правится ни на строку — его
* привозит генератор из продукта и переписывает целиком при каждой пересборке.
*
* Три вещи, которые показывает этот пример:
* 1. язык, которого у нас нет, доезжает до подписей на ленте;
* 2. непереведённое идёт ОБЪЯВЛЕННОЙ цепочкой (чужой словарь → наш язык →
* английский) и никогда не становится пустой строкой;
* 3. отдельную подпись можно переопределить, не переводя словарь целиком, —
* тем же вызовом, но с кодом нашего языка.
*/
/* ОБЪЯВЛЕНИЕ ИДЁТ ПЕРВЫМ, и это не стиль: `resolveLocale` внутри `hostAsks`
сужает строку из адреса к языку, на котором поверхность УМЕЕТ говорить.
Объяви словарь после — и `?locale=uk` молча стал бы запасным языком. */
registerLocale(UKRAINIAN);
registerLocale(OUR_RUSSIAN_TWEAKED);
const { locale } = hostAsks();
const core = createSheetsCore({
mount: document.getElementById("app")!,
transport,
locale,
});
/**
* ПОЛОСА ПОДПИСЕЙ — то же `label()`, которым говорит вся поверхность.
*
* Без неё пример пришлось бы принимать на веру: лист рисует числа, а перевод
* виден на подписях органов. Здесь взяты подписи, часть которых переведена, а
* часть нет, — и вторые обязаны остаться СЛОВОМ.
*/
const SHOWN = ["Главная", "Вставка", "Полужирный", "Курсив", "Подчёркивание", "Объединить и поместить в центре"];
function showLabels(): void {
const strip = document.querySelector("[data-locale-strip]")!;
strip.textContent = "";
for (const key of SHOWN) {
const chip = document.createElement("span");
chip.className = "badge badge-sm";
chip.dataset.label = key;
chip.textContent = label(key);
strip.append(chip);
}
}
subscribeLocale(showLabels);
/**
* ПОКРЫТИЕ — ТОТ ЖЕ СЧЁТЧИК, ЧТО У НАС, а не свой в примере: `labelCoverage`
* отвечает переводчику на вопрос «сколько осталось» и называет недостающее
* поимённо. Второй счётчик разошёлся бы с первым молча.
*/
function showCoverage(code: string): void {
const { total, translated, missing } = labelCoverage(surfaceLabelKeys(), code);
const box = document.querySelector("[data-locale-coverage]")!;
box.textContent =
`${localeName(code)} (${code}): переведено ${translated} из ${total}; ` +
`цепочка ${localeChain(code).join(" → ")}; ` +
`не переведено, например: ${missing.slice(0, 3).join(", ")}`;
}
/* Переключатель языков — из состава, а не из списка руками: `availableLocales`
уже знает и наши три, и всё, что объявил хозяин. */
const picker = document.querySelector("[data-locale-picker]")!;
for (const code of availableLocales()) {
const button = document.createElement("button");
button.className = "btn btn-xs";
button.textContent = localeName(code);
button.dataset.locale = code;
button.addEventListener("click", () => {
setLocale(code);
applyLocaleAttributes(document.documentElement, code);
showCoverage(code);
});
picker.append(button);
}
showLabels();
showCoverage(locale);
import.meta.hot?.dispose(() => core.destroy());

Книга примера: данные, с которыми лист открывается.

src/book.ts
import { createMockTransport } from "@f1/contract";
/**
* КНИГА-ЗАГЛУШКА — ВМЕСТО НАСТОЯЩЕГО ЯДРА.
*
* Транспорт контракта — единственное место, где лист разговаривает с ядром
* (инвариант №1: считает ядро, оболочка только показывает). В продукте здесь
* стоит `wasm` или мост хоста, и меняется РОВНО эта строка — всё остальное в
* проекте остаётся как есть.
*/
export const transport = createMockTransport({
cellAt: (row, col) => (col === 0 ? `строка ${row + 1}` : `${(row + 1) * (col + 1)}`),
});

Сторона хозяина страницы: что приложение отвечает листу и что от него получает.

src/host.ts
import { applyLocaleAttributes, resolveLocale, setLocale, type Locale } from "@f1/i18n";
/**
* ЧТО ХОЗЯИН СТРАНИЦЫ ДАЁТ ЛИСТУ — тема и язык.
*
* У встраивающего это уже есть своё: тему он знает из настроек, язык — из
* профиля пользователя. Здесь и то и другое читается из адреса
* (`?theme=dark&locale=ar`) ровно затем, чтобы на любое состояние можно было
* дать ссылку и чтобы его проверял обход: настройка, доступная только щелчком,
* недоступна ни ссылке, ни автоматической проверке.
*
* ПРИЗНАКИ СТАВЯТСЯ НА КОРЕНЬ ДОКУМЕНТА — потому что это делает ХОЗЯИН, а не
* лист. Лист читает тему оттуда, где стоит (`:host-context`), а язык получает
* СВОЙСТВОМ сборки: два листа на одной странице вправе говорить на разном.
*
* НАПРАВЛЕНИЕ ПИСЬМА — ИНТЕРФЕЙСА, А НЕ ДОКУМЕНТА. `dir="rtl"` зеркалит хром
* страницы; «Лист справа налево» из Excel — свойство КНИГИ, и сетка от
* арабского языка интерфейса не переворачивается. Это не пропуск, а решение
* (`locale.ts`).
*/
export function hostAsks(): { readonly theme: "light" | "dark"; readonly locale: Locale } {
const params = new URLSearchParams(location.search);
const theme = params.get("theme") === "dark" ? "dark" : "light";
const locale = resolveLocale(params.get("locale") ?? undefined, "ru");
document.documentElement.setAttribute("data-theme", theme);
/* Язык — один на процесс: словарь модульный, и ставит его хозяин страницы. */
setLocale(locale);
applyLocaleAttributes(document.documentElement, locale);
return { theme, locale };
}

Оформление страницы вокруг листа.

src/styles.css
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ.
Собственное оформление лист приносит в теневые корни сам, но утилиты
раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без
него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл
замера у сетки без размеров). У потребителя вне репозитория это
`@fastoffices/formula-kit` из его node_modules. */
@import "@fastoffices/formula-kit"; /* см. примечание выше */
html,
body {
height: 100%;
margin: 0;
}

Из каких пакетов собран пример.

package.json
{
"name": "f1-example-custom-locale",
"private": true,
"type": "module",
"description": "Свой язык: словарь приносит хозяин страницы, непереведённое идёт запасной цепочкой.",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@f1/preset-sheets-core": "*",
"@f1/contract": "*",
"@f1/i18n": "*"
},
"devDependencies": {
"vite": "^7.0.0"
}
}

Файл примера.

src/dictionary.ts
import type { LocalePack } from "@f1/i18n";
/**
* СЛОВАРЬ ВСТРАИВАЮЩЕГО — ОБЫЧНЫЕ ДАННЫЕ, КОТОРЫЕ МОЖНО ПРИВЕЗТИ ОТКУДА УГОДНО.
*
* Здесь он записан прямо в файле, чтобы пример читался целиком; в настоящем
* проекте это `await fetch("/i18n/uk.json")` или пакет переводчика. Формы это
* не меняет: `registerLocale` принимает объект.
*
* ЧТО ПЕРЕВОДИТСЯ:
* · `messages` — ключевые строки хрома (`MessageKey`), написанные нами;
* · `labels` — подписи поверхности, у которых ключ — РУССКАЯ строка. Так
* устроен наш словарь: перенесённая из продукта подпись не имеет второго
* идентификатора, и русский текст и есть её имя. Полный список ключей
* отдаёт `surfaceLabelKeys()`, а сколько уже переведено — `labelCoverage()`.
*
* Переведено НАРОЧНО НЕ ВСЁ: пример показывает не только удачу, но и запасную
* цепочку — `fallback: "ru"` — и то, что непереведённое остаётся СЛОВОМ.
*/
export const UKRAINIAN: LocalePack = {
code: "uk",
name: "Українська",
/* Запасной язык: чего нет здесь, берётся из русского, а не пропадает.
Не объявишь — последним звеном встанет английский. */
fallback: "ru",
messages: {
"home.action.cancel": "Скасувати",
"home.action.create": "Створити",
"home.page.main": "Головна",
"home.page.new": "Створити",
},
labels: {
Главная: "Головна",
Вставка: "Вставлення",
Данные: "Дані",
Рецензирование: "Рецензування",
Вид: "Вигляд",
Полужирный: "Жирний",
Курсив: "Курсив",
"Шрифт": "Шрифт",
"Выравнивание": "Вирівнювання",
"Число": "Число",
},
};
/**
* ПОПРАВКА К НАШЕМУ ЯЗЫКУ — ТОТ ЖЕ ПРИЁМ, НО ДЛЯ ОДНОЙ ПОДПИСИ.
*
* Встраивающему не всегда нужен новый язык: чаще он хочет назвать одно место
* по-своему («Полужирный» → «Жирный шрифт»). Это тот же `registerLocale`, но с
* кодом НАШЕГО языка: слой ложится поверх нашего словаря, ничего в нём не
* стирая, и снимается возвратом `registerLocale`.
*/
export const OUR_RUSSIAN_TWEAKED: LocalePack = {
code: "ru",
labels: { Полужирный: "Жирный шрифт" },
};

Ассистент документации

Ответ собран из документации и может быть неточным — сверяйтесь с источниками.