@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.
- package/LICENSE.md +3 -0
- package/README.md +125 -0
- package/dist/cli.js +1273 -0
- package/dist/cli.js.map +7 -0
- package/docs/cli.md +74 -0
- package/docs/how-wait-mode-works.md +381 -0
- package/docs/shell-examples.md +254 -0
- package/package.json +58 -0
- package/src/cli.ts +327 -0
- package/src/describe-format.ts +53 -0
- package/src/docs/README.lit-md.ts +126 -0
- package/src/docs/cli.lit-md.ts +44 -0
- package/src/docs/shell-examples.lit-md.ts +243 -0
- package/src/index.ts +7 -0
- package/src/parser.ts +970 -0
- package/src/renderer.ts +130 -0
- package/src/resolver.ts +65 -0
- package/src/shell.ts +362 -0
- package/src/typecheck.ts +51 -0
package/src/renderer.ts
ADDED
|
@@ -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
|
+
}
|
package/src/resolver.ts
ADDED
|
@@ -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
|
+
}
|
package/src/typecheck.ts
ADDED
|
@@ -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
|
+
|