@safetnsr/md-pipe 0.1.0 → 0.2.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/README.md +86 -2
- package/dist/cli.js +42 -10
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +27 -3
- package/dist/core/matcher.d.ts +3 -0
- package/dist/core/matcher.js +36 -1
- package/dist/core/once.d.ts +2 -1
- package/dist/core/once.js +23 -3
- package/dist/core/runner.d.ts +1 -1
- package/dist/core/runner.js +27 -2
- package/dist/core/state.d.ts +17 -0
- package/dist/core/state.js +47 -0
- package/dist/core/test-file.js +14 -1
- package/dist/core/watcher.d.ts +2 -0
- package/dist/core/watcher.js +26 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ md-pipe watch
|
|
|
33
33
|
|
|
34
34
|
```yaml
|
|
35
35
|
watch: ./docs
|
|
36
|
+
debounce: 500 # ms, for watch mode (default: 200)
|
|
36
37
|
|
|
37
38
|
triggers:
|
|
38
39
|
- name: publish
|
|
@@ -40,6 +41,7 @@ triggers:
|
|
|
40
41
|
path: "posts/**"
|
|
41
42
|
frontmatter:
|
|
42
43
|
status: publish
|
|
44
|
+
cwd: project # run from config dir instead of file dir
|
|
43
45
|
run: "./scripts/deploy.sh $FILE"
|
|
44
46
|
|
|
45
47
|
- name: reindex
|
|
@@ -73,7 +75,9 @@ triggers:
|
|
|
73
75
|
| `--config, -c <path>` | Path to config file |
|
|
74
76
|
| `--dry-run` | Show matches without executing actions |
|
|
75
77
|
| `--json` | Output in JSON format |
|
|
76
|
-
| `--verbose` | Show
|
|
78
|
+
| `--verbose` | Show trigger + file + first line of command |
|
|
79
|
+
| `--debug` | Show full interpolated commands |
|
|
80
|
+
| `--state <path>` | State file for idempotent `once` mode |
|
|
77
81
|
| `--version, -v` | Show version |
|
|
78
82
|
| `--help, -h` | Show help |
|
|
79
83
|
|
|
@@ -93,6 +97,13 @@ match:
|
|
|
93
97
|
type: blog
|
|
94
98
|
```
|
|
95
99
|
|
|
100
|
+
### Negation — match anything except a value
|
|
101
|
+
```yaml
|
|
102
|
+
match:
|
|
103
|
+
frontmatter:
|
|
104
|
+
status: "!draft" # matches publish, review, etc — NOT draft
|
|
105
|
+
```
|
|
106
|
+
|
|
96
107
|
### `frontmatter_changed` — fires when specific fields change
|
|
97
108
|
```yaml
|
|
98
109
|
match:
|
|
@@ -110,8 +121,32 @@ match:
|
|
|
110
121
|
- review
|
|
111
122
|
```
|
|
112
123
|
|
|
124
|
+
### `content` — match body text (substring)
|
|
125
|
+
```yaml
|
|
126
|
+
match:
|
|
127
|
+
content: "TODO" # fires on files containing "TODO" in the body
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `content_regex` — match body text (regex)
|
|
131
|
+
```yaml
|
|
132
|
+
match:
|
|
133
|
+
content_regex: "\\[ \\]" # unchecked checkboxes
|
|
134
|
+
```
|
|
135
|
+
|
|
113
136
|
Matchers can be combined — all conditions must match for the trigger to fire.
|
|
114
137
|
|
|
138
|
+
## Trigger Options
|
|
139
|
+
|
|
140
|
+
### `cwd` — working directory for commands
|
|
141
|
+
```yaml
|
|
142
|
+
triggers:
|
|
143
|
+
- name: build
|
|
144
|
+
match:
|
|
145
|
+
path: "**"
|
|
146
|
+
cwd: project # "project" = config dir, "file" = file dir (default)
|
|
147
|
+
run: bash scripts/build.sh $FILE
|
|
148
|
+
```
|
|
149
|
+
|
|
115
150
|
## Environment Variables
|
|
116
151
|
|
|
117
152
|
Scripts receive these env vars:
|
|
@@ -125,6 +160,52 @@ Scripts receive these env vars:
|
|
|
125
160
|
| `$FRONTMATTER` | JSON string of all frontmatter |
|
|
126
161
|
| `$DIFF` | JSON string of changed frontmatter fields |
|
|
127
162
|
| `$TAGS` | Comma-separated list of tags |
|
|
163
|
+
| `$FM_<field>` | Direct access to frontmatter fields |
|
|
164
|
+
|
|
165
|
+
### `$FM_` variables
|
|
166
|
+
|
|
167
|
+
Every frontmatter field is exposed as `$FM_<fieldname>`:
|
|
168
|
+
|
|
169
|
+
```yaml
|
|
170
|
+
---
|
|
171
|
+
title: "Hello World"
|
|
172
|
+
status: publish
|
|
173
|
+
version: "2.1"
|
|
174
|
+
---
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
echo $FM_title # → Hello World
|
|
179
|
+
echo $FM_status # → publish
|
|
180
|
+
echo $FM_version # → 2.1
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
No more `node -pe "JSON.parse(...)"` to extract a single field!
|
|
184
|
+
|
|
185
|
+
## Idempotent Mode (--state)
|
|
186
|
+
|
|
187
|
+
For CI/cron use, `--state` tracks which files have been processed:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# First run: processes all matching files
|
|
191
|
+
md-pipe once --state .md-pipe-state.json
|
|
192
|
+
|
|
193
|
+
# Second run: skips unchanged files
|
|
194
|
+
md-pipe once --state .md-pipe-state.json
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The state file tracks content hashes per trigger+file combo. Only fires when actual content changes.
|
|
198
|
+
|
|
199
|
+
## Watch Mode Debouncing
|
|
200
|
+
|
|
201
|
+
File saves can trigger multiple events. Configure debounce in your config:
|
|
202
|
+
|
|
203
|
+
```yaml
|
|
204
|
+
debounce: 500 # milliseconds (default: 200)
|
|
205
|
+
# or
|
|
206
|
+
debounce: "500ms"
|
|
207
|
+
debounce: "2s"
|
|
208
|
+
```
|
|
128
209
|
|
|
129
210
|
## Agent Interface (--json)
|
|
130
211
|
|
|
@@ -136,6 +217,7 @@ md-pipe once --json
|
|
|
136
217
|
{
|
|
137
218
|
"total": 42,
|
|
138
219
|
"matched": 3,
|
|
220
|
+
"skipped": 0,
|
|
139
221
|
"actions": [
|
|
140
222
|
{
|
|
141
223
|
"triggerName": "publish",
|
|
@@ -166,7 +248,9 @@ md-pipe watch --json
|
|
|
166
248
|
|
|
167
249
|
**Digital garden** — auto-generate RSS feed when new posts are added
|
|
168
250
|
|
|
169
|
-
**CI/CD for markdown** — `md-pipe once` in your CI pipeline
|
|
251
|
+
**CI/CD for markdown** — `md-pipe once --state .state.json` in your CI pipeline for idempotent processing
|
|
252
|
+
|
|
253
|
+
**TODO tracking** — find files with `content: "TODO"` or `content_regex: "\\[ \\]"` for unchecked tasks
|
|
170
254
|
|
|
171
255
|
## Pair With
|
|
172
256
|
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ const config_js_1 = require("./core/config.js");
|
|
|
11
11
|
const watcher_js_1 = require("./core/watcher.js");
|
|
12
12
|
const once_js_1 = require("./core/once.js");
|
|
13
13
|
const test_file_js_1 = require("./core/test-file.js");
|
|
14
|
-
const VERSION = '0.
|
|
14
|
+
const VERSION = '0.2.0';
|
|
15
15
|
function printHelp() {
|
|
16
16
|
console.log(`
|
|
17
17
|
${chalk_1.default.bold('md-pipe')} — event-driven automation for markdown directories
|
|
@@ -27,17 +27,23 @@ ${chalk_1.default.bold('Flags:')}
|
|
|
27
27
|
--config, -c <path> Path to config file (default: .md-pipe.yml)
|
|
28
28
|
--dry-run Show matches without executing actions
|
|
29
29
|
--json Output in JSON format
|
|
30
|
-
--verbose Show
|
|
30
|
+
--verbose Show trigger + file + first line of command
|
|
31
|
+
--debug Show full interpolated commands
|
|
32
|
+
--state <path> State file for idempotent once mode
|
|
31
33
|
--version, -v Show version
|
|
32
34
|
--help, -h Show this help
|
|
33
35
|
|
|
34
36
|
${chalk_1.default.bold('Config (.md-pipe.yml):')}
|
|
35
37
|
watch: ./docs
|
|
38
|
+
debounce: 500 # ms, for watch mode (default: 200)
|
|
36
39
|
triggers:
|
|
37
40
|
- name: publish
|
|
38
41
|
match:
|
|
39
42
|
path: "posts/**"
|
|
40
43
|
frontmatter: { status: publish }
|
|
44
|
+
content: "TODO" # body substring
|
|
45
|
+
content_regex: "\\\\[\\\\s\\\\]" # body regex
|
|
46
|
+
cwd: project # "project" or "file" (default)
|
|
41
47
|
run: "echo Publishing $FILE"
|
|
42
48
|
|
|
43
49
|
${chalk_1.default.bold('Env vars passed to scripts:')}
|
|
@@ -48,6 +54,7 @@ ${chalk_1.default.bold('Env vars passed to scripts:')}
|
|
|
48
54
|
$FRONTMATTER JSON string of all frontmatter
|
|
49
55
|
$DIFF JSON string of changed frontmatter fields
|
|
50
56
|
$TAGS Comma-separated list of tags
|
|
57
|
+
$FM_<field> Direct access to frontmatter (e.g. $FM_title, $FM_status)
|
|
51
58
|
|
|
52
59
|
${chalk_1.default.bold('More:')} https://github.com/safetnsr/md-pipe
|
|
53
60
|
`);
|
|
@@ -59,6 +66,8 @@ function parseArgs(args) {
|
|
|
59
66
|
let dryRun = false;
|
|
60
67
|
let json = false;
|
|
61
68
|
let verbose = false;
|
|
69
|
+
let debug = false;
|
|
70
|
+
let state;
|
|
62
71
|
for (let i = 0; i < args.length; i++) {
|
|
63
72
|
const arg = args[i];
|
|
64
73
|
if (arg === '--help' || arg === '-h') {
|
|
@@ -81,6 +90,14 @@ function parseArgs(args) {
|
|
|
81
90
|
verbose = true;
|
|
82
91
|
continue;
|
|
83
92
|
}
|
|
93
|
+
if (arg === '--debug') {
|
|
94
|
+
debug = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (arg === '--state') {
|
|
98
|
+
state = args[++i];
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
84
101
|
if (arg === '--config' || arg === '-c') {
|
|
85
102
|
config = args[++i];
|
|
86
103
|
continue;
|
|
@@ -94,7 +111,7 @@ function parseArgs(args) {
|
|
|
94
111
|
continue;
|
|
95
112
|
}
|
|
96
113
|
}
|
|
97
|
-
return { command: command || 'help', file, config, dryRun, json, verbose };
|
|
114
|
+
return { command: command || 'help', file, config, dryRun, json, verbose, debug, state };
|
|
98
115
|
}
|
|
99
116
|
function getConfig(configPath) {
|
|
100
117
|
const cwd = process.cwd();
|
|
@@ -105,6 +122,14 @@ function getConfig(configPath) {
|
|
|
105
122
|
}
|
|
106
123
|
return (0, config_js_1.loadConfig)(cfgPath);
|
|
107
124
|
}
|
|
125
|
+
/** Format command for display: verbose shows first line, debug shows full */
|
|
126
|
+
function formatCommand(command, debug) {
|
|
127
|
+
if (debug)
|
|
128
|
+
return command;
|
|
129
|
+
const firstLine = command.split('\n')[0].trim();
|
|
130
|
+
const truncated = firstLine.length > 80 ? firstLine.slice(0, 77) + '...' : firstLine;
|
|
131
|
+
return command.includes('\n') ? truncated + ' …' : truncated;
|
|
132
|
+
}
|
|
108
133
|
async function cmdInit() {
|
|
109
134
|
const target = (0, path_1.resolve)(process.cwd(), '.md-pipe.yml');
|
|
110
135
|
if ((0, fs_1.existsSync)(target)) {
|
|
@@ -125,13 +150,15 @@ async function cmdWatch(opts) {
|
|
|
125
150
|
watcher.on('ready', () => {
|
|
126
151
|
console.log(chalk_1.default.green('✓') + ` Watching ${config.watch}`);
|
|
127
152
|
console.log(chalk_1.default.dim(` ${config.triggers.length} trigger(s) loaded`));
|
|
153
|
+
if (config.debounce)
|
|
154
|
+
console.log(chalk_1.default.dim(` debounce: ${config.debounce}ms`));
|
|
128
155
|
if (opts.dryRun)
|
|
129
156
|
console.log(chalk_1.default.yellow(' [dry-run mode — actions will not execute]'));
|
|
130
157
|
console.log(chalk_1.default.dim(' Press Ctrl+C to stop\n'));
|
|
131
158
|
});
|
|
132
159
|
watcher.on('match', (result) => {
|
|
133
160
|
if (opts.json)
|
|
134
|
-
return;
|
|
161
|
+
return;
|
|
135
162
|
const ts = new Date().toLocaleTimeString();
|
|
136
163
|
console.log(chalk_1.default.dim(`[${ts}]`) + ' ' +
|
|
137
164
|
chalk_1.default.cyan(`▸ ${result.trigger.name}`) + ' ' +
|
|
@@ -146,8 +173,9 @@ async function cmdWatch(opts) {
|
|
|
146
173
|
const status = result.exitCode === 0
|
|
147
174
|
? chalk_1.default.green('✓')
|
|
148
175
|
: chalk_1.default.red(`✗ exit ${result.exitCode}`);
|
|
149
|
-
|
|
150
|
-
|
|
176
|
+
const cmdDisplay = formatCommand(result.command, opts.debug);
|
|
177
|
+
console.log(` ${status} ${chalk_1.default.dim(cmdDisplay)}`);
|
|
178
|
+
if (result.stdout && (opts.verbose || opts.debug)) {
|
|
151
179
|
console.log(chalk_1.default.dim(' ' + result.stdout.replace(/\n/g, '\n ')));
|
|
152
180
|
}
|
|
153
181
|
if (result.stderr) {
|
|
@@ -171,13 +199,16 @@ function cmdOnce(opts) {
|
|
|
171
199
|
console.error(chalk_1.default.red(`Watch directory not found: ${config.watch}`));
|
|
172
200
|
process.exit(1);
|
|
173
201
|
}
|
|
174
|
-
const result = (0, once_js_1.runOnce)(config, opts.dryRun);
|
|
202
|
+
const result = (0, once_js_1.runOnce)(config, opts.dryRun, opts.state);
|
|
175
203
|
if (opts.json) {
|
|
176
204
|
console.log(JSON.stringify(result, null, 2));
|
|
177
205
|
return;
|
|
178
206
|
}
|
|
179
207
|
console.log(chalk_1.default.bold('md-pipe once'));
|
|
180
|
-
|
|
208
|
+
let summary = ` Scanned ${result.total} files, ${result.matched} trigger match(es)`;
|
|
209
|
+
if (result.skipped > 0)
|
|
210
|
+
summary += `, ${result.skipped} skipped (unchanged)`;
|
|
211
|
+
console.log(chalk_1.default.dim(summary + '\n'));
|
|
181
212
|
if (opts.dryRun) {
|
|
182
213
|
console.log(chalk_1.default.yellow(' [dry-run mode — actions were not executed]\n'));
|
|
183
214
|
}
|
|
@@ -185,8 +216,9 @@ function cmdOnce(opts) {
|
|
|
185
216
|
const status = action.exitCode === 0
|
|
186
217
|
? chalk_1.default.green('✓')
|
|
187
218
|
: chalk_1.default.red(`✗ exit ${action.exitCode}`);
|
|
188
|
-
|
|
189
|
-
|
|
219
|
+
const cmdDisplay = formatCommand(action.command, opts.debug);
|
|
220
|
+
console.log(`${status} ${chalk_1.default.cyan(action.triggerName)} → ${chalk_1.default.dim(cmdDisplay)}`);
|
|
221
|
+
if (action.stdout && (opts.verbose || opts.debug)) {
|
|
190
222
|
console.log(chalk_1.default.dim(' ' + action.stdout));
|
|
191
223
|
}
|
|
192
224
|
if (action.stderr) {
|
package/dist/core/config.d.ts
CHANGED
|
@@ -3,15 +3,20 @@ export interface TriggerMatch {
|
|
|
3
3
|
frontmatter?: Record<string, unknown>;
|
|
4
4
|
frontmatter_changed?: string[];
|
|
5
5
|
tags?: string[];
|
|
6
|
+
content?: string;
|
|
7
|
+
content_regex?: string;
|
|
6
8
|
}
|
|
7
9
|
export interface TriggerDef {
|
|
8
10
|
name: string;
|
|
9
11
|
match: TriggerMatch;
|
|
10
12
|
run: string;
|
|
13
|
+
cwd?: 'project' | 'file';
|
|
11
14
|
}
|
|
12
15
|
export interface MdPipeConfig {
|
|
13
16
|
watch: string;
|
|
17
|
+
configDir: string;
|
|
14
18
|
triggers: TriggerDef[];
|
|
19
|
+
debounce?: number;
|
|
15
20
|
}
|
|
16
21
|
export declare function findConfigFile(dir: string): string | null;
|
|
17
22
|
export declare function loadConfig(configPath: string): MdPipeConfig;
|
package/dist/core/config.js
CHANGED
|
@@ -30,6 +30,7 @@ function loadConfig(configPath) {
|
|
|
30
30
|
if (!Array.isArray(parsed.triggers) || parsed.triggers.length === 0) {
|
|
31
31
|
throw new Error(`Config error: 'triggers' must be a non-empty array`);
|
|
32
32
|
}
|
|
33
|
+
const configDir = (0, path_1.dirname)(configPath);
|
|
33
34
|
const triggers = [];
|
|
34
35
|
for (let i = 0; i < parsed.triggers.length; i++) {
|
|
35
36
|
const t = parsed.triggers[i];
|
|
@@ -59,12 +60,35 @@ function loadConfig(configPath) {
|
|
|
59
60
|
}
|
|
60
61
|
match.tags = t.match.tags.map(String);
|
|
61
62
|
}
|
|
62
|
-
|
|
63
|
+
if (t.match.content !== undefined)
|
|
64
|
+
match.content = String(t.match.content);
|
|
65
|
+
if (t.match.content_regex !== undefined)
|
|
66
|
+
match.content_regex = String(t.match.content_regex);
|
|
67
|
+
// cwd option
|
|
68
|
+
const cwd = t.cwd === 'project' ? 'project' : (t.cwd === 'file' ? 'file' : undefined);
|
|
69
|
+
triggers.push({ name: t.name, match, run: t.run, ...(cwd ? { cwd } : {}) });
|
|
63
70
|
}
|
|
64
71
|
// Resolve watch path relative to config file directory
|
|
65
|
-
const configDir = (0, path_1.dirname)(configPath);
|
|
66
72
|
const watchPath = (0, path_1.resolve)(configDir, parsed.watch);
|
|
67
|
-
|
|
73
|
+
// Debounce
|
|
74
|
+
let debounce;
|
|
75
|
+
if (parsed.debounce !== undefined) {
|
|
76
|
+
if (typeof parsed.debounce === 'string') {
|
|
77
|
+
// Parse "500ms" or "1s"
|
|
78
|
+
const ms = parsed.debounce.match(/^(\d+)\s*ms$/i);
|
|
79
|
+
const s = parsed.debounce.match(/^(\d+)\s*s$/i);
|
|
80
|
+
if (ms)
|
|
81
|
+
debounce = parseInt(ms[1], 10);
|
|
82
|
+
else if (s)
|
|
83
|
+
debounce = parseInt(s[1], 10) * 1000;
|
|
84
|
+
else
|
|
85
|
+
debounce = parseInt(parsed.debounce, 10) || undefined;
|
|
86
|
+
}
|
|
87
|
+
else if (typeof parsed.debounce === 'number') {
|
|
88
|
+
debounce = parsed.debounce;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { watch: watchPath, configDir, triggers, ...(debounce ? { debounce } : {}) };
|
|
68
92
|
}
|
|
69
93
|
function generateDefaultConfig() {
|
|
70
94
|
return `# md-pipe configuration
|
package/dist/core/matcher.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export interface FileState {
|
|
|
3
3
|
filePath: string;
|
|
4
4
|
relativePath: string;
|
|
5
5
|
content: string;
|
|
6
|
+
body: string;
|
|
6
7
|
frontmatter: Record<string, unknown>;
|
|
7
8
|
tags: string[];
|
|
8
9
|
}
|
|
@@ -19,6 +20,8 @@ export declare function parseMarkdownContent(content: string, filePath: string,
|
|
|
19
20
|
export declare function matchPath(relativePath: string, pattern: string): boolean;
|
|
20
21
|
export declare function matchFrontmatter(frontmatter: Record<string, unknown>, expected: Record<string, unknown>): boolean;
|
|
21
22
|
export declare function matchTags(fileTags: string[], requiredTags: string[]): boolean;
|
|
23
|
+
export declare function matchContent(body: string, substring: string): boolean;
|
|
24
|
+
export declare function matchContentRegex(body: string, pattern: string): boolean;
|
|
22
25
|
export declare function computeFrontmatterDiff(oldFm: Record<string, unknown>, newFm: Record<string, unknown>, watchFields: string[]): Record<string, {
|
|
23
26
|
old: unknown;
|
|
24
27
|
new: unknown;
|
package/dist/core/matcher.js
CHANGED
|
@@ -8,6 +8,8 @@ exports.parseMarkdownContent = parseMarkdownContent;
|
|
|
8
8
|
exports.matchPath = matchPath;
|
|
9
9
|
exports.matchFrontmatter = matchFrontmatter;
|
|
10
10
|
exports.matchTags = matchTags;
|
|
11
|
+
exports.matchContent = matchContent;
|
|
12
|
+
exports.matchContentRegex = matchContentRegex;
|
|
11
13
|
exports.computeFrontmatterDiff = computeFrontmatterDiff;
|
|
12
14
|
exports.evaluateTrigger = evaluateTrigger;
|
|
13
15
|
const micromatch_1 = __importDefault(require("micromatch"));
|
|
@@ -20,9 +22,11 @@ function parseMarkdownFile(filePath, relativePath) {
|
|
|
20
22
|
function parseMarkdownContent(content, filePath, relativePath) {
|
|
21
23
|
let frontmatter = {};
|
|
22
24
|
let tags = [];
|
|
25
|
+
let body = content;
|
|
23
26
|
try {
|
|
24
27
|
const parsed = (0, gray_matter_1.default)(content);
|
|
25
28
|
frontmatter = parsed.data || {};
|
|
29
|
+
body = parsed.content || '';
|
|
26
30
|
// Extract tags from frontmatter
|
|
27
31
|
if (Array.isArray(frontmatter.tags)) {
|
|
28
32
|
tags = frontmatter.tags.map(String);
|
|
@@ -31,7 +35,7 @@ function parseMarkdownContent(content, filePath, relativePath) {
|
|
|
31
35
|
catch {
|
|
32
36
|
// File has no valid frontmatter — that's fine
|
|
33
37
|
}
|
|
34
|
-
return { filePath, relativePath, content, frontmatter, tags };
|
|
38
|
+
return { filePath, relativePath, content, body, frontmatter, tags };
|
|
35
39
|
}
|
|
36
40
|
function matchPath(relativePath, pattern) {
|
|
37
41
|
return micromatch_1.default.isMatch(relativePath, pattern);
|
|
@@ -39,6 +43,15 @@ function matchPath(relativePath, pattern) {
|
|
|
39
43
|
function matchFrontmatter(frontmatter, expected) {
|
|
40
44
|
for (const [key, val] of Object.entries(expected)) {
|
|
41
45
|
const actual = frontmatter[key];
|
|
46
|
+
// Negation: "!value" means match anything EXCEPT that value
|
|
47
|
+
if (typeof val === 'string' && val.startsWith('!')) {
|
|
48
|
+
const negated = val.slice(1);
|
|
49
|
+
if (actual === undefined)
|
|
50
|
+
continue; // undefined != negated, so it passes
|
|
51
|
+
if (String(actual) === negated)
|
|
52
|
+
return false;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
42
55
|
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
|
43
56
|
if (typeof actual !== 'object' || actual === null || Array.isArray(actual))
|
|
44
57
|
return false;
|
|
@@ -69,6 +82,18 @@ function matchTags(fileTags, requiredTags) {
|
|
|
69
82
|
return normalized.includes(norm);
|
|
70
83
|
});
|
|
71
84
|
}
|
|
85
|
+
function matchContent(body, substring) {
|
|
86
|
+
return body.includes(substring);
|
|
87
|
+
}
|
|
88
|
+
function matchContentRegex(body, pattern) {
|
|
89
|
+
try {
|
|
90
|
+
const re = new RegExp(pattern);
|
|
91
|
+
return re.test(body);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
72
97
|
function computeFrontmatterDiff(oldFm, newFm, watchFields) {
|
|
73
98
|
const diff = {};
|
|
74
99
|
for (const field of watchFields) {
|
|
@@ -98,6 +123,16 @@ function evaluateTrigger(trigger, file, previousFrontmatter) {
|
|
|
98
123
|
if (!matchTags(file.tags, match.tags))
|
|
99
124
|
return null;
|
|
100
125
|
}
|
|
126
|
+
// Check content substring
|
|
127
|
+
if (match.content) {
|
|
128
|
+
if (!matchContent(file.body, match.content))
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
// Check content regex
|
|
132
|
+
if (match.content_regex) {
|
|
133
|
+
if (!matchContentRegex(file.body, match.content_regex))
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
101
136
|
// Check frontmatter_changed (requires previous state)
|
|
102
137
|
if (match.frontmatter_changed) {
|
|
103
138
|
if (!previousFrontmatter) {
|
package/dist/core/once.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { RunResult } from './runner.js';
|
|
|
3
3
|
export interface OnceResult {
|
|
4
4
|
total: number;
|
|
5
5
|
matched: number;
|
|
6
|
+
skipped: number;
|
|
6
7
|
actions: RunResult[];
|
|
7
8
|
errors: string[];
|
|
8
9
|
}
|
|
9
|
-
export declare function runOnce(config: MdPipeConfig, dryRun?: boolean): OnceResult;
|
|
10
|
+
export declare function runOnce(config: MdPipeConfig, dryRun?: boolean, statePath?: string): OnceResult;
|
package/dist/core/once.js
CHANGED
|
@@ -5,6 +5,7 @@ const fs_1 = require("fs");
|
|
|
5
5
|
const path_1 = require("path");
|
|
6
6
|
const matcher_js_1 = require("./matcher.js");
|
|
7
7
|
const runner_js_1 = require("./runner.js");
|
|
8
|
+
const state_js_1 = require("./state.js");
|
|
8
9
|
function findMarkdownFiles(dir) {
|
|
9
10
|
const results = [];
|
|
10
11
|
function walk(current) {
|
|
@@ -22,11 +23,17 @@ function findMarkdownFiles(dir) {
|
|
|
22
23
|
walk(dir);
|
|
23
24
|
return results;
|
|
24
25
|
}
|
|
25
|
-
function runOnce(config, dryRun = false) {
|
|
26
|
+
function runOnce(config, dryRun = false, statePath) {
|
|
26
27
|
const files = findMarkdownFiles(config.watch);
|
|
27
28
|
const actions = [];
|
|
28
29
|
const errors = [];
|
|
29
30
|
let matched = 0;
|
|
31
|
+
let skipped = 0;
|
|
32
|
+
// Load state if state path provided
|
|
33
|
+
let state = null;
|
|
34
|
+
if (statePath) {
|
|
35
|
+
state = (0, state_js_1.loadState)(statePath);
|
|
36
|
+
}
|
|
30
37
|
for (const filePath of files) {
|
|
31
38
|
const relativePath = (0, path_1.relative)(config.watch, filePath);
|
|
32
39
|
let file;
|
|
@@ -43,11 +50,24 @@ function runOnce(config, dryRun = false) {
|
|
|
43
50
|
continue;
|
|
44
51
|
const match = (0, matcher_js_1.evaluateTrigger)(trigger, file);
|
|
45
52
|
if (match) {
|
|
53
|
+
// If state tracking, check if file has changed
|
|
54
|
+
if (state && !(0, state_js_1.hasChanged)(state, trigger.name, relativePath, file.content)) {
|
|
55
|
+
skipped++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
46
58
|
matched++;
|
|
47
|
-
const result = (0, runner_js_1.executeAction)(match, dryRun);
|
|
59
|
+
const result = (0, runner_js_1.executeAction)(match, dryRun, config.configDir);
|
|
48
60
|
actions.push(result);
|
|
61
|
+
// Mark as processed in state
|
|
62
|
+
if (state && !dryRun) {
|
|
63
|
+
(0, state_js_1.markProcessed)(state, trigger.name, relativePath, file.content);
|
|
64
|
+
}
|
|
49
65
|
}
|
|
50
66
|
}
|
|
51
67
|
}
|
|
52
|
-
|
|
68
|
+
// Save state if tracking
|
|
69
|
+
if (state && statePath && !dryRun) {
|
|
70
|
+
(0, state_js_1.saveState)(statePath, state);
|
|
71
|
+
}
|
|
72
|
+
return { total: files.length, matched, skipped, actions, errors };
|
|
53
73
|
}
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -8,4 +8,4 @@ export interface RunResult {
|
|
|
8
8
|
stderr: string;
|
|
9
9
|
durationMs: number;
|
|
10
10
|
}
|
|
11
|
-
export declare function executeAction(match: MatchResult, dryRun?: boolean): RunResult;
|
|
11
|
+
export declare function executeAction(match: MatchResult, dryRun?: boolean, configDir?: string): RunResult;
|
package/dist/core/runner.js
CHANGED
|
@@ -18,7 +18,22 @@ function interpolateCommand(template, match) {
|
|
|
18
18
|
.replace(/\$DIFF/g, diffJson)
|
|
19
19
|
.replace(/\$TAGS/g, tags);
|
|
20
20
|
}
|
|
21
|
-
function
|
|
21
|
+
function buildFmEnvVars(frontmatter) {
|
|
22
|
+
const env = {};
|
|
23
|
+
for (const [key, val] of Object.entries(frontmatter)) {
|
|
24
|
+
const envKey = `FM_${key}`;
|
|
25
|
+
if (val === null || val === undefined)
|
|
26
|
+
continue;
|
|
27
|
+
if (typeof val === 'object') {
|
|
28
|
+
env[envKey] = JSON.stringify(val);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
env[envKey] = String(val);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return env;
|
|
35
|
+
}
|
|
36
|
+
function executeAction(match, dryRun = false, configDir) {
|
|
22
37
|
const command = interpolateCommand(match.trigger.run, match);
|
|
23
38
|
const triggerName = match.trigger.name;
|
|
24
39
|
const filePath = match.file.filePath;
|
|
@@ -33,10 +48,19 @@ function executeAction(match, dryRun = false) {
|
|
|
33
48
|
durationMs: 0,
|
|
34
49
|
};
|
|
35
50
|
}
|
|
51
|
+
// Determine cwd: "project" = configDir, "file" = file's directory (default)
|
|
52
|
+
let cwd;
|
|
53
|
+
if (match.trigger.cwd === 'project' && configDir) {
|
|
54
|
+
cwd = configDir;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
cwd = (0, path_1.dirname)(match.file.filePath);
|
|
58
|
+
}
|
|
59
|
+
const fmEnv = buildFmEnvVars(match.file.frontmatter);
|
|
36
60
|
const start = Date.now();
|
|
37
61
|
try {
|
|
38
62
|
const stdout = (0, child_process_1.execSync)(command, {
|
|
39
|
-
cwd
|
|
63
|
+
cwd,
|
|
40
64
|
timeout: 30000,
|
|
41
65
|
encoding: 'utf-8',
|
|
42
66
|
env: {
|
|
@@ -48,6 +72,7 @@ function executeAction(match, dryRun = false) {
|
|
|
48
72
|
FRONTMATTER: JSON.stringify(match.file.frontmatter),
|
|
49
73
|
DIFF: match.diff ? JSON.stringify(match.diff) : '{}',
|
|
50
74
|
TAGS: match.file.tags.join(','),
|
|
75
|
+
...fmEnv,
|
|
51
76
|
},
|
|
52
77
|
});
|
|
53
78
|
return {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface StateEntry {
|
|
2
|
+
hash: string;
|
|
3
|
+
processedAt: string;
|
|
4
|
+
}
|
|
5
|
+
export interface StateFile {
|
|
6
|
+
version: 1;
|
|
7
|
+
entries: Record<string, StateEntry>;
|
|
8
|
+
}
|
|
9
|
+
export declare function loadState(statePath: string): StateFile;
|
|
10
|
+
export declare function saveState(statePath: string, state: StateFile): void;
|
|
11
|
+
export declare function computeFileHash(content: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Returns true if the file+trigger combo has changed since last run.
|
|
14
|
+
* Key format: "triggerName::relativePath"
|
|
15
|
+
*/
|
|
16
|
+
export declare function hasChanged(state: StateFile, triggerName: string, relativePath: string, content: string): boolean;
|
|
17
|
+
export declare function markProcessed(state: StateFile, triggerName: string, relativePath: string, content: string): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadState = loadState;
|
|
4
|
+
exports.saveState = saveState;
|
|
5
|
+
exports.computeFileHash = computeFileHash;
|
|
6
|
+
exports.hasChanged = hasChanged;
|
|
7
|
+
exports.markProcessed = markProcessed;
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
10
|
+
function loadState(statePath) {
|
|
11
|
+
if (!(0, fs_1.existsSync)(statePath)) {
|
|
12
|
+
return { version: 1, entries: {} };
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const raw = (0, fs_1.readFileSync)(statePath, 'utf-8');
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (parsed.version === 1 && parsed.entries)
|
|
18
|
+
return parsed;
|
|
19
|
+
return { version: 1, entries: {} };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { version: 1, entries: {} };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function saveState(statePath, state) {
|
|
26
|
+
(0, fs_1.writeFileSync)(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
function computeFileHash(content) {
|
|
29
|
+
return (0, crypto_1.createHash)('sha256').update(content).digest('hex').slice(0, 16);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Returns true if the file+trigger combo has changed since last run.
|
|
33
|
+
* Key format: "triggerName::relativePath"
|
|
34
|
+
*/
|
|
35
|
+
function hasChanged(state, triggerName, relativePath, content) {
|
|
36
|
+
const key = `${triggerName}::${relativePath}`;
|
|
37
|
+
const hash = computeFileHash(content);
|
|
38
|
+
const existing = state.entries[key];
|
|
39
|
+
return !existing || existing.hash !== hash;
|
|
40
|
+
}
|
|
41
|
+
function markProcessed(state, triggerName, relativePath, content) {
|
|
42
|
+
const key = `${triggerName}::${relativePath}`;
|
|
43
|
+
state.entries[key] = {
|
|
44
|
+
hash: computeFileHash(content),
|
|
45
|
+
processedAt: new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
package/dist/core/test-file.js
CHANGED
|
@@ -13,8 +13,17 @@ function testFile(config, filePath) {
|
|
|
13
13
|
const file = (0, matcher_js_1.parseMarkdownFile)(absPath, relativePath);
|
|
14
14
|
const matches = [];
|
|
15
15
|
for (const trigger of config.triggers) {
|
|
16
|
-
//
|
|
16
|
+
// For frontmatter_changed triggers, still check other conditions first
|
|
17
17
|
if (trigger.match.frontmatter_changed) {
|
|
18
|
+
const staticMatch = { ...trigger.match };
|
|
19
|
+
delete staticMatch.frontmatter_changed;
|
|
20
|
+
const hasStaticConditions = staticMatch.path || staticMatch.frontmatter || staticMatch.tags || staticMatch.content || staticMatch.content_regex;
|
|
21
|
+
if (hasStaticConditions) {
|
|
22
|
+
const staticTrigger = { ...trigger, match: staticMatch };
|
|
23
|
+
const staticResult = (0, matcher_js_1.evaluateTrigger)(staticTrigger, file);
|
|
24
|
+
if (!staticResult)
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
18
27
|
matches.push({
|
|
19
28
|
triggerName: trigger.name,
|
|
20
29
|
reason: `frontmatter_changed: would fire on changes to [${trigger.match.frontmatter_changed.join(', ')}] (no previous state to diff in test mode)`,
|
|
@@ -30,6 +39,10 @@ function testFile(config, filePath) {
|
|
|
30
39
|
reasons.push(`frontmatter matches ${JSON.stringify(trigger.match.frontmatter)}`);
|
|
31
40
|
if (trigger.match.tags)
|
|
32
41
|
reasons.push(`has tags [${trigger.match.tags.join(', ')}]`);
|
|
42
|
+
if (trigger.match.content)
|
|
43
|
+
reasons.push(`body contains "${trigger.match.content}"`);
|
|
44
|
+
if (trigger.match.content_regex)
|
|
45
|
+
reasons.push(`body matches /${trigger.match.content_regex}/`);
|
|
33
46
|
matches.push({
|
|
34
47
|
triggerName: trigger.name,
|
|
35
48
|
reason: reasons.join(' + ') || 'all conditions matched',
|
package/dist/core/watcher.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare class MdPipeWatcher extends EventEmitter {
|
|
|
13
13
|
private fsWatcher;
|
|
14
14
|
private frontmatterCache;
|
|
15
15
|
private dryRun;
|
|
16
|
+
private debounceMs;
|
|
17
|
+
private debounceTimers;
|
|
16
18
|
constructor(config: MdPipeConfig, dryRun?: boolean);
|
|
17
19
|
start(): Promise<void>;
|
|
18
20
|
stop(): Promise<void>;
|
package/dist/core/watcher.js
CHANGED
|
@@ -11,28 +11,48 @@ class MdPipeWatcher extends events_1.EventEmitter {
|
|
|
11
11
|
fsWatcher = null;
|
|
12
12
|
frontmatterCache = new Map();
|
|
13
13
|
dryRun;
|
|
14
|
+
debounceMs;
|
|
15
|
+
debounceTimers = new Map();
|
|
14
16
|
constructor(config, dryRun = false) {
|
|
15
17
|
super();
|
|
16
18
|
this.config = config;
|
|
17
19
|
this.dryRun = dryRun;
|
|
20
|
+
this.debounceMs = config.debounce ?? 200;
|
|
18
21
|
}
|
|
19
22
|
async start() {
|
|
20
23
|
const watchDir = this.config.watch;
|
|
21
|
-
this.fsWatcher = (0, chokidar_1.watch)(
|
|
22
|
-
cwd: watchDir,
|
|
24
|
+
this.fsWatcher = (0, chokidar_1.watch)(watchDir, {
|
|
23
25
|
ignoreInitial: true,
|
|
24
26
|
persistent: true,
|
|
25
27
|
awaitWriteFinish: {
|
|
26
|
-
stabilityThreshold:
|
|
28
|
+
stabilityThreshold: this.debounceMs,
|
|
27
29
|
pollInterval: 50,
|
|
28
30
|
},
|
|
29
31
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const handleEvent = (absPath, event) => {
|
|
33
|
+
if (!absPath.endsWith('.md'))
|
|
34
|
+
return;
|
|
35
|
+
const relPath = (0, path_1.relative)(watchDir, absPath);
|
|
36
|
+
// Debounce: cancel any pending timer for this file
|
|
37
|
+
const existing = this.debounceTimers.get(relPath);
|
|
38
|
+
if (existing)
|
|
39
|
+
clearTimeout(existing);
|
|
40
|
+
this.debounceTimers.set(relPath, setTimeout(() => {
|
|
41
|
+
this.debounceTimers.delete(relPath);
|
|
42
|
+
this.handleFile(relPath, event);
|
|
43
|
+
}, this.debounceMs));
|
|
44
|
+
};
|
|
45
|
+
this.fsWatcher.on('add', (p) => handleEvent(p, 'add'));
|
|
46
|
+
this.fsWatcher.on('change', (p) => handleEvent(p, 'change'));
|
|
32
47
|
this.fsWatcher.on('ready', () => this.emit('ready'));
|
|
33
48
|
this.fsWatcher.on('error', (err) => this.emit('error', err instanceof Error ? err : new Error(String(err))));
|
|
34
49
|
}
|
|
35
50
|
async stop() {
|
|
51
|
+
// Clear pending debounce timers
|
|
52
|
+
for (const timer of this.debounceTimers.values()) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
this.debounceTimers.clear();
|
|
36
56
|
if (this.fsWatcher) {
|
|
37
57
|
await this.fsWatcher.close();
|
|
38
58
|
this.fsWatcher = null;
|
|
@@ -54,7 +74,7 @@ class MdPipeWatcher extends events_1.EventEmitter {
|
|
|
54
74
|
const match = (0, matcher_js_1.evaluateTrigger)(trigger, file, previousFm);
|
|
55
75
|
if (match) {
|
|
56
76
|
this.emit('match', match);
|
|
57
|
-
const result = (0, runner_js_1.executeAction)(match, this.dryRun);
|
|
77
|
+
const result = (0, runner_js_1.executeAction)(match, this.dryRun, this.config.configDir);
|
|
58
78
|
this.emit('action', result);
|
|
59
79
|
}
|
|
60
80
|
}
|