@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/build.ts CHANGED
@@ -3,23 +3,35 @@ import fs from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
 
5
5
  import { transform } from 'lightningcss'
6
+ import MagicString, { type SourceMap } from 'magic-string'
6
7
 
7
- import type { BuildContext, PluginConfig, Tags } from './types.js'
8
+ import type { PluginConfig, Tags } from './types.js'
8
9
  import { AUTOGEN_MSG, GENERATED_DIR, PKG_NAME } from './config.js'
9
10
  import { LruCache } from './lru.js'
10
11
  import { pascalise } from './utils.js'
11
12
 
13
+ // decode bytes to a string, then stringify into a safe literal. `${bytes}` only
14
+ // works by accident for Buffer; a plain Uint8Array stringifies as `97,123,125`
15
+ const decoder = new TextDecoder()
16
+ const encoder = new TextEncoder()
17
+
12
18
  /**
13
- * LRU cache of `collect` results by file id and last seen source so watcher and transform can
14
- * reuse the same parsed template/tags.
19
+ * LRU cache of `collect` results by file id + last seen source + config so watcher and transform
20
+ * can reuse the same parsed template/tags.
15
21
  */
16
- const collectCache = new LruCache<{ source: string; template: string; tags: Tags }>()
22
+ const collectCache = new LruCache<{
23
+ source: string
24
+ config: string
25
+ template: string
26
+ tags: Tags
27
+ map: SourceMap | null
28
+ }>()
17
29
 
18
30
  /**
19
- * LRU cache for file content. Stores the last written content per file path so `maybeWrite`
20
- * can skip disk i/o when nothing has changed.
31
+ * Serialises type file writes inside a single process so overlapping writes can't interleave or
32
+ * land out of order.
21
33
  */
22
- const fileCache = new LruCache<string>()
34
+ let writeChain: Promise<void> = Promise.resolve()
23
35
 
24
36
  /**
25
37
  * Returns whether the error was caused by a missing file or directory.
@@ -29,40 +41,63 @@ export function isENOENT(err: unknown) {
29
41
  }
30
42
 
31
43
  /**
32
- * Write a file only if the content has changed since the last build.
44
+ * Write a file by writing to a unique temporary file and renaming it into place, so concurrent
45
+ * writers never observe a partial file. The temp file is cleaned up if the rename fails.
33
46
  */
