@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 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 detailed output |
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 to validate or transform docs
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.1.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 detailed output
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; // json mode outputs on action
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
- console.log(` ${status} ${chalk_1.default.dim(result.command)}`);
150
- if (result.stdout && opts.verbose) {
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
- console.log(chalk_1.default.dim(` Scanned ${result.total} files, ${result.matched} trigger match(es)\n`));
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
- console.log(`${status} ${chalk_1.default.cyan(action.triggerName)} ${chalk_1.default.dim(action.command)}`);
189
- if (action.stdout && opts.verbose) {
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) {
@@ -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;
@@ -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
- triggers.push({ name: t.name, match, run: t.run });
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
- return { watch: watchPath, triggers };
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
@@ -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;
@@ -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) {
@@ -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
- return { total: files.length, matched, actions, errors };
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
  }
@@ -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;
@@ -18,7 +18,22 @@ function interpolateCommand(template, match) {
18
18
  .replace(/\$DIFF/g, diffJson)
19
19
  .replace(/\$TAGS/g, tags);
20
20
  }
21
- function executeAction(match, dryRun = false) {
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: (0, path_1.dirname)(match.file.filePath),
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
+ }
@@ -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
- // Skip frontmatter_changed in test mode (no previous state)
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',
@@ -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>;
@@ -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)('**/*.md', {
22
- cwd: watchDir,
24
+ this.fsWatcher = (0, chokidar_1.watch)(watchDir, {
23
25
  ignoreInitial: true,
24
26
  persistent: true,
25
27
  awaitWriteFinish: {
26
- stabilityThreshold: 200,
28
+ stabilityThreshold: this.debounceMs,
27
29
  pollInterval: 50,
28
30
  },
29
31
  });
30
- this.fsWatcher.on('add', (relPath) => this.handleFile(relPath, 'add'));
31
- this.fsWatcher.on('change', (relPath) => this.handleFile(relPath, 'change'));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/md-pipe",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Event-driven automation for markdown directories. entr watches files. md-pipe understands them.",
5
5
  "main": "./dist/cli.js",
6
6
  "bin": {