vite-theme-plugin.ts 4.8 KB

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