summaryrefslogtreecommitdiffhomepage
path: root/packages/storybook/.storybook/playground-css-plugin.ts
blob: 23bb20b7dd17f72cdb1117b3c80dcc41fe2c17cb (plain)
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
124
125
126
127
128
129
130
131
132
133
134
135
136
/**
 * Vite plugin that exposes a POST endpoint for the timeline playground
 * to write CSS changes back to source files on disk.
 *
 * POST /__playground/apply-css
 * Body: { edits: Array<{ file: string; anchor: string; prop: string; value: string }> }
 *
 * For each edit the plugin finds `anchor` in the file, then locates the
 * next `prop: <anything>;` after it and replaces the value portion.
 * `file` is a basename resolved relative to packages/ui/src/components/.
 */
import type { Plugin } from "vite"
import type { IncomingMessage, ServerResponse } from "node:http"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"

const here = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(here, "../../ui/src/components")

const ENDPOINT = "/__playground/apply-css"

type Edit = { file: string; anchor: string; prop: string; value: string }
type Result = { file: string; prop: string; ok: boolean; error?: string }

function applyEdits(content: string, edits: Edit[]): { content: string; results: Result[] } {
  const results: Result[] = []
  let out = content

  for (const edit of edits) {
    const name = edit.file
    const idx = out.indexOf(edit.anchor)
    if (idx === -1) {
      results.push({ file: name, prop: edit.prop, ok: false, error: `Anchor not found: ${edit.anchor.slice(0, 50)}` })
      continue
    }

    // From the anchor position, find the next occurrence of `prop: <value>`
    // We match `prop:` followed by any value up to `;`
    const after = out.slice(idx)
    const re = new RegExp(`(${escapeRegex(edit.prop)}\\s*:\\s*)([^;]+)(;)`)
    const match = re.exec(after)
    if (!match) {
      results.push({ file: name, prop: edit.prop, ok: false, error: `Property "${edit.prop}" not found after anchor` })
      continue
    }

    const start = idx + match.index + match[1].length
    const end = start + match[2].length
    out = out.slice(0, start) + edit.value + out.slice(end)
    results.push({ file: name, prop: edit.prop, ok: true })
  }

  return { content: out, results }
}

function escapeRegex(s: string) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

export function playgroundCss(): Plugin {
  return {
    name: "playground-css",
    configureServer(server) {
      server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
        if (req.url !== ENDPOINT) return next()
        if (req.method !== "POST") {
          res.statusCode = 405
          res.setHeader("Content-Type", "application/json")
          res.end(JSON.stringify({ error: "Method not allowed" }))
          return
        }

        let data = ""
        req.on("data", (chunk: Buffer) => {
          data += chunk.toString()
        })
        req.on("end", () => {
          let payload: { edits: Edit[] }
          try {
            payload = JSON.parse(data)
          } catch {
            res.statusCode = 400
            res.setHeader("Content-Type", "application/json")
            res.end(JSON.stringify({ error: "Invalid JSON" }))
            return
          }

          if (!Array.isArray(payload.edits)) {
            res.statusCode = 400
            res.setHeader("Content-Type", "application/json")
            res.end(JSON.stringify({ error: "Missing edits array" }))
            return
          }

          // Group by file
          const grouped = new Map<string, Edit[]>()
          for (const edit of payload.edits) {
            if (!edit.file || !edit.anchor || !edit.prop || edit.value === undefined) continue
            const abs = path.resolve(root, edit.file)
            if (!abs.startsWith(root)) continue
            const key = abs
            if (!grouped.has(key)) grouped.set(key, [])
            grouped.get(key)!.push(edit)
          }

          const results: Result[] = []

          for (const [abs, edits] of grouped) {
            const name = path.basename(abs)
            if (!fs.existsSync(abs)) {
              for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: "File not found" })
              continue
            }

            try {
              const content = fs.readFileSync(abs, "utf-8")
              const applied = applyEdits(content, edits)
              results.push(...applied.results)

              if (applied.results.some((r) => r.ok)) {
                fs.writeFileSync(abs, applied.content, "utf-8")
              }
            } catch (err) {
              for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: String(err) })
            }
          }

          res.statusCode = 200
          res.setHeader("Content-Type", "application/json")
          res.end(JSON.stringify({ results }))
        })
      })
    },
  }
}