@bliztek/mdx-utils 1.0.0 → 2.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/dist/node.js CHANGED
@@ -5,13 +5,333 @@ import {
5
5
  } from "./chunk-2WOYQBZW.js";
6
6
 
7
7
  // src/node.ts
8
+ import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
9
+ import path2 from "path";
10
+
11
+ // src/collection.ts
8
12
  import { readdir, readFile } from "fs/promises";
9
13
  import path from "path";
14
+
15
+ // src/frontmatter.ts
16
+ function defaultParseFrontmatter(raw) {
17
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
18
+ if (!match) return {};
19
+ const lines = match[1].split(/\r?\n/);
20
+ const result = {};
21
+ let i = 0;
22
+ while (i < lines.length) {
23
+ const line = lines[i];
24
+ if (/^\s*(#|$)/.test(line)) {
25
+ i++;
26
+ continue;
27
+ }
28
+ const kv = line.match(/^(\s*)([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
29
+ if (!kv) {
30
+ i++;
31
+ continue;
32
+ }
33
+ const key = kv[2];
34
+ const rawValue = kv[3].trim();
35
+ if (rawValue === "") {
36
+ const items = [];
37
+ let j = i + 1;
38
+ while (j < lines.length) {
39
+ const next = lines[j];
40
+ if (/^\s*$/.test(next)) {
41
+ j++;
42
+ continue;
43
+ }
44
+ const blockItem = next.match(/^\s+-\s+(.*)$/);
45
+ if (!blockItem) break;
46
+ items.push(parseScalar(blockItem[1].trim()));
47
+ j++;
48
+ }
49
+ if (items.length > 0) {
50
+ result[key] = items;
51
+ i = j;
52
+ continue;
53
+ }
54
+ result[key] = "";
55
+ i++;
56
+ continue;
57
+ }
58
+ if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
59
+ const inner = rawValue.slice(1, -1).trim();
60
+ result[key] = inner === "" ? [] : splitInlineArray(inner).map(parseScalar);
61
+ i++;
62
+ continue;
63
+ }
64
+ result[key] = parseScalar(rawValue);
65
+ i++;
66
+ }
67
+ return result;
68
+ }
69
+ function parseScalar(value) {
70
+ if (value.length >= 2) {
71
+ const first = value[0];
72
+ const last = value[value.length - 1];
73
+ if (first === '"' && last === '"' || first === "'" && last === "'") {
74
+ return value.slice(1, -1);
75
+ }
76
+ }
77
+ return value;
78
+ }
79
+ function splitInlineArray(inner) {
80
+ const out = [];
81
+ let buf = "";
82
+ let quote = null;
83
+ for (let i = 0; i < inner.length; i++) {
84
+ const ch = inner[i];
85
+ if (quote) {
86
+ buf += ch;
87
+ if (ch === quote) quote = null;
88
+ continue;
89
+ }
90
+ if (ch === '"' || ch === "'") {
91
+ quote = ch;
92
+ buf += ch;
93
+ continue;
94
+ }
95
+ if (ch === ",") {
96
+ out.push(buf.trim());
97
+ buf = "";
98
+ continue;
99
+ }
100
+ buf += ch;
101
+ }
102
+ if (buf.trim() !== "") out.push(buf.trim());
103
+ return out;
104
+ }
105
+
106
+ // src/errors.ts
107
+ var MAX_INLINE_ISSUES = 10;
108
+ function formatMessage(issues) {
109
+ if (issues.length === 0) {
110
+ return "MdxValidationError: no issues (this is a bug \u2014 do not throw with an empty list)";
111
+ }
112
+ const head = `MdxValidationError: ${issues.length} issue${issues.length === 1 ? "" : "s"} found in frontmatter`;
113
+ const shown = issues.slice(0, MAX_INLINE_ISSUES);
114
+ const lines = shown.map(
115
+ (issue) => ` - ${issue.filePath} @ ${issue.path}: ${issue.message}`
116
+ );
117
+ if (issues.length > shown.length) {
118
+ lines.push(` ... and ${issues.length - shown.length} more`);
119
+ }
120
+ return [head, ...lines].join("\n");
121
+ }
122
+ var MdxValidationError = class extends Error {
123
+ constructor(issues) {
124
+ super(formatMessage(issues));
125
+ this.name = "MdxValidationError";
126
+ this.issues = issues;
127
+ }
128
+ };
129
+
130
+ // src/collection.ts
131
+ async function walkCollection(rootAbs, namespaceDepth) {
132
+ const dirents = await readdir(rootAbs, {
133
+ withFileTypes: true,
134
+ recursive: true
135
+ });
136
+ const files = [];
137
+ for (const dirent of dirents) {
138
+ if (dirent.isDirectory()) continue;
139
+ if (!dirent.name.endsWith(".mdx")) continue;
140
+ const parentPath = dirent.parentPath ?? rootAbs;
141
+ const relativeDir = path.relative(rootAbs, parentPath);
142
+ const segments = relativeDir === "" ? [] : relativeDir.split(path.sep);
143
+ if (segments.length !== namespaceDepth) {
144
+ const filePath = path.join(parentPath, dirent.name);
145
+ throw new Error(
146
+ `[mdx-utils] File ${filePath} is at depth ${segments.length} but the collection declares namespaceDepth=${namespaceDepth}. Move the file or adjust namespaceDepth.`
147
+ );
148
+ }
149
+ files.push({
150
+ namespace: segments.join("/"),
151
+ slug: dirent.name.slice(0, -".mdx".length),
152
+ filePath: path.join(parentPath, dirent.name)
153
+ });
154
+ }
155
+ return files;
156
+ }
157
+ function stripFrontmatterBlock(raw) {
158
+ const match = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
159
+ return match ? raw.slice(match[0].length) : raw;
160
+ }
161
+ function sortEntries(entries) {
162
+ const withDate = [];
163
+ const withoutDate = [];
164
+ for (const entry of entries) {
165
+ const meta = entry.metadata;
166
+ const publishedAt = meta?.publishedAt;
167
+ if (typeof publishedAt === "string") {
168
+ const ts = new Date(publishedAt).getTime();
169
+ if (!Number.isNaN(ts)) {
170
+ withDate.push({ entry, ts });
171
+ continue;
172
+ }
173
+ }
174
+ withoutDate.push(entry);
175
+ }
176
+ withDate.sort((a, b) => b.ts - a.ts);
177
+ return [...withDate.map((x) => x.entry), ...withoutDate];
178
+ }
179
+ function extractIssues(filePath, error) {
180
+ if (error && typeof error === "object" && "issues" in error && Array.isArray(error.issues)) {
181
+ const raw = error.issues;
182
+ const mapped = [];
183
+ for (const entry of raw) {
184
+ const pathValue = entry.path;
185
+ const dotted = Array.isArray(pathValue) ? pathValue.join(".") : typeof pathValue === "string" ? pathValue : "(root)";
186
+ const message2 = typeof entry.message === "string" ? entry.message : String(entry);
187
+ mapped.push({ filePath, path: dotted || "(root)", message: message2 });
188
+ }
189
+ if (mapped.length > 0) return mapped;
190
+ }
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ return [{ filePath, path: "(root)", message }];
193
+ }
194
+ function createMdxCollection(options) {
195
+ const {
196
+ root,
197
+ namespaceDepth = 0,
198
+ frontmatterSchema,
199
+ parseFrontmatter = defaultParseFrontmatter,
200
+ wordsPerMinute
201
+ } = options;
202
+ const rootAbs = path.resolve(root);
203
+ let entriesPromise = null;
204
+ async function loadEntries() {
205
+ const files = await walkCollection(rootAbs, namespaceDepth);
206
+ const loaded = await Promise.all(
207
+ files.map(async (file) => {
208
+ const raw = await readFile(file.filePath, "utf-8");
209
+ const metadata = parseFrontmatter(raw);
210
+ const body = stripFrontmatterBlock(raw);
211
+ const { minutes } = calculateReadTime(body, { wordsPerMinute });
212
+ return {
213
+ namespace: file.namespace,
214
+ slug: file.slug,
215
+ metadata,
216
+ readTime: minutes,
217
+ tableOfContents: void 0,
218
+ filePath: file.filePath
219
+ };
220
+ })
221
+ );
222
+ return sortEntries(loaded);
223
+ }
224
+ function getAll() {
225
+ if (!entriesPromise) {
226
+ entriesPromise = loadEntries().catch((err) => {
227
+ entriesPromise = null;
228
+ throw err;
229
+ });
230
+ }
231
+ return entriesPromise;
232
+ }
233
+ async function getAllByNamespace(namespace) {
234
+ const all = await getAll();
235
+ return all.filter((entry) => entry.namespace === namespace);
236
+ }
237
+ async function get(args) {
238
+ const targetNamespace = args.namespace ?? "";
239
+ const all = await getAll();
240
+ return all.find(
241
+ (entry) => entry.namespace === targetNamespace && entry.slug === args.slug
242
+ ) ?? null;
243
+ }
244
+ async function resolveRelated(namespace, slugs) {
245
+ const all = await getAll();
246
+ const inNamespace = new Map(
247
+ all.filter((entry) => entry.namespace === namespace).map((entry) => [entry.slug, entry])
248
+ );
249
+ const resolved = [];
250
+ for (const slug of slugs) {
251
+ const entry = inNamespace.get(slug);
252
+ if (entry) {
253
+ resolved.push(entry);
254
+ } else {
255
+ console.warn(
256
+ `[mdx-utils] resolveRelated: no entry for "${slug}" in namespace "${namespace}"`
257
+ );
258
+ }
259
+ }
260
+ return resolved;
261
+ }
262
+ async function validate() {
263
+ if (!frontmatterSchema) return;
264
+ const all = await getAll();
265
+ const issues = [];
266
+ for (const entry of all) {
267
+ try {
268
+ frontmatterSchema.parse(entry.metadata);
269
+ } catch (err) {
270
+ issues.push(...extractIssues(entry.filePath, err));
271
+ }
272
+ }
273
+ if (issues.length > 0) {
274
+ throw new MdxValidationError(issues);
275
+ }
276
+ }
277
+ function invalidate() {
278
+ entriesPromise = null;
279
+ }
280
+ return {
281
+ getAll,
282
+ getAllByNamespace,
283
+ get,
284
+ resolveRelated,
285
+ validate,
286
+ invalidate
287
+ };
288
+ }
289
+
290
+ // src/redirects.ts
291
+ function applyTokens(template, namespace, slug) {
292
+ return template.replace(/\{namespace\}/g, namespace).replace(/\{slug\}/g, slug);
293
+ }
294
+ async function collectRedirects(options) {
295
+ const {
296
+ root,
297
+ namespaceDepth = 0,
298
+ basePath,
299
+ permanent = true,
300
+ aliasField = "aliases"
301
+ } = options;
302
+ const collection = createMdxCollection({ root, namespaceDepth });
303
+ const entries = await collection.getAll();
304
+ const redirects = [];
305
+ const sourceOwners = /* @__PURE__ */ new Map();
306
+ for (const entry of entries) {
307
+ const meta = entry.metadata;
308
+ const rawAliases = meta[aliasField];
309
+ if (!Array.isArray(rawAliases)) continue;
310
+ const destination = applyTokens(basePath, entry.namespace, entry.slug);
311
+ for (const alias of rawAliases) {
312
+ if (typeof alias !== "string" || alias === "") continue;
313
+ const source = applyTokens(basePath, entry.namespace, alias);
314
+ if (source === destination) continue;
315
+ const existing = sourceOwners.get(source);
316
+ if (existing && existing !== entry.filePath) {
317
+ console.warn(
318
+ `[mdx-utils] collectRedirects: duplicate alias "${alias}" defined in both ${existing} and ${entry.filePath}. Next.js will pick the first match.`
319
+ );
320
+ } else {
321
+ sourceOwners.set(source, entry.filePath);
322
+ }
323
+ redirects.push({ source, destination, permanent });
324
+ }
325
+ }
326
+ return redirects;
327
+ }
328
+
329
+ // src/node.ts
10
330
  var DEFAULT_EXTENSIONS = [".mdx"];
11
331
  async function getContentSlugs(dirPath, options) {
12
332
  const extensions = options?.extensions ?? DEFAULT_EXTENSIONS;
13
333
  const recursive = options?.recursive ?? false;
14
- const dirents = await readdir(dirPath, {
334
+ const dirents = await readdir2(dirPath, {
15
335
  withFileTypes: true,
16
336
  recursive
17
337
  });
@@ -20,15 +340,19 @@ async function getContentSlugs(dirPath, options) {
20
340
  ).map((dirent) => {
21
341
  const slug = dirent.name.substring(0, dirent.name.lastIndexOf("."));
22
342
  if (!recursive || !dirent.parentPath) return slug;
23
- const relativePath = path.relative(dirPath, dirent.parentPath);
24
- return relativePath ? path.join(relativePath, slug) : slug;
343
+ const relativePath = path2.relative(dirPath, dirent.parentPath);
344
+ return relativePath ? path2.join(relativePath, slug) : slug;
25
345
  });
26
346
  }
27
347
  async function readMdxFile(filePath) {
28
- return readFile(filePath, "utf-8");
348
+ return readFile2(filePath, "utf-8");
29
349
  }
30
350
  export {
351
+ MdxValidationError,
31
352
  calculateReadTime,
353
+ collectRedirects,
354
+ createMdxCollection,
355
+ defaultParseFrontmatter,
32
356
  getContentSlugs,
33
357
  readMdxFile,
34
358
  sortByDateDescending,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bliztek/mdx-utils",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Zero-dependency MDX content utilities for read time calculation and file discovery",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -27,7 +27,8 @@
27
27
  "scripts": {
28
28
  "build": "tsup",
29
29
  "dev": "tsup --watch",
30
- "prepublishOnly": "npm run build"
30
+ "test": "node --import tsx --test src/**/*.test.ts",
31
+ "prepublishOnly": "pnpm run build"
31
32
  },
32
33
  "keywords": [
33
34
  "mdx",
@@ -45,11 +46,12 @@
45
46
  },
46
47
  "repository": {
47
48
  "type": "git",
48
- "url": "git+https://github.com/bliztek/mdx-utils.git"
49
+ "url": "git+https://gitlab.com/bliztek/mdx-utils.git"
49
50
  },
50
51
  "devDependencies": {
51
- "@types/node": "^24.10.3",
52
+ "@types/node": "^25.6.0",
52
53
  "tsup": "^8.4.0",
53
- "typescript": "^5.9.3"
54
+ "tsx": "^4.21.0",
55
+ "typescript": "^6.0.2"
54
56
  }
55
57
  }