diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bafc30b..21a59626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Add a delayed notification for reading Markdown from the stdin stream ([#573](https://github.com/marp-team/marp-cli/issues/573), [#644](https://github.com/marp-team/marp-cli/pull/644)) + ### Changed - Upgrade dependent packages to the latest versions ([#648](https://github.com/marp-team/marp-cli/pull/648)) diff --git a/jest.setup.js b/jest.setup.js index 29e7d628..54ee4e98 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,4 +1,5 @@ jest.mock('wrap-ansi') +jest.mock('./src/utils/stdin') require('css.escape') // Polyfill for CSS.escape diff --git a/package-lock.json b/package-lock.json index 6d4bd988..bbdbb1d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,6 @@ "eslint-plugin-unicorn": "^56.0.1", "express": "^4.21.2", "fast-plist": "^0.1.3", - "get-stdin": "^9.0.0", "globals": "^15.15.0", "globby": "~14.0.2", "image-size": "^1.2.0", @@ -8759,19 +8758,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stdin": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz", - "integrity": "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", diff --git a/package.json b/package.json index eeea5f78..dffd9c01 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,6 @@ "eslint-plugin-unicorn": "^56.0.1", "express": "^4.21.2", "fast-plist": "^0.1.3", - "get-stdin": "^9.0.0", "globals": "^15.15.0", "globby": "~14.0.2", "image-size": "^1.2.0", diff --git a/src/config.ts b/src/config.ts index ecd4889c..c900b200 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ import { TemplateOption } from './templates' import { Theme, ThemeSet } from './theme' import { isStandaloneBinary } from './utils/binary' import { isOfficialContainerImage } from './utils/container' +import { debugConfig } from './utils/debug' type Overwrite = Omit> & U @@ -108,6 +109,8 @@ export class MarpCLIConfig { static async fromArguments(args: IMarpCLIArguments) { const conf = new MarpCLIConfig() + + debugConfig('Passed arguments: %o', args) conf.args = args if (args.configFile !== false) await conf.loadConf(args.configFile) @@ -437,15 +440,37 @@ export class MarpCLIConfig { : cosmiconfigSync(MarpCLIConfig.moduleName) try { - const ret = await (confPath === undefined - ? explorer.search(process.cwd()) - : explorer.load(confPath)) + const ret = await (async () => { + if (confPath !== undefined) { + debugConfig( + 'Loading configuration file from specified path: %s', + confPath + ) + return explorer.load(confPath) + } + + const currentDir = process.cwd() + debugConfig( + 'Finding configuration file from current directory: %s', + currentDir + ) + + return explorer.search(currentDir) + })() + + if (ret && !ret.isEmpty) { + debugConfig('Loaded configuration file: %s', ret.filepath) + } else { + debugConfig('No configuration file found.') + } if (ret) { this.confPath = ret.filepath this.conf = ret.config } } catch (e: unknown) { + debugConfig('Error occurred during loading configuration file: %o', e) + const isErr = isError(e) if (isErr && e.code === 'ERR_REQUIRE_ESM') { diff --git a/src/file.ts b/src/file.ts index 8e79f168..21787964 100644 --- a/src/file.ts +++ b/src/file.ts @@ -2,9 +2,9 @@ import fs from 'node:fs' import path from 'node:path' import * as url from 'node:url' -import getStdin from 'get-stdin' import { globby, Options as GlobbyOptions } from 'globby' import { debug } from './utils/debug' +import { getStdin } from './utils/stdin' import { generateTmpName } from './utils/tmp' export const markdownExtensions = ['md', 'mdown', 'markdown', 'markdn'] @@ -236,7 +236,7 @@ export class File { } static async stdin(): Promise { - this.stdinBuffer = this.stdinBuffer || (await getStdin.buffer()) + this.stdinBuffer = this.stdinBuffer || (await getStdin()) if (this.stdinBuffer.length === 0) return undefined return this.initialize('-', (f) => { diff --git a/src/utils/__mocks__/stdin.ts b/src/utils/__mocks__/stdin.ts new file mode 100644 index 00000000..709a6eb5 --- /dev/null +++ b/src/utils/__mocks__/stdin.ts @@ -0,0 +1 @@ +export const getStdin = async () => Buffer.alloc(0) diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 301d2713..d0c218df 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -1,7 +1,9 @@ import dbg from 'debug' export const debug = dbg('marp-cli') +export const debugConfig = dbg('marp-cli:config') export const debugBrowser = dbg('marp-cli:browser') export const debugBrowserFinder = dbg('marp-cli:browser:finder') export const debugEngine = dbg('marp-cli:engine') export const debugPreview = dbg('marp-cli:preview') +export const debugWatcher = dbg('marp-cli:watcher') diff --git a/src/utils/stdin.ts b/src/utils/stdin.ts new file mode 100644 index 00000000..d22d4e0f --- /dev/null +++ b/src/utils/stdin.ts @@ -0,0 +1,34 @@ +import streamConsumers from 'node:stream/consumers' +import { setTimeout as setTimeoutPromise } from 'node:timers/promises' +import chalk from 'chalk' +import { info } from '../cli' +import { debug } from './debug' + +const STDIN_NOTICE_DELAY = 3000 + +export const getStdin = async (): Promise => { + if (process.stdin.isTTY) return Buffer.alloc(0) + + const delayedNoticeController = new AbortController() + + setTimeoutPromise(STDIN_NOTICE_DELAY, null, { + ref: false, + signal: delayedNoticeController.signal, + }) + .then(() => { + info( + `Currently waiting data from stdin stream. Conversion will start after finished reading. (Pass ${chalk.yellow`--no-stdin`} option if it was not intended)` + ) + }) + .catch(() => { + // No ops + }) + + debug('Reading stdin stream...') + const buf = await streamConsumers.buffer(process.stdin) + + debug('Read from stdin: %d bytes', buf.length) + delayedNoticeController.abort() + + return buf +} diff --git a/src/watcher.ts b/src/watcher.ts index 782971ac..37287188 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -1,13 +1,19 @@ /* eslint-disable @typescript-eslint/no-namespace */ import crypto from 'node:crypto' import path from 'node:path' -import { watch as chokidarWatch, type FSWatcher } from 'chokidar' +import { watch as _watch, type FSWatcher } from 'chokidar' import { getPortPromise } from 'portfinder' import { WebSocketServer } from 'ws' import type { ServerOptions } from 'ws' import { Converter, ConvertedCallback } from './converter' import { isError } from './error' import { File, FileType } from './file' +import { debugWatcher } from './utils/debug' + +const chokidarWatch: typeof _watch = (...args) => { + debugWatcher('Start watching with chokidar: %O', args) + return _watch(...args) +} export class Watcher { chokidar: FSWatcher @@ -24,6 +30,7 @@ export class Watcher { this.mode = opts.mode this.chokidar = chokidarWatch(watchPath, { ignoreInitial: true }) + .on('all', (event, path) => this.log(event, path)) .on('change', (f) => this.convert(f)) .on('add', (f) => this.convert(f)) .on('unlink', (f) => this.delete(f)) @@ -33,6 +40,10 @@ export class Watcher { notifier.start() } + private log(event: string, path: string) { + debugWatcher('Chokidar event: [%s] %s', event, path) + } + private async convert(filename: string) { const resolvedFn = path.resolve(filename) const mdFiles = (await this.finder()).filter( diff --git a/test/__mocks__/get-stdin.ts b/test/__mocks__/get-stdin.ts deleted file mode 100644 index 7adc1e46..00000000 --- a/test/__mocks__/get-stdin.ts +++ /dev/null @@ -1,5 +0,0 @@ -const getStdin = Object.assign(async () => '', { - buffer: async () => Buffer.from(''), -}) - -export default getStdin diff --git a/test/index.ts b/test/index.ts index 3f15c9c2..ac3171d2 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,7 +1,7 @@ import path from 'node:path' -import getStdin from 'get-stdin' import api, { CLIError } from '../src/index' import * as marpCli from '../src/marp-cli' +import * as stdin from '../src/utils/stdin' afterEach(() => { jest.clearAllMocks() @@ -27,7 +27,7 @@ describe('Marp CLI API interface', () => { it('does not read input from stdin if called API', async () => { jest.spyOn(console, 'log').mockImplementation() - const stdinBuffer = jest.spyOn(getStdin, 'buffer') + const stdinBuffer = jest.spyOn(stdin, 'getStdin') await marpCli.cliInterface([]) expect(stdinBuffer).toHaveBeenCalled() diff --git a/test/marp-cli.ts b/test/marp-cli.ts index 8ec7e0ca..917a6fe0 100644 --- a/test/marp-cli.ts +++ b/test/marp-cli.ts @@ -4,7 +4,6 @@ import path from 'node:path' import { version as coreVersion } from '@marp-team/marp-core/package.json' import { version as marpitVersion } from '@marp-team/marpit/package.json' import * as cosmiconfigExplorer from 'cosmiconfig/dist/Explorer' // eslint-disable-line import-x/namespace -import getStdin from 'get-stdin' import stripAnsi from 'strip-ansi' import { version as cliVersion } from '../package.json' import { defaultFinders } from '../src/browser/finder' @@ -22,6 +21,7 @@ import { Preview } from '../src/preview' import { Server } from '../src/server' import { ThemeSet } from '../src/theme' import * as container from '../src/utils/container' +import * as stdin from '../src/utils/stdin' import * as version from '../src/version' import { Watcher } from '../src/watcher' @@ -1752,9 +1752,7 @@ describe('Marp CLI', () => { cliInfo = jest.spyOn(cli, 'info').mockImplementation() stdout = jest.spyOn(process.stdout, 'write').mockImplementation() - jest - .spyOn(getStdin, 'buffer') - .mockResolvedValue(Buffer.from('# markdown')) + jest.spyOn(stdin, 'getStdin').mockResolvedValue(Buffer.from('# markdown')) // reset cached stdin buffer ;(File as any).stdinBuffer = undefined diff --git a/test/utils/stdin.ts b/test/utils/stdin.ts new file mode 100644 index 00000000..ebb85b94 --- /dev/null +++ b/test/utils/stdin.ts @@ -0,0 +1,81 @@ +import { Readable } from 'node:stream' +import * as cli from '../../src/cli' +import { getStdin } from '../../src/utils/stdin' + +jest.unmock('../../src/utils/stdin') + +afterEach(() => jest.restoreAllMocks()) + +const createMockedStream = (data: string, delay = 0) => { + const { length } = data + + let cursor = 0 + + return new Readable({ + read(size) { + setTimeout(() => { + const end = cursor + size + + this.push(data.slice(cursor, end)) + if (length < end) this.push(null) + + cursor = end + }, delay) + }, + }) +} + +const createMockedStdinStream = ( + data: string, + { delay = 0, isTTY = false }: { delay?: number; isTTY?: boolean } = {} +) => + Object.assign( + createMockedStream(data, delay) as unknown as NodeJS.ReadStream, + { fd: 0 as const, isTTY } + ) + +describe('getStdin()', () => { + it('always returns empty buffer if stdin is TTY', async () => { + jest + .spyOn(process, 'stdin', 'get') + .mockImplementation(() => + createMockedStdinStream('foobar', { isTTY: true }) + ) + + const buf = await getStdin() + expect(buf).toBeInstanceOf(Buffer) + expect(buf).toHaveLength(0) + expect(buf.toString()).toBe('') + }) + + it('reads buffer from stdin', async () => { + jest + .spyOn(process, 'stdin', 'get') + .mockImplementation(() => createMockedStdinStream('foobar')) + + const buf = await getStdin() + expect(buf).toBeInstanceOf(Buffer) + expect(buf).toHaveLength(6) + expect(buf.toString()).toBe('foobar') + }) + + it('shows info message 3 seconds after reading stream', async () => { + jest + .spyOn(process, 'stdin', 'get') + .mockImplementation(() => + createMockedStdinStream('foobar', { delay: 3210 }) + ) + + jest.spyOn(cli, 'info').mockImplementation() + + const buf = await getStdin() + expect(buf).toBeInstanceOf(Buffer) + expect(buf).toHaveLength(6) + expect(buf.toString()).toBe('foobar') + + expect(cli.info).toHaveBeenCalledTimes(1) + expect(cli.info).toHaveBeenCalledWith( + expect.stringContaining('Currently waiting data from stdin stream') + ) + }, 7000) +}) diff --git a/test/watcher.ts b/test/watcher.ts index b5ea611f..8ad66d94 100644 --- a/test/watcher.ts +++ b/test/watcher.ts @@ -68,19 +68,25 @@ describe('Watcher', () => { // Chokidar events const on = watcher.chokidar.on as jest.Mock + expect(on).toHaveBeenCalledWith('all', expect.any(Function)) expect(on).toHaveBeenCalledWith('change', expect.any(Function)) expect(on).toHaveBeenCalledWith('add', expect.any(Function)) expect(on).toHaveBeenCalledWith('unlink', expect.any(Function)) + const onAll = on.mock.calls.find(([e]) => e === 'all')[1] const onChange = on.mock.calls.find(([e]) => e === 'change')[1] const onAdd = on.mock.calls.find(([e]) => e === 'add')[1] const onUnlink = on.mock.calls.find(([e]) => e === 'unlink')[1] // Callbacks + const log = jest.spyOn(watcher as any, 'log') const conv = jest.spyOn(watcher as any, 'convert').mockImplementation() const del = jest.spyOn(watcher as any, 'delete').mockImplementation() try { + onAll('event', 'path') + expect(log).toHaveBeenCalledWith('event', 'path') + onChange('change') expect(conv).toHaveBeenCalledWith('change')