@typed/vite-plugin 0.0.11 → 0.0.13

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.
@@ -1,164 +1,475 @@
1
1
  import { existsSync } from 'fs'
2
- import { join, resolve } from 'path'
2
+ import { readFile } from 'fs/promises'
3
+ import { basename, dirname, join, relative, resolve } from 'path'
3
4
 
4
- /// <reference types="vavite/vite-config" />
5
-
6
- import { setupTsProject, scanSourceFiles, buildEntryPoint } from '@typed/compiler'
7
- import { Environment } from '@typed/html'
8
- import { Project, ts } from 'ts-morph'
9
- // @ts-expect-error Types don't seem to work with ESNext module resolution
10
- import { default as vavite } from 'vavite'
11
- import { Plugin } from 'vite'
5
+ import {
6
+ setupTsProject,
7
+ makeBrowserModule,
8
+ makeHtmlModule,
9
+ makeRuntimeModule,
10
+ readDirectory,
11
+ readModules,
12
+ } from '@typed/compiler'
13
+ import glob from 'fast-glob'
14
+ import { Project, SourceFile, ts } from 'ts-morph'
15
+ // @ts-expect-error Unable to resolve types w/ NodeNext
16
+ import vavite from 'vavite'
17
+ import { ConfigEnv, Plugin, PluginOption, UserConfig, ViteDevServer } from 'vite'
18
+ import compression from 'vite-plugin-compression'
12
19
  import tsconfigPaths from 'vite-tsconfig-paths'
13
20
 
21
+ /**
22
+ * The Configuration for the Typed Plugin. All file paths can be relative to sourceDirectory or
23
+ * can be absolute, path.resolve is used to stitch things together.
24
+ */
14
25
  export interface PluginOptions {
15
26
  /**
16
- * The directory in which you have your entry files. Namely an index.html file and optionally a server.ts file
27
+ * The directory in which you have your application.
28
+ * This can be relative to the current working directory or absolute.
29
+ */
30
+ readonly sourceDirectory: string
31
+
32
+ /**
33
+ * The file path to your tsconfig.json file.
34
+ */
35
+ readonly tsConfig?: string
36
+
37
+ /**
38
+ * The file path to your server entry file
39
+ */
40
+ readonly serverFilePath?: string
41
+
42
+ /**
43
+ * The output directory for your client code
17
44
  */
18
- readonly directory: string
45
+ readonly clientOutputDirectory?: string
46
+
19
47
  /**
20
- * The name/path to your tsconfig.json file, relative to the directory above or absolute
48
+ * The output directory for your server code
21
49
  */
22
- readonly tsConfig: string
50
+ readonly serverOutputDirectory?: string
51
+
23
52
  /**
24
- * File globs to scan for pages, relative to the directory above or absolute
53
+ * File globs to use to look for your HTML entry points.
25
54
  */
26
- readonly pages: readonly string[]
55
+ readonly htmlFileGlobs?: readonly string[]
27
56
  }
28
57
 
29
58
  const cwd = process.cwd()
30
59
 
31
60
  const PLUGIN_NAME = '@typed/vite-plugin'
32
- const BROWSER_VIRTUAL_ENTRYPOINT = 'virtual:browser-entry'
33
- const SERVER_VIRTUAL_ENTRYPOINT = 'virtual:server-entry'
34
61
 
