@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/src/cli.ts ADDED
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
3
+ import { join, dirname, basename, extname, resolve } from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+ import { parse } from './parser.ts'
6
+ import { render } from './renderer.ts'
7
+ import { typecheck } from './typecheck.ts'
8
+ import { stripTypesFlag, watchFilesAndWait } from './shell.ts'
9
+ import { resolveOutputFiles } from './resolver.ts'
10
+ import { resolveDescribeFormat, resetDescribeFormat } from './describe-format.ts'
11
+
12
+ // --- Argument parsing ---
13
+
14
+ const args = process.argv.slice(2)
15
+
16
+ function extractFlag(flag: string): boolean {
17
+ const idx = args.indexOf(flag)
18
+ if (idx === -1) return false
19
+ args.splice(idx, 1)
20
+ return true
21
+ }
22
+
23
+ function extractFlagValue(flag: string): string | undefined {
24
+ const idx = args.indexOf(flag)
25
+ if (idx === -1) {
26
+ // Check for --flag=value format
27
+ const eqIdx = args.findIndex(arg => arg.startsWith(flag + '='))
28
+ if (eqIdx === -1) return undefined
29
+ const value = args[eqIdx]!.slice(flag.length + 1)
30
+ args.splice(eqIdx, 1)
31
+ return value
32
+ }
33
+ const value = args[idx + 1]
34
+ args.splice(idx, 2)
35
+ return value
36
+ }
37
+
38
+ const showHelp = extractFlag('--help') || extractFlag('-h')
39
+ const dryrun = extractFlag('--dryrun')
40
+ const runTests = extractFlag('--test')
41
+ const runTypecheck = extractFlag('--typecheck')
42
+ const updateSnapshots = extractFlag('--update-snapshots') || extractFlag('-u')
43
+ const wait = extractFlag('--wait')
44
+ const outFlag = extractFlagValue('--out')
45
+ const outputDir = extractFlagValue('--outDir')
46
+ const describeFormat = extractFlagValue('--describe') || '##'
47
+
48
+ // Helper to parse test summary from output
49
+ function parseTestSummary(output: string): { passed: number; failed: number; total: number; hasFailed: boolean } {
50
+ const lines = output.split('\n')
51
+ let stats = { passed: 0, failed: 0, total: 0, hasFailed: false }
52
+
53
+ for (const line of lines) {
54
+ if (line.includes('ℹ pass')) {
55
+ const match = line.match(/pass\s+(\d+)/)
56
+ if (match) stats.passed = parseInt(match[1])
57
+ }
58
+ if (line.includes('ℹ fail')) {
59
+ const match = line.match(/fail\s+(\d+)/)
60
+ if (match) stats.failed = parseInt(match[1])
61
+ }
62
+ if (line.includes('ℹ tests')) {
63
+ const match = line.match(/tests\s+(\d+)/)
64
+ if (match) stats.total = parseInt(match[1])
65
+ }
66
+ }
67
+
68
+ stats.hasFailed = stats.failed > 0
69
+ return stats
70
+ }
71
+
72
+ // Check for unknown options
73
+ const unknownOptions = args.filter(a => a.startsWith('--') || (a.startsWith('-') && a.length > 1 && a !== '-'))
74
+ if (unknownOptions.length > 0) {
75
+ console.error(`error: unknown option${unknownOptions.length > 1 ? 's' : ''}: ${unknownOptions.join(', ')}`)
76
+ process.exit(1)
77
+ }
78
+
79
+ const inputPaths = args.filter(a => !a.startsWith('--'))
80
+
81
+ // --- File name rewriting helper ---
82
+
83
+ function getOutputFileName(inputPath: string): string {
84
+ const base = basename(inputPath)
85
+ // Check if file ends with .lit-md.ts or .lit-md.js pattern
86
+ if (base.endsWith('.lit-md.ts') || base.endsWith('.lit-md.js')) {
87
+ // Remove the entire .lit-md.ts or .lit-md.js extension
88
+ return base.slice(0, -(base.endsWith('.lit-md.ts') ? '.lit-md.ts'.length : '.lit-md.js'.length)) + '.md'
89
+ }
90
+ // Otherwise, remove the final extension (.ts, .js, etc.) and add .md
91
+ return basename(inputPath, extname(inputPath)) + '.md'
92
+ }
93
+
94
+ // --- Help ---
95
+
96
+ if (showHelp) {
97
+ console.log(`lit-md - Generate markdown documentation from test files
98
+
99
+ Usage: lit-md [options] <file.ts|js> [file2 ...]
100
+
101
+ Options:
102
+ --help, -h Show this help message
103
+ --test Run tests before generating markdown
104
+ --typecheck Run type checking before generating markdown
105
+ --dryrun Show what would be written without writing files
106
+ -u, --update-snapshots Update snapshot files instead of generating markdown
107
+ --wait After generating, keep the process alive and watch for file
108
+ changes. Press space to manually regenerate, Ctrl+C to exit.
109
+ Works with --test and --typecheck (reruns on each change).
110
+ --out <output.md> Write to a specific output file (requires single input)
111
+ --outDir <dir> Write generated markdown files to this directory
112
+ --describe <format> Control describe() block rendering (default: ##)
113
+ Formats:
114
+ hidden - Omit describes
115
+ # - Render as h1 headers, nested as h2, h3, etc.
116
+ ## - Render as h2 headers, nested as h3, h4, etc. (default)
117
+ ### - Render as h3 headers, nested as h4, h5, etc.
118
+ #### - Render as h4 headers, nested as h5, h6, etc.
119
+ auto - Dynamically determine level based on document structure
120
+ (h1 if no headers exist, else one level deeper than last header)
121
+
122
+ By default, output is written to stdout. Use --out or --outDir to write to files.
123
+
124
+ Examples:
125
+ lit-md README.md.test.ts # outputs to stdout
126
+ lit-md --test --typecheck README.md.test.ts # outputs to stdout after testing
127
+ lit-md --wait README.md.test.ts # outputs to stdout, then waits for changes
128
+ lit-md --out /tmp/docs.md README.md.test.ts # writes to file
129
+ lit-md --outDir ./docs src/**/*.md.test.ts # writes to directory
130
+ lit-md --describe="#" README.md.test.ts # outputs to stdout with custom format
131
+ lit-md --describe="auto" README.md.test.ts # outputs to stdout with auto format
132
+ `)
133
+ process.exit(0)
134
+ }
135
+
136
+ // --- Validation ---
137
+
138
+ if (!inputPaths.length) {
139
+ console.error('Usage: lit-md [--test] [--typecheck] [--dryrun] [-u|--update-snapshots] [--out <output.md>] [--outDir <dir>] <file.ts|js> [file2 ...]')
140
+ process.exit(1)
141
+ }
142
+
143
+ const validDescribeFormats = ['hidden', 'auto', '#', '##', '###', '####']
144
+ if (!validDescribeFormats.includes(describeFormat)) {
145
+ console.error(`error: invalid --describe format: ${describeFormat}. Valid formats: ${validDescribeFormats.join(', ')}`)
146
+ process.exit(1)
147
+ }
148
+
149
+ if (outFlag && outputDir) {
150
+ console.error('error: --out and --outDir are mutually exclusive')
151
+ process.exit(1)
152
+ }
153
+
154
+ if (outFlag && inputPaths.length > 1) {
155
+ console.error('error: --out can only be used with a single input file')
156
+ process.exit(1)
157
+ }
158
+
159
+ if (runTypecheck) {
160
+ const jsFiles = inputPaths.filter(f => extname(f) === '.js')
161
+ if (jsFiles.length) {
162
+ console.error(`error: --typecheck requires .ts files; received: ${jsFiles.join(', ')}`)
163
+ process.exit(1)
164
+ }
165
+ }
166
+
167
+ // --- Typecheck ---
168
+ // NOTE: Moved into executeTasks() to run on each regeneration when --wait is used
169
+
170
+ // --- Run tests ---
171
+ // NOTE: Moved into executeTasks() to run on each regeneration when --wait is used
172
+
173
+ // --- Generate markdown ---
174
+
175
+ async function generateMarkdown(): Promise<void> {
176
+ let filesGenerated = 0
177
+
178
+ for (const inputPath of inputPaths) {
179
+ // Reset the describe format override before processing each file
180
+ resetDescribeFormat()
181
+
182
+ // Import the file to allow module-level setup (like setDescribeFormat calls)
183
+ const absolutePath = resolve(inputPath)
184
+ try {
185
+ // Suppress test output during import and test execution
186
+ const origStdoutWrite = process.stdout.write
187
+ const origStderrWrite = process.stderr.write
188
+ const origLog = console.log
189
+ const origInfo = console.info
190
+ const origWarn = console.warn
191
+ try {
192
+ process.stdout.write = () => true as any
193
+ process.stderr.write = () => true as any
194
+ console.log = () => {}
195
+ console.info = () => {}
196
+ console.warn = () => {}
197
+ await import(absolutePath)
198
+ // Wait for deferred test execution to complete while output is suppressed
199
+ await new Promise(resolve => setTimeout(resolve, 100))
200
+ } finally {
201
+ process.stdout.write = origStdoutWrite
202
+ process.stderr.write = origStderrWrite
203
+ console.log = origLog
204
+ console.info = origInfo
205
+ console.warn = origWarn
206
+ }
207
+ } catch {
208
+ // File might not be valid JavaScript/TypeScript module, continue
209
+ }
210
+
211
+ const src = readFileSync(inputPath, 'utf8')
212
+ const lang = extname(inputPath) === '.js' ? 'javascript' : 'typescript'
213
+ let nodes = parse(src, lang)
214
+ if (!dryrun) {
215
+ nodes = resolveOutputFiles(nodes)
216
+ }
217
+ // Use resolved format (CLI value + file override)
218
+ const finalDescribeFormat = resolveDescribeFormat(describeFormat)
219
+ const md = render(nodes, finalDescribeFormat)
220
+
221
+ let outPath: string | null = null
222
+ let isStdout = false
223
+ if (updateSnapshots) {
224
+ const outputFileName = getOutputFileName(inputPath)
225
+ const fileNameWithoutMd = outputFileName.slice(0, -3) // Remove .md
226
+ outPath = join(dirname(resolve(inputPath)), `${fileNameWithoutMd}.snapshot.md`)
227
+ } else if (outFlag) {
228
+ outPath = outFlag
229
+ } else if (outputDir) {
230
+ mkdirSync(outputDir, { recursive: true })
231
+ outPath = join(outputDir, getOutputFileName(inputPath))
232
+ } else {
233
+ isStdout = true
234
+ }
235
+
236
+ if (dryrun) {
237
+ if (isStdout) {
238
+ console.error(`dry run: would write to stdout`)
239
+ } else {
240
+ console.error(`dry run: would write ${outPath}`)
241
+ }
242
+ } else if (isStdout) {
243
+ process.stdout.write(md + '\n')
244
+ } else {
245
+ writeFileSync(outPath!, md + '\n', 'utf8')
246
+ filesGenerated++
247
+ }
248
+ }
249
+
250
+ // Show summary if files were written
251
+ if (!dryrun && filesGenerated > 0) {
252
+ const fileWord = filesGenerated === 1 ? 'file' : 'files'
253
+ console.error(`✅ Generated ${filesGenerated} ${fileWord}`)
254
+ }
255
+ }
256
+
257
+ async function executeTasks(): Promise<void> {
258
+ // Run typecheck before generation (if enabled)
259
+ if (runTypecheck) {
260
+ const result = typecheck(inputPaths.map(p => resolve(p)))
261
+ if (!result.ok) {
262
+ for (const msg of result.messages) console.error(msg)
263
+ console.error('❌ Typecheck failed')
264
+ // In wait mode, report error but continue; in normal mode, exit
265
+ if (!wait) process.exit(1)
266
+ // Continue to markdown generation even if typecheck failed
267
+ } else {
268
+ console.error('✅ Typecheck passed')
269
+ }
270
+ }
271
+
272
+ // Run tests before generation (if enabled)
273
+ if (runTests) {
274
+ const stripFlag = stripTypesFlag()
275
+ const nodeArgs = ['--test', ...(stripFlag ? [stripFlag] : []), ...inputPaths.map(p => resolve(p))]
276
+
277
+ // In wait mode, capture output for summary display; otherwise inherit (show full output)
278
+ const spawnOptions = wait ? { encoding: 'utf-8' as const } : { stdio: 'inherit' as const, env: process.env }
279
+
280
+ const result = spawnSync(process.execPath, nodeArgs, spawnOptions)
281
+
282
+ // Handle output based on mode
283
+ if (wait && result.stdout) {
284
+ // In wait mode: capture output and show condensed summary
285
+ const output = result.stdout.toString()
286
+ const stats = parseTestSummary(output)
287
+
288
+ if (stats.hasFailed) {
289
+ // Extract and show failures section
290
+ const failureStart = output.indexOf('✖ failing tests')
291
+ if (failureStart !== -1) {
292
+ const failureSection = output.substring(failureStart)
293
+ console.error(failureSection)
294
+ }
295
+ console.error(`\n❌ Tests failed: ${stats.failed}/${stats.total} failed`)
296
+ } else {
297
+ // All passed: show one-line summary
298
+ console.log(`✅ Tests passed: ${stats.passed} passed`)
299
+ }
300
+ }
301
+
302
+ if (result.status !== 0) {
303
+ // In wait mode, report error but continue; in normal mode, exit
304
+ if (!wait) process.exit(result.status ?? 1)
305
+ // Continue to markdown generation even if tests failed
306
+ }
307
+ }
308
+
309
+ // Generate markdown (always do this, even if tests/typecheck failed)
310
+ await generateMarkdown()
311
+ }
312
+
313
+ ;(async () => {
314
+ await executeTasks()
315
+
316
+ // If --wait flag is set and we're in an interactive terminal, enter the watch loop
317
+ if (wait && process.stdin.isTTY) {
318
+ while (true) {
319
+ const trigger = await watchFilesAndWait(inputPaths)
320
+ // On spacebar or file change, regenerate
321
+ await executeTasks()
322
+ }
323
+ }
324
+ })()
325
+
326
+
327
+
@@ -0,0 +1,53 @@
1
+ export type DescribeFormatType = 'hidden' | '#' | '##' | '###' | '####' | 'auto'
2
+
3
+ let overrideFormat: DescribeFormatType | undefined = undefined
4
+
5
+ /**
6
+ * Set the describe format for the current file, overriding the CLI --describe option.
7
+ * This must be called at the top of the file, before any examples or describes.
8
+ *
9
+ * @param format - The format to use for rendering describe() block names:
10
+ * - 'hidden': omit describe names (default)
11
+ * - '#', '##', '###', '####': render as H1-H4 headers with nesting support
12
+ * - 'auto': dynamically determine level based on document structure (h1 if no headers exist, else one level deeper than last header)
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { setDescribeFormat } from '@ndp-software/lit-md'
17
+ * setDescribeFormat('##')
18
+ * // All describe() blocks in this file will be rendered as H2+ headers
19
+ * ```
20
+ */
21
+ export function setDescribeFormat(format: DescribeFormatType): void {
22
+ if (overrideFormat !== undefined && overrideFormat !== format) {
23
+ console.warn(`setDescribeFormat called multiple times with different formats: ${overrideFormat} -> ${format}. Only the last call will be used.`)
24
+ }
25
+ overrideFormat = format
26
+ }
27
+
28
+ /**
29
+ * Get the current describe format override (if any).
30
+ * Returns undefined if no override has been set.
31
+ * @internal
32
+ */
33
+ export function getDescribeFormatOverride(): DescribeFormatType | undefined {
34
+ return overrideFormat
35
+ }
36
+
37
+ /**
38
+ * Reset the describe format override.
39
+ * Mainly useful for testing.
40
+ * @internal
41
+ */
42
+ export function resetDescribeFormat(): void {
43
+ overrideFormat = undefined
44
+ }
45
+
46
+ /**
47
+ * Resolve the final describe format to use.
48
+ * Takes the CLI format and applies the override if set.
49
+ * @internal
50
+ */
51
+ export function resolveDescribeFormat(cliFormat: string): string {
52
+ return overrideFormat ?? cliFormat
53
+ }
@@ -0,0 +1,126 @@
1
+ import {
2
+ describe,
3
+ example,
4
+ shellExample,
5
+ metaExample,
6
+ alias,
7
+ stripTypesFlag,
8
+ setDescribeFormat
9
+ } from '../index.ts'
10
+ import assert from 'node:assert/strict'
11
+
12
+ const _flag = stripTypesFlag()
13
+ alias('lit-md', ['node', _flag, './src/cli.ts'].filter(Boolean).join(' '))
14
+
15
+ /*
16
+ # @ndp-software/lit-md
17
+
18
+ Literate test files that generate `README.md`s.
19
+
20
+ ## Introduction
21
+
22
+ Some projects, especially libraries, require numerous and
23
+ detailed code examples. For those responsible for updating
24
+ a README, or other documentation, this can be burdensome
25
+ and error-prone.
26
+
27
+ With lit-md, you write your documentation with embedded code samples
28
+ that are automatically type-checked and run through node to
29
+ verify them. Every example actually works!
30
+
31
+ Key features:
32
+ - works with Typescript or Javascript
33
+ - provides utilities to include shell commands and their outputs
34
+ as part of your documentation. This is important if you tool has
35
+ a CLI, or you just need to show how it works in the terminal.
36
+ - supports flexible assertion methods for both code examples and shell commands,
37
+ with options to display actual outputs in the generated markdown
38
+ - fully tested with its own test suite, which also serves as
39
+ documentation and examples for users
40
+
41
+ There _are_ other tools with the same aims (e.g. [TwoSlash](https://github.com/microsoft/TypeScript-Website/tree/v2/packages/ts-twoslasher)),
42
+ but this follows in the Literate programming tradition but updated
43
+ for the Typescript and TDD era. I have aimed to provide a great DX
44
+ for writing documentation, with a simple syntax and powerful features
45
+ that work well with the node ecosystem.
46
+
47
+ ```sh
48
+ # You can "run" your documentation as a test:
49
+ node --test README.lit-md.ts
50
+ # You can also typecheck:
51
+ tsc README.lit-md.ts
52
+ # An it can be converted from Typescript to a plain old markdown
53
+ # README file with `lit-md`:
54
+ lit-md README.lit-md.ts # generates README.md
55
+
56
+ # Or, you can do it all in one step with:
57
+ lit-md --test --typecheck README.lit-md.ts # all-in-one!
58
+ ```
59
+ ## How it Works
60
+ A **lit-md** file contains prose in comments and examples in test bodies.
61
+ At a basic level, a file is processed and
62
+ - comments are directly transferred into markdown, and
63
+ - example (or `test`, `it`, `spec`) bodies become fenced code blocks.
64
+ To make this work well, there are quite a few nuances and features to control
65
+ what appears in the output and how it looks.
66
+
67
+ # A simple example
68
+ */
69
+ shellExample(`lit-md tmp.ts --out out.md`, {
70
+ inputFiles: [{
71
+ path: 'tmp.ts',
72
+ displayPath: false,
73
+ content: `
74
+ describe('My Project README.', () => {
75
+ /*
76
+ This is a really great project!
77
+ Adding numbers is as simple as using the "+" operator:
78
+ */
79
+ example('add example', () => {
80
+ const a = 1
81
+ const b = 2
82
+ console.log(a + b) // => 3
83
+ })
84
+
85
+ // Also supported is multiplication:
86
+ example('multiply example', () => {
87
+ const x = 3
88
+ const y = 4
89
+ console.log(x * y) // => 12
90
+ })
91
+ })
92
+ `
93
+ }],
94
+ outputFiles: [{
95
+ path: "out.md"
96
+ }]
97
+ })
98
+
99
+ /*
100
+ For more information on the CLI, see [CLI documentation](./docs/cli.md).
101
+ */
102
+
103
+ describe('Shell examples', () => {
104
+ /*
105
+ Use `shellExample` to include executable shell commands in the README.
106
+ It verifies a 0 return code and provides flexible assertion and display options.
107
+ */
108
+
109
+ describe('shellExample function', () => {
110
+ shellExample('echo "hello world"', { meta: true, stdout: { display: true } })
111
+ })
112
+
113
+ /*
114
+ For more information on the `shellExample`, see [shellCommand documentation](./docs/shell-examples.md).
115
+ */
116
+ })
117
+
118
+ /*
119
+ ## CREDITS
120
+
121
+ Built by Andrew J. Peterson, although there was some manual code changes,
122
+ most of the code was Github Copilot CLI, using mostly Claude Haiku 4.5
123
+ and some Claude Sonnet 4.6.
124
+ Most tasks used a plan-autopilot loop, but other approaches were used as well.
125
+ */
126
+
@@ -0,0 +1,44 @@
1
+ import {alias, describe, stripTypesFlag, shellExample} from '../index.ts'
2
+
3
+ const _flag = stripTypesFlag()
4
+ alias('lit-md', ['node', _flag, './src/cli.ts'].filter(Boolean).join(' '))
5
+
6
+ describe('CLI', () => {
7
+ // By default, output is written to stdout.
8
+ shellExample('lit-md tmp.ts', {
9
+ displayCommand: true,
10
+ inputFiles: [{
11
+ path: 'tmp.ts',
12
+ content: `// # My Document\nimport { example } from 'node:test'\nexample('test', () => {})`
13
+ }],
14
+ stdout: {
15
+ contains: '# My Document',
16
+ display: true
17
+ }
18
+ })
19
+
20
+ describe('Custom output path', () => {
21
+ // Use --out to write to a different location.
22
+ shellExample('lit-md tmp.ts --out /tmp/docs.md', {
23
+ displayCommand: true,
24
+ inputFiles: [{
25
+ path: 'tmp.ts',
26
+ content: `// # Documentation\nimport { example } from 'node:test'`
27
+ }],
28
+ outputFiles: [{
29
+ path: '/tmp/docs.md',
30
+ contains: '# Documentation'
31
+ }]
32
+ })
33
+ })
34
+
35
+ describe('Help', () => {
36
+ // Use `lit-md --help` for options.
37
+ shellExample('lit-md --help', {
38
+ displayCommand: true,
39
+ stdout: {
40
+ display: true
41
+ }
42
+ })
43
+ })
44
+ })