@ndp-software/lit-md 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ import type { DocNode, CodeNode } from './parser.ts'
2
+
3
+ const langAliases: Record<string, string> = {
4
+ typescript: 'ts',
5
+ javascript: 'js',
6
+ }
7
+
8
+ /** Merges consecutive code blocks of the same language */
9
+ function mergeConsecutiveCodeBlocks(nodes: DocNode[]): DocNode[] {
10
+ if (nodes.length === 0) return nodes
11
+
12
+ const result: DocNode[] = []
13
+ let currentCodeBlock: CodeNode | null = null
14
+
15
+ for (const node of nodes) {
16
+ if (node.kind === 'code') {
17
+ if (currentCodeBlock && currentCodeBlock.lang === node.lang && !currentCodeBlock.title && !node.title) {
18
+ // Merge with current block
19
+ currentCodeBlock.text += '\n\n' + node.text
20
+ } else {
21
+ // Save previous block and start new one
22
+ if (currentCodeBlock) result.push(currentCodeBlock)
23
+ currentCodeBlock = { ...node }
24
+ }
25
+ } else {
26
+ // Non-code node: flush current block and add this node
27
+ if (currentCodeBlock) {
28
+ result.push(currentCodeBlock)
29
+ currentCodeBlock = null
30
+ }
31
+ result.push(node)
32
+ }
33
+ }
34
+
35
+ // Don't forget the last code block
36
+ if (currentCodeBlock) result.push(currentCodeBlock)
37
+
38
+ return result
39
+ }
40
+
41
+ export function render(nodes: DocNode[], describeFormat: string = 'hidden'): string {
42
+ const merged = mergeConsecutiveCodeBlocks(nodes.filter(n => n.kind !== 'output-file-display'))
43
+ if (!merged.length) return ''
44
+ let out = ''
45
+ let lastHeaderLevel = 0
46
+ for (let i = 0; i < merged.length; i++) {
47
+ if (i > 0) {
48
+ const prev = merged[i - 1]!
49
+ const curr = merged[i]!
50
+ const noBlank = (prev.kind === 'prose' && prev.noBlankAfter) ||
51
+ (curr.kind === 'prose' && curr.noBlankBefore)
52
+ out += noBlank ? '\n' : '\n\n'
53
+ }
54
+ const rendered = renderNode(merged[i]!, describeFormat, lastHeaderLevel)
55
+ out += rendered
56
+ // Update lastHeaderLevel after rendering
57
+ const node = merged[i]!
58
+ if (node.kind === 'describe' && describeFormat !== 'hidden') {
59
+ const baseLevel = describeFormat === 'auto' ? lastHeaderLevel : describeFormat.length
60
+ lastHeaderLevel = baseLevel + node.depth
61
+ lastHeaderLevel = Math.min(lastHeaderLevel, 6)
62
+ } else if (node.kind === 'prose') {
63
+ // Check for headers in prose and update lastHeaderLevel
64
+ const proseHeaderLevel = getMaxHeaderLevelInProse(node.text)
65
+ if (proseHeaderLevel > 0) {
66
+ lastHeaderLevel = proseHeaderLevel
67
+ }
68
+ }
69
+ }
70
+ return out
71
+ }
72
+
73
+ function renderNode(node: DocNode, describeFormat: string = 'hidden', lastHeaderLevel: number = 0): string {
74
+ if (node.kind === 'prose') return node.text
75
+ if (node.kind === 'output-file-display') return ''
76
+ if (node.kind === 'describe') {
77
+ if (describeFormat === 'hidden') return ''
78
+ let baseLevel: number
79
+ if (describeFormat === 'auto') {
80
+ // If no headers yet, start at h1. Otherwise, go one level deeper than last header
81
+ baseLevel = lastHeaderLevel === 0 ? 1 : lastHeaderLevel + 1
82
+ } else {
83
+ // Explicit format (e.g., "#", "##", etc.)
84
+ baseLevel = describeFormat.length > 0 ? describeFormat.length : 1
85
+ }
86
+ const level = baseLevel + node.depth
87
+ const hashes = '#'.repeat(Math.min(level, 6))
88
+ return `${hashes} ${node.name}`
89
+ }
90
+ const lang = langAliases[node.lang] ?? node.lang
91
+ // Omit language if it's 'text'
92
+ const info = lang === 'text' ? (node.title ?? '') : (node.title ? `${lang} ${node.title}` : lang)
93
+
94
+ // Use dynamic fence delimiters to handle nested code blocks
95
+ // Find the longest sequence of backticks in the content
96
+ const maxBackticks = findMaxBacktickSequence(node.text)
97
+ const fenceLength = Math.max(3, maxBackticks + 1)
98
+ const fence = '`'.repeat(fenceLength)
99
+
100
+ return `${fence}${info}\n${node.text}\n${fence}`
101
+ }
102
+
103
+ function findMaxBacktickSequence(text: string): number {
104
+ let maxSeq = 0
105
+ let currentSeq = 0
106
+ for (const char of text) {
107
+ if (char === '`') {
108
+ currentSeq++
109
+ maxSeq = Math.max(maxSeq, currentSeq)
110
+ } else {
111
+ currentSeq = 0
112
+ }
113
+ }
114
+ return maxSeq
115
+ }
116
+
117
+ /** Detects the maximum header level in prose text (1-6) */
118
+ function getMaxHeaderLevelInProse(text: string): number {
119
+ let maxLevel = 0
120
+ const lines = text.split('\n')
121
+ for (const line of lines) {
122
+ // Match lines that start with # characters
123
+ const match = line.match(/^(#+)\s/)
124
+ if (match) {
125
+ const level = match[1].length
126
+ maxLevel = Math.max(maxLevel, Math.min(level, 6))
127
+ }
128
+ }
129
+ return maxLevel
130
+ }
@@ -0,0 +1,65 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import type { DocNode, OutputFileDisplayNode } from './parser.ts'
6
+
7
+ /**
8
+ * Resolves `output-file-display` nodes by using cached execution results (if available) or
9
+ * executing the shell command, reading the output file, and replacing the node with a code block.
10
+ * Updates the preceding prose summary to end with `:` (instead of `.`) when content is shown.
11
+ * Handles empty files and command failures appropriately.
12
+ */
13
+ export function resolveOutputFiles(nodes: DocNode[]): DocNode[] {
14
+ const result: DocNode[] = []
15
+ for (const node of nodes) {
16
+ if (node.kind !== 'output-file-display') {
17
+ result.push(node)
18
+ continue
19
+ }
20
+ const content = runAndCapture(node)
21
+ if (content !== null) {
22
+ const prev = result[result.length - 1]
23
+ if (content.trim()) {
24
+ // File has content: add code block, change period to colon in preceding prose
25
+ if (prev?.kind === 'prose' && prev.text.endsWith('.')) {
26
+ result[result.length - 1] = { ...prev, text: prev.text.slice(0, -1) + ':', noBlankAfter: true }
27
+ }
28
+ result.push({ kind: 'code', lang: node.lang, text: content.trimEnd() })
29
+ } else {
30
+ // File is empty: replace period or colon with " is empty."
31
+ if (prev?.kind === 'prose' && (prev.text.endsWith(':') || prev.text.endsWith('.'))) {
32
+ result[result.length - 1] = { ...prev, text: prev.text.slice(0, -1) + ' is empty.' }
33
+ }
34
+ }
35
+ }
36
+ // If content is null (command failed), silently drop the display node
37
+ }
38
+ return result
39
+ }
40
+
41
+ function runAndCapture(node: OutputFileDisplayNode): string | null {
42
+ // Use cached execution if available
43
+ if (node.execution) {
44
+ if (node.execution.exitCode !== 0) return null
45
+ const content = node.execution.outputFiles.get(node.path)
46
+ return content ?? null
47
+ }
48
+
49
+ // Fall back to direct execution if no cache
50
+ const tmpDir = mkdtempSync(join(tmpdir(), 'lit-md-cap-'))
51
+ try {
52
+ for (const f of node.inputFiles) {
53
+ writeFileSync(join(tmpDir, f.path), f.content, 'utf8')
54
+ }
55
+ const result = spawnSync(node.cmd, { shell: true, encoding: 'utf8', cwd: tmpDir })
56
+ if (result.status !== 0) return null
57
+ try {
58
+ return readFileSync(join(tmpDir, node.path), 'utf8')
59
+ } catch {
60
+ return null
61
+ }
62
+ } finally {
63
+ rmSync(tmpDir, { recursive: true, force: true })
64
+ }
65
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,362 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { readFileSync, writeFileSync, unlinkSync, mkdtempSync, rmSync, existsSync } from 'node:fs'
3
+ import { isAbsolute, resolve, join, dirname, extname } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { test, describe } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+
8
+ export { test as example, test as metaExample, describe } from 'node:test'
9
+
10
+ // Module-level alias registry: name → resolved shell command string
11
+ const _aliases = new Map<string, string>()
12
+
13
+ /**
14
+ * Register a shell alias. `cmdString` may be a plain path or a command with
15
+ * arguments (e.g. `'node --experimental-strip-types ./cli.ts'`). Any token
16
+ * that looks like a file path (contains `/` or starts with `.`) is resolved
17
+ * relative to `process.cwd()` at call time. The resulting alias is prepended
18
+ * to every shell command executed by `shellExample`.
19
+ *
20
+ * Alias calls produce **no markdown output**.
21
+ */
22
+ export function alias(name: string, cmdString: string): void {
23
+ const resolved = resolveCmdPath(cmdString)
24
+ _aliases.set(name, resolved)
25
+ }
26
+
27
+ /** Internal: clear all registered aliases. Used in tests for isolation. */
28
+ export function _clearAliases(): void {
29
+ _aliases.clear()
30
+ }
31
+
32
+ /** Resolve path-like tokens in a command string to absolute paths. */
33
+ function resolveCmdPath(cmdString: string): string {
34
+ return cmdString.replace(/\S+/g, token => {
35
+ if (token.startsWith('/') || token.startsWith('./') || token.startsWith('../') ||
36
+ (!isAbsolute(token) && token.includes('/'))) {
37
+ return resolve(process.cwd(), token)
38
+ }
39
+ return token
40
+ })
41
+ }
42
+
43
+ /** Build shell alias prefix lines to prepend to commands. */
44
+ function buildAliasPrefix(): string {
45
+ if (_aliases.size === 0) return ''
46
+ const lines = [..._aliases.entries()].map(([name, cmd]) => `alias ${name}='${cmd}'`)
47
+ return lines.join('\n') + '\n'
48
+ }
49
+
50
+ /** Check if content matches a pattern (string or regex). */
51
+ function matchesPattern(content: string, pattern: string | RegExp): boolean {
52
+ return pattern instanceof RegExp ? pattern.test(content) : content.includes(pattern)
53
+ }
54
+
55
+ export interface ShellFileAssertion {
56
+ path: string
57
+ contains?: string | RegExp
58
+ matches?: string | RegExp
59
+ displayPath?: boolean | 'hidden'
60
+ summary?: boolean
61
+ }
62
+
63
+ type ExampleInputFile = {
64
+ path: string;
65
+ content: string;
66
+ displayPath?: boolean | 'hidden';
67
+ display?: boolean | 'hidden';
68
+ summary?: boolean
69
+ }
70
+
71
+ export interface ShellExampleOpts {
72
+ stdout?: { contains?: string | RegExp; matches?: string | RegExp; display?: boolean }
73
+ outputFiles?: ShellFileAssertion[]
74
+ inputFiles?: Array<ExampleInputFile>
75
+ displayCommand?: boolean | 'hidden'
76
+ meta?: boolean
77
+ exitCode?: number
78
+ timeout?: number // Timeout in milliseconds, default 3000
79
+ }
80
+
81
+ /** Internal: executes a shell command and runs any assertions. Throws on failure.
82
+ * Exported for direct testing. */
83
+ export function _runShellExample(cmd: string, opts: ShellExampleOpts): void {
84
+ const tmpDir = mkdtempSync(join(tmpdir(), 'lit-md-shell-'))
85
+ const resolvePath = (p: string) => isAbsolute(p) ? p : join(tmpDir, p)
86
+ try {
87
+ for (const f of opts.inputFiles ?? []) {
88
+ writeFileSync(resolvePath(f.path), f.content, 'utf8')
89
+ }
90
+ const prefix = buildAliasPrefix()
91
+ const fullCmd = prefix ? `${prefix}${cmd}` : cmd
92
+ const timeoutMs = opts.timeout ?? 3000
93
+ const result = spawnSync(fullCmd, { shell: true, encoding: 'utf8', cwd: tmpDir, timeout: timeoutMs })
94
+
95
+ // Check for timeout error
96
+ if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
97
+ throw new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`)
98
+ }
99
+
100
+ const actualExitCode = result.status ?? 1
101
+ if (opts.exitCode !== undefined) {
102
+ if (actualExitCode !== opts.exitCode) {
103
+ const err = result.stderr || result.error?.message || ''
104
+ throw new Error(`Command failed: ${cmd}\nexit ${actualExitCode} (expected exit code ${opts.exitCode})${err ? ': ' + err : ''}`)
105
+ }
106
+ } else if (actualExitCode !== 0) {
107
+ const err = result.stderr || result.error?.message || ''
108
+ throw new Error(`Command failed: ${cmd}\nexit ${actualExitCode}${err ? ': ' + err : ''}`)
109
+ }
110
+ const stdout = result.stdout
111
+ if (opts.stdout !== undefined) {
112
+ if (opts.stdout.contains !== undefined) {
113
+ const actualDesc = stdout === '' ? '(empty)' : stdout
114
+ assert.ok(
115
+ matchesPattern(stdout, opts.stdout.contains),
116
+ `stdout did not contain: ${JSON.stringify(opts.stdout.contains)}\nActual: ${actualDesc}`
117
+ )
118
+ }
119
+ if (opts.stdout.matches !== undefined) {
120
+ const actualDesc = stdout === '' ? '(empty)' : stdout
121
+ assert.ok(
122
+ matchesPattern(stdout, opts.stdout.matches),
123
+ `stdout did not match: ${opts.stdout.matches}\nActual: ${actualDesc}`
124
+ )
125
+ }
126
+ }
127
+ for (const fa of opts.outputFiles ?? []) {
128
+ let content: string
129
+ try {
130
+ content = readFileSync(resolvePath(fa.path), 'utf8')
131
+ } catch (e) {
132
+ const err = e as NodeJS.ErrnoException
133
+ if (err.code === 'ENOENT') {
134
+ throw new Error(`Output file not found: ${fa.path}\n\nThe command may not have created this file, or it may be in a different location.\nCommand: ${cmd}`)
135
+ }
136
+ throw e
137
+ }
138
+ const actualDesc = content === '' ? '(empty)' : content
139
+ if (fa.contains !== undefined) {
140
+ assert.ok(
141
+ matchesPattern(content, fa.contains),
142
+ `file ${fa.path} does not contain: ${JSON.stringify(fa.contains)}\nActual:\n${actualDesc}`
143
+ )
144
+ }
145
+ if (fa.matches !== undefined) {
146
+ assert.ok(
147
+ matchesPattern(content, fa.matches),
148
+ `file ${fa.path} does not match: ${fa.matches}\nActual:\n${actualDesc}`
149
+ )
150
+ }
151
+ }
152
+ // Clean up absolute-path inputFiles (relative ones are removed with tmpDir below)
153
+ for (const f of opts.inputFiles ?? []) {
154
+ if (isAbsolute(f.path)) try { unlinkSync(f.path) } catch {}
155
+ }
156
+ } finally {
157
+ rmSync(tmpDir, { recursive: true, force: true })
158
+ }
159
+ }
160
+
161
+
162
+
163
+ /** Registers a node:test test that executes the shell command and verifies assertions. */
164
+ export function shellExample(cmd: string, opts: ShellExampleOpts = {}): void {
165
+ const timeoutMs = opts.timeout ?? 3000
166
+ test(cmd, { timeout: timeoutMs }, () => _runShellExample(cmd, opts))
167
+ }
168
+
169
+ /**
170
+ * Returns `'--experimental-strip-types'` on Node.js versions where the flag is required
171
+ * (v22.6–v23.5), or `''` on versions where TypeScript stripping is stable (v23.6+).
172
+ */
173
+ export function stripTypesFlag(): string {
174
+ const parts = process.versions.node.split('.')
175
+ const major = parseInt(parts[0] ?? '0', 10)
176
+ const minor = parseInt(parts[1] ?? '0', 10)
177
+ if (major === 22 && minor >= 6) return '--experimental-strip-types'
178
+ if (major === 23 && minor < 6) return '--experimental-strip-types'
179
+ return ''
180
+ }
181
+
182
+ /**
183
+ * Extract import paths from a TypeScript/JavaScript file using regex.
184
+ * Returns absolute paths to files that could be imported.
185
+ */
186
+ function extractImportPaths(filePath: string, baseDir: string): Set<string> {
187
+ const importedPaths = new Set<string>()
188
+ try {
189
+ const content = readFileSync(filePath, 'utf8')
190
+
191
+ // Match import statements: import ... from 'path' or "path"
192
+ const importRegex = /import\s+(?:(?:{[^}]*})|(?:\*\s+as\s+\w+)|(?:\w+))?(?:\s*,\s*(?:{[^}]*}|\*\s+as\s+\w+|\w+))*\s+from\s+['"]([^'"]+)['"]/g
193
+
194
+ // Match require statements: require('path') or require("path")
195
+ const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
196
+
197
+ let match
198
+ while ((match = importRegex.exec(content)) !== null) {
199
+ const importPath = match[1]
200
+ if (importPath && !importPath.startsWith('.') && !importPath.startsWith('/')) {
201
+ // Skip node_modules and absolute imports
202
+ continue
203
+ }
204
+ const resolved = resolveImportPath(importPath, baseDir)
205
+ if (resolved) importedPaths.add(resolved)
206
+ }
207
+
208
+ while ((match = requireRegex.exec(content)) !== null) {
209
+ const importPath = match[1]
210
+ if (importPath && !importPath.startsWith('.') && !importPath.startsWith('/')) {
211
+ continue
212
+ }
213
+ const resolved = resolveImportPath(importPath, baseDir)
214
+ if (resolved) importedPaths.add(resolved)
215
+ }
216
+ } catch {
217
+ // If we can't read the file, skip it
218
+ }
219
+
220
+ return importedPaths
221
+ }
222
+
223
+ /**
224
+ * Resolve an import path to an actual file path.
225
+ * Handles .ts, .js, .tsx, .jsx extensions and directory index files.
226
+ */
227
+ function resolveImportPath(importPath: string, baseDir: string): string | null {
228
+ const basePath = resolve(baseDir, importPath)
229
+
230
+ // Try the path as-is
231
+ if (existsSync(basePath)) return basePath
232
+
233
+ // Try with common extensions
234
+ for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
235
+ if (existsSync(basePath + ext)) {
236
+ return basePath + ext
237
+ }
238
+ }
239
+
240
+ // Try as a directory with index file
241
+ for (const indexFile of ['index.ts', 'index.tsx', 'index.js', 'index.jsx']) {
242
+ const indexPath = join(basePath, indexFile)
243
+ if (existsSync(indexPath)) {
244
+ return indexPath
245
+ }
246
+ }
247
+
248
+ return null
249
+ }
250
+
251
+ /**
252
+ * Recursively collect all dependencies of input files.
253
+ * Returns a set of all files that should be watched.
254
+ */
255
+ function collectAllDependencies(inputPaths: string[], visited = new Set<string>()): Set<string> {
256
+ const allDeps = new Set<string>(inputPaths.map(p => resolve(p)))
257
+ const toProcess = [...inputPaths]
258
+
259
+ while (toProcess.length > 0) {
260
+ const current = toProcess.shift()!
261
+ const resolvedCurrent = resolve(current)
262
+
263
+ if (visited.has(resolvedCurrent)) continue
264
+ visited.add(resolvedCurrent)
265
+
266
+ const deps = extractImportPaths(resolvedCurrent, dirname(resolvedCurrent))
267
+ for (const dep of deps) {
268
+ allDeps.add(dep)
269
+ if (!visited.has(dep)) {
270
+ toProcess.push(dep)
271
+ }
272
+ }
273
+ }
274
+
275
+ return allDeps
276
+ }
277
+
278
+ /**
279
+ * Watch input files for changes and listen for keyboard input.
280
+ * Returns 'spacebar' if user presses spacebar, or 'filechange' if a file changes.
281
+ * Exit cleanly on Ctrl+C (SIGINT). Gracefully handles non-TTY environments by
282
+ * resolving immediately.
283
+ */
284
+ export async function watchFilesAndWait(inputPaths: string[]): Promise<'spacebar' | 'filechange'> {
285
+ // Check if stdin is a TTY (interactive terminal)
286
+ if (!process.stdin.isTTY) {
287
+ // Non-interactive environment: resolve immediately without waiting
288
+ return 'spacebar'
289
+ }
290
+
291
+ const { watch } = await import('node:fs')
292
+
293
+ let lastChangeTime = 0
294
+ const DEBOUNCE_MS = 300
295
+ let changeDetected = false
296
+
297
+ // Collect all dependencies to watch, not just the input files
298
+ const filesToWatch = collectAllDependencies(inputPaths)
299
+
300
+ const watchers = Array.from(filesToWatch).map(filePath => {
301
+ return watch(filePath, (eventType) => {
302
+ const now = Date.now()
303
+ // Debounce: only consider changes if enough time has passed
304
+ if (now - lastChangeTime >= DEBOUNCE_MS) {
305
+ lastChangeTime = now
306
+ changeDetected = true
307
+ }
308
+ })
309
+ })
310
+
311
+ // Set up signal handlers for clean exit
312
+ const exitHandler = () => {
313
+ watchers.forEach(w => w.close())
314
+ process.stdin.setRawMode(false)
315
+ process.exit(0)
316
+ }
317
+
318
+ process.on('SIGINT', exitHandler)
319
+ process.on('SIGTERM', exitHandler)
320
+
321
+ // Display wait message
322
+ console.error('Press space to regenerate, q to quit...')
323
+
324
+ // Set raw mode to detect individual key presses
325
+ process.stdin.setRawMode(true)
326
+ process.stdin.resume()
327
+
328
+ return new Promise<'spacebar' | 'filechange'>(resolve => {
329
+ const onData = (data: Buffer) => {
330
+ const char = data[0]
331
+ // 0x20 is the spacebar
332
+ if (char === 0x20) {
333
+ cleanup()
334
+ resolve('spacebar')
335
+ } else if (char === 0x03 || char === 0x71 || char === 0x78 || char === 0x1b) {
336
+ // Ctrl+C (0x03), q (0x71), x (0x78), or esc (0x1b)
337
+ cleanup()
338
+ process.exit(0)
339
+ }
340
+ }
341
+
342
+ const checkForChanges = setInterval(() => {
343
+ if (changeDetected) {
344
+ cleanup()
345
+ console.error('Files changed, regenerating...')
346
+ resolve('filechange')
347
+ }
348
+ }, 50)
349
+
350
+ const cleanup = () => {
351
+ process.stdin.off('data', onData)
352
+ clearInterval(checkForChanges)
353
+ process.stdin.setRawMode(false)
354
+ process.stdin.pause()
355
+ process.removeListener('SIGINT', exitHandler)
356
+ process.removeListener('SIGTERM', exitHandler)
357
+ watchers.forEach(w => w.close())
358
+ }
359
+
360
+ process.stdin.on('data', onData)
361
+ })
362
+ }
@@ -0,0 +1,51 @@
1
+ import ts from 'typescript'
2
+ import { join } from 'path'
3
+
4
+ export interface TypecheckResult {
5
+ ok: boolean
6
+ messages: string[]
7
+ }
8
+
9
+ export function typecheck(files: string[]): TypecheckResult {
10
+ // Only look for tsconfig.json in the exact CWD (no upward walk).
11
+ // This matches the user's expectation: run lit-md from the project root,
12
+ // and if a tsconfig.json is there, it will be used.
13
+ const tsConfigPath = join(process.cwd(), 'tsconfig.json')
14
+ const configPath = ts.sys.fileExists(tsConfigPath) ? tsConfigPath : undefined
15
+
16
+ let compilerOptions: ts.CompilerOptions
17
+
18
+ if (configPath) {
19
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
20
+ if (configFile.error) {
21
+ return { ok: false, messages: [ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n')] }
22
+ }
23
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, ts.sys.getCurrentDirectory())
24
+ compilerOptions = { ...parsed.options, noEmit: true }
25
+ } else {
26
+ // Sensible defaults when no tsconfig.json is present
27
+ compilerOptions = {
28
+ strict: true,
29
+ noEmit: true,
30
+ module: ts.ModuleKind.NodeNext,
31
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
32
+ target: ts.ScriptTarget.ESNext,
33
+ allowImportingTsExtensions: true,
34
+ }
35
+ }
36
+
37
+ const program = ts.createProgram(files, compilerOptions)
38
+ const diagnostics = ts.getPreEmitDiagnostics(program)
39
+
40
+ if (!diagnostics.length) return { ok: true, messages: [] }
41
+
42
+ // Use TypeScript's built-in formatting with colors and context for better readability
43
+ const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
44
+ getCanonicalFileName: fileName => fileName,
45
+ getCurrentDirectory: () => process.cwd(),
46
+ getNewLine: () => '\n',
47
+ })
48
+
49
+ return { ok: false, messages: [formatted] }
50
+ }
51
+