From 16f16de0f0999510288ea644e0102a1d1d818e92 Mon Sep 17 00:00:00 2001 From: Bretton Date: Thu, 27 Aug 2026 16:16:31 -0700 Subject: [PATCH] refactor(quality): live-code cleanup, dead-code sweep, and hardening pass Address the live-code quality backlog and delete verified-dead code, then harden the paths a multi-model review flagged. No intended behaviour changes beyond those listed under "Behaviour". Behaviour: - isImage/isVideo are URL-parser based and anchored to the end of the pathname; a ".png" in a query string or fragment no longer classifies as media. Documented as a classifier, not a gate (callers still pass isSafeHref/isWebUrl first). - VirtualFeed drops svelte-infinite-scroll; the IntersectionObserver sentinel is the sole trigger. Fixes the feed-switch stall (a trigger dropped while a request is in flight is remembered and re-armed on every settle, including stale-feed discard paths), replaces the document-bottom chain check with a sentinel-relative one, and treats a page with zero new posts as end-of-feed instead of refetching forever. - PostIframe: sandbox attribute on the embed iframe; urlToEmbed is total (a scheme-less youtu.be link in markdown previously threw during render -> SSR 500); no iframe is emitted for a rejected URL. - theme.svelte.ts: localStorage reads/writes guarded, mirroring settings. - settings.parseTags toggle is now honoured by Post.svelte (was dead). - Search.svelte: a rejected search shows an error instead of spinning forever. Changes: - Rename ui/form/Switch -> SegmentedControl (collided with the kit toggle); drop the MultiSelect alias; Svelte-5 generics="T". - Extract parseTags/textToTag/MetaTag from PostMeta's module script into feature/post/tags.ts (readonly, copy-on-lookup, trimmed title). - Move theme.svelte.ts $effect.root below the export it references (TDZ). - Remove the three createEventDispatcher holdouts (Portal, Commands, UserAutocomplete -> onselect prop); drop dead CommunityCard block(). - Normalise all 90 svelte-hero-icons imports to @xylightdev/svelte-hero-icons and remove the svelte.config.js alias. - Delete 14 dead files (ImagePreviewInput, Duration, ProfileButton, ShieldIcon, ApplicationDenyModal, PrivateMessage, PostPoll, PostEvent, icon/photon.ts, legacy/contentview.ts, params/integer.ts, ca/tok i18n) and six dead exports with their tests (escapeHtml, findClosestNumber, toLemmyCommentSort, isGuest, SSR_ENABLED, validateInstance) plus the addSubscription/hasFavorite stubs; drop svelte-infinite-scroll and tslib. - hooks.server.test.ts fixtures target a real /util route. Tests: +97 (1372 -> 1469). New: tags.test.ts, PostIframe.test.ts (sandbox token set, embed origin allowlist), VirtualFeed.test.ts (jsdom component tests for the stall/loop cases), theme storage-failure tests, hostile-corpus positive controls so the markdown security tests cannot go vacuous. Verified: pnpm check/lint/test/build green; feed pagination and the feed-switch race validated in Firefox against the live dev stack. Co-Authored-By: Claude Fable 5 --- package.json | 2 - pnpm-lock.yaml | 16 - src/hooks.server.test.ts | 4 +- src/lib/api/client.svelte.ts | 37 -- src/lib/api/coves/sort.test.ts | 18 - src/lib/api/coves/sort.ts | 23 - src/lib/app/state/auth.svelte.test.ts | 47 +- src/lib/app/state/auth.svelte.ts | 7 - src/lib/app/state/i18n/ca.json | 442 -------------- src/lib/app/state/i18n/tok.json | 549 ------------------ src/lib/app/state/settings.svelte.ts | 4 - src/lib/app/state/theme/theme.svelte.test.ts | 65 +++ src/lib/app/state/theme/theme.svelte.ts | 132 +++-- src/lib/app/util/array.test.ts | 16 +- src/lib/app/util/array.ts | 7 - src/lib/app/util/text.test.ts | 16 - src/lib/app/util/text.ts | 9 - src/lib/app/util/url.test.ts | 117 +++- src/lib/app/util/url.ts | 44 +- src/lib/feature/comment/Comment.svelte | 8 +- src/lib/feature/comment/CommentActions.svelte | 2 +- src/lib/feature/comment/CommentForm.svelte | 2 +- .../feature/comment/CommentProvider.svelte | 2 +- src/lib/feature/comment/CommentTree.svelte | 11 +- .../feature/community/CommunityCard.svelte | 44 +- .../feature/community/CommunityForm.svelte | 2 +- .../feature/community/CommunityHeader.svelte | 2 +- .../feature/community/CommunityItem.svelte | 2 +- .../feature/community/CommunityItemBig.svelte | 2 +- .../feature/community/SubscribeButton.svelte | 2 +- src/lib/feature/filter/SortMenu.svelte | 2 +- src/lib/feature/filter/ViewSelect.svelte | 2 +- src/lib/feature/inbox/PrivateMessage.svelte | 30 - src/lib/feature/instance/CovesSidebar.svelte | 2 +- src/lib/feature/legacy/contentview.ts | 111 ---- .../feature/markdown/MarkdownEditor.svelte | 6 +- .../feature/markdown/renderers/MdCode.svelte | 2 +- .../feature/markdown/renderers/MdImage.svelte | 14 +- .../markdown/renderers/MdImage.test.ts | 83 ++- .../markdown/renderers/plugins.test.ts | 24 +- src/lib/feature/markdown/security.test.ts | 66 ++- .../moderation/ApplicationDenyModal.svelte | 49 -- src/lib/feature/moderation/ShieldIcon.svelte | 42 -- src/lib/feature/post/Post.svelte | 19 +- src/lib/feature/post/PostBody.svelte | 2 +- src/lib/feature/post/PostLink.svelte | 6 +- src/lib/feature/post/PostMeta.svelte | 94 +-- .../feature/post/actions/PostActions.svelte | 2 +- .../post/actions/PostActionsMenu.svelte | 6 +- src/lib/feature/post/feed/PostFeed.svelte | 2 +- src/lib/feature/post/feed/VirtualFeed.svelte | 119 ++-- src/lib/feature/post/feed/VirtualFeed.test.ts | 463 +++++++++++++++ src/lib/feature/post/form/PostForm.svelte | 2 +- src/lib/feature/post/index.ts | 8 +- src/lib/feature/post/media/PostEvent.svelte | 8 - src/lib/feature/post/media/PostIframe.svelte | 39 +- src/lib/feature/post/media/PostIframe.test.ts | 208 +++++++ .../post/media/PostMediaCompact.svelte | 2 +- src/lib/feature/post/media/PostPoll.svelte | 8 - src/lib/feature/post/tags.test.ts | 86 +++ src/lib/feature/post/tags.ts | 54 ++ .../feature/richtext/RichTextCodeBlock.svelte | 2 +- src/lib/feature/shell/PostListShell.svelte | 2 +- src/lib/feature/shell/Sidebar.svelte | 2 +- src/lib/feature/shell/navbar/Navbar.svelte | 7 +- src/lib/feature/shell/navbar/Profile.svelte | 2 +- .../shell/navbar/commands/CommandItem.svelte | 2 +- .../shell/navbar/commands/Commands.svelte | 6 +- src/lib/feature/user/PictrsImage.svelte | 2 +- src/lib/feature/user/ProfileButton.svelte | 58 -- src/lib/feature/user/ProfileSelection.svelte | 2 +- src/lib/feature/user/UserAutocomplete.svelte | 11 +- src/lib/feature/user/UserLink.svelte | 6 +- src/lib/feature/user/index.ts | 34 -- src/lib/ui/form/Duration.svelte | 59 -- src/lib/ui/form/ImageAttachForm.svelte | 2 +- src/lib/ui/form/ImageInputModal.svelte | 4 +- src/lib/ui/form/ImageInputUpload.svelte | 2 +- src/lib/ui/form/ImagePreviewInput.svelte | 70 --- src/lib/ui/form/ObjectAutocomplete.svelte | 2 +- ...{Switch.svelte => SegmentedControl.svelte} | 9 +- src/lib/ui/generic/ExpandableImage.svelte | 2 +- src/lib/ui/icon/photon.ts | 38 -- src/lib/ui/info/ErrorContainer.svelte | 2 +- src/lib/ui/info/Placeholder.svelte | 2 +- src/lib/ui/kit/button/Button.svelte | 2 +- src/lib/ui/kit/disclosure/Expandable.svelte | 2 +- src/lib/ui/kit/forms/select/Option.svelte | 2 +- src/lib/ui/kit/forms/select/Select.svelte | 6 +- src/lib/ui/kit/forms/select/context.ts | 2 +- src/lib/ui/kit/materials/Material.svelte | 2 +- src/lib/ui/kit/modal/Modal.svelte | 2 +- src/lib/ui/kit/modal/ModalContainer.svelte | 2 +- src/lib/ui/kit/note/Note.svelte | 2 +- src/lib/ui/kit/popover/MenuButton.svelte | 6 +- src/lib/ui/kit/popover/Portal.svelte | 5 +- src/lib/ui/kit/search/Search.svelte | 30 +- src/lib/ui/kit/toast/Toast.svelte | 2 +- src/lib/ui/layout/Pageination.svelte | 6 +- src/lib/ui/layout/SearchBar.svelte | 2 +- src/lib/ui/navbar/NavButton.svelte | 2 +- src/lib/ui/sidebar/SidebarButton.svelte | 2 +- src/params/integer.ts | 3 - src/routes/+layout.svelte | 2 +- src/routes/+page.svelte | 5 +- src/routes/accounts/+page.svelte | 2 +- src/routes/accounts/login/+page.svelte | 2 +- .../[handle=handle]/post/[rkey]/+page.svelte | 6 +- .../post/[rkey]/comment/[crkey]/+page.svelte | 2 +- .../settings/team/+page.svelte | 4 +- src/routes/explore/+layout.svelte | 2 +- src/routes/explore/communities/+page.svelte | 2 +- .../blocks/communities/+page.svelte | 2 +- .../blocks/instances/+page.svelte | 2 +- .../(local_user)/blocks/users/+page.svelte | 2 +- .../(local_user)/settings/+page.svelte | 5 +- .../profile/[handle=handle]/+page.svelte | 2 +- .../[handle=handle]/UserActions.svelte | 2 +- src/routes/settings/+layout.svelte | 6 +- src/routes/settings/Setting.svelte | 2 +- src/routes/settings/ToggleSetting.svelte | 2 +- src/routes/settings/app/+page.svelte | 6 +- src/routes/settings/embeds/+page.svelte | 2 +- src/routes/settings/moderation/+page.svelte | 2 +- src/routes/settings/other/+page.svelte | 2 +- src/routes/theme/+page.svelte | 2 +- src/routes/theme/ThemePreset.svelte | 2 +- svelte.config.js | 3 - 128 files changed, 1625 insertions(+), 2049 deletions(-) delete mode 100644 src/lib/app/state/i18n/ca.json delete mode 100644 src/lib/app/state/i18n/tok.json delete mode 100644 src/lib/app/util/text.test.ts delete mode 100644 src/lib/feature/inbox/PrivateMessage.svelte delete mode 100644 src/lib/feature/legacy/contentview.ts delete mode 100644 src/lib/feature/moderation/ApplicationDenyModal.svelte delete mode 100644 src/lib/feature/moderation/ShieldIcon.svelte create mode 100644 src/lib/feature/post/feed/VirtualFeed.test.ts delete mode 100644 src/lib/feature/post/media/PostEvent.svelte create mode 100644 src/lib/feature/post/media/PostIframe.test.ts delete mode 100644 src/lib/feature/post/media/PostPoll.svelte create mode 100644 src/lib/feature/post/tags.test.ts create mode 100644 src/lib/feature/post/tags.ts delete mode 100644 src/lib/feature/user/ProfileButton.svelte delete mode 100644 src/lib/ui/form/Duration.svelte delete mode 100644 src/lib/ui/form/ImagePreviewInput.svelte rename src/lib/ui/form/{Switch.svelte => SegmentedControl.svelte} (89%) delete mode 100644 src/lib/ui/icon/photon.ts delete mode 100644 src/params/integer.ts diff --git a/package.json b/package.json index d578fcea..a3713a14 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "openapi-fetch": "^0.14.1", "svelte": "^5.56.10", "svelte-floating-ui": "^1.6.2", - "svelte-infinite-scroll": "^2.0.1", "sveltekit-i18n": "^2.4.2", "trap-focus-svelte": "^1.1.0" }, @@ -39,7 +38,6 @@ "prettier-plugin-svelte": "^3.5.2", "svelte-check": "^4.7.6", "tailwindcss": "^4.3.3", - "tslib": "^2.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.68.0", "vite": "^8.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c7720a5..f1377f39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: svelte-floating-ui: specifier: ^1.6.2 version: 1.6.2 - svelte-infinite-scroll: - specifier: ^2.0.1 - version: 2.0.1 sveltekit-i18n: specifier: ^2.4.2 version: 2.4.2(svelte@5.56.10(@typescript-eslint/types@8.68.0)) @@ -111,9 +108,6 @@ importers: tailwindcss: specifier: ^4.3.3 version: 4.3.3 - tslib: - specifier: ^2.8.1 - version: 2.8.1 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1931,9 +1925,6 @@ packages: svelte-floating-ui@1.6.2: resolution: {integrity: sha512-EC+DZtBey50P6l3NSzNQWon3cip8a1bzwdpmCdc45kymqEWL4BKhPemAq7SQ9QLebDPaMECW6YodxFbs2d+O/w==} - svelte-infinite-scroll@2.0.1: - resolution: {integrity: sha512-goTHCfOHRDCs8C5MeSuIc6LlAQ8zVQ+M4Y3LyvrDjx5rqSSxSrdCuQwIyWYNcO6j6/mnqRro3QB64ClBzfn+Wg==} - svelte@5.56.10: resolution: {integrity: sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==} engines: {node: '>=18'} @@ -1989,9 +1980,6 @@ packages: peerDependencies: typescript: '>=4.8.4' - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3817,8 +3805,6 @@ snapshots: dependencies: '@floating-ui/dom': 1.8.0 - svelte-infinite-scroll@2.0.1: {} - svelte@5.56.10(@typescript-eslint/types@8.68.0): dependencies: '@jridgewell/remapping': 2.3.5 @@ -3885,8 +3871,6 @@ snapshots: dependencies: typescript: 5.9.3 - tslib@2.8.1: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index 51c1effe..fa330346 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -708,7 +708,7 @@ describe('hooks.server request id', () => { it('carries the request id on the production /util 404 early return', async () => { const event = createMockEvent({ cookies: createMockCookies(), - url: 'http://localhost:5173/util/photonify', + url: 'http://localhost:5173/util/instance', }) const response = await handle({ event, resolve: createMockResolve() }) @@ -1301,7 +1301,7 @@ describe('hooks.server security headers', () => { it('hardens the production /util 404 that bypasses resolve()', async () => { const event = createMockEvent({ - url: 'https://coves.social/util/photonify', + url: 'https://coves.social/util/instance', }) const resolve = createMockResolve() diff --git a/src/lib/api/client.svelte.ts b/src/lib/api/client.svelte.ts index c0a821ab..0d9bc9ff 100644 --- a/src/lib/api/client.svelte.ts +++ b/src/lib/api/client.svelte.ts @@ -248,40 +248,3 @@ export function getClient( ) { return client({ instanceURL, func, auth }) } - -/** - * Result of instance validation. - * Either valid with no error, or invalid with an error message. - */ -export type ValidateInstanceResult = - | { valid: true } - | { valid: false; error: string } - -export async function validateInstance( - instance: string, - type: ClientType, -): Promise { - if (instance == '') { - console.warn('[validateInstance] Validation failed: instance URL is empty') - return { valid: false, error: 'Instance URL cannot be empty' } - } - - try { - await client({ - instanceURL: instance, - clientType: type, - auth: '', - }).getSite() - - return { valid: true } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - console.warn( - '[validateInstance] Validation failed for instance:', - { instance, clientType: type?.name ?? 'default' }, - 'Error:', - errorMessage, - ) - return { valid: false, error: errorMessage } - } -} diff --git a/src/lib/api/coves/sort.test.ts b/src/lib/api/coves/sort.test.ts index 54023c80..01a205fe 100644 --- a/src/lib/api/coves/sort.test.ts +++ b/src/lib/api/coves/sort.test.ts @@ -9,7 +9,6 @@ import { normalizeTimeframe, resolveFeedSort, TIMEFRAME_OPTIONS, - toLemmyCommentSort, } from './sort' describe('mapSort', () => { @@ -349,20 +348,3 @@ describe('TIMEFRAME_OPTIONS', () => { } }) }) - -describe('toLemmyCommentSort', () => { - it('maps Coves values to capitalized Lemmy values', () => { - expect(toLemmyCommentSort('hot')).toBe('Hot') - expect(toLemmyCommentSort('top')).toBe('Top') - expect(toLemmyCommentSort('new')).toBe('New') - }) - - it('handles already-capitalized legacy values', () => { - expect(toLemmyCommentSort('Hot')).toBe('Hot') - expect(toLemmyCommentSort('TopAll')).toBe('Top') - }) - - it('falls back to "Hot" for unknown values', () => { - expect(toLemmyCommentSort('bogus')).toBe('Hot') - }) -}) diff --git a/src/lib/api/coves/sort.ts b/src/lib/api/coves/sort.ts index 3d7909cd..907d8e83 100644 --- a/src/lib/api/coves/sort.ts +++ b/src/lib/api/coves/sort.ts @@ -142,29 +142,6 @@ export function normalizeCommentSort(sort: unknown): CovesSortType { return normalizeSort(sort) } -/** Comment sort values accepted by the legacy Lemmy API. */ -export type LemmyCommentSortType = - | 'Hot' - | 'Top' - | 'New' - | 'Old' - | 'Controversial' - -/** - * Maps a Coves comment sort value back to the capitalized sort expected by - * the legacy Lemmy client. Unknown values fall back to `'Hot'`. - */ -export function toLemmyCommentSort(sort: string): LemmyCommentSortType { - switch (normalizeCommentSort(sort)) { - case 'top': - return 'Top' - case 'new': - return 'New' - default: - return 'Hot' - } -} - // --------------------------------------------------------------------------- // Community sort validation // --------------------------------------------------------------------------- diff --git a/src/lib/app/state/auth.svelte.test.ts b/src/lib/app/state/auth.svelte.test.ts index 1c014ab9..5b8a17a2 100644 --- a/src/lib/app/state/auth.svelte.test.ts +++ b/src/lib/app/state/auth.svelte.test.ts @@ -16,7 +16,6 @@ vi.mock('$lib/server/session', () => ({ // Import actual functions AFTER mocks are set up import { isAuthenticated, - isGuest, profile, type ProfileInfo, type GuestProfile, @@ -66,46 +65,6 @@ describe('isAuthenticated type guard', () => { }) }) -describe('isGuest type guard', () => { - it('should return true for guest profiles', () => { - const profile: GuestProfile = { - type: 'guest', - id: 'guest', - instance: 'https://coves.social', - } - expect(isGuest(profile)).toBe(true) - }) - - it('should return false for authenticated profiles', () => { - const profile: AuthenticatedProfile = { - type: 'authenticated', - id: 'test-id', - instance: 'https://coves.social' as any, - jwt: 'authenticated', - did: 'did:plc:abc123' as any, - handle: 'test.user' as any, - } - expect(isGuest(profile)).toBe(false) - }) - - it('should narrow type to GuestProfile', () => { - const profile: ProfileInfo = { - type: 'guest', - id: 'guest', - instance: 'https://coves.social', - } - - if (isGuest(profile)) { - // TypeScript should narrow to GuestProfile - expect(profile.type).toBe('guest') - expect(profile.did).toBeUndefined() - } else { - // This branch should not be reached - expect.fail('Expected profile to be guest') - } - }) -}) - describe('ProfileInfo discriminated union', () => { it('should correctly narrow type based on type field', () => { const guestProfile: ProfileInfo = { @@ -124,7 +83,7 @@ describe('ProfileInfo discriminated union', () => { } // Type narrowing test using the actual type guards - if (isGuest(guestProfile)) { + if (!isAuthenticated(guestProfile)) { expect(guestProfile.did).toBeUndefined() } @@ -204,7 +163,7 @@ describe('Profile.syncFromServer', () => { expect(profile.meta.profile).toBe('guest') expect(profile.meta.profiles).toHaveLength(1) - expect(isGuest(profile.meta.profiles[0])).toBe(true) + expect(profile.meta.profiles[0].type).toBe('guest') }) it('drops a persisted authenticated profile when session data is missing', () => { @@ -213,7 +172,7 @@ describe('Profile.syncFromServer', () => { profile.syncFromServer(undefined) expect(profile.meta.profile).toBe('guest') - expect(isGuest(profile.meta.profiles[0])).toBe(true) + expect(profile.meta.profiles[0].type).toBe('guest') }) it('leaves an existing guest profile untouched when unauthenticated', () => { diff --git a/src/lib/app/state/auth.svelte.ts b/src/lib/app/state/auth.svelte.ts index 8070bdb7..b4bf2ac3 100644 --- a/src/lib/app/state/auth.svelte.ts +++ b/src/lib/app/state/auth.svelte.ts @@ -144,13 +144,6 @@ export function isAuthenticated( return profile.type === 'authenticated' } -/** - * Type guard to check if a profile is a guest. - */ -export function isGuest(profile: ProfileInfo): profile is GuestProfile { - return profile.type === 'guest' -} - /** * What gets stored in localStorage. * Note: JWT tokens are no longer stored here for security. diff --git a/src/lib/app/state/i18n/ca.json b/src/lib/app/state/i18n/ca.json deleted file mode 100644 index 033d6b57..00000000 --- a/src/lib/app/state/i18n/ca.json +++ /dev/null @@ -1,442 +0,0 @@ -{ - "profile": { - "inbox": "Safata d'entrada", - "saved": "Guardat", - "subscribed": "Subscripcions", - "profile": "Perfil" - }, - "nav": { - "home": "Inici", - "admin": "Administració", - "moderation": "Moderació", - "communities": "Comunitats", - "search": "Cerca", - "create": { - "label": "Crear", - "community": "Comunitat", - "post": "Publicació", - "logingate": "Has d'iniciar sessió per crear contingut." - }, - "menu": { - "app": "Aplicació", - "settings": "Configuració", - "colorscheme": { - "label": "Esquema de colors", - "dark": "Fosc", - "light": "Clar", - "system": "Sistema" - }, - "theme": "Tema", - "about": "Quant a", - "label": "Perfil", - "instance": "Instància" - }, - "commands": { - "prompt": "Paleta de comandes", - "recents": "Recents", - "content": "Contingut", - "main": "Principal", - "setView": "Canvia la vista", - "setColor": "Canvia l’esquema de colors", - "feeds": "Canals", - "setTheme": "Canvia el tema", - "setThemeTo": "Canvia el tema a {{default}}", - "contextual": "En aquesta pàgina", - "search": "Cerca {{default}}", - "setViewTo": "Canvia la vista a {{default}}", - "setColorTo": "Canvia l’esquema de colors a {{default}}", - "downloading": "Accions de descàrrega" - }, - "notifications": "Notificacions" - }, - "account": { - "logout": "Tanca sessió", - "signup": "Registra't", - "accounts": "Comptes", - "resetColor": "Reinicia el color", - "moveUp": "Mou amunt", - "guest": "Convidat", - "piefedGate": "El suport a Piefed és experimental. Algunes funcions no estan disponibles.", - "moveDown": "Mou avall", - "login": "Inicia sessió", - "changeinstance": "Canvia d'instància", - "versionGate": "Aquesta versió de Kelp suporta instàncies que funcionin amb {{version}} o superior." - }, - "filter": { - "location": { - "label": "Ubicació", - "all": "Tot", - "local": "Local", - "subscribed": "Subscripcions", - "moderator": "Moderador", - "popular": "Popular", - "global": "Global" - }, - "sort": { - "label": "Ordenar", - "active": "Actiu", - "hot": "Popular", - "top": { - "label": "Millors", - "time": { - "label": "Temps", - "all": "Tot", - "9months": "9 mesos", - "6months": "6 mesos", - "3months": "3 mesos", - "week": "Setmana", - "day": "Dia", - "12hours": "12 hores", - "6hours": "6 hores", - "hour": "Hora", - "month": "Mes" - } - }, - "old": "Antics", - "new": "Nous", - "scaled": "Escalat", - "mostcomments": "Més comentats", - "newcomments": "Noves respostes", - "controversial": "Controvertit" - }, - "view": { - "label": "Vista", - "list": "Llista", - "compact": "Compacta", - "legacy": "Clàssica", - "cozy": "Ampliada" - }, - "type": "Tipus", - "filter": "Filtre", - "unread": "No llegits", - "inbox": { - "messages": "Missatges", - "mentions": "Mencions", - "replies": "Respostes" - } - }, - "form": { - "username": "Nom d'usuari", - "name": "Nom", - "instance": "URL de la instància", - "password": "Contrasenya", - "2fa": "Codi 2FA", - "forgotpassword": "Has oblidat la contrasenya?", - "submit": "Enviar", - "edit": "Editar", - "preview": "Previsualitzar", - "post": { - "community": "Comunitat", - "title": "Títol", - "url": "URL", - "body": "Cos", - "nsfw": "NSFW", - "generateTitle": "Generar títol", - "uploadImage": "Pujar imatge", - "selectFile": "Selecciona un fitxer", - "addUrl": "Afegeix URL", - "setLanguage": "Defineix idioma", - "unset": "Sense definir", - "readMore": "Llegeix més", - "edit": "Editar publicació", - "altText": "Text alternatiu", - "create": "Crear publicació" - }, - "profile": { - "bio": "Biografia", - "avatar": "Avatar", - "banner": "Capçalera", - "email": "Correu electrònic", - "matrix": "Usuari Matrix", - "showNSFW": "Mostra contingut NSFW", - "scores": "Mostra puntuacions", - "showBots": "Mostra bots", - "showRead": "Mostra publicacions llegides", - "newPassword": "Nova contrasenya", - "verifyNewPassword": "Verifica la nova contrasenya", - "2fa": { - "totp": "TOTP", - "2fa": "2FA", - "paste": "Enganxa això a la teva aplicació d'autenticació.", - "disabled": "El 2FA està desactivat" - }, - "deleteAccount": { - "deleteContent": "Esborra contingut", - "warning": "Això esborrarà TOTES les teves publicacions." - }, - "languages": { - "title": "Idiomes", - "description": "Si en configures algun, només veuràs contingut explícitament etiquetat amb aquest idioma. La major part del contingut sense etiqueta es mostrarà com a 'Indeterminat'." - }, - "credentials": { - "title": "Gestiona com inicies sessió", - "description": "Aquí pots habilitar el 2FA per a més seguretat, canviar la contrasenya, veure els dispositius amb sessió iniciada o eliminar el teu compte." - }, - "displayName": "Nom visible", - "bot": "Compte bot", - "currentPassword": "Contrasenya actual" - }, - "email": "Correu electrònic", - "setup": "Configuració", - "signup": { - "title": "Registre", - "info": "Una instància és el servidor on s’allotjarà el teu compte, però encara pots accedir i interactuar amb contingut d’altres instàncies.", - "chooseInstance": "Escull una instància", - "application": { - "notice": "El teu registre està pendent. Quan un administrador el revisi, rebràs un correu electrònic si ha estat acceptat. Llavors podràs iniciar sessió.", - "label": "Sol·licitud", - "info": "Per unir-te a aquesta instància, has d’emplenar aquesta sol·licitud i esperar a ser acceptat. Rebràs un correu electrònic si s’accepta la teva sol·licitud." - }, - "closed": { - "title": "Registres tancats", - "anotherInstance": "Troba una altra instància", - "description": "Aquesta instància té els registres tancats. Cerca una altra instància." - }, - "verify": { - "title": "Verifica el correu electrònic", - "submit": "He verificat el meu correu electrònic", - "error": "El teu correu electrònic no ha estat verificat.", - "description": "Revisa la teva safata d’entrada per trobar l’enllaç de verificació. Un cop l’hagis clicat, prem el botó de sota." - }, - "description": "Escull una instància que t’interessi, o escriu l’URL manualment." - }, - "description": "Descripció", - "time": { - "duration": "Durada", - "hours": "Hores", - "days": "Dies", - "months": "Mesos", - "years": "Anys", - "permanent": "Permanent", - "minutes": "Minuts" - }, - "confirmPassword": "Confirma la contrasenya" - }, - "routes": { - "frontpage": { - "endFeed": "Has arribat al final de {{community_name; undefined:the feed.; default:{{community_name}}", - "title": "Pàgina principal", - "footer": "{{users:number}} actius {{users; 1:user; default:users;}}", - "empty": { - "title": "No hi ha publicacions", - "description": "No hi ha cap publicació que coincideixi amb aquest filtre." - } - }, - "modlog": { - "title": "Registre de moderació", - "filters": "Filtres" - }, - "instances": "Instàncies", - "legal": { - "title": "Informació legal", - "noLegal": "Aquesta instància no té informació legal." - }, - "profile": { - "user": "Usuari", - "settings": "Configuració", - "blocks": { - "title": "Blocats", - "empty": { - "title": "Cap entitat blocada", - "description": "Quan bloquegis alguna cosa, ja no apareixerà als teus canals." - } - }, - "credentials": "Credencials", - "favorites": "Favorits", - "upvoted": "Vots positius", - "downvoted": "Vots negatius", - "media": { - "title": "Multimèdia", - "download": "Descarregar" - }, - "logins": "Inicis de sessió", - "delete": { - "title": "Esborrar compte", - "warning": "Això és permanent. Segur que vols continuar?" - }, - "overview": "Resum", - "submissions": "Publicacions", - "edit": "Editar", - "moderates": "Moderant" - }, - "communities": { - "title": "Comunitats", - "trending": "Tendència", - "search": { - "placeholder": "Cerca una comunitat nova" - } - }, - "search": { - "title": "Cerca", - "query": "Consulta", - "noResults": { - "title": "Cap resultat", - "alt": "Cerca qualsevol cosa a través del Fedivers.", - "description": "No hi ha resultats que coincideixin amb aquest filtre." - }, - "other": "Altres resultats", - "top": "Resultats principals", - "federating": "Federant" - }, - "saved": "Guardat", - "post": { - "crosspostCount": "Creuaments", - "commentCount": "Comentaris", - "addComment": "Afegeix un comentari", - "remoteView": "Vista remota", - "localView": "Vista local", - "thread": { - "single": "Estàs veient un únic fil.", - "allComments": "Tots els comentaris", - "part": "Estàs veient una part d’un fil.", - "context": "Mostra context" - }, - "emptyComments": { - "title": "Sense comentaris", - "description": "Comença la conversa!" - }, - "commented": "El teu comentari s’ha publicat.", - "loadComments": "Carrega comentaris", - "scrollToTop": "Torna a l’inici", - "instanceWarning": "Aquesta publicació s’ha sol·licitat des d’un servidor diferent de teu compte. Moltes accions no funcionaran." - }, - "postRedirect": { - "title": "Instància no coincident", - "actions": { - "back": "Torna enrere", - "switch": "Canvia a un compte a {{destInstance}}", - "fetch": "Localitza la publicació a {{homeInstance}}" - }, - "description": "Aquest enllaç porta a una publicació que no és a la instància del teu compte, {{homeInstance}}." - }, - "inbox": { - "title": "Safata d'entrada", - "markAsRead": "Marca-ho tot com a llegit", - "empty": { - "title": "No hi ha notificacions noves", - "description": "Els missatges, respostes i mencions apareixeran aquí." - }, - "item": { - "reply": "{{user}} t’ha respost a {{post}}", - "mention": "{{user}} t’ha mencionat a {{post}}", - "message": "{{user}} ha enviat un missatge a {{recipient}}" - }, - "messages": { - "conversation": "Aquest és l’inici de la teva conversa amb {{user}}", - "indicator": "Enviant missatges a {{user}}", - "empty": { - "title": "Cap missatge" - }, - "start": "Comença el xat" - } - }, - "moderation": { - "title": "Moderació", - "empty": { - "title": "No hi ha informes nous\"", - "description": "Quan es reportin publicacions, podràs actuar-hi aquí." - }, - "reason": "Motiu", - "resolved": "Resolt", - "unresolve": "Desfés la resolució", - "resolvedBy": "Resolt per", - "markAll": "Marca la pàgina com a resolta", - "feed": "Informes", - "manage": { - "title": "Gestiona", - "modlogs": "Registres recents de moderació", - "ban": "Bloqueja", - "unban": "Desbloqueja" - }, - "resolve": "Resol", - "markAllComplete": "Acció massiva completada." - }, - "admin": { - "title": "Administració", - "config": { - "sidebar": "Barra lateral", - "icon": "Icona", - "banner": "Capçalera", - "downvotesEnabled": "Habilita vots negatius", - "nsfwEnabled": "Habilita NSFW", - "registration": { - "open": "Obert", - "closed": "Tancat", - "application": "Requereix sol·licitud", - "label": "Mode de registre" - }, - "applicationQuestion": "Pregunta de la sol·licitud", - "emailAdminsOnApplication": "Envia un correu als administradors quan es rebin noves sol·licituds", - "emailAdminsOnReport": "Envia un correu als administradors quan es rebin nous informes", - "listingType": { - "label": "Tipus de llistat", - "all": "Tot", - "local": "Local" - }, - "private": "Instància privada", - "hideModlogModNames": "Amaga els noms al registre de moderació", - "slurFilter": "Regex de filtre de termes ofensius", - "federation": "Federació habilitada", - "title": "Configuració", - "federationDebug": "Mode de depuració de federació", - "captcha": { - "enabled": "CAPTCHA habilitat" - }, - "adminCommunityOnly": "Només els administradors poden crear comunitats", - "requireVerifyEmail": "Requereix verificació de correu electrònic" - }, - "applications": { - "title": "Sol·licituds", - "empty": { - "description": "Les sol·licituds per unir-se a la teva instància apareixeran aquí.", - "title": "No hi ha noves sol·licituds" - }, - "application": "Sol·licitud", - "approved": "Aprovada", - "denied": "Denegada", - "user": "Usuari", - "approve": "Aprova", - "reason": "Motiu", - "deny": "Denega", - "modalTitle": "Denega sol·licitud" - }, - "taglines": { - "title": "Eslògans", - "add": "Afegeix un eslògan", - "empty": { - "title": "Sense eslògan", - "description": "Un eslògan aleatori apareixerà a la targeta del lloc quan els usuaris visitin la teva instància." - } - }, - "team": { - "title": "Administradors", - "empty": { - "title": "Cap administrador", - "description": "D’alguna manera no hi ha administradors d’aquest lloc. Com pot ser?" - } - }, - "federation": { - "title": "Federació", - "csv": "Pujar CSV", - "blocked": "Instàncies blocades", - "allowed": "Instàncies permeses", - "allow": "Permet", - "allowplaceholder": "Permet una instància", - "emptyBlock": { - "title": "Cap instància blocada", - "description": "Blocar una instància amagarà les noves publicacions d’aquesta instància." - }, - "emptyAllow": { - "title": "Cap instància permesa", - "description": "Permetre una instància blocarà totes les altres instàncies." - }, - "block": "Bloca", - "blockplaceholder": "Bloca una instància..." - }, - "media": { - "title": "Multimèdia" - } - }, - "accounts": "Comptes" - } -} diff --git a/src/lib/app/state/i18n/tok.json b/src/lib/app/state/i18n/tok.json deleted file mode 100644 index 4b985db2..00000000 --- a/src/lib/app/state/i18n/tok.json +++ /dev/null @@ -1,549 +0,0 @@ -{ - "form": { - "username": "nimi", - "profile": { - "avatar": "sitelen", - "banner": "sitelen monsi", - "displayName": "nimi jan", - "bio": "nimi tan jan ni", - "matrix": "nimi Matrix", - "bot": "ilo", - "email": "Email", - "showNSFW": "o lukin e jaki", - "scores": "o lukin e nanpa pilin", - "showRead": "o lukin e toki pi lukin mi", - "currentPassword": "nimi lawa lon", - "newPassword": "nimi lawa sin", - "verifyNewPassword": "nimi lawa sin tu", - "2fa": { - "totp": "TOTP", - "2fa": "2FA", - "paste": "o pana e nimi ni tawa ilo Authenticator sina" - }, - "deleteAccount": { - "label": "o weka e sijelo ilo", - "warning": "ni li weka e ale sina.", - "deleteContent": "o weka" - }, - "languages": { - "title": "toki" - }, - "showBots": "o lukin e jan ilo" - }, - "name": "nimi jan", - "password": "nimi lawa", - "confirmPassword": "nimi lawa tu", - "2fa": "nimi 2FA", - "forgotpassword": "mi sona ala e nimi lawa", - "submit": "pona", - "edit": "o ante", - "post": { - "body": "insa", - "community": "kulupu", - "nsfw": "jaki", - "create": "o toki", - "edit": "o ante e toki", - "title": "nimi suli", - "url": "URL", - "generateTitle": "o pali e nimi suli", - "uploadImage": "o pana e sitelen", - "selectFile": "o pana e lipu", - "altText": "nimi sitelen", - "unset": "o weka", - "addUrl": "o pana e URL sin", - "setLanguage": "o ante e toki", - "readMore": "o lukin e toki mute" - }, - "preview": "lukin", - "email": "Email", - "signup": { - "title": "o pali e sijelo ilo", - "info": "\"tomo\" li ma pi sijelo ilo sina, taso sina ken awen lukin e ijo tan tomo ante.", - "chooseInstance": "o wile e tomo", - "application": { - "notice": "o awen e ni: jan lawa wile e sina. ni la sina ken kepeken e tomo ni.", - "info": "sina wile kama tawa tomo ni la o toki tawa jan lawa. ona wile e sina la sina jo e Email." - }, - "closed": { - "title": "sina ken ala kama tawa tomo ni.", - "description": "o alasa e tomo ante.", - "anotherInstance": "o alasa e tomo ante" - }, - "verify": { - "title": "o pona e Email", - "submit": "mi pona e Email mi", - "error": "Email sina li pona ala.", - "description": "o lukin e poki toki sina. sina lukin e URL la kepeken e ona. ni la kepeken e nena anpa." - }, - "description": "o kama tawa tomo pi olin sina. kin sina ken pana e URL ona." - }, - "time": { - "duration": "tenpo", - "days": "tenpo suno", - "years": "tenpo sike", - "permanent": "tenpo ale" - }, - "instance": "ma tomo" - }, - "routes": { - "modlog": { - "title": "lipu lawa" - }, - "search": { - "noResults": { - "alt": "o alasa e ale tan ma", - "title": "ala" - }, - "title": "o alasa", - "query": "nimi" - }, - "post": { - "scrollToTop": "o tawa sewi", - "addComment": "o toki", - "crosspostCount": "toki tan kulupu ante", - "commentCount": "toki", - "thread": { - "part": "sina lukin e kipisi toki", - "single": "sina lukin e toki wan.", - "allComments": "o lukin e toki ale" - }, - "emptyComments": { - "description": "o open e toki!", - "title": "toki ala" - }, - "commented": "toki sina li kama lon.", - "loadComments": "o lukin e toki mute", - "instanceWarning": "toki ni lon tomo ante. ijo mute li pakala." - }, - "postRedirect": { - "actions": { - "back": "o tawa monsi", - "fetch": "o alasa e toki tan {{homeInstance}}", - "switch": "o kepeken e jan sina tan {{destInstance}}" - }, - "title": "tomo li sama ala" - }, - "profile": { - "delete": { - "title": "o weka tan tomo ni", - "warning": "ni la sina ken ala awen kepeken. pona?" - }, - "favorites": "olin", - "user": "jan", - "settings": "ante nasin", - "upvoted": "pona", - "downvoted": "ike", - "media": { - "title": "sitelen", - "download": "o lanpan e lipu" - }, - "welcome": "o kama pona, {{default}}!", - "overview": "suli" - }, - "communities": { - "title": "kulupu", - "search": { - "placeholder": "o alasa e kulupu sin" - } - }, - "inbox": { - "messages": { - "start": "o open e toki", - "indicator": "sina toki tawa {{user}}", - "empty": { - "title": "toki ala" - }, - "conversation": "ni li open pi toki tawa {{user}}" - }, - "title": "poki toki", - "markAsRead": "o majuna e ale", - "empty": { - "title": "sin ala", - "description": "toki lon ni" - }, - "item": { - "reply": "{{user}} toki tawa sina lon {{post}}", - "mention": "{{user}} toki e nimi sina lon {{post}}", - "message": "{{user}} toki tawa {{recipient}}" - } - }, - "legal": { - "noLegal": "tomo ni jo ala e lipu lawa", - "title": "lipu pi lawa tomo" - }, - "instances": "tomo", - "frontpage": { - "empty": { - "title": "toki li lon ala", - "description": "toki tan alasa sina li lon ala" - }, - "title": "lipu sewi", - "endFeed": "sina kama tawa pini." - }, - "accounts": "jan sina", - "createPost": "o toki", - "createCommunity": "o pali e kulupu", - "saved": "olin", - "moderation": { - "title": "lawa", - "manage": { - "ban": "o weka e jan", - "title": "o ante", - "unban": "o pini weka e jan", - "modlogs": "lipu lawa sin" - } - }, - "admin": { - "config": { - "sidebar": "lipu poka", - "banner": "sitelen monsi", - "icon": "sitelen", - "title": "ante nasin", - "nsfwEnabled": "o pali e lawa ni: ken jaki", - "registration": { - "application": "o wile e lipu", - "closed": "ala ken kama", - "open": "ale ken kama" - }, - "requireVerifyEmail": "o wile e Email pona", - "listingType": { - "all": "ale" - }, - "emailAdminsOnApplication": "lipu sin la o Email e jan lawa", - "slurFilter": "nimi Regex pi nimi ike", - "adminCommunityOnly": "jan lawa taso ken pali e kulupu sin" - }, - "title": "lawa", - "applications": { - "title": "lipu kama", - "empty": { - "title": "lipu kama ala", - "description": "lipu kama pi tomo li kama lon ni." - }, - "deny": "ken ala awen", - "user": "jan", - "application": "lipu kama", - "approved": "pona", - "denied": "ike", - "modalTitle": "ike", - "approve": "pona" - }, - "team": { - "title": "jan lawa", - "empty": { - "title": "jan lawa ala", - "description": "tomo ni jo ala e jan lawa. nasa a!" - } - }, - "federation": { - "csv": "o pana e lipu CSV" - }, - "media": { - "title": "sitelen" - } - }, - "theme": { - "reset": "o weka", - "import": "o pana e lipu", - "export": "o lanpan e lipu", - "title": "kule", - "resetWarning": { - "title": "o weka e kule", - "description": "ni la kule ale sina li weka. sina wile e ni: o lanpan e lipu pi kule sina la weka e ona." - }, - "preset": { - "title": "ken ala ante", - "description": "ni li kule ilo. sina ken ala ante e ona.", - "delete": { - "confirm": "pona", - "title": "o weka e kule" - }, - "default": "kule ilo", - "imported": "kule sina", - "new": "o pali e kule", - "classic": "kule majuna" - }, - "accent": "kule namako" - }, - "passwordChange": { - "title": "o ante e nimi lawa", - "description": "o pana e nimi lawa sin sina.", - "instance": "sijelo ilo sina li lon tomo seme?" - }, - "verifyEmail": { - "message": "nimi Email sina li pona. sina ken weka e lipu ni.", - "error": "nimi Email sina li ala pona." - }, - "resetLogin": { - "title": "o weka e nimi lawa", - "description": "o pana e nimi Email pi sijelo ilo sina. ni la sina ken weka e nimi lawa. sijelo ilo sina jo ala e nimi Email la toki tawa jan lawa pi tomo sina." - }, - "community": { - "settings": { - "settings": "ante nasin", - "manage": "o ante" - }, - "languageWarning": "ijo lon tomo ni kepeken e toki pi sina jo ala. toki mute ken weka." - } - }, - "nav": { - "menu": { - "colorscheme": { - "light": "walo", - "dark": "pimeja", - "label": "tenpo suno", - "system": "tenpo suno ilo" - }, - "instance": "tomo", - "label": "jan sina", - "settings": "ante nasin", - "app": "ilo ni", - "theme": "kule ilo", - "about": "sona pi ilo ni" - }, - "search": "o alasa", - "create": { - "label": "o pali", - "post": "o toki", - "community": "kulupu", - "logingate": "sina wile e toki la o kepeken e sijelo ilo." - }, - "communities": "kulupu", - "moderation": "lawa", - "admin": "lawa", - "commands": { - "prompt": "ma lawa", - "recents": "sin", - "search": "o alasa e {{default}}", - "main": "suli", - "content": "insa", - "setView": "o kepeken e lukin ante", - "setViewTo": "o kepeken e lukin {{default}}", - "setColor": "o kepeken e tenpo suno ante", - "setTheme": "o kepeken e kule ante", - "contextual": "lon ni", - "setColorTo": "o kepeken e tenpo suno {{default}}", - "setThemeTo": "o kepeken e kule {{default}}" - }, - "notifications": "sin" - }, - "filter": { - "sort": { - "top": { - "label": "suli", - "time": { - "day": "tenpo suno", - "label": "tenpo", - "all": "tenpo ale", - "9months": "tenpo pi sike mun 9", - "6months": "tenpo pi sike mun 6", - "3months": "tenpo pi sike mun 3", - "month": "tenpo pi sike mun", - "12hours": "tenpo 12", - "6hours": "tenpo 6", - "hour": "tenpo lili" - } - }, - "active": "jo e toki mute", - "new": "sin", - "old": "majuna", - "mostcomments": "jo e toki mute", - "newcomments": "jo e toki sin", - "controversial": "jo e utala toki" - }, - "location": { - "label": "ma", - "all": "ale", - "local": "tan ma ni", - "subscribed": "tan olin mi", - "moderator": "tan ma pi lawa mi", - "global": "tan ma ale" - }, - "inbox": { - "messages": "toki", - "replies": "toki" - }, - "view": { - "legacy": "majuna" - } - }, - "profile": { - "inbox": "poki toki", - "saved": "poki olin", - "profile": "sina" - }, - "account": { - "moveUp": "o tawa sewi", - "moveDown": "o tawa anpa", - "changeinstance": "o ante e tomo", - "login": "o kepeken e sijelo ilo", - "logout": "o pini kepeken e sijelo ilo", - "signup": "o pali e sijelo ilo", - "accounts": "sijelo ilo", - "resetColor": "o weka e kule", - "guest": "sijelo ilo sin", - "versionGate": "ilo Kelp ken jo e tomo pi nanpa {{version}} anu sewi" - }, - "cards": { - "site": { - "admins": "jan lawa", - "about": "ni li seme?", - "stats": "nanpa" - }, - "community": { - "moderators": "jan lawa", - "members": "jan", - "favorite": "o olin", - "unfavorite": "o olin ala", - "modlog": "lipu lawa" - } - }, - "admin": { - "purge": "o weka", - "label": "lawa", - "purgeCommunity": { - "title": "weka kulupu", - "warning": "ni la ale li weka. pona? (tenpo lili li kama la sina ken kepeken e nena.)" - }, - "instance": "tomo" - }, - "content": { - "all": "ale", - "comments": "toki", - "posts": "toki", - "users": "jan", - "instances": "tomo", - "communities": "kulupu", - "message": "toki", - "url": "URL" - }, - "post": { - "actions": { - "more": { - "creator": "jan pali", - "label": "nena nasa", - "actions": "nena", - "share": "o pana tawa jan ante", - "translate": "o ante e toki", - "mediaBias": "nasa sona", - "delete": "o weka", - "edit": "o ante", - "crosspost": "o pana tawa kulupu ante" - }, - "save": "o olin", - "unsave": "o pini olin", - "comments": "toki", - "mediaBias": { - "noData": { - "title": "sona ala" - }, - "downloading": "mi kama jo" - }, - "vote": { - "upvote": "ni li pona", - "score": "nanpa pilin", - "downvote": "ni li ike" - } - }, - "badges": { - "deleted": "weka", - "nsfw": "jaki", - "featured": "suli", - "removed": "weka", - "saved": "olin" - }, - "image": { - "zoomIn": "o lukin suli", - "zoomOut": "o lukin lili", - "close": "o pini lukin" - }, - "meta": { - "created": "tenpo {{default}} la ni li lon", - "lastEdited": "tenpo {{default}} la ni li ante" - } - }, - "comment": { - "reply": "o toki", - "actions": { - "label": "nena", - "link": "o lanpan e URL" - }, - "banned": "jan lawa li weka e sina tan kulupu ni." - }, - "moderation": { - "ban": { - "deleteData": "o weka", - "banFromCommunity": "o weka e jan tan kulupu ni", - "unbanFromCommunity": "o pini weka e jan tan kulupu ni", - "banning": "weka e jan", - "unbanning": "pini weka e jan", - "warning": "ni la ale tan jan ni li weka." - }, - "removeSubmission": { - "title": "weka e toki", - "titleRestore": "pini weka e toki", - "titlePurge": "o weka e toki" - }, - "remove": "o weka", - "restore": "o pini weka", - "label": "lawa", - "labelInstanceOnly": "lawa pi tomo ni", - "feature": "o suli e ni", - "unfeature": "o pini suli e ni", - "votes": "nanpa pilin", - "modlog": { - "user": "lipu lawa pi jan ni", - "post": "lipu lawa pi toki ni", - "comment": "lipu lawa pi toki ni" - } - }, - "common": { - "back": "o tawa monsi", - "next": "o tawa poka", - "debug": "lipu pi nanpa ilo", - "download": "o lanpan e lipu", - "refresh": "o lukin e sin", - "remove": "o weka", - "cancel": "ala", - "save": "o olin", - "send": "o toki" - }, - "error": { - "incorrect_login_7": "sina musi?", - "incorrect_login_4": "pona a- mi pakala. nimi lawa li awen ike.", - "incorrect_login_5": "o lukin e ilo sitelen sina!", - "incorrect_login_3": "nimi lawa li awen ike. open sin.", - "incorrect_login": "nimi lawa ike. open sin.", - "incorrect_login_8": "mi wile ala awen", - "incorrect_login_9": "sina pana e nanpa tawa ilo pi sona mute. sina kepeken e ilo ni tawa pali pi nimi lawa ike.", - "incorrect_login_12": "...", - "couldnt_find_post": "toki li lon ala anu lon tomo ante anu weka.", - "incorrect_login_10": "o sona e ni: jan lawa pona pali e tomo. sina kepeken ike e tomo ona.", - "downvotes_are_disabled": "sina ken ala pilin ike lon tomo ni.", - "email_not_verified": "nimi Email sina li lon ala.", - "passwords_do_not_match": "nimi lawa sina wan li sama ala e nimi tu.", - "couldnt_find_person": "jan li lon ala.", - "rate_limit_error": "o awen e tenpo lili.", - "incorrect_login_6": "mi pilin e ni: sina wawa!", - "slurs": "toki sina jo e nimi ike", - "honeypot_failed": "sina ilo? (mu ilo) ala la ilo li pilin taso e ni.", - "email_already_exists": "sijelo ilo lon. ona li kepeken e nimi Email ni.", - "not_an_admin": "sina ala jan lawa.", - "incorrect_login_2": "nimi lawa ike. open sin.", - "couldnt_find_community": "kulupu ni li lon ala." - }, - "aria": { - "pagination": { - "nav": "kipisi lipu", - "current": "lipu ni" - }, - "vote": { - "downvotes": "jan {{default}} pilin ike", - "upvotes": "jan {{default}} pilin pona" - } - }, - "placeholders": { - "comments": "o toki tawa jan lon ni", - "body": "sina wile toki e seme?" - } -} diff --git a/src/lib/app/state/settings.svelte.ts b/src/lib/app/state/settings.svelte.ts index 9652df68..b4891bab 100644 --- a/src/lib/app/state/settings.svelte.ts +++ b/src/lib/app/state/settings.svelte.ts @@ -15,8 +15,6 @@ import { export type View = 'cozy' | 'compact' -export const SSR_ENABLED = env.PUBLIC_SSR_ENABLED?.toLowerCase() == 'true' - // Returns a proper boolean or null. Used to set boolean values from env var strings while allowing nullish coalescing to set default values. const toBool = (str: string | undefined) => { if (!str) { @@ -314,6 +312,4 @@ $effect.root(() => { if (browser) locale.set(navigator?.language) } }) - - return () => {} }) diff --git a/src/lib/app/state/theme/theme.svelte.test.ts b/src/lib/app/state/theme/theme.svelte.test.ts index b8b1f94c..d770371d 100644 --- a/src/lib/app/state/theme/theme.svelte.test.ts +++ b/src/lib/app/state/theme/theme.svelte.test.ts @@ -30,6 +30,36 @@ async function importThemeWith(stored: string | null) { return { theme: module.theme, store } } +/** + * localStorage doesn't merely return null when it's unavailable — Safari's + * private mode and blocked-storage contexts throw on access, and a full quota + * throws on write. Both have to degrade to "theme isn't persisted", never to a + * throw out of module init or out of the persist effect. + */ +async function importThemeWithThrowingStorage(err: Error) { + vi.resetModules() + vi.doMock('$app/environment', () => ({ + browser: true, + dev: true, + building: false, + })) + vi.doMock('$env/dynamic/public', () => ({ env: {} })) + + vi.stubGlobal('localStorage', { + getItem: () => { + throw err + }, + setItem: () => { + throw err + }, + removeItem: () => { + throw err + }, + }) + + return import('./theme.svelte') +} + describe('theme initialization from localStorage', () => { it('uses presets when nothing is stored', async () => { const { theme } = await importThemeWith(null) @@ -88,3 +118,38 @@ describe('theme initialization from localStorage', () => { expect(theme.data.currentTheme).toBe(0) }) }) + +describe('theme persistence when localStorage is unavailable', () => { + it('falls back to defaults when reads throw at module init', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const { theme } = await importThemeWithThrowingStorage( + new DOMException('The operation is insecure.', 'SecurityError'), + ) + + expect(theme.data.themes.length).toBe(presets.length) + expect(theme.data.currentTheme).toBe(0) + expect(theme.colorScheme).toBe('system') + expect(error).toHaveBeenCalled() + expect(String(error.mock.calls[0][0])).toContain('[theme]') + }) + + it('does not throw out of the persist path when writes throw', async () => { + const module = await importThemeWithThrowingStorage( + new DOMException('Quota exceeded.', 'QuotaExceededError'), + ) + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + expect(() => module.persistThemeData(module.theme.data)).not.toThrow() + expect(() => module.persistColorScheme('dark')).not.toThrow() + + expect(error).toHaveBeenCalledTimes(2) + expect(String(error.mock.calls[0][0])).toContain( + 'will not survive a reload', + ) + expect(String(error.mock.calls[1][0])).toContain( + 'Failed to persist colorScheme', + ) + expect(String(error.mock.calls[0][1])).toContain('Quota exceeded.') + }) +}) diff --git a/src/lib/app/state/theme/theme.svelte.ts b/src/lib/app/state/theme/theme.svelte.ts index 3799cff4..2f9cf26b 100644 --- a/src/lib/app/state/theme/theme.svelte.ts +++ b/src/lib/app/state/theme/theme.svelte.ts @@ -68,46 +68,83 @@ export function rgbToHex(rgbString: string): string { return `#${toHex(r)}${toHex(g)}${toHex(b)}` } -$effect.root(() => { - $effect(() => { - if (browser) { - const filteredThemes = theme.data.themes.filter((t) => t.id > 0) - localStorage.setItem( - 'theme.data', - JSON.stringify({ - ...theme.data, - themes: filteredThemes, - }), - ) - } - }) - $effect(() => { - if (browser) { - const prefersDark = window.matchMedia( - '(prefers-color-scheme: dark)', - ).matches +const configuredColorScheme = env.PUBLIC_COLORSCHEME ?? 'system' - const html = document.querySelector('html') +/** + * localStorage throws, it doesn't just fail: Safari's private mode denies + * access outright (SecurityError), blocked-storage contexts do the same, and a + * full origin quota rejects writes (QuotaExceededError). These wrappers keep + * any of that from taking module init or the reactive graph down with it — a + * browser that can't persist the theme still has to render one. + */ +function readStorage(key: string): string | null { + if (!browser) return null + try { + return localStorage.getItem(key) + } catch (err) { + console.error( + `[theme] Failed to read ${key} from localStorage:`, + err instanceof Error ? err.message : String(err), + ) + return null + } +} - if (theme.colorScheme == 'system') - html?.classList.toggle('dark', prefersDark) - else html?.classList.toggle('dark', theme.colorScheme === 'dark') +function removeStorage(key: string): void { + if (!browser) return + try { + localStorage.removeItem(key) + } catch (err) { + console.error( + `[theme] Failed to clear ${key} from localStorage:`, + err instanceof Error ? err.message : String(err), + ) + } +} - localStorage.setItem('colorScheme', theme.colorScheme) - } - }) -}) +function isColorScheme(value: unknown): value is ColorScheme { + return value === 'system' || value === 'light' || value === 'dark' +} -const configuredColorScheme = env.PUBLIC_COLORSCHEME ?? 'system' +/** + * Persist helpers are exported so the failure paths stay testable: the + * module-level effects below never flush outside a browser runtime. + */ +export function persistThemeData(data: ThemeData): void { + try { + localStorage.setItem( + 'theme.data', + JSON.stringify({ + ...data, + themes: data.themes.filter((t) => t.id > 0), + }), + ) + } catch (err) { + console.error( + '[theme] Failed to persist theme.data — theme customizations will not survive a reload:', + err instanceof Error ? err.message : String(err), + ) + } +} + +export function persistColorScheme(scheme: ColorScheme): void { + try { + localStorage.setItem('colorScheme', scheme) + } catch (err) { + console.error( + '[theme] Failed to persist colorScheme:', + err instanceof Error ? err.message : String(err), + ) + } +} class ThemeState { constructor() { - const localColorScheme: ColorScheme = browser - ? (localStorage.getItem('colorScheme') as ColorScheme) || - configuredColorScheme - : (configuredColorScheme as ColorScheme) + const stored = readStorage('colorScheme') - this.#colorScheme = localColorScheme + this.#colorScheme = isColorScheme(stored) + ? stored + : (configuredColorScheme as ColorScheme) } #data = $state( @@ -154,6 +191,31 @@ class ThemeState { export const theme = new ThemeState() +$effect.root(() => { + $effect(() => { + if (browser) { + persistThemeData(theme.data) + } + }) + $effect(() => { + if (browser) { + const prefersDark = window.matchMedia( + '(prefers-color-scheme: dark)', + ).matches + + const html = document.querySelector('html') + + // The class toggle stays ahead of the write: when persistence fails, + // the page must still be in the right color scheme for this session. + if (theme.colorScheme == 'system') + html?.classList.toggle('dark', prefersDark) + else html?.classList.toggle('dark', theme.colorScheme === 'dark') + + persistColorScheme(theme.colorScheme) + } + }) +}) + export function calculateVars(theme: Theme) { let cssVariables = '' @@ -188,7 +250,7 @@ function isTheme(value: unknown): value is Theme { function loadTheme(): ThemeData | undefined { if (!browser) return - const localTheme = localStorage.getItem('theme.data') + const localTheme = readStorage('theme.data') if (!localTheme) return // This runs at module init: a corrupted or malformed value must fall back @@ -205,7 +267,7 @@ function loadTheme(): ThemeData | undefined { !Array.isArray((data as ThemeData).themes) ) { console.warn('[theme] discarding malformed theme.data:', localTheme) - localStorage.removeItem('theme.data') + removeStorage('theme.data') return } const parsed = data as ThemeData @@ -223,7 +285,7 @@ function loadTheme(): ThemeData | undefined { return parsed } catch (err) { console.warn('[theme] discarding unparseable theme.data:', localTheme, err) - localStorage.removeItem('theme.data') + removeStorage('theme.data') return } } diff --git a/src/lib/app/util/array.test.ts b/src/lib/app/util/array.test.ts index 1460a06b..b2b7ebbc 100644 --- a/src/lib/app/util/array.test.ts +++ b/src/lib/app/util/array.test.ts @@ -1,19 +1,5 @@ import { describe, it, expect } from 'vitest' -import { findClosestNumber, moveItem, recursiveEqual } from './array' - -describe('findClosestNumber', () => { - it('rounds up to nearest value at or above target', () => { - expect(findClosestNumber([8, 16, 32, 64, 128], 76)).toBe(128) - }) - - it('returns exact match when present', () => { - expect(findClosestNumber([8, 16, 32, 64, 128], 64)).toBe(64) - }) - - it('returns smallest value above target', () => { - expect(findClosestNumber([128, 256, 512, 1024], 200)).toBe(256) - }) -}) +import { moveItem, recursiveEqual } from './array' describe('moveItem', () => { it('moves an item forward and backward without mutating the input', () => { diff --git a/src/lib/app/util/array.ts b/src/lib/app/util/array.ts index 2693fd3e..31a5cb31 100644 --- a/src/lib/app/util/array.ts +++ b/src/lib/app/util/array.ts @@ -1,10 +1,3 @@ -// Finds the smallest number in `numbers` that is >= target. -// Falls back to the closest number below target if none are >=. -export const findClosestNumber = (numbers: number[], target: number): number => - numbers.reduce((prev, curr) => - curr >= target && (prev < target || curr < prev) ? curr : prev, - ) - export function moveItem( array: T[], currentIndex: number, diff --git a/src/lib/app/util/text.test.ts b/src/lib/app/util/text.test.ts deleted file mode 100644 index e86f985b..00000000 --- a/src/lib/app/util/text.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { escapeHtml } from './text' - -describe('escapeHtml', () => { - it('escapes angle brackets', () => { - expect(escapeHtml('')).toBe( - '<script>alert("xss")</script>', - ) - }) - - it('escapes ampersands and quotes', () => { - expect(escapeHtml('rock & roll\'s "best"')).toBe( - 'rock & roll's "best"', - ) - }) -}) diff --git a/src/lib/app/util/text.ts b/src/lib/app/util/text.ts index edb0c1cd..7cf3b28e 100644 --- a/src/lib/app/util/text.ts +++ b/src/lib/app/util/text.ts @@ -1,12 +1,3 @@ -export function escapeHtml(input: string): string { - return input - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - export function fuzzySearch(text: string, pattern: string): number { const textLower = text.toLowerCase() const patternLower = pattern.toLowerCase() diff --git a/src/lib/app/util/url.test.ts b/src/lib/app/util/url.test.ts index ecb338e2..9a4152b3 100644 --- a/src/lib/app/util/url.test.ts +++ b/src/lib/app/util/url.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from 'vitest' -import { canParseUrl, isImage, isVideo, isSafeHref, isWebUrl, parseWebUrl } from './url' +import { + canParseUrl, + isImage, + isVideo, + isSafeHref, + isWebUrl, + parseWebUrl, +} from './url' describe('isImage', () => { it('returns false for undefined', () => { @@ -21,6 +28,97 @@ describe('isImage', () => { it('returns false for URL without image extension', () => { expect(isImage('https://example.com/page')).toBe(false) }) + + it('ignores an image extension in the query string', () => { + expect(isImage('https://example.com/page?file=x.png')).toBe(false) + }) + + it('ignores an image extension in the fragment', () => { + expect(isImage('https://example.com/page#.png')).toBe(false) + }) + + it('ignores an image extension in a non-final path segment', () => { + expect(isImage('https://example.com/a.png/page')).toBe(false) + }) + + it('matches an uppercase extension', () => { + expect(isImage('https://example.com/photo.JPG')).toBe(true) + }) + + it('still matches when a query string follows the extension', () => { + expect(isImage('https://example.com/photo.png?v=1')).toBe(true) + expect(isImage('https://example.com/photo.png#top')).toBe(true) + }) + + it('matches a site-relative image path', () => { + expect(isImage('/uploads/photo.webp')).toBe(true) + }) + + it('returns false for a data: URL that only mentions .png in its fragment', () => { + expect(isImage('data:text/html;base64,PHN2Zz4=#.png')).toBe(false) + }) + + it('returns false for unparseable input', () => { + expect(isImage('http://')).toBe(false) + expect(isImage('')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// isImage()/isVideo() classify, they do not validate +// +// These are not decorative. markdown/security.test.ts and +// markdown/renderers/MdImage.test.ts both build hostile corpora out of +// data:/javascript:/blob: URLs that must reach the /