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

100% Statements 27/27
100% Branches 18/18
100% Functions 8/8
100% Lines 25/25

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                                                  18x 18x 18x 4x   14x                 20x 20x 18x 9x             7x                         13x                       5x                                     9x   9x 9x   9x 6x 6x 6x 3x 3x       9x                   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 { 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 {
  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` attribute 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);
  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);
  });
}