All files / packages/theme-selector/src persistence.ts

100% Statements 28/28
100% Branches 18/18
100% Functions 8/8
100% Lines 26/26

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126                                                    19x 19x 19x 4x   15x                   21x 21x 19x 10x             7x                         14x                       5x                                     10x   10x 10x 10x   10x 7x 7x 7x 3x 3x       10x                   8x 12x 12x      
// SPDX-License-Identifier: MIT
/**
 * Theme persistence utilities for FOUC prevention.
 *
 * Provides testable implementations of the critical-path logic
 * used in the blocking inline script (BaseLayout.astro lines 85-118)
 * and the theme-switching UI script (lines 140-230).
 */
 
import { resolveThemeAppearance } from './appearance.js';
import { DEFAULT_THEME } from './constants.js';
import { migrateLegacyStorage, getSavedTheme } from './storage.js';
 
// Re-export for convenience
export { DEFAULT_THEME } from './constants.js';
 
/**
 * Resolves the initial theme for FOUC prevention.
 *
 * Performs legacy storage migration, reads the stored theme, and validates
 * it against the provided list of valid theme IDs. Returns the default
 * theme if the stored value is missing or invalid.
 *
 * Mirrors the blocking inline script logic in BaseLayout.astro.
 */
export function resolveInitialTheme(windowObj: Window, validThemes: string[]): string {
  migrateLegacyStorage(windowObj);
  const saved = getSavedTheme(windowObj);
  if (validThemes.indexOf(saved) === -1) {
    return DEFAULT_THEME;
  }
  return saved;
}
 
/**
 * Sanitizes a base URL to prevent XSS via protocol injection.
 * Strips leading whitespace and control characters, then rejects
 * protocol-relative and absolute URLs. Only allows relative paths.
 */
export function sanitizeBaseUrl(raw: string): string {
  // oxlint-disable-next-line no-control-regex -- intentionally stripping control characters
  const normalized = raw.replace(/^[\s\u0000-\u001F\u007F]+/, '');
  if (normalized.startsWith('//')) return '';
  if (/^[a-z][a-z0-9+.-]*:/i.test(normalized)) return '';
  return normalized;
}
 
/**
 * Builds the full CSS href path for a theme.
 */
export function buildThemeCssHref(baseUrl: string, themeId: string): string {
  return `${baseUrl}/assets/css/themes/turbo/${themeId}.css`;
}
 
/**
 * Determines if the theme CSS link element needs updating.
 *
 * The default theme CSS is pre-loaded in the HTML, so we skip the
 * update to avoid an unnecessary network request and style flash.
 */
export function needsCssUpdate(
  themeId: string,
  defaultTheme: string = DEFAULT_THEME,
): boolean {
  return themeId !== defaultTheme;
}
 
/**
 * Builds the theme icon image src path.
 */
export function buildThemeIconSrc(
  baseUrl: string,
  themeIcons: Record<string, string>,
  themeId: string,
  fallbackIcon: string = 'catppuccin-logo-macchiato.png',
): string {
  return `${baseUrl}/assets/img/${themeIcons[themeId] || fallbackIcon}`;
}
 
/**
 * Applies the initial theme to prevent FOUC (Flash of Unstyled Content).
 *
 * This is the testable equivalent of the blocking inline script in
 * BaseLayout.astro. It:
 * 1. Migrates legacy storage keys
 * 2. Reads and validates the stored theme
 * 3. Sets the `data-theme` and `data-appearance` attributes on `<html>`
 * 4. Sets `window.__INITIAL_THEME__` for downstream scripts
 * 5. Updates the CSS link href if the theme differs from the default
 */
export function applyInitialTheme(
  doc: Document,
  windowObj: Window,
  validThemes: string[],
): string {
  const theme = resolveInitialTheme(windowObj, validThemes);
 
  doc.documentElement.setAttribute('data-theme', theme);
  doc.documentElement.setAttribute('data-appearance', resolveThemeAppearance(theme));
  windowObj.__INITIAL_THEME__ = theme;
 
  if (needsCssUpdate(theme)) {
    const baseUrl = sanitizeBaseUrl(doc.documentElement.getAttribute('data-baseurl') || '');
    const themeLink = doc.getElementById('turbo-theme-css') as HTMLLinkElement | null;
    if (themeLink) {
      const href = buildThemeCssHref(baseUrl, theme);
      themeLink.href = new URL(href, windowObj.location.href).pathname;
    }
  }
 
  return theme;
}
 
/**
 * Updates active theme state on theme option elements.
 *
 * Toggles the `active` class on each element based on whether its
 * `data-theme-id` or `data-theme` attribute matches the given active theme ID.
 */
export function updateActiveTheme(options: ArrayLike<Element>, activeTheme: string): void {
  Array.from(options).forEach((opt) => {
    const themeId = opt.getAttribute('data-theme-id') || opt.getAttribute('data-theme');
    opt.classList.toggle('active', themeId === activeTheme);
  });
}