fix(i18n): fall unmatched locales back to English

Unknown browser and system languages now resolve to English instead of
Simplified Chinese, and Japanese App Store / Play listing copy is kept
in store-assets for console publishing.
This commit is contained in:
tianma-if committed 2026-09-14 10:56:53 +08:00
1 parent 0e5be33a44
commit 760e82ab50
27 files changed
+628 -40

No files matched your search

@@ -72,4 +72,20 @@ describe("workspace provisioning", () => {
expect(calls.filter((call) => call.sql.includes("INSERT OR IGNORE INTO memo_templates"))).toHaveLength(5);
expect(calls.some((call) => call.sql.includes("INSERT OR IGNORE INTO ai_prompt_templates"))).toBe(true);
});
test("seeds English templates when Accept-Language is neither Chinese nor English", () => {
const calls = [];
const db = {
prepare: (sql) => statement(sql, calls),
};
createWorkspaceDefaultSeedStatements(
db,
"ws_ja",
"2026-08-14T00:00:00.000Z",
"ja-JP,ja;q=0.9",
);
expect(calls[0].values).toContain("Quick Spark");
});
});
@@ -2,6 +2,17 @@ import Foundation
import Observation
import SwiftUI
enum AppUILocale {
/// Unmatched system languages use English, not Simplified Chinese.
static func usesEnglish(preferenceCode: String, systemLanguageCode: String?) -> Bool {
if preferenceCode == "en-US" { return true }
if preferenceCode == "zh-CN" { return false }
let code = (systemLanguageCode ?? "").lowercased()
if code.isEmpty { return true }
return !code.hasPrefix("zh")
}
}
@Observable
@MainActor
final class PreferencesStore {
@@ -53,9 +64,10 @@ final class PreferencesStore {
}
var isEnglish: Bool {
if localeCode == "en-US" { return true }
if localeCode == "zh-CN" { return false }
return Locale.autoupdatingCurrent.language.languageCode?.identifier == "en"
AppUILocale.usesEnglish(
preferenceCode: localeCode,
systemLanguageCode: Locale.autoupdatingCurrent.language.languageCode?.identifier
)
}
var colorScheme: ColorScheme? {
@@ -4,6 +4,16 @@ import XCTest
/// Exercises shipped parity helpers (same semantics as Android `@edgeever/shared/mobile-ui` + notebooks).
final class MobileUIParityTests: XCTestCase {
func testUnmatchedSystemLanguageUsesEnglishUI() {
XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "ja"))
XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "fr"))
XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "en"))
XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: nil))
XCTAssertFalse(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "zh"))
XCTAssertFalse(AppUILocale.usesEnglish(preferenceCode: "zh-CN", systemLanguageCode: "ja"))
XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "en-US", systemLanguageCode: "zh"))
}
func testToggleFilterReturnsToAllWhenPressedAgain() {
XCTAssertEqual(
MobileUI.toggleMemoFilterMode(current: .all, requested: .pinned),
+2 -1
View File
@@ -1,4 +1,5 @@
import { enUS, zhCN } from "@edgeever/shared/i18n";
import { resolveSupportedLocale } from "@edgeever/shared/i18n/locales";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import {
readMobileLocalePreference,
@@ -214,7 +215,7 @@ const templateTranslations = [
].sort((left, right) => right.source.length - left.source.length);
const resolveSystemLocale = (): SupportedMobileLocale =>
(Intl.DateTimeFormat().resolvedOptions().locale || "zh-CN").toLowerCase().startsWith("en") ? "en-US" : "zh-CN";
resolveSupportedLocale(Intl.DateTimeFormat().resolvedOptions().locale);
export const translateMobileText = (value: string, locale: SupportedMobileLocale) => {
if (locale !== "en-US" || !/[\u3400-\u9fff]/.test(value)) {
@@ -6,7 +6,9 @@ import {
flattenNotebooks,
getNotebookAncestorIds,
getNotebookParentIdSet,
getResolvedMobileLocale,
getTextSearchMatches,
isEnglishMobileLocale,
markdownToLocalText,
parseTags,
} from "./workspace-utils";
@@ -57,4 +59,24 @@ describe("mobile workspace utilities", () => {
expect(formatMemoDetailDate("2010-08-30T12:34:00.000Z", "en-US")).toContain("2010");
expect(formatMemoDetailDate("not-a-date", "zh-CN")).toBe("");
});
test("treats explicit locale preferences as the resolved UI locale", () => {
expect(getResolvedMobileLocale("zh-CN")).toBe("zh-CN");
expect(getResolvedMobileLocale("en-US")).toBe("en-US");
expect(isEnglishMobileLocale("zh-CN")).toBe(false);
expect(isEnglishMobileLocale("en-US")).toBe(true);
});
test("falls unmatched system languages back to English", () => {
const original = Intl.DateTimeFormat.prototype.resolvedOptions;
Intl.DateTimeFormat.prototype.resolvedOptions = function resolvedOptions() {
return { ...original.call(this), locale: "ja-JP" };
};
try {
expect(getResolvedMobileLocale("system")).toBe("en-US");
expect(isEnglishMobileLocale("system")).toBe(true);
} finally {
Intl.DateTimeFormat.prototype.resolvedOptions = original;
}
});
});
+5 -2
View File
@@ -1,4 +1,5 @@
import { DEFAULT_MEMO_TITLE, type MemoSummary, type Notebook } from "@edgeever/shared";
import { resolveSupportedLocale } from "@edgeever/shared/i18n/locales";
import type { MobileLocalePreference } from "../lib/preferences";
export type NotebookOption = {
@@ -113,10 +114,12 @@ export const filterCollapsedNotebookOptions = (options: NotebookOption[], collap
};
export const getResolvedMobileLocale = (localePreference: MobileLocalePreference) =>
localePreference === "system" ? Intl.DateTimeFormat().resolvedOptions().locale || "zh-CN" : localePreference;
localePreference === "system"
? resolveSupportedLocale(Intl.DateTimeFormat().resolvedOptions().locale)
: localePreference;
export const isEnglishMobileLocale = (localePreference: MobileLocalePreference) =>
getResolvedMobileLocale(localePreference).startsWith("en");
getResolvedMobileLocale(localePreference) === "en-US";
export const formatDate = (value: string, localePreference: MobileLocalePreference = "system") =>
new Intl.DateTimeFormat(getResolvedMobileLocale(localePreference), {
+33 -1
View File
@@ -3,6 +3,10 @@
This directory contains the current EdgeEver store listing artwork, screenshots,
and metadata.
Listing copy is pasted in App Store Connect and Play Console. It is not uploaded
by `bun run publish:stores`. That command only delivers store binaries; adding
an incomplete Japanese locale there would block App Review.
The root-level artwork is used by Google Play:
- `feature-graphic.png`: 1024 × 500 feature graphic.
@@ -10,5 +14,33 @@ The root-level artwork is used by Google Play:
- `phone-*-1080x1920.jpg`: Play-ready portrait screenshots used for phone and tablet listing slots.
The `app-store/` directory contains the App Store metadata source of truth and
Apple-ready iPhone screenshots. Keep the English and Simplified Chinese
Apple-ready iPhone screenshots. Keep English, Simplified Chinese, and Japanese
metadata files aligned when changing listing content.
The `play/` directory contains Google Play listing copy for English, Simplified
Chinese, and Japanese.
## Publish a Japanese listing
Japanese in-app UI is not required. Japanese users currently see English chrome.
Reuse the existing screenshots.
### App Store Connect
1. Open the EdgeEver app → App Information.
2. Add the **Japanese** (`ja`) localization if it is missing.
3. Paste `app-store/metadata.ja.md` into name, subtitle, promotional text,
description, keywords, URLs, and the current version's What's New.
4. Save. Do not attach this locale to a binary review until those fields are
complete.
### Google Play Console
Play may already show Japanese from an earlier translation. Replace it with the
copy in this repository.
1. Open Grow → Store presence → Main store listing → Manage translations.
2. Add **Japanese (Japan)** if it is missing.
3. Paste `play/metadata.ja-JP.md` into title, short description, and full
description.
4. Publish the store listing. This does not upload a new AAB.
@@ -0,0 +1,94 @@
# EdgeEver App Store metadata (ja)
Paste into App Store Connect → App Information / version localization → Japanese (`ja`).
Do not submit this locale through `bun run publish:stores`. Fastlane only uploads
English and Simplified Chinese "What's New" during binary review.
## App information
- Name: EdgeEver
- Subtitle: セルフホストのノートワークスペース
- Primary category: 仕事効率化
- Secondary category: ユーティリティ
- Content rights: EdgeEver は初期状態で第三者のコンテンツを含みません。ユーザーは自分で管理する EdgeEver インスタンスにアプリを接続します。
- Privacy policy URL: https://edgeever.org/en/privacy
## Version listing
### Promotional text
自分で運用するオープンソースの EdgeEver に接続して、ノートをすばやく記録・整理・同期できます。データは自分の手元に置けます。
### Description
EdgeEver は、オープンソースでセルフホストできるノートワークスペースです。書き心地のよさと、データの主導権を両立したい人向けです。
自分または所属組織が運用する EdgeEver インスタンスにアプリを接続すると、iPhone からノートブック、ノート、タグ、添付ファイル、検索、ゴミ箱を使えます。
主な機能:
• ノートブックとノートのすばやい閲覧
• リッチテキストでのノート編集
• 入れ子のノートブックとタグ
• ワークスペース全体の検索
• 画像と添付ファイルのアップロード
• 通信が不安定なときのローカル下書きと同期キュー
• 端末内に安全に保存されるログイン情報
• 広告、解析、追跡、テレメトリーなし
EdgeEver は、あなた自身の Cloudflare アカウント上で動作します。ノートや認証情報を保管・中継する EdgeEver の中央サービスはありません。利用するには、edgeever.org の案内に従ってインスタンスを導入するか、所属組織が用意したインスタンスに接続してください。
EdgeEver は独立したプロジェクトであり、Evernote Corporation およびその関連会社とは提携、後援、承認の関係にありません。
### Keywords
ノート,メモ帳,ノートブック,セルフホスト,Markdown,知識管理,同期,オープンソース,Cloudflare,効率化
### URLs
- Support URL: https://edgeever.org/en/contact
- Marketing URL: https://edgeever.org/en/
### Copyright
2026 Zhengzhou Binggui Network Technology Co., Ltd.
### What's New
端末からモデル提供元へ直接接続し、AI によるノート生成をストリーミングします。クラウド上のインスタンスを経由しません。このクライアントが接続先インスタンスより新しい場合は、対応する Cloudflare または Docker の更新手順をシステム情報に静かに表示します。
## App Review information
- Demo instance URL: https://demo.edgeever.org
- Username: ee-demo
- Password: demo#dZ6Q29Zjfor%
- Contact first name: Shihao
- Contact last name: Ma
- Contact phone: +86 185 3927 0558
- Contact email: 1245207870@qq.com
### Review notes
APP REVIEW LOGIN — paste these values into the sign-in form.
Demo Instance URL (type this exact HTTPS URL into Instance URL; do not type "demo"):
https://demo.edgeever.org
Username: ee-demo
Password: demo#dZ6Q29Zjfor%
How to sign in:
1. Launch EdgeEver.
2. In Instance URL, enter https://demo.edgeever.org
3. Enter the username and password above.
4. Tap Sign in. You should land in a notes workspace with sample content.
Do not enter "demo" alone, and do not use any example.com placeholder.
EdgeEver is a self-hosted personal knowledge base. Users connect the app to their own instance URL; there is no shared SaaS backend. The public demo is for App Review and resets daily around 03:00 China Standard Time (19:00 UTC). Please do not store private data there.
HTTPS is required for the public demo. HTTP is only for local/LAN self-hosting and shows an explicit warning.
The app does not create accounts. Production accounts are created and controlled by the administrator of the self-hosted EdgeEver instance. Notes, attachments, credentials, and sync traffic travel directly between the device and the instance selected by the user; the app developer operates no relay or centralized storage service. Account and server-side data deletion is handled by that instance administrator. Users can sign out to remove the local session and can remove local app data through iOS Settings.
The app uses only standard encryption supplied by the operating system and HTTPS libraries and does not implement proprietary or non-standard cryptography.
@@ -0,0 +1,31 @@
# EdgeEver Google Play metadata (en-US)
Source of truth for the default Play listing. Paste into Play Console →
Main store listing → English (United States).
## Title
EdgeEver
## Short description
Private notes for your self-hosted EdgeEver workspace.
## Full description
EdgeEver is an open-source, self-hosted notes workspace built for fast capture, focused organization, and reliable access across devices.
Connect the app directly to your own EdgeEver instance and keep control of where your data lives.
• Browse notebooks, notes, tags, and attachments
• Search your local note mirror instantly
• Create and edit notes even when the network is unreliable
• Sync queued changes with your self-hosted workspace in the background
• Organize content with nested notebooks and tags
• Add images and file attachments
• Review note history and manage your workspace
• Use a clean mobile interface aligned with the EdgeEver web experience
EdgeEver contains no ads, analytics, tracking, or telemetry. The app connects directly to the instance you choose; the project maintainers do not operate a relay that receives or stores your notes.
An EdgeEver self-hosted instance is required. Learn more and deploy your own workspace at edgeever.org.
@@ -0,0 +1,32 @@
# EdgeEver Google Play metadata (ja-JP)
Paste into Play Console → Main store listing → Japanese (Japan).
Do not ship this locale through `bun run publish:stores`; listing copy is
console metadata, not a store binary.
## Title
EdgeEver
## Short description
自分のサーバーで使う、オープンソースのノートアプリ
## Full description
EdgeEver は、すばやく書き留め、整理し、どの端末からでも安定して開ける、オープンソースのセルフホスト型ノートワークスペースです。
アプリをご自身の EdgeEver インスタンスに直接接続し、データの置き場所を自分で管理できます。
• ノートブック、ノート、タグ、添付ファイルを閲覧
• 端末内のノートミラーをすぐ検索
• 通信が不安定でもノートの作成と編集が可能
• キューに入れた変更をバックグラウンドで同期
• 入れ子のノートブックとタグで整理
• 画像やファイルを添付
• ノートの履歴を確認し、ワークスペースを管理
• Web 版と揃えた、すっきりしたモバイル画面
広告、解析、追跡、テレメトリーはありません。アプリはあなたが選んだインスタンスに直接接続します。プロジェクトの運営者は、ノートを受け取ったり保存したりする中継サーバーを運用していません。
利用にはセルフホストした EdgeEver インスタンスが必要です。詳しい案内と導入手順は edgeever.org をご覧ください。
@@ -0,0 +1,30 @@
# EdgeEver Google Play 元数据(简体中文)
Play 商店简体中文文案的来源。粘贴到 Play Console → 商店列表 → 中文(简体)。
## 标题
EdgeEver
## 简短说明
为你的自托管 EdgeEver 工作区记录私人笔记。
## 完整说明
EdgeEver 是开源、自托管的笔记工作区,适合快速记录、专注整理,并在各设备间可靠访问。
将应用直接连接到你自己的 EdgeEver 实例,由你决定数据存放在哪里。
• 浏览笔记本、笔记、标签和附件
• 即时搜索本机笔记镜像
• 网络不稳定时也能创建和编辑笔记
• 在后台把排队中的改动同步到自托管工作区
• 用嵌套笔记本和标签整理内容
• 添加图片和文件附件
• 查看笔记历史并管理工作区
• 使用与 EdgeEver 网页端一致的简洁移动界面
EdgeEver 不含广告、分析、追踪或遥测。应用直接连接到你选择的实例;项目维护者不运营接收或存储笔记的中转服务。
使用前需要自托管的 EdgeEver 实例。请访问 edgeever.org 了解详情并部署自己的工作区。
+6 -3
View File
@@ -60,13 +60,13 @@ const localeRedirectScript = `(() => {
const supportedLocales = ["zh-CN", "en-US"];
const normalizeLocale = (locale) => {
if (!locale) return null;
const normalized = String(locale).toLowerCase();
const normalized = String(locale).trim().replaceAll("_", "-").toLowerCase();
if (normalized === "zh" || normalized.startsWith("zh-")) {
return "zh-CN";
}
if (normalized === "en" || normalized === "en-us" || normalized.startsWith("en-")) {
if (normalized === "en" || normalized.startsWith("en-")) {
return "en-US";
}
@@ -90,8 +90,11 @@ const localeRedirectScript = `(() => {
};
const getBrowserLocale = () => {
const browserLocales = navigator.languages?.length ? navigator.languages : [navigator.language];
let sawCandidate = false;
for (const locale of browserLocales) {
if (!locale) continue;
sawCandidate = true;
const supported = normalizeLocale(locale);
if (supported) {
@@ -99,7 +102,7 @@ const localeRedirectScript = `(() => {
}
}
return null;
return sawCandidate ? "en-US" : null;
};
const getCurrentLocale = (pathname) => pathname === "/en" || pathname.startsWith("/en/") ? "en-US" : "zh-CN";
const isExplicitLocalePath = (pathname) => pathname === "/en" || pathname.startsWith("/en/");
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, test } from "bun:test";
import { defaultLocale, getBrowserLocale, getInitialLocale, normalizeLocale } from "./locales.ts";
const originalNavigator = globalThis.navigator;
const originalWindow = globalThis.window;
const setNavigatorLanguages = (languages, language = languages[0]) => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { languages, language },
});
};
afterEach(() => {
if (originalNavigator === undefined) {
delete globalThis.navigator;
} else {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: originalNavigator,
});
}
if (originalWindow === undefined) {
delete globalThis.window;
} else {
globalThis.window = originalWindow;
}
});
describe("web locale resolution", () => {
test("keeps Chinese and English browser languages on their shipped locales", () => {
setNavigatorLanguages(["zh-TW", "en-US"]);
expect(getBrowserLocale()).toBe("zh-CN");
setNavigatorLanguages(["en-GB"]);
expect(getBrowserLocale()).toBe("en-US");
expect(normalizeLocale("zh-Hans")).toBe("zh-CN");
});
test("falls unmatched browser languages back to English instead of Chinese", () => {
setNavigatorLanguages(["ja-JP", "ja"]);
expect(getBrowserLocale()).toBe("en-US");
setNavigatorLanguages(["fr-FR", "de-DE"]);
expect(getInitialLocale()).toBe("en-US");
expect(defaultLocale).toBe("zh-CN");
});
test("uses the first supported language in the browser preference list", () => {
setNavigatorLanguages(["ja-JP", "zh-CN"]);
expect(getBrowserLocale()).toBe("zh-CN");
});
});
+17 -27
View File
@@ -1,9 +1,17 @@
export const supportedLocales = ["zh-CN", "en-US"] as const;
import {
defaultLocale as sharedDefaultLocale,
matchSupportedLocale,
resolveSupportedLocaleFromCandidates,
supportedLocales as sharedSupportedLocales,
type SupportedLocale as SharedSupportedLocale,
} from "@edgeever/shared/i18n/locales";
export type SupportedLocale = (typeof supportedLocales)[number];
export const supportedLocales = sharedSupportedLocales;
export type SupportedLocale = SharedSupportedLocale;
export type AppLocalePreference = "system" | SupportedLocale;
export const defaultLocale: SupportedLocale = "zh-CN";
export const defaultLocale: SupportedLocale = sharedDefaultLocale;
export const localeStorageKey = "edgeever.locale.preference";
const legacyLocaleStorageKey = "edgeever.locale";
@@ -13,23 +21,8 @@ export const localeLabels: Record<SupportedLocale, string> = {
"en-US": "English",
};
export const normalizeLocale = (locale: string | null | undefined): SupportedLocale | null => {
if (!locale) {
return null;
}
const normalized = locale.toLowerCase();
if (normalized === "zh" || normalized.startsWith("zh-")) {
return "zh-CN";
}
if (normalized === "en" || normalized === "en-us" || normalized.startsWith("en-")) {
return "en-US";
}
return null;
};
export const normalizeLocale = (locale: string | null | undefined): SupportedLocale | null =>
matchSupportedLocale(locale);
export const readStoredLocale = (): SupportedLocale | null => {
try {
@@ -70,6 +63,7 @@ export const clearStoredLocale = () => {
export const getAppLocalePreference = (): AppLocalePreference => readStoredLocale() ?? "system";
/** Returns null only when the browser exposes no language list; unmatched languages become English. */
export const getBrowserLocale = (): SupportedLocale | null => {
if (typeof navigator === "undefined") {
return null;
@@ -77,15 +71,11 @@ export const getBrowserLocale = (): SupportedLocale | null => {
const browserLocales = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const locale of browserLocales) {
const supported = normalizeLocale(locale);
if (supported) {
return supported;
}
if (!browserLocales.some((locale) => locale?.trim())) {
return null;
}
return null;
return resolveSupportedLocaleFromCandidates(browserLocales);
};
export const getInitialLocale = () => readStoredLocale() ?? getBrowserLocale() ?? defaultLocale;
+2 -1
View File
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot } from "react-dom/client";
import { MobileStandaloneTiptapEditor } from "@/components/MobileStandaloneTiptapEditor";
import { initializeTheme } from "@/components/ThemeProvider";
import { defaultLocale, getBrowserLocale } from "@/i18n/locales";
import "./i18n";
import "./styles/mobile-markdown-editor.css";
@@ -42,7 +43,7 @@ class MobileEditorErrorBoundary extends React.Component<React.PropsWithChildren,
render() {
if (!this.state.failed) return this.props.children;
const english = navigator.language.toLowerCase().startsWith("en");
const english = (getBrowserLocale() ?? defaultLocale) === "en-US";
return (
<main className="mobile-editor-fatal" role="alert">
<section className="mobile-editor-fatal-card">
+6
View File
@@ -133,3 +133,9 @@ Fastlane (`apps/ios` `submit_review`) then selects the exact app version and
build number, submits App Review, and configures automatic release after
approval. Missing metadata, agreements, review information, or credentials
cause the workflow to fail without submitting a different build.
Store listing localizations (including Japanese) live in
`apps/mobile/store-assets/` and are pasted in App Store Connect and Play
Console. Do not add a new locale to Fastlane `submit_review` until that
locale is complete in the console. An incomplete localization can fail
App Review without changing the binary.
+5
View File
@@ -119,3 +119,8 @@ App Store Connect API Key 上传 App Store IPA。随后 Fastlane(`apps/ios`
`submit_review`)精确选择相同的 App Version 与 Build Number,提交 App Review,
并设置为审核通过后自动发布。元数据、协议、审核信息或凭据不完整时工作流会失败,
不会改为提交其他构建。
商店列表本地化(含日文)存放在 `apps/mobile/store-assets/`,需在 App Store
Connect 和 Play Console 中粘贴发布。在控制台补齐该语言的必填字段之前,不要把
新语言加入 Fastlane `submit_review`。不完整的本地化会在不改二进制的情况下让
App Review 失败。
+1
View File
@@ -13,6 +13,7 @@
"./image-gallery-editing": "./src/image-gallery-editing.ts",
"./native-image-gallery": "./src/native-image-gallery.ts",
"./i18n": "./src/i18n/index.ts",
"./i18n/locales": "./src/i18n/locales.ts",
"./memo-template-seeds": "./src/memo-template-seeds.ts",
"./mathematics": "./src/mathematics.ts",
"./mobile-editor": "./src/mobile-editor.ts",
+2 -1
View File
@@ -3,6 +3,7 @@ import type {
AiPromptParameterKind,
AiPromptResultMode,
} from "./ai-assistant";
import { resolveSupportedLocale } from "./i18n/locales";
export type AiPromptSeedKey = Exclude<AiAction, "custom">;
export type AiPromptSeedLocale = "zh-CN" | "en-US";
@@ -33,7 +34,7 @@ const seed = (
});
export const normalizeAiPromptSeedLocale = (locale: string | null | undefined): AiPromptSeedLocale =>
locale?.toLowerCase().startsWith("en") ? "en-US" : "zh-CN";
resolveSupportedLocale(locale);
export const localizeAiPromptSeed = (
promptSeed: AiPromptSeed,
+10
View File
@@ -1,2 +1,12 @@
export { enUS } from "./en-US";
export { zhCN } from "./zh-CN";
export {
defaultLocale,
matchSupportedLocale,
parseAcceptLanguage,
resolveSupportedLocale,
resolveSupportedLocaleFromCandidates,
supportedLocales,
unmatchedLocale,
type SupportedLocale,
} from "./locales";
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import {
defaultLocale,
matchSupportedLocale,
parseAcceptLanguage,
resolveSupportedLocale,
resolveSupportedLocaleFromCandidates,
unmatchedLocale,
} from "./locales.ts";
describe("supported locale matching", () => {
test("maps Chinese and English families onto the two shipped locales", () => {
expect(matchSupportedLocale("zh")).toBe("zh-CN");
expect(matchSupportedLocale("zh-CN")).toBe("zh-CN");
expect(matchSupportedLocale("zh_TW")).toBe("zh-CN");
expect(matchSupportedLocale("zh-Hans-CN")).toBe("zh-CN");
expect(matchSupportedLocale("en")).toBe("en-US");
expect(matchSupportedLocale("en-GB")).toBe("en-US");
expect(matchSupportedLocale("en_US")).toBe("en-US");
});
test("does not treat unrelated tags as Chinese or English", () => {
expect(matchSupportedLocale("ja")).toBeNull();
expect(matchSupportedLocale("ja-JP")).toBeNull();
expect(matchSupportedLocale("fr-FR")).toBeNull();
expect(matchSupportedLocale("pt-BR")).toBeNull();
expect(matchSupportedLocale("english")).toBeNull();
expect(matchSupportedLocale(null)).toBeNull();
});
});
describe("supported locale resolution", () => {
test("keeps Chinese as the empty default and English as the unmatched fallback", () => {
expect(defaultLocale).toBe("zh-CN");
expect(unmatchedLocale).toBe("en-US");
expect(resolveSupportedLocale(null)).toBe("zh-CN");
expect(resolveSupportedLocale("")).toBe("zh-CN");
expect(resolveSupportedLocale("ja-JP")).toBe("en-US");
expect(resolveSupportedLocale("ko")).toBe("en-US");
expect(resolveSupportedLocale("de-DE")).toBe("en-US");
});
test("walks browser or Accept-Language candidate lists in preference order", () => {
expect(parseAcceptLanguage("ja-JP,ja;q=0.9,en-US;q=0.8")).toEqual(["ja-JP", "ja", "en-US"]);
expect(resolveSupportedLocaleFromCandidates(["ja-JP", "en-US"])).toBe("en-US");
expect(resolveSupportedLocaleFromCandidates(["ja-JP", "zh-CN"])).toBe("zh-CN");
expect(resolveSupportedLocaleFromCandidates(["fr-FR", "de-DE"])).toBe("en-US");
expect(resolveSupportedLocale("ja-JP,zh-CN;q=0.8")).toBe("zh-CN");
expect(resolveSupportedLocale("fr-FR,fr;q=0.9")).toBe("en-US");
expect(resolveSupportedLocale("zh-CN,zh;q=0.9,en;q=0.8")).toBe("zh-CN");
});
});
+60
View File
@@ -0,0 +1,60 @@
export const supportedLocales = ["zh-CN", "en-US"] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
/** Used when there is no locale evidence (legacy callers, missing headers). */
export const defaultLocale: SupportedLocale = "zh-CN";
/** Used when locale evidence exists but is neither Chinese nor English. */
export const unmatchedLocale: SupportedLocale = "en-US";
export const matchSupportedLocale = (locale: string | null | undefined): SupportedLocale | null => {
if (!locale) {
return null;
}
const normalized = locale.trim().replaceAll("_", "-").toLowerCase();
if (normalized === "zh" || normalized.startsWith("zh-")) {
return "zh-CN";
}
if (normalized === "en" || normalized.startsWith("en-")) {
return "en-US";
}
return null;
};
export const parseAcceptLanguage = (value: string): string[] =>
value
.split(",")
.map((part) => part.split(";")[0]?.trim() ?? "")
.filter(Boolean);
export const resolveSupportedLocaleFromCandidates = (
candidates: readonly (string | null | undefined)[],
): SupportedLocale => {
let sawCandidate = false;
for (const candidate of candidates) {
if (!candidate || !candidate.trim()) {
continue;
}
sawCandidate = true;
for (const tag of parseAcceptLanguage(candidate)) {
const matched = matchSupportedLocale(tag);
if (matched) {
return matched;
}
}
}
return sawCandidate ? unmatchedLocale : defaultLocale;
};
export const resolveSupportedLocale = (locale?: string | null): SupportedLocale =>
resolveSupportedLocaleFromCandidates(locale ? [locale] : []);
@@ -11,6 +11,8 @@ describe("memo template seeds", () => {
expect(new Set(DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => seed.key)).size).toBe(5);
expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "zh-CN").title).toBe("灵感速记");
expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "en-GB").title).toBe("Quick Spark");
expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "ja-JP").title).toBe("Quick Spark");
expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], null).title).toBe("灵感速记");
});
test("uses workspace-scoped deterministic ids", () => {
+3 -1
View File
@@ -1,3 +1,5 @@
import { resolveSupportedLocale } from "./i18n/locales";
export type MemoTemplateSeedLocale = "zh-CN" | "en-US";
export type MemoTemplateSeedTranslation = {
@@ -92,7 +94,7 @@ export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [
export const normalizeMemoTemplateSeedLocale = (
locale: string | null | undefined,
): MemoTemplateSeedLocale => locale?.toLowerCase().startsWith("en") ? "en-US" : "zh-CN";
): MemoTemplateSeedLocale => resolveSupportedLocale(locale);
export const localizeMemoTemplateSeed = (
templateSeed: MemoTemplateSeed,
+2
View File
@@ -13,6 +13,7 @@ const loginSurfaces = [
const reviewNotes = [
"apps/mobile/store-assets/app-store/metadata.en-US.md",
"apps/mobile/store-assets/app-store/metadata.zh-CN.md",
"apps/mobile/store-assets/app-store/metadata.ja.md",
];
describe("App Review login", () => {
@@ -39,5 +40,6 @@ describe("App Review login", () => {
}
expect(read("apps/mobile/store-assets/app-store/metadata.en-US.md")).toContain("Username: ee-demo");
expect(read("apps/mobile/store-assets/app-store/metadata.zh-CN.md")).toContain("用户名:ee-demo");
expect(read("apps/mobile/store-assets/app-store/metadata.ja.md")).toContain("Username: ee-demo");
});
});
+101
View File
@@ -0,0 +1,101 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
const read = (relativePath: string) =>
readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8");
const APP_STORE_LIMITS = {
name: 30,
subtitle: 30,
promotionalText: 170,
description: 4000,
keywords: 100,
whatsNew: 4000,
};
const PLAY_LIMITS = {
title: 50,
shortDescription: 80,
fullDescription: 4000,
};
const field = (source: string, label: string) => {
const match = source.match(new RegExp(`^- ${label}: (.+)$`, "m"));
if (!match) {
throw new Error(`Missing field: ${label}`);
}
return match[1].trim();
};
const headingBlock = (source: string, heading: string) => {
const match = source.match(
new RegExp(`(?:### |## )${heading}\\n\\n([\\s\\S]*?)(?:\\n\\n(?:### |## )|\\n## |$)`),
);
if (!match) {
throw new Error(`Missing heading: ${heading}`);
}
return match[1].trim();
};
describe("App Store listing copy", () => {
const english = read("apps/mobile/store-assets/app-store/metadata.en-US.md");
const chinese = read("apps/mobile/store-assets/app-store/metadata.zh-CN.md");
const japanese = read("apps/mobile/store-assets/app-store/metadata.ja.md");
test("keeps Japanese fields within App Store Connect limits", () => {
expect(field(japanese, "Name").length).toBeLessThanOrEqual(APP_STORE_LIMITS.name);
expect(field(japanese, "Subtitle").length).toBeLessThanOrEqual(APP_STORE_LIMITS.subtitle);
expect(headingBlock(japanese, "Promotional text").length).toBeLessThanOrEqual(
APP_STORE_LIMITS.promotionalText,
);
expect(headingBlock(japanese, "Description").length).toBeLessThanOrEqual(
APP_STORE_LIMITS.description,
);
expect(headingBlock(japanese, "Keywords").length).toBeLessThanOrEqual(APP_STORE_LIMITS.keywords);
expect(headingBlock(japanese, "What's New").length).toBeLessThanOrEqual(APP_STORE_LIMITS.whatsNew);
});
test("points Japanese listing URLs at the English site and keeps the Evernote disclaimer", () => {
expect(field(japanese, "Privacy policy URL")).toBe("https://edgeever.org/en/privacy");
expect(japanese).toContain("https://edgeever.org/en/contact");
expect(japanese).toContain("https://edgeever.org/en/");
expect(headingBlock(japanese, "Description")).toContain("Evernote Corporation");
expect(headingBlock(japanese, "Keywords")).not.toContain("Evernote");
});
test("keeps App Review demo credentials in every App Store locale file", () => {
for (const source of [english, chinese, japanese]) {
expect(source).toContain("https://demo.edgeever.org");
expect(source).toContain("ee-demo");
expect(source).toContain("demo#dZ6Q29Zjfor%");
expect(source).not.toContain("notes.example.com");
}
});
});
describe("Google Play listing copy", () => {
const english = read("apps/mobile/store-assets/play/metadata.en-US.md");
const chinese = read("apps/mobile/store-assets/play/metadata.zh-CN.md");
const japanese = read("apps/mobile/store-assets/play/metadata.ja-JP.md");
test("keeps Play listing fields within Console limits", () => {
for (const [source, titleHeading, shortHeading, fullHeading] of [
[english, "Title", "Short description", "Full description"],
[chinese, "标题", "简短说明", "完整说明"],
[japanese, "Title", "Short description", "Full description"],
] as const) {
expect(headingBlock(source, titleHeading).length).toBeLessThanOrEqual(PLAY_LIMITS.title);
expect(headingBlock(source, shortHeading).length).toBeLessThanOrEqual(
PLAY_LIMITS.shortDescription,
);
expect(headingBlock(source, fullHeading).length).toBeLessThanOrEqual(
PLAY_LIMITS.fullDescription,
);
}
});
test("tells Japanese Play users that a self-hosted instance is required", () => {
expect(headingBlock(japanese, "Full description")).toContain("セルフホスト");
expect(headingBlock(japanese, "Full description")).toContain("edgeever.org");
});
});
+18
View File
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
const read = (relativePath: string) => readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8");
describe("unmatched locale fallback", () => {
test("site, Android, and iOS resolve non-Chinese system languages to English", () => {
const siteLayout = read("apps/site/src/layouts/Layout.astro");
const mobileLocale = read("apps/mobile/src/lib/mobile-locale.tsx");
const mobileUtils = read("apps/mobile/src/screens/workspace-utils.ts");
const iosPreferences = read("apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift");
expect(siteLayout).toContain('return sawCandidate ? "en-US" : null');
expect(mobileLocale).toContain("resolveSupportedLocale(Intl.DateTimeFormat().resolvedOptions().locale)");
expect(mobileUtils).toContain("resolveSupportedLocale(Intl.DateTimeFormat().resolvedOptions().locale)");
expect(iosPreferences).toContain("return !code.hasPrefix(\"zh\")");
});
});