35
- export default function makePlugin({ directory, tsConfig, pages }: PluginOptions): Plugin {
62
+ const RUNTIME_VIRTUAL_ENTRYPOINT_PREFIX = 'runtime'
63
+ const BROWSER_VIRTUAL_ENTRYPOINT_PREFIX = 'browser'
64
+ const HTML_VIRTUAL_ENTRYPOINT_PREFIX = 'html'
65
+
66
+ const VIRTUAL_ID_PREFIX = '\0'
67
+
68
+ export default function makePlugin({
69
+ sourceDirectory: directory,
70
+ tsConfig,
71
+ serverFilePath,
72
+ clientOutputDirectory,
73
+ serverOutputDirectory,
74
+ htmlFileGlobs,
75
+ }: PluginOptions): PluginOption[] {
76
+ // Resolved options
36
77
  const sourceDirectory = resolve(cwd, directory)
37
- const tsConfigFilePath = resolve(sourceDirectory, tsConfig)
78
+ const tsConfigFilePath = resolve(sourceDirectory, tsConfig ?? 'tsconfig.json')
79
+ const resolvedServerFilePath = resolve(sourceDirectory, serverFilePath ?? 'server.ts')
80
+ const resolvedServerOutputDirectory = resolve(
81
+ sourceDirectory,
82
+ serverOutputDirectory ?? 'dist/server',
83
+ )
84
+ const resolvedClientOutputDirectory = resolve(
85
+ sourceDirectory,
86
+ clientOutputDirectory ?? 'dist/client',
87
+ )
38
88
 
39
- console.info(`[${PLUGIN_NAME}]: Setting up typescript project...`)
40
- const project = setupTsProject(tsConfigFilePath)
89
+ const virtualIds = new Set<string>()
90
+ const dependentsMap = new Map<string, Set<string>>()
91
+ const sourceFilePathToVirtualId = new Map<string, string>()
92
+ let devServer: ViteDevServer
93
+ let project: Project
41
94
 
42
- const BROWSER_VIRTUAL_ID = '\0' + join(sourceDirectory, 'browser.ts')
43
- const SERVER_VIRTUAL_ID = '\0' + join(sourceDirectory, 'server.ts')
95
+ const serverExists = existsSync(resolvedServerFilePath)
44
96
 
45
- const indexHtmlFilePath = join(sourceDirectory, 'index.html')
97
+ const plugins: PluginOption[] = [
98
+ tsconfigPaths({
99
+ projects: [join(sourceDirectory, 'tsconfig.json')],
100
+ }),
101
+ ...(serverExists
102
+ ? [
103
+ vavite({
104
+ serverEntry: resolvedServerFilePath,
105
+ serveClientAssetsInDev: true,
106
+ }),
107
+ ]
108
+ : []),
109
+ ]
110
+
111
+ const setupProject = () => {
112
+ info(`Setting up TypeScript project...`)
113
+ const project = setupTsProject(tsConfigFilePath)
114
+ info(`Setup TypeScript project.`)
46
115
 
47
- if (!existsSync(indexHtmlFilePath)) {
48
- throw new Error(`[${PLUGIN_NAME}]: Could not find index.html file at ${indexHtmlFilePath}`)
116
+ return project
49
117
  }
50
118
 
51
- const serverFilePath = join(sourceDirectory, 'server.ts')
52
- const serverExists = existsSync(serverFilePath)
119
+ const handleFileChange = async (path: string, event: 'create' | 'update' | 'delete') => {
120
+ if (/\.tsx?$/.test(path)) {
121
+ switch (event) {
122
+ case 'create': {
123
+ project.addSourceFileAtPath(path)
124
+ break
125
+ }
126
+ case 'update': {
127
+ const sourceFile = project.getSourceFile(path)
53
128
 
54
- return {
55
- name: PLUGIN_NAME,
56
- config(config) {
57
- if (!config.plugins) {
58
- config.plugins = []
129
+ if (sourceFile) {
130
+ sourceFile.refreshFromFileSystemSync()
131
+ } else {
132
+ project.addSourceFileAtPath(path)
133
+ }
134
+
135
+ if (devServer) {
136
+ const dependents = dependentsMap.get(path.replace(/.ts(x)?/, '.js$1'))
137
+
138
+ for (const dependent of dependents ?? []) {
139
+ const id = sourceFilePathToVirtualId.get(dependent) ?? dependent
140
+ const mod = devServer.moduleGraph.getModuleById(id)
141
+
142
+ if (mod) {
143
+ info(`reloading ${id}`)
144
+
145
+ await devServer.reloadModule(mod)
146
+ }
147
+ }
148
+ }
149
+
150
+ break
151
+ }
152
+ case 'delete': {
153
+ await project.getSourceFile(path)?.deleteImmediately()
154
+ break
155
+ }
59
156
  }
157
+ }
158
+ }
60
159
 
61
- config.plugins.push(
62
- tsconfigPaths({
63
- projects: [tsConfigFilePath],
64
- }),
65
- ...(serverExists
66
- ? [
67
- vavite({
68
- serverEntry: serverFilePath,
69
- serveClientAssetsInDev: true,
70
- }),
71
- ]
72
- : []),
73
- )
160
+ const virtualModulePlugin = {
161
+ name: PLUGIN_NAME,
74
162
 
75
- // Setup vavite multi-build
163
+ config(config: UserConfig, env: ConfigEnv) {
164
+ // Configure Build steps when running with vavite
165
+ if (env.mode === 'multibuild') {
166
+ const clientBuild: UserConfig['build'] = {
167
+ outDir: resolvedClientOutputDirectory,
168
+ rollupOptions: {
169
+ input: buildClientInput(
170
+ findHtmlFiles(sourceDirectory, htmlFileGlobs).map((p) => resolve(sourceDirectory, p)),
171
+ ),
172
+ },
173
+ }
174
+
175
+ const serverBuild: UserConfig['build'] = {
176
+ ssr: true,
177
+ outDir: resolvedServerOutputDirectory,
178
+ rollupOptions: {
179
+ input: resolvedServerFilePath,
180
+ },
181
+ }
76
182
 
77
- if (serverExists && !(config as any).buildSteps) {
78
183
  ;(config as any).buildSteps = [
79
184
  {
80
185
  name: 'client',
81
- config: {
82
- build: {
83
- outDir: 'dist/client',
84
- rollupOptions: { input: resolve(sourceDirectory, 'index.html') },
85
- },
86
- },
87
- },
88
- {
89
- name: 'server',
90
- config: {
91
- build: {
92
- ssr: true,
93
- outDir: 'dist/server',
94
- rollupOptions: { input: serverFilePath },
95
- },
96
- },
186
+ // @ts-expect-error Unable to resolve types w/ NodeNext
187
+ config: { build: clientBuild, plugins: [compression()] },
97
188
  },
189
+ ...(serverExists
190
+ ? [
191
+ {
192
+ name: 'server',
193
+ config: { build: serverBuild },
194
+ },
195
+ ]
196
+ : []),
98
197
  ]
198
+
199
+ return
99
200
  }
100
201
  },
101
202
 
102
- async resolveId(id, importer) {
103
- if (id === BROWSER_VIRTUAL_ENTRYPOINT) {
104
- return BROWSER_VIRTUAL_ID
203
+ configureServer(server) {
204
+ devServer = server
205
+
206
+ server.watcher.on('all', (event, path) => {
207
+ if (event === 'change') {
208
+ handleFileChange(path, 'update')
209
+ } else if (event === 'add') {
210
+ handleFileChange(path, 'create')
211
+ } else if (event === 'unlink') {
212
+ handleFileChange(path, 'delete')
213
+ }
214
+ })
215
+ },
216
+
217
+ async watchChange(path, { event }) {
218
+ handleFileChange(path, event)
219
+ },
220
+
221
+ closeBundle() {
222
+ if (project) {
223
+ const diagnostics = project.getPreEmitDiagnostics()
224
+
225
+ if (diagnostics.length > 0) {
226
+ this.error(project.formatDiagnosticsWithColorAndContext(diagnostics))
227
+ }
105
228
  }
229
+ },
230
+
231
+ async resolveId(id: string, importer?: string) {
232
+ if (id.startsWith(RUNTIME_VIRTUAL_ENTRYPOINT_PREFIX)) {
233
+ const virtualId =
234
+ VIRTUAL_ID_PREFIX + `${importer}?modules=${parseModulesFromId(id, importer)}`
106
235
 
107
- if (id === SERVER_VIRTUAL_ENTRYPOINT) {
108
- return SERVER_VIRTUAL_ID
236
+ virtualIds.add(virtualId)
237
+
238
+ return virtualId
109
239
  }
110
240
 
111
- // Virtual modules have problems with resolving modules due to not having a real directory to work with
112
- // thus the need to resolve them manually.
113
- if (importer === BROWSER_VIRTUAL_ID || importer === SERVER_VIRTUAL_ID) {
114
- // If a relative path, attempt to match to a source .ts(x) file
115
- if (id.startsWith('.')) {
116
- const tsPath = resolve(sourceDirectory, id.replace(/.js(x)?$/, '.ts$1'))
241
+ if (id.startsWith(BROWSER_VIRTUAL_ENTRYPOINT_PREFIX)) {
242
+ const virtualId =
243
+ VIRTUAL_ID_PREFIX + `${importer}?modules=${parseModulesFromId(id, importer)}&browser`
117
244
 
118
- if (existsSync(tsPath)) {
119
- return tsPath
120
- }
245
+ virtualIds.add(virtualId)
121
246
 
122
- const jsPath = resolve(sourceDirectory, id)
247
+ return virtualId
248
+ }
123
249
 
124
- if (existsSync(jsPath)) {
125
- return tsPath
126
- }
127
- }
250
+ if (id.startsWith(HTML_VIRTUAL_ENTRYPOINT_PREFIX)) {
251
+ const virtualId =
252
+ VIRTUAL_ID_PREFIX + `${importer}?source=${parseModulesFromId(id, importer)}`
253
+
254
+ virtualIds.add(virtualId)
255
+
256
+ return virtualId
128
257
  }
129
- },
130
258
 
131
- load(id) {
132
- if (id === BROWSER_VIRTUAL_ID) {
133
- return scanAndBuild(sourceDirectory, pages, project, 'browser')
259
+ // Virtual modules have problems with resolving relative paths due to not
260
+ // having a real directory to work with thus the need to resolve them manually.
261
+ if (importer?.startsWith(VIRTUAL_ID_PREFIX) && id.startsWith('.')) {
262
+ return findRelativeFile(importer, id)
134
263
  }
264
+ },
265
+
266
+ async load(id: string) {
267
+ if (virtualIds.has(id)) {
268
+ // Setup the TypeScript project if it hasn't been already
269
+ if (!project) {
270
+ project = setupProject()
271
+ }
272
+
273
+ // Build our virtual module as a SourceFile
274
+ const sourceFile: SourceFile = await buildVirtualModule(
275
+ project,
276
+ id,
277
+ sourceDirectory,
278
+ resolvedServerOutputDirectory,
279
+ resolvedClientOutputDirectory,
280
+ devServer,
281
+ )
282
+ const filePath = sourceFile.getFilePath()
283
+
284
+ sourceFilePathToVirtualId.set(filePath, id)
135
285
 
136
- if (id === SERVER_VIRTUAL_ID) {
137
- return scanAndBuild(sourceDirectory, pages, project, 'server')
286
+ // If we're in a development evnironment, we need to track the dependencies of
287
+ // our virtual module so we can reload them when they change.
288
+ if (devServer) {
289
+ const importModuleSpecifiers = await Promise.all(
290
+ sourceFile.getLiteralsReferencingOtherSourceFiles().map((l) => l.getLiteralText()),
291
+ )
292
+
293
+ for (const specifier of importModuleSpecifiers) {
294
+ let resolved =
295
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
296
+ (await virtualModulePlugin.resolveId!.apply(this, [specifier, filePath])) || specifier
297
+
298
+ if (resolved.startsWith('.')) {
299
+ resolved = resolve(dirname(filePath), resolved)
300
+ }
301
+
302
+ const current = dependentsMap.get(resolved) ?? new Set()
303
+
304
+ current.add(filePath)
305
+ dependentsMap.set(resolved, current)
306
+ }
307
+ }
308
+
309
+ const diagnostics = sourceFile.getPreEmitDiagnostics()
310
+ const relativeFilePath = relative(sourceDirectory, filePath)
311
+
312
+ if (diagnostics.length > 0) {
313
+ info(project.formatDiagnosticsWithColorAndContext(diagnostics))
314
+ info(sourceFile.getFullText())
315
+ } else {
316
+ info(`${relativeFilePath} virtual module successfuly typed-checked.`)
317
+ }
318
+
319
+ const output = ts.transpileModule(sourceFile.getFullText(), {
320
+ compilerOptions: project.getCompilerOptions(),
321
+ })
322
+
323
+ return {
324
+ code: output.outputText,
325
+ map: output.sourceMapText,
326
+ moduleSideEffects: false,
327
+ }
138
328
  }
139
329
  },
140
- }
330
+ } satisfies Plugin
331
+
332
+ plugins.push(virtualModulePlugin)
333
+
334
+ return plugins
141
335
  }
142
336
 
143
- function scanAndBuild(
144
- dir: string,
145
- pages: readonly string[],
337
+ async function buildVirtualModule(
146
338
  project: Project,
147
- environment: Environment,
339
+ id: string,
340
+ sourceDirectory: string,
341
+ serverOutputDirectory: string,
342
+ clientOutputDirectory: string,
343
+ devServer?: ViteDevServer,
148
344
  ) {
149
- const scanned = scanSourceFiles(
150
- pages.map((x) => join(dir, x)),
345
+ // Parse our virtual ID into the original importer and whatever query was passed to it
346
+ const [importer, query] = id.split(VIRTUAL_ID_PREFIX)[1].split('?')
347
+
348
+ // If the query is for a runtime module, read the directory and transform it into a module.
349
+ if (query.includes('modules=')) {
350
+ const moduleDirectory = resolve(dirname(importer), query.split('modules=')[1].split('&')[0])
351
+ const relativeDirectory = relative(sourceDirectory, moduleDirectory)
352
+ const directory = await readDirectory(moduleDirectory)
353
+ const moduleTree = readModules(project, directory)
354
+ const isBrowser = query.includes('&browser')
355
+
356
+ info(`Building ${isBrowser ? 'browser' : 'runtime'} module for ${relativeDirectory}...`)
357
+
358
+ const mod = isBrowser
359
+ ? makeBrowserModule(project, moduleTree, importer)
360
+ : makeRuntimeModule(project, moduleTree, importer)
361
+
362
+ info(`Built ${isBrowser ? 'browser' : 'runtime'} module for ${relativeDirectory}.`)
363
+
364
+ return mod
365
+ }
366
+
367
+ // If the query is for an HTML file, read the file and transform it into a module.
368
+
369
+ const htmlFile = query.split('source=')[1]
370
+ const htmlFilePath = resolve(dirname(importer), htmlFile + '.html')
371
+ const relativeHtmlFilePath = relative(sourceDirectory, htmlFilePath)
372
+ let html = ''
373
+
374
+ info(`Building html module for ${relativeHtmlFilePath}...`)
375
+
376
+ // If there's a dev server, use it to transform the HTML for development
377
+ if (devServer) {
378
+ html = (await readFile(htmlFilePath, 'utf-8')).toString()
379
+ html = await devServer.transformIndexHtml(getRelativePath(sourceDirectory, htmlFilePath), html)
380
+ } else {
381
+ // Otherwise, read the already transformed file from the output directory.
382
+ html = (
383
+ await readFile(
384
+ resolve(clientOutputDirectory, relative(sourceDirectory, htmlFilePath)),
385
+ 'utf-8',
386
+ )
387
+ ).toString()
388
+ }
389
+
390
+ const module = await makeHtmlModule({
151
391
  project,
152
- )
153
- const filePath = join(dir, `${environment}.ts`)
154
- const entryPoint = buildEntryPoint(scanned, project, environment, filePath)
155
- const output = ts.transpileModule(entryPoint.getFullText(), {
156
- fileName: entryPoint.getFilePath(),
157
- compilerOptions: project.getCompilerOptions(),
392
+ filePath: htmlFilePath,
393
+ html,
394
+ importer,
395
+ serverOutputDirectory,
396
+ clientOutputDirectory,
397
+ devServer,
398
+ })
399
+
400
+ info(`Built html module for ${relativeHtmlFilePath}.`)
401
+
402
+ return module
403
+ }
404
+
405
+ function findRelativeFile(importer: string, id: string) {
406
+ const dir = getVirtualSourceDirectory(importer)
407
+ const tsPath = resolve(dir, id.replace(/.js(x)?$/, '.ts$1'))
408
+
409
+ if (existsSync(tsPath)) {
410
+ return tsPath
411
+ }
412
+
413
+ const jsPath = resolve(dir, id)
414
+
415
+ if (existsSync(jsPath)) {
416
+ return tsPath
417
+ }
418
+ }
419
+
420
+ function getVirtualSourceDirectory(id: string) {
421
+ return dirname(id.split(VIRTUAL_ID_PREFIX)[1].split('?')[0])
422
+ }
423
+
424
+ function parseModulesFromId(id: string, importer: string | undefined): string {
425
+ const pages = id
426
+ .replace(RUNTIME_VIRTUAL_ENTRYPOINT_PREFIX + ':', '')
427
+ .replace(BROWSER_VIRTUAL_ENTRYPOINT_PREFIX + ':', '')
428
+ .replace(HTML_VIRTUAL_ENTRYPOINT_PREFIX + ':', '')
429
+
430
+ if (pages === '') {
431
+ throw new Error(`[${PLUGIN_NAME}]: No pages were specified from ${importer}`)
432
+ }
433
+
434
+ return pages
435
+ }
436
+
437
+ function findHtmlFiles(directory: string, htmlFileGlobs?: readonly string[]): readonly string[] {
438
+ if (htmlFileGlobs) {
439
+ // eslint-disable-next-line import/no-named-as-default-member
440
+ return glob.sync([...htmlFileGlobs], { cwd: directory })
441
+ }
442
+
443
+ // eslint-disable-next-line import/no-named-as-default-member
444
+ return glob.sync(['**/*.html', '!' + '**/node_modules/**', '!' + '**/dist/**'], {
445
+ cwd: directory,
158
446
  })
447
+ }
159
448
 
160
- return {
161
- code: output.outputText,
162
- map: output.sourceMapText,
449
+ function buildClientInput(htmlFilePaths: readonly string[]) {
450
+ const input: Record<string, string> = {}
451
+
452
+ for (const htmlFilePath of htmlFilePaths) {
453
+ const htmlFile = basename(htmlFilePath, '.html')
454
+
455
+ input[htmlFile] = htmlFilePath
163
456
  }
457
+
458
+ return input
459
+ }
460
+
461
+ function getRelativePath(from: string, to: string) {
462
+ const path = relative(from, to)
463
+
464
+ if (!path.startsWith('.')) {
465
+ return './' + path
466
+ }
467
+
468
+ return path
469
+ }
470
+
471
+ function info(message: string) {
472
+ const date = new Date()
473
+
474
+ console.info(`[${PLUGIN_NAME}] ${date.toISOString()};`, `${message}`)
164
475
  }