From 043124578a2d9b0191c6b96ecc58d80068843df8 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 23 Aug 2026 00:02:12 +0900 Subject: [PATCH] Add locale-aware dictionary links and Korean column defaults (#43) --- app/composables/prefs.ts | 46 +++++++++++++++++++++++++-------- app/locales/index.ts | 33 ++++++++++++++++++----- app/locales/ko-kr.ts | 2 +- app/pages/char/[key].vue | 13 ++++++++-- app/utils/preference-restore.ts | 1 + scripts/tests/chars.test.ts | 40 ++++++++++++++++++++++++++-- scripts/tests/columns.test.ts | 9 +++++++ scripts/tests/i18n.test.ts | 27 +++++++++++++++++++ shared/links.ts | 27 +++++++++++++++---- shared/types.ts | 15 ++++++++++- 10 files changed, 185 insertions(+), 28 deletions(-) diff --git a/app/composables/prefs.ts b/app/composables/prefs.ts index 4f778ec..f18f166 100644 --- a/app/composables/prefs.ts +++ b/app/composables/prefs.ts @@ -1,4 +1,5 @@ import { + applyKoreanColumnDefault, COLUMNS, DEFAULT_HIDDEN_COLUMNS, REGIONS, @@ -6,12 +7,14 @@ import { type Region, } from '~~/shared/types.ts' import { + COLUMN_MODE_KEY, FLAGS_KEY, HIDDEN_KEY, OUTLINE_KEY, VISIBILITY_VERSION_KEY, } from '~/utils/preference-restore.ts' +type PreferenceMode = '' | 'default' | 'custom' const isColumn = (value: string): value is Column => (COLUMNS as readonly string[]).includes(value) @@ -24,33 +27,53 @@ const isColumn = (value: string): value is Column => * remaining forms are partitioned again among themselves, and two regions that * only ever parted company through the hidden one now read as one form. * - * Stored as what is hidden rather than what is shown. Korea is the one - * opt-in comparison: the original four regions and Japan's old form remain - * visible by default. + * Stored as what is hidden rather than what is shown. Korea starts hidden in + * every interface except Korean, where it starts visible. That locale-aware + * default follows language changes only until the reader customizes any + * column; from then on their stored selection is authoritative. */ export function useColumnVisibility() { + const { locale } = useT() const hidden = useLocalStorage(HIDDEN_KEY, [ ...DEFAULT_HIDDEN_COLUMNS, ]) const visibilityInitialized = useLocalStorage(VISIBILITY_VERSION_KEY, false) + // An empty value identifies installations from before preference provenance + // was recorded. Do not write that default: its absence is what lets the + // migration preserve every existing v2 selection as custom. + const preferenceMode = useLocalStorage(COLUMN_MODE_KEY, '', { + writeDefaults: false, + }) + + const applyLocaleDefault = () => { + hidden.value = applyKoreanColumnDefault( + hidden.value, + locale.value === 'ko-KR', + ) + } // Read on the client only: the prerendered HTML has to match what hydration // produces, and localStorage is not available while prerendering. const mounted = ref(false) onMounted(() => { - // Existing installations already have an empty `hanji:hidden` value from - // the four-region build. Mark Korea hidden once during the upgrade while - // preserving every earlier column choice. Afterwards the reader's Korea - // toggle is authoritative. + // The v2 migration predates the locale-aware default. A missing v2 marker + // means either a new reader or the older four-region build, whose choices + // could not include Korea; preserve its other columns and choose Korea by + // locale. Existing v2 readers may already have toggled Korea, so their + // exact stored value becomes custom rather than being guessed at. if (!visibilityInitialized.value) { - hidden.value = COLUMNS.filter( - (column) => column === 'kr' || hidden.value.includes(column), - ) + applyLocaleDefault() + preferenceMode.value = 'default' visibilityInitialized.value = true - } + } else if (!preferenceMode.value) preferenceMode.value = 'custom' mounted.value = true }) + watch(locale, () => { + if (mounted.value && preferenceMode.value === 'default') + applyLocaleDefault() + }) + const off = computed>(() => { if (!mounted.value) return new Set(DEFAULT_HIDDEN_COLUMNS) const chosen = new Set(hidden.value.filter(isColumn)) @@ -90,6 +113,7 @@ export function useColumnVisibility() { function toggle(column: Column): void { if (locked(column)) return + preferenceMode.value = 'custom' const chosen = new Set(off.value) if (chosen.has(column)) chosen.delete(column) else chosen.add(column) diff --git a/app/locales/index.ts b/app/locales/index.ts index f92df8c..ef3cc0c 100644 --- a/app/locales/index.ts +++ b/app/locales/index.ts @@ -1,5 +1,9 @@ +import { + REGIONS, + type FrequencyRegion, + type Region, +} from '../../shared/types.ts' import { zhCN } from './zh-cn.ts' -import type { FrequencyRegion } from '../../shared/types.ts' /** * The language never enters the URL: one prerendered build, locale decided on @@ -13,17 +17,34 @@ export type Locale = (typeof LOCALES)[number] /** What the prerendered HTML carries, and what anything unmatched falls back to. */ export const DEFAULT_LOCALE: Locale = 'zh-CN' +/** The regional dictionaries native to each interface language. */ +export const LOCALE_DICTIONARY_REGION = { + 'zh-CN': 'cn', + 'zh-TW': 'tw', + 'zh-HK': 'hk', + 'ja-JP': 'jp', + 'ko-KR': 'kr', +} as const satisfies Record + /** * The corpus that best matches each interface language. Korea has no * frequency dataset, so its interface deliberately falls back to the existing * mainland corpus instead of implying that another region is Korean data. */ -export const LOCALE_FREQUENCY_REGION: Record = { - 'zh-CN': 'cn', - 'zh-TW': 'tw', - 'zh-HK': 'hk', - 'ja-JP': 'jp', +export const LOCALE_FREQUENCY_REGION = { + ...LOCALE_DICTIONARY_REGION, 'ko-KR': 'cn', +} as const satisfies Record + +/** Dictionaries follow either the comparison or the active interface locale. */ +export function dictionaryRegionsFor( + locale: Locale, + visible: readonly Region[], +): Region[] { + const local = LOCALE_DICTIONARY_REGION[locale] + return REGIONS.filter( + (region) => region === local || visible.includes(region), + ) } export type Messages = typeof zhCN diff --git a/app/locales/ko-kr.ts b/app/locales/ko-kr.ts index e821463..f1c3cad 100644 --- a/app/locales/ko-kr.ts +++ b/app/locales/ko-kr.ts @@ -163,7 +163,7 @@ export const koKR: typeof zhCN = { method3: '페이지는 이 판정에 따라 칸을 다시 묶습니다. 같은 자형으로 판정된 칸은 같은 그룹 내 한 지역의 Noto 글꼴을 공통으로 사용하므로 화면에서도 실제로 같은 윤곽으로 표시됩니다. 대신 특정 지역판에만 있는 작은 차이가 위 규칙에서 제외되면 그 차이는 표시되지 않습니다.', scope1: - '중국 대륙의 《통용규범한자표》(通用規範漢字表, 2013), 대만의 《상용국자표준자체표》(常用國字標準字體表, 1982), 홍콩의 《상용자자형표》(常用字字形表), 일본의 《상용한자표》(常用漢字表, 2010), 한국의 《한문교육용기초한자》(漢文教育用基礎漢字, 2000) 다섯 목록의 합집합을 수록합니다. 간체자와 번체자, 일본 신자체와 구자체처럼 코드 포인트가 다른 대응 글자는 한 행으로 합쳐 총 {rows}행입니다. 한국 열은 기본적으로 숨겨져 있으며 표시 설정에서 켤 수 있습니다.', + '중국 대륙의 《통용규범한자표》(通用規範漢字表, 2013), 대만의 《상용국자표준자체표》(常用國字標準字體表, 1982), 홍콩의 《상용자자형표》(常用字字形表), 일본의 《상용한자표》(常用漢字表, 2010), 한국의 《한문교육용기초한자》(漢文教育用基礎漢字, 2000) 다섯 목록의 합집합을 수록합니다. 간체자와 번체자, 일본 신자체와 구자체처럼 코드 포인트가 다른 대응 글자는 한 행으로 합쳐 총 {rows}행입니다. 한국어 화면에서는 한국 열이 기본적으로 표시되며 표시 설정에서 끈 수 있습니다.', scope2: '이 가운데 {identical}행은 다섯 지역의 자형이 완전히 같고, {allDiffer}행은 다섯 지역이 모두 다릅니다. 자형이 같은 한자도 수록합니다. 이 사이트는 다섯 지역의 한자표이며, 자형 차이는 여러 비교 기준 중 하나일 뿐입니다.', scope3: diff --git a/app/pages/char/[key].vue b/app/pages/char/[key].vue index 84c72ed..3a984f6 100644 --- a/app/pages/char/[key].vue +++ b/app/pages/char/[key].vue @@ -14,6 +14,7 @@ import { rowsNaming, useMorphingKey, } from '~/composables/chars.ts' +import { dictionaryRegionsFor } from '~/locales/index.ts' import { listPlace } from '~/utils/list-place.ts' // char-alias sends the regional forms -- /char/国, /char/著 -- to the row they @@ -21,7 +22,7 @@ import { listPlace } from '~/utils/list-place.ts' definePageMeta({ middleware: 'char-alias' }) const route = useRoute() -const { t, list } = useT() +const { t, list, locale } = useT() const { flagsOn, visibleColumns, visibleRegions } = usePrefs() const key = computed(() => decodeURIComponent(String(route.params.key))) @@ -306,7 +307,15 @@ const alsoSee = computed(() => { }) /** One row of references per character the group is written with. */ -const references = computed(() => dictGroups(row.value!, visibleRegions.value)) +const dictionaryRegions = computed(() => + dictionaryRegionsFor(locale.value, visibleRegions.value), +) +const references = computed(() => + dictGroups(row.value!, { + formRegions: visibleRegions.value, + dictionaryRegions: dictionaryRegions.value, + }), +) /** * Receives the morph from the list. The name goes on whatever the thumbnail diff --git a/app/utils/preference-restore.ts b/app/utils/preference-restore.ts index 7cf1b48..9587790 100644 --- a/app/utils/preference-restore.ts +++ b/app/utils/preference-restore.ts @@ -3,6 +3,7 @@ export const FLAGS_KEY = 'hanji:emoji-flags' export const OUTLINE_KEY = 'hanji:outline' export const HIDDEN_KEY = 'hanji:hidden' export const VISIBILITY_VERSION_KEY = 'hanji:columns-v2' +export const COLUMN_MODE_KEY = 'hanji:columns-mode' export const RESTORING_ATTRIBUTE = 'data-restoring-preferences' diff --git a/scripts/tests/chars.test.ts b/scripts/tests/chars.test.ts index d9b141a..ef736ff 100644 --- a/scripts/tests/chars.test.ts +++ b/scripts/tests/chars.test.ts @@ -214,10 +214,10 @@ describe('per-region stroke counts', () => { describe('outside references', () => { it('lists every character in the group, each with the full set', () => { - // 國 is written 国 and 國; both get all eight references + // 國 is written 国 and 國; both get all nine references const groups = dictGroups(row('國')) expect(groups.map((g) => g.form.char)).toEqual(['国', '國']) - expect(groups.every((g) => g.links.length === 8)).toBe(true) + expect(groups.every((g) => g.links.length === 9)).toBe(true) }) it('counts a name the group merged with as a character of its own', () => { @@ -240,6 +240,42 @@ describe('outside references', () => { expect(links.get('unihan')).toContain('codepoint=570B') }) + it('adds NAVER when Korean references are requested', () => { + expect(dictLinks('島', ['cn']).some((link) => link.id === 'naver')).toBe( + false, + ) + + const naver = dictLinks('島', ['kr']).find((link) => link.id === 'naver') + expect(naver).toEqual({ + id: 'naver', + name: 'NAVER 한자사전', + region: 'kr', + url: 'https://hanja.dict.naver.com/#/search?range=letter&query=%E5%B3%B6', + }) + expect( + dictGroups(row('的'), { + formRegions: ['cn'], + dictionaryRegions: ['kr'], + })[0]?.links, + ).toContainEqual(expect.objectContaining({ id: 'naver' })) + }) + + it.each([ + ['cn', ['zdic']], + ['hk', ['humanum']], + ['tw', ['moedict']], + ['jp', ['jitenon', 'jisho']], + ['kr', ['naver']], + ] as const)( + 'includes only %s regional dictionaries when that region is active', + (region, ids) => { + const regional = dictLinks('國', [region]) + .filter((link) => link.region) + .map((link) => link.id) + expect(regional).toEqual(ids) + }, + ) + it('looks 漢字辞典オンライン up by codepoint', () => { // The plain-text form only ever reached the search page expect( diff --git a/scripts/tests/columns.test.ts b/scripts/tests/columns.test.ts index 803d7b8..193ff0b 100644 --- a/scripts/tests/columns.test.ts +++ b/scripts/tests/columns.test.ts @@ -10,6 +10,7 @@ import { varietyOf, } from '../../shared/row.ts' import { + applyKoreanColumnDefault, COLUMNS, DEFAULT_HIDDEN_COLUMNS, REGIONS, @@ -52,6 +53,14 @@ describe('column ordering', () => { }) }) +describe('locale-aware column defaults', () => { + it('shows Korea for Korean while preserving every other hidden column', () => { + expect(applyKoreanColumnDefault(DEFAULT_HIDDEN_COLUMNS, true)).toEqual([]) + expect(applyKoreanColumnDefault(['cn', 'kr'], true)).toEqual(['cn']) + expect(applyKoreanColumnDefault(['cn'], false)).toEqual(['cn', 'kr']) + }) +}) + describe('reading a partition over the columns on show', () => { it('leaves a full row exactly as the data ships it', () => { for (const key of ['返', '骨', '海', '的']) diff --git a/scripts/tests/i18n.test.ts b/scripts/tests/i18n.test.ts index 0c497c9..8e14c42 100644 --- a/scripts/tests/i18n.test.ts +++ b/scripts/tests/i18n.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { + dictionaryRegionsFor, + LOCALE_DICTIONARY_REGION, LOCALE_FREQUENCY_REGION, matchLocale, } from '../../app/locales/index.ts' @@ -52,6 +54,31 @@ describe('locale-aware frequency defaults', () => { }) }) +describe('locale-aware dictionaries', () => { + it('maps every interface locale to its own regional dictionaries', () => { + expect(LOCALE_DICTIONARY_REGION).toEqual({ + 'zh-CN': 'cn', + 'zh-TW': 'tw', + 'zh-HK': 'hk', + 'ja-JP': 'jp', + 'ko-KR': 'kr', + }) + }) + + it('combines the locale region with the regions being compared', () => { + expect(dictionaryRegionsFor('ko-KR', ['cn', 'jp'])).toEqual([ + 'cn', + 'jp', + 'kr', + ]) + expect(dictionaryRegionsFor('zh-HK', ['cn', 'jp'])).toEqual([ + 'cn', + 'hk', + 'jp', + ]) + }) +}) + describe('locale-aware small numbers', () => { it('uses Korean counter forms in Korean copy', () => { expect(hanNumber(1, 'ko-KR')).toBe('한') diff --git a/shared/links.ts b/shared/links.ts index a49bd61..599c667 100644 --- a/shared/links.ts +++ b/shared/links.ts @@ -15,6 +15,13 @@ export interface DictLink { url: string } +export interface DictGroupOptions { + /** Regions whose forms are represented by reference rows. */ + formRegions?: readonly Region[] + /** Regions whose own dictionaries are included in each row. */ + dictionaryRegions?: readonly Region[] +} + /** A character the group is written with, and the font that draws it. */ export interface Form { char: string @@ -57,8 +64,11 @@ const enc = encodeURIComponent const hex = (char: string) => char.codePointAt(0)!.toString(16) /** Outside references for one character. */ -export function dictLinks(char: string): DictLink[] { - return [ +export function dictLinks( + char: string, + regions: readonly Region[] = REGIONS, +): DictLink[] { + const links: DictLink[] = [ { id: 'zdic', name: '汉典', @@ -91,6 +101,12 @@ export function dictLinks(char: string): DictLink[] { region: 'jp', url: `https://jisho.org/search/${enc(char)}%20%23kanji`, }, + { + id: 'naver', + name: 'NAVER 한자사전', + region: 'kr', + url: `https://hanja.dict.naver.com/#/search?range=letter&query=${enc(char)}`, + }, { id: 'zitools', name: '字統网', @@ -109,15 +125,16 @@ export function dictLinks(char: string): DictLink[] { url: `https://www.unicode.org/cgi-bin/GetUnihanData.pl?codepoint=${hex(char).toUpperCase()}`, }, ] + return links.filter((link) => !link.region || regions.includes(link.region)) } /** Every character the group is written with, each with its references. */ export function dictGroups( row: CharRow, - regions?: readonly Region[], + { formRegions = REGIONS, dictionaryRegions = REGIONS }: DictGroupOptions = {}, ): { form: Form; links: DictLink[] }[] { - return formsOf(row, regions).map((form) => ({ + return formsOf(row, formRegions).map((form) => ({ form, - links: dictLinks(form.char), + links: dictLinks(form.char, dictionaryRegions), })) } diff --git a/shared/types.ts b/shared/types.ts index 30a2479..4c7ce1a 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -21,9 +21,22 @@ export type Style = (typeof STYLES)[number] export const COLUMNS = ['cn', 'jp', 'old', 'hk', 'tw', 'kr'] as const export type Column = (typeof COLUMNS)[number] -/** Korea is available as an opt-in comparison; all other columns start on. */ +/** Korea starts hidden outside the Korean interface; all other columns start on. */ export const DEFAULT_HIDDEN_COLUMNS: readonly Column[] = ['kr'] +/** + * Apply the interface-language default for Korea without disturbing any other + * hidden columns. Callers only use this until the reader customizes the set. + */ +export function applyKoreanColumnDefault( + hidden: readonly string[], + koreanLocale: boolean, +): Column[] { + return COLUMNS.filter((column) => + column === 'kr' ? !koreanLocale : hidden.includes(column), + ) +} + /** A tuple in REGIONS order. */ export type RegionalTuple = [T, T, T, T, T] -- 2.51.2