vite-theme-plugin.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import type { Plugin } from "vite"
  2. import { readdir, readFile, writeFile } from "fs/promises"
  3. import { join, resolve } from "path"
  4. interface ThemeDefinition {
  5. $schema?: string
  6. defs?: Record<string, string>
  7. theme: Record<string, any>
  8. }
  9. interface ResolvedThemeColor {
  10. dark: string
  11. light: string
  12. }
  13. class ColorResolver {
  14. private colors: Map<string, any> = new Map()
  15. private visited: Set<string> = new Set()
  16. constructor(defs: Record<string, string> = {}, theme: Record<string, any> = {}) {
  17. Object.entries(defs).forEach(([key, value]) => {
  18. this.colors.set(key, value)
  19. })
  20. Object.entries(theme).forEach(([key, value]) => {
  21. this.colors.set(key, value)
  22. })
  23. }
  24. resolveColor(key: string, value: any): ResolvedThemeColor {
  25. if (this.visited.has(key)) {
  26. throw new Error(`Circular reference detected for color ${key}`)
  27. }
  28. this.visited.add(key)
  29. try {
  30. if (typeof value === "string") {
  31. if (value === "none") return { dark: value, light: value }
  32. if (value.startsWith("#")) {
  33. return { dark: value.toLowerCase(), light: value.toLowerCase() }
  34. }
  35. const resolved = this.resolveReference(value)
  36. return { dark: resolved, light: resolved }
  37. }
  38. if (typeof value === "object" && value !== null) {
  39. const dark = this.resolveColorValue(value.dark || value.light || "#000000")
  40. const light = this.resolveColorValue(value.light || value.dark || "#FFFFFF")
  41. return { dark, light }
  42. }
  43. return { dark: "#000000", light: "#FFFFFF" }
  44. } finally {
  45. this.visited.delete(key)
  46. }
  47. }
  48. private resolveColorValue(value: any): string {
  49. if (typeof value === "string") {
  50. if (value === "none") return value
  51. if (value.startsWith("#")) {
  52. return value.toLowerCase()
  53. }
  54. return this.resolveReference(value)
  55. }
  56. return value
  57. }
  58. private resolveReference(ref: string): string {
  59. const colorValue = this.colors.get(ref)
  60. if (colorValue === undefined) {
  61. throw new Error(`Color reference '${ref}' not found`)
  62. }
  63. if (typeof colorValue === "string") {
  64. if (colorValue === "none") return colorValue
  65. if (colorValue.startsWith("#")) {
  66. return colorValue.toLowerCase()
  67. }
  68. return this.resolveReference(colorValue)
  69. }
  70. return colorValue
  71. }
  72. }
  73. function kebabCase(str: string): string {
  74. return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
  75. }
  76. function parseTheme(themeData: ThemeDefinition): Record<string, ResolvedThemeColor> {
  77. const resolver = new ColorResolver(themeData.defs, themeData.theme)
  78. const colors: Record<string, ResolvedThemeColor> = {}
  79. Object.entries(themeData.theme).forEach(([key, value]) => {
  80. colors[key] = resolver.resolveColor(key, value)
  81. })
  82. return colors
  83. }
  84. async function loadThemes(): Promise<Record<string, Record<string, ResolvedThemeColor>>> {
  85. const themesDir = resolve(__dirname, "../../tui/internal/theme/themes")
  86. const files = await readdir(themesDir)
  87. const themes: Record<string, Record<string, ResolvedThemeColor>> = {}
  88. for (const file of files) {
  89. if (!file.endsWith(".json")) continue
  90. const themeName = file.replace(".json", "")
  91. const themeData: ThemeDefinition = JSON.parse(await readFile(join(themesDir, file), "utf-8"))
  92. themes[themeName] = parseTheme(themeData)
  93. }
  94. return themes
  95. }
  96. function generateCSS(themes: Record<string, Record<string, ResolvedThemeColor>>): string {
  97. let css = `/* Auto-generated theme CSS - Do not edit manually */\n:root {\n`
  98. const defaultTheme = themes["opencode"] || Object.values(themes)[0]
  99. if (defaultTheme) {
  100. Object.entries(defaultTheme).forEach(([key, color]) => {
  101. const cssVar = `--theme-${kebabCase(key)}`
  102. css += ` ${cssVar}: ${color.light};\n`
  103. })
  104. }
  105. css += `}\n\n`
  106. Object.entries(themes).forEach(([themeName, colors]) => {
  107. css += `[data-theme="${themeName}"][data-dark="false"] {\n`
  108. Object.entries(colors).forEach(([key, color]) => {
  109. const cssVar = `--theme-${kebabCase(key)}`
  110. css += ` ${cssVar}: ${color.light};\n`
  111. })
  112. css += `}\n\n`
  113. css += `[data-theme="${themeName}"][data-dark="true"] {\n`
  114. Object.entries(colors).forEach(([key, color]) => {
  115. const cssVar = `--theme-${kebabCase(key)}`
  116. css += ` ${cssVar}: ${color.dark};\n`
  117. })
  118. css += `}\n\n`
  119. })
  120. return css
  121. }
  122. export function generateThemeCSS(): Plugin {
  123. return {
  124. name: "generate-theme-css",
  125. async buildStart() {
  126. try {
  127. console.log("Generating theme CSS...")
  128. const themes = await loadThemes()
  129. const css = generateCSS(themes)
  130. const outputPath = resolve(__dirname, "../src/assets/theme.css")
  131. await writeFile(outputPath, css)
  132. console.log(`✅ Generated theme CSS with ${Object.keys(themes).length} themes`)
  133. console.log(` Output: ${outputPath}`)
  134. } catch (error) {
  135. throw new Error(`Theme CSS generation failed: ${error}`)
  136. }
  137. },
  138. }
  139. }