@jk2908/cxx 0.2.4 → 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 ADDED
@@ -0,0 +1,373 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+
5
+ import { transform } from 'lightningcss'
6
+ import MagicString, { type SourceMap } from 'magic-string'
7
+
8
+ import type { BuildContext, PluginConfig, Tags } from './types.js'
9
+ import { AUTOGEN_MSG, GENERATED_DIR, PKG_NAME } from './config.js'
10
+ import { LruCache } from './lru.js'
11
+ import { pascalise } from './utils.js'
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
+
17
+ /**
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.
20
+ */
21
+ const collectCache = new LruCache<{
22
+ source: string
23
+ config: string
24
+ template: string
25
+ tags: Tags
26
+ map: SourceMap | null
27
+ }>()
28
+
29
+ /**
30
+ * LRU cache for file content. Stores the last written content per file path so `maybeWrite`
31
+ * can skip disk i/o when nothing has changed.
32
+ */
33
+ const fileCache = new LruCache<string>()
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
+
41
+ /**
42
+ * Returns whether the error was caused by a missing file or directory.
43
+ */
44
+ export function isENOENT(err: unknown) {
45
+ return err instanceof Error && 'code' in err && err.code === 'ENOENT'
46
+ }
47
+
48
+ /**
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.
51
+ */
52
+ async function writeFileAtomic(filePath: string, content: string) {
53
+ const tmp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
54
+
55
+ await fs.writeFile(tmp, content)
56
+ await fs.rename(tmp, filePath)
57
+ }
58
+
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
+ )
72
+
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
87
+ try {
88
+ if ((await fs.readFile(filePath, 'utf-8')) === content) {
89
+ fileCache.set(filePath, content)
90
+ return false
91
+ }
92
+ } catch (err) {
93
+ if (!isENOENT(err)) throw err
94
+ }
95
+
96
+ await writeFileAtomic(filePath, content)
97
+ fileCache.set(filePath, content)
98
+
99
+ return true
100
+ }
101
+
102
+ // matches `[css, classes, href] = cxx\`...\`` and `cxx.tag<T>('name')\`...\``
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
105
+ const regex =
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_$]*$/
109
+
110
+ /**
111
+ * Hash transformed CSS into a stable href for style deduplication.
112
+ */
113
+ function hashCss(code: Uint8Array) {
114
+ return `cxx-${createHash('sha256').update(code).digest('base64url').slice(0, 12)}`
115
+ }
116
+
117
+ export class DuplicateTagError extends Error {
118
+ name = 'DuplicateTagError'
119
+
120
+ constructor(tag: string) {
121
+ super(`Tag ${tag} has already been seen. Tags must be unique across your application`)
122
+ }
123
+ }
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
+
136
+ /**
137
+ * Transform `cxx` templates in one source file and collect any exported tag types.
138
+ */
139
+ export function collect(source: string, id: string, config: PluginConfig) {
140
+ const configKey = JSON.stringify(config)
141
+
142
+ // reuse the last result when the source and config are unchanged
143
+ const cached = collectCache.get(id)
144
+ if (cached?.source === source && cached.config === configKey) return cached
145
+
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
+ }
192
+
193
+ const statements: string[] = []
194
+
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'))
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
221
+ }
222
+
223
+ /**
224
+ * Render the generated declaration file contents for a set of collected tags.
225
+ */
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) {
243
+ const filePath = path.join(GENERATED_DIR, 'index.d.ts')
244
+
245
+ if (!tags.size) {
246
+ // `force` makes this a no-op when the file is already absent
247
+ await fs.rm(filePath, { force: true })
248
+ fileCache.delete(filePath)
249
+
250
+ return
251
+ }
252
+
253
+ await fs.mkdir(GENERATED_DIR, { recursive: true })
254
+ await maybeWrite(filePath, renderTypes(tags))
255
+ }
256
+
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
+ }
263
+
264
+ const PARTIAL_DIR = path.join(GENERATED_DIR, 'tags')
265
+
266
+ function partialPath(filePath: string) {
267
+ return path.join(
268
+ PARTIAL_DIR,
269
+ `${createHash('sha1').update(filePath).digest('hex')}.json`,
270
+ )
271
+ }
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
+
340
+ /**
341
+ * Render collected tags as a module augmentation for the package entrypoint.
342
+ */
343
+ export function tagsToType(tags: Tags) {
344
+ return [...tags]
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
+ })
356
+ .join('\n')
357
+ }
358
+
359
+ /**
360
+ * Merge per-file tag maps into one application-wide tag map.
361
+ */
362
+ export function flattenTags(tagsByFile: BuildContext['tagsByFile']) {
363
+ const tags = new Map<string, string[]>()
364
+
365
+ for (const fileTags of tagsByFile.values()) {
366
+ for (const [name, classes] of fileTags) {
367
+ if (tags.has(name)) throw new DuplicateTagError(name)
368
+ tags.set(name, classes)
369
+ }
370
+ }
371
+
372
+ return tags
373
+ }
package/src/index.ts CHANGED
@@ -1,219 +1,33 @@
1
- import { createHash } from 'node:crypto'
2
- import fs from 'node:fs/promises'
3
- import path from 'node:path'
1
+ import type { Cxx, Result } from './types.js'
4
2
 
