@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/README.md +18 -2
- package/dist/build.d.ts +22 -11
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +242 -73
- package/dist/build.js.map +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -7
- package/dist/index.js.map +3 -3
- package/dist/logger.d.ts +0 -2
- package/dist/logger.d.ts.map +1 -1
- package/dist/next/cxx-loader.cjs +287 -146
- package/dist/next/cxx-loader.cjs.map +7 -7
- package/dist/next/index.d.ts +34 -9
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +338 -142
- package/dist/next/index.js.map +5 -5
- package/dist/types.d.ts +5 -6
- 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 +4 -3
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +336 -139
- package/dist/vite/index.js.map +6 -6
- package/package.json +85 -81
- package/src/build.ts +312 -108
- package/src/index.ts +26 -9
- package/src/logger.ts +5 -13
- package/src/next/cxx-loader.js +9 -6
- package/src/next/index.ts +152 -100
- package/src/types.ts +5 -8
- package/src/utils.ts +81 -8
- package/src/vite/index.ts +141 -89
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 {
|
|
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
|
|
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<{
|
|
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
|
-
*
|
|
20
|
-
*
|
|
31
|
+
* Serialises type file writes inside a single process so overlapping writes can't interleave or
|
|
32
|
+
* land out of order.
|
|
21
33
|
*/
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
const
|
|
47
|
+
async function writeFileAtomic(filePath: string, content: string) {
|
|
48
|
+
const tmp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
|
|
36
49
|
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
63
|
-
//
|
|
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
|
|
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
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
186
|
+
if (!IDENTIFIER.test(tagWithSuffix)) throw new InvalidTagError(tag)
|
|
187
|
+
if (tags.has(tagWithSuffix)) throw new DuplicateTagError(tagWithSuffix)
|
|
122
188
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
134
|
-
|
|
195
|
+
if (varOne) {
|
|
196
|
+
statements.push(`${exp}const ${varOne} = ${JSON.stringify(decoder.decode(code))}`)
|
|
197
|
+
}
|
|
135
198
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
*
|
|
224
|
+
* Render the generated declaration file contents for a set of collected tags.
|
|
144
225
|
*/
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
filePath,
|
|
163
|
-
`${AUTOGEN_MSG}
|
|
254
|
+
const PARTIAL_DIR = path.join(GENERATED_DIR, 'tags')
|
|
164
255
|
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
201
|
-
|
|
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(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
74
|
+
return this.#level
|
|
83
75
|
}
|
|
84
76
|
|
|
85
77
|
/**
|
package/src/next/cxx-loader.js
CHANGED
|
@@ -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
|
}
|