@jk2908/cxx 0.3.1 → 0.3.4
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 +0 -3
- package/dist/build.d.ts +19 -11
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +204 -82
- package/dist/build.js.map +6 -5
- package/dist/logger.d.ts +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/next/cxx-loader.cjs +109 -58
- package/dist/next/cxx-loader.cjs.map +7 -7
- package/dist/next/index.d.ts +5 -3
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +219 -107
- package/dist/next/index.js.map +7 -6
- package/dist/types.d.ts +1 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +0 -16
- package/dist/utils.d.ts.map +1 -1
- package/dist/vite/index.d.ts +1 -7
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +240 -247
- package/dist/vite/index.js.map +7 -7
- package/package.json +6 -6
- package/src/build.ts +202 -73
- package/src/logger.ts +2 -7
- package/src/next/cxx-loader.js +8 -1
- package/src/next/index.ts +25 -27
- package/src/types.ts +1 -7
- package/src/utils.ts +0 -79
- package/src/vite/index.ts +60 -100
package/src/next/cxx-loader.js
CHANGED
|
@@ -5,7 +5,14 @@ const loader = function (source) {
|
|
|
5
5
|
const { pluginConfig } = this.getOptions()
|
|
6
6
|
const logger = new Logger()
|
|
7
7
|
|
|
8
|
-
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
|
+
}
|
|
9
16
|
|
|
10
17
|
return template
|
|
11
18
|
}
|
package/src/next/index.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { AsyncSubscription, Event } from '@parcel/watcher'
|
|
2
2
|
import type { NextConfig } from 'next'
|
|
3
3
|
|
|
4
|
-
import fs from 'node:fs/promises'
|
|
5
4
|
import path from 'node:path'
|
|
6
5
|
import { fileURLToPath } from 'node:url'
|
|
7
6
|
|
|
8
|
-
import type { Logger } from '../logger.js'
|
|
9
7
|
import type { PluginConfig } from '../types.js'
|
|
10
|
-
import { collect,
|
|
8
|
+
import { collect, refreshFile, seedTypes, writeTypesForFile } from '../build.js'
|
|
11
9
|
import { GENERATED_DIR } from '../config.js'
|
|
10
|
+
import { Logger } from '../logger.js'
|
|
11
|
+
|
|
12
|
+
const typeLogger = new Logger('cxx')
|
|
12
13
|
|
|
13
14
|
const nextDir = path.dirname(fileURLToPath(import.meta.url))
|
|
14
15
|
const loader = path.join(nextDir, 'cxx-loader.cjs')
|
|
@@ -38,27 +39,10 @@ async function onEvent(event: Event['type'], filePath: string) {
|
|
|
38
39
|
|
|
39
40
|
if (event === 'delete') {
|
|
40
41
|
await writeTypesForFile(filePath, new Map())
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
let source: string
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
source = await fs.readFile(filePath, 'utf-8')
|
|
50
|
-
} catch (err) {
|
|
51
|
-
if (!isENOENT(err)) throw err
|
|
52
|
-
|
|
53
|
-
await writeTypesForFile(filePath, new Map())
|
|
54
|
-
logger.info(`updated types from ${filePath}`)
|
|
55
|
-
|
|
56
|
-
return
|
|
42
|
+
} else {
|
|
43
|
+
await refreshFile(filePath, pluginConfig)
|
|
57
44
|
}
|
|
58
45
|
|
|
59
|
-
const { tags } = collect(source, filePath, pluginConfig)
|
|
60
|
-
|
|
61
|
-
await writeTypesForFile(filePath, tags)
|
|
62
46
|
logger.info(`updated types from ${filePath}`)
|
|
63
47
|
}
|
|
64
48
|
|
|
@@ -72,7 +56,6 @@ function ensureWatcher(pluginConfig: PluginConfig, logger: Logger) {
|
|
|
72
56
|
if (subscriptionPromise) return
|
|
73
57
|
|
|
74
58
|
const root = pluginConfig.watch?.root ?? process.cwd()
|
|
75
|
-
const ignore = [...DEFAULT_IGNORE, ...(pluginConfig.watch?.ignore ?? [])]
|
|
76
59
|
|
|
77
60
|
subscriptionPromise = (async () => {
|
|
78
61
|
// lazily imported so the native module is only loaded when watching is needed
|
|
@@ -96,7 +79,7 @@ function ensureWatcher(pluginConfig: PluginConfig, logger: Logger) {
|
|
|
96
79
|
})
|
|
97
80
|
}
|
|
98
81
|
},
|
|
99
|
-
{ ignore },
|
|
82
|
+
{ ignore: DEFAULT_IGNORE },
|
|
100
83
|
)
|
|
101
84
|
|
|
102
85
|
registerCleanup()
|
|
@@ -150,6 +133,11 @@ export function processFile(
|
|
|
150
133
|
) {
|
|
151
134
|
const resolvedFilePath = path.resolve(filePath)
|
|
152
135
|
|
|
136
|
+
// skip files that cannot contain a template, including node_modules
|
|
137
|
+
if (!source.includes('cxx') || /[\\/]node_modules[\\/]/.test(resolvedFilePath)) {
|
|
138
|
+
return { template: source, tags: new Map(), map: null }
|
|
139
|
+
}
|
|
140
|
+
|
|
153
141
|
if (process.env['NODE_ENV'] !== 'production') {
|
|
154
142
|
watchedFiles.set(resolvedFilePath, { pluginConfig, logger })
|
|
155
143
|
ensureWatcher(pluginConfig, logger)
|
|
@@ -157,9 +145,7 @@ export function processFile(
|
|
|
157
145
|
|
|
158
146
|
const result = collect(source, resolvedFilePath, pluginConfig)
|
|
159
147
|
|
|
160
|
-
void writeTypesForFile(resolvedFilePath, result.tags)
|
|
161
|
-
logger.error(`failed to write types for ${resolvedFilePath}`, err)
|
|
162
|
-
})
|
|
148
|
+
void writeTypesForFile(resolvedFilePath, result.tags)
|
|
163
149
|
|
|
164
150
|
return result
|
|
165
151
|
}
|
|
@@ -170,6 +156,18 @@ export function withCxx(nextConfig: NextConfig = {}, pluginConfig: PluginConfig
|
|
|
170
156
|
const watchRoot =
|
|
171
157
|
pluginConfig.watch?.root ?? nextConfig.turbopack?.root ?? process.cwd()
|
|
172
158
|
|
|
159
|
+
// Seed on dev startup only, matching Vite. A production build is covered by the loader, and
|
|
160
|
+
// `next start` must not scan or write at runtime.
|
|
161
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
162
|
+
// the app directory, not the whole Turbopack root, so sibling apps in a monorepo do not
|
|
163
|
+
// leak into this app's types
|
|
164
|
+
const seedRoot = pluginConfig.watch?.root ?? process.cwd()
|
|
165
|
+
|
|
166
|
+
void seedTypes(seedRoot, pluginConfig).catch(err =>
|
|
167
|
+
typeLogger.error('failed to seed cxx types', err),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
173
171
|
const loaderItem = {
|
|
174
172
|
loader,
|
|
175
173
|
options: {
|
package/src/types.ts
CHANGED
|
@@ -9,17 +9,11 @@ export type Tags = Map<string, string[]>
|
|
|
9
9
|
export type PluginConfig = {
|
|
10
10
|
typeSuffix?: string | false
|
|
11
11
|
watch?: {
|
|
12
|
-
|
|
12
|
+
/** Root used for type-regeneration watching. Defaults to the Vite root or Next Turbopack root. */
|
|
13
13
|
root?: string
|
|
14
|
-
// extra globs to ignore on top of the defaults
|
|
15
|
-
ignore?: string[]
|
|
16
14
|
}
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
export type BuildContext = {
|
|
20
|
-
tagsByFile: Map<string, Tags>
|
|
21
|
-
}
|
|
22
|
-
|
|
23
17
|
export type Tag = <ClassName extends string = string>(
|
|
24
18
|
name: string,
|
|
25
19
|
) => (_: TemplateStringsArray) => Result<ClassName>
|
package/src/utils.ts
CHANGED
|
@@ -8,82 +8,3 @@ export function pascalise(str: string) {
|
|
|
8
8
|
.map(capitalise)
|
|
9
9
|
.join('')
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
export type Batcher<K, V> = {
|
|
13
|
-
/**
|
|
14
|
-
* Queue a value for a key. Queueing the same key again replaces the previous value.
|
|
15
|
-
*/
|
|
16
|
-
add: (key: K, value: V) => void
|
|
17
|
-
/**
|
|
18
|
-
* Run any waiting work now and resolve once it has finished.
|
|
19
|
-
*/
|
|
20
|
-
flush: () => Promise<void>
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Group bursts of work and run them together. Every key queued within `wait` is
|
|
25
|
-
* included once, and a key queued while a batch is running is handled in the next
|
|
26
|
-
* batch instead of replacing the one in progress.
|
|
27
|
-
*/
|
|
28
|
-
export function createBatcher<K, V>(
|
|
29
|
-
wait: number,
|
|
30
|
-
flush: (batch: Map<K, V>) => void | Promise<void>,
|
|
31
|
-
): Batcher<K, V> {
|
|
32
|
-
// work waiting to run, keyed so each key is only queued once
|
|
33
|
-
const pending = new Map<K, V>()
|
|
34
|
-
// waits for a burst of changes to settle before running
|
|
35
|
-
let timer: ReturnType<typeof setTimeout> | null = null
|
|
36
|
-
// true while a batch is running, so we never start a second one
|
|
37
|
-
let running = false
|
|
38
|
-
|
|
39
|
-
async function drain() {
|
|
40
|
-
// a run is already going, so let it pick up the new work
|
|
41
|
-
if (running) return
|
|
42
|
-
running = true
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
// keep looping while there is work, including work added during a flush
|
|
46
|
-
while (pending.size > 0) {
|
|
47
|
-
// snapshot the queued work
|
|
48
|
-
const batch = new Map(pending)
|
|
49
|
-
// empty the queue right away, so anything added during flush waits for the next loop
|
|
50
|
-
pending.clear()
|
|
51
|
-
|
|
52
|
-
await flush(batch)
|
|
53
|
-
}
|
|
54
|
-
} finally {
|
|
55
|
-
// always allow the next run, even if flush threw
|
|
56
|
-
running = false
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function schedule() {
|
|
61
|
-
// cancel the previous wait, so the timer restarts from now
|
|
62
|
-
if (timer) clearTimeout(timer)
|
|
63
|
-
|
|
64
|
-
// run once the calls stop coming for `wait` ms
|
|
65
|
-
timer = setTimeout(() => {
|
|
66
|
-
// the timer has fired, so drop the stale handle
|
|
67
|
-
timer = null
|
|
68
|
-
// start the run without waiting for it (this is a timer callback)
|
|
69
|
-
void drain()
|
|
70
|
-
}, wait)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
add(key, value) {
|
|
75
|
-
// the newest value for this key wins
|
|
76
|
-
pending.set(key, value)
|
|
77
|
-
schedule()
|
|
78
|
-
},
|
|
79
|
-
flush() {
|
|
80
|
-
// run now instead of waiting out the timer
|
|
81
|
-
if (timer) {
|
|
82
|
-
clearTimeout(timer)
|
|
83
|
-
timer = null
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return drain()
|
|
87
|
-
},
|
|
88
|
-
}
|
|
89
|
-
}
|
package/src/vite/index.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs'
|
|
2
|
-
import fs from 'node:fs/promises'
|
|
3
2
|
import path from 'node:path'
|
|
4
3
|
|
|
5
4
|
import { type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'
|
|
6
5
|
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
6
|
+
import type { PluginConfig } from '../types.js'
|
|
7
|
+
import {
|
|
8
|
+
collect,
|
|
9
|
+
flushTypes,
|
|
10
|
+
refreshFile,
|
|
11
|
+
seedTypes,
|
|
12
|
+
SOURCE_EXTENSIONS,
|
|
13
|
+
writeTypesForFile,
|
|
14
|
+
} from '../build.js'
|
|
9
15
|
import { GENERATED_DIR } from '../config.js'
|
|
10
16
|
import { Logger } from '../logger.js'
|
|
11
|
-
import { createBatcher } from '../utils.js'
|
|
12
|
-
|
|
13
|
-
const ACCEPTED_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx'])
|
|
14
17
|
|
|
15
18
|
type WatchEvent = 'add' | 'change' | 'unlink'
|
|
16
19
|
|
|
@@ -19,38 +22,44 @@ function normaliseWatchPath(p: string) {
|
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export default function cxx(pluginConfig: PluginConfig = {}) {
|
|
22
|
-
const buildContext = {
|
|
23
|
-
tagsByFile: new Map(),
|
|
24
|
-
} satisfies BuildContext
|
|
25
|
-
|
|
26
25
|
const logger = new Logger()
|
|
27
26
|
|
|
28
27
|
// resolved from Vite's root in `configResolved`, not from `process.cwd()`
|
|
29
28
|
let watchRoot = normaliseWatchPath(process.cwd())
|
|
29
|
+
let seeded = false
|
|
30
|
+
|
|
31
|
+
// realpath is synchronous, so cache it per directory to keep it off the transform hot path
|
|
32
|
+
const realpaths = new Map<string, string>()
|
|
30
33
|
|
|
31
34
|
function resolveWatchFile(filePath: string) {
|
|
32
35
|
const absolutePath = path.resolve(watchRoot, filePath)
|
|
33
36
|
const parentPath = path.dirname(absolutePath)
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
let resolvedParentPath = realpaths.get(parentPath)
|
|
39
|
+
|
|
40
|
+
if (resolvedParentPath === undefined) {
|
|
41
|
+
try {
|
|
42
|
+
resolvedParentPath = normaliseWatchPath(realpathSync.native(parentPath))
|
|
43
|
+
} catch {
|
|
44
|
+
resolvedParentPath = normaliseWatchPath(parentPath)
|
|
45
|
+
}
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
path.join(resolvedParentPath, path.basename(absolutePath)),
|
|
40
|
-
)
|
|
41
|
-
} catch {
|
|
42
|
-
return normaliseWatchPath(absolutePath)
|
|
47
|
+
realpaths.set(parentPath, resolvedParentPath)
|
|
43
48
|
}
|
|
49
|
+
|
|
50
|
+
return normaliseWatchPath(path.join(resolvedParentPath, path.basename(absolutePath)))
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Returns the canonical, watchable path, or null when the file is irrelevant.
|
|
55
|
+
*/
|
|
56
|
+
function watchedFile(filePath: string) {
|
|
48
57
|
const resolvedPath = resolveWatchFile(filePath)
|
|
49
58
|
|
|
50
59
|
if (
|
|
51
60
|
resolvedPath.startsWith(`${watchRoot}/`) &&
|
|
52
61
|
!resolvedPath.includes(`/${GENERATED_DIR}/`) &&
|
|
53
|
-
|
|
62
|
+
SOURCE_EXTENSIONS.has(path.extname(resolvedPath))
|
|
54
63
|
) {
|
|
55
64
|
return resolvedPath
|
|
56
65
|
}
|
|
@@ -58,78 +67,37 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
|
|
|
58
67
|
return null
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
async function
|
|
62
|
-
const hadTags = buildContext.tagsByFile.has(file)
|
|
63
|
-
|
|
70
|
+
async function handleEvent(event: WatchEvent, file: string) {
|
|
64
71
|
if (event === 'unlink') {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
buildContext.tagsByFile.delete(file)
|
|
68
|
-
await writeTypes(flattenTags(buildContext.tagsByFile))
|
|
69
|
-
logger.info(`updated types from ${file}`)
|
|
70
|
-
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let source: string
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
source = await fs.readFile(file, 'utf-8')
|
|
78
|
-
} catch (err) {
|
|
79
|
-
if (!isENOENT(err)) throw err
|
|
80
|
-
if (!hadTags) return
|
|
81
|
-
|
|
82
|
-
buildContext.tagsByFile.delete(file)
|
|
83
|
-
await writeTypes(flattenTags(buildContext.tagsByFile))
|
|
84
|
-
logger.info(`updated types from ${file}`)
|
|
85
|
-
|
|
86
|
-
return
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const { tags } = collect(source, file, pluginConfig)
|
|
90
|
-
const hasTags = tags.size > 0
|
|
91
|
-
|
|
92
|
-
if (hasTags) {
|
|
93
|
-
buildContext.tagsByFile.set(file, tags)
|
|
72
|
+
await writeTypesForFile(file, new Map())
|
|
94
73
|
} else {
|
|
95
|
-
|
|
74
|
+
await refreshFile(file, pluginConfig)
|
|
96
75
|
}
|
|
97
76
|
|
|
98
|
-
if (!hadTags && !hasTags) return
|
|
99
|
-
|
|
100
|
-
await writeTypes(flattenTags(buildContext.tagsByFile))
|
|
101
77
|
logger.info(`updated types from ${file}`)
|
|
102
78
|
}
|
|
103
79
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
await onEvent(event, file)
|
|
111
|
-
} catch (err) {
|
|
80
|
+
function onWatch(event: WatchEvent) {
|
|
81
|
+
return (p: string) => {
|
|
82
|
+
const file = watchedFile(p)
|
|
83
|
+
if (!file) return
|
|
84
|
+
|
|
85
|
+
void handleEvent(event, file).catch(err => {
|
|
112
86
|
logger.error(`failed to update types from ${file}`, err)
|
|
113
|
-
}
|
|
87
|
+
})
|
|
114
88
|
}
|
|
115
|
-
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function seed() {
|
|
92
|
+
if (seeded) return
|
|
93
|
+
seeded = true
|
|
94
|
+
|
|
95
|
+
await seedTypes(watchRoot, pluginConfig)
|
|
96
|
+
}
|
|
116
97
|
|
|
117
98
|
return {
|
|
118
99
|
name: 'cxx',
|
|
119
100
|
enforce: 'pre',
|
|
120
|
-
config() {
|
|
121
|
-
const ignore = pluginConfig.watch?.ignore
|
|
122
|
-
if (!ignore?.length) return null
|
|
123
|
-
|
|
124
|
-
// Vite concatenates `server.watch.ignored`, so only return the additions
|
|
125
|
-
return {
|
|
126
|
-
server: {
|
|
127
|
-
watch: {
|
|
128
|
-
ignored: ignore,
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
101
|
configResolved(config: ResolvedConfig) {
|
|
134
102
|
const root = pluginConfig.watch?.root ?? config.root
|
|
135
103
|
|
|
@@ -138,6 +106,11 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
|
|
|
138
106
|
} catch {
|
|
139
107
|
watchRoot = normaliseWatchPath(root)
|
|
140
108
|
}
|
|
109
|
+
|
|
110
|
+
realpaths.clear()
|
|
111
|
+
},
|
|
112
|
+
async buildStart() {
|
|
113
|
+
await seed()
|
|
141
114
|
},
|
|
142
115
|
transform(code, id) {
|
|
143
116
|
if (id.startsWith('\0') || id.includes('/node_modules/')) return null
|
|
@@ -145,18 +118,14 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
|
|
|
145
118
|
if (!code.includes('cxx')) return null
|
|
146
119
|
|
|
147
120
|
const cleanId = id.split('?')[0]
|
|
148
|
-
|
|
121
|
+
|
|
122
|
+
if (!SOURCE_EXTENSIONS.has(path.extname(cleanId))) return null
|
|
149
123
|
|
|
150
124
|
const { template, tags, map } = collect(code, cleanId, pluginConfig)
|
|
151
|
-
if (template === code) return null
|
|
152
125
|
|
|
153
|
-
|
|
126
|
+
if (template === code) return null
|
|
154
127
|
|
|
155
|
-
|
|
156
|
-
buildContext.tagsByFile.set(resolved, tags)
|
|
157
|
-
} else {
|
|
158
|
-
buildContext.tagsByFile.delete(resolved)
|
|
159
|
-
}
|
|
128
|
+
void writeTypesForFile(resolveWatchFile(cleanId), tags)
|
|
160
129
|
|
|
161
130
|
return {
|
|
162
131
|
code: template,
|
|
@@ -167,21 +136,12 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
|
|
|
167
136
|
logger.info('Watching for changes...')
|
|
168
137
|
|
|
169
138
|
server.watcher
|
|
170
|
-
.on('add', (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
})
|
|
174
|
-
.on('change', (p: string) => {
|
|
175
|
-
const file = watchedFile(p)
|
|
176
|
-
if (file) queue.add(file, 'change')
|
|
177
|
-
})
|
|
178
|
-
.on('unlink', (p: string) => {
|
|
179
|
-
const file = watchedFile(p)
|
|
180
|
-
if (file) queue.add(file, 'unlink')
|
|
181
|
-
})
|
|
139
|
+
.on('add', onWatch('add'))
|
|
140
|
+
.on('change', onWatch('change'))
|
|
141
|
+
.on('unlink', onWatch('unlink'))
|
|
182
142
|
},
|
|
183
143
|
async writeBundle() {
|
|
184
|
-
await
|
|
144
|
+
await flushTypes()
|
|
185
145
|
},
|
|
186
146
|
} satisfies Plugin
|
|
187
147
|
}
|