diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 54faed9..43134bd 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -1,11 +1,9 @@ -import type { CompileOptions, CompileResult } from './types'; +import { COMPILER_PLUGIN_NAME, SURIMI_CSS_EXPORT_NAME } from './constants'; +import { extractSurimiResult, type SurimiModule } from './extract'; +import type { CompileOptions } from './types'; -/** Minimal rolldown input shape; compatible with both 'rolldown' and '@rolldown/browser'. */ -export interface RolldownInput { - input: string; - cwd: string; - plugins: unknown[]; -} +export { COMPILER_PLUGIN_NAME, SURIMI_CSS_EXPORT_NAME } from './constants'; +export { extractSurimiResult, isSerializable, type SurimiModule } from './extract'; /** Base64-encode UTF-8 string; works in Node (Buffer) and browser (TextEncoder + btoa). */ function toBase64Utf8(str: string): string { @@ -20,9 +18,6 @@ function toBase64Utf8(str: string): string { return btoa(binary); } -export const SURIMI_CSS_EXPORT_NAME = '__SURIMI_GENERATED_CSS__'; -export const COMPILER_PLUGIN_NAME = 'surimi:compiler-transform'; - const DEV_SURIMI_PACKAGES = [ '/packages/surimi', '/packages/common', @@ -31,12 +26,7 @@ const DEV_SURIMI_PACKAGES = [ '/packages/conditional', ]; -interface SurimiModule extends Record { - default?: unknown; - [SURIMI_CSS_EXPORT_NAME]?: unknown; -} - -function createSurimiTransformPlugin(include: CompileOptions['include'], exclude: CompileOptions['exclude']) { +export function createSurimiTransformPlugin(include: CompileOptions['include'], exclude: CompileOptions['exclude']) { return { name: COMPILER_PLUGIN_NAME, transform: { @@ -53,7 +43,7 @@ export const ${SURIMI_CSS_EXPORT_NAME} = __surimi__instance__.build(); }; } -function createVirtualSourcePlugin(input: string, source: string) { +export function createVirtualSourcePlugin(input: string, source: string) { return { name: 'surimi:virtual-source', resolveId(id: string) { @@ -70,8 +60,6 @@ export function getRolldownInput(options: CompileOptions) { const { input, cwd, source } = options; - // When compiling inline source, the entry path may not match the user's include globs - // (e.g. a Vue SFC virtual path vs '**/*.css.ts'). Adding it ensures the surimi wrapper is applied. const effectiveInclude = source != null ? [...options.include, input] : options.include; const virtualSourcePlugin = source != null ? [createVirtualSourcePlugin(input, source)] : []; @@ -96,21 +84,14 @@ function rewriteDataUrlInError(error: Error, sourcePath: string): Error { return rewritten; } -/** - * Execute the compiled Surimi code and extract the CSS and preserved exports. - * - * Code, imports etc. are passed individually to support `BindingOutput` chunks from Rolldown watch mode - */ export async function getCompileResult( code: string, imports: string[], dynamicImports: string[], moduleIds: string[], sourcePath?: string, -): Promise { +) { const { css, js } = await execute(code, sourcePath); - - // Extract all imported modules as watch files const watchFiles = getModuleDependencies(imports, dynamicImports, moduleIds); return { @@ -121,46 +102,15 @@ export async function getCompileResult( }; } -/** - * Executes the compiled Surimi code in a data URL module context - * and extracts the generated CSS and preserved exports. - * When sourcePath is provided, errors are rewritten to show it instead of the data: URL. - */ export async function execute(code: string, sourcePath?: string) { try { - // Dynamic import with variable URL so Vite (and other bundlers) don't try to pre-bundle this data URL const dataUrl = `data:text/javascript;base64,${toBase64Utf8(code)}`; const module = (await import( - // TODO: Fix this. We need to preserve the vite-ignore comment so this import isn't flagged - // by vite, as it cannot be analyzed. @preserve doesn't work for some reason. //! @vite-ignore dataUrl )) as SurimiModule; - // Get the generated CSS - const cssValue = module[SURIMI_CSS_EXPORT_NAME] ?? ''; - const css = typeof cssValue === 'string' ? cssValue : ''; - - // Collect all exports except the special CSS export and default. - // We only re-export values that can be JSON-serialized (so they can be inlined in the output). - const exports: string[] = []; - for (const [key, value] of Object.entries(module)) { - if (key !== 'default' && key !== SURIMI_CSS_EXPORT_NAME) { - if (!isSerializable(value)) { - continue; - } - let serialized: string; - try { - serialized = JSON.stringify(value); - exports.push(`export const ${key} = ${serialized};`); - } catch {} - } - } - - // Generate the transformed JS - const js = exports.length > 0 ? exports.join('\n') : ''; - - return { css, js }; + return extractSurimiResult(module); } catch (error) { if (error instanceof Error) { if (sourcePath) { @@ -183,13 +133,6 @@ export async function execute(code: string, sourcePath?: string) { } } -// Type guard to check if a value is serializable to JSON -function isSerializable(value: unknown): value is string | number | boolean | null | object { - const type = typeof value; - return type === 'string' || type === 'number' || type === 'boolean' || value === null || type === 'object'; -} - -// Validates compilation options - throws Error if options are invalid function validateCompileOptions(options: CompileOptions): void { if (!options.input || typeof options.input !== 'string') { throw new Error('input must be a non-empty string'); @@ -209,21 +152,13 @@ function validateCompileOptions(options: CompileOptions): void { } } -/** - * Extracts module dependencies from the Rolldown output chunk. - * - * Will exclude dependencies from `node_modules`, rolldown runtime modules - * and development Surimi packages (only relevant in development). - */ function getModuleDependencies(imports: string[], dynamicImports: string[], moduleIds: string[]): string[] { const watchFiles: string[] = []; - // Add all imports from the rolldown output if (imports.length > 0) { watchFiles.push(...imports); } - // Add dynamic imports if any if (dynamicImports.length > 0) { watchFiles.push(...dynamicImports); } @@ -241,8 +176,6 @@ function getModuleDependencies(imports: string[], dynamicImports: string[], modu return watchFiles; } -// Checks if a module ID is from the development surimi or parsers packages -// Development files are not tracked in watch mode as they're part of the library itself function isDevelopmentSurimiFile(id: string): boolean { return DEV_SURIMI_PACKAGES.some(pkgPath => id.includes(pkgPath)); } diff --git a/packages/compiler/src/constants.ts b/packages/compiler/src/constants.ts new file mode 100644 index 0000000..8223476 --- /dev/null +++ b/packages/compiler/src/constants.ts @@ -0,0 +1,2 @@ +export const SURIMI_CSS_EXPORT_NAME = '__SURIMI_GENERATED_CSS__'; +export const COMPILER_PLUGIN_NAME = 'surimi:compiler-transform'; diff --git a/packages/compiler/src/extract.ts b/packages/compiler/src/extract.ts new file mode 100644 index 0000000..1e972b7 --- /dev/null +++ b/packages/compiler/src/extract.ts @@ -0,0 +1,33 @@ +import { SURIMI_CSS_EXPORT_NAME } from './constants'; + +export interface SurimiModule extends Record { + default?: unknown; + [SURIMI_CSS_EXPORT_NAME]?: unknown; +} + +export function isSerializable(value: unknown): value is string | number | boolean | null | object { + const type = typeof value; + return type === 'string' || type === 'number' || type === 'boolean' || value === null || type === 'object'; +} + +/** Extract CSS and JSON-serializable exports from an evaluated surimi module namespace. */ +export function extractSurimiResult(module: SurimiModule): { css: string; js: string } { + const cssValue = module[SURIMI_CSS_EXPORT_NAME] ?? ''; + const css = typeof cssValue === 'string' ? cssValue : ''; + + const exports: string[] = []; + // Sort by code-unit order to match ES module-namespace key ordering, which is how the + // rolldown `execute()` path enumerated exports before this was shared. Keeps both evaluators + // deterministic and byte-identical (see parity tests). + for (const [key, value] of Object.entries(module).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + if (key === 'default' || key === SURIMI_CSS_EXPORT_NAME) continue; + if (!isSerializable(value)) continue; + try { + exports.push(`export const ${key} = ${JSON.stringify(value)};`); + } catch { + // skip values that fail JSON serialization + } + } + + return { css, js: exports.length > 0 ? exports.join('\n') : '' }; +} diff --git a/packages/compiler/src/index.browser.ts b/packages/compiler/src/index.browser.ts index 5e3afff..84684e5 100644 --- a/packages/compiler/src/index.browser.ts +++ b/packages/compiler/src/index.browser.ts @@ -2,10 +2,29 @@ import type { RolldownWatcher, RolldownWatcherEvent } from '@rolldown/browser'; import { rolldown, watch } from '@rolldown/browser'; import { createCompile, type RolldownApi } from './compile-api'; +import { + COMPILER_PLUGIN_NAME, + createSurimiTransformPlugin, + createVirtualSourcePlugin, + extractSurimiResult, + isSerializable, + SURIMI_CSS_EXPORT_NAME, + type SurimiModule, +} from './compiler'; import type { CompileOptions, CompileResult, WatchOptions } from './types'; const { compile, compileWatch } = createCompile({ rolldown, watch } as RolldownApi); export type { CompileOptions, CompileResult, RolldownWatcher, RolldownWatcherEvent, WatchOptions }; -export { compile, compileWatch }; +export { + COMPILER_PLUGIN_NAME, + compile, + compileWatch, + createSurimiTransformPlugin, + createVirtualSourcePlugin, + extractSurimiResult, + isSerializable, + SURIMI_CSS_EXPORT_NAME, + type SurimiModule, +}; diff --git a/packages/compiler/src/index.node.ts b/packages/compiler/src/index.node.ts index 33dc4b1..318b9ed 100644 --- a/packages/compiler/src/index.node.ts +++ b/packages/compiler/src/index.node.ts @@ -2,10 +2,29 @@ import type { RolldownWatcher, RolldownWatcherEvent } from 'rolldown'; import { rolldown, watch } from 'rolldown'; import { createCompile, type RolldownApi } from './compile-api'; +import { + COMPILER_PLUGIN_NAME, + createSurimiTransformPlugin, + createVirtualSourcePlugin, + extractSurimiResult, + isSerializable, + SURIMI_CSS_EXPORT_NAME, + type SurimiModule, +} from './compiler'; import type { CompileOptions, CompileResult, WatchOptions } from './types'; const { compile, compileWatch } = createCompile({ rolldown, watch } as RolldownApi); export type { CompileOptions, CompileResult, RolldownWatcher, RolldownWatcherEvent, WatchOptions }; -export { compile, compileWatch }; +export { + COMPILER_PLUGIN_NAME, + compile, + compileWatch, + createSurimiTransformPlugin, + createVirtualSourcePlugin, + extractSurimiResult, + isSerializable, + SURIMI_CSS_EXPORT_NAME, + type SurimiModule, +}; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 11469c9..d77c957 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -2,6 +2,15 @@ * Default entry: Node (uses native `rolldown` for CLI and watch). * For browser/WASM use the "browser" export: @surimi/compiler/browser */ +export { + COMPILER_PLUGIN_NAME, + createSurimiTransformPlugin, + createVirtualSourcePlugin, + extractSurimiResult, + isSerializable, + SURIMI_CSS_EXPORT_NAME, + type SurimiModule, +} from './compiler'; export { type CompileOptions, type CompileResult, diff --git a/packages/compiler/src/types.ts b/packages/compiler/src/types.ts index a8db6e5..3126a97 100644 --- a/packages/compiler/src/types.ts +++ b/packages/compiler/src/types.ts @@ -37,6 +37,12 @@ export interface CompileResult { js: string; /** List of file dependencies. Can be used for HMR, watch mode etc. */ dependencies: string[]; + /** + * Canonical absolute paths of bare (query-less) side-effect asset imports (e.g. plain `.css`) + * that must be re-emitted as imports in the client output. Query/value imports (`?raw`, `?url`, + * `?inline`) are deliberately excluded: their content is already baked into `css`/`js`. + */ + sideEffectDependencies?: string[]; /** Duration of the compilation in milliseconds */ duration: number; } diff --git a/packages/vite-plugin-surimi/src/normalize-module-id.ts b/packages/vite-plugin-surimi/src/normalize-module-id.ts new file mode 100644 index 0000000..074a24a --- /dev/null +++ b/packages/vite-plugin-surimi/src/normalize-module-id.ts @@ -0,0 +1,60 @@ +import { existsSync, realpathSync } from 'node:fs'; +import path from 'node:path'; +import { normalizePath } from 'vite'; + +import { VIRTUAL_CSS_SUFFIX } from './constants.js'; + +/** + * Canonical, absolute, symlink-resolved, posix module id. Used as the single key for caches, the + * module runner, and dependency graphs so one file is never tracked under two shapes (e.g. macOS + * `/var` vs `/private/var`, or a Vite root-relative URL like `/src/x` vs its on-disk path). + */ +export function normalizeModuleId(id: string, root?: string): string { + const cleanId = id.split('?')[0] ?? id; + const absoluteId = toAbsolute(cleanId, root); + + try { + return normalizePath(realpathSync.native?.(absoluteId) ?? realpathSync(absoluteId)); + } catch { + return normalizePath(absoluteId); + } +} + +function toAbsolute(cleanId: string, root?: string): string { + if (!path.isAbsolute(cleanId)) { + return root ? path.join(root, cleanId) : path.resolve(cleanId); + } + // Vite emits root-relative URLs ("/src/x") that also pass path.isAbsolute. When the literal path + // is missing but the rooted one exists, it was a URL — resolve it against root. + if (root && !existsSync(cleanId)) { + const rooted = path.join(root, cleanId.slice(1)); + if (existsSync(rooted)) return rooted; + } + return cleanId; +} + +/** + * Absolutize an id that may be root-relative even when it does not exist on disk (e.g. virtual CSS + * ids). Falls back to a first-segment comparison so non-existent root-relative URLs still rebase. + */ +export function toAbsoluteModuleId(filePath: string, root: string): string { + const alreadyAbsolute = + filePath.startsWith(root) || + (path.isAbsolute(filePath) && filePath.split(path.posix.sep)[1] === root.split(path.posix.sep)[1]); + return normalizeModuleId(alreadyAbsolute ? filePath : path.join(root, filePath), root); +} + +export function toImportPath(dependencyId: string, ownerId: string, root?: string): string { + const absoluteDependency = normalizeModuleId(dependencyId, root); + const absoluteOwner = normalizeModuleId(ownerId, root); + const relative = path.relative(path.dirname(absoluteOwner), absoluteDependency); + const posixRelative = relative.split(path.sep).join(path.posix.sep); + return posixRelative.startsWith('.') ? posixRelative : `./${posixRelative}`; +} + +export function toVirtualCssImportPath(sourceId: string, ownerId: string, root?: string): string { + return `${toImportPath(sourceId, ownerId, root)}${VIRTUAL_CSS_SUFFIX}`; +} + +export const toVirtualCssId = (sourceId: string): string => `${sourceId}${VIRTUAL_CSS_SUFFIX}`; +export const fromVirtualCssId = (virtualId: string): string => virtualId.replace(VIRTUAL_CSS_SUFFIX, ''); diff --git a/packages/vite-plugin-surimi/src/plugin.ts b/packages/vite-plugin-surimi/src/plugin.ts index 53fc6cd..90b996d 100644 --- a/packages/vite-plugin-surimi/src/plugin.ts +++ b/packages/vite-plugin-surimi/src/plugin.ts @@ -1,10 +1,18 @@ import path from 'node:path'; import type { CompileResult } from '@surimi/compiler'; -import { compile } from '@surimi/compiler'; -import type { EnvironmentModuleGraph, EnvironmentModuleNode, Plugin, ResolvedConfig } from 'vite'; -import { createFilter, normalizePath } from 'vite'; +import type { EnvironmentModuleGraph, EnvironmentModuleNode, Plugin } from 'vite'; +import { createFilter } from 'vite'; import { VIRTUAL_CSS_REGEX, VIRTUAL_CSS_SUFFIX, VIRTUAL_SURIMI_PATH_REGEX } from './constants.js'; +import { + fromVirtualCssId, + normalizeModuleId, + toAbsoluteModuleId, + toImportPath, + toVirtualCssId, + toVirtualCssImportPath, +} from './normalize-module-id.js'; +import { SurimiEvaluator } from './runner.js'; import type { SharedPluginContext, SurimiOptions } from './types.js'; import { addWatchFilesForDeps, createSourceMap, injectCssChunk } from './utils.js'; import { @@ -22,7 +30,6 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { const { include = ['**/*.css.{ts,js}'], exclude = ['node_modules/**', '**/*.d.ts'], inlineCss = false } = options; const tsFileFilter = createFilter(include, exclude); - // Shared state accessible by framework-specific plugins (Vue, etc.) const ctx: SharedPluginContext = { compilationCache: new Map(), include, @@ -30,59 +37,85 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { inlineCss, resolvedConfig: undefined, isDev: undefined, + evaluator: undefined, }; - // Track files we've already added to watch list to avoid duplicates const filesWatched = new Set(); const normalizeDependencyId = (dependencyId: string, ownerId: string): string => { - // Rolldown mixes relative, absolute, and virtual ("\0") ids. Normalizing avoids cache misses when Vite - // requests the same file under a different shape (e.g. absolute path during SSR versus relative in dev). const cleanId = dependencyId.split('?')[0] ?? dependencyId; if (cleanId.startsWith('\0')) return cleanId; - if (path.isAbsolute(cleanId)) return normalizePath(cleanId); - if (ctx.resolvedConfig) return getAbsoluteId(cleanId, ctx.resolvedConfig); + if (path.isAbsolute(cleanId)) { + return normalizeModuleId(cleanId, ctx.resolvedConfig?.root); + } + if (ctx.resolvedConfig) { + return toAbsoluteModuleId(cleanId, ctx.resolvedConfig.root); + } - return normalizePath(path.resolve(path.dirname(ownerId), cleanId)); + return normalizeModuleId(path.resolve(path.dirname(ownerId), cleanId)); }; ctx.normalizeDependencyId = normalizeDependencyId; - const getCompilationResult = async (id: string): Promise => { - if (!ctx.compilationCache.has(id)) { - const compileResult = await compile({ - input: normalizePath(id), - cwd: ctx.resolvedConfig?.root ?? process.cwd(), + const ensureEvaluator = (): SurimiEvaluator => { + if (!ctx.resolvedConfig) { + throw new Error('Surimi evaluator accessed before config was resolved'); + } + + if (!ctx.evaluator) { + ctx.evaluator = new SurimiEvaluator({ + root: ctx.resolvedConfig.root, include, exclude, + resolvedConfig: ctx.resolvedConfig, + userPlugins: ctx.resolvedConfig.plugins.flat(), }); + } - if (compileResult) { - const normalizedResult: CompileResult = { - ...compileResult, - dependencies: compileResult.dependencies.map((dependencyId: string) => - normalizeDependencyId(dependencyId, id), - ), - }; + return ctx.evaluator; + }; - // Storing the normalized graph ensures follow-up builds (virtual CSS loads, HMR invalidations) - // can reuse work instead of recompiling the same module under multiple cache keys. - ctx.compilationCache.set(id, normalizedResult); - } + const getCompilationResult = async ( + id: string, + options: { source?: string } = {}, + ): Promise => { + const normalizedId = normalizeModuleId(id, ctx.resolvedConfig?.root); + + if (!ctx.compilationCache.has(normalizedId)) { + const evaluator = ensureEvaluator(); + const compileResult = await evaluator.evaluate(normalizedId, options); + + const normalizedResult: CompileResult = { + ...compileResult, + dependencies: compileResult.dependencies.map((dependencyId: string) => + normalizeDependencyId(dependencyId, normalizedId), + ), + ...(compileResult.sideEffectDependencies + ? { + sideEffectDependencies: compileResult.sideEffectDependencies.map((dependencyId: string) => + normalizeDependencyId(dependencyId, normalizedId), + ), + } + : {}), + }; + + ctx.compilationCache.set(normalizedId, normalizedResult); } - const cacheEntry = ctx.compilationCache.get(id); + const cacheEntry = ctx.compilationCache.get(normalizedId); if (!cacheEntry) throw new Error('Unexpected missing cache entry'); return cacheEntry; }; + ctx.getCompilationResult = getCompilationResult; + const collectDependentModules = ( changedFile: string, moduleGraph: EnvironmentModuleGraph, ): EnvironmentModuleNode[] => { const modules: EnvironmentModuleNode[] = []; - const normalizedChangedFile = normalizePath(changedFile); + const normalizedChangedFile = normalizeModuleId(changedFile, ctx.resolvedConfig?.root); for (const [cachedFile] of ctx.compilationCache) { const isRelevant = tsFileFilter(cachedFile) || VUE_SURIMI_VIRTUAL_PATH_RE.test(cachedFile); @@ -91,6 +124,7 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { const cacheEntry = ctx.compilationCache.get(cachedFile); if (cacheEntry?.dependencies.includes(normalizedChangedFile)) { ctx.compilationCache.delete(cachedFile); + ctx.evaluator?.invalidate(cachedFile); modules.push(...collectModulesForInvalidation(cachedFile, moduleGraph, inlineCss)); } } @@ -98,24 +132,33 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { return modules; }; - const generateJsWithHmr = (js: string, css: string, id: string, styleDependencies: string[]): string => { + const generateJsWithHmr = ( + js: string, + css: string, + id: string, + styleDependencies: string[], + sideEffectDependencies: string[], + ): string => { + const root = ctx.resolvedConfig?.root; + const sideEffectImports = sideEffectDependencies.map( + dependencyId => `import "${toImportPath(dependencyId, id, root)}";`, + ); + let jsCode: string; if (inlineCss) { const inliningSnippet = injectCssChunk(css, id, ctx.isDev ?? false); - jsCode = `${js}\n${inliningSnippet}`; + jsCode = `${js}\n${Array.from(new Set(sideEffectImports)).join('\n')}\n${inliningSnippet}`.trim(); } else { - const cssImports = new Set(); + const cssImports = new Set(sideEffectImports); for (const dependency of styleDependencies) { if (dependency === id) continue; - cssImports.add(`import "${getVirtualCssId(dependency)}";`); + cssImports.add(`import "${toVirtualCssImportPath(dependency, id, root)}";`); } - cssImports.add(`import "${getVirtualCssId(id)}";`); + cssImports.add(`import "${toVirtualCssImportPath(id, id, root)}";`); - // Importing every dependent virtual CSS module lets Vite handle deduplication while ensuring shared - // style files (like theme definitions) still emit their own chunks once per entry. jsCode = `${js}\n${Array.from(cssImports).join('\n')}`; } @@ -128,6 +171,15 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { const corePlugin: Plugin = { name: 'vite-plugin-surimi', + config(config) { + if (config.build?.ssr) { + return { + build: { + ssrEmitAssets: true, + }, + }; + } + }, configResolved(config) { ctx.resolvedConfig = config; ctx.isDev = config.command === 'serve'; @@ -137,6 +189,14 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { 'Surimi is still in early development. Please report any issues you encounter at https://github.com/surimidev/surimi\n', ); }, + async buildEnd() { + await ctx.evaluator?.close(); + ctx.evaluator = undefined; + }, + async closeBundle() { + await ctx.evaluator?.close(); + ctx.evaluator = undefined; + }, async hotUpdate({ file, modules, timestamp, type }) { if (type !== 'update') return; @@ -150,10 +210,11 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { ); if (vueResult !== undefined) return vueResult; - const normalizedFile = normalizePath(file); + const normalizedFile = normalizeModuleId(file, ctx.resolvedConfig?.root); if (tsFileFilter(file)) { ctx.compilationCache.delete(normalizedFile); + ctx.evaluator?.invalidate(normalizedFile); const additionalModules = [ ...collectModulesForInvalidation(normalizedFile, this.environment.moduleGraph, inlineCss), ...collectDependentModules(normalizedFile, this.environment.moduleGraph), @@ -162,6 +223,7 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { return [...modules, ...additionalModules]; } } else { + ctx.evaluator?.invalidate(normalizedFile); const additionalModules = collectDependentModules(normalizedFile, this.environment.moduleGraph); if (additionalModules.length > 0) { return [...modules, ...additionalModules]; @@ -174,18 +236,30 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { include: [VIRTUAL_CSS_REGEX, VIRTUAL_SURIMI_PATH_REGEX], }, }, - handler(source) { + handler(source, importer) { if (!ctx.resolvedConfig) throw new Error('resolveId called before config was resolved'); + const { root } = ctx.resolvedConfig; const [validId, query] = source.split('?'); - const absoluteId = getAbsoluteId(validId ?? '', ctx.resolvedConfig); + const withQuery = (resolved: string) => (query ? `${resolved}?${query}` : resolved); + + // Relative virtual imports (e.g. "./styles.css.ts.surimi.css") resolve against the importer. + const resolveRelativeToImporter = (relativeId: string): string => + normalizeModuleId(path.join(path.dirname(importer?.split('?')[0] ?? importer ?? ''), relativeId), root); if (validId?.endsWith(VIRTUAL_CSS_SUFFIX)) { - return query ? `${absoluteId}?${query}` : absoluteId; + const absoluteId = + importer && !path.isAbsolute(validId) ? resolveRelativeToImporter(validId) : toAbsoluteModuleId(validId, root); + return withQuery(absoluteId); } - // Bare virtual path (e.g. from compiler output): resolve to virtual CSS module so load() can serve from cache. + + const absoluteId = toAbsoluteModuleId(validId ?? '', root); if (VIRTUAL_SURIMI_PATH_REGEX.test(validId ?? '') && ctx.compilationCache.has(absoluteId)) { - return query ? `${getVirtualCssId(absoluteId)}?${query}` : getVirtualCssId(absoluteId); + const virtualCssId = + importer && !path.isAbsolute(validId ?? '') + ? resolveRelativeToImporter(`${validId}${VIRTUAL_CSS_SUFFIX}`) + : toVirtualCssId(absoluteId); + return withQuery(virtualCssId); } return null; }, @@ -200,14 +274,11 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { if (!ctx.resolvedConfig) throw new Error('load handler called before config was resolved'); const [validId] = id.split('?'); - // Load virtual CSS files. Surimi TS files are handled in transform() if (validId?.endsWith(VIRTUAL_CSS_SUFFIX)) { - const originalId = getSourceIdFromVirtual(validId); - // In SSR Mode, we can end up with paths like /src/styles.css.ts - const absoluteId = getAbsoluteId(originalId, ctx.resolvedConfig); + const absoluteId = normalizeModuleId(fromVirtualCssId(validId), ctx.resolvedConfig.root); + let cacheEntry = ctx.compilationCache.get(absoluteId); - // If cache entry is missing (e.g., during HMR), regenerate it if (!cacheEntry && tsFileFilter(absoluteId)) { this.debug(`Regenerating cache for: ${absoluteId}`); cacheEntry = await getCompilationResult(absoluteId); @@ -237,20 +308,22 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { this.error('The inlineCss option is not supported during SSR builds.'); } + // Normalize once: the cached `dependencies` are normalized, so comparing them against a + // raw `id` (e.g. a symlink path under resolve.preserveSymlinks) would break self-exclusion. + const normalizedId = normalizeModuleId(id, ctx.resolvedConfig?.root); + try { - const { css, js, dependencies } = await getCompilationResult(id); + const { css, js, dependencies, sideEffectDependencies } = await getCompilationResult(normalizedId); const styleDependencies = dependencies.filter( - dependencyId => dependencyId !== id && tsFileFilter(dependencyId), + dependencyId => dependencyId !== normalizedId && tsFileFilter(dependencyId), ); - // Pre-building nested style files guarantees their virtual CSS modules exist before Vite tries to - // load them, avoiding waterfalls where parents compile successfully but dependants 404. for (const dependencyId of styleDependencies) { await getCompilationResult(dependencyId); } - const jsCode = generateJsWithHmr(js, css, id, styleDependencies); + const jsCode = generateJsWithHmr(js, css, normalizedId, styleDependencies, sideEffectDependencies ?? []); if (ctx.isDev && !options?.ssr) { const addWatch = this.addWatchFile.bind(this); @@ -260,7 +333,8 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { const lineCount = (jsCode.match(/\n/g)?.length ?? 0) + 1; return { code: jsCode, - map: createSourceMap(path.basename(id), path.basename(id), lineCount), + map: createSourceMap(path.basename(normalizedId), path.basename(normalizedId), lineCount), + moduleSideEffects: 'no-treeshake', }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -273,17 +347,6 @@ export default function surimiPlugin(options: SurimiOptions = {}): Plugin[] { return [corePlugin, createVuePlugin(ctx)]; } -/** Normalize to absolute path; handles root-relative paths used in some SSR setups (e.g. Astro). */ -function getAbsoluteId(filePath: string, config: ResolvedConfig): string { - const alreadyAbsolute = - filePath.startsWith(config.root) || - (path.isAbsolute(filePath) && filePath.split(path.posix.sep)[1] === config.root.split(path.posix.sep)[1]); - return normalizePath(alreadyAbsolute ? filePath : path.join(config.root, filePath)); -} - -const getVirtualCssId = (sourceId: string): string => `${sourceId}${VIRTUAL_CSS_SUFFIX}`; -const getSourceIdFromVirtual = (virtualId: string): string => virtualId.replace(VIRTUAL_CSS_SUFFIX, ''); - function collectModulesForInvalidation( fileId: string, moduleGraph: EnvironmentModuleGraph, @@ -300,7 +363,7 @@ function collectModulesForInvalidation( addModule(fileId); if (!inlineCss) { - addModule(getVirtualCssId(fileId)); + addModule(toVirtualCssId(fileId)); } return modules; } diff --git a/packages/vite-plugin-surimi/src/runner.ts b/packages/vite-plugin-surimi/src/runner.ts new file mode 100644 index 0000000..237e5de --- /dev/null +++ b/packages/vite-plugin-surimi/src/runner.ts @@ -0,0 +1,290 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import type { CompileResult } from '@surimi/compiler'; +import { createSurimiTransformPlugin, extractSurimiResult, type SurimiModule } from '@surimi/compiler'; +import type { InlineConfig, Plugin, ResolvedConfig, ViteDevServer } from 'vite'; +import { createServer, createServerModuleRunner, normalizePath } from 'vite'; +import type { ModuleRunner } from 'vite/module-runner'; + +import { normalizeModuleId } from './normalize-module-id.js'; + +const DEV_SURIMI_PACKAGES = [ + '/packages/surimi', + '/packages/common', + '/packages/parsers', + '/packages/core', + '/packages/conditional', + '/packages/theme', +]; + +const VUE_BLOCK_INCLUDE = '**/*.__surimi_*.css.ts'; + +export interface EvaluateSurimiFileOptions extends SurimiEvaluatorOptions { + source?: string; +} + +export interface SurimiEvaluatorOptions { + root: string; + include: string[]; + exclude: string[]; + resolvedConfig?: ResolvedConfig; + userPlugins?: Plugin[]; + resolve?: InlineConfig['resolve']; + /** Additional prefix aliases resolved relative to root (used by tests and host config seeding). */ + prefixAliases?: Record; + /** Which host plugins to seed into the owned server. Default drops this plugin only. */ + pluginFilter?: (plugin: Plugin) => boolean; +} + +function defaultPluginFilter(plugin: Plugin): boolean { + if (!plugin || typeof plugin !== 'object') return false; + if (!('name' in plugin) || typeof plugin.name !== 'string') return true; + if (plugin.name === 'vite-plugin-surimi') return false; + if (plugin.name === 'vite-plugin-surimi:vue') return false; + return true; +} + +function isDevelopmentSurimiFile(id: string): boolean { + return DEV_SURIMI_PACKAGES.some(pkgPath => id.includes(pkgPath)); +} + +function filterUserPlugins(plugins: Plugin[], pluginFilter: (plugin: Plugin) => boolean): Plugin[] { + return plugins.flat().filter((plugin): plugin is Plugin => pluginFilter(plugin)); +} + +function createPrefixAliasPlugin(prefixAliases: Record): Plugin { + return { + name: 'surimi:prefix-alias', + enforce: 'pre', + resolveId(source) { + for (const [prefix, targetDir] of Object.entries(prefixAliases)) { + if (source === prefix || source.startsWith(`${prefix}/`)) { + const remainder = source === prefix ? '' : source.slice(prefix.length + 1); + return remainder ? path.join(targetDir, remainder) : targetDir; + } + } + }, + }; +} + +function createSurimiCssTsResolvePlugin(): Plugin { + return { + name: 'surimi:css-ts-resolve', + enforce: 'pre', + resolveId: { + filter: { id: /\.css$/ }, + handler(source, importer) { + if (!importer || source.includes('?')) return null; + + const [cleanSource] = source.split('?'); + if (!cleanSource?.endsWith('.css')) return null; + + const absoluteCss = normalizePath(path.resolve(path.dirname(importer), cleanSource)); + if (existsSync(absoluteCss)) return null; + + // Surimi convention: `import { theme } from './theme.css'` authors `theme.css.ts`. + // Append the source extension (`theme.css` -> `theme.css.ts`); do NOT drop `.css`. + for (const ext of ['.ts', '.js']) { + const candidate = `${absoluteCss}${ext}`; + if (existsSync(candidate)) return candidate; + } + + return null; + }, + }, + }; +} + +function createVirtualSourcesPlugin(virtualSources: Map): Plugin { + return { + name: 'surimi:virtual-sources', + resolveId(id) { + if (virtualSources.has(id)) return id; + }, + load(id) { + return virtualSources.get(id) ?? null; + }, + }; +} + +const SIDE_EFFECT_ASSET_REGEX = /\.(css|scss|sass|less)$/i; + +interface CollectedDependencies { + /** Canonical, query-stripped absolute paths (watch / HMR / module-graph / style-dep detection). */ + dependencies: string[]; + /** Canonical paths of bare (query-less) side-effect asset imports to re-emit in client output. */ + sideEffectDependencies: string[]; +} + +/** + * Walk the evaluated module graph once. Classification that depends on the import *query* + * (`?raw`/`?url`/`?inline` are value imports, a bare `./reset.css` is a side effect) happens here, + * where the original specifier is still intact, so no downstream layer has to reconstruct it from a + * lossy canonical path. + */ +function collectDependencies(server: ViteDevServer, entryId: string, root: string): CollectedDependencies { + const deps = new Set(); + // A path imported bare (query-less) anywhere is a side effect; a value import (`?raw`/`?url`/ + // `?inline`) never is — its content is already baked into the extracted css/js. + const sideEffects = new Set(); + const moduleGraph = server.environments.ssr.moduleGraph; + const visited = new Set(); + + const visit = (moduleId: string) => { + const normalized = normalizeModuleId(moduleId.split('?')[0] ?? moduleId, root); + if (visited.has(normalized)) return; + visited.add(normalized); + + const mod = moduleGraph.getModuleById(normalized) ?? moduleGraph.getModuleById(moduleId); + if (!mod) return; + + for (const imported of mod.importedModules) { + const importedId = imported.id ?? imported.url; + if (!importedId) continue; + + const hadQuery = importedId.includes('?'); + const cleanId = importedId.split('?')[0] ?? importedId; + if (cleanId.includes('node_modules')) continue; + if (isDevelopmentSurimiFile(cleanId)) continue; + if (cleanId.startsWith('\0')) continue; + + const normalizedImport = normalizeModuleId(cleanId, root); + deps.add(normalizedImport); + + if (!hadQuery && SIDE_EFFECT_ASSET_REGEX.test(cleanId)) { + sideEffects.add(normalizedImport); + } + + visit(normalizedImport); + } + }; + + visit(entryId); + deps.add(normalizeModuleId(entryId.split('?')[0] ?? entryId, root)); + + return { dependencies: [...deps], sideEffectDependencies: [...sideEffects] }; +} + +export class SurimiEvaluator { + private server: ViteDevServer | null = null; + private runner: ModuleRunner | null = null; + private serverInit: Promise<{ server: ViteDevServer; runner: ModuleRunner }> | null = null; + private readonly virtualSources = new Map(); + private readonly normalizedRoot: string; + private readonly harnessInclude: string[]; + private readonly pluginFilter: (plugin: Plugin) => boolean; + + constructor(private readonly options: SurimiEvaluatorOptions) { + this.normalizedRoot = normalizeModuleId(options.root); + this.harnessInclude = [...options.include, VUE_BLOCK_INCLUDE]; + this.pluginFilter = options.pluginFilter ?? defaultPluginFilter; + } + + private async ensureServer(): Promise<{ server: ViteDevServer; runner: ModuleRunner }> { + if (this.server && this.runner) { + return { server: this.server, runner: this.runner }; + } + // Single-flight: concurrent evaluate() calls must share one owned server, not race to create + // (and leak) several. The first caller installs the init promise; the rest await it. + if (this.serverInit) return this.serverInit; + + this.serverInit = this.createOwnedServer(); + try { + const created = await this.serverInit; + this.server = created.server; + this.runner = created.runner; + return created; + } catch (error) { + this.serverInit = null; + throw error; + } + } + + private async createOwnedServer(): Promise<{ server: ViteDevServer; runner: ModuleRunner }> { + const hostConfig = this.options.resolvedConfig; + const prefixAliases = this.options.prefixAliases; + const plugins: Plugin[] = [ + ...filterUserPlugins(this.options.userPlugins ?? hostConfig?.plugins.flat() ?? [], this.pluginFilter), + ...(prefixAliases && Object.keys(prefixAliases).length > 0 ? [createPrefixAliasPlugin(prefixAliases)] : []), + createSurimiCssTsResolvePlugin(), + createVirtualSourcesPlugin(this.virtualSources), + createSurimiTransformPlugin(this.harnessInclude, this.options.exclude), + ]; + + const baseResolve = this.options.resolve ?? hostConfig?.resolve; + + const server = await createServer({ + configFile: false, + root: this.normalizedRoot, + logLevel: 'silent', + ...(baseResolve ? { resolve: baseResolve } : {}), + plugins, + server: { middlewareMode: true, ws: false }, + }); + + const runner = createServerModuleRunner(server.environments.ssr, { hmr: false }); + + return { server, runner }; + } + + async evaluate(id: string, options: { source?: string } = {}): Promise { + const start = Date.now(); + const normalizedId = normalizeModuleId(id, this.normalizedRoot); + + if (options.source != null) { + this.virtualSources.set(normalizedId, options.source); + } + + const { server, runner } = await this.ensureServer(); + + try { + const mod = (await runner.import(normalizedId)) as SurimiModule; + const { css, js } = extractSurimiResult(mod); + const { dependencies, sideEffectDependencies } = collectDependencies(server, normalizedId, this.normalizedRoot); + + return { + css, + js, + dependencies, + sideEffectDependencies, + duration: Date.now() - start, + }; + } catch (error) { + if (error instanceof Error) throw error; + throw new Error(String(error)); + } + } + + invalidate(id: string): void { + if (!this.runner) return; + const normalizedId = normalizeModuleId(id, this.normalizedRoot); + const { evaluatedModules } = this.runner; + const node = evaluatedModules.getModuleById(normalizedId); + const nodes = node ? [node] : [...(evaluatedModules.getModulesByFile(normalizedId) ?? [])]; + for (const evaluated of nodes) { + evaluatedModules.invalidateModule(evaluated); + } + this.virtualSources.delete(normalizedId); + } + + async close(): Promise { + if (this.server) { + await this.server.close(); + } + this.server = null; + this.runner = null; + this.serverInit = null; + this.virtualSources.clear(); + } +} + +export async function evaluateSurimiFile(id: string, options: EvaluateSurimiFileOptions): Promise { + const { source, ...evaluatorOptions } = options; + const evaluator = new SurimiEvaluator(evaluatorOptions); + + try { + return await evaluator.evaluate(id, source != null ? { source } : {}); + } finally { + await evaluator.close(); + } +} diff --git a/packages/vite-plugin-surimi/src/types.ts b/packages/vite-plugin-surimi/src/types.ts index 1d93d9c..68f79ba 100644 --- a/packages/vite-plugin-surimi/src/types.ts +++ b/packages/vite-plugin-surimi/src/types.ts @@ -1,6 +1,8 @@ import type { CompileResult } from '@surimi/compiler'; import type { ResolvedConfig } from 'vite'; +import type { SurimiEvaluator } from './runner.js'; + /** Shared state passed from the core plugin to framework-specific plugins (Vue, etc.) */ export interface SharedPluginContext { compilationCache: Map; @@ -9,8 +11,10 @@ export interface SharedPluginContext { inlineCss: boolean; resolvedConfig: ResolvedConfig | undefined; isDev: boolean | undefined; + evaluator: SurimiEvaluator | undefined; /** Normalize dependency paths so collectDependentModules can match changed files. Set by core plugin. */ normalizeDependencyId?: (dependencyId: string, ownerId: string) => string; + getCompilationResult?: (id: string, options?: { source?: string }) => Promise; } export interface SurimiOptions { diff --git a/packages/vite-plugin-surimi/src/vue.ts b/packages/vite-plugin-surimi/src/vue.ts index 615ca93..54edd92 100644 --- a/packages/vite-plugin-surimi/src/vue.ts +++ b/packages/vite-plugin-surimi/src/vue.ts @@ -1,9 +1,9 @@ import path from 'node:path'; -import { type CompileResult, compile } from '@surimi/compiler'; +import type { CompileResult } from '@surimi/compiler'; import type { EnvironmentModuleGraph, EnvironmentModuleNode, Plugin } from 'vite'; import { createFilter, normalizePath } from 'vite'; -import { VIRTUAL_CSS_SUFFIX } from './constants.js'; +import { toImportPath, toVirtualCssImportPath } from './normalize-module-id.js'; import type { SharedPluginContext } from './types.js'; import { addWatchFilesForDeps, createSourceMap, injectCssChunk } from './utils.js'; @@ -129,21 +129,24 @@ export function createVuePlugin(ctx: SharedPluginContext): Plugin { const virtualInput = normalizePath(`${filePath}.__surimi_${blockIndex}.css.ts`); - const compileResult = await compile({ - input: virtualInput, - source: code, - cwd: ctx.resolvedConfig?.root ?? process.cwd(), - include: ctx.include, - exclude: ctx.exclude, - }); + if (!ctx.getCompilationResult) { + throw new Error('getCompilationResult is not available on shared plugin context'); + } - if (!compileResult) return; + const compileResult = await ctx.getCompilationResult(virtualInput, { source: code }); const normalizer = ctx.normalizeDependencyId; const resultToCache: CompileResult = normalizer ? { ...compileResult, dependencies: compileResult.dependencies.map((dep: string): string => normalizer(dep, virtualInput)), + ...(compileResult.sideEffectDependencies + ? { + sideEffectDependencies: compileResult.sideEffectDependencies.map((dep: string): string => + normalizer(dep, virtualInput), + ), + } + : {}), } : compileResult; ctx.compilationCache.set(virtualInput, resultToCache); @@ -153,15 +156,24 @@ export function createVuePlugin(ctx: SharedPluginContext): Plugin { addWatchFilesForDeps(resultToCache.dependencies, filesWatched, addWatch, (p: string) => path.isAbsolute(p)); } + const root = ctx.resolvedConfig?.root; const styleDependencies = resultToCache.dependencies.filter( (dep: string) => dep !== virtualInput && tsFileFilter(dep), ); - const cssImportLines = styleDependencies.map((dep: string) => `import "${dep}${VIRTUAL_CSS_SUFFIX}";`); + const cssImportLines = styleDependencies.map( + (dep: string) => `import "${toVirtualCssImportPath(dep, virtualInput, root)}";`, + ); + const sideEffectImports = (resultToCache.sideEffectDependencies ?? []).map( + (dep: string) => `import "${toImportPath(dep, virtualInput, root)}";`, + ); + const sideEffectImportBlock = + sideEffectImports.length > 0 ? `\n${Array.from(new Set(sideEffectImports)).join('\n')}` : ''; if (options?.ssr) { let ssrCode = compileResult.js; + if (sideEffectImportBlock) ssrCode += sideEffectImportBlock; if (cssImportLines.length > 0) ssrCode += `\n${cssImportLines.join('\n')}`; - ssrCode += `\nimport "${virtualInput}${VIRTUAL_CSS_SUFFIX}";`; + ssrCode += `\nimport "${toVirtualCssImportPath(virtualInput, virtualInput, root)}";`; ssrCode += `\nexport default () => {};`; const lineCount = (ssrCode.match(/\n/g)?.length ?? 0) + 1; return { @@ -171,12 +183,12 @@ export function createVuePlugin(ctx: SharedPluginContext): Plugin { } let jsCode: string; - const selfCssImport = `import "${virtualInput}${VIRTUAL_CSS_SUFFIX}";`; + const selfCssImport = `import "${toVirtualCssImportPath(virtualInput, virtualInput, root)}";`; const dependencyImports = cssImportLines.length > 0 ? `\n${cssImportLines.join('\n')}` : ''; if (ctx.isDev || ctx.inlineCss) { - jsCode = `${compileResult.js}${dependencyImports}\n${injectCssChunk(compileResult.css, virtualInput, !!ctx.isDev)}`; + jsCode = `${compileResult.js}${sideEffectImportBlock}${dependencyImports}\n${injectCssChunk(compileResult.css, virtualInput, !!ctx.isDev)}`; } else { - jsCode = `${compileResult.js}${dependencyImports}\n${selfCssImport}`; + jsCode = `${compileResult.js}${sideEffectImportBlock}${dependencyImports}\n${selfCssImport}`; } if (ctx.isDev) { diff --git a/packages/vite-plugin-surimi/test/css-emission.spec.ts b/packages/vite-plugin-surimi/test/css-emission.spec.ts new file mode 100644 index 0000000..9224fce --- /dev/null +++ b/packages/vite-plugin-surimi/test/css-emission.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, onTestFinished } from 'vitest'; + +import { VIRTUAL_CSS_SUFFIX } from '../src/constants.js'; +import { buildApp, serveTransform, simpleStylesFixture } from './helpers/vite.js'; + +describe('css emission', () => { + it('imports the virtual CSS module from the served transform output', async () => { + const result = await serveTransform(simpleStylesFixture, 'src/styles.css.ts'); + onTestFinished(() => result.cleanup()); + + expect(result.transformedCode).toContain(VIRTUAL_CSS_SUFFIX); + expect(result.transformedCode).toContain('import.meta.hot'); + expect(result.virtualCss).toContain('.container'); + expect(result.virtualCss).toContain('display'); + }); + + it('emits a separate CSS asset on build when inlineCss is false', async () => { + const result = await buildApp(simpleStylesFixture, { inlineCss: false }); + onTestFinished(() => result.cleanup()); + + expect(result.cssAssets.some(css => css.includes('.container'))).toBe(true); + expect(result.jsAssets.some(js => !js.includes('document.createElement'))).toBe(true); + }); + + it('injects CSS at runtime on build when inlineCss is true', async () => { + const result = await buildApp(simpleStylesFixture, { inlineCss: true }); + onTestFinished(() => result.cleanup()); + + expect(result.cssAssets).toHaveLength(0); + expect(result.jsAssets.some(js => js.includes('document.createElement'))).toBe(true); + expect(result.jsAssets.some(js => js.includes('.container'))).toBe(true); + }); + + it('emits CSS via virtual imports on SSR build', async () => { + const result = await buildApp(simpleStylesFixture, { ssr: true }); + onTestFinished(() => result.cleanup()); + + const hasCssOutput = + result.cssAssets.length > 0 || + result.jsAssets.some(js => js.includes(VIRTUAL_CSS_SUFFIX) || js.includes('.container')); + expect(hasCssOutput).toBe(true); + }); +}); diff --git a/packages/vite-plugin-surimi/test/errors.spec.ts b/packages/vite-plugin-surimi/test/errors.spec.ts new file mode 100644 index 0000000..552539f --- /dev/null +++ b/packages/vite-plugin-surimi/test/errors.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { badStylesFixture, buildApp, serveTransform, simpleStylesFixture } from './helpers/vite.js'; + +describe('error handling', () => { + it('surfaces build errors from invalid surimi files', async () => { + await expect(buildApp(badStylesFixture)).rejects.toThrow(); + }); + + it('rejects inlineCss during SSR transform', async () => { + await expect( + serveTransform(simpleStylesFixture, 'src/styles.css.ts', { inlineCss: true, ssr: true }), + ).rejects.toThrow(/inlineCss option is not supported during SSR/i); + }); +}); diff --git a/packages/vite-plugin-surimi/test/exports.spec.ts b/packages/vite-plugin-surimi/test/exports.spec.ts new file mode 100644 index 0000000..0b74605 --- /dev/null +++ b/packages/vite-plugin-surimi/test/exports.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, onTestFinished } from 'vitest'; + +import { evaluateViaPlugin } from './helpers/vite.js'; + +describe('module exports', () => { + it('exports class name strings to .ts consumers', async () => { + const result = await evaluateViaPlugin( + { + 'src/styles.css.ts': `import { select } from 'surimi'; + +select('.btn').style({ color: 'white' }); +export const btnClass = 'btn'; +`, + }, + 'src/styles.css.ts', + ); + onTestFinished(() => result.cleanup()); + + expect(result.js).toContain('export const btnClass = "btn"'); + }); + + it('preserves serializable exports and drops non-serializable ones', async () => { + const result = await evaluateViaPlugin( + { + 'src/styles.css.ts': `import { select } from 'surimi'; + +select('.x').style({ color: 'red' }); +export const className = 'x'; +export function notSerialized() {} +`, + }, + 'src/styles.css.ts', + ); + onTestFinished(() => result.cleanup()); + + expect(result.js).toContain('export const className = "x"'); + expect(result.js).not.toContain('notSerialized'); + }); + + it('handles empty .css.ts files', async () => { + const result = await evaluateViaPlugin( + { + 'src/empty.css.ts': `// empty`, + }, + 'src/empty.css.ts', + ); + onTestFinished(() => result.cleanup()); + + expect(result.css).toBe(''); + expect(result.js).toBe(''); + }); + + it('handles imports-only .css.ts files without styles', async () => { + const result = await evaluateViaPlugin( + { + 'src/base.css': `.only-import { color: black; }`, + 'src/styles.css.ts': `import './base.css'; +export const marker = 'imports-only'; +`, + }, + 'src/styles.css.ts', + ); + onTestFinished(() => result.cleanup()); + + expect(result.css).toBe(''); + expect(result.js).toContain('marker'); + }); +}); diff --git a/packages/vite-plugin-surimi/test/helpers/vite.ts b/packages/vite-plugin-surimi/test/helpers/vite.ts new file mode 100644 index 0000000..003dc4a --- /dev/null +++ b/packages/vite-plugin-surimi/test/helpers/vite.ts @@ -0,0 +1,252 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { CompileResult } from '@surimi/compiler'; +import type { InlineConfig, Rollup } from 'vite'; +import { build, createServer, normalizePath } from 'vite'; +import { VIRTUAL_CSS_SUFFIX } from '../../src/constants.js'; +import surimiPlugin from '../../src/index.js'; +import { normalizeModuleId } from '../../src/normalize-module-id.js'; +import { evaluateSurimiFile } from '../../src/runner.js'; +import type { SurimiOptions } from '../../src/types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../../../..'); +const surimiPackageRoot = path.resolve(repoRoot, 'packages/surimi'); + +export function resolveSurimiAlias(): string { + return path.join(surimiPackageRoot, 'dist/index.js'); +} + +export function testResolveConfig(): InlineConfig['resolve'] { + return { + alias: { + surimi: resolveSurimiAlias(), + }, + }; +} + +/** Merge test surimi alias with extra resolve (evaluateViaPlugin configure callback). */ +export function mergeTestResolve(extra?: InlineConfig['resolve']): InlineConfig['resolve'] { + const surimiEntry = { find: /^surimi$/, replacement: resolveSurimiAlias() }; + const extraAlias = extra?.alias; + + if (Array.isArray(extraAlias)) { + return { ...extra, alias: [surimiEntry, ...extraAlias] }; + } + + return { + ...extra, + alias: { + surimi: resolveSurimiAlias(), + ...(typeof extraAlias === 'object' && extraAlias != null && !Array.isArray(extraAlias) ? extraAlias : {}), + }, + }; +} + +export interface FixtureApp { + root: string; + cleanup: () => Promise; +} + +export interface BuildAppResult { + root: string; + output: Rollup.RollupOutput; + jsAssets: string[]; + cssAssets: string[]; + cleanup: () => Promise; +} + +export interface ServeTransformResult { + root: string; + transformedCode: string; + virtualCss: string; + cleanup: () => Promise; +} + +export interface PluginEvaluateResult extends CompileResult { + root: string; + cleanup: () => Promise; +} + +export async function writeFixture(files: Record): Promise { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'surimi-vite-test-')); + const root = await realpath(tempRoot); + for (const [relativePath, content] of Object.entries(files)) { + const absolutePath = path.join(root, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, content, 'utf8'); + } + return { + root, + cleanup: async () => { + await rm(root, { recursive: true, force: true }); + }, + }; +} + +export function baseViteConfig(root: string, options: SurimiOptions = {}, extra: InlineConfig = {}): InlineConfig { + return { + configFile: false, + root, + logLevel: 'silent', + optimizeDeps: { + noDiscovery: true, + }, + resolve: { + ...testResolveConfig(), + ...(extra.resolve ?? {}), + }, + plugins: [surimiPlugin(options)], + ...extra, + }; +} + +export async function buildApp( + files: Record, + options: SurimiOptions & { entry?: string; ssr?: boolean } = {}, +): Promise { + const { entry = 'src/main.ts', ssr = false, ...surimiOptions } = options; + const fixture = await writeFixture(files); + const input = normalizePath(path.join(fixture.root, entry)); + + const output = (await build({ + ...baseViteConfig(fixture.root, surimiOptions, { + build: { + write: false, + ssr, + ssrEmitAssets: ssr, + rollupOptions: { input }, + }, + }), + })) as Rollup.RollupOutput; + + const jsAssets = output.output + .filter((chunk): chunk is Rollup.OutputChunk => chunk.type === 'chunk') + .map(chunk => chunk.code); + const cssAssets = output.output + .filter((chunk): chunk is Rollup.OutputAsset => chunk.type === 'asset' && chunk.fileName.endsWith('.css')) + .map(chunk => (typeof chunk.source === 'string' ? chunk.source : '')) + .filter(Boolean); + + return { + root: fixture.root, + output, + jsAssets, + cssAssets, + cleanup: fixture.cleanup, + }; +} + +export async function serveTransform( + files: Record, + moduleId: string, + options: SurimiOptions & { ssr?: boolean } = {}, +): Promise { + const { ssr = false, ...surimiOptions } = options; + const fixture = await writeFixture(files); + const requestId = `/${moduleId.split(path.sep).join('/')}`; + + const server = await createServer( + baseViteConfig(fixture.root, surimiOptions, { + server: { middlewareMode: true, ws: false }, + }), + ); + + try { + await server.pluginContainer.buildStart({}); + const transformResult = await server.transformRequest(requestId, { ssr }); + + if (!transformResult?.code) { + throw new Error(`transform returned no code for ${requestId}`); + } + + const absoluteId = normalizeModuleId(path.join(fixture.root, moduleId), fixture.root); + const virtualCssId = `${absoluteId}${VIRTUAL_CSS_SUFFIX}`; + const cssLoadResult = await server.pluginContainer.load(virtualCssId); + const virtualCss = + typeof cssLoadResult === 'string' + ? cssLoadResult + : cssLoadResult && 'code' in cssLoadResult + ? cssLoadResult.code + : ''; + + return { + root: fixture.root, + transformedCode: transformResult.code, + virtualCss, + cleanup: async () => { + await server.close(); + await fixture.cleanup(); + }, + }; + } catch (error) { + await server.close(); + await fixture.cleanup(); + throw error; + } +} + +/** Evaluate an on-disk .css.ts path (used for compiler fixture parity). */ +export async function evaluateFromPath(inputPath: string, options: SurimiOptions = {}): Promise { + const absoluteId = normalizePath(inputPath); + const root = path.dirname(absoluteId); + + return evaluateSurimiFile(absoluteId, { + root, + include: options.include ?? ['**/*.css.{ts,js}'], + exclude: options.exclude ?? ['node_modules/**', '**/*.d.ts'], + resolve: testResolveConfig(), + }); +} + +/** Evaluate a .css.ts file through the plugin's surimi evaluator (runner after migration). */ +export async function evaluateViaPlugin( + files: Record, + moduleId: string, + options: SurimiOptions = {}, + configure?: (root: string) => { resolve?: InlineConfig['resolve'] }, +): Promise Promise }> { + const fixture = await writeFixture(files); + const absoluteId = normalizePath(path.join(fixture.root, moduleId)); + const extra = configure?.(fixture.root) ?? {}; + + const result = await evaluateSurimiFile(absoluteId, { + root: fixture.root, + include: options.include ?? ['**/*.css.{ts,js}'], + exclude: options.exclude ?? ['node_modules/**', '**/*.d.ts'], + resolve: mergeTestResolve(extra.resolve), + }); + + return { + ...result, + root: fixture.root, + cleanup: fixture.cleanup, + }; +} + +export const simpleStylesFixture = { + 'src/styles.css.ts': `import { select } from 'surimi'; + +select('.container').style({ + display: 'flex', + padding: '20px', +}); + +export const buttonClass = 'btn-primary'; +`, + 'src/main.ts': `import './styles.css.ts'; +export {}; +`, +}; + +export const badStylesFixture = { + 'src/styles.css.ts': `import { select } from 'surimi'; +throw new Error('surimi compile error'); +select('.bad').style({ color: 'red' }); +`, + 'src/main.ts': `import './styles.css.ts'; +export {}; +`, +}; diff --git a/packages/vite-plugin-surimi/test/module-resolution.spec.ts b/packages/vite-plugin-surimi/test/module-resolution.spec.ts new file mode 100644 index 0000000..22e1393 --- /dev/null +++ b/packages/vite-plugin-surimi/test/module-resolution.spec.ts @@ -0,0 +1,193 @@ +import path from 'node:path'; +import { describe, expect, it, onTestFinished } from 'vitest'; + +import { buildApp, evaluateViaPlugin } from './helpers/vite.js'; + +describe('module resolution', () => { + it('imports a plain .css file into a .css.ts file', async () => { + const result = await buildApp({ + 'src/base.css': `.external { color: navy; }`, + 'src/styles.css.ts': `import { select } from 'surimi'; +import './base.css'; + +select('.local').style({ color: 'red' }); +`, + 'src/main.ts': `import './styles.css.ts'; +export {}; +`, + }); + onTestFinished(() => result.cleanup()); + + const allCss = result.cssAssets.join('\n'); + expect(allCss).toContain('.local'); + expect(allCss).toContain('.external'); + expect(allCss).toContain('navy'); + }); + + it('imports const values from a .ts file into generated CSS', async () => { + const result = await evaluateViaPlugin( + { + 'src/tokens.ts': `export const accent = '#abc123';`, + 'src/styles.css.ts': `import { select } from 'surimi'; +import { accent } from './tokens'; + +select('.tokenized').style({ color: accent }); +`, + }, + 'src/styles.css.ts', + ); + onTestFinished(() => result.cleanup()); + + expect(result.css).toContain('#abc123'); + expect(result.css).toContain('.tokenized'); + }); + + it('imports const values from a .tsx file into generated CSS', async () => { + const result = await evaluateViaPlugin( + { + 'src/theme.tsx': `export const brand = '#654321';`, + 'src/styles.css.ts': `import { select } from 'surimi'; +import { brand } from './theme.tsx'; + +select('.brand').style({ backgroundColor: brand }); +`, + }, + 'src/styles.css.ts', + ); + onTestFinished(() => result.cleanup()); + + expect(result.css).toContain('#654321'); + expect(result.css).toContain('.brand'); + }); + + it('resolves path aliases into .css.ts files', async () => { + const result = await evaluateViaPlugin( + { + 'src/shared/tokens.ts': `export const spacing = '24px';`, + 'src/styles.css.ts': `import { select } from 'surimi'; +import { spacing } from '@shared/tokens'; + +select('.spaced').style({ padding: spacing }); +`, + }, + 'src/styles.css.ts', + {}, + root => ({ + resolve: { + alias: [{ find: /^@shared\//, replacement: `${path.join(root, 'src/shared')}/` }], + }, + }), + ); + onTestFinished(() => result.cleanup()); + + expect(result.css).toContain('24px'); + }); + + it('supports ?raw imports inside .css.ts', async () => { + const result = await evaluateViaPlugin( + { + 'src/snippet.css': `.raw { opacity: 0.5; }`, + 'src/styles.css.ts': `import { select } from 'surimi'; +import rawCss from './snippet.css?raw'; + +select('.probe').style({ color: 'green' }); +export const importedRaw = rawCss; +`, + }, + 'src/styles.css.ts', + ); + onTestFinished(() => result.cleanup()); + + expect(result.css).toContain('.probe'); + expect(result.js).toContain('.raw'); + // A ?raw import is a value import: its content is baked into js, never re-emitted as CSS. + expect(result.sideEffectDependencies ?? []).toHaveLength(0); + }); + + it('does not emit a ?raw css import as bundled CSS', async () => { + const result = await buildApp({ + 'src/snippet.css': `.raw-leak { opacity: 0.5; }`, + 'src/styles.css.ts': `import { select } from 'surimi'; +import rawCss from './snippet.css?raw'; + +select('.probe').style({ color: 'green' }); +export const importedRaw = rawCss; +`, + 'src/main.ts': `import { importedRaw } from './styles.css.ts'; +console.log(importedRaw); +export {}; +`, + }); + onTestFinished(() => result.cleanup()); + + const allCss = result.cssAssets.join('\n'); + expect(allCss).toContain('.probe'); + // The ?raw content must NOT leak into a CSS asset (it's a value, captured in JS). + expect(allCss).not.toContain('.raw-leak'); + expect(result.jsAssets.join('\n')).toContain('.raw-leak'); + }); + + it('resolves `import { x } from "./theme.css"` to the authored theme.css.ts', async () => { + const result = await buildApp({ + 'src/theme.css.ts': `import { select } from 'surimi'; +export const accent = '#0ff'; +select('.themed').style({ color: accent }); +`, + 'src/styles.css.ts': `import { select } from 'surimi'; +import { accent } from './theme.css'; +select('.consumer').style({ borderColor: accent }); +`, + 'src/main.ts': `import './styles.css.ts'; +export {}; +`, + }); + onTestFinished(() => result.cleanup()); + + const allCss = result.cssAssets.join('\n'); + expect(allCss).toContain('.consumer'); + expect(allCss).toContain('#0ff'); + expect(allCss).toContain('.themed'); + }); + + it('supports transitive .css.ts -> .ts -> .css imports', async () => { + const result = await buildApp({ + 'src/layers/base.css': `.deep { font-weight: bold; }`, + 'src/layers/index.ts': `import '../layers/base.css'; +export const weight = '700';`, + 'src/styles.css.ts': `import { select } from 'surimi'; +import { weight } from './layers/index'; + +select('.deep-style').style({ fontWeight: weight }); +`, + 'src/main.ts': `import './styles.css.ts'; +export {}; +`, + }); + onTestFinished(() => result.cleanup()); + + const allCss = result.cssAssets.join('\n'); + expect(allCss).toContain('.deep-style'); + expect(allCss).toContain('700'); + expect(allCss).toContain('.deep'); + }); + + it('deduplicates dependency paths', async () => { + const result = await evaluateViaPlugin( + { + 'src/shared.css.ts': `import { select } from 'surimi'; +select('.shared').style({ color: 'blue' });`, + 'src/styles.css.ts': `import { select } from 'surimi'; +import './shared.css.ts'; +import './shared.css.ts'; + +select('.main').style({ color: 'red' }); +`, + }, + 'src/styles.css.ts', + ); + onTestFinished(() => result.cleanup()); + + const unique = new Set(result.dependencies); + expect(unique.size).toBe(result.dependencies.length); + }); +}); diff --git a/packages/vite-plugin-surimi/test/parity.spec.ts b/packages/vite-plugin-surimi/test/parity.spec.ts new file mode 100644 index 0000000..55f62dd --- /dev/null +++ b/packages/vite-plugin-surimi/test/parity.spec.ts @@ -0,0 +1,41 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compile } from '@surimi/compiler'; +import { describe, expect, it } from 'vitest'; + +import { evaluateFromPath } from './helpers/vite.js'; + +const fixturesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../compiler/test/fixtures'); + +const fixtureFiles = [ + 'simple.css.ts', + 'empty.css.ts', + 'with-exports.css.ts', + 'with-imports.css.ts', + 'complex-exports.css.ts', + 'media-queries.css.ts', +] as const; + +describe('vite-plugin-surimi compiler parity', () => { + for (const fixture of fixtureFiles) { + it(`matches compiler output for ${fixture}`, async () => { + const inputPath = path.join(fixturesDir, fixture); + const compilerResult = await compile({ + input: inputPath, + cwd: process.cwd(), + include: ['**/*.css.ts'], + exclude: ['**/node_modules/**'], + }); + + expect(compilerResult).toBeDefined(); + + const pluginResult = await evaluateFromPath(inputPath, { + include: ['**/*.css.ts'], + exclude: ['**/node_modules/**'], + }); + + expect(pluginResult.css).toBe(compilerResult?.css); + expect(pluginResult.js).toBe(compilerResult?.js); + }); + } +}); diff --git a/packages/vite-plugin-surimi/test/vue.spec.ts b/packages/vite-plugin-surimi/test/vue.spec.ts new file mode 100644 index 0000000..28626db --- /dev/null +++ b/packages/vite-plugin-surimi/test/vue.spec.ts @@ -0,0 +1,131 @@ +import path from 'node:path'; +import { normalizePath } from 'vite'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { evaluateSurimiFile, SurimiEvaluator } from '../src/runner.js'; +import { mergeTestResolve, writeFixture } from './helpers/vite.js'; + +async function evaluateVueBlock( + files: Record, + blockSource: string, + vuePath = 'src/App.vue', + blockIndex = 0, +) { + const fixture = await writeFixture({ + ...files, + [vuePath]: '', + }); + const absoluteVuePath = normalizePath(path.join(fixture.root, vuePath)); + const virtualInput = normalizePath(`${absoluteVuePath}.__surimi_${blockIndex}.css.ts`); + + const result = await evaluateSurimiFile(virtualInput, { + root: fixture.root, + include: ['**/*.css.{ts,js}'], + exclude: ['node_modules/**', '**/*.d.ts'], + resolve: mergeTestResolve(), + source: blockSource, + }); + + return { ...result, root: fixture.root, virtualInput, cleanup: fixture.cleanup }; +} + +describe('Vue surimi blocks', () => { + it('evaluates inline block source via the stable Vue virtual id glob', async () => { + const result = await evaluateVueBlock( + {}, + `import { select } from 'surimi'; + +select('.vue-btn').style({ color: 'white' }); +export const btnClass = 'vue-btn'; +`, + ); + onTestFinished(() => result.cleanup()); + + expect(result.css).toContain('.vue-btn'); + expect(result.css).toContain('white'); + expect(result.js).toContain('btnClass'); + }); + + it('resolves .ts tokens and plain .css from inline block source', async () => { + const result = await evaluateVueBlock( + { + 'src/tokens.ts': `export const accent = '#fedcba';`, + 'src/base.css': `.imported { opacity: 0.8; }`, + }, + `import { select } from 'surimi'; +import { accent } from './tokens'; +import './base.css'; + +select('.themed').style({ color: accent }); +`, + ); + onTestFinished(() => result.cleanup()); + + expect(result.css).toContain('#fedcba'); + expect(result.css).toContain('.themed'); + }); + + it('reuses one owned evaluator server for multiple Vue virtual sources', async () => { + const fixture = await writeFixture({ 'src/App.vue': '' }); + const absoluteVuePath = normalizePath(path.join(fixture.root, 'src/App.vue')); + const virtual0 = normalizePath(`${absoluteVuePath}.__surimi_0.css.ts`); + const virtual1 = normalizePath(`${absoluteVuePath}.__surimi_1.css.ts`); + + const evaluator = new SurimiEvaluator({ + root: fixture.root, + include: ['**/*.css.{ts,js}'], + exclude: ['node_modules/**', '**/*.d.ts'], + resolve: mergeTestResolve(), + }); + + try { + const block0 = await evaluator.evaluate(virtual0, { + source: `import { select } from 'surimi'; +select('.block-a').style({ color: 'red' });`, + }); + const block1 = await evaluator.evaluate(virtual1, { + source: `import { select } from 'surimi'; +select('.block-b').style({ color: 'blue' });`, + }); + + expect(block0.css).toContain('.block-a'); + expect(block1.css).toContain('.block-b'); + } finally { + await evaluator.close(); + await fixture.cleanup(); + } + }); + + it('creates the owned server once under concurrent evaluations (single-flight)', async () => { + const fixture = await writeFixture({ 'src/App.vue': '' }); + const absoluteVuePath = normalizePath(path.join(fixture.root, 'src/App.vue')); + + const evaluator = new SurimiEvaluator({ + root: fixture.root, + include: ['**/*.css.{ts,js}'], + exclude: ['node_modules/**', '**/*.d.ts'], + resolve: mergeTestResolve(), + }); + + const createSpy = vi.spyOn( + evaluator as unknown as { createOwnedServer: () => Promise }, + 'createOwnedServer', + ); + + try { + const results = await Promise.all( + Array.from({ length: 5 }, (_, i) => + evaluator.evaluate(normalizePath(`${absoluteVuePath}.__surimi_${i}.css.ts`), { + source: `import { select } from 'surimi';\nselect('.race-${i}').style({ color: 'red' });`, + }), + ), + ); + + expect(createSpy).toHaveBeenCalledTimes(1); + results.forEach((result, i) => expect(result.css).toContain(`.race-${i}`)); + } finally { + createSpy.mockRestore(); + await evaluator.close(); + await fixture.cleanup(); + } + }); +});