From 481ffd59ae0b442ca6c5ce5071e1aa507ea9a63c Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Tue, 5 May 2026 23:19:55 +0900 Subject: [PATCH 01/21] test: cover render-mode docker option bypass --- tests/container.test.ts | 235 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/container.test.ts diff --git a/tests/container.test.ts b/tests/container.test.ts new file mode 100644 index 00000000..d52aa960 --- /dev/null +++ b/tests/container.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PdfOutput, ResolvedTaskConfig } from '../src/config/resolve.js'; +import type { ParsedVivliostyleInlineConfig } from '../src/config/schema.js'; +import { + CONTAINER_LOCAL_HOSTNAME, + CONTAINER_ROOT_DIR, +} from '../src/constants.js'; + +const tinyexecMock = vi.hoisted(() => { + const x = vi.fn(async function* docker() { + // no output; tinyexec proc is async-iterable + }); + return { x }; +}); +vi.mock('tinyexec', () => tinyexecMock); + +const commandExistsMock = vi.hoisted(() => ({ + default: vi.fn().mockResolvedValue(true), +})); +vi.mock('command-exists', () => commandExistsMock); + +const utilExecMock = vi.hoisted(() => + vi.fn().mockResolvedValue({ stdout: '24.0.0', stderr: '' }), +); +vi.mock('../src/util.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, exec: utilExecMock }; +}); + +const { buildPDFWithContainer } = await import('../src/container.js'); + +const fabricateConfig = ( + overrides: Partial = {}, +): ResolvedTaskConfig => + ({ + rootUrl: 'http://localhost:13000', + base: '/vivliostyle/', + workspaceDir: '/workspace', + serverRootDir: '/workspace', + image: 'ghcr.io/vivliostyle/cli:test', + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'http://localhost:13000/vivliostyle/index.html', + webbookPath: undefined, + }, + ...overrides, + }) as unknown as ResolvedTaskConfig; + +const fabricateTarget = (overrides: Partial = {}): PdfOutput => ({ + format: 'pdf', + path: '/workspace/out/test.pdf', + renderMode: 'docker', + preflight: undefined, + preflightOption: [], + cmyk: false, + replaceImage: [], + ...overrides, +}); + +const lastDockerArgs = (): string[] => { + const calls = tinyexecMock.x.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const lastCall = calls[calls.length - 1] as unknown as [string, string[]]; + expect(lastCall[0]).toBe('docker'); + return lastCall[1]; +}; + +const envFromArgs = (args: string[]): Record => { + const env: Record = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '-e' && i + 1 < args.length) { + const [k, ...rest] = args[i + 1].split('='); + env[k] = rest.join('='); + } + } + return env; +}; + +const volumesFromArgs = ( + args: string[], +): { host: string; container: string }[] => { + const mounts: { host: string; container: string }[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '-v' && i + 1 < args.length) { + const [host, container] = args[i + 1].split(':'); + mounts.push({ host, container }); + } + } + return mounts; +}; + +beforeEach(() => { + tinyexecMock.x.mockClear(); + utilExecMock.mockClear(); + commandExistsMock.default.mockClear(); +}); + +describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { + it('rewrites the entry hostname to host.docker.internal so the in-container CLI can reach the host Vite', async () => { + const config = fabricateConfig({ + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'http://localhost:13000/vivliostyle/index.html', + webbookPath: undefined, + }, + }); + await buildPDFWithContainer({ + target: fabricateTarget(), + config, + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); + expect(bypassed.input).toEqual({ + format: 'webbook', + entry: `http://${CONTAINER_LOCAL_HOSTNAME}:13000/vivliostyle/index.html`, + }); + expect(bypassed.host).toBe(CONTAINER_LOCAL_HOSTNAME); + }); + + it('translates the output PDF path to the container path and mounts the output directory', async () => { + const config = fabricateConfig(); + await buildPDFWithContainer({ + target: fabricateTarget({ path: '/elsewhere/dist/out.pdf' }), + config, + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); + expect(bypassed.output).toEqual([ + expect.objectContaining({ + path: `${CONTAINER_ROOT_DIR}/elsewhere/dist/out.pdf`, + }), + ]); + expect(volumesFromArgs(args)).toEqual( + expect.arrayContaining([ + { + host: '/workspace', + container: `${CONTAINER_ROOT_DIR}/workspace`, + }, + { + host: '/elsewhere/dist', + container: `${CONTAINER_ROOT_DIR}/elsewhere/dist`, + }, + ]), + ); + }); + + it('passes through external (non-host-Vite) HTTPS entries without hostname rewriting', async () => { + const config = fabricateConfig({ + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'https://example.com/book/index.html', + webbookPath: undefined, + }, + }); + await buildPDFWithContainer({ + target: fabricateTarget(), + config, + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); + expect(bypassed.input.entry).toBe('https://example.com/book/index.html'); + }); +}); + +// These tests document the *current* behavior of the file:// + Docker +// combination. They are intentionally written to lock in observations rather +// than to assert that the path is correct — see notes.md for the design +// discussion. If/when the design changes (e.g. "render-mode docker requires +// HTTP serve" is made an explicit requirement), these tests should be updated +// or replaced with an explicit error-path assertion. +describe('buildPDFWithContainer: file:// source URL (disableServerStartup path)', () => { + it('passes file:// entry URLs through unchanged, without toContainerPath translation', async () => { + const config = fabricateConfig({ + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'file:///abs/manuscript/index.html', + webbookPath: '/abs/manuscript/index.html', + }, + }); + await buildPDFWithContainer({ + target: fabricateTarget(), + config, + inlineConfig: { + disableServerStartup: true, + } as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); + // The host-side path leaks into the container as-is. Inside the container + // the file is served at /data/abs/manuscript/index.html (toContainerPath + // mapping), so this URL will not resolve unless the in-container CLI + // happens to translate it — which it currently does not. + expect(bypassed.input).toEqual({ + format: 'webbook', + entry: 'file:///abs/manuscript/index.html', + }); + }); + + it('does not auto-mount the directory containing a file:// entry; only serverRootDir and the output dir are mounted', async () => { + const config = fabricateConfig({ + serverRootDir: '/workspace', + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'file:///elsewhere/manuscript/index.html', + webbookPath: '/elsewhere/manuscript/index.html', + }, + }); + await buildPDFWithContainer({ + target: fabricateTarget({ path: '/workspace/out/test.pdf' }), + config, + inlineConfig: { + disableServerStartup: true, + } as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const mounts = volumesFromArgs(args); + // /workspace/out is de-duplicated because /workspace already covers it + // (collectVolumeArgs drops paths whose parent is also a mount). + expect(mounts.map((m) => m.host)).toEqual(['/workspace']); + // The manuscript's directory is NOT mounted — even if the in-container + // CLI translated the file:// URL to its container path, the file would + // not exist there. + expect( + mounts.find((m) => m.host === '/elsewhere/manuscript'), + ).toBeUndefined(); + }); +}); From c01ea34cdc69c3df1397c3ae80aeeef149c50027 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Tue, 5 May 2026 23:32:18 +0900 Subject: [PATCH 02/21] test: cover render-on-docker example end-to-end --- tests/fixtures/render-on-docker/manuscript.md | 3 ++ tests/render-on-docker.test.ts | 52 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 tests/fixtures/render-on-docker/manuscript.md create mode 100644 tests/render-on-docker.test.ts diff --git a/tests/fixtures/render-on-docker/manuscript.md b/tests/fixtures/render-on-docker/manuscript.md new file mode 100644 index 00000000..d453f1f7 --- /dev/null +++ b/tests/fixtures/render-on-docker/manuscript.md @@ -0,0 +1,3 @@ +# Demonstration of Render mode + +Compare the difference in results between different rendering environments! diff --git a/tests/render-on-docker.test.ts b/tests/render-on-docker.test.ts new file mode 100644 index 00000000..de335787 --- /dev/null +++ b/tests/render-on-docker.test.ts @@ -0,0 +1,52 @@ +import { execFileSync } from 'node:child_process'; +import { fileTypeFromFile } from 'file-type'; +import { describe, expect, it } from 'vitest'; +import { resolveFixture, runCommand } from './command-util.js'; + +const probe = (cmd: string, args: string[]): boolean => { + try { + execFileSync(cmd, args, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +}; + +const dockerAvailable = probe('docker', ['version']); + +// Only one image is consulted: VIVLIOSTYLE_TEST_IMAGE if set, otherwise the +// latest published image. The image must already exist locally — we do not +// pull on demand, so CI without a pre-pulled image skips this suite. +const candidateImage = + process.env.VIVLIOSTYLE_TEST_IMAGE || 'ghcr.io/vivliostyle/cli:latest'; + +const image = + dockerAvailable && probe('docker', ['image', 'inspect', candidateImage]) + ? candidateImage + : undefined; + +describe.skipIf(!image)( + 'render-mode docker (mirrors examples/render-on-docker/)', + () => { + it('produces a valid PDF for a markdown manuscript via docker render', async () => { + await runCommand( + [ + 'build', + '--render-mode', + 'docker', + '--image', + image!, + '-o', + '.vs-pdf/out.pdf', + 'manuscript.md', + ], + { cwd: resolveFixture('render-on-docker'), port: 23100 }, + ); + + const type = await fileTypeFromFile( + resolveFixture('render-on-docker/.vs-pdf/out.pdf'), + ); + expect(type?.mime).toEqual('application/pdf'); + }, 240000); + }, +); From 18a5d62abfbef6ac2c603f641977b9aca4b901ea Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Sat, 9 May 2026 18:54:56 +0900 Subject: [PATCH 03/21] feat: support --render-mode docker on raw Linux dockerd and Win+WSL hybrid --- docs/api-javascript.md | 108 ++++++++++++++- docs/config.md | 13 +- src/config/resolve.ts | 41 +++++- src/config/schema.ts | 21 ++- src/container.ts | 43 ++++-- src/core/build.ts | 2 +- src/index.ts | 6 + src/wsl.ts | 167 ++++++++++++++++++++++++ tests/__snapshots__/config.test.ts.snap | 12 +- tests/container.test.ts | 133 ++++++++++++++++++- tests/render-on-docker.test.ts | 113 +++++++++++++++- tests/wsl.test.ts | 69 ++++++++++ 12 files changed, 694 insertions(+), 34 deletions(-) create mode 100644 src/wsl.ts create mode 100644 tests/wsl.test.ts diff --git a/docs/api-javascript.md b/docs/api-javascript.md index cc72f882..45fd38d5 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -9,8 +9,12 @@ - [`create`](#create) - [`createVitePlugin`](#createviteplugin) - [`defineConfig`](#defineconfig) +- [`getWslHostIp`](#getwslhostip) - [`preview`](#preview) - [`VFM`](#vfm) +- [`wslMirroredRenderMode`](#wslmirroredrendermode) +- [`wslNatRenderMode`](#wslnatrendermode) +- [`wslPathTransformer`](#wslpathtransformer) ### Interfaces @@ -196,7 +200,7 @@ build({ ###### renderMode? -`"local"` \| `"docker"` = `...` +`"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -428,7 +432,7 @@ Scaffold a new Vivliostyle project. ###### renderMode? -`"local"` \| `"docker"` = `...` +`"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -658,7 +662,7 @@ Scaffold a new Vivliostyle project. ###### renderMode? -`"local"` \| `"docker"` = `...` +`"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -752,6 +756,22 @@ Define the configuration for Vivliostyle CLI. *** +### getWslHostIp() + +> **getWslHostIp**(): `string` + +Returns the IP at which the Windows host is reachable from inside WSL +(the default gateway of WSL's eth0). Useful as `renderMode.hostGateway` +for the NAT networking mode (the WSL default). + +Windows host only. Caller is responsible for gating on `process.platform`. + +#### Returns + +`string` + +*** + ### preview() > **preview**(`options`): `Promise`\<`ViteDevServer`\> @@ -908,7 +928,7 @@ Open a browser for previewing the publication. ###### renderMode? -`"local"` \| `"docker"` = `...` +`"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -1008,6 +1028,84 @@ Options. Unified processor. +*** + +### wslMirroredRenderMode() + +> **wslMirroredRenderMode**(): `object` + +`renderMode` fields (without `mode`) for the WSL hybrid + mirrored +networking case. Spread into a `renderMode` literal: + +```ts +renderMode: { mode: 'docker', ...wslMirroredRenderMode() } +``` + +The values are static; this is a function only to mirror +`wslNatRenderMode`'s shape. + +#### Returns + +| Name | Type | Default value | +| ------ | ------ | ------ | +| `extraRunArgs` | readonly \[`"--network=host"`\] | - | +| `hostGateway` | `"127.0.0.1"` | - | +| `pathTransformer()` | (`hostPath`) => `string` | `wslPathTransformer` | + +*** + +### wslNatRenderMode() + +> **wslNatRenderMode**(): `object` + +`renderMode` fields (without `mode`) for the WSL hybrid + NAT networking +case. Spread into a `renderMode` literal: + +```ts +renderMode: { mode: 'docker', ...wslNatRenderMode() } +``` + +It's a function so `getWslHostIp()` runs at the call site; the WSL default +gateway can change across VM restarts. + +#### Returns + +| Name | Type | Default value | +| ------ | ------ | ------ | +| `hostGateway` | `string` | - | +| `pathTransformer()` | (`hostPath`) => `string` | `wslPathTransformer` | + +*** + +### wslPathTransformer() + +> **wslPathTransformer**(`hostPath`): `string` + +Translate a Windows drive-letter absolute path to its WSL drvfs automount +counterpart (`/mnt//...`). Useful as `renderMode.pathTransformer` +when the docker daemon is upstream moby running inside a WSL distro. + +Operating contract: + The input is expected to be an absolute path produced by `upath.resolve()` + (the canonical resolver used in `src/config/resolve.ts` for `workspaceDir`, + `target.path`, etc.). Under that contract the input is one of: + - POSIX absolute (`/foo/bar`) on Linux/macOS hosts: passed through + - Drive-letter + forward slash (`C:/Users/foo`) on Windows hosts: translated + + Drive-letter + backslash (`C:\Users\foo`) is handled defensively for paths + that bypass `upath`. Anything else (relative paths, UNC `\\server\share\...`, + empty input) violates the contract and throws. + +#### Parameters + +##### hostPath + +`string` + +#### Returns + +`string` + ## Interfaces ### StringifyMarkdownOptions @@ -1084,7 +1182,7 @@ Option for convert Markdown to a stringify (HTML). | `proxyUser?` | `string` | | `quick?` | `boolean` | | `readingProgression?` | `"ltr"` \| `"rtl"` | -| `renderMode?` | `"local"` \| `"docker"` | +| `renderMode?` | `"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} | | `sandbox?` | `boolean` | | `signal?` | `AbortSignal` | | `singleDoc?` | `boolean` | diff --git a/docs/config.md b/docs/config.md index 6ad91e23..9d8de3c0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -379,7 +379,7 @@ type ArticleEntryConfig = { - `format`: "pdf" | "epub" | "webpub" Specifies the output format. - - `renderMode`: "local" | "docker" + - `renderMode`: "local" | "docker" | {mode: "docker"; hostGateway?: string; pathTransformer?: "{custom(unknown)}"; extraRunArgs?: (string)[]} | {mode: "local"} If set to `docker`, Vivliostyle will render the PDF using a Docker container. (default: `local`) - ~~`preflight`~~ _Deprecated_ @@ -399,7 +399,16 @@ type ArticleEntryConfig = { type OutputConfig = { path: string; format?: "pdf" | "epub" | "webpub"; - renderMode?: "local" | "docker"; + renderMode?: + | "local" + | "docker" + | { + mode: "docker"; + hostGateway?: string; + pathTransformer?: "{custom(unknown)}"; + extraRunArgs?: string[]; + } + | { mode: "local" }; preflight?: | "press-ready" | "press-ready-local"; diff --git a/src/config/resolve.ts b/src/config/resolve.ts index c096cc7d..70773d38 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -19,6 +19,7 @@ import { CoverEntryConfig, EntryConfig, type InputFormat, + type RenderMode, StructuredDocument, StructuredDocumentSection, ThemeConfig, @@ -275,10 +276,40 @@ export interface ReplaceImageEntry { export type ReplaceImageConfig = ReplaceImageEntry[]; +export type ResolvedRenderMode = + | { + mode: 'docker'; + hostGateway: string | undefined; + pathTransformer: ((hostPath: string) => string) | undefined; + extraRunArgs: string[] | undefined; + } + | { mode: 'local' }; + +export function normalizeRenderMode( + input: RenderMode | undefined, +): ResolvedRenderMode { + if (input === undefined || input === 'local') return { mode: 'local' }; + if (input === 'docker') { + return { + mode: 'docker', + hostGateway: undefined, + pathTransformer: undefined, + extraRunArgs: undefined, + }; + } + if (input.mode === 'local') return { mode: 'local' }; + return { + mode: 'docker', + hostGateway: input.hostGateway ?? undefined, + pathTransformer: input.pathTransformer ?? undefined, + extraRunArgs: input.extraRunArgs ?? undefined, + }; +} + export interface PdfOutput { format: 'pdf'; path: string; - renderMode: 'local' | 'docker'; + renderMode: ResolvedRenderMode; preflight: 'press-ready' | 'press-ready-local' | undefined; preflightOption: string[]; cmyk: CmykConfig | false; @@ -779,7 +810,7 @@ export function resolveTaskConfig( const defaultPdfOptions: Omit = { format: 'pdf', - renderMode: options.renderMode ?? 'local', + renderMode: normalizeRenderMode(options.renderMode), preflight: resolveDefaultPreflight(), preflightOption: resolveDefaultPreflightOption(), cmyk: resolveCmykConfig(config.pdfPostprocess?.cmyk), @@ -834,6 +865,9 @@ export function resolveTaskConfig( preflightOption: resolvedPreflightOption, cmyk: resolvedCmyk, replaceImage: resolvedReplaceImage, + renderMode: normalizeRenderMode( + target.renderMode ?? options.renderMode, + ), }; } case 'epub': @@ -870,7 +904,8 @@ export function resolveTaskConfig( const port = config.server?.port ?? 13000; if ( outputs.some( - (target) => target.format === 'pdf' && target.renderMode === 'docker', + (target) => + target.format === 'pdf' && target.renderMode.mode === 'docker', ) && !isInContainer() ) { diff --git a/src/config/schema.ts b/src/config/schema.ts index d7101d2b..2eabad3a 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -231,7 +231,26 @@ export const OutputFormat = v.union([ ]); export type OutputFormat = v.InferInput; -export const RenderMode = v.union([v.literal('local'), v.literal('docker')]); +export const RenderModeDockerObject = v.object({ + mode: v.literal('docker'), + hostGateway: v.optional(ValidString), + pathTransformer: v.optional( + v.custom<(hostPath: string) => string>( + (value) => typeof value === 'function', + 'pathTransformer must be a function (hostPath: string) => string', + ), + ), + extraRunArgs: v.optional(v.array(ValidString)), +}); +export const RenderModeLocalObject = v.object({ + mode: v.literal('local'), +}); +export const RenderMode = v.union([ + v.literal('local'), + v.literal('docker'), + RenderModeDockerObject, + RenderModeLocalObject, +]); export type RenderMode = v.InferInput; const RGBValueObjectSchema = v.pipe( diff --git a/src/container.ts b/src/container.ts index 384d41dc..ae25d567 100644 --- a/src/container.ts +++ b/src/container.ts @@ -29,7 +29,10 @@ export function toContainerPath(urlOrAbsPath: string): string { ); } -export function collectVolumeArgs(mountPoints: string[]): string[] { +export function collectVolumeArgs( + mountPoints: string[], + pathTransformer?: (hostPath: string) => string, +): string[] { return mountPoints .filter((p, i, array) => { if (i !== array.indexOf(p)) { @@ -46,7 +49,10 @@ export function collectVolumeArgs(mountPoints: string[]): string[] { } return true; }) - .map((p) => `${p}:${toContainerPath(p)}`); + .map( + (p) => + `${pathTransformer ? pathTransformer(p) : p}:${toContainerPath(p)}`, + ); } export async function runContainer({ @@ -56,6 +62,8 @@ export async function runContainer({ entrypoint, env, workdir, + hostGateway, + extraRunArgs, }: { image: string; userVolumeArgs: string[]; @@ -63,6 +71,8 @@ export async function runContainer({ entrypoint?: string; env?: [string, string][]; workdir?: string; + hostGateway?: string; + extraRunArgs?: string[]; }) { const { default: commandExists } = await importNodeModule('command-exists'); if (!(await commandExists('docker'))) { @@ -86,6 +96,14 @@ export async function runContainer({ 'run', ...(Logger.isInteractive ? ['-it'] : []), '--rm', + // Docker Desktop (and Colima) auto-provide host.docker.internal; raw + // Linux dockerd, including the dockerd that runs inside WSL, does not. + // `host-gateway` resolves to the daemon's docker0 bridge by default; the + // hostGateway override lets users point it at a different IP (e.g. the + // WSL eth0 gateway when the daemon lives in WSL but Vite runs on Windows). + '--add-host', + `${CONTAINER_LOCAL_HOSTNAME}:${hostGateway ?? 'host-gateway'}`, + ...(extraRunArgs ?? []), ...(entrypoint ? ['--entrypoint', entrypoint] : []), ...(env ? env.flatMap(([k, v]) => ['-e', `${k}=${v}`]) : []), ...(process.env.DEBUG @@ -145,20 +163,29 @@ export async function buildPDFWithContainer({ host: CONTAINER_LOCAL_HOSTNAME, } satisfies ParsedVivliostyleInlineConfig; + // buildPDFWithContainer is only invoked for docker-mode targets (see build.ts) + const renderMode = + target.renderMode.mode === 'docker' ? target.renderMode : undefined; + await runContainer({ image: config.image, - userVolumeArgs: collectVolumeArgs([ - ...(typeof config.serverRootDir === 'string' - ? [config.serverRootDir] - : []), - upath.dirname(target.path), - ]), + userVolumeArgs: collectVolumeArgs( + [ + ...(typeof config.serverRootDir === 'string' + ? [config.serverRootDir] + : []), + upath.dirname(target.path), + ], + renderMode?.pathTransformer, + ), env: [['VS_CLI_BUILD_PDF_OPTIONS', JSON.stringify(bypassedOption)]], commandArgs: ['build'], workdir: typeof config.serverRootDir === 'string' ? toContainerPath(config.serverRootDir) : undefined, + hostGateway: renderMode?.hostGateway, + extraRunArgs: renderMode?.extraRunArgs, }); return target.path; diff --git a/src/core/build.ts b/src/core/build.ts index d19818d6..86c07bd1 100644 --- a/src/core/build.ts +++ b/src/core/build.ts @@ -109,7 +109,7 @@ export async function build( let output: string | null = null; const { format } = target; if (format === 'pdf') { - if (!containerForkMode && target.renderMode === 'docker') { + if (!containerForkMode && target.renderMode.mode === 'docker') { output = await buildPDFWithContainer({ target, config, diff --git a/src/index.ts b/src/index.ts index 6bd85b6c..e766cf65 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,12 @@ export type { } from './config/schema.js'; export type { TemplateVariable } from './create-template.js'; export { createVitePlugin } from './vite-adapter.js'; +export { + getWslHostIp, + wslMirroredRenderMode, + wslNatRenderMode, + wslPathTransformer, +} from './wsl.js'; /** @hidden */ export type PublicationManifest = _PublicationManifest; diff --git a/src/wsl.ts b/src/wsl.ts new file mode 100644 index 00000000..f4b01751 --- /dev/null +++ b/src/wsl.ts @@ -0,0 +1,167 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import upath from 'upath'; + +/** + * Helpers for the case where vivliostyle-cli runs on Windows-native Node.js + * but the docker daemon is upstream moby inside a WSL distro (for example, + * apt-installed Docker Engine on Ubuntu, bridged to Windows by WinSocat). + * + * Plain `renderMode: 'docker'` does not work in that setup for two reasons: + * + * 1. `host.docker.internal:host-gateway` resolves to the WSL VM's docker0, + * not the Windows host where Vite is running. Override + * `renderMode.hostGateway` with an IP that reaches the Windows host. + * 2. The bind-mount sources are absolute Windows paths (`C:\Users\...`), + * which upstream dockerd cannot parse. Translate them to drvfs + * automount form (`/mnt/c/Users/...`) via `renderMode.pathTransformer`. + * + * `wslNatRenderMode()` and `wslMirroredRenderMode()` return the settings + * that work for each WSL networking mode. Spread the result into a + * `renderMode` literal at the use site: + * + * ```ts + * import { wslNatRenderMode } from '@vivliostyle/cli'; + * export default { + * output: [{ + * path: 'output.pdf', + * renderMode: process.platform === 'win32' + * ? { mode: 'docker', ...wslNatRenderMode() } + * : 'docker', + * }], + * }; + * ``` + * + * You also need to open the host-side firewall once. The commands below + * persist across reboots (`New-NetFirewallRule` writes to `PersistentStore` + * by default; `Set-NetFirewallHyperVVMSetting` updates both + * `PersistentStore` and `ActiveStore`). Pass `-PolicyStore ActiveStore` + * to limit the change to the current session. + * + * --- NAT mode (`networkingMode=nat`, the WSL default) --- + * Use `wslNatRenderMode()`. The WSL VM has its own subnet; its eth0 default + * gateway points at the Windows host, and `getWslHostIp()` resolves that IP + * at config-evaluation time. + * + * Allow inbound on the `vEthernet (WSL)` interface from an elevated + * PowerShell: + * + * ```powershell + * New-NetFirewallRule -DisplayName "Vivliostyle dev server (WSL)" ` + * -Direction Inbound ` + * -InterfaceAlias "vEthernet (WSL (Hyper-V firewall))" ` + * -Protocol TCP -Action Allow + * ``` + * + * The exact interface alias varies by Windows build; check it with + * `Get-NetIPAddress -AddressFamily IPv4`. + * + * --- Mirrored mode (`networkingMode=mirrored`) --- + * Use `wslMirroredRenderMode()`. The WSL VM shares Windows network + * interfaces, so the WSL TCP stack intercepts the shared IPs and a + * default-bridged container has no IP it can use to reach Windows. The + * preset works around this by putting the container in the WSL VM netns + * via `--network=host` and reaching Windows over the localhost forwarder. + * + * Mirrored Windows-to-WSL traffic goes through the Hyper-V firewall, which + * blocks inbound by default. From an elevated PowerShell: + * + * ```powershell + * Set-NetFirewallHyperVVMSetting ` + * -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' ` + * -DefaultInboundAction Allow + * ``` + * + * `{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}` is the WSL VM identifier + * (`c_wslFirewallVmCreatorId` in microsoft/WSL). + */ + +/** + * Translate a Windows drive-letter absolute path to its WSL drvfs automount + * counterpart (`/mnt//...`). Useful as `renderMode.pathTransformer` + * when the docker daemon is upstream moby running inside a WSL distro. + * + * Operating contract: + * The input is expected to be an absolute path produced by `upath.resolve()` + * (the canonical resolver used in `src/config/resolve.ts` for `workspaceDir`, + * `target.path`, etc.). Under that contract the input is one of: + * - POSIX absolute (`/foo/bar`) on Linux/macOS hosts: passed through + * - Drive-letter + forward slash (`C:/Users/foo`) on Windows hosts: translated + * + * Drive-letter + backslash (`C:\Users\foo`) is handled defensively for paths + * that bypass `upath`. Anything else (relative paths, UNC `\\server\share\...`, + * empty input) violates the contract and throws. + */ +export function wslPathTransformer(hostPath: string): string { + const { root } = path.win32.parse(hostPath); + + if (root === '/') return hostPath; + + if (root.length === 3 && root[1] === ':') { + return `/mnt/${root[0].toLowerCase()}/${upath.toUnix(hostPath.slice(root.length))}`; + } + + throw new Error( + `wslPathTransformer: expected absolute path from upath.resolve(), ` + + `got ${JSON.stringify(hostPath)} (parsed root: ${JSON.stringify(root)}). ` + + `UNC, relative, and non-standard paths are out of scope; supply a custom ` + + `\`renderMode.pathTransformer\` to handle them.`, + ); +} + +/** + * Returns the IP at which the Windows host is reachable from inside WSL + * (the default gateway of WSL's eth0). Useful as `renderMode.hostGateway` + * for the NAT networking mode (the WSL default). + * + * Windows host only. Caller is responsible for gating on `process.platform`. + */ +export function getWslHostIp(): string { + const out = execFileSync('wsl', ['--', 'ip', 'route', 'show', 'default'], { + encoding: 'utf8', + }); + const m = /default via (\S+)/.exec(out); + if (!m) { + throw new Error( + `getWslHostIp: failed to parse WSL default gateway from: ${out.trim()}`, + ); + } + return m[1]; +} + +/** + * `renderMode` fields (without `mode`) for the WSL hybrid + NAT networking + * case. Spread into a `renderMode` literal: + * + * ```ts + * renderMode: { mode: 'docker', ...wslNatRenderMode() } + * ``` + * + * It's a function so `getWslHostIp()` runs at the call site; the WSL default + * gateway can change across VM restarts. + */ +export function wslNatRenderMode() { + return { + hostGateway: getWslHostIp(), + pathTransformer: wslPathTransformer, + }; +} + +/** + * `renderMode` fields (without `mode`) for the WSL hybrid + mirrored + * networking case. Spread into a `renderMode` literal: + * + * ```ts + * renderMode: { mode: 'docker', ...wslMirroredRenderMode() } + * ``` + * + * The values are static; this is a function only to mirror + * `wslNatRenderMode`'s shape. + */ +export function wslMirroredRenderMode() { + return { + hostGateway: '127.0.0.1' as const, + pathTransformer: wslPathTransformer, + extraRunArgs: ['--network=host'] as const, + }; +} diff --git a/tests/__snapshots__/config.test.ts.snap b/tests/__snapshots__/config.test.ts.snap index 41da2217..e33c9ae4 100644 --- a/tests/__snapshots__/config.test.ts.snap +++ b/tests/__snapshots__/config.test.ts.snap @@ -129,7 +129,9 @@ Object { "path": "__WORKSPACE__/tests/fixtures/config/yuno.pdf", "preflight": "press-ready", "preflightOption": Array [], - "renderMode": "local", + "renderMode": Object { + "mode": "local", + }, "replaceImage": Array [], }, Object { @@ -338,7 +340,9 @@ Object { "path": "__WORKSPACE__/tests/fixtures/config/output1.pdf", "preflight": "press-ready", "preflightOption": Array [], - "renderMode": "local", + "renderMode": Object { + "mode": "local", + }, "replaceImage": Array [], }, Object { @@ -347,7 +351,9 @@ Object { "path": "__WORKSPACE__/tests/fixtures/config/output2.pdf", "preflight": "press-ready", "preflightOption": Array [], - "renderMode": "local", + "renderMode": Object { + "mode": "local", + }, "replaceImage": Array [], }, ], diff --git a/tests/container.test.ts b/tests/container.test.ts index d52aa960..210642d2 100644 --- a/tests/container.test.ts +++ b/tests/container.test.ts @@ -49,7 +49,12 @@ const fabricateConfig = ( const fabricateTarget = (overrides: Partial = {}): PdfOutput => ({ format: 'pdf', path: '/workspace/out/test.pdf', - renderMode: 'docker', + renderMode: { + mode: 'docker', + hostGateway: undefined, + pathTransformer: undefined, + extraRunArgs: undefined, + }, preflight: undefined, preflightOption: [], cmyk: false, @@ -96,6 +101,126 @@ beforeEach(() => { }); describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { + it('passes --add-host=host.docker.internal:host-gateway so the alias resolves on raw Linux dockerd (incl. WSL)', async () => { + await buildPDFWithContainer({ + target: fabricateTarget(), + config: fabricateConfig(), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const idx = args.indexOf('--add-host'); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe(`${CONTAINER_LOCAL_HOSTNAME}:host-gateway`); + }); + + it('overrides --add-host gateway when renderMode.hostGateway is set', async () => { + await buildPDFWithContainer({ + target: fabricateTarget({ + renderMode: { + mode: 'docker', + hostGateway: '172.21.112.1', + pathTransformer: undefined, + extraRunArgs: undefined, + }, + }), + config: fabricateConfig(), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const idx = args.indexOf('--add-host'); + expect(args[idx + 1]).toBe(`${CONTAINER_LOCAL_HOSTNAME}:172.21.112.1`); + }); + + it('passes renderMode.extraRunArgs through verbatim before the image', async () => { + await buildPDFWithContainer({ + target: fabricateTarget({ + renderMode: { + mode: 'docker', + hostGateway: '127.0.0.1', + pathTransformer: undefined, + extraRunArgs: ['--network=host', '--gpus=all'], + }, + }), + config: fabricateConfig(), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const networkIdx = args.indexOf('--network=host'); + const gpusIdx = args.indexOf('--gpus=all'); + const imageIdx = args.indexOf('ghcr.io/vivliostyle/cli:test'); + expect(networkIdx).toBeGreaterThan(-1); + expect(gpusIdx).toBe(networkIdx + 1); + expect(imageIdx).toBeGreaterThan(gpusIdx); + }); + + it('applies renderMode.pathTransformer to the host side of -v bind mounts only', async () => { + await buildPDFWithContainer({ + target: fabricateTarget({ + path: '/workspace/out/test.pdf', + renderMode: { + mode: 'docker', + hostGateway: undefined, + pathTransformer: (p) => + p.startsWith('/workspace') ? `/mnt/c${p}` : p, + extraRunArgs: undefined, + }, + }), + config: fabricateConfig({ serverRootDir: '/workspace' }), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const mounts = volumesFromArgs(args); + expect(mounts.map((m) => m.host)).toEqual( + expect.arrayContaining(['/mnt/c/workspace']), + ); + // container side is unchanged + const workspaceMount = mounts.find((m) => m.host === '/mnt/c/workspace'); + expect(workspaceMount?.container).toBe(`${CONTAINER_ROOT_DIR}/workspace`); + }); + + // Smoke test for the WSL+Win hybrid wiring: a single renderMode object + // form should produce both --add-host pointing at the Windows host (not + // the docker0 gateway, which here is the WSL VM) and -v paths translated + // to /mnt//. + it('produces WSL+Win hybrid-ready args when renderMode.hostGateway and pathTransformer are both set', async () => { + const { wslPathTransformer } = await import('../src/wsl.js'); + await buildPDFWithContainer({ + target: fabricateTarget({ + path: 'C:/Users/me/work/out/test.pdf', + renderMode: { + mode: 'docker', + hostGateway: '172.21.112.1', + pathTransformer: wslPathTransformer, + extraRunArgs: undefined, + }, + }), + config: fabricateConfig({ serverRootDir: 'C:/Users/me/work' }), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + + // host.docker.internal must point at the Windows host IP, not host-gateway + const idx = args.indexOf('--add-host'); + expect(args[idx + 1]).toBe(`${CONTAINER_LOCAL_HOSTNAME}:172.21.112.1`); + + // -v host paths must be /mnt/c/... (parsable by upstream dockerd in WSL), + // while container paths stay in CONTAINER_ROOT_DIR namespace. + // The output dir (work/out) is de-duplicated under serverRootDir (work) + // by collectVolumeArgs, so a single mount covers both. + const mounts = volumesFromArgs(args); + expect(mounts).toEqual([ + { + host: '/mnt/c/Users/me/work', + container: `${CONTAINER_ROOT_DIR}/Users/me/work`, + }, + ]); + }); + it('rewrites the entry hostname to host.docker.internal so the in-container CLI can reach the host Vite', async () => { const config = fabricateConfig({ viewerInput: { @@ -170,7 +295,7 @@ describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { // These tests document the *current* behavior of the file:// + Docker // combination. They are intentionally written to lock in observations rather -// than to assert that the path is correct — see notes.md for the design +// than to assert that the path is correct; see notes.md for the design // discussion. If/when the design changes (e.g. "render-mode docker requires // HTTP serve" is made an explicit requirement), these tests should be updated // or replaced with an explicit error-path assertion. @@ -196,7 +321,7 @@ describe('buildPDFWithContainer: file:// source URL (disableServerStartup path)' // The host-side path leaks into the container as-is. Inside the container // the file is served at /data/abs/manuscript/index.html (toContainerPath // mapping), so this URL will not resolve unless the in-container CLI - // happens to translate it — which it currently does not. + // happens to translate it, which it currently does not. expect(bypassed.input).toEqual({ format: 'webbook', entry: 'file:///abs/manuscript/index.html', @@ -225,7 +350,7 @@ describe('buildPDFWithContainer: file:// source URL (disableServerStartup path)' // /workspace/out is de-duplicated because /workspace already covers it // (collectVolumeArgs drops paths whose parent is also a mount). expect(mounts.map((m) => m.host)).toEqual(['/workspace']); - // The manuscript's directory is NOT mounted — even if the in-container + // The manuscript's directory is NOT mounted; even if the in-container // CLI translated the file:// URL to its container path, the file would // not exist there. expect( diff --git a/tests/render-on-docker.test.ts b/tests/render-on-docker.test.ts index de335787..9afc272a 100644 --- a/tests/render-on-docker.test.ts +++ b/tests/render-on-docker.test.ts @@ -15,7 +15,7 @@ const probe = (cmd: string, args: string[]): boolean => { const dockerAvailable = probe('docker', ['version']); // Only one image is consulted: VIVLIOSTYLE_TEST_IMAGE if set, otherwise the -// latest published image. The image must already exist locally — we do not +// latest published image. The image must already exist locally; we do not // pull on demand, so CI without a pre-pulled image skips this suite. const candidateImage = process.env.VIVLIOSTYLE_TEST_IMAGE || 'ghcr.io/vivliostyle/cli:latest'; @@ -25,26 +25,125 @@ const image = ? candidateImage : undefined; +// On the WSL+Win hybrid (CLI on Windows, dockerd in WSL), the default docker +// invocation can't work: Windows paths are unparseable by upstream dockerd, +// and host.docker.internal lands on the WSL VM rather than the Windows host. +// The basic test is expected to fail there, so we run it under `it.fails`. +// The two hybrid tests below cover the working configurations. +const enableHybridNat = process.env.VIVLIOSTYLE_TEST_WSL_HYBRID_NAT === '1'; +const enableHybridMirrored = + process.env.VIVLIOSTYLE_TEST_WSL_HYBRID_MIRRORED === '1'; +const enableAnyHybrid = enableHybridNat || enableHybridMirrored; +const itBasic = enableAnyHybrid ? it.fails : it; + describe.skipIf(!image)( 'render-mode docker (mirrors examples/render-on-docker/)', () => { - it('produces a valid PDF for a markdown manuscript via docker render', async () => { + itBasic( + 'produces a valid PDF for a markdown manuscript via docker render', + async () => { + await runCommand( + [ + 'build', + '--render-mode', + 'docker', + '--image', + image!, + '-o', + '.vs-pdf/out.pdf', + 'manuscript.md', + ], + { cwd: resolveFixture('render-on-docker'), port: 23100 }, + ); + + const type = await fileTypeFromFile( + resolveFixture('render-on-docker/.vs-pdf/out.pdf'), + ); + expect(type?.mime).toEqual('application/pdf'); + }, + 240000, + ); + }, +); + +// Win + WSL hybrid, NAT mode (the WSL default). The container reaches the +// Windows host through the WSL eth0 default gateway. Opt-in via env var; the +// host also needs a Defender Firewall rule allowing inbound on the +// vEthernet (WSL) interface. +describe.skipIf(!image || !enableHybridNat)( + 'render-mode docker (Win + WSL hybrid, networkingMode=nat)', + () => { + it('renders via { mode: "docker", ...wslNatRenderMode() }', async () => { + const { wslNatRenderMode } = await import('../src/wsl.js'); + await runCommand( + [ + 'build', + '--image', + image!, + '-o', + '.vs-pdf/out-wsl-nat.pdf', + 'manuscript.md', + ], + { + cwd: resolveFixture('render-on-docker'), + port: 23101, + config: { + entry: 'manuscript.md', + output: [ + { + path: '.vs-pdf/out-wsl-nat.pdf', + renderMode: { mode: 'docker', ...wslNatRenderMode() }, + }, + ], + }, + }, + ); + + const type = await fileTypeFromFile( + resolveFixture('render-on-docker/.vs-pdf/out-wsl-nat.pdf'), + ); + expect(type?.mime).toEqual('application/pdf'); + }, 240000); + }, +); + +// Win + WSL hybrid, mirrored mode. The WSL VM shares Windows network +// interfaces, so the WSL TCP stack intercepts the shared IPs and a +// default-bridged container has no IP it can use to reach Windows. The fix +// is to put the container in the WSL VM netns (`--network=host`) and reach +// Windows over the localhost forwarder. Opt-in via env var; the host also +// needs the Hyper-V firewall set to allow inbound for the WSL VM. +describe.skipIf(!image || !enableHybridMirrored)( + 'render-mode docker (Win + WSL hybrid, networkingMode=mirrored)', + () => { + it('renders via { mode: "docker", ...wslMirroredRenderMode() }', async () => { + const { wslMirroredRenderMode } = await import('../src/wsl.js'); await runCommand( [ 'build', - '--render-mode', - 'docker', '--image', image!, '-o', - '.vs-pdf/out.pdf', + '.vs-pdf/out-wsl-mirrored.pdf', 'manuscript.md', ], - { cwd: resolveFixture('render-on-docker'), port: 23100 }, + { + cwd: resolveFixture('render-on-docker'), + port: 23102, + config: { + entry: 'manuscript.md', + output: [ + { + path: '.vs-pdf/out-wsl-mirrored.pdf', + renderMode: { mode: 'docker', ...wslMirroredRenderMode() }, + }, + ], + }, + }, ); const type = await fileTypeFromFile( - resolveFixture('render-on-docker/.vs-pdf/out.pdf'), + resolveFixture('render-on-docker/.vs-pdf/out-wsl-mirrored.pdf'), ); expect(type?.mime).toEqual('application/pdf'); }, 240000); diff --git a/tests/wsl.test.ts b/tests/wsl.test.ts new file mode 100644 index 00000000..4774aff2 --- /dev/null +++ b/tests/wsl.test.ts @@ -0,0 +1,69 @@ +import { execFileSync } from 'node:child_process'; +import { describe, expect, it, vi } from 'vitest'; +import { getWslHostIp, wslPathTransformer } from '../src/wsl.js'; + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })); + +describe('wslNatRenderMode', () => { + it('returns hostGateway from getWslHostIp() and pathTransformer = wslPathTransformer', async () => { + vi.mocked(execFileSync).mockReturnValueOnce( + 'default via 172.21.112.1 dev eth0 proto kernel\n', + ); + const { wslNatRenderMode, wslPathTransformer } = await import( + '../src/wsl.js' + ); + expect(wslNatRenderMode()).toEqual({ + hostGateway: '172.21.112.1', + pathTransformer: wslPathTransformer, + }); + }); +}); + +describe('wslMirroredRenderMode', () => { + it('returns hostGateway = 127.0.0.1, wslPathTransformer, and extraRunArgs = [--network=host]', async () => { + const { wslMirroredRenderMode, wslPathTransformer } = await import( + '../src/wsl.js' + ); + expect(wslMirroredRenderMode()).toEqual({ + hostGateway: '127.0.0.1', + pathTransformer: wslPathTransformer, + extraRunArgs: ['--network=host'], + }); + }); +}); + +describe('wslPathTransformer', () => { + it.each([ + ['C:\\Users\\foo', '/mnt/c/Users/foo'], + ['C:/Users/foo', '/mnt/c/Users/foo'], + ['d:\\bar\\baz', '/mnt/d/bar/baz'], + ['/posix/abs', '/posix/abs'], + ])('translates %s → %s', (input, expected) => { + expect(wslPathTransformer(input)).toBe(expected); + }); + + it.each([ + ['relative/path'], + ['./dot-relative'], + [''], + ['\\\\server\\share\\foo'], + ])('throws on out-of-spec input %j', (input) => { + expect(() => wslPathTransformer(input)).toThrow( + /expected absolute path from upath\.resolve/, + ); + }); +}); + +describe('getWslHostIp', () => { + it('extracts the IP from `default via X.X.X.X dev eth0`', () => { + vi.mocked(execFileSync).mockReturnValueOnce( + 'default via 172.21.112.1 dev eth0 proto kernel\n', + ); + expect(getWslHostIp()).toBe('172.21.112.1'); + }); + + it('throws when output is unparseable', () => { + vi.mocked(execFileSync).mockReturnValueOnce('garbage\n'); + expect(() => getWslHostIp()).toThrow(/failed to parse/); + }); +}); From 11bbd9c60c20f21536220017c3105499fa00287c Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Sat, 9 May 2026 19:56:51 +0900 Subject: [PATCH 04/21] chore: add changeset --- .changeset/render-mode-docker-engine.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/render-mode-docker-engine.md diff --git a/.changeset/render-mode-docker-engine.md b/.changeset/render-mode-docker-engine.md new file mode 100644 index 00000000..64d618fa --- /dev/null +++ b/.changeset/render-mode-docker-engine.md @@ -0,0 +1,5 @@ +--- +'@vivliostyle/cli': minor +--- + +Support `renderMode: docker` without Docker Desktop From f9180996adb6e3c9dbc2ef50fbc3fef93b55029f Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Sat, 9 May 2026 20:06:44 +0900 Subject: [PATCH 05/21] docs: describe renderMode docker object fields --- docs/api-javascript.md | 10 ++++----- docs/config.md | 47 ++++++++++++++++++++++++++++++++++------ src/config/schema.ts | 49 +++++++++++++++++++++++++++++++++--------- 3 files changed, 84 insertions(+), 22 deletions(-) diff --git a/docs/api-javascript.md b/docs/api-javascript.md index 45fd38d5..3beb8c8d 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -200,7 +200,7 @@ build({ ###### renderMode? -`"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -432,7 +432,7 @@ Scaffold a new Vivliostyle project. ###### renderMode? -`"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -662,7 +662,7 @@ Scaffold a new Vivliostyle project. ###### renderMode? -`"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -928,7 +928,7 @@ Open a browser for previewing the publication. ###### renderMode? -`"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -1182,7 +1182,7 @@ Option for convert Markdown to a stringify (HTML). | `proxyUser?` | `string` | | `quick?` | `boolean` | | `readingProgression?` | `"ltr"` \| `"rtl"` | -| `renderMode?` | `"docker"` \| `"local"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| \{ `mode`: `"local"`; \} | +| `renderMode?` | `"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} | | `sandbox?` | `boolean` | | `signal?` | `AbortSignal` | | `singleDoc?` | `boolean` | diff --git a/docs/config.md b/docs/config.md index 9d8de3c0..259a7844 100644 --- a/docs/config.md +++ b/docs/config.md @@ -379,7 +379,7 @@ type ArticleEntryConfig = { - `format`: "pdf" | "epub" | "webpub" Specifies the output format. - - `renderMode`: "local" | "docker" | {mode: "docker"; hostGateway?: string; pathTransformer?: "{custom(unknown)}"; extraRunArgs?: (string)[]} | {mode: "local"} + - `renderMode`: "local" | "docker" | [RenderModeDocker](#rendermodedocker) | {mode: "local"} If set to `docker`, Vivliostyle will render the PDF using a Docker container. (default: `local`) - ~~`preflight`~~ _Deprecated_ @@ -402,12 +402,7 @@ type OutputConfig = { renderMode?: | "local" | "docker" - | { - mode: "docker"; - hostGateway?: string; - pathTransformer?: "{custom(unknown)}"; - extraRunArgs?: string[]; - } + | RenderModeDocker | { mode: "local" }; preflight?: | "press-ready" @@ -417,6 +412,44 @@ type OutputConfig = { }; ``` +### RenderModeDocker + +Object form of `renderMode: 'docker'`. Use this to tune the docker +invocation when the daemon is not Docker Desktop (e.g. raw Linux Docker +Engine, or dockerd inside a WSL distro). + +#### Properties + +- `RenderModeDocker` + + - `mode`: "docker" + + - `hostGateway`: string + Override the IP that `host.docker.internal` resolves to inside the + container. Default: Docker's special token `host-gateway`. + + - `pathTransformer`: (hostPath: string) => string + Rewrite the host side of `-v` bind paths before they reach dockerd. + Used to translate Windows paths to WSL drvfs form, etc. + + - `extraRunArgs`: (string)[] + Additional arguments inserted between `--rm` and the image name in + `docker run`. Used for WSL mirrored mode (`['--network=host']`), + GPU passthrough, etc. + +#### Type definition + +```ts +type RenderModeDocker = { + mode: "docker"; + hostGateway?: string; + pathTransformer?: ( + hostPath: string, + ) => string; + extraRunArgs?: string[]; +}; +``` + ### PdfPostprocessConfig PDF post-processing options. diff --git a/src/config/schema.ts b/src/config/schema.ts index 2eabad3a..a5ccb449 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -231,17 +231,46 @@ export const OutputFormat = v.union([ ]); export type OutputFormat = v.InferInput; -export const RenderModeDockerObject = v.object({ - mode: v.literal('docker'), - hostGateway: v.optional(ValidString), - pathTransformer: v.optional( - v.custom<(hostPath: string) => string>( - (value) => typeof value === 'function', - 'pathTransformer must be a function (hostPath: string) => string', +export const RenderModeDockerObject = v.pipe( + v.object({ + mode: v.literal('docker'), + hostGateway: v.optional( + v.pipe( + ValidString, + v.description($` + Override the IP that \`host.docker.internal\` resolves to inside the + container. Default: Docker's special token \`host-gateway\`. + `), + ), ), - ), - extraRunArgs: v.optional(v.array(ValidString)), -}); + pathTransformer: v.optional( + v.pipe( + v.function() as v.GenericSchema<(hostPath: string) => string>, + v.metadata({ typeString: '(hostPath: string) => string' }), + v.description($` + Rewrite the host side of \`-v\` bind paths before they reach dockerd. + Used to translate Windows paths to WSL drvfs form, etc. + `), + ), + ), + extraRunArgs: v.optional( + v.pipe( + v.array(ValidString), + v.description($` + Additional arguments inserted between \`--rm\` and the image name in + \`docker run\`. Used for WSL mirrored mode (\`['--network=host']\`), + GPU passthrough, etc. + `), + ), + ), + }), + v.title('RenderModeDocker'), + v.description($` + Object form of \`renderMode: 'docker'\`. Use this to tune the docker + invocation when the daemon is not Docker Desktop (e.g. raw Linux Docker + Engine, or dockerd inside a WSL distro). + `), +); export const RenderModeLocalObject = v.object({ mode: v.literal('local'), }); From f79efc807aa4ca4abc0549f7017ae00061c9eb38 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 22:04:50 +0900 Subject: [PATCH 06/21] Update src/config/resolve.ts Co-authored-by: Akihiro Tamada --- src/config/resolve.ts | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 70773d38..65f864c5 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -279,31 +279,18 @@ export type ReplaceImageConfig = ReplaceImageEntry[]; export type ResolvedRenderMode = | { mode: 'docker'; - hostGateway: string | undefined; - pathTransformer: ((hostPath: string) => string) | undefined; - extraRunArgs: string[] | undefined; + hostGateway?: string | undefined; + pathTransformer?: ((hostPath: string) => string) | undefined; + extraRunArgs?: string[] | undefined; } | { mode: 'local' }; -export function normalizeRenderMode( +function normalizeRenderMode( input: RenderMode | undefined, ): ResolvedRenderMode { - if (input === undefined || input === 'local') return { mode: 'local' }; - if (input === 'docker') { - return { - mode: 'docker', - hostGateway: undefined, - pathTransformer: undefined, - extraRunArgs: undefined, - }; - } - if (input.mode === 'local') return { mode: 'local' }; - return { - mode: 'docker', - hostGateway: input.hostGateway ?? undefined, - pathTransformer: input.pathTransformer ?? undefined, - extraRunArgs: input.extraRunArgs ?? undefined, - }; + return input && typeof input === 'object' + ? input + : { mode: input || 'local' }; } export interface PdfOutput { From 25acbacefdcd074e0182c80cead23b3ae369870a Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 22:05:23 +0900 Subject: [PATCH 07/21] Update src/config/schema.ts Co-authored-by: Akihiro Tamada --- src/config/schema.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config/schema.ts b/src/config/schema.ts index a5ccb449..01222641 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -277,8 +277,10 @@ export const RenderModeLocalObject = v.object({ export const RenderMode = v.union([ v.literal('local'), v.literal('docker'), - RenderModeDockerObject, - RenderModeLocalObject, + v.variant('mode', [ + RenderModeDockerObject, + RenderModeLocalObject, + ]), ]); export type RenderMode = v.InferInput; From e978f4ad8348b74f4744d534fb4662d19da9f877 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 19:08:43 +0900 Subject: [PATCH 08/21] refactor: expose WSL renderMode helpers as create* factories with automountRoot option --- src/index.ts | 6 +- src/wsl.ts | 123 +++++++++++++++++++++++---------- tests/container.test.ts | 4 +- tests/render-on-docker.test.ts | 20 ++++-- tests/wsl.test.ts | 74 ++++++++++++++------ 5 files changed, 157 insertions(+), 70 deletions(-) diff --git a/src/index.ts b/src/index.ts index e766cf65..c7c1a19e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,10 +21,10 @@ export type { export type { TemplateVariable } from './create-template.js'; export { createVitePlugin } from './vite-adapter.js'; export { + createDefaultWslMirroredRenderMode, + createDefaultWslNatRenderMode, + createWslPathTransformer, getWslHostIp, - wslMirroredRenderMode, - wslNatRenderMode, - wslPathTransformer, } from './wsl.js'; /** @hidden */ export type PublicationManifest = _PublicationManifest; diff --git a/src/wsl.ts b/src/wsl.ts index f4b01751..3612e292 100644 --- a/src/wsl.ts +++ b/src/wsl.ts @@ -16,17 +16,17 @@ import upath from 'upath'; * which upstream dockerd cannot parse. Translate them to drvfs * automount form (`/mnt/c/Users/...`) via `renderMode.pathTransformer`. * - * `wslNatRenderMode()` and `wslMirroredRenderMode()` return the settings - * that work for each WSL networking mode. Spread the result into a - * `renderMode` literal at the use site: + * `createDefaultWslNatRenderMode()` and `createDefaultWslMirroredRenderMode()` + * return the settings that work for each WSL networking mode. Spread the + * result into a `renderMode` literal at the use site: * * ```ts - * import { wslNatRenderMode } from '@vivliostyle/cli'; + * import { createDefaultWslNatRenderMode } from '@vivliostyle/cli'; * export default { * output: [{ * path: 'output.pdf', * renderMode: process.platform === 'win32' - * ? { mode: 'docker', ...wslNatRenderMode() } + * ? { mode: 'docker', ...createDefaultWslNatRenderMode() } * : 'docker', * }], * }; @@ -39,9 +39,9 @@ import upath from 'upath'; * to limit the change to the current session. * * --- NAT mode (`networkingMode=nat`, the WSL default) --- - * Use `wslNatRenderMode()`. The WSL VM has its own subnet; its eth0 default - * gateway points at the Windows host, and `getWslHostIp()` resolves that IP - * at config-evaluation time. + * Use `createDefaultWslNatRenderMode()`. The WSL VM has its own subnet; its + * eth0 default gateway points at the Windows host, and `getWslHostIp()` + * resolves that IP at config-evaluation time. * * Allow inbound on the `vEthernet (WSL)` interface from an elevated * PowerShell: @@ -57,7 +57,7 @@ import upath from 'upath'; * `Get-NetIPAddress -AddressFamily IPv4`. * * --- Mirrored mode (`networkingMode=mirrored`) --- - * Use `wslMirroredRenderMode()`. The WSL VM shares Windows network + * Use `createDefaultWslMirroredRenderMode()`. The WSL VM shares Windows network * interfaces, so the WSL TCP stack intercepts the shared IPs and a * default-bridged container has no IP it can use to reach Windows. The * preset works around this by putting the container in the WSL VM netns @@ -76,12 +76,38 @@ import upath from 'upath'; * (`c_wslFirewallVmCreatorId` in microsoft/WSL). */ +export interface WslPathTransformerOptions { + /** + * The WSL automount root directory — i.e. the value of `automount.root` + * in the target distro's `/etc/wsl.conf`. `/mnt/` (the WSL default, used + * by Ubuntu and every other distro out of the box) means `C:` is mounted + * at `/mnt/c/`. Set this if the target distro has changed `automount.root` + * (e.g. `root = /` mounts `C:` at `/c/`) or if you have a custom value + * like `/windir/`. Trailing slash is optional. + * + * Note: `automount.root` is a WSL-level (DrvFs) setting, not a distro + * convention; the default is identical across Ubuntu, Debian, Alpine, + * Arch, openSUSE, etc. + */ + automountRoot?: string; +} + /** - * Translate a Windows drive-letter absolute path to its WSL drvfs automount - * counterpart (`/mnt//...`). Useful as `renderMode.pathTransformer` - * when the docker daemon is upstream moby running inside a WSL distro. + * Build a `renderMode.pathTransformer` that translates Windows drive-letter + * absolute paths to their WSL drvfs automount counterpart (default + * `/mnt//...`). Useful when the docker daemon is upstream moby + * running inside a WSL distro. + * + * Example: + * ```ts + * renderMode: { + * mode: 'docker', + * pathTransformer: createWslPathTransformer(), + * // ... + * } + * ``` * - * Operating contract: + * Contract of the returned transformer: * The input is expected to be an absolute path produced by `upath.resolve()` * (the canonical resolver used in `src/config/resolve.ts` for `workspaceDir`, * `target.path`, etc.). Under that contract the input is one of: @@ -91,22 +117,33 @@ import upath from 'upath'; * Drive-letter + backslash (`C:\Users\foo`) is handled defensively for paths * that bypass `upath`. Anything else (relative paths, UNC `\\server\share\...`, * empty input) violates the contract and throws. + * + * Pass `{ automountRoot }` if the target WSL distro has changed + * `automount.root` in `/etc/wsl.conf` (see {@link WslPathTransformerOptions}). + * Override does not apply to POSIX inputs; those pass through unchanged. */ -export function wslPathTransformer(hostPath: string): string { - const { root } = path.win32.parse(hostPath); +export function createWslPathTransformer({ + automountRoot = '/mnt/', +}: WslPathTransformerOptions = {}): (hostPath: string) => string { + const base = automountRoot.endsWith('/') + ? automountRoot + : `${automountRoot}/`; + return (hostPath) => { + const { root } = path.win32.parse(hostPath); - if (root === '/') return hostPath; + if (root === '/') return hostPath; - if (root.length === 3 && root[1] === ':') { - return `/mnt/${root[0].toLowerCase()}/${upath.toUnix(hostPath.slice(root.length))}`; - } + if (root.length === 3 && root[1] === ':') { + return `${base}${root[0].toLowerCase()}/${upath.toUnix(hostPath.slice(root.length))}`; + } - throw new Error( - `wslPathTransformer: expected absolute path from upath.resolve(), ` + - `got ${JSON.stringify(hostPath)} (parsed root: ${JSON.stringify(root)}). ` + - `UNC, relative, and non-standard paths are out of scope; supply a custom ` + - `\`renderMode.pathTransformer\` to handle them.`, - ); + throw new Error( + `createWslPathTransformer: expected absolute path from upath.resolve(), ` + + `got ${JSON.stringify(hostPath)} (parsed root: ${JSON.stringify(root)}). ` + + `UNC, relative, and non-standard paths are out of scope; supply a custom ` + + `\`renderMode.pathTransformer\` to handle them.`, + ); + }; } /** @@ -130,38 +167,48 @@ export function getWslHostIp(): string { } /** - * `renderMode` fields (without `mode`) for the WSL hybrid + NAT networking - * case. Spread into a `renderMode` literal: + * Build the conventional default `renderMode` fields (without `mode`) for + * the WSL hybrid + NAT networking case. Spread into a `renderMode` literal: * * ```ts - * renderMode: { mode: 'docker', ...wslNatRenderMode() } + * renderMode: { mode: 'docker', ...createDefaultWslNatRenderMode() } * ``` * - * It's a function so `getWslHostIp()` runs at the call site; the WSL default + * `options` is forwarded to {@link createWslPathTransformer}; pass + * `{ automountRoot }` if the target WSL distro has changed `automount.root` + * in `/etc/wsl.conf`. + * + * It's a factory so `getWslHostIp()` runs at the call site; the WSL default * gateway can change across VM restarts. */ -export function wslNatRenderMode() { +export function createDefaultWslNatRenderMode( + options: WslPathTransformerOptions = {}, +) { return { hostGateway: getWslHostIp(), - pathTransformer: wslPathTransformer, + pathTransformer: createWslPathTransformer(options), }; } /** - * `renderMode` fields (without `mode`) for the WSL hybrid + mirrored - * networking case. Spread into a `renderMode` literal: + * Build the conventional default `renderMode` fields (without `mode`) for + * the WSL hybrid + mirrored networking case. Spread into a `renderMode` + * literal: * * ```ts - * renderMode: { mode: 'docker', ...wslMirroredRenderMode() } + * renderMode: { mode: 'docker', ...createDefaultWslMirroredRenderMode() } * ``` * - * The values are static; this is a function only to mirror - * `wslNatRenderMode`'s shape. + * `options` is forwarded to {@link createWslPathTransformer}; pass + * `{ automountRoot }` if the target WSL distro has changed `automount.root` + * in `/etc/wsl.conf`. */ -export function wslMirroredRenderMode() { +export function createDefaultWslMirroredRenderMode( + options: WslPathTransformerOptions = {}, +) { return { hostGateway: '127.0.0.1' as const, - pathTransformer: wslPathTransformer, + pathTransformer: createWslPathTransformer(options), extraRunArgs: ['--network=host'] as const, }; } diff --git a/tests/container.test.ts b/tests/container.test.ts index 210642d2..e1dbfe45 100644 --- a/tests/container.test.ts +++ b/tests/container.test.ts @@ -187,14 +187,14 @@ describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { // the docker0 gateway, which here is the WSL VM) and -v paths translated // to /mnt//. it('produces WSL+Win hybrid-ready args when renderMode.hostGateway and pathTransformer are both set', async () => { - const { wslPathTransformer } = await import('../src/wsl.js'); + const { createWslPathTransformer } = await import('../src/wsl.js'); await buildPDFWithContainer({ target: fabricateTarget({ path: 'C:/Users/me/work/out/test.pdf', renderMode: { mode: 'docker', hostGateway: '172.21.112.1', - pathTransformer: wslPathTransformer, + pathTransformer: createWslPathTransformer(), extraRunArgs: undefined, }, }), diff --git a/tests/render-on-docker.test.ts b/tests/render-on-docker.test.ts index 9afc272a..43066814 100644 --- a/tests/render-on-docker.test.ts +++ b/tests/render-on-docker.test.ts @@ -73,8 +73,8 @@ describe.skipIf(!image)( describe.skipIf(!image || !enableHybridNat)( 'render-mode docker (Win + WSL hybrid, networkingMode=nat)', () => { - it('renders via { mode: "docker", ...wslNatRenderMode() }', async () => { - const { wslNatRenderMode } = await import('../src/wsl.js'); + it('renders via { mode: "docker", ...createDefaultWslNatRenderMode() }', async () => { + const { createDefaultWslNatRenderMode } = await import('../src/wsl.js'); await runCommand( [ 'build', @@ -92,7 +92,10 @@ describe.skipIf(!image || !enableHybridNat)( output: [ { path: '.vs-pdf/out-wsl-nat.pdf', - renderMode: { mode: 'docker', ...wslNatRenderMode() }, + renderMode: { + mode: 'docker', + ...createDefaultWslNatRenderMode(), + }, }, ], }, @@ -116,8 +119,10 @@ describe.skipIf(!image || !enableHybridNat)( describe.skipIf(!image || !enableHybridMirrored)( 'render-mode docker (Win + WSL hybrid, networkingMode=mirrored)', () => { - it('renders via { mode: "docker", ...wslMirroredRenderMode() }', async () => { - const { wslMirroredRenderMode } = await import('../src/wsl.js'); + it('renders via { mode: "docker", ...createDefaultWslMirroredRenderMode() }', async () => { + const { createDefaultWslMirroredRenderMode } = await import( + '../src/wsl.js' + ); await runCommand( [ 'build', @@ -135,7 +140,10 @@ describe.skipIf(!image || !enableHybridMirrored)( output: [ { path: '.vs-pdf/out-wsl-mirrored.pdf', - renderMode: { mode: 'docker', ...wslMirroredRenderMode() }, + renderMode: { + mode: 'docker', + ...createDefaultWslMirroredRenderMode(), + }, }, ], }, diff --git a/tests/wsl.test.ts b/tests/wsl.test.ts index 4774aff2..9b0505fe 100644 --- a/tests/wsl.test.ts +++ b/tests/wsl.test.ts @@ -1,45 +1,77 @@ import { execFileSync } from 'node:child_process'; import { describe, expect, it, vi } from 'vitest'; -import { getWslHostIp, wslPathTransformer } from '../src/wsl.js'; +import { createWslPathTransformer, getWslHostIp } from '../src/wsl.js'; vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })); -describe('wslNatRenderMode', () => { - it('returns hostGateway from getWslHostIp() and pathTransformer = wslPathTransformer', async () => { +describe('createDefaultWslNatRenderMode', () => { + it('returns hostGateway from getWslHostIp() and a default-rooted path transformer', async () => { vi.mocked(execFileSync).mockReturnValueOnce( 'default via 172.21.112.1 dev eth0 proto kernel\n', ); - const { wslNatRenderMode, wslPathTransformer } = await import( - '../src/wsl.js' + const { createDefaultWslNatRenderMode } = await import('../src/wsl.js'); + const result = createDefaultWslNatRenderMode(); + expect(result.hostGateway).toBe('172.21.112.1'); + expect(result.pathTransformer('C:/Users/foo')).toBe('/mnt/c/Users/foo'); + }); + + it('threads automountRoot through to the returned pathTransformer', async () => { + vi.mocked(execFileSync).mockReturnValueOnce( + 'default via 172.21.112.1 dev eth0 proto kernel\n', ); - expect(wslNatRenderMode()).toEqual({ - hostGateway: '172.21.112.1', - pathTransformer: wslPathTransformer, - }); + const { createDefaultWslNatRenderMode } = await import('../src/wsl.js'); + const result = createDefaultWslNatRenderMode({ automountRoot: '/' }); + expect(result.pathTransformer('C:/Users/foo')).toBe('/c/Users/foo'); }); }); -describe('wslMirroredRenderMode', () => { - it('returns hostGateway = 127.0.0.1, wslPathTransformer, and extraRunArgs = [--network=host]', async () => { - const { wslMirroredRenderMode, wslPathTransformer } = await import( +describe('createDefaultWslMirroredRenderMode', () => { + it('returns hostGateway = 127.0.0.1, a default-rooted path transformer, and extraRunArgs = [--network=host]', async () => { + const { createDefaultWslMirroredRenderMode } = await import( + '../src/wsl.js' + ); + const result = createDefaultWslMirroredRenderMode(); + expect(result.hostGateway).toBe('127.0.0.1'); + expect(result.extraRunArgs).toEqual(['--network=host']); + expect(result.pathTransformer('C:/Users/foo')).toBe('/mnt/c/Users/foo'); + }); + + it('threads automountRoot through to the returned pathTransformer', async () => { + const { createDefaultWslMirroredRenderMode } = await import( '../src/wsl.js' ); - expect(wslMirroredRenderMode()).toEqual({ - hostGateway: '127.0.0.1', - pathTransformer: wslPathTransformer, - extraRunArgs: ['--network=host'], + const result = createDefaultWslMirroredRenderMode({ + automountRoot: '/windir', }); + expect(result.pathTransformer('C:/Users/foo')).toBe('/windir/c/Users/foo'); }); }); -describe('wslPathTransformer', () => { +describe('createWslPathTransformer', () => { it.each([ ['C:\\Users\\foo', '/mnt/c/Users/foo'], ['C:/Users/foo', '/mnt/c/Users/foo'], ['d:\\bar\\baz', '/mnt/d/bar/baz'], ['/posix/abs', '/posix/abs'], - ])('translates %s → %s', (input, expected) => { - expect(wslPathTransformer(input)).toBe(expected); + ])('default-built transformer translates %s → %s', (input, expected) => { + expect(createWslPathTransformer()(input)).toBe(expected); + }); + + it.each([ + ['C:/Users/foo', '/', '/c/Users/foo'], + ['C:/Users/foo', '/windir', '/windir/c/Users/foo'], + ['C:/Users/foo', '/windir/', '/windir/c/Users/foo'], + ['D:\\bar\\baz', '/c', '/c/d/bar/baz'], + ])( + 'with automountRoot=%2$s, transformer translates %1$s → %3$s', + (input, automountRoot, expected) => { + expect(createWslPathTransformer({ automountRoot })(input)).toBe(expected); + }, + ); + + it('leaves POSIX absolute paths unchanged even when automountRoot is overridden', () => { + const transform = createWslPathTransformer({ automountRoot: '/windir' }); + expect(transform('/posix/abs')).toBe('/posix/abs'); }); it.each([ @@ -48,8 +80,8 @@ describe('wslPathTransformer', () => { [''], ['\\\\server\\share\\foo'], ])('throws on out-of-spec input %j', (input) => { - expect(() => wslPathTransformer(input)).toThrow( - /expected absolute path from upath\.resolve/, + expect(() => createWslPathTransformer()(input)).toThrow( + /createWslPathTransformer: expected absolute path from upath\.resolve/, ); }); }); From 0317cf9fd80877bd23984baf89a78f2633cc6beb Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 19:35:54 +0900 Subject: [PATCH 09/21] test(container): inline expected constants instead of importing from src --- tests/container.test.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/tests/container.test.ts b/tests/container.test.ts index e1dbfe45..be9ad9b9 100644 --- a/tests/container.test.ts +++ b/tests/container.test.ts @@ -1,10 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { PdfOutput, ResolvedTaskConfig } from '../src/config/resolve.js'; import type { ParsedVivliostyleInlineConfig } from '../src/config/schema.js'; -import { - CONTAINER_LOCAL_HOSTNAME, - CONTAINER_ROOT_DIR, -} from '../src/constants.js'; const tinyexecMock = vi.hoisted(() => { const x = vi.fn(async function* docker() { @@ -111,7 +107,7 @@ describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { const args = lastDockerArgs(); const idx = args.indexOf('--add-host'); expect(idx).toBeGreaterThan(-1); - expect(args[idx + 1]).toBe(`${CONTAINER_LOCAL_HOSTNAME}:host-gateway`); + expect(args[idx + 1]).toBe('host.docker.internal:host-gateway'); }); it('overrides --add-host gateway when renderMode.hostGateway is set', async () => { @@ -130,7 +126,7 @@ describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { const args = lastDockerArgs(); const idx = args.indexOf('--add-host'); - expect(args[idx + 1]).toBe(`${CONTAINER_LOCAL_HOSTNAME}:172.21.112.1`); + expect(args[idx + 1]).toBe('host.docker.internal:172.21.112.1'); }); it('passes renderMode.extraRunArgs through verbatim before the image', async () => { @@ -179,7 +175,7 @@ describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { ); // container side is unchanged const workspaceMount = mounts.find((m) => m.host === '/mnt/c/workspace'); - expect(workspaceMount?.container).toBe(`${CONTAINER_ROOT_DIR}/workspace`); + expect(workspaceMount?.container).toBe('/data/workspace'); }); // Smoke test for the WSL+Win hybrid wiring: a single renderMode object @@ -206,17 +202,17 @@ describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { // host.docker.internal must point at the Windows host IP, not host-gateway const idx = args.indexOf('--add-host'); - expect(args[idx + 1]).toBe(`${CONTAINER_LOCAL_HOSTNAME}:172.21.112.1`); + expect(args[idx + 1]).toBe('host.docker.internal:172.21.112.1'); // -v host paths must be /mnt/c/... (parsable by upstream dockerd in WSL), - // while container paths stay in CONTAINER_ROOT_DIR namespace. + // while container paths stay in '/data' namespace. // The output dir (work/out) is de-duplicated under serverRootDir (work) // by collectVolumeArgs, so a single mount covers both. const mounts = volumesFromArgs(args); expect(mounts).toEqual([ { host: '/mnt/c/Users/me/work', - container: `${CONTAINER_ROOT_DIR}/Users/me/work`, + container: '/data/Users/me/work', }, ]); }); @@ -239,9 +235,9 @@ describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); expect(bypassed.input).toEqual({ format: 'webbook', - entry: `http://${CONTAINER_LOCAL_HOSTNAME}:13000/vivliostyle/index.html`, + entry: 'http://host.docker.internal:13000/vivliostyle/index.html', }); - expect(bypassed.host).toBe(CONTAINER_LOCAL_HOSTNAME); + expect(bypassed.host).toBe('host.docker.internal'); }); it('translates the output PDF path to the container path and mounts the output directory', async () => { @@ -256,18 +252,18 @@ describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); expect(bypassed.output).toEqual([ expect.objectContaining({ - path: `${CONTAINER_ROOT_DIR}/elsewhere/dist/out.pdf`, + path: '/data/elsewhere/dist/out.pdf', }), ]); expect(volumesFromArgs(args)).toEqual( expect.arrayContaining([ { host: '/workspace', - container: `${CONTAINER_ROOT_DIR}/workspace`, + container: '/data/workspace', }, { host: '/elsewhere/dist', - container: `${CONTAINER_ROOT_DIR}/elsewhere/dist`, + container: '/data/elsewhere/dist', }, ]), ); From a51c48ac77d47e28d61edf0936b82e16fba0908d Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 19:37:16 +0900 Subject: [PATCH 10/21] fix: accept readonly string[] for renderMode.extraRunArgs to support const-asserted presets --- src/config/resolve.ts | 2 +- src/config/schema.ts | 2 +- src/container.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 65f864c5..e0e32cd8 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -281,7 +281,7 @@ export type ResolvedRenderMode = mode: 'docker'; hostGateway?: string | undefined; pathTransformer?: ((hostPath: string) => string) | undefined; - extraRunArgs?: string[] | undefined; + extraRunArgs?: readonly string[] | undefined; } | { mode: 'local' }; diff --git a/src/config/schema.ts b/src/config/schema.ts index 01222641..f7ac174f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -255,7 +255,7 @@ export const RenderModeDockerObject = v.pipe( ), extraRunArgs: v.optional( v.pipe( - v.array(ValidString), + v.array(ValidString) as v.GenericSchema, v.description($` Additional arguments inserted between \`--rm\` and the image name in \`docker run\`. Used for WSL mirrored mode (\`['--network=host']\`), diff --git a/src/container.ts b/src/container.ts index ae25d567..a7982653 100644 --- a/src/container.ts +++ b/src/container.ts @@ -72,7 +72,7 @@ export async function runContainer({ env?: [string, string][]; workdir?: string; hostGateway?: string; - extraRunArgs?: string[]; + extraRunArgs?: readonly string[]; }) { const { default: commandExists } = await importNodeModule('command-exists'); if (!(await commandExists('docker'))) { From ffd6f38d40c982a967f1afb3bf5dd496a0afa497 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 20:18:38 +0900 Subject: [PATCH 11/21] ci: run render-on-docker tests on Linux and Win+WSL NAT hybrid --- .github/workflows/test.yml | 143 +++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 261b2965..0b15b213 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,149 @@ jobs: - run: pnpm install - run: pnpm build:cli - run: pnpm test + # Runs render-on-docker.test.ts against a locally-built CLI image on Linux, + # where the docker daemon is native (no Docker Desktop, no WSL). + render-on-docker-linux: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install + - run: pnpm build:cli + - name: Output package version + id: package + run: echo "version=$(jq -r '.version' package.json)" >> "$GITHUB_OUTPUT" + - name: Output browser version + id: browser + run: | + BROWSER_VERSION=$(sed -n '/START DEFAULT_BROWSER_VERSIONS/,/END DEFAULT_BROWSER_VERSIONS/p' src/constants.ts | grep -oP 'chrome:\s*\K\{[^}]+\}' | jq -r '.linux') + echo "browser=chrome@$BROWSER_VERSION" >> "$GITHUB_OUTPUT" + - name: Build vivliostyle/cli image + run: | + docker build -t vivliostyle/cli:ci \ + --build-arg VS_CLI_VERSION=${{ steps.package.outputs.version }} \ + --build-arg BROWSER=${{ steps.browser.outputs.browser }} \ + . + - name: Run render-on-docker tests + env: + VIVLIOSTYLE_TEST_IMAGE: vivliostyle/cli:ci + run: npx vitest run tests/render-on-docker.test.ts + + # Runs render-on-docker.test.ts in the Win+WSL hybrid NAT configuration: + # Node runs on Windows; dockerd runs inside a WSL Ubuntu distro; docker.exe + # on Windows is pointed at the WSL daemon via DOCKER_HOST. This is the + # configuration that `createDefaultWslNatRenderMode()` is designed for. + # + # The mirrored networking mode (`createDefaultWslMirroredRenderMode()`) is + # NOT covered here — see u1f992-temp/comment.md for why. + render-on-docker-wsl-nat: + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install + - run: pnpm build:cli + + # Vampire/setup-wsl handles `wsl --install`, distro download, and + # default-user provisioning (which `wsl --install -d` would otherwise + # prompt for interactively). + - name: Set up WSL with Ubuntu + uses: Vampire/setup-wsl@v3 + with: + distribution: Ubuntu-24.04 + set-as-default: 'true' + + - name: Install Docker Engine inside WSL + shell: wsl-bash {0} + run: | + set -eux + curl -fsSL https://get.docker.com -o /tmp/get-docker.sh + sudo sh /tmp/get-docker.sh + # Make dockerd listen on TCP so Windows-side docker.exe can reach + # it. 0.0.0.0:2375 is unauthenticated; acceptable for a one-shot + # CI VM but never for a real host. + sudo mkdir -p /etc/docker + echo '{"hosts":["unix:///var/run/docker.sock","tcp://0.0.0.0:2375"]}' | sudo tee /etc/docker/daemon.json + # WSL doesn't run systemd by default; start dockerd directly. The + # `-H` flags on the command line conflict with daemon.json hosts, + # so override via the systemd-style override-less direct invocation. + sudo nohup dockerd > /tmp/dockerd.log 2>&1 & + # Wait for the socket to come up + for i in $(seq 1 30); do + if sudo docker info > /dev/null 2>&1; then break; fi + sleep 1 + done + sudo docker info + + - name: Output package version + id: package + shell: bash + run: echo "version=$(jq -r '.version' package.json)" >> "$GITHUB_OUTPUT" + - name: Output browser version + id: browser + shell: bash + run: | + BROWSER_VERSION=$(sed -n '/START DEFAULT_BROWSER_VERSIONS/,/END DEFAULT_BROWSER_VERSIONS/p' src/constants.ts | grep -oP 'chrome:\s*\K\{[^}]+\}' | jq -r '.linux') + echo "browser=chrome@$BROWSER_VERSION" >> "$GITHUB_OUTPUT" + + - name: Build vivliostyle/cli image inside WSL + shell: wsl-bash {0} + env: + VS_CLI_VERSION: ${{ steps.package.outputs.version }} + BROWSER: ${{ steps.browser.outputs.browser }} + run: | + set -eux + # The runner workspace is at /mnt/c/...; building from drvfs is + # very slow (small-file I/O over 9P). Copy to the WSL ext4 fs. + cp -r "$GITHUB_WORKSPACE" /tmp/repo + cd /tmp/repo + sudo docker build -t vivliostyle/cli:ci \ + --build-arg VS_CLI_VERSION="$VS_CLI_VERSION" \ + --build-arg BROWSER="$BROWSER" \ + . + + - name: Allow inbound on vEthernet (WSL) interface + shell: pwsh + run: | + # The WSL VM eth0 default gateway points at this Windows interface; + # Defender Firewall blocks inbound by default, which prevents the + # container from reaching the host Vite. Loosen for this run only. + $iface = Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.InterfaceAlias -like 'vEthernet (WSL*' } | + Select-Object -First 1 + if (-not $iface) { throw 'No vEthernet (WSL*) interface found.' } + New-NetFirewallRule -DisplayName 'Vivliostyle CI: WSL inbound' ` + -Direction Inbound ` + -InterfaceAlias $iface.InterfaceAlias ` + -Protocol TCP -Action Allow + + - name: Run render-on-docker WSL hybrid NAT test + shell: pwsh + env: + VIVLIOSTYLE_TEST_IMAGE: vivliostyle/cli:ci + VIVLIOSTYLE_TEST_WSL_HYBRID_NAT: '1' + run: | + # Resolve WSL VM IP and point Windows-side docker.exe (pre-installed + # via Docker Desktop) at the in-WSL dockerd's TCP listener. + $wslIp = (wsl -- hostname -I).Trim().Split(' ')[0] + $env:DOCKER_HOST = "tcp://${wslIp}:2375" + docker version + npx vitest run tests/render-on-docker.test.ts + build-and-push-image: runs-on: ubuntu-latest services: From 2811d33d82ba03820052abe3bebbc8a4589acae7 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 22:48:32 +0900 Subject: [PATCH 12/21] ci: declare minimum permissions for test workflow --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b15b213..8b5a4707 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,9 @@ on: - synchronize merge_group: +permissions: + contents: read + jobs: build-and-test: runs-on: ${{ matrix.os }} From 1df80efbfb0ee3ab0b4387b3f8601d4cfc5d62d8 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 22:54:58 +0900 Subject: [PATCH 13/21] wip: capture dockerd startup diagnostics in WSL job --- .github/workflows/test.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b5a4707..a2be39b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -122,6 +122,26 @@ jobs: if sudo docker info > /dev/null 2>&1; then break; fi sleep 1 done + if ! sudo docker info > /dev/null 2>&1; then + echo "::group::dockerd log (/tmp/dockerd.log)" + sudo cat /tmp/dockerd.log || true + echo "::endgroup::" + echo "::group::process tree" + ps auxf 2>&1 | grep -E 'docker|containerd' || true + echo "::endgroup::" + echo "::group::docker socket presence" + sudo ls -la /var/run/docker.sock /run/docker.sock 2>&1 || true + echo "::endgroup::" + echo "::group::iptables alternatives state" + update-alternatives --display iptables 2>&1 || true + update-alternatives --display ip6tables 2>&1 || true + echo "::endgroup::" + echo "::group::kernel info" + uname -a + cat /proc/version + echo "::endgroup::" + exit 1 + fi sudo docker info - name: Output package version From d947ef9ebb23305523f8974dac9ee75dbc55e446 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 23:19:00 +0900 Subject: [PATCH 14/21] ci: upgrade Vampire/setup-wsl@v3 to @v7 so distros install as WSL2 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a2be39b5..5cafd27a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -97,7 +97,7 @@ jobs: # default-user provisioning (which `wsl --install -d` would otherwise # prompt for interactively). - name: Set up WSL with Ubuntu - uses: Vampire/setup-wsl@v3 + uses: Vampire/setup-wsl@v7 with: distribution: Ubuntu-24.04 set-as-default: 'true' From 6a2312ddc314e3678f22da9f853fb63ecd8402aa Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 23:28:14 +0900 Subject: [PATCH 15/21] ci: pass GITHUB_WORKSPACE into wsl-bash so image build can find the checkout --- .github/workflows/test.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5cafd27a..2554e976 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -158,13 +158,19 @@ jobs: - name: Build vivliostyle/cli image inside WSL shell: wsl-bash {0} env: + # GitHub Actions injects environment variables (GITHUB_WORKSPACE etc.) + # into the Windows-side shell only; they do not propagate into the + # wsl-bash wrapper. Re-export the Windows path explicitly so we can + # translate it to a WSL path with wslpath below. + WORKSPACE_WIN: ${{ github.workspace }} VS_CLI_VERSION: ${{ steps.package.outputs.version }} BROWSER: ${{ steps.browser.outputs.browser }} run: | set -eux - # The runner workspace is at /mnt/c/...; building from drvfs is + # The runner workspace is at /mnt/d/...; building from drvfs is # very slow (small-file I/O over 9P). Copy to the WSL ext4 fs. - cp -r "$GITHUB_WORKSPACE" /tmp/repo + WORKSPACE=$(wslpath -u "$WORKSPACE_WIN") + cp -r "$WORKSPACE" /tmp/repo cd /tmp/repo sudo docker build -t vivliostyle/cli:ci \ --build-arg VS_CLI_VERSION="$VS_CLI_VERSION" \ From 50614324ff9f9d7a574543d54249a938209d4cce Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 23:28:41 +0900 Subject: [PATCH 16/21] fix: preserve error.cause in runContainer so callers see the underlying docker failure --- src/container.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/container.ts b/src/container.ts index a7982653..a20ea6c3 100644 --- a/src/container.ts +++ b/src/container.ts @@ -131,6 +131,7 @@ export async function runContainer({ } catch (error) { throw new Error( 'An error occurred on the running container. Please see logs above.', + { cause: error }, ); } } From 69d49f6b46a702cf938ee67fa2a68d71639815ca Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 23:37:46 +0900 Subject: [PATCH 17/21] ci: inline workspace path via YAML expansion since env: does not reach wsl-bash --- .github/workflows/test.yml | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2554e976..f668f87a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -157,24 +157,22 @@ jobs: - name: Build vivliostyle/cli image inside WSL shell: wsl-bash {0} - env: - # GitHub Actions injects environment variables (GITHUB_WORKSPACE etc.) - # into the Windows-side shell only; they do not propagate into the - # wsl-bash wrapper. Re-export the Windows path explicitly so we can - # translate it to a WSL path with wslpath below. - WORKSPACE_WIN: ${{ github.workspace }} - VS_CLI_VERSION: ${{ steps.package.outputs.version }} - BROWSER: ${{ steps.browser.outputs.browser }} + # GitHub Actions `env:` values are set on the Windows-side shell only; + # they do NOT propagate into Vampire/setup-wsl's wsl-bash wrapper. + # Substitute the values directly via YAML expression expansion so the + # script body contains the literals. Single quotes preserve the + # Windows-style backslashes in the workspace path so bash does not + # interpret them as escape sequences. run: | set -eux # The runner workspace is at /mnt/d/...; building from drvfs is # very slow (small-file I/O over 9P). Copy to the WSL ext4 fs. - WORKSPACE=$(wslpath -u "$WORKSPACE_WIN") + WORKSPACE=$(wslpath -u '${{ github.workspace }}') cp -r "$WORKSPACE" /tmp/repo cd /tmp/repo sudo docker build -t vivliostyle/cli:ci \ - --build-arg VS_CLI_VERSION="$VS_CLI_VERSION" \ - --build-arg BROWSER="$BROWSER" \ + --build-arg VS_CLI_VERSION='${{ steps.package.outputs.version }}' \ + --build-arg BROWSER='${{ steps.browser.outputs.browser }}' \ . - name: Allow inbound on vEthernet (WSL) interface From ca2ddbf6b9ee35f195a7c88f36454d7090a44489 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 23:37:48 +0900 Subject: [PATCH 18/21] fix: include container output tail in runContainer error message --- src/container.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/container.ts b/src/container.ts index a20ea6c3..76441232 100644 --- a/src/container.ts +++ b/src/container.ts @@ -90,6 +90,10 @@ export async function runContainer({ ); } + // Collect lines so we can surface the actual container output if the + // process exits non-zero (tinyexec's thrown error only contains exit + // status + spawn args, and Logger.log may be silenced by log level). + const collectedOutput: string[] = []; try { using _ = Logger.suspendLogging('Launching docker container'); const args = [ @@ -126,11 +130,15 @@ export async function runContainer({ } else { for await (const line of proc) { Logger.log(line); + collectedOutput.push(line); } } } catch (error) { + const tail = collectedOutput.slice(-50).join('\n'); throw new Error( - 'An error occurred on the running container. Please see logs above.', + `An error occurred on the running container.${ + tail ? `\nLast container output:\n${tail}` : '' + }`, { cause: error }, ); } From c422e400e8f61b5ce0b5ebd480f0cd5c784cd3b3 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 13 May 2026 23:51:44 +0900 Subject: [PATCH 19/21] ci: chmod fixture dir so container (uid 1000) can write its output --- .github/workflows/test.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f668f87a..64b2b370 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,13 @@ jobs: - name: Run render-on-docker tests env: VIVLIOSTYLE_TEST_IMAGE: vivliostyle/cli:ci - run: npx vitest run tests/render-on-docker.test.ts + run: | + # The container runs as the Dockerfile's `USER vivliostyle` (uid + # 1000); the runner's checkout dir is owned by a different uid, so + # the bind-mounted fixture dir is not writable by the container. + # Loosen permissions so the container can mkdir the output dir. + chmod -R a+rwX tests/fixtures/render-on-docker + npx vitest run tests/render-on-docker.test.ts # Runs render-on-docker.test.ts in the Win+WSL hybrid NAT configuration: # Node runs on Windows; dockerd runs inside a WSL Ubuntu distro; docker.exe From f32383004cceff809fa5625df9170989f81f0afb Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 14 May 2026 00:24:41 +0900 Subject: [PATCH 20/21] ci: pre-create .vs-pdf output dir so teardown can remove container-owned files --- .github/workflows/test.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64b2b370..698b632f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,9 +71,12 @@ jobs: VIVLIOSTYLE_TEST_IMAGE: vivliostyle/cli:ci run: | # The container runs as the Dockerfile's `USER vivliostyle` (uid - # 1000); the runner's checkout dir is owned by a different uid, so - # the bind-mounted fixture dir is not writable by the container. - # Loosen permissions so the container can mkdir the output dir. + # 1000); the runner's checkout dir is owned by a different uid. + # Pre-create the output dir owned by the runner with permissive + # perms so (a) the container can write inside it and (b) vitest's + # global teardown (running as the runner user) can rm + # container-created files afterwards. + mkdir -p tests/fixtures/render-on-docker/.vs-pdf chmod -R a+rwX tests/fixtures/render-on-docker npx vitest run tests/render-on-docker.test.ts From ff15d840d7292a73b1cac418626c1ce9b28fa56a Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 14 May 2026 15:30:00 +0900 Subject: [PATCH 21/21] docs: Update docs --- docs/api-javascript.md | 218 +++++++++++++++++++++++++---------------- docs/config.md | 43 +------- 2 files changed, 134 insertions(+), 127 deletions(-) diff --git a/docs/api-javascript.md b/docs/api-javascript.md index 3beb8c8d..332c2ff7 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -7,14 +7,14 @@ - [`build`](#build) - [`create`](#create) +- [`createDefaultWslMirroredRenderMode`](#createdefaultwslmirroredrendermode) +- [`createDefaultWslNatRenderMode`](#createdefaultwslnatrendermode) - [`createVitePlugin`](#createviteplugin) +- [`createWslPathTransformer`](#createwslpathtransformer) - [`defineConfig`](#defineconfig) - [`getWslHostIp`](#getwslhostip) - [`preview`](#preview) - [`VFM`](#vfm) -- [`wslMirroredRenderMode`](#wslmirroredrendermode) -- [`wslNatRenderMode`](#wslnatrendermode) -- [`wslPathTransformer`](#wslpathtransformer) ### Interfaces @@ -200,7 +200,7 @@ build({ ###### renderMode? -`"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -432,7 +432,7 @@ Scaffold a new Vivliostyle project. ###### renderMode? -`"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -508,6 +508,75 @@ Scaffold a new Vivliostyle project. *** +### createDefaultWslMirroredRenderMode() + +> **createDefaultWslMirroredRenderMode**(`options`): `object` + +Build the conventional default `renderMode` fields (without `mode`) for +the WSL hybrid + mirrored networking case. Spread into a `renderMode` +literal: + +```ts +renderMode: { mode: 'docker', ...createDefaultWslMirroredRenderMode() } +``` + +`options` is forwarded to [createWslPathTransformer](#createwslpathtransformer); pass +`{ automountRoot }` if the target WSL distro has changed `automount.root` +in `/etc/wsl.conf`. + +#### Parameters + +##### options + +`WslPathTransformerOptions` = `{}` + +#### Returns + +`object` + +| Name | Type | +| ------ | ------ | +| `extraRunArgs` | readonly \[`"--network=host"`\] | +| `hostGateway` | `"127.0.0.1"` | +| `pathTransformer()` | (`hostPath`) => `string` | + +*** + +### createDefaultWslNatRenderMode() + +> **createDefaultWslNatRenderMode**(`options`): `object` + +Build the conventional default `renderMode` fields (without `mode`) for +the WSL hybrid + NAT networking case. Spread into a `renderMode` literal: + +```ts +renderMode: { mode: 'docker', ...createDefaultWslNatRenderMode() } +``` + +`options` is forwarded to [createWslPathTransformer](#createwslpathtransformer); pass +`{ automountRoot }` if the target WSL distro has changed `automount.root` +in `/etc/wsl.conf`. + +It's a factory so `getWslHostIp()` runs at the call site; the WSL default +gateway can change across VM restarts. + +#### Parameters + +##### options + +`WslPathTransformerOptions` = `{}` + +#### Returns + +`object` + +| Name | Type | +| ------ | ------ | +| `hostGateway` | `string` | +| `pathTransformer()` | (`hostPath`) => `string` | + +*** + ### createVitePlugin() > **createVitePlugin**(`inlineConfig`): `Promise`\<`Plugin`\<`any`\>[]\> @@ -662,7 +731,7 @@ Scaffold a new Vivliostyle project. ###### renderMode? -`"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -738,6 +807,61 @@ Scaffold a new Vivliostyle project. *** +### createWslPathTransformer() + +> **createWslPathTransformer**(`__namedParameters`): (`hostPath`) => `string` + +Build a `renderMode.pathTransformer` that translates Windows drive-letter +absolute paths to their WSL drvfs automount counterpart (default +`/mnt//...`). Useful when the docker daemon is upstream moby +running inside a WSL distro. + +Example: +```ts +renderMode: { + mode: 'docker', + pathTransformer: createWslPathTransformer(), + // ... +} +``` + +Contract of the returned transformer: + The input is expected to be an absolute path produced by `upath.resolve()` + (the canonical resolver used in `src/config/resolve.ts` for `workspaceDir`, + `target.path`, etc.). Under that contract the input is one of: + - POSIX absolute (`/foo/bar`) on Linux/macOS hosts: passed through + - Drive-letter + forward slash (`C:/Users/foo`) on Windows hosts: translated + + Drive-letter + backslash (`C:\Users\foo`) is handled defensively for paths + that bypass `upath`. Anything else (relative paths, UNC `\\server\share\...`, + empty input) violates the contract and throws. + +Pass `{ automountRoot }` if the target WSL distro has changed +`automount.root` in `/etc/wsl.conf` (see WslPathTransformerOptions). +Override does not apply to POSIX inputs; those pass through unchanged. + +#### Parameters + +##### \_\_namedParameters + +`WslPathTransformerOptions` = `{}` + +#### Returns + +> (`hostPath`): `string` + +##### Parameters + +###### hostPath + +`string` + +##### Returns + +`string` + +*** + ### defineConfig() > **defineConfig**(`config`): [`VivliostyleConfigSchema`](#vivliostyleconfigschema) @@ -928,7 +1052,7 @@ Open a browser for previewing the publication. ###### renderMode? -`"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -1028,84 +1152,6 @@ Options. Unified processor. -*** - -### wslMirroredRenderMode() - -> **wslMirroredRenderMode**(): `object` - -`renderMode` fields (without `mode`) for the WSL hybrid + mirrored -networking case. Spread into a `renderMode` literal: - -```ts -renderMode: { mode: 'docker', ...wslMirroredRenderMode() } -``` - -The values are static; this is a function only to mirror -`wslNatRenderMode`'s shape. - -#### Returns - -| Name | Type | Default value | -| ------ | ------ | ------ | -| `extraRunArgs` | readonly \[`"--network=host"`\] | - | -| `hostGateway` | `"127.0.0.1"` | - | -| `pathTransformer()` | (`hostPath`) => `string` | `wslPathTransformer` | - -*** - -### wslNatRenderMode() - -> **wslNatRenderMode**(): `object` - -`renderMode` fields (without `mode`) for the WSL hybrid + NAT networking -case. Spread into a `renderMode` literal: - -```ts -renderMode: { mode: 'docker', ...wslNatRenderMode() } -``` - -It's a function so `getWslHostIp()` runs at the call site; the WSL default -gateway can change across VM restarts. - -#### Returns - -| Name | Type | Default value | -| ------ | ------ | ------ | -| `hostGateway` | `string` | - | -| `pathTransformer()` | (`hostPath`) => `string` | `wslPathTransformer` | - -*** - -### wslPathTransformer() - -> **wslPathTransformer**(`hostPath`): `string` - -Translate a Windows drive-letter absolute path to its WSL drvfs automount -counterpart (`/mnt//...`). Useful as `renderMode.pathTransformer` -when the docker daemon is upstream moby running inside a WSL distro. - -Operating contract: - The input is expected to be an absolute path produced by `upath.resolve()` - (the canonical resolver used in `src/config/resolve.ts` for `workspaceDir`, - `target.path`, etc.). Under that contract the input is one of: - - POSIX absolute (`/foo/bar`) on Linux/macOS hosts: passed through - - Drive-letter + forward slash (`C:/Users/foo`) on Windows hosts: translated - - Drive-letter + backslash (`C:\Users\foo`) is handled defensively for paths - that bypass `upath`. Anything else (relative paths, UNC `\\server\share\...`, - empty input) violates the contract and throws. - -#### Parameters - -##### hostPath - -`string` - -#### Returns - -`string` - ## Interfaces ### StringifyMarkdownOptions @@ -1182,7 +1228,7 @@ Option for convert Markdown to a stringify (HTML). | `proxyUser?` | `string` | | `quick?` | `boolean` | | `readingProgression?` | `"ltr"` \| `"rtl"` | -| `renderMode?` | `"docker"` \| \{ `extraRunArgs?`: `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} | +| `renderMode?` | `"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} | | `sandbox?` | `boolean` | | `signal?` | `AbortSignal` | | `singleDoc?` | `boolean` | diff --git a/docs/config.md b/docs/config.md index 259a7844..6fbf9d90 100644 --- a/docs/config.md +++ b/docs/config.md @@ -379,7 +379,7 @@ type ArticleEntryConfig = { - `format`: "pdf" | "epub" | "webpub" Specifies the output format. - - `renderMode`: "local" | "docker" | [RenderModeDocker](#rendermodedocker) | {mode: "local"} + - `renderMode`: "local" | "docker" | "{variant(Object)}" If set to `docker`, Vivliostyle will render the PDF using a Docker container. (default: `local`) - ~~`preflight`~~ _Deprecated_ @@ -402,8 +402,7 @@ type OutputConfig = { renderMode?: | "local" | "docker" - | RenderModeDocker - | { mode: "local" }; + | "{variant(Object)}"; preflight?: | "press-ready" | "press-ready-local"; @@ -412,44 +411,6 @@ type OutputConfig = { }; ``` -### RenderModeDocker - -Object form of `renderMode: 'docker'`. Use this to tune the docker -invocation when the daemon is not Docker Desktop (e.g. raw Linux Docker -Engine, or dockerd inside a WSL distro). - -#### Properties - -- `RenderModeDocker` - - - `mode`: "docker" - - - `hostGateway`: string - Override the IP that `host.docker.internal` resolves to inside the - container. Default: Docker's special token `host-gateway`. - - - `pathTransformer`: (hostPath: string) => string - Rewrite the host side of `-v` bind paths before they reach dockerd. - Used to translate Windows paths to WSL drvfs form, etc. - - - `extraRunArgs`: (string)[] - Additional arguments inserted between `--rm` and the image name in - `docker run`. Used for WSL mirrored mode (`['--network=host']`), - GPU passthrough, etc. - -#### Type definition - -```ts -type RenderModeDocker = { - mode: "docker"; - hostGateway?: string; - pathTransformer?: ( - hostPath: string, - ) => string; - extraRunArgs?: string[]; -}; -``` - ### PdfPostprocessConfig PDF post-processing options.