diff --git a/packages/@kamado-io/script-compiler/README.md b/packages/@kamado-io/script-compiler/README.md index c3be6ab..e8c6414 100644 --- a/packages/@kamado-io/script-compiler/README.md +++ b/packages/@kamado-io/script-compiler/README.md @@ -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 diff --git a/packages/@kamado-io/script-compiler/src/script-compiler.spec.ts b/packages/@kamado-io/script-compiler/src/script-compiler.spec.ts new file mode 100644 index 0000000..de60f78 --- /dev/null +++ b/packages/@kamado-io/script-compiler/src/script-compiler.spec.ts @@ -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 { + 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; +} + +/** + * + * @param mode + * @param options + * @param source + */ +async function compile( + mode: 'serve' | 'build', + options: Parameters>>[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()(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); + }); +}); diff --git a/packages/@kamado-io/script-compiler/src/script-compiler.ts b/packages/@kamado-io/script-compiler/src/script-compiler.ts index bea2c09..db36ce4 100644 --- a/packages/@kamado-io/script-compiler/src/script-compiler.ts +++ b/packages/@kamado-io/script-compiler/src/script-compiler.ts @@ -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'; } /** @@ -48,7 +57,7 @@ export function createScriptCompiler() { return createCustomCompiler(() => ({ 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, @@ -57,6 +66,12 @@ export function createScriptCompiler() { */ 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' @@ -70,6 +85,7 @@ export function createScriptCompiler() { outfile: tmpFilePath, minify: options?.minifier, charset: 'utf8', + sourcemap: enableSourcemap ? 'inline' : false, banner: { js: banner, }, diff --git a/packages/@kamado-io/style-compiler/README.md b/packages/@kamado-io/style-compiler/README.md index 6f00c93..1903357 100644 --- a/packages/@kamado-io/style-compiler/README.md +++ b/packages/@kamado-io/style-compiler/README.md @@ -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 diff --git a/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts b/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts new file mode 100644 index 0000000..a746a74 --- /dev/null +++ b/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts @@ -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(); + +vi.mock('kamado/files', async (importOriginal) => { + const original = await importOriginal(); + 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 { + 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; +} + +/** + * + * @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>>[0], + css = '.a{color:red}', +) { + mockFileContents.clear(); + const file = makeFile(); + setMockFile(file.inputPath, css); + const entry = createStyleCompiler()(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('/*!'); + }); +}); diff --git a/packages/@kamado-io/style-compiler/src/style-compiler.ts b/packages/@kamado-io/style-compiler/src/style-compiler.ts index 8b18f75..cb39f0c 100644 --- a/packages/@kamado-io/style-compiler/src/style-compiler.ts +++ b/packages/@kamado-io/style-compiler/src/style-compiler.ts @@ -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*/`; } /** @@ -46,7 +81,13 @@ export function createStyleCompiler() { return createCustomCompiler(() => ({ 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[] = [ @@ -110,18 +151,23 @@ export function createStyleCompiler() { 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; }; }, })); diff --git a/packages/kamado/README.ja.md b/packages/kamado/README.ja.md index 0d1c344..2f9eb33 100644 --- a/packages/kamado/README.ja.md +++ b/packages/kamado/README.ja.md @@ -191,6 +191,7 @@ def(createPageCompiler(), { - `outputExtension`(オプション): 出力ファイルの拡張子(デフォルト: `'.css'`) - `alias`: パスエイリアスのマップ(PostCSSの`@import`で使用) - `banner`: バナー設定(CreateBanner関数または文字列を指定可能) +- `sourcemap`: 出力末尾にインラインソースマップを付与する。`boolean | 'onServer'`を受け付ける。`'onServer'`を指定すると、kamadoがserveモードで動作している間のみソースマップを出力する。デフォルト: `'onServer'` **例**: `.scss`ファイルを`.css`にコンパイルし、ソースファイルを無視する場合: @@ -213,6 +214,7 @@ def(createStyleCompiler(), { - `alias`: パスエイリアスのマップ(esbuildのエイリアス) - `minifier`: ミニファイを有効にするか - `banner`: バナー設定(CreateBanner関数または文字列を指定可能) +- `sourcemap`: 出力末尾にインラインソースマップを付与する。`boolean | 'onServer'`を受け付ける。`'onServer'`を指定すると、kamadoがserveモードで動作している間のみソースマップを出力する。デフォルト: `'onServer'` **例**: TypeScriptファイルをJavaScriptにコンパイルする場合: @@ -227,6 +229,23 @@ def(createScriptCompiler(), { }); ``` +##### コマンドごとにソースマップを切り替える + +デフォルトは`'onServer'`で、`kamado server`の間のみインラインソースマップを出力し、`kamado build`では出力されません。常に出力したい場合は`true`、常に出力したくない場合は`false`を指定します: + +```ts +export default defineConfig({ + compilers: (def) => [ + def(createScriptCompiler(), { + sourcemap: true, // 常に出力(build + serve) + }), + def(createStyleCompiler(), { + sourcemap: false, // 出力しない + }), + ], +}); +``` + #### ページリスト設定 `pageList`オプションを使用すると、ナビゲーション、パンくずリスト、その他ページリストを必要とする機能で使用されるページリストをカスタマイズできます。 diff --git a/packages/kamado/README.md b/packages/kamado/README.md index b1735a8..a9a0b90 100644 --- a/packages/kamado/README.md +++ b/packages/kamado/README.md @@ -191,6 +191,7 @@ def(createPageCompiler(), { - `outputExtension` (optional): Output file extension (default: `'.css'`) - `alias`: Path alias map (used in PostCSS `@import`) - `banner`: Banner configuration (can specify CreateBanner function or string) +- `sourcemap`: Emit an inline source map appended to the output. Accepts `boolean | 'onServer'`. When set to `'onServer'`, the source map is emitted only while kamado runs in serve mode. Default: `'onServer'`. **Example**: To compile `.scss` files to `.css` while ignoring source files: @@ -213,6 +214,7 @@ def(createStyleCompiler(), { - `alias`: Path alias map (esbuild alias) - `minifier`: Whether to enable minification - `banner`: Banner configuration (can specify CreateBanner function or string) +- `sourcemap`: Emit an inline source map appended to the output. Accepts `boolean | 'onServer'`. When set to `'onServer'`, the source map is emitted only while kamado runs in serve mode. Default: `'onServer'`. **Example**: To compile TypeScript files to JavaScript: @@ -227,6 +229,23 @@ def(createScriptCompiler(), { }); ``` +##### Switching the source map per command + +The default is `'onServer'`, so the inline source map is emitted only during `kamado server` and omitted in `kamado build`. Pass `true` to always emit, or `false` to always omit: + +```ts +export default defineConfig({ + compilers: (def) => [ + def(createScriptCompiler(), { + sourcemap: true, // always emit (build + serve) + }), + def(createStyleCompiler(), { + sourcemap: false, // never emit + }), + ], +}); +``` + #### Page List Configuration The `pageList` option allows you to customize the page list used for navigation, breadcrumbs, and other features that require a list of pages.