@safetnsr/md-pipe 0.1.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 +21 -0
- package/README.md +178 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +255 -0
- package/dist/core/config.d.ts +18 -0
- package/dist/core/config.js +97 -0
- package/dist/core/matcher.d.ts +26 -0
- package/dist/core/matcher.js +121 -0
- package/dist/core/once.d.ts +9 -0
- package/dist/core/once.js +53 -0
- package/dist/core/runner.d.ts +11 -0
- package/dist/core/runner.js +74 -0
- package/dist/core/test-file.d.ts +12 -0
- package/dist/core/test-file.js +46 -0
- package/dist/core/watcher.d.ts +20 -0
- package/dist/core/watcher.js +65 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 safetnsr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# md-pipe
|
|
2
|
+
|
|
3
|
+
Event-driven automation for markdown directories.
|
|
4
|
+
`entr` watches files. `md-pipe` understands them.
|
|
5
|
+
|
|
6
|
+
12 lines of YAML → your markdown folder auto-publishes, auto-indexes, and auto-archives based on frontmatter changes. Works with any `.md` directory — Obsidian vaults, agent workspaces, docs-as-code repos, digital gardens.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx @safetnsr/md-pipe init
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or install globally:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g @safetnsr/md-pipe
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 1. Create config
|
|
24
|
+
md-pipe init
|
|
25
|
+
|
|
26
|
+
# 2. Edit .md-pipe.yml with your triggers
|
|
27
|
+
|
|
28
|
+
# 3. Start watching
|
|
29
|
+
md-pipe watch
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Config (.md-pipe.yml)
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
watch: ./docs
|
|
36
|
+
|
|
37
|
+
triggers:
|
|
38
|
+
- name: publish
|
|
39
|
+
match:
|
|
40
|
+
path: "posts/**"
|
|
41
|
+
frontmatter:
|
|
42
|
+
status: publish
|
|
43
|
+
run: "./scripts/deploy.sh $FILE"
|
|
44
|
+
|
|
45
|
+
- name: reindex
|
|
46
|
+
match:
|
|
47
|
+
frontmatter_changed:
|
|
48
|
+
- title
|
|
49
|
+
- tags
|
|
50
|
+
- category
|
|
51
|
+
run: "./scripts/reindex.sh $FILE"
|
|
52
|
+
|
|
53
|
+
- name: urgent-notify
|
|
54
|
+
match:
|
|
55
|
+
tags:
|
|
56
|
+
- urgent
|
|
57
|
+
run: "curl -X POST $WEBHOOK -d '{\"file\": \"$FILE\"}'"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
| Command | Description |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `md-pipe init` | Scaffold a `.md-pipe.yml` config file |
|
|
65
|
+
| `md-pipe watch` | Start watching for changes and trigger actions |
|
|
66
|
+
| `md-pipe once` | Run triggers against current files (CI/batch mode) |
|
|
67
|
+
| `md-pipe test <file>` | Show which triggers match a specific file |
|
|
68
|
+
|
|
69
|
+
## Flags
|
|
70
|
+
|
|
71
|
+
| Flag | Description |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `--config, -c <path>` | Path to config file |
|
|
74
|
+
| `--dry-run` | Show matches without executing actions |
|
|
75
|
+
| `--json` | Output in JSON format |
|
|
76
|
+
| `--verbose` | Show detailed output |
|
|
77
|
+
| `--version, -v` | Show version |
|
|
78
|
+
| `--help, -h` | Show help |
|
|
79
|
+
|
|
80
|
+
## Trigger Matchers
|
|
81
|
+
|
|
82
|
+
### `path` — glob pattern matching
|
|
83
|
+
```yaml
|
|
84
|
+
match:
|
|
85
|
+
path: "posts/**/*.md"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `frontmatter` — match specific frontmatter values
|
|
89
|
+
```yaml
|
|
90
|
+
match:
|
|
91
|
+
frontmatter:
|
|
92
|
+
status: publish
|
|
93
|
+
type: blog
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `frontmatter_changed` — fires when specific fields change
|
|
97
|
+
```yaml
|
|
98
|
+
match:
|
|
99
|
+
frontmatter_changed:
|
|
100
|
+
- title
|
|
101
|
+
- tags
|
|
102
|
+
```
|
|
103
|
+
This is the key differentiator over generic file watchers. `md-pipe` tracks frontmatter state and only triggers when the fields you care about actually change.
|
|
104
|
+
|
|
105
|
+
### `tags` — match files with specific tags
|
|
106
|
+
```yaml
|
|
107
|
+
match:
|
|
108
|
+
tags:
|
|
109
|
+
- urgent
|
|
110
|
+
- review
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Matchers can be combined — all conditions must match for the trigger to fire.
|
|
114
|
+
|
|
115
|
+
## Environment Variables
|
|
116
|
+
|
|
117
|
+
Scripts receive these env vars:
|
|
118
|
+
|
|
119
|
+
| Variable | Description |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `$FILE` | Absolute path to the changed file |
|
|
122
|
+
| `$DIR` | Directory containing the file |
|
|
123
|
+
| `$BASENAME` | Filename without directory |
|
|
124
|
+
| `$RELATIVE` | Path relative to watch directory |
|
|
125
|
+
| `$FRONTMATTER` | JSON string of all frontmatter |
|
|
126
|
+
| `$DIFF` | JSON string of changed frontmatter fields |
|
|
127
|
+
| `$TAGS` | Comma-separated list of tags |
|
|
128
|
+
|
|
129
|
+
## Agent Interface (--json)
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
md-pipe once --json
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"total": 42,
|
|
138
|
+
"matched": 3,
|
|
139
|
+
"actions": [
|
|
140
|
+
{
|
|
141
|
+
"triggerName": "publish",
|
|
142
|
+
"filePath": "/docs/posts/hello.md",
|
|
143
|
+
"command": "./scripts/deploy.sh /docs/posts/hello.md",
|
|
144
|
+
"exitCode": 0,
|
|
145
|
+
"stdout": "deployed",
|
|
146
|
+
"stderr": "",
|
|
147
|
+
"durationMs": 150
|
|
148
|
+
}
|
|
149
|
+
],
|
|
150
|
+
"errors": []
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
md-pipe watch --json
|
|
156
|
+
# streams one JSON object per triggered action
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Use Cases
|
|
160
|
+
|
|
161
|
+
**Docs-as-code auto-deploy** — publish pages when `status: publish` is set in frontmatter
|
|
162
|
+
|
|
163
|
+
**Agent workspace automation** — trigger reindexing when an agent updates `AGENTS.md` or `NOW.md`
|
|
164
|
+
|
|
165
|
+
**Obsidian vault pipeline** — auto-archive notes, generate backlinks, push to git on tag changes
|
|
166
|
+
|
|
167
|
+
**Digital garden** — auto-generate RSS feed when new posts are added
|
|
168
|
+
|
|
169
|
+
**CI/CD for markdown** — `md-pipe once` in your CI pipeline to validate or transform docs
|
|
170
|
+
|
|
171
|
+
## Pair With
|
|
172
|
+
|
|
173
|
+
- [`@safetnsr/md-kit`](https://github.com/safetnsr/md-kit) — markdown workspace toolkit (wikilinks, broken links, backlink maps)
|
|
174
|
+
- [`@safetnsr/ai-ready`](https://github.com/safetnsr/ai-ready) — AI-compatibility score for your codebase
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const path_1 = require("path");
|
|
10
|
+
const config_js_1 = require("./core/config.js");
|
|
11
|
+
const watcher_js_1 = require("./core/watcher.js");
|
|
12
|
+
const once_js_1 = require("./core/once.js");
|
|
13
|
+
const test_file_js_1 = require("./core/test-file.js");
|
|
14
|
+
const VERSION = '0.1.0';
|
|
15
|
+
function printHelp() {
|
|
16
|
+
console.log(`
|
|
17
|
+
${chalk_1.default.bold('md-pipe')} — event-driven automation for markdown directories
|
|
18
|
+
${chalk_1.default.dim('entr watches files. md-pipe understands them.')}
|
|
19
|
+
|
|
20
|
+
${chalk_1.default.bold('Usage:')}
|
|
21
|
+
md-pipe init Scaffold a .md-pipe.yml config file
|
|
22
|
+
md-pipe watch Start watching for changes and trigger actions
|
|
23
|
+
md-pipe once Run triggers against current files (CI/batch mode)
|
|
24
|
+
md-pipe test <file> Show which triggers match a specific file
|
|
25
|
+
|
|
26
|
+
${chalk_1.default.bold('Flags:')}
|
|
27
|
+
--config, -c <path> Path to config file (default: .md-pipe.yml)
|
|
28
|
+
--dry-run Show matches without executing actions
|
|
29
|
+
--json Output in JSON format
|
|
30
|
+
--verbose Show detailed output
|
|
31
|
+
--version, -v Show version
|
|
32
|
+
--help, -h Show this help
|
|
33
|
+
|
|
34
|
+
${chalk_1.default.bold('Config (.md-pipe.yml):')}
|
|
35
|
+
watch: ./docs
|
|
36
|
+
triggers:
|
|
37
|
+
- name: publish
|
|
38
|
+
match:
|
|
39
|
+
path: "posts/**"
|
|
40
|
+
frontmatter: { status: publish }
|
|
41
|
+
run: "echo Publishing $FILE"
|
|
42
|
+
|
|
43
|
+
${chalk_1.default.bold('Env vars passed to scripts:')}
|
|
44
|
+
$FILE Absolute path to the changed file
|
|
45
|
+
$DIR Directory containing the file
|
|
46
|
+
$BASENAME Filename without directory
|
|
47
|
+
$RELATIVE Path relative to watch directory
|
|
48
|
+
$FRONTMATTER JSON string of all frontmatter
|
|
49
|
+
$DIFF JSON string of changed frontmatter fields
|
|
50
|
+
$TAGS Comma-separated list of tags
|
|
51
|
+
|
|
52
|
+
${chalk_1.default.bold('More:')} https://github.com/safetnsr/md-pipe
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
function parseArgs(args) {
|
|
56
|
+
let command = '';
|
|
57
|
+
let file;
|
|
58
|
+
let config;
|
|
59
|
+
let dryRun = false;
|
|
60
|
+
let json = false;
|
|
61
|
+
let verbose = false;
|
|
62
|
+
for (let i = 0; i < args.length; i++) {
|
|
63
|
+
const arg = args[i];
|
|
64
|
+
if (arg === '--help' || arg === '-h') {
|
|
65
|
+
printHelp();
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
if (arg === '--version' || arg === '-v') {
|
|
69
|
+
console.log(VERSION);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
if (arg === '--dry-run') {
|
|
73
|
+
dryRun = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (arg === '--json') {
|
|
77
|
+
json = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (arg === '--verbose') {
|
|
81
|
+
verbose = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (arg === '--config' || arg === '-c') {
|
|
85
|
+
config = args[++i];
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!command) {
|
|
89
|
+
command = arg;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (command === 'test' && !file) {
|
|
93
|
+
file = arg;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { command: command || 'help', file, config, dryRun, json, verbose };
|
|
98
|
+
}
|
|
99
|
+
function getConfig(configPath) {
|
|
100
|
+
const cwd = process.cwd();
|
|
101
|
+
const cfgPath = configPath || (0, config_js_1.findConfigFile)(cwd);
|
|
102
|
+
if (!cfgPath) {
|
|
103
|
+
console.error(chalk_1.default.red('No .md-pipe.yml found. Run `md-pipe init` to create one.'));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
return (0, config_js_1.loadConfig)(cfgPath);
|
|
107
|
+
}
|
|
108
|
+
async function cmdInit() {
|
|
109
|
+
const target = (0, path_1.resolve)(process.cwd(), '.md-pipe.yml');
|
|
110
|
+
if ((0, fs_1.existsSync)(target)) {
|
|
111
|
+
console.error(chalk_1.default.yellow('.md-pipe.yml already exists. Delete it first to re-initialize.'));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
(0, fs_1.writeFileSync)(target, (0, config_js_1.generateDefaultConfig)(), 'utf-8');
|
|
115
|
+
console.log(chalk_1.default.green('✓') + ' Created .md-pipe.yml');
|
|
116
|
+
console.log(chalk_1.default.dim(' Edit the config, then run: md-pipe watch'));
|
|
117
|
+
}
|
|
118
|
+
async function cmdWatch(opts) {
|
|
119
|
+
const config = getConfig(opts.config);
|
|
120
|
+
const watcher = new watcher_js_1.MdPipeWatcher(config, opts.dryRun);
|
|
121
|
+
if (!(0, fs_1.existsSync)(config.watch)) {
|
|
122
|
+
console.error(chalk_1.default.red(`Watch directory not found: ${config.watch}`));
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
watcher.on('ready', () => {
|
|
126
|
+
console.log(chalk_1.default.green('✓') + ` Watching ${config.watch}`);
|
|
127
|
+
console.log(chalk_1.default.dim(` ${config.triggers.length} trigger(s) loaded`));
|
|
128
|
+
if (opts.dryRun)
|
|
129
|
+
console.log(chalk_1.default.yellow(' [dry-run mode — actions will not execute]'));
|
|
130
|
+
console.log(chalk_1.default.dim(' Press Ctrl+C to stop\n'));
|
|
131
|
+
});
|
|
132
|
+
watcher.on('match', (result) => {
|
|
133
|
+
if (opts.json)
|
|
134
|
+
return; // json mode outputs on action
|
|
135
|
+
const ts = new Date().toLocaleTimeString();
|
|
136
|
+
console.log(chalk_1.default.dim(`[${ts}]`) + ' ' +
|
|
137
|
+
chalk_1.default.cyan(`▸ ${result.trigger.name}`) + ' ' +
|
|
138
|
+
chalk_1.default.dim('matched') + ' ' +
|
|
139
|
+
result.file.relativePath);
|
|
140
|
+
});
|
|
141
|
+
watcher.on('action', (result) => {
|
|
142
|
+
if (opts.json) {
|
|
143
|
+
console.log(JSON.stringify(result));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const status = result.exitCode === 0
|
|
147
|
+
? chalk_1.default.green('✓')
|
|
148
|
+
: chalk_1.default.red(`✗ exit ${result.exitCode}`);
|
|
149
|
+
console.log(` ${status} ${chalk_1.default.dim(result.command)}`);
|
|
150
|
+
if (result.stdout && opts.verbose) {
|
|
151
|
+
console.log(chalk_1.default.dim(' ' + result.stdout.replace(/\n/g, '\n ')));
|
|
152
|
+
}
|
|
153
|
+
if (result.stderr) {
|
|
154
|
+
console.log(chalk_1.default.red(' ' + result.stderr.replace(/\n/g, '\n ')));
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
watcher.on('error', (err, filePath) => {
|
|
158
|
+
console.error(chalk_1.default.red(`Error${filePath ? ` (${filePath})` : ''}: ${err.message}`));
|
|
159
|
+
});
|
|
160
|
+
// Graceful shutdown
|
|
161
|
+
process.on('SIGINT', async () => {
|
|
162
|
+
console.log(chalk_1.default.dim('\nStopping watcher...'));
|
|
163
|
+
await watcher.stop();
|
|
164
|
+
process.exit(0);
|
|
165
|
+
});
|
|
166
|
+
await watcher.start();
|
|
167
|
+
}
|
|
168
|
+
function cmdOnce(opts) {
|
|
169
|
+
const config = getConfig(opts.config);
|
|
170
|
+
if (!(0, fs_1.existsSync)(config.watch)) {
|
|
171
|
+
console.error(chalk_1.default.red(`Watch directory not found: ${config.watch}`));
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const result = (0, once_js_1.runOnce)(config, opts.dryRun);
|
|
175
|
+
if (opts.json) {
|
|
176
|
+
console.log(JSON.stringify(result, null, 2));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
console.log(chalk_1.default.bold('md-pipe once'));
|
|
180
|
+
console.log(chalk_1.default.dim(` Scanned ${result.total} files, ${result.matched} trigger match(es)\n`));
|
|
181
|
+
if (opts.dryRun) {
|
|
182
|
+
console.log(chalk_1.default.yellow(' [dry-run mode — actions were not executed]\n'));
|
|
183
|
+
}
|
|
184
|
+
for (const action of result.actions) {
|
|
185
|
+
const status = action.exitCode === 0
|
|
186
|
+
? chalk_1.default.green('✓')
|
|
187
|
+
: chalk_1.default.red(`✗ exit ${action.exitCode}`);
|
|
188
|
+
console.log(`${status} ${chalk_1.default.cyan(action.triggerName)} → ${chalk_1.default.dim(action.command)}`);
|
|
189
|
+
if (action.stdout && opts.verbose) {
|
|
190
|
+
console.log(chalk_1.default.dim(' ' + action.stdout));
|
|
191
|
+
}
|
|
192
|
+
if (action.stderr) {
|
|
193
|
+
console.log(chalk_1.default.red(' ' + action.stderr));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const err of result.errors) {
|
|
197
|
+
console.error(chalk_1.default.red(err));
|
|
198
|
+
}
|
|
199
|
+
if (result.errors.length > 0)
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
function cmdTest(opts) {
|
|
203
|
+
if (!opts.file) {
|
|
204
|
+
console.error(chalk_1.default.red('Usage: md-pipe test <file>'));
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
const config = getConfig(opts.config);
|
|
208
|
+
const result = (0, test_file_js_1.testFile)(config, opts.file);
|
|
209
|
+
if (opts.json) {
|
|
210
|
+
console.log(JSON.stringify(result, null, 2));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
console.log(chalk_1.default.bold(`Testing: ${result.relativePath}`));
|
|
214
|
+
console.log(chalk_1.default.dim(` Frontmatter: ${JSON.stringify(result.frontmatter)}`));
|
|
215
|
+
console.log(chalk_1.default.dim(` Tags: [${result.tags.join(', ')}]\n`));
|
|
216
|
+
if (result.matches.length === 0) {
|
|
217
|
+
console.log(chalk_1.default.yellow(' No triggers matched this file.'));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
for (const m of result.matches) {
|
|
221
|
+
console.log(chalk_1.default.green(' ✓') + ` ${chalk_1.default.cyan(m.triggerName)}: ${m.reason}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function main() {
|
|
225
|
+
const args = process.argv.slice(2);
|
|
226
|
+
const opts = parseArgs(args);
|
|
227
|
+
try {
|
|
228
|
+
switch (opts.command) {
|
|
229
|
+
case 'init':
|
|
230
|
+
await cmdInit();
|
|
231
|
+
break;
|
|
232
|
+
case 'watch':
|
|
233
|
+
await cmdWatch(opts);
|
|
234
|
+
break;
|
|
235
|
+
case 'once':
|
|
236
|
+
cmdOnce(opts);
|
|
237
|
+
break;
|
|
238
|
+
case 'test':
|
|
239
|
+
cmdTest(opts);
|
|
240
|
+
break;
|
|
241
|
+
case 'help':
|
|
242
|
+
printHelp();
|
|
243
|
+
break;
|
|
244
|
+
default:
|
|
245
|
+
console.error(chalk_1.default.red(`Unknown command: ${opts.command}`));
|
|
246
|
+
printHelp();
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
console.error(chalk_1.default.red(`Error: ${err.message}`));
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
main();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface TriggerMatch {
|
|
2
|
+
path?: string;
|
|
3
|
+
frontmatter?: Record<string, unknown>;
|
|
4
|
+
frontmatter_changed?: string[];
|
|
5
|
+
tags?: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface TriggerDef {
|
|
8
|
+
name: string;
|
|
9
|
+
match: TriggerMatch;
|
|
10
|
+
run: string;
|
|
11
|
+
}
|
|
12
|
+
export interface MdPipeConfig {
|
|
13
|
+
watch: string;
|
|
14
|
+
triggers: TriggerDef[];
|
|
15
|
+
}
|
|
16
|
+
export declare function findConfigFile(dir: string): string | null;
|
|
17
|
+
export declare function loadConfig(configPath: string): MdPipeConfig;
|
|
18
|
+
export declare function generateDefaultConfig(): string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.findConfigFile = findConfigFile;
|
|
7
|
+
exports.loadConfig = loadConfig;
|
|
8
|
+
exports.generateDefaultConfig = generateDefaultConfig;
|
|
9
|
+
const fs_1 = require("fs");
|
|
10
|
+
const path_1 = require("path");
|
|
11
|
+
const yaml_1 = __importDefault(require("yaml"));
|
|
12
|
+
const CONFIG_FILENAMES = ['.md-pipe.yml', '.md-pipe.yaml', 'md-pipe.yml', 'md-pipe.yaml'];
|
|
13
|
+
function findConfigFile(dir) {
|
|
14
|
+
for (const name of CONFIG_FILENAMES) {
|
|
15
|
+
const p = (0, path_1.resolve)(dir, name);
|
|
16
|
+
if ((0, fs_1.existsSync)(p))
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function loadConfig(configPath) {
|
|
22
|
+
const raw = (0, fs_1.readFileSync)(configPath, 'utf-8');
|
|
23
|
+
const parsed = yaml_1.default.parse(raw);
|
|
24
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
25
|
+
throw new Error(`Invalid config: expected YAML object in ${configPath}`);
|
|
26
|
+
}
|
|
27
|
+
if (!parsed.watch || typeof parsed.watch !== 'string') {
|
|
28
|
+
throw new Error(`Config error: 'watch' must be a string path (e.g. "./docs")`);
|
|
29
|
+
}
|
|
30
|
+
if (!Array.isArray(parsed.triggers) || parsed.triggers.length === 0) {
|
|
31
|
+
throw new Error(`Config error: 'triggers' must be a non-empty array`);
|
|
32
|
+
}
|
|
33
|
+
const triggers = [];
|
|
34
|
+
for (let i = 0; i < parsed.triggers.length; i++) {
|
|
35
|
+
const t = parsed.triggers[i];
|
|
36
|
+
if (!t.name || typeof t.name !== 'string') {
|
|
37
|
+
throw new Error(`Config error: trigger[${i}] must have a 'name' string`);
|
|
38
|
+
}
|
|
39
|
+
if (!t.match || typeof t.match !== 'object') {
|
|
40
|
+
throw new Error(`Config error: trigger '${t.name}' must have a 'match' object`);
|
|
41
|
+
}
|
|
42
|
+
if (!t.run || typeof t.run !== 'string') {
|
|
43
|
+
throw new Error(`Config error: trigger '${t.name}' must have a 'run' string`);
|
|
44
|
+
}
|
|
45
|
+
const match = {};
|
|
46
|
+
if (t.match.path)
|
|
47
|
+
match.path = String(t.match.path);
|
|
48
|
+
if (t.match.frontmatter)
|
|
49
|
+
match.frontmatter = t.match.frontmatter;
|
|
50
|
+
if (t.match.frontmatter_changed) {
|
|
51
|
+
if (!Array.isArray(t.match.frontmatter_changed)) {
|
|
52
|
+
throw new Error(`Config error: trigger '${t.name}' frontmatter_changed must be an array`);
|
|
53
|
+
}
|
|
54
|
+
match.frontmatter_changed = t.match.frontmatter_changed.map(String);
|
|
55
|
+
}
|
|
56
|
+
if (t.match.tags) {
|
|
57
|
+
if (!Array.isArray(t.match.tags)) {
|
|
58
|
+
throw new Error(`Config error: trigger '${t.name}' tags must be an array`);
|
|
59
|
+
}
|
|
60
|
+
match.tags = t.match.tags.map(String);
|
|
61
|
+
}
|
|
62
|
+
triggers.push({ name: t.name, match, run: t.run });
|
|
63
|
+
}
|
|
64
|
+
// Resolve watch path relative to config file directory
|
|
65
|
+
const configDir = (0, path_1.dirname)(configPath);
|
|
66
|
+
const watchPath = (0, path_1.resolve)(configDir, parsed.watch);
|
|
67
|
+
return { watch: watchPath, triggers };
|
|
68
|
+
}
|
|
69
|
+
function generateDefaultConfig() {
|
|
70
|
+
return `# md-pipe configuration
|
|
71
|
+
# docs: https://github.com/safetnsr/md-pipe
|
|
72
|
+
|
|
73
|
+
watch: ./docs
|
|
74
|
+
|
|
75
|
+
triggers:
|
|
76
|
+
- name: publish
|
|
77
|
+
match:
|
|
78
|
+
path: "posts/**"
|
|
79
|
+
frontmatter:
|
|
80
|
+
status: publish
|
|
81
|
+
run: "echo Publishing $FILE"
|
|
82
|
+
|
|
83
|
+
- name: reindex
|
|
84
|
+
match:
|
|
85
|
+
frontmatter_changed:
|
|
86
|
+
- title
|
|
87
|
+
- tags
|
|
88
|
+
- category
|
|
89
|
+
run: "echo Reindexing $FILE — changed fields: $DIFF"
|
|
90
|
+
|
|
91
|
+
- name: urgent-notify
|
|
92
|
+
match:
|
|
93
|
+
tags:
|
|
94
|
+
- urgent
|
|
95
|
+
run: "echo URGENT: $FILE needs attention"
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { TriggerDef } from './config.js';
|
|
2
|
+
export interface FileState {
|
|
3
|
+
filePath: string;
|
|
4
|
+
relativePath: string;
|
|
5
|
+
content: string;
|
|
6
|
+
frontmatter: Record<string, unknown>;
|
|
7
|
+
tags: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface MatchResult {
|
|
10
|
+
trigger: TriggerDef;
|
|
11
|
+
file: FileState;
|
|
12
|
+
diff: Record<string, {
|
|
13
|
+
old: unknown;
|
|
14
|
+
new: unknown;
|
|
15
|
+
}> | null;
|
|
16
|
+
}
|
|
17
|
+
export declare function parseMarkdownFile(filePath: string, relativePath: string): FileState;
|
|
18
|
+
export declare function parseMarkdownContent(content: string, filePath: string, relativePath: string): FileState;
|
|
19
|
+
export declare function matchPath(relativePath: string, pattern: string): boolean;
|
|
20
|
+
export declare function matchFrontmatter(frontmatter: Record<string, unknown>, expected: Record<string, unknown>): boolean;
|
|
21
|
+
export declare function matchTags(fileTags: string[], requiredTags: string[]): boolean;
|
|
22
|
+
export declare function computeFrontmatterDiff(oldFm: Record<string, unknown>, newFm: Record<string, unknown>, watchFields: string[]): Record<string, {
|
|
23
|
+
old: unknown;
|
|
24
|
+
new: unknown;
|
|
25
|
+
}> | null;
|
|
26
|
+
export declare function evaluateTrigger(trigger: TriggerDef, file: FileState, previousFrontmatter?: Record<string, unknown>): MatchResult | null;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseMarkdownFile = parseMarkdownFile;
|
|
7
|
+
exports.parseMarkdownContent = parseMarkdownContent;
|
|
8
|
+
exports.matchPath = matchPath;
|
|
9
|
+
exports.matchFrontmatter = matchFrontmatter;
|
|
10
|
+
exports.matchTags = matchTags;
|
|
11
|
+
exports.computeFrontmatterDiff = computeFrontmatterDiff;
|
|
12
|
+
exports.evaluateTrigger = evaluateTrigger;
|
|
13
|
+
const micromatch_1 = __importDefault(require("micromatch"));
|
|
14
|
+
const gray_matter_1 = __importDefault(require("gray-matter"));
|
|
15
|
+
const fs_1 = require("fs");
|
|
16
|
+
function parseMarkdownFile(filePath, relativePath) {
|
|
17
|
+
const raw = (0, fs_1.readFileSync)(filePath, 'utf-8');
|
|
18
|
+
return parseMarkdownContent(raw, filePath, relativePath);
|
|
19
|
+
}
|
|
20
|
+
function parseMarkdownContent(content, filePath, relativePath) {
|
|
21
|
+
let frontmatter = {};
|
|
22
|
+
let tags = [];
|
|
23
|
+
try {
|
|
24
|
+
const parsed = (0, gray_matter_1.default)(content);
|
|
25
|
+
frontmatter = parsed.data || {};
|
|
26
|
+
// Extract tags from frontmatter
|
|
27
|
+
if (Array.isArray(frontmatter.tags)) {
|
|
28
|
+
tags = frontmatter.tags.map(String);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// File has no valid frontmatter — that's fine
|
|
33
|
+
}
|
|
34
|
+
return { filePath, relativePath, content, frontmatter, tags };
|
|
35
|
+
}
|
|
36
|
+
function matchPath(relativePath, pattern) {
|
|
37
|
+
return micromatch_1.default.isMatch(relativePath, pattern);
|
|
38
|
+
}
|
|
39
|
+
function matchFrontmatter(frontmatter, expected) {
|
|
40
|
+
for (const [key, val] of Object.entries(expected)) {
|
|
41
|
+
const actual = frontmatter[key];
|
|
42
|
+
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
|
43
|
+
if (typeof actual !== 'object' || actual === null || Array.isArray(actual))
|
|
44
|
+
return false;
|
|
45
|
+
if (!matchFrontmatter(actual, val))
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
else if (Array.isArray(val)) {
|
|
49
|
+
if (!Array.isArray(actual))
|
|
50
|
+
return false;
|
|
51
|
+
// Check that expected array is a subset of actual
|
|
52
|
+
for (const item of val) {
|
|
53
|
+
if (!actual.includes(item))
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
// Loose equality for string/number comparison
|
|
59
|
+
if (String(actual) !== String(val))
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
function matchTags(fileTags, requiredTags) {
|
|
66
|
+
const normalized = fileTags.map(t => t.replace(/^#/, '').toLowerCase());
|
|
67
|
+
return requiredTags.every(tag => {
|
|
68
|
+
const norm = tag.replace(/^#/, '').toLowerCase();
|
|
69
|
+
return normalized.includes(norm);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function computeFrontmatterDiff(oldFm, newFm, watchFields) {
|
|
73
|
+
const diff = {};
|
|
74
|
+
for (const field of watchFields) {
|
|
75
|
+
const oldVal = oldFm[field];
|
|
76
|
+
const newVal = newFm[field];
|
|
77
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
78
|
+
diff[field] = { old: oldVal ?? null, new: newVal ?? null };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return Object.keys(diff).length > 0 ? diff : null;
|
|
82
|
+
}
|
|
83
|
+
function evaluateTrigger(trigger, file, previousFrontmatter) {
|
|
84
|
+
const { match } = trigger;
|
|
85
|
+
let diff = null;
|
|
86
|
+
// Check path pattern
|
|
87
|
+
if (match.path) {
|
|
88
|
+
if (!matchPath(file.relativePath, match.path))
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
// Check frontmatter values
|
|
92
|
+
if (match.frontmatter) {
|
|
93
|
+
if (!matchFrontmatter(file.frontmatter, match.frontmatter))
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
// Check tags
|
|
97
|
+
if (match.tags) {
|
|
98
|
+
if (!matchTags(file.tags, match.tags))
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
// Check frontmatter_changed (requires previous state)
|
|
102
|
+
if (match.frontmatter_changed) {
|
|
103
|
+
if (!previousFrontmatter) {
|
|
104
|
+
// No previous state — treat as "all fields changed" (new file)
|
|
105
|
+
diff = {};
|
|
106
|
+
for (const field of match.frontmatter_changed) {
|
|
107
|
+
if (file.frontmatter[field] !== undefined) {
|
|
108
|
+
diff[field] = { old: null, new: file.frontmatter[field] };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (Object.keys(diff).length === 0)
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
diff = computeFrontmatterDiff(previousFrontmatter, file.frontmatter, match.frontmatter_changed);
|
|
116
|
+
if (!diff)
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { trigger, file, diff };
|
|
121
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { MdPipeConfig } from './config.js';
|
|
2
|
+
import { RunResult } from './runner.js';
|
|
3
|
+
export interface OnceResult {
|
|
4
|
+
total: number;
|
|
5
|
+
matched: number;
|
|
6
|
+
actions: RunResult[];
|
|
7
|
+
errors: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function runOnce(config: MdPipeConfig, dryRun?: boolean): OnceResult;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runOnce = runOnce;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const matcher_js_1 = require("./matcher.js");
|
|
7
|
+
const runner_js_1 = require("./runner.js");
|
|
8
|
+
function findMarkdownFiles(dir) {
|
|
9
|
+
const results = [];
|
|
10
|
+
function walk(current) {
|
|
11
|
+
const entries = (0, fs_1.readdirSync)(current, { withFileTypes: true });
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const full = (0, path_1.resolve)(current, entry.name);
|
|
14
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
15
|
+
walk(full);
|
|
16
|
+
}
|
|
17
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
18
|
+
results.push(full);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
walk(dir);
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
function runOnce(config, dryRun = false) {
|
|
26
|
+
const files = findMarkdownFiles(config.watch);
|
|
27
|
+
const actions = [];
|
|
28
|
+
const errors = [];
|
|
29
|
+
let matched = 0;
|
|
30
|
+
for (const filePath of files) {
|
|
31
|
+
const relativePath = (0, path_1.relative)(config.watch, filePath);
|
|
32
|
+
let file;
|
|
33
|
+
try {
|
|
34
|
+
file = (0, matcher_js_1.parseMarkdownFile)(filePath, relativePath);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
errors.push(`Error parsing ${filePath}: ${err}`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const trigger of config.triggers) {
|
|
41
|
+
// For once mode, skip frontmatter_changed triggers (no previous state to diff)
|
|
42
|
+
if (trigger.match.frontmatter_changed)
|
|
43
|
+
continue;
|
|
44
|
+
const match = (0, matcher_js_1.evaluateTrigger)(trigger, file);
|
|
45
|
+
if (match) {
|
|
46
|
+
matched++;
|
|
47
|
+
const result = (0, runner_js_1.executeAction)(match, dryRun);
|
|
48
|
+
actions.push(result);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { total: files.length, matched, actions, errors };
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { MatchResult } from './matcher.js';
|
|
2
|
+
export interface RunResult {
|
|
3
|
+
triggerName: string;
|
|
4
|
+
filePath: string;
|
|
5
|
+
command: string;
|
|
6
|
+
exitCode: number;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function executeAction(match: MatchResult, dryRun?: boolean): RunResult;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeAction = executeAction;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
function interpolateCommand(template, match) {
|
|
7
|
+
const { file, diff } = match;
|
|
8
|
+
const dir = (0, path_1.dirname)(file.filePath);
|
|
9
|
+
const tags = file.tags.join(',');
|
|
10
|
+
const fmJson = JSON.stringify(file.frontmatter);
|
|
11
|
+
const diffJson = diff ? JSON.stringify(diff) : '{}';
|
|
12
|
+
return template
|
|
13
|
+
.replace(/\$FILE/g, file.filePath)
|
|
14
|
+
.replace(/\$DIR/g, dir)
|
|
15
|
+
.replace(/\$BASENAME/g, (0, path_1.basename)(file.filePath))
|
|
16
|
+
.replace(/\$RELATIVE/g, file.relativePath)
|
|
17
|
+
.replace(/\$FRONTMATTER/g, fmJson)
|
|
18
|
+
.replace(/\$DIFF/g, diffJson)
|
|
19
|
+
.replace(/\$TAGS/g, tags);
|
|
20
|
+
}
|
|
21
|
+
function executeAction(match, dryRun = false) {
|
|
22
|
+
const command = interpolateCommand(match.trigger.run, match);
|
|
23
|
+
const triggerName = match.trigger.name;
|
|
24
|
+
const filePath = match.file.filePath;
|
|
25
|
+
if (dryRun) {
|
|
26
|
+
return {
|
|
27
|
+
triggerName,
|
|
28
|
+
filePath,
|
|
29
|
+
command,
|
|
30
|
+
exitCode: 0,
|
|
31
|
+
stdout: '[dry run]',
|
|
32
|
+
stderr: '',
|
|
33
|
+
durationMs: 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const start = Date.now();
|
|
37
|
+
try {
|
|
38
|
+
const stdout = (0, child_process_1.execSync)(command, {
|
|
39
|
+
cwd: (0, path_1.dirname)(match.file.filePath),
|
|
40
|
+
timeout: 30000,
|
|
41
|
+
encoding: 'utf-8',
|
|
42
|
+
env: {
|
|
43
|
+
...process.env,
|
|
44
|
+
FILE: match.file.filePath,
|
|
45
|
+
DIR: (0, path_1.dirname)(match.file.filePath),
|
|
46
|
+
BASENAME: (0, path_1.basename)(match.file.filePath),
|
|
47
|
+
RELATIVE: match.file.relativePath,
|
|
48
|
+
FRONTMATTER: JSON.stringify(match.file.frontmatter),
|
|
49
|
+
DIFF: match.diff ? JSON.stringify(match.diff) : '{}',
|
|
50
|
+
TAGS: match.file.tags.join(','),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
triggerName,
|
|
55
|
+
filePath,
|
|
56
|
+
command,
|
|
57
|
+
exitCode: 0,
|
|
58
|
+
stdout: stdout.trim(),
|
|
59
|
+
stderr: '',
|
|
60
|
+
durationMs: Date.now() - start,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
return {
|
|
65
|
+
triggerName,
|
|
66
|
+
filePath,
|
|
67
|
+
command,
|
|
68
|
+
exitCode: err.status ?? 1,
|
|
69
|
+
stdout: err.stdout?.trim() ?? '',
|
|
70
|
+
stderr: err.stderr?.trim() ?? '',
|
|
71
|
+
durationMs: Date.now() - start,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { MdPipeConfig } from './config.js';
|
|
2
|
+
export interface TestResult {
|
|
3
|
+
filePath: string;
|
|
4
|
+
relativePath: string;
|
|
5
|
+
frontmatter: Record<string, unknown>;
|
|
6
|
+
tags: string[];
|
|
7
|
+
matches: Array<{
|
|
8
|
+
triggerName: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
12
|
+
export declare function testFile(config: MdPipeConfig, filePath: string): TestResult;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.testFile = testFile;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const matcher_js_1 = require("./matcher.js");
|
|
7
|
+
function testFile(config, filePath) {
|
|
8
|
+
const absPath = (0, path_1.resolve)(filePath);
|
|
9
|
+
if (!(0, fs_1.existsSync)(absPath)) {
|
|
10
|
+
throw new Error(`File not found: ${absPath}`);
|
|
11
|
+
}
|
|
12
|
+
const relativePath = (0, path_1.relative)(config.watch, absPath);
|
|
13
|
+
const file = (0, matcher_js_1.parseMarkdownFile)(absPath, relativePath);
|
|
14
|
+
const matches = [];
|
|
15
|
+
for (const trigger of config.triggers) {
|
|
16
|
+
// Skip frontmatter_changed in test mode (no previous state)
|
|
17
|
+
if (trigger.match.frontmatter_changed) {
|
|
18
|
+
matches.push({
|
|
19
|
+
triggerName: trigger.name,
|
|
20
|
+
reason: `frontmatter_changed: would fire on changes to [${trigger.match.frontmatter_changed.join(', ')}] (no previous state to diff in test mode)`,
|
|
21
|
+
});
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const result = (0, matcher_js_1.evaluateTrigger)(trigger, file);
|
|
25
|
+
if (result) {
|
|
26
|
+
const reasons = [];
|
|
27
|
+
if (trigger.match.path)
|
|
28
|
+
reasons.push(`path matches "${trigger.match.path}"`);
|
|
29
|
+
if (trigger.match.frontmatter)
|
|
30
|
+
reasons.push(`frontmatter matches ${JSON.stringify(trigger.match.frontmatter)}`);
|
|
31
|
+
if (trigger.match.tags)
|
|
32
|
+
reasons.push(`has tags [${trigger.match.tags.join(', ')}]`);
|
|
33
|
+
matches.push({
|
|
34
|
+
triggerName: trigger.name,
|
|
35
|
+
reason: reasons.join(' + ') || 'all conditions matched',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
filePath: absPath,
|
|
41
|
+
relativePath,
|
|
42
|
+
frontmatter: file.frontmatter,
|
|
43
|
+
tags: file.tags,
|
|
44
|
+
matches,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { MdPipeConfig } from './config.js';
|
|
3
|
+
import { MatchResult } from './matcher.js';
|
|
4
|
+
import { RunResult } from './runner.js';
|
|
5
|
+
export interface WatcherEvents {
|
|
6
|
+
match: (result: MatchResult) => void;
|
|
7
|
+
action: (result: RunResult) => void;
|
|
8
|
+
error: (error: Error, filePath?: string) => void;
|
|
9
|
+
ready: () => void;
|
|
10
|
+
}
|
|
11
|
+
export declare class MdPipeWatcher extends EventEmitter {
|
|
12
|
+
private config;
|
|
13
|
+
private fsWatcher;
|
|
14
|
+
private frontmatterCache;
|
|
15
|
+
private dryRun;
|
|
16
|
+
constructor(config: MdPipeConfig, dryRun?: boolean);
|
|
17
|
+
start(): Promise<void>;
|
|
18
|
+
stop(): Promise<void>;
|
|
19
|
+
private handleFile;
|
|
20
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MdPipeWatcher = void 0;
|
|
4
|
+
const chokidar_1 = require("chokidar");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const events_1 = require("events");
|
|
7
|
+
const matcher_js_1 = require("./matcher.js");
|
|
8
|
+
const runner_js_1 = require("./runner.js");
|
|
9
|
+
class MdPipeWatcher extends events_1.EventEmitter {
|
|
10
|
+
config;
|
|
11
|
+
fsWatcher = null;
|
|
12
|
+
frontmatterCache = new Map();
|
|
13
|
+
dryRun;
|
|
14
|
+
constructor(config, dryRun = false) {
|
|
15
|
+
super();
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.dryRun = dryRun;
|
|
18
|
+
}
|
|
19
|
+
async start() {
|
|
20
|
+
const watchDir = this.config.watch;
|
|
21
|
+
this.fsWatcher = (0, chokidar_1.watch)('**/*.md', {
|
|
22
|
+
cwd: watchDir,
|
|
23
|
+
ignoreInitial: true,
|
|
24
|
+
persistent: true,
|
|
25
|
+
awaitWriteFinish: {
|
|
26
|
+
stabilityThreshold: 200,
|
|
27
|
+
pollInterval: 50,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
this.fsWatcher.on('add', (relPath) => this.handleFile(relPath, 'add'));
|
|
31
|
+
this.fsWatcher.on('change', (relPath) => this.handleFile(relPath, 'change'));
|
|
32
|
+
this.fsWatcher.on('ready', () => this.emit('ready'));
|
|
33
|
+
this.fsWatcher.on('error', (err) => this.emit('error', err instanceof Error ? err : new Error(String(err))));
|
|
34
|
+
}
|
|
35
|
+
async stop() {
|
|
36
|
+
if (this.fsWatcher) {
|
|
37
|
+
await this.fsWatcher.close();
|
|
38
|
+
this.fsWatcher = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
handleFile(relativePath, event) {
|
|
42
|
+
const absolutePath = (0, path_1.resolve)(this.config.watch, relativePath);
|
|
43
|
+
let file;
|
|
44
|
+
try {
|
|
45
|
+
file = (0, matcher_js_1.parseMarkdownFile)(absolutePath, relativePath);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)), absolutePath);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const previousFm = this.frontmatterCache.get(relativePath);
|
|
52
|
+
// Run through triggers
|
|
53
|
+
for (const trigger of this.config.triggers) {
|
|
54
|
+
const match = (0, matcher_js_1.evaluateTrigger)(trigger, file, previousFm);
|
|
55
|
+
if (match) {
|
|
56
|
+
this.emit('match', match);
|
|
57
|
+
const result = (0, runner_js_1.executeAction)(match, this.dryRun);
|
|
58
|
+
this.emit('action', result);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Cache frontmatter for diff tracking
|
|
62
|
+
this.frontmatterCache.set(relativePath, { ...file.frontmatter });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exports.MdPipeWatcher = MdPipeWatcher;
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@safetnsr/md-pipe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Event-driven automation for markdown directories. entr watches files. md-pipe understands them.",
|
|
5
|
+
"main": "./dist/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"md-pipe": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"build:test": "tsc -p tsconfig.test.json",
|
|
12
|
+
"test": "npm run build:test && node --test build/tests/*.test.js",
|
|
13
|
+
"prepublishOnly": "npm run build && npm test"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"markdown",
|
|
17
|
+
"frontmatter",
|
|
18
|
+
"automation",
|
|
19
|
+
"file-watcher",
|
|
20
|
+
"cli",
|
|
21
|
+
"ai",
|
|
22
|
+
"agents",
|
|
23
|
+
"obsidian",
|
|
24
|
+
"docs-as-code",
|
|
25
|
+
"pipeline"
|
|
26
|
+
],
|
|
27
|
+
"author": "safetnsr",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/safetnsr/md-pipe.git"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist/",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"chalk": "^4.1.2",
|
|
43
|
+
"chokidar": "^4.0.3",
|
|
44
|
+
"gray-matter": "^4.0.3",
|
|
45
|
+
"micromatch": "^4.0.8",
|
|
46
|
+
"yaml": "^2.8.2"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/micromatch": "^4.0.10",
|
|
50
|
+
"@types/node": "^20.0.0",
|
|
51
|
+
"typescript": "^5.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|