Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ef1747e
feat(script-compiler): add inline sourcemap option
yusasa16 May 14, 2026
4660ded
feat(style-compiler): add inline sourcemap option
yusasa16 May 14, 2026
8bfc317
docs(script-compiler): document sourcemap option
yusasa16 May 14, 2026
4081513
docs(style-compiler): document sourcemap option
yusasa16 May 14, 2026
da5ba43
docs(kamado): document compiler sourcemap option and per-command swit…
yusasa16 May 14, 2026
fdec413
feat(script-compiler): support 'onServer' for sourcemap option
yusasa16 May 15, 2026
10c8019
feat(style-compiler): support 'onServer' for sourcemap option
yusasa16 May 15, 2026
3966832
docs(script-compiler): document 'onServer' sourcemap value
yusasa16 May 15, 2026
cdb4516
docs(style-compiler): document 'onServer' sourcemap value
yusasa16 May 15, 2026
e1578f8
docs(kamado): document 'onServer' sourcemap value and simplify per-co…
yusasa16 May 15, 2026
0924bfc
chore(script-compiler): note enableSourcemap is fixed per command run
yusasa16 May 15, 2026
76f8e34
test(script-compiler): cover sourcemap option matrix
yusasa16 May 15, 2026
d759139
refactor(style-compiler): unify banner handling regardless of sourcem…
yusasa16 May 15, 2026
1a7b397
test(style-compiler): cover sourcemap option and banner parity
yusasa16 May 15, 2026
641e8f1
test(script-compiler): return Promise from compile callback to satisf…
yusasa16 May 18, 2026
d5d7e1a
test(style-compiler): return Promise from compile callback to satisfy…
yusasa16 May 18, 2026
e3c1a53
feat(script-compiler): default sourcemap to 'onServer'
yusasa16 May 18, 2026
0e8f13e
feat(style-compiler): default sourcemap to 'onServer'
yusasa16 May 18, 2026
14407c9
docs(script-compiler): document 'onServer' as default sourcemap
yusasa16 May 18, 2026
a283c48
docs(style-compiler): document 'onServer' as default sourcemap
yusasa16 May 18, 2026
dece2e8
docs(kamado): document 'onServer' as default sourcemap
yusasa16 May 18, 2026
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
1 change: 1 addition & 0 deletions packages/@kamado-io/script-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default defineConfig({
- `alias`: Map of path aliases (key is alias name, value is actual path)
- `minifier`: Whether to enable minification
- `banner`: Banner configuration (can specify CreateBanner function or string)
- `sourcemap`: Emit an inline source map (data URI appended as `//# sourceMappingURL=...`). Accepts `boolean | 'onServer'`. When set to `'onServer'`, the source map is emitted only while kamado runs in serve mode (`context.mode === 'serve'`). Default: `'onServer'`. esbuild adjusts mappings to account for the banner automatically.

## License

Expand Down
99 changes: 99 additions & 0 deletions packages/@kamado-io/script-compiler/src/script-compiler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Context } from 'kamado/config';
import type { CompilableFile, MetaData } from 'kamado/files';

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, test } from 'vitest';

import { createScriptCompiler } from './script-compiler.js';

let workDir: string;

beforeEach(async () => {
workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'script-compiler-spec-'));
});

afterEach(async () => {
await fs.rm(workDir, { recursive: true, force: true });
});

/**
*
* @param mode
*/
function makeContext(mode: 'serve' | 'build'): Context<MetaData> {
return {
mode,
pkg: { name: 'test', version: '1.0.0' },
dir: { root: workDir, input: workDir, output: workDir },
devServer: { port: 3000, host: 'localhost', open: false, transforms: [] },
compilers: () => [],
} as Context<MetaData>;
}

