Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary
51 changes: 43 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,28 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
name: Test
lint:
name: Lint
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22, 24]
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false

- name: Set up mise
uses: jdx/mise-action@v3
with:
cache: true
mise_toml: |
[tools]
node = "${{ matrix.node-version }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand All @@ -32,9 +36,40 @@ jobs:
- name: Run linter
run: npm run lint

- name: Check formatting
run: npm run format:check

- name: Typecheck
run: npm run typecheck

test:
name: Test (${{ matrix.os }}, Node ${{ matrix.node-version }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [22, 24]
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false

- name: Set up mise
uses: jdx/mise-action@v3
with:
cache: true
mise_toml: |
[tools]
node = "${{ matrix.node-version }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

Expand Down
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
dist/
coverage/
test/fixtures/
CHANGELOG.md
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Follow these guidelines when modifying the repository.

## Setup

- Use **Node.js 22.16** (or the version specified in `mise.toml`). Node 18+ is required.
- Use **Node.js 22.16** (or the version specified in `mise.toml`). Node 22+ is required (`engines` in `package.json`).
- Install dependencies with `npm install`.
- Run the TypeScript build with `npm run build` (or `npm run dev` for watch mode).
- Run the linter with `npm run lint` (use `npm run lint:fix` to autofix).
Expand Down Expand Up @@ -40,7 +40,7 @@ Follow these guidelines when modifying the repository.

## Continuous Integration

- GitHub Actions (`.github/workflows/ci.yml`) install dependencies, run linting, typecheck, build the project, and execute tests on Node 22 and 24.
- GitHub Actions (`.github/workflows/ci.yml`) run lint, `format:check` and typecheck once, then build, test and `test:package` on Ubuntu, macOS and Windows with Node 22 and 24. Keep paths, globs and test expectations portable (use `path.join`, not `/`).

## Useful Scripts

Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ npm run build

# Lint code
npm run lint

# Check formatting (npm run format to fix)
npm run format:check
```

## Project Structure
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ syntax as a local path:

````markdown
```ts snippet=https://raw.githubusercontent.com/example/project/main/app.ts#L10-L20

```
````

Expand Down Expand Up @@ -285,6 +286,11 @@ Create a `.markdown-coderc.json` file in your project root:
| **includeExtensions** | File extensions to consider for snippets and extraction | `[".ts", ".js", ".tsx", ".jsx", ".py", ".rb", ".go", ".rs", ".java", ".cpp", ".c", ".cs", ".php", ".sh", ".bash", ".zsh", ".fish", ".json", ".yaml", ".yml", ".toml", ".xml", ".html", ".css", ".scss", ".less", ".sql", ".md", ".txt"]` |
| **missingSnippetSeverity** | Severity for missing local snippet files (`error` or `warning`) | `"error"` |

On Windows, `\` in `markdownGlob` and `excludeGlob` is treated as a path
separator, so `docs\**\*.md` works. To match a literal glob character such as
`[` or `(` there, use a bracket expression (`docs/[[]draft].md`) instead of a
backslash escape.

## Eject Safety

`eject` is destructive: it removes snippet directives, deletes the configured
Expand Down
18 changes: 11 additions & 7 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,29 +59,33 @@ Use a fence info string with a `snippet=` metadata token:

````markdown
```bash snippet=path/to/file.sh

```

```ts snippet=src/utils/auth.ts#L10-L20

```

```ts snippet=src/utils/auth.ts#L5

```

```ts snippet=src/utils/auth.ts#L5-

```
````

Paths are relative to `snippetRoot` in `.markdown-coderc.json` (the runtime default is the project root; `init` writes `./snippets`). For Workflow B referencing source files, use paths relative to the project root. Use `markdownGlob: "**/*.{md,mdx}"` when both Markdown and MDX files should be discovered.

## Commands

| Command | Description |
|---------|-------------|
| `npx markdown-code init` | Create `.markdown-coderc.json` and `snippets/` directory |
| `npx markdown-code extract` | Extract code blocks to snippet files and insert directives (Workflow A) |
| `npx markdown-code sync` | Populate markdown code blocks from snippet files (Workflow B) |
| `npx markdown-code check` | Verify markdown matches snippet files — exits non-zero if drift or an error-severity missing snippet is detected |
| `npx markdown-code eject` | Remove snippet directives, snippets, and config after confirmation |
| Command | Description |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `npx markdown-code init` | Create `.markdown-coderc.json` and `snippets/` directory |
| `npx markdown-code extract` | Extract code blocks to snippet files and insert directives (Workflow A) |
| `npx markdown-code sync` | Populate markdown code blocks from snippet files (Workflow B) |
| `npx markdown-code check` | Verify markdown matches snippet files — exits non-zero if drift or an error-severity missing snippet is detected |
| `npx markdown-code eject` | Remove snippet directives, snippets, and config after confirmation |

## CI Integration

Expand Down
15 changes: 12 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import tseslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";

export default [
{
ignores: ["dist/**", "coverage/**", "test/fixtures/**"],
},
js.configs.recommended,
{
files: ["src/**/*.ts"],
files: ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.mjs", "*.config.ts"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: "./tsconfig.json",
project: "./tsconfig.eslint.json",
},
globals: {
...globals.node,
Expand Down Expand Up @@ -42,7 +45,7 @@ export default [
"max-len": ["error", { code: 100, ignoreUrls: true }],

// TypeScript-specific rules (Airbnb-inspired)
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-unused-vars": ["error", { ignoreRestSiblings: true }],
"@typescript-eslint/no-use-before-define": "error",
"@typescript-eslint/no-shadow": "error",
"@typescript-eslint/prefer-optional-chain": "error",
Expand All @@ -52,4 +55,10 @@ export default [
"@typescript-eslint/explicit-module-boundary-types": "off",
},
},
{
files: ["test/**/*.ts"],
rules: {
"max-len": "off",
},
},
];
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"format": "prettier --write \"**/*.ts\" \"**/*.md\"",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format": "prettier --write \"**/*.{ts,mjs,md}\"",
"format:check": "prettier --check \"**/*.{ts,mjs,md}\"",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prepublishOnly": "npm run build",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
7 changes: 5 additions & 2 deletions scripts/smoke-test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,14 @@ try {
mdCode('md-code', ['check'], project);

const exported = run(
'node',
process.execPath,
[
'--input-type=module',
'--eval',
"const m = await import('markdown-code'); console.log([typeof m.syncMarkdownFiles, m.VERSION].join(' '));",
[
"const m = await import('markdown-code');",
"console.log(typeof m.syncMarkdownFiles + ' ' + m.VERSION);",
].join(' '),
],
project,
);
Expand Down
8 changes: 2 additions & 6 deletions src/commands/eject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { writeFile, rm, unlink, realpath } from 'node:fs/promises';
import { parse as parsePath, resolve } from 'node:path';
import { createInterface } from 'node:readline/promises';
import type { ArgumentsCamelCase, Argv } from 'yargs';
import fg from 'fast-glob';
import { findMarkdownFiles } from '../glob.js';
import {
configExists,
configPathOrDefault,
Expand Down Expand Up @@ -63,11 +63,7 @@ export async function removeSnippetDirectives(config: RuntimeConfig) {
};

try {
const markdownFiles = await fg(config.markdownGlob, {
ignore: config.excludeGlob,
cwd: config.workingDir,
absolute: true,
});
const markdownFiles = await findMarkdownFiles(config);

for (const filePath of markdownFiles) {
try {
Expand Down
28 changes: 28 additions & 0 deletions src/glob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { resolve } from 'node:path';
import fg from 'fast-glob';

export function toGlobPattern(
pattern: string,
platform: NodeJS.Platform = process.platform,
): string {
return platform === 'win32' ? pattern.replaceAll('\\', '/') : pattern;
}

export interface MarkdownFileQuery {
markdownGlob: string;
excludeGlob: ReadonlyArray<string>;
workingDir: string;
}

export async function findMarkdownFiles({
markdownGlob,
excludeGlob,
workingDir,
}: MarkdownFileQuery): Promise<Array<string>> {
const files = await fg(toGlobPattern(markdownGlob), {
ignore: excludeGlob.map((pattern) => toGlobPattern(pattern)),
cwd: workingDir,
absolute: true,
});
return files.map((file) => resolve(file));
}
20 changes: 16 additions & 4 deletions src/snippet-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, realpath } from 'node:fs/promises';
import { resolve, dirname, isAbsolute } from 'node:path';
import { resolve, dirname, isAbsolute, relative } from 'node:path';
import type { Issue, RuntimeConfig, SnippetDirective } from './types.js';
import type { DocumentCodeBlock } from './markdown-document.js';
import { fileExists, isInWorkingDir } from './utils.js';
Expand Down Expand Up @@ -34,10 +34,22 @@ export function isManagedCodeBlock(
return codeBlock.directive !== undefined;
}

function allowedSnippetRoots(config: RuntimeConfig): Array<string> {
function realpathOrSelf(path: string): Promise<string> {
return realpath(path).catch(() => path);
}

async function allowedSnippetRoots(
config: RuntimeConfig,
): Promise<Array<string>> {
const workingDir = resolve(config.workingDir);
const snippetRoot = resolve(workingDir, config.snippetRoot || '.');
return snippetRoot !== workingDir ? [workingDir, snippetRoot] : [workingDir];
const realWorkingDir = await realpathOrSelf(workingDir);
const realSnippetRoot = isInWorkingDir(snippetRoot, workingDir)
? resolve(realWorkingDir, relative(workingDir, snippetRoot))
: await realpathOrSelf(snippetRoot);
return realSnippetRoot !== realWorkingDir
? [realWorkingDir, realSnippetRoot]
: [realWorkingDir];
}

function countLines(content: string): number {
Expand Down Expand Up @@ -152,7 +164,7 @@ async function readLocalSnippet(
};
}

if (!isInWorkingDir(realPath, allowedSnippetRoots(config))) {
if (!isInWorkingDir(realPath, await allowedSnippetRoots(config))) {
return { ok: false, step: 'contain' };
}

Expand Down
8 changes: 2 additions & 6 deletions src/sync-plan.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import fg from 'fast-glob';
import { findMarkdownFiles } from './glob.js';
import type { Issue, RuntimeConfig } from './types.js';
import { readMarkdownDocument } from './parser.js';
import { createIssue } from './issues.js';
Expand Down Expand Up @@ -80,11 +80,7 @@ async function* planFiles(
export async function planSync(config: RuntimeConfig): Promise<SyncPlan> {
let markdownFiles: Array<string>;
try {
markdownFiles = await fg(config.markdownGlob, {
ignore: config.excludeGlob,
cwd: config.workingDir,
absolute: true,
});
markdownFiles = await findMarkdownFiles(config);
} catch (error) {
return {
status: 'error',
Expand Down
17 changes: 8 additions & 9 deletions src/sync.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { writeFile, mkdir } from 'node:fs/promises';
import { join, resolve, basename, extname } from 'node:path';
import { createRequire } from 'node:module';
import fg from 'fast-glob';
import { findMarkdownFiles } from './glob.js';
import { fileExists, isInWorkingDir } from './utils.js';
import type {
RuntimeConfig,
Expand Down Expand Up @@ -167,10 +167,9 @@ export async function extractSnippets(
}

try {
const markdownFiles = await fg(config.markdownGlob, {
ignore: config.excludeGlob,
cwd: workingDir ?? process.cwd(),
absolute: true,
const markdownFiles = await findMarkdownFiles({
...config,
workingDir: workingDir ?? process.cwd(),
});

for (const filePath of markdownFiles) {
Expand Down Expand Up @@ -267,10 +266,10 @@ export async function discoverCodeBlocks(
};

try {
const markdownFiles = await fg(markdownGlob, {
ignore: excludeGlob,
cwd: workingDir,
absolute: true,
const markdownFiles = await findMarkdownFiles({
markdownGlob,
excludeGlob,
workingDir,
});

for (const filePath of markdownFiles) {
Expand Down
Loading
Loading