@typed/vite-plugin 0.0.11 → 0.0.12

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