/**
*
* @param mode
* @param options
* @param source
*/
async function compile(
mode: 'serve' | 'build',
options: Parameters<ReturnType<typeof createScriptCompiler<MetaData>>>[0],
source = 'export const answer = 42;\n',
) {
const inputPath = path.join(workDir, 'entry.ts');
await fs.writeFile(inputPath, source, 'utf8');
// outputPath is appended to os.tmpdir() inside the compiler; make it unique
// per call so parallel workers don't collide.
const uniqueOutput = path.join(path.basename(workDir), 'entry.js');
const file: CompilableFile = {
inputPath,
outputPath: uniqueOutput,
fileSlug: 'entry',
filePathStem: path.join(workDir, 'entry'),
url: '/entry.js',
extension: '.ts',
date: new Date(),
};
const entry = createScriptCompiler<MetaData>()(options);
const fn = await entry.compiler(makeContext(mode));
const out = await fn(file, () => Promise.resolve(''), undefined, false);
return typeof out === 'string' ? out : new TextDecoder().decode(out);
}

const SOURCE_MAP_RE = /\/\/#\s*sourceMappingURL=data:application\/json;base64,/;

describe('createScriptCompiler / sourcemap', () => {
test("defaults to 'onServer': omits source map in build mode", async () => {
const out = await compile('build', {});
expect(out).not.toMatch(SOURCE_MAP_RE);
});

test("defaults to 'onServer': emits source map in serve mode", async () => {
const out = await compile('serve', {});
expect(out).toMatch(SOURCE_MAP_RE);
});

test('emits inline source map when sourcemap is true', async () => {
const out = await compile('build', { sourcemap: true });
expect(out).toMatch(SOURCE_MAP_RE);
});

test('omits source map when sourcemap is false', async () => {
const out = await compile('build', { sourcemap: false });
expect(out).not.toMatch(SOURCE_MAP_RE);
});

test("emits source map when sourcemap is 'onServer' and mode is serve", async () => {
const out = await compile('serve', { sourcemap: 'onServer' });
expect(out).toMatch(SOURCE_MAP_RE);
});

test("omits source map when sourcemap is 'onServer' and mode is build", async () => {
const out = await compile('build', { sourcemap: 'onServer' });
expect(out).not.toMatch(SOURCE_MAP_RE);
});
});
18 changes: 17 additions & 1 deletion packages/@kamado-io/script-compiler/src/script-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ export interface ScriptCompilerOptions {
* Can specify CreateBanner function or string
*/
readonly banner?: CreateBanner | string;
/**
* Emit an inline source map (data URI appended as `//# sourceMappingURL=...`).
*
* - `true` / `false`: always emit / never emit.
* - `'onServer'`: emit only when kamado runs in serve mode (`context.mode === 'serve'`).
*
* Default: `'onServer'`.
*/
readonly sourcemap?: boolean | 'onServer';
}

