@jk2908/cxx 0.3.6 → 0.4.0

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
@@ -1,13 +1,14 @@
1
1
  import type { Dirent } from 'node:fs'
2
2
 
3
3
  import { createHash } from 'node:crypto'
4
+ import { mkdirSync, writeFileSync } from 'node:fs'
4
5
  import fs from 'node:fs/promises'
5
6
  import path from 'node:path'
6
7
 
7
8
  import { transform } from 'lightningcss'
8
9
  import MagicString, { type SourceMap } from 'magic-string'
9
10
 
10
- import type { PluginConfig, Tags } from './types.js'
11
+ import type { EmbedStyleDescriptor, PluginConfig, Tags } from './types.js'
11
12
  import { AUTOGEN_MSG, GENERATED_DIR, NAME, PKG_NAME } from './config.js'
12
13
  import { Logger } from './logger.js'
13
14
  import { LruCache } from './lru.js'
@@ -22,11 +23,17 @@ const encoder = new TextEncoder()
22
23
  * LRU cache of `collect` results by file id + last seen source + config so watcher and transform
23
24
  * can reuse the same parsed template/tags.
24
25
  */
26
+ export type StyleSheet = {
27
+ hash: string
28
+ css: string
29
+ }
30
+
25
31
  const collectCache = new LruCache<{
26
32
  source: string
27
33
  config: string
28
34
  template: string
29
35
  tags: Tags
36
+ sheets: StyleSheet[]
30
37
  map: SourceMap | null
31
38
  }>()
32
39
 
@@ -94,19 +101,20 @@ export async function maybeWrite(filePath: string, content: string) {
94
101
  return true
95
102
  }
96
103
 
97
- // matches `[css, classes, href] = cxx\`...\`` and `cxx.tag<T>('name')\`...\``
98
- // capture groups: 1 optional `export `, 2-4 assigned variable names, 5 plain
99
- // css body, 6 quote kind, 7 tag name, 8 tagged css body
104
+ // matches cxx.embed and cxx.extract, with an optional `.tag('name')`.
105
+ // capture groups: 1 mode, 2 quote kind, 3 tag name, 4 css body
100
106
  const regex =
