import { theme } from 'antdv-next'; import { computed, reactive, watch } from 'vue'; import { PREVIOUS_BLOG_AUTHOR_AVATAR, PREVIOUS_BLOG_BACKGROUND_IMAGE, LOCAL_BLOG_AUTHOR_AVATAR, LOCAL_BLOG_BACKGROUND_IMAGE, resolveBlogStaticAsset, unwrapBlogCssImage, } from '@/data/blogStaticAssets'; import { createBlogMotionCssVariables } from '@/factories/blogAnimationFactory'; import { BLOG_META_NAMES, blogDomId, blogDomSelector, blogMetaSelector } from '@/factories/blogDomFactory'; export type BlogThemeMode = 'dark' | 'light'; export type BlogFontMode = 'sans' | 'serif'; export type BlogShadowMode = 'small' | 'big'; export type BlogFilterMode = 'off' | 'sunset' | 'darkness' | 'grayscale'; export interface BlogThemePreferences { colorPrimary: string; filter: BlogFilterMode; font: BlogFontMode; mode: BlogThemeMode; radius: number; shadow: BlogShadowMode; } export interface BlogThemeMenuItem { external?: boolean; href: string; icon?: string; label: string; } export interface WordpressArgonThemeConfig { argonConfig?: { codeHighlight?: { breakLine?: boolean; enable?: boolean; hideLinenumber?: boolean; transparentLinenumber?: boolean; }; dateFormat?: string; disablePjax?: boolean; foldLongComments?: boolean; foldLongShuoshuo?: boolean; headroom?: boolean | string; language?: string; lazyload?: { effect?: string; threshold?: number; }; pangu?: string; pjaxAnimationDuration?: number; waterflowColumns?: number | string; wpPath?: string; zoomify?: boolean; }; backgroundDarkBrightness?: number | string; backgroundDarkImage?: string; backgroundDarkOpacity?: number | string; backgroundImage?: string; backgroundOpacity?: number | string; bodyClass?: string | string[]; darkmodeAutoSwitch?: 'alwaysoff' | 'alwayson' | 'system' | 'time' | string; enableCustomThemeColor?: boolean; headerMenu?: BlogThemeMenuItem[]; htmlClass?: string | string[]; site?: { authorAvatar?: string; authorName?: string; description?: string; home?: string; title?: string; url?: string; }; sidebarMenu?: BlogThemeMenuItem[]; themeCardRadius?: number | string; themeColor?: string; themeColorRgb?: string; themeVersion?: string; [key: string]: unknown; } interface BlogRuntimeThemeConfig { articleHeaderStyle: 'default' | string; backgroundDarkBrightness: number; backgroundDarkImage: string; backgroundDarkOpacity: number; backgroundImage: string; backgroundOpacity: number; bodyClass: string[]; headerMenu: BlogThemeMenuItem[]; siteAuthorAvatar: string; htmlClass: string[]; immersionColor: boolean; siteAuthorName: string; siteDescription: string; siteHome: string; siteTitle: string; siteUrl: string; sidebarMenu: BlogThemeMenuItem[]; themeVersion: string; toolbarBlur: boolean; tripleColumn: boolean; wordpressThemeConfig: WordpressArgonThemeConfig | null; } const STORAGE_KEY = 'KT_BLOG_THEME_PREFERENCES'; const BLOG_THEME_BLOCK_CLASS = 'kt-blog'; const ARGON_SANS_FONT_FAMILY = 'Comfortaa, "Open Sans", -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", SimSun, sans-serif'; const ARGON_SERIF_FONT_FAMILY = 'Georgia, "Times New Roman", "Noto Serif SC", serif'; const ARGON_DEFAULT_COLOR_PRIMARY = '#c3a1ed'; const ARGON_PRIMARY_SOFT = '#4a4058'; const ARGON_CARD_SHADOW = '0 2px 4px rgba(0, 0, 0, 0.075)'; const ARGON_DEFAULT_BACKGROUND = PREVIOUS_BLOG_BACKGROUND_IMAGE; const ARGON_DEFAULT_AUTHOR_AVATAR = PREVIOUS_BLOG_AUTHOR_AVATAR; const defaultHeaderMenu: BlogThemeMenuItem[] = []; const defaultSidebarMenu: BlogThemeMenuItem[] = [ { href: '/', icon: 'fa-home', label: '首页' }, { external: true, href: 'http://blog.kwitsukasa.top/wp-admin/', icon: 'fa-user', label: '管理' }, ]; const defaultPreferences: BlogThemePreferences = { colorPrimary: ARGON_DEFAULT_COLOR_PRIMARY, filter: 'off', font: 'sans', mode: 'dark', radius: 4, shadow: 'small', }; const preferences = reactive(loadPreferences()); const runtimeConfig = reactive({ articleHeaderStyle: 'default', backgroundDarkBrightness: 0.65, backgroundDarkImage: ARGON_DEFAULT_BACKGROUND, backgroundDarkOpacity: 1, backgroundImage: ARGON_DEFAULT_BACKGROUND, backgroundOpacity: 1, bodyClass: ['home', 'blog', 'wp-theme-argon'], headerMenu: [...defaultHeaderMenu], htmlClass: [ 'triple-column', 'immersion-color', 'toolbar-blur', 'article-header-style-default', ], immersionColor: true, siteAuthorAvatar: ARGON_DEFAULT_AUTHOR_AVATAR, siteAuthorName: 'KwiTsukasa', siteDescription: '', siteHome: '', siteTitle: 'KwiTsukasa的小站', siteUrl: '', sidebarMenu: [...defaultSidebarMenu], themeVersion: '', toolbarBlur: true, tripleColumn: true, wordpressThemeConfig: null, }); let themeWatcherReady = false; let systemThemeMediaQuery: MediaQueryList | null = null; let systemThemeChangeHandler: | ((event: MediaQueryList | MediaQueryListEvent) => void) | null = null; const isDarkTheme = computed(() => preferences.mode === 'dark'); const siteConfig = computed(() => ({ authorAvatar: runtimeConfig.siteAuthorAvatar, authorName: runtimeConfig.siteAuthorName, description: runtimeConfig.siteDescription, headerMenu: runtimeConfig.headerMenu, home: runtimeConfig.siteHome, sidebarMenu: runtimeConfig.sidebarMenu, title: runtimeConfig.siteTitle, url: runtimeConfig.siteUrl, })); const wordpressThemeConfig = computed(() => runtimeConfig.wordpressThemeConfig); const themeRootClass = computed(() => [ BLOG_THEME_BLOCK_CLASS, `${BLOG_THEME_BLOCK_CLASS}--wp-argon`, `${BLOG_THEME_BLOCK_CLASS}--home`, `${BLOG_THEME_BLOCK_CLASS}--blog`, runtimeConfig.tripleColumn && `${BLOG_THEME_BLOCK_CLASS}--triple-column`, runtimeConfig.immersionColor && `${BLOG_THEME_BLOCK_CLASS}--immersion-color`, runtimeConfig.toolbarBlur && `${BLOG_THEME_BLOCK_CLASS}--toolbar-blur`, `${BLOG_THEME_BLOCK_CLASS}--article-header-${runtimeConfig.articleHeaderStyle}`, runtimeConfig.themeVersion && `${BLOG_THEME_BLOCK_CLASS}--argon-${runtimeConfig.themeVersion.replace(/\./g, '-')}`, `${BLOG_THEME_BLOCK_CLASS}--${preferences.mode}`, preferences.font === 'serif' && `${BLOG_THEME_BLOCK_CLASS}--font-serif`, preferences.shadow === 'big' && `${BLOG_THEME_BLOCK_CLASS}--shadow-big`, isThemeColorTooDark(preferences.colorPrimary) && `${BLOG_THEME_BLOCK_CLASS}--theme-too-dark`, ].filter(Boolean).join(' ')); const themeConfig = computed(() => { const palette = createThemePalette(preferences.colorPrimary, preferences.mode); return { algorithm: isDarkTheme.value ? theme.darkAlgorithm : theme.defaultAlgorithm, components: { Button: { borderRadius: preferences.radius, colorPrimary: preferences.colorPrimary, controlHeightLG: 42, }, Checkbox: { colorPrimary: preferences.colorPrimary, }, Input: { activeBorderColor: preferences.colorPrimary, hoverBorderColor: preferences.colorPrimary, }, Modal: { borderRadiusLG: preferences.radius, colorBgElevated: palette.card, }, Switch: { colorPrimary: preferences.colorPrimary, }, }, token: { borderRadius: preferences.radius, colorBgBase: palette.page, colorBgContainer: palette.card, colorBorder: palette.border, colorPrimary: preferences.colorPrimary, colorText: palette.text, colorTextSecondary: palette.muted, fontFamily: getThemeFontFamily(preferences.font), }, }; }); /** * @param nextMode 目标主题模式,和 Argon 的 darkmode/lightmode 类保持一致。 */ function setThemeMode(nextMode: BlogThemeMode) { preferences.mode = nextMode; } /** * @param nextFont 目标字体模式,sans 对应 Argon 默认无衬线,serif 对应 use-serif。 */ function setFontMode(nextFont: BlogFontMode) { preferences.font = nextFont; } /** * @param nextShadow 阴影强度,big 会启用 Argon 的 use-big-shadow 类。 */ function setShadowMode(nextShadow: BlogShadowMode) { preferences.shadow = nextShadow; } /** * @param nextFilter Argon 视觉滤镜名称,off 会移除全部滤镜类。 */ function setFilterMode(nextFilter: BlogFilterMode) { preferences.filter = nextFilter; } /** * @param nextRadius 卡片圆角像素值,同步到 CSS 变量和 antdv-next token。 */ function setRadius(nextRadius: number) { preferences.radius = nextRadius; } /** * @param nextColor 主题色十六进制值,同步 Argon toolbar 和 antdv-next 组件色。 */ function setPrimaryColor(nextColor: string) { preferences.colorPrimary = nextColor; } function applyWordpressThemeConfig(config: WordpressArgonThemeConfig) { const nextHtmlClass = normalizeClassList(config.htmlClass); const nextBodyClass = normalizeClassList(config.bodyClass); const nextBackgroundDarkBrightness = normalizePositiveNumber(config.backgroundDarkBrightness); const nextBackgroundDarkOpacity = normalizeOpacity(config.backgroundDarkOpacity); const nextBackgroundOpacity = normalizeOpacity(config.backgroundOpacity); const nextColor = normalizeHexColor(config.themeColor); const nextRadius = normalizeRadius(config.themeCardRadius); const hasRemoteHtmlClass = nextHtmlClass.length > 0; runtimeConfig.wordpressThemeConfig = config; runtimeConfig.htmlClass = nextHtmlClass.length ? nextHtmlClass : runtimeConfig.htmlClass; runtimeConfig.bodyClass = nextBodyClass.length ? nextBodyClass : runtimeConfig.bodyClass; if (Array.isArray(config.headerMenu)) { runtimeConfig.headerMenu = normalizeMenuItems( config.headerMenu, config.site?.home || config.site?.url || runtimeConfig.siteHome || runtimeConfig.siteUrl, ); } runtimeConfig.tripleColumn = hasRemoteHtmlClass ? nextHtmlClass.includes('triple-column') : runtimeConfig.tripleColumn; runtimeConfig.immersionColor = hasRemoteHtmlClass ? nextHtmlClass.includes('immersion-color') : runtimeConfig.immersionColor; runtimeConfig.toolbarBlur = hasRemoteHtmlClass ? nextHtmlClass.includes('toolbar-blur') : runtimeConfig.toolbarBlur; runtimeConfig.articleHeaderStyle = getArticleHeaderStyle(nextHtmlClass) || (hasRemoteHtmlClass ? 'default' : runtimeConfig.articleHeaderStyle); runtimeConfig.backgroundDarkBrightness = typeof nextBackgroundDarkBrightness === 'number' ? nextBackgroundDarkBrightness : runtimeConfig.backgroundDarkBrightness; runtimeConfig.backgroundDarkImage = normalizeCssImage(config.backgroundDarkImage) || runtimeConfig.backgroundDarkImage; runtimeConfig.backgroundDarkOpacity = typeof nextBackgroundDarkOpacity === 'number' ? nextBackgroundDarkOpacity : runtimeConfig.backgroundDarkOpacity; runtimeConfig.backgroundImage = normalizeCssImage(config.backgroundImage) || runtimeConfig.backgroundImage; runtimeConfig.backgroundOpacity = typeof nextBackgroundOpacity === 'number' ? nextBackgroundOpacity : runtimeConfig.backgroundOpacity; runtimeConfig.siteAuthorAvatar = normalizeThemeAsset(config.site?.authorAvatar) || runtimeConfig.siteAuthorAvatar; runtimeConfig.siteDescription = config.site?.description ?? runtimeConfig.siteDescription; runtimeConfig.siteAuthorName = config.site?.authorName || runtimeConfig.siteAuthorName; runtimeConfig.siteHome = config.site?.home || runtimeConfig.siteHome; runtimeConfig.siteTitle = config.site?.title || runtimeConfig.siteTitle; runtimeConfig.siteUrl = config.site?.url || runtimeConfig.siteUrl; if (Array.isArray(config.sidebarMenu)) { runtimeConfig.sidebarMenu = normalizeMenuItems( config.sidebarMenu, config.site?.home || config.site?.url || runtimeConfig.siteHome || runtimeConfig.siteUrl, ); } runtimeConfig.themeVersion = config.themeVersion || runtimeConfig.themeVersion; if (nextColor && config.enableCustomThemeColor !== false) { preferences.colorPrimary = nextColor; } if (hasRemoteHtmlClass) { preferences.font = nextHtmlClass.includes('use-serif') ? 'serif' : 'sans'; preferences.shadow = nextHtmlClass.includes('use-big-shadow') ? 'big' : 'small'; } if (typeof nextRadius === 'number') { preferences.radius = nextRadius; } syncSystemThemeMode(config); const nextMode = getModeFromWordpressConfig(config); if (nextMode) { preferences.mode = nextMode; } } /** * @returns 当前 Blog Web 的主题偏好、Antdv token 配置与偏好更新函数。 */ export function useBlogTheme() { ensureThemeWatcher(); return { isDarkTheme, preferences, applyWordpressThemeConfig, siteConfig, setFilterMode, setFontMode, setPrimaryColor, setRadius, setShadowMode, setThemeMode, themeConfig, themeRootClass, wordpressThemeConfig, }; } /** * @returns localStorage 中的主题偏好;解析失败时回退到默认 Argon 深色方案。 */ function loadPreferences(): BlogThemePreferences { if (typeof window === 'undefined') { return { ...defaultPreferences }; } try { const rawValue = window.localStorage.getItem(STORAGE_KEY); if (!rawValue) { return { ...defaultPreferences }; } return { ...defaultPreferences, ...JSON.parse(rawValue), }; } catch { return { ...defaultPreferences }; } } /** * 注册一次全局主题监听,复用 kt-template-admin 的“偏好 -> CSS 变量 -> Antdv token”链路。 */ function ensureThemeWatcher() { if (themeWatcherReady) { return; } themeWatcherReady = true; watch( () => ({ preferences: { ...preferences }, runtimeConfig: { ...runtimeConfig }, }), ({ preferences: currentPreferences }) => { if (typeof document === 'undefined') { return; } applyCssVariables(currentPreferences); window.localStorage.setItem(STORAGE_KEY, JSON.stringify(currentPreferences)); }, { deep: true, immediate: true, }, ); } /** * @param currentPreferences 当前主题偏好,用于生成命名空间内的 CSS 变量。 */ function applyCssVariables(currentPreferences: BlogThemePreferences) { const primaryRgb = hexToRgb(currentPreferences.colorPrimary); const palette = createThemePalette(currentPreferences.colorPrimary, currentPreferences.mode); const fontFamily = getThemeFontFamily(currentPreferences.font); const argonColorVariables = createArgonColorVariables(primaryRgb, currentPreferences.mode); const backgroundImage = appendCssImageFallback( normalizeCssImage( currentPreferences.mode === 'dark' ? runtimeConfig.backgroundDarkImage || runtimeConfig.backgroundImage : runtimeConfig.backgroundImage, ) || `url('${ARGON_DEFAULT_BACKGROUND}')`, LOCAL_BLOG_BACKGROUND_IMAGE, ); const backgroundOpacity = currentPreferences.mode === 'dark' ? runtimeConfig.backgroundDarkOpacity : runtimeConfig.backgroundOpacity; const primaryHsl = rgbToHsl(primaryRgb.r, primaryRgb.g, primaryRgb.b); const rootThemeVariables = ` --themecolor: ${currentPreferences.colorPrimary}; --themecolor-R: ${primaryRgb.r}; --themecolor-G: ${primaryRgb.g}; --themecolor-B: ${primaryRgb.b}; --themecolor-H: ${primaryHsl.h}; --themecolor-S: ${primaryHsl.s}; --themecolor-L: ${primaryHsl.l}; --themecolor-dark0: ${formatArgonThemeHsl(primaryHsl, -2.5)}; --themecolor-dark: ${formatArgonThemeHsl(primaryHsl, -5)}; --themecolor-dark2: ${formatArgonThemeHsl(primaryHsl, -10)}; --themecolor-dark3: ${formatArgonThemeHsl(primaryHsl, -15)}; --themecolor-light: ${formatArgonThemeHsl(primaryHsl, 10)}; --themecolor-gradient: linear-gradient(150deg, var(--themecolor-light) 15%, var(--themecolor) 70%, var(--themecolor-dark0) 94%); --themecolor-rgbstr: ${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}; --argon-page: var(--color-background); --argon-card: var(--color-foreground); --argon-card-deep: var(--color-foreground); --argon-card-soft: var(--color-widgets); --argon-card-overlay-weak: ${palette.cardOverlayWeak}; --argon-card-overlay-strong: ${palette.cardOverlayStrong}; --argon-control: var(--color-border-on-foreground-deeper); --argon-control-soft: var(--color-widgets); --argon-pill: var(--color-widgets-disabled); --argon-text: ${palette.text}; --argon-muted: ${palette.muted}; --argon-title: ${palette.title}; --argon-border: var(--color-border); --argon-meta: ${palette.meta}; --argon-widget-text: ${palette.widgetText}; --argon-subtle: ${palette.subtle}; --argon-faint: ${palette.faint}; --argon-placeholder: ${palette.placeholder}; --argon-scrollbar-track: var(--kt-blog-scrollbar-track); --argon-scrollbar-thumb: var(--kt-blog-scrollbar-thumb); --argon-scrollbar-thumb-hover: var(--kt-blog-scrollbar-thumb-hover); --argon-scrollbar-thin-thumb: ${palette.scrollbarThinThumb}; --argon-scrollbar-size: var(--kt-blog-scrollbar-size); `; ensureThemeMetaElement(BLOG_META_NAMES.themeColor).setAttribute('content', currentPreferences.colorPrimary); ensureThemeMetaElement(BLOG_META_NAMES.themeColorRgb).setAttribute('content', `${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}`); updateThemeStyle(` :root { --kt-blog-scrollbar-track: ${palette.scrollbarTrack}; --kt-blog-scrollbar-thumb: ${palette.scrollbarThumb}; --kt-blog-scrollbar-thumb-hover: ${palette.scrollbarThumbHover}; --kt-blog-scrollbar-size: 10px; ${rootThemeVariables} ${serializeCssVariables(argonColorVariables.root)} } body.wp-theme-argon { ${serializeCssVariables(argonColorVariables.current)} } .${BLOG_THEME_BLOCK_CLASS} { --radius: ${currentPreferences.radius}px; --card-radius: ${currentPreferences.radius}px; --argon-background-image: ${backgroundImage}; --argon-background-light-image: ${appendCssImageFallback(normalizeCssImage(runtimeConfig.backgroundImage) || `url('${ARGON_DEFAULT_BACKGROUND}')`, LOCAL_BLOG_BACKGROUND_IMAGE)}; --argon-background-dark-image: ${appendCssImageFallback(normalizeCssImage(runtimeConfig.backgroundDarkImage) || backgroundImage, LOCAL_BLOG_BACKGROUND_IMAGE)}; --argon-background-filter: ${currentPreferences.mode === 'dark' ? `brightness(${runtimeConfig.backgroundDarkBrightness})` : 'none'}; --argon-background-opacity: ${backgroundOpacity}; --argon-background-light-opacity: ${runtimeConfig.backgroundOpacity}; --argon-background-dark-opacity: ${runtimeConfig.backgroundDarkOpacity}; --argon-author-avatar: ${appendCssImageFallback(normalizeCssImage(runtimeConfig.siteAuthorAvatar) || `url('${ARGON_DEFAULT_AUTHOR_AVATAR}')`, LOCAL_BLOG_AUTHOR_AVATAR)}; --argon-font-family: ${fontFamily}; --argon-shadow: ${ARGON_CARD_SHADOW}; --argon-primary-soft: ${ARGON_PRIMARY_SOFT}; ${createBlogMotionCssVariables()} ${rootThemeVariables} } `); } /** * @param font 当前字体模式。 * @returns Argon 主题使用的完整字体栈。 */ function getThemeFontFamily(font: BlogFontMode) { return font === 'serif' ? ARGON_SERIF_FONT_FAMILY : ARGON_SANS_FONT_FAMILY; } /** * @param cssText 主题动态变量,文档滚动条使用 :root 变量,业务样式仍收敛在 .kt-blog。 */ function updateThemeStyle(cssText: string) { let styleElement = document.querySelector(blogDomSelector('themeStyle')); if (!styleElement) { styleElement = document.createElement('style'); styleElement.id = blogDomId('themeStyle'); document.head.appendChild(styleElement); } styleElement.textContent = cssText.trim(); } /** * @param colorPrimary 当前主题色。 * @param mode 明暗模式。 * @returns Argon 容器、标题、弱文本和边框使用的派生色板。 */ function createThemePalette(colorPrimary: string, mode: BlogThemeMode) { const { b, g, r } = hexToRgb(colorPrimary); const { h, s } = rgbToHsl(r, g, b); if (mode === 'light') { const paleSaturation = Math.min(Math.max(Math.round(s * 0.38), 16), 42); return { border: 'rgba(15, 23, 42, 0.1)', card: `hsl(${h}, ${paleSaturation}%, 98%)`, cardDeep: `hsl(${h}, ${paleSaturation}%, 96%)`, cardOverlayStrong: 'rgba(15, 23, 42, 0.56)', cardOverlayWeak: 'rgba(15, 23, 42, 0.08)', cardSoft: `hsl(${h}, ${paleSaturation}%, 94%)`, control: `hsl(${h}, ${Math.min(paleSaturation + 8, 52)}%, 88%)`, controlSoft: `hsl(${h}, ${Math.min(paleSaturation + 6, 50)}%, 92%)`, faint: 'rgba(23, 32, 51, 0.28)', meta: 'rgba(23, 32, 51, 0.56)', muted: '#5b6472', page: '#f7f8fb', pill: `hsl(${h}, ${Math.min(paleSaturation + 10, 56)}%, 90%)`, placeholder: 'rgba(23, 32, 51, 0.42)', scrollbarThinThumb: 'rgba(0, 0, 0, 0.2)', scrollbarThumb: 'rgba(0, 0, 0, 0.25)', scrollbarThumbHover: `rgba(${r}, ${g}, ${b}, 0.7)`, scrollbarTrack: 'transparent', subtle: 'rgba(23, 32, 51, 0.48)', text: '#172033', title: '#263146', toolbarRgb: '111, 95, 137', widgetText: 'rgba(23, 32, 51, 0.66)', }; } const baseSaturation = Math.min(Math.max(Math.round(s * 0.22), 8), 24); const textSaturation = Math.min(Math.max(Math.round(s * 0.42), 24), 70); const mutedSaturation = Math.min(Math.max(Math.round(s * 0.16), 12), 32); return { border: 'rgba(255, 255, 255, 0.06)', card: '#2f2b33', cardDeep: '#2f2b33', cardOverlayStrong: `hsla(${h}, ${baseSaturation + 6}%, 16%, 0.72)`, cardOverlayWeak: `rgba(${r}, ${g}, ${b}, 0.1)`, cardSoft: '#2f2b33', control: `hsl(${h}, ${baseSaturation + 12}%, 28%)`, controlSoft: `hsl(${h}, ${baseSaturation + 10}%, 24%)`, faint: 'rgba(238, 238, 238, 0.34)', meta: '#eeeeee', muted: `hsl(${h}, ${mutedSaturation}%, 72%)`, page: `hsl(${h}, ${baseSaturation}%, 14%)`, pill: `hsl(${h}, ${baseSaturation + 12}%, 26%)`, placeholder: 'rgba(238, 238, 238, 0.45)', scrollbarThinThumb: 'rgba(255, 255, 255, 0.2)', scrollbarThumb: 'rgba(255, 255, 255, 0.25)', scrollbarThumbHover: `rgba(${r}, ${g}, ${b}, 0.7)`, scrollbarTrack: 'transparent', subtle: 'rgba(238, 238, 238, 0.6)', text: '#eeeeee', title: '#deccf5', toolbarRgb: `${r}, ${g}, ${b}`, widgetText: 'rgba(238, 238, 238, 0.66)', }; } /** * @param hexColor 目标颜色十六进制字符串。 * @returns RGB 三通道数值。 */ function hexToRgb(hexColor: string) { const normalized = hexColor.replace('#', ''); const value = normalized.length === 3 ? normalized .split('') .map((item) => item + item) .join('') : normalized.padEnd(6, '0').slice(0, 6); return { b: Number.parseInt(value.slice(4, 6), 16), g: Number.parseInt(value.slice(2, 4), 16), r: Number.parseInt(value.slice(0, 2), 16), }; } /** * @param name Meta 标签名称,Argon 用它同步浏览器主题色。 * @returns 现有或新建的 meta 元素,保证后续主题应用不会因为缺标签而静默失效。 */ function ensureThemeMetaElement(name: string) { let metaElement = document.querySelector(blogMetaSelector(name)); if (!metaElement) { metaElement = document.createElement('meta'); metaElement.name = name; document.head.appendChild(metaElement); } return metaElement; } /** * @param r 红色通道。 * @param g 绿色通道。 * @param b 蓝色通道。 * @returns HSL 色相、饱和度和亮度,用于复现 Argon 原生主题变量。 */ function rgbToHsl(r: number, g: number, b: number) { const red = r / 255; const green = g / 255; const blue = b / 255; const max = Math.max(red, green, blue); const min = Math.min(red, green, blue); const lightness = (max + min) / 2; if (max === min) { return { h: 0, l: Math.round(lightness * 100), s: 0 }; } const delta = max - min; const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min); const hue = max === red ? (green - blue) / delta + (green < blue ? 6 : 0) : max === green ? (blue - red) / delta + 2 : (red - green) / delta + 4; return { h: Math.round(hue * 60), l: Math.round(lightness * 100), s: Math.round(saturation * 100), }; } /** * @param primaryRgb 当前主题色 RGB,Argon 用它派生 immersion-color 明暗色阶。 * @param mode 当前明暗模式,决定 body 上暴露亮色 tint 变量还是暗色 shade 变量。 * @returns root 默认变量和 body 当前变量,供 CSS 继承链模拟 WordPress Argon。 */ function createArgonColorVariables( primaryRgb: ReturnType, mode: BlogThemeMode, ) { const root = { '--color-background': '#f4f5f7', '--color-border': '#dce0e5', '--color-border-on-foreground': '#f3f3f3', '--color-border-on-foreground-deeper': '#eee', '--color-darkmode-banner': '', '--color-darkmode-toolbar': '', '--color-foreground': '#fff', '--color-shade-70': formatArgonShadeChannels(primaryRgb, 0.7), '--color-shade-75': formatArgonShadeChannels(primaryRgb, 0.75), '--color-shade-80': formatArgonShadeChannels(primaryRgb, 0.8), '--color-shade-82': formatArgonShadeChannels(primaryRgb, 0.82), '--color-shade-86': formatArgonShadeChannels(primaryRgb, 0.86), '--color-shade-90': formatArgonShadeChannels(primaryRgb, 0.9), '--color-shade-94': formatArgonShadeChannels(primaryRgb, 0.94), '--color-shade-96': formatArgonShadeChannels(primaryRgb, 0.96), '--color-text-deeper': '#212529', '--color-tint-82': formatArgonTintChannels(primaryRgb, 0.82), '--color-widgets': '#fff', '--color-widgets-disabled': '#e9ecef', }; const light = { '--color-background': formatArgonTintRgb(primaryRgb, 0.86), '--color-border': formatArgonTintRgb(primaryRgb, 0.78), '--color-border-on-foreground': formatArgonTintRgb(primaryRgb, 0.86), '--color-border-on-foreground-deeper': formatArgonTintRgb(primaryRgb, 0.8), '--color-darkmode-banner': '', '--color-darkmode-toolbar': '', '--color-foreground': formatArgonTintRgb(primaryRgb, 0.92), '--color-text-deeper': formatArgonShadeRgb(primaryRgb, 0.82), '--color-widgets': formatArgonTintRgb(primaryRgb, 0.95), '--color-widgets-disabled': formatArgonTintRgb(primaryRgb, 0.86), }; const dark = { '--color-background': formatArgonShadeRgb(primaryRgb, 0.94), '--color-border': formatArgonShadeRgb(primaryRgb, 0.8), '--color-border-on-foreground': formatArgonShadeRgb(primaryRgb, 0.82), '--color-border-on-foreground-deeper': formatArgonShadeRgb(primaryRgb, 0.75), '--color-darkmode-banner': formatArgonShadeRgb(primaryRgb, 0.96), '--color-darkmode-toolbar': formatArgonShadeChannels(primaryRgb, 0.9), '--color-foreground': formatArgonShadeRgb(primaryRgb, 0.9), '--color-text-deeper': formatArgonTintRgb(primaryRgb, 0.82), '--color-widgets': formatArgonShadeRgb(primaryRgb, 0.86), '--color-widgets-disabled': formatArgonShadeRgb(primaryRgb, 0.82), }; return { current: mode === 'dark' ? dark : light, root, }; } /** * @param variables CSS custom property map generated for the current Argon theme. * @returns Multiline CSS declarations ready to inject into the singleton theme style. */ function serializeCssVariables(variables: Record) { return Object.entries(variables) .map(([name, value]) => ` ${name}: ${value};`) .join('\n'); } /** * @param hsl 当前主题色 HSL,来源于 `--themecolor`。 * @param lightnessDelta Argon 原生亮度偏移;正数生成 light,负数生成 dark 阶梯。 * @returns 复刻 WordPress Argon `--themecolor-light/dark*` 的 HSL calc 表达式。 */ function formatArgonThemeHsl( hsl: ReturnType, lightnessDelta: number, ) { const direction = lightnessDelta >= 0 ? '+' : '-'; const boundary = lightnessDelta >= 0 ? '100%' : '0%'; const clampFunction = lightnessDelta >= 0 ? 'min' : 'max'; const delta = Math.abs(lightnessDelta); return `hsl(${hsl.h}, calc(${hsl.s} * 1%), ${clampFunction}(calc(${hsl.l} * 1% ${direction} ${delta}%), ${boundary}))`; } /** * @param primaryRgb 当前主题色 RGB。 * @param ratio Argon shade 系数,越接近 1 越贴近深色基准 30。 * @returns 可作为 `rgb()` 通道列表使用的 shade calc 表达式。 */ function formatArgonShadeChannels(primaryRgb: ReturnType, ratio: number) { return [ formatArgonShadeChannel(primaryRgb.r, ratio), formatArgonShadeChannel(primaryRgb.g, ratio), formatArgonShadeChannel(primaryRgb.b, ratio), ].join(',\n '); } /** * @param primaryRgb 当前主题色 RGB。 * @param ratio Argon tint 系数,越接近 1 越贴近白色基准 255。 * @returns 可作为 `rgb()` 通道列表使用的 tint calc 表达式。 */ function formatArgonTintChannels(primaryRgb: ReturnType, ratio: number) { return [ formatArgonTintChannel(primaryRgb.r, ratio), formatArgonTintChannel(primaryRgb.g, ratio), formatArgonTintChannel(primaryRgb.b, ratio), ].join(',\n '); } /** * @param primaryRgb 当前主题色 RGB。 * @param ratio Argon shade 系数。 * @returns 完整 `rgb(...)` shade 表达式。 */ function formatArgonShadeRgb(primaryRgb: ReturnType, ratio: number) { return `rgb(${formatArgonShadeChannels(primaryRgb, ratio)})`; } /** * @param primaryRgb 当前主题色 RGB。 * @param ratio Argon tint 系数。 * @returns 完整 `rgb(...)` tint 表达式。 */ function formatArgonTintRgb(primaryRgb: ReturnType, ratio: number) { return `rgb(${formatArgonTintChannels(primaryRgb, ratio)})`; } /** * @param channel 当前主题色单通道值。 * @param ratio Argon shade 系数。 * @returns 单个深色通道 calc 表达式,基准值 30 来自 Argon immersion-color 算法。 */ function formatArgonShadeChannel(channel: number, ratio: number) { return `calc(30 * ${ratio} + ${channel} * (1 - ${ratio}))`; } /** * @param channel 当前主题色单通道值。 * @param ratio Argon tint 系数。 * @returns 单个浅色通道 calc 表达式,基准值 255 来自 Argon immersion-color 算法。 */ function formatArgonTintChannel(channel: number, ratio: number) { return `calc(${channel} + (255 - ${channel}) * ${ratio})`; } /** * @param r 红色通道。 * @param g 绿色通道。 * @param b 蓝色通道。 * @returns Argon 用于判断主题色是否过暗的灰度值。 */ function getGray(r: number, g: number, b: number) { return r * 0.299 + g * 0.587 + b * 0.114; } /** * @param hexColor 当前主题色。 * @returns 主题色是否过暗,用于根节点 modifier。 */ function isThemeColorTooDark(hexColor: string) { const { b, g, r } = hexToRgb(hexColor); return getGray(r, g, b) < 50; } function normalizeClassList(value?: string | string[]): string[] { if (Array.isArray(value)) { return value.flatMap((item) => normalizeClassList(item)); } return String(value || '') .split(/\s+/) .map((item) => item.trim()) .filter(Boolean); } function normalizeHexColor(value?: string) { if (!value) return ''; const normalized = value.trim(); if (/^#[0-9a-f]{6}$/i.test(normalized)) return normalized.toUpperCase(); if (/^#[0-9a-f]{3}$/i.test(normalized)) { return `#${normalized .slice(1) .split('') .map((item) => item + item) .join('')}`.toUpperCase(); } return ''; } function normalizeRadius(value?: number | string) { if (value === undefined || value === null || value === '') return null; const numericValue = Number(value); return Number.isFinite(numericValue) ? Math.max(0, numericValue) : null; } function normalizeOpacity(value?: number | string) { if (value === undefined || value === null || value === '') return null; const numericValue = Number(value); return Number.isFinite(numericValue) ? Math.min(Math.max(numericValue, 0), 1) : null; } function normalizePositiveNumber(value?: number | string) { if (value === undefined || value === null || value === '') return null; const numericValue = Number(value); return Number.isFinite(numericValue) ? Math.max(0, numericValue) : null; } /** * @param value Theme image URL returned by the API or static defaults. * @returns CSS `url(...)` token after replacing legacy Argon demo placeholders with previous blog assets. */ function normalizeCssImage(value?: string) { const normalized = resolveBlogStaticAsset(value, ''); if (normalized.startsWith('url(')) return normalized; if (/^https?:\/\//i.test(normalized) || normalized.startsWith('/')) { return `url('${normalized.replace(/'/g, "\\'")}')`; } return ''; } /** * @param primaryCssImage CSS `url(...)` token for the preferred remote image. * @param localFallback Local packaged image path used when the remote image cannot load. * @returns A CSS background-image layer list with the remote image first and local backup second. */ function appendCssImageFallback(primaryCssImage: string, localFallback: string) { const fallbackCssImage = normalizeCssImage(localFallback); if (!fallbackCssImage || primaryCssImage.includes(fallbackCssImage)) { return primaryCssImage; } return `${primaryCssImage}, ${fallbackCssImage}`; } /** * @param value Theme asset URL returned by WordPress-compatible config. * @returns Plain URL/path after replacing legacy Argon demo placeholders, suitable for component state. */ function normalizeThemeAsset(value?: string) { const asset = resolveBlogStaticAsset(unwrapBlogCssImage(value), ''); return /^https?:\/\//i.test(asset) || asset.startsWith('/') ? asset : ''; } function normalizeMenuItems(items: BlogThemeMenuItem[], siteHome = ''): BlogThemeMenuItem[] { return items.reduce((result, item) => { const label = `${item.label || ''}`.trim(); const href = normalizeMenuHref(`${item.href || ''}`.trim(), siteHome); if (!label || !href) return result; const isInternalHref = href.startsWith('/') && !href.startsWith('//'); result.push({ external: !isInternalHref && (item.external || /^https?:\/\//i.test(href)), href, ...(item.icon ? { icon: item.icon } : {}), label, }); return result; }, []); } function normalizeMenuHref(href: string, siteHome = '') { if (!href) return ''; const normalizedSiteHome = siteHome.replace(/\/+$/g, ''); if (normalizedSiteHome && href.replace(/\/+$/g, '') === normalizedSiteHome) { return '/'; } if (normalizedSiteHome && href.startsWith(`${normalizedSiteHome}/`)) { return href.slice(normalizedSiteHome.length) || '/'; } return href; } function getModeFromWordpressConfig(config: WordpressArgonThemeConfig): BlogThemeMode | '' { const htmlClass = normalizeClassList(config.htmlClass); const switchMode = `${config.darkmodeAutoSwitch || ''}`.toLowerCase(); if (htmlClass.includes('darkmode')) return 'dark'; if (switchMode === 'alwayson') return 'dark'; if (switchMode === 'alwaysoff' || switchMode === 'false') return 'light'; if (switchMode === 'system') return getSystemThemeMode(); if (switchMode === 'time') return getTimeThemeMode(); return ''; } function getSystemThemeMode(): BlogThemeMode | '' { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { return ''; } return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } function syncSystemThemeMode(config: WordpressArgonThemeConfig) { stopSystemThemeModeSync(); if ( `${config.darkmodeAutoSwitch || ''}`.toLowerCase() !== 'system' || typeof window === 'undefined' || typeof window.matchMedia !== 'function' ) { return; } systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); systemThemeChangeHandler = (event) => { preferences.mode = event.matches ? 'dark' : 'light'; }; if (typeof systemThemeMediaQuery.addEventListener === 'function') { systemThemeMediaQuery.addEventListener('change', systemThemeChangeHandler); } else { systemThemeMediaQuery.addListener(systemThemeChangeHandler); } } function stopSystemThemeModeSync() { if (!systemThemeMediaQuery || !systemThemeChangeHandler) { return; } if (typeof systemThemeMediaQuery.removeEventListener === 'function') { systemThemeMediaQuery.removeEventListener( 'change', systemThemeChangeHandler, ); } else { systemThemeMediaQuery.removeListener(systemThemeChangeHandler); } systemThemeMediaQuery = null; systemThemeChangeHandler = null; } function getTimeThemeMode(): BlogThemeMode { const hour = new Date().getHours(); return hour < 7 || hour >= 22 ? 'dark' : 'light'; } function getArticleHeaderStyle(htmlClass: string[]) { const prefix = 'article-header-style-'; const className = htmlClass.find((item) => item.startsWith(prefix)); return className?.slice(prefix.length) || ''; }