@safetnsr/md-pipe 0.2.0 → 0.3.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.
@@ -0,0 +1,66 @@
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.executeUpdateFrontmatterStep = executeUpdateFrontmatterStep;
7
+ const fs_1 = require("fs");
8
+ const gray_matter_1 = __importDefault(require("gray-matter"));
9
+ const template_vars_js_1 = require("../template-vars.js");
10
+ function executeUpdateFrontmatterStep(config, ctx, dryRun) {
11
+ const updates = (0, template_vars_js_1.expandDeep)(config['update-frontmatter'], ctx);
12
+ const start = Date.now();
13
+ try {
14
+ const raw = (0, fs_1.readFileSync)(ctx.file, 'utf-8');
15
+ const parsed = (0, gray_matter_1.default)(raw);
16
+ const fm = parsed.data || {};
17
+ // Apply updates
18
+ for (const [key, value] of Object.entries(updates)) {
19
+ fm[key] = coerceValue(value);
20
+ }
21
+ // Rebuild file: use gray-matter stringify
22
+ const output = gray_matter_1.default.stringify(parsed.content, fm);
23
+ if (!dryRun) {
24
+ (0, fs_1.writeFileSync)(ctx.file, output, 'utf-8');
25
+ }
26
+ // Update the context's frontmatter so subsequent steps see changes
27
+ Object.assign(ctx.frontmatter, fm);
28
+ const updatedFields = Object.keys(updates).join(', ');
29
+ return {
30
+ type: 'update-frontmatter',
31
+ success: true,
32
+ stdout: `Updated frontmatter: ${updatedFields}${dryRun ? ' [dry run]' : ''}`,
33
+ stderr: '',
34
+ exitCode: 0,
35
+ durationMs: Date.now() - start,
36
+ };
37
+ }
38
+ catch (err) {
39
+ return {
40
+ type: 'update-frontmatter',
41
+ success: false,
42
+ stdout: '',
43
+ stderr: err.message || String(err),
44
+ exitCode: 1,
45
+ durationMs: Date.now() - start,
46
+ };
47
+ }
48
+ }
49
+ /**
50
+ * Coerce string values to appropriate types.
51
+ * "true"/"false" → boolean, numeric strings → numbers.
52
+ */
53
+ function coerceValue(value) {
54
+ if (typeof value !== 'string')
55
+ return value;
56
+ if (value === 'true')
57
+ return true;
58
+ if (value === 'false')
59
+ return false;
60
+ // Don't coerce ISO dates or other strings that look numeric-ish
61
+ if (/^\d+$/.test(value) && value.length < 16)
62
+ return parseInt(value, 10);
63
+ if (/^\d+\.\d+$/.test(value) && value.length < 16)
64
+ return parseFloat(value);
65
+ return value;
66
+ }
@@ -0,0 +1,11 @@
1
+ import { TemplateContext } from '../template-vars.js';
2
+ import type { StepResult } from './run.js';
3
+ export interface WebhookStepConfig {
4
+ webhook: {
5
+ url: string;
6
+ method?: string;
7
+ headers?: Record<string, string>;
8
+ body?: unknown;
9
+ };
10
+ }
11
+ export declare function executeWebhookStep(config: WebhookStepConfig, ctx: TemplateContext, dryRun: boolean): Promise<StepResult>;
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.executeWebhookStep = executeWebhookStep;
4
+ const template_vars_js_1 = require("../template-vars.js");
5
+ async function executeWebhookStep(config, ctx, dryRun) {
6
+ const start = Date.now();
7
+ const wh = config.webhook;
8
+ // Expand env vars in URL (e.g. $WEBHOOK_URL)
9
+ let url = (0, template_vars_js_1.expandTemplate)(wh.url, ctx);
10
+ url = expandEnvVars(url);
11
+ const method = (wh.method || 'POST').toUpperCase();
12
+ const headers = {
13
+ 'Content-Type': 'application/json',
14
+ ...(wh.headers ? (0, template_vars_js_1.expandDeep)(wh.headers, ctx) : {}),
15
+ };
16
+ // Expand body
17
+ let body;
18
+ if (wh.body) {
19
+ const expanded = (0, template_vars_js_1.expandDeep)(wh.body, ctx);
20
+ // Also expand $ENV_VAR style in string values
21
+ body = JSON.stringify(expanded, (_key, val) => {
22
+ if (typeof val === 'string')
23
+ return expandEnvVars(val);
24
+ return val;
25
+ });
26
+ }
27
+ else {
28
+ // Default body: file info + frontmatter
29
+ body = JSON.stringify({
30
+ file: ctx.file,
31
+ relative: ctx.relative,
32
+ slug: ctx.slug,
33
+ frontmatter: ctx.frontmatter,
34
+ });
35
+ }
36
+ if (dryRun) {
37
+ return {
38
+ type: 'webhook',
39
+ success: true,
40
+ stdout: `[dry run] ${method} ${url}`,
41
+ stderr: '',
42
+ exitCode: 0,
43
+ durationMs: Date.now() - start,
44
+ };
45
+ }
46
+ try {
47
+ const response = await fetch(url, {
48
+ method,
49
+ headers,
50
+ body: method !== 'GET' ? body : undefined,
51
+ });
52
+ const responseText = await response.text();
53
+ const success = response.ok;
54
+ return {
55
+ type: 'webhook',
56
+ success,
57
+ stdout: responseText.slice(0, 4096),
58
+ stderr: success ? '' : `HTTP ${response.status} ${response.statusText}`,
59
+ exitCode: success ? 0 : 1,
60
+ durationMs: Date.now() - start,
61
+ };
62
+ }
63
+ catch (err) {
64
+ return {
65
+ type: 'webhook',
66
+ success: false,
67
+ stdout: '',
68
+ stderr: err.message || String(err),
69
+ exitCode: 1,
70
+ durationMs: Date.now() - start,
71
+ };
72
+ }
73
+ }
74
+ /**
75
+ * Expand $ENV_VAR and ${ENV_VAR} style variables from process.env
76
+ */
77
+ function expandEnvVars(str) {
78
+ return str.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (_match, name) => {
79
+ return process.env[name] || '';
80
+ });
81
+ }
@@ -0,0 +1,43 @@
1
+ export interface TemplateContext {
2
+ file: string;
3
+ dir: string;
4
+ basename: string;
5
+ relative: string;
6
+ slug: string;
7
+ frontmatter: Record<string, unknown>;
8
+ tags: string[];
9
+ content: string;
10
+ body: string;
11
+ diff: Record<string, {
12
+ old: unknown;
13
+ new: unknown;
14
+ }> | null;
15
+ steps: StepOutput[];
16
+ }
17
+ export interface StepOutput {
18
+ stdout: string;
19
+ stderr: string;
20
+ exitCode: number;
21
+ [key: string]: unknown;
22
+ }
23
+ /**
24
+ * Expand template variables in a string.
25
+ * Supports: {{now}}, {{date}}, {{slug}}, {{file}}, {{basename}}, {{relative}},
26
+ * {{dir}}, {{tags}}, {{fm.<field>}}, {{step.<index>.<field>}}
27
+ */
28
+ export declare function expandTemplate(template: string, ctx: TemplateContext): string;
29
+ /**
30
+ * Deep-expand all string values in an object/array/primitive.
31
+ */
32
+ export declare function expandDeep(value: unknown, ctx: TemplateContext): unknown;
33
+ /**
34
+ * Build a TemplateContext from file state and match info.
35
+ */
36
+ export declare function buildContext(filePath: string, relativePath: string, dir: string, frontmatter: Record<string, unknown>, tags: string[], content: string, body: string, diff: Record<string, {
37
+ old: unknown;
38
+ new: unknown;
39
+ }> | null): TemplateContext;
40
+ /**
41
+ * Build env vars for shell commands (backward compatible + new).
42
+ */
43
+ export declare function buildEnvVars(ctx: TemplateContext): Record<string, string>;
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.expandTemplate = expandTemplate;
4
+ exports.expandDeep = expandDeep;
5
+ exports.buildContext = buildContext;
6
+ exports.buildEnvVars = buildEnvVars;
7
+ const path_1 = require("path");
8
+ /**
9
+ * Expand template variables in a string.
10
+ * Supports: {{now}}, {{date}}, {{slug}}, {{file}}, {{basename}}, {{relative}},
11
+ * {{dir}}, {{tags}}, {{fm.<field>}}, {{step.<index>.<field>}}
12
+ */
13
+ function expandTemplate(template, ctx) {
14
+ return template.replace(/\{\{([^}]+)\}\}/g, (_match, key) => {
15
+ const k = key.trim();
16
+ // Built-in variables
17
+ switch (k) {
18
+ case 'now':
19
+ return new Date().toISOString();
20
+ case 'date':
21
+ return new Date().toISOString().split('T')[0];
22
+ case 'timestamp':
23
+ return String(Date.now());
24
+ case 'slug':
25
+ return ctx.slug;
26
+ case 'file':
27
+ return ctx.file;
28
+ case 'basename':
29
+ return ctx.basename;
30
+ case 'relative':
31
+ return ctx.relative;
32
+ case 'dir':
33
+ return ctx.dir;
34
+ case 'tags':
35
+ return ctx.tags.join(',');
36
+ case 'content':
37
+ return ctx.body;
38
+ }
39
+ // Frontmatter access: {{fm.title}}, {{fm.status}}
40
+ if (k.startsWith('fm.')) {
41
+ const field = k.slice(3);
42
+ const val = ctx.frontmatter[field];
43
+ if (val === undefined || val === null)
44
+ return '';
45
+ if (typeof val === 'object')
46
+ return JSON.stringify(val);
47
+ return String(val);
48
+ }
49
+ // Step output access: {{step.0.stdout}}, {{step.1.exitCode}}
50
+ if (k.startsWith('step.')) {
51
+ const parts = k.slice(5).split('.');
52
+ const idx = parseInt(parts[0], 10);
53
+ const field = parts.slice(1).join('.');
54
+ if (isNaN(idx) || idx < 0 || idx >= ctx.steps.length)
55
+ return '';
56
+ const step = ctx.steps[idx];
57
+ if (!step)
58
+ return '';
59
+ const val = step[field];
60
+ if (val === undefined || val === null)
61
+ return '';
62
+ if (typeof val === 'object')
63
+ return JSON.stringify(val);
64
+ return String(val);
65
+ }
66
+ // Unknown variable — leave as-is
67
+ return `{{${k}}}`;
68
+ });
69
+ }
70
+ /**
71
+ * Deep-expand all string values in an object/array/primitive.
72
+ */
73
+ function expandDeep(value, ctx) {
74
+ if (typeof value === 'string') {
75
+ return expandTemplate(value, ctx);
76
+ }
77
+ if (Array.isArray(value)) {
78
+ return value.map(v => expandDeep(v, ctx));
79
+ }
80
+ if (value !== null && typeof value === 'object') {
81
+ const result = {};
82
+ for (const [k, v] of Object.entries(value)) {
83
+ result[k] = expandDeep(v, ctx);
84
+ }
85
+ return result;
86
+ }
87
+ return value;
88
+ }
89
+ /**
90
+ * Build a TemplateContext from file state and match info.
91
+ */
92
+ function buildContext(filePath, relativePath, dir, frontmatter, tags, content, body, diff) {
93
+ const ext = (0, path_1.extname)(relativePath);
94
+ const slug = (0, path_1.basename)(relativePath, ext);
95
+ return {
96
+ file: filePath,
97
+ dir,
98
+ basename: (0, path_1.basename)(filePath),
99
+ relative: relativePath,
100
+ slug,
101
+ frontmatter,
102
+ tags,
103
+ content,
104
+ body,
105
+ diff,
106
+ steps: [],
107
+ };
108
+ }
109
+ /**
110
+ * Build env vars for shell commands (backward compatible + new).
111
+ */
112
+ function buildEnvVars(ctx) {
113
+ const env = {
114
+ FILE: ctx.file,
115
+ DIR: ctx.dir,
116
+ BASENAME: ctx.basename,
117
+ RELATIVE: ctx.relative,
118
+ SLUG: ctx.slug,
119
+ FRONTMATTER: JSON.stringify(ctx.frontmatter),
120
+ DIFF: ctx.diff ? JSON.stringify(ctx.diff) : '{}',
121
+ TAGS: ctx.tags.join(','),
122
+ };
123
+ // FM_ env vars
124
+ for (const [key, val] of Object.entries(ctx.frontmatter)) {
125
+ if (val === null || val === undefined)
126
+ continue;
127
+ env[`FM_${key}`] = typeof val === 'object' ? JSON.stringify(val) : String(val);
128
+ }
129
+ // Step outputs
130
+ for (let i = 0; i < ctx.steps.length; i++) {
131
+ const step = ctx.steps[i];
132
+ if (step.stdout)
133
+ env[`STEP_${i}_STDOUT`] = step.stdout;
134
+ if (step.stderr)
135
+ env[`STEP_${i}_STDERR`] = step.stderr;
136
+ }
137
+ // Last step output shortcut
138
+ if (ctx.steps.length > 0) {
139
+ const last = ctx.steps[ctx.steps.length - 1];
140
+ env.STEP_OUTPUT = last.stdout || '';
141
+ }
142
+ return env;
143
+ }
@@ -6,6 +6,7 @@ export interface TestResult {
6
6
  tags: string[];
7
7
  matches: Array<{
8
8
  triggerName: string;
9
+ type: 'trigger' | 'pipeline';
9
10
  reason: string;
10
11
  }>;
11
12
  }
