@jk2908/cxx 0.2.6 → 0.3.1

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,17 +3,28 @@ 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
8
  import type { BuildContext, 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
+
12
17
  /**
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.
18
+ * LRU cache of `collect` results by file id + last seen source + config so watcher and transform
19
+ * can reuse the same parsed template/tags.
15
20
  */
16
- const collectCache = new LruCache<{ source: string; template: string; tags: Tags }>()
21
+ const collectCache = new LruCache<{
22
+ source: string
23
+ config: string
24
+ template: string
25
+ tags: Tags
26
+ map: SourceMap | null
27
+ }>()
17
28
 
18
29
  /**
19
30
  * LRU cache for file content. Stores the last written content per file path so `maybeWrite`
@@ -21,6 +32,12 @@ const collectCache = new LruCache<{ source: string; template: string; tags: Tags
21
32
  */
22
33
  const fileCache = new LruCache<string>()
23
34
 
35
+ /**
36
+ * Serialises type file writes inside a single process so overlapping `writeTypes` calls can't
37
+ * interleave or land out of order.
38
+ */
39
+ let writeChain: Promise<void> = Promise.resolve()
40
+
24
41
  /**
25
42
  * Returns whether the error was caused by a missing file or directory.
26
43
  */
@@ -29,20 +46,44 @@ export function isENOENT(err: unknown) {
29
46
  }
30
47
 
31
48
  /**
32
- * Write a file only if the content has changed since the last build.
49
+ * Write a file by first writing to a unique temporary file and then renaming it into place, so
50
+ * concurrent writers (e.g. Turbopack loader workers) never observe a partial file.
33
51
  */
34
- export async function maybeWrite(filePath: string, content: string) {
35
- const cached = fileCache.get(filePath)
52
+ async function writeFileAtomic(filePath: string, content: string) {
53
+ const tmp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
36
54
 
37
- if (cached !== content) {
38
- // cache says content changed, write without reading
39
- await fs.writeFile(filePath, content)
40
- fileCache.set(filePath, content)
55
+ await fs.writeFile(tmp, content)
56
+ await fs.rename(tmp, filePath)
57
+ }
41
58
 
42
- return true
43
- }
59
+ /**
60
+ * Run a write task after all previously queued writes have settled. A rejection doesn't break the
61
+ * chain for later tasks.
62
+ */
63
+ function enqueueWrite<T>(task: () => Promise<T>) {
64
+ // wait for the previous write to finish, then run this one, even if the previous one failed
65
+ const run = writeChain.then(task, task)
66
+
67
+ // move the queue on to this write, but turn either outcome into success so it never rejects
68
+ writeChain = run.then(
69
+ () => undefined,
70
+ () => undefined,
71
+ )
44
72
 
45
- // cache miss or cache hit with same content — verify on disk
73
+ // return the real promise, so the caller still sees this write's value or error
74
+ return run
75
+ }
76
+
77
+ /**
78
+ * Write a file only if the content actually differs from what is already on disk.
79
+ */
80
+ export async function maybeWrite(filePath: string, content: string) {
81
+ const cached = fileCache.get(filePath)
82
+ // fast path: already wrote this exact content in this process
83
+ if (cached === content) return false
84
+
85
+ // verify against disk on a cold start (or when the cache is stale) so we
86
+ // don't needlessly bump the mtime and invalidate downstream watchers
46
87
  try {
47
88
  if ((await fs.readFile(filePath, 'utf-8')) === content) {
48
89
  fileCache.set(filePath, content)
@@ -52,17 +93,19 @@ export async function maybeWrite(filePath: string, content: string) {
52
93
  if (!isENOENT(err)) throw err
53
94
  }
54
95
 
55
- await fs.writeFile(filePath, content)
96
+ await writeFileAtomic(filePath, content)
56
97
  fileCache.set(filePath, content)
57
98
 
58
99
  return true
59
100
  }
60
101
 
61
102
  // 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
103
+ // capture groups: 1 optional `export `, 2-4 assigned variable names, 5 plain
104
+ // css body, 6 quote kind, 7 tag name, 8 tagged css body
64
105
  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
106
+ /((?: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
107
+
108
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
66
109
 
67
110
  /**
68
111
  * Hash transformed CSS into a stable href for style deduplication.
@@ -79,115 +122,237 @@ export class DuplicateTagError extends Error {
79
122
  }
80
123
  }
81
124
 
125
+ export class InvalidTagError extends Error {
126
+ name = 'InvalidTagError'
127
+
128
+ constructor(tag: string) {
129
+ super(
130
+ `Tag "${tag}" does not map to a valid TypeScript identifier. ` +
131
+ `Use letters, numbers, spaces, "_" or "-"`,
132
+ )
133
+ }
134
+ }
135
+
82
136
  /**
83
137
  * Transform `cxx` templates in one source file and collect any exported tag types.
84
138
  */
85
139
  export function collect(source: string, id: string, config: PluginConfig) {
86
- const tags: Tags = new Map()
140
+ const configKey = JSON.stringify(config)
87
141
 
142
+ // reuse the last result when the source and config are unchanged
88
143
  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
- )
144
+ if (cached?.source === source && cached.config === configKey) return cached
107
145
 
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}`)
116
-
117
- if (tags.has(tagWithSuffix)) throw new DuplicateTagError(tagWithSuffix)
118
-
119
- // side effect but neccessary
120
- tags.set(tagWithSuffix, Object.keys(styles))
121
- }
122
-
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
- )
146
+ const tags: Tags = new Map()
147
+ const magic = new MagicString(source)
148
+ let matched = false
149
+
150
+ // the regex is global, so reset its cursor before scanning
151
+ regex.lastIndex = 0
152
+
153
+ let match: RegExpExecArray | null
154
+
155
+ // walk every cxx template and replace it in place
156
+ while ((match = regex.exec(source))) {
157
+ matched = true
158
+
159
+ // capture groups: export, three bindings, plain css, (quote), tag, tagged css
160
+ const [, exp, varOne, varTwo, varThree, plainCss, , tag, taggedCss] = match
161
+ const css = plainCss ?? taggedCss
162
+
163
+ // compile the CSS to minified CSS Modules
164
+ const { code, exports = {} } = transform({
165
+ minify: true,
166
+ cssModules: true,
167
+ filename: id,
168
+ code: new TextEncoder().encode(css),
169
+ })
170
+
171
+ const href = hashCss(code)
172
+ // map each class name to its generated (scoped) name
173
+ const styles = Object.fromEntries(
174
+ Object.entries(exports).map(([k, v]) => [k, v.name]),
175
+ )
176
+
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}`)
185
+
186
+ if (!IDENTIFIER.test(tagWithSuffix)) throw new InvalidTagError(tag)
187
+ if (tags.has(tagWithSuffix)) throw new DuplicateTagError(tagWithSuffix)
188
+
189
+ // side effect but necessary
190
+ tags.set(tagWithSuffix, Object.keys(styles))
191
+ }
132
192
 
133
- // cache
134
- collectCache.set(id, { source, template, tags })
193
+ const statements: string[] = []
135
194
 
136
- return {
137
- template,
138
- tags,
195
+ if (varOne) {
196
+ statements.push(`${exp}const ${varOne} = ${JSON.stringify(decoder.decode(code))}`)
197
+ }
198
+
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(match.index, match.index + 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
- }
246
+ // `force` makes this a no-op when the file is already absent
247
+ await fs.rm(filePath, { force: true })
248
+ fileCache.delete(filePath)
155
249
 
156
250
  return
157
251
  }
158
252
 
159
253
  await fs.mkdir(GENERATED_DIR, { recursive: true })
254
+ await maybeWrite(filePath, renderTypes(tags))
255
+ }
160
256
 
161
- await maybeWrite(
162
- filePath,
163
- `${AUTOGEN_MSG}
257
+ /**
258
+ * Write the generated declaration file for the current set of collected tags.
259
+ */
260
+ export function writeTypes(tags: Tags) {
261
+ return enqueueWrite(() => writeRenderedTypes(tags))
262
+ }
164
263
 
165
- import '${PKG_NAME}'
264
+ const PARTIAL_DIR = path.join(GENERATED_DIR, 'tags')
166
265
 
167
- declare module '${PKG_NAME}' {
168
- ${tagsToType(tags)}
169
- }
170
- `,
266
+ function partialPath(filePath: string) {
267
+ return path.join(
268
+ PARTIAL_DIR,
269
+ `${createHash('sha1').update(filePath).digest('hex')}.json`,
171
270
  )
172
271
  }
173
272
 
273
+ /**
274
+ * Read every per-file partial written by the Next loader workers and merge them into one tag map.
275
+ * Partials whose source file no longer exists are pruned so deleted files drop out of the types.
276
+ */
277
+ async function readPartials() {
278
+ let entries: string[]
279
+
280
+ try {
281
+ entries = await fs.readdir(PARTIAL_DIR)
282
+ } catch (err) {
283
+ if (isENOENT(err)) return new Map()
284
+ throw err
285
+ }
286
+
287
+ const tags: Tags = new Map()
288
+
289
+ for (const entry of entries) {
290
+ if (!entry.endsWith('.json')) continue
291
+
292
+ const partial = path.join(PARTIAL_DIR, entry)
293
+ let parsed: { file: string; tags: Record<string, string[]> }
294
+
295
+ try {
296
+ parsed = JSON.parse(await fs.readFile(partial, 'utf-8'))
297
+ } catch {
298
+ continue
299
+ }
300
+
301
+ try {
302
+ await fs.access(parsed.file)
303
+ } catch {
304
+ await fs.rm(partial, { force: true })
305
+ continue
306
+ }
307
+
308
+ for (const [name, classes] of Object.entries(parsed.tags)) {
309
+ if (tags.has(name)) throw new DuplicateTagError(name)
310
+ tags.set(name, classes)
311
+ }
312
+ }
313
+
314
+ return tags
315
+ }
316
+
317
+ /**
318
+ * Next-specific write path. Turbopack spreads loaders across worker processes, so each worker
319
+ * persists its own per-file partial and rebuilds the shared declaration file from all of them.
320
+ */
321
+ export function writeTypesForFile(filePath: string, tags: Tags) {
322
+ return enqueueWrite(async () => {
323
+ await fs.mkdir(PARTIAL_DIR, { recursive: true })
324
+
325
+ const partial = partialPath(filePath)
326
+
327
+ if (!tags.size) {
328
+ await fs.rm(partial, { force: true })
329
+ } else {
330
+ await writeFileAtomic(
331
+ partial,
332
+ JSON.stringify({ file: filePath, tags: Object.fromEntries(tags) }),
333
+ )
334
+ }
335
+
336
+ await writeRenderedTypes(await readPartials())
337
+ })
338
+ }
339
+
174
340
  /**
175
341
  * Render collected tags as a module augmentation for the package entrypoint.
176
342
  */
177
343
  export function tagsToType(tags: Tags) {
178
344
  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
- )
345
+ .map(([tag, classes]) => {
346
+ const union =
347
+ classes.length > 0
348
+ ? classes
349
+ .toSorted((a, b) => a.length - b.length)
350
+ .map(c => `'${c}'`)
351
+ .join(' | ')
352
+ : 'never'
353
+
354
+ return `\texport type ${tag} = ${union}`
355
+ })
191
356
  .join('\n')
192
357
  }
193
358
 
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,11 +3,7 @@ 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
8
  const { template } = processFile(source, this.resourcePath, pluginConfig, logger)
13
9