/**
Expand All @@ -48,7 +57,7 @@ export function createScriptCompiler<M extends MetaData>() {
return createCustomCompiler<ScriptCompilerOptions, M>(() => ({
defaultFiles: '**/*.{js,ts,jsx,tsx,mjs,cjs}',
defaultOutputExtension: '.js',
compile: (options) => async () => {
compile: (options) => async (context) => {
/**
* When loading kamado.config.ts via getConfig(cosmiconfig),
* if that kamado.config.ts invokes this compiler,
Expand All @@ -57,6 +66,12 @@ export function createScriptCompiler<M extends MetaData>() {
*/
const esbuild = await import('esbuild');

// `context.mode` is fixed for the lifetime of a command, so evaluate
// the sourcemap flag once here rather than per-file.
const sourcemapOption = options?.sourcemap ?? 'onServer';
const enableSourcemap =
sourcemapOption === 'onServer' ? context.mode === 'serve' : sourcemapOption;

return async (file) => {
const banner =
typeof options?.banner === 'string'
Expand All @@ -70,6 +85,7 @@ export function createScriptCompiler<M extends MetaData>() {
outfile: tmpFilePath,
minify: options?.minifier,
charset: 'utf8',
sourcemap: enableSourcemap ? 'inline' : false,
banner: {
js: banner,
},
Expand Down
1 change: 1 addition & 0 deletions packages/@kamado-io/style-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default defineConfig({
- `outputExtension` (optional): Output file extension (default: `'.css'`)
- `alias`: Map of path aliases (key is alias name, value is actual path)
- `banner`: Banner configuration (can specify CreateBanner function or string)
- `sourcemap`: Emit an inline source map (`/*# sourceMappingURL=data:... */` appended to the output). Accepts `boolean | 'onServer'`. When set to `'onServer'`, the source map is emitted only while kamado runs in serve mode (`context.mode === 'serve'`). Default: `'onServer'`. When enabled, the banner is fed through PostCSS as a `/*!` important comment so cssnano preserves it and the source map line offsets stay correct.

## License

Expand Down
141 changes: 141 additions & 0 deletions packages/@kamado-io/style-compiler/src/style-compiler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { Context } from 'kamado/config';
import type * as KamadoFiles from 'kamado/files';
import type { CompilableFile, FileContent, MetaData } from 'kamado/files';

import { describe, expect, test, vi } from 'vitest';

import { createStyleCompiler } from './style-compiler.js';

const mockFileContents = new Map<string, FileContent>();

vi.mock('kamado/files', async (importOriginal) => {
const original = await importOriginal<typeof KamadoFiles>();
return {
...original,
getContentFromFile: vi.fn((file: CompilableFile) => {
const content = mockFileContents.get(file.inputPath);
if (!content) {
throw new Error(`ENOENT: no such file or directory, open '${file.inputPath}'`);
}
return Promise.resolve(content);
}),
};
});

/**
*
* @param inputPath
* @param css
*/
function setMockFile(inputPath: string, css: string) {
mockFileContents.set(inputPath, { content: css, raw: css });
}

/**
*
* @param mode
*/
function makeContext(mode: 'serve' | 'build'): Context<MetaData> {
return {
mode,
pkg: { name: 'test', version: '1.0.0' },
dir: { root: '/test', input: '/test/src', output: '/test/dist' },
devServer: { port: 3000, host: 'localhost', open: false, transforms: [] },
compilers: () => [],
} as Context<MetaData>;
}

/**
*
* @param inputPath
*/
function makeFile(inputPath = '/test/src/style.css'): CompilableFile {
return {
inputPath,
outputPath: '/test/dist/style.css',
fileSlug: 'style',
filePathStem: '/test/src/style',
url: '/style.css',
extension: '.css',
date: new Date(),
};
}

const SOURCE_MAP_RE = /\/\*#\s*sourceMappingURL=data:application\/json;base64,/;

/**
*
* @param mode
* @param options
* @param css
*/
async function compile(
mode: 'serve' | 'build',
options: Parameters<ReturnType<typeof createStyleCompiler<MetaData>>>[0],
css = '.a{color:red}',
) {
mockFileContents.clear();
const file = makeFile();
setMockFile(file.inputPath, css);
const entry = createStyleCompiler<MetaData>()(options);
const fn = await entry.compiler(makeContext(mode));
const out = await fn(file, () => Promise.resolve(''), undefined, false);
return typeof out === 'string' ? out : new TextDecoder().decode(out);
}

describe('createStyleCompiler / sourcemap', () => {
test("defaults to 'onServer': omits source map in build mode", async () => {
const out = await compile('build', {});
expect(out).not.toMatch(SOURCE_MAP_RE);
});

test("defaults to 'onServer': emits source map in serve mode", async () => {
const out = await compile('serve', {});
expect(out).toMatch(SOURCE_MAP_RE);
});

test('emits inline source map when sourcemap is true', async () => {
const out = await compile('build', { sourcemap: true });
expect(out).toMatch(SOURCE_MAP_RE);
});

test('omits source map when sourcemap is false', async () => {
const out = await compile('build', { sourcemap: false });
expect(out).not.toMatch(SOURCE_MAP_RE);
});

test("emits source map when sourcemap is 'onServer' and mode is serve", async () => {
const out = await compile('serve', { sourcemap: 'onServer' });
expect(out).toMatch(SOURCE_MAP_RE);
});

test("omits source map when sourcemap is 'onServer' and mode is build", async () => {
const out = await compile('build', { sourcemap: 'onServer' });
expect(out).not.toMatch(SOURCE_MAP_RE);
});
});

describe('createStyleCompiler / banner parity', () => {
test('banner text is preserved through cssnano regardless of sourcemap flag', async () => {
const banner = 'rev. 2026-05-15\ncopyright marker';
const off = await compile('build', { banner });
const on = await compile('serve', { sourcemap: 'onServer', banner });
expect(off).toContain('rev. 2026-05-15');
expect(off).toContain('copyright marker');
expect(on).toContain('rev. 2026-05-15');
expect(on).toContain('copyright marker');
});

test('banner survives minification (preserved as /*! comment)', async () => {
const banner = '/*\nkeep me\n*/';
const out = await compile('build', { banner });
expect(out).toContain('keep me');
expect(out).toContain('/*!');
});

test('plain string banner is wrapped in a /*! comment safely', async () => {
const out = await compile('build', { banner: 'plain text marker' });
expect(out).toContain('plain text marker');
expect(out).toContain('/*!');
});
});
64 changes: 55 additions & 9 deletions packages/@kamado-io/style-compiler/src/style-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,41 @@ export interface StyleCompilerOptions {
* Can specify CreateBanner function or string
*/
readonly banner?: CreateBanner | string;
/**
* Emit an inline source map (`/*# sourceMappingURL=data:... *\/` appended to the output).
*
* - `true` / `false`: always emit / never emit.
* - `'onServer'`: emit only when kamado runs in serve mode (`context.mode === 'serve'`).
*
* Default: `'onServer'`.
*/
readonly sourcemap?: boolean | 'onServer';
}

/**
* Coerces a banner string into a `/*! ... *\/` important comment so it can be
* safely prepended to PostCSS input and survive cssnano minification.
*
* - `/*! ... *\/` → returned as-is.
* - `/* ... *\/` → leading `/*` rewritten to `/*!`.
* - anything else (including plain strings without comment markers) → wrapped
* in `/*! ... *\/`. Any embedded `*\/` is split with a space so it cannot
* prematurely close the surrounding comment.
* @param raw
*/
function normalizeBanner(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return '';
}
if (trimmed.startsWith('/*!')) {
return trimmed;
}
if (trimmed.startsWith('/*') && trimmed.endsWith('*/')) {
return '/*!' + trimmed.slice(2);
}
const safe = trimmed.replaceAll('*/', '* /');
return `/*!\n${safe}\n*/`;
}

/**
Expand All @@ -46,7 +81,13 @@ export function createStyleCompiler<M extends MetaData>() {
return createCustomCompiler<StyleCompilerOptions, M>(() => ({
defaultFiles: '**/*.css',
defaultOutputExtension: '.css',
compile: (options) => () => {
compile: (options) => (context) => {
// `context.mode` is fixed for the lifetime of a command, so evaluate
// the sourcemap flag once here rather than per-file.
const sourcemapOption = options?.sourcemap ?? 'onServer';
const enableSourcemap =
sourcemapOption === 'onServer' ? context.mode === 'serve' : sourcemapOption;

return async (file, _, __, cache) => {
// Configure plugins with alias resolver for postcss-import
const plugins: postcss.AcceptedPlugin[] = [
Expand Down Expand Up @@ -110,18 +151,23 @@ export function createStyleCompiler<M extends MetaData>() {

const css = await getContentFromFile(file, cache);

// Process CSS with PostCSS
const result = await postcss(plugins).process(css.content, {
from: file.inputPath,
to: undefined,
});

const banner =
const rawBanner =
typeof options?.banner === 'string'
? options.banner
: createBanner(options?.banner?.());
// Normalize to a `/*! ... */` important comment so that:
// 1) cssnano preserves it through minification (the `!` flag),
// 2) it can be safely prepended to the PostCSS input — which
// keeps inline source map line offsets correct and ensures
// the output is identical regardless of the sourcemap flag.
const banner = normalizeBanner(rawBanner);

return banner + '\n' + result.css;
const result = await postcss(plugins).process(banner + '\n' + css.content, {
from: file.inputPath,
to: undefined,
...(enableSourcemap ? { map: { inline: true } } : {}),
});
return result.css;
};
},
}));
Expand Down
Loading
Loading