@@ -12,8 +12,8 @@ function testFile(config, filePath) {
12
12
  const relativePath = (0, path_1.relative)(config.watch, absPath);
13
13
  const file = (0, matcher_js_1.parseMarkdownFile)(absPath, relativePath);
14
14
  const matches = [];
15
+ // Check legacy triggers
15
16
  for (const trigger of config.triggers) {
16
- // For frontmatter_changed triggers, still check other conditions first
17
17
  if (trigger.match.frontmatter_changed) {
18
18
  const staticMatch = { ...trigger.match };
19
19
  delete staticMatch.frontmatter_changed;
@@ -26,34 +26,62 @@ function testFile(config, filePath) {
26
26
  }
27
27
  matches.push({
28
28
  triggerName: trigger.name,
29
- reason: `frontmatter_changed: would fire on changes to [${trigger.match.frontmatter_changed.join(', ')}] (no previous state to diff in test mode)`,
29
+ type: 'trigger',
30
+ reason: `frontmatter_changed: would fire on changes to [${trigger.match.frontmatter_changed.join(', ')}]`,
30
31
  });
31
32
  continue;
32
33
  }
33
34
  const result = (0, matcher_js_1.evaluateTrigger)(trigger, file);
34
35
  if (result) {
35
- const reasons = [];
36
- if (trigger.match.path)
37
- reasons.push(`path matches "${trigger.match.path}"`);
38
- if (trigger.match.frontmatter)
39
- reasons.push(`frontmatter matches ${JSON.stringify(trigger.match.frontmatter)}`);
40
- if (trigger.match.tags)
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}/`);
46
36
  matches.push({
47
37
  triggerName: trigger.name,
48
- reason: reasons.join(' + ') || 'all conditions matched',
38
+ type: 'trigger',
39
+ reason: buildMatchReason(trigger.match),
49
40
  });
50
41
  }
51
42
  }
52
- return {
53
- filePath: absPath,
54
- relativePath,
55
- frontmatter: file.frontmatter,
56
- tags: file.tags,
57
- matches,
58
- };
43
+ // Check pipelines
44
+ for (const pipeline of config.pipelines) {
45
+ const triggerDef = { name: pipeline.name, match: pipeline.trigger, run: '' };
46
+ if (pipeline.trigger.frontmatter_changed) {
47
+ const staticMatch = { ...pipeline.trigger };
48
+ delete staticMatch.frontmatter_changed;
49
+ const hasStaticConditions = staticMatch.path || staticMatch.frontmatter || staticMatch.tags || staticMatch.content || staticMatch.content_regex;
50
+ if (hasStaticConditions) {
51
+ const staticTrigger = { ...triggerDef, match: staticMatch };
52
+ const staticResult = (0, matcher_js_1.evaluateTrigger)(staticTrigger, file);
53
+ if (!staticResult)
54
+ continue;
55
+ }
56
+ matches.push({
57
+ triggerName: pipeline.name,
58
+ type: 'pipeline',
59
+ reason: `frontmatter_changed: would fire on changes to [${pipeline.trigger.frontmatter_changed.join(', ')}] (${pipeline.steps.length} steps)`,
60
+ });
61
+ continue;
62
+ }
63
+ const result = (0, matcher_js_1.evaluateTrigger)(triggerDef, file);
64
+ if (result) {
65
+ matches.push({
66
+ triggerName: pipeline.name,
67
+ type: 'pipeline',
68
+ reason: `${buildMatchReason(pipeline.trigger)} (${pipeline.steps.length} steps)`,
69
+ });
70
+ }
71
+ }
72
+ return { filePath: absPath, relativePath, frontmatter: file.frontmatter, tags: file.tags, matches };
73
+ }
74
+ function buildMatchReason(match) {
75
+ const reasons = [];
76
+ if (match.path)
77
+ reasons.push(`path matches "${match.path}"`);
78
+ if (match.frontmatter)
79
+ reasons.push(`frontmatter matches ${JSON.stringify(match.frontmatter)}`);
80
+ if (match.tags)
81
+ reasons.push(`has tags [${match.tags.join(', ')}]`);
82
+ if (match.content)
83
+ reasons.push(`body contains "${match.content}"`);
84
+ if (match.content_regex)
85
+ reasons.push(`body matches /${match.content_regex}/`);
86
+ return reasons.join(' + ') || 'all conditions matched';
59
87
  }
@@ -2,9 +2,11 @@ import { EventEmitter } from 'events';
2
2
  import { MdPipeConfig } from './config.js';
3
3
  import { MatchResult } from './matcher.js';
4
4
  import { RunResult } from './runner.js';
5
+ import { PipelineResult } from './pipeline.js';
5
6
  export interface WatcherEvents {
6
7
  match: (result: MatchResult) => void;
7
8
  action: (result: RunResult) => void;
9
+ pipeline: (result: PipelineResult) => void;
8
10
  error: (error: Error, filePath?: string) => void;
9
11
  ready: () => void;
10
12
  }
@@ -6,6 +6,7 @@ const path_1 = require("path");
6
6
  const events_1 = require("events");
7
7
  const matcher_js_1 = require("./matcher.js");
8
8
  const runner_js_1 = require("./runner.js");
9
+ const pipeline_js_1 = require("./pipeline.js");
9
10
  class MdPipeWatcher extends events_1.EventEmitter {
10
11
  config;
11
12
  fsWatcher = null;
@@ -33,7 +34,6 @@ class MdPipeWatcher extends events_1.EventEmitter {
33
34
  if (!absPath.endsWith('.md'))
34
35
  return;
35
36
  const relPath = (0, path_1.relative)(watchDir, absPath);
36
- // Debounce: cancel any pending timer for this file
37
37
  const existing = this.debounceTimers.get(relPath);
38
38
  if (existing)
39
39
  clearTimeout(existing);
@@ -48,7 +48,6 @@ class MdPipeWatcher extends events_1.EventEmitter {
48
48
  this.fsWatcher.on('error', (err) => this.emit('error', err instanceof Error ? err : new Error(String(err))));
49
49
  }
50
50
  async stop() {
51
- // Clear pending debounce timers
52
51
  for (const timer of this.debounceTimers.values()) {
53
52
  clearTimeout(timer);
54
53
  }
@@ -69,7 +68,7 @@ class MdPipeWatcher extends events_1.EventEmitter {
69
68
  return;
70
69
  }
71
70
  const previousFm = this.frontmatterCache.get(relativePath);
72
- // Run through triggers
71
+ // Run legacy triggers
73
72
  for (const trigger of this.config.triggers) {
74
73
  const match = (0, matcher_js_1.evaluateTrigger)(trigger, file, previousFm);
75
74
  if (match) {
@@ -78,6 +77,23 @@ class MdPipeWatcher extends events_1.EventEmitter {
78
77
  this.emit('action', result);
79
78
  }
80
79
  }
80
+ // Run pipelines
81
+ for (const pipeline of this.config.pipelines) {
82
+ // Create a synthetic trigger def for evaluation
83
+ const triggerDef = {
84
+ name: pipeline.name,
85
+ match: pipeline.trigger,
86
+ run: '', // not used, just for type compat
87
+ };
88
+ const match = (0, matcher_js_1.evaluateTrigger)(triggerDef, file, previousFm);
89
+ if (match) {
90
+ this.emit('match', match);
91
+ // Execute pipeline async
92
+ (0, pipeline_js_1.executePipeline)(pipeline, match, this.config.configDir, this.dryRun)
93
+ .then(result => this.emit('pipeline', result))
94
+ .catch(err => this.emit('error', err instanceof Error ? err : new Error(String(err)), absolutePath));
95
+ }
96
+ }
81
97
  // Cache frontmatter for diff tracking
82
98
  this.frontmatterCache.set(relativePath, { ...file.frontmatter });
83
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/md-pipe",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {