diffwiki-core 0.2.0-rc.f44d17c → 0.3.0-rc.202607211217.f5290fa

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/index.cjs CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  NoDefaultCollectionError: () => NoDefaultCollectionError,
39
39
  REPO_CONFIG_FILE: () => REPO_CONFIG_FILE,
40
40
  addTags: () => addTags,
41
+ buildArticleTree: () => buildArticleTree,
41
42
  collectionDir: () => collectionDir,
42
43
  collectionsDir: () => collectionsDir,
43
44
  configPath: () => configPath,
@@ -47,11 +48,13 @@ __export(index_exports, {
47
48
  findCollection: () => findCollection,
48
49
  getConfigValue: () => getConfigValue,
49
50
  initRepoWiki: () => initRepoWiki,
51
+ listArticleTree: () => listArticleTree,
50
52
  listCollections: () => listCollections,
51
53
  parseAddTarget: () => parseAddTarget,
52
54
  parseArticle: () => parseArticle,
53
55
  parsePathTarget: () => parsePathTarget,
54
56
  query: () => query,
57
+ readArticle: () => readArticle,
55
58
  readConfig: () => readConfig,
56
59
  readRegistry: () => readRegistry,
57
60
  readRepoConfig: () => readRepoConfig,
@@ -126,51 +129,28 @@ function collectionDir(name) {
126
129
  }
127
130
 
128
131
  // src/config.ts
129
- var import_promises = __toESM(require("fs/promises"), 1);
130
- function isMissing(err) {
131
- return err?.code === "ENOENT";
132
- }
133
- async function readConfig() {
134
- try {
135
- return JSON.parse(await import_promises.default.readFile(configPath(), "utf8"));
136
- } catch (err) {
137
- if (isMissing(err)) return {};
138
- throw err;
139
- }
140
- }
141
- async function writeConfig(config) {
142
- await import_promises.default.mkdir(resolveHome(), { recursive: true });
143
- await import_promises.default.writeFile(configPath(), `${JSON.stringify(config, null, 2)}
144
- `, "utf8");
145
- }
146
- async function setConfigValue(key, value) {
147
- const config = await readConfig();
148
- config[key] = value;
149
- await writeConfig(config);
150
- }
151
- async function getConfigValue(key) {
152
- const config = await readConfig();
153
- const value = config[key];
154
- return typeof value === "string" ? value : void 0;
155
- }
132
+ var import_promises3 = __toESM(require("fs/promises"), 1);
156
133
 
157
- // src/registry.ts
134
+ // src/collections.ts
158
135
  var import_promises2 = __toESM(require("fs/promises"), 1);
159
- function isMissing2(err) {
136
+
137
+ // src/registry.ts
138
+ var import_promises = __toESM(require("fs/promises"), 1);
139
+ function isMissing(err) {
160
140
  return err?.code === "ENOENT";
161
141
  }
162
142
  async function readRegistry() {
163
143
  try {
164
- const parsed = JSON.parse(await import_promises2.default.readFile(registryPath(), "utf8"));
144
+ const parsed = JSON.parse(await import_promises.default.readFile(registryPath(), "utf8"));
165
145
  return { version: parsed.version ?? 1, collections: parsed.collections ?? [] };
166
146
  } catch (err) {
167
- if (isMissing2(err)) return { version: 1, collections: [] };
147
+ if (isMissing(err)) return { version: 1, collections: [] };
168
148
  throw err;
169
149
  }
170
150
  }
171
151
  async function writeRegistry(registry) {
172
- await import_promises2.default.mkdir(resolveHome(), { recursive: true });
173
- await import_promises2.default.writeFile(registryPath(), `${JSON.stringify(registry, null, 2)}
152
+ await import_promises.default.mkdir(resolveHome(), { recursive: true });
153
+ await import_promises.default.writeFile(registryPath(), `${JSON.stringify(registry, null, 2)}
174
154
  `, "utf8");
175
155
  }
176
156
  async function registerCollection(entry) {
@@ -189,29 +169,7 @@ async function unregisterCollection(name) {
189
169
  await writeRegistry(registry);
190
170
  }
191
171
 
192
- // src/slugify.ts
193
- function slugify(input) {
194
- const slug = input.toLowerCase().replace(/^#+\s*/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80).replace(/-+$/g, "");
195
- return slug.length > 0 ? slug : "untitled";
196
- }
197
-
198
- // src/frontmatter.ts
199
- var import_gray_matter = __toESM(require("gray-matter"), 1);
200
- function parseArticle(raw) {
201
- const { data, content } = (0, import_gray_matter.default)(raw);
202
- const title = typeof data.title === "string" ? data.title : "";
203
- const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [];
204
- return { title, tags, body: content.replace(/^\n+/, "") };
205
- }
206
- function serializeArticle(article) {
207
- const front = { title: article.title, tags: article.tags };
208
- if (article.created) front.created = article.created;
209
- if (article.updated) front.updated = article.updated;
210
- return import_gray_matter.default.stringify(article.body, front);
211
- }
212
-
213
172
  // src/collections.ts
214
- var import_promises3 = __toESM(require("fs/promises"), 1);
215
173
  async function listCollections() {
216
174
  return (await readRegistry()).collections;
217
175
  }
@@ -221,7 +179,7 @@ async function findCollection(name) {
221
179
  async function createCollection(name) {
222
180
  if (await findCollection(name)) throw new CollectionExistsError(name);
223
181
  const dir = collectionDir(name);
224
- await import_promises3.default.mkdir(dir, { recursive: true });
182
+ await import_promises2.default.mkdir(dir, { recursive: true });
225
183
  const entry = {
226
184
  name,
227
185
  type: "global",
@@ -236,11 +194,64 @@ async function removeCollection(name) {
236
194
  if (!entry) throw new CollectionNotFoundError(name);
237
195
  await unregisterCollection(name);
238
196
  if (entry.type === "global") {
239
- await import_promises3.default.rm(entry.path, { recursive: true, force: true });
197
+ await import_promises2.default.rm(entry.path, { recursive: true, force: true });
240
198
  }
241
199
  return entry;
242
200
  }
243
201
 
202
+ // src/config.ts
203
+ function isMissing2(err) {
204
+ return err?.code === "ENOENT";
205
+ }
206
+ async function readConfig() {
207
+ try {
208
+ return JSON.parse(await import_promises3.default.readFile(configPath(), "utf8"));
209
+ } catch (err) {
210
+ if (isMissing2(err)) return {};
211
+ throw err;
212
+ }
213
+ }
214
+ async function writeConfig(config) {
215
+ await import_promises3.default.mkdir(resolveHome(), { recursive: true });
216
+ await import_promises3.default.writeFile(configPath(), `${JSON.stringify(config, null, 2)}
217
+ `, "utf8");
218
+ }
219
+ async function setConfigValue(key, value) {
220
+ if (key === "defaultCollection" && !await findCollection(value)) {
221
+ throw new CollectionNotFoundError(value);
222
+ }
223
+ const config = await readConfig();
224
+ config[key] = value;
225
+ await writeConfig(config);
226
+ }
227
+ async function getConfigValue(key) {
228
+ const config = await readConfig();
229
+ const value = config[key];
230
+ return typeof value === "string" ? value : void 0;
231
+ }
232
+
233
+ // src/slugify.ts
234
+ function slugify(input) {
235
+ const slug = input.toLowerCase().replace(/^#+\s*/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80).replace(/-+$/g, "");
236
+ return slug.length > 0 ? slug : "untitled";
237
+ }
238
+
239
+ // src/frontmatter.ts
240
+ var import_gray_matter = __toESM(require("gray-matter"), 1);
241
+ function parseArticle(raw) {
242
+ const { data, content } = (0, import_gray_matter.default)(raw);
243
+ const title = typeof data.title === "string" ? data.title : "";
244
+ const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [];
245
+ const created = typeof data.created === "string" ? data.created : void 0;
246
+ return { title, tags, created, body: content.replace(/^\n+/, "") };
247
+ }
248
+ function serializeArticle(article) {
249
+ const front = { title: article.title, tags: article.tags };
250
+ if (article.created) front.created = article.created;
251
+ if (article.updated) front.updated = article.updated;
252
+ return import_gray_matter.default.stringify(article.body, front);
253
+ }
254
+
244
255
  // src/articles.ts
245
256
  var import_promises4 = __toESM(require("fs/promises"), 1);
246
257
  var import_node_path2 = __toESM(require("path"), 1);
@@ -275,7 +286,11 @@ async function resolveCollectionDir(name) {
275
286
  return { name: coll, dir: entry.path };
276
287
  }
277
288
  function ensureMdPath(file) {
278
- return /\.mdx?$/.test(file) ? file : `${file}.md`;
289
+ if (/\.mdx?$/.test(file)) return file;
290
+ const dir = import_node_path2.default.dirname(file);
291
+ const base = slugify(import_node_path2.default.basename(file));
292
+ const rel = dir === "." ? `${base}.md` : import_node_path2.default.join(dir, `${base}.md`);
293
+ return rel;
279
294
  }
280
295
  async function createArticle(opts) {
281
296
  const { collection, title } = parseAddTarget(opts.target);
@@ -307,6 +322,7 @@ async function updateArticleBody(target, body) {
307
322
  serializeArticle({
308
323
  title: parsed.title,
309
324
  tags: parsed.tags,
325
+ created: parsed.created,
310
326
  updated: (/* @__PURE__ */ new Date()).toISOString(),
311
327
  body
312
328
  }),
@@ -323,6 +339,7 @@ async function mutateTags(target, fn) {
323
339
  serializeArticle({
324
340
  title: parsed.title,
325
341
  tags,
342
+ created: parsed.created,
326
343
  updated: (/* @__PURE__ */ new Date()).toISOString(),
327
344
  body: parsed.body
328
345
  }),
@@ -414,7 +431,8 @@ async function query(term, collection) {
414
431
  let targets;
415
432
  if (collection) {
416
433
  const one = await findCollection(collection);
417
- targets = one ? [one] : [];
434
+ if (!one) throw new CollectionNotFoundError(collection);
435
+ targets = [one];
418
436
  } else {
419
437
  targets = await listCollections();
420
438
  }
@@ -479,6 +497,128 @@ async function doctor() {
479
497
  }
480
498
  return out;
481
499
  }
500
+
501
+ // src/browse.ts
502
+ var import_promises8 = __toESM(require("fs/promises"), 1);
503
+ var import_node_path5 = __toESM(require("path"), 1);
504
+ function buildArticleTree(relPaths) {
505
+ const root = {};
506
+ for (const raw of relPaths) {
507
+ const rel = raw.replace(/\\/g, "/").replace(/^\/+/, "");
508
+ if (!rel) continue;
509
+ const segs = rel.split("/");
510
+ let cur = root;
511
+ segs.forEach((seg, i) => {
512
+ if (i === segs.length - 1) {
513
+ if (!(seg in cur)) cur[seg] = null;
514
+ } else {
515
+ if (cur[seg] === null || !(seg in cur)) cur[seg] = {};
516
+ cur = cur[seg];
517
+ }
518
+ });
519
+ }
520
+ const stripExt = (f) => f.replace(/\.(mdx?)$/, "");
521
+ const walk = (trie, parents) => {
522
+ const entries = Object.entries(trie);
523
+ const branches = entries.filter(([, v]) => v !== null);
524
+ const leaves = entries.filter(([, v]) => v === null).map(([k]) => k);
525
+ branches.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()));
526
+ leaves.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
527
+ const branchNodes = branches.map(([seg, sub]) => ({
528
+ name: seg,
529
+ title: seg,
530
+ children: walk(sub, [...parents, seg])
531
+ }));
532
+ const leafNodes = leaves.map((file) => {
533
+ const nameNoExt = stripExt(file);
534
+ return { name: nameNoExt, title: nameNoExt, slug: [...parents, nameNoExt].join("/") };
535
+ });
536
+ return [...branchNodes, ...leafNodes];
537
+ };
538
+ return walk(root, []);
539
+ }
540
+ async function collectRelPaths(dir, rel = "") {
541
+ let dirents;
542
+ try {
543
+ dirents = await import_promises8.default.readdir(dir, { withFileTypes: true });
544
+ } catch {
545
+ return [];
546
+ }
547
+ const out = [];
548
+ for (const d of dirents) {
549
+ if (d.name.startsWith(".")) continue;
550
+ const childRel = rel ? `${rel}/${d.name}` : d.name;
551
+ if (d.isDirectory()) {
552
+ out.push(...await collectRelPaths(import_node_path5.default.join(dir, d.name), childRel));
553
+ } else if (/\.(mdx?)$/.test(d.name)) {
554
+ out.push(childRel);
555
+ }
556
+ }
557
+ return out;
558
+ }
559
+ async function cheapTitle(absPath) {
560
+ let handle;
561
+ try {
562
+ handle = await import_promises8.default.open(absPath, "r");
563
+ const buf = Buffer.alloc(512);
564
+ const { bytesRead } = await handle.read(buf, 0, 512, 0);
565
+ const snippet = buf.subarray(0, bytesRead).toString("utf8");
566
+ const m = snippet.match(/^---[\s\S]*?^title:\s*(.+)$/m);
567
+ if (m) return m[1].trim().replace(/^["']|["']$/g, "");
568
+ } catch {
569
+ } finally {
570
+ await handle?.close();
571
+ }
572
+ return void 0;
573
+ }
574
+ async function enrichTitles(nodes, collDir) {
575
+ return Promise.all(
576
+ nodes.map(async (node) => {
577
+ if (node.children) {
578
+ return { ...node, children: await enrichTitles(node.children, collDir) };
579
+ }
580
+ for (const ext of [".mdx", ".md"]) {
581
+ const title = await cheapTitle(import_node_path5.default.join(collDir, `${node.slug}${ext}`));
582
+ if (title) return { ...node, title };
583
+ }
584
+ return node;
585
+ })
586
+ );
587
+ }
588
+ async function listArticleTree(coll) {
589
+ const entry = await findCollection(coll);
590
+ if (!entry) throw new CollectionNotFoundError(coll);
591
+ const rels = await collectRelPaths(entry.path);
592
+ return enrichTitles(buildArticleTree(rels), import_node_path5.default.resolve(entry.path));
593
+ }
594
+ async function readArticle(coll, slug) {
595
+ const entry = await findCollection(coll);
596
+ if (!entry) throw new CollectionNotFoundError(coll);
597
+ const collDir = import_node_path5.default.resolve(entry.path);
598
+ const normalized = slug.replace(/\\/g, "/");
599
+ if (normalized.split("/").some((s) => s === "..")) {
600
+ throw new InvalidTargetError(slug, "path traversal detected");
601
+ }
602
+ const safeSlug = normalized.split("/").filter((s) => s !== "" && s !== ".").join("/");
603
+ if (!safeSlug) throw new InvalidTargetError(slug, "expected a non-empty article slug");
604
+ let abs;
605
+ for (const ext of [".mdx", ".md"]) {
606
+ const candidate = import_node_path5.default.resolve(collDir, `${safeSlug}${ext}`);
607
+ if (candidate !== collDir && !candidate.startsWith(collDir + import_node_path5.default.sep)) {
608
+ throw new InvalidTargetError(slug, "path traversal detected");
609
+ }
610
+ try {
611
+ await import_promises8.default.access(candidate);
612
+ abs = candidate;
613
+ break;
614
+ } catch {
615
+ }
616
+ }
617
+ if (!abs) throw new ArticleNotFoundError(`${coll}/${safeSlug}`);
618
+ const parsed = parseArticle(await import_promises8.default.readFile(abs, "utf8"));
619
+ const title = parsed.title?.trim() || (safeSlug.split("/").pop() ?? safeSlug);
620
+ return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs };
621
+ }
482
622
  // Annotate the CommonJS export names for ESM import in node:
483
623
  0 && (module.exports = {
484
624
  ArticleNotFoundError,
@@ -489,6 +629,7 @@ async function doctor() {
489
629
  NoDefaultCollectionError,
490
630
  REPO_CONFIG_FILE,
491
631
  addTags,
632
+ buildArticleTree,
492
633
  collectionDir,
493
634
  collectionsDir,
494
635
  configPath,
@@ -498,11 +639,13 @@ async function doctor() {
498
639
  findCollection,
499
640
  getConfigValue,
500
641
  initRepoWiki,
642
+ listArticleTree,
501
643
  listCollections,
502
644
  parseAddTarget,
503
645
  parseArticle,
504
646
  parsePathTarget,
505
647
  query,
648
+ readArticle,
506
649
  readConfig,
507
650
  readRegistry,
508
651
  readRepoConfig,
package/dist/index.d.cts CHANGED
@@ -25,6 +25,28 @@ interface RepoConfig {
25
25
  /** Path to the wiki directory, relative to the repo root. */
26
26
  path: string;
27
27
  }
28
+ /** A node in a collection's article tree. Branches have `children`; leaves have `slug`. */
29
+ interface ArticleNode {
30
+ /** Segment label — dir name for branches, filename-without-ext for leaves. */
31
+ name: string;
32
+ /** Display title — frontmatter `title` for leaves when available, else `name`. */
33
+ title?: string;
34
+ /** Leaf only: article path within the collection, without extension, e.g. "guide/setup". */
35
+ slug?: string;
36
+ /** Branch only: nested nodes. */
37
+ children?: ArticleNode[];
38
+ }
39
+ /** A resolved article's content, returned by `readArticle`. */
40
+ interface ArticleContent {
41
+ coll: string;
42
+ slug: string;
43
+ title: string;
44
+ tags: string[];
45
+ /** Frontmatter-stripped body (md/mdx). */
46
+ body: string;
47
+ /** Absolute path to the file on disk. */
48
+ path: string;
49
+ }
28
50
 
29
51
  /** Base class for all expected, user-facing diffwiki failures. */
30
52
  declare class DiffwikiError extends Error {
@@ -70,6 +92,7 @@ declare function slugify(input: string): string;
70
92
  interface ParsedArticle {
71
93
  title: string;
72
94
  tags: string[];
95
+ created?: string;
73
96
  body: string;
74
97
  }
75
98
  interface SerializableArticle {
@@ -141,4 +164,23 @@ interface Diagnostic {
141
164
  }
142
165
  declare function doctor(): Promise<Diagnostic[]>;
143
166
 
144
- export { type Article, ArticleNotFoundError, type CollectionEntry, CollectionExistsError, CollectionNotFoundError, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type InitOptions, InvalidTargetError, NoDefaultCollectionError, type ParsedArticle, type QueryHit, REPO_CONFIG_FILE, type Registry, type RepoConfig, type SerializableArticle, addTags, collectionDir, collectionsDir, configPath, createArticle, createCollection, doctor, findCollection, getConfigValue, initRepoWiki, listCollections, parseAddTarget, parseArticle, parsePathTarget, query, readConfig, readRegistry, readRepoConfig, registerCollection, registryPath, removeArticle, removeCollection, removeTags, resolveHome, serializeArticle, setConfigValue, setTags, slugify, unregisterCollection, updateArticleBody, writeConfig, writeRegistry };
167
+ /**
168
+ * Build a nested ArticleNode[] from a flat list of relative file paths.
169
+ * Pure — no I/O. Directories become branches; files become leaves whose `slug`
170
+ * is the relative path without extension. Dirs-first, then alpha (case-insensitive).
171
+ */
172
+ declare function buildArticleTree(relPaths: string[]): ArticleNode[];
173
+ /**
174
+ * List the article tree of a collection. Walks the collection's registered
175
+ * `entry.path` — so collections living OUTSIDE `~/.diffwiki/collections`
176
+ * (e.g. in-repo wikis registered via `diffwiki init`) work unchanged.
177
+ */
178
+ declare function listArticleTree(coll: string): Promise<ArticleNode[]>;
179
+ /**
180
+ * Read and resolve a single article by collection + slug (relative path without
181
+ * extension). Tries `.mdx` then `.md`, guards against path traversal, and splits
182
+ * frontmatter. Throws CollectionNotFoundError / ArticleNotFoundError / InvalidTargetError.
183
+ */
184
+ declare function readArticle(coll: string, slug: string): Promise<ArticleContent>;
185
+
186
+ export { type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, type CollectionEntry, CollectionExistsError, CollectionNotFoundError, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type InitOptions, InvalidTargetError, NoDefaultCollectionError, type ParsedArticle, type QueryHit, REPO_CONFIG_FILE, type Registry, type RepoConfig, type SerializableArticle, addTags, buildArticleTree, collectionDir, collectionsDir, configPath, createArticle, createCollection, doctor, findCollection, getConfigValue, initRepoWiki, listArticleTree, listCollections, parseAddTarget, parseArticle, parsePathTarget, query, readArticle, readConfig, readRegistry, readRepoConfig, registerCollection, registryPath, removeArticle, removeCollection, removeTags, resolveHome, serializeArticle, setConfigValue, setTags, slugify, unregisterCollection, updateArticleBody, writeConfig, writeRegistry };
package/dist/index.d.ts CHANGED
@@ -25,6 +25,28 @@ interface RepoConfig {
25
25
  /** Path to the wiki directory, relative to the repo root. */
26
26
  path: string;
27
27
  }
28
+ /** A node in a collection's article tree. Branches have `children`; leaves have `slug`. */
29
+ interface ArticleNode {
30
+ /** Segment label — dir name for branches, filename-without-ext for leaves. */
31
+ name: string;
32
+ /** Display title — frontmatter `title` for leaves when available, else `name`. */
33
+ title?: string;
34
+ /** Leaf only: article path within the collection, without extension, e.g. "guide/setup". */
35
+ slug?: string;
36
+ /** Branch only: nested nodes. */
37
+ children?: ArticleNode[];
38
+ }
39
+ /** A resolved article's content, returned by `readArticle`. */
40
+ interface ArticleContent {
41
+ coll: string;
42
+ slug: string;
43
+ title: string;
44
+ tags: string[];
45
+ /** Frontmatter-stripped body (md/mdx). */
46
+ body: string;
47
+ /** Absolute path to the file on disk. */
48
+ path: string;
49
+ }
28
50
 
29
51
  /** Base class for all expected, user-facing diffwiki failures. */
30
52
  declare class DiffwikiError extends Error {
@@ -70,6 +92,7 @@ declare function slugify(input: string): string;
70
92
  interface ParsedArticle {
71
93
  title: string;
72
94
  tags: string[];
95
+ created?: string;
73
96
  body: string;
74
97
  }
75
98
  interface SerializableArticle {
@@ -141,4 +164,23 @@ interface Diagnostic {
141
164
  }
142
165
  declare function doctor(): Promise<Diagnostic[]>;
143
166
 
144
- export { type Article, ArticleNotFoundError, type CollectionEntry, CollectionExistsError, CollectionNotFoundError, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type InitOptions, InvalidTargetError, NoDefaultCollectionError, type ParsedArticle, type QueryHit, REPO_CONFIG_FILE, type Registry, type RepoConfig, type SerializableArticle, addTags, collectionDir, collectionsDir, configPath, createArticle, createCollection, doctor, findCollection, getConfigValue, initRepoWiki, listCollections, parseAddTarget, parseArticle, parsePathTarget, query, readConfig, readRegistry, readRepoConfig, registerCollection, registryPath, removeArticle, removeCollection, removeTags, resolveHome, serializeArticle, setConfigValue, setTags, slugify, unregisterCollection, updateArticleBody, writeConfig, writeRegistry };
167
+ /**
168
+ * Build a nested ArticleNode[] from a flat list of relative file paths.
169
+ * Pure — no I/O. Directories become branches; files become leaves whose `slug`
170
+ * is the relative path without extension. Dirs-first, then alpha (case-insensitive).
171
+ */
172
+ declare function buildArticleTree(relPaths: string[]): ArticleNode[];
173
+ /**
174
+ * List the article tree of a collection. Walks the collection's registered
175
+ * `entry.path` — so collections living OUTSIDE `~/.diffwiki/collections`
176
+ * (e.g. in-repo wikis registered via `diffwiki init`) work unchanged.
177
+ */
178
+ declare function listArticleTree(coll: string): Promise<ArticleNode[]>;
179
+ /**
180
+ * Read and resolve a single article by collection + slug (relative path without
181
+ * extension). Tries `.mdx` then `.md`, guards against path traversal, and splits
182
+ * frontmatter. Throws CollectionNotFoundError / ArticleNotFoundError / InvalidTargetError.
183
+ */
184
+ declare function readArticle(coll: string, slug: string): Promise<ArticleContent>;
185
+
186
+ export { type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, type CollectionEntry, CollectionExistsError, CollectionNotFoundError, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type InitOptions, InvalidTargetError, NoDefaultCollectionError, type ParsedArticle, type QueryHit, REPO_CONFIG_FILE, type Registry, type RepoConfig, type SerializableArticle, addTags, buildArticleTree, collectionDir, collectionsDir, configPath, createArticle, createCollection, doctor, findCollection, getConfigValue, initRepoWiki, listArticleTree, listCollections, parseAddTarget, parseArticle, parsePathTarget, query, readArticle, readConfig, readRegistry, readRepoConfig, registerCollection, registryPath, removeArticle, removeCollection, removeTags, resolveHome, serializeArticle, setConfigValue, setTags, slugify, unregisterCollection, updateArticleBody, writeConfig, writeRegistry };
package/dist/index.js CHANGED
@@ -52,51 +52,28 @@ function collectionDir(name) {
52
52
  }
53
53
 
54
54
  // src/config.ts
55
- import fs from "fs/promises";
56
- function isMissing(err) {
57
- return err?.code === "ENOENT";
58
- }
59
- async function readConfig() {
60
- try {
61
- return JSON.parse(await fs.readFile(configPath(), "utf8"));
62
- } catch (err) {
63
- if (isMissing(err)) return {};
64
- throw err;
65
- }
66
- }
67
- async function writeConfig(config) {
68
- await fs.mkdir(resolveHome(), { recursive: true });
69
- await fs.writeFile(configPath(), `${JSON.stringify(config, null, 2)}
70
- `, "utf8");
71
- }
72
- async function setConfigValue(key, value) {
73
- const config = await readConfig();
74
- config[key] = value;
75
- await writeConfig(config);
76
- }
77
- async function getConfigValue(key) {
78
- const config = await readConfig();
79
- const value = config[key];
80
- return typeof value === "string" ? value : void 0;
81
- }
55
+ import fs3 from "fs/promises";
82
56
 
83
- // src/registry.ts
57
+ // src/collections.ts
84
58
  import fs2 from "fs/promises";
85
- function isMissing2(err) {
59
+
60
+ // src/registry.ts
61
+ import fs from "fs/promises";
62
+ function isMissing(err) {
86
63
  return err?.code === "ENOENT";
87
64
  }
88
65
  async function readRegistry() {
89
66
  try {
90
- const parsed = JSON.parse(await fs2.readFile(registryPath(), "utf8"));
67
+ const parsed = JSON.parse(await fs.readFile(registryPath(), "utf8"));
91
68
  return { version: parsed.version ?? 1, collections: parsed.collections ?? [] };
92
69
  } catch (err) {
93
- if (isMissing2(err)) return { version: 1, collections: [] };
70
+ if (isMissing(err)) return { version: 1, collections: [] };
94
71
  throw err;
95
72
  }
96
73
  }
97
74
  async function writeRegistry(registry) {
98
- await fs2.mkdir(resolveHome(), { recursive: true });
99
- await fs2.writeFile(registryPath(), `${JSON.stringify(registry, null, 2)}
75
+ await fs.mkdir(resolveHome(), { recursive: true });
76
+ await fs.writeFile(registryPath(), `${JSON.stringify(registry, null, 2)}
100
77
  `, "utf8");
101
78
  }
102
79
  async function registerCollection(entry) {
@@ -115,29 +92,7 @@ async function unregisterCollection(name) {
115
92
  await writeRegistry(registry);
116
93
  }
117
94
 
118
- // src/slugify.ts
119
- function slugify(input) {
120
- const slug = input.toLowerCase().replace(/^#+\s*/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80).replace(/-+$/g, "");
121
- return slug.length > 0 ? slug : "untitled";
122
- }
123
-
124
- // src/frontmatter.ts
125
- import matter from "gray-matter";
126
- function parseArticle(raw) {
127
- const { data, content } = matter(raw);
128
- const title = typeof data.title === "string" ? data.title : "";
129
- const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [];
130
- return { title, tags, body: content.replace(/^\n+/, "") };
131
- }
132
- function serializeArticle(article) {
133
- const front = { title: article.title, tags: article.tags };
134
- if (article.created) front.created = article.created;
135
- if (article.updated) front.updated = article.updated;
136
- return matter.stringify(article.body, front);
137
- }
138
-
139
95
  // src/collections.ts
140
- import fs3 from "fs/promises";
141
96
  async function listCollections() {
142
97
  return (await readRegistry()).collections;
143
98
  }
@@ -147,7 +102,7 @@ async function findCollection(name) {
147
102
  async function createCollection(name) {
148
103
  if (await findCollection(name)) throw new CollectionExistsError(name);
149
104
  const dir = collectionDir(name);
150
- await fs3.mkdir(dir, { recursive: true });
105
+ await fs2.mkdir(dir, { recursive: true });
151
106
  const entry = {
152
107
  name,
153
108
  type: "global",
@@ -162,11 +117,64 @@ async function removeCollection(name) {
162
117
  if (!entry) throw new CollectionNotFoundError(name);
163
118
  await unregisterCollection(name);
164
119
  if (entry.type === "global") {
165
- await fs3.rm(entry.path, { recursive: true, force: true });
120
+ await fs2.rm(entry.path, { recursive: true, force: true });
166
121
  }
167
122
  return entry;
168
123
  }
169
124
 
125
+ // src/config.ts
126
+ function isMissing2(err) {
127
+ return err?.code === "ENOENT";
128
+ }
129
+ async function readConfig() {
130
+ try {
131
+ return JSON.parse(await fs3.readFile(configPath(), "utf8"));
132
+ } catch (err) {
133
+ if (isMissing2(err)) return {};
134
+ throw err;
135
+ }
136
+ }
137
+ async function writeConfig(config) {
138
+ await fs3.mkdir(resolveHome(), { recursive: true });
139
+ await fs3.writeFile(configPath(), `${JSON.stringify(config, null, 2)}
140
+ `, "utf8");
141
+ }
142
+ async function setConfigValue(key, value) {
143
+ if (key === "defaultCollection" && !await findCollection(value)) {
144
+ throw new CollectionNotFoundError(value);
145
+ }
146
+ const config = await readConfig();
147
+ config[key] = value;
148
+ await writeConfig(config);
149
+ }
150
+ async function getConfigValue(key) {
151
+ const config = await readConfig();
152
+ const value = config[key];
153
+ return typeof value === "string" ? value : void 0;
154
+ }
155
+
156
+ // src/slugify.ts
157
+ function slugify(input) {
158
+ const slug = input.toLowerCase().replace(/^#+\s*/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80).replace(/-+$/g, "");
159
+ return slug.length > 0 ? slug : "untitled";
160
+ }
161
+
162
+ // src/frontmatter.ts
163
+ import matter from "gray-matter";
164
+ function parseArticle(raw) {
165
+ const { data, content } = matter(raw);
166
+ const title = typeof data.title === "string" ? data.title : "";
167
+ const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [];
168
+ const created = typeof data.created === "string" ? data.created : void 0;
169
+ return { title, tags, created, body: content.replace(/^\n+/, "") };
170
+ }
171
+ function serializeArticle(article) {
172
+ const front = { title: article.title, tags: article.tags };
173
+ if (article.created) front.created = article.created;
174
+ if (article.updated) front.updated = article.updated;
175
+ return matter.stringify(article.body, front);
176
+ }
177
+
170
178
  // src/articles.ts
171
179
  import fs4 from "fs/promises";
172
180
  import path2 from "path";
@@ -201,7 +209,11 @@ async function resolveCollectionDir(name) {
201
209
  return { name: coll, dir: entry.path };
202
210
  }
203
211
  function ensureMdPath(file) {
204
- return /\.mdx?$/.test(file) ? file : `${file}.md`;
212
+ if (/\.mdx?$/.test(file)) return file;
213
+ const dir = path2.dirname(file);
214
+ const base = slugify(path2.basename(file));
215
+ const rel = dir === "." ? `${base}.md` : path2.join(dir, `${base}.md`);
216
+ return rel;
205
217
  }
206
218
  async function createArticle(opts) {
207
219
  const { collection, title } = parseAddTarget(opts.target);
@@ -233,6 +245,7 @@ async function updateArticleBody(target, body) {
233
245
  serializeArticle({
234
246
  title: parsed.title,
235
247
  tags: parsed.tags,
248
+ created: parsed.created,
236
249
  updated: (/* @__PURE__ */ new Date()).toISOString(),
237
250
  body
238
251
  }),
@@ -249,6 +262,7 @@ async function mutateTags(target, fn) {
249
262
  serializeArticle({
250
263
  title: parsed.title,
251
264
  tags,
265
+ created: parsed.created,
252
266
  updated: (/* @__PURE__ */ new Date()).toISOString(),
253
267
  body: parsed.body
254
268
  }),
@@ -340,7 +354,8 @@ async function query(term, collection) {
340
354
  let targets;
341
355
  if (collection) {
342
356
  const one = await findCollection(collection);
343
- targets = one ? [one] : [];
357
+ if (!one) throw new CollectionNotFoundError(collection);
358
+ targets = [one];
344
359
  } else {
345
360
  targets = await listCollections();
346
361
  }
@@ -405,6 +420,128 @@ async function doctor() {
405
420
  }
406
421
  return out;
407
422
  }
423
+
424
+ // src/browse.ts
425
+ import fs8 from "fs/promises";
426
+ import path5 from "path";
427
+ function buildArticleTree(relPaths) {
428
+ const root = {};
429
+ for (const raw of relPaths) {
430
+ const rel = raw.replace(/\\/g, "/").replace(/^\/+/, "");
431
+ if (!rel) continue;
432
+ const segs = rel.split("/");
433
+ let cur = root;
434
+ segs.forEach((seg, i) => {
435
+ if (i === segs.length - 1) {
436
+ if (!(seg in cur)) cur[seg] = null;
437
+ } else {
438
+ if (cur[seg] === null || !(seg in cur)) cur[seg] = {};
439
+ cur = cur[seg];
440
+ }
441
+ });
442
+ }
443
+ const stripExt = (f) => f.replace(/\.(mdx?)$/, "");
444
+ const walk = (trie, parents) => {
445
+ const entries = Object.entries(trie);
446
+ const branches = entries.filter(([, v]) => v !== null);
447
+ const leaves = entries.filter(([, v]) => v === null).map(([k]) => k);
448
+ branches.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()));
449
+ leaves.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
450
+ const branchNodes = branches.map(([seg, sub]) => ({
451
+ name: seg,
452
+ title: seg,
453
+ children: walk(sub, [...parents, seg])
454
+ }));
455
+ const leafNodes = leaves.map((file) => {
456
+ const nameNoExt = stripExt(file);
457
+ return { name: nameNoExt, title: nameNoExt, slug: [...parents, nameNoExt].join("/") };
458
+ });
459
+ return [...branchNodes, ...leafNodes];
460
+ };
461
+ return walk(root, []);
462
+ }
463
+ async function collectRelPaths(dir, rel = "") {
464
+ let dirents;
465
+ try {
466
+ dirents = await fs8.readdir(dir, { withFileTypes: true });
467
+ } catch {
468
+ return [];
469
+ }
470
+ const out = [];
471
+ for (const d of dirents) {
472
+ if (d.name.startsWith(".")) continue;
473
+ const childRel = rel ? `${rel}/${d.name}` : d.name;
474
+ if (d.isDirectory()) {
475
+ out.push(...await collectRelPaths(path5.join(dir, d.name), childRel));
476
+ } else if (/\.(mdx?)$/.test(d.name)) {
477
+ out.push(childRel);
478
+ }
479
+ }
480
+ return out;
481
+ }
482
+ async function cheapTitle(absPath) {
483
+ let handle;
484
+ try {
485
+ handle = await fs8.open(absPath, "r");
486
+ const buf = Buffer.alloc(512);
487
+ const { bytesRead } = await handle.read(buf, 0, 512, 0);
488
+ const snippet = buf.subarray(0, bytesRead).toString("utf8");
489
+ const m = snippet.match(/^---[\s\S]*?^title:\s*(.+)$/m);
490
+ if (m) return m[1].trim().replace(/^["']|["']$/g, "");
491
+ } catch {
492
+ } finally {
493
+ await handle?.close();
494
+ }
495
+ return void 0;
496
+ }
497
+ async function enrichTitles(nodes, collDir) {
498
+ return Promise.all(
499
+ nodes.map(async (node) => {
500
+ if (node.children) {
501
+ return { ...node, children: await enrichTitles(node.children, collDir) };
502
+ }
503
+ for (const ext of [".mdx", ".md"]) {
504
+ const title = await cheapTitle(path5.join(collDir, `${node.slug}${ext}`));
505
+ if (title) return { ...node, title };
506
+ }
507
+ return node;
508
+ })
509
+ );
510
+ }
511
+ async function listArticleTree(coll) {
512
+ const entry = await findCollection(coll);
513
+ if (!entry) throw new CollectionNotFoundError(coll);
514
+ const rels = await collectRelPaths(entry.path);
515
+ return enrichTitles(buildArticleTree(rels), path5.resolve(entry.path));
516
+ }
517
+ async function readArticle(coll, slug) {
518
+ const entry = await findCollection(coll);
519
+ if (!entry) throw new CollectionNotFoundError(coll);
520
+ const collDir = path5.resolve(entry.path);
521
+ const normalized = slug.replace(/\\/g, "/");
522
+ if (normalized.split("/").some((s) => s === "..")) {
523
+ throw new InvalidTargetError(slug, "path traversal detected");
524
+ }
525
+ const safeSlug = normalized.split("/").filter((s) => s !== "" && s !== ".").join("/");
526
+ if (!safeSlug) throw new InvalidTargetError(slug, "expected a non-empty article slug");
527
+ let abs;
528
+ for (const ext of [".mdx", ".md"]) {
529
+ const candidate = path5.resolve(collDir, `${safeSlug}${ext}`);
530
+ if (candidate !== collDir && !candidate.startsWith(collDir + path5.sep)) {
531
+ throw new InvalidTargetError(slug, "path traversal detected");
532
+ }
533
+ try {
534
+ await fs8.access(candidate);
535
+ abs = candidate;
536
+ break;
537
+ } catch {
538
+ }
539
+ }
540
+ if (!abs) throw new ArticleNotFoundError(`${coll}/${safeSlug}`);
541
+ const parsed = parseArticle(await fs8.readFile(abs, "utf8"));
542
+ const title = parsed.title?.trim() || (safeSlug.split("/").pop() ?? safeSlug);
543
+ return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs };
544
+ }
408
545
  export {
409
546
  ArticleNotFoundError,
410
547
  CollectionExistsError,
@@ -414,6 +551,7 @@ export {
414
551
  NoDefaultCollectionError,
415
552
  REPO_CONFIG_FILE,
416
553
  addTags,
554
+ buildArticleTree,
417
555
  collectionDir,
418
556
  collectionsDir,
419
557
  configPath,
@@ -423,11 +561,13 @@ export {
423
561
  findCollection,
424
562
  getConfigValue,
425
563
  initRepoWiki,
564
+ listArticleTree,
426
565
  listCollections,
427
566
  parseAddTarget,
428
567
  parseArticle,
429
568
  parsePathTarget,
430
569
  query,
570
+ readArticle,
431
571
  readConfig,
432
572
  readRegistry,
433
573
  readRepoConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffwiki-core",
3
- "version": "0.2.0-rc.f44d17c",
3
+ "version": "0.3.0-rc.202607211217.f5290fa",
4
4
  "description": "Core configuration, registry, and article management for diffwiki",
5
5
  "license": "MIT",
6
6
  "type": "module",