@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 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
+ }
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -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
@@ -0,0 +1,9 @@
1
+ export class DuplicateSlugError extends Error {
2
+ readonly duplicates: string[];
3
+
4
+ constructor(duplicates: string[]) {
5
+ super(`Duplicate slugs detected: ${duplicates.join(", ")}`);
6
+ this.name = "DuplicateSlugError";
7
+ this.duplicates = duplicates;
8
+ }
9
+ }
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
+ }
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "module": "ES2020",
6
+ "moduleResolution": "node"
7
+ },
8
+ "include": ["src/**/*"]
9
+ }