5
- import { transform } from 'lightningcss'
6
-
7
- import type { BuildContext, Cxx, PluginConfig, Result, Tags } from './types.js'
8
- import { AUTOGEN_MSG, GENERATED_DIR, PKG_NAME } from './config.js'
9
- import { LruCache } from './lru.js'
10
- import { pascalise } from './utils.js'
11
-
12
- /**
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
15
- */
16
- const collectCache = new LruCache<{ source: string; template: string; tags: Tags }>()
17
-
18
- /**
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
21
- */
22
- const fileCache = new LruCache<string>()
23
-
24
- /**
25
- * Returns whether the error was caused by a missing file or directory
26
- */
27
- export function isENOENT(err: unknown) {
28
- return err instanceof Error && 'code' in err && err.code === 'ENOENT'
29
- }
30
-
31
- /**
32
- * Write a file only if the content has changed since the last build
33
- */
34
- export async function maybeWrite(filePath: string, content: string) {
35
- const cached = fileCache.get(filePath)
36
-
37
- if (cached !== content) {
38
- // cache says content changed, write without reading
39
- await fs.writeFile(filePath, content)
40
- fileCache.set(filePath, content)
41
-
42
- return true
43
- }
44
-
45
- // cache miss or cache hit with same content — verify on disk
46
- try {
47
- if ((await fs.readFile(filePath, 'utf-8')) === content) {
48
- fileCache.set(filePath, content)
49
- return false
50
- }
51
- } catch (err) {
52
- if (!isENOENT(err)) throw err
53
- }
54
-
55
- await fs.writeFile(filePath, content)
56
- fileCache.set(filePath, content)
57
-
58
- return true
59
- }
60
-
61
- // 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
64
- 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
66
-
67
- /**
68
- * Hash transformed CSS into a stable href for style deduplication
69
- */
70
- function hashCss(code: Uint8Array) {
71
- return `cxx-${createHash('sha256').update(code).digest('base64url').slice(0, 12)}`
72
- }
73
-
74
- export class TagDuplicateError extends Error {
75
- constructor(tag: string) {
76
- super(`Tag ${tag} has already been seen. Tags must be unique across your application`)
77
- }
78
- }
79
-
80
- /**
81
- * Transform `cxx` templates in one source file and collect any exported tag types
82
- */
83
- export function collect(source: string, id: string, config: PluginConfig) {
84
- const tags: Tags = new Map()
85
-
86
- const cached = collectCache.get(id)
87
- if (cached?.source === source) return cached
88
-
89
- const template = source.replace(
90
- regex,
91
- (_match, varOne, varTwo, varThree, plainCss, _quote, tag, taggedCss) => {
92
- const css = plainCss ?? taggedCss
93
-
94
- const { code, exports = {} } = transform({
95
- minify: true,
96
- cssModules: true,
97
- filename: id,
98
- code: new TextEncoder().encode(css),
99
- })
100
-
101
- const href = hashCss(code)
102
- const styles = Object.fromEntries(
103
- Object.entries(exports).map(([k, v]) => [k, v.name]),
104
- )
105
-
106
- if (tag) {
107
- const suffix =
108
- typeof config.typeSuffix === 'string'
109
- ? config.typeSuffix
110
- : config.typeSuffix === false
111
- ? ''
112
- : 'Classes'
113
- const tagWithSuffix = pascalise(`${tag}${suffix}`)
114
-
115
- if (tags.has(tagWithSuffix)) throw new TagDuplicateError(tagWithSuffix)
116
-
117
- // side effect but neccessary
118
- tags.set(tagWithSuffix, Object.keys(styles))
119
- }
120
-
121
- return [
122
- varOne && `const ${varOne} = \`${code}\``,
123
- varTwo && `const ${varTwo} = ${JSON.stringify(styles)}`,
124
- varThree && `const ${varThree} = \`${href}\``,
125
- ]
126
- .filter(Boolean)
127
- .join('\n')
128
- },
129
- )
130
-
131
- // cache
132
- collectCache.set(id, { source, template, tags })
133
-
134
- return {
135
- template,
136
- tags,
137
- }
138
- }
3
+ export type { Cxx, Result, Tag } from './types.js'
139
4
 
140
5
  /**
141
- * Write the generated declaration file for the current set of collected tags
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
+ * ```
142
15
  */
143
- export async function writeTypes(tags: Tags) {
144
- const filePath = path.join(GENERATED_DIR, 'index.d.ts')
145
-
146
- if (!tags.size) {
147
- try {
148
- await fs.rm(filePath, { force: true })
149
- fileCache.delete(filePath)
150
- } catch (err) {
151
- if (!isENOENT(err)) throw err
152
- }
153
-
154
- return
155
- }
156
-
157
- await fs.mkdir(GENERATED_DIR, { recursive: true })
158
-
159
- await maybeWrite(
160
- filePath,
161
- `${AUTOGEN_MSG}
162
-
163
- import '${PKG_NAME}'
164
-
165
- declare module '${PKG_NAME}' {
166
- ${tagsToType(tags)}
167
- }
168
- `,
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.',
169
19
  )
170
20
  }
171
21
 
172
22
  /**
173
- * Render collected tags as a module augmentation for the package entrypoint
174
- */
175
- export function tagsToType(tags: Tags) {
176
- return [...tags]
177
- .map(
178
- ([tag, classes]) => `
179
- export type ${tag} = ${
180
- classes.length > 0
181
- ? classes
182
- .toSorted((a, b) => a.length - b.length)
183
- .map(c => `'${c}'`)
184
- .join(' | ')
185
- : 'never'
186
- }
187
- `,
188
- )
189
- .join('\n')
190
- }
191
-
192
- /**
193
- * Merge per-file tag maps into one application-wide tag map
194
- */
195
- export function flattenTags(tagsByFile: BuildContext['tagsByFile']) {
196
- const tags = new Map<string, string[]>()
197
-
198
- for (const fileTags of tagsByFile.values()) {
199
- for (const [name, classes] of fileTags) {
200
- if (tags.has(name)) throw new TagDuplicateError(name)
201
- tags.set(name, classes)
202
- }
203
- }
204
-
205
- return tags
206
- }
207
-
208
- /**
209
- * Build-time template tag placeholder used by the transform to emit CSS, classes and href values
23
+ * Build-time template tag placeholder used by the transform to emit CSS, classes and href values.
210
24
  */
211
- export const cxx: Cxx = Object.assign((_: TemplateStringsArray): Result => ['', {}, ''], {
212
- tag:
213
- <ClassName extends string = string>(_: string) =>
214
- (_strings: TemplateStringsArray): Result<ClassName> => [
215
- '',
216
- {} as Readonly<Record<ClassName, string>>,
217
- '',
218
- ],
219
- })
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
+ )