@jk2908/cxx 0.2.6 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vite/index.ts CHANGED
@@ -2,42 +2,46 @@ import { realpathSync } from 'node:fs'
2
2
  import fs from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
 
5
- import { type Plugin, type ViteDevServer } from 'vite'
6
-
7
- import type { BuildContext, PluginConfig } from '../types.js'
8
- import { collect, flattenTags, isENOENT, writeTypes } from '../build.js'
5
+ import { type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'
6
+
7
+ import type { PluginConfig } from '../types.js'
8
+ import {
9
+ collect,
10
+ flushTypes,
11
+ isENOENT,
12
+ takeRebuildError,
13
+ writeTypesForFile,
14
+ } from '../build.js'
9
15
  import { GENERATED_DIR } from '../config.js'
10
16
  import { Logger } from '../logger.js'
11
- import { debounce } from '../utils.js'
17
+ import { createBatcher } from '../utils.js'
12
18
 
13
19
  const ACCEPTED_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx'])
14
20
 
21
+ type WatchEvent = 'add' | 'change' | 'unlink'
22
+
15
23
  function normaliseWatchPath(p: string) {
16
24
  return p.replace(/\\/g, '/')
17
25
  }
18
26
 
27
+ /** Tiny glob matcher for the ignore patterns we support (`**`, `*`, `?`). */
28
+ function globToRegExp(glob: string) {
29
+ const pattern = glob
30
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
31
+ .replace(/\*\*(\/|$)/g, '.*$1')
32
+ .replace(/\*/g, '[^/]*')
33
+ .replace(/\?/g, '[^/]')
34
+
35
+ return new RegExp(`^${pattern}$`)
36
+ }
37
+
19
38
  export default function cxx(pluginConfig: PluginConfig = {}) {
20
- const buildContext = {
21
- tagsByFile: new Map(),
22
- } satisfies BuildContext
23
-
24
- const logger = new Logger(
25
- (pluginConfig?.logger?.level ?? process.env.NODE_ENV === 'production')
26
- ? 'error'
27
- : 'debug',
28
- )
29
-
30
- // write generated files into a hidden folder at the project root
31
- // keep the generated surface out of src
32
- const watchRoot = normaliseWatchPath(realpathSync.native(process.cwd()))
33
- const watchedRoots = [`${watchRoot}/`]
34
-
35
- let rebuildRunning = false
36
- let rebuildQueued = false
37
-
38
- // watcher events can arrive through symlinked paths like /var while cwd has
39
- // already resolved to /private/var, so canonicalise the parent dir once and
40
- // reattach the file name for stable prefix checks
39
+ const logger = new Logger()
40
+
41
+ // resolved from Vite's root in `configResolved`, not from `process.cwd()`
42
+ let watchRoot = normaliseWatchPath(process.cwd())
43
+ const ignored = (pluginConfig.watch?.ignore ?? []).map(globToRegExp)
44
+
41
45
  function resolveWatchFile(filePath: string) {
42
46
  const absolutePath = path.resolve(watchRoot, filePath)
43
47
  const parentPath = path.dirname(absolutePath)
@@ -53,104 +57,152 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
53
57
  }
54
58
  }
55
59
 
56
- function watchedFile(filePath: string) {
60
+ /** Returns the canonical, watchable path, or null when the file is irrelevant. */
61
+ function watchedFile(filePath: string): string | null {
57
62
  const resolvedPath = resolveWatchFile(filePath)
58
63
 
59
- return (
60
- watchedRoots.some(root => resolvedPath.startsWith(root)) &&
64
+ if (
65
+ resolvedPath.startsWith(`${watchRoot}/`) &&
61
66
  !resolvedPath.includes(`/${GENERATED_DIR}/`) &&
62
- ACCEPTED_EXTENSIONS.has(path.extname(resolvedPath))
63
- )
64
- }
67
+ ACCEPTED_EXTENSIONS.has(path.extname(resolvedPath)) &&
68
+ !ignored.some(regexp => regexp.test(resolvedPath))
69
+ ) {
70
+ return resolvedPath
71
+ }
65
72
 
66
- const rebuild = debounce((event: 'add' | 'change' | 'unlink', filePath: string) => {
67
- function queue() {
68
- void (async () => {
69
- // collapse bursts of file events into one active rebuild plus a single
70
- // queued rerun when changes land mid-build
71
- if (rebuildRunning) {
72
- rebuildQueued = true
73
- return
74
- }
73
+ return null
74
+ }
75
75
 
76
- rebuildRunning = true
76
+ async function recordFile(file: string) {
77
+ let source: string
77
78
 
78
- do {
79
- rebuildQueued = false
79
+ try {
80
+ source = await fs.readFile(file, 'utf-8')
81
+ } catch (err) {
82
+ if (!isENOENT(err)) throw err
83
+ await writeTypesForFile(file, new Map())
84
+ return
85
+ }
80
86
 
81
- try {
82
- const hadTags = buildContext.tagsByFile.has(file)
83
- let hasTags = false
87
+ const tags = source.includes('cxx')
88
+ ? collect(source, file, pluginConfig).tags
89
+ : new Map()
90
+ await writeTypesForFile(file, tags)
91
+ }
84
92
 
85
- if (event === 'unlink') {
86
- buildContext.tagsByFile.delete(file)
87
- } else {
88
- const source = await fs.readFile(file, 'utf-8')
89
- const { tags } = collect(source, file, pluginConfig)
93
+ /**
94
+ * Seed the type surface from the source tree, so a fresh checkout has types before any file is
95
+ * transformed or edited.
96
+ */
97
+ async function scan(dir: string) {
98
+ let entries
90
99
 
91
- hasTags = tags.size > 0
100
+ try {
101
+ entries = await fs.readdir(dir, { withFileTypes: true })
102
+ } catch {
103
+ return
104
+ }
92
105
 
93
- if (hasTags) {
94
- buildContext.tagsByFile.set(file, tags)
95
- } else {
96
- buildContext.tagsByFile.delete(file)
97
- }
98
- }
106
+ await Promise.all(
107
+ entries.map(async entry => {
108
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') return
99
109
 
100
- if (!hadTags && !hasTags) continue
110
+ const full = path.join(dir, entry.name)
101
111
 
102
- await writeTypes(flattenTags(buildContext.tagsByFile))
103
- logger.info(`updated types from ${file}`)
104
- } catch (err) {
105
- if (isENOENT(err)) {
106
- if (!buildContext.tagsByFile.has(file)) continue
112
+ if (entry.isDirectory()) {
113
+ await scan(full)
114
+ return
115
+ }
107
116
 
108
- buildContext.tagsByFile.delete(file)
109
- await writeTypes(flattenTags(buildContext.tagsByFile))
117
+ if (!ACCEPTED_EXTENSIONS.has(path.extname(entry.name))) return
110
118
 
111
- logger.info(`updated types from ${file}`)
119
+ const resolved = resolveWatchFile(full)
120
+ if (ignored.some(regexp => regexp.test(resolved))) return
112
121
 
113
- continue
114
- }
122
+ await recordFile(resolved)
123
+ }),
124
+ )
125
+ }
115
126
 
116
- logger.error(`failed to update types from ${file}`, err)
117
- }
118
- } while (rebuildQueued)
127
+ // collect change events that arrive close together and handle each file once.
128
+ // The file path is the key, so changes to different files are all kept, and a
129
+ // change that arrives during a rebuild runs after that rebuild finishes.
130
+ const queue = createBatcher<string, WatchEvent>(75, async batch => {
131
+ for (const [file, event] of batch) {
132
+ try {
133
+ if (event === 'unlink') {
134
+ await writeTypesForFile(file, new Map())
135
+ } else {
136
+ await recordFile(file)
137
+ }
119
138
 
120
- rebuildRunning = false
121
- })()
139
+ logger.info(`updated types from ${file}`)
140
+ } catch (err) {
141
+ logger.error(`failed to update types from ${file}`, err)
142
+ }
122
143
  }
123
-
124
- // ignore anything outside the watched content dirs
125
- if (!watchedFile(filePath)) return
126
-
127
- const file = resolveWatchFile(filePath)
128
- queue()
129
- }, 75)
144
+ })
130
145
 
131
146
  return {
132
147
  name: 'cxx',
133
148
  enforce: 'pre',
149
+ configResolved(config: ResolvedConfig) {
150
+ const root = pluginConfig.watch?.root ?? config.root
151
+
152
+ try {
153
+ watchRoot = normaliseWatchPath(realpathSync.native(root))
154
+ } catch {
155
+ watchRoot = normaliseWatchPath(root)
156
+ }
157
+ },
134
158
  transform(code, id) {
135
- const { template, tags } = collect(code, id, pluginConfig)
159
+ if (id.startsWith('\0') || id.includes('/node_modules/')) return null
160
+
161
+ // cheap bail-out before running the regex/lightningcss on every module
162
+ if (!code.includes('cxx')) return null
163
+
164
+ const cleanId = id.split('?')[0]
165
+
166
+ if (!ACCEPTED_EXTENSIONS.has(path.extname(cleanId))) return null
167
+
168
+ const { template, tags, map } = collect(code, cleanId, pluginConfig)
169
+
170
+ if (template === code) return null
136
171
 
137
- if (tags.size) buildContext.tagsByFile.set(id, tags)
172
+ void writeTypesForFile(resolveWatchFile(cleanId), tags)
138
173
 
139
174
  return {
140
175
  code: template,
141
- map: null,
176
+ map,
142
177
  }
143
178
  },
144
179
  configureServer(server: ViteDevServer) {
145
180
  logger.info('Watching for changes...')
146
181
 
147
182
  server.watcher
148
- .on('add', (p: string) => rebuild('add', p))
149
- .on('change', (p: string) => rebuild('change', p))
150
- .on('unlink', (p: string) => rebuild('unlink', p))
183
+ .on('add', (p: string) => {
184
+ const file = watchedFile(p)
185
+ if (file) queue.add(file, 'add')
186
+ })
187
+ .on('change', (p: string) => {
188
+ const file = watchedFile(p)
189
+ if (file) queue.add(file, 'change')
190
+ })
191
+ .on('unlink', (p: string) => {
192
+ const file = watchedFile(p)
193
+ if (file) queue.add(file, 'unlink')
194
+ })
195
+
196
+ void scan(watchRoot)
197
+ .then(() => flushTypes())
198
+ .then(() => {
199
+ const err = takeRebuildError()
200
+ if (err) logger.error('failed to generate types', err)
201
+ })
202
+ .catch(err => logger.error('failed to scan for cxx templates', err))
151
203
  },
152
204
  async writeBundle() {
153
- await writeTypes(flattenTags(buildContext.tagsByFile))
205
+ await flushTypes()
154
206
  },
155
207
  } satisfies Plugin
156
208
  }