Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 9 additions & 76 deletions packages/compiler/src/compiler.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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',
Expand All @@ -31,12 +26,7 @@ const DEV_SURIMI_PACKAGES = [
'/packages/conditional',
];

interface SurimiModule extends Record<string, unknown> {
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: {
Expand All @@ -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) {
Expand All @@ -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)] : [];

Expand All @@ -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<CompileResult | undefined> {
) {
const { css, js } = await execute(code, sourcePath);

// Extract all imported modules as watch files
const watchFiles = getModuleDependencies(imports, dynamicImports, moduleIds);

return {
Expand All @@ -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) {
Expand All @@ -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');
Expand All @@ -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);
}
Expand All @@ -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));
}
2 changes: 2 additions & 0 deletions packages/compiler/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const SURIMI_CSS_EXPORT_NAME = '__SURIMI_GENERATED_CSS__';
export const COMPILER_PLUGIN_NAME = 'surimi:compiler-transform';
33 changes: 33 additions & 0 deletions packages/compiler/src/extract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { SURIMI_CSS_EXPORT_NAME } from './constants';

export interface SurimiModule extends Record<string, unknown> {
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') : '' };
}
21 changes: 20 additions & 1 deletion packages/compiler/src/index.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
21 changes: 20 additions & 1 deletion packages/compiler/src/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
9 changes: 9 additions & 0 deletions packages/compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/compiler/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
60 changes: 60 additions & 0 deletions packages/vite-plugin-surimi/src/normalize-module-id.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Comment on lines +47 to +53

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, '');
Loading
Loading