Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/

# Build output
dist/
.astro/

# Lock files (using npm)
package-lock.json
Expand Down
57 changes: 57 additions & 0 deletions packages/scribe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# @sailkit/scribe

Test code fences from markdown documentation.

**READ-ONLY FILESYSTEM SAFE** - No temp files, no disk writes. Everything stays in memory.

## Usage

```bash
scribe <path> # Test a file or directory
scribe docs/ # Scan all .md/.mdx files
scribe README.md # Test a single file
```

All JavaScript/TypeScript code blocks are tested by default. Use `nocheck` to skip:

~~~markdown
```typescript nocheck
// This block won't be tested
```
~~~

## Writing Testable Examples

Code blocks should be self-contained and use assertions. Node's `assert` module is globally available:

```typescript
const items = ['a', 'b', 'c']
assert.strictEqual(items[0], 'a')
assert.deepStrictEqual(items, ['a', 'b', 'c'])
```

No import needed - `assert` is injected by scribe. Code remains copy-pasteable since `assert` is a standard Node module.

## CLI Options

- `-i, --runInBand` - Run tests sequentially (no parallelism)
- `-j, --parallel <n>` - Set number of parallel workers (default: CPU count)

## API

```typescript
import { parseMarkdown, filterTestableBlocks, runBlocks } from '@sailkit/scribe'

const blocks = parseMarkdown(content, 'file.md')
const testable = filterTestableBlocks(blocks)
const results = await runBlocks(testable)
```

## Future

Scribe will evolve beyond static analysis to enable interactive documentation:

- **In-browser execution** - Run code blocks directly on the page
- **Live verification** - See pass/fail results as you read
- **Monaco Editor integration** - Edit and re-run examples inline
- **Playground mode** - Experiment with code without leaving the docs
31 changes: 31 additions & 0 deletions packages/scribe/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@sailkit/scribe",
"version": "0.0.1",
"description": "Extract and test code fences from markdown documentation",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"scribe": "dist/cli.js"
},
"scripts": {
"build": "tsc",
"test": "vitest run"
},
"keywords": [
"documentation",
"testing",
"markdown",
"code-fence"
],
"license": "MIT",
"devDependencies": {
"typescript": "^5.0.0",
"vitest": "^4.0.15"
},
"dependencies": {
"commander": "^14.0.2",
"esbuild": "^0.27.1",
"log-update": "^7.0.2"
}
}
160 changes: 160 additions & 0 deletions packages/scribe/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { run, type IO, type RunOptions } from './cli.js'
import { writeFile, mkdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'

// Mock IO that captures output
function createMockIO(): IO & { stdout: string; stderr: string } {
const io = {
stdout: '',
stderr: '',
write(text: string) {
io.stdout += text
},
writeError(text: string) {
io.stderr += text
}
}
return io
}

describe('scribe CLI', () => {
let testDir: string

beforeEach(async () => {
testDir = join(tmpdir(), `scribe-test-${Date.now()}`)
await mkdir(testDir, { recursive: true })
})

it('reports error for non-existent path', async () => {
const io = createMockIO()
const result = await run({ targetPath: '/nonexistent/path', concurrency: 1 }, io)

expect(result.failed).toBe(1)
expect(io.stderr).toContain('Error: Path not found')
})

it('reports no testable blocks for markdown without code fences', async () => {
const mdPath = join(testDir, 'empty.md')
await writeFile(mdPath, '# Hello\n\nNo code here.')

const io = createMockIO()
const result = await run({ targetPath: mdPath, concurrency: 1 }, io)

expect(result.passed).toBe(0)
expect(result.failed).toBe(0)
expect(io.stdout).toContain('No testable code blocks found')
})

it('runs passing JavaScript code blocks', async () => {
const mdPath = join(testDir, 'passing.md')
await writeFile(mdPath, `# Test

\`\`\`javascript
console.log("hello")
\`\`\`
`)

const io = createMockIO()
const result = await run({ targetPath: mdPath, concurrency: 1 }, io)

expect(result.passed).toBe(1)
expect(result.failed).toBe(0)
expect(io.stdout).toContain('Results: 1/1 passed')
})

it('runs passing TypeScript code blocks', async () => {
const mdPath = join(testDir, 'typescript.md')
await writeFile(mdPath, `# TypeScript Test

\`\`\`typescript
const x: number = 42
console.log(x)
\`\`\`
`)

const io = createMockIO()
const result = await run({ targetPath: mdPath, concurrency: 1 }, io)

expect(result.passed).toBe(1)
expect(result.failed).toBe(0)
})

it('reports failing code blocks', async () => {
const mdPath = join(testDir, 'failing.md')
await writeFile(mdPath, `# Failing Test

\`\`\`javascript
throw new Error("oops")
\`\`\`
`)

const io = createMockIO()
const result = await run({ targetPath: mdPath, concurrency: 1 }, io)

expect(result.passed).toBe(0)
expect(result.failed).toBe(1)
expect(io.stdout).toContain('Results: 0/1 passed')
})

it('scans directories for markdown files', async () => {
await writeFile(join(testDir, 'a.md'), `\`\`\`js
console.log(1)
\`\`\``)
await writeFile(join(testDir, 'b.md'), `\`\`\`js
console.log(2)
\`\`\``)

const io = createMockIO()
const result = await run({ targetPath: testDir, concurrency: 1 }, io)

expect(result.passed).toBe(2)
expect(result.failed).toBe(0)
expect(io.stdout).toContain('Found 2 markdown file(s)')
})

it('ignores code blocks with nocheck marker', async () => {
const mdPath = join(testDir, 'nocheck.md')
await writeFile(mdPath, `# Test

\`\`\`javascript nocheck
// This should be ignored
throw new Error("not run")
\`\`\`

\`\`\`javascript
console.log("this runs")
\`\`\`
`)

const io = createMockIO()
const result = await run({ targetPath: mdPath, concurrency: 1 }, io)

expect(result.passed).toBe(1)
expect(result.failed).toBe(0)
})

it('runs multiple blocks in parallel when concurrency > 1', async () => {
const mdPath = join(testDir, 'parallel.md')
await writeFile(mdPath, `
\`\`\`js
console.log(1)
\`\`\`

\`\`\`js
console.log(2)
\`\`\`

\`\`\`js
console.log(3)
\`\`\`
`)

const io = createMockIO()
const result = await run({ targetPath: mdPath, concurrency: 3 }, io)

expect(result.passed).toBe(3)
expect(result.failed).toBe(0)
})
})
Loading