@mzebley/mark-down-cli 1.0.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 +102 -0
- package/dist/index.cjs +270 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +247 -0
- package/package.json +27 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +58 -0
- package/src/logger.ts +37 -0
- package/src/manifest.ts +155 -0
- package/src/watch.ts +64 -0
- package/tsconfig.json +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# mark↓ CLI
|
|
2
|
+
*(published as `@mzebley/mark-down-cli`)*
|
|
3
|
+
|
|
4
|
+
`mark-down` is the command-line companion to the mark↓ runtime. It scans Markdown snippets, parses YAML front matter, and emits a sorted `snippets-index.json` manifest consumed by [the core client](../core/README.md). For a full project overview, see the [monorepo README](../../README.md).
|
|
5
|
+
|
|
6
|
+
## Table of contents
|
|
7
|
+
|
|
8
|
+
1. [Installation](#installation)
|
|
9
|
+
2. [Usage](#usage)
|
|
10
|
+
3. [Commands](#commands)
|
|
11
|
+
4. [Configuration options](#configuration-options)
|
|
12
|
+
5. [Watching for changes](#watching-for-changes)
|
|
13
|
+
6. [Exit codes](#exit-codes)
|
|
14
|
+
7. [Troubleshooting](#troubleshooting)
|
|
15
|
+
8. [Roadmap](#roadmap)
|
|
16
|
+
9. [Related packages](#related-packages)
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Install globally to expose the `mark-down` binary:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g @mzebley/mark-down-cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
or run it on demand via `npx` without a global install:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx @mzebley/mark-down-cli build content/snippets
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
mark-down build <sourceDir> [options]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The CLI walks the directory tree, gathers front matter, and writes `snippets-index.json` alongside your Markdown files by default.
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
### `mark-down build <sourceDir>`
|
|
43
|
+
|
|
44
|
+
- Discovers `*.md` files under `sourceDir` (defaults to `content/snippets`).
|
|
45
|
+
- Parses YAML with the `yaml` package, normalizes slugs, flags duplicates, and removes drafts (`draft: true`).
|
|
46
|
+
- Writes `snippets-index.json` to the source directory by default (use `--outDir` to override).
|
|
47
|
+
- Supports relative or absolute paths.
|
|
48
|
+
|
|
49
|
+
### `mark-down watch <sourceDir>`
|
|
50
|
+
|
|
51
|
+
- Uses `chokidar` to watch for file changes.
|
|
52
|
+
- Debounces rebuilds to avoid thrashing during writes.
|
|
53
|
+
- Logs progress with the familiar `[mark↓]` prefix.
|
|
54
|
+
- Accepts the same options as `build`.
|
|
55
|
+
|
|
56
|
+
## Configuration options
|
|
57
|
+
|
|
58
|
+
The CLI stays intentionally small so it can be composed inside any toolchain. Currently supported flags:
|
|
59
|
+
|
|
60
|
+
- `-o, --output <path>` – write the manifest to a custom file instead of `<sourceDir>/snippets-index.json`.
|
|
61
|
+
|
|
62
|
+
Add flags directly after the command (`mark-down build content/snippets -o public/snippets-index.json`). Package scripts can capture these options as well.
|
|
63
|
+
|
|
64
|
+
## Watching for changes
|
|
65
|
+
|
|
66
|
+
Use watch mode when authoring content:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
mark-down watch content/snippets --outDir public/snippets
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The CLI will rebuild whenever files are created, changed, or removed.
|
|
73
|
+
|
|
74
|
+
## Exit codes
|
|
75
|
+
|
|
76
|
+
- `0` – success.
|
|
77
|
+
- `1` – generic failure (I/O issues, parse errors, etc.).
|
|
78
|
+
- `2` – duplicate slugs detected. Fix conflicts, then rerun.
|
|
79
|
+
|
|
80
|
+
CI pipelines can fail fast by treating non-zero exit codes as errors.
|
|
81
|
+
|
|
82
|
+
## Troubleshooting
|
|
83
|
+
|
|
84
|
+
- **No snippets found** – confirm the path is correct and that files end with `.md`. The CLI always uses the recursive `**/*.md` pattern.
|
|
85
|
+
- **Duplicate slugs** – check the log output; offending files are listed. Override slugs in front matter or reorganize filenames.
|
|
86
|
+
- **ESM/TypeScript projects** – invoke via `npx` or add an npm script: `"snippets:build": "mark-down build content/snippets"`.
|
|
87
|
+
|
|
88
|
+
## Roadmap
|
|
89
|
+
|
|
90
|
+
- **Additional flags** – support opt-in draft inclusion, custom glob patterns, and alternative output formats.
|
|
91
|
+
- **Manifest plugins** – allow teams to run transforms (MDX, remark, syntax highlighting) before JSON is written.
|
|
92
|
+
- **Watch dashboard** – provide a TUI/HTML preview server so content teams can inspect snippets while writing.
|
|
93
|
+
- **Parallel builds** – shard large repositories across worker pools for faster CI times.
|
|
94
|
+
|
|
95
|
+
Open an issue if one of these would unlock your workflow.
|
|
96
|
+
|
|
97
|
+
## Related packages
|
|
98
|
+
|
|
99
|
+
- [Core runtime](../core/README.md) – consume generated manifests at runtime.
|
|
100
|
+
- [Angular adapter](../angular/README.md) – use snippets inside Angular apps.
|
|
101
|
+
- [React adapter](../react/README.md) – integrate with React, Next.js, or Remix.
|
|
102
|
+
- [Example app](../../examples/basic/README.md) – see the CLI in action.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/index.ts
|
|
27
|
+
var import_commander = require("commander");
|
|
28
|
+
|
|
29
|
+
// src/manifest.ts
|
|
30
|
+
var import_promises = __toESM(require("fs/promises"), 1);
|
|
31
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
32
|
+
var import_fast_glob = __toESM(require("fast-glob"), 1);
|
|
33
|
+
var import_gray_matter = __toESM(require("gray-matter"), 1);
|
|
34
|
+
var import_yaml = __toESM(require("yaml"), 1);
|
|
35
|
+
var import_mark_down = require("@mzebley/mark-down");
|
|
36
|
+
|
|
37
|
+
// src/errors.ts
|
|
38
|
+
var DuplicateSlugError = class extends Error {
|
|
39
|
+
constructor(duplicates) {
|
|
40
|
+
super(`Duplicate slugs detected: ${duplicates.join(", ")}`);
|
|
41
|
+
this.name = "DuplicateSlugError";
|
|
42
|
+
this.duplicates = duplicates;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/manifest.ts
|
|
47
|
+
var MATTER_OPTIONS = {
|
|
48
|
+
engines: {
|
|
49
|
+
yaml: (source) => import_yaml.default.parse(source) ?? {}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
async function buildManifestFile(options) {
|
|
53
|
+
const manifest = await buildManifest(options.sourceDir);
|
|
54
|
+
const target = options.outputPath ?? import_node_path.default.join(options.sourceDir, "snippets-index.json");
|
|
55
|
+
await import_promises.default.writeFile(target, JSON.stringify(manifest, null, 2));
|
|
56
|
+
return { manifest, outputPath: target };
|
|
57
|
+
}
|
|
58
|
+
async function buildManifest(sourceDir) {
|
|
59
|
+
const cwd = import_node_path.default.resolve(sourceDir);
|
|
60
|
+
const files = await (0, import_fast_glob.default)(["**/*.md"], { cwd, absolute: true });
|
|
61
|
+
const manifest = [];
|
|
62
|
+
for (const absolutePath of files) {
|
|
63
|
+
const relativePath = import_node_path.default.relative(cwd, absolutePath);
|
|
64
|
+
const normalizedPath = toPosix(relativePath);
|
|
65
|
+
const content = await import_promises.default.readFile(absolutePath, "utf8");
|
|
66
|
+
const parsed = (0, import_gray_matter.default)(content, MATTER_OPTIONS);
|
|
67
|
+
const snippet = createSnippet(normalizedPath, parsed.data ?? {});
|
|
68
|
+
if (snippet.draft) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
manifest.push(snippet);
|
|
72
|
+
}
|
|
73
|
+
ensureUniqueSlugs(manifest);
|
|
74
|
+
manifest.sort((a, b) => {
|
|
75
|
+
const orderA = typeof a.order === "number" ? a.order : Number.POSITIVE_INFINITY;
|
|
76
|
+
const orderB = typeof b.order === "number" ? b.order : Number.POSITIVE_INFINITY;
|
|
77
|
+
if (orderA !== orderB) {
|
|
78
|
+
return orderA - orderB;
|
|
79
|
+
}
|
|
80
|
+
const titleA = a.title?.toLowerCase() ?? "";
|
|
81
|
+
const titleB = b.title?.toLowerCase() ?? "";
|
|
82
|
+
return titleA.localeCompare(titleB);
|
|
83
|
+
});
|
|
84
|
+
return manifest;
|
|
85
|
+
}
|
|
86
|
+
function createSnippet(relativePath, frontMatter) {
|
|
87
|
+
const group = deriveGroup(relativePath);
|
|
88
|
+
const slugSource = typeof frontMatter.slug === "string" && frontMatter.slug.trim().length ? frontMatter.slug : relativePath.replace(/\.md$/i, "");
|
|
89
|
+
const slug = (0, import_mark_down.normalizeSlug)(slugSource);
|
|
90
|
+
const { title, order, type, tags, draft } = normalizeKnownFields(frontMatter);
|
|
91
|
+
const extra = collectExtra(frontMatter);
|
|
92
|
+
return {
|
|
93
|
+
slug,
|
|
94
|
+
title,
|
|
95
|
+
order,
|
|
96
|
+
type,
|
|
97
|
+
tags,
|
|
98
|
+
draft,
|
|
99
|
+
path: relativePath,
|
|
100
|
+
group,
|
|
101
|
+
extra
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function normalizeKnownFields(data) {
|
|
105
|
+
return {
|
|
106
|
+
title: typeof data.title === "string" ? data.title : void 0,
|
|
107
|
+
order: typeof data.order === "number" ? data.order : data.order === null ? null : void 0,
|
|
108
|
+
type: typeof data.type === "string" ? data.type : void 0,
|
|
109
|
+
tags: normalizeTags(data.tags),
|
|
110
|
+
draft: data.draft === true ? true : void 0
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function collectExtra(data) {
|
|
114
|
+
const extra = {};
|
|
115
|
+
const reserved = /* @__PURE__ */ new Set(["slug", "title", "order", "type", "tags", "draft"]);
|
|
116
|
+
for (const [key, value] of Object.entries(data)) {
|
|
117
|
+
if (reserved.has(key)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
extra[key] = value;
|
|
121
|
+
}
|
|
122
|
+
return Object.keys(extra).length ? extra : void 0;
|
|
123
|
+
}
|
|
124
|
+
function normalizeTags(value) {
|
|
125
|
+
if (!value) {
|
|
126
|
+
return void 0;
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
return value.map((entry) => String(entry));
|
|
130
|
+
}
|
|
131
|
+
if (typeof value === "string") {
|
|
132
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
133
|
+
}
|
|
134
|
+
return void 0;
|
|
135
|
+
}
|
|
136
|
+
function deriveGroup(relativePath) {
|
|
137
|
+
const dirname = toPosix(import_node_path.default.dirname(relativePath));
|
|
138
|
+
if (dirname === "." || !dirname.length) {
|
|
139
|
+
return "root";
|
|
140
|
+
}
|
|
141
|
+
return dirname;
|
|
142
|
+
}
|
|
143
|
+
function toPosix(value) {
|
|
144
|
+
return value.split(import_node_path.default.sep).join("/");
|
|
145
|
+
}
|
|
146
|
+
function ensureUniqueSlugs(manifest) {
|
|
147
|
+
const seen = /* @__PURE__ */ new Map();
|
|
148
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
149
|
+
for (const snippet of manifest) {
|
|
150
|
+
if (seen.has(snippet.slug)) {
|
|
151
|
+
duplicates.add(snippet.slug);
|
|
152
|
+
} else {
|
|
153
|
+
seen.set(snippet.slug, snippet.path);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (duplicates.size) {
|
|
157
|
+
throw new DuplicateSlugError([...duplicates.values()]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/watch.ts
|
|
162
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
163
|
+
var import_chokidar = __toESM(require("chokidar"), 1);
|
|
164
|
+
|
|
165
|
+
// src/logger.ts
|
|
166
|
+
var brand = "mark\u2193";
|
|
167
|
+
function logEvent(level, event, fields = {}) {
|
|
168
|
+
const entry = {
|
|
169
|
+
brand,
|
|
170
|
+
level,
|
|
171
|
+
event,
|
|
172
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
173
|
+
...fields
|
|
174
|
+
};
|
|
175
|
+
const output = `${JSON.stringify(entry)}
|
|
176
|
+
`;
|
|
177
|
+
const stream = level === "error" ? process.stderr : process.stdout;
|
|
178
|
+
stream.write(output);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/watch.ts
|
|
182
|
+
async function watch(sourceDir, outputPath) {
|
|
183
|
+
const cwd = import_node_path2.default.resolve(sourceDir);
|
|
184
|
+
logEvent("info", "watch.start", {
|
|
185
|
+
directory: cwd,
|
|
186
|
+
outputPath: outputPath ?? import_node_path2.default.join(cwd, "snippets-index.json")
|
|
187
|
+
});
|
|
188
|
+
await rebuild(cwd, outputPath);
|
|
189
|
+
const watcher = import_chokidar.default.watch(["**/*.md"], {
|
|
190
|
+
cwd,
|
|
191
|
+
ignoreInitial: true,
|
|
192
|
+
awaitWriteFinish: {
|
|
193
|
+
stabilityThreshold: 200,
|
|
194
|
+
pollInterval: 50
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
const schedule = debounce(async () => {
|
|
198
|
+
await rebuild(cwd, outputPath);
|
|
199
|
+
}, 150);
|
|
200
|
+
watcher.on("all", (event, filePath) => {
|
|
201
|
+
logEvent("info", "watch.change", { event, file: filePath });
|
|
202
|
+
schedule();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async function rebuild(sourceDir, outputPath) {
|
|
206
|
+
try {
|
|
207
|
+
const result = await buildManifestFile({ sourceDir, outputPath });
|
|
208
|
+
logEvent("info", "manifest.updated", {
|
|
209
|
+
outputPath: result.outputPath,
|
|
210
|
+
snippetCount: result.manifest.length
|
|
211
|
+
});
|
|
212
|
+
return result;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const err = error;
|
|
215
|
+
logEvent("error", "manifest.update_failed", {
|
|
216
|
+
message: err.message,
|
|
217
|
+
stack: err.stack
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function debounce(fn, delay) {
|
|
222
|
+
let timer = null;
|
|
223
|
+
return (...args) => {
|
|
224
|
+
if (timer) {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
}
|
|
227
|
+
timer = setTimeout(() => {
|
|
228
|
+
timer = null;
|
|
229
|
+
void fn(...args);
|
|
230
|
+
}, delay);
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/index.ts
|
|
235
|
+
var program = new import_commander.Command();
|
|
236
|
+
program.name("mark-down").description(`${brand} CLI for building snippet manifests`).version("0.1.0");
|
|
237
|
+
program.command("build").argument("[sourceDir]", "directory containing snippets", "content/snippets").option("-o, --output <path>", "where to write snippets-index.json").action(async (sourceDir, options) => {
|
|
238
|
+
try {
|
|
239
|
+
const result = await buildManifestFile({ sourceDir, outputPath: options.output });
|
|
240
|
+
logEvent("info", "manifest.written", {
|
|
241
|
+
outputPath: result.outputPath,
|
|
242
|
+
snippetCount: result.manifest.length
|
|
243
|
+
});
|
|
244
|
+
} catch (error) {
|
|
245
|
+
handleError(error);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
program.command("watch").argument("[sourceDir]", "directory containing snippets", "content/snippets").option("-o, --output <path>", "where to write snippets-index.json").action(async (sourceDir, options) => {
|
|
249
|
+
try {
|
|
250
|
+
await watch(sourceDir, options.output);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
handleError(error);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
program.parseAsync(process.argv).catch(handleError);
|
|
256
|
+
function handleError(error) {
|
|
257
|
+
const err = error;
|
|
258
|
+
if (err instanceof DuplicateSlugError) {
|
|
259
|
+
logEvent("error", "manifest.duplicate_slug", {
|
|
260
|
+
message: err.message,
|
|
261
|
+
slugs: err.duplicates
|
|
262
|
+
});
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
265
|
+
logEvent("error", "cli.error", {
|
|
266
|
+
message: err.message,
|
|
267
|
+
stack: err.stack
|
|
268
|
+
});
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/manifest.ts
|
|
7
|
+
import fs from "fs/promises";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import fg from "fast-glob";
|
|
10
|
+
import matter from "gray-matter";
|
|
11
|
+
import YAML from "yaml";
|
|
12
|
+
import { normalizeSlug } from "@mzebley/mark-down";
|
|
13
|
+
|
|
14
|
+
// src/errors.ts
|
|
15
|
+
var DuplicateSlugError = class extends Error {
|
|
16
|
+
constructor(duplicates) {
|
|
17
|
+
super(`Duplicate slugs detected: ${duplicates.join(", ")}`);
|
|
18
|
+
this.name = "DuplicateSlugError";
|
|
19
|
+
this.duplicates = duplicates;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// src/manifest.ts
|
|
24
|
+
var MATTER_OPTIONS = {
|
|
25
|
+
engines: {
|
|
26
|
+
yaml: (source) => YAML.parse(source) ?? {}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
async function buildManifestFile(options) {
|
|
30
|
+
const manifest = await buildManifest(options.sourceDir);
|
|
31
|
+
const target = options.outputPath ?? path.join(options.sourceDir, "snippets-index.json");
|
|
32
|
+
await fs.writeFile(target, JSON.stringify(manifest, null, 2));
|
|
33
|
+
return { manifest, outputPath: target };
|
|
34
|
+
}
|
|
35
|
+
async function buildManifest(sourceDir) {
|
|
36
|
+
const cwd = path.resolve(sourceDir);
|
|
37
|
+
const files = await fg(["**/*.md"], { cwd, absolute: true });
|
|
38
|
+
const manifest = [];
|
|
39
|
+
for (const absolutePath of files) {
|
|
40
|
+
const relativePath = path.relative(cwd, absolutePath);
|
|
41
|
+
const normalizedPath = toPosix(relativePath);
|
|
42
|
+
const content = await fs.readFile(absolutePath, "utf8");
|
|
43
|
+
const parsed = matter(content, MATTER_OPTIONS);
|
|
44
|
+
const snippet = createSnippet(normalizedPath, parsed.data ?? {});
|
|
45
|
+
if (snippet.draft) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
manifest.push(snippet);
|
|
49
|
+
}
|
|
50
|
+
ensureUniqueSlugs(manifest);
|
|
51
|
+
manifest.sort((a, b) => {
|
|
52
|
+
const orderA = typeof a.order === "number" ? a.order : Number.POSITIVE_INFINITY;
|
|
53
|
+
const orderB = typeof b.order === "number" ? b.order : Number.POSITIVE_INFINITY;
|
|
54
|
+
if (orderA !== orderB) {
|
|
55
|
+
return orderA - orderB;
|
|
56
|
+
}
|
|
57
|
+
const titleA = a.title?.toLowerCase() ?? "";
|
|
58
|
+
const titleB = b.title?.toLowerCase() ?? "";
|
|
59
|
+
return titleA.localeCompare(titleB);
|
|
60
|
+
});
|
|
61
|
+
return manifest;
|
|
62
|
+
}
|
|
63
|
+
function createSnippet(relativePath, frontMatter) {
|
|
64
|
+
const group = deriveGroup(relativePath);
|
|
65
|
+
const slugSource = typeof frontMatter.slug === "string" && frontMatter.slug.trim().length ? frontMatter.slug : relativePath.replace(/\.md$/i, "");
|
|
66
|
+
const slug = normalizeSlug(slugSource);
|
|
67
|
+
const { title, order, type, tags, draft } = normalizeKnownFields(frontMatter);
|
|
68
|
+
const extra = collectExtra(frontMatter);
|
|
69
|
+
return {
|
|
70
|
+
slug,
|
|
71
|
+
title,
|
|
72
|
+
order,
|
|
73
|
+
type,
|
|
74
|
+
tags,
|
|
75
|
+
draft,
|
|
76
|
+
path: relativePath,
|
|
77
|
+
group,
|
|
78
|
+
extra
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function normalizeKnownFields(data) {
|
|
82
|
+
return {
|
|
83
|
+
title: typeof data.title === "string" ? data.title : void 0,
|
|
84
|
+
order: typeof data.order === "number" ? data.order : data.order === null ? null : void 0,
|
|
85
|
+
type: typeof data.type === "string" ? data.type : void 0,
|
|
86
|
+
tags: normalizeTags(data.tags),
|
|
87
|
+
draft: data.draft === true ? true : void 0
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function collectExtra(data) {
|
|
91
|
+
const extra = {};
|
|
92
|
+
const reserved = /* @__PURE__ */ new Set(["slug", "title", "order", "type", "tags", "draft"]);
|
|
93
|
+
for (const [key, value] of Object.entries(data)) {
|
|
94
|
+
if (reserved.has(key)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
extra[key] = value;
|
|
98
|
+
}
|
|
99
|
+
return Object.keys(extra).length ? extra : void 0;
|
|
100
|
+
}
|
|
101
|
+
function normalizeTags(value) {
|
|
102
|
+
if (!value) {
|
|
103
|
+
return void 0;
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(value)) {
|
|
106
|
+
return value.map((entry) => String(entry));
|
|
107
|
+
}
|
|
108
|
+
if (typeof value === "string") {
|
|
109
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
function deriveGroup(relativePath) {
|
|
114
|
+
const dirname = toPosix(path.dirname(relativePath));
|
|
115
|
+
if (dirname === "." || !dirname.length) {
|
|
116
|
+
return "root";
|
|
117
|
+
}
|
|
118
|
+
return dirname;
|
|
119
|
+
}
|
|
120
|
+
function toPosix(value) {
|
|
121
|
+
return value.split(path.sep).join("/");
|
|
122
|
+
}
|
|
123
|
+
function ensureUniqueSlugs(manifest) {
|
|
124
|
+
const seen = /* @__PURE__ */ new Map();
|
|
125
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
126
|
+
for (const snippet of manifest) {
|
|
127
|
+
if (seen.has(snippet.slug)) {
|
|
128
|
+
duplicates.add(snippet.slug);
|
|
129
|
+
} else {
|
|
130
|
+
seen.set(snippet.slug, snippet.path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (duplicates.size) {
|
|
134
|
+
throw new DuplicateSlugError([...duplicates.values()]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/watch.ts
|
|
139
|
+
import path2 from "path";
|
|
140
|
+
import chokidar from "chokidar";
|
|
141
|
+
|
|
142
|
+
// src/logger.ts
|
|
143
|
+
var brand = "mark\u2193";
|
|
144
|
+
function logEvent(level, event, fields = {}) {
|
|
145
|
+
const entry = {
|
|
146
|
+
brand,
|
|
147
|
+
level,
|
|
148
|
+
event,
|
|
149
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
150
|
+
...fields
|
|
151
|
+
};
|
|
152
|
+
const output = `${JSON.stringify(entry)}
|
|
153
|
+
`;
|
|
154
|
+
const stream = level === "error" ? process.stderr : process.stdout;
|
|
155
|
+
stream.write(output);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/watch.ts
|
|
159
|
+
async function watch(sourceDir, outputPath) {
|
|
160
|
+
const cwd = path2.resolve(sourceDir);
|
|
161
|
+
logEvent("info", "watch.start", {
|
|
162
|
+
directory: cwd,
|
|
163
|
+
outputPath: outputPath ?? path2.join(cwd, "snippets-index.json")
|
|
164
|
+
});
|
|
165
|
+
await rebuild(cwd, outputPath);
|
|
166
|
+
const watcher = chokidar.watch(["**/*.md"], {
|
|
167
|
+
cwd,
|
|
168
|
+
ignoreInitial: true,
|
|
169
|
+
awaitWriteFinish: {
|
|
170
|
+
stabilityThreshold: 200,
|
|
171
|
+
pollInterval: 50
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
const schedule = debounce(async () => {
|
|
175
|
+
await rebuild(cwd, outputPath);
|
|
176
|
+
}, 150);
|
|
177
|
+
watcher.on("all", (event, filePath) => {
|
|
178
|
+
logEvent("info", "watch.change", { event, file: filePath });
|
|
179
|
+
schedule();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
async function rebuild(sourceDir, outputPath) {
|
|
183
|
+
try {
|
|
184
|
+
const result = await buildManifestFile({ sourceDir, outputPath });
|
|
185
|
+
logEvent("info", "manifest.updated", {
|
|
186
|
+
outputPath: result.outputPath,
|
|
187
|
+
snippetCount: result.manifest.length
|
|
188
|
+
});
|
|
189
|
+
return result;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const err = error;
|
|
192
|
+
logEvent("error", "manifest.update_failed", {
|
|
193
|
+
message: err.message,
|
|
194
|
+
stack: err.stack
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function debounce(fn, delay) {
|
|
199
|
+
let timer = null;
|
|
200
|
+
return (...args) => {
|
|
201
|
+
if (timer) {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
}
|
|
204
|
+
timer = setTimeout(() => {
|
|
205
|
+
timer = null;
|
|
206
|
+
void fn(...args);
|
|
207
|
+
}, delay);
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/index.ts
|
|
212
|
+
var program = new Command();
|
|
213
|
+
program.name("mark-down").description(`${brand} CLI for building snippet manifests`).version("0.1.0");
|
|
214
|
+
program.command("build").argument("[sourceDir]", "directory containing snippets", "content/snippets").option("-o, --output <path>", "where to write snippets-index.json").action(async (sourceDir, options) => {
|
|
215
|
+
try {
|
|
216
|
+
const result = await buildManifestFile({ sourceDir, outputPath: options.output });
|
|
217
|
+
logEvent("info", "manifest.written", {
|
|
218
|
+
outputPath: result.outputPath,
|
|
219
|
+
snippetCount: result.manifest.length
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
handleError(error);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
program.command("watch").argument("[sourceDir]", "directory containing snippets", "content/snippets").option("-o, --output <path>", "where to write snippets-index.json").action(async (sourceDir, options) => {
|
|
226
|
+
try {
|
|
227
|
+
await watch(sourceDir, options.output);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
handleError(error);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
program.parseAsync(process.argv).catch(handleError);
|
|
233
|
+
function handleError(error) {
|
|
234
|
+
const err = error;
|
|
235
|
+
if (err instanceof DuplicateSlugError) {
|
|
236
|
+
logEvent("error", "manifest.duplicate_slug", {
|
|
237
|
+
message: err.message,
|
|
238
|
+
slugs: err.duplicates
|
|
239
|
+
});
|
|
240
|
+
process.exit(2);
|
|
241
|
+
}
|
|
242
|
+
logEvent("error", "cli.error", {
|
|
243
|
+
message: err.message,
|
|
244
|
+
stack: err.stack
|
|
245
|
+
});
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mzebley/mark-down-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "mark↓ CLI for building snippet manifests",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mark-down": "dist/index.cjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.cjs",
|
|
10
|
+
"module": "dist/index.mjs",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
17
|
+
"dev": "tsup src/index.ts --watch"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@mzebley/mark-down": "file:../core",
|
|
21
|
+
"chokidar": "^3.6.0",
|
|
22
|
+
"commander": "^11.1.0",
|
|
23
|
+
"fast-glob": "^3.3.2",
|
|
24
|
+
"gray-matter": "^4.0.3",
|
|
25
|
+
"yaml": "^2.4.1"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/errors.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { buildManifestFile } from "./manifest.js";
|
|
4
|
+
import { watch as watchSnippets } from "./watch.js";
|
|
5
|
+
import { brand, logEvent } from "./logger.js";
|
|
6
|
+
import { DuplicateSlugError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name("mark-down")
|
|
11
|
+
.description(`${brand} CLI for building snippet manifests`)
|
|
12
|
+
.version("0.1.0");
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.command("build")
|
|
16
|
+
.argument("[sourceDir]", "directory containing snippets", "content/snippets")
|
|
17
|
+
.option("-o, --output <path>", "where to write snippets-index.json")
|
|
18
|
+
.action(async (sourceDir: string, options: { output?: string }) => {
|
|
19
|
+
try {
|
|
20
|
+
const result = await buildManifestFile({ sourceDir, outputPath: options.output });
|
|
21
|
+
logEvent("info", "manifest.written", {
|
|
22
|
+
outputPath: result.outputPath,
|
|
23
|
+
snippetCount: result.manifest.length
|
|
24
|
+
});
|
|
25
|
+
} catch (error) {
|
|
26
|
+
handleError(error);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
program
|
|
31
|
+
.command("watch")
|
|
32
|
+
.argument("[sourceDir]", "directory containing snippets", "content/snippets")
|
|
33
|
+
.option("-o, --output <path>", "where to write snippets-index.json")
|
|
34
|
+
.action(async (sourceDir: string, options: { output?: string }) => {
|
|
35
|
+
try {
|
|
36
|
+
await watchSnippets(sourceDir, options.output);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
handleError(error);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
program.parseAsync(process.argv).catch(handleError);
|
|
43
|
+
|
|
44
|
+
function handleError(error: unknown) {
|
|
45
|
+
const err = error as Error;
|
|
46
|
+
if (err instanceof DuplicateSlugError) {
|
|
47
|
+
logEvent("error", "manifest.duplicate_slug", {
|
|
48
|
+
message: err.message,
|
|
49
|
+
slugs: err.duplicates
|
|
50
|
+
});
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
logEvent("error", "cli.error", {
|
|
54
|
+
message: err.message,
|
|
55
|
+
stack: err.stack
|
|
56
|
+
});
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const brand = "mark↓";
|
|
2
|
+
|
|
3
|
+
export type LogLevel = "info" | "warn" | "error";
|
|
4
|
+
|
|
5
|
+
export interface LogFields {
|
|
6
|
+
message?: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function logEvent(level: LogLevel, event: string, fields: LogFields = {}) {
|
|
11
|
+
const entry = {
|
|
12
|
+
brand,
|
|
13
|
+
level,
|
|
14
|
+
event,
|
|
15
|
+
timestamp: new Date().toISOString(),
|
|
16
|
+
...fields
|
|
17
|
+
};
|
|
18
|
+
const output = `${JSON.stringify(entry)}\n`;
|
|
19
|
+
const stream = level === "error" ? process.stderr : process.stdout;
|
|
20
|
+
stream.write(output);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function log(message: string, fields?: LogFields) {
|
|
24
|
+
if (fields) {
|
|
25
|
+
logEvent("info", message, fields);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
logEvent("info", "message", { message });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function logError(message: string, fields?: LogFields) {
|
|
32
|
+
if (fields) {
|
|
33
|
+
logEvent("error", message, fields);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
logEvent("error", "message", { message });
|
|
37
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fg from "fast-glob";
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
import { normalizeSlug, type SnippetMeta } from "@mzebley/mark-down";
|
|
7
|
+
import { DuplicateSlugError } from "./errors.js";
|
|
8
|
+
|
|
9
|
+
const MATTER_OPTIONS = {
|
|
10
|
+
engines: {
|
|
11
|
+
yaml: (source: string) => YAML.parse(source) ?? {}
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface BuildOptions {
|
|
16
|
+
sourceDir: string;
|
|
17
|
+
outputPath?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BuildResult {
|
|
21
|
+
manifest: SnippetMeta[];
|
|
22
|
+
outputPath: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function buildManifestFile(options: BuildOptions): Promise<BuildResult> {
|
|
26
|
+
const manifest = await buildManifest(options.sourceDir);
|
|
27
|
+
const target = options.outputPath ?? path.join(options.sourceDir, "snippets-index.json");
|
|
28
|
+
await fs.writeFile(target, JSON.stringify(manifest, null, 2));
|
|
29
|
+
return { manifest, outputPath: target };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function buildManifest(sourceDir: string): Promise<SnippetMeta[]> {
|
|
33
|
+
const cwd = path.resolve(sourceDir);
|
|
34
|
+
const files = await fg(["**/*.md"], { cwd, absolute: true });
|
|
35
|
+
const manifest: SnippetMeta[] = [];
|
|
36
|
+
|
|
37
|
+
for (const absolutePath of files) {
|
|
38
|
+
const relativePath = path.relative(cwd, absolutePath);
|
|
39
|
+
const normalizedPath = toPosix(relativePath);
|
|
40
|
+
const content = await fs.readFile(absolutePath, "utf8");
|
|
41
|
+
const parsed = matter(content, MATTER_OPTIONS);
|
|
42
|
+
const snippet = createSnippet(normalizedPath, parsed.data ?? {});
|
|
43
|
+
if (snippet.draft) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
manifest.push(snippet);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ensureUniqueSlugs(manifest);
|
|
50
|
+
|
|
51
|
+
manifest.sort((a, b) => {
|
|
52
|
+
const orderA = typeof a.order === "number" ? a.order : Number.POSITIVE_INFINITY;
|
|
53
|
+
const orderB = typeof b.order === "number" ? b.order : Number.POSITIVE_INFINITY;
|
|
54
|
+
if (orderA !== orderB) {
|
|
55
|
+
return orderA - orderB;
|
|
56
|
+
}
|
|
57
|
+
const titleA = a.title?.toLowerCase() ?? "";
|
|
58
|
+
const titleB = b.title?.toLowerCase() ?? "";
|
|
59
|
+
return titleA.localeCompare(titleB);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return manifest;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createSnippet(relativePath: string, frontMatter: Record<string, unknown>): SnippetMeta {
|
|
66
|
+
const group = deriveGroup(relativePath);
|
|
67
|
+
const slugSource = typeof frontMatter.slug === "string" && frontMatter.slug.trim().length
|
|
68
|
+
? frontMatter.slug
|
|
69
|
+
: relativePath.replace(/\.md$/i, "");
|
|
70
|
+
const slug = normalizeSlug(slugSource);
|
|
71
|
+
|
|
72
|
+
const { title, order, type, tags, draft } = normalizeKnownFields(frontMatter);
|
|
73
|
+
const extra = collectExtra(frontMatter);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
slug,
|
|
77
|
+
title,
|
|
78
|
+
order,
|
|
79
|
+
type,
|
|
80
|
+
tags,
|
|
81
|
+
draft,
|
|
82
|
+
path: relativePath,
|
|
83
|
+
group,
|
|
84
|
+
extra
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeKnownFields(data: Record<string, unknown>) {
|
|
89
|
+
return {
|
|
90
|
+
title: typeof data.title === "string" ? data.title : undefined,
|
|
91
|
+
order: typeof data.order === "number"
|
|
92
|
+
? data.order
|
|
93
|
+
: data.order === null
|
|
94
|
+
? null
|
|
95
|
+
: undefined,
|
|
96
|
+
type: typeof data.type === "string" ? data.type : undefined,
|
|
97
|
+
tags: normalizeTags(data.tags),
|
|
98
|
+
draft: data.draft === true ? true : undefined
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function collectExtra(data: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
103
|
+
const extra: Record<string, unknown> = {};
|
|
104
|
+
const reserved = new Set(["slug", "title", "order", "type", "tags", "draft"]);
|
|
105
|
+
for (const [key, value] of Object.entries(data)) {
|
|
106
|
+
if (reserved.has(key)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
extra[key] = value;
|
|
110
|
+
}
|
|
111
|
+
return Object.keys(extra).length ? extra : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeTags(value: unknown): string[] | undefined {
|
|
115
|
+
if (!value) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(value)) {
|
|
119
|
+
return value.map((entry) => String(entry));
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === "string") {
|
|
122
|
+
return value
|
|
123
|
+
.split(",")
|
|
124
|
+
.map((entry) => entry.trim())
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function deriveGroup(relativePath: string): string {
|
|
131
|
+
const dirname = toPosix(path.dirname(relativePath));
|
|
132
|
+
if (dirname === "." || !dirname.length) {
|
|
133
|
+
return "root";
|
|
134
|
+
}
|
|
135
|
+
return dirname;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function toPosix(value: string): string {
|
|
139
|
+
return value.split(path.sep).join("/");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function ensureUniqueSlugs(manifest: SnippetMeta[]) {
|
|
143
|
+
const seen = new Map<string, string>();
|
|
144
|
+
const duplicates = new Set<string>();
|
|
145
|
+
for (const snippet of manifest) {
|
|
146
|
+
if (seen.has(snippet.slug)) {
|
|
147
|
+
duplicates.add(snippet.slug);
|
|
148
|
+
} else {
|
|
149
|
+
seen.set(snippet.slug, snippet.path);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (duplicates.size) {
|
|
153
|
+
throw new DuplicateSlugError([...duplicates.values()]);
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/watch.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import chokidar from "chokidar";
|
|
3
|
+
import { buildManifestFile, type BuildResult } from "./manifest.js";
|
|
4
|
+
import { logEvent } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
export async function watch(sourceDir: string, outputPath?: string) {
|
|
7
|
+
const cwd = path.resolve(sourceDir);
|
|
8
|
+
logEvent("info", "watch.start", {
|
|
9
|
+
directory: cwd,
|
|
10
|
+
outputPath: outputPath ?? path.join(cwd, "snippets-index.json")
|
|
11
|
+
});
|
|
12
|
+
await rebuild(cwd, outputPath);
|
|
13
|
+
|
|
14
|
+
const watcher = chokidar.watch(["**/*.md"], {
|
|
15
|
+
cwd,
|
|
16
|
+
ignoreInitial: true,
|
|
17
|
+
awaitWriteFinish: {
|
|
18
|
+
stabilityThreshold: 200,
|
|
19
|
+
pollInterval: 50
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const schedule = debounce(async () => {
|
|
24
|
+
await rebuild(cwd, outputPath);
|
|
25
|
+
}, 150);
|
|
26
|
+
|
|
27
|
+
watcher.on("all", (event, filePath) => {
|
|
28
|
+
logEvent("info", "watch.change", { event, file: filePath });
|
|
29
|
+
schedule();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function rebuild(sourceDir: string, outputPath?: string): Promise<BuildResult | void> {
|
|
34
|
+
try {
|
|
35
|
+
const result = await buildManifestFile({ sourceDir, outputPath });
|
|
36
|
+
logEvent("info", "manifest.updated", {
|
|
37
|
+
outputPath: result.outputPath,
|
|
38
|
+
snippetCount: result.manifest.length
|
|
39
|
+
});
|
|
40
|
+
return result;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const err = error as Error;
|
|
43
|
+
logEvent("error", "manifest.update_failed", {
|
|
44
|
+
message: err.message,
|
|
45
|
+
stack: err.stack
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function debounce<T extends (...args: unknown[]) => Promise<unknown> | void>(
|
|
51
|
+
fn: T,
|
|
52
|
+
delay: number
|
|
53
|
+
) {
|
|
54
|
+
let timer: NodeJS.Timeout | null = null;
|
|
55
|
+
return (...args: Parameters<T>) => {
|
|
56
|
+
if (timer) {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
timer = setTimeout(() => {
|
|
60
|
+
timer = null;
|
|
61
|
+
void fn(...args);
|
|
62
|
+
}, delay);
|
|
63
|
+
};
|
|
64
|
+
}
|