@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/README.md +18 -2
- package/dist/build.d.ts +46 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +314 -0
- package/dist/build.js.map +13 -0
- package/dist/index.d.ts +3 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -188
- package/dist/index.js.map +4 -7
- package/dist/logger.d.ts +9 -11
- package/dist/logger.d.ts.map +1 -1
- package/dist/lru.d.ts +2 -2
- package/dist/next/cxx-loader.cjs +249 -152
- package/dist/next/cxx-loader.cjs.map +9 -9
- package/dist/next/index.d.ts +32 -11
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +278 -135
- package/dist/next/index.js.map +7 -7
- package/dist/types.d.ts +3 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +16 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/vite/index.d.ts +14 -6
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +281 -129
- package/dist/vite/index.js.map +8 -8
- package/package.json +83 -75
- package/src/build.ts +373 -0
- package/src/index.ts +24 -210
- package/src/logger.ts +14 -22
- package/src/lru.ts +2 -2
- package/src/next/cxx-loader.js +1 -5
- package/src/next/index.ts +143 -100
- package/src/types.ts +5 -4
- package/src/utils.ts +72 -7
- package/src/vite/index.ts +110 -79
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 {
|
|
2
|
-
import fs from 'node:fs/promises'
|
|
3
|
-
import path from 'node:path'
|
|
1
|
+
import type { Cxx, Result } from './types.js'
|
|
4
2
|
|
|
5
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
*
|
|
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(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
+
)
|