refactor(lib): consolidate generic infra into src/lib

Move domain-agnostic, feature-free helpers out of the flat utils/ and store/
roots into src/lib/ (plan M4, §3 lib/ layer):
- lib/format/: formatBytes, formatClockTime, formatDuration, formatHumanDuration,
  relativeTime (pure format/date/byte/clock helpers)
- lib/i18n.ts: i18next bootstrap (global app infra)
- lib/util/: sanitizeHtml, platform, dedupeById, safeStorage (pure helpers +
  the zustand storage adapter)

playbackScheduleFormat stays in utils/format (runtime usePlayerStore dep =
audio-core coupling, not generic). formatClockTime keeps a type-only
@/store/authStoreTypes import (erased, no runtime inversion — accepted per the
deviceSync precedent).

Pure move via deep @/lib/* specifiers (no barrel → no mock-collapse surface).
tsc 0, lint 0/0, full suite 319 files / 2353 tests green.

Tooling note: lib_move.py regex extended to also rewrite side-effect imports
(import './i18n') and vi.importActual paths — both were silent gaps.
This commit is contained in:
Psychotoxical
2026-06-30 08:07:04 +02:00
parent 8cc022581f
commit 209dd61442
103 changed files with 119 additions and 119 deletions
+17
View File
@@ -0,0 +1,17 @@
export function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
/** Always-MB variant for size totals that are conventionally shown in MB
* regardless of magnitude (album / playlist / device-sync totals). */
export function formatMb(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/** Align hot-cache size slider (step 32 MB) to valid values. */
export function snapHotCacheMb(v: number): number {
const x = Math.min(20000, Math.max(32, Math.round(v)));
return Math.round((x - 32) / 32) * 32 + 32;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* `formatClockTime` is a thin wrapper around `Date.toLocaleTimeString` whose
* only knob is `hour12`, mapped from the user's `ClockFormat` setting. The
* exact `HH:MM` output is locale-dependent and not asserted here — these
* tests pin only the `hour12` mapping, which is what the setting controls.
*/
import { describe, expect, it } from 'vitest';
import { formatClockTime } from '@/lib/format/formatClockTime';
const SAMPLE_TS = Date.UTC(2026, 0, 1, 19, 17, 0); // 19:17 UTC, deterministic
describe('formatClockTime — clockFormat mapping', () => {
it('forces 24-hour output when clockFormat === "24h" (no AM/PM marker)', () => {
const out = formatClockTime(SAMPLE_TS, '24h');
expect(out).not.toMatch(/AM|PM/i);
});
it('forces 12-hour output when clockFormat === "12h" (renders an AM/PM marker)', () => {
const out = formatClockTime(SAMPLE_TS, '12h');
expect(out).toMatch(/AM|PM/i);
});
it('falls through to the locale default when clockFormat === "auto"', () => {
// We do not assert AM/PM either way here — `'auto'` deliberately defers to
// the JS engine's locale. The contract is just "do not force `hour12`".
expect(() => formatClockTime(SAMPLE_TS, 'auto')).not.toThrow();
});
it('falls through to the locale default when clockFormat is omitted', () => {
expect(() => formatClockTime(SAMPLE_TS)).not.toThrow();
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { ClockFormat } from '@/store/authStoreTypes';
/**
* Localized wall-clock `HH:MM` for a timestamp (sleep-timer / queue-ETA labels).
* `clockFormat` overrides the locale's `hour12` default. `'auto'` (or omitted)
* defers to `locale` — pass `i18n.language` so the App's UI language picks the
* 12h/24h convention; the JS engine's default locale is unreliable inside
* WebKitGTK and often resolves to `en-US` regardless of OS `LC_TIME`.
*/
export function formatClockTime(timestampMs: number, clockFormat?: ClockFormat, locale?: string): string {
const hour12 = clockFormat === '24h' ? false : clockFormat === '12h' ? true : undefined;
return new Date(timestampMs).toLocaleTimeString(locale, {
hour: '2-digit',
minute: '2-digit',
hour12,
});
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import {
formatTrackTime,
formatLongDuration,
formatPlaybarClock,
formatPlaybarToggleClock,
playbarMinuteFieldWidth,
PLAYBAR_CLOCK_PAD,
} from '@/lib/format/formatDuration';
describe('formatTrackTime', () => {
it('formats m:ss with zero-padded seconds', () => {
expect(formatTrackTime(5)).toBe('0:05');
expect(formatTrackTime(65)).toBe('1:05');
expect(formatTrackTime(599)).toBe('9:59');
});
it('does not roll minutes into hours (used for short track times)', () => {
expect(formatTrackTime(3600)).toBe('60:00');
expect(formatTrackTime(3661)).toBe('61:01');
});
it('floors fractional seconds', () => {
expect(formatTrackTime(90.9)).toBe('1:30');
});
it('returns the fallback for zero / non-finite / negative input', () => {
expect(formatTrackTime(0)).toBe('0:00');
expect(formatTrackTime(NaN)).toBe('0:00');
expect(formatTrackTime(Infinity)).toBe('0:00');
expect(formatTrackTime(-5)).toBe('0:00');
expect(formatTrackTime(undefined as unknown as number)).toBe('0:00');
});
it('honours a custom fallback', () => {
expect(formatTrackTime(0, '')).toBe('');
expect(formatTrackTime(NaN, '')).toBe('');
expect(formatTrackTime(42, '')).toBe('0:42');
});
});
describe('formatLongDuration', () => {
it('formats m:ss below one hour', () => {
expect(formatLongDuration(5)).toBe('0:05');
expect(formatLongDuration(125)).toBe('2:05');
expect(formatLongDuration(3599)).toBe('59:59');
});
it('formats h:mm:ss at or above one hour', () => {
expect(formatLongDuration(3600)).toBe('1:00:00');
expect(formatLongDuration(3661)).toBe('1:01:01');
expect(formatLongDuration(7325)).toBe('2:02:05');
});
it('returns 0:00 for zero / non-finite / negative input', () => {
expect(formatLongDuration(0)).toBe('0:00');
expect(formatLongDuration(NaN)).toBe('0:00');
expect(formatLongDuration(-1)).toBe('0:00');
});
});
describe('playbarMinuteFieldWidth', () => {
it('matches the minute digit count of the track duration', () => {
expect(playbarMinuteFieldWidth(65)).toBe(1);
expect(playbarMinuteFieldWidth(599)).toBe(1);
expect(playbarMinuteFieldWidth(600)).toBe(2);
expect(playbarMinuteFieldWidth(3600)).toBe(2);
expect(playbarMinuteFieldWidth(6000)).toBe(3);
});
it('returns 1 for invalid duration', () => {
expect(playbarMinuteFieldWidth(0)).toBe(1);
expect(playbarMinuteFieldWidth(NaN)).toBe(1);
});
});
describe('formatPlaybarClock', () => {
it('pads minutes with figure spaces to a fixed field width', () => {
expect(formatPlaybarClock(65, 1)).toBe('1:05');
expect(formatPlaybarClock(599, 2)).toBe(`${PLAYBAR_CLOCK_PAD}9:59`);
expect(formatPlaybarClock(600, 2)).toBe('10:00');
});
it('keeps remaining/elapsed strings the same length while ticking', () => {
expect(formatPlaybarClock(599, 2).length).toBe(formatPlaybarClock(600, 2).length);
expect(formatPlaybarClock(59, 2).length).toBe(formatPlaybarClock(60, 2).length);
});
it('returns a padded zero clock for invalid input', () => {
expect(formatPlaybarClock(0, 2)).toBe(`${PLAYBAR_CLOCK_PAD}0:00`);
expect(formatPlaybarClock(NaN, 3)).toBe(`${PLAYBAR_CLOCK_PAD.repeat(2)}0:00`);
});
});
describe('formatPlaybarToggleClock', () => {
it('prefixes remaining with minus and duration with a figure space', () => {
expect(formatPlaybarToggleClock(65, 1, true)).toBe('-1:05');
expect(formatPlaybarToggleClock(65, 1, false)).toBe(`${PLAYBAR_CLOCK_PAD}1:05`);
expect(formatPlaybarToggleClock(65, 1, true).length).toBe(formatPlaybarToggleClock(65, 1, false).length);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* `m:ss` track time from a seconds value. Non-positive / non-finite input
* returns `fallback` (default `'0:00'`; pass e.g. `''` for placeholder rows).
*/
export function formatTrackTime(seconds: number, fallback = '0:00'): string {
if (!seconds || !isFinite(seconds) || seconds < 0) return fallback;
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
/**
* `h:mm:ss` when the duration reaches an hour, otherwise `m:ss`. Used for
* album / queue totals. Non-positive / non-finite input returns `'0:00'`.
*/
export function formatLongDuration(seconds: number): string {
if (!seconds || !isFinite(seconds) || seconds < 0) return '0:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/** Invisible same-width pad for fixed-length playbar clocks (no leading zeros). */
export const PLAYBAR_CLOCK_PAD = '\u2007';
/** Minute digit count for a fixed-width playbar clock derived from track duration. */
export function playbarMinuteFieldWidth(durationSeconds: number): number {
if (!durationSeconds || !isFinite(durationSeconds) || durationSeconds <= 0) return 1;
return Math.max(1, String(Math.floor(durationSeconds / 60)).length);
}
/**
* Fixed-width `m:ss` for the player seekbar — minutes padded with figure spaces
* so digit columns do not shift while the clock ticks (WebKit waveform resize).
*/
export function formatPlaybarClock(
seconds: number,
minuteFieldWidth: number,
fallback = '0:00',
): string {
if (!seconds || !isFinite(seconds) || seconds < 0) {
if (fallback === '0:00') {
return `${PLAYBAR_CLOCK_PAD.repeat(Math.max(0, minuteFieldWidth - 1))}0:00`;
}
return fallback;
}
const clamped = Math.max(0, seconds);
const m = Math.floor(clamped / 60);
const s = Math.floor(clamped % 60);
return `${String(m).padStart(minuteFieldWidth, PLAYBAR_CLOCK_PAD)}:${s.toString().padStart(2, '0')}`;
}
/** Right-hand playbar toggle: `-m:ss` (remaining) or figure-space + `m:ss` (duration). */
export function formatPlaybarToggleClock(
seconds: number,
minuteFieldWidth: number,
remaining: boolean,
): string {
const body = formatPlaybarClock(seconds, minuteFieldWidth);
return remaining ? `-${body}` : `${PLAYBAR_CLOCK_PAD}${body}`;
}
@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from 'vitest';
// Echo the i18n key + params so the rounding/branching can be asserted
// without depending on a locale's exact "N hours M minutes" template.
vi.mock('@/lib/i18n', () => ({
default: {
t: (key: string, params: Record<string, unknown>) => `${key}|${JSON.stringify(params)}`,
resolvedLanguage: 'en',
language: 'en',
},
}));
import { formatHumanHoursMinutes, formatPlayerStatsListeningTotal, formatPlayerStatsListenedSec } from '@/lib/format/formatHumanDuration';
describe('formatHumanHoursMinutes', () => {
it('rounds to the nearest minute instead of truncating', () => {
// 3 min 30 s → rounds up to 4 min
expect(formatHumanHoursMinutes(210)).toBe('common.durationMinutesOnly|{"minutes":4}');
// 3 min 29 s → rounds down to 3 min
expect(formatHumanHoursMinutes(209)).toBe('common.durationMinutesOnly|{"minutes":3}');
});
it('rolls a near-hour total up into the hours branch', () => {
// 59 min 30 s → rounds to 60 min → "1 h 0 m", not "59 m"
expect(formatHumanHoursMinutes(3570)).toBe('common.durationHoursMinutes|{"hours":"1","minutes":0}');
});
it('formats hours plus minutes', () => {
expect(formatHumanHoursMinutes(3 * 3600 + 25 * 60)).toBe(
'common.durationHoursMinutes|{"hours":"3","minutes":25}',
);
});
it('clamps negative input to zero', () => {
expect(formatHumanHoursMinutes(-5)).toBe('common.durationMinutesOnly|{"minutes":0}');
});
});
describe('formatPlayerStatsListeningTotal', () => {
it('formats 25.5 hours as compact day hour minute parts', () => {
expect(formatPlayerStatsListeningTotal(25.5 * 3600)).toBe(
'statistics.playerListeningDayShort|{"count":1} statistics.playerListeningHourShort|{"count":1} statistics.playerListeningMinuteShort|{"count":30}',
);
});
it('shows minutes only for sub-hour totals', () => {
expect(formatPlayerStatsListeningTotal(45 * 60)).toBe(
'statistics.playerListeningMinuteShort|{"count":45}',
);
});
it('omits zero day and hour parts', () => {
expect(formatPlayerStatsListeningTotal(2 * 24 * 3600)).toBe(
'statistics.playerListeningDayShort|{"count":2}',
);
});
});
describe('formatPlayerStatsListenedSec', () => {
it('uses seconds below one minute', () => {
expect(formatPlayerStatsListenedSec(45.6)).toBe('statistics.playerListenedSecShort|{"seconds":46}');
});
it('uses decimal minutes from one minute upward', () => {
expect(formatPlayerStatsListenedSec(90)).toBe('statistics.playerListenedMinDecimal|{"minutes":"1.5"}');
expect(formatPlayerStatsListenedSec(125)).toBe('statistics.playerListenedMinDecimal|{"minutes":"2.1"}');
});
});
+51
View File
@@ -0,0 +1,51 @@
import i18n from '@/lib/i18n';
/**
* Totals / statistics: localized "N hours M minutes" (not track mm:ss).
*
* Rounds to the nearest minute before splitting into hours/minutes — a 3:21:40
* total reads as "3 h 22 m", and a 59:30 total rolls up to "1 h 0 m" rather
* than truncating to "59 m". Negative input is clamped to zero.
*/
export function formatHumanHoursMinutes(seconds: number): string {
const totalMin = Math.max(0, Math.round(seconds / 60));
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
if (h > 0) {
return i18n.t('common.durationHoursMinutes', { hours: h.toLocaleString(), minutes: m });
}
return i18n.t('common.durationMinutesOnly', { minutes: totalMin });
}
/** Player stats totals: compact days, hours, minutes (omit zero parts). */
export function formatPlayerStatsListeningTotal(seconds: number): string {
const totalMin = Math.max(0, Math.round(seconds / 60));
const days = Math.floor(totalMin / 1440);
const hours = Math.floor((totalMin % 1440) / 60);
const minutes = totalMin % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(i18n.t('statistics.playerListeningDayShort', { count: days }));
}
if (hours > 0) {
parts.push(i18n.t('statistics.playerListeningHourShort', { count: hours }));
}
if (minutes > 0 || parts.length === 0) {
parts.push(i18n.t('statistics.playerListeningMinuteShort', { count: minutes }));
}
return parts.join(' ');
}
/** Per-track listened time in player stats drill-down. */
export function formatPlayerStatsListenedSec(seconds: number): string {
const sec = Math.max(0, seconds);
if (sec >= 60) {
const minutes = (sec / 60).toLocaleString(i18n.resolvedLanguage ?? i18n.language, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
return i18n.t('statistics.playerListenedMinDecimal', { minutes });
}
return i18n.t('statistics.playerListenedSecShort', { seconds: Math.round(sec) });
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { formatRelativeTime } from '@/lib/format/relativeTime';
describe('formatRelativeTime', () => {
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
it('picks the largest sensible unit for past dates (en)', () => {
expect(formatRelativeTime(new Date(Date.now() - 5 * MINUTE), 'en')).toBe('5 minutes ago');
expect(formatRelativeTime(new Date(Date.now() - 3 * HOUR), 'en')).toBe('3 hours ago');
expect(formatRelativeTime(new Date(Date.now() - 3 * DAY), 'en')).toBe('3 days ago');
expect(formatRelativeTime(new Date(Date.now() - 14 * DAY), 'en')).toBe('2 weeks ago');
});
it('handles future dates and respects the locale', () => {
expect(formatRelativeTime(new Date(Date.now() + 2 * DAY), 'en')).toBe('in 2 days');
expect(formatRelativeTime(new Date(Date.now() - 3 * DAY), 'de')).toBe('vor 3 Tagen');
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Render a relative time like "3 hours ago" / "in 2 weeks" in the given locale,
* picking the largest sensible unit. Locale-aware via `Intl.RelativeTimeFormat`,
* so no per-string i18n keys are needed.
*/
export function formatRelativeTime(iso: string | number | Date, locale: string): string {
const diffSec = (new Date(iso).getTime() - Date.now()) / 1000;
const abs = Math.abs(diffSec);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (abs < 60) return rtf.format(Math.round(diffSec), 'second');
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
if (abs < 604800) return rtf.format(Math.round(diffSec / 86400), 'day');
if (abs < 2592000) return rtf.format(Math.round(diffSec / 604800), 'week');
if (abs < 31536000) return rtf.format(Math.round(diffSec / 2592000), 'month');
return rtf.format(Math.round(diffSec / 31536000), 'year');
}
+46
View File
@@ -0,0 +1,46 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { deTranslation } from '@/locales/de';
import { enTranslation } from '@/locales/en';
import { frTranslation } from '@/locales/fr';
import { zhTranslation } from '@/locales/zh';
import { nbTranslation } from '@/locales/nb';
import { ruTranslation } from '@/locales/ru';
import { nlTranslation } from '@/locales/nl';
import { esTranslation } from '@/locales/es';
import { roTranslation } from '@/locales/ro';
import { jaTranslation } from '@/locales/ja';
import { huTranslation } from '@/locales/hu';
import { plTranslation } from '@/locales/pl';
const savedLanguage = localStorage.getItem('psysonic_language') || 'en';
i18n
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslation },
de: { translation: deTranslation },
es: { translation: esTranslation },
fr: { translation: frTranslation },
nl: { translation: nlTranslation },
zh: { translation: zhTranslation },
nb: { translation: nbTranslation },
ru: { translation: ruTranslation },
ro: { translation: roTranslation },
ja: { translation: jaTranslation },
hu: { translation: huTranslation },
pl: { translation: plTranslation },
},
lng: savedLanguage,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
});
i18n.on('languageChanged', lng => {
localStorage.setItem('psysonic_language', lng);
});
export default i18n;
+16
View File
@@ -0,0 +1,16 @@
/**
* Keeps the first occurrence of each `id`. Subsonic responses (and merged pages)
* occasionally repeat the same album/song id; duplicate React keys then warn and
* break reconciliation.
*/
export function dedupeById<T extends { id: string; serverId?: string }>(items: T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const item of items) {
const key = item.serverId ? `${item.serverId}:${item.id}` : item.id;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
+6
View File
@@ -0,0 +1,6 @@
/** True when running on Linux (WebKitGTK). Used to show the custom title bar. */
export const IS_LINUX = navigator.platform.toLowerCase().includes('linux');
/** True when running on macOS (WKWebView). */
export const IS_MACOS = navigator.platform.toLowerCase().includes('mac');
/** True when running on Windows (WebView2). */
export const IS_WINDOWS = navigator.platform.toLowerCase().includes('win');
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createSafeJSONStorage } from '@/lib/util/safeStorage';
describe('createSafeJSONStorage', () => {
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
it('swallows a QuotaExceededError from setItem instead of throwing', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('exceeded', 'QuotaExceededError');
});
// Must NOT throw — zustand calls this from inside set(); a throw here would
// abort the calling action (this is what killed playback on huge queues).
expect(() => storage.setItem('k', { state: { a: 1 }, version: 0 })).not.toThrow();
});
it('round-trips a value through localStorage when there is room', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
storage.setItem('k2', { state: { a: 5 }, version: 0 });
expect(storage.getItem('k2')).toEqual({ state: { a: 5 }, version: 0 });
});
it('does not throw across repeated quota failures (write stays a no-op)', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('exceeded', 'QuotaExceededError');
});
expect(() => {
for (let i = 0; i < 5; i++) {
storage.setItem('quota-throttle-key', { state: { a: i }, version: 0 });
}
}).not.toThrow();
});
it('returns null from getItem if the underlying read throws', () => {
const storage = createSafeJSONStorage<{ a: number }>()!;
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(storage.getItem('missing')).toBeNull();
});
});
+79
View File
@@ -0,0 +1,79 @@
import { createJSONStorage, type PersistStorage, type StateStorage } from 'zustand/middleware';
/**
* `localStorage` wrapped so a failed write never throws.
*
* zustand's persist middleware calls the storage from *inside* `set()`. When a
* persisted slice grows past the origin quota (~5 MB) — e.g. a multi-thousand
* track queue — `localStorage.setItem` throws `QuotaExceededError`, and because
* that throw happens inside `set()` it aborts the calling action. That is how a
* full quota previously killed `playTrack` before it ever reached `audio_play`
* (no audio output at all on huge queues).
*
* Persistence is best-effort: a dropped write just means the in-memory store
* keeps working and the slice isn't saved this time. This is the same try/catch
* shape already used ad-hoc for direct `localStorage.setItem` calls elsewhere
* (e.g. mini-player geometry); this is its shared home for persist stores.
*/
// Warn once per key per quota-exceeded streak — a 50k+ queue persists on every
// mutation, so an unthrottled warning floods the console. Re-armed when a write
// to that key next succeeds (queue shrank back under the quota).
const quotaWarned = new Set<string>();
const safeLocalStorage: StateStorage = {
getItem: (name) => {
try {
return localStorage.getItem(name);
} catch {
return null;
}
},
setItem: (name, value) => {
try {
localStorage.setItem(name, value);
quotaWarned.delete(name);
} catch (e) {
if (import.meta.env.DEV && !quotaWarned.has(name)) {
quotaWarned.add(name);
console.warn(
`[psysonic] persist write skipped for "${name}" (storage quota?) — further skips silenced until it fits`,
e,
);
}
}
},
removeItem: (name) => {
try {
localStorage.removeItem(name);
} catch {
/* best-effort */
}
},
};
/**
* Drop-in replacement for `createJSONStorage(() => localStorage)` whose writes
* never throw. Use for any persist store whose slice can grow unbounded.
*/
export const createSafeJSONStorage = <S>() => createJSONStorage<S>(() => safeLocalStorage);
/**
* Wraps a persist storage so `setItem` is a no-op until the store finishes its
* first hydration. Zustand v5 persists on every `setState`; without this gate,
* startup side effects (e.g. `runInitialAudioSync`) can overwrite the saved
* blob with in-memory defaults before async rehydration merges localStorage.
*/
export function createHydrationGatedStorage<S>(
base: PersistStorage<S> | undefined,
isWriteAllowed: () => boolean,
): PersistStorage<S> | undefined {
if (!base) return undefined;
return {
getItem: base.getItem.bind(base),
removeItem: base.removeItem.bind(base),
setItem: (name, value) => {
if (!isWriteAllowed()) return;
return base.setItem(name, value);
},
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Strip dangerous tags / attributes from server-provided HTML (artist & album
* biographies). Removes embedded/active elements and `on*` / `javascript:` /
* `data:` handlers before the result is fed to `dangerouslySetInnerHTML`.
*/
export function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
doc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const val = attr.value.toLowerCase().trim();
if (
name.startsWith('on') ||
(name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) ||
(name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))
) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
}