diff --git a/scripts/settingsPersistence.test.ts b/scripts/settingsPersistence.test.ts index 50b67d7..6df0894 100644 --- a/scripts/settingsPersistence.test.ts +++ b/scripts/settingsPersistence.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { callbackBodies, sliceBetween } from './sourceTree.js'; +// Plain TypeScript, no runes: safe to import statically, unlike the store below. +import { getSupportedLanguages, translations } from '../src/lib/utils/i18n.js'; /* * `settings.svelte.ts` is a runes module, so it cannot be imported the way the @@ -90,6 +92,7 @@ const { clampToRange, createSettingsPersistence, detectSystemLanguage, + isSupportedLanguage, isWithinRange, parseStoredNumber, resolveLanguageTag, @@ -475,6 +478,32 @@ test('a stored language is validated against the catalogue', () => { Object.defineProperty(globalThis, 'navigator', { value: { language: 'en-US' }, configurable: true }); }); +test('the validator accepts exactly the languages the dialog offers', () => { + // SUPPORTED_LANGUAGE_CODES is derived from getSupportedLanguages(), so the + // first assertion is true by construction *today* — and that is the point. + // It is the assertion that fails the moment someone re-forks the catalogue + // into this module, which is how the two copies got here in the first place. + // The `pt` drift the fork produced was in `name`/`nativeName`, which codes + // cannot see; the `nativeName:` rule in singleImplementationConvention.test.ts + // covers that half. + const offered = getSupportedLanguages().map((entry) => entry.code); + + // Not by construction: `translations` and LANGUAGE_BY_PRIMARY_SUBTAG (via + // resolveLanguageTag) are hand-maintained beside the catalogue. A language + // offered in the but has no dictionary`); + } + assert.deepEqual(Object.keys(translations).sort(), [...offered].sort()); + + for (const tag of ['nb', 'nn', 'pt-PT', 'pt-BR', 'zh-Hant', 'zh-Hans', 'en_GB']) { + const resolved = resolveLanguageTag(tag); + assert.ok(resolved && isSupportedLanguage(resolved), `${tag} resolves to ${resolved}, which will not survive a reload`); + } +}); + /* * --------------------------------------------------------------------------- * Task 5 — the settings dialog restores focus to whatever opened it. diff --git a/scripts/singleImplementationConvention.test.ts b/scripts/singleImplementationConvention.test.ts index 6c8a042..d39976e 100644 --- a/scripts/singleImplementationConvention.test.ts +++ b/scripts/singleImplementationConvention.test.ts @@ -137,6 +137,48 @@ const RULES: Rule[] = [ marker: /--highlight-color:/g, allowed: ['src/lib/MarkdownViewer.svelte'], }, + { + name: 'the supported-language catalogue has one definition', + why: 'settings.svelte.ts carried a second { code, name, nativeName } table beside getSupportedLanguages(). Only its `code` column was read — SUPPORTED_LANGUAGE_CODES mapped it and nothing else touched it — so the display columns were dead data that nothing compared against the live catalogue, and they drifted: `pt` was "Portuguese" in the table the language renders, rather than `getSupportedLanguages` — a second + // table is by construction a site that does NOT call the accessor. The + // property is public: Settings.svelte reads `lang.nativeName` off the + // returned objects, so this is a cross-module field name, not a local. + // The read site spells it `lang.nativeName`, which the `:` excludes. + marker: /nativeName\s*:/g, + allowed: ['src/lib/utils/i18n.ts'], + }, + { + name: 'the LanguageCode union has one definition', + why: 'The union was declared identically in i18n.ts and settings.svelte.ts. Two unions over the same 26 codes type-check against each other only while they agree; adding a language to one leaves the other rejecting it, and the compiler reports that as an unrelated assignability error far from either declaration.', + // The declaration, not a mention: `export type { LanguageCode }` (the + // re-export settings.svelte.ts keeps so its importers are unaffected) has + // a brace between the keyword and the name and is deliberately not matched. + marker: /export type LanguageCode\s*=/g, + allowed: ['src/lib/utils/i18n.ts'], + }, + { + name: 'the TOC width bounds have one definition', + why: 'MarkdownViewer.svelte re-declared `const TOC_MIN_WIDTH = 180` / `TOC_MAX_WIDTH = 420` next to TOC_WIDTH_RANGE, the object NumericSettingRange documents as the single source of truth and the object settings.setTocWidth already clamps against. Bounds the drag handle enforces but persistence does not (or the reverse) are a width the user can set and not keep.', + // The defect shape, spelled as it was spelled. Allowed nowhere: the drag + // clamp, the Home/End jumps and the aria-valuemin/max on the separator all + // read TOC_WIDTH_RANGE now, so any reappearance of a locally named TOC + // bound is the second definition. `TOC_RESIZE_STEP` is *not* covered and + // must not be: it is the 16px arrow-key increment, not TOC_WIDTH_RANGE.step. + marker: /TOC_(?:MIN|MAX)_WIDTH/g, + allowed: [], + }, + { + name: 'the TOC separator advertises the settings range', + why: 'aria-valuemin/max is the bound assistive tech reports; a literal there goes stale silently because no visual check can see it. This is the half of the TOC rule above that a differently-named copy would escape.', + marker: /aria-valuemin=/g, + allowed: ['src/lib/MarkdownViewer.svelte'], + requires: { + pattern: /aria-valuemin=\{TOC_WIDTH_RANGE\.min\}\s*\n\s*aria-valuemax=\{TOC_WIDTH_RANGE\.max\}/, + message: 'the resize separator must advertise TOC_WIDTH_RANGE.min/.max, not numbers of its own', + }, + }, ]; const SOURCES = readSourceFiles('src'); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index ed4a6be..fe59896 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -85,7 +85,7 @@ import { getScrollTopForSyncPosition, type ScrollSyncPosition, } from './utils/scrollSync.js'; -import { settings } from './stores/settings.svelte.js'; +import { settings, TOC_WIDTH_RANGE } from './stores/settings.svelte.js'; import { t } from './utils/i18n.js'; import { createWindowSession } from './sessions/windowSession.svelte.js'; import { createDocumentSession, type LoadMarkdownOptions } from './sessions/documentSession.svelte.js'; @@ -264,8 +264,10 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu localStorage.getItem('isFullWidth'), )); let viewerWidth = $state(0); - const TOC_MIN_WIDTH = 180; - const TOC_MAX_WIDTH = 420; + // The bounds come from TOC_WIDTH_RANGE, the same object settings.setTocWidth + // clamps against, so the handle cannot offer a width persistence would shrink. + // The keyboard increment stays local: TOC_WIDTH_RANGE.step is 1, the spin-button + // granularity of the numeric settings input, and arrow keys move the splitter 16px. const TOC_RESIZE_STEP = 16; let isTocResizing = $state(false); let previewContentWidth = $derived(getPreviewContentWidth(settings.previewMaxWidth, isFullWidth)); @@ -427,7 +429,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu } function setTocWidth(width: number) { - settings.setTocWidth(Math.min(TOC_MAX_WIDTH, Math.max(TOC_MIN_WIDTH, width))); + settings.setTocWidth(Math.min(TOC_WIDTH_RANGE.max, Math.max(TOC_WIDTH_RANGE.min, width))); } function handleTocResizeKeyDown(e: KeyboardEvent) { @@ -441,10 +443,10 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu if (e.key === 'Home') { e.preventDefault(); - setTocWidth(TOC_MIN_WIDTH); + setTocWidth(TOC_WIDTH_RANGE.min); } else if (e.key === 'End') { e.preventDefault(); - setTocWidth(TOC_MAX_WIDTH); + setTocWidth(TOC_WIDTH_RANGE.max); } } @@ -3465,8 +3467,8 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu role="separator" aria-label={t('toc.resizeTableOfContents', settings.language)} aria-orientation="vertical" - aria-valuemin={TOC_MIN_WIDTH} - aria-valuemax={TOC_MAX_WIDTH} + aria-valuemin={TOC_WIDTH_RANGE.min} + aria-valuemax={TOC_WIDTH_RANGE.max} aria-valuenow={settings.tocWidth} tabindex="0" onpointerdown={startTocResize} diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index 7358782..393aa56 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -22,68 +22,26 @@ import { DEFAULT_PREVIEW_MAX_WIDTH, normalizePreviewMaxWidth, } from '../utils/previewWidth.js'; +import { getSupportedLanguages, type LanguageCode } from '../utils/i18n.js'; export type OSType = 'macos' | 'windows' | 'linux' | 'unknown'; -export type LanguageCode = - | 'en' // English - | 'ja' // Japanese - | 'zh-CN' // Chinese (Simplified) - | 'zh-TW' // Chinese (Traditional) - | 'ko' // Korean - | 'ru' // Russian - | 'es' // Spanish - | 'fr' // French - | 'de' // German - | 'pt-BR' // Portuguese (Brazil) - | 'it' // Italian - | 'pl' // Polish - | 'nl' // Dutch - | 'sv' // Swedish - | 'vi' // Vietnamese - | 'pt' // Portuguese (European) - | 'ro' // Romanian - | 'hu' // Hungarian - | 'cs' // Czech - | 'sk' // Slovak - | 'el' // Greek - | 'fi' // Finnish - | 'da' // Danish - | 'no' // Norwegian - | 'id' // Indonesian - | 'tr'; // Turkish - -const SUPPORTED_LANGUAGES: { code: LanguageCode; name: string; nativeName: string }[] = [ - { code: 'cs', name: 'Czech', nativeName: 'Čeština' }, - { code: 'da', name: 'Danish', nativeName: 'Dansk' }, - { code: 'nl', name: 'Dutch', nativeName: 'Nederlands' }, - { code: 'en', name: 'English', nativeName: 'English' }, - { code: 'fi', name: 'Finnish', nativeName: 'Suomi' }, - { code: 'fr', name: 'French', nativeName: 'Français' }, - { code: 'de', name: 'German', nativeName: 'Deutsch' }, - { code: 'el', name: 'Greek', nativeName: 'Ελληνικά' }, - { code: 'hu', name: 'Hungarian', nativeName: 'Magyar' }, - { code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia' }, - { code: 'it', name: 'Italian', nativeName: 'Italiano' }, - { code: 'ja', name: 'Japanese', nativeName: '日本語' }, - { code: 'ko', name: 'Korean', nativeName: '한국어' }, - { code: 'no', name: 'Norwegian', nativeName: 'Norsk' }, - { code: 'pl', name: 'Polish', nativeName: 'Polski' }, - { code: 'pt', name: 'Portuguese (European)', nativeName: 'Português (Europeu)' }, - { code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' }, - { code: 'ro', name: 'Romanian', nativeName: 'Română' }, - { code: 'ru', name: 'Russian', nativeName: 'Русский' }, - { code: 'sk', name: 'Slovak', nativeName: 'Slovenčina' }, - { code: 'es', name: 'Spanish', nativeName: 'Español' }, - { code: 'sv', name: 'Swedish', nativeName: 'Svenska' }, - { code: 'tr', name: 'Turkish', nativeName: 'Türkçe' }, - { code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt' }, - { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' }, - { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' }, -]; - -const SUPPORTED_LANGUAGE_CODES: readonly LanguageCode[] = SUPPORTED_LANGUAGES.map((entry) => entry.code); - -function isSupportedLanguage(value: unknown): value is LanguageCode { + +export type { LanguageCode }; + +/** + * The codes `isSupportedLanguage` will accept out of persisted storage, taken + * from the same catalogue the language `