@mzebley/mark-down-cli 1.2.1 → 1.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mzebley/mark-down-cli",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "mark↓ CLI for building snippet manifests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "main": "dist/index.mjs",
10
10
  "module": "dist/index.mjs",
11
11
  "types": "dist/index.d.ts",
12
+ "files": ["dist"],
12
13
  "publishConfig": {
13
14
  "access": "public"
14
15
  },
@@ -17,7 +18,7 @@
17
18
  "dev": "tsup --config tsup.config.ts --watch"
18
19
  },
19
20
  "dependencies": {
20
- "@mzebley/mark-down": "^1.2.1",
21
+ "@mzebley/mark-down": "^1.2.2",
21
22
  "cheerio": "^1.0.0",
22
23
  "chokidar": "^3.6.0",
23
24
  "commander": "^11.1.0",
@@ -1,116 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { load as loadHtml } from "cheerio";
4
- import { parseFrontMatter, renderMarkdown, type SnippetMeta } from "@mzebley/mark-down";
5
- import { logEvent } from "./logger.js";
6
-
7
- export interface CompilePageOptions {
8
- manifest?: string;
9
- outDir?: string;
10
- inPlace?: boolean;
11
- }
12
-
13
- const DEFAULT_OUT_DIR = "dist";
14
-
15
- export async function compilePage(inputHtml: string, options: CompilePageOptions = {}): Promise<string> {
16
- const sourcePath = path.resolve(inputHtml);
17
- await assertExists(sourcePath, `Input HTML file not found at '${inputHtml}'.`);
18
-
19
- const manifestPath = await resolveManifestPath(sourcePath, options.manifest);
20
- const manifestDir = path.dirname(manifestPath);
21
- const manifest = await loadManifest(manifestPath);
22
-
23
- const rawHtml = await fs.readFile(sourcePath, "utf8");
24
- const doctypeMatch = rawHtml.match(/^(<!doctype[^>]*>\s*)/i);
25
- const doctype = doctypeMatch?.[1] ?? "";
26
- const dom = loadHtml(rawHtml, { decodeEntities: false });
27
-
28
- const targets = dom("[data-snippet]").toArray();
29
- for (const node of targets) {
30
- const element = dom(node);
31
- const slug = element.attr("data-snippet");
32
- if (!slug) {
33
- continue;
34
- }
35
- const entry = manifest.find((item) => item.slug === slug);
36
- if (!entry) {
37
- console.warn(`mark↓: no snippet found for "${slug}"`);
38
- continue;
39
- }
40
-
41
- const snippetPath = path.resolve(manifestDir, entry.path);
42
- let raw: string;
43
- try {
44
- raw = await fs.readFile(snippetPath, "utf8");
45
- } catch (error) {
46
- console.warn(`mark↓: failed to read snippet at '${entry.path}'`, error);
47
- continue;
48
- }
49
-
50
- let body = raw;
51
- let frontMatterSlug: string | undefined;
52
- try {
53
- const frontMatter = parseFrontMatter(raw);
54
- body = frontMatter.content;
55
- frontMatterSlug = frontMatter.slug;
56
- } catch (error) {
57
- console.warn(`mark↓: failed to parse front matter for '${entry.path}'`, error);
58
- }
59
-
60
- const html = renderMarkdown(body);
61
- element.html(html);
62
-
63
- if (!element.attr("id")) {
64
- element.attr("id", frontMatterSlug ?? `snippet-${slug}`);
65
- }
66
- }
67
-
68
- const outputDir = options.inPlace ? path.dirname(sourcePath) : path.resolve(options.outDir ?? DEFAULT_OUT_DIR);
69
- if (!options.inPlace) {
70
- await fs.mkdir(outputDir, { recursive: true });
71
- }
72
- const outputPath = options.inPlace
73
- ? sourcePath
74
- : path.join(outputDir, path.basename(sourcePath));
75
-
76
- const outputHtml = `${doctype}${dom.html() ?? ""}`;
77
- await fs.writeFile(outputPath, outputHtml);
78
-
79
- logEvent("info", "compile_page.written", { outputPath });
80
- return outputPath;
81
- }
82
-
83
- async function resolveManifestPath(inputHtml: string, manifestFlag?: string): Promise<string> {
84
- const manifestPath = manifestFlag
85
- ? path.resolve(manifestFlag)
86
- : path.join(path.dirname(path.resolve(inputHtml)), "snippets-index.json");
87
- await assertExists(manifestPath, `Manifest file not found at '${manifestPath}'.`);
88
- return manifestPath;
89
- }
90
-
91
- async function loadManifest(manifestPath: string): Promise<SnippetMeta[]> {
92
- let raw: string;
93
- try {
94
- raw = await fs.readFile(manifestPath, "utf8");
95
- } catch (error) {
96
- throw new Error(`Failed to read manifest at '${manifestPath}': ${String(error)}`);
97
- }
98
-
99
- try {
100
- const parsed = JSON.parse(raw);
101
- if (!Array.isArray(parsed)) {
102
- throw new Error("Manifest must be a JSON array.");
103
- }
104
- return parsed as SnippetMeta[];
105
- } catch (error) {
106
- throw new Error(`Failed to parse manifest at '${manifestPath}': ${String(error)}`);
107
- }
108
- }
109
-
110
- async function assertExists(target: string, message: string) {
111
- try {
112
- await fs.access(target);
113
- } catch {
114
- throw new Error(message);
115
- }
116
- }
package/src/errors.ts DELETED
@@ -1,9 +0,0 @@
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 DELETED
@@ -1,77 +0,0 @@
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
- import { compilePage } from "./compile-page.js";
8
-
9
- const program = new Command();
10
- program
11
- .name("mark-down")
12
- .description(`${brand} CLI for building snippet manifests`)
13
- .version("1.2.1");
14
-
15
- program
16
- .command("build")
17
- .argument("[sourceDir]", "directory containing snippets", "content/snippets")
18
- .option("-o, --output <path>", "where to write snippets-index.json")
19
- .action(async (sourceDir: string, options: { output?: string }) => {
20
- try {
21
- const result = await buildManifestFile({ sourceDir, outputPath: options.output });
22
- logEvent("info", "manifest.written", {
23
- outputPath: result.outputPath,
24
- snippetCount: result.manifest.length
25
- });
26
- } catch (error) {
27
- handleError(error);
28
- }
29
- });
30
-
31
- program
32
- .command("watch")
33
- .argument("[sourceDir]", "directory containing snippets", "content/snippets")
34
- .option("-o, --output <path>", "where to write snippets-index.json")
35
- .action(async (sourceDir: string, options: { output?: string }) => {
36
- try {
37
- await watchSnippets(sourceDir, options.output);
38
- } catch (error) {
39
- handleError(error);
40
- }
41
- });
42
-
43
- program
44
- .command("compile-page")
45
- .argument("<inputHtml>", "HTML file containing data-snippet placeholders")
46
- .option("--manifest <path>", "path to snippets-index.json")
47
- .option("--outDir <path>", "output directory for compiled HTML", "dist")
48
- .option("--inPlace", "overwrite the input HTML file instead of writing to outDir")
49
- .action(async (inputHtml: string, options: { manifest?: string; outDir?: string; inPlace?: boolean }) => {
50
- try {
51
- await compilePage(inputHtml, {
52
- manifest: options.manifest,
53
- outDir: options.outDir,
54
- inPlace: options.inPlace
55
- });
56
- } catch (error) {
57
- handleError(error);
58
- }
59
- });
60
-
61
- program.parseAsync(process.argv).catch(handleError);
62
-
63
- function handleError(error: unknown) {
64
- const err = error as Error;
65
- if (err instanceof DuplicateSlugError) {
66
- logEvent("error", "manifest.duplicate_slug", {
67
- message: err.message,
68
- slugs: err.duplicates
69
- });
70
- process.exit(2);
71
- }
72
- logEvent("error", "cli.error", {
73
- message: err.message,
74
- stack: err.stack
75
- });
76
- process.exit(1);
77
- }
package/src/logger.ts DELETED
@@ -1,37 +0,0 @@
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 DELETED
@@ -1,155 +0,0 @@
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 DELETED
@@ -1,64 +0,0 @@
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 DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "module": "ES2020",
6
- "moduleResolution": "node"
7
- },
8
- "include": ["src/**/*"]
9
- }
package/tsup.config.ts DELETED
@@ -1,15 +0,0 @@
1
- import { defineConfig } from "tsup";
2
-
3
- export default defineConfig({
4
- entry: ["src/index.ts"],
5
- format: ["esm", "cjs"],
6
- dts: true,
7
- sourcemap: true,
8
- splitting: false,
9
- clean: true,
10
- outExtension({ format }) {
11
- return {
12
- js: format === "esm" ? ".mjs" : ".cjs"
13
- };
14
- }
15
- });