From 4b1d1897c4db2daadba7a4555f916f6682eed48c Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Tue, 12 May 2026 12:29:48 +0900 Subject: [PATCH 01/20] refactor(page-compiler)!: route all transform failures through formatOptions.parseError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the parseError catch out of the prettier transform and into the page pipeline. The same policy now covers every transform — prettier, minifier, manipulateDOM, and any custom transforms supplied via the `transforms` option. Motivation: html-minifier-terser was throwing on malformed input with no source context, mirroring the original prettier problem. Adding a second per-transform parseError option would have meant duplicating the catch and giving users a way to set inconsistent policies. A single pipeline-level catch is simpler and more intuitive. Behavior on failure: - 'silent' (default): the failing transform is skipped, the previous step's output flows through to the next transform - 'warning': console.warn with `Transform '' failed on : ` then skip - 'error': throw an Error with the same message; original error preserved on `error.cause` BREAKING CHANGE: `PrettierOptions.parseError` and the `PrettierParseErrorMode` type are removed. The `DefaultPageTransformsOptions` interface is removed and `createDefaultPageTransforms()` no longer takes an argument. The unified type is now `ParseErrorMode` exported from the package entry. Error message format changed to `Transform '' failed on : `. Tests cover prettier, minifier, and custom-transform failure under each of the three modes, plus pipeline continuation (a second failure after the first is still logged) and the `inputPath` -> `outputPath` fallback in the message. --- packages/@kamado-io/page-compiler/README.md | 14 +- .../src/page-compiler-format-options.spec.ts | 279 +++++++++++++++--- .../page-compiler/src/page-compiler.ts | 33 ++- .../src/page-transform-parse-error.spec.ts | 115 -------- .../page-compiler/src/page-transform.ts | 24 +- .../page-compiler/src/transform/minifier.ts | 6 +- .../src/transform/prettier.spec.ts | 134 +-------- .../page-compiler/src/transform/prettier.ts | 60 +--- .../@kamado-io/page-compiler/src/types.ts | 27 +- 9 files changed, 309 insertions(+), 383 deletions(-) delete mode 100644 packages/@kamado-io/page-compiler/src/page-transform-parse-error.spec.ts diff --git a/packages/@kamado-io/page-compiler/README.md b/packages/@kamado-io/page-compiler/README.md index 534ccaf..3f2f68d 100644 --- a/packages/@kamado-io/page-compiler/README.md +++ b/packages/@kamado-io/page-compiler/README.md @@ -55,8 +55,11 @@ export default defineConfig({ - `'path'`: Sort by path using `pathComparator` - `(a: string, b: string) => number`: Custom comparator function - `null` (default): No sorting (preserve original order) -- `formatOptions`: Options applied to the **default** format transforms. Only forwarded to transforms produced by `createDefaultPageTransforms`; if a custom `transforms` array is supplied, pass these settings to the relevant transform factories directly (e.g. `prettier({ parseError })`). - - `parseError`: Behavior when Prettier fails to parse the input. One of `'silent' | 'warning' | 'error'` (default: `'silent'`). See the [`prettier` transform](#transform-pipeline) section for details. +- `formatOptions`: Pipeline-level error policy applied to **every** transform in the compiled chain (both built-in transforms like `prettier`/`minifier` and any custom ones from `transforms`). + - `parseError`: Behavior when a transform throws. One of: + - `'silent'` (default) — swallow the error, skip the failing transform, and pass the previous step's output to the next one + - `'warning'` — `console.warn` with `Transform '' failed on : `, then skip the failing transform + - `'error'` — throw an `Error` with the same message prefix; the original error is preserved on `error.cause` - Example: ```typescript @@ -207,11 +210,7 @@ The package provides **6 transform factory functions** (5 included in default pi // ... other Prettier options } ``` - - `options.parseError`: Behavior when Prettier fails to parse the input (for example, when the HTML parser chokes on malformed markup). Defaults to `'silent'`. - - `'silent'` — swallow the error and emit the unformatted source as-is. - - `'warning'` — `console.warn` with a message prefixed by the source file path (`ctx.inputPath`, falling back to `ctx.outputPath`), then emit the unformatted source. - - `'error'` — throw an `Error` whose message is prefixed by the source file path. The underlying Prettier error is preserved on `error.cause`, so handlers can inspect `loc` and other Prettier-specific fields. - - **Top-level shortcut**: When using `createDefaultPageTransforms()`, pass `formatOptions.parseError` on `PageCompilerOptions` instead of constructing the transform yourself. + - **Errors**: This transform throws raw Prettier errors (typically `SyntaxError` for malformed input). Failures are handled at the pipeline level by `PageCompilerOptions.formatOptions.parseError` — see the [Options](#options) section. 5. **`minifier(options?)`** - Minify HTML - `options.options`: html-minifier-terser configuration object @@ -224,6 +223,7 @@ The package provides **6 transform factory functions** (5 included in default pi // ... other minifier options } ``` + - **Errors**: This transform throws raw `html-minifier-terser` errors when the input cannot be parsed. Failures are handled at the pipeline level by `PageCompilerOptions.formatOptions.parseError` — see the [Options](#options) section. 6. **`lineBreak(options?)`** - Normalize line breaks - `options.lineBreak`: Line break style diff --git a/packages/@kamado-io/page-compiler/src/page-compiler-format-options.spec.ts b/packages/@kamado-io/page-compiler/src/page-compiler-format-options.spec.ts index 6a11629..57105b6 100644 --- a/packages/@kamado-io/page-compiler/src/page-compiler-format-options.spec.ts +++ b/packages/@kamado-io/page-compiler/src/page-compiler-format-options.spec.ts @@ -1,20 +1,29 @@ +import type { Transform } from 'kamado/config'; import type { CompilableFile, FileContent, MetaData } from 'kamado/files'; +import { minify } from 'html-minifier-terser'; import { mergeConfig } from 'kamado/config'; +import { format as prettierFormat } from 'prettier'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { createPageCompiler } from './page-compiler.js'; -import { prettier } from './transform/prettier.js'; -// Force prettier.format to always reject so the full -// pageCompiler({ formatOptions }) → createDefaultPageTransforms → prettier -// wire-through can be observed without depending on real parser behavior. +// Both format dependencies are mocked with their real implementation by default. +// Individual tests override the mock per-call to force a failure. vi.mock('prettier', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - format: vi.fn(() => Promise.reject(new SyntaxError('forced test parse error'))), - resolveConfig: vi.fn(() => Promise.resolve(null)), + format: vi.fn(actual.format), + resolveConfig: vi.fn(actual.resolveConfig), + }; +}); + +vi.mock('html-minifier-terser', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + minify: vi.fn(actual.minify), }; }); @@ -51,7 +60,25 @@ const TEST_PAGE: CompilableFile = { date: new Date(), }; -describe('createPageCompiler - formatOptions.parseError wire-through', async () => { +/** + * + * @param message + */ +function forcePrettierFailureOnce(message: string) { + vi.mocked(prettierFormat).mockImplementationOnce(() => + Promise.reject(new SyntaxError(message)), + ); +} + +/** + * + * @param message + */ +function forceMinifierFailureOnce(message: string) { + vi.mocked(minify).mockImplementationOnce(() => Promise.reject(new Error(message))); +} + +describe('createPageCompiler — formatOptions.parseError (pipeline-level)', async () => { const config = await mergeConfig({}); let warnSpy: ReturnType; @@ -69,54 +96,212 @@ describe('createPageCompiler - formatOptions.parseError wire-through', async () warnSpy.mockRestore(); }); - test('default formatOptions: prettier failure is swallowed silently', async () => { - const pageC = createPageCompiler()({}); - const fn = await pageC.compiler(config); - const result = await fn(TEST_PAGE, () => ''); - // Body of the unformatted page reaches the output (manipulateDOM wraps in /) - expect(typeof result).toBe('string'); - expect(result).toContain('

Hello

'); - expect(warnSpy).not.toHaveBeenCalled(); + describe('prettier failure', () => { + test("default 'silent': prettier failure is swallowed, output flows through", async () => { + forcePrettierFailureOnce('forced prettier failure'); + const pageC = createPageCompiler()({}); + const fn = await pageC.compiler(config); + const result = await fn(TEST_PAGE, () => ''); + + expect(result).toContain('

Hello

'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("'warning': prettier failure logs the full message", async () => { + forcePrettierFailureOnce('forced prettier failure'); + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'warning' }, + }); + const fn = await pageC.compiler(config); + const result = await fn(TEST_PAGE, () => ''); + + expect(result).toContain('

Hello

'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toBe( + "Transform 'prettier' failed on /path/to/page.html: forced prettier failure", + ); + }); + + test("'error': prettier failure throws with transform name and source path", async () => { + forcePrettierFailureOnce('forced prettier failure'); + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'error' }, + }); + const fn = await pageC.compiler(config); + + await expect(fn(TEST_PAGE, () => '')).rejects.toThrow( + "Transform 'prettier' failed on /path/to/page.html: forced prettier failure", + ); + }); + + test("'error' preserves the underlying error on cause", async () => { + forcePrettierFailureOnce('forced prettier failure'); + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'error' }, + }); + const fn = await pageC.compiler(config); + + const err = await fn(TEST_PAGE, () => '').then( + () => null, + (error: unknown) => error, + ); + + expect(err).toBeInstanceOf(Error); + const wrapped = err as Error; + expect(wrapped.cause).toBeInstanceOf(SyntaxError); + expect((wrapped.cause as Error).message).toBe('forced prettier failure'); + }); }); - test("formatOptions.parseError = 'silent' is forwarded to the default prettier", async () => { - const pageC = createPageCompiler()({ formatOptions: { parseError: 'silent' } }); - const fn = await pageC.compiler(config); - const result = await fn(TEST_PAGE, () => ''); - expect(result).toContain('

Hello

'); - expect(warnSpy).not.toHaveBeenCalled(); + describe('minifier failure', () => { + test("default 'silent': minifier failure is swallowed, output flows through", async () => { + forceMinifierFailureOnce('forced minifier failure'); + const pageC = createPageCompiler()({}); + const fn = await pageC.compiler(config); + const result = await fn(TEST_PAGE, () => ''); + + expect(result).toContain('

Hello

'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("'warning': minifier failure logs the full message", async () => { + forceMinifierFailureOnce('forced minifier failure'); + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'warning' }, + }); + const fn = await pageC.compiler(config); + const result = await fn(TEST_PAGE, () => ''); + + expect(result).toContain('

Hello

'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toBe( + "Transform 'minifier' failed on /path/to/page.html: forced minifier failure", + ); + }); + + test("'error': minifier failure throws with transform name and source path", async () => { + forceMinifierFailureOnce('forced minifier failure'); + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'error' }, + }); + const fn = await pageC.compiler(config); + + await expect(fn(TEST_PAGE, () => '')).rejects.toThrow( + "Transform 'minifier' failed on /path/to/page.html: forced minifier failure", + ); + }); }); - test("formatOptions.parseError = 'warning' is forwarded: console.warn fires with the full message", async () => { - const pageC = createPageCompiler()({ formatOptions: { parseError: 'warning' } }); - const fn = await pageC.compiler(config); - const result = await fn(TEST_PAGE, () => ''); - expect(result).toContain('

Hello

'); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0]?.[0]).toBe( - 'Prettier failed to format /path/to/page.html: forced test parse error', - ); + describe('custom transform failure (verifies "all transforms" scope)', () => { + const throwingCustomTransform: Transform = { + name: 'my-broken-transform', + transform: () => { + throw new Error('custom transform exploded'); + }, + }; + + test("'silent': a failing custom transform is skipped", async () => { + const pageC = createPageCompiler()({ + transforms: [throwingCustomTransform], + }); + const fn = await pageC.compiler(config); + const result = await fn(TEST_PAGE, () => ''); + + expect(typeof result).toBe('string'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("'error': a failing custom transform throws with its name in the message", async () => { + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'error' }, + transforms: [throwingCustomTransform], + }); + const fn = await pageC.compiler(config); + + await expect(fn(TEST_PAGE, () => '')).rejects.toThrow( + "Transform 'my-broken-transform' failed on /path/to/page.html: custom transform exploded", + ); + }); + + test("'warning': a failing custom transform logs with its name", async () => { + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'warning' }, + transforms: [throwingCustomTransform], + }); + const fn = await pageC.compiler(config); + const result = await fn(TEST_PAGE, () => ''); + + expect(typeof result).toBe('string'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toBe( + "Transform 'my-broken-transform' failed on /path/to/page.html: custom transform exploded", + ); + }); }); - test("formatOptions.parseError = 'error' is forwarded: compilePage throws with the full wrapped message", async () => { - const pageC = createPageCompiler()({ formatOptions: { parseError: 'error' } }); - const fn = await pageC.compiler(config); - await expect(fn(TEST_PAGE, () => '')).rejects.toThrow( - 'Prettier failed to format /path/to/page.html: forced test parse error', - ); + describe('pipeline continuation', () => { + test("'warning': loop continues after a failure — a later transform that also fails logs too", async () => { + // prettier is the 3rd transform; minifier is the 4th. If the catch broke + // out of the loop after prettier failed, minifier would never run and warnSpy + // would be called only once. We assert it is called twice — the loop continues. + forcePrettierFailureOnce('forced prettier failure'); + forceMinifierFailureOnce('forced minifier failure'); + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'warning' }, + }); + const fn = await pageC.compiler(config); + await fn(TEST_PAGE, () => ''); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[0]?.[0]).toBe( + "Transform 'prettier' failed on /path/to/page.html: forced prettier failure", + ); + expect(warnSpy.mock.calls[1]?.[0]).toBe( + "Transform 'minifier' failed on /path/to/page.html: forced minifier failure", + ); + }); + + test("'silent': the next transform receives the previous step's output", async () => { + // Verifies that after a silent failure, the pipeline continues AND the + // downstream transform observes content. Asserted indirectly via the + // minify mock being invoked even though prettier failed. + forcePrettierFailureOnce('forced prettier failure'); + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'silent' }, + }); + const fn = await pageC.compiler(config); + await fn(TEST_PAGE, () => ''); + + expect(vi.mocked(minify)).toHaveBeenCalled(); + }); }); - test('formatOptions.parseError is IGNORED when a custom transforms array is supplied', async () => { - // User supplies their own transforms array → defaults (and their parseError) are dropped. - // Even though formatOptions.parseError = 'error', the custom prettier with parseError = 'silent' - // wins, so compilation must NOT throw. - const pageC = createPageCompiler()({ - formatOptions: { parseError: 'error' }, - transforms: [prettier({ parseError: 'silent' })], + describe('inputPath -> outputPath fallback', () => { + test('uses outputPath in the message when inputPath is missing', async () => { + const fileWithoutInputPath = { + ...TEST_PAGE, + inputPath: undefined as unknown as string, + outputPath: '/build/page.html', + }; + // The kamado/files mock looks up content via file.inputPath; register the + // undefined key so the lookup succeeds for this scenario. + mockFileContents.set(undefined as unknown as string, { + metaData: {} as MetaData, + content: '

Hello

', + raw: '

Hello

', + }); + + forcePrettierFailureOnce('forced prettier failure'); + const pageC = createPageCompiler()({ + formatOptions: { parseError: 'warning' }, + }); + const fn = await pageC.compiler(config); + await fn(fileWithoutInputPath, () => ''); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toBe( + "Transform 'prettier' failed on /build/page.html: forced prettier failure", + ); }); - const fn = await pageC.compiler(config); - const result = await fn(TEST_PAGE, () => ''); - expect(typeof result).toBe('string'); - expect(warnSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/@kamado-io/page-compiler/src/page-compiler.ts b/packages/@kamado-io/page-compiler/src/page-compiler.ts index 910492a..24beece 100644 --- a/packages/@kamado-io/page-compiler/src/page-compiler.ts +++ b/packages/@kamado-io/page-compiler/src/page-compiler.ts @@ -1,6 +1,6 @@ import type { GetNavTreeOptions } from './features/nav.js'; import type { TitleListOptions } from './features/title-list.js'; -import type { CompileData, PageCompilerOptions } from './types.js'; +import type { CompileData, PageCompilerOptions, ParseErrorMode } from './types.js'; import type { Transform, TransformContext } from 'kamado/config'; import type { MetaData } from 'kamado/files'; @@ -160,9 +160,7 @@ export function createPageCompiler() { compile, }; - const defaultPageTransforms = createDefaultPageTransforms({ - parseError: options?.formatOptions?.parseError, - }); + const defaultPageTransforms = createDefaultPageTransforms(); // Use provided transforms or default const transforms: Transform[] = @@ -170,10 +168,30 @@ export function createPageCompiler() { ? options.transforms(defaultPageTransforms) : (options?.transforms ?? defaultPageTransforms); - // Apply transforms sequentially + const parseErrorMode: ParseErrorMode = + options?.formatOptions?.parseError ?? 'silent'; + + // Apply transforms sequentially. Any transform failure is routed through + // the formatOptions.parseError policy: on silent/warning the failing + // transform is skipped and the previous step's output flows through. let result: string | ArrayBuffer = html; for (const transform of transforms) { - result = await transform.transform(result, transformContext); + try { + result = await transform.transform(result, transformContext); + } catch (error) { + const source = transformContext.inputPath ?? transformContext.outputPath; + const original = error instanceof Error ? error.message : String(error); + const message = `Transform '${transform.name}' failed on ${source}: ${original}`; + + if (parseErrorMode === 'error') { + throw new Error(message, { cause: error }); + } + if (parseErrorMode === 'warning') { + // eslint-disable-next-line no-console + console.warn(message); + } + // silent / warning: keep `result` as-is and continue + } } // Ensure result is string @@ -190,8 +208,7 @@ export function createPageCompiler() { // Re-export types export type * from './types.js'; -export type { DefaultPageTransformsOptions } from './page-transform.js'; -export type { PrettierOptions, PrettierParseErrorMode } from './transform/prettier.js'; +export type { PrettierOptions } from './transform/prettier.js'; // Re-export page transforms export { createDefaultPageTransforms } from './page-transform.js'; diff --git a/packages/@kamado-io/page-compiler/src/page-transform-parse-error.spec.ts b/packages/@kamado-io/page-compiler/src/page-transform-parse-error.spec.ts deleted file mode 100644 index b699572..0000000 --- a/packages/@kamado-io/page-compiler/src/page-transform-parse-error.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { TransformContext } from 'kamado/config'; -import type { MetaData } from 'kamado/files'; - -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import { createDefaultPageTransforms } from './page-transform.js'; - -// Force prettier.format to always reject so parseError propagation through -// createDefaultPageTransforms can be observed deterministically. -vi.mock('prettier', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - format: vi.fn(() => Promise.reject(new SyntaxError('forced test parse error'))), - resolveConfig: vi.fn(() => Promise.resolve(null)), - }; -}); - -/** - * - * @param overrides - */ -function createMockTransformInfo( - overrides?: Partial>, -): TransformContext { - const defaultContext: TransformContext = { - path: 'page.html', - filePath: 'page.html', - inputPath: '/test/input/page.html', - outputPath: '/test/output/page.html', - outputDir: '/test/output', - isServe: false, - context: { - mode: 'build', - dir: { - root: '/test', - input: '/test/input', - output: '/test/output', - }, - pkg: { - production: { - baseURL: 'https://example.com', - }, - }, - compilers: [], - devServer: { - host: 'localhost', - port: 3000, - open: false, - }, - }, - compile: () => Promise.resolve('
mock
'), - }; - - return { ...defaultContext, ...overrides }; -} - -/** - * - * @param parseError - */ -function getPrettierFromDefaults(parseError?: 'silent' | 'warning' | 'error') { - const transforms = createDefaultPageTransforms( - parseError === undefined ? undefined : { parseError }, - ); - const prettierTransform = transforms.find((t) => t.name === 'prettier'); - expect(prettierTransform).toBeDefined(); - return prettierTransform!; -} - -describe('createDefaultPageTransforms - parseError propagation', () => { - let warnSpy: ReturnType; - - beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - test("default (no option) propagates as 'silent': returns content, no warn", async () => { - const prettierTransform = getPrettierFromDefaults(); - const result = await prettierTransform.transform('', createMockTransformInfo()); - expect(result).toBe(''); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test("parseError 'silent' propagates: returns content, no warn", async () => { - const prettierTransform = getPrettierFromDefaults('silent'); - const result = await prettierTransform.transform('', createMockTransformInfo()); - expect(result).toBe(''); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test("parseError 'warning' propagates: console.warn called once with full message", async () => { - const prettierTransform = getPrettierFromDefaults('warning'); - const result = await prettierTransform.transform('', createMockTransformInfo()); - expect(result).toBe(''); - expect(warnSpy).toHaveBeenCalledTimes(1); - const calledWith = warnSpy.mock.calls[0]?.[0] as string; - expect(calledWith).toBe( - 'Prettier failed to format /test/input/page.html: forced test parse error', - ); - }); - - test("parseError 'error' propagates: throw with full wrapped message", async () => { - const prettierTransform = getPrettierFromDefaults('error'); - await expect( - prettierTransform.transform('', createMockTransformInfo()), - ).rejects.toThrow( - 'Prettier failed to format /test/input/page.html: forced test parse error', - ); - }); -}); diff --git a/packages/@kamado-io/page-compiler/src/page-transform.ts b/packages/@kamado-io/page-compiler/src/page-transform.ts index 8e6aa9d..529dbd1 100644 --- a/packages/@kamado-io/page-compiler/src/page-transform.ts +++ b/packages/@kamado-io/page-compiler/src/page-transform.ts @@ -1,5 +1,4 @@ // Import transform factories for defaultPageTransforms -import type { PrettierParseErrorMode } from './transform/prettier.js'; import type { Transform } from 'kamado/config'; import type { MetaData } from 'kamado/files'; @@ -9,35 +8,24 @@ import { manipulateDOM } from './transform/manipulate-dom.js'; import { minifier } from './transform/minifier.js'; import { prettier } from './transform/prettier.js'; -/** - * Options for createDefaultPageTransforms - */ -export interface DefaultPageTransformsOptions { - /** - * Forwarded to the default prettier transform. See {@link PrettierParseErrorMode}. - */ - readonly parseError?: PrettierParseErrorMode; -} - /** * Creates the default page transform pipeline: `manipulateDOM`, `doctype`, * `prettier`, `minifier`, `lineBreak` (in execution order). + * + * Transform failures are routed through the pipeline-level + * `formatOptions.parseError` policy on `PageCompilerOptions` — there is no + * per-transform error mode. * @template M - Metadata (frontmatter) type for pages handled by these transforms - * @param options - Settings forwarded to individual default transforms. See - * {@link DefaultPageTransformsOptions} for the full list of fields. Currently - * only `parseError` is forwarded (to the default `prettier` transform). * @returns Array of default transforms, ready to use or to extend via * `PageCompilerOptions.transforms` */ -export function createDefaultPageTransforms( - options?: DefaultPageTransformsOptions, -): Transform[] { +export function createDefaultPageTransforms(): Transform[] { return [ manipulateDOM({ imageSizes: true }), // Postprocess phase doctype(), - prettier({ parseError: options?.parseError }), + prettier(), minifier(), lineBreak(), ]; diff --git a/packages/@kamado-io/page-compiler/src/transform/minifier.ts b/packages/@kamado-io/page-compiler/src/transform/minifier.ts index 90615e3..ba1c2bf 100644 --- a/packages/@kamado-io/page-compiler/src/transform/minifier.ts +++ b/packages/@kamado-io/page-compiler/src/transform/minifier.ts @@ -11,7 +11,11 @@ export interface MinifierOptions { } /** - * Creates a transform for HTML minification + * Creates a transform for HTML minification. + * + * Throws when `html-minifier-terser` fails to parse the input. The page + * compiler routes such failures through the `formatOptions.parseError` + * policy on `PageCompilerOptions`. * @template M - Metadata (frontmatter) type for pages handled by this transform * @param options - Minifier options * @returns Transform object diff --git a/packages/@kamado-io/page-compiler/src/transform/prettier.spec.ts b/packages/@kamado-io/page-compiler/src/transform/prettier.spec.ts index de9cfa5..322d5a5 100644 --- a/packages/@kamado-io/page-compiler/src/transform/prettier.spec.ts +++ b/packages/@kamado-io/page-compiler/src/transform/prettier.spec.ts @@ -1,6 +1,6 @@ import type { Context, TransformContext } from 'kamado/config'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { describe, expect, test } from 'vitest'; import { prettier } from './prettier.js'; @@ -80,134 +80,12 @@ describe('prettier', () => { expect(typeof result).toBe('string'); }); - describe('parseError mode', () => { - let warnSpy: ReturnType; - - beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - test('should default to silent: return original content and not warn', async () => { - const transform = prettier({ - options: { parser: 'json' }, - }); - const info = createMockTransformInfo({ - inputPath: '/test/input/broken.pug', - }); - - const result = await transform.transform('not-valid-json', info); - - expect(result).toBe('not-valid-json'); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test("'silent' should return original content and not warn", async () => { - const transform = prettier({ - options: { parser: 'json' }, - parseError: 'silent', - }); - const info = createMockTransformInfo({ - inputPath: '/test/input/broken.pug', - }); - - const result = await transform.transform('not-valid-json', info); - - expect(result).toBe('not-valid-json'); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test("'warning' should console.warn with source path and return original content", async () => { - const transform = prettier({ - options: { parser: 'json' }, - parseError: 'warning', - }); - const info = createMockTransformInfo({ - inputPath: '/test/input/broken.pug', - }); - - const result = await transform.transform('not-valid-json', info); - - expect(result).toBe('not-valid-json'); - expect(warnSpy).toHaveBeenCalledTimes(1); - const calledWith = warnSpy.mock.calls[0]?.[0] as string; - expect(calledWith).toMatch( - /^Prettier failed to format \/test\/input\/broken\.pug: \S/, - ); - }); - - test("'warning' should fall back to outputPath when inputPath is missing", async () => { - const transform = prettier({ - options: { parser: 'json' }, - parseError: 'warning', - }); - const info = createMockTransformInfo({ - inputPath: undefined, - outputPath: '/test/output/broken.html', - }); - - const result = await transform.transform('not-valid-json', info); - - expect(result).toBe('not-valid-json'); - expect(warnSpy).toHaveBeenCalledTimes(1); - const calledWith = warnSpy.mock.calls[0]?.[0] as string; - expect(calledWith).toMatch( - /^Prettier failed to format \/test\/output\/broken\.html: \S/, - ); - }); - - test("'error' should include source file path when Prettier parser fails", async () => { - const transform = prettier({ - options: { parser: 'json' }, - parseError: 'error', - }); - const info = createMockTransformInfo({ - inputPath: '/test/input/broken.pug', - }); - - await expect(transform.transform('not-valid-json', info)).rejects.toThrow( - /^Prettier failed to format \/test\/input\/broken\.pug: \S/, - ); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - test("'error' should fall back to outputPath when inputPath is missing", async () => { - const transform = prettier({ - options: { parser: 'json' }, - parseError: 'error', - }); - const info = createMockTransformInfo({ - inputPath: undefined, - outputPath: '/test/output/broken.html', - }); - - await expect(transform.transform('not-valid-json', info)).rejects.toThrow( - /^Prettier failed to format \/test\/output\/broken\.html: \S/, - ); + test('should throw raw Prettier error so the page compiler can apply formatOptions.parseError', async () => { + const transform = prettier({ + options: { parser: 'json' }, }); + const info = createMockTransformInfo(); - test("'error' should preserve the original Prettier error as cause", async () => { - const transform = prettier({ - options: { parser: 'json' }, - parseError: 'error', - }); - const info = createMockTransformInfo({ - inputPath: '/test/input/broken.pug', - }); - - const error = await transform - .transform('not-valid-json', info) - .then(() => null) - .catch((error_: unknown) => error_); - - expect(error).toBeInstanceOf(Error); - const wrapped = error as Error; - expect(wrapped.cause).toBeInstanceOf(Error); - expect((wrapped.cause as Error).message.length).toBeGreaterThan(0); - expect(wrapped.message).toContain((wrapped.cause as Error).message); - }); + await expect(transform.transform('not-valid-json', info)).rejects.toThrow(); }); }); diff --git a/packages/@kamado-io/page-compiler/src/transform/prettier.ts b/packages/@kamado-io/page-compiler/src/transform/prettier.ts index 1c42605..a230d11 100644 --- a/packages/@kamado-io/page-compiler/src/transform/prettier.ts +++ b/packages/@kamado-io/page-compiler/src/transform/prettier.ts @@ -7,44 +7,22 @@ import { type Options as PrettierFormatOptions, } from 'prettier'; -/** - * Behavior when Prettier fails to parse the input. - * - `silent` (default): swallow the error and return the unformatted source - * - `warning`: console.warn with the source path, then return the unformatted source - * - `error`: throw an Error prefixed with the source path - */ -export type PrettierParseErrorMode = 'silent' | 'warning' | 'error'; - /** * Options for prettier */ export interface PrettierOptions { readonly options?: PrettierFormatOptions; - /** - * How to handle Prettier parse failures. Defaults to `'silent'`. - */ - readonly parseError?: PrettierParseErrorMode; } /** * Creates a transform for Prettier formatting. * - * When Prettier fails (typically a parser error on malformed HTML), the - * `parseError` option determines the behavior: - * - * - `'silent'` (default) — the unformatted source is returned as-is - * - `'warning'` — `console.warn` is invoked with a message prefixed with the - * source file path (`ctx.inputPath`, falling back to `ctx.outputPath`), and - * the unformatted source is returned - * - `'error'` — an `Error` prefixed with the source file path is thrown; the - * underlying Prettier error is preserved on `error.cause` so downstream - * handlers can inspect details such as `loc` + * Throws when Prettier fails (typically a parser error on malformed HTML). + * The page compiler routes such failures through the + * `formatOptions.parseError` policy on `PageCompilerOptions`. * @template M - Metadata (frontmatter) type for pages handled by this transform * @param options - Prettier options * @returns Transform object - * @throws Error - Only when `parseError` is `'error'` and Prettier fails to - * format the input. The message identifies the source file; the underlying - * Prettier error is available via `error.cause`. */ export function prettier(options?: PrettierOptions): Transform { return { @@ -59,30 +37,14 @@ export function prettier(options?: PrettierOptions): Transfo ? await prettierResolveConfig(ctx.inputPath) : null; - try { - return await prettierFormat(content, { - parser: 'html', - printWidth: 100_000, - tabWidth: 2, - useTabs: false, - ...prettierConfig, - ...options?.options, - }); - } catch (error) { - const source = ctx.inputPath ?? ctx.outputPath; - const original = error instanceof Error ? error.message : String(error); - const message = `Prettier failed to format ${source}: ${original}`; - const mode: PrettierParseErrorMode = options?.parseError ?? 'silent'; - - if (mode === 'error') { - throw new Error(message, { cause: error }); - } - if (mode === 'warning') { - // eslint-disable-next-line no-console - console.warn(message); - } - return content; - } + return await prettierFormat(content, { + parser: 'html', + printWidth: 100_000, + tabWidth: 2, + useTabs: false, + ...prettierConfig, + ...options?.options, + }); }, }; } diff --git a/packages/@kamado-io/page-compiler/src/types.ts b/packages/@kamado-io/page-compiler/src/types.ts index c76dee3..d6ec878 100644 --- a/packages/@kamado-io/page-compiler/src/types.ts +++ b/packages/@kamado-io/page-compiler/src/types.ts @@ -1,11 +1,20 @@ import type { BreadcrumbItem } from './features/breadcrumbs.js'; import type { GetNavTreeOptions, NavNode } from './features/nav.js'; import type { TitleListOptions } from './features/title-list.js'; -import type { PrettierParseErrorMode } from './transform/prettier.js'; import type { PathListToTreeOptions } from '@d-zero/shared/path-list-to-tree'; import type { Transform } from 'kamado/config'; import type { CompilableFile, FileObject, MetaData } from 'kamado/files'; +/** + * Pipeline-level error policy applied when a transform throws during page + * compilation. + * - `'silent'` (default): swallow the error, skip the failing transform, + * and pass the previous step's output to the next one + * - `'warning'`: `console.warn` with the transform name and source path, then skip + * - `'error'`: throw an `Error` prefixed with the transform name and source path + */ +export type ParseErrorMode = 'silent' | 'warning' | 'error'; + /** * Options for the page compiler * @template M - Custom metadata type extending MetaData @@ -157,11 +166,11 @@ export interface PageCompilerOptions { */ readonly navigationComparator?: PathListToTreeOptions['comparator']; /** - * Options applied to the default format transforms. + * Pipeline-level format error policy. * - * Only forwarded to the transforms produced by `createDefaultPageTransforms`. - * If a custom `transforms` array is supplied, pass these settings to the - * relevant transform factories directly (e.g. `prettier({ parseError })`). + * Applied to **every** transform in the compiled pipeline, including the + * default transforms (`prettier`, `minifier`, etc.) and any custom + * transforms supplied via `transforms`. * @example * ```typescript * createPageCompiler()({ @@ -171,12 +180,10 @@ export interface PageCompilerOptions { */ readonly formatOptions?: { /** - * Behavior when Prettier fails to parse the input. - * - `'silent'` (default): swallow the error and emit the unformatted source - * - `'warning'`: `console.warn` with the source path, then emit the unformatted source - * - `'error'`: throw an `Error` prefixed with the source path + * Behavior when ANY transform throws during the page pipeline. + * See {@link ParseErrorMode}. */ - readonly parseError?: PrettierParseErrorMode; + readonly parseError?: ParseErrorMode; }; } From e75dcbb6a5bc39e524811c9a0c49386043f40f3c Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Tue, 12 May 2026 16:03:41 +0900 Subject: [PATCH 02/20] feat(kamado): add outputPathConflict policy for output-path collisions The orchestrator's output-path collision behavior is now configurable per compiler entry via `outputPathConflict: 'error' | 'warning' | 'silent'`, defaulting to `'warning'`. When a winner must be picked, a file whose outputPath came from the frontmatter override beats one using the default computed path; otherwise the first-seen file wins. Co-Authored-By: Claude Opus 4.7 --- packages/kamado/ARCHITECTURE.ja.md | 4 +- packages/kamado/ARCHITECTURE.md | 4 +- .../compiler/create-custom-compiler.spec.ts | 26 ++ .../src/compiler/create-custom-compiler.ts | 2 + packages/kamado/src/compiler/types.ts | 23 ++ packages/kamado/src/data/assets.spec.ts | 370 +++++++++++++++++- packages/kamado/src/data/get-asset-group.ts | 52 ++- 7 files changed, 462 insertions(+), 19 deletions(-) diff --git a/packages/kamado/ARCHITECTURE.ja.md b/packages/kamado/ARCHITECTURE.ja.md index 1931663..438d2a2 100644 --- a/packages/kamado/ARCHITECTURE.ja.md +++ b/packages/kamado/ARCHITECTURE.ja.md @@ -239,7 +239,9 @@ graph TD フィールドが設定されている場合、`getAssetGroup()` は各ファイルの frontmatter(および同名 `.json` サイドカー)を返却前に読み込みます。指定フィールドの値が非空の文字列であれば、`resolveMetaPath()`(`packages/kamado/src/path/resolve-meta-path.ts`)で `outputPath` / `url` / `filePathStem` / `fileSlug` を上書きパスから再計算します。文字列以外の値(数値・配列・オブジェクト・null など)は無視されます。 -許容形式は3種類です。`/foo/bar.html`(そのまま使用)、`/foo/bar`(コンパイラの `outputExtension` を補完)、`/foo/bar/`(ディレクトリ扱い → `index` を補完)。`.` と `..` の両セグメントは拒否され、最終ガードとして `dir.output` 外に解決されるパスも拒否します。複数のソースが同一の出力パスに解決された場合は衝突エラーで停止します。 +許容形式は3種類です。`/foo/bar.html`(そのまま使用)、`/foo/bar`(コンパイラの `outputExtension` を補完)、`/foo/bar/`(ディレクトリ扱い → `index` を補完)。`.` と `..` の両セグメントは拒否され、最終ガードとして `dir.output` 外に解決されるパスも拒否します。 + +複数のソースが同一の出力パスに解決された場合の挙動は、コンパイラエントリの `outputPathConflict` 設定で切り替えます。`'error'`(throw)、`'warning'`(デフォルト — `stderr` に警告を出して勝者を残す)、`'silent'`(ログなしで勝者を残す)の3値を取ります。勝者判定のルールは2段階で、まず **frontmatter による上書きを持つファイルがデフォルト計算パスのファイルに優先** し、同等の場合は **先勝ち** です。`getAssetGroup()` 内の `seen` Map で出力パスを追跡し、置換が起きても返却される `CompilableFile[]` の位置は最初に観測したファイルの位置を保持するため、処理順に依存しない結果になります。 先読みは `files/file-content.ts` のモジュールレベルキャッシュを温めるため、build の後段の `getContentFromFile`(`cache=true`)はディスク再読込を行いません。dev server は編集を反映するためリクエスト毎に `cache=false` を渡すので、先読みコストは起動時の1回のみ支払われます。上書きは `getAssetGroup` が返す `CompilableFile` に既に反映されているため、`compilableFileMap`(dev server)と `build()`(`file.outputPath` に書き出し)はどちらも追加変更なしで上書きを尊重します。 diff --git a/packages/kamado/ARCHITECTURE.md b/packages/kamado/ARCHITECTURE.md index d98f114..7d90ff1 100644 --- a/packages/kamado/ARCHITECTURE.md +++ b/packages/kamado/ARCHITECTURE.md @@ -239,7 +239,9 @@ Compiler entries may opt in to frontmatter-driven output-path overrides by setti When the field is configured, `getAssetGroup()` reads each matched file's frontmatter (and JSON sidecar) before returning. If the resolved value is a non-empty string, the file's `outputPath`, `url`, `filePathStem`, and `fileSlug` are recomputed from that override via `resolveMetaPath()` (`packages/kamado/src/path/resolve-meta-path.ts`). Non-string values (numbers, arrays, objects, null) are ignored. -Three forms are accepted: `/foo/bar.html` (used as-is), `/foo/bar` (compiler's `outputExtension` is appended), and `/foo/bar/` (treated as a directory; `index` is appended). Both `.` and `..` segments are rejected, and a final guard rejects any path that resolves outside `dir.output`. Two source files resolving to the same output path raise a collision error. +Three forms are accepted: `/foo/bar.html` (used as-is), `/foo/bar` (compiler's `outputExtension` is appended), and `/foo/bar/` (treated as a directory; `index` is appended). Both `.` and `..` segments are rejected, and a final guard rejects any path that resolves outside `dir.output`. + +When two source files resolve to the same output path, the compiler entry's `outputPathConflict` setting decides the reaction: `'error'` (throw), `'warning'` (default — log to `stderr` and pick a winner), or `'silent'` (pick a winner with no log). Winner selection rules: a file whose `outputPath` came from the frontmatter override beats one using the default computed path; among ties the first-seen file wins. The map of seen output paths is built in `getAssetGroup()` and replacement is order-independent because the surviving entry's position in the Map (and therefore in the returned `CompilableFile[]`) is the first-seen position. The eager read warms the module-level cache in `files/file-content.ts`, so the build's later `getContentFromFile` call (with `cache=true`) does not re-read from disk. The dev server's per-request compile passes `cache=false` to pick up edits, so the eager read is paid only once at startup. Because the override is reflected in the `CompilableFile` returned by `getAssetGroup`, both `compilableFileMap` (dev server) and `build()` (which writes to `file.outputPath`) honor the override with no further changes. diff --git a/packages/kamado/src/compiler/create-custom-compiler.spec.ts b/packages/kamado/src/compiler/create-custom-compiler.spec.ts index e8bd0af..e9f62cd 100644 --- a/packages/kamado/src/compiler/create-custom-compiler.spec.ts +++ b/packages/kamado/src/compiler/create-custom-compiler.spec.ts @@ -66,4 +66,30 @@ describe('createCustomCompiler', () => { }); expect(entry.ignore).toBe('**/_*'); }); + + test('outputPathConflict is undefined when user omits it', () => { + const entry = createCustomCompiler(factoryWithoutDefaults)(); + expect(entry.outputPathConflict).toBeUndefined(); + }); + + test('user-provided outputPathConflict "silent" is propagated', () => { + const entry = createCustomCompiler(factoryWithoutDefaults)({ + outputPathConflict: 'silent', + }); + expect(entry.outputPathConflict).toBe('silent'); + }); + + test('user-provided outputPathConflict "error" is propagated', () => { + const entry = createCustomCompiler(factoryWithoutDefaults)({ + outputPathConflict: 'error', + }); + expect(entry.outputPathConflict).toBe('error'); + }); + + test('user-provided outputPathConflict "warning" is propagated', () => { + const entry = createCustomCompiler(factoryWithoutDefaults)({ + outputPathConflict: 'warning', + }); + expect(entry.outputPathConflict).toBe('warning'); + }); }); diff --git a/packages/kamado/src/compiler/create-custom-compiler.ts b/packages/kamado/src/compiler/create-custom-compiler.ts index f74e814..56b9e9a 100644 --- a/packages/kamado/src/compiler/create-custom-compiler.ts +++ b/packages/kamado/src/compiler/create-custom-compiler.ts @@ -23,12 +23,14 @@ export function createCustomCompiler( const outputExtension = userOptions?.outputExtension ?? result.defaultOutputExtension; const ignore = userOptions?.ignore; const outputPathField = userOptions?.outputPathField ?? result.defaultOutputPathField; + const outputPathConflict = userOptions?.outputPathConflict; return { files, ignore, outputExtension, outputPathField, + outputPathConflict, compiler: result.compile(userOptions), }; }; diff --git a/packages/kamado/src/compiler/types.ts b/packages/kamado/src/compiler/types.ts index e617929..df2ad1c 100644 --- a/packages/kamado/src/compiler/types.ts +++ b/packages/kamado/src/compiler/types.ts @@ -105,8 +105,26 @@ export interface CustomCompilerMetadataOptions { * pre-read). */ readonly outputPathField?: string; + /** + * Policy for handling two source files that resolve to the same output path. + * - `'error'`: throw and abort the build. + * - `'warning'`: log a warning and keep one file (default). + * - `'silent'`: keep one file with no log output. + * + * When a winner must be picked (`'warning'` / `'silent'`), a file whose + * `outputPath` came from the frontmatter override always beats one using + * the default computed path; among ties the first-seen file wins. + * Default: `'warning'`. + */ + readonly outputPathConflict?: OutputPathConflictPolicy; } +/** + * Policy for handling output-path collisions. + * See {@link CustomCompilerMetadataOptions.outputPathConflict}. + */ +export type OutputPathConflictPolicy = 'silent' | 'warning' | 'error'; + /** * Compiler with metadata * Contains compiler function and metadata for file matching @@ -130,6 +148,11 @@ export interface CustomCompilerWithMetadata { * See {@link CustomCompilerMetadataOptions.outputPathField} for details. */ readonly outputPathField?: string; + /** + * Policy for handling output-path collisions. + * See {@link CustomCompilerMetadataOptions.outputPathConflict} for details. + */ + readonly outputPathConflict?: OutputPathConflictPolicy; /** * Compiler function */ diff --git a/packages/kamado/src/data/assets.spec.ts b/packages/kamado/src/data/assets.spec.ts index d31a66d..7c174c1 100644 --- a/packages/kamado/src/data/assets.spec.ts +++ b/packages/kamado/src/data/assets.spec.ts @@ -286,7 +286,7 @@ describe("getAssetGroup with frontmatter 'path' override", () => { expect(result[0]?.fileSlug).toBe('some'); }); - test('throws when two files resolve to the same output path', async () => { + test('"error" throws when two override files resolve to the same output path', async () => { vol.fromJSON({ '/mock/input/dir/a.html': '---\npath: /shared.html\n---\n

A

', '/mock/input/dir/b.html': '---\npath: /shared.html\n---\n

B

', @@ -300,12 +300,380 @@ describe("getAssetGroup with frontmatter 'path' override", () => { files: '**/*.html', outputExtension: '.html', outputPathField: 'path', + outputPathConflict: 'error', compiler: () => () => '', }, }), ).rejects.toThrow(/Output path collision/); }); + test('"error" message includes a hint on how to switch policies', async () => { + vol.fromJSON({ + '/mock/input/dir/a.html': '---\npath: /shared.html\n---\n

A

', + '/mock/input/dir/b.html': '---\npath: /shared.html\n---\n

B

', + }); + + await expect( + getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'error', + compiler: () => () => '', + }, + }), + ).rejects.toThrow(/outputPathConflict/); + }); + + test('"error" throws on non-override conflicts (same name, different extensions)', async () => { + vol.fromJSON({ + '/mock/input/dir/page.html': '

html

', + '/mock/input/dir/page.pug': 'p pug', + }); + + await expect( + getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.{html,pug}', + outputExtension: '.html', + outputPathConflict: 'error', + compiler: () => () => '', + }, + }), + ).rejects.toThrow(/Output path collision/); + }); + + test('default policy ("warning") between two overrides: first-seen wins, warns once', async () => { + vol.fromJSON({ + '/mock/input/dir/a.html': '---\npath: /shared.html\n---\n

A

', + '/mock/input/dir/b.html': '---\npath: /shared.html\n---\n

B

', + }); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + compiler: () => () => '', + }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.inputPath).toBe('/mock/input/dir/a.html'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/Output path collision/)); + } finally { + warn.mockRestore(); + } + }); + + test('"warning" on non-override conflict: first-seen wins, no override required', async () => { + vol.fromJSON({ + '/mock/input/dir/page.html': '

html-version

', + '/mock/input/dir/page.pug': 'p pug-version', + }); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.{html,pug}', + outputExtension: '.html', + outputPathConflict: 'warning', + compiler: () => () => '', + }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.inputPath).toBe('/mock/input/dir/page.html'); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + test('"silent" suppresses the warning but still drops the loser', async () => { + vol.fromJSON({ + '/mock/input/dir/a.html': '---\npath: /shared.html\n---\n

A

', + '/mock/input/dir/b.html': '---\npath: /shared.html\n---\n

B

', + }); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'silent', + compiler: () => () => '', + }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.inputPath).toBe('/mock/input/dir/a.html'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + test('override beats default when the default is seen first (override replaces previous)', async () => { + // memfs preserves insertion order, so `shared.html` (no override) is visited before + // `with-override.html`. The override file is the second arrival yet must win. + vol.fromJSON({ + '/mock/input/dir/shared.html': '

plain

', + '/mock/input/dir/with-override.html': + '---\npath: /shared.html\n---\n

override

', + }); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'silent', + compiler: () => () => '', + }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.inputPath).toBe('/mock/input/dir/with-override.html'); + expect(result[0]?.outputPath).toBe('/mock/output/dir/shared.html'); + } finally { + warn.mockRestore(); + } + }); + + test('override beats default when the override is seen first (default discarded)', async () => { + // Insert the override file first so it is processed before the default. + // The default-path file must be discarded, confirming the rule is order-independent. + vol.fromJSON({ + '/mock/input/dir/with-override.html': + '---\npath: /shared.html\n---\n

override

', + '/mock/input/dir/shared.html': '

plain

', + }); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'silent', + compiler: () => () => '', + }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.inputPath).toBe('/mock/input/dir/with-override.html'); + expect(result[0]?.outputPath).toBe('/mock/output/dir/shared.html'); + } finally { + warn.mockRestore(); + } + }); + + test('three-way conflict: first override wins, default and second override are dropped', async () => { + // Processing order (memfs insertion order): + // 1. collide.html — default path → stored + // 2. first-override.html — override beats default → replaces #1 + // 3. second-override.html — override + first-wins among ties → dropped + vol.fromJSON({ + '/mock/input/dir/collide.html': '

plain default

', + '/mock/input/dir/first-override.html': + '---\npath: /collide.html\n---\n

first override

', + '/mock/input/dir/second-override.html': + '---\npath: /collide.html\n---\n

second override

', + }); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'silent', + compiler: () => () => '', + }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.inputPath).toBe('/mock/input/dir/first-override.html'); + expect(result[0]?.outputPath).toBe('/mock/output/dir/collide.html'); + } finally { + warn.mockRestore(); + } + }); + + test('frontmatter parse errors still throw under "silent" policy', async () => { + // Conflict policy must not swallow parse failures — they are a separate failure mode. + vol.fromJSON({ + '/mock/input/dir/source.html': '

X

', + '/mock/input/dir/source.json': '{ this is not json', + }); + + await expect( + getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'silent', + compiler: () => () => '', + }, + }), + ).rejects.toThrow(/Failed to read frontmatter from \/mock\/input\/dir\/source\.html/); + }); + + test('"warning" emits one warning per collision (N-way conflict)', async () => { + // 3 override files sharing the same output path → 2 conflicts → 2 warnings. + vol.fromJSON({ + '/mock/input/dir/a.html': '---\npath: /shared.html\n---\n

A

', + '/mock/input/dir/b.html': '---\npath: /shared.html\n---\n

B

', + '/mock/input/dir/c.html': '---\npath: /shared.html\n---\n

C

', + }); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'warning', + compiler: () => () => '', + }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.inputPath).toBe('/mock/input/dir/a.html'); + expect(warn).toHaveBeenCalledTimes(2); + } finally { + warn.mockRestore(); + } + }); + + test('non-string `path` is treated as fromOverride=false in conflict context', async () => { + // `with-override.html` has a valid override → fromOverride=true, outputPath=/shared.html. + // `shared.html` has a non-string `path: 42` → ignored, fromOverride=false, default + // outputPath=/shared.html. They collide on /shared.html; since only one side carries + // a real override, the override file must win regardless of glob order. + vol.fromJSON({ + '/mock/input/dir/with-override.html': + '---\npath: /shared.html\n---\n

override

', + '/mock/input/dir/shared.html': '---\npath: 42\n---\n

non-string

', + }); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = await getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'silent', + compiler: () => () => '', + }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.inputPath).toBe('/mock/input/dir/with-override.html'); + expect(result[0]?.outputPath).toBe('/mock/output/dir/shared.html'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + test('"error" throws on the first collision without processing later files', async () => { + // File 3 has malformed frontmatter that would throw a parse error if reached. + // If 'error' policy correctly aborts on the first collision (between files 1 and 2), + // file 3 is never read and the thrown error references the first pair, not the parse error. + vol.fromJSON({ + '/mock/input/dir/a.html': '---\npath: /shared.html\n---\n

A

', + '/mock/input/dir/b.html': '---\npath: /shared.html\n---\n

B

', + '/mock/input/dir/c.html': '

C

', + '/mock/input/dir/c.json': '{ this is not json', + }); + + await expect( + getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'error', + compiler: () => () => '', + }, + }), + ).rejects.toThrow( + /Output path collision.+'\/mock\/input\/dir\/a\.html' and '\/mock\/input\/dir\/b\.html'/, + ); + }); + + test('"silent" emits no console output at all (warn, log, and error are untouched)', async () => { + vol.fromJSON({ + '/mock/input/dir/a.html': '---\npath: /shared.html\n---\n

A

', + '/mock/input/dir/b.html': '---\npath: /shared.html\n---\n

B

', + }); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await getAssetGroup({ + inputDir: '/mock/input/dir', + outputDir: '/mock/output/dir', + compilerEntry: { + files: '**/*.html', + outputExtension: '.html', + outputPathField: 'path', + outputPathConflict: 'silent', + compiler: () => () => '', + }, + }); + + expect(warn).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + log.mockRestore(); + error.mockRestore(); + } + }); + test('rejects an invalid `path` with a helpful message', async () => { vol.fromJSON({ '/mock/input/dir/source.html': '---\npath: relative/no-slash\n---\n

X

', diff --git a/packages/kamado/src/data/get-asset-group.ts b/packages/kamado/src/data/get-asset-group.ts index f4e072c..d18444f 100644 --- a/packages/kamado/src/data/get-asset-group.ts +++ b/packages/kamado/src/data/get-asset-group.ts @@ -33,13 +33,18 @@ export interface GetAssetGroupOptions { * (and same-name JSON sidecar) is read eagerly. If the named field holds a * non-empty string, the file's `outputPath`, `url`, `filePathStem`, and * `fileSlug` are recomputed from that override via {@link resolveMetaPath}. - * Two source files resolving to the same `outputPath` raise a collision error. + * + * When two source files resolve to the same `outputPath`, the behavior is + * controlled by `compilerEntry.outputPathConflict` (default: `'warning'`). + * For `'warning'` and `'silent'`, a file with a frontmatter-override path + * beats one using the default path; otherwise the first-seen file wins. * @param context - Required context (inputDir, outputDir, compilerEntry). * @param options - Optional filtering options (glob). - * @returns The list of matched files as `CompilableFile` objects, in the order - * returned by the underlying glob. - * @throws {Error} on output-path collision, on invalid override path, or when - * frontmatter parsing fails for a matched file. + * @returns The list of matched files as `CompilableFile` objects. Order follows + * the underlying glob; on a non-`'error'` conflict the loser is dropped and + * the winner remains at the first-seen position. + * @throws {Error} on output-path collision when policy is `'error'`, on invalid + * override path, or when frontmatter parsing fails for a matched file. * @template M - Metadata type carried by the compiler entry. */ export async function getAssetGroup( @@ -66,8 +71,11 @@ export async function getAssetGroup( filePaths = filePaths.filter((filePath) => isMatch(filePath)); } - const results: CompilableFile[] = []; - const seen = new Map(); + const conflictPolicy = compilerEntry.outputPathConflict ?? 'warning'; + const seen = new Map< + string, + { filePath: string; file: CompilableFile; fromOverride: boolean } + >(); for (const filePath of filePaths) { let file = getFile(filePath, { @@ -76,6 +84,7 @@ export async function getAssetGroup( outputExtension: compilerEntry.outputExtension, }); + let fromOverride = false; const overrideField = compilerEntry.outputPathField; if (overrideField) { let metaData: Record; @@ -93,6 +102,7 @@ export async function getAssetGroup( outputDir, outputExtension: compilerEntry.outputExtension, }); + fromOverride = true; } catch (error) { throw new Error( `Invalid frontmatter '${overrideField}' in ${filePath}: ${(error as Error).message}`, @@ -101,18 +111,28 @@ export async function getAssetGroup( } } - const previousInput = seen.get(file.outputPath); - if (previousInput) { - throw new Error( - `Output path collision: '${file.outputPath}' is produced by both '${previousInput}' and '${filePath}'`, - ); + const previous = seen.get(file.outputPath); + if (previous) { + const message = + `Output path collision: '${file.outputPath}' is produced by both '${previous.filePath}' and '${filePath}'` + + ` (set \`outputPathConflict: 'warning' | 'silent'\` on the compiler entry to keep one file instead of throwing)`; + if (conflictPolicy === 'error') { + throw new Error(message); + } + if (conflictPolicy === 'warning') { + console.warn(message); + } + // Frontmatter override beats default; otherwise first-seen wins. + const newWins = fromOverride && !previous.fromOverride; + if (newWins) { + seen.set(file.outputPath, { filePath, file, fromOverride }); + } + continue; } - seen.set(file.outputPath, filePath); - - results.push(file); + seen.set(file.outputPath, { filePath, file, fromOverride }); } - return results; + return [...seen.values()].map((entry) => entry.file); } /** From 8f8241e017650e8f38729f0b463e99f1d5f489a2 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Tue, 12 May 2026 16:06:20 +0900 Subject: [PATCH 03/20] docs(page-compiler): document outputPathConflict policy --- packages/@kamado-io/page-compiler/README.md | 26 ++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/@kamado-io/page-compiler/README.md b/packages/@kamado-io/page-compiler/README.md index 3f2f68d..8403b8c 100644 --- a/packages/@kamado-io/page-compiler/README.md +++ b/packages/@kamado-io/page-compiler/README.md @@ -125,7 +125,7 @@ Rules: - The value must start with `/`. - `.` and `..` segments are rejected (the resolved path must stay inside `dir.output`). - Non-string values (numbers, arrays, etc.) for the configured field are ignored — only string values trigger an override. -- If two source files resolve to the same output path, the build aborts with a collision error pointing to both sources. +- If two source files resolve to the same output path, the behavior is controlled by `outputPathConflict` (see below). - A same-name `.json` sidecar takes precedence over the YAML frontmatter — the field declared in JSON wins. - Quote the value when it contains characters with special meaning in YAML (e.g. `:`). For example, `path: '/foo:bar/'` instead of `path: /foo:bar/`. @@ -135,6 +135,30 @@ Rules: `outputPathField` is intentionally configurable so it cannot collide with a frontmatter key your project already uses for other purposes. Pick a name that doesn't conflict — common choices are `path`, `permalink`, `outputPath`, or a project-specific name. Without `outputPathField` set, the page compiler does **not** read frontmatter eagerly and **does not** treat any field as routing data. +### Handling Output-Path Conflicts + +When two source files resolve to the same output path, `outputPathConflict` selects how the build reacts. The option lives on the compiler entry next to `outputPathField` and accepts three values: + +| Value | Behavior | +| ----------- | ---------------------------------------------------------------- | +| `'error'` | Abort the build with a collision error pointing to both sources. | +| `'warning'` | Log a warning to `stderr` and keep one file. **Default.** | +| `'silent'` | Keep one file with no log output. | + +```ts +def(createPageCompiler(), { + outputPathField: 'path', + outputPathConflict: 'error', // abort the build on any collision +}); +``` + +When the policy is `'warning'` or `'silent'` and a winner must be picked: + +1. A file whose `outputPath` came from the frontmatter override **beats** one using the default computed path. +2. Among ties (both override, or both default), the **first-seen** file wins. + +This means a routing override always takes precedence over an accidental default-path collision, regardless of the order in which the files are processed. + ## Transform Pipeline The page compiler uses a Transform Pipeline to process HTML content after compilation. Each transform is an object with a `name` and `transform` function that receives content and contextual information. From 5794229d6847d5121e7477f0f872f679b57d0889 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Tue, 12 May 2026 16:47:38 +0900 Subject: [PATCH 04/20] chore(release): publish v2.0.0-alpha.16 --- CHANGELOG.md | 20 +++++++++++++++++++ lerna.json | 2 +- .../@kamado-io/page-compiler/CHANGELOG.md | 16 +++++++++++++++ .../@kamado-io/page-compiler/package.json | 4 ++-- packages/@kamado-io/pug-compiler/CHANGELOG.md | 4 ++++ packages/@kamado-io/pug-compiler/package.json | 6 +++--- .../@kamado-io/script-compiler/CHANGELOG.md | 4 ++++ .../@kamado-io/script-compiler/package.json | 4 ++-- .../@kamado-io/style-compiler/CHANGELOG.md | 4 ++++ .../@kamado-io/style-compiler/package.json | 4 ++-- packages/kamado/CHANGELOG.md | 6 ++++++ packages/kamado/package.json | 2 +- yarn.lock | 14 ++++++------- 13 files changed, 72 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba442e7..2d76c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.0.0-alpha.16](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.15...v2.0.0-alpha.16) (2026-05-12) + +- refactor(page-compiler)!: route all transform failures through formatOptions.parseError ([4b1d189](https://github.com/d-zero-dev/kamado/commit/4b1d1897c4db2daadba7a4555f916f6682eed48c)) + +### Features + +- **kamado:** add outputPathConflict policy for output-path collisions ([e75dcbb](https://github.com/d-zero-dev/kamado/commit/e75dcbb6a5bc39e524811c9a0c49386043f40f3c)) + +### BREAKING CHANGES + +- `PrettierOptions.parseError` and the `PrettierParseErrorMode` + type are removed. The `DefaultPageTransformsOptions` interface is removed and + `createDefaultPageTransforms()` no longer takes an argument. The unified type + is now `ParseErrorMode` exported from the package entry. Error message format + changed to `Transform '' failed on : `. + +Tests cover prettier, minifier, and custom-transform failure under each of the +three modes, plus pipeline continuation (a second failure after the first is +still logged) and the `inputPath` -> `outputPath` fallback in the message. + # [2.0.0-alpha.15](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.14...v2.0.0-alpha.15) (2026-05-12) ### Bug Fixes diff --git a/lerna.json b/lerna.json index bcc3419..fbb25c2 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "lerna": "8.1.2", - "version": "2.0.0-alpha.15", + "version": "2.0.0-alpha.16", "npmClient": "yarn", "packages": ["packages/*", "packages/@kamado-io/*"], "command": { diff --git a/packages/@kamado-io/page-compiler/CHANGELOG.md b/packages/@kamado-io/page-compiler/CHANGELOG.md index c57e339..e68cc89 100644 --- a/packages/@kamado-io/page-compiler/CHANGELOG.md +++ b/packages/@kamado-io/page-compiler/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.0.0-alpha.16](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.15...v2.0.0-alpha.16) (2026-05-12) + +- refactor(page-compiler)!: route all transform failures through formatOptions.parseError ([4b1d189](https://github.com/d-zero-dev/kamado/commit/4b1d1897c4db2daadba7a4555f916f6682eed48c)) + +### BREAKING CHANGES + +- `PrettierOptions.parseError` and the `PrettierParseErrorMode` + type are removed. The `DefaultPageTransformsOptions` interface is removed and + `createDefaultPageTransforms()` no longer takes an argument. The unified type + is now `ParseErrorMode` exported from the package entry. Error message format + changed to `Transform '' failed on : `. + +Tests cover prettier, minifier, and custom-transform failure under each of the +three modes, plus pipeline continuation (a second failure after the first is +still logged) and the `inputPath` -> `outputPath` fallback in the message. + # [2.0.0-alpha.15](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.14...v2.0.0-alpha.15) (2026-05-12) ### Bug Fixes diff --git a/packages/@kamado-io/page-compiler/package.json b/packages/@kamado-io/page-compiler/package.json index b6c09bb..c9b2c0a 100644 --- a/packages/@kamado-io/page-compiler/package.json +++ b/packages/@kamado-io/page-compiler/package.json @@ -1,6 +1,6 @@ { "name": "@kamado-io/page-compiler", - "version": "2.0.0-alpha.15", + "version": "2.0.0-alpha.16", "description": "Page compiler for Kamado", "repository": { "type": "git", @@ -83,7 +83,7 @@ "fast-glob": "3.3.3", "html-minifier-terser": "7.2.0", "image-size": "2.0.2", - "kamado": "2.0.0-alpha.15", + "kamado": "2.0.0-alpha.16", "prettier": "3.8.1" }, "devDependencies": { diff --git a/packages/@kamado-io/pug-compiler/CHANGELOG.md b/packages/@kamado-io/pug-compiler/CHANGELOG.md index d1c9cf3..d60ee2a 100644 --- a/packages/@kamado-io/pug-compiler/CHANGELOG.md +++ b/packages/@kamado-io/pug-compiler/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.0.0-alpha.16](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.15...v2.0.0-alpha.16) (2026-05-12) + +**Note:** Version bump only for package @kamado-io/pug-compiler + # [2.0.0-alpha.15](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.14...v2.0.0-alpha.15) (2026-05-12) **Note:** Version bump only for package @kamado-io/pug-compiler diff --git a/packages/@kamado-io/pug-compiler/package.json b/packages/@kamado-io/pug-compiler/package.json index 596b1a0..6d56f9a 100644 --- a/packages/@kamado-io/pug-compiler/package.json +++ b/packages/@kamado-io/pug-compiler/package.json @@ -1,6 +1,6 @@ { "name": "@kamado-io/pug-compiler", - "version": "2.0.0-alpha.15", + "version": "2.0.0-alpha.16", "description": "Pug compiler for Kamado", "repository": { "type": "git", @@ -36,9 +36,9 @@ "kamado": "^1.0.0" }, "devDependencies": { - "@kamado-io/page-compiler": "2.0.0-alpha.15", + "@kamado-io/page-compiler": "2.0.0-alpha.16", "@types/pug": "2.0.10", - "kamado": "2.0.0-alpha.15", + "kamado": "2.0.0-alpha.16", "typescript": "6.0.2" } } diff --git a/packages/@kamado-io/script-compiler/CHANGELOG.md b/packages/@kamado-io/script-compiler/CHANGELOG.md index 653ac85..8cd26e9 100644 --- a/packages/@kamado-io/script-compiler/CHANGELOG.md +++ b/packages/@kamado-io/script-compiler/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.0.0-alpha.16](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.15...v2.0.0-alpha.16) (2026-05-12) + +**Note:** Version bump only for package @kamado-io/script-compiler + # [2.0.0-alpha.15](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.14...v2.0.0-alpha.15) (2026-05-12) **Note:** Version bump only for package @kamado-io/script-compiler diff --git a/packages/@kamado-io/script-compiler/package.json b/packages/@kamado-io/script-compiler/package.json index 6b19648..eb413fb 100644 --- a/packages/@kamado-io/script-compiler/package.json +++ b/packages/@kamado-io/script-compiler/package.json @@ -1,6 +1,6 @@ { "name": "@kamado-io/script-compiler", - "version": "2.0.0-alpha.15", + "version": "2.0.0-alpha.16", "description": "Script compiler for Kamado", "repository": { "type": "git", @@ -30,7 +30,7 @@ ], "dependencies": { "esbuild": "0.27.4", - "kamado": "2.0.0-alpha.15" + "kamado": "2.0.0-alpha.16" }, "devDependencies": { "typescript": "6.0.2" diff --git a/packages/@kamado-io/style-compiler/CHANGELOG.md b/packages/@kamado-io/style-compiler/CHANGELOG.md index 25f6de1..6d81779 100644 --- a/packages/@kamado-io/style-compiler/CHANGELOG.md +++ b/packages/@kamado-io/style-compiler/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.0.0-alpha.16](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.15...v2.0.0-alpha.16) (2026-05-12) + +**Note:** Version bump only for package @kamado-io/style-compiler + # [2.0.0-alpha.15](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.14...v2.0.0-alpha.15) (2026-05-12) **Note:** Version bump only for package @kamado-io/style-compiler diff --git a/packages/@kamado-io/style-compiler/package.json b/packages/@kamado-io/style-compiler/package.json index 33acd12..2db412d 100644 --- a/packages/@kamado-io/style-compiler/package.json +++ b/packages/@kamado-io/style-compiler/package.json @@ -1,6 +1,6 @@ { "name": "@kamado-io/style-compiler", - "version": "2.0.0-alpha.15", + "version": "2.0.0-alpha.16", "description": "Style compiler for Kamado", "repository": { "type": "git", @@ -30,7 +30,7 @@ ], "dependencies": { "cssnano": "7.1.3", - "kamado": "2.0.0-alpha.15", + "kamado": "2.0.0-alpha.16", "postcss": "8.5.8", "postcss-import": "16.1.1", "postcss-load-config": "6.0.1" diff --git a/packages/kamado/CHANGELOG.md b/packages/kamado/CHANGELOG.md index 02de2c3..1a2d159 100644 --- a/packages/kamado/CHANGELOG.md +++ b/packages/kamado/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.0.0-alpha.16](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.15...v2.0.0-alpha.16) (2026-05-12) + +### Features + +- **kamado:** add outputPathConflict policy for output-path collisions ([e75dcbb](https://github.com/d-zero-dev/kamado/commit/e75dcbb6a5bc39e524811c9a0c49386043f40f3c)) + # [2.0.0-alpha.15](https://github.com/d-zero-dev/kamado/compare/v2.0.0-alpha.14...v2.0.0-alpha.15) (2026-05-12) **Note:** Version bump only for package kamado diff --git a/packages/kamado/package.json b/packages/kamado/package.json index 726f38b..91b2211 100644 --- a/packages/kamado/package.json +++ b/packages/kamado/package.json @@ -1,6 +1,6 @@ { "name": "kamado", - "version": "2.0.0-alpha.15", + "version": "2.0.0-alpha.16", "description": "Bake your HTML hard. The on-demand static site generator.", "repository": "https://github.com/d-zero-dev/kamado.git", "author": "D-ZERO Co., Ltd.", diff --git a/yarn.lock b/yarn.lock index 0e5d907..bc044e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2108,7 +2108,7 @@ __metadata: languageName: node linkType: hard -"@kamado-io/page-compiler@npm:2.0.0-alpha.15, @kamado-io/page-compiler@workspace:packages/@kamado-io/page-compiler": +"@kamado-io/page-compiler@npm:2.0.0-alpha.16, @kamado-io/page-compiler@workspace:packages/@kamado-io/page-compiler": version: 0.0.0-use.local resolution: "@kamado-io/page-compiler@workspace:packages/@kamado-io/page-compiler" dependencies: @@ -2119,7 +2119,7 @@ __metadata: fast-glob: "npm:3.3.3" html-minifier-terser: "npm:7.2.0" image-size: "npm:2.0.2" - kamado: "npm:2.0.0-alpha.15" + kamado: "npm:2.0.0-alpha.16" prettier: "npm:3.8.1" typescript: "npm:6.0.2" languageName: unknown @@ -2129,9 +2129,9 @@ __metadata: version: 0.0.0-use.local resolution: "@kamado-io/pug-compiler@workspace:packages/@kamado-io/pug-compiler" dependencies: - "@kamado-io/page-compiler": "npm:2.0.0-alpha.15" + "@kamado-io/page-compiler": "npm:2.0.0-alpha.16" "@types/pug": "npm:2.0.10" - kamado: "npm:2.0.0-alpha.15" + kamado: "npm:2.0.0-alpha.16" pug: "npm:3.0.4" typescript: "npm:6.0.2" peerDependencies: @@ -2145,7 +2145,7 @@ __metadata: resolution: "@kamado-io/script-compiler@workspace:packages/@kamado-io/script-compiler" dependencies: esbuild: "npm:0.27.4" - kamado: "npm:2.0.0-alpha.15" + kamado: "npm:2.0.0-alpha.16" typescript: "npm:6.0.2" languageName: unknown linkType: soft @@ -2156,7 +2156,7 @@ __metadata: dependencies: "@types/postcss-import": "npm:14.0.3" cssnano: "npm:7.1.3" - kamado: "npm:2.0.0-alpha.15" + kamado: "npm:2.0.0-alpha.16" postcss: "npm:8.5.8" postcss-import: "npm:16.1.1" postcss-load-config: "npm:6.0.1" @@ -8729,7 +8729,7 @@ __metadata: languageName: unknown linkType: soft -"kamado@npm:2.0.0-alpha.15, kamado@workspace:packages/kamado": +"kamado@npm:2.0.0-alpha.16, kamado@workspace:packages/kamado": version: 0.0.0-use.local resolution: "kamado@workspace:packages/kamado" dependencies: From 0baea5f96d3d3a04a3fbd93258d7396c3f09c299 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Sat, 6 Jun 2026 13:17:25 +0900 Subject: [PATCH 05/20] chore(repo): wire up build benchmark command - add `yarn bench` script pointing to packages/kamado/benchmark - ignore generated benchmark fixtures (packages/**/.bench/) - document the bench command in CLAUDE.md --- .gitignore | 3 +++ CLAUDE.md | 1 + package.json | 1 + 3 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index d9c09fa..8e3efbb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ yarn-error.log # build outputs packages/**/dist/ packages/**/tsconfig.build.tsbuildinfo + +# benchmark fixtures +packages/**/.bench/ diff --git a/CLAUDE.md b/CLAUDE.md index d5e2173..04157c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ kamado — オンデマンド静的サイトジェネレータ。Lerna + Yarn Wo - `yarn dev` — `lerna run dev` - `yarn test` — Vitest でテスト(test-timeout 60000) - `yarn lint` — eslint / prettier / textlint / cspell を直列実行 +- `yarn bench` — ビルドベンチマーク(`--pages=N` / `--runs=N` / `--full`。要事前 `yarn build`。詳細は `packages/kamado/ARCHITECTURE.md` 参照) - `yarn release` / `yarn release:alpha` 等 — `lerna version`(push なし)。リリース手順は `/release` コマンド参照 ### コマンド制約 diff --git a/package.json b/package.json index 46bba37..26cda6a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "private": true, "type": "module", "scripts": { + "bench": "node packages/kamado/benchmark/run-bench.ts", "build": "lerna run build", "dev": "lerna run dev", "test": "vitest run --test-timeout 60000", From 445ae6a705b749947ce05347b61e240f4842684d Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Sat, 6 Jun 2026 13:23:02 +0900 Subject: [PATCH 06/20] feat(kamado): optimize the build pipeline and add skip-unchanged writes - clear module caches (asset group / file content / global data) at build start so consecutive builds in one process always reflect source edits - memoize getAssetGroup() by enumeration inputs so build() and getGlobalData() share a single glob + frontmatter pass; export clearAssetGroupCache from kamado/data - deduplicate output-directory mkdir calls within a build - add the skipUnchanged build option and --skip-unchanged CLI flag that skip rewriting outputs whose content is unchanged (stat size check first, then content comparison; mtime is preserved on a match) - add a build benchmark harness (yarn bench) and a CLI e2e test --- .../kamado/benchmark/generate-fixtures.ts | 147 +++++++++++++ packages/kamado/benchmark/run-bench.ts | 107 ++++++++++ packages/kamado/src/builder/build.spec.ts | 194 ++++++++++++++++++ packages/kamado/src/builder/build.ts | 46 ++++- packages/kamado/src/cli.spec.ts | 93 +++++++++ packages/kamado/src/cli.ts | 5 + packages/kamado/src/data/assets.spec.ts | 5 +- packages/kamado/src/data/data.ts | 2 +- packages/kamado/src/data/get-asset-group.ts | 48 +++++ packages/kamado/src/data/map.spec.ts | 3 + packages/kamado/tsconfig.build.json | 2 +- 11 files changed, 646 insertions(+), 6 deletions(-) create mode 100644 packages/kamado/benchmark/generate-fixtures.ts create mode 100644 packages/kamado/benchmark/run-bench.ts create mode 100644 packages/kamado/src/cli.spec.ts diff --git a/packages/kamado/benchmark/generate-fixtures.ts b/packages/kamado/benchmark/generate-fixtures.ts new file mode 100644 index 0000000..9971257 --- /dev/null +++ b/packages/kamado/benchmark/generate-fixtures.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** + * Generated fixture directory set + */ +export interface FixtureSet { + /** + * Fixture root directory + */ + readonly rootDir: string; + /** + * Input directory (pages, styles, scripts) + */ + readonly inputDir: string; + /** + * Output directory + */ + readonly outputDir: string; + /** + * Layouts directory + */ + readonly layoutsDir: string; + /** + * Global data directory + */ + readonly dataDir: string; +} + +const STYLE_COUNT = 5; +const SCRIPT_COUNT = 5; + +/** + * Generates a synthetic site for build benchmarking: + * - N Pug pages sharing a single layout (the layout includes a partial) + * - a few CSS files importing a shared partial + * - a few TS files importing a shared module + * @param baseDir - Directory to generate fixtures under + * @param pageCount - Number of pages to generate + * @returns Generated fixture directory set + */ +export async function generateFixtures( + baseDir: string, + pageCount: number, +): Promise { + const rootDir = path.resolve(baseDir, `fixtures-${pageCount}`); + const inputDir = path.join(rootDir, 'input'); + const outputDir = path.join(rootDir, 'output'); + const layoutsDir = path.join(rootDir, 'layouts'); + const partialsDir = path.join(rootDir, 'partials'); + const dataDir = path.join(rootDir, 'data'); + + await fs.rm(rootDir, { recursive: true, force: true }); + await fs.mkdir(inputDir, { recursive: true }); + await fs.mkdir(path.join(inputDir, 'css'), { recursive: true }); + await fs.mkdir(path.join(inputDir, 'js'), { recursive: true }); + await fs.mkdir(layoutsDir, { recursive: true }); + await fs.mkdir(partialsDir, { recursive: true }); + await fs.mkdir(dataDir, { recursive: true }); + + await fs.writeFile( + path.join(partialsDir, 'header.pug'), + ['mixin header(t)', '\theader', '\t\th1= t', '\t\tp= site.description', ''].join( + '\n', + ), + ); + + await fs.writeFile( + path.join(layoutsDir, 'default.pug'), + [ + 'include /partials/header.pug', + 'doctype html', + 'html', + '\thead', + '\t\ttitle= title', + '\tbody', + '\t\t+header(title)', + '\t\tmain !{content}', + '', + ].join('\n'), + ); + + await fs.writeFile( + path.join(dataDir, 'site.yml'), + ['description: Benchmark fixture site', ''].join('\n'), + ); + + const pageWrites: Promise[] = []; + for (let i = 0; i < pageCount; i++) { + const name = `page-${String(i).padStart(5, '0')}`; + const body = [ + '---', + 'layout: default.pug', + `title: Page ${i}`, + '---', + `h2 Section ${i}`, + `p This is the body of page ${i}. It exists to give the compiler real work.`, + 'ul', + '\teach item in [1, 2, 3, 4, 5]', + `\t\tli Item #{item} of page ${i}`, + '', + ].join('\n'); + pageWrites.push(fs.writeFile(path.join(inputDir, `${name}.pug`), body)); + } + await Promise.all(pageWrites); + + await fs.writeFile( + path.join(partialsDir, 'base.css'), + ['body {', '\tmargin: 0;', '\tfont-family: sans-serif;', '}', ''].join('\n'), + ); + for (let i = 0; i < STYLE_COUNT; i++) { + await fs.writeFile( + path.join(inputDir, 'css', `style-${i}.css`), + [ + '@import "../../partials/base.css";', + `.component-${i} {`, + `\tcolor: rgb(${i * 10}, 0, 0);`, + '\tdisplay: flex;', + '}', + '', + ].join('\n'), + ); + } + + await fs.writeFile( + path.join(partialsDir, 'util.ts'), + [ + 'export function greet(name: string): string {', + '\treturn `Hello, ${name}!`;', + '}', + '', + ].join('\n'), + ); + for (let i = 0; i < SCRIPT_COUNT; i++) { + await fs.writeFile( + path.join(inputDir, 'js', `main-${i}.ts`), + [ + "import { greet } from '../../partials/util';", + '', + `console.log(greet('page-${i}'));`, + '', + ].join('\n'), + ); + } + + return { rootDir, inputDir, outputDir, layoutsDir, dataDir }; +} diff --git a/packages/kamado/benchmark/run-bench.ts b/packages/kamado/benchmark/run-bench.ts new file mode 100644 index 0000000..cbe6f9f --- /dev/null +++ b/packages/kamado/benchmark/run-bench.ts @@ -0,0 +1,107 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- dev-only benchmark; imports sibling workspace packages that depend on kamado */ +/** + * Build benchmark runner. + * + * Generates a synthetic site (see generate-fixtures.ts) and measures `build()` + * wall-clock time. Runs against the built dist of each package — run + * `yarn build` before benchmarking. + * + * Usage: + * yarn bench [--pages=1000] [--runs=3] [--full] + * + * --full enables the default page transforms (jsdom/prettier/minifier), which + * dominate CPU time. By default transforms are disabled to isolate the + * compile/IO pipeline. + */ +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; + +import { createPageCompiler } from '@kamado-io/page-compiler'; +import { createCompileHooks } from '@kamado-io/pug-compiler'; +import { createScriptCompiler } from '@kamado-io/script-compiler'; +import { createStyleCompiler } from '@kamado-io/style-compiler'; + +import { generateFixtures } from './generate-fixtures.ts'; + +import { build } from 'kamado/build'; +import { clearGlobalDataCache } from 'kamado/data'; +import { clearFileContentCache } from 'kamado/files'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BENCH_DIR = path.resolve(__dirname, '..', '.bench'); + +/** + * + * @param name + * @param fallback + */ +function readArg(name: string, fallback: number): number { + const raw = process.argv.find((argument) => argument.startsWith(`--${name}=`)); + if (!raw) { + return fallback; + } + const value = Number.parseInt(raw.split('=')[1] ?? '', 10); + return Number.isNaN(value) ? fallback : value; +} + +const pageCount = readArg('pages', 1000); +const runCount = readArg('runs', 3); +const useFullTransforms = process.argv.includes('--full'); + +const fixtures = await generateFixtures(BENCH_DIR, pageCount); + +const durations: number[] = []; + +for (let run = 0; run < runCount; run++) { + // Reset module-level caches so each run measures a cold build + clearFileContentCache(); + clearGlobalDataCache(); + + const start = performance.now(); + await build({ + // @ts-expect-error -- pkg is accepted by mergeConfig to skip package.json lookup + pkg: { name: 'kamado-bench', version: '0.0.0' }, + rootDir: fixtures.rootDir, + dir: { + input: fixtures.inputDir, + output: fixtures.outputDir, + }, + compilers: (def) => [ + def(createPageCompiler(), { + files: '**/*.pug', + compileHooks: createCompileHooks({ + basedir: fixtures.rootDir, + }), + layouts: { dir: fixtures.layoutsDir }, + globalData: { dir: fixtures.dataDir }, + ...(useFullTransforms ? {} : { transforms: [] }), + }), + def(createStyleCompiler(), {}), + def(createScriptCompiler(), {}), + ], + }); + const duration = performance.now() - start; + durations.push(duration); +} + +durations.sort((a, b) => a - b); +const middle = Math.floor(durations.length / 2); +const median = + durations.length % 2 === 0 + ? ((durations[middle - 1] ?? 0) + (durations[middle] ?? 0)) / 2 + : (durations[middle] ?? 0); +const fastest = durations[0] ?? 0; +const slowest = durations.at(-1) ?? 0; + +console.log(''); +console.log('=== kamado build benchmark ==='); +console.log( + `pages: ${pageCount}, runs: ${runCount}, transforms: ${useFullTransforms ? 'default' : 'none'}`, +); +console.log( + `median: ${(median / 1000).toFixed(2)}s (${(pageCount / (median / 1000)).toFixed(1)} pages/s)`, +); +console.log(`fastest: ${(fastest / 1000).toFixed(2)}s`); +console.log(`slowest: ${(slowest / 1000).toFixed(2)}s`); +console.log(`rss: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(0)} MB`); diff --git a/packages/kamado/src/builder/build.spec.ts b/packages/kamado/src/builder/build.spec.ts index a9bd707..0668d8a 100644 --- a/packages/kamado/src/builder/build.spec.ts +++ b/packages/kamado/src/builder/build.spec.ts @@ -11,6 +11,7 @@ import { } from 'vitest'; import { mergeConfig } from '../config/merge-config.js'; +import { getContentFromFile } from '../files/get-content-from-file.js'; import { build } from './build.js'; @@ -138,6 +139,199 @@ describe('getAssetGroup with virtual file system', async () => { }, 10_000); }); +describe('build with skipUnchanged', async () => { + const config = await mergeConfig( + // @ts-ignore + { pkg: { name: 'mock' } }, + ); + + beforeEach(() => { + vol.fromJSON({ + '/mock/input/dir/index.html': 'Index', + }); + }); + + afterEach(() => { + vol.reset(); + }); + + test('skips rewriting outputs whose content is unchanged', async () => { + const buildConfig = { + ...config, + dir: { + ...config.dir, + input: '/mock/input/dir', + output: '/mock/output/dir', + }, + compilers: () => [ + { + files: '**/*.html', + outputExtension: '.html', + compiler: () => () => 'page content', + }, + ], + skipUnchanged: true, + verbose: true, + }; + + await build(buildConfig); + expect(vol.toJSON()['/mock/output/dir/index.html']).toBe('page content'); + + const writeFileSpy = vi.spyOn(memfs.promises, 'writeFile'); + await build(buildConfig); + expect(writeFileSpy).not.toHaveBeenCalled(); + writeFileSpy.mockRestore(); + }, 10_000); + + test('writes outputs when content changes but the size stays the same', async () => { + const makeBuildConfig = (content: string) => ({ + ...config, + dir: { + ...config.dir, + input: '/mock/input/dir', + output: '/mock/output/dir', + }, + compilers: () => [ + { + files: '**/*.html', + outputExtension: '.html', + compiler: () => () => content, + }, + ], + skipUnchanged: true, + verbose: true, + }); + + // 'aaa' and 'bbb' have identical byte lengths — this must defeat the + // stat-size fast path and fall through to the content comparison + await build(makeBuildConfig('aaa')); + + const writeFileSpy = vi.spyOn(memfs.promises, 'writeFile'); + await build(makeBuildConfig('bbb')); + expect(writeFileSpy).toHaveBeenCalled(); + writeFileSpy.mockRestore(); + + expect(vol.toJSON()['/mock/output/dir/index.html']).toBe('bbb'); + }, 10_000); + + test('skips unchanged ArrayBuffer outputs', async () => { + const makeBuildConfig = () => ({ + ...config, + dir: { + ...config.dir, + input: '/mock/input/dir', + output: '/mock/output/dir', + }, + compilers: () => [ + { + files: '**/*.html', + outputExtension: '.html', + compiler: () => () => new TextEncoder().encode('binary content').buffer, + }, + ], + skipUnchanged: true, + verbose: true, + }); + + // @ts-ignore + await build(makeBuildConfig()); + expect(vol.toJSON()['/mock/output/dir/index.html']).toBe('binary content'); + + const writeFileSpy = vi.spyOn(memfs.promises, 'writeFile'); + // @ts-ignore + await build(makeBuildConfig()); + expect(writeFileSpy).not.toHaveBeenCalled(); + writeFileSpy.mockRestore(); + }, 10_000); + + test('writes outputs when content changes', async () => { + const makeBuildConfig = (content: string) => ({ + ...config, + dir: { + ...config.dir, + input: '/mock/input/dir', + output: '/mock/output/dir', + }, + compilers: () => [ + { + files: '**/*.html', + outputExtension: '.html', + compiler: () => () => content, + }, + ], + skipUnchanged: true, + verbose: true, + }); + + await build(makeBuildConfig('first content')); + + const writeFileSpy = vi.spyOn(memfs.promises, 'writeFile'); + await build(makeBuildConfig('second content')); + expect(writeFileSpy).toHaveBeenCalled(); + writeFileSpy.mockRestore(); + + expect(vol.toJSON()['/mock/output/dir/index.html']).toBe('second content'); + }, 10_000); +}); + +describe('consecutive builds in the same process', async () => { + const config = await mergeConfig( + // @ts-ignore + { pkg: { name: 'mock' } }, + ); + + afterEach(() => { + vol.reset(); + }); + + test('reflects source file edits on the next build', async () => { + vol.fromJSON({ + '/mock/input/dir/index.html': '

ORIGINAL

', + }); + const buildConfig = { + ...config, + dir: { + ...config.dir, + input: '/mock/input/dir', + output: '/mock/output/dir', + }, + compilers: () => [ + { + files: '**/*.html', + outputExtension: '.html', + // Reads the actual source content so that stale file-content + // caching between builds would surface here + compiler: + () => + async ( + file: { inputPath: string }, + _: unknown, + __: unknown, + cache?: boolean, + ) => { + // @ts-ignore + const fileContent = await getContentFromFile(file, cache); + return fileContent.content; + }, + }, + ], + verbose: true, + }; + + // @ts-ignore + await build(buildConfig); + expect(vol.toJSON()['/mock/output/dir/index.html']).toBe('

ORIGINAL

'); + + // Edit the source file, then build again in the same process + vol.fromJSON({ + '/mock/input/dir/index.html': '

EDITED

', + }); + // @ts-ignore + await build(buildConfig); + expect(vol.toJSON()['/mock/output/dir/index.html']).toBe('

EDITED

'); + }, 10_000); +}); + describe("build with frontmatter 'path' override", async () => { const config = await mergeConfig( // @ts-ignore diff --git a/packages/kamado/src/builder/build.ts b/packages/kamado/src/builder/build.ts index a167c04..bf11b9e 100644 --- a/packages/kamado/src/builder/build.ts +++ b/packages/kamado/src/builder/build.ts @@ -11,7 +11,9 @@ import { createCompileFunctions } from '../compiler/compile-functions.js'; import { createCompiler } from '../compiler/create-compiler.js'; import { createCompileFunctionMap } from '../compiler/function-map.js'; import { mergeConfig } from '../config/merge-config.js'; -import { getAssetGroup } from '../data/get-asset-group.js'; +import { clearAssetGroupCache, getAssetGroup } from '../data/get-asset-group.js'; +import { clearGlobalDataCache } from '../data/get-global-data.js'; +import { clearFileContentCache } from '../files/file-content.js'; import { filePathColorizer } from '../stdout/color.js'; /** @@ -30,6 +32,14 @@ interface BuildConfig { * Whether to enable verbose logging */ readonly verbose?: boolean; + /** + * Whether to skip writing output files whose content is unchanged. + * Compares the new content against the existing output file; when equal, + * the write is skipped and the existing file's mtime is preserved + * (useful for mtime-based deployment diffing). + * @default false + */ + readonly skipUnchanged?: boolean; } /** @@ -42,6 +52,13 @@ interface BuildConfig { export async function build( buildConfig: UserConfig & BuildConfig, ) { + // Each build starts from a clean slate: re-enumerate files and re-read + // file contents and global data so that source edits between consecutive + // builds in the same process are always reflected + clearAssetGroupCache(); + clearFileContentCache(); + clearGlobalDataCache(); + const config = await mergeConfig(buildConfig, buildConfig.rootDir); // Create execution context @@ -86,6 +103,11 @@ export async function build( const CHECK_MARK = c.green('✔'); + // Tracks directories already ensured in this build to avoid redundant mkdir + // calls. mkdir with recursive:true is idempotent, so a rare duplicate from + // concurrent tasks is harmless. + const ensuredDirs = new Set(); + await deal( allFiles, (file, log, _, setLineHeader) => { @@ -95,10 +117,28 @@ export async function build( return async () => { const content = await compile(file, log); + const buffer = + typeof content === 'string' ? Buffer.from(content) : new Uint8Array(content); + + if (buildConfig.skipUnchanged) { + // Cheap size check first; read the file only when sizes match + const stat = await fs.stat(file.outputPath).catch(() => null); + if (stat && stat.size === buffer.byteLength) { + const existing = await fs.readFile(file.outputPath).catch(() => null); + if (existing && existing.equals(buffer)) { + log(`${CHECK_MARK} Unchanged`); + return; + } + } + } + log(c.yellow('Writing...')); - await fs.mkdir(path.dirname(file.outputPath), { recursive: true }); + const outputDir = path.dirname(file.outputPath); + if (!ensuredDirs.has(outputDir)) { + await fs.mkdir(outputDir, { recursive: true }); + ensuredDirs.add(outputDir); + } - const buffer = typeof content === 'string' ? content : new Uint8Array(content); await fs.writeFile(file.outputPath, buffer); log(`${CHECK_MARK} Compiled!`); diff --git a/packages/kamado/src/cli.spec.ts b/packages/kamado/src/cli.spec.ts new file mode 100644 index 0000000..8630f84 --- /dev/null +++ b/packages/kamado/src/cli.spec.ts @@ -0,0 +1,93 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; + +const execFileAsync = promisify(execFile); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const cliPath = path.resolve(__dirname, '..', 'dist', 'cli.js'); + +let tmpDir: string; +let configPath: string; +let outputFile: string; + +/** + * Runs the built CLI in the fixture directory + * @param args + */ +async function runCli(args: readonly string[]) { + return await execFileAsync(process.execPath, [cliPath, ...args], { + cwd: tmpDir, + }); +} + +beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kamado-cli-')); + configPath = path.join(tmpDir, 'kamado.config.mjs'); + outputFile = path.join(tmpDir, 'output', 'index.html'); + + await fs.writeFile( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'cli-fixture', version: '0.0.0', type: 'module' }), + ); + await fs.writeFile( + configPath, + [ + 'export default {', + "\tdir: { input: './input', output: './output' },", + '\tcompilers: () => [', + '\t\t{', + "\t\t\tfiles: '**/*.html',", + "\t\t\toutputExtension: '.html',", + "\t\t\tcompiler: () => () => 'page content',", + '\t\t},', + '\t],', + '};', + '', + ].join('\n'), + ); + await fs.mkdir(path.join(tmpDir, 'input'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'input', 'index.html'), '

source

'); +}); + +afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +// E2E test for the built CLI binary — requires `yarn build` to have produced +// dist/cli.js. Skipped on a fresh checkout where dist does not exist yet. +describe.skipIf(!existsSync(cliPath))('kamado build CLI', () => { + test('--skip-unchanged reaches build(): an unchanged output is not rewritten', async () => { + await runCli(['build', '--skip-unchanged', '-c', configPath]); + expect(await fs.readFile(outputFile, 'utf8')).toBe('page content'); + const firstStat = await fs.stat(outputFile); + const firstMtime = firstStat.mtimeMs; + + await sleep(50); + await runCli(['build', '--skip-unchanged', '-c', configPath]); + + const secondStat = await fs.stat(outputFile); + const secondMtime = secondStat.mtimeMs; + expect(secondMtime).toBe(firstMtime); + }, 30_000); + + test('without --skip-unchanged the output is rewritten every build', async () => { + await runCli(['build', '-c', configPath]); + const firstStat = await fs.stat(outputFile); + const firstMtime = firstStat.mtimeMs; + + await sleep(50); + await runCli(['build', '-c', configPath]); + + const secondStat = await fs.stat(outputFile); + const secondMtime = secondStat.mtimeMs; + expect(secondMtime).toBeGreaterThan(firstMtime); + }, 30_000); +}); diff --git a/packages/kamado/src/cli.ts b/packages/kamado/src/cli.ts index 8395172..b2d0948 100644 --- a/packages/kamado/src/cli.ts +++ b/packages/kamado/src/cli.ts @@ -29,6 +29,10 @@ const cli = parseCli({ desc: 'Build static files', flags: { ...commonFlags, + skipUnchanged: { + type: 'boolean' as const, + desc: 'Skip writing output files whose content is unchanged', + }, }, }, server: { @@ -60,6 +64,7 @@ switch (cli.command) { ...config, targetGlob: pathResolver(cli.args), verbose: cli.flags.verbose, + skipUnchanged: cli.flags.skipUnchanged, }); break; } diff --git a/packages/kamado/src/data/assets.spec.ts b/packages/kamado/src/data/assets.spec.ts index 7c174c1..f68cfbf 100644 --- a/packages/kamado/src/data/assets.spec.ts +++ b/packages/kamado/src/data/assets.spec.ts @@ -3,7 +3,7 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { clearFileContentCache } from '../files/file-content.js'; -import { getAssetGroup } from './get-asset-group.js'; +import { clearAssetGroupCache, getAssetGroup } from './get-asset-group.js'; vi.mock('fast-glob', async () => { const actual = await vi.importActual('fast-glob'); @@ -30,6 +30,7 @@ vi.mock('node:fs/promises', () => { describe('getAssetGroup with virtual file system', () => { beforeEach(() => { + clearAssetGroupCache(); vol.fromJSON({ '/mock/input/dir/index.html': 'Index', '/mock/input/dir/about.html': 'About', @@ -126,11 +127,13 @@ describe('getAssetGroup with virtual file system', () => { describe("getAssetGroup with frontmatter 'path' override", () => { beforeEach(() => { clearFileContentCache(); + clearAssetGroupCache(); }); afterEach(() => { vol.reset(); clearFileContentCache(); + clearAssetGroupCache(); }); test('honors `path` with explicit extension', async () => { diff --git a/packages/kamado/src/data/data.ts b/packages/kamado/src/data/data.ts index 46b559c..1c98ddd 100644 --- a/packages/kamado/src/data/data.ts +++ b/packages/kamado/src/data/data.ts @@ -1,3 +1,3 @@ -export { getAssetGroup } from './get-asset-group.js'; +export { getAssetGroup, clearAssetGroupCache } from './get-asset-group.js'; export { getGlobalData, clearGlobalDataCache } from './get-global-data.js'; export type { GlobalData } from './types.js'; diff --git a/packages/kamado/src/data/get-asset-group.ts b/packages/kamado/src/data/get-asset-group.ts index d18444f..00e6e0e 100644 --- a/packages/kamado/src/data/get-asset-group.ts +++ b/packages/kamado/src/data/get-asset-group.ts @@ -26,9 +26,24 @@ export interface GetAssetGroupOptions { readonly glob?: string; } +const assetGroupCache = new Map>(); + +/** + * Clears the asset group memoization cache. + * Called at the start of each build so every build re-enumerates files. + */ +export function clearAssetGroupCache(): void { + assetGroupCache.clear(); +} + /** * Gets asset files for the specified compiler entry. * + * Results are memoized by the value-inputs of the enumeration until + * {@link clearAssetGroupCache} is called (done at the start of each build), + * so the same compiler entry enumerated by both `build()` and + * `getGlobalData()` shares a single glob + frontmatter pass. + * * When `compilerEntry.outputPathField` is set, each matched file's frontmatter * (and same-name JSON sidecar) is read eagerly. If the named field holds a * non-empty string, the file's `outputPath`, `url`, `filePathStem`, and @@ -52,6 +67,39 @@ export async function getAssetGroup( options?: GetAssetGroupOptions, ): Promise { const { inputDir, outputDir, compilerEntry } = context; + + // Memoize by the value-inputs of the enumeration. The same compiler entry + // is enumerated both by build() and by getGlobalData() (page list); the + // second call reuses the same glob + frontmatter pass. + const cacheKey = [ + inputDir, + outputDir, + compilerEntry.files, + compilerEntry.ignore ?? '', + compilerEntry.outputExtension, + compilerEntry.outputPathField ?? '', + compilerEntry.outputPathConflict ?? '', + options?.glob ?? '', + ].join('\0'); + const cached = assetGroupCache.get(cacheKey); + if (cached) { + return cached; + } + const promise = enumerateAssetGroup(context, options); + assetGroupCache.set(cacheKey, promise); + return promise; +} + +/** + * Performs the actual glob + frontmatter enumeration for {@link getAssetGroup}. + * @param context - Required context (inputDir, outputDir, compilerEntry). + * @param options - Optional filtering options (glob). + */ +async function enumerateAssetGroup( + context: GetAssetGroupContext, + options?: GetAssetGroupOptions, +): Promise { + const { inputDir, outputDir, compilerEntry } = context; const baseGlob = path.resolve(inputDir, compilerEntry.files); const fgOptions: { diff --git a/packages/kamado/src/data/map.spec.ts b/packages/kamado/src/data/map.spec.ts index 4b6888e..7d9b76d 100644 --- a/packages/kamado/src/data/map.spec.ts +++ b/packages/kamado/src/data/map.spec.ts @@ -5,6 +5,7 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { clearFileContentCache } from '../files/file-content.js'; +import { clearAssetGroupCache } from './get-asset-group.js'; import { getCompilableFileMap } from './map.js'; vi.mock('fast-glob', async () => { @@ -33,11 +34,13 @@ vi.mock('node:fs/promises', () => { describe('getCompilableFileMap', () => { beforeEach(() => { clearFileContentCache(); + clearAssetGroupCache(); }); afterEach(() => { vol.reset(); clearFileContentCache(); + clearAssetGroupCache(); }); test('keys map by default outputPath when no override', async () => { diff --git a/packages/kamado/tsconfig.build.json b/packages/kamado/tsconfig.build.json index 708a850..1456740 100644 --- a/packages/kamado/tsconfig.build.json +++ b/packages/kamado/tsconfig.build.json @@ -7,5 +7,5 @@ "declarationMap": true }, "include": ["**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.spec.ts"] + "exclude": ["node_modules", "dist", "benchmark", "**/*.spec.ts"] } From 33132ee68f8c7f9438206342a6fbccf2a05a167c Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Sat, 6 Jun 2026 13:23:41 +0900 Subject: [PATCH 07/20] feat(page-compiler)!: resolve compileHooks and transforms once per context BREAKING CHANGE: function forms of `compileHooks` and `transforms` are now resolved once per build/serve context instead of once per file. Resolved hooks and transform instances are shared by all pages of a context and may run concurrently, so factories must be file-independent and instances must not keep per-page mutable state. See MILESTONE.md for the migration guide. Details: - pass the compile cache flag through the transpile layer into compile hook compilers (CompilerFunction gains an optional 4th `cache` parameter) - hoist parseErrorMode resolution out of the per-file path - add specs for cache-flag plumbing and once-per-context hook resolution --- packages/@kamado-io/page-compiler/README.md | 6 +- .../page-compiler/src/page-compiler.spec.ts | 90 +++++++++++++++++++ .../page-compiler/src/page-compiler.ts | 39 ++++---- .../page-compiler/src/transpile-layout.ts | 8 +- .../page-compiler/src/transpile-main.ts | 8 +- .../@kamado-io/page-compiler/src/transpile.ts | 14 ++- .../@kamado-io/page-compiler/src/types.ts | 12 +++ 7 files changed, 153 insertions(+), 24 deletions(-) diff --git a/packages/@kamado-io/page-compiler/README.md b/packages/@kamado-io/page-compiler/README.md index 8403b8c..47f002e 100644 --- a/packages/@kamado-io/page-compiler/README.md +++ b/packages/@kamado-io/page-compiler/README.md @@ -49,6 +49,7 @@ export default defineConfig({ - `(defaultTransforms: readonly Transform[]) => Transform[]` - Function that receives default transforms (5 transforms) and returns modified array - If omitted, uses `createDefaultPageTransforms()` (5 transforms: manipulateDOM, doctype, prettier, minifier, lineBreak). See [Transform Pipeline](#transform-pipeline) for details. - **Note**: Uses the same `Transform` interface as `devServer.transforms`, but applies only to HTML pages in both build and serve modes. The `filter` option is ignored here (use `devServer.transforms` for filtering). + - **Note**: When given as a function, it is resolved **once per build/serve context**, not per file. The returned transform instances are shared by all pages in that context (and may run concurrently), so they must not keep per-page mutable state. - `transformBreadcrumbItem`: Function to transform each breadcrumb item. Each item includes a `meta` property containing the source page's metadata, enabling metadata-based transformations (e.g., redirect URLs). `(item: BreadcrumbItem) => BreadcrumbItem` - `filterNavigationNode`: Function to filter navigation nodes. Return `true` to keep the node, `false` to remove it. `(node: NavNode) => boolean` - `navigationComparator`: Sort comparator for the navigation path list. Can be overridden per-call via `nav({ comparator })` in templates. @@ -72,14 +73,15 @@ export default defineConfig({ - `compileHooks`: Compilation hooks for customizing compile process - Can be an object or a function `(options: PageCompilerOptions) => CompileHooksObject | Promise>` that returns an object (sync or async) + - **Note**: A function form is resolved **once per build/serve context**, not per file. Hook factories must be file-independent; the returned hooks are shared by all pages compiled in that context. - `main`: Hooks for main content compilation - `before`: Hook called before compilation (receives content and data, returns processed content) - `after`: Hook called after compilation (receives HTML and data, returns processed HTML) - - `compiler`: Custom compiler function `(content: string, data: CompileData, extension: string) => Promise | string` + - `compiler`: Custom compiler function `(content: string, data: CompileData, extension: string, cache?: boolean) => Promise | string`. The optional `cache` flag tells the compiler whether it may reuse cached compilation artifacts (e.g. compiled template functions) — it is `false` in serve mode so that template/include edits are always reflected - `layout`: Hooks for layout compilation - `before`: Hook called before compilation (receives content and data, returns processed content) - `after`: Hook called after compilation (receives HTML and data, returns processed HTML) - - `compiler`: Custom compiler function `(content: string, data: CompileData, extension: string) => Promise | string` + - `compiler`: Custom compiler function `(content: string, data: CompileData, extension: string, cache?: boolean) => Promise | string` (same `cache` semantics as `main.compiler`) **Note**: To use Pug templates, install `@kamado-io/pug-compiler` and use `createCompileHooks` helper. See the [@kamado-io/pug-compiler README](../@kamado-io/pug-compiler/README.md) for integration examples. diff --git a/packages/@kamado-io/page-compiler/src/page-compiler.spec.ts b/packages/@kamado-io/page-compiler/src/page-compiler.spec.ts index 6151c92..ff17cbb 100644 --- a/packages/@kamado-io/page-compiler/src/page-compiler.spec.ts +++ b/packages/@kamado-io/page-compiler/src/page-compiler.spec.ts @@ -105,6 +105,96 @@ describe('page compiler', async () => { expect(result).toBe('

Hello, world!

\n'); }); + test('passes the cache flag through to main and layout compile hooks', async () => { + clearMockFileContents(); + const page: CompilableFile = { + inputPath: '/path/to/page.pug', + outputPath: '/path/to/page.html', + fileSlug: 'page', + filePathStem: '/path/to/page', + url: '/path/to/page', + extension: '.pug', + date: new Date(), + }; + setMockFileContent('/path/to/page.pug', { + metaData: { layout: 'default' }, + content: 'p main', + raw: 'p main', + }); + setMockFileContent('/layouts/default.pug', { + metaData: {}, + content: 'p layout', + raw: 'p layout', + }); + + const compilerSpy = vi.fn((content: string) => content); + const pageC = createPageCompiler()({ + layouts: { + files: { + default: { inputPath: '/layouts/default.pug' }, + }, + }, + compileHooks: { + main: { compiler: compilerSpy }, + layout: { compiler: compilerSpy }, + }, + transforms: [], + }); + const fn = await pageC.compiler(config); + + // Serve mode: cache=false must reach both compilers as the 4th argument + await fn(page, () => '', undefined, false); + expect(compilerSpy).toHaveBeenCalledTimes(2); + expect(compilerSpy.mock.calls[0]?.[3]).toBe(false); // main content + expect(compilerSpy.mock.calls[1]?.[3]).toBe(false); // layout + + // Build mode: cache is left undefined (compilers default to caching) + compilerSpy.mockClear(); + await fn(page, () => ''); + expect(compilerSpy).toHaveBeenCalledTimes(2); + expect(compilerSpy.mock.calls[0]?.[3]).toBeUndefined(); // main content + expect(compilerSpy.mock.calls[1]?.[3]).toBeUndefined(); // layout + }); + + test('resolves a compileHooks factory once per context, not per file', async () => { + clearMockFileContents(); + const pageA: CompilableFile = { + inputPath: '/path/to/a.html', + outputPath: '/path/to/a.html', + fileSlug: 'a', + filePathStem: '/path/to/a', + url: '/path/to/a', + extension: '.html', + date: new Date(), + }; + const pageB: CompilableFile = { ...pageA, inputPath: '/path/to/b.html' }; + setMockFileContent('/path/to/a.html', { + metaData: {}, + content: '

A

', + raw: '

A

', + }); + setMockFileContent('/path/to/b.html', { + metaData: {}, + content: '

B

', + raw: '

B

', + }); + + const hooksFactory = vi.fn(() => ({ + main: { compiler: (content: string) => content }, + })); + const pageC = createPageCompiler()({ + compileHooks: hooksFactory, + transforms: [], + }); + const fn = await pageC.compiler(config); + + await fn(pageA, () => ''); + await fn(pageB, () => ''); + + // The factory is file-independent and must be resolved exactly once + expect(hooksFactory).toHaveBeenCalledTimes(1); + }); + test('should pass through pug file without compiler', async () => { clearMockFileContents(); const content = 'p Hello, world!'; diff --git a/packages/@kamado-io/page-compiler/src/page-compiler.ts b/packages/@kamado-io/page-compiler/src/page-compiler.ts index 24beece..a6e573f 100644 --- a/packages/@kamado-io/page-compiler/src/page-compiler.ts +++ b/packages/@kamado-io/page-compiler/src/page-compiler.ts @@ -60,6 +60,24 @@ export function createPageCompiler() { ...options?.globalData?.data, }; + // Resolve compileHooks (can be object or function) once per context, + // not per file — hook factories take only `options` and are file-independent + const compileHooks = + typeof options?.compileHooks === 'function' + ? await options.compileHooks(options) + : options?.compileHooks; + + // Resolve transforms once per context — they receive file info at + // transform time, not at creation time + const defaultPageTransforms = createDefaultPageTransforms(); + const transforms: Transform[] = + typeof options?.transforms === 'function' + ? options.transforms(defaultPageTransforms) + : (options?.transforms ?? defaultPageTransforms); + + const parseErrorMode: ParseErrorMode = + options?.formatOptions?.parseError ?? 'silent'; + return async (file, compile, log, cache) => { log?.(c.blue('Building...')); const pageContent = await getContentFromFile(file, cache); @@ -97,16 +115,10 @@ export function createPageCompiler() { breadcrumbs, }; - // Resolve compileHooks (can be object or function) - const compileHooks = - typeof options?.compileHooks === 'function' - ? await options.compileHooks(options) - : options?.compileHooks; - // Transpile main content const mainContentHtml = await transpileMainContent( { content: pageMainContent, compileData, file }, - { compileHook: compileHooks?.main, log }, + { compileHook: compileHooks?.main, log, cache }, ); let html = mainContentHtml; @@ -141,7 +153,7 @@ export function createPageCompiler() { layout, file, }, - { compileHook: compileHooks?.layout, log }, + { compileHook: compileHooks?.layout, log, cache }, ); } @@ -160,17 +172,6 @@ export function createPageCompiler() { compile, }; - const defaultPageTransforms = createDefaultPageTransforms(); - - // Use provided transforms or default - const transforms: Transform[] = - typeof options?.transforms === 'function' - ? options.transforms(defaultPageTransforms) - : (options?.transforms ?? defaultPageTransforms); - - const parseErrorMode: ParseErrorMode = - options?.formatOptions?.parseError ?? 'silent'; - // Apply transforms sequentially. Any transform failure is routed through // the formatOptions.parseError policy: on silent/warning the failing // transform is skipped and the previous step's output flows through. diff --git a/packages/@kamado-io/page-compiler/src/transpile-layout.ts b/packages/@kamado-io/page-compiler/src/transpile-layout.ts index 525c080..f2ad7a9 100644 --- a/packages/@kamado-io/page-compiler/src/transpile-layout.ts +++ b/packages/@kamado-io/page-compiler/src/transpile-layout.ts @@ -22,6 +22,11 @@ export interface TranspileLayoutContext { export interface TranspileLayoutOptions { readonly compileHook?: CompileHook; readonly log?: (message: string) => void; + /** + * Whether the compiler may reuse cached compilation artifacts. + * Passed through to the compile hook's compiler. Default: `true` + */ + readonly cache?: boolean; } /** @@ -41,7 +46,7 @@ export async function transpileLayout( options?: TranspileLayoutOptions, ): Promise { const { layoutContent, layoutCompileData, layoutExtension, layout, file } = context; - const { compileHook, log } = options ?? {}; + const { compileHook, log, cache } = options ?? {}; return transpile( { @@ -52,6 +57,7 @@ export async function transpileLayout( { compileHook, log, + cache, compileLogMessage: 'Compiling layout...', compileLogColor: c.greenBright, errorLogMessage: `Layout: ${layout.inputPath} (Content: ${file.inputPath})`, diff --git a/packages/@kamado-io/page-compiler/src/transpile-main.ts b/packages/@kamado-io/page-compiler/src/transpile-main.ts index cdbcc6d..53b56e6 100644 --- a/packages/@kamado-io/page-compiler/src/transpile-main.ts +++ b/packages/@kamado-io/page-compiler/src/transpile-main.ts @@ -20,6 +20,11 @@ export interface TranspileMainContext { export interface TranspileMainOptions { readonly compileHook?: CompileHook; readonly log?: (message: string) => void; + /** + * Whether the compiler may reuse cached compilation artifacts. + * Passed through to the compile hook's compiler. Default: `true` + */ + readonly cache?: boolean; } /** @@ -39,7 +44,7 @@ export async function transpileMainContent( options?: TranspileMainOptions, ): Promise { const { content, compileData, file } = context; - const { compileHook, log } = options ?? {}; + const { compileHook, log, cache } = options ?? {}; return transpile( { @@ -50,6 +55,7 @@ export async function transpileMainContent( { compileHook, log, + cache, compileLogMessage: 'Compiling main content...', compileLogColor: c.yellowBright, errorLogMessage: file.inputPath, diff --git a/packages/@kamado-io/page-compiler/src/transpile.ts b/packages/@kamado-io/page-compiler/src/transpile.ts index ee18bac..b2d97d5 100644 --- a/packages/@kamado-io/page-compiler/src/transpile.ts +++ b/packages/@kamado-io/page-compiler/src/transpile.ts @@ -57,6 +57,12 @@ export interface TranspileOptions { * @default false */ readonly useBeforeResultWhenNoCompiler?: boolean; + /** + * Whether the compiler may reuse cached compilation artifacts + * (e.g. compiled template functions). Passed through to the compiler. + * @default true + */ + readonly cache?: boolean; } /** @@ -80,6 +86,7 @@ export async function transpile( errorLogMessage, errorMessage, useBeforeResultWhenNoCompiler = false, + cache, } = options ?? {}; if (!compileHook) { @@ -100,7 +107,12 @@ export async function transpile( if (log && compileLogMessage && compileLogColor) { log(compileLogColor(compileLogMessage)); } - result = await compileHook.compiler(processedContent, compileData, extension); + result = await compileHook.compiler( + processedContent, + compileData, + extension, + cache, + ); } else { // Behavior depends on useBeforeResultWhenNoCompiler flag result = useBeforeResultWhenNoCompiler ? processedContent : content; diff --git a/packages/@kamado-io/page-compiler/src/types.ts b/packages/@kamado-io/page-compiler/src/types.ts index d6ec878..5e930d8 100644 --- a/packages/@kamado-io/page-compiler/src/types.ts +++ b/packages/@kamado-io/page-compiler/src/types.ts @@ -60,6 +60,11 @@ export interface PageCompilerOptions { /** * Array of transform functions to apply to compiled HTML, or a function that receives and returns transforms * If omitted, uses createDefaultPageTransforms() + * + * Note: when a function is given, it is resolved **once per build/serve + * context**, not per file. The returned transform instances are shared by + * all pages compiled in that context (and may run concurrently), so they + * must not keep per-page mutable state. * @example * ```typescript * import { createDefaultPageTransforms } from '@kamado-io/page-compiler'; @@ -228,12 +233,15 @@ export type ContentHook = ( * @param content - Template content to compile * @param data - Compile data object containing page info, navigation, and breadcrumbs * @param extension - File extension of the source file (e.g., `.pug`, `.html`) + * @param cache - Whether the compiler may reuse cached compilation artifacts + * (e.g. compiled template functions). `false` in serve mode. Default: `true` * @returns Compiled HTML string (sync or async) */ export type CompilerFunction = ( content: string, data: CompileData, extension: string, + cache?: boolean, ) => Promise | string; /** @@ -282,6 +290,10 @@ export interface CompileHooksObject { /** * Compilation hooks for customizing compile process * Can be an object or a function that returns an object (sync or async) + * + * Note: when a function is given, it is resolved **once per build/serve + * context**, not per file. Hook factories must be file-independent; the + * returned hooks are shared by all pages compiled in that context. * @template M - Custom metadata type extending MetaData */ export type CompileHooks = From 3c0e50f8621c8d92eff3a2f83bcdf239ef224f51 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Sat, 6 Jun 2026 13:24:12 +0900 Subject: [PATCH 08/20] feat(style-compiler): build the PostCSS processor once per context - load the PostCSS config and construct plugins once per build context and reuse the processor across files; rebuild per compilation when cache=false so postcss.config.js edits apply during dev without restart - do not cache a failed processor build; the next compilation retries - warn when the PostCSS config fails to load for a reason other than not existing instead of silently dropping user plugins - resolve the banner once per context (recomputed when cache=false) - add style-compiler specs --- packages/@kamado-io/style-compiler/README.md | 8 + .../style-compiler/src/style-compiler.spec.ts | 170 ++++++++++++++++++ .../style-compiler/src/style-compiler.ts | 112 ++++++++---- 3 files changed, 251 insertions(+), 39 deletions(-) create mode 100644 packages/@kamado-io/style-compiler/src/style-compiler.spec.ts diff --git a/packages/@kamado-io/style-compiler/README.md b/packages/@kamado-io/style-compiler/README.md index 6f00c93..9c7ef40 100644 --- a/packages/@kamado-io/style-compiler/README.md +++ b/packages/@kamado-io/style-compiler/README.md @@ -34,6 +34,14 @@ export default defineConfig({ - `alias`: Map of path aliases (key is alias name, value is actual path) - `banner`: Banner configuration (can specify CreateBanner function or string) +## PostCSS Configuration + +The compiler loads the project's PostCSS config (e.g. `postcss.config.js`) via `postcss-load-config` and merges its plugins after the built-in ones (`postcss-import` with alias support, then `cssnano`). A `postcss-import` entry in the user config is skipped to avoid duplicates. + +- During `kamado build`, the config is loaded **once per build** and the processor is reused for all CSS files. +- During `kamado server`, the config is reloaded **per compilation**, so edits to `postcss.config.js` apply without restarting the dev server. +- If no config exists, the built-in plugins alone are used. If the config fails to load for any other reason (e.g. a syntax error), a warning is printed and the built-in plugins are used as a fallback — check the console if your plugins do not seem to apply. + ## License MIT 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..f41c7cd --- /dev/null +++ b/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts @@ -0,0 +1,170 @@ +import type { StyleCompilerOptions } from './style-compiler.js'; +import type { CompilableFile, FileContent } from 'kamado/files'; + +// eslint-disable-next-line import-x/default +import postcssLoadConfig from 'postcss-load-config'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { createStyleCompiler } from './style-compiler.js'; + +// Mock file content storage for tests +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); + }), + }; +}); + +vi.mock('postcss-load-config', () => ({ + default: vi.fn(), +})); + +const mockedLoadConfig = vi.mocked(postcssLoadConfig); + +/** + * Helper to create a minimal CompilableFile for tests + * @param inputPath + */ +function createFile(inputPath: string): CompilableFile { + return { + inputPath, + outputPath: inputPath, + fileSlug: 'style', + filePathStem: '/style', + url: '/style.css', + extension: '.css', + date: new Date(), + }; +} + +/** + * Helper to create the innermost compile function + * @param options + */ +async function createCompileFn(options?: StyleCompilerOptions) { + const entry = createStyleCompiler()(options); + // @ts-ignore -- context is unused by the style compiler + return await entry.compiler({}); +} + +describe('style-compiler', () => { + beforeEach(() => { + mockFileContents.clear(); + mockedLoadConfig.mockReset(); + // @ts-ignore -- minimal config shape for tests + mockedLoadConfig.mockResolvedValue({ plugins: [] }); + }); + + test('compiles CSS with cssnano and prepends the banner', async () => { + mockFileContents.set('/in/style.css', { + metaData: {}, + content: 'body { background-color: #ffffff; }', + raw: 'body { background-color: #ffffff; }', + }); + const compile = await createCompileFn({ banner: '/* BANNER */' }); + + const result = await compile(createFile('/in/style.css'), () => ''); + + expect(result).toBe('/* BANNER */\nbody{background-color:#fff}'); + }); + + test('loads the PostCSS config only once across files when cache is enabled', async () => { + mockFileContents.set('/in/a.css', { + metaData: {}, + content: 'a { color: #ff0000; }', + raw: 'a { color: #ff0000; }', + }); + mockFileContents.set('/in/b.css', { + metaData: {}, + content: 'b { color: #00ff00; }', + raw: 'b { color: #00ff00; }', + }); + const compile = await createCompileFn({ banner: '/* B */' }); + + await compile(createFile('/in/a.css'), () => ''); + await compile(createFile('/in/b.css'), () => ''); + + expect(mockedLoadConfig).toHaveBeenCalledTimes(1); + }); + + test('reloads the PostCSS config per compilation when cache is disabled (serve mode)', async () => { + mockFileContents.set('/in/a.css', { + metaData: {}, + content: 'a { color: #ff0000; }', + raw: 'a { color: #ff0000; }', + }); + const compile = await createCompileFn({ banner: '/* B */' }); + + await compile(createFile('/in/a.css'), () => '', undefined, false); + await compile(createFile('/in/a.css'), () => '', undefined, false); + + expect(mockedLoadConfig).toHaveBeenCalledTimes(2); + }); + + test('retries processor creation after a failure instead of caching the rejection', async () => { + mockFileContents.set('/in/a.css', { + metaData: {}, + content: 'a { color: #ff0000; }', + raw: 'a { color: #ff0000; }', + }); + // First load yields an invalid plugin so postcss() throws during + // processor creation; second load succeeds + // @ts-ignore -- intentionally invalid plugin shape + mockedLoadConfig.mockResolvedValueOnce({ plugins: ['not-a-plugin'] }); + const compile = await createCompileFn({ banner: '/* B */' }); + + await expect(compile(createFile('/in/a.css'), () => '')).rejects.toThrow(); + + // The rejected processor must not be cached: the next file succeeds + const result = await compile(createFile('/in/a.css'), () => ''); + expect(result).toBe('/* B */\na{color:red}'); + }); + + test('warns when the PostCSS config fails to load for a reason other than not existing', async () => { + mockFileContents.set('/in/a.css', { + metaData: {}, + content: 'a { color: #ff0000; }', + raw: 'a { color: #ff0000; }', + }); + mockedLoadConfig.mockRejectedValueOnce( + new Error('Unexpected token in postcss.config.js'), + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const compile = await createCompileFn({ banner: '/* B */' }); + + // Falls back to the default plugins and still compiles + const result = await compile(createFile('/in/a.css'), () => ''); + expect(result).toBe('/* B */\na{color:red}'); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to load PostCSS config: Unexpected token in postcss.config.js', + ); + warnSpy.mockRestore(); + }); + + test('does not warn when no PostCSS config exists', async () => { + mockFileContents.set('/in/a.css', { + metaData: {}, + content: 'a { color: #ff0000; }', + raw: 'a { color: #ff0000; }', + }); + mockedLoadConfig.mockRejectedValueOnce( + new Error('No PostCSS Config found in: /project'), + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const compile = await createCompileFn({ banner: '/* B */' }); + + const result = await compile(createFile('/in/a.css'), () => ''); + expect(result).toBe('/* B */\na{color:red}'); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/@kamado-io/style-compiler/src/style-compiler.ts b/packages/@kamado-io/style-compiler/src/style-compiler.ts index 8b18f75..bc51c2f 100644 --- a/packages/@kamado-io/style-compiler/src/style-compiler.ts +++ b/packages/@kamado-io/style-compiler/src/style-compiler.ts @@ -47,51 +47,63 @@ export function createStyleCompiler() { defaultFiles: '**/*.css', defaultOutputExtension: '.css', compile: (options) => () => { - return async (file, _, __, cache) => { - // Configure plugins with alias resolver for postcss-import - const plugins: postcss.AcceptedPlugin[] = [ - postcssImport({ - // Add postcss-import plugin with alias resolver - resolve: - // Create alias resolver for postcss-import - (id: string, basedir: string) => { - // Check if the import starts with an alias - for (const [alias, aliasPath] of Object.entries(options?.alias ?? {})) { - // Arias must be followed by a slash - if (id.startsWith(alias + '/')) { - const resolvedPath = id.replace(alias, aliasPath); - return [path.resolve(basedir, resolvedPath)]; - } + // Configure plugins once per context — plugin instances and the + // loaded PostCSS config are file-independent + const basePlugins: postcss.AcceptedPlugin[] = [ + postcssImport({ + // Add postcss-import plugin with alias resolver + resolve: + // Create alias resolver for postcss-import + (id: string, basedir: string) => { + // Check if the import starts with an alias + for (const [alias, aliasPath] of Object.entries(options?.alias ?? {})) { + // Arias must be followed by a slash + if (id.startsWith(alias + '/')) { + const resolvedPath = id.replace(alias, aliasPath); + return [path.resolve(basedir, resolvedPath)]; } - // For non-alias imports, fallback to default postcss-import resolution - return [id]; - }, - }), - cssnano({ - preset: [ - 'default', - { - // Preserve !important comments (license, copyright, etc.) - discardComments: { - removeAll: false, - removeAllButFirst: false, - }, - // Custom comment removal that preserves ! comments - cssDeclarationSorter: false, + } + // For non-alias imports, fallback to default postcss-import resolution + return [id]; + }, + }), + cssnano({ + preset: [ + 'default', + { + // Preserve !important comments (license, copyright, etc.) + discardComments: { + removeAll: false, + removeAllButFirst: false, }, - ], - }), - ]; + // Custom comment removal that preserves ! comments + cssDeclarationSorter: false, + }, + ], + }), + ]; + const createProcessor = async () => { // Try to load PostCSS config from project root let config; try { config = await postcssLoadConfig(); - } catch { - // Fallback to default config if no config found + } catch (error) { + // Fallback to default config if no config found. + // A missing config is expected; anything else (e.g. a syntax + // error in postcss.config.js) is surfaced so plugin loss is + // not silent. + if ( + error instanceof Error && + !error.message.includes('No PostCSS Config found') + ) { + // eslint-disable-next-line no-console + console.warn(`Failed to load PostCSS config: ${error.message}`); + } config = { plugins: [] }; } + const plugins = [...basePlugins]; // Add other plugins from config (excluding postcss-import if it exists) if (config.plugins) { for (const plugin of config.plugins) { @@ -107,19 +119,41 @@ export function createStyleCompiler() { plugins.push(plugin); } } + return postcss(plugins); + }; + + const resolveBanner = () => + typeof options?.banner === 'string' + ? options.banner + : createBanner(options?.banner?.()); + + // Lazily build the processor and banner once and reuse them across + // files. A failed processor build is NOT cached, so the next file + // retries instead of replaying the same rejection forever. + let processorPromise: Promise | undefined; + let cachedBanner: string | undefined; + + return async (file, _, __, cache) => { + // cache === false (serve mode): rebuild per compilation so that + // postcss.config.js edits are picked up without a restart + const processor = + cache === false + ? await createProcessor() + : await (processorPromise ??= createProcessor().catch((error) => { + processorPromise = undefined; + throw error; + })); const css = await getContentFromFile(file, cache); // Process CSS with PostCSS - const result = await postcss(plugins).process(css.content, { + const result = await processor.process(css.content, { from: file.inputPath, to: undefined, }); const banner = - typeof options?.banner === 'string' - ? options.banner - : createBanner(options?.banner?.()); + cache === false ? resolveBanner() : (cachedBanner ??= resolveBanner()); return banner + '\n' + result.css; }; From fb87d986b777371be39bf7efd0a63df7eeb672fc Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Sat, 6 Jun 2026 13:24:46 +0900 Subject: [PATCH 09/20] feat(script-compiler): bundle in memory and select output by path - use esbuild write:false instead of a tmp-file round-trip - select the output file whose path matches the outfile instead of outputFiles[0]; warn about ignored extra outputs (e.g. extracted CSS) - resolve the banner once per context (recomputed when cache=false) - add script-compiler specs including a CSS-import case --- packages/@kamado-io/script-compiler/README.md | 4 + .../src/script-compiler.spec.ts | 103 ++++++++++++++++++ .../script-compiler/src/script-compiler.ts | 43 ++++++-- 3 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 packages/@kamado-io/script-compiler/src/script-compiler.spec.ts diff --git a/packages/@kamado-io/script-compiler/README.md b/packages/@kamado-io/script-compiler/README.md index c3be6ab..1011b0f 100644 --- a/packages/@kamado-io/script-compiler/README.md +++ b/packages/@kamado-io/script-compiler/README.md @@ -36,6 +36,10 @@ export default defineConfig({ - `minifier`: Whether to enable minification - `banner`: Banner configuration (can specify CreateBanner function or string) +## Bundling Behavior + +Each entry file is bundled in memory with esbuild (no temporary files are written) and the resulting JavaScript bundle is returned as the output. When esbuild emits additional output files alongside the bundle — for example a CSS file extracted from a `import './style.css'` statement — only the JavaScript bundle matching the output path is used; the extra outputs are ignored with a console warning. + ## License MIT 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..7dcac7a --- /dev/null +++ b/packages/@kamado-io/script-compiler/src/script-compiler.spec.ts @@ -0,0 +1,103 @@ +import type { CompilableFile } from 'kamado/files'; + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, test, expect, vi, beforeAll, afterAll } from 'vitest'; + +import { createScriptCompiler } from './script-compiler.js'; + +let tmpDir: string; + +/** + * Helper to create a minimal CompilableFile for tests + * @param inputPath + * @param outputPath + */ +function createFile(inputPath: string, outputPath: string): CompilableFile { + return { + inputPath, + outputPath, + fileSlug: 'main', + filePathStem: '/main', + url: '/main.js', + extension: '.ts', + date: new Date(), + }; +} + +/** + * Helper to create the innermost compile function + * @param options + */ +async function createCompileFn( + options?: Parameters>[0], +) { + const entry = createScriptCompiler()(options); + // @ts-ignore -- context is unused by the script compiler + return await entry.compiler({}); +} + +beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kamado-script-compiler-')); + await fs.writeFile( + path.join(tmpDir, 'util.ts'), + 'export const MSG: string = "HELLO_FROM_UTIL";\n', + ); + await fs.writeFile( + path.join(tmpDir, 'entry.ts'), + "import { MSG } from './util';\nconsole.log(MSG);\n", + ); + await fs.writeFile(path.join(tmpDir, 'style.css'), 'body { color: red; }\n'); + await fs.writeFile( + path.join(tmpDir, 'entry-with-css.ts'), + "import './style.css';\nimport { MSG } from './util';\nconsole.log(MSG);\n", + ); +}); + +afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('script-compiler', () => { + test('bundles imported modules and prepends the banner', async () => { + const compile = await createCompileFn({ banner: '/* BANNER */' }); + const file = createFile( + path.join(tmpDir, 'entry.ts'), + path.join(tmpDir, 'out', 'main.js'), + ); + + // @ts-ignore -- compile/log/cache are unused by the script compiler + const result = await compile(file, () => ''); + + expect(typeof result).toBe('string'); + const code = result as string; + expect(code.startsWith('/* BANNER */')).toBe(true); + // The imported module is bundled in + expect(code).toContain('HELLO_FROM_UTIL'); + // No write to the real output path happened (write: false) + await expect(fs.access(file.outputPath)).rejects.toThrow(); + }); + + test('returns the JS bundle (not the extracted CSS) when the entry imports CSS', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const compile = await createCompileFn({ banner: '/* BANNER */' }); + const file = createFile( + path.join(tmpDir, 'entry-with-css.ts'), + path.join(tmpDir, 'out', 'main-with-css.js'), + ); + + // @ts-ignore -- compile/log/cache are unused by the script compiler + const result = await compile(file, () => ''); + + const code = result as string; + // JS bundle is selected by output path, not by array position + expect(code).toContain('HELLO_FROM_UTIL'); + expect(code).not.toContain('color: red'); + // The extracted CSS output is reported as ignored + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain('main-with-css.css'); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/@kamado-io/script-compiler/src/script-compiler.ts b/packages/@kamado-io/script-compiler/src/script-compiler.ts index bea2c09..d9d0179 100644 --- a/packages/@kamado-io/script-compiler/src/script-compiler.ts +++ b/packages/@kamado-io/script-compiler/src/script-compiler.ts @@ -1,8 +1,6 @@ import type { CreateBanner } from 'kamado/compiler/banner'; import type { MetaData } from 'kamado/files'; -import fs from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import { createCustomCompiler } from 'kamado/compiler'; @@ -57,24 +55,49 @@ export function createScriptCompiler() { */ const esbuild = await import('esbuild'); - return async (file) => { + const resolveBanner = () => + typeof options?.banner === 'string' + ? options.banner + : createBanner(options?.banner?.()); + // Banner is file-independent; build once per context. Serve mode + // (cache === false) recomputes so date-based banners stay fresh + let cachedBanner: string | undefined; + + return async (file, _, __, cache) => { const banner = - typeof options?.banner === 'string' - ? options.banner - : createBanner(options?.banner?.()); - const tmpFilePath = path.join(os.tmpdir(), file.outputPath); - await esbuild.build({ + cache === false ? resolveBanner() : (cachedBanner ??= resolveBanner()); + // write: false keeps the bundle in memory — no tmp-file round-trip + const result = await esbuild.build({ entryPoints: [file.inputPath], bundle: true, alias: options?.alias, - outfile: tmpFilePath, + outfile: file.outputPath, + write: false, minify: options?.minifier, charset: 'utf8', banner: { js: banner, }, }); - return await fs.readFile(tmpFilePath, 'utf8'); + // outputFiles order is not guaranteed (e.g. extracted CSS or + // sourcemaps come alongside the bundle) — select by output path + const expectedPath = path.resolve(file.outputPath); + const outputFile = result.outputFiles.find( + (output) => path.resolve(output.path) === expectedPath, + ); + if (!outputFile) { + throw new Error(`esbuild produced no output for ${file.inputPath}`); + } + for (const output of result.outputFiles) { + if (output === outputFile) { + continue; + } + // eslint-disable-next-line no-console + console.warn( + `Ignoring additional esbuild output '${output.path}' for ${file.inputPath}`, + ); + } + return outputFile.text; }; }, })); From fe7da15ac55258a19068e95c2b56e55a0c30a7e1 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Sat, 6 Jun 2026 13:25:20 +0900 Subject: [PATCH 10/20] feat(pug-compiler): cache compiled templates per build context - cache compiled template functions per compiler instance, keyed by the template source with a bounded LRU, so shared layouts compile once per build instead of once per page - honor the cache flag: false (serve mode) bypasses the cache so include and extends edits are always reflected per request - create a fresh compiler (and cache) per compile-hooks factory resolution, binding the cache lifetime to a single build context - add cache behavior specs and a real-FS build pipeline integration spec --- packages/@kamado-io/pug-compiler/README.md | 16 +- .../pug-compiler/src/build-pipeline.spec.ts | 147 ++++++++++++++++++ .../pug-compiler/src/compile-pug.ts | 41 ++++- .../pug-compiler/src/create-compile-hooks.ts | 37 +++-- .../pug-compiler/src/pug-compiler.spec.ts | 123 ++++++++++++++- packages/@kamado-io/pug-compiler/src/types.ts | 6 + 6 files changed, 352 insertions(+), 18 deletions(-) create mode 100644 packages/@kamado-io/pug-compiler/src/build-pipeline.spec.ts diff --git a/packages/@kamado-io/pug-compiler/README.md b/packages/@kamado-io/pug-compiler/README.md index b1ce244..c54afd3 100644 --- a/packages/@kamado-io/pug-compiler/README.md +++ b/packages/@kamado-io/pug-compiler/README.md @@ -40,7 +40,9 @@ Creates a Pug compiler function. - `doctype` (string): Document type (default: `'html'`) - `pretty` (boolean): Whether to pretty-print HTML (default: `true`) -Returns: `CompilerFunction` - A function that takes `(template: string, data: Record)` and returns `Promise` +Returns: `CompilerFunction` - A function that takes `(template: string, data: Record, cache?: boolean)` and returns `Promise` + +**Template caching**: Compiled template functions are cached per compiler instance, keyed by the template source string and bounded by an LRU policy. Shared templates (e.g. layouts rendered for every page) are compiled once and rendered many times. Pass `cache = false` (done automatically by the dev server) to bypass the cache — `include`/`extends` files are then re-read on every compilation, so edits to them are always reflected. **Note**: This `CompilerFunction` type is specific to `pug-compiler` and differs from `page-compiler`'s `CompilerFunction` type. To use with `page-compiler`, use `createCompileHooks` which returns compatible compiler functions. @@ -54,6 +56,8 @@ Returns: `() => CompileHooksObject` - A function that returns compile hooks obje The returned compiler functions automatically check the file extension and only compile `.pug` files. Other file types are passed through unchanged. +Each invocation of the returned factory creates a **fresh compiler instance with a fresh template cache**. The page compiler resolves the factory once per build/serve context, so the template cache's lifetime is bound to a single build — template or include edits between consecutive builds in the same process are always reflected. + ## Integration with @kamado-io/page-compiler To use Pug templates with `@kamado-io/page-compiler`, you need to install both packages: @@ -73,7 +77,7 @@ import { createPageCompiler } from '@kamado-io/page-compiler'; import { createCompileHooks } from '@kamado-io/pug-compiler'; const hooks = createCompileHooks({ - pathAlias: './src', // pug-compiler のオプション + pathAlias: './src', // pug-compiler option doctype: 'html', pretty: true, })(); @@ -105,7 +109,7 @@ import { createPageCompiler } from '@kamado-io/page-compiler'; import { createCompileHooks } from '@kamado-io/pug-compiler'; const hooksFactory = createCompileHooks({ - pathAlias: './src', // pug-compiler のオプション + pathAlias: './src', // pug-compiler option doctype: 'html', pretty: true, }); @@ -134,7 +138,7 @@ export const config = { globalData: { dir: './data' }, compileHooks: () => { const hooks = createCompileHooks({ - pathAlias: './src', // pug-compiler のオプション + pathAlias: './src', // pug-compiler option doctype: 'html', pretty: true, }); @@ -145,6 +149,8 @@ export const config = { }; ``` +**Note**: A `compileHooks` function is resolved **once per build/serve context**, not per file. Values read inside the function (environment variables, etc.) are therefore evaluated once when the build or the dev server initializes the compiler — per-file dynamic behavior is not possible. + ### Pattern 4: Using `createCompileHooks` Helper (Most Concise) ```ts @@ -171,7 +177,7 @@ export const config = { - Use `createCompileHooks` (Pattern 4) when you want the simplest setup with the same compiler for both main content and layouts - Use Pattern 1 when you need to customize `main` and `layout` hooks individually (e.g., different `before`/`after` hooks) - Use Pattern 2 when you need a function form and want to define the hooks factory outside the config -- Use Pattern 3 when you need to create the hooks factory dynamically inside the function (e.g., based on environment variables or other runtime values) +- Use Pattern 3 when you need to create the hooks factory dynamically inside the function (e.g., based on environment variables). Note that the function is resolved once per build/serve context, not per file ## Troubleshooting diff --git a/packages/@kamado-io/pug-compiler/src/build-pipeline.spec.ts b/packages/@kamado-io/pug-compiler/src/build-pipeline.spec.ts new file mode 100644 index 0000000..4874d13 --- /dev/null +++ b/packages/@kamado-io/pug-compiler/src/build-pipeline.spec.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { createPageCompiler } from '@kamado-io/page-compiler'; +import { build } from 'kamado/build'; +import { describe, test, expect, beforeAll, afterAll, vi } from 'vitest'; + +import { createCompileHooks } from './pug-compiler.js'; + +/** + * Real-FS integration test: runs the full build() pipeline + * (kamado core → page-compiler → pug-compiler) with a shared layout that + * includes a partial, and asserts the written HTML output. + */ + +let tmpDir: string; +let inputDir: string; +let outputDir: string; +let layoutsDir: string; +let consoleLogSpy: ReturnType; +let stdoutWriteSpy: ReturnType; + +beforeAll(async () => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kamado-build-pipeline-')); + inputDir = path.join(tmpDir, 'input'); + outputDir = path.join(tmpDir, 'output'); + layoutsDir = path.join(tmpDir, 'layouts'); + const partialsDir = path.join(tmpDir, 'partials'); + await fs.mkdir(inputDir, { recursive: true }); + await fs.mkdir(layoutsDir, { recursive: true }); + await fs.mkdir(partialsDir, { recursive: true }); + + await fs.writeFile( + path.join(partialsDir, 'header.pug'), + ['mixin header(t)', '\theader', '\t\th1= t', ''].join('\n'), + ); + await fs.writeFile( + path.join(layoutsDir, 'default.pug'), + [ + 'include /partials/header.pug', + 'doctype html', + 'html', + '\thead', + '\t\ttitle= title', + '\tbody', + '\t\t+header(title)', + '\t\tmain !{content}', + '', + ].join('\n'), + ); + for (const name of ['a', 'b', 'c']) { + await fs.writeFile( + path.join(inputDir, `page-${name}.pug`), + [ + '---', + 'layout: default.pug', + `title: Page ${name.toUpperCase()}`, + '---', + `p Hello ${name.toUpperCase()}`, + '', + ].join('\n'), + ); + } +}); + +afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + consoleLogSpy.mockRestore(); + stdoutWriteSpy.mockRestore(); +}); + +/** + * Runs build() against the fixture site + */ +async function buildFixture() { + await build({ + // @ts-expect-error -- pkg is accepted by mergeConfig to skip package.json lookup + pkg: { name: 'pipeline-fixture', version: '0.0.0' }, + rootDir: tmpDir, + dir: { input: inputDir, output: outputDir }, + compilers: (def) => [ + def(createPageCompiler(), { + files: '**/*.pug', + compileHooks: createCompileHooks({ basedir: tmpDir }), + layouts: { dir: layoutsDir }, + transforms: [], + }), + ], + }); +} + +describe('build pipeline (kamado core → page-compiler → pug-compiler)', () => { + test('builds pug pages with a shared layout and include into the expected HTML', async () => { + await buildFixture(); + + const html = await fs.readFile(path.join(outputDir, 'page-a.html'), 'utf8'); + expect(html).toBe( + [ + '', + '', + ' ', + ' Page A', + ' ', + ' ', + '
', + '

Page A

', + '
', + '
', + '

Hello A

', + ' ', + '', + ].join('\n'), + ); + + // All pages are written + const outputs = await fs.readdir(outputDir); + expect(outputs.toSorted()).toStrictEqual([ + 'page-a.html', + 'page-b.html', + 'page-c.html', + ]); + }); + + test('reflects edits to an included partial on the next build in the same process', async () => { + const partialPath = path.join(tmpDir, 'partials', 'header.pug'); + const original = await fs.readFile(partialPath, 'utf8'); + try { + await fs.writeFile( + partialPath, + ['mixin header(t)', '\theader', '\t\th1= t', '\t\tp Edited partial', ''].join( + '\n', + ), + ); + + await buildFixture(); + + const html = await fs.readFile(path.join(outputDir, 'page-b.html'), 'utf8'); + expect(html).toContain('

Edited partial

'); + } finally { + await fs.writeFile(partialPath, original); + } + }); +}); diff --git a/packages/@kamado-io/pug-compiler/src/compile-pug.ts b/packages/@kamado-io/pug-compiler/src/compile-pug.ts index 6faa883..41f315a 100644 --- a/packages/@kamado-io/pug-compiler/src/compile-pug.ts +++ b/packages/@kamado-io/pug-compiler/src/compile-pug.ts @@ -3,10 +3,22 @@ import type { Options as PugOptions } from 'pug'; import pug from 'pug'; +/** + * Maximum number of compiled template functions kept per compiler instance. + * Shared layouts stay hot via LRU refresh; unique page templates churn + * through without growing memory unboundedly on large sites. + */ +const TEMPLATE_CACHE_LIMIT = 256; + /** * Creates a Pug compiler function + * + * Compiled template functions are cached per compiler instance, keyed by the + * template source string, so shared templates (e.g. layouts) are compiled + * once and rendered many times. Pass `cache = false` (serve mode) to bypass + * the cache — includes/extends are then re-read on every compilation. * @param options - Pug compiler options - * @returns Compiler function that takes template and data + * @returns Compiler function that takes template, data, and a cache flag * @example * ```typescript * const compiler = compilePug({ @@ -25,9 +37,32 @@ export function compilePug(options: PugCompilerOptions = {}): CompilerFunction { ...options, }; - return (template: string, data: Record): Promise => { + const templateCache = new Map(); + + return ( + template: string, + data: Record, + cache = true, + ): Promise => { try { - const compiler = pug.compile(template, pugOptions); + let compiler = cache ? templateCache.get(template) : undefined; + if (compiler) { + // Refresh LRU position so frequently used templates (layouts) stay hot + templateCache.delete(template); + templateCache.set(template, compiler); + } else { + compiler = pug.compile(template, pugOptions); + if (cache) { + if (templateCache.size >= TEMPLATE_CACHE_LIMIT) { + // Evict the least recently used entry (first key in insertion order) + const oldestKey = templateCache.keys().next().value; + if (oldestKey !== undefined) { + templateCache.delete(oldestKey); + } + } + templateCache.set(template, compiler); + } + } return Promise.resolve(compiler(data)); } catch (error) { return Promise.reject( diff --git a/packages/@kamado-io/pug-compiler/src/create-compile-hooks.ts b/packages/@kamado-io/pug-compiler/src/create-compile-hooks.ts index 5bf0f8c..f3a63b1 100644 --- a/packages/@kamado-io/pug-compiler/src/create-compile-hooks.ts +++ b/packages/@kamado-io/pug-compiler/src/create-compile-hooks.ts @@ -14,22 +14,34 @@ function createCompilerWithExtensionCheck( content: string, data: Record, extension: string, + cache?: boolean, ) => Promise { - return async (content: string, data: Record, extension: string) => { + return async ( + content: string, + data: Record, + extension: string, + cache?: boolean, + ) => { // Check if the file extension is .pug if (extension !== '.pug') { // If not .pug, return content as-is return content; } // If .pug, compile it - return compiler(content, data); + return compiler(content, data, cache); }; } /** * Creates compile hooks for page-compiler + * + * Each invocation of the returned factory creates a fresh Pug compiler + * instance with a fresh template cache. The page compiler resolves the + * factory once per build/serve context, so the template cache's lifetime is + * bound to a single build. * @param options - Pug compiler options - * @returns Function that returns compile hooks object + * @returns Factory that returns a compile hooks object with `main` and + * `layout` compilers backed by a context-scoped Pug compiler * @example * ```typescript * import { createPageCompiler } from '@kamado-io/page-compiler'; @@ -51,10 +63,17 @@ function createCompilerWithExtensionCheck( export function createCompileHooks( options: PugCompilerOptions, ): () => CompileHooksObject { - const compiler = compilePug(options); - const compilerWithExtensionCheck = createCompilerWithExtensionCheck(compiler); - return () => ({ - main: { compiler: compilerWithExtensionCheck }, - layout: { compiler: compilerWithExtensionCheck }, - }); + // Create the compiler inside the factory so each resolution (once per + // build/serve context) gets a fresh compiler instance — and with it a + // fresh template cache. This keeps the cache lifetime bound to a single + // build, so include/extends edits between consecutive builds in the same + // process are always reflected. + return () => { + const compiler = compilePug(options); + const compilerWithExtensionCheck = createCompilerWithExtensionCheck(compiler); + return { + main: { compiler: compilerWithExtensionCheck }, + layout: { compiler: compilerWithExtensionCheck }, + }; + }; } diff --git a/packages/@kamado-io/pug-compiler/src/pug-compiler.spec.ts b/packages/@kamado-io/pug-compiler/src/pug-compiler.spec.ts index 0336846..ee427d0 100644 --- a/packages/@kamado-io/pug-compiler/src/pug-compiler.spec.ts +++ b/packages/@kamado-io/pug-compiler/src/pug-compiler.spec.ts @@ -1,4 +1,5 @@ -import { describe, test, expect } from 'vitest'; +import pug from 'pug'; +import { describe, test, expect, vi, afterEach } from 'vitest'; import { compilePug, createCompileHooks } from './pug-compiler.js'; @@ -47,6 +48,126 @@ describe('pug-compiler', () => { expect(hooks.layout?.compiler).toBeDefined(); }); + describe('template function cache', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('compiles the same template only once when cache is enabled', async () => { + const compileSpy = vi.spyOn(pug, 'compile'); + const compiler = compilePug({ doctype: 'html', pretty: true }); + + const first = await compiler('p= title', { title: 'A' }); + const second = await compiler('p= title', { title: 'B' }); + + expect(first).toBe('\n

A

'); + expect(second).toBe('\n

B

'); + expect(compileSpy).toHaveBeenCalledTimes(1); + }); + + test('recompiles every time when cache is disabled', async () => { + const compileSpy = vi.spyOn(pug, 'compile'); + const compiler = compilePug({ doctype: 'html', pretty: true }); + + await compiler('p= title', { title: 'A' }, false); + await compiler('p= title', { title: 'B' }, false); + + expect(compileSpy).toHaveBeenCalledTimes(2); + }); + + test('compiles different templates separately', async () => { + const compileSpy = vi.spyOn(pug, 'compile'); + const compiler = compilePug({ doctype: 'html', pretty: true }); + + await compiler('p one', {}); + await compiler('p two', {}); + + expect(compileSpy).toHaveBeenCalledTimes(2); + }); + + test('passes the cache flag through createCompileHooks to the pug compiler', async () => { + const compileSpy = vi.spyOn(pug, 'compile'); + const hooks = createCompileHooks({ doctype: 'html', pretty: true })(); + const compiler = hooks.main?.compiler; + expect(compiler).toBeDefined(); + + // cache=false (serve mode): recompiles every time + // @ts-ignore + await compiler?.('p= t', { t: 1 }, '.pug', false); + // @ts-ignore + await compiler?.('p= t', { t: 2 }, '.pug', false); + expect(compileSpy).toHaveBeenCalledTimes(2); + + // cache undefined (build mode): compiled once, then reused + compileSpy.mockClear(); + // @ts-ignore + await compiler?.('p= u', { u: 1 }, '.pug'); + // @ts-ignore + await compiler?.('p= u', { u: 2 }, '.pug'); + expect(compileSpy).toHaveBeenCalledTimes(1); + }); + + test('uses a fresh template cache per hooks-factory resolution', async () => { + const compileSpy = vi.spyOn(pug, 'compile'); + const hooksFactory = createCompileHooks({ doctype: 'html', pretty: true }); + + const first = hooksFactory(); + // @ts-ignore + await first.main?.compiler?.('p shared', {}, '.pug'); + + // A new resolution (= a new build context) must not reuse the + // previous build's compiled templates + const second = hooksFactory(); + // @ts-ignore + await second.main?.compiler?.('p shared', {}, '.pug'); + + expect(compileSpy).toHaveBeenCalledTimes(2); + }); + + test('evicts the least recently used template beyond the cache limit, keeping hot templates', async () => { + const compileSpy = vi.spyOn(pug, 'compile'); + const compiler = compilePug({ doctype: 'html', pretty: true }); + + // A "hot" template (e.g. a shared layout) + await compiler('p hot', {}); + expect(compileSpy).toHaveBeenCalledTimes(1); + + // Fill the cache up to its limit (256) with unique templates + for (let i = 0; i < 255; i++) { + await compiler(`p unique-${i}`, {}); + } + expect(compileSpy).toHaveBeenCalledTimes(256); + + // Touch the hot template: served from cache + refreshed in LRU order + await compiler('p hot', {}); + expect(compileSpy).toHaveBeenCalledTimes(256); + + // One more unique template evicts the LRU entry — which must be + // 'p unique-0', not the freshly touched hot template + await compiler('p unique-overflow', {}); + expect(compileSpy).toHaveBeenCalledTimes(257); + + // Hot template is still cached... + await compiler('p hot', {}); + expect(compileSpy).toHaveBeenCalledTimes(257); + + // ...but the evicted one is recompiled + await compiler('p unique-0', {}); + expect(compileSpy).toHaveBeenCalledTimes(258); + }); + + test('does not share cache between compiler instances', async () => { + const compileSpy = vi.spyOn(pug, 'compile'); + const compilerA = compilePug({ doctype: 'html', pretty: true }); + const compilerB = compilePug({ doctype: 'html', pretty: true }); + + await compilerA('p shared', {}); + await compilerB('p shared', {}); + + expect(compileSpy).toHaveBeenCalledTimes(2); + }); + }); + test('should use the same compiler for main and layout', async () => { const hooksFactory = createCompileHooks({ doctype: 'html', diff --git a/packages/@kamado-io/pug-compiler/src/types.ts b/packages/@kamado-io/pug-compiler/src/types.ts index 15201a3..6be4fad 100644 --- a/packages/@kamado-io/pug-compiler/src/types.ts +++ b/packages/@kamado-io/pug-compiler/src/types.ts @@ -26,8 +26,14 @@ export interface PugCompilerOptions extends PugOptions { /** * Compiler function type + * @param template - Pug template source + * @param data - Data object passed to the compiled template + * @param cache - Whether the compiled template function may be reused from + * cache. `false` in serve mode so that include/extends changes are always + * reflected. Default: `true` */ export type CompilerFunction = ( template: string, data: Record, + cache?: boolean, ) => Promise; From d8a558496efade728df4a333d69392324e725c68 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Sat, 6 Jun 2026 13:25:53 +0900 Subject: [PATCH 11/20] docs(kamado): document caching layers, skip-unchanged, and benchmarking - README (en/ja): add the --skip-unchanged build flag and a note on once-per-context resolution of compileHooks/transforms - ARCHITECTURE (en/ja): add the caching layers table, update the build flow diagram (cache clearing, skip-unchanged write), describe the cache-flag propagation, and document the yarn bench workflow --- packages/kamado/ARCHITECTURE.ja.md | 49 +++++++++++++++++++++++++++--- packages/kamado/ARCHITECTURE.md | 49 +++++++++++++++++++++++++++--- packages/kamado/README.ja.md | 11 +++++++ packages/kamado/README.md | 11 +++++++ 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/packages/kamado/ARCHITECTURE.ja.md b/packages/kamado/ARCHITECTURE.ja.md index 438d2a2..074936f 100644 --- a/packages/kamado/ARCHITECTURE.ja.md +++ b/packages/kamado/ARCHITECTURE.ja.md @@ -172,7 +172,8 @@ export function imageSizes(context: { elements: Element[] }, options?: ImageSize ```mermaid graph TD - A[CLI: build] --> B[config のロード & マージ] + A[CLI: build] --> A2[モジュールキャッシュのクリア
アセットグループ / ファイル内容 / グローバルデータ] + A2 --> B[config のロード & マージ] B --> B2[Context の作成 mode='build'] B2 --> C[onBeforeBuild フックの実行] C --> D[コンパイラ関数マップの作成] @@ -182,13 +183,18 @@ graph TD F --> G{出力拡張子に対応する
コンパイラが存在するか?} G -- Yes --> H[コンパイラで実行] G -- No --> I[生のコンテンツを読み込み] - H --> J[出力ファイルとして書き出し] + H --> J{skipUnchanged 有効 &
既存出力と内容が同一?} I --> J - J --> K[全ファイル完了] + J -- Yes --> J2[書き込みスキップ
mtime 保持] + J -- No --> J3[出力ファイルとして書き出し] + J2 --> K[全ファイル完了] + J3 --> K K --> L[onAfterBuild フックの実行] L --> M[ビルド完了を表示] ``` +`build()` は毎回クリーンな状態から開始します。最初にモジュールレベルのキャッシュ(アセットグループのメモ化、ファイル内容、グローバルデータ)をクリアするため、同一プロセスで連続してビルドしてもソースの編集が必ず反映されます。出力ディレクトリの作成はビルド内で重複排除されます。`skipUnchanged` ビルドオプション(`kamado build --skip-unchanged`)を有効にすると、内容が変わっていない出力は書き込まれません。その際はまずファイルサイズ(`stat`)を比較します。一致した場合のみ内容を比較し、同一なら既存ファイルの mtime を保持します。 + ### 2. 開発サーバー・フロー (`kamado server`) ローカル開発時のオンデマンド・コンパイルのフローです。 @@ -243,7 +249,7 @@ graph TD 複数のソースが同一の出力パスに解決された場合の挙動は、コンパイラエントリの `outputPathConflict` 設定で切り替えます。`'error'`(throw)、`'warning'`(デフォルト — `stderr` に警告を出して勝者を残す)、`'silent'`(ログなしで勝者を残す)の3値を取ります。勝者判定のルールは2段階で、まず **frontmatter による上書きを持つファイルがデフォルト計算パスのファイルに優先** し、同等の場合は **先勝ち** です。`getAssetGroup()` 内の `seen` Map で出力パスを追跡し、置換が起きても返却される `CompilableFile[]` の位置は最初に観測したファイルの位置を保持するため、処理順に依存しない結果になります。 -先読みは `files/file-content.ts` のモジュールレベルキャッシュを温めるため、build の後段の `getContentFromFile`(`cache=true`)はディスク再読込を行いません。dev server は編集を反映するためリクエスト毎に `cache=false` を渡すので、先読みコストは起動時の1回のみ支払われます。上書きは `getAssetGroup` が返す `CompilableFile` に既に反映されているため、`compilableFileMap`(dev server)と `build()`(`file.outputPath` に書き出し)はどちらも追加変更なしで上書きを尊重します。 +先読みは `files/file-content.ts` のモジュールレベルキャッシュを温めるため、build の後段の `getContentFromFile`(`cache=true`)はディスク再読込を行いません。さらに `getAssetGroup()` の結果自体も列挙の値入力をキーにメモ化される(毎ビルド開始時にクリア)ため、`build()` と `getGlobalData()` の両方から同じコンパイラエントリが列挙されても glob + frontmatter の走査は1回で済みます。dev server は編集を反映するためリクエスト毎に `cache=false` を渡すので、先読みコストは起動時の1回のみ支払われます。上書きは `getAssetGroup` が返す `CompilableFile` に既に反映されているため、`compilableFileMap`(dev server)と `build()`(`file.outputPath` に書き出し)はどちらも追加変更なしで上書きを尊重します。 --- @@ -367,7 +373,7 @@ export interface CustomCompileFunction { - `compilableFile`: コンパイル対象のファイル - `compile`: コンパイル中に他のファイルを再帰的にコンパイルできる関数(レイアウトやインクルードなど) - `log`: オプションのログ出力関数 -- `cache`: ファイルコンテンツをキャッシュするかどうか +- `cache`: キャッシュ済みのファイル内容やコンパイル成果物(コンパイル済みテンプレート関数、プロセッサ等)を再利用してよいかどうか。dev server はファイル編集を必ず反映するためリクエストごとに `false` を渡し、`build()` は `undefined` のまま(各コンパイラはキャッシュ有効をデフォルトとする) ソースコードの読み込みやキャッシュの管理は`CompilableFile`クラス(`src/files/`)が隠蔽します。`compile`パラメータにより、コンパイラは依存ファイルを再帰的にコンパイルできます。 @@ -565,6 +571,39 @@ export interface ProxyRule { --- +## キャッシュ層 + +Kamado はファイルごとの処理の繰り返しを避けるため、複数の独立したキャッシュを使います。ビルド・コンパイルパイプラインに手を入れるコントリビューターは、それぞれのスコープと無効化ルールを把握してください: + +| キャッシュ | 場所 | スコープ / 無効化 | +| ------------------------------ | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ファイル内容 | `src/files/file-content.ts` | モジュールレベルの `Map`。毎ビルド開始時にクリア(`clearFileContentCache`)。serve モードではリクエストごとにバイパス(`cache=false`)。 | +| グローバルデータ | `src/data/get-global-data.ts` | データファイル用のモジュールレベル `Map`。毎ビルド開始時にクリア(`clearGlobalDataCache`)。 | +| アセットグループのメモ化 | `src/data/get-asset-group.ts` | 列挙の値入力をキーとするモジュールレベル `Map`。`build()` と `getGlobalData()` による glob + frontmatter 走査の共有用。毎ビルド開始時にクリア(`clearAssetGroupCache`)。 | +| コンパイル済みテンプレート関数 | `@kamado-io/pug-compiler`(`compile-pug.ts`) | コンパイラインスタンスごと、テンプレートソースをキーとする上限付き LRU。コンパイルフックのファクトリ解決のたび(= build/serve コンテキストごと)に新しいインスタンスとキャッシュを生成。`cache=false` 時はスキップ。 | +| PostCSS プロセッサ / banner | `@kamado-io/style-compiler`、`script-compiler` | コンテキストごとに遅延構築され全ファイルで再利用。`cache=false`(serve)ではコンパイルごとに再構築されるため、開発中の `postcss.config.js` の編集や日付ベースの banner の鮮度を維持。プロセッサ構築の失敗はキャッシュせず、次のコンパイルで再試行。 | + +これらのキャッシュに関連して、page compiler の `compileHooks` と `transforms` のファクトリは、ファイルごとではなく **build/serve コンテキストごとに1回**(コンパイラのコンテキストセットアップ時)解決されます。フックファクトリと transform インスタンスは、ビルド内の全ページ・並行コンパイル間で共有されます。 + +`cache` フラグは `CustomCompileFunction`(第4引数)から page compiler の transpile 層を通ってコンパイルフックの `compiler` 関数(第4引数)まで伝播するため、テンプレートエンジン側のパッケージは serve モードのキャッシュ無効セマンティクスを尊重できます。 + +--- + +## ベンチマーク + +合成ビルドのベンチマークが `packages/kamado/benchmark/` にあります: + +```bash +yarn bench # 1000ページ、3回計測、transforms 無効 +yarn bench --pages=500 # ページ数 +yarn bench --runs=5 # 計測回数(中央値を報告) +yarn bench --full # デフォルトの page transforms(jsdom/prettier/minifier)を有効化 +``` + +フィクスチャサイト(include を持つ共有レイアウト1つ+ N ページの Pug、少数の CSS/TS)を `packages/kamado/.bench/` に生成し、ビルド済み `dist` に対して `build()` の実時間を計測します — 事前に `yarn build` が必要です。ビルドパイプラインを変更する際の前後比較に使用してください。モジュールレベルのキャッシュは計測ごとにクリアされるため、毎回コールドビルドが計測されます。 + +--- + ## 主要な依存ライブラリ - **[@d-zero/dealer](https://www.npmjs.com/package/@d-zero/dealer)**: 全体の並列処理とプログレス表示を制御。 diff --git a/packages/kamado/ARCHITECTURE.md b/packages/kamado/ARCHITECTURE.md index 7d90ff1..3d21599 100644 --- a/packages/kamado/ARCHITECTURE.md +++ b/packages/kamado/ARCHITECTURE.md @@ -172,7 +172,8 @@ The flow for compiling all files at once and exporting them as static files. ```mermaid graph TD - A[CLI: build] --> B[Load & Merge config] + A[CLI: build] --> A2[Clear module caches
asset group / file content / global data] + A2 --> B[Load & Merge config] B --> B2[Create Context with mode='build'] B2 --> C[Execute onBeforeBuild hook] C --> D[Create compiler function map] @@ -182,13 +183,18 @@ graph TD F --> G{Compiler exists for
output extension?} G -- Yes --> H[Execute compiler] G -- No --> I[Read raw content] - H --> J[Write to output file] + H --> J{skipUnchanged enabled &
existing output identical?} I --> J - J --> K[All files completed] + J -- Yes --> J2[Skip write
preserve mtime] + J -- No --> J3[Write to output file] + J2 --> K[All files completed] + J3 --> K K --> L[Execute onAfterBuild hook] L --> M[Display Build Completed] ``` +Every `build()` invocation starts from a clean slate: the module-level caches (asset group memoization, file contents, global data) are cleared first, so source edits between consecutive builds in the same process are always reflected. Output directories are created lazily and deduplicated within a build, and with the `skipUnchanged` build option (`kamado build --skip-unchanged`) an output whose content is unchanged is not rewritten — the file size is compared first (via `stat`), then the content, and the existing file's mtime is preserved on a match. + ### 2. Dev Server Flow (`kamado server`) The flow for on-demand compilation during local development. @@ -243,7 +249,7 @@ Three forms are accepted: `/foo/bar.html` (used as-is), `/foo/bar` (compiler's ` When two source files resolve to the same output path, the compiler entry's `outputPathConflict` setting decides the reaction: `'error'` (throw), `'warning'` (default — log to `stderr` and pick a winner), or `'silent'` (pick a winner with no log). Winner selection rules: a file whose `outputPath` came from the frontmatter override beats one using the default computed path; among ties the first-seen file wins. The map of seen output paths is built in `getAssetGroup()` and replacement is order-independent because the surviving entry's position in the Map (and therefore in the returned `CompilableFile[]`) is the first-seen position. -The eager read warms the module-level cache in `files/file-content.ts`, so the build's later `getContentFromFile` call (with `cache=true`) does not re-read from disk. The dev server's per-request compile passes `cache=false` to pick up edits, so the eager read is paid only once at startup. Because the override is reflected in the `CompilableFile` returned by `getAssetGroup`, both `compilableFileMap` (dev server) and `build()` (which writes to `file.outputPath`) honor the override with no further changes. +The eager read warms the module-level cache in `files/file-content.ts`, so the build's later `getContentFromFile` call (with `cache=true`) does not re-read from disk. In addition, `getAssetGroup()` results themselves are memoized by the enumeration's value-inputs (cleared at the start of every build), so the same compiler entry enumerated by both `build()` and `getGlobalData()` pays the glob + frontmatter pass only once. The dev server's per-request compile passes `cache=false` to pick up edits, so the eager read is paid only once at startup. Because the override is reflected in the `CompilableFile` returned by `getAssetGroup`, both `compilableFileMap` (dev server) and `build()` (which writes to `file.outputPath`) honor the override with no further changes. --- @@ -367,7 +373,7 @@ The `CustomCompiler` receives a `Context` object (which includes `mode: 'serv - `compilableFile`: The file to compile - `compile`: A recursive compile function that can compile other files during compilation (e.g., layouts, includes) - `log`: Optional logging function -- `cache`: Whether to cache file content +- `cache`: Whether cached file contents and compiled artifacts (e.g. compiled template functions, processors) may be reused. The dev server passes `false` on every request so that file edits are always reflected; `build()` leaves it `undefined` (compilers default to caching) The `CompilableFile` class (`src/files/`) handles file reading and cache management behind the scenes. The `compile` parameter enables compilers to recursively compile dependencies. @@ -565,6 +571,39 @@ Proxy routes are registered conditionally — only when `context.devServer.proxy --- +## Caching Layers + +Kamado uses several independent caches to avoid repeating per-file work. Contributors touching the build or compile pipeline should know their scopes and invalidation rules: + +| Cache | Location | Scope / Invalidation | +| --------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| File contents | `src/files/file-content.ts` | Module-level `Map`. Cleared at the start of every `build()` (`clearFileContentCache`). Bypassed per request in serve mode (`cache=false`). | +| Global data | `src/data/get-global-data.ts` | Module-level `Map` for data files. Cleared at the start of every `build()` (`clearGlobalDataCache`). | +| Asset group memoization | `src/data/get-asset-group.ts` | Module-level `Map` keyed by the enumeration's value-inputs. Lets `build()` and `getGlobalData()` share one glob + frontmatter pass. Cleared at the start of every `build()` (`clearAssetGroupCache`). | +| Compiled template functions | `@kamado-io/pug-compiler` (`compile-pug.ts`) | Per compiler instance, keyed by template source, bounded LRU. A fresh instance (and cache) is created each time the compile hooks factory is resolved — i.e. once per build/serve context. Skipped when `cache=false`. | +| PostCSS processor / banner | `@kamado-io/style-compiler`, `script-compiler` | Lazily built once per context and reused across files. Rebuilt per compilation when `cache=false` (serve), so `postcss.config.js` edits and date-based banners stay fresh during development. A failed processor build is not cached, so the next compilation retries. | + +Related to these caches, `compileHooks` and `transforms` factories on the page compiler are resolved **once per build/serve context** (in the compiler's context setup), not per file. Hook factories and transform instances are therefore shared across all pages of a build and across concurrent compilations. + +The `cache` flag travels from `CustomCompileFunction` (4th parameter) through the page compiler's transpile layer into the compile hooks' `compiler` function (4th parameter), so template-engine packages can honor serve mode's no-cache semantics. + +--- + +## Benchmarking + +A synthetic-build benchmark lives in `packages/kamado/benchmark/`: + +```bash +yarn bench # 1000 pages, 3 runs, transforms disabled +yarn bench --pages=500 # page count +yarn bench --runs=5 # number of runs (median is reported) +yarn bench --full # enable the default page transforms (jsdom/prettier/minifier) +``` + +It generates a fixture site (N Pug pages sharing one layout with an include, plus a few CSS/TS files) under `packages/kamado/.bench/` and measures `build()` wall-clock time against the built `dist` output — run `yarn build` first. Use it to compare before/after numbers when changing the build pipeline; module-level caches are cleared between runs so every run measures a cold build. + +--- + ## Main Dependencies - **[@d-zero/dealer](https://www.npmjs.com/package/@d-zero/dealer)**: Controls parallel processing and progress display. diff --git a/packages/kamado/README.ja.md b/packages/kamado/README.ja.md index 0d1c344..06b16e7 100644 --- a/packages/kamado/README.ja.md +++ b/packages/kamado/README.ja.md @@ -168,6 +168,8 @@ export default defineConfig({ - `compileHooks`: コンパイルプロセスをカスタマイズするコンパイルフック(Pugテンプレートを使用する場合は必須) - `transforms`: コンパイル済みHTMLに適用する変換関数の配列。省略時は`createDefaultPageTransforms()`を使用。Transform Pipeline APIの詳細は[@kamado-io/page-compiler](../packages/@kamado-io/page-compiler/README.md)を参照してください +**注意**: `compileHooks`や`transforms`を関数として指定した場合、ファイルごとではなく**build/serveコンテキストごとに1回だけ**解決されます。解決されたフックやtransformインスタンスは全ページで共有され(並行コンパイルされる場合もあります)、ファイルに依存しない・ページごとの可変状態を持たない実装である必要があります。 + **注意**: `page-compiler`は汎用コンテナコンパイラであり、デフォルトではPugテンプレートをコンパイルしません。Pugテンプレートを使用するには、`@kamado-io/pug-compiler`をインストールし、`compileHooks`を設定してください。詳細は[@kamado-io/pug-compiler README](../@kamado-io/pug-compiler/README.md)を参照してください。 **例**: `.pug`ファイルを`.html`にコンパイルする場合: @@ -531,6 +533,12 @@ kamado server | `--config ` | `-c` | 設定ファイルのパスを指定。未指定の場合、`kamado.config.js`、`kamado.config.ts`などを自動探索 | | `--verbose` | | 詳細なログ出力を有効化 | +`build`コマンドのみで利用可能なオプション: + +| オプション | 短縮形 | 説明 | +| ------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `--skip-unchanged` | | 内容が変わっていない出力ファイルの書き込みをスキップ。既存ファイルのmtimeが保持されるため、mtimeベースの差分デプロイに有効 | + #### 使用例 ```bash @@ -540,6 +548,9 @@ kamado server -c ./dev.config.js # ビルド時に詳細ログを出力 kamado build --verbose + +# 内容が変わっていない出力の書き込みをスキップ +kamado build --skip-unchanged ``` ### 型安全性とジェネリクス diff --git a/packages/kamado/README.md b/packages/kamado/README.md index b1735a8..a7d7c16 100644 --- a/packages/kamado/README.md +++ b/packages/kamado/README.md @@ -168,6 +168,8 @@ The order of entries in the returned array determines the processing order. - `compileHooks`: Compilation hooks for customizing compile process (required for Pug templates) - `transforms`: Array of transform functions to apply to compiled HTML. If omitted, uses `createDefaultPageTransforms()`. See [@kamado-io/page-compiler](../packages/@kamado-io/page-compiler/README.md) for details on the Transform Pipeline API. +**Note**: When `compileHooks` or `transforms` is given as a function, it is resolved **once per build/serve context**, not per file. The resolved hooks and transform instances are shared by all pages (which may compile concurrently), so they must be file-independent and must not keep per-page mutable state. + **Note**: `page-compiler` is a generic container compiler and does not compile Pug templates by default. To use Pug templates, install `@kamado-io/pug-compiler` and configure `compileHooks`. See [@kamado-io/pug-compiler README](../@kamado-io/pug-compiler/README.md) for details. **Example**: To compile `.pug` files to `.html`: @@ -531,6 +533,12 @@ The following options are available for all commands: | `--config ` | `-c` | Path to a specific config file. If not specified, Kamado searches for `kamado.config.js`, `kamado.config.ts`, etc. | | `--verbose` | | Enable verbose logging | +The following options are available for the `build` command only: + +| Option | Short | Description | +| ------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `--skip-unchanged` | | Skip writing output files whose content is unchanged. The existing file's mtime is preserved, which helps mtime-based deployment diffing. | + #### Examples ```bash @@ -540,6 +548,9 @@ kamado server -c ./dev.config.js # Enable verbose logging during build kamado build --verbose + +# Skip rewriting outputs whose content has not changed +kamado build --skip-unchanged ``` ### Type Safety & Generics From 514e92964a042b896b10e9b2cabff6ee936ef085 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Sat, 6 Jun 2026 13:26:26 +0900 Subject: [PATCH 12/20] docs(repo): add migration guide for per-context hook resolution Add the v2.0.0 migration entry for the compileHooks/transforms resolution timing change (per file to per build/serve context) with before/after rewrite examples and guidance for affected factory patterns. --- MILESTONE.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/MILESTONE.md b/MILESTONE.md index 7d35c62..f05539b 100644 --- a/MILESTONE.md +++ b/MILESTONE.md @@ -10,9 +10,55 @@ - [x] `getTitleFromStaticFile` を削除 - [x] `kamado/features` に deprecation 警告を追加(v2.0.0 で削除予定) - [x] `PageData` 型を追加(`metaData.title` でタイトル管理) +- [x] `compileHooks` / `transforms` の関数形式の解決タイミングを「ファイルごと」から「build/serve コンテキストごとに1回」に変更(ビルド高速化) ## Migration Guide +### `compileHooks` / `transforms` の解決タイミング変更 (v2.0.0) + +ビルド高速化のため、`PageCompilerOptions` の `compileHooks` と `transforms` を関数として指定した場合の解決タイミングが変更されました。 + +#### 変更内容 + +| API | v1.x(変更前) | v2.0.0(変更後) | +| ---------------------- | -------------------- | ----------------------------------------- | +| `compileHooks`(関数) | ページごとに毎回呼出 | build/serve コンテキストごとに1回だけ呼出 | +| `transforms`(関数) | ページごとに毎回呼出 | build/serve コンテキストごとに1回だけ呼出 | + +返されたフック・transform インスタンスは、そのコンテキストの全ページ(並行コンパイルを含む)で共有されます。 + +#### 影響を受けるケースと対応 + +ファクトリ関数内でページごとに変わる値を読んでいた場合のみ影響があります。 + +```ts +// ❌ v1.x ではページごとに評価されていたが、v2.0.0 では起動時に1回だけ評価される +def(createPageCompiler(), { + transforms: (defaults) => [ + { + name: 'timestamp', + transform: (content) => content.replace('%TIME%', factoryTime), // factoryTime はファクトリ実行時に固定 + }, + ...defaults, + ], +}); + +// ✅ v2.0.0 — ページごとに変わる値は transform 関数の「実行時」(content, context を受け取る側)で読む +def(createPageCompiler(), { + transforms: (defaults) => [ + { + name: 'timestamp', + transform: (content) => content.replace('%TIME%', String(Date.now())), // 変換実行時に評価 + }, + ...defaults, + ], +}); +``` + +`compileHooks` も同様に、ページごとの動的な値はファクトリ内ではなく `before` / `compiler` / `after` フックの実行時(`content` と `data` を受け取る側)で評価してください。transform / フックのインスタンスにページごとの可変状態(カウンタ・蓄積バッファ等)を持たせることはできません。 + +ファクトリがページに依存しない初期化のみを行っている場合(`@kamado-io/pug-compiler` の `createCompileHooks` を含む)、対応は不要です。 + ### `kamado/features` の削除 (v2.0.0) `kamado/features` エクスポートは v2.0.0 で削除されました。 From ad148e419b5d8a3ad2c5274b3a059c34e5a209b7 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 10 Jun 2026 22:24:47 +0900 Subject: [PATCH 13/20] feat(kamado): add shared compiler helpers and unified cache clearing - add clearBuildCaches() as the single entry point for resetting all module-level build caches; use it in build() and the benchmark runner - add createBannerResolver() and resolveSourcemapFlag() so asset compilers share the per-context banner caching and sourcemap-flag evaluation - export the SourcemapOption type from kamado/compiler - document the cache-flag contract: undefined means caching is enabled; test for serve mode with `cache === false`, never a truthiness check - defer output buffer allocation until needed (size precheck uses Buffer.byteLength; strings are written directly) - clamp benchmark --pages/--runs args to positive integers so invalid input falls back instead of reporting meaningless numbers --- packages/kamado/benchmark/run-bench.ts | 12 ++++----- packages/kamado/src/builder/build.ts | 27 +++++++++++-------- packages/kamado/src/compiler/compiler.ts | 2 ++ .../src/compiler/create-banner-resolver.ts | 26 ++++++++++++++++++ .../src/compiler/resolve-sourcemap-flag.ts | 20 ++++++++++++++ packages/kamado/src/compiler/types.ts | 17 ++++++++++-- .../kamado/src/data/clear-build-caches.ts | 18 +++++++++++++ packages/kamado/src/data/data.ts | 1 + 8 files changed, 104 insertions(+), 19 deletions(-) create mode 100644 packages/kamado/src/compiler/create-banner-resolver.ts create mode 100644 packages/kamado/src/compiler/resolve-sourcemap-flag.ts create mode 100644 packages/kamado/src/data/clear-build-caches.ts diff --git a/packages/kamado/benchmark/run-bench.ts b/packages/kamado/benchmark/run-bench.ts index cbe6f9f..fc3ee52 100644 --- a/packages/kamado/benchmark/run-bench.ts +++ b/packages/kamado/benchmark/run-bench.ts @@ -25,14 +25,15 @@ import { createStyleCompiler } from '@kamado-io/style-compiler'; import { generateFixtures } from './generate-fixtures.ts'; import { build } from 'kamado/build'; -import { clearGlobalDataCache } from 'kamado/data'; -import { clearFileContentCache } from 'kamado/files'; +import { clearBuildCaches } from 'kamado/data'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const BENCH_DIR = path.resolve(__dirname, '..', '.bench'); /** - * + * Reads a positive integer CLI argument; non-numeric or non-positive values + * fall back so the benchmark never runs with 0 pages or 0 runs (which would + * silently report meaningless numbers like Infinity pages/s) * @param name * @param fallback */ @@ -42,7 +43,7 @@ function readArg(name: string, fallback: number): number { return fallback; } const value = Number.parseInt(raw.split('=')[1] ?? '', 10); - return Number.isNaN(value) ? fallback : value; + return Number.isNaN(value) || value < 1 ? fallback : value; } const pageCount = readArg('pages', 1000); @@ -55,8 +56,7 @@ const durations: number[] = []; for (let run = 0; run < runCount; run++) { // Reset module-level caches so each run measures a cold build - clearFileContentCache(); - clearGlobalDataCache(); + clearBuildCaches(); const start = performance.now(); await build({ diff --git a/packages/kamado/src/builder/build.ts b/packages/kamado/src/builder/build.ts index bf11b9e..2f9fb56 100644 --- a/packages/kamado/src/builder/build.ts +++ b/packages/kamado/src/builder/build.ts @@ -11,9 +11,8 @@ import { createCompileFunctions } from '../compiler/compile-functions.js'; import { createCompiler } from '../compiler/create-compiler.js'; import { createCompileFunctionMap } from '../compiler/function-map.js'; import { mergeConfig } from '../config/merge-config.js'; -import { clearAssetGroupCache, getAssetGroup } from '../data/get-asset-group.js'; -import { clearGlobalDataCache } from '../data/get-global-data.js'; -import { clearFileContentCache } from '../files/file-content.js'; +import { clearBuildCaches } from '../data/clear-build-caches.js'; +import { getAssetGroup } from '../data/get-asset-group.js'; import { filePathColorizer } from '../stdout/color.js'; /** @@ -55,9 +54,7 @@ export async function build( // Each build starts from a clean slate: re-enumerate files and re-read // file contents and global data so that source edits between consecutive // builds in the same process are always reflected - clearAssetGroupCache(); - clearFileContentCache(); - clearGlobalDataCache(); + clearBuildCaches(); const config = await mergeConfig(buildConfig, buildConfig.rootDir); @@ -117,15 +114,20 @@ export async function build( return async () => { const content = await compile(file, log); - const buffer = + // Allocated lazily: the size precheck needs only the byte length, + // and fs.writeFile accepts strings directly + const toWritable = () => typeof content === 'string' ? Buffer.from(content) : new Uint8Array(content); if (buildConfig.skipUnchanged) { - // Cheap size check first; read the file only when sizes match + // Cheap size check first (no allocation); read the file only + // when sizes match + const byteLength = + typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength; const stat = await fs.stat(file.outputPath).catch(() => null); - if (stat && stat.size === buffer.byteLength) { + if (stat && stat.size === byteLength) { const existing = await fs.readFile(file.outputPath).catch(() => null); - if (existing && existing.equals(buffer)) { + if (existing && existing.equals(toWritable())) { log(`${CHECK_MARK} Unchanged`); return; } @@ -139,7 +141,10 @@ export async function build( ensuredDirs.add(outputDir); } - await fs.writeFile(file.outputPath, buffer); + await fs.writeFile( + file.outputPath, + typeof content === 'string' ? content : new Uint8Array(content), + ); log(`${CHECK_MARK} Compiled!`); }; diff --git a/packages/kamado/src/compiler/compiler.ts b/packages/kamado/src/compiler/compiler.ts index 09688a7..52c2966 100644 --- a/packages/kamado/src/compiler/compiler.ts +++ b/packages/kamado/src/compiler/compiler.ts @@ -1,3 +1,5 @@ +export { createBannerResolver } from './create-banner-resolver.js'; export { createCompiler } from './create-compiler.js'; export { createCustomCompiler } from './create-custom-compiler.js'; +export { resolveSourcemapFlag } from './resolve-sourcemap-flag.js'; export type * from './types.js'; diff --git a/packages/kamado/src/compiler/create-banner-resolver.ts b/packages/kamado/src/compiler/create-banner-resolver.ts new file mode 100644 index 0000000..8d838e6 --- /dev/null +++ b/packages/kamado/src/compiler/create-banner-resolver.ts @@ -0,0 +1,26 @@ +import type { CreateBanner } from './banner.js'; + +import { createBanner } from './banner.js'; + +/** + * Creates a banner resolver that caches the resolved banner per context. + * + * The banner is file-independent, so it is resolved once and reused across + * all files of a build. Passing `cache = false` (serve mode) re-resolves on + * every call so that date-based banners stay fresh per request. + * @param banner - Banner configuration (a `CreateBanner` factory or a string) + * @param transform - Optional post-processing applied to the resolved banner + * (e.g. normalizing it into a minifier-safe comment) + * @returns Function that takes the compile `cache` flag and returns the banner + */ +export function createBannerResolver( + banner: CreateBanner | string | undefined, + transform?: (banner: string) => string, +): (cache?: boolean) => string { + const resolve = () => { + const raw = typeof banner === 'string' ? banner : createBanner(banner?.()); + return transform ? transform(raw) : raw; + }; + let cached: string | undefined; + return (cache) => (cache === false ? resolve() : (cached ??= resolve())); +} diff --git a/packages/kamado/src/compiler/resolve-sourcemap-flag.ts b/packages/kamado/src/compiler/resolve-sourcemap-flag.ts new file mode 100644 index 0000000..5a2d6c5 --- /dev/null +++ b/packages/kamado/src/compiler/resolve-sourcemap-flag.ts @@ -0,0 +1,20 @@ +import type { SourcemapOption } from './types.js'; + +/** + * Resolves a compiler's `sourcemap` option into a concrete boolean for the + * current execution mode. + * + * `context.mode` is fixed for the lifetime of a command, so call this once + * per build/serve context rather than per file. + * @param sourcemap - The compiler's `sourcemap` option. `'onServer'` emits + * only in serve mode. Default: `'onServer'` + * @param mode - The execution mode from the context + * @returns Whether an inline source map should be emitted + */ +export function resolveSourcemapFlag( + sourcemap: SourcemapOption | undefined, + mode: 'build' | 'serve', +): boolean { + const option = sourcemap ?? 'onServer'; + return option === 'onServer' ? mode === 'serve' : option; +} diff --git a/packages/kamado/src/compiler/types.ts b/packages/kamado/src/compiler/types.ts index df2ad1c..ab9bff7 100644 --- a/packages/kamado/src/compiler/types.ts +++ b/packages/kamado/src/compiler/types.ts @@ -9,7 +9,10 @@ export interface CompileFunction { /** * @param file - File to compile (CompilableFile or file seed with inputPath and outputExtension) * @param log - Log output function (optional) - * @param cache - Whether to cache the file content (default: true) + * @param cache - Whether caches may be used (default: true). `build()` leaves + * this `undefined`, which means caching is ENABLED — treat `undefined` the + * same as `true` and test for serve mode with `cache === false`, never with + * a truthiness check like `if (cache)` * @returns Compilation result (string or ArrayBuffer) */ ( @@ -24,6 +27,13 @@ export interface CompileFunction { ): Promise; } +/** + * Inline source map emission policy shared by asset compilers + * - `true` / `false`: always emit / never emit + * - `'onServer'`: emit only when kamado runs in serve mode + */ +export type SourcemapOption = boolean | 'onServer'; + /** * Compiler context with compile function map * Extends Context to include a map of compiler functions by output extension @@ -44,7 +54,10 @@ export interface CustomCompileFunction { * @param compilableFile - File to compile * @param compile - Recursive compiler function to compile other files during compilation * @param log - Log output function (optional) - * @param cache - Whether to cache the file content (default: true) + * @param cache - Whether caches may be used (default: true). `build()` leaves + * this `undefined`, which means caching is ENABLED — treat `undefined` the + * same as `true` and test for serve mode with `cache === false`, never with + * a truthiness check like `if (cache)` * @returns Compilation result (string or ArrayBuffer) */ ( diff --git a/packages/kamado/src/data/clear-build-caches.ts b/packages/kamado/src/data/clear-build-caches.ts new file mode 100644 index 0000000..d9365bb --- /dev/null +++ b/packages/kamado/src/data/clear-build-caches.ts @@ -0,0 +1,18 @@ +import { clearFileContentCache } from '../files/file-content.js'; + +import { clearAssetGroupCache } from './get-asset-group.js'; +import { clearGlobalDataCache } from './get-global-data.js'; + +/** + * Clears every module-level cache that a build relies on: the asset group + * memoization, file contents, and global data. + * + * This is the single entry point for "start from a clean slate" — when a new + * module-level cache is added, register its clear function here so that + * consecutive builds in the same process never see stale data. + */ +export function clearBuildCaches(): void { + clearAssetGroupCache(); + clearFileContentCache(); + clearGlobalDataCache(); +} diff --git a/packages/kamado/src/data/data.ts b/packages/kamado/src/data/data.ts index 1c98ddd..cb669e4 100644 --- a/packages/kamado/src/data/data.ts +++ b/packages/kamado/src/data/data.ts @@ -1,3 +1,4 @@ +export { clearBuildCaches } from './clear-build-caches.js'; export { getAssetGroup, clearAssetGroupCache } from './get-asset-group.js'; export { getGlobalData, clearGlobalDataCache } from './get-global-data.js'; export type { GlobalData } from './types.js'; From d495848da0ed468ca0d091cf542b7b2016ec2129 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 10 Jun 2026 22:26:00 +0900 Subject: [PATCH 14/20] refactor(style-compiler): use shared banner/sourcemap helpers - replace local banner caching and sourcemap-flag evaluation with createBannerResolver and resolveSourcemapFlag from kamado/compiler - use the shared SourcemapOption type - unify the spec on a single test harness (makeContext/makeFile/ createCompileFn) instead of two parallel helper sets --- .../style-compiler/src/style-compiler.spec.ts | 197 +++++++----------- .../style-compiler/src/style-compiler.ts | 45 ++-- 2 files changed, 97 insertions(+), 145 deletions(-) diff --git a/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts b/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts index 4f8a95e..ba523a0 100644 --- a/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts +++ b/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts @@ -42,15 +42,38 @@ beforeEach(() => { }); /** - * Helper to create a minimal CompilableFile for tests + * Registers mock CSS content for a source path * @param inputPath + * @param css */ -function createFile(inputPath: string): CompilableFile { +function setMockFile(inputPath: string, css: string) { + mockFileContents.set(inputPath, { content: css, raw: css }); +} + +/** + * Creates a minimal execution context for the given mode + * @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; +} + +/** + * Creates a minimal CompilableFile for tests + * @param inputPath + */ +function makeFile(inputPath = '/test/src/style.css'): CompilableFile { return { inputPath, - outputPath: inputPath, + outputPath: '/test/dist/style.css', fileSlug: 'style', - filePathStem: '/style', + filePathStem: '/test/src/style', url: '/style.css', extension: '.css', date: new Date(), @@ -58,95 +81,94 @@ function createFile(inputPath: string): CompilableFile { } /** - * Helper to create the innermost compile function (build mode) + * Creates the innermost compile function for the given mode * @param options + * @param mode */ -async function createCompileFn(options?: StyleCompilerOptions) { - const entry = createStyleCompiler()(options); - // @ts-ignore -- only context.mode is read by the style compiler - return await entry.compiler({ mode: 'build' }); +async function createCompileFn( + options?: StyleCompilerOptions, + mode: 'serve' | 'build' = 'build', +) { + const entry = createStyleCompiler()(options); + return await entry.compiler(makeContext(mode)); +} + +/** + * One-shot helper: registers a CSS file, compiles it in the given mode with + * cache=false, and returns the output as a string + * @param mode + * @param options + * @param css + */ +async function compile( + mode: 'serve' | 'build', + options: StyleCompilerOptions, + css = '.a{color:red}', +) { + mockFileContents.clear(); + const file = makeFile(); + setMockFile(file.inputPath, css); + const fn = await createCompileFn(options, mode); + const out = await fn(file, () => Promise.resolve(''), undefined, false); + return typeof out === 'string' ? out : new TextDecoder().decode(out); } describe('style-compiler', () => { test('compiles CSS with cssnano and preserves the banner', async () => { - mockFileContents.set('/in/style.css', { - metaData: {}, - content: 'body { background-color: #ffffff; }', - raw: 'body { background-color: #ffffff; }', - }); - const compile = await createCompileFn({ banner: '/* BANNER */' }); + setMockFile('/in/style.css', 'body { background-color: #ffffff; }'); + const compileFn = await createCompileFn({ banner: '/* BANNER */' }); - const result = await compile(createFile('/in/style.css'), () => ''); + const result = await compileFn(makeFile('/in/style.css'), () => ''); expect(result).toBe('/*! BANNER */body{background-color:#fff}'); }); test('loads the PostCSS config only once across files when cache is enabled', async () => { - mockFileContents.set('/in/a.css', { - metaData: {}, - content: 'a { color: #ff0000; }', - raw: 'a { color: #ff0000; }', - }); - mockFileContents.set('/in/b.css', { - metaData: {}, - content: 'b { color: #00ff00; }', - raw: 'b { color: #00ff00; }', - }); - const compile = await createCompileFn({ banner: '/* B */' }); - - await compile(createFile('/in/a.css'), () => ''); - await compile(createFile('/in/b.css'), () => ''); + setMockFile('/in/a.css', 'a { color: #ff0000; }'); + setMockFile('/in/b.css', 'b { color: #00ff00; }'); + const compileFn = await createCompileFn({ banner: '/* B */' }); + + await compileFn(makeFile('/in/a.css'), () => ''); + await compileFn(makeFile('/in/b.css'), () => ''); expect(mockedLoadConfig).toHaveBeenCalledTimes(1); }); test('reloads the PostCSS config per compilation when cache is disabled (serve mode)', async () => { - mockFileContents.set('/in/a.css', { - metaData: {}, - content: 'a { color: #ff0000; }', - raw: 'a { color: #ff0000; }', - }); - const compile = await createCompileFn({ banner: '/* B */' }); + setMockFile('/in/a.css', 'a { color: #ff0000; }'); + const compileFn = await createCompileFn({ banner: '/* B */' }); - await compile(createFile('/in/a.css'), () => '', undefined, false); - await compile(createFile('/in/a.css'), () => '', undefined, false); + await compileFn(makeFile('/in/a.css'), () => '', undefined, false); + await compileFn(makeFile('/in/a.css'), () => '', undefined, false); expect(mockedLoadConfig).toHaveBeenCalledTimes(2); }); test('retries processor creation after a failure instead of caching the rejection', async () => { - mockFileContents.set('/in/a.css', { - metaData: {}, - content: 'a { color: #ff0000; }', - raw: 'a { color: #ff0000; }', - }); + setMockFile('/in/a.css', 'a { color: #ff0000; }'); // First load yields an invalid plugin so postcss() throws during // processor creation; second load succeeds // @ts-ignore -- intentionally invalid plugin shape mockedLoadConfig.mockResolvedValueOnce({ plugins: ['not-a-plugin'] }); - const compile = await createCompileFn({ banner: '/* B */' }); + const compileFn = await createCompileFn({ banner: '/* B */' }); - await expect(compile(createFile('/in/a.css'), () => '')).rejects.toThrow(); + await expect(compileFn(makeFile('/in/a.css'), () => '')).rejects.toThrow(); // The rejected processor must not be cached: the next file succeeds - const result = await compile(createFile('/in/a.css'), () => ''); + const result = await compileFn(makeFile('/in/a.css'), () => ''); expect(result).toBe('/*! B */a{color:red}'); }); test('warns when the PostCSS config fails to load for a reason other than not existing', async () => { - mockFileContents.set('/in/a.css', { - metaData: {}, - content: 'a { color: #ff0000; }', - raw: 'a { color: #ff0000; }', - }); + setMockFile('/in/a.css', 'a { color: #ff0000; }'); mockedLoadConfig.mockRejectedValueOnce( new Error('Unexpected token in postcss.config.js'), ); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const compile = await createCompileFn({ banner: '/* B */' }); + const compileFn = await createCompileFn({ banner: '/* B */' }); // Falls back to the default plugins and still compiles - const result = await compile(createFile('/in/a.css'), () => ''); + const result = await compileFn(makeFile('/in/a.css'), () => ''); expect(result).toBe('/*! B */a{color:red}'); expect(warnSpy).toHaveBeenCalledWith( 'Failed to load PostCSS config: Unexpected token in postcss.config.js', @@ -155,85 +177,22 @@ describe('style-compiler', () => { }); test('does not warn when no PostCSS config exists', async () => { - mockFileContents.set('/in/a.css', { - metaData: {}, - content: 'a { color: #ff0000; }', - raw: 'a { color: #ff0000; }', - }); + setMockFile('/in/a.css', 'a { color: #ff0000; }'); mockedLoadConfig.mockRejectedValueOnce( new Error('No PostCSS Config found in: /project'), ); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const compile = await createCompileFn({ banner: '/* B */' }); + const compileFn = await createCompileFn({ banner: '/* B */' }); - const result = await compile(createFile('/in/a.css'), () => ''); + const result = await compileFn(makeFile('/in/a.css'), () => ''); expect(result).toBe('/*! B */a{color:red}'); expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); }); -/** - * Helper to set mock file content for sourcemap/banner tests - * @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', {}); diff --git a/packages/@kamado-io/style-compiler/src/style-compiler.ts b/packages/@kamado-io/style-compiler/src/style-compiler.ts index faa900d..75460ad 100644 --- a/packages/@kamado-io/style-compiler/src/style-compiler.ts +++ b/packages/@kamado-io/style-compiler/src/style-compiler.ts @@ -1,10 +1,15 @@ +import type { SourcemapOption } from 'kamado/compiler'; import type { MetaData } from 'kamado/files'; import path from 'node:path'; import cssnano from 'cssnano'; -import { createCustomCompiler } from 'kamado/compiler'; -import { createBanner, type CreateBanner } from 'kamado/compiler/banner'; +import { + createBannerResolver, + createCustomCompiler, + resolveSourcemapFlag, +} from 'kamado/compiler'; +import { type CreateBanner } from 'kamado/compiler/banner'; import { getContentFromFile } from 'kamado/files'; import postcss from 'postcss'; import postcssImport from 'postcss-import'; @@ -33,7 +38,7 @@ export interface StyleCompilerOptions { * * Default: `'onServer'`. */ - readonly sourcemap?: boolean | 'onServer'; + readonly sourcemap?: SourcemapOption; } /** @@ -82,11 +87,7 @@ export function createStyleCompiler() { defaultFiles: '**/*.css', defaultOutputExtension: '.css', 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; + const enableSourcemap = resolveSourcemapFlag(options?.sourcemap, context.mode); // Configure plugins once per context — plugin instances and the // loaded PostCSS config are file-independent @@ -163,24 +164,17 @@ export function createStyleCompiler() { return postcss(plugins); }; - const resolveBanner = () => { - 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. - return normalizeBanner(rawBanner); - }; + // 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 resolveBanner = createBannerResolver(options?.banner, normalizeBanner); - // Lazily build the processor and banner once and reuse them across - // files. A failed processor build is NOT cached, so the next file - // retries instead of replaying the same rejection forever. + // Lazily build the processor once and reuse it across files. A failed + // processor build is NOT cached, so the next file retries instead of + // replaying the same rejection forever. let processorPromise: Promise | undefined; - let cachedBanner: string | undefined; return async (file, _, __, cache) => { // cache === false (serve mode): rebuild per compilation so that @@ -195,8 +189,7 @@ export function createStyleCompiler() { const css = await getContentFromFile(file, cache); - const banner = - cache === false ? resolveBanner() : (cachedBanner ??= resolveBanner()); + const banner = resolveBanner(cache); const result = await processor.process(banner + '\n' + css.content, { from: file.inputPath, From 18690e3351914effee00b0fd6538215ec416f0ba Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 10 Jun 2026 22:26:37 +0900 Subject: [PATCH 15/20] refactor(script-compiler): use shared banner/sourcemap helpers Replace local banner caching and sourcemap-flag evaluation with createBannerResolver and resolveSourcemapFlag from kamado/compiler, and use the shared SourcemapOption type. --- .../script-compiler/src/script-compiler.ts | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/@kamado-io/script-compiler/src/script-compiler.ts b/packages/@kamado-io/script-compiler/src/script-compiler.ts index 80145a5..b72790f 100644 --- a/packages/@kamado-io/script-compiler/src/script-compiler.ts +++ b/packages/@kamado-io/script-compiler/src/script-compiler.ts @@ -1,10 +1,14 @@ +import type { SourcemapOption } from 'kamado/compiler'; import type { CreateBanner } from 'kamado/compiler/banner'; import type { MetaData } from 'kamado/files'; import path from 'node:path'; -import { createCustomCompiler } from 'kamado/compiler'; -import { createBanner } from 'kamado/compiler/banner'; +import { + createBannerResolver, + createCustomCompiler, + resolveSourcemapFlag, +} from 'kamado/compiler'; /** * Options for the script compiler @@ -32,7 +36,7 @@ export interface ScriptCompilerOptions { * * Default: `'onServer'`. */ - readonly sourcemap?: boolean | 'onServer'; + readonly sourcemap?: SourcemapOption; } /** @@ -64,23 +68,11 @@ export function createScriptCompiler() { */ const esbuild = await import('esbuild'); - const resolveBanner = () => - typeof options?.banner === 'string' - ? options.banner - : createBanner(options?.banner?.()); - // Banner is file-independent; build once per context. Serve mode - // (cache === false) recomputes so date-based banners stay fresh - let cachedBanner: string | undefined; - - // `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; + const resolveBanner = createBannerResolver(options?.banner); + const enableSourcemap = resolveSourcemapFlag(options?.sourcemap, context.mode); return async (file, _, __, cache) => { - const banner = - cache === false ? resolveBanner() : (cachedBanner ??= resolveBanner()); + const banner = resolveBanner(cache); // write: false keeps the bundle in memory — no tmp-file round-trip const result = await esbuild.build({ entryPoints: [file.inputPath], From 10e8e3d5030fef0512d939cf4515b2c9c0fd3f3f Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 10 Jun 2026 22:27:41 +0900 Subject: [PATCH 16/20] docs(pug-compiler): explain why the template cache uses LRU over FIFO --- packages/@kamado-io/pug-compiler/src/compile-pug.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/@kamado-io/pug-compiler/src/compile-pug.ts b/packages/@kamado-io/pug-compiler/src/compile-pug.ts index 41f315a..5df02e5 100644 --- a/packages/@kamado-io/pug-compiler/src/compile-pug.ts +++ b/packages/@kamado-io/pug-compiler/src/compile-pug.ts @@ -47,7 +47,11 @@ export function compilePug(options: PugCompilerOptions = {}): CompilerFunction { try { let compiler = cache ? templateCache.get(template) : undefined; if (compiler) { - // Refresh LRU position so frequently used templates (layouts) stay hot + // Refresh LRU position so frequently used templates (layouts) stay + // hot. Plain FIFO would NOT suffice here: once unique page templates + // exceed the cache limit, a shared layout inserted early would be + // evicted every TEMPLATE_CACHE_LIMIT pages and recompiled — exactly + // the cost this cache exists to avoid templateCache.delete(template); templateCache.set(template, compiler); } else { From 16dddf9b25c6144961957c6bca629db42ac2af31 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 10 Jun 2026 22:28:03 +0900 Subject: [PATCH 17/20] docs(kamado): document the cache-flag contract and dual serve-mode signals Clarify in ARCHITECTURE (en/ja) that build() leaves the cache flag undefined (caching enabled; compare with `cache === false`), and add a design note about the cache flag and context.mode being two parallel serve-mode signals to consolidate if a new mode is ever introduced. --- packages/kamado/ARCHITECTURE.ja.md | 4 +++- packages/kamado/ARCHITECTURE.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/kamado/ARCHITECTURE.ja.md b/packages/kamado/ARCHITECTURE.ja.md index 074936f..0bc4217 100644 --- a/packages/kamado/ARCHITECTURE.ja.md +++ b/packages/kamado/ARCHITECTURE.ja.md @@ -585,7 +585,9 @@ Kamado はファイルごとの処理の繰り返しを避けるため、複数 これらのキャッシュに関連して、page compiler の `compileHooks` と `transforms` のファクトリは、ファイルごとではなく **build/serve コンテキストごとに1回**(コンパイラのコンテキストセットアップ時)解決されます。フックファクトリと transform インスタンスは、ビルド内の全ページ・並行コンパイル間で共有されます。 -`cache` フラグは `CustomCompileFunction`(第4引数)から page compiler の transpile 層を通ってコンパイルフックの `compiler` 関数(第4引数)まで伝播するため、テンプレートエンジン側のパッケージは serve モードのキャッシュ無効セマンティクスを尊重できます。 +`cache` フラグは `CustomCompileFunction`(第4引数)から page compiler の transpile 層を通ってコンパイルフックの `compiler` 関数(第4引数)まで伝播するため、テンプレートエンジン側のパッケージは serve モードのキャッシュ無効セマンティクスを尊重できます。`build()` はフラグを `undefined` のまま渡し、これは「キャッシュ**有効**」を意味します。コンパイラ実装は `undefined` を `true` と同義に扱い、serve モードの判定は必ず `cache === false` で行ってください(`if (cache)` のような真偽値判定は不可)。 + +**設計ノート — serve モードの2つのシグナル。** 現在コンパイラは「serve モードかどうか」を2つの経路で受け取ります。呼び出しごとの `cache` フラグ(serve では `false`)と、コンテキストレベルの `context.mode`(例: `sourcemap: 'onServer'` オプションが `resolveSourcemapFlag` 経由で参照)です。現状この2つは常に一致しますが、評価タイミングが異なります(コンパイルごと vs コンテキストごと)。将来新しいモードや「serve でもキャッシュする」オプションを導入する場合は、両シグナルを手動で同期させ続けるのではなく、単一のコンパイルコンテキストオブジェクトに統合してください。 --- diff --git a/packages/kamado/ARCHITECTURE.md b/packages/kamado/ARCHITECTURE.md index 3d21599..3475254 100644 --- a/packages/kamado/ARCHITECTURE.md +++ b/packages/kamado/ARCHITECTURE.md @@ -585,7 +585,9 @@ Kamado uses several independent caches to avoid repeating per-file work. Contrib Related to these caches, `compileHooks` and `transforms` factories on the page compiler are resolved **once per build/serve context** (in the compiler's context setup), not per file. Hook factories and transform instances are therefore shared across all pages of a build and across concurrent compilations. -The `cache` flag travels from `CustomCompileFunction` (4th parameter) through the page compiler's transpile layer into the compile hooks' `compiler` function (4th parameter), so template-engine packages can honor serve mode's no-cache semantics. +The `cache` flag travels from `CustomCompileFunction` (4th parameter) through the page compiler's transpile layer into the compile hooks' `compiler` function (4th parameter), so template-engine packages can honor serve mode's no-cache semantics. `build()` leaves the flag `undefined`, which means caching is **enabled** — compilers must treat `undefined` the same as `true` and test for serve mode with `cache === false`, never with a truthiness check. + +**Design note — two serve-mode signals.** Compilers currently receive "is this serve mode?" through two channels: the per-call `cache` flag (`false` in serve) and the context-level `context.mode` (used by, e.g., the `sourcemap: 'onServer'` option via `resolveSourcemapFlag`). Today the two always agree, but they are evaluated at different times (per compilation vs. per context). If a new mode or a "cache in serve" option is ever introduced, consolidate both into a single compile-context object instead of keeping the signals in sync manually. --- From a11c328b67497dd4fcab5a107fd7659724d1ca7a Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 10 Jun 2026 22:38:54 +0900 Subject: [PATCH 18/20] test(kamado): cover the new public helpers and cache-clear wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clear-build-caches.spec: wiring test asserting every registered cache clear is invoked — removing a clear call from clearBuildCaches fails - build.spec: files added between consecutive builds are picked up, guarding the asset-group cache clearing end-to-end - create-banner-resolver.spec: once-per-context resolution, cache=false re-resolution, string pass-through, and transform application - resolve-sourcemap-flag.spec: table-driven contract for all option/mode combinations --- packages/kamado/src/builder/build.spec.ts | 34 +++++++++++++++++ .../compiler/create-banner-resolver.spec.ts | 38 +++++++++++++++++++ .../compiler/resolve-sourcemap-flag.spec.ts | 18 +++++++++ .../src/data/clear-build-caches.spec.ts | 31 +++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 packages/kamado/src/compiler/create-banner-resolver.spec.ts create mode 100644 packages/kamado/src/compiler/resolve-sourcemap-flag.spec.ts create mode 100644 packages/kamado/src/data/clear-build-caches.spec.ts diff --git a/packages/kamado/src/builder/build.spec.ts b/packages/kamado/src/builder/build.spec.ts index 0668d8a..84521d8 100644 --- a/packages/kamado/src/builder/build.spec.ts +++ b/packages/kamado/src/builder/build.spec.ts @@ -284,6 +284,40 @@ describe('consecutive builds in the same process', async () => { vol.reset(); }); + test('reflects files added between builds on the next build', async () => { + vol.fromJSON({ + '/mock/input/dir/index.html': '

A

', + }); + const buildConfig = { + ...config, + dir: { + ...config.dir, + input: '/mock/input/dir', + output: '/mock/output/dir', + }, + compilers: () => [ + { + files: '**/*.html', + outputExtension: '.html', + compiler: () => () => 'page content', + }, + ], + verbose: true, + }; + + await build(buildConfig); + expect(vol.toJSON()['/mock/output/dir/added.html']).toBeUndefined(); + + // Add a new source file, then build again in the same process. The + // asset-group memoization must not survive into the second build — + // removing clearAssetGroupCache() from clearBuildCaches() fails here + vol.fromJSON({ + '/mock/input/dir/added.html': '

B

', + }); + await build(buildConfig); + expect(vol.toJSON()['/mock/output/dir/added.html']).toBe('page content'); + }, 10_000); + test('reflects source file edits on the next build', async () => { vol.fromJSON({ '/mock/input/dir/index.html': '

ORIGINAL

', diff --git a/packages/kamado/src/compiler/create-banner-resolver.spec.ts b/packages/kamado/src/compiler/create-banner-resolver.spec.ts new file mode 100644 index 0000000..4e4fe32 --- /dev/null +++ b/packages/kamado/src/compiler/create-banner-resolver.spec.ts @@ -0,0 +1,38 @@ +import { describe, test, expect, vi } from 'vitest'; + +import { createBannerResolver } from './create-banner-resolver.js'; + +describe('createBannerResolver', () => { + test('resolves the banner factory only once when cache is enabled', () => { + const factory = vi.fn(() => () => 'BANNER'); + const resolve = createBannerResolver(factory); + + expect(resolve()).toBe('/*\nBANNER\n*/'); + expect(resolve(true)).toBe('/*\nBANNER\n*/'); + expect(resolve()).toBe('/*\nBANNER\n*/'); + + expect(factory).toHaveBeenCalledTimes(1); + }); + + test('re-resolves the banner on every call when cache is false (serve mode)', () => { + const factory = vi.fn(() => () => 'BANNER'); + const resolve = createBannerResolver(factory); + + resolve(false); + resolve(false); + + expect(factory).toHaveBeenCalledTimes(2); + }); + + test('passes a string banner through unchanged', () => { + const resolve = createBannerResolver('plain'); + + expect(resolve()).toBe('plain'); + }); + + test('applies the transform to the resolved banner', () => { + const resolve = createBannerResolver('x', (banner) => `[${banner}]`); + + expect(resolve()).toBe('[x]'); + }); +}); diff --git a/packages/kamado/src/compiler/resolve-sourcemap-flag.spec.ts b/packages/kamado/src/compiler/resolve-sourcemap-flag.spec.ts new file mode 100644 index 0000000..fe4c6e9 --- /dev/null +++ b/packages/kamado/src/compiler/resolve-sourcemap-flag.spec.ts @@ -0,0 +1,18 @@ +import { describe, test, expect } from 'vitest'; + +import { resolveSourcemapFlag } from './resolve-sourcemap-flag.js'; + +describe('resolveSourcemapFlag', () => { + test.each([ + [undefined, 'build', false], + [undefined, 'serve', true], + [true, 'build', true], + [true, 'serve', true], + [false, 'build', false], + [false, 'serve', false], + ['onServer', 'build', false], + ['onServer', 'serve', true], + ] as const)('sourcemap=%j, mode=%s → %j', (sourcemap, mode, expected) => { + expect(resolveSourcemapFlag(sourcemap, mode)).toBe(expected); + }); +}); diff --git a/packages/kamado/src/data/clear-build-caches.spec.ts b/packages/kamado/src/data/clear-build-caches.spec.ts new file mode 100644 index 0000000..46c0fd2 --- /dev/null +++ b/packages/kamado/src/data/clear-build-caches.spec.ts @@ -0,0 +1,31 @@ +import { describe, test, expect, vi } from 'vitest'; + +import { clearFileContentCache } from '../files/file-content.js'; + +import { clearBuildCaches } from './clear-build-caches.js'; +import { clearAssetGroupCache } from './get-asset-group.js'; +import { clearGlobalDataCache } from './get-global-data.js'; + +vi.mock('../files/file-content.js', () => ({ + clearFileContentCache: vi.fn(), +})); + +vi.mock('./get-asset-group.js', () => ({ + clearAssetGroupCache: vi.fn(), +})); + +vi.mock('./get-global-data.js', () => ({ + clearGlobalDataCache: vi.fn(), +})); + +describe('clearBuildCaches', () => { + // Wiring test: this function exists so that no module-level cache is + // forgotten at build start. Removing any clear call must fail here. + test('clears every registered module-level cache exactly once', () => { + clearBuildCaches(); + + expect(clearAssetGroupCache).toHaveBeenCalledTimes(1); + expect(clearFileContentCache).toHaveBeenCalledTimes(1); + expect(clearGlobalDataCache).toHaveBeenCalledTimes(1); + }); +}); From d06ab0db34c1241ad685f5bb101262184af50f55 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 10 Jun 2026 22:39:20 +0900 Subject: [PATCH 19/20] test(style-compiler): assert string output instead of branching in the spec helper --- packages/@kamado-io/style-compiler/src/style-compiler.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts b/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts index ba523a0..fd370bd 100644 --- a/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts +++ b/packages/@kamado-io/style-compiler/src/style-compiler.spec.ts @@ -110,7 +110,9 @@ async function compile( setMockFile(file.inputPath, css); const fn = await createCompileFn(options, mode); const out = await fn(file, () => Promise.resolve(''), undefined, false); - return typeof out === 'string' ? out : new TextDecoder().decode(out); + // The style compiler always returns a string; assert instead of branching + expect(typeof out).toBe('string'); + return out as string; } describe('style-compiler', () => { From 3481f42445b888acb3697607b98f47f417cf7de1 Mon Sep 17 00:00:00 2001 From: Yusuke Hirao Date: Wed, 10 Jun 2026 22:39:47 +0900 Subject: [PATCH 20/20] test(script-compiler): assert string output instead of branching in the spec helper --- .../@kamado-io/script-compiler/src/script-compiler.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@kamado-io/script-compiler/src/script-compiler.spec.ts b/packages/@kamado-io/script-compiler/src/script-compiler.spec.ts index ef9032c..4046372 100644 --- a/packages/@kamado-io/script-compiler/src/script-compiler.spec.ts +++ b/packages/@kamado-io/script-compiler/src/script-compiler.spec.ts @@ -164,7 +164,9 @@ async function compile( 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); + // The script compiler always returns a string; assert instead of branching + expect(typeof out).toBe('string'); + return out as string; } const SOURCE_MAP_RE = /\/\/#\s*sourceMappingURL=data:application\/json;base64,/;