diffwiki-core 0.6.0 → 0.6.1

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
@@ -591,6 +591,7 @@ __export(index_exports, {
591
591
  emitPluginEvent: () => emitPluginEvent,
592
592
  ensureGitRepo: () => ensureGitRepo,
593
593
  ensurePluginRoot: () => ensurePluginRoot,
594
+ fileTimestamps: () => fileTimestamps,
594
595
  findCollection: () => findCollection,
595
596
  findCollectionById: () => findCollectionById,
596
597
  findEnabledPlugin: () => findEnabledPlugin,
@@ -755,8 +756,6 @@ function parseArticle(raw) {
755
756
  }
756
757
  function serializeArticle(article) {
757
758
  const front = { title: article.title, tags: article.tags };
758
- if (article.created) front.created = article.created;
759
- if (article.updated) front.updated = article.updated;
760
759
  return import_gray_matter.default.stringify(article.body, front, MATTER_OPTS);
761
760
  }
762
761
 
@@ -816,11 +815,10 @@ async function createArticle(opts) {
816
815
  const { collection, title } = parseAddTarget(opts.target);
817
816
  const { name, dir } = await resolveCollectionDir(collection);
818
817
  const filePath = import_node_path3.default.join(dir, `${slugify(title)}.md`);
819
- const now = (/* @__PURE__ */ new Date()).toISOString();
820
818
  const body = opts.body ?? `# ${title}
821
819
  `;
822
820
  await import_promises5.default.mkdir(dir, { recursive: true });
823
- await import_promises5.default.writeFile(filePath, serializeArticle({ title, tags: opts.tags, created: now, updated: now, body }), "utf8");
821
+ await import_promises5.default.writeFile(filePath, serializeArticle({ title, tags: opts.tags, body }), "utf8");
824
822
  void fireHook2("article-created", name, import_node_path3.default.relative(dir, filePath));
825
823
  return { title, tags: opts.tags, path: filePath, collection: name };
826
824
  }
@@ -838,17 +836,7 @@ async function loadArticle(target) {
838
836
  async function updateArticleBody(target, body) {
839
837
  const { filePath, dir, raw, collection } = await loadArticle(target);
840
838
  const parsed = parseArticle(raw);
841
- await import_promises5.default.writeFile(
842
- filePath,
843
- serializeArticle({
844
- title: parsed.title,
845
- tags: parsed.tags,
846
- created: parsed.created,
847
- updated: (/* @__PURE__ */ new Date()).toISOString(),
848
- body
849
- }),
850
- "utf8"
851
- );
839
+ await import_promises5.default.writeFile(filePath, serializeArticle({ title: parsed.title, tags: parsed.tags, body }), "utf8");
852
840
  void fireHook2("article-updated", collection, import_node_path3.default.relative(dir, filePath));
853
841
  return { title: parsed.title, tags: parsed.tags, path: filePath, collection };
854
842
  }
@@ -856,17 +844,7 @@ async function mutateTags(target, fn) {
856
844
  const { filePath, dir, raw, collection } = await loadArticle(target);
857
845
  const parsed = parseArticle(raw);
858
846
  const tags = fn(parsed.tags);
859
- await import_promises5.default.writeFile(
860
- filePath,
861
- serializeArticle({
862
- title: parsed.title,
863
- tags,
864
- created: parsed.created,
865
- updated: (/* @__PURE__ */ new Date()).toISOString(),
866
- body: parsed.body
867
- }),
868
- "utf8"
869
- );
847
+ await import_promises5.default.writeFile(filePath, serializeArticle({ title: parsed.title, tags, body: parsed.body }), "utf8");
870
848
  void fireHook2("article-updated", collection, import_node_path3.default.relative(dir, filePath));
871
849
  return { title: parsed.title, tags, path: filePath, collection };
872
850
  }
@@ -888,7 +866,7 @@ async function removeArticle(target) {
888
866
  }
889
867
 
890
868
  // src/repo.ts
891
- var import_promises6 = __toESM(require("fs/promises"), 1);
869
+ var import_promises7 = __toESM(require("fs/promises"), 1);
892
870
  var import_node_path5 = __toESM(require("path"), 1);
893
871
  var import_node_child_process3 = require("child_process");
894
872
  var import_node_util2 = require("util");
@@ -897,6 +875,7 @@ init_collections();
897
875
  init_registry();
898
876
 
899
877
  // src/git.ts
878
+ var import_promises6 = __toESM(require("fs/promises"), 1);
900
879
  var import_node_path4 = __toESM(require("path"), 1);
901
880
  var import_node_child_process2 = require("child_process");
902
881
  var import_node_util = require("util");
@@ -959,6 +938,35 @@ async function ensureGitRepo(cwd) {
959
938
  if (!after) throw new Error("git init did not produce a repository");
960
939
  return after;
961
940
  }
941
+ function isEpochish(d) {
942
+ return !Number.isFinite(d.getTime()) || d.getTime() <= 0;
943
+ }
944
+ var timestampCache = /* @__PURE__ */ new Map();
945
+ async function fileTimestamps(absPath) {
946
+ const cached = timestampCache.get(absPath);
947
+ if (cached) return cached;
948
+ const dir = import_node_path4.default.dirname(absPath);
949
+ const base = import_node_path4.default.basename(absPath);
950
+ const [addLog, lastLog] = await Promise.all([
951
+ git(dir, ["log", "--follow", "--diff-filter=A", "--format=%aI", "--", base]),
952
+ git(dir, ["log", "-1", "--format=%aI", "--", base])
953
+ ]);
954
+ const gitCreated = addLog ? addLog.split("\n").filter(Boolean).pop() : void 0;
955
+ const gitUpdated = lastLog || void 0;
956
+ let created = gitCreated;
957
+ let updated = gitUpdated;
958
+ if (!created || !updated) {
959
+ try {
960
+ const st = await import_promises6.default.stat(absPath);
961
+ if (!updated) updated = st.mtime.toISOString();
962
+ if (!created) created = isEpochish(st.birthtime) ? st.mtime.toISOString() : st.birthtime.toISOString();
963
+ } catch {
964
+ }
965
+ }
966
+ const result = { created, updated };
967
+ timestampCache.set(absPath, result);
968
+ return result;
969
+ }
962
970
 
963
971
  // src/repo.ts
964
972
  init_errors();
@@ -974,7 +982,7 @@ async function detectRepoName(cwd) {
974
982
  }
975
983
  async function readRepoConfig(cwd) {
976
984
  try {
977
- const raw = await import_promises6.default.readFile(import_node_path5.default.join(cwd, REPO_CONFIG_FILE), "utf8");
985
+ const raw = await import_promises7.default.readFile(import_node_path5.default.join(cwd, REPO_CONFIG_FILE), "utf8");
978
986
  const parsed = (0, import_yaml2.parse)(raw) ?? {};
979
987
  if (!parsed.path) return void 0;
980
988
  return {
@@ -1004,9 +1012,9 @@ async function initRepoWiki(opts) {
1004
1012
  n += 1;
1005
1013
  }
1006
1014
  }
1007
- await import_promises6.default.mkdir(absWiki, { recursive: true });
1015
+ await import_promises7.default.mkdir(absWiki, { recursive: true });
1008
1016
  const config = { collection: name, path: wikiPath };
1009
- await import_promises6.default.writeFile(import_node_path5.default.join(cwd, REPO_CONFIG_FILE), (0, import_yaml2.stringify)(config), "utf8");
1017
+ await import_promises7.default.writeFile(import_node_path5.default.join(cwd, REPO_CONFIG_FILE), (0, import_yaml2.stringify)(config), "utf8");
1010
1018
  const entry = {
1011
1019
  name,
1012
1020
  type: "repo",
@@ -1078,7 +1086,7 @@ async function collectionGitStatus(entry) {
1078
1086
  init_collections();
1079
1087
 
1080
1088
  // src/browse.ts
1081
- var import_promises7 = __toESM(require("fs/promises"), 1);
1089
+ var import_promises8 = __toESM(require("fs/promises"), 1);
1082
1090
  var import_node_path6 = __toESM(require("path"), 1);
1083
1091
  init_collections();
1084
1092
  init_errors();
@@ -1125,7 +1133,7 @@ function buildArticleTree(relPaths) {
1125
1133
  async function collectRelPaths(dir, rel = "") {
1126
1134
  let dirents;
1127
1135
  try {
1128
- dirents = await import_promises7.default.readdir(dir, { withFileTypes: true });
1136
+ dirents = await import_promises8.default.readdir(dir, { withFileTypes: true });
1129
1137
  } catch {
1130
1138
  return [];
1131
1139
  }
@@ -1143,7 +1151,7 @@ async function collectRelPaths(dir, rel = "") {
1143
1151
  }
1144
1152
  async function readdirSafe(dir) {
1145
1153
  try {
1146
- return await import_promises7.default.readdir(dir);
1154
+ return await import_promises8.default.readdir(dir);
1147
1155
  } catch {
1148
1156
  return [];
1149
1157
  }
@@ -1160,7 +1168,7 @@ function pickIndexFile(names) {
1160
1168
  async function cheapTitle(absPath) {
1161
1169
  let handle;
1162
1170
  try {
1163
- handle = await import_promises7.default.open(absPath, "r");
1171
+ handle = await import_promises8.default.open(absPath, "r");
1164
1172
  const buf = Buffer.alloc(512);
1165
1173
  const { bytesRead } = await handle.read(buf, 0, 512, 0);
1166
1174
  const snippet = buf.subarray(0, bytesRead).toString("utf8");
@@ -1210,11 +1218,12 @@ async function readArticle(coll, slug) {
1210
1218
  const safeSlug = normalized.split("/").filter((s) => s !== "" && s !== ".").join("/");
1211
1219
  const inJail = (p) => p === collDir || p.startsWith(collDir + import_node_path6.default.sep);
1212
1220
  let abs;
1221
+ let isIndex = false;
1213
1222
  for (const rel of safeSlug ? [`${safeSlug}.mdx`, `${safeSlug}.md`] : []) {
1214
1223
  const candidate = import_node_path6.default.resolve(collDir, rel);
1215
1224
  if (!inJail(candidate)) throw new InvalidTargetError(slug, "path traversal detected");
1216
1225
  try {
1217
- await import_promises7.default.access(candidate);
1226
+ await import_promises8.default.access(candidate);
1218
1227
  abs = candidate;
1219
1228
  break;
1220
1229
  } catch {
@@ -1224,16 +1233,20 @@ async function readArticle(coll, slug) {
1224
1233
  const dir = import_node_path6.default.resolve(collDir, safeSlug);
1225
1234
  if (inJail(dir)) {
1226
1235
  const idx = pickIndexFile(await readdirSafe(dir));
1227
- if (idx) abs = import_node_path6.default.join(dir, idx);
1236
+ if (idx) {
1237
+ abs = import_node_path6.default.join(dir, idx);
1238
+ isIndex = true;
1239
+ }
1228
1240
  }
1229
1241
  }
1230
1242
  if (!abs) throw new ArticleNotFoundError(`${coll}/${safeSlug}`);
1231
- const parsed = parseArticle(await import_promises7.default.readFile(abs, "utf8"));
1243
+ const parsed = parseArticle(await import_promises8.default.readFile(abs, "utf8"));
1232
1244
  const title = parsed.title?.trim() || (safeSlug ? safeSlug.split("/").pop() ?? safeSlug : coll);
1233
- return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs };
1245
+ return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs, isIndex };
1234
1246
  }
1235
1247
 
1236
1248
  // src/render.ts
1249
+ var import_node_path7 = __toESM(require("path"), 1);
1237
1250
  var import_unified = require("unified");
1238
1251
  var import_remark_parse = __toESM(require("remark-parse"), 1);
1239
1252
  var import_remark_gfm = __toESM(require("remark-gfm"), 1);
@@ -1243,6 +1256,36 @@ var import_rehype = __toESM(require("@shikijs/rehype"), 1);
1243
1256
  var import_rehype_stringify = __toESM(require("rehype-stringify"), 1);
1244
1257
  var import_unist_util_visit = require("unist-util-visit");
1245
1258
  var import_hast_util_to_string = require("hast-util-to-string");
1259
+ function isNonRelativeHref(href) {
1260
+ if (href === "") return true;
1261
+ if (href.startsWith("/") || href.startsWith("#") || href.startsWith("?")) return true;
1262
+ if (href.startsWith("//")) return true;
1263
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
1264
+ }
1265
+ function rehypeRewriteLinks(ctx) {
1266
+ const slugDir = ctx.isIndex ? ctx.slug || "." : import_node_path7.default.posix.dirname(ctx.slug || ".");
1267
+ return (tree) => {
1268
+ (0, import_unist_util_visit.visit)(tree, "element", (node) => {
1269
+ if (node.tagName !== "a") return;
1270
+ const raw = node.properties?.href;
1271
+ if (typeof raw !== "string" || isNonRelativeHref(raw)) return;
1272
+ const suffixMatch = /[#?].*$/.exec(raw);
1273
+ const suffix = suffixMatch ? suffixMatch[0] : "";
1274
+ const pathPart = suffix ? raw.slice(0, raw.length - suffix.length) : raw;
1275
+ if (pathPart === "") return;
1276
+ const joined = import_node_path7.default.posix.join(slugDir === "." ? "" : slugDir, pathPart);
1277
+ const normalized = import_node_path7.default.posix.normalize(joined);
1278
+ if (normalized.startsWith("..")) return;
1279
+ let resolved = normalized.replace(/\.(mdx?)$/i, "");
1280
+ resolved = resolved.replace(/(^|\/)(index|readme)$/i, "$1");
1281
+ resolved = resolved.replace(/\/+$/, "").replace(/^\/+/, "");
1282
+ node.properties = {
1283
+ ...node.properties,
1284
+ href: `/${ctx.coll}${resolved ? `/${resolved}` : ""}${suffix}`
1285
+ };
1286
+ });
1287
+ };
1288
+ }
1246
1289
  var LANG_ALIASES = {
1247
1290
  "c#": "csharp",
1248
1291
  cs: "csharp",
@@ -1286,9 +1329,9 @@ function rehypeMarkMermaid() {
1286
1329
  });
1287
1330
  };
1288
1331
  }
1289
- async function renderArticle(body) {
1332
+ async function renderArticle(body, ctx) {
1290
1333
  const toc = [];
1291
- const file = await (0, import_unified.unified)().use(import_remark_parse.default).use(import_remark_gfm.default).use(import_remark_rehype.default).use(import_rehype_slug.default).use(rehypeCollectToc, toc).use(rehypeStripLeadingH1).use(rehypeMarkMermaid).use(import_rehype.default, {
1334
+ const file = await (0, import_unified.unified)().use(import_remark_parse.default).use(import_remark_gfm.default).use(import_remark_rehype.default).use(import_rehype_slug.default).use(rehypeCollectToc, toc).use(rehypeStripLeadingH1).use(rehypeMarkMermaid).use(rehypeRewriteLinks, ctx).use(import_rehype.default, {
1292
1335
  themes: { light: "github-light", dark: "github-dark" },
1293
1336
  defaultColor: false,
1294
1337
  langAlias: LANG_ALIASES,
@@ -1351,7 +1394,12 @@ function findNode(nodes, slug) {
1351
1394
  async function resolvePage(coll, slug) {
1352
1395
  try {
1353
1396
  const art = await readArticle(coll, slug);
1354
- const { html, toc } = await renderArticle(art.body);
1397
+ const { html, toc } = await renderArticle(art.body, {
1398
+ coll: art.coll,
1399
+ slug: art.slug,
1400
+ isIndex: art.isIndex
1401
+ });
1402
+ const { created, updated } = await fileTimestamps(art.path);
1355
1403
  return {
1356
1404
  kind: "doc",
1357
1405
  coll: art.coll,
@@ -1360,7 +1408,9 @@ async function resolvePage(coll, slug) {
1360
1408
  tags: art.tags,
1361
1409
  html,
1362
1410
  toc,
1363
- path: `/${art.coll}/${art.slug}`
1411
+ path: `/${art.coll}/${art.slug}`,
1412
+ created,
1413
+ updated
1364
1414
  };
1365
1415
  } catch (err) {
1366
1416
  if (err instanceof CollectionNotFoundError) return null;
@@ -1418,8 +1468,8 @@ function resolveCollectionSelection(all, sel = {}) {
1418
1468
  }
1419
1469
 
1420
1470
  // src/query.ts
1421
- var import_promises8 = __toESM(require("fs/promises"), 1);
1422
- var import_node_path7 = __toESM(require("path"), 1);
1471
+ var import_promises9 = __toESM(require("fs/promises"), 1);
1472
+ var import_node_path8 = __toESM(require("path"), 1);
1423
1473
  init_collections();
1424
1474
  init_errors();
1425
1475
  init_logger();
@@ -1488,13 +1538,13 @@ var log2 = getLogger("query");
1488
1538
  async function collectMarkdownFiles(dir) {
1489
1539
  let entries;
1490
1540
  try {
1491
- entries = await import_promises8.default.readdir(dir, { withFileTypes: true });
1541
+ entries = await import_promises9.default.readdir(dir, { withFileTypes: true });
1492
1542
  } catch {
1493
1543
  return [];
1494
1544
  }
1495
1545
  const files = [];
1496
1546
  for (const entry of entries) {
1497
- const full = import_node_path7.default.join(dir, entry.name);
1547
+ const full = import_node_path8.default.join(dir, entry.name);
1498
1548
  if (entry.isDirectory()) {
1499
1549
  files.push(...await collectMarkdownFiles(full));
1500
1550
  } else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
@@ -1513,12 +1563,12 @@ async function nativeSearch(term, collections, collection) {
1513
1563
  const docs = [];
1514
1564
  for (const entry of targets) {
1515
1565
  for (const full of await collectMarkdownFiles(entry.path)) {
1516
- const fallbackTitle = import_node_path7.default.basename(full).replace(/\.mdx?$/, "");
1566
+ const fallbackTitle = import_node_path8.default.basename(full).replace(/\.mdx?$/, "");
1517
1567
  let title = fallbackTitle;
1518
1568
  let tags = [];
1519
1569
  let body = "";
1520
1570
  try {
1521
- const parsed = parseArticle(await import_promises8.default.readFile(full, "utf8"));
1571
+ const parsed = parseArticle(await import_promises9.default.readFile(full, "utf8"));
1522
1572
  if (parsed.title) title = parsed.title;
1523
1573
  tags = parsed.tags;
1524
1574
  body = parsed.body;
@@ -1601,13 +1651,13 @@ async function query(term, opts) {
1601
1651
  }
1602
1652
 
1603
1653
  // src/search.ts
1604
- var import_promises9 = __toESM(require("fs/promises"), 1);
1605
- var import_node_path8 = __toESM(require("path"), 1);
1654
+ var import_promises10 = __toESM(require("fs/promises"), 1);
1655
+ var import_node_path9 = __toESM(require("path"), 1);
1606
1656
  init_collections();
1607
1657
  async function readArticleParts(collDir, slug) {
1608
1658
  for (const ext of [".mdx", ".md"]) {
1609
1659
  try {
1610
- const parsed = parseArticle(await import_promises9.default.readFile(import_node_path8.default.join(collDir, `${slug}${ext}`), "utf8"));
1660
+ const parsed = parseArticle(await import_promises10.default.readFile(import_node_path9.default.join(collDir, `${slug}${ext}`), "utf8"));
1611
1661
  return { tags: parsed.tags, body: parsed.body };
1612
1662
  } catch {
1613
1663
  }
@@ -1641,7 +1691,7 @@ async function buildSearchIndex(collection) {
1641
1691
  for (const e of entries) {
1642
1692
  try {
1643
1693
  const collEntry = await findCollection(e.name);
1644
- const collDir = import_node_path8.default.resolve(collEntry?.path ?? e.path);
1694
+ const collDir = import_node_path9.default.resolve(collEntry?.path ?? e.path);
1645
1695
  await flatten(e.name, collDir, await listArticleTree(e.name), out);
1646
1696
  } catch {
1647
1697
  }
@@ -1650,15 +1700,15 @@ async function buildSearchIndex(collection) {
1650
1700
  }
1651
1701
 
1652
1702
  // src/doctor.ts
1653
- var import_promises11 = __toESM(require("fs/promises"), 1);
1703
+ var import_promises12 = __toESM(require("fs/promises"), 1);
1654
1704
  init_paths();
1655
1705
  init_registry();
1656
1706
  init_collections();
1657
1707
 
1658
1708
  // src/plugins/manager.ts
1659
- var import_promises10 = __toESM(require("fs/promises"), 1);
1709
+ var import_promises11 = __toESM(require("fs/promises"), 1);
1660
1710
  var import_node_os2 = __toESM(require("os"), 1);
1661
- var import_node_path10 = __toESM(require("path"), 1);
1711
+ var import_node_path11 = __toESM(require("path"), 1);
1662
1712
  init_paths();
1663
1713
  init_errors();
1664
1714
  init_collections();
@@ -1669,7 +1719,7 @@ init_host();
1669
1719
  var import_node_child_process4 = require("child_process");
1670
1720
  var import_node_util3 = require("util");
1671
1721
  var import_node_fs = require("fs");
1672
- var import_node_path9 = require("path");
1722
+ var import_node_path10 = require("path");
1673
1723
  var import_node_url = require("url");
1674
1724
  var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
1675
1725
  async function runProcess(file, args, opts) {
@@ -1685,7 +1735,7 @@ async function runProcess(file, args, opts) {
1685
1735
  // src/plugins/manager.ts
1686
1736
  async function readPackageJson(file) {
1687
1737
  try {
1688
- return JSON.parse(await import_promises10.default.readFile(file, "utf8"));
1738
+ return JSON.parse(await import_promises11.default.readFile(file, "utf8"));
1689
1739
  } catch {
1690
1740
  return null;
1691
1741
  }
@@ -1701,7 +1751,7 @@ function binOf(manifest) {
1701
1751
  async function pluginManifestDirs(nodeModules) {
1702
1752
  let entries;
1703
1753
  try {
1704
- entries = await import_promises10.default.readdir(nodeModules);
1754
+ entries = await import_promises11.default.readdir(nodeModules);
1705
1755
  } catch {
1706
1756
  return [];
1707
1757
  }
@@ -1711,44 +1761,44 @@ async function pluginManifestDirs(nodeModules) {
1711
1761
  if (entry.startsWith("@")) {
1712
1762
  let scoped;
1713
1763
  try {
1714
- scoped = await import_promises10.default.readdir(import_node_path10.default.join(nodeModules, entry));
1764
+ scoped = await import_promises11.default.readdir(import_node_path11.default.join(nodeModules, entry));
1715
1765
  } catch {
1716
1766
  continue;
1717
1767
  }
1718
1768
  for (const pkg of scoped) {
1719
1769
  if (pkg.startsWith(".")) continue;
1720
- dirs.push(import_node_path10.default.join(nodeModules, entry, pkg));
1770
+ dirs.push(import_node_path11.default.join(nodeModules, entry, pkg));
1721
1771
  }
1722
1772
  } else {
1723
- dirs.push(import_node_path10.default.join(nodeModules, entry));
1773
+ dirs.push(import_node_path11.default.join(nodeModules, entry));
1724
1774
  }
1725
1775
  }
1726
1776
  return dirs;
1727
1777
  }
1728
1778
  async function ensurePluginRoot() {
1729
1779
  const dir = pluginsDir();
1730
- await import_promises10.default.mkdir(dir, { recursive: true });
1731
- const pkgPath = import_node_path10.default.join(dir, "package.json");
1780
+ await import_promises11.default.mkdir(dir, { recursive: true });
1781
+ const pkgPath = import_node_path11.default.join(dir, "package.json");
1732
1782
  if (await readPackageJson(pkgPath) === null) {
1733
1783
  const pkg = { name: "diffwiki-plugins", private: true, version: "0.0.0" };
1734
- await import_promises10.default.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
1784
+ await import_promises11.default.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
1735
1785
  `, "utf8");
1736
1786
  }
1737
1787
  return dir;
1738
1788
  }
1739
1789
  async function asSearchPackage(pkgDir) {
1740
- const manifest = await readPackageJson(import_node_path10.default.join(pkgDir, "package.json"));
1790
+ const manifest = await readPackageJson(import_node_path11.default.join(pkgDir, "package.json"));
1741
1791
  if (!manifest?.name || manifest.diffwiki?.kind !== "search") return null;
1742
1792
  const bin = binOf(manifest);
1743
1793
  if (!bin) return null;
1744
- return { name: manifest.name, version: manifest.version, binAbs: import_node_path10.default.resolve(pkgDir, bin) };
1794
+ return { name: manifest.name, version: manifest.version, binAbs: import_node_path11.default.resolve(pkgDir, bin) };
1745
1795
  }
1746
1796
  async function packageNameFromSpec(spec) {
1747
1797
  const looksLikePath = spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~") || spec.startsWith("file:");
1748
1798
  if (looksLikePath) {
1749
1799
  const raw = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
1750
- const abs = import_node_path10.default.resolve(raw.replace(/^~(?=$|\/)/, import_node_os2.default.homedir()));
1751
- return (await readPackageJson(import_node_path10.default.join(abs, "package.json")))?.name ?? null;
1800
+ const abs = import_node_path11.default.resolve(raw.replace(/^~(?=$|\/)/, import_node_os2.default.homedir()));
1801
+ return (await readPackageJson(import_node_path11.default.join(abs, "package.json")))?.name ?? null;
1752
1802
  }
1753
1803
  if (/^(git\+|git:|https?:|ssh:)/.test(spec)) return null;
1754
1804
  if (spec.startsWith("@")) {
@@ -1759,10 +1809,10 @@ async function packageNameFromSpec(spec) {
1759
1809
  return at <= 0 ? spec : spec.slice(0, at);
1760
1810
  }
1761
1811
  async function resolveSearchPackage(dir, name) {
1762
- return asSearchPackage(import_node_path10.default.join(dir, "node_modules", ...name.split("/")));
1812
+ return asSearchPackage(import_node_path11.default.join(dir, "node_modules", ...name.split("/")));
1763
1813
  }
1764
1814
  async function discoverSearchPackage(dir) {
1765
- const nodeModules = import_node_path10.default.join(dir, "node_modules");
1815
+ const nodeModules = import_node_path11.default.join(dir, "node_modules");
1766
1816
  for (const pkgDir of await pluginManifestDirs(nodeModules)) {
1767
1817
  const found = await asSearchPackage(pkgDir);
1768
1818
  if (found) return found;
@@ -1894,7 +1944,7 @@ init_host();
1894
1944
  init_types();
1895
1945
  async function exists(target) {
1896
1946
  try {
1897
- await import_promises11.default.access(target);
1947
+ await import_promises12.default.access(target);
1898
1948
  return true;
1899
1949
  } catch {
1900
1950
  return false;
@@ -2061,6 +2111,7 @@ init_registry2();
2061
2111
  emitPluginEvent,
2062
2112
  ensureGitRepo,
2063
2113
  ensurePluginRoot,
2114
+ fileTimestamps,
2064
2115
  findCollection,
2065
2116
  findCollectionById,
2066
2117
  findEnabledPlugin,
package/dist/index.d.cts CHANGED
@@ -60,6 +60,13 @@ interface ArticleContent {
60
60
  body: string;
61
61
  /** Absolute path to the file on disk. */
62
62
  path: string;
63
+ /**
64
+ * True when the resolved file is a folder-index page (`<slug>/index.md` or
65
+ * `<slug>/README.md`) rather than a leaf `<slug>.md`. The route slug is the
66
+ * same for both, so this flag is needed to resolve folder-local relative
67
+ * links against the article's own folder.
68
+ */
69
+ isIndex: boolean;
63
70
  }
64
71
  type PluginKind = 'search';
65
72
  declare const NATIVE_ENGINE = "native";
@@ -181,6 +188,10 @@ interface DocData {
181
188
  html: string;
182
189
  toc: TocItem[];
183
190
  path: string;
191
+ /** ISO-8601 creation date, sourced from git history / filesystem (not frontmatter). */
192
+ created?: string;
193
+ /** ISO-8601 last-update date, sourced from git history / filesystem (not frontmatter). */
194
+ updated?: string;
184
195
  }
185
196
  interface ListingEntry {
186
197
  name: string;
@@ -261,8 +272,6 @@ interface ParsedArticle {
261
272
  interface SerializableArticle {
262
273
  title: string;
263
274
  tags: string[];
264
- created?: string;
265
- updated?: string;
266
275
  body: string;
267
276
  }
268
277
  declare function parseArticle(raw: string): ParsedArticle;
@@ -369,6 +378,23 @@ interface UpstreamStatus {
369
378
  declare function upstreamStatus(cwd: string): Promise<UpstreamStatus>;
370
379
  /** Ensure `cwd` is inside a git repo, running `git init` if not. Returns the toplevel. */
371
380
  declare function ensureGitRepo(cwd: string): Promise<string>;
381
+ interface FileTimestamps {
382
+ /** ISO-8601 creation date — git first-add commit, else filesystem birthtime. */
383
+ created?: string;
384
+ /** ISO-8601 update date — git last commit, else filesystem mtime. */
385
+ updated?: string;
386
+ }
387
+ /**
388
+ * Creation/update timestamps for a file, preferring git history (stable across
389
+ * clones and worktrees) and falling back to filesystem stat. Git created date is
390
+ * the earliest add-commit (`--follow` tracks renames); updated is the latest
391
+ * commit touching the file. When git yields nothing (untracked / no repo), uses
392
+ * birthtime/mtime. Never throws — returns `{}` if even stat fails.
393
+ *
394
+ * Results are memoised per absolute path (process-scoped) and the two git
395
+ * `log` execs run concurrently, since this runs in the render request path.
396
+ */
397
+ declare function fileTimestamps(absPath: string): Promise<FileTimestamps>;
372
398
 
373
399
  /** Map core article nodes to UI nav nodes, assigning each its route path. */
374
400
  declare function toTreeNodes(coll: string, nodes: ArticleNode[]): TreeNode[];
@@ -577,6 +603,19 @@ declare function listArticleTree(coll: string): Promise<ArticleNode[]>;
577
603
  */
578
604
  declare function readArticle(coll: string, slug: string): Promise<ArticleContent>;
579
605
 
606
+ /** Route context for an article being rendered — used to resolve relative links. */
607
+ interface RenderContext {
608
+ /** Collection name (first route segment). */
609
+ coll: string;
610
+ /** Article route slug (path within the collection, no extension), e.g. "a/b/deep". */
611
+ slug: string;
612
+ /**
613
+ * True when the source file is a folder-index page (`<slug>/index.md`). Such a
614
+ * page shares its route slug with the folder, so relative links must resolve
615
+ * against the folder itself, not its parent. Defaults to false (leaf page).
616
+ */
617
+ isIndex?: boolean;
618
+ }
580
619
  /** The result of rendering an article body to HTML. */
581
620
  interface RenderedArticle {
582
621
  /** Fully-rendered, Shiki-highlighted HTML — mounted directly on the client (no eval). */
@@ -586,12 +625,13 @@ interface RenderedArticle {
586
625
  }
587
626
  /**
588
627
  * Render an article body (markdown) to Shiki-highlighted HTML plus its table of
589
- * contents. Runs entirely server-side; the client mounts the HTML directly with
628
+ * contents, rewriting relative links to in-app routes via `ctx`.
629
+ * Runs entirely server-side; the client mounts the HTML directly with
590
630
  * no `eval`/`new Function`, so it works under a strict CSP. Code is highlighted
591
631
  * here (dual github-light/github-dark via CSS variables); mermaid fences are
592
632
  * marked for client rendering.
593
633
  */
594
- declare function renderArticle(body: string): Promise<RenderedArticle>;
634
+ declare function renderArticle(body: string, ctx: RenderContext): Promise<RenderedArticle>;
595
635
 
596
636
  /**
597
637
  * Host-side plugin invocation and search-provider loading.
@@ -762,4 +802,4 @@ declare function findEnabledPlugin(kind: PluginKind, name?: string): Promise<Plu
762
802
  /** Find a plugin record by name (regardless of enabled state). */
763
803
  declare function findPluginRecord(name: string): Promise<PluginRecord | undefined>;
764
804
 
765
- export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, CollectionAlreadyAddedError, type CollectionEntry, CollectionExistsError, type CollectionGitStatus, type CollectionInfo, CollectionNotFoundError, type CollectionSelection, type CollectionSelectionResult, type CollectionTree, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type DiffwikiLogLevel, type DocData, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type ListingEntry, NATIVE_ENGINE, NoDefaultCollectionError, type NodeMeta, type PageData, type ParsedArticle, type PluginCapabilities, PluginError, type PluginEvent, type PluginHealth, type PluginKind, type PluginReadiness, type PluginRecord, type PluginRegistryFile, type QueryHit, type QueryOptions, REPO_CONFIG_FILE, type Registry, type RenderedArticle, type RepoConfig, SearchDoc, type SearchMode, type SearchOutcome, type SearchProvider, type SerializableArticle, type SetupOutcome, type SetupStep, type TocItem, type TreeNode, type UpstreamStatus, WorktreeNotAllowedError, addExternalCollection, addTags, buildArticleTree, buildCollections, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, nativeSearch, parseAddTarget, parseArticle, parsePathTarget, pluginsDir, pluginsRegistryPath, query, readArticle, readConfig, readPluginRegistry, readRegistry, readRepoConfig, registerCollection, registerPlugin, registryPath, removeArticle, removeCollection, removePlugin, removeTags, renderArticle, repoId, repoRemoteName, resolveCollectionSelection, resolveCwdCollection, resolveDefaultSearchMode, resolveHome, resolveLogLevel, resolvePage, search, serializeArticle, setConfigValue, setTags, slugify, toTreeNodes, unregisterCollection, updateArticleBody, upsertPlugin, upstreamStatus, writeConfig, writePluginRegistry, writeRegistry };
805
+ export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, CollectionAlreadyAddedError, type CollectionEntry, CollectionExistsError, type CollectionGitStatus, type CollectionInfo, CollectionNotFoundError, type CollectionSelection, type CollectionSelectionResult, type CollectionTree, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type DiffwikiLogLevel, type DocData, type FileTimestamps, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type ListingEntry, NATIVE_ENGINE, NoDefaultCollectionError, type NodeMeta, type PageData, type ParsedArticle, type PluginCapabilities, PluginError, type PluginEvent, type PluginHealth, type PluginKind, type PluginReadiness, type PluginRecord, type PluginRegistryFile, type QueryHit, type QueryOptions, REPO_CONFIG_FILE, type Registry, type RenderContext, type RenderedArticle, type RepoConfig, SearchDoc, type SearchMode, type SearchOutcome, type SearchProvider, type SerializableArticle, type SetupOutcome, type SetupStep, type TocItem, type TreeNode, type UpstreamStatus, WorktreeNotAllowedError, addExternalCollection, addTags, buildArticleTree, buildCollections, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, nativeSearch, parseAddTarget, parseArticle, parsePathTarget, pluginsDir, pluginsRegistryPath, query, readArticle, readConfig, readPluginRegistry, readRegistry, readRepoConfig, registerCollection, registerPlugin, registryPath, removeArticle, removeCollection, removePlugin, removeTags, renderArticle, repoId, repoRemoteName, resolveCollectionSelection, resolveCwdCollection, resolveDefaultSearchMode, resolveHome, resolveLogLevel, resolvePage, search, serializeArticle, setConfigValue, setTags, slugify, toTreeNodes, unregisterCollection, updateArticleBody, upsertPlugin, upstreamStatus, writeConfig, writePluginRegistry, writeRegistry };
package/dist/index.d.ts CHANGED
@@ -60,6 +60,13 @@ interface ArticleContent {
60
60
  body: string;
61
61
  /** Absolute path to the file on disk. */
62
62
  path: string;
63
+ /**
64
+ * True when the resolved file is a folder-index page (`<slug>/index.md` or
65
+ * `<slug>/README.md`) rather than a leaf `<slug>.md`. The route slug is the
66
+ * same for both, so this flag is needed to resolve folder-local relative
67
+ * links against the article's own folder.
68
+ */
69
+ isIndex: boolean;
63
70
  }
64
71
  type PluginKind = 'search';
65
72
  declare const NATIVE_ENGINE = "native";
@@ -181,6 +188,10 @@ interface DocData {
181
188
  html: string;
182
189
  toc: TocItem[];
183
190
  path: string;
191
+ /** ISO-8601 creation date, sourced from git history / filesystem (not frontmatter). */
192
+ created?: string;
193
+ /** ISO-8601 last-update date, sourced from git history / filesystem (not frontmatter). */
194
+ updated?: string;
184
195
  }
185
196
  interface ListingEntry {
186
197
  name: string;
@@ -261,8 +272,6 @@ interface ParsedArticle {
261
272
  interface SerializableArticle {
262
273
  title: string;
263
274
  tags: string[];
264
- created?: string;
265
- updated?: string;
266
275
  body: string;
267
276
  }
268
277
  declare function parseArticle(raw: string): ParsedArticle;
@@ -369,6 +378,23 @@ interface UpstreamStatus {
369
378
  declare function upstreamStatus(cwd: string): Promise<UpstreamStatus>;
370
379
  /** Ensure `cwd` is inside a git repo, running `git init` if not. Returns the toplevel. */
371
380
  declare function ensureGitRepo(cwd: string): Promise<string>;
381
+ interface FileTimestamps {
382
+ /** ISO-8601 creation date — git first-add commit, else filesystem birthtime. */
383
+ created?: string;
384
+ /** ISO-8601 update date — git last commit, else filesystem mtime. */
385
+ updated?: string;
386
+ }
387
+ /**
388
+ * Creation/update timestamps for a file, preferring git history (stable across
389
+ * clones and worktrees) and falling back to filesystem stat. Git created date is
390
+ * the earliest add-commit (`--follow` tracks renames); updated is the latest
391
+ * commit touching the file. When git yields nothing (untracked / no repo), uses
392
+ * birthtime/mtime. Never throws — returns `{}` if even stat fails.
393
+ *
394
+ * Results are memoised per absolute path (process-scoped) and the two git
395
+ * `log` execs run concurrently, since this runs in the render request path.
396
+ */
397
+ declare function fileTimestamps(absPath: string): Promise<FileTimestamps>;
372
398
 
373
399
  /** Map core article nodes to UI nav nodes, assigning each its route path. */
374
400
  declare function toTreeNodes(coll: string, nodes: ArticleNode[]): TreeNode[];
@@ -577,6 +603,19 @@ declare function listArticleTree(coll: string): Promise<ArticleNode[]>;
577
603
  */
578
604
  declare function readArticle(coll: string, slug: string): Promise<ArticleContent>;
579
605
 
606
+ /** Route context for an article being rendered — used to resolve relative links. */
607
+ interface RenderContext {
608
+ /** Collection name (first route segment). */
609
+ coll: string;
610
+ /** Article route slug (path within the collection, no extension), e.g. "a/b/deep". */
611
+ slug: string;
612
+ /**
613
+ * True when the source file is a folder-index page (`<slug>/index.md`). Such a
614
+ * page shares its route slug with the folder, so relative links must resolve
615
+ * against the folder itself, not its parent. Defaults to false (leaf page).
616
+ */
617
+ isIndex?: boolean;
618
+ }
580
619
  /** The result of rendering an article body to HTML. */
581
620
  interface RenderedArticle {
582
621
  /** Fully-rendered, Shiki-highlighted HTML — mounted directly on the client (no eval). */
@@ -586,12 +625,13 @@ interface RenderedArticle {
586
625
  }
587
626
  /**
588
627
  * Render an article body (markdown) to Shiki-highlighted HTML plus its table of
589
- * contents. Runs entirely server-side; the client mounts the HTML directly with
628
+ * contents, rewriting relative links to in-app routes via `ctx`.
629
+ * Runs entirely server-side; the client mounts the HTML directly with
590
630
  * no `eval`/`new Function`, so it works under a strict CSP. Code is highlighted
591
631
  * here (dual github-light/github-dark via CSS variables); mermaid fences are
592
632
  * marked for client rendering.
593
633
  */
594
- declare function renderArticle(body: string): Promise<RenderedArticle>;
634
+ declare function renderArticle(body: string, ctx: RenderContext): Promise<RenderedArticle>;
595
635
 
596
636
  /**
597
637
  * Host-side plugin invocation and search-provider loading.
@@ -762,4 +802,4 @@ declare function findEnabledPlugin(kind: PluginKind, name?: string): Promise<Plu
762
802
  /** Find a plugin record by name (regardless of enabled state). */
763
803
  declare function findPluginRecord(name: string): Promise<PluginRecord | undefined>;
764
804
 
765
- export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, CollectionAlreadyAddedError, type CollectionEntry, CollectionExistsError, type CollectionGitStatus, type CollectionInfo, CollectionNotFoundError, type CollectionSelection, type CollectionSelectionResult, type CollectionTree, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type DiffwikiLogLevel, type DocData, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type ListingEntry, NATIVE_ENGINE, NoDefaultCollectionError, type NodeMeta, type PageData, type ParsedArticle, type PluginCapabilities, PluginError, type PluginEvent, type PluginHealth, type PluginKind, type PluginReadiness, type PluginRecord, type PluginRegistryFile, type QueryHit, type QueryOptions, REPO_CONFIG_FILE, type Registry, type RenderedArticle, type RepoConfig, SearchDoc, type SearchMode, type SearchOutcome, type SearchProvider, type SerializableArticle, type SetupOutcome, type SetupStep, type TocItem, type TreeNode, type UpstreamStatus, WorktreeNotAllowedError, addExternalCollection, addTags, buildArticleTree, buildCollections, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, nativeSearch, parseAddTarget, parseArticle, parsePathTarget, pluginsDir, pluginsRegistryPath, query, readArticle, readConfig, readPluginRegistry, readRegistry, readRepoConfig, registerCollection, registerPlugin, registryPath, removeArticle, removeCollection, removePlugin, removeTags, renderArticle, repoId, repoRemoteName, resolveCollectionSelection, resolveCwdCollection, resolveDefaultSearchMode, resolveHome, resolveLogLevel, resolvePage, search, serializeArticle, setConfigValue, setTags, slugify, toTreeNodes, unregisterCollection, updateArticleBody, upsertPlugin, upstreamStatus, writeConfig, writePluginRegistry, writeRegistry };
805
+ export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, CollectionAlreadyAddedError, type CollectionEntry, CollectionExistsError, type CollectionGitStatus, type CollectionInfo, CollectionNotFoundError, type CollectionSelection, type CollectionSelectionResult, type CollectionTree, type CollectionType, type Config, type Diagnostic, type DiagnosticLevel, DiffwikiError, type DiffwikiLogLevel, type DocData, type FileTimestamps, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type ListingEntry, NATIVE_ENGINE, NoDefaultCollectionError, type NodeMeta, type PageData, type ParsedArticle, type PluginCapabilities, PluginError, type PluginEvent, type PluginHealth, type PluginKind, type PluginReadiness, type PluginRecord, type PluginRegistryFile, type QueryHit, type QueryOptions, REPO_CONFIG_FILE, type Registry, type RenderContext, type RenderedArticle, type RepoConfig, SearchDoc, type SearchMode, type SearchOutcome, type SearchProvider, type SerializableArticle, type SetupOutcome, type SetupStep, type TocItem, type TreeNode, type UpstreamStatus, WorktreeNotAllowedError, addExternalCollection, addTags, buildArticleTree, buildCollections, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, nativeSearch, parseAddTarget, parseArticle, parsePathTarget, pluginsDir, pluginsRegistryPath, query, readArticle, readConfig, readPluginRegistry, readRegistry, readRepoConfig, registerCollection, registerPlugin, registryPath, removeArticle, removeCollection, removePlugin, removeTags, renderArticle, repoId, repoRemoteName, resolveCollectionSelection, resolveCwdCollection, resolveDefaultSearchMode, resolveHome, resolveLogLevel, resolvePage, search, serializeArticle, setConfigValue, setTags, slugify, toTreeNodes, unregisterCollection, updateArticleBody, upsertPlugin, upstreamStatus, writeConfig, writePluginRegistry, writeRegistry };
package/dist/index.js CHANGED
@@ -139,8 +139,6 @@ function parseArticle(raw) {
139
139
  }
140
140
  function serializeArticle(article) {
141
141
  const front = { title: article.title, tags: article.tags };
142
- if (article.created) front.created = article.created;
143
- if (article.updated) front.updated = article.updated;
144
142
  return matter.stringify(article.body, front, MATTER_OPTS);
145
143
  }
146
144
 
@@ -195,11 +193,10 @@ async function createArticle(opts) {
195
193
  const { collection, title } = parseAddTarget(opts.target);
196
194
  const { name, dir } = await resolveCollectionDir(collection);
197
195
  const filePath = path.join(dir, `${slugify(title)}.md`);
198
- const now = (/* @__PURE__ */ new Date()).toISOString();
199
196
  const body = opts.body ?? `# ${title}
200
197
  `;
201
198
  await fs2.mkdir(dir, { recursive: true });
202
- await fs2.writeFile(filePath, serializeArticle({ title, tags: opts.tags, created: now, updated: now, body }), "utf8");
199
+ await fs2.writeFile(filePath, serializeArticle({ title, tags: opts.tags, body }), "utf8");
203
200
  void fireHook("article-created", name, path.relative(dir, filePath));
204
201
  return { title, tags: opts.tags, path: filePath, collection: name };
205
202
  }
@@ -217,17 +214,7 @@ async function loadArticle(target) {
217
214
  async function updateArticleBody(target, body) {
218
215
  const { filePath, dir, raw, collection } = await loadArticle(target);
219
216
  const parsed = parseArticle(raw);
220
- await fs2.writeFile(
221
- filePath,
222
- serializeArticle({
223
- title: parsed.title,
224
- tags: parsed.tags,
225
- created: parsed.created,
226
- updated: (/* @__PURE__ */ new Date()).toISOString(),
227
- body
228
- }),
229
- "utf8"
230
- );
217
+ await fs2.writeFile(filePath, serializeArticle({ title: parsed.title, tags: parsed.tags, body }), "utf8");
231
218
  void fireHook("article-updated", collection, path.relative(dir, filePath));
232
219
  return { title: parsed.title, tags: parsed.tags, path: filePath, collection };
233
220
  }
@@ -235,17 +222,7 @@ async function mutateTags(target, fn) {
235
222
  const { filePath, dir, raw, collection } = await loadArticle(target);
236
223
  const parsed = parseArticle(raw);
237
224
  const tags = fn(parsed.tags);
238
- await fs2.writeFile(
239
- filePath,
240
- serializeArticle({
241
- title: parsed.title,
242
- tags,
243
- created: parsed.created,
244
- updated: (/* @__PURE__ */ new Date()).toISOString(),
245
- body: parsed.body
246
- }),
247
- "utf8"
248
- );
225
+ await fs2.writeFile(filePath, serializeArticle({ title: parsed.title, tags, body: parsed.body }), "utf8");
249
226
  void fireHook("article-updated", collection, path.relative(dir, filePath));
250
227
  return { title: parsed.title, tags, path: filePath, collection };
251
228
  }
@@ -267,13 +244,14 @@ async function removeArticle(target) {
267
244
  }
268
245
 
269
246
  // src/repo.ts
270
- import fs3 from "fs/promises";
247
+ import fs4 from "fs/promises";
271
248
  import path3 from "path";
272
249
  import { execFile as execFile2 } from "child_process";
273
250
  import { promisify as promisify2 } from "util";
274
251
  import { parse as parseYaml2, stringify as stringifyYaml2 } from "yaml";
275
252
 
276
253
  // src/git.ts
254
+ import fs3 from "fs/promises";
277
255
  import path2 from "path";
278
256
  import { execFile } from "child_process";
279
257
  import { promisify } from "util";
@@ -335,6 +313,35 @@ async function ensureGitRepo(cwd) {
335
313
  if (!after) throw new Error("git init did not produce a repository");
336
314
  return after;
337
315
  }
316
+ function isEpochish(d) {
317
+ return !Number.isFinite(d.getTime()) || d.getTime() <= 0;
318
+ }
319
+ var timestampCache = /* @__PURE__ */ new Map();
320
+ async function fileTimestamps(absPath) {
321
+ const cached = timestampCache.get(absPath);
322
+ if (cached) return cached;
323
+ const dir = path2.dirname(absPath);
324
+ const base = path2.basename(absPath);
325
+ const [addLog, lastLog] = await Promise.all([
326
+ git(dir, ["log", "--follow", "--diff-filter=A", "--format=%aI", "--", base]),
327
+ git(dir, ["log", "-1", "--format=%aI", "--", base])
328
+ ]);
329
+ const gitCreated = addLog ? addLog.split("\n").filter(Boolean).pop() : void 0;
330
+ const gitUpdated = lastLog || void 0;
331
+ let created = gitCreated;
332
+ let updated = gitUpdated;
333
+ if (!created || !updated) {
334
+ try {
335
+ const st = await fs3.stat(absPath);
336
+ if (!updated) updated = st.mtime.toISOString();
337
+ if (!created) created = isEpochish(st.birthtime) ? st.mtime.toISOString() : st.birthtime.toISOString();
338
+ } catch {
339
+ }
340
+ }
341
+ const result = { created, updated };
342
+ timestampCache.set(absPath, result);
343
+ return result;
344
+ }
338
345
 
339
346
  // src/repo.ts
340
347
  var execFileAsync2 = promisify2(execFile2);
@@ -349,7 +356,7 @@ async function detectRepoName(cwd) {
349
356
  }
350
357
  async function readRepoConfig(cwd) {
351
358
  try {
352
- const raw = await fs3.readFile(path3.join(cwd, REPO_CONFIG_FILE), "utf8");
359
+ const raw = await fs4.readFile(path3.join(cwd, REPO_CONFIG_FILE), "utf8");
353
360
  const parsed = parseYaml2(raw) ?? {};
354
361
  if (!parsed.path) return void 0;
355
362
  return {
@@ -379,9 +386,9 @@ async function initRepoWiki(opts) {
379
386
  n += 1;
380
387
  }
381
388
  }
382
- await fs3.mkdir(absWiki, { recursive: true });
389
+ await fs4.mkdir(absWiki, { recursive: true });
383
390
  const config = { collection: name, path: wikiPath };
384
- await fs3.writeFile(path3.join(cwd, REPO_CONFIG_FILE), stringifyYaml2(config), "utf8");
391
+ await fs4.writeFile(path3.join(cwd, REPO_CONFIG_FILE), stringifyYaml2(config), "utf8");
385
392
  const entry = {
386
393
  name,
387
394
  type: "repo",
@@ -450,7 +457,7 @@ async function collectionGitStatus(entry) {
450
457
  }
451
458
 
452
459
  // src/browse.ts
453
- import fs4 from "fs/promises";
460
+ import fs5 from "fs/promises";
454
461
  import path4 from "path";
455
462
  var INDEX_RE = /^(index|readme)\.mdx?$/i;
456
463
  function buildArticleTree(relPaths) {
@@ -495,7 +502,7 @@ function buildArticleTree(relPaths) {
495
502
  async function collectRelPaths(dir, rel = "") {
496
503
  let dirents;
497
504
  try {
498
- dirents = await fs4.readdir(dir, { withFileTypes: true });
505
+ dirents = await fs5.readdir(dir, { withFileTypes: true });
499
506
  } catch {
500
507
  return [];
501
508
  }
@@ -513,7 +520,7 @@ async function collectRelPaths(dir, rel = "") {
513
520
  }
514
521
  async function readdirSafe(dir) {
515
522
  try {
516
- return await fs4.readdir(dir);
523
+ return await fs5.readdir(dir);
517
524
  } catch {
518
525
  return [];
519
526
  }
@@ -530,7 +537,7 @@ function pickIndexFile(names) {
530
537
  async function cheapTitle(absPath) {
531
538
  let handle;
532
539
  try {
533
- handle = await fs4.open(absPath, "r");
540
+ handle = await fs5.open(absPath, "r");
534
541
  const buf = Buffer.alloc(512);
535
542
  const { bytesRead } = await handle.read(buf, 0, 512, 0);
536
543
  const snippet = buf.subarray(0, bytesRead).toString("utf8");
@@ -580,11 +587,12 @@ async function readArticle(coll, slug) {
580
587
  const safeSlug = normalized.split("/").filter((s) => s !== "" && s !== ".").join("/");
581
588
  const inJail = (p) => p === collDir || p.startsWith(collDir + path4.sep);
582
589
  let abs;
590
+ let isIndex = false;
583
591
  for (const rel of safeSlug ? [`${safeSlug}.mdx`, `${safeSlug}.md`] : []) {
584
592
  const candidate = path4.resolve(collDir, rel);
585
593
  if (!inJail(candidate)) throw new InvalidTargetError(slug, "path traversal detected");
586
594
  try {
587
- await fs4.access(candidate);
595
+ await fs5.access(candidate);
588
596
  abs = candidate;
589
597
  break;
590
598
  } catch {
@@ -594,16 +602,20 @@ async function readArticle(coll, slug) {
594
602
  const dir = path4.resolve(collDir, safeSlug);
595
603
  if (inJail(dir)) {
596
604
  const idx = pickIndexFile(await readdirSafe(dir));
597
- if (idx) abs = path4.join(dir, idx);
605
+ if (idx) {
606
+ abs = path4.join(dir, idx);
607
+ isIndex = true;
608
+ }
598
609
  }
599
610
  }
600
611
  if (!abs) throw new ArticleNotFoundError(`${coll}/${safeSlug}`);
601
- const parsed = parseArticle(await fs4.readFile(abs, "utf8"));
612
+ const parsed = parseArticle(await fs5.readFile(abs, "utf8"));
602
613
  const title = parsed.title?.trim() || (safeSlug ? safeSlug.split("/").pop() ?? safeSlug : coll);
603
- return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs };
614
+ return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs, isIndex };
604
615
  }
605
616
 
606
617
  // src/render.ts
618
+ import path5 from "path";
607
619
  import { unified } from "unified";
608
620
  import remarkParse from "remark-parse";
609
621
  import remarkGfm from "remark-gfm";
@@ -613,6 +625,36 @@ import rehypeShiki from "@shikijs/rehype";
613
625
  import rehypeStringify from "rehype-stringify";
614
626
  import { visit, SKIP } from "unist-util-visit";
615
627
  import { toString as hastToString } from "hast-util-to-string";
628
+ function isNonRelativeHref(href) {
629
+ if (href === "") return true;
630
+ if (href.startsWith("/") || href.startsWith("#") || href.startsWith("?")) return true;
631
+ if (href.startsWith("//")) return true;
632
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
633
+ }
634
+ function rehypeRewriteLinks(ctx) {
635
+ const slugDir = ctx.isIndex ? ctx.slug || "." : path5.posix.dirname(ctx.slug || ".");
636
+ return (tree) => {
637
+ visit(tree, "element", (node) => {
638
+ if (node.tagName !== "a") return;
639
+ const raw = node.properties?.href;
640
+ if (typeof raw !== "string" || isNonRelativeHref(raw)) return;
641
+ const suffixMatch = /[#?].*$/.exec(raw);
642
+ const suffix = suffixMatch ? suffixMatch[0] : "";
643
+ const pathPart = suffix ? raw.slice(0, raw.length - suffix.length) : raw;
644
+ if (pathPart === "") return;
645
+ const joined = path5.posix.join(slugDir === "." ? "" : slugDir, pathPart);
646
+ const normalized = path5.posix.normalize(joined);
647
+ if (normalized.startsWith("..")) return;
648
+ let resolved = normalized.replace(/\.(mdx?)$/i, "");
649
+ resolved = resolved.replace(/(^|\/)(index|readme)$/i, "$1");
650
+ resolved = resolved.replace(/\/+$/, "").replace(/^\/+/, "");
651
+ node.properties = {
652
+ ...node.properties,
653
+ href: `/${ctx.coll}${resolved ? `/${resolved}` : ""}${suffix}`
654
+ };
655
+ });
656
+ };
657
+ }
616
658
  var LANG_ALIASES = {
617
659
  "c#": "csharp",
618
660
  cs: "csharp",
@@ -656,9 +698,9 @@ function rehypeMarkMermaid() {
656
698
  });
657
699
  };
658
700
  }
659
- async function renderArticle(body) {
701
+ async function renderArticle(body, ctx) {
660
702
  const toc = [];
661
- const file = await unified().use(remarkParse).use(remarkGfm).use(remarkRehype).use(rehypeSlug).use(rehypeCollectToc, toc).use(rehypeStripLeadingH1).use(rehypeMarkMermaid).use(rehypeShiki, {
703
+ const file = await unified().use(remarkParse).use(remarkGfm).use(remarkRehype).use(rehypeSlug).use(rehypeCollectToc, toc).use(rehypeStripLeadingH1).use(rehypeMarkMermaid).use(rehypeRewriteLinks, ctx).use(rehypeShiki, {
662
704
  themes: { light: "github-light", dark: "github-dark" },
663
705
  defaultColor: false,
664
706
  langAlias: LANG_ALIASES,
@@ -720,7 +762,12 @@ function findNode(nodes, slug) {
720
762
  async function resolvePage(coll, slug) {
721
763
  try {
722
764
  const art = await readArticle(coll, slug);
723
- const { html, toc } = await renderArticle(art.body);
765
+ const { html, toc } = await renderArticle(art.body, {
766
+ coll: art.coll,
767
+ slug: art.slug,
768
+ isIndex: art.isIndex
769
+ });
770
+ const { created, updated } = await fileTimestamps(art.path);
724
771
  return {
725
772
  kind: "doc",
726
773
  coll: art.coll,
@@ -729,7 +776,9 @@ async function resolvePage(coll, slug) {
729
776
  tags: art.tags,
730
777
  html,
731
778
  toc,
732
- path: `/${art.coll}/${art.slug}`
779
+ path: `/${art.coll}/${art.slug}`,
780
+ created,
781
+ updated
733
782
  };
734
783
  } catch (err) {
735
784
  if (err instanceof CollectionNotFoundError) return null;
@@ -787,19 +836,19 @@ function resolveCollectionSelection(all, sel = {}) {
787
836
  }
788
837
 
789
838
  // src/query.ts
790
- import fs5 from "fs/promises";
791
- import path5 from "path";
839
+ import fs6 from "fs/promises";
840
+ import path6 from "path";
792
841
  var log2 = getLogger("query");
793
842
  async function collectMarkdownFiles(dir) {
794
843
  let entries;
795
844
  try {
796
- entries = await fs5.readdir(dir, { withFileTypes: true });
845
+ entries = await fs6.readdir(dir, { withFileTypes: true });
797
846
  } catch {
798
847
  return [];
799
848
  }
800
849
  const files = [];
801
850
  for (const entry of entries) {
802
- const full = path5.join(dir, entry.name);
851
+ const full = path6.join(dir, entry.name);
803
852
  if (entry.isDirectory()) {
804
853
  files.push(...await collectMarkdownFiles(full));
805
854
  } else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
@@ -818,12 +867,12 @@ async function nativeSearch(term, collections, collection) {
818
867
  const docs = [];
819
868
  for (const entry of targets) {
820
869
  for (const full of await collectMarkdownFiles(entry.path)) {
821
- const fallbackTitle = path5.basename(full).replace(/\.mdx?$/, "");
870
+ const fallbackTitle = path6.basename(full).replace(/\.mdx?$/, "");
822
871
  let title = fallbackTitle;
823
872
  let tags = [];
824
873
  let body = "";
825
874
  try {
826
- const parsed = parseArticle(await fs5.readFile(full, "utf8"));
875
+ const parsed = parseArticle(await fs6.readFile(full, "utf8"));
827
876
  if (parsed.title) title = parsed.title;
828
877
  tags = parsed.tags;
829
878
  body = parsed.body;
@@ -906,12 +955,12 @@ async function query(term, opts) {
906
955
  }
907
956
 
908
957
  // src/search.ts
909
- import fs6 from "fs/promises";
910
- import path6 from "path";
958
+ import fs7 from "fs/promises";
959
+ import path7 from "path";
911
960
  async function readArticleParts(collDir, slug) {
912
961
  for (const ext of [".mdx", ".md"]) {
913
962
  try {
914
- const parsed = parseArticle(await fs6.readFile(path6.join(collDir, `${slug}${ext}`), "utf8"));
963
+ const parsed = parseArticle(await fs7.readFile(path7.join(collDir, `${slug}${ext}`), "utf8"));
915
964
  return { tags: parsed.tags, body: parsed.body };
916
965
  } catch {
917
966
  }
@@ -945,7 +994,7 @@ async function buildSearchIndex(collection) {
945
994
  for (const e of entries) {
946
995
  try {
947
996
  const collEntry = await findCollection(e.name);
948
- const collDir = path6.resolve(collEntry?.path ?? e.path);
997
+ const collDir = path7.resolve(collEntry?.path ?? e.path);
949
998
  await flatten(e.name, collDir, await listArticleTree(e.name), out);
950
999
  } catch {
951
1000
  }
@@ -954,15 +1003,15 @@ async function buildSearchIndex(collection) {
954
1003
  }
955
1004
 
956
1005
  // src/doctor.ts
957
- import fs8 from "fs/promises";
1006
+ import fs9 from "fs/promises";
958
1007
 
959
1008
  // src/plugins/manager.ts
960
- import fs7 from "fs/promises";
1009
+ import fs8 from "fs/promises";
961
1010
  import os from "os";
962
- import path7 from "path";
1011
+ import path8 from "path";
963
1012
  async function readPackageJson(file) {
964
1013
  try {
965
- return JSON.parse(await fs7.readFile(file, "utf8"));
1014
+ return JSON.parse(await fs8.readFile(file, "utf8"));
966
1015
  } catch {
967
1016
  return null;
968
1017
  }
@@ -978,7 +1027,7 @@ function binOf(manifest) {
978
1027
  async function pluginManifestDirs(nodeModules) {
979
1028
  let entries;
980
1029
  try {
981
- entries = await fs7.readdir(nodeModules);
1030
+ entries = await fs8.readdir(nodeModules);
982
1031
  } catch {
983
1032
  return [];
984
1033
  }
@@ -988,44 +1037,44 @@ async function pluginManifestDirs(nodeModules) {
988
1037
  if (entry.startsWith("@")) {
989
1038
  let scoped;
990
1039
  try {
991
- scoped = await fs7.readdir(path7.join(nodeModules, entry));
1040
+ scoped = await fs8.readdir(path8.join(nodeModules, entry));
992
1041
  } catch {
993
1042
  continue;
994
1043
  }
995
1044
  for (const pkg of scoped) {
996
1045
  if (pkg.startsWith(".")) continue;
997
- dirs.push(path7.join(nodeModules, entry, pkg));
1046
+ dirs.push(path8.join(nodeModules, entry, pkg));
998
1047
  }
999
1048
  } else {
1000
- dirs.push(path7.join(nodeModules, entry));
1049
+ dirs.push(path8.join(nodeModules, entry));
1001
1050
  }
1002
1051
  }
1003
1052
  return dirs;
1004
1053
  }
1005
1054
  async function ensurePluginRoot() {
1006
1055
  const dir = pluginsDir();
1007
- await fs7.mkdir(dir, { recursive: true });
1008
- const pkgPath = path7.join(dir, "package.json");
1056
+ await fs8.mkdir(dir, { recursive: true });
1057
+ const pkgPath = path8.join(dir, "package.json");
1009
1058
  if (await readPackageJson(pkgPath) === null) {
1010
1059
  const pkg = { name: "diffwiki-plugins", private: true, version: "0.0.0" };
1011
- await fs7.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
1060
+ await fs8.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
1012
1061
  `, "utf8");
1013
1062
  }
1014
1063
  return dir;
1015
1064
  }
1016
1065
  async function asSearchPackage(pkgDir) {
1017
- const manifest = await readPackageJson(path7.join(pkgDir, "package.json"));
1066
+ const manifest = await readPackageJson(path8.join(pkgDir, "package.json"));
1018
1067
  if (!manifest?.name || manifest.diffwiki?.kind !== "search") return null;
1019
1068
  const bin = binOf(manifest);
1020
1069
  if (!bin) return null;
1021
- return { name: manifest.name, version: manifest.version, binAbs: path7.resolve(pkgDir, bin) };
1070
+ return { name: manifest.name, version: manifest.version, binAbs: path8.resolve(pkgDir, bin) };
1022
1071
  }
1023
1072
  async function packageNameFromSpec(spec) {
1024
1073
  const looksLikePath = spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~") || spec.startsWith("file:");
1025
1074
  if (looksLikePath) {
1026
1075
  const raw = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
1027
- const abs = path7.resolve(raw.replace(/^~(?=$|\/)/, os.homedir()));
1028
- return (await readPackageJson(path7.join(abs, "package.json")))?.name ?? null;
1076
+ const abs = path8.resolve(raw.replace(/^~(?=$|\/)/, os.homedir()));
1077
+ return (await readPackageJson(path8.join(abs, "package.json")))?.name ?? null;
1029
1078
  }
1030
1079
  if (/^(git\+|git:|https?:|ssh:)/.test(spec)) return null;
1031
1080
  if (spec.startsWith("@")) {
@@ -1036,10 +1085,10 @@ async function packageNameFromSpec(spec) {
1036
1085
  return at <= 0 ? spec : spec.slice(0, at);
1037
1086
  }
1038
1087
  async function resolveSearchPackage(dir, name) {
1039
- return asSearchPackage(path7.join(dir, "node_modules", ...name.split("/")));
1088
+ return asSearchPackage(path8.join(dir, "node_modules", ...name.split("/")));
1040
1089
  }
1041
1090
  async function discoverSearchPackage(dir) {
1042
- const nodeModules = path7.join(dir, "node_modules");
1091
+ const nodeModules = path8.join(dir, "node_modules");
1043
1092
  for (const pkgDir of await pluginManifestDirs(nodeModules)) {
1044
1093
  const found = await asSearchPackage(pkgDir);
1045
1094
  if (found) return found;
@@ -1169,7 +1218,7 @@ async function listPlugins() {
1169
1218
  // src/doctor.ts
1170
1219
  async function exists(target) {
1171
1220
  try {
1172
- await fs8.access(target);
1221
+ await fs9.access(target);
1173
1222
  return true;
1174
1223
  } catch {
1175
1224
  return false;
@@ -1325,6 +1374,7 @@ export {
1325
1374
  emitPluginEvent,
1326
1375
  ensureGitRepo,
1327
1376
  ensurePluginRoot,
1377
+ fileTimestamps,
1328
1378
  findCollection,
1329
1379
  findCollectionById,
1330
1380
  findEnabledPlugin,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffwiki-core",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Core configuration, registry, and article management for diffwiki",
5
5
  "license": "MIT",
6
6
  "type": "module",