101
- /((?: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
+ /\bcxx\.(embed|extract)(?:\.tag(?:\s*<[^>]+>)?\s*\(\s*(['"])(.*?)\2\s*\))?\s*`([\s\S]*?)`/g
102
108
 
103
109
  const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
104
110
 
105
111
  /**
106
- * Hash transformed CSS into a stable href for style deduplication.
112
+ * Hash transformed CSS into a stable href and asset key.
107
113
  */
108
114
  function hashCss(code: Uint8Array) {
109
- return `cxx-${createHash('sha256').update(code).digest('base64url').slice(0, 12)}`
115
+ const hash = createHash('sha256').update(code).digest('base64url').slice(0, 12)
116
+
117
+ return { href: `cxx-${hash}`, key: hash }
110
118
  }
111
119
 
112
120
  export class DuplicateTagError extends Error {
@@ -139,6 +147,7 @@ export function collect(source: string, id: string, config: PluginConfig) {
139
147
  if (cached?.source === source && cached.config === configKey) return cached
140
148
 
141
149
  const tags: Tags = new Map()
150
+ const sheets: StyleSheet[] = []
142
151
  const magic = new MagicString(source)
143
152
 
144
153
  let matched = false
@@ -152,9 +161,8 @@ export function collect(source: string, id: string, config: PluginConfig) {
152
161
  while ((match = regex.exec(source))) {
153
162
  matched = true
154
163
 
155
- // capture groups: export, three bindings, plain css, (quote), tag, tagged css
156
- const [, exp, varOne, varTwo, varThree, plainCss, , tag, taggedCss] = match
157
- const css = plainCss ?? taggedCss
164
+ const [, rawMode, , tag, css] = match
165
+ const mode = rawMode === 'extract' ? 'extract' : 'embed'
158
166
  const matchIndex = match.index
159
167
 
160
168
  // compile the CSS to minified CSS Modules, reporting the source line on failure
@@ -172,7 +180,9 @@ export function collect(source: string, id: string, config: PluginConfig) {
172
180
  }
173
181
  })()
174
182
 
175
- const href = hashCss(code)
183
+ const { href, key } = hashCss(code)
184
+ const cssText = decoder.decode(code)
185
+
176
186
  // map each class name to its generated (scoped) name
177
187
  const styles = Object.fromEntries(
178
188
  Object.entries(classExports).map(([k, v]) => [k, v.name]),
@@ -194,22 +204,23 @@ export function collect(source: string, id: string, config: PluginConfig) {
194
204
  tags.set(tagWithSuffix, Object.keys(styles))
195
205
  }
196
206
 
197
- const statements: string[] = []
207
+ let replacement: string
198
208
 
199
- if (varOne) {
200
- statements.push(`${exp}const ${varOne} = ${JSON.stringify(decoder.decode(code))}`)
201
- }
202
-
203
- if (varTwo) {
204
- statements.push(`${exp}const ${varTwo} = ${JSON.stringify(styles)}`)
205
- }
209
+ if (mode === 'extract') {
210
+ sheets.push({ hash: key, css: cssText })
211
+ replacement = `{\n\tmode: 'extract',\n\tstyles: ${JSON.stringify(styles)}\n}`
212
+ } else {
213
+ const styleProps: EmbedStyleDescriptor = {
214
+ mode: 'embed',
215
+ href,
216
+ css: cssText,
217
+ precedence: 'default',
218
+ }
206
219
 
207
- if (varThree) {
208
- statements.push(`${exp}const ${varThree} = ${JSON.stringify(href)}`)
220
+ replacement = `{\n\tmode: 'embed',\n\tstyles: ${JSON.stringify(styles)},\n\tstyleProps: ${JSON.stringify(styleProps)}\n}`
209
221
  }
210
222
 
211
- // swap the template for the generated declarations, keeping the export keyword
212
- magic.overwrite(matchIndex, matchIndex + match[0].length, statements.join('\n'))
223
+ magic.overwrite(matchIndex, matchIndex + match[0].length, replacement)
213
224
  }
214
225
 
215
226
  const template = magic.toString()
@@ -217,13 +228,44 @@ export function collect(source: string, id: string, config: PluginConfig) {
217
228
  const map = matched
218
229
  ? magic.generateMap({ source: id, includeContent: true, hires: true })
219
230
  : null
220
- const result = { source, config: configKey, template, tags, map }
231
+ const result = { source, config: configKey, template, tags, sheets, map }
221
232
 
222
233
  collectCache.set(id, result)
223
234
 
224
235
  return result
225
236
  }
226
237
 
238
+ /**
239
+ * Write generated styles next to their source file and return relative import specifiers.
240
+ */
241
+ export function writeStyleSheets(filePath: string, sheets: StyleSheet[]) {
242
+ if (sheets.length === 0) return []
243
+
244
+ const dir = path.join(path.dirname(filePath), '.cxx')
245
+ mkdirSync(dir, { recursive: true })
246
+
247
+ return sheets.map(sheet => {
248
+ writeFileSync(path.join(dir, `${sheet.hash}.css`), sheet.css)
249
+ return `./.cxx/${sheet.hash}.css`
250
+ })
251
+ }
252
+
253
+ /**
254
+ * Insert style imports after any leading module directive.
255
+ */
256
+ export function injectStyleImports(template: string, imports: string[]) {
257
+ if (imports.length === 0) return template
258
+
259
+ const directive =
260
+ /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*(['"])use (?:client|server)\1;?/.exec(template)
261
+ const at = directive ? directive[0].length : 0
262
+ const lines = imports
263
+ .map(specifier => `import ${JSON.stringify(specifier)};`)
264
+ .join('\n')
265
+
266
+ return `${template.slice(0, at)}\n${lines}\n${template.slice(at)}`
267
+ }
268
+
227
269
  /**
228
270
  * Render the generated declaration file contents for a set of collected tags.
229
271
  */
@@ -447,11 +489,36 @@ export async function refreshFile(file: string, config: PluginConfig) {
447
489
  await writeTypesForFile(file, tags)
448
490
  }
449
491
 
492
+ /**
493
+ * Remove orphaned `.tmp` files left by a write that was killed before its rename. Only the seed
494
+ * does this, because it runs once at startup before any write is in flight.
495
+ */
496
+ async function sweepTmpFiles() {
497
+ for (const dir of [GENERATED_DIR, PARTIAL_DIR]) {
498
+ let entries: string[]
499
+
500
+ try {
501
+ entries = await fs.readdir(dir)
502
+ } catch (err) {
503
+ if (isENOENT(err)) continue
504
+ throw err
505
+ }
506
+
507
+ for (const entry of entries) {
508
+ if (entry.endsWith('.tmp')) {
509
+ await fs.rm(path.join(dir, entry), { force: true })
510
+ }
511
+ }
512
+ }
513
+ }
514
+
450
515
  /**
451
516
  * Walk every source file under `root` and seed the type surface, so a fresh checkout (and a build
452
517
  * with no edits) has types without waiting for a transform.
453
518
  */
454
519
  export async function seedTypes(root: string, config: PluginConfig) {
520
+ await sweepTmpFiles()
521
+
455
522
  async function scan(dir: string) {
456
523
  let entries: Dirent[]
457
524
 
package/src/index.ts CHANGED
@@ -1,6 +1,23 @@
1
- import type { Cxx, Result } from './types.js'
1
+ import type {
2
+ Cxx,
3
+ EmbedResult,
4
+ EmbedStyleTag,
5
+ ExtractResult,
6
+ ExtractStyleTag,
7
+ } from './types.js'
2
8
 
3
- export type { Cxx, Result, Tag } from './types.js'
9
+ export { Style } from './style.js'
10
+ export type {
11
+ Cxx,
12
+ EmbedResult,
13
+ EmbedStyleDescriptor,
14
+ EmbedStyleTag,
15
+ ExtractResult,
16
+ ExtractStyleTag,
17
+ Result,
18
+ StyleMode,
19
+ StyleProps,
20
+ } from './types.js'
4
21
 
5
22
  /**
6
23
  * Hit when a `cxx` template reaches runtime, which means the build transform did
@@ -9,8 +26,8 @@ export type { Cxx, Result, Tag } from './types.js'
9
26
  * @example
10
27
  * ```ts
11
28
  * // no cxx plugin configured
12
- * const [css, styles, href] = cxx`.a { color: red; }`
13
- * // Error: cxx template was not transformed ...
29
+ * const button = cxx.embed`.a { color: red; }`
30
+ * // error: cxx template was not transformed ...
14
31
  * ```
15
32
  */
16
33
  function untransformed(): never {
@@ -19,15 +36,25 @@ function untransformed(): never {
19
36
  )
20
37
  }
21
38
 
39
+ function tag<_ClassName extends string = string>(_: string) {
40
+ return (_strings: TemplateStringsArray): never => untransformed()
41
+ }
42
+
43
+ function embedTag(): EmbedStyleTag {
44
+ return Object.assign((_: TemplateStringsArray): EmbedResult => untransformed(), { tag })
45
+ }
46
+
47
+ function extractTag(): ExtractStyleTag {
48
+ return Object.assign((_: TemplateStringsArray): ExtractResult => untransformed(), {
49
+ tag,
50
+ })
51
+ }
52
+
53
+ const embed = embedTag()
54
+ const extract = extractTag()
55
+
22
56
  /**
23
- * Build-time template tag placeholder used by the transform to emit CSS, classes and href values.
57
+ * Build-time template tag namespace used by the transform to emit class maps
58
+ * and style descriptors.
24
59
  */
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
- )
60
+ export const cxx: Cxx = { embed, extract }
package/src/next/index.ts CHANGED
@@ -4,7 +4,13 @@ import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
6
  import type { PluginConfig } from '../types.js'
7
- import { collect, seedTypes, writeTypesForFile } from '../build.js'
7
+ import {
8
+ collect,
9
+ injectStyleImports,
10
+ seedTypes,
11
+ writeStyleSheets,
12
+ writeTypesForFile,
13
+ } from '../build.js'
8
14
  import { Logger } from '../logger.js'
9
15
 
10
16
  const typeLogger = new Logger('cxx')
@@ -29,10 +35,12 @@ export function processFile(
29
35
  }
30
36
 
31
37
  const result = collect(source, resolvedFilePath, pluginConfig)
38
+ const imports = writeStyleSheets(resolvedFilePath, result.sheets)
39
+ const template = injectStyleImports(result.template, imports)
32
40
 
33
41
  void writeTypesForFile(resolvedFilePath, result.tags)
34
42
 
35
- return result
43
+ return { ...result, template }
36
44
  }
37
45
 
38
46
  const TURBOPACK_GLOB = '*.{tsx,jsx,ts,js}'
package/src/style.tsx ADDED
@@ -0,0 +1,14 @@
1
+ import type { StyleProps } from './types.js'
2
+
3
+ /**
4
+ * Render an embedded style descriptor as a hoisted style element.
5
+ */
6
+ export function Style(props: StyleProps) {
7
+ const { mode: _mode, href, css, precedence, ...rest } = props
8
+
9
+ return (
10
+ <style {...rest} href={href} precedence={precedence}>
11
+ {css}
12
+ </style>
13
+ )
14
+ }
package/src/types.ts CHANGED
@@ -1,8 +1,38 @@
1
- export type Result<ClassName extends string = string> = readonly [
2
- css: string,
3
- classes: Readonly<Record<ClassName, string>>,
4
- href: string,
5
- ]
1
+ import type { ComponentPropsWithRef } from 'react'
2
+
3
+ export type StyleMode = 'embed' | 'extract'
4
+
5
+ export type EmbedStyleDescriptor = {
6
+ mode: 'embed'
7
+ href: string
8
+ css: string
9
+ precedence: string
10
+ }
11
+
12
+ export type EmbedResult<ClassName extends string = string> = {
13
+ mode: 'embed'
14
+ styles: Readonly<Record<ClassName, string>>
15
+ styleProps: EmbedStyleDescriptor
16
+ }
17
+
18
+ export type ExtractResult<ClassName extends string = string> = {
19
+ mode: 'extract'
20
+ styles: Readonly<Record<ClassName, string>>
21
+ }
22
+
23
+ export type Result<ClassName extends string = string> =
24
+ | EmbedResult<ClassName>
25
+ | ExtractResult<ClassName>
26
+
27
+ type StyleElementProps = Omit<
28
+ ComponentPropsWithRef<'style'>,
29
+ 'href' | 'precedence' | 'children'
30
+ > & {
31
+ href: string
32
+ precedence: string
33
+ }
34
+
35
+ export type StyleProps = EmbedStyleDescriptor & StyleElementProps
6
36
 
7
37
  export type Tags = Map<string, string[]>
8
38
 
@@ -14,10 +44,23 @@ export type PluginConfig = {
14
44
  }
15
45
  }
16
46
 
17
- export type Tag = <ClassName extends string = string>(
47
+ export type EmbedTag = <ClassName extends string = string>(
48
+ name: string,
49
+ ) => (_: TemplateStringsArray) => EmbedResult<ClassName>
50
+
51
+ export type ExtractTag = <ClassName extends string = string>(
18
52
  name: string,
19
- ) => (_: TemplateStringsArray) => Result<ClassName>
53
+ ) => (_: TemplateStringsArray) => ExtractResult<ClassName>
54
+
55
+ export type EmbedStyleTag = ((_: TemplateStringsArray) => EmbedResult) & {
56
+ tag: EmbedTag
57
+ }
58
+
59
+ export type ExtractStyleTag = ((_: TemplateStringsArray) => ExtractResult) & {
60
+ tag: ExtractTag
61
+ }
20
62
 
21
- export type Cxx = ((_: TemplateStringsArray) => Result) & {
22
- tag: Tag
63
+ export type Cxx = {
64
+ embed: EmbedStyleTag
65
+ extract: ExtractStyleTag
23
66
  }
package/src/vite/index.ts CHANGED
@@ -7,9 +7,11 @@ import type { PluginConfig } from '../types.js'
7
7
  import {
8
8
  collect,
9
9
  flushTypes,
10
+ injectStyleImports,
10
11
  refreshFile,
11
12
  seedTypes,
12
13
  SOURCE_EXTENSIONS,
14
+ writeStyleSheets,
13
15
  writeTypesForFile,
14
16
  } from '../build.js'
15
17
  import { GENERATED_DIR } from '../config.js'
@@ -121,15 +123,19 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
121
123
 
122
124
  if (!SOURCE_EXTENSIONS.has(path.extname(cleanId))) return null
123
125
 
124
- const { template, tags, map } = collect(code, cleanId, pluginConfig)
126
+ const result = collect(code, cleanId, pluginConfig)
125
127
 
126
- if (template === code) return null
128
+ if (result.template === code) return null
127
129
 
128
- void writeTypesForFile(resolveWatchFile(cleanId), tags)
130
+ const file = resolveWatchFile(cleanId)
131
+ const imports = writeStyleSheets(file, result.sheets)
132
+ const template = injectStyleImports(result.template, imports)
133
+
134
+ void writeTypesForFile(file, result.tags)
129
135
 
130
136
  return {
131
137
  code: template,
132
- map,
138
+ map: result.map,
133
139
  }
134
140
  },
135
141
  configureServer(server: ViteDevServer) {