diff --git a/.changeset/frontends-option.md b/.changeset/frontends-option.md new file mode 100644 index 0000000..9e02462 --- /dev/null +++ b/.changeset/frontends-option.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': minor +--- + +Add a `frontends` option: other languages that lower to Solid can claim a file extension and compile its modules themselves (`frontends: { ".x": x() }`). A front end's `compile(source, { id, moduleId, ssr, root, solid, addWatchFile })` returns `{ code, map, css }`, given the compiler options this plugin would use for a JSX module in that environment; the result then takes the lowered `.tsrx` route (lazy and refresh passes, server functions keyed by the module's real path, `css` as a CSS module, dep scanning). Server functions in a front end's modules need `@solidjs/compiler` with secondary passes that accept any extension. `.rx` (ReactiveScript) is built in: with `@reactivescript/compiler` installed in the project, `.rx` modules compile with no option. diff --git a/README.md b/README.md index 66f402e..aa9507c 100644 --- a/README.md +++ b/README.md @@ -1122,6 +1122,46 @@ By default, the plugin transforms `jsx`, `tsx`, and experimental `tsrx` files. TSRX is always recognized and does not need to be added here. This is useful if you want to transform `mdx` files for example. +#### options.frontends + +- Type: `Record` +- Default: `{}` (plus the built-in `.rx`, below) + +Front ends for languages that lower to Solid themselves, keyed by file +extension (`{ ".x": x() }`). Unlike `extensions`, whose files are parsed as +JSX text, a front end's module is handed to its `compile` function, which +returns the module's code, source map and stylesheet: + +```ts +interface Frontend { + compile( + source: string, + context: { + id: string; // the module's file path + moduleId: string; // its full id, query included (`./x.rx?dep`) + ssr: boolean; // compiled for a server environment + root: string; // Vite's resolved root + solid: SolidOptions; // what this plugin would pass @solidjs/compiler for a JSX module here + addWatchFile(file: string): void; // recompile this module when `file` changes + }, + ): { code: string; map?: string | object | null; css?: string } | null; +} +``` + +A front end usually ends in `@solidjs/compiler` with `context.solid`, so its +output matches this environment's `generate`, `hydratable` and `moduleName`. +From there the module takes the route a lowered `.tsrx` module takes: the +lazy and HMR refresh passes, server functions (IDs from the module's own +path), and `css` as a CSS module the module imports. Solid's own extensions +(`.jsx`, `.tsx`, `.tsrx`, ...) can't be claimed. Return `null` to leave a +module to the rest of the pipeline. + +`.rx` ([ReactiveScript](https://github.com/ryansolid/reactivescript)) is +built in: install `@reactivescript/compiler` in the project and `.rx` +modules compile with no option. The plugin loads the package's `/vite` +front end the first time it meets an `.rx` module, so a project without +`.rx` files never loads it. A `.rx` entry in `frontends` replaces it. + ## `server-only` and `client-only` boundary markers The plugin always claims the bare specifiers `server-only` and `client-only` diff --git a/src/frontends.test.ts b/src/frontends.test.ts new file mode 100644 index 0000000..b8133d9 --- /dev/null +++ b/src/frontends.test.ts @@ -0,0 +1,200 @@ +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { transformAsync, transformDirectives } from '@solidjs/compiler'; +import { createServer, type ViteDevServer } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import solidPlugin, { type Frontend, type FrontendContext } from './index.js'; + +let root: string; + +function directivesTakeAnyExtension() { + try { + transformDirectives('export async function f() { "use server"; }', { + filename: '/p/a.fe', + root: '/p', + mode: 'server', + }); + return true; + } catch { + return false; + } +} +let server: ViteDevServer; +const calls: FrontendContext[] = []; + +// A front end whose language is JSX under another extension: it lowers +// through Solid's compiler with the options the plugin hands it, and returns +// a stylesheet — what a real one (ReactiveScript's `.rx`) returns. +const fe: Frontend = { + async compile(source, context) { + calls.push(context); + context.addWatchFile(path.join(root, 'src/shared.d.fe')); + const result = await transformAsync(source, { + ...context.solid, + filename: context.id + '.jsx', + sourceMap: true, + }); + // the map names the module, as a front end's own map would + const map = { ...JSON.parse(result.map!), sources: [context.id] }; + return { code: result.code, map, css: '.fe { color: red; }' }; + }, +}; + +beforeAll(async () => { + root = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-frontends-'))); + await symlink( + fileURLToPath(new URL('../node_modules', import.meta.url)), + path.join(root, 'node_modules'), + 'dir', + ); + await mkdir(path.join(root, 'src')); + await writeFile( + path.join(root, 'src/App.fe'), + 'export default function App() { return

hi

; }\n', + ); + await writeFile(path.join(root, 'src/shared.d.fe'), '// declarations the front end reads\n'); + await writeFile( + path.join(root, 'src/actions.fe'), + 'export async function save(x) { "use server"; return x; }\nexport const View = () => ;\n', + ); + server = await createServer({ + root, + configFile: false, + appType: 'custom', + logLevel: 'silent', + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + environments: { client: {}, ssr: { consumer: 'server' } }, + plugins: [solidPlugin({ ssr: true, serverFunctions: true, frontends: { '.fe': fe } })], + }); +}); + +afterAll(async () => { + await server?.close(); + await rm(root, { recursive: true, force: true }); +}); + +describe('frontends', () => { + it('compiles a module of its extension with the client options', async () => { + const result = await server.environments.client.transformRequest('/src/App.fe'); + expect(result?.code).toContain('_$template('); + const context = calls.find((c) => c.id.endsWith('App.fe') && !c.ssr); + expect(context?.solid.generate).toBe('dom'); + expect(context?.solid.hydratable).toBe(true); + }); + + it('compiles it for the server in a server environment', async () => { + const result = await server.environments.ssr.transformRequest('/src/App.fe'); + expect(result?.code).toContain('ssrHydrationKey'); + expect(calls.find((c) => c.id.endsWith('App.fe') && c.ssr)?.solid.generate).toBe('ssr'); + }); + + it('serves its stylesheet as a CSS module the module imports', async () => { + const result = await server.environments.client.transformRequest('/src/App.fe'); + const css = result?.code.match(/import "([^"]*solid-tsrx-css[^"]*)"/)?.[1]; + expect(css).toBeTruthy(); + const loaded = await server.environments.client.transformRequest(css!); + expect(loaded?.code).toContain('.fe { color: red; }'); + }); + + it('keeps a source map back to the module', async () => { + const result = await server.environments.client.transformRequest('/src/App.fe'); + const map = result?.map as { sources?: string[] } | null; + expect(map?.sources?.some((s) => s.endsWith('App.fe'))).toBe(true); + }); + + // Needs @solidjs/compiler's secondary passes to take another front end's + // filename (solidjs/solid, secondary passes parse unknown extensions as TSX). + it.skipIf(!directivesTakeAnyExtension())( + 'extracts server functions from its output', + async () => { + const client = await server.environments.client.transformRequest('/src/actions.fe'); + expect(client?.code).toContain('createServerReference'); + expect(client?.code).not.toContain('return x'); + }, + ); + + it('hands over the queried module id beside the file path', async () => { + const result = await server.environments.client.transformRequest('/src/App.fe?dep'); + expect(result?.code).toContain('_$template('); + const context = calls.find((c) => c.moduleId.endsWith('App.fe?dep')); + expect(context?.id).toBe(path.join(root, 'src/App.fe')); + }); + + it('watches the files the front end read', async () => { + await server.environments.client.transformRequest('/src/App.fe'); + const node = await server.environments.client.moduleGraph.getModuleByUrl('/src/App.fe'); + expect([...(node?.importedModules ?? [])].some((m) => m.file?.endsWith('shared.d.fe'))).toBe( + true, + ); + }); + + it("refuses an extension Solid's compiler already owns", () => { + expect(() => solidPlugin({ frontends: { '.tsx': fe } })).toThrow(/frontends/); + }); +}); + +// `.rx` is built in: its front end is `@reactivescript/compiler/vite`, resolved from the +// project the first time an `.rx` module is compiled. +describe('built-in .rx', () => { + let rxRoot: string; + let rxServer: ViteDevServer; + const start = (dir: string) => + createServer({ + root: dir, + configFile: false, + appType: 'custom', + logLevel: 'silent', + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + plugins: [solidPlugin()], + }); + + beforeAll(async () => { + rxRoot = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-rx-'))); + const pkg = path.join(rxRoot, 'node_modules/@reactivescript/compiler'); + await mkdir(pkg, { recursive: true }); + await writeFile( + path.join(pkg, 'package.json'), + JSON.stringify({ + name: '@reactivescript/compiler', + type: 'module', + exports: { './vite': './vite.js' }, + }), + ); + await writeFile( + path.join(pkg, 'vite.js'), + 'export default () => ({ compile: (source, { id, moduleId }) => ({ code: `export default ${JSON.stringify(moduleId)};` }) });\n', + ); + await mkdir(path.join(rxRoot, 'src')); + await writeFile(path.join(rxRoot, 'src/main.rx'), 'export default component App() {}\n'); + rxServer = await start(rxRoot); + }); + + afterAll(async () => { + await rxServer?.close(); + await rm(rxRoot, { recursive: true, force: true }); + }); + + it('compiles .rx with the @reactivescript/compiler package the project installed', async () => { + const result = await rxServer.environments.client.transformRequest('/src/main.rx'); + expect(result?.code).toContain('main.rx'); + }); + + it('asks for the package when the project has none', async () => { + const bare = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-rx-bare-'))); + await mkdir(path.join(bare, 'src')); + await writeFile(path.join(bare, 'src/main.rx'), 'export default component App() {}\n'); + const server = await start(bare); + try { + await expect(server.environments.client.transformRequest('/src/main.rx')).rejects.toThrow( + /needs the `@reactivescript\/compiler` package/, + ); + } finally { + await server.close(); + await rm(bare, { recursive: true, force: true }); + } + }); +}); diff --git a/src/index.ts b/src/index.ts index 3b62e56..ca3c6ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import solid from '@solidjs/babel-plugin'; import { existsSync, readFileSync, realpathSync } from 'fs'; import { mergeAndConcat } from 'merge-anything'; import { createRequire } from 'module'; +import { pathToFileURL } from 'url'; import { createDevAssetResolver, registerDevAssetResolver, @@ -261,6 +262,57 @@ export interface ExtensionOptions { export type Compiler = 'babel' | 'native'; export type { SourceNamesOptions }; +/** + * Another front end for Solid (see `Options.frontends`): a language whose + * modules it compiles itself, to what `@solidjs/compiler` emits for a JSX + * module — Solid's output, no JSX left. The plugin then treats the module + * the way it treats lowered `.tsrx`: primitive names, `lazy()` module URLs, + * the HMR refresh pass, server functions, and the stylesheet as a CSS module. + */ +export interface Frontend { + /** + * Compiles one module for one environment. `null` leaves the module to + * the rest of the pipeline untouched. + */ + compile( + source: string, + context: FrontendContext, + ): FrontendResult | null | Promise; +} + +export interface FrontendContext { + /** The module's file path. */ + id: string; + /** + * The module's full id, query included: a front end that imports its own + * modules under a query (`./x.rx?dep`) tells the forms apart by it. + */ + moduleId: string; + /** Whether the module is compiled for a server environment. */ + ssr: boolean; + /** Vite's resolved project root. */ + root: string; + /** + * The options this plugin would hand `@solidjs/compiler` for a JSX module + * in this environment (`generate`, `hydratable`, `moduleName`, `dev`, …), + * so a front end that ends in Solid's compiler compiles alike. + */ + solid: SolidOptions & { root?: string }; + /** + * Marks a file the front end read for this module (an imported module's + * declarations, say): a change to it recompiles this module. + */ + addWatchFile(file: string): void; +} + +export interface FrontendResult { + code: string; + /** A source map from `code` back to the module's source. */ + map?: string | object | null; + /** The module's stylesheet; the module imports it as a CSS module. */ + css?: string; +} + // `root` (the project root `sourceNames.locations` paths are relative to) // is not a user option: the plugin passes Vite's resolved `config.root`. export type SolidOptions = Omit< @@ -495,6 +547,17 @@ export interface Options { * @default undefined */ extensions?: (string | [string, ExtensionOptions])[]; + /** + * Other front ends, by file extension (`{ ".x": x() }`): a module of that + * extension is compiled by the front end instead of Solid's JSX compiler, + * then takes the route lowered `.tsrx` takes (see `Frontend`). Its + * extension joins the default server-functions filter and the dependency + * scan. `.rx` (ReactiveScript) is built in, from the + * `@reactivescript/compiler` package; an entry for `.rx` here replaces it. + * + * @default undefined + */ + frontends?: Record; /** * Pass any additional babel transform options. They will be merged with * the transformations required by Solid. @@ -1191,6 +1254,38 @@ function collapseDotRuns( */ const SERVER_COMPONENTS_DEFINE = '__SOLID_SERVER_COMPONENTS__'; +/** + * `.rx` out of the box: ReactiveScript's front end, the default export of + * `@reactivescript/compiler/vite`, resolved from the project root the first + * time an `.rx` module is compiled. A project without `.rx` files never loads + * it. + */ +function lazyFrontend(pkg: string, extension: string): Frontend { + let loaded: Promise | undefined; + return { + async compile(source, context) { + loaded ??= (async () => { + let entry: string; + try { + entry = createRequire(path.join(context.root, 'package.json')).resolve(`${pkg}/vite`); + } catch { + throw new Error( + `[@solidjs/vite-plugin] compiling ${extension} needs the \`${pkg}\` package: install it in the project.`, + ); + } + const mod = await import(pathToFileURL(entry).href); + return (mod.default ?? mod)() as Frontend; + })(); + // a failed load is retried, so installing the package fixes a running server + const frontend = await loaded.catch((error) => { + loaded = undefined; + throw error; + }); + return frontend.compile(source, context); + }, + }; +} + export default function solidPlugin(options: Partial = {}): Plugin[] { if (typeof options.ssr === 'object') { throw new Error( @@ -1203,6 +1298,26 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // resolve against the Vite root, not process.cwd() — running `vite` from // outside the project would otherwise change what the filter matches. let filter = createFilter(options.include, options.exclude); + const frontends = Object.entries({ + '.rx': lazyFrontend('@reactivescript/compiler', '.rx'), + ...options.frontends, + }).map(([extension, frontend]) => { + if (!/^\.[\w.-]+$/.test(extension) || /^\.(?:[mc]?[jt]sx?|tsrx)$/i.test(extension)) { + throw new Error( + `[@solidjs/vite-plugin] \`frontends\` takes file extensions other than Solid's own, such as ".rx"; got ${JSON.stringify(extension)}.`, + ); + } + return [extension.toLowerCase(), frontend] as const; + }); + const frontendFor = (id: string): Frontend | undefined => { + if (frontends.length === 0) return undefined; + const path = cleanModuleId(id).toLowerCase(); + return frontends.find(([extension]) => path.endsWith(extension))?.[1]; + }; + // A module that reaches Solid's pipeline already lowered: `.tsrx` (lowered + // here, first) or another front end's. + const isLoweredModule = (id: string) => isTsrxModule(id) || !!frontendFor(id); + const scanExtensions = ['.tsrx', ...frontends.map(([extension]) => extension)]; const serverComponentsOption = typeof options.serverFunctions === 'object' ? options.serverFunctions.components : undefined; const serverComponents = !!serverComponentsOption; @@ -1293,8 +1408,28 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { const tsrxDepScanPlugin = { name: 'solid:tsrx-dep-scan', async transform(source: string, id: string) { - if (!isTsrxModule(id) || isTsrxCssModule(id)) return null; + if (!isLoweredModule(id) || isTsrxCssModule(id)) return null; const compiler = await loadNativeCompiler(); + const frontend = frontendFor(id); + if (frontend) { + const result = await frontend.compile(source, { + id: cleanModuleId(id), + moduleId: id, + ssr: false, + root: projectRoot, + solid: getSolidOptions( + options, + false, + replaceDev, + observe, + isTestMode, + 'native', + projectRoot, + ), + addWatchFile: () => {}, + }); + return result ? { code: result.code, map: null } : null; + } const result = await compiler.transformAsync(source, { ...getSolidOptions(options, false, replaceDev, observe, isTestMode, 'native', projectRoot), filename: cleanModuleId(id), @@ -1454,6 +1589,30 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { return typeof css === 'string' ? css : ''; } + async function compileLoweredCss(source: string, id: string): Promise { + const frontend = frontendFor(id); + if (frontend) { + const result = await frontend.compile(source, { + id: cleanModuleId(id), + moduleId: id, + ssr: false, + root: projectRoot, + solid: getSolidOptions( + options, + false, + replaceDev, + observe, + isTestMode, + 'native', + projectRoot, + ), + addWatchFile: () => {}, + }); + return result?.css ?? ''; + } + return compileTsrxCss(source, id); + } + async function compileTsrxCss(source: string, id: string): Promise { if (options.compiler === 'babel') { const solidOptions = getSolidOptions( @@ -1769,7 +1928,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { dedupe, }, optimizeDeps: { - extensions: ['.tsrx'], + extensions: scanExtensions, include: [ ...nestedDeps, // Dev refresh wrappers import the solid-js/refresh runtime in @@ -1918,8 +2077,11 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // outside Node turn discovery back on (@cloudflare/vite-plugin), #387. if (name !== 'client') { const optimizeDeps = (config.optimizeDeps ??= {}); - if (!optimizeDeps.extensions?.includes('.tsrx')) { - optimizeDeps.extensions = [...(optimizeDeps.extensions ?? []), '.tsrx']; + const missing = scanExtensions.filter( + (extension) => !optimizeDeps.extensions?.includes(extension), + ); + if (missing.length) { + optimizeDeps.extensions = [...(optimizeDeps.extensions ?? []), ...missing]; } const rolldownOptions = (optimizeDeps.rolldownOptions ??= {}); const transform = (rolldownOptions.transform ??= {}); @@ -2152,8 +2314,8 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { devAssetResolver.invalidate(); this.environment.hot.send({ type: 'custom', event: DEV_MANIFEST_INVALIDATE_EVENT }); } - if (isTsrxModule(file) && this.environment.name === 'client') { - updateTsrxCss(tsrxCss, file, await compileTsrxCss(await read(), file)); + if (isLoweredModule(file) && this.environment.name === 'client') { + updateTsrxCss(tsrxCss, file, await compileLoweredCss(await read(), file)); const cssModule = this.environment.moduleGraph.getModuleById(resolvedTsrxCssModuleId(file)); if (cssModule) { this.environment.moduleGraph.invalidateModule(cssModule); @@ -2211,7 +2373,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { for (const depId of info.dynamicallyImportedIds || []) { const cleanId = depId.split('?')[0]; if (/node_modules/.test(cleanId) || cleanId.startsWith('\0')) continue; - if (!(/\.[mc]?[tj]sx?$/i.test(cleanId) || isTsrxModule(cleanId))) continue; + if (!(/\.[mc]?[tj]sx?$/i.test(cleanId) || isLoweredModule(cleanId))) continue; if (emittedLazyChunks.has(depId)) continue; emittedLazyChunks.add(depId); emittedLazyChunkRefs.push( @@ -2392,12 +2554,16 @@ export default manifest;`; const moduleId = id; id = getTransformPath( id, - (path) => /\.[mc]?[tj]sx$/i.test(path) || allExtensions.includes(getExtension(path)), + (path) => + /\.[mc]?[tj]sx$/i.test(path) || + allExtensions.includes(getExtension(path)) || + !!frontendFor(path), ); // Read the extension from the query-free path, so a dot inside the // query (`a.mdx?x=1.5`) is not taken as the extension. const currentFileExtension = getExtension(id); const isTsrx = isTsrxModule(id); + const frontend = frontendFor(id); const inNodeModules = /node_modules/.test(id); // Primitive names stop at node_modules: a dependency's internals // (Solid's own flow controls included) name what they mean to name, @@ -2406,7 +2572,12 @@ export default manifest;`; const sourceNames = getSourceNames(options.solid?.sourceNames, replaceDev, observe); const namePrimitives = !inNodeModules && sourceNames.primitives; - if (!(/\.[mc]?[tj]sx$/i.test(id) || isTsrx || allExtensions.includes(currentFileExtension))) { + if (!( + /\.[mc]?[tj]sx$/i.test(id) || + isTsrx || + frontend || + allExtensions.includes(currentFileExtension) + )) { // Not a JSX module. Primitive naming stays gated as before // (`createSignal` lives in `.ts`/`.js` as much as in components). // The lazy module-URL pipeline also runs, and it is not gated on @@ -2525,9 +2696,10 @@ export default manifest;`; let code = source; const maps: ChainableMap[] = []; - // Authored TSRX cannot be parsed by a standalone pass; its primitives - // are named after Solid lowering, on the generated module, below. - if (namePrimitives && !isTsrx) { + // Authored TSRX (or another front end's source) cannot be parsed by a + // standalone pass; its primitives are named after Solid lowering, on + // the generated module, below. + if (namePrimitives && !isTsrx && !frontend) { const named = await transformPrimitiveNames( this, compiler, @@ -2542,7 +2714,7 @@ export default manifest;`; } } - if (isTsrx) { + if (isTsrx || frontend) { // Solid lowering preserves authored TypeScript annotations; secondary // passes therefore parse the generated module as TSX even though no // template syntax remains. @@ -2561,7 +2733,30 @@ export default manifest;`; }; let css = ''; - if (options.compiler !== 'babel') { + if (frontend) { + // Another front end lowers the module itself, through the options + // a JSX module of this environment would get from this plugin. + const result = await frontend.compile(code, { + id, + moduleId, + ssr: !!isSsr, + root: projectRoot, + solid: getSolidOptions( + options, + !!isSsr, + replaceDev, + observe, + isTestMode, + 'native', + projectRoot, + ), + addWatchFile: (file) => this.addWatchFile(file), + }); + if (!result) return null; + code = result.code; + css = result.css ?? ''; + maps.push(result.map as ChainableMap); + } else if (options.compiler !== 'babel') { const result = await compiler.transformAsync(code, { ...solidOptions, filename: id, @@ -2637,7 +2832,7 @@ export default manifest;`; } code = injectSsrModuleId(await resolveLazyModuleUrls(this, code, id), moduleId, !!isSsr); - let map = options.compiler === 'babel' ? combineSourcemaps(maps) : null; + let map = options.compiler === 'babel' || frontend ? combineSourcemaps(maps) : null; updateTsrxCss(tsrxCss, id, css); if (css) { code = prependTsrxCssImport(code, id); @@ -2788,6 +2983,7 @@ export default manifest;`; externalDevServer, tsrxAfterSolid: true, tsrxSourceMap: options.compiler === 'babel', + frontendExtensions: frontends.map(([extension]) => extension), // With start mode on (either variant), the dev middleware dispatches // the endpoint through the SSR handler so user middleware and the // stub-backed request event front it exactly like page SSR. diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts index ad43011..03afb22 100644 --- a/src/server-functions/index.ts +++ b/src/server-functions/index.ts @@ -380,10 +380,23 @@ export function serverFunctions( ssrHandler?: string; tsrxAfterSolid?: boolean; tsrxSourceMap?: boolean; + /** + * Other front ends' extensions (`Options.frontends`): their modules are + * in the default filter and, like `.tsrx`, compiled after Solid's. + */ + frontendExtensions?: string[]; } = {}, ): Plugin[] { - const filterInclude = options.filter?.include || DEFAULT_INCLUDE; - const filterExclude = options.filter?.exclude || DEFAULT_EXCLUDE; + const frontendExtensions = internal.frontendExtensions ?? []; + const withFrontends = (pattern: string) => + frontendExtensions.length + ? pattern.replace('tsrx,', `tsrx,${frontendExtensions.map((e) => e.slice(1)).join(',')},`) + : pattern; + const filterInclude = options.filter?.include || withFrontends(DEFAULT_INCLUDE); + const filterExclude = options.filter?.exclude || withFrontends(DEFAULT_EXCLUDE); + // Lowered before this pass sees it: `.tsrx`, or another front end's module. + const isLowered = (id: string) => + isTsrxModule(id) || frontendExtensions.some((e) => id.toLowerCase().endsWith(e)); // Recreated in configResolved: relative patterns (the defaults included) // must resolve against the Vite root, not process.cwd() — running `vite` // from outside the project would otherwise silently skip every module. @@ -724,7 +737,7 @@ export function serverFunctions( ) { const mode = getEnvironmentConsumer(ctx.environment, opts); const [id] = fileId.split('?'); - if (!id || !filter(id) || isTsrxModule(id) !== tsrx) return null; + if (!id || !filter(id) || isLowered(id) !== tsrx) return null; // The directive has to appear literally, so anything without the // substring can skip the native parse entirely. @@ -738,7 +751,8 @@ export function serverFunctions( mode, env, root, - sourceMap: !tsrx || !!internal.tsrxSourceMap, + // A front end's module arrives with its map (native `.tsrx` does not). + sourceMap: !tsrx || !!internal.tsrxSourceMap || !isTsrxModule(id), }); if (!result.valid) { diff --git a/src/tsrx.ts b/src/tsrx.ts index a411cbf..ca5a663 100644 --- a/src/tsrx.ts +++ b/src/tsrx.ts @@ -16,10 +16,10 @@ export function isTsrxCssModule(id: string): boolean { : id.startsWith(NULL_BYTE_PLACEHOLDER) ? id.slice(NULL_BYTE_PLACEHOLDER.length) : id; + // The query alone names the stylesheet module: `.tsrx` and other front + // ends' modules (`Options.frontends`) take it alike. const queryIndex = unwrapped.indexOf('?'); - if (queryIndex === -1 || !unwrapped.slice(0, queryIndex).toLowerCase().endsWith('.tsrx')) { - return false; - } + if (queryIndex === -1) return false; const params = unwrapped.slice(queryIndex + 1).split('&'); return params.includes('solid-tsrx-css') && params.includes('lang.css'); }