Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions scripts/settingsPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,6 +92,7 @@ const {
clampToRange,
createSettingsPersistence,
detectSystemLanguage,
isSupportedLanguage,
isWithinRange,
parseStoredNumber,
resolveLanguageTag,
Expand Down Expand Up @@ -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 <select> with no dictionary renders as raw English, and a
// language the tag resolver can produce but the validator rejects makes
// detectSystemLanguage's result unstorable.
for (const code of offered) {
assert.ok(translations[code], `${code} is offered by the language <select> 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.
Expand Down
42 changes: 42 additions & 0 deletions scripts/singleImplementationConvention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <select> renders and "Portuguese (European)" in the dead one. A drift no user can see is a drift no bug report can find.',
// Pins `nativeName`, the column a catalogue exists to carry and the one
// the <select> 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');
Expand Down
18 changes: 10 additions & 8 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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}
Expand Down
78 changes: 18 additions & 60 deletions src/lib/stores/settings.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<select>` renders.
*
* This module used to carry its own `{ code, name, nativeName }` table beside
* `getSupportedLanguages()`. Only the `code` column was ever read, so the two
* copies of the display columns drifted unnoticed: `pt` was "Portuguese" in
* the catalogue the dialog renders and "Portuguese (European)" in the copy
* here, and no user ever saw the second spelling. Deriving from the catalogue
* means a language can only be added, removed or renamed in one place.
*/
const SUPPORTED_LANGUAGE_CODES: readonly LanguageCode[] = getSupportedLanguages().map((entry) => entry.code);

export function isSupportedLanguage(value: unknown): value is LanguageCode {
return typeof value === 'string' && (SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(value);
}

Expand Down
Loading