playground-css-plugin.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. /**
  2. * Vite plugin that exposes a POST endpoint for the timeline playground
  3. * to write CSS changes back to source files on disk.
  4. *
  5. * POST /__playground/apply-css
  6. * Body: { edits: Array<{ file: string; anchor: string; prop: string; value: string }> }
  7. *
  8. * For each edit the plugin finds `anchor` in the file, then locates the
  9. * next `prop: <anything>;` after it and replaces the value portion.
  10. * `file` is a basename resolved against the UI component packages.
  11. */
  12. import type { Plugin } from "vite"
  13. import type { IncomingMessage, ServerResponse } from "node:http"
  14. import fs from "node:fs"
  15. import path from "node:path"
  16. import { fileURLToPath } from "node:url"
  17. const here = path.dirname(fileURLToPath(import.meta.url))
  18. const roots = [path.resolve(here, "../../session-ui/src/components"), path.resolve(here, "../../ui/src/components")]
  19. const ENDPOINT = "/__playground/apply-css"
  20. type Edit = { file: string; anchor: string; prop: string; value: string }
  21. type Result = { file: string; prop: string; ok: boolean; error?: string }
  22. function applyEdits(content: string, edits: Edit[]): { content: string; results: Result[] } {
  23. const results: Result[] = []
  24. let out = content
  25. for (const edit of edits) {
  26. const name = edit.file
  27. const idx = out.indexOf(edit.anchor)
  28. if (idx === -1) {
  29. results.push({ file: name, prop: edit.prop, ok: false, error: `Anchor not found: ${edit.anchor.slice(0, 50)}` })
  30. continue
  31. }
  32. // From the anchor position, find the next occurrence of `prop: <value>`
  33. // We match `prop:` followed by any value up to `;`
  34. const after = out.slice(idx)
  35. const re = new RegExp(`(${escapeRegex(edit.prop)}\\s*:\\s*)([^;]+)(;)`)
  36. const match = re.exec(after)
  37. if (!match) {
  38. results.push({ file: name, prop: edit.prop, ok: false, error: `Property "${edit.prop}" not found after anchor` })
  39. continue
  40. }
  41. const start = idx + match.index + match[1].length
  42. const end = start + match[2].length
  43. out = out.slice(0, start) + edit.value + out.slice(end)
  44. results.push({ file: name, prop: edit.prop, ok: true })
  45. }
  46. return { content: out, results }
  47. }
  48. function escapeRegex(s: string) {
  49. return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
  50. }
  51. export function playgroundCss(): Plugin {
  52. return {
  53. name: "playground-css",
  54. configureServer(server) {
  55. server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
  56. if (req.url !== ENDPOINT) return next()
  57. if (req.method !== "POST") {
  58. res.statusCode = 405
  59. res.setHeader("Content-Type", "application/json")
  60. res.end(JSON.stringify({ error: "Method not allowed" }))
  61. return
  62. }
  63. let data = ""
  64. req.on("data", (chunk: Buffer) => {
  65. data += chunk.toString()
  66. })
  67. req.on("end", () => {
  68. let payload: { edits: Edit[] }
  69. try {
  70. payload = JSON.parse(data)
  71. } catch {
  72. res.statusCode = 400
  73. res.setHeader("Content-Type", "application/json")
  74. res.end(JSON.stringify({ error: "Invalid JSON" }))
  75. return
  76. }
  77. if (!Array.isArray(payload.edits)) {
  78. res.statusCode = 400
  79. res.setHeader("Content-Type", "application/json")
  80. res.end(JSON.stringify({ error: "Missing edits array" }))
  81. return
  82. }
  83. // Group by file
  84. const grouped = new Map<string, Edit[]>()
  85. for (const edit of payload.edits) {
  86. if (!edit.file || !edit.anchor || !edit.prop || edit.value === undefined) continue
  87. const abs = roots.map((root) => path.resolve(root, edit.file)).find((file) => fs.existsSync(file))
  88. if (!abs || !roots.some((root) => abs.startsWith(root))) continue
  89. const key = abs
  90. if (!grouped.has(key)) grouped.set(key, [])
  91. grouped.get(key)!.push(edit)
  92. }
  93. const results: Result[] = []
  94. for (const [abs, edits] of grouped) {
  95. const name = path.basename(abs)
  96. if (!fs.existsSync(abs)) {
  97. for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: "File not found" })
  98. continue
  99. }
  100. try {
  101. const content = fs.readFileSync(abs, "utf-8")
  102. const applied = applyEdits(content, edits)
  103. results.push(...applied.results)
  104. if (applied.results.some((r) => r.ok)) {
  105. fs.writeFileSync(abs, applied.content, "utf-8")
  106. }
  107. } catch (err) {
  108. for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: String(err) })
  109. }
  110. }
  111. res.statusCode = 200
  112. res.setHeader("Content-Type", "application/json")
  113. res.end(JSON.stringify({ results }))
  114. })
  115. })
  116. },
  117. }
  118. }