diff --git a/CLAUDE.md b/CLAUDE.md
index b038a574..c78515c2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -107,15 +107,25 @@ pnpm test # Vitest
```
src/
├── lib/
-│ ├── api/ # API clients (Lemmy, PieFed)
-│ ├── feature/ # Feature modules (post, comment, community, etc.)
-│ ├── ui/ # UI components
-│ │ └── shared/ # mono-svelte component library
-│ └── settings/ # App settings and theme
-├── routes/ # SvelteKit routes
-└── app.html # HTML template
+│ ├── api/ # Coves API client, sort/image-proxy mappings (bottom layer)
+│ ├── app/
+│ │ ├── state/ # auth, settings, instance, session, theme, i18n
+│ │ └── util/ # pure helpers, one concern per file (links, url, text…)
+│ ├── ui/ # presentational components; may use app/ + api/, never feature/
+│ │ └── kit/ # hard fork of mono-svelte — imports nothing from app/feature/api
+│ ├── feature/ # feature modules (post, comment, community, markdown, shell…)
+│ ├── server/ # server-only code
+│ └── types/ # shared types, importable from every layer
+├── routes/ # SvelteKit routes
+└── app.html # HTML template
```
+**Layering** (enforced by `no-restricted-imports` in `eslint.config.js`):
+`routes → feature → ui → app → api`, with `ui/kit` as a leaf below `ui`.
+Kit components take app state via props/snippets (e.g. `ToastContainer`'s
+`content` snippet, `ModalContainer`'s `closeLabel`), never by importing it.
+Import the kit as `$lib/ui/kit` — there is no `mono-svelte` alias.
+
## Browser Testing (Playwright MCP)
- Always use **Firefox** as the browser — Chrome is not installed
- Dev environment runs on `http://localhost:8080` (Caddy proxy) with Go backend on :8081 and Vite on :5173
diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md
index 59adb938..0ea9b5ce 100644
--- a/docs/ENVIRONMENT.md
+++ b/docs/ENVIRONMENT.md
@@ -1,7 +1,7 @@
# Environment variables
The single reference for every variable the frontend reads. Code resolves
-instance URLs in exactly one place — `src/lib/app/instance/resolve.ts` — and
+instance URLs in exactly one place — `src/lib/app/state/instance/resolve.ts` — and
the tables below describe that behaviour. If you change a default or add a
variable, update this file; `Dockerfile`, `.github/README.md` and
`.env.development` only point here.
@@ -43,7 +43,7 @@ Resolution precedence:
## Appearance and default settings
All optional. Booleans accept `true`/`false`. They seed a new user's settings
-(`src/lib/app/settings.svelte.ts`); users can change them afterwards.
+(`src/lib/app/state/settings.svelte.ts`); users can change them afterwards.
| Variable | Default | Description |
| --- | --- | --- |
diff --git a/eslint.config.js b/eslint.config.js
index 758943d3..bb8c9ac5 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -12,7 +12,7 @@ export default ts.config(
includeIgnoreFile(gitignorePath),
// Ignore legacy code that will be replaced.
//
- // src/lib/app/markdown is deliberately NOT ignored: it renders untrusted
+ // src/lib/feature/markdown is deliberately NOT ignored: it renders untrusted
// post and comment bodies, and svelte/no-at-html-tags below is the tripwire
// for XSS sinks there. A directory-wide ignore would switch that off silently.
{
@@ -75,4 +75,96 @@ export default ts.config(
},
},
},
+ // ---------------------------------------------------------------------------
+ // Layering. Each layer may import only from the layers below it:
+ //
+ // routes/ → feature/ → ui/ → app/ (state, util) → api/
+ // ui/kit/ (imports none of the above)
+ //
+ // `$lib/types` is shared by every layer, and `$lib/server` is off-limits to
+ // every client-shipped layer (type-only imports of it are allowed — they are
+ // erased). `api/client.svelte.ts` reaching into `app/state` for the active
+ // profile is a known, tolerated exception — it is why `api/` is not
+ // restricted from `app/` here.
+ //
+ // Known limits: the rule is lexical, so `./../x` or `$lib/../x` spellings
+ // and dynamic `import()` expressions are not caught. Nothing in the tree
+ // uses either; src/lib/ui/kit/layering.test.ts pins the covered cases.
+ // ---------------------------------------------------------------------------
+ ...layerRules([
+ {
+ files: ['src/lib/ui/kit/**'],
+ forbid: ['app', 'feature', 'api', 'server'],
+ // The kit may only import itself: no non-kit ui/ via the alias, and no
+ // parent-relative path at all (kit-internal imports are `./` or `$lib/ui/kit`).
+ alsoForbid: ['^\\$lib/ui/(?!kit(/|$))', '^\\.\\.(/|$)'],
+ why: 'the kit is the leaf UI layer; pass app state in via props or snippets',
+ },
+ {
+ files: ['src/lib/ui/**'],
+ ignores: ['src/lib/ui/kit/**'],
+ forbid: ['feature', 'server'],
+ why: 'ui/ is below feature/; page compositions belong in feature/shell',
+ },
+ {
+ files: ['src/lib/app/**'],
+ forbid: ['feature', 'ui', 'server'],
+ why: 'app/ holds state and utilities only',
+ },
+ {
+ files: ['src/lib/api/**'],
+ forbid: ['feature', 'ui', 'server'],
+ why: 'api/ is the bottom layer',
+ },
+ {
+ files: ['src/lib/feature/**'],
+ forbid: ['server'],
+ why: 'feature/ ships to the client',
+ },
+ {
+ files: ['src/routes/**'],
+ ignores: [
+ 'src/routes/**/*.server.ts',
+ 'src/routes/**/+server.ts',
+ // API routes are server-only end to end, helpers included.
+ 'src/routes/api/**',
+ 'src/routes/**/*.test.ts',
+ ],
+ forbid: ['server'],
+ why: 'only server-side route files (*.server.ts, +server.ts, routes/api) may use $lib/server',
+ },
+ ]),
)
+
+/**
+ * Builds a `no-restricted-imports` config block per layer. `forbid` lists
+ * top-level `src/lib` directories; each is blocked both as `$lib/
` and as
+ * a parent-relative path (`../..//x`). `alsoForbid` takes raw regex
+ * sources for anything finer-grained. The typescript-eslint flavour of the
+ * rule is used so `import type` from a forbidden layer stays legal.
+ */
+function layerRules(layers) {
+ return layers.map(({ files, ignores, forbid, alsoForbid = [], why }) => ({
+ files,
+ ...(ignores ? { ignores } : {}),
+ rules: {
+ '@typescript-eslint/no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ regex: [
+ `^(\\$lib/|(\\.\\./)+)(${forbid.join('|')})(/|$)`,
+ ...alsoForbid,
+ ]
+ .map((source) => `(?:${source})`)
+ .join('|'),
+ message: `Layering violation: ${why}.`,
+ allowTypeImports: true,
+ },
+ ],
+ },
+ ],
+ },
+ }))
+}
diff --git a/src/lib/api/client.svelte.ts b/src/lib/api/client.svelte.ts
index 62678637..c0a821ab 100644
--- a/src/lib/api/client.svelte.ts
+++ b/src/lib/api/client.svelte.ts
@@ -1,7 +1,7 @@
import { browser } from '$app/environment'
-import { profile } from '$lib/app/auth.svelte'
-import { DEFAULT_INSTANCE_URL } from '$lib/app/instance.svelte'
-import { instanceToURL } from '$lib/app/util.svelte'
+import { profile } from '$lib/app/state/auth.svelte'
+import { DEFAULT_INSTANCE_URL } from '$lib/app/state/instance.svelte'
+import { instanceToURL } from '$lib/app/util/url'
import { error } from '@sveltejs/kit'
import { BaseClient, DEFAULT_CLIENT_TYPE, type ClientType } from './base'
import { CovesClient } from './coves'
diff --git a/src/lib/feature/post/image-proxy.test.ts b/src/lib/api/coves/image-proxy.test.ts
similarity index 100%
rename from src/lib/feature/post/image-proxy.test.ts
rename to src/lib/api/coves/image-proxy.test.ts
diff --git a/src/lib/feature/post/image-proxy.ts b/src/lib/api/coves/image-proxy.ts
similarity index 100%
rename from src/lib/feature/post/image-proxy.ts
rename to src/lib/api/coves/image-proxy.ts
diff --git a/src/lib/app/sort.test.ts b/src/lib/api/coves/sort.test.ts
similarity index 100%
rename from src/lib/app/sort.test.ts
rename to src/lib/api/coves/sort.test.ts
diff --git a/src/lib/app/sort.ts b/src/lib/api/coves/sort.ts
similarity index 100%
rename from src/lib/app/sort.ts
rename to src/lib/api/coves/sort.ts
diff --git a/src/lib/api/piefed/rewrite.ts b/src/lib/api/piefed/rewrite.ts
index 94f19646..0c5bdce6 100644
--- a/src/lib/api/piefed/rewrite.ts
+++ b/src/lib/api/piefed/rewrite.ts
@@ -1,5 +1,5 @@
// @ts-nocheck
-import { Search } from 'mono-svelte'
+import { Search } from '$lib/ui/kit'
import type {
Comment,
CommentReplyView,
diff --git a/src/lib/api/upload.ts b/src/lib/api/upload.ts
new file mode 100644
index 00000000..6bdcc1e8
--- /dev/null
+++ b/src/lib/api/upload.ts
@@ -0,0 +1,16 @@
+import { client } from '$lib/api/client.svelte'
+
+export async function uploadImage(
+ image: File | null | undefined,
+ instance: string,
+ jwt: string,
+): Promise {
+ if (!image) return
+
+ const res = await client({ auth: jwt, instanceURL: instance }).uploadImage({
+ image: image,
+ })
+
+ if (res.url) return res.url
+ else throw new Error(`Failed to upload image. ${res.msg}`)
+}
diff --git a/src/lib/app/auth.svelte.test.ts b/src/lib/app/state/auth.svelte.test.ts
similarity index 92%
rename from src/lib/app/auth.svelte.test.ts
rename to src/lib/app/state/auth.svelte.test.ts
index 19d01940..2e358c9d 100644
--- a/src/lib/app/auth.svelte.test.ts
+++ b/src/lib/app/state/auth.svelte.test.ts
@@ -9,21 +9,18 @@ vi.mock('./instance.svelte', () => ({
DEFAULT_INSTANCE_URL: 'https://coves.social',
}))
-vi.mock('./util.svelte', () => ({
- moveItem: (arr: T[], from: number, to: number): T[] => {
- const result = [...arr]
- const [item] = result.splice(from, 1)
- result.splice(to, 0, item)
- return result
- },
-}))
-
vi.mock('$lib/server/session', () => ({
// Types are re-exported as empty since they're only used for type checking
}))
// Import actual functions AFTER mocks are set up
-import { isAuthenticated, isGuest, type ProfileInfo, type GuestProfile, type AuthenticatedProfile } from './auth.svelte'
+import {
+ isAuthenticated,
+ isGuest,
+ type ProfileInfo,
+ type GuestProfile,
+ type AuthenticatedProfile,
+} from './auth.svelte'
describe('isAuthenticated type guard', () => {
it('should return true for authenticated profiles', () => {
diff --git a/src/lib/app/auth.svelte.ts b/src/lib/app/state/auth.svelte.ts
similarity index 99%
rename from src/lib/app/auth.svelte.ts
rename to src/lib/app/state/auth.svelte.ts
index f604f529..7bb199f7 100644
--- a/src/lib/app/auth.svelte.ts
+++ b/src/lib/app/state/auth.svelte.ts
@@ -1,6 +1,6 @@
import { browser } from '$app/environment'
import { DEFAULT_INSTANCE_URL } from './instance.svelte'
-import { moveItem } from './util.svelte'
+import { moveItem } from '../util/array'
import type {
ClientSession,
DID,
diff --git a/src/lib/app/i18n/ar.json b/src/lib/app/state/i18n/ar.json
similarity index 100%
rename from src/lib/app/i18n/ar.json
rename to src/lib/app/state/i18n/ar.json
diff --git a/src/lib/app/i18n/bg.json b/src/lib/app/state/i18n/bg.json
similarity index 100%
rename from src/lib/app/i18n/bg.json
rename to src/lib/app/state/i18n/bg.json
diff --git a/src/lib/app/i18n/ca.json b/src/lib/app/state/i18n/ca.json
similarity index 100%
rename from src/lib/app/i18n/ca.json
rename to src/lib/app/state/i18n/ca.json
diff --git a/src/lib/app/i18n/de.json b/src/lib/app/state/i18n/de.json
similarity index 100%
rename from src/lib/app/i18n/de.json
rename to src/lib/app/state/i18n/de.json
diff --git a/src/lib/app/i18n/en.json b/src/lib/app/state/i18n/en.json
similarity index 100%
rename from src/lib/app/i18n/en.json
rename to src/lib/app/state/i18n/en.json
diff --git a/src/lib/app/i18n/es.json b/src/lib/app/state/i18n/es.json
similarity index 100%
rename from src/lib/app/i18n/es.json
rename to src/lib/app/state/i18n/es.json
diff --git a/src/lib/app/i18n/et.json b/src/lib/app/state/i18n/et.json
similarity index 100%
rename from src/lib/app/i18n/et.json
rename to src/lib/app/state/i18n/et.json
diff --git a/src/lib/app/i18n/fi.json b/src/lib/app/state/i18n/fi.json
similarity index 100%
rename from src/lib/app/i18n/fi.json
rename to src/lib/app/state/i18n/fi.json
diff --git a/src/lib/app/i18n/fr.json b/src/lib/app/state/i18n/fr.json
similarity index 100%
rename from src/lib/app/i18n/fr.json
rename to src/lib/app/state/i18n/fr.json
diff --git a/src/lib/app/i18n/he.json b/src/lib/app/state/i18n/he.json
similarity index 100%
rename from src/lib/app/i18n/he.json
rename to src/lib/app/state/i18n/he.json
diff --git a/src/lib/app/i18n/hu.json b/src/lib/app/state/i18n/hu.json
similarity index 100%
rename from src/lib/app/i18n/hu.json
rename to src/lib/app/state/i18n/hu.json
diff --git a/src/lib/app/i18n/index.ts b/src/lib/app/state/i18n/index.ts
similarity index 100%
rename from src/lib/app/i18n/index.ts
rename to src/lib/app/state/i18n/index.ts
diff --git a/src/lib/app/i18n/ja.json b/src/lib/app/state/i18n/ja.json
similarity index 100%
rename from src/lib/app/i18n/ja.json
rename to src/lib/app/state/i18n/ja.json
diff --git a/src/lib/app/i18n/nl.json b/src/lib/app/state/i18n/nl.json
similarity index 100%
rename from src/lib/app/i18n/nl.json
rename to src/lib/app/state/i18n/nl.json
diff --git a/src/lib/app/i18n/pl.json b/src/lib/app/state/i18n/pl.json
similarity index 100%
rename from src/lib/app/i18n/pl.json
rename to src/lib/app/state/i18n/pl.json
diff --git a/src/lib/app/i18n/pt-BR.json b/src/lib/app/state/i18n/pt-BR.json
similarity index 100%
rename from src/lib/app/i18n/pt-BR.json
rename to src/lib/app/state/i18n/pt-BR.json
diff --git a/src/lib/app/i18n/pt.json b/src/lib/app/state/i18n/pt.json
similarity index 100%
rename from src/lib/app/i18n/pt.json
rename to src/lib/app/state/i18n/pt.json
diff --git a/src/lib/app/i18n/ru.json b/src/lib/app/state/i18n/ru.json
similarity index 100%
rename from src/lib/app/i18n/ru.json
rename to src/lib/app/state/i18n/ru.json
diff --git a/src/lib/app/i18n/tok.json b/src/lib/app/state/i18n/tok.json
similarity index 100%
rename from src/lib/app/i18n/tok.json
rename to src/lib/app/state/i18n/tok.json
diff --git a/src/lib/app/i18n/tr.json b/src/lib/app/state/i18n/tr.json
similarity index 100%
rename from src/lib/app/i18n/tr.json
rename to src/lib/app/state/i18n/tr.json
diff --git a/src/lib/app/i18n/zh-Hans.json b/src/lib/app/state/i18n/zh-Hans.json
similarity index 100%
rename from src/lib/app/i18n/zh-Hans.json
rename to src/lib/app/state/i18n/zh-Hans.json
diff --git a/src/lib/app/i18n/zh-Hant.json b/src/lib/app/state/i18n/zh-Hant.json
similarity index 100%
rename from src/lib/app/i18n/zh-Hant.json
rename to src/lib/app/state/i18n/zh-Hant.json
diff --git a/src/lib/app/instance.svelte.ts b/src/lib/app/state/instance.svelte.ts
similarity index 100%
rename from src/lib/app/instance.svelte.ts
rename to src/lib/app/state/instance.svelte.ts
diff --git a/src/lib/app/instance/resolve.test.ts b/src/lib/app/state/instance/resolve.test.ts
similarity index 100%
rename from src/lib/app/instance/resolve.test.ts
rename to src/lib/app/state/instance/resolve.test.ts
diff --git a/src/lib/app/instance/resolve.ts b/src/lib/app/state/instance/resolve.ts
similarity index 98%
rename from src/lib/app/instance/resolve.ts
rename to src/lib/app/state/instance/resolve.ts
index 2e9e5237..1216e6d1 100644
--- a/src/lib/app/instance/resolve.ts
+++ b/src/lib/app/state/instance/resolve.ts
@@ -16,7 +16,7 @@
*
* The functions are pure and take their env as a parameter so the same rules
* are unit-testable and shareable between the browser-facing module
- * (`$lib/app/instance.svelte`) and the server-only one (`$lib/server/instance`),
+ * (`$lib/app/state/instance.svelte`) and the server-only one (`$lib/server/instance`),
* which is the only place private env may be read.
*
* See docs/ENVIRONMENT.md for the operator-facing reference.
diff --git a/src/lib/app/session.ts b/src/lib/app/state/session.ts
similarity index 100%
rename from src/lib/app/session.ts
rename to src/lib/app/state/session.ts
diff --git a/src/lib/app/settings.svelte.ts b/src/lib/app/state/settings.svelte.ts
similarity index 99%
rename from src/lib/app/settings.svelte.ts
rename to src/lib/app/state/settings.svelte.ts
index 0ed23440..981c0ac2 100644
--- a/src/lib/app/settings.svelte.ts
+++ b/src/lib/app/state/settings.svelte.ts
@@ -1,7 +1,7 @@
import { browser } from '$app/environment'
import { env } from '$env/dynamic/public'
import { locale } from './i18n'
-import { mergeDeep } from './merge'
+import { mergeDeep } from '../util/merge'
import {
normalizeCommentSort,
normalizeListing,
@@ -10,7 +10,7 @@ import {
type CovesListingType,
type CovesSortType,
type CovesTimeframe,
-} from './sort'
+} from '$lib/api/coves/sort'
export type View = 'cozy' | 'compact'
diff --git a/src/lib/app/settings.test.ts b/src/lib/app/state/settings.test.ts
similarity index 100%
rename from src/lib/app/settings.test.ts
rename to src/lib/app/state/settings.test.ts
diff --git a/src/lib/app/theme/presets.ts b/src/lib/app/state/theme/presets.ts
similarity index 100%
rename from src/lib/app/theme/presets.ts
rename to src/lib/app/state/theme/presets.ts
diff --git a/src/lib/app/theme/theme.svelte.test.ts b/src/lib/app/state/theme/theme.svelte.test.ts
similarity index 100%
rename from src/lib/app/theme/theme.svelte.test.ts
rename to src/lib/app/state/theme/theme.svelte.test.ts
diff --git a/src/lib/app/theme/theme.svelte.ts b/src/lib/app/state/theme/theme.svelte.ts
similarity index 100%
rename from src/lib/app/theme/theme.svelte.ts
rename to src/lib/app/state/theme/theme.svelte.ts
diff --git a/src/lib/app/util.svelte.ts b/src/lib/app/util.svelte.ts
deleted file mode 100644
index 9461bec3..00000000
--- a/src/lib/app/util.svelte.ts
+++ /dev/null
@@ -1,275 +0,0 @@
-import { browser } from '$app/environment'
-import { goto } from '$app/navigation'
-import { client } from '$lib/api/client.svelte'
-import type {
- CommunityRef,
- CommunityView as CovesCommunityView,
-} from '$lib/api/coves/types'
-import { SvelteURL } from 'svelte/reactivity'
-import { t } from './i18n'
-
-// 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 const searchParam = async (
- url: URL,
- key: string,
- value: string,
- ...deleteKeys: string[]
-): Promise => {
- url.searchParams.set(key, value)
- deleteKeys.forEach((k) => url.searchParams.delete(k))
- try {
- await goto(url, {
- invalidateAll: true,
- })
- } catch (err) {
- console.error('[searchParam] Navigation failed:', err)
- }
-}
-
-export const fullCommunityName = (name: string, actorId: string): string => {
- try {
- return `${name}@${new SvelteURL(actorId).hostname}`
- } catch {
- return name
- }
-}
-
-/**
- * Strips the "c-" prefix from a community handle to produce its canonical form.
- *
- * Communities provisioned on a Coves instance use a "c-" prefix convention
- * (e.g. "c-mycommunity.coves.social") to distinguish community actors from
- * user actors in the ATProto namespace. Communities bridged in from other
- * platforms keep their source handle and carry no prefix, so this strips the
- * prefix only when it is actually there.
- *
- * The prefix is internal plumbing: route params, URLs, and any handle shown to
- * a user all use the bare form. There is deliberately no inverse — a slug is
- * sent to the API as-is, because the bare form is ambiguous (both
- * "gardening.coves.social" and a bridged "linux.lemmy-ml.tdpl.io" are
- * prefix-free) and only the AppView knows which stored handle it maps to. It
- * resolves either form.
- */
-export function communitySlug(handle: string): string {
- return handle.startsWith('c-') ? handle.slice(2) : handle
-}
-
-export const placeholders = {
- get: (type: 'url' | 'post' | 'body' | 'comment') => {
- switch (type) {
- case 'post':
- return Math.random() < 0.01
- ? 'A C E C* B* G D E E F G F E D C E'
- : t.get('placeholders.title')
- case 'body':
- return t.get('placeholders.body')
- case 'comment':
- return t.get('placeholders.comments')
- case 'url':
- return 'https://example.com'
- }
- },
-}
-
-export function moveItem(
- array: T[],
- currentIndex: number,
- newIndex: number,
-): T[] {
- if (
- currentIndex < 0 ||
- currentIndex >= array.length ||
- newIndex < 0 ||
- newIndex >= array.length
- ) {
- throw new Error('Invalid index')
- }
-
- const newArray = [...array]
-
- // Remove the item from the current index
- const [item] = newArray.splice(currentIndex, 1)
-
- // Insert the item at the new index
- newArray.splice(newIndex, 0, item)
-
- return newArray
-}
-
-export const DOMAIN_REGEX =
- /^(http(s)?:\/\/)?((?!-)[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,63}(:[0-9]{0,5})?$/g
-export const DOMAIN_REGEX_FORMS =
- '(http(s)?://)?((?!-)[A-Za-z0-9]{1,63}.)+[A-Za-z]{2,63}(:[0-9]{0,5})?'
-
-export async function uploadImage(
- image: File | null | undefined,
- instance: string,
- jwt: string,
-): Promise {
- if (!image) return
-
- const formData = new FormData()
- formData.append('images[]', image)
-
- const res = await client({ auth: jwt, instanceURL: instance }).uploadImage({
- image: image,
- })
-
- if (res.url) return res.url
- else throw new Error(`Failed to upload image. ${res.msg}`)
-}
-
-export const instanceToURL = (input: string) =>
- input.startsWith('http://') || input.startsWith('https://')
- ? input
- : `https://${input}`
-
-export function canParseUrl(url: string): boolean {
- try {
- new SvelteURL(url)
- return true
- } catch {
- return false
- }
-}
-
-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()
- let score = 0
- let lastIndex = -1
- let consecutiveBonus = 0
-
- for (let i = 0; i < patternLower.length; i++) {
- const index = textLower.indexOf(patternLower[i], lastIndex + 1)
- if (index === -1) return 0
-
- score += 1
- if (index === lastIndex + 1) {
- consecutiveBonus++
- score += consecutiveBonus
- } else {
- consecutiveBonus = 0
- }
-
- lastIndex = index
- }
-
- // Bonus for matching start of words
- if (textLower.startsWith(patternLower)) {
- score += 2
- } else if (textLower.includes(' ' + patternLower)) {
- score += 1
- }
-
- return score
-}
-
-export const awaitIfServer = async (
- promise: Promise,
-): Promise<{
- data: Promise | T
-}> => ({ data: browser ? promise : await promise })
-
-export class ReactiveState {
- value = $state()!
-
- constructor(initialValue: T) {
- this.value = initialValue as NonNullable
- }
-}
-
-export function snapshot(item: T) {
- return $state.snapshot(item)
-}
-
-export const isImage = (url: string | undefined): boolean => {
- if (!url) return false
- return /\.(jpeg|jpg|gif|png|svg|bmp|webp|avif)/i.test(url)
-}
-
-export const isVideo = (url: string | undefined): boolean => {
- if (!url) return false
- return /\.(mp4|mov|webm|mkv|avi)/i.test(url)
-}
-
-/**
- * Generate a link path for a community.
- * Accepts a Coves CommunityRef or CommunityView.
- *
- * Falls back to the community DID when the handle is missing — the
- * `[handle=handle]` route matcher accepts handles and DIDs but not bare
- * community names, so a `name`-based URL would 404 at routing.
- */
-export function communityLink(
- community: CommunityRef | CovesCommunityView,
- prefix: string = '',
-): string {
- if ('handle' in community && community.handle) {
- return `${prefix}/c/${encodeURIComponent(communitySlug(community.handle))}`
- }
- return `${prefix}/c/${encodeURIComponent(community.did)}`
-}
-
-/**
- * Generate a link path for a user profile.
- *
- * Takes any user-shaped view: `handle` is optional on some of them
- * (`ProfileViewDetailed`) and the DID route is the fallback either way.
- */
-export function userLink(
- user: { did: string; handle?: string },
- prefix: string = '',
-): string {
- if (user.handle) {
- return `${prefix}/profile/${encodeURIComponent(user.handle)}`
- }
- return `${prefix}/profile/${encodeURIComponent(user.did)}`
-}
-
-/**
- * Basic types only, don't use for anything more than basic equality
- */
-export function recursiveEqual(a: T, b: T): boolean {
- if (a === b) return true
- if (typeof a !== 'object' || typeof b !== 'object') return false
-
- if (a == null || b == null) {
- if (a == null && b == null) return true
- else return false
- }
-
- const keysA = Object.keys(a) as (keyof typeof a)[]
- const keysB = Object.keys(b) as (keyof typeof b)[]
-
- if (keysA.length != keysB.length) return false
-
- for (const key of keysA) {
- const valA = a[key]
- const valB = b[key]
-
- if (typeof valA == 'object' && typeof valB == 'object') {
- const result = recursiveEqual(valA!, valB!)
- if (!result) return false
- } else {
- if (valA != valB) return false
- }
- }
-
- return true
-}
diff --git a/src/lib/app/util/array.test.ts b/src/lib/app/util/array.test.ts
new file mode 100644
index 00000000..1460a06b
--- /dev/null
+++ b/src/lib/app/util/array.test.ts
@@ -0,0 +1,67 @@
+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)
+ })
+})
+
+describe('moveItem', () => {
+ it('moves an item forward and backward without mutating the input', () => {
+ const source = ['a', 'b', 'c']
+ expect(moveItem(source, 0, 2)).toEqual(['b', 'c', 'a'])
+ expect(moveItem(source, 2, 0)).toEqual(['c', 'a', 'b'])
+ expect(source).toEqual(['a', 'b', 'c'])
+ })
+
+ // auth.svelte.ts reorders the account list through this; an out-of-range
+ // index must throw rather than silently corrupt the list.
+ it.each([
+ [-1, 0],
+ [0, 3],
+ [3, 0],
+ ])('throws on an out-of-range index (%i -> %i)', (from, to) => {
+ expect(() => moveItem(['a', 'b', 'c'], from, to)).toThrow('Invalid index')
+ })
+})
+
+// recursiveEqual gates the feed cache in feature/feeds/feed.svelte.ts.
+describe('recursiveEqual', () => {
+ it('compares nested objects structurally', () => {
+ expect(recursiveEqual({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true)
+ expect(recursiveEqual({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false)
+ })
+
+ it('treats a differing key count as unequal', () => {
+ expect(recursiveEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
+ })
+
+ it('handles null leaves without recursing into them', () => {
+ expect(recursiveEqual({ a: null }, { a: null })).toBe(true)
+ expect(recursiveEqual({ a: null }, { a: {} })).toBe(false)
+ })
+
+ it('compares primitives and identical references directly', () => {
+ expect(recursiveEqual(1, 1)).toBe(true)
+ expect(recursiveEqual('a', 'b')).toBe(false)
+ const same = { x: 1 }
+ expect(recursiveEqual(same, same)).toBe(true)
+ })
+
+ // Documents the loose leaf comparison. If this ever needs to be strict,
+ // Feed.load's cache-invalidation check is the caller to re-verify.
+ it('does not distinguish a numeric leaf from its string form', () => {
+ expect(
+ recursiveEqual({ limit: 20 }, { limit: '20' as unknown as number }),
+ ).toBe(true)
+ })
+})
diff --git a/src/lib/app/util/array.ts b/src/lib/app/util/array.ts
new file mode 100644
index 00000000..2693fd3e
--- /dev/null
+++ b/src/lib/app/util/array.ts
@@ -0,0 +1,63 @@
+// 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,
+ newIndex: number,
+): T[] {
+ if (
+ currentIndex < 0 ||
+ currentIndex >= array.length ||
+ newIndex < 0 ||
+ newIndex >= array.length
+ ) {
+ throw new Error('Invalid index')
+ }
+
+ const newArray = [...array]
+
+ // Remove the item from the current index
+ const [item] = newArray.splice(currentIndex, 1)
+
+ // Insert the item at the new index
+ newArray.splice(newIndex, 0, item)
+
+ return newArray
+}
+
+/**
+ * Basic types only, don't use for anything more than basic equality
+ */
+export function recursiveEqual(a: T, b: T): boolean {
+ if (a === b) return true
+ if (typeof a !== 'object' || typeof b !== 'object') return false
+
+ if (a == null || b == null) {
+ if (a == null && b == null) return true
+ else return false
+ }
+
+ const keysA = Object.keys(a) as (keyof typeof a)[]
+ const keysB = Object.keys(b) as (keyof typeof b)[]
+
+ if (keysA.length != keysB.length) return false
+
+ for (const key of keysA) {
+ const valA = a[key]
+ const valB = b[key]
+
+ if (typeof valA == 'object' && typeof valB == 'object') {
+ const result = recursiveEqual(valA!, valB!)
+ if (!result) return false
+ } else {
+ if (valA != valB) return false
+ }
+ }
+
+ return true
+}
diff --git a/src/lib/app/error.ts b/src/lib/app/util/error.ts
similarity index 63%
rename from src/lib/app/error.ts
rename to src/lib/app/util/error.ts
index 50eb49b2..6869bed8 100644
--- a/src/lib/app/error.ts
+++ b/src/lib/app/util/error.ts
@@ -1,5 +1,10 @@
-import { t } from './i18n'
+import { t } from '$lib/app/state/i18n'
+/**
+ * Turns anything an API call or loader can throw into a user-facing string.
+ * Consumers render the result directly (toasts, error pages), so this must
+ * always return a readable message — never a raw object or "undefined".
+ */
// eslint-disable-next-line
export function errorMessage(error: any, instance?: string): string {
try {
@@ -12,7 +17,13 @@ export function errorMessage(error: any, instance?: string): string {
}
if (error?.body?.message) {
- error = JSON.parse(error?.body?.message)
+ // Lemmy-style errors nest a JSON document in `body.message`; a plain
+ // text message there is already the message.
+ try {
+ error = JSON.parse(error.body.message)
+ } catch {
+ error = error.body.message
+ }
if (typeof error?.message == 'string') {
// probably piefed weird error format
@@ -25,7 +36,7 @@ export function errorMessage(error: any, instance?: string): string {
if (error?.error && typeof error?.error === 'string') {
error = error.error
}
- if (!error) throw error
+ if (!error) return t.get('error.unknown')
// Only key-shaped messages (error codes like "couldnt_find_post") can be
// translation keys — free-text messages would just make t.get log a
@@ -37,15 +48,13 @@ export function errorMessage(error: any, instance?: string): string {
const key = `error.${error}`
const translated = t.get(key)
return translated === key ? String(error) : translated
- } catch {
- // Consumers render this (e.g. inside toast markdown), so it must always
- // be a string — a raw object here crashes the toast into an empty box.
+ } catch (formatError) {
+ console.error('[errorMessage] failed to format error', {
+ error,
+ formatError,
+ })
if (typeof error === 'string') return error
if (error instanceof Error) return error.message
- try {
- return JSON.stringify(error) ?? String(error)
- } catch {
- return String(error)
- }
+ return t.get('error.unknown')
}
}
diff --git a/src/lib/app/util.svelte.test.ts b/src/lib/app/util/links.test.ts
similarity index 60%
rename from src/lib/app/util.svelte.test.ts
rename to src/lib/app/util/links.test.ts
index 32ddd33b..3031940a 100644
--- a/src/lib/app/util.svelte.test.ts
+++ b/src/lib/app/util/links.test.ts
@@ -1,16 +1,7 @@
import { describe, it, expect } from 'vitest'
import type { AuthorView, CommunityRef } from '$lib/api/coves/types'
import type { DID, Handle } from '$lib/types/atproto'
-import {
- canParseUrl,
- communityLink,
- communitySlug,
- escapeHtml,
- findClosestNumber,
- isImage,
- isVideo,
- userLink,
-} from './util.svelte'
+import { communityLink, communitySlug, userLink } from './links'
// ---------------------------------------------------------------------------
// communityLink()
@@ -147,101 +138,3 @@ describe('userLink', () => {
expect(userLink(author, '/app')).toBe('/app/profile/alice.coves.social')
})
})
-
-// ---------------------------------------------------------------------------
-// isImage()
-// ---------------------------------------------------------------------------
-
-describe('isImage', () => {
- it('returns false for undefined', () => {
- expect(isImage(undefined)).toBe(false)
- })
-
- it('returns true for .jpg URL', () => {
- expect(isImage('https://example.com/photo.jpg')).toBe(true)
- })
-
- it('returns true for .png URL (case-insensitive)', () => {
- expect(isImage('https://example.com/PHOTO.PNG')).toBe(true)
- })
-
- it('returns false for .mp4 URL', () => {
- expect(isImage('https://example.com/video.mp4')).toBe(false)
- })
-
- it('returns false for URL without image extension', () => {
- expect(isImage('https://example.com/page')).toBe(false)
- })
-})
-
-// ---------------------------------------------------------------------------
-// isVideo()
-// ---------------------------------------------------------------------------
-
-describe('isVideo', () => {
- it('returns false for undefined', () => {
- expect(isVideo(undefined)).toBe(false)
- })
-
- it('returns true for .mp4 URL', () => {
- expect(isVideo('https://example.com/clip.mp4')).toBe(true)
- })
-
- it('returns true for .webm URL', () => {
- expect(isVideo('https://example.com/clip.webm')).toBe(true)
- })
-
- it('returns false for .jpg URL', () => {
- expect(isVideo('https://example.com/photo.jpg')).toBe(false)
- })
-})
-
-// ---------------------------------------------------------------------------
-// findClosestNumber()
-// ---------------------------------------------------------------------------
-
-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)
- })
-})
-
-// ---------------------------------------------------------------------------
-// canParseUrl()
-// ---------------------------------------------------------------------------
-
-describe('canParseUrl', () => {
- it('returns true for valid URL', () => {
- expect(canParseUrl('https://example.com')).toBe(true)
- })
-
- it('returns false for invalid URL', () => {
- expect(canParseUrl('not a url')).toBe(false)
- })
-})
-
-// ---------------------------------------------------------------------------
-// escapeHtml()
-// ---------------------------------------------------------------------------
-
-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/links.ts b/src/lib/app/util/links.ts
new file mode 100644
index 00000000..824a17ac
--- /dev/null
+++ b/src/lib/app/util/links.ts
@@ -0,0 +1,58 @@
+import type {
+ CommunityRef,
+ CommunityView as CovesCommunityView,
+} from '$lib/api/coves/types'
+
+/**
+ * Strips the "c-" prefix from a community handle to produce its canonical form.
+ *
+ * Communities provisioned on a Coves instance use a "c-" prefix convention
+ * (e.g. "c-mycommunity.coves.social") to distinguish community actors from
+ * user actors in the ATProto namespace. Communities bridged in from other
+ * platforms keep their source handle and carry no prefix, so this strips the
+ * prefix only when it is actually there.
+ *
+ * The prefix is internal plumbing: route params, URLs, and any handle shown to
+ * a user all use the bare form. There is deliberately no inverse — a slug is
+ * sent to the API as-is, because the bare form is ambiguous (both
+ * "gardening.coves.social" and a bridged "linux.lemmy-ml.tdpl.io" are
+ * prefix-free) and only the AppView knows which stored handle it maps to. It
+ * resolves either form.
+ */
+export function communitySlug(handle: string): string {
+ return handle.startsWith('c-') ? handle.slice(2) : handle
+}
+
+/**
+ * Generate a link path for a community.
+ * Accepts a Coves CommunityRef or CommunityView.
+ *
+ * Falls back to the community DID when the handle is missing — the
+ * `[handle=handle]` route matcher accepts handles and DIDs but not bare
+ * community names, so a `name`-based URL would 404 at routing.
+ */
+export function communityLink(
+ community: CommunityRef | CovesCommunityView,
+ prefix: string = '',
+): string {
+ if ('handle' in community && community.handle) {
+ return `${prefix}/c/${encodeURIComponent(communitySlug(community.handle))}`
+ }
+ return `${prefix}/c/${encodeURIComponent(community.did)}`
+}
+
+/**
+ * Generate a link path for a user profile.
+ *
+ * Takes any user-shaped view: `handle` is optional on some of them
+ * (`ProfileViewDetailed`) and the DID route is the fallback either way.
+ */
+export function userLink(
+ user: { did: string; handle?: string },
+ prefix: string = '',
+): string {
+ if (user.handle) {
+ return `${prefix}/profile/${encodeURIComponent(user.handle)}`
+ }
+ return `${prefix}/profile/${encodeURIComponent(user.did)}`
+}
diff --git a/src/lib/app/merge.test.ts b/src/lib/app/util/merge.test.ts
similarity index 100%
rename from src/lib/app/merge.test.ts
rename to src/lib/app/util/merge.test.ts
diff --git a/src/lib/app/merge.ts b/src/lib/app/util/merge.ts
similarity index 100%
rename from src/lib/app/merge.ts
rename to src/lib/app/util/merge.ts
diff --git a/src/lib/app/util/navigation.ts b/src/lib/app/util/navigation.ts
new file mode 100644
index 00000000..a3da9978
--- /dev/null
+++ b/src/lib/app/util/navigation.ts
@@ -0,0 +1,22 @@
+import { goto } from '$app/navigation'
+
+/**
+ * Sets `key=value` on `url` (dropping `deleteKeys`) and navigates to it with
+ * a full data reload.
+ */
+export const searchParam = async (
+ url: URL,
+ key: string,
+ value: string,
+ ...deleteKeys: string[]
+): Promise => {
+ url.searchParams.set(key, value)
+ deleteKeys.forEach((k) => url.searchParams.delete(k))
+ try {
+ await goto(url, {
+ invalidateAll: true,
+ })
+ } catch (err) {
+ console.error('[searchParam] Navigation failed:', err)
+ }
+}
diff --git a/src/lib/app/util/placeholders.ts b/src/lib/app/util/placeholders.ts
new file mode 100644
index 00000000..6c7214df
--- /dev/null
+++ b/src/lib/app/util/placeholders.ts
@@ -0,0 +1,18 @@
+import { t } from '$lib/app/state/i18n'
+
+export const placeholders = {
+ get: (type: 'url' | 'post' | 'body' | 'comment'): string => {
+ switch (type) {
+ case 'post':
+ return Math.random() < 0.01
+ ? 'A C E C* B* G D E E F G F E D C E'
+ : t.get('placeholders.title')
+ case 'body':
+ return t.get('placeholders.body')
+ case 'comment':
+ return t.get('placeholders.comments')
+ case 'url':
+ return 'https://example.com'
+ }
+ },
+}
diff --git a/src/lib/app/util/reactive.svelte.ts b/src/lib/app/util/reactive.svelte.ts
new file mode 100644
index 00000000..5a313f54
--- /dev/null
+++ b/src/lib/app/util/reactive.svelte.ts
@@ -0,0 +1,14 @@
+/**
+ * A `$state`-backed box for a single value.
+ *
+ * Load functions cannot return runes directly, so route loaders wrap values
+ * that components need to mutate reactively (e.g. a paginated list the page
+ * appends to) in a `ReactiveState` instead.
+ */
+export class ReactiveState {
+ value: T = $state()!
+
+ constructor(initialValue: T) {
+ this.value = initialValue
+ }
+}
diff --git a/src/lib/app/util/ssr.ts b/src/lib/app/util/ssr.ts
new file mode 100644
index 00000000..6996f45c
--- /dev/null
+++ b/src/lib/app/util/ssr.ts
@@ -0,0 +1,11 @@
+import { browser } from '$app/environment'
+
+/**
+ * Streams `promise` to the client when running in the browser, but awaits it
+ * during SSR so the server never renders a pending placeholder.
+ */
+export const awaitIfServer = async (
+ promise: Promise,
+): Promise<{
+ data: Promise | T
+}> => ({ data: browser ? promise : await promise })
diff --git a/src/lib/app/util/text.test.ts b/src/lib/app/util/text.test.ts
new file mode 100644
index 00000000..e86f985b
--- /dev/null
+++ b/src/lib/app/util/text.test.ts
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 00000000..edb0c1cd
--- /dev/null
+++ b/src/lib/app/util/text.ts
@@ -0,0 +1,40 @@
+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()
+ let score = 0
+ let lastIndex = -1
+ let consecutiveBonus = 0
+
+ for (let i = 0; i < patternLower.length; i++) {
+ const index = textLower.indexOf(patternLower[i], lastIndex + 1)
+ if (index === -1) return 0
+
+ score += 1
+ if (index === lastIndex + 1) {
+ consecutiveBonus++
+ score += consecutiveBonus
+ } else {
+ consecutiveBonus = 0
+ }
+
+ lastIndex = index
+ }
+
+ // Bonus for matching start of words
+ if (textLower.startsWith(patternLower)) {
+ score += 2
+ } else if (textLower.includes(' ' + patternLower)) {
+ score += 1
+ }
+
+ return score
+}
diff --git a/src/lib/app/util/url.test.ts b/src/lib/app/util/url.test.ts
new file mode 100644
index 00000000..fbd1a8e5
--- /dev/null
+++ b/src/lib/app/util/url.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest'
+import { canParseUrl, isImage, isVideo } from './url'
+
+describe('isImage', () => {
+ it('returns false for undefined', () => {
+ expect(isImage(undefined)).toBe(false)
+ })
+
+ it('returns true for .jpg URL', () => {
+ expect(isImage('https://example.com/photo.jpg')).toBe(true)
+ })
+
+ it('returns true for .png URL (case-insensitive)', () => {
+ expect(isImage('https://example.com/PHOTO.PNG')).toBe(true)
+ })
+
+ it('returns false for .mp4 URL', () => {
+ expect(isImage('https://example.com/video.mp4')).toBe(false)
+ })
+
+ it('returns false for URL without image extension', () => {
+ expect(isImage('https://example.com/page')).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// isVideo()
+// ---------------------------------------------------------------------------
+
+describe('isVideo', () => {
+ it('returns false for undefined', () => {
+ expect(isVideo(undefined)).toBe(false)
+ })
+
+ it('returns true for .mp4 URL', () => {
+ expect(isVideo('https://example.com/clip.mp4')).toBe(true)
+ })
+
+ it('returns true for .webm URL', () => {
+ expect(isVideo('https://example.com/clip.webm')).toBe(true)
+ })
+
+ it('returns false for .jpg URL', () => {
+ expect(isVideo('https://example.com/photo.jpg')).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// canParseUrl()
+// ---------------------------------------------------------------------------
+
+describe('canParseUrl', () => {
+ it('returns true for valid URL', () => {
+ expect(canParseUrl('https://example.com')).toBe(true)
+ })
+
+ it('returns false for invalid URL', () => {
+ expect(canParseUrl('not a url')).toBe(false)
+ })
+})
diff --git a/src/lib/app/util/url.ts b/src/lib/app/util/url.ts
new file mode 100644
index 00000000..06716a25
--- /dev/null
+++ b/src/lib/app/util/url.ts
@@ -0,0 +1,28 @@
+export const DOMAIN_REGEX =
+ /^(http(s)?:\/\/)?((?!-)[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,63}(:[0-9]{0,5})?$/g
+export const DOMAIN_REGEX_FORMS =
+ '(http(s)?://)?((?!-)[A-Za-z0-9]{1,63}.)+[A-Za-z]{2,63}(:[0-9]{0,5})?'
+
+export const instanceToURL = (input: string): string =>
+ input.startsWith('http://') || input.startsWith('https://')
+ ? input
+ : `https://${input}`
+
+export function canParseUrl(url: string): boolean {
+ try {
+ new URL(url)
+ return true
+ } catch {
+ return false
+ }
+}
+
+export const isImage = (url: string | undefined): boolean => {
+ if (!url) return false
+ return /\.(jpeg|jpg|gif|png|svg|bmp|webp|avif)/i.test(url)
+}
+
+export const isVideo = (url: string | undefined): boolean => {
+ if (!url) return false
+ return /\.(mp4|mov|webm|mkv|avi)/i.test(url)
+}
diff --git a/src/lib/feature/comment/Comment.svelte b/src/lib/feature/comment/Comment.svelte
index 84ad3c9a..18b2f95a 100644
--- a/src/lib/feature/comment/Comment.svelte
+++ b/src/lib/feature/comment/Comment.svelte
@@ -4,17 +4,17 @@
import type { StrongRef } from '$lib/api/coves/types'
import { parseAtUri } from '$lib/api/coves/types'
import type { DID } from '$lib/types/atproto'
- import { profile } from '$lib/app/auth.svelte'
- import { errorMessage } from '$lib/app/error'
- import { t } from '$lib/app/i18n'
- import Markdown from '$lib/app/markdown/Markdown.svelte'
- import RichText from '$lib/app/richtext/RichText.svelte'
- import { hasFacets } from '$lib/app/richtext/facets'
- import { settings } from '$lib/app/settings.svelte'
+ import { profile } from '$lib/app/state/auth.svelte'
+ import { errorMessage } from '$lib/app/util/error'
+ import { t } from '$lib/app/state/i18n'
+ import Markdown from '$lib/feature/markdown/Markdown.svelte'
+ import RichText from '$lib/feature/richtext/RichText.svelte'
+ import { hasFacets } from '$lib/feature/richtext/facets'
+ import { settings } from '$lib/app/state/settings.svelte'
import type { PostLinkRef } from '$lib/feature/post'
import { publishedToDate } from '$lib/ui/util/date'
- import { Button, Modal, toast } from 'mono-svelte'
- import RelativeDate from 'mono-svelte/util/RelativeDate.svelte'
+ import { Button, Modal, toast } from '$lib/ui/kit'
+ import RelativeDate from '$lib/ui/util/RelativeDate.svelte'
import { Icon, Microphone, Minus, Plus, Trash } from 'svelte-hero-icons/dist'
import { expoOut } from 'svelte/easing'
import type { ClassValue } from 'svelte/elements'
diff --git a/src/lib/feature/comment/CommentActions.svelte b/src/lib/feature/comment/CommentActions.svelte
index 694b7b77..1ae502b6 100644
--- a/src/lib/feature/comment/CommentActions.svelte
+++ b/src/lib/feature/comment/CommentActions.svelte
@@ -1,17 +1,17 @@
+
+
diff --git a/src/lib/ui/shared/forms/select/Select.svelte b/src/lib/ui/kit/forms/select/Select.svelte
similarity index 85%
rename from src/lib/ui/shared/forms/select/Select.svelte
rename to src/lib/ui/kit/forms/select/Select.svelte
index 809418d6..f72bce51 100644
--- a/src/lib/ui/shared/forms/select/Select.svelte
+++ b/src/lib/ui/kit/forms/select/Select.svelte
@@ -1,16 +1,16 @@
@@ -35,7 +42,7 @@
{#if action.icon}
{/if}
- {action.content}
+ {action.content ?? closeLabel}
{/each}
diff --git a/src/lib/ui/shared/modal/modal.ts b/src/lib/ui/kit/modal/modal.ts
similarity index 53%
rename from src/lib/ui/shared/modal/modal.ts
rename to src/lib/ui/kit/modal/modal.ts
index b70e2004..8c1e45d9 100644
--- a/src/lib/ui/shared/modal/modal.ts
+++ b/src/lib/ui/kit/modal/modal.ts
@@ -1,4 +1,3 @@
-import { t } from '$lib/app/i18n'
import type { IconSource } from '@xylightdev/svelte-hero-icons'
import type { Snippet } from 'svelte'
import { writable } from 'svelte/store'
@@ -17,34 +16,49 @@ interface Modal {
type: 'error' | 'info' | 'success'
}
-type Action = {
+export interface Action {
/**
* What function to run when this action is clicked. If undefined, it'll close the window.
*/
action: () => void
type: 'danger' | 'secondary' | 'primary'
- content: string
+ /**
+ * Button label. Undefined means "use the ModalContainer's `closeLabel`",
+ * which is how the default close action gets a localized label without the
+ * kit depending on the app's i18n.
+ */
+ content?: string
icon?: IconSource
- close: boolean
}
-export const action = (action?: {
+interface ActionInput {
action?: () => void
type?: 'danger' | 'secondary' | 'primary'
content?: string
icon?: IconSource
+ /** Close the modal after `action` runs. Defaults to true. */
close?: boolean
-}): Action => ({
- action: action?.action
- ? () => {
- action?.action?.()
- if (action.close) shownModal.set(undefined)
- }
- : () => shownModal.set(undefined),
- type: action?.type ?? 'secondary',
- content: action?.content || t.get('common.back'),
- close: action?.close ?? true,
-})
+}
+
+/** The implicit dismiss button: no handler, labelled by `ModalContainer`. */
+export function action(): Action
+/** A labelled action. Closes the modal afterwards unless `close: false`. */
+export function action(input: ActionInput & { content: string }): Action
+export function action(input?: ActionInput): Action {
+ const closeAfter = input?.close ?? true
+ return {
+ action: input?.action
+ ? () => {
+ input.action?.()
+ if (closeAfter) shownModal.set(undefined)
+ }
+ : () => shownModal.set(undefined),
+ type: input?.type ?? 'secondary',
+ // Empty labels fall back to `closeLabel` too, never a blank button.
+ content: input?.content || undefined,
+ icon: input?.icon,
+ }
+}
export function modal(inputModal: {
actions?: Action[]
diff --git a/src/lib/ui/shared/note/Note.svelte b/src/lib/ui/kit/note/Note.svelte
similarity index 95%
rename from src/lib/ui/shared/note/Note.svelte
rename to src/lib/ui/kit/note/Note.svelte
index f5efd6bc..c62eb9c8 100644
--- a/src/lib/ui/shared/note/Note.svelte
+++ b/src/lib/ui/kit/note/Note.svelte
@@ -1,5 +1,5 @@