34
- export async function maybeWrite(filePath: string, content: string) {
35
- const cached = fileCache.get(filePath)
47
+ async function writeFileAtomic(filePath: string, content: string) {
48
+ const tmp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
36
49
 
37
- if (cached !== content) {
38
- // cache says content changed, write without reading
39
- await fs.writeFile(filePath, content)
40
- fileCache.set(filePath, content)
50
+ await fs.writeFile(tmp, content)
41
51
 
42
- return true
52
+ try {
53
+ await fs.rename(tmp, filePath)
54
+ } catch (err) {
55
+ await fs.rm(tmp, { force: true })
56
+ throw err
43
57
  }
58
+ }
44
59
 
45
- // cache miss or cache hit with same content — verify on disk
60
+ /**
61
+ * Run a write task after all previously queued writes have settled. A rejection doesn't break the
62
+ * chain for later tasks.
63
+ */
64
+ function enqueueWrite<T>(task: () => Promise<T>) {
65
+ // wait for the previous write to finish, then run this one, even if the previous one failed
66
+ const run = writeChain.then(task, task)
67
+
68
+ // move the queue on to this write, but turn either outcome into success so it never rejects
69
+ writeChain = run.then(
70
+ () => undefined,
71
+ () => undefined,
72
+ )
73
+
74
+ // return the real promise, so the caller still sees this write's value or error
75
+ return run
76
+ }
77
+
78
+ /**
79
+ * Write a file only if the content differs from what is already on disk, so watchers are not woken
80
+ * by a needless mtime change.
81
+ */
82
+ export async function maybeWrite(filePath: string, content: string) {
46
83
  try {
47
- if ((await fs.readFile(filePath, 'utf-8')) === content) {
48
- fileCache.set(filePath, content)
49
- return false
50
- }
84
+ if ((await fs.readFile(filePath, 'utf-8')) === content) return false
51
85
  } catch (err) {
52
86
  if (!isENOENT(err)) throw err
53
87
  }
54
88
 
55
- await fs.writeFile(filePath, content)
56
- fileCache.set(filePath, content)
89
+ await writeFileAtomic(filePath, content)
57
90
 
58
91
  return true
59
92
  }
60
93
 
61
94
  // matches `[css, classes, href] = cxx\`...\`` and `cxx.tag<T>('name')\`...\``
62
- // capture groups: 1-3 assigned variable names, 4 plain css body, 5 quote
63
- // kind, 6 tag name, 7 tagged css body
95
+ // capture groups: 1 optional `export `, 2-4 assigned variable names, 5 plain
96
+ // css body, 6 quote kind, 7 tag name, 8 tagged css body
64
97
  const regex =
65
- /(?:const|var|let)\s*\[(\w+|)\s*,?\s*(\w+|)\s*,?\s*(\w+|)\s*\]\s*=\s*cxx(?:\s*`([\s\S]*?)`|\s*\.\s*tag(?:\s*<[^>]+>)?\s*\(\s*(['"])(.*?)\5\s*\)\s*`([\s\S]*?)`)/gm
98
+ /((?:export\s+)?)(?:const|var|let)\s*\[(\w*)\s*,?\s*(\w*)\s*,?\s*(\w*)\s*\]\s*=\s*cxx(?:\s*`([\s\S]*?)`|\s*\.\s*tag(?:\s*<[^>]+>)?\s*\(\s*(['"])(.*?)\6\s*\)\s*`([\s\S]*?)`)/gm
99
+
100
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
66
101
 
67
102
  /**
68
103
  * Hash transformed CSS into a stable href for style deduplication.
@@ -79,126 +114,208 @@ export class DuplicateTagError extends Error {
79
114
  }
80
115
  }
81
116
 
117
+ export class InvalidTagError extends Error {
118
+ name = 'InvalidTagError'
119
+
120
+ constructor(tag: string) {
121
+ super(
122
+ `Tag "${tag}" does not map to a valid TypeScript identifier. ` +
123
+ `Use letters, numbers, spaces, "_" or "-"`,
124
+ )
125
+ }
126
+ }
127
+
82
128
  /**
83
129
  * Transform `cxx` templates in one source file and collect any exported tag types.
84
130
  */
85
131
  export function collect(source: string, id: string, config: PluginConfig) {
86
- const tags: Tags = new Map()
132
+ const configKey = JSON.stringify(config)
87
133
 
134
+ // reuse the last result when the source and config are unchanged
88
135
  const cached = collectCache.get(id)
89
- if (cached?.source === source) return cached
90
-
91
- const template = source.replace(
92
- regex,
93
- (_match, varOne, varTwo, varThree, plainCss, _quote, tag, taggedCss) => {
94
- const css = plainCss ?? taggedCss
95
-
96
- const { code, exports = {} } = transform({
97
- minify: true,
98
- cssModules: true,
99
- filename: id,
100
- code: new TextEncoder().encode(css),
101
- })
102
-
103
- const href = hashCss(code)
104
- const styles = Object.fromEntries(
105
- Object.entries(exports).map(([k, v]) => [k, v.name]),
106
- )
136
+ if (cached?.source === source && cached.config === configKey) return cached
137
+
138
+ const tags: Tags = new Map()
139
+ const magic = new MagicString(source)
140
+ let matched = false
141
+
142
+ // the regex is global, so reset its cursor before scanning
143
+ regex.lastIndex = 0
144
+
145
+ let match: RegExpExecArray | null
146
+
147
+ // walk every cxx template and replace it in place
148
+ while ((match = regex.exec(source))) {
149
+ matched = true
150
+
151
+ // capture groups: export, three bindings, plain css, (quote), tag, tagged css
152
+ const [, exp, varOne, varTwo, varThree, plainCss, , tag, taggedCss] = match
153
+ const css = plainCss ?? taggedCss
154
+ const matchIndex = match.index
155
+
156
+ // compile the CSS to minified CSS Modules, reporting the source line on failure
157
+ const { code, exports: classExports = {} } = (() => {
158
+ try {
159
+ return transform({
160
+ minify: true,
161
+ cssModules: true,
162
+ filename: id,
163
+ code: encoder.encode(css),
164
+ })
165
+ } catch (err) {
166
+ const line = source.slice(0, matchIndex).split('\n').length
167
+ throw new Error(`Failed to compile cxx CSS at ${id}:${line}`, { cause: err })
168
+ }
169
+ })()
107
170
 
108
- if (tag) {
109
- const suffix =
110
- typeof config.typeSuffix === 'string'
111
- ? config.typeSuffix
112
- : config.typeSuffix === false
113
- ? ''
114
- : 'Classes'
115
- const tagWithSuffix = pascalise(`${tag}${suffix}`)
171
+ const href = hashCss(code)
172
+ // map each class name to its generated (scoped) name
173
+ const styles = Object.fromEntries(
174
+ Object.entries(classExports).map(([k, v]) => [k, v.name]),
175
+ )
116
176
 
117
- if (tags.has(tagWithSuffix)) throw new DuplicateTagError(tagWithSuffix)
177
+ if (tag) {
178
+ const suffix =
179
+ typeof config.typeSuffix === 'string'
180
+ ? config.typeSuffix
181
+ : config.typeSuffix === false
182
+ ? ''
183
+ : 'Classes'
184
+ const tagWithSuffix = pascalise(`${tag}${suffix}`)
118
185
 
119
- // side effect but neccessary
120
- tags.set(tagWithSuffix, Object.keys(styles))
121
- }
186
+ if (!IDENTIFIER.test(tagWithSuffix)) throw new InvalidTagError(tag)
187
+ if (tags.has(tagWithSuffix)) throw new DuplicateTagError(tagWithSuffix)
122
188
 
123
- return [
124
- varOne && `const ${varOne} = \`${code}\``,
125
- varTwo && `const ${varTwo} = ${JSON.stringify(styles)}`,
126
- varThree && `const ${varThree} = \`${href}\``,
127
- ]
128
- .filter(Boolean)
129
- .join('\n')
130
- },
131
- )
189
+ // side effect but necessary
190
+ tags.set(tagWithSuffix, Object.keys(styles))
191
+ }
192
+
193
+ const statements: string[] = []
132
194
 
133
- // cache
134
- collectCache.set(id, { source, template, tags })
195
+ if (varOne) {
196
+ statements.push(`${exp}const ${varOne} = ${JSON.stringify(decoder.decode(code))}`)
197
+ }
135
198
 
136
- return {
137
- template,
138
- tags,
199
+ if (varTwo) {
200
+ statements.push(`${exp}const ${varTwo} = ${JSON.stringify(styles)}`)
201
+ }
202
+
203
+ if (varThree) {
204
+ statements.push(`${exp}const ${varThree} = ${JSON.stringify(href)}`)
205
+ }
206
+
207
+ // swap the template for the generated declarations, keeping the export keyword
208
+ magic.overwrite(matchIndex, matchIndex + match[0].length, statements.join('\n'))
139
209
  }
210
+
211
+ const template = magic.toString()
212
+ // only build a map when something was actually rewritten
213
+ const map = matched
214
+ ? magic.generateMap({ source: id, includeContent: true, hires: true })
215
+ : null
216
+ const result = { source, config: configKey, template, tags, map }
217
+
218
+ collectCache.set(id, result)
219
+
220
+ return result
140
221
  }
141
222
 
142
223
  /**
143
- * Write the generated declaration file for the current set of collected tags.
224
+ * Render the generated declaration file contents for a set of collected tags.
144
225
  */
145
- export async function writeTypes(tags: Tags) {
226
+ function renderTypes(tags: Tags) {
227
+ return [
228
+ AUTOGEN_MSG,
229
+ '',
230
+ `import '${PKG_NAME}'`,
231
+ '',
232
+ `declare module '${PKG_NAME}' {`,
233
+ tagsToType(tags),
234
+ '}',
235
+ '',
236
+ ].join('\n')
237
+ }
238
+
239
+ /**
240
+ * Write (or remove) the generated `index.d.ts` for a full tag surface.
241
+ */
242
+ async function writeRenderedTypes(tags: Tags) {
146
243
  const filePath = path.join(GENERATED_DIR, 'index.d.ts')
147
244
 
148
245
  if (!tags.size) {
149
- try {
150
- await fs.rm(filePath, { force: true })
151
- fileCache.delete(filePath)
152
- } catch (err) {
153
- if (!isENOENT(err)) throw err
154
- }
155
-
246
+ await fs.rm(filePath, { force: true })
156
247
  return
157
248
  }
158
249
 
159
250
  await fs.mkdir(GENERATED_DIR, { recursive: true })
251
+ await maybeWrite(filePath, renderTypes(tags))
252
+ }
160
253
 
161
- await maybeWrite(
162
- filePath,
163
- `${AUTOGEN_MSG}
254
+ const PARTIAL_DIR = path.join(GENERATED_DIR, 'tags')
164
255
 
165
- import '${PKG_NAME}'
256
+ // wait this long after the last file before rebuilding, so a burst of files
257
+ // only re-reads the partials once
258
+ const REBUILD_WAIT = 100
166
259
 
167
- declare module '${PKG_NAME}' {
168
- ${tagsToType(tags)}
169
- }
170
- `,
260
+ let rebuildTimer: ReturnType<typeof setTimeout> | null = null
261
+ let rebuildError: unknown = null
262
+
263
+ function partialPath(filePath: string) {
264
+ return path.join(
265
+ PARTIAL_DIR,
266
+ `${createHash('sha1').update(filePath).digest('hex')}.json`,
171
267
  )
172
268
  }
173
269
 
174
270
  /**
175
- * Render collected tags as a module augmentation for the package entrypoint.
271
+ * The partial directory changes whenever any process adds, removes or overwrites a partial, so
272
+ * its mtime tells us whether another writer moved while we were rebuilding.
176
273
  */
177
- export function tagsToType(tags: Tags) {
178
- return [...tags]
179
- .map(
180
- ([tag, classes]) => `
181
- export type ${tag} = ${
182
- classes.length > 0
183
- ? classes
184
- .toSorted((a, b) => a.length - b.length)
185
- .map(c => `'${c}'`)
186
- .join(' | ')
187
- : 'never'
188
- }
189
- `,
190
- )
191
- .join('\n')
274
+ async function partialDirMtime() {
275
+ try {
276
+ return (await fs.stat(PARTIAL_DIR)).mtimeMs
277
+ } catch (err) {
278
+ if (isENOENT(err)) return 0
279
+ throw err
280
+ }
192
281
  }
193
282
 
194
283
  /**
195
- * Merge per-file tag maps into one application-wide tag map.
284
+ * Read every per-file partial and merge them into one tag map. Partials whose source file no
285
+ * longer exists are pruned so deleted files drop out of the types.
196
286
  */
197
- export function flattenTags(tagsByFile: BuildContext['tagsByFile']) {
198
- const tags = new Map<string, string[]>()
287
+ async function readPartials() {
288
+ let entries: string[]
289
+
290
+ try {
291
+ entries = await fs.readdir(PARTIAL_DIR)
292
+ } catch (err) {
293
+ if (isENOENT(err)) return new Map()
294
+ throw err
295
+ }
296
+
297
+ const tags: Tags = new Map()
199
298
 
200
- for (const fileTags of tagsByFile.values()) {
201
- for (const [name, classes] of fileTags) {
299
+ for (const entry of entries) {
300
+ if (!entry.endsWith('.json')) continue
301
+
302
+ const partial = path.join(PARTIAL_DIR, entry)
303
+ let parsed: { file: string; tags: Record<string, string[]> }
304
+
305
+ try {
306
+ parsed = JSON.parse(await fs.readFile(partial, 'utf-8'))
307
+ } catch {
308
+ continue
309
+ }
310
+
311
+ try {
312
+ await fs.access(parsed.file)
313
+ } catch {
314
+ await fs.rm(partial, { force: true })
315
+ continue
316
+ }
317
+
318
+ for (const [name, classes] of Object.entries(parsed.tags)) {
202
319
  if (tags.has(name)) throw new DuplicateTagError(name)
203
320
  tags.set(name, classes)
204
321
  }
@@ -206,3 +323,90 @@ export function flattenTags(tagsByFile: BuildContext['tagsByFile']) {
206
323
 
207
324
  return tags
208
325
  }
326
+
327
+ /**
328
+ * Persist one file's tags to its own partial, then rebuild the shared types soon. Rebuilding is
329
+ * debounced and serialised, so a large build does not re-read every partial per file.
330
+ */
331
+ export function writeTypesForFile(filePath: string, tags: Tags) {
332
+ return enqueueWrite(async () => {
333
+ await fs.mkdir(PARTIAL_DIR, { recursive: true })
334
+
335
+ const partial = partialPath(filePath)
336
+
337
+ if (!tags.size) {
338
+ await fs.rm(partial, { force: true })
339
+ } else {
340
+ await writeFileAtomic(
341
+ partial,
342
+ JSON.stringify({ file: filePath, tags: Object.fromEntries(tags) }),
343
+ )
344
+ }
345
+ }).then(() => {
346
+ scheduleRebuild()
347
+ })
348
+ }
349
+
350
+ function scheduleRebuild() {
351
+ if (rebuildTimer) clearTimeout(rebuildTimer)
352
+
353
+ rebuildTimer = setTimeout(() => {
354
+ rebuildTimer = null
355
+ flushTypes().catch(err => {
356
+ rebuildError = err
357
+ })
358
+ }, REBUILD_WAIT)
359
+ }
360
+
361
+ /**
362
+ * Rebuild `index.d.ts` from every partial now. If another writer touches a partial mid-rebuild,
363
+ * rebuild once more so the final write reflects every partial. Bounded to avoid spinning.
364
+ */
365
+ export function flushTypes() {
366
+ if (rebuildTimer) {
367
+ clearTimeout(rebuildTimer)
368
+ rebuildTimer = null
369
+ }
370
+
371
+ return enqueueWrite(async () => {
372
+ let before = await partialDirMtime()
373
+
374
+ for (let attempt = 0; attempt < 3; attempt++) {
375
+ await writeRenderedTypes(await readPartials())
376
+
377
+ const after = await partialDirMtime()
378
+ if (after === before) return
379
+
380
+ before = after
381
+ }
382
+ })
383
+ }
384
+
385
+ /**
386
+ * Return and clear any error from a background rebuild, so a caller can fail loudly.
387
+ */
388
+ export function takeRebuildError() {
389
+ const err = rebuildError
390
+ rebuildError = null
391
+ return err
392
+ }
393
+
394
+ /**
395
+ * Render collected tags as a module augmentation for the package entrypoint.
396
+ */
397
+ export function tagsToType(tags: Tags) {
398
+ return [...tags]
399
+ .toSorted(([a], [b]) => a.localeCompare(b))
400
+ .map(([tag, classes]) => {
401
+ const union =
402
+ classes.length > 0
403
+ ? classes
404
+ .toSorted((a, b) => a.length - b.length)
405
+ .map(c => `'${c}'`)
406
+ .join(' | ')
407
+ : 'never'
408
+
409
+ return `\texport type ${tag} = ${union}`
410
+ })
411
+ .join('\n')
412
+ }
package/src/index.ts CHANGED
@@ -2,15 +2,32 @@ import type { Cxx, Result } from './types.js'
2
2
 
3
3
  export type { Cxx, Result, Tag } from './types.js'
4
4
 
5
+ /**
6
+ * Hit when a `cxx` template reaches runtime, which means the build transform did
7
+ * not run. Throws so the problem is obvious instead of rendering empty styles.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * // no cxx plugin configured
12
+ * const [css, styles, href] = cxx`.a { color: red; }`
13
+ * // Error: cxx template was not transformed ...
14
+ * ```
15
+ */
16
+ function untransformed(): never {
17
+ throw new Error(
18
+ 'cxx template was not transformed. Make sure the cxx Vite plugin or Next integration is configured and applies to this file.',
19
+ )
20
+ }
21
+
5
22
  /**
6
23
  * Build-time template tag placeholder used by the transform to emit CSS, classes and href values.
7
24
  */
8
- export const cxx: Cxx = Object.assign((_: TemplateStringsArray): Result => ['', {}, ''], {
9
- tag:
10
- <ClassName extends string = string>(_: string) =>
11
- (_strings: TemplateStringsArray): Result<ClassName> => [
12
- '',
13
- {} as Readonly<Record<ClassName, string>>,
14
- '',
15
- ],
16
- })
25
+ export const cxx: Cxx = Object.assign(
26
+ (_: TemplateStringsArray): Result => untransformed(),
27
+ {
28
+ tag:
29
+ <ClassName extends string = string>(_: string) =>
30
+ (_strings: TemplateStringsArray): Result<ClassName> =>
31
+ untransformed(),
32
+ },
33
+ )
package/src/logger.ts CHANGED
@@ -8,6 +8,8 @@ const LEVELS = {
8
8
 
9
9
  export type LogLevel = keyof typeof LEVELS
10
10
 
11
+ const defaultLevel: LogLevel = process.env['NODE_ENV'] === 'production' ? 'error' : 'info'
12
+
11
13
  type LogEntry = {
12
14
  ts: number
13
15
  level: LogLevel
@@ -25,23 +27,13 @@ type LogEntry = {
25
27
  * Log messages with different severity levels.
26
28
  */
27
29
  export class Logger {
28
- static #defaultLevel: LogLevel = 'info'
29
-
30
- #level?: LogLevel
30
+ #level: LogLevel
31
31
 
32
32
  constructor(
33
33
  level?: LogLevel,
34
34
  public name?: string,
35
35
  ) {
36
- this.#level = level
37
- }
38
-
39
- static set defaultLevel(level: LogLevel) {
40
- Logger.#defaultLevel = level
41
- }
42
-
43
- static get defaultLevel() {
44
- return Logger.#defaultLevel
36
+ this.#level = level ?? defaultLevel
45
37
  }
46
38
 
47
39
  /**
@@ -79,7 +71,7 @@ export class Logger {
79
71
  }
80
72
 
81
73
  get level() {
82
- return this.#level ?? Logger.#defaultLevel
74
+ return this.#level
83
75
  }
84
76
 
85
77
  /**
@@ -3,13 +3,16 @@ import { processFile } from './index.js'
3
3
 
4
4
  const loader = function (source) {
5
5
  const { pluginConfig } = this.getOptions()
6
- const logger = new Logger(
7
- (pluginConfig?.logger?.level ?? process.env.NODE_ENV === 'production')
8
- ? 'error'
9
- : 'debug',
10
- )
6
+ const logger = new Logger()
11
7
 
12
- const { template } = processFile(source, this.resourcePath, pluginConfig, logger)
8
+ const { template, map } = processFile(source, this.resourcePath, pluginConfig, logger)
9
+
10
+ // hand the transform's source map back to webpack/turbopack when they support it
11
+ if (map && typeof this.callback === 'function') {
12
+ this.callback(null, template, map)
13
+
14
+ return
15
+ }
13
16
 
14
17
  return template
15
18
  }