diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c073e31d..93616554 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -78,3 +78,5 @@ export { ViewConfigBuilder } from './view/config-builder.js' export { ViewEngine, ViewSharedData } from './view/engine.js' export { ViewBuilderCallback } from './view/response.js' export { ViewResponseConfig } from './view/response-config.js' + +export { ViteConfig } from './vite/config.js' diff --git a/packages/contracts/src/vite/config.ts b/packages/contracts/src/vite/config.ts new file mode 100644 index 00000000..9b5e3005 --- /dev/null +++ b/packages/contracts/src/vite/config.ts @@ -0,0 +1,39 @@ + +export interface ViteConfig { + /** + * Stores the URL used as a prefix when creating asset URLs. This could be a + * CDN URL for production builds. If empty, the created asset URL starts + * with a leading slash to serve it locally from the running server. + * + * @default `/build` + */ + assetsUrl?: string + + /** + * Stores the path to the hot-reload file, relative from the application’s base directory. + * + * @default `/.vite/hot.json` + */ + hotReloadFilePath?: string + + /** + * Stores the Vite manifest file path, relative from the application’s base directory. + * + * @default `/.vite/manifest.json` + */ + manifestFilePath?: string + + /** + * Stores an object of attributes to apply on all HTML `script` tags. + * + * @default `{}` + */ + scriptAttributes?: Record + + /** + * Stores an object of attributes to apply on all HTML `style` tags. + * + * @default `{}` + */ + styleAttributes?: Record +} diff --git a/packages/vite/package.json b/packages/vite/package.json index 73fb54db..0335fdb1 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -49,7 +49,7 @@ ], "license": "MIT", "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + "vite": ">=4.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/vite/src/backend/vite-config.ts b/packages/vite/src/backend/vite-config.ts new file mode 100644 index 00000000..a37cbd0d --- /dev/null +++ b/packages/vite/src/backend/vite-config.ts @@ -0,0 +1,113 @@ + +import Path from 'node:path' +import { Str } from '@supercharge/strings' +import { ViteConfig as ViteConfigContract } from '@supercharge/contracts' + +export class ViteConfig { + /** + * Stores the Vite config object. + */ + private readonly config: Required + + /** + * Create a new instance. + */ + constructor (config: ViteConfigContract) { + this.config = this.createConfigFrom(config ?? {}) + } + + /** + * Returns a new instance for the given Vite `config`. + */ + static from (config: ViteConfigContract): ViteConfig { + return new this(config) + } + + /** + * Returns the resolved Vite config. + */ + private createConfigFrom (config: Partial = {}): Required { + const assetsUrl = Str( + this.isCdnUrl(config.assetsUrl) + ? config.assetsUrl + : Str(config.assetsUrl ?? '/build') + .ltrim('/') + .start('/') + ) + .rtrim('/') + .get() + + return { + assetsUrl, + hotReloadFilePath: config.hotReloadFilePath ?? Path.join(assetsUrl, '/.vite/hot.json'), + manifestFilePath: config.manifestFilePath ?? Path.join(assetsUrl, '.vite/manifest.json'), + styleAttributes: { ...config.styleAttributes }, + scriptAttributes: { ...config.scriptAttributes }, + } + } + + /** + * Determine whether the given `assetsUrl` is a full URL. + */ + private isCdnUrl (assetsUrl: ViteConfigContract['assetsUrl']): assetsUrl is string { + if (!assetsUrl) { + return false + } + + try { + const url = new URL(assetsUrl) + + return url.protocol.startsWith('http') + } catch (error) { + return false + } + } + + /** + * Returns the Vite config object. + */ + toJSON (): ViteConfigContract { + return { + assetsUrl: this.assetsUrl(), + hotReloadFilePath: this.hotReloadFilePath(), + manifestFilePath: this.manifestFilePath(), + styleAttributes: this.styleAttributes(), + scriptAttributes: this.scriptAttributes(), + } + } + + /** + * Returns the Vite hot-reload file path. The hot-reload file contains the Vite dev server URL. + */ + hotReloadFilePath (): string { + return this.config.hotReloadFilePath + } + + /** + * Returns the Vite manifest file path. + */ + manifestFilePath (): string { + return this.config.manifestFilePath + } + + /** + * Returns the assets URL. + */ + assetsUrl (): string { + return this.config.assetsUrl + } + + /** + * Returns the default attributes assigned to every `script` tag. + */ + scriptAttributes (): ViteConfigContract['scriptAttributes'] { + return this.config.scriptAttributes + } + + /** + * Returns the default attributes assigned to every `style` tag. + */ + styleAttributes (): ViteConfigContract['styleAttributes'] { + return this.config.styleAttributes + } +} diff --git a/packages/vite/src/vite-handlebars-helper.ts b/packages/vite/src/backend/vite-handlebars-helper.ts similarity index 67% rename from packages/vite/src/vite-handlebars-helper.ts rename to packages/vite/src/backend/vite-handlebars-helper.ts index 80ffbc34..667bbb58 100644 --- a/packages/vite/src/vite-handlebars-helper.ts +++ b/packages/vite/src/backend/vite-handlebars-helper.ts @@ -1,13 +1,12 @@ import { Vite } from './vite.js' import { HelperOptions } from 'handlebars' -import { Application } from '@supercharge/contracts' export class ViteHandlebarsHelper { /** - * Stores the application instance. + * Stores the Vite config instance. */ - private readonly app: Application + private readonly vite: Vite /** * Stores the Vite entrypoints for which we should generate HTML tags. @@ -19,8 +18,11 @@ export class ViteHandlebarsHelper { */ private readonly handlebarsOptions: HelperOptions - constructor (app: Application, ...args: any[] | any[][]) { - this.app = app + /** + * Create a new instance. + */ + constructor (vite: Vite, ...args: any[] | any[][]) { + this.vite = vite this.handlebarsOptions = args.pop() this.entrypoints = this.findEntrypoints(...args) } @@ -57,15 +59,31 @@ export class ViteHandlebarsHelper { * Splits the given `input` at the comma character and trims each value in the result. */ private resolveStringInput (input: string): string[] { - return input.split(',').map(entry => { - return entry.trim() - }) + return input + .split(',') + .map(entry => entry.trim()) } /** * Generate the Vite CSS and JavaScript tags for the HTML header. */ generateTags (): string { - return Vite.generateTags(this.app, this.entrypoints) + return this.vite + .generateTagsFromEntrypoints( + this.entrypoints, + this.attributesFromOptionsHash() + ) + .toString() + } + + /** + * Returns the configured attributes from the Handlebars helper’s `attributes` hash object. + */ + private attributesFromOptionsHash (): string { + const attributes = this.handlebarsOptions.hash.attributes + + return typeof attributes === 'string' + ? attributes + : String(attributes ?? '') } } diff --git a/packages/vite/src/vite-manifest.ts b/packages/vite/src/backend/vite-manifest.ts similarity index 94% rename from packages/vite/src/vite-manifest.ts rename to packages/vite/src/backend/vite-manifest.ts index 3bf13f01..b94ef77c 100644 --- a/packages/vite/src/vite-manifest.ts +++ b/packages/vite/src/backend/vite-manifest.ts @@ -56,7 +56,7 @@ export class ViteManifest { const chunk = this.manifest[entrypoint] if (!chunk) { - throw new Error(`Entrypoint not found in manifest: ${entrypoint}`) + throw new Error(`Entrypoint not found in manifest: "${entrypoint}"`) } return chunk diff --git a/packages/vite/src/backend/vite.ts b/packages/vite/src/backend/vite.ts new file mode 100644 index 00000000..89bd3931 --- /dev/null +++ b/packages/vite/src/backend/vite.ts @@ -0,0 +1,197 @@ + +import Fs from 'node:fs' +import Path from 'node:path' +import { Arr } from '@supercharge/arrays' +import { ViteConfig } from './vite-config.js' +import { ViteManifest } from './vite-manifest.js' +import { HtmlString } from '@supercharge/support' +import { HotReloadFileContent } from '../plugin/types.js' + +export type ViteTagAttributes = Record + +export class Vite { + /** + * Stores the Vite config instance. + */ + private readonly viteConfig: ViteConfig + + /** + * Stores the entrypoints. + */ + private entrypoints: Arr + + /** + * Stores the cached Vite manifest file. + */ + private manifestCache: ViteManifest | undefined + + /** + * Create a new instance. + */ + constructor (viteConfig: ViteConfig) { + this.viteConfig = viteConfig + this.entrypoints = Arr.from() + } + + /** + * Create a new instance with the given `viteConfig`. + */ + static from (viteConfig: ViteConfig): Vite { + return new this(viteConfig) + } + + /** + * Generate HTML tags for the given Vite entrypoints. + */ + generateTagsFromEntrypoints (entrypoints: string | string[], userProvidedAttributes: string): HtmlString { + this.entrypoints = Arr.from(entrypoints) + + return this.generateTags(userProvidedAttributes) + } + + /** + * Generate HTML tags for the given Vite entrypoints. + */ + generateTags (userProvidedAttributes: string): HtmlString { + const tags = this.hasExistingHotReloadFile() + ? this.generateTagsForHotReload(userProvidedAttributes) + : this.generateTagsFromManifest(userProvidedAttributes) + + return HtmlString.from(tags) + } + + /** + * Determine whether a hot-reload file exists. This method checks the file + * existence synchronously because we’re using the `generateTags` method + * in a Handlebars helper and they only support synchronous calls. + */ + hasExistingHotReloadFile (): boolean { + return Fs.existsSync( + this.viteConfig.hotReloadFilePath() + ) + } + + /** + * Returns the hot-reload file content. + */ + readHotReloadFile (): HotReloadFileContent { + const content = Fs.readFileSync( + this.viteConfig.hotReloadFilePath(), 'utf8' + ) + + return JSON.parse(content) + } + + /** + * Returns the generated HTML tags for the given `entrypoints` and Vite client. + */ + generateTagsForHotReload (userProvidedAttributes: string): string { + const hotfile = this.readHotReloadFile() + + return this.entrypoints + .prepend('@vite/client') + .map(entrypoint => { + return this.makeTagForChunk(`${hotfile.viteDevServerUrl}/${String(entrypoint)}`, userProvidedAttributes) + }) + .join('') + } + + /** + * Returns the generated style and script HTML tags based on the Vite manifest. + */ + generateTagsFromManifest (attributes: string): string { + const tags = Arr.from<{ path: string, tag: string }>([]) + const manifest = this.manifest() + + for (const entrypoint of this.entrypoints) { + const chunk = manifest.getChunk(entrypoint) + + tags.push({ + tag: this.makeTagForChunk(`${this.viteConfig.assetsUrl()}/${chunk.file}`, attributes), + path: chunk.file, + }) + + chunk.css?.forEach(cssFile => { + tags.push({ + tag: this.makeTagForChunk(`${this.viteConfig.assetsUrl()}/${cssFile}`, attributes), + path: cssFile + }) + }) + } + + return tags + .sort(tag => tag.path.endsWith('.css') ? -1 : 1) // CSS first + .flatMap(tag => tag.tag) + .unique() + .join('') + } + + /** + * Returns the parsed Vite manifest. + */ + manifest (): ViteManifest { + if (this.manifestCache) { + return this.manifestCache + } + + this.manifestCache = ViteManifest.loadFrom( + this.viteConfig.manifestFilePath() + ) + + return this.manifestCache + } + + /** + * Returns a generated CSS link tag with the given `attributes` for the provided `url`. + */ + protected makeTagForChunk (url: string, userProvidedAttributes: string): string { + return this.isCssPath(url) + ? this.makeStylesheetTagWithAttributes(url, userProvidedAttributes) + : this.makeScriptTagWithAttributes(url, userProvidedAttributes) + } + + /** + * Determine whether the given `path` is a CSS file. + */ + protected isCssPath (path: string): boolean { + return Path.extname(path).match(/.(css|less|sass|scss|styl|stylus|pcss|postcss)/) != null + } + + /** + * Returns a generated CSS link tag for the provided `url` with given `attributes`. + */ + protected makeStylesheetTagWithAttributes (url: string, userProvidedAttributes: string): string { + const attributes = { + href: url, + rel: 'stylesheet', + ...this.viteConfig.styleAttributes(), + } + + return `` + } + + /** + * Returns a generated JavaScript link tag for the provided `url` with given `attributes`. + */ + protected makeScriptTagWithAttributes (url: string, userProvidedAttributes: string): string { + const attributes = { + src: url, + type: 'module', + ...this.viteConfig.scriptAttributes(), + } + + return `` + } + + /** + * Returns the key="value" pairs as a string for the given `attributes`. + */ + private attributesToString (attributes: ViteTagAttributes, userProvidedAttributes: string): string { + return Object + .entries(attributes) + .map(([key, value]) => `${key}="${String(value)}"`) + .concat(userProvidedAttributes) + .join(' ') + .trim() + } +} diff --git a/packages/vite/src/contracts/index.ts b/packages/vite/src/contracts/index.ts deleted file mode 100644 index bc396c8f..00000000 --- a/packages/vite/src/contracts/index.ts +++ /dev/null @@ -1,2 +0,0 @@ - -export * from './plugin.js' diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index e655505e..1ef44487 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -1,10 +1,13 @@ -import { supercharge } from './plugin.js' +import { supercharge } from './plugin/plugin.js' export default supercharge export { supercharge } -export { PluginConfigContract, DevServerUrl } from './contracts/index.js' -export { resolvePageComponent } from './inertia-helpers.js' -export { Vite, Attributes } from './vite.js' +export { HotReloadFile } from './plugin/hotfile.js' +export { PluginConfigContract, DevServerUrl } from './plugin/types.js' + +export { resolvePageComponent, InertiaPageNotFoundError } from './inertia-helpers.js' +export { Vite, ViteTagAttributes } from './backend/vite.js' +export { ViteConfig } from './backend/vite-config.js' export { ViteServiceProvider } from './vite-service-provider.js' diff --git a/packages/vite/src/inertia-helpers.ts b/packages/vite/src/inertia-helpers.ts index b4740d4b..e1d1e48d 100644 --- a/packages/vite/src/inertia-helpers.ts +++ b/packages/vite/src/inertia-helpers.ts @@ -1,4 +1,16 @@ +export class InertiaPageNotFoundError extends Error { + /** + * Create a new instance. + */ + constructor (path: string) { + super(`Inertia page not found: ${path}`) + + this.name = 'PageNotFoundError' + InertiaPageNotFoundError.captureStackTrace(this, this.constructor) + } +} + /** * Resolves the inertia page component for the given `path` from the available `pages`. * @@ -16,10 +28,10 @@ export async function resolvePageComponent (path: string, pages: Record this.deleteHotfile()) + process.on('SIGINT', process.exit) + process.on('SIGHUP', process.exit) + process.on('SIGTERM', process.exit) + } + + /** + * Delete the hot-reload file from disk. + */ + deleteHotfile (): void { + if (Fs.existsSync(this.hotfilePath)) { + Fs.rmSync(this.hotfilePath) + } + } + + /** + * Write the hot-reload file to disk. + */ + writeFileSync (content: HotReloadFileContent): void { + Fs.mkdirSync(Path.dirname(this.hotfilePath), { recursive: true }) + Fs.writeFileSync(this.hotfilePath, JSON.stringify(content)) + } +} diff --git a/packages/vite/src/plugin.ts b/packages/vite/src/plugin/plugin.ts similarity index 87% rename from packages/vite/src/plugin.ts rename to packages/vite/src/plugin/plugin.ts index 1c626f93..3d09149c 100644 --- a/packages/vite/src/plugin.ts +++ b/packages/vite/src/plugin/plugin.ts @@ -1,9 +1,9 @@ -import Fs from 'node:fs' import Path from 'node:path' import { AddressInfo } from 'node:net' import { Str } from '@supercharge/strings' -import { DevServerUrl, PluginConfigContract } from './contracts/plugin.js' +import { HotReloadFile } from './hotfile.js' +import { DevServerUrl, PluginConfigContract } from './types.js' import { ConfigEnv, Plugin, ResolvedConfig, UserConfig, ViteDevServer } from 'vite' /** @@ -39,7 +39,7 @@ function resolvePluginConfig (config: string | string[] | PluginConfigContract): config.publicDirectory = Str(config.publicDirectory).trim().ltrim('/').get() if (config.publicDirectory === '') { - throw new Error('supercharge-vite-plugin: the publicDirectory option must be a subdirectory, like "public"') + throw new Error('supercharge-vite-plugin: the "publicDirectory" option must be a subdirectory, like "public"') } } @@ -47,7 +47,7 @@ function resolvePluginConfig (config: string | string[] | PluginConfigContract): config.buildDirectory = Str(config.buildDirectory).trim().ltrim('/').rtrim('/').get() if (config.buildDirectory === '') { - throw new Error('supercharge-vite-plugin: the buildDirectory option must be a subdirectory, like "build"') + throw new Error('supercharge-vite-plugin: the "buildDirectory" option must be a subdirectory, like "build"') } } @@ -55,7 +55,7 @@ function resolvePluginConfig (config: string | string[] | PluginConfigContract): config.ssrOutputDirectory = Str(config.ssrOutputDirectory).trim().ltrim('/').rtrim('/').get() if (config.ssrOutputDirectory === '') { - throw new Error('supercharge-vite-plugin: the ssrOutputDirectory option must be a subdirectory, like "ssr"') + throw new Error('supercharge-vite-plugin: the "ssrOutputDirectory" option must be a subdirectory, like "ssr"') } } @@ -67,7 +67,7 @@ function resolvePluginConfig (config: string | string[] | PluginConfigContract): buildDirectory: config.buildDirectory ?? 'build', ssr: config.ssr ?? config.input, ssrOutputDirectory: config.ssrOutputDirectory ?? 'bootstrap/ssr', - hotFilePath: config.hotFilePath ?? Path.join(publicDirectory, 'hot'), + hotFilePath: config.hotFilePath ?? Path.join(publicDirectory, '.vite', 'hot'), } } @@ -87,24 +87,27 @@ function resolveSuperchargePlugin (pluginConfig: Required) * configuration for a project using the Supercharge directory structure. */ config (userConfig: UserConfig, { command }: ConfigEnv): UserConfig { - const useSsr = !!userConfig.build?.ssr + const isSsrBuild = !!userConfig.build?.ssr return { - base: userConfig.base ?? (command === 'build' ? resolveBase(pluginConfig) : ''), + base: userConfig.base ?? (command === 'build' ? resolveBase(pluginConfig) : '/'), + publicDir: userConfig.publicDir ?? false, + build: { - manifest: !useSsr, - outDir: userConfig.build?.outDir ?? resolveOutDir(pluginConfig, useSsr), + manifest: !isSsrBuild, + outDir: userConfig.build?.outDir ?? resolveOutDir(pluginConfig, isSsrBuild), rollupOptions: { - input: userConfig.build?.rollupOptions?.input ?? resolveInput(pluginConfig, useSsr) + input: userConfig.build?.rollupOptions?.input ?? resolveInput(pluginConfig, isSsrBuild) }, assetsInlineLimit: userConfig.build?.assetsInlineLimit ?? 0, }, + server: { origin: '__supercharge_vite_placeholder__', - host: 'localhost', ...userConfig.server }, + ssr: { noExternal: noExternalInertiaHelpers(userConfig), }, @@ -135,19 +138,18 @@ function resolveSuperchargePlugin (pluginConfig: Required) * Configure the Vite server. */ configureServer (server: ViteDevServer) { + const hotfile = new HotReloadFile( + Path.join(resolvedConfig.root, pluginConfig.hotFilePath) + ) + server.httpServer?.once('listening', () => { const address = server.httpServer?.address() if (isAddressInfo(address)) { viteDevServerUrl = resolveDevServerUrl(address, server.config) - Fs.writeFileSync(pluginConfig.hotFilePath, viteDevServerUrl) + hotfile.writeFileSync({ viteDevServerUrl }) } }) - - process.on('SIGINT', process.exit) - process.on('SIGHUP', process.exit) - process.on('SIGTERM', process.exit) - process.on('exit', () => deleteHotFile(pluginConfig)) } } } @@ -186,15 +188,6 @@ function isAddressInfo (address: string | AddressInfo | null | undefined): addre return typeof address === 'object' } -/** - * Delete a possibly existing hot-reload file. - */ -function deleteHotFile ({ hotFilePath }: Required): void { - if (Fs.existsSync(hotFilePath)) { - Fs.rmSync(hotFilePath) - } -} - /** * Returns the resolved Vite dev server URL. */ diff --git a/packages/vite/src/contracts/plugin.ts b/packages/vite/src/plugin/types.ts similarity index 91% rename from packages/vite/src/contracts/plugin.ts rename to packages/vite/src/plugin/types.ts index 43f8d4c9..73ec4350 100644 --- a/packages/vite/src/contracts/plugin.ts +++ b/packages/vite/src/plugin/types.ts @@ -1,4 +1,10 @@ +export interface HotReloadFileContent { + viteDevServerUrl: string +} + +export type DevServerUrl = `${'http' | 'https'}://${string}:${number}` + export interface PluginConfigContract { /** * The path or paths to the entrypoints to compile with Vite. @@ -38,5 +44,3 @@ export interface PluginConfigContract { */ ssrOutputDirectory?: string } - -export type DevServerUrl = `${'http' | 'https'}://${string}:${number}` diff --git a/packages/vite/src/vite-service-provider.ts b/packages/vite/src/vite-service-provider.ts index b27881ed..c04fb9c5 100644 --- a/packages/vite/src/vite-service-provider.ts +++ b/packages/vite/src/vite-service-provider.ts @@ -1,24 +1,49 @@ -import { ViewEngine } from '@supercharge/contracts' +import { Vite } from './backend/vite.js' +import { ViteConfig } from './backend/vite-config.js' import { ServiceProvider } from '@supercharge/support' -import { ViteHandlebarsHelper } from './vite-handlebars-helper.js' +import { ViteHandlebarsHelper } from './backend/vite-handlebars-helper.js' +import { ViewEngine, ViteConfig as ViteConfigContract } from '@supercharge/contracts' + +/** + * Add container bindings for services from this provider. + */ +declare module '@supercharge/contracts' { + export interface ContainerBindings { + 'vite': Vite + } +} export class ViteServiceProvider extends ServiceProvider { /** * Register application services. */ override async boot (): Promise { + this.registerVite() this.registerViteViewHelpers() } + /** + * Register the Vite binding. + */ + private registerVite (): void { + this.app().singleton('vite', () => { + const config = this.app().config().get('vite') + const viteConfig = ViteConfig.from(config) + + return Vite.from(viteConfig) + }) + } + /** * Register the Vite view helper. */ private registerViteViewHelpers (): void { + const vite = this.app().make('vite') const view = this.app().make('view') - view.registerHelper('vite', (...entrypoints: string[] | string[][]) => { - return new ViteHandlebarsHelper(this.app(), ...entrypoints).generateTags() + view.registerHelper('vite', (...args: any[]) => { + return new ViteHandlebarsHelper(vite, ...args).generateTags() }) } } diff --git a/packages/vite/src/vite.ts b/packages/vite/src/vite.ts deleted file mode 100644 index 617e3b18..00000000 --- a/packages/vite/src/vite.ts +++ /dev/null @@ -1,179 +0,0 @@ - -import Fs from 'node:fs' -import Path from 'node:path' -import { Str } from '@supercharge/strings' -import { Arr } from '@supercharge/arrays' -import { ViteManifest } from './vite-manifest.js' -import { HtmlString } from '@supercharge/support' -import { Application } from '@supercharge/contracts' - -export type Attributes = Record - -export class Vite { - /** - * Stores the application instance. - */ - private readonly app: Application - - /** - * Stores the entrypoints. - */ - private readonly entrypoints: Arr - - /** - * Stores the relative path to the "build" directory. - */ - private readonly buildDirectory: string - - /** - * Create a new instance. - */ - constructor (app: Application, entrypoints: string | string[], buildDirectory?: string) { - this.app = app - this.entrypoints = Arr.from(entrypoints) - this.buildDirectory = Str(buildDirectory ?? 'build').start('/').get() - } - - /** - * Generate HTML tags for the given `entrypoints`. - */ - static generateTags (app: Application, entrypoints: string[], buildDirectory?: string): string { - return new this(app, entrypoints, buildDirectory).generateTags().toString() - } - - /** - * Generate HTML tags for the given Vite entrypoints. - */ - generateTags (): HtmlString { - const tags = this.hasExistingHotReloadFile() - ? this.generateTagsForHotReload() - : this.generateTagsFromManifest() - - return HtmlString.from(tags) - } - - /** - * Determine whether a hot-reload file exists. This method checks the file - * existence synchronously because we’re using the `generateTags` method - * in a Handlebars helper and they only support synchronous calls. - */ - hasExistingHotReloadFile (): boolean { - return Fs.existsSync( - this.hotReloadFilePath() - ) - } - - /** - * Returns the path to the hot-reload file. The hot-reload file contains the Vite dev server URL. - */ - hotReloadFilePath (): string { - return this.app.publicPath('hot') - } - - /** - * Returns the generated HTML tags for the given `entrypoints` and Vite client. - */ - generateTagsForHotReload (): string { - const url = Fs.readFileSync(this.hotReloadFilePath(), 'utf8') - - return this.entrypoints - .prepend('@vite/client') - .map(entrypoint => { - return this.makeTagForChunk(entrypoint, `${url}/${String(entrypoint)}`) - }) - .join('') - } - - /** - * Returns the generated style and script HTML tags based on the Vite manifest. - */ - generateTagsFromManifest (): string { - const tags = [] - const manifest = this.manifest() - - for (const entrypoint of this.entrypoints) { - const chunk = manifest.getChunk(entrypoint) - - tags.push( - this.makeTagForChunk(entrypoint, `${this.buildDirectory}/${chunk.file}`) - ) - - chunk.css?.forEach(cssFile => { - tags.push( - this.makeTagForChunk(cssFile, `${this.buildDirectory}/${cssFile}`) - ) - }) - } - - return tags.join('') - } - - /** - * Returns the path to the hot-reload file. The hot-reload file contains the Vite dev server URL. - */ - manifestPath (): string { - return this.app.publicPath( - Str(this.buildDirectory).ltrim('/').get(), 'manifest.json' - ) - } - - /** - * Returns the parsed Vite manifest. - */ - manifest (): ViteManifest { - return ViteManifest.loadFrom(this.manifestPath()) - } - - /** - * Returns a generated CSS link tag with the given `attributes` for the provided `url`. - */ - protected makeTagForChunk (src: string, url: string): string { - return this.isCssPath(src) - ? this.makeStylesheetTagWithAttributes(url, {}) - : this.makeScriptTagWithAttributes(url, {}) - } - - /** - * Determine whether the given `path` is a CSS file. - */ - protected isCssPath (path: string): boolean { - return Path.extname(path).match(/.(css|less|sass|scss|styl|stylus|pcss|postcss)/) != null - } - - /** - * Returns a generated CSS link tag for the provided `url` with given `attributes`. - */ - protected makeStylesheetTagWithAttributes (url: string, attributes: Attributes): string { - attributes = { - rel: 'stylesheet', - href: url, - ...attributes, - } - - return `` - } - - /** - * Returns a generated JavaScript link tag for the provided `url` with given `attributes`. - */ - protected makeScriptTagWithAttributes (url: string, attributes: Attributes): string { - attributes = { - type: 'module', - src: url, - ...attributes, - } - - return `` - } - - /** - * Returns the key="value" pairs as a string for the given `attributes`. - */ - private attributesToString (attributes: Attributes): string { - return Object - .entries(attributes) - .map(([key, value]) => `${key}="${String(value)}"`) - .join(' ') - .trim() - } -} diff --git a/packages/vite/test/fixtures/resources/views/test-vite-helper-with-attributes.hbs b/packages/vite/test/fixtures/resources/views/test-vite-helper-with-attributes.hbs new file mode 100644 index 00000000..dec9aa85 --- /dev/null +++ b/packages/vite/test/fixtures/resources/views/test-vite-helper-with-attributes.hbs @@ -0,0 +1 @@ +{{{ vite input="resources/js/hash-app.js, resources/css/app.css" attributes='data-turbo-track="reload" async' }}} diff --git a/packages/vite/test/helpers/index.js b/packages/vite/test/helpers/index.js index 0b24a033..873503df 100644 --- a/packages/vite/test/helpers/index.js +++ b/packages/vite/test/helpers/index.js @@ -1,7 +1,16 @@ +/** + * @typedef {import('@supercharge/contracts').ViteConfig} ViteConfigContract + */ + +import Path from 'node:path' import Fs from '@supercharge/fs' import { fileURLToPath } from 'node:url' import { Application } from '@supercharge/application' +import { ViteConfig, HotReloadFile } from '../../dist/index.js' + +const __dirname = Path.dirname(fileURLToPath(import.meta.url)) +const fixturesPath = Path.resolve(__dirname, '../fixtures') /** * Returns a test application. @@ -9,30 +18,33 @@ import { Application } from '@supercharge/application' * @returns {Application} */ export function makeApp () { - const app = Application.createWithAppRoot( - fileURLToPath(new URL('./../fixtures', import.meta.url)) - ) - - app.config().set('view', { - driver: 'handlebars', - handlebars: { - views: app.resourcePath('views'), - partials: app.resourcePath('views/partials'), - helpers: app.resourcePath('views/helpers') + const app = Application.createWithAppRoot(fixturesPath) + + app.config() + .set('view', { + driver: 'handlebars', + handlebars: { + views: app.resourcePath('views'), + partials: app.resourcePath('views/partials'), + helpers: app.resourcePath('views/helpers') // layouts: app.resourcePath('views/layouts') // defaultLayout: 'app' - } - }) + } + }) + .set('vite', { + assetsUrl: '/build', + hotReloadFilePath: app.publicPath('build/.vite/hot.json'), + manifestFilePath: app.publicPath('build/.vite/manifest.json') + }) return app } /** - * @param {Application} app + * @param {ViteConfig} viteConfig * @param {Object} content - * @param {String} buildDirectory */ -export async function createViteManifest (app, content, buildDirectory = 'build') { +export async function createViteManifest (viteConfig, content) { const manifest = content || { 'resources/js/app.js': { file: 'assets/app.version.js' @@ -68,7 +80,11 @@ export async function createViteManifest (app, content, buildDirectory = 'build' } } - const manifestPath = app.publicPath(`${buildDirectory}/manifest.json`) + if (!viteConfig) { + viteConfig = createViteConfig() + } + + const manifestPath = viteConfig.manifestFilePath() await Fs.outputJSON(manifestPath, manifest) } @@ -76,8 +92,9 @@ export async function createViteManifest (app, content, buildDirectory = 'build' /** * @param {Application} app */ -export async function clearViteManifest (app, buildDirectory = 'build') { - const manifestPath = app.publicPath(`${buildDirectory}/manifest.json`) +export async function clearViteManifest () { + const viteConfig = createViteConfig() + const manifestPath = viteConfig.manifestFilePath() if (await Fs.exists(manifestPath)) { await Fs.removeFile(manifestPath) @@ -85,19 +102,46 @@ export async function clearViteManifest (app, buildDirectory = 'build') { } /** - * @param {Application} app + * @param {ViteConfigContract} viteConfig */ -export async function createViteHotReloadFile (app) { - await Fs.writeFile(app.publicPath('hot'), 'http://localhost:3000') +export function createViteConfig (viteConfig) { + const app = makeApp() + const defaultViteConfig = app.config().get('vite') + + if (typeof viteConfig === 'object') { + return ViteConfig.from({ + ...defaultViteConfig, + ...viteConfig + }) + } + + return ViteConfig.from(defaultViteConfig) } /** - * @param {Application} app + * @param {ViteConfig} viteConfig */ -export async function clearViteHotReloadFile (app) { - const hotReloadFilePath = app.publicPath('hot') +export function createViteHotReloadFile (viteConfig) { + if (!viteConfig) { + viteConfig = createViteConfig() + } - if (await Fs.exists(hotReloadFilePath)) { - await Fs.removeFile(hotReloadFilePath) + HotReloadFile + .from(viteConfig.hotReloadFilePath()) + .writeFileSync({ + viteDevServerUrl: 'http://localhost:3000' + }) +} + +/** + * @param {ViteConfig} viteConfig + */ +export function clearViteHotReloadFile (viteConfig) { + if (!viteConfig) { + viteConfig = createViteConfig() } + + HotReloadFile + .from(viteConfig.hotReloadFilePath()) + .deleteHotfile() } diff --git a/packages/vite/test/inertia-helpers.test.js b/packages/vite/test/inertia-helpers.js similarity index 76% rename from packages/vite/test/inertia-helpers.test.js rename to packages/vite/test/inertia-helpers.js index 7621eaf3..9f3093e7 100644 --- a/packages/vite/test/inertia-helpers.test.js +++ b/packages/vite/test/inertia-helpers.js @@ -1,7 +1,7 @@ import { test } from 'uvu' import { expect } from 'expect' -import { resolvePageComponent } from '../dist/index.js' +import { resolvePageComponent, InertiaPageNotFoundError } from '../dist/index.js' const testPage = './fixtures/test-page.js' @@ -16,6 +16,10 @@ test('pass eagerly globed value to resolvePageComponent', async () => { }) test('fails for non-existing page', async () => { + await expect( + resolvePageComponent('./fixtures/not-existing.js', import.meta.glob('./fixtures/*.js')) + ).rejects.toThrowError(InertiaPageNotFoundError) + await expect( resolvePageComponent('./fixtures/not-existing.js', import.meta.glob('./fixtures/*.js')) ).rejects.toThrow('Inertia page not found: ./fixtures/not-existing.js') diff --git a/packages/vite/test/vite-config.js b/packages/vite/test/vite-config.js new file mode 100644 index 00000000..7e21b0c6 --- /dev/null +++ b/packages/vite/test/vite-config.js @@ -0,0 +1,80 @@ + +import { test } from 'uvu' +import { expect } from 'expect' +import { ViteConfig } from '../dist/index.js' + +test('default Vite config', async () => { + const config = ViteConfig.from() + + expect(config.toJSON()).toEqual({ + assetsUrl: '/build', + hotReloadFilePath: '/build/.vite/hot.json', + manifestFilePath: '/build/.vite/manifest.json', + styleAttributes: {}, + scriptAttributes: {} + }) +}) + +test('assetsUrl', async () => { + expect( + ViteConfig.from({ assetsUrl: 'build' }).toJSON() + ).toMatchObject({ + assetsUrl: '/build', + hotReloadFilePath: '/build/.vite/hot.json', + manifestFilePath: '/build/.vite/manifest.json' + }) + + expect( + ViteConfig.from({ assetsUrl: 'build/' }).toJSON() + ).toMatchObject({ + assetsUrl: '/build', + hotReloadFilePath: '/build/.vite/hot.json', + manifestFilePath: '/build/.vite/manifest.json' + }) + + expect( + ViteConfig.from({ assetsUrl: '//foo/bar' }).toJSON() + ).toMatchObject({ + assetsUrl: '/foo/bar', + hotReloadFilePath: '/foo/bar/.vite/hot.json', + manifestFilePath: '/foo/bar/.vite/manifest.json' + }) +}) + +test('hotReloadFilePath', async () => { + expect( + ViteConfig.from({ hotReloadFilePath: 'foo/bar/hot.json' }).toJSON() + ).toMatchObject({ + hotReloadFilePath: 'foo/bar/hot.json' + }) + + expect( + ViteConfig.from({ + assetsUrl: '/build', + hotReloadFilePath: 'foo/bar/hot.json' + }).toJSON() + ).toMatchObject({ + assetsUrl: '/build', + hotReloadFilePath: 'foo/bar/hot.json' + }) +}) + +test('manifestFilePath', async () => { + expect( + ViteConfig.from({ manifestFilePath: 'foo/bar/manifest.json' }).toJSON() + ).toMatchObject({ + manifestFilePath: 'foo/bar/manifest.json' + }) + + expect( + ViteConfig.from({ + assetsUrl: '/build', + manifestFilePath: 'foo/bar/manifest.json' + }).toJSON() + ).toMatchObject({ + assetsUrl: '/build', + manifestFilePath: 'foo/bar/manifest.json' + }) +}) + +test.run() diff --git a/packages/vite/test/vite-plugin.test.js b/packages/vite/test/vite-plugin.js similarity index 90% rename from packages/vite/test/vite-plugin.test.js rename to packages/vite/test/vite-plugin.js index 30f544ae..efe62de3 100644 --- a/packages/vite/test/vite-plugin.test.js +++ b/packages/vite/test/vite-plugin.js @@ -27,26 +27,26 @@ test('throws when missing configuration', async () => { test('throws for empty publicDirectory configuration', async () => { expect(() => supercharge({ input: 'app.ts', publicDirectory: '' })) - .toThrow('supercharge-vite-plugin: the publicDirectory option must be a subdirectory, like "public"') + .toThrow('supercharge-vite-plugin: the "publicDirectory" option must be a subdirectory, like "public"') expect(() => supercharge({ input: 'app.ts', publicDirectory: ' / ' })) - .toThrow('supercharge-vite-plugin: the publicDirectory option must be a subdirectory, like "public"') + .toThrow('supercharge-vite-plugin: the "publicDirectory" option must be a subdirectory, like "public"') }) test('throws for empty buildDirectory configuration', async () => { expect(() => supercharge({ input: 'app.ts', buildDirectory: '' })) - .toThrow('supercharge-vite-plugin: the buildDirectory option must be a subdirectory, like "build"') + .toThrow('supercharge-vite-plugin: the "buildDirectory" option must be a subdirectory, like "build"') expect(() => supercharge({ input: 'app.ts', buildDirectory: ' / ' })) - .toThrow('supercharge-vite-plugin: the buildDirectory option must be a subdirectory, like "build"') + .toThrow('supercharge-vite-plugin: the "buildDirectory" option must be a subdirectory, like "build"') }) test('throws for empty ssrOutputDirectory configuration', async () => { expect(() => supercharge({ input: 'app.ts', ssrOutputDirectory: '' })) - .toThrow('supercharge-vite-plugin: the ssrOutputDirectory option must be a subdirectory, like "ssr"') + .toThrow('supercharge-vite-plugin: the "ssrOutputDirectory" option must be a subdirectory, like "ssr"') expect(() => supercharge({ input: 'app.ts', ssrOutputDirectory: ' / ' })) - .toThrow('supercharge-vite-plugin: the ssrOutputDirectory option must be a subdirectory, like "ssr"') + .toThrow('supercharge-vite-plugin: the "ssrOutputDirectory" option must be a subdirectory, like "ssr"') }) test('uses "supercharge" as the name', async () => { @@ -172,7 +172,7 @@ test('configures the Vite dev server', async () => { const plugin = supercharge('resources/js/app.ts') const config = plugin.config({}, { command: 'serve' }) - expect(config.base).toEqual('') + expect(config.base).toEqual('/') expect(config.server.origin).toEqual('__supercharge_vite_placeholder__') }) diff --git a/packages/vite/test/vite-service-provider.test.js b/packages/vite/test/vite-service-provider.js similarity index 60% rename from packages/vite/test/vite-service-provider.test.js rename to packages/vite/test/vite-service-provider.js index 39a39690..d445ec81 100644 --- a/packages/vite/test/vite-service-provider.test.js +++ b/packages/vite/test/vite-service-provider.js @@ -7,11 +7,9 @@ import { HttpServiceProvider } from '@supercharge/http' import { ViewServiceProvider } from '@supercharge/view' import { clearViteHotReloadFile, clearViteManifest, createViteManifest, makeApp } from './helpers/index.js' -const app = makeApp() - test.before.each(async () => { - await clearViteManifest(app) - await clearViteHotReloadFile(app) + await clearViteManifest() + await clearViteHotReloadFile() }) test('registers view helpers', async () => { @@ -28,9 +26,9 @@ test('registers view helpers', async () => { expect(view.hasHelper('vite')).toBe(true) }) -test('render vite view helper with unnamed arguments', async () => { +test('render Vite view helper with unnamed arguments', async () => { const app = makeApp() - await createViteManifest(app) + await createViteManifest() await app .register(new HttpServiceProvider(app)) @@ -41,14 +39,15 @@ test('render vite view helper with unnamed arguments', async () => { const rendered = await app.make('view').render('test-vite-helper-unnamed-arguments') expect(rendered).toEqual( - '' + - '' + EOL + '' + + '' + + EOL ) }) -test('render vite view helper with named "input" arguments', async () => { +test('render Vite view helper with named "input" arguments', async () => { const app = makeApp() - await createViteManifest(app) + await createViteManifest() await app .register(new HttpServiceProvider(app)) @@ -59,14 +58,34 @@ test('render vite view helper with named "input" arguments', async () => { const rendered = await app.make('view').render('test-vite-helper-hash-arguments') expect(rendered).toEqual( - '' + - '' + EOL + '' + + '' + + EOL + ) +}) + +test('render Vite view helper with named "input" and "attributes"', async () => { + const app = makeApp() + await createViteManifest() + + await app + .register(new HttpServiceProvider(app)) + .register(new ViewServiceProvider(app)) + .register(new ViteServiceProvider(app)) + .boot() + + const rendered = await app.make('view').render('test-vite-helper-with-attributes') + + expect(rendered).toEqual( + '' + + '' + + EOL ) }) test('fails to render vite view helper with named "input" arguments and wrong type', async () => { const app = makeApp() - await createViteManifest(app) + await createViteManifest() await app .register(new HttpServiceProvider(app)) diff --git a/packages/vite/test/vite.js b/packages/vite/test/vite.js new file mode 100644 index 00000000..f47e11c9 --- /dev/null +++ b/packages/vite/test/vite.js @@ -0,0 +1,153 @@ + +import { test } from 'uvu' +import { expect } from 'expect' +import { Vite } from '../dist/index.js' +import { makeApp, createViteManifest, createViteHotReloadFile, clearViteManifest, clearViteHotReloadFile, createViteConfig } from './helpers/index.js' + +const app = makeApp() + +test.before.each(async () => { + await clearViteManifest() + await clearViteHotReloadFile() +}) + +test('JS import', async () => { + const viteConfig = createViteConfig() + await createViteManifest(viteConfig) + + const tags = Vite + .from(viteConfig) + .generateTagsFromEntrypoints('resources/js/app.js') + .toString() + + expect(tags).toEqual('') +}) + +test('JS import with default attribute', async () => { + const viteConfig = createViteConfig({ + scriptAttributes: { + foo: 'bar' + } + }) + await createViteManifest(viteConfig) + + const tags = Vite + .from(viteConfig) + .generateTagsFromEntrypoints('resources/js/app.js') + .toString() + + expect(tags).toEqual('') +}) + +test('CSS import', async () => { + const viteConfig = createViteConfig() + await createViteManifest(viteConfig) + + const tags = Vite + .from(viteConfig) + .generateTagsFromEntrypoints(['resources/css/app.css']) + .toString() + + expect(tags).toEqual('') +}) + +test('JS and CSS imports (sorts CSS first)', async () => { + const viteConfig = createViteConfig() + await createViteManifest(viteConfig) + + const tags = Vite + .from(viteConfig) + .generateTagsFromEntrypoints(['resources/js/app.js', 'resources/css/app.css']) + .toString() + + expect(tags).toEqual( + '' + + '' + ) +}) + +test('JS with CSS imports', async () => { + const viteConfig = createViteConfig() + await createViteManifest(viteConfig) + + const tags = Vite + .from(viteConfig) + .generateTagsFromEntrypoints(['resources/js/app-with-css-import.js']) + .toString() + + expect(tags).toEqual( + '' + + '' + ) +}) + +test('Vite hot module replacement with JS only', async () => { + await createViteHotReloadFile() + const viteConfig = createViteConfig() + + const tags = Vite + .from(viteConfig) + .generateTagsFromEntrypoints(['resources/js/app.js']) + .toString() + + expect(tags).toEqual( + '' + + '' + ) +}) + +test('Vite hot module replacement with JS and CSS (sorts tags in input order)', async () => { + await createViteHotReloadFile() + const viteConfig = createViteConfig() + + const tags = Vite + .from(viteConfig) + .generateTagsFromEntrypoints(['resources/css/app.css', 'resources/js/app.js']) + .toString() + + expect(tags).toEqual( + '' + + '' + + '' + ) +}) + +test('Vite uses a full asset URL', async () => { + const viteConfig = createViteConfig({ assetsUrl: 'https://superchargejs.com/assets-dir/' }) + // await createViteHotReloadFile(viteConfig) + await createViteManifest(viteConfig) + + const tags = Vite + .from(viteConfig) + .generateTagsFromEntrypoints(['resources/js/app-with-css-import.js']) + .toString() + + expect(tags).toEqual( + '' + + '' + ) +}) + +test('fails when manifest file is not available', async () => { + const viteConfig = createViteConfig() + + expect(() => { + Vite + .from(viteConfig) + .generateTagsFromEntrypoints(['resources/css/app.css', 'resources/js/app.js']) + .toString() + }).toThrow(`Vite manifest file not found at path: ${app.publicPath('build/.vite/manifest.json')}`) +}) + +test('fails when entrypoint is missing in manifest file', async () => { + await createViteManifest() + const viteConfig = createViteConfig() + + expect(() => { + Vite.from(viteConfig) + .generateTagsFromEntrypoints(['missing/entrypoing/file.css']) + .toString() + }).toThrow('Entrypoint not found in manifest: "missing/entrypoing/file.css"') +}) + +test.run() diff --git a/packages/vite/test/vite.test.js b/packages/vite/test/vite.test.js deleted file mode 100644 index 121ebe6a..00000000 --- a/packages/vite/test/vite.test.js +++ /dev/null @@ -1,89 +0,0 @@ - -import { test } from 'uvu' -import { expect } from 'expect' -import { Vite } from '../dist/index.js' -import { makeApp, createViteManifest, createViteHotReloadFile, clearViteManifest, clearViteHotReloadFile } from './helpers/index.js' - -const app = makeApp() - -test.before.each(async () => { - await clearViteManifest(app) - await clearViteHotReloadFile(app) -}) - -test('JS import', async () => { - await createViteManifest(app) - - const tags = Vite.generateTags(app, 'resources/js/app.js') - - expect(tags).toEqual('') -}) - -test('CSS import', async () => { - await createViteManifest(app) - - const tags = Vite.generateTags(app, ['resources/css/app.css']) - - expect(tags).toEqual('') -}) - -test('JS and CSS imports', async () => { - await createViteManifest(app) - - const tags = Vite.generateTags(app, ['resources/css/app.css', 'resources/js/app.js']) - - expect(tags).toEqual( - '' + - '' - ) -}) - -test('JS with CSS imports', async () => { - await createViteManifest(app) - - const tags = Vite.generateTags(app, ['resources/js/app-with-css-import.js']) - - expect(tags).toEqual( - '' + - '' - ) -}) - -test('Vite hot module replacement with JS only', async () => { - await createViteHotReloadFile(app) - - const tags = Vite.generateTags(app, ['resources/js/app.js']) - - expect(tags).toEqual( - '' + - '' - ) -}) - -test('Vite hot module replacement with JS and CSS', async () => { - await createViteHotReloadFile(app) - - const tags = Vite.generateTags(app, ['resources/css/app.css', 'resources/js/app.js']) - - expect(tags).toEqual( - '' + - '' + - '' - ) -}) - -test('fails when manifest file is not available', async () => { - expect(() => { - Vite.generateTags(app, ['resources/css/app.css', 'resources/js/app.js']) - }).toThrow(`Vite manifest file not found at path: ${app.publicPath('build/manifest.json')}`) -}) - -test('fails when entrypoint is missing in manifest file', async () => { - await createViteManifest(app) - - expect(() => { - Vite.generateTags(app, ['missing/entrypoing/file.css']) - }).toThrow('Entrypoint not found in manifest: missing/entrypoing/file.css') -}) - -test.run()