@jk2908/cxx 0.1.10 → 0.2.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 +37 -20
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/index.d.ts +40 -8
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +196 -6
- package/dist/index.js.map +13 -0
- package/dist/logger.d.ts +54 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/lru.d.ts +21 -0
- package/dist/lru.d.ts.map +1 -0
- package/dist/next/cxx-loader.cjs +412 -5
- package/dist/next/cxx-loader.cjs.map +16 -0
- package/dist/next/index.d.ts +104 -262
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +305 -51
- package/dist/next/index.js.map +14 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/vite/index.d.ts +13 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +380 -0
- package/dist/vite/index.js.map +15 -0
- package/package.json +11 -26
- package/src/config.ts +4 -0
- package/src/index.ts +218 -9
- package/src/logger.ts +151 -0
- package/src/lru.ts +51 -0
- package/src/next/cxx-loader.js +16 -4
- package/src/next/index.ts +169 -46
- package/src/types.ts +28 -0
- package/src/utils.ts +24 -0
- package/src/vite/index.ts +156 -0
- package/LICENSE +0 -21
- package/dist/inject/index.cjs +0 -60
- package/dist/inject/index.d.ts +0 -8
- package/dist/inject/index.js +0 -58
- package/dist/next/cxx-loader.d.ts +0 -7
- package/dist/next/cxx-loader.js +0 -7
- package/dist/vite-plugin-cxx/index.d.ts +0 -6
- package/dist/vite-plugin-cxx/index.js +0 -16
- package/src/cyrb53.ts +0 -20
- package/src/inject.ts +0 -54
- package/src/vite-plugin-cxx/index.ts +0 -16
package/src/index.ts
CHANGED
|
@@ -1,12 +1,221 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
+
|
|
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
|
|
5
53
|
}
|
|
54
|
+
|
|
55
|
+
await fs.writeFile(filePath, content)
|
|
56
|
+
fileCache.set(filePath, content)
|
|
57
|
+
|
|
58
|
+
return true
|
|
6
59
|
}
|
|
7
60
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
]
|
|
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
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Write the generated declaration file for the current set of collected tags
|
|
142
|
+
*/
|
|
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
|
+
`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
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
|
|
210
|
+
*/
|
|
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
|
+
})
|
|
220
|
+
|
|
221
|
+
export const css = cxx
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const LEVELS = {
|
|
2
|
+
debug: 0,
|
|
3
|
+
info: 1,
|
|
4
|
+
warn: 2,
|
|
5
|
+
error: 3,
|
|
6
|
+
fatal: 4,
|
|
7
|
+
} as const
|
|
8
|
+
|
|
9
|
+
export type LogLevel = keyof typeof LEVELS
|
|
10
|
+
|
|
11
|
+
type LogEntry = {
|
|
12
|
+
ts: number
|
|
13
|
+
level: LogLevel
|
|
14
|
+
message: string
|
|
15
|
+
error?:
|
|
16
|
+
| Error
|
|
17
|
+
| {
|
|
18
|
+
message: string
|
|
19
|
+
stack?: string
|
|
20
|
+
cause?: unknown
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Log messages with different severity levels
|
|
26
|
+
*/
|
|
27
|
+
export class Logger {
|
|
28
|
+
static #defaultLevel: LogLevel = 'info'
|
|
29
|
+
|
|
30
|
+
#level?: LogLevel
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
level?: LogLevel,
|
|
34
|
+
public name?: string,
|
|
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
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Convert a value to an Error instance
|
|
49
|
+
*/
|
|
50
|
+
static toError(err: unknown) {
|
|
51
|
+
return err instanceof Error ? err : new Error(String(err), { cause: err })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Stringify the error for logging
|
|
56
|
+
*/
|
|
57
|
+
static print(err: unknown) {
|
|
58
|
+
if (err instanceof Error) {
|
|
59
|
+
return err.message + (err.stack ? `\n${err.stack}` : '')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// for plain objects, attempt to stringify with indentation
|
|
63
|
+
// for readability
|
|
64
|
+
if (typeof err === 'object' && err !== null) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.stringify(err, null, 2)
|
|
67
|
+
} catch {
|
|
68
|
+
// if stringify fails (e.g. circular reference), fall back
|
|
69
|
+
// to basic string conversion
|
|
70
|
+
return String(err)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return String(err)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
set level(level: LogLevel) {
|
|
78
|
+
this.#level = level
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get level() {
|
|
82
|
+
return this.#level ?? Logger.#defaultLevel
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Log a message with a specific level
|
|
87
|
+
*/
|
|
88
|
+
log(level: LogLevel, message: string, error?: Error) {
|
|
89
|
+
if (LEVELS[level] < LEVELS[this.level]) return
|
|
90
|
+
|
|
91
|
+
const entry: LogEntry = {
|
|
92
|
+
ts: Date.now(),
|
|
93
|
+
level,
|
|
94
|
+
message,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (level === 'error' || level === 'fatal') {
|
|
98
|
+
entry.error = error ? Logger.toError(error) : new Error(message)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const line = `${this.name ? `[${this.name}]` : ''} [${entry.ts}] [${level.toUpperCase()}] ${message}`
|
|
102
|
+
const extra = entry.error ? `\n${Logger.print(entry.error)}` : ''
|
|
103
|
+
|
|
104
|
+
if (level === 'warn') {
|
|
105
|
+
console.warn(line, extra)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (level === 'error' || level === 'fatal') {
|
|
110
|
+
console.error(line, extra)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(line, extra)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Log a debug message
|
|
119
|
+
*/
|
|
120
|
+
debug(...messages: string[]) {
|
|
121
|
+
this.log('debug', messages.join(' '))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Log an info message
|
|
126
|
+
*/
|
|
127
|
+
info(...messages: string[]) {
|
|
128
|
+
this.log('info', messages.join(' '))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Log a warning message
|
|
133
|
+
*/
|
|
134
|
+
warn(...messages: string[]) {
|
|
135
|
+
this.log('warn', messages.join(' '))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Log an error message
|
|
140
|
+
*/
|
|
141
|
+
error(message: string, error?: unknown) {
|
|
142
|
+
this.log('error', message, error === undefined ? undefined : Logger.toError(error))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Log a fatal error message
|
|
147
|
+
*/
|
|
148
|
+
fatal(message: string, error?: unknown) {
|
|
149
|
+
this.log('fatal', message, error === undefined ? undefined : Logger.toError(error))
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/lru.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const CACHE_MAX_SIZE = 100
|
|
2
|
+
|
|
3
|
+
export class LruCache<T> {
|
|
4
|
+
#cache: Map<string, T> = new Map()
|
|
5
|
+
#maxSize: number
|
|
6
|
+
|
|
7
|
+
constructor(maxSize = CACHE_MAX_SIZE) {
|
|
8
|
+
this.#maxSize = maxSize
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
get size() {
|
|
12
|
+
return this.#cache.size
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Retrieve a cached value and promote it to most-recently-used so it won't
|
|
17
|
+
* be evicted while still actively referenced
|
|
18
|
+
*/
|
|
19
|
+
get(key: string) {
|
|
20
|
+
const cached = this.#cache.get(key)
|
|
21
|
+
if (this.#cache.has(key)) this.set(key, cached as T)
|
|
22
|
+
|
|
23
|
+
return cached
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Promote an existing cache entry to most-recently-used by deleting and
|
|
28
|
+
* re-inserting it, which moves it to the end of the Map's iteration
|
|
29
|
+
* order. If the cache is at capacity, evict the least recently used
|
|
30
|
+
* entry (front) before inserting
|
|
31
|
+
*/
|
|
32
|
+
set(key: string, value: T) {
|
|
33
|
+
// drop any existing entry
|
|
34
|
+
this.#cache.delete(key)
|
|
35
|
+
|
|
36
|
+
if (this.#cache.size >= this.#maxSize) {
|
|
37
|
+
const lru = this.#cache.keys().next().value
|
|
38
|
+
if (lru !== undefined) this.#cache.delete(lru)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.#cache.set(key, value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
delete(key: string) {
|
|
45
|
+
return this.#cache.delete(key)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
clear() {
|
|
49
|
+
return this.#cache.clear()
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/next/cxx-loader.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Logger } from '../logger.js'
|
|
2
|
+
import { processFile } from './index.js'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
const loader = function (source) {
|
|
5
|
+
const { pluginConfig } = this.getOptions()
|
|
6
|
+
const logger = new Logger(
|
|
7
|
+
(pluginConfig?.logger?.level ?? process.env.NODE_ENV === 'production')
|
|
8
|
+
? 'error'
|
|
9
|
+
: 'debug',
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const { template } = processFile(source, this.resourcePath, pluginConfig, logger)
|
|
13
|
+
|
|
14
|
+
return template
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default loader
|
package/src/next/index.ts
CHANGED
|
@@ -1,56 +1,179 @@
|
|
|
1
|
+
import type { AsyncSubscription, Event } from '@parcel/watcher'
|
|
2
|
+
import type { NextConfig } from 'next'
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs/promises'
|
|
1
5
|
import path from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
2
7
|
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
import type {
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
8
|
+
import watcher from '@parcel/watcher'
|
|
9
|
+
|
|
10
|
+
import type { Logger } from '../logger.js'
|
|
11
|
+
import type { PluginConfig, Tags } from '../types.js'
|
|
12
|
+
import { collect, flattenTags, isENOENT, writeTypes } from '../index.js'
|
|
13
|
+
|
|
14
|
+
const nextDir = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const loader = path.join(nextDir, 'cxx-loader.cjs')
|
|
16
|
+
|
|
17
|
+
// keep the latest extracted tags per file so one edit can rebuild the full type surface
|
|
18
|
+
const tagsByFile: Map<string, Tags> = new Map()
|
|
19
|
+
|
|
20
|
+
// directory watches are shared, but rebuilds still need the file's config and logger
|
|
21
|
+
const watchedFiles = new Map<string, { pluginConfig: PluginConfig; logger: Logger }>()
|
|
22
|
+
const subscriptionsByDir = new Map<string, Promise<AsyncSubscription>>()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rebuild the generated type surface for one watched file after a Parcel event
|
|
26
|
+
*/
|
|
27
|
+
async function rebuildFile(filePath: string, event: Event['type']) {
|
|
28
|
+
const watchedFile = watchedFiles.get(filePath)
|
|
29
|
+
if (!watchedFile) return
|
|
30
|
+
|
|
31
|
+
const { pluginConfig, logger } = watchedFile
|
|
32
|
+
const hadTags = tagsByFile.has(filePath)
|
|
33
|
+
|
|
34
|
+
if (event === 'delete') {
|
|
35
|
+
tagsByFile.delete(filePath)
|
|
36
|
+
if (!hadTags) return
|
|
37
|
+
|
|
38
|
+
await writeTypes(flattenTags(tagsByFile))
|
|
39
|
+
logger.info(`updated types from ${filePath}`)
|
|
40
|
+
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const source = await fs.readFile(filePath, 'utf-8')
|
|
46
|
+
const { tags } = collect(source, filePath, pluginConfig)
|
|
47
|
+
const hasTags = tags.size > 0
|
|
48
|
+
|
|
49
|
+
if (hasTags) {
|
|
50
|
+
tagsByFile.set(filePath, tags)
|
|
51
|
+
} else {
|
|
52
|
+
tagsByFile.delete(filePath)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!hadTags && !hasTags) return
|
|
56
|
+
|
|
57
|
+
await writeTypes(flattenTags(tagsByFile))
|
|
58
|
+
logger.info(`updated types from ${filePath}`)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (!isENOENT(err)) throw err
|
|
61
|
+
if (!hadTags) return
|
|
62
|
+
|
|
63
|
+
tagsByFile.delete(filePath)
|
|
64
|
+
await writeTypes(flattenTags(tagsByFile))
|
|
65
|
+
|
|
66
|
+
logger.info(`updated types from ${filePath}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Transform one module and, in development, register it with the shared watcher state
|
|
72
|
+
*/
|
|
73
|
+
export function processFile(
|
|
74
|
+
source: string,
|
|
75
|
+
filePath: string,
|
|
76
|
+
pluginConfig: PluginConfig,
|
|
77
|
+
logger: Logger,
|
|
78
|
+
) {
|
|
79
|
+
const resolvedFilePath = path.resolve(filePath)
|
|
80
|
+
const directoryPath = path.dirname(resolvedFilePath)
|
|
81
|
+
let subscription = subscriptionsByDir.get(directoryPath)
|
|
82
|
+
|
|
83
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
84
|
+
// Parcel watches directories, so remember the exact file inputs we care about
|
|
85
|
+
watchedFiles.set(resolvedFilePath, { pluginConfig, logger })
|
|
86
|
+
|
|
87
|
+
if (!subscription) {
|
|
88
|
+
// one subscription per directory is enough; the callback filters back down to watched files
|
|
89
|
+
subscription = watcher.subscribe(directoryPath, async (err, events) => {
|
|
90
|
+
if (err) {
|
|
91
|
+
logger.error(`failed to update types from ${directoryPath}`, err)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Parcel can report unrelated files from the same directory, so ignore anything unregistered
|
|
96
|
+
for (const event of events) {
|
|
97
|
+
const eventFilePath = path.resolve(event.path)
|
|
98
|
+
if (!watchedFiles.has(eventFilePath)) continue
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await rebuildFile(eventFilePath, event.type)
|
|
102
|
+
} catch (watchErr) {
|
|
103
|
+
logger.error(`failed to update types from ${eventFilePath}`, watchErr)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
subscriptionsByDir.set(directoryPath, subscription)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = collect(source, filePath, pluginConfig)
|
|
113
|
+
|
|
114
|
+
if (result.tags.size > 0) {
|
|
115
|
+
tagsByFile.set(filePath, result.tags)
|
|
116
|
+
} else {
|
|
117
|
+
tagsByFile.delete(filePath)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
void writeTypes(flattenTags(tagsByFile))
|
|
121
|
+
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Close all active Parcel subscriptions before the process exits.
|
|
127
|
+
*/
|
|
128
|
+
async function cleanup() {
|
|
129
|
+
await Promise.all(
|
|
130
|
+
[...subscriptionsByDir].map(async ([directoryPath, subPromise]) => {
|
|
131
|
+
subscriptionsByDir.delete(directoryPath)
|
|
132
|
+
await (await subPromise).unsubscribe()
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
subscriptionsByDir.clear()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
process.once('SIGINT', () => {
|
|
140
|
+
cleanup().finally(() => process.exit(130))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
process.once('SIGTERM', () => {
|
|
144
|
+
cleanup().finally(() => process.exit(143))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
export async function withCxx(
|
|
148
|
+
nextConfig: NextConfig = {},
|
|
149
|
+
pluginConfig: PluginConfig = {},
|
|
150
|
+
) {
|
|
151
|
+
const TURBOPACK_GLOB = '*.{tsx,jsx,ts,js}'
|
|
152
|
+
|
|
153
|
+
const loaderItem = {
|
|
154
|
+
loader,
|
|
155
|
+
options: {
|
|
156
|
+
pluginConfig,
|
|
31
157
|
},
|
|
32
158
|
}
|
|
33
159
|
|
|
160
|
+
const currentRule = nextConfig.turbopack?.rules?.[TURBOPACK_GLOB]
|
|
161
|
+
|
|
34
162
|
return {
|
|
35
163
|
...nextConfig,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return resolvedWebpackConfig
|
|
164
|
+
turbopack: {
|
|
165
|
+
...nextConfig.turbopack,
|
|
166
|
+
rules: {
|
|
167
|
+
...nextConfig.turbopack?.rules,
|
|
168
|
+
[TURBOPACK_GLOB]: Array.isArray(currentRule)
|
|
169
|
+
? [...currentRule, loaderItem]
|
|
170
|
+
: currentRule
|
|
171
|
+
? {
|
|
172
|
+
...currentRule,
|
|
173
|
+
loaders: [...(currentRule.loaders ?? []), loaderItem],
|
|
174
|
+
}
|
|
175
|
+
: { loaders: [loaderItem] },
|
|
176
|
+
},
|
|
51
177
|
},
|
|
52
|
-
...(IS_CANARY
|
|
53
|
-
? { experimental: { ...nextConfig?.experimental, turbo: turboConfig } }
|
|
54
|
-
: { turbo: turboConfig }),
|
|
55
178
|
}
|
|
56
179
|
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LogLevel } from './logger.js'
|
|
2
|
+
|
|
3
|
+
export type Result<ClassName extends string = string> = readonly [
|
|
4
|
+
css: string,
|
|
5
|
+
classes: Readonly<Record<ClassName, string>>,
|
|
6
|
+
href: string,
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
export type Tags = Map<string, string[]>
|
|
10
|
+
|
|
11
|
+
export type PluginConfig = {
|
|
12
|
+
typeSuffix?: string | false
|
|
13
|
+
logger?: {
|
|
14
|
+
level?: LogLevel
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type BuildContext = {
|
|
19
|
+
tagsByFile: Map<string, Tags>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Tag = <ClassName extends string = string>(
|
|
23
|
+
name: string,
|
|
24
|
+
) => (_: TemplateStringsArray) => Result<ClassName>
|
|
25
|
+
|
|
26
|
+
export type Cxx = ((_: TemplateStringsArray) => Result) & {
|
|
27
|
+
tag: Tag
|
|
28
|
+
}
|