diffwiki-core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -582,6 +582,7 @@ __export(index_exports, {
582
582
  buildSearchIndex: () => buildSearchIndex,
583
583
  collectionDir: () => collectionDir,
584
584
  collectionGitStatus: () => collectionGitStatus,
585
+ collectionMeta: () => collectionMeta,
585
586
  collectionsDir: () => collectionsDir,
586
587
  configPath: () => configPath,
587
588
  createArticle: () => createArticle,
@@ -729,22 +730,39 @@ var YAML_ENGINE = {
729
730
  stringify: (data) => (0, import_yaml.stringify)(data)
730
731
  };
731
732
  var MATTER_OPTS = { engines: { yaml: YAML_ENGINE } };
733
+ function coerceAudience(value) {
734
+ return value === "private" ? "private" : "public";
735
+ }
736
+ function coerceStatus(value) {
737
+ return value === "draft" ? "draft" : "published";
738
+ }
732
739
  function parseFallback(raw) {
733
740
  const fenceRe = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*\n/;
734
741
  const fenceMatch = fenceRe.exec(raw);
735
742
  let title = "";
743
+ let audience = "public";
744
+ let status = "published";
736
745
  let body;
746
+ const clean = (s) => s.trim().replace(/^["']|["']$/g, "");
737
747
  if (fenceMatch) {
738
748
  const inner = fenceMatch[1];
739
749
  const titleMatch = /^title:\s*(.+)$/m.exec(inner);
740
750
  if (titleMatch) {
741
- title = titleMatch[1].trim().replace(/^["']|["']$/g, "");
751
+ title = clean(titleMatch[1]);
752
+ }
753
+ const audienceMatch = /^audience:\s*(.+)$/m.exec(inner);
754
+ if (audienceMatch) {
755
+ audience = coerceAudience(clean(audienceMatch[1]));
756
+ }
757
+ const statusMatch = /^status:\s*(.+)$/m.exec(inner);
758
+ if (statusMatch) {
759
+ status = coerceStatus(clean(statusMatch[1]));
742
760
  }
743
761
  body = raw.slice(fenceMatch[0].length);
744
762
  } else {
745
763
  body = raw;
746
764
  }
747
- return { title, tags: [], body: body.replace(/^\n+/, "") };
765
+ return { title, tags: [], audience, status, body: body.replace(/^\n+/, "") };
748
766
  }
749
767
  function parseArticle(raw) {
750
768
  try {
@@ -752,13 +770,17 @@ function parseArticle(raw) {
752
770
  const title = typeof data.title === "string" ? data.title : "";
753
771
  const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [];
754
772
  const created = typeof data.created === "string" ? data.created : void 0;
755
- return { title, tags, created, body: content.replace(/^\n+/, "") };
773
+ const audience = coerceAudience(data.audience);
774
+ const status = coerceStatus(data.status);
775
+ return { title, tags, created, audience, status, body: content.replace(/^\n+/, "") };
756
776
  } catch {
757
777
  return parseFallback(raw);
758
778
  }
759
779
  }
760
780
  function serializeArticle(article) {
761
781
  const front = { title: article.title, tags: article.tags };
782
+ if (article.audience === "private") front.audience = "private";
783
+ if (article.status === "draft") front.status = "draft";
762
784
  return import_gray_matter.default.stringify(article.body, front, MATTER_OPTS);
763
785
  }
764
786
 
@@ -839,7 +861,17 @@ async function loadArticle(target) {
839
861
  async function updateArticleBody(target, body) {
840
862
  const { filePath, dir, raw, collection } = await loadArticle(target);
841
863
  const parsed = parseArticle(raw);
842
- await import_promises5.default.writeFile(filePath, serializeArticle({ title: parsed.title, tags: parsed.tags, body }), "utf8");
864
+ await import_promises5.default.writeFile(
865
+ filePath,
866
+ serializeArticle({
867
+ title: parsed.title,
868
+ tags: parsed.tags,
869
+ audience: parsed.audience,
870
+ status: parsed.status,
871
+ body
872
+ }),
873
+ "utf8"
874
+ );
843
875
  void fireHook2("article-updated", collection, import_node_path3.default.relative(dir, filePath));
844
876
  return { title: parsed.title, tags: parsed.tags, path: filePath, collection };
845
877
  }
@@ -847,7 +879,17 @@ async function mutateTags(target, fn) {
847
879
  const { filePath, dir, raw, collection } = await loadArticle(target);
848
880
  const parsed = parseArticle(raw);
849
881
  const tags = fn(parsed.tags);
850
- await import_promises5.default.writeFile(filePath, serializeArticle({ title: parsed.title, tags, body: parsed.body }), "utf8");
882
+ await import_promises5.default.writeFile(
883
+ filePath,
884
+ serializeArticle({
885
+ title: parsed.title,
886
+ tags,
887
+ audience: parsed.audience,
888
+ status: parsed.status,
889
+ body: parsed.body
890
+ }),
891
+ "utf8"
892
+ );
851
893
  void fireHook2("article-updated", collection, import_node_path3.default.relative(dir, filePath));
852
894
  return { title: parsed.title, tags, path: filePath, collection };
853
895
  }
@@ -1168,20 +1210,30 @@ function pickIndexFile(names) {
1168
1210
  };
1169
1211
  return names.filter((n) => INDEX_RE.test(n)).sort((a, b) => rank(a) - rank(b))[0];
1170
1212
  }
1171
- async function cheapTitle(absPath) {
1213
+ var CHEAP_META_BYTES = 8192;
1214
+ async function cheapMeta(absPath) {
1172
1215
  let handle;
1173
1216
  try {
1174
1217
  handle = await import_promises8.default.open(absPath, "r");
1175
- const buf = Buffer.alloc(512);
1176
- const { bytesRead } = await handle.read(buf, 0, 512, 0);
1218
+ const buf = Buffer.alloc(CHEAP_META_BYTES);
1219
+ const { bytesRead } = await handle.read(buf, 0, CHEAP_META_BYTES, 0);
1177
1220
  const snippet = buf.subarray(0, bytesRead).toString("utf8");
1178
- const m = snippet.match(/^---[\s\S]*?^title:\s*(.+)$/m);
1179
- if (m) return m[1].trim().replace(/^["']|["']$/g, "");
1221
+ const fence = /^---[ \t]*\n([\s\S]*?)(?:\n---[ \t]*\n|$)/.exec(snippet);
1222
+ if (!fence) return {};
1223
+ const inner = fence[1];
1224
+ const clean = (s) => s.trim().replace(/^["']|["']$/g, "");
1225
+ const titleM = /^title:\s*(.+)$/m.exec(inner);
1226
+ const audienceM = /^audience:\s*(.+)$/m.exec(inner);
1227
+ const statusM = /^status:\s*(.+)$/m.exec(inner);
1228
+ const title = titleM ? clean(titleM[1]) : void 0;
1229
+ const audience = audienceM && clean(audienceM[1]) === "private" ? "private" : void 0;
1230
+ const status = statusM && clean(statusM[1]) === "draft" ? "draft" : void 0;
1231
+ return { title, audience, status };
1180
1232
  } catch {
1181
1233
  } finally {
1182
1234
  await handle?.close();
1183
1235
  }
1184
- return void 0;
1236
+ return {};
1185
1237
  }
1186
1238
  async function enrichTitles(nodes, collDir) {
1187
1239
  return Promise.all(
@@ -1189,21 +1241,41 @@ async function enrichTitles(nodes, collDir) {
1189
1241
  if (node.children) {
1190
1242
  const children = await enrichTitles(node.children, collDir);
1191
1243
  let title = node.title;
1244
+ let audience = node.audience;
1245
+ let status = node.status;
1192
1246
  if (node.slug) {
1193
1247
  const dir = import_node_path6.default.join(collDir, node.slug);
1194
1248
  const idx = pickIndexFile(await readdirSafe(dir));
1195
- if (idx) title = await cheapTitle(import_node_path6.default.join(dir, idx)) ?? title;
1249
+ if (idx) {
1250
+ const meta = await cheapMeta(import_node_path6.default.join(dir, idx));
1251
+ title = meta.title ?? title;
1252
+ audience = meta.audience ?? audience;
1253
+ status = meta.status ?? status;
1254
+ }
1196
1255
  }
1197
- return { ...node, title, children };
1256
+ return { ...node, title, audience, status, children };
1198
1257
  }
1199
1258
  for (const ext of [".mdx", ".md"]) {
1200
- const title = await cheapTitle(import_node_path6.default.join(collDir, `${node.slug}${ext}`));
1201
- if (title) return { ...node, title };
1259
+ const meta = await cheapMeta(import_node_path6.default.join(collDir, `${node.slug}${ext}`));
1260
+ if (meta.title || meta.audience || meta.status) {
1261
+ return {
1262
+ ...node,
1263
+ title: meta.title ?? node.title,
1264
+ audience: meta.audience ?? node.audience,
1265
+ status: meta.status ?? node.status
1266
+ };
1267
+ }
1202
1268
  }
1203
1269
  return node;
1204
1270
  })
1205
1271
  );
1206
1272
  }
1273
+ async function collectionMeta(collDir) {
1274
+ const idx = pickIndexFile(await readdirSafe(collDir));
1275
+ if (!idx) return {};
1276
+ const meta = await cheapMeta(import_node_path6.default.join(collDir, idx));
1277
+ return { audience: meta.audience, status: meta.status };
1278
+ }
1207
1279
  async function listArticleTree(coll) {
1208
1280
  const entry = await findCollection(coll);
1209
1281
  if (!entry) throw new CollectionNotFoundError(coll);
@@ -1245,7 +1317,17 @@ async function readArticle(coll, slug) {
1245
1317
  if (!abs) throw new ArticleNotFoundError(`${coll}/${safeSlug}`);
1246
1318
  const parsed = parseArticle(await import_promises8.default.readFile(abs, "utf8"));
1247
1319
  const title = parsed.title?.trim() || (safeSlug ? safeSlug.split("/").pop() ?? safeSlug : coll);
1248
- return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs, isIndex };
1320
+ return {
1321
+ coll,
1322
+ slug: safeSlug,
1323
+ title,
1324
+ tags: parsed.tags,
1325
+ audience: parsed.audience,
1326
+ status: parsed.status,
1327
+ body: parsed.body,
1328
+ path: abs,
1329
+ isIndex
1330
+ };
1249
1331
  }
1250
1332
 
1251
1333
  // src/render.ts
@@ -1278,10 +1360,15 @@ function rehypeRewriteLinks(ctx) {
1278
1360
  if (pathPart === "") return;
1279
1361
  const joined = import_node_path7.default.posix.join(slugDir === "." ? "" : slugDir, pathPart);
1280
1362
  const normalized = import_node_path7.default.posix.normalize(joined);
1363
+ const toRoute = (p) => p.replace(/\.(mdx?)$/i, "").replace(/(^|\/)(index|readme)$/i, "$1").replace(/\/+$/, "").replace(/^\/+/, "");
1364
+ if (normalized.startsWith("../")) {
1365
+ const sibling = normalized.slice(3);
1366
+ if (sibling === "" || sibling.startsWith("..")) return;
1367
+ node.properties = { ...node.properties, href: `/${toRoute(sibling)}${suffix}` };
1368
+ return;
1369
+ }
1281
1370
  if (normalized.startsWith("..")) return;
1282
- let resolved = normalized.replace(/\.(mdx?)$/i, "");
1283
- resolved = resolved.replace(/(^|\/)(index|readme)$/i, "$1");
1284
- resolved = resolved.replace(/\/+$/, "").replace(/^\/+/, "");
1371
+ const resolved = toRoute(normalized);
1285
1372
  node.properties = {
1286
1373
  ...node.properties,
1287
1374
  href: `/${ctx.coll}${resolved ? `/${resolved}` : ""}${suffix}`
@@ -1496,13 +1583,22 @@ function localGraph(graph, focusId, include, siblingCap = 12) {
1496
1583
  init_errors();
1497
1584
  var isStatic = () => process.env.DIFFWIKI_STATIC === "1";
1498
1585
  function toTreeNodes(coll, nodes) {
1499
- return nodes.map(
1586
+ const visible = isStatic() ? nodes.filter((n) => n.audience !== "private" && n.status !== "draft") : nodes;
1587
+ return visible.map(
1500
1588
  (n) => n.children ? {
1501
1589
  name: n.name,
1502
1590
  title: n.title ?? n.name,
1503
1591
  path: n.slug ? `/${coll}/${n.slug}` : void 0,
1592
+ ...n.audience ? { audience: n.audience } : {},
1593
+ ...n.status ? { status: n.status } : {},
1504
1594
  children: toTreeNodes(coll, n.children)
1505
- } : { name: n.name, title: n.title ?? n.name, path: `/${coll}/${n.slug}` }
1595
+ } : {
1596
+ name: n.name,
1597
+ title: n.title ?? n.name,
1598
+ path: `/${coll}/${n.slug}`,
1599
+ ...n.audience ? { audience: n.audience } : {},
1600
+ ...n.status ? { status: n.status } : {}
1601
+ }
1506
1602
  );
1507
1603
  }
1508
1604
  async function gitInfo(e) {
@@ -1515,23 +1611,40 @@ async function gitInfo(e) {
1515
1611
  }
1516
1612
  async function buildCollections() {
1517
1613
  const entries = await listCollections();
1518
- return Promise.all(
1519
- entries.map(async (e) => ({
1520
- name: e.name,
1521
- type: e.type,
1522
- path: e.path,
1523
- ...await gitInfo(e)
1524
- }))
1614
+ const infos = await Promise.all(
1615
+ entries.map(async (e) => {
1616
+ const { audience, status } = await collectionMeta(e.path);
1617
+ return {
1618
+ name: e.name,
1619
+ type: e.type,
1620
+ path: e.path,
1621
+ ...await gitInfo(e),
1622
+ ...audience ? { audience } : {},
1623
+ ...status ? { status } : {}
1624
+ };
1625
+ })
1525
1626
  );
1627
+ return isStatic() ? infos.filter((c) => c.audience !== "private" && c.status !== "draft") : infos;
1526
1628
  }
1527
1629
  async function buildNavTree() {
1528
1630
  const entries = await listCollections();
1529
- return Promise.all(
1530
- entries.map(async (e) => ({
1531
- collection: { name: e.name, type: e.type, path: e.path, ...await gitInfo(e) },
1532
- nodes: toTreeNodes(e.name, await listArticleTree(e.name))
1533
- }))
1631
+ const trees = await Promise.all(
1632
+ entries.map(async (e) => {
1633
+ const { audience, status } = await collectionMeta(e.path);
1634
+ return {
1635
+ collection: {
1636
+ name: e.name,
1637
+ type: e.type,
1638
+ path: e.path,
1639
+ ...await gitInfo(e),
1640
+ ...audience ? { audience } : {},
1641
+ ...status ? { status } : {}
1642
+ },
1643
+ nodes: toTreeNodes(e.name, await listArticleTree(e.name))
1644
+ };
1645
+ })
1534
1646
  );
1647
+ return isStatic() ? trees.filter((t) => t.collection.audience !== "private" && t.collection.status !== "draft") : trees;
1535
1648
  }
1536
1649
  function findNode(nodes, slug) {
1537
1650
  for (const n of nodes) {
@@ -1559,6 +1672,8 @@ async function resolvePage(coll, slug, opts) {
1559
1672
  slug: art.slug,
1560
1673
  title: art.title,
1561
1674
  tags: art.tags,
1675
+ ...art.audience === "private" ? { audience: art.audience } : {},
1676
+ ...art.status === "draft" ? { status: art.status } : {},
1562
1677
  html,
1563
1678
  toc,
1564
1679
  path: `/${art.coll}/${art.slug}`,
@@ -1808,6 +1923,7 @@ async function query(term, opts) {
1808
1923
  var import_promises10 = __toESM(require("fs/promises"), 1);
1809
1924
  var import_node_path9 = __toESM(require("path"), 1);
1810
1925
  init_collections();
1926
+ var isStatic2 = () => process.env.DIFFWIKI_STATIC === "1";
1811
1927
  async function readArticleParts(collDir, slug) {
1812
1928
  for (const ext of [".mdx", ".md"]) {
1813
1929
  try {
@@ -1820,6 +1936,7 @@ async function readArticleParts(collDir, slug) {
1820
1936
  }
1821
1937
  async function flatten(coll, collDir, nodes, out) {
1822
1938
  for (const n of nodes) {
1939
+ if (isStatic2() && (n.audience === "private" || n.status === "draft")) continue;
1823
1940
  if (n.slug && !n.children) {
1824
1941
  const title = n.title ?? n.name;
1825
1942
  const { tags, body } = await readArticleParts(collDir, n.slug);
@@ -1846,6 +1963,10 @@ async function buildSearchIndex(collection) {
1846
1963
  try {
1847
1964
  const collEntry = await findCollection(e.name);
1848
1965
  const collDir = import_node_path9.default.resolve(collEntry?.path ?? e.path);
1966
+ if (isStatic2()) {
1967
+ const cm = await collectionMeta(collDir);
1968
+ if (cm.audience === "private" || cm.status === "draft") continue;
1969
+ }
1849
1970
  await flatten(e.name, collDir, await listArticleTree(e.name), out);
1850
1971
  } catch {
1851
1972
  }
@@ -2256,6 +2377,7 @@ init_registry2();
2256
2377
  buildSearchIndex,
2257
2378
  collectionDir,
2258
2379
  collectionGitStatus,
2380
+ collectionMeta,
2259
2381
  collectionsDir,
2260
2382
  configPath,
2261
2383
  createArticle,
package/dist/index.d.cts CHANGED
@@ -2,6 +2,10 @@ import { SearchDoc } from './bm25.cjs';
2
2
  export { RankableFields, rankDocs, tokenize } from './bm25.cjs';
3
3
  import { LogLevel, Logger } from '@logtape/logtape';
4
4
 
5
+ /** Audience axis — who an article is for. Private is never published. Missing/invalid → 'public'. */
6
+ type Audience = 'public' | 'private';
7
+ /** Lifecycle axis — readiness. Draft is not yet published. Missing/invalid → 'published'. */
8
+ type PublishStatus = 'draft' | 'published';
5
9
  type CollectionType = 'global' | 'repo' | 'external';
6
10
  interface CollectionEntry {
7
11
  name: string;
@@ -47,6 +51,10 @@ interface ArticleNode {
47
51
  * leaves (the article) and on branches (the folder, navigable to its index).
48
52
  */
49
53
  slug?: string;
54
+ /** Audience, set only when 'private' (omitted for 'public'). */
55
+ audience?: Audience;
56
+ /** Lifecycle status, set only when 'draft' (omitted for 'published'). */
57
+ status?: PublishStatus;
50
58
  /** Branch only: nested nodes. */
51
59
  children?: ArticleNode[];
52
60
  }
@@ -56,6 +64,10 @@ interface ArticleContent {
56
64
  slug: string;
57
65
  title: string;
58
66
  tags: string[];
67
+ /** Audience from frontmatter ('public' when absent). */
68
+ audience: Audience;
69
+ /** Lifecycle status from frontmatter ('published' when absent). */
70
+ status: PublishStatus;
59
71
  /** Frontmatter-stripped body (md/mdx). */
60
72
  body: string;
61
73
  /** Absolute path to the file on disk. */
@@ -160,6 +172,10 @@ interface TreeNode {
160
172
  name: string;
161
173
  title?: string;
162
174
  path?: string;
175
+ /** Audience; present only when 'private' (drives the sidebar lock). */
176
+ audience?: Audience;
177
+ /** Lifecycle status; present only when 'draft' (drives the sidebar DRAFT tag). */
178
+ status?: PublishStatus;
163
179
  children?: TreeNode[];
164
180
  /** Set only on top-level collection rows: git branch/behind badge data. */
165
181
  meta?: NodeMeta;
@@ -170,6 +186,10 @@ interface CollectionInfo {
170
186
  path: string;
171
187
  branch?: string;
172
188
  behind?: number;
189
+ /** Audience from the collection's root index page; present only when 'private'. */
190
+ audience?: Audience;
191
+ /** Lifecycle status from the collection's root index page; present only when 'draft'. */
192
+ status?: PublishStatus;
173
193
  }
174
194
  interface CollectionTree {
175
195
  collection: CollectionInfo;
@@ -185,6 +205,10 @@ interface DocData {
185
205
  slug: string;
186
206
  title: string;
187
207
  tags: string[];
208
+ /** Audience; present only when 'private'. */
209
+ audience?: Audience;
210
+ /** Lifecycle status; present only when 'draft'. */
211
+ status?: PublishStatus;
188
212
  html: string;
189
213
  toc: TocItem[];
190
214
  path: string;
@@ -307,11 +331,15 @@ interface ParsedArticle {
307
331
  title: string;
308
332
  tags: string[];
309
333
  created?: string;
334
+ audience: Audience;
335
+ status: PublishStatus;
310
336
  body: string;
311
337
  }
312
338
  interface SerializableArticle {
313
339
  title: string;
314
340
  tags: string[];
341
+ audience?: Audience;
342
+ status?: PublishStatus;
315
343
  body: string;
316
344
  }
317
345
  declare function parseArticle(raw: string): ParsedArticle;
@@ -436,7 +464,10 @@ interface FileTimestamps {
436
464
  */
437
465
  declare function fileTimestamps(absPath: string): Promise<FileTimestamps>;
438
466
 
439
- /** Map core article nodes to UI nav nodes, assigning each its route path. */
467
+ /** Map core article nodes to UI nav nodes, assigning each its route path.
468
+ * In static builds, private (audience) or draft (status) articles are dropped
469
+ * entirely (no nav row, and — since routes + per-doc JSON derive from this tree
470
+ * — no route/JSON). */
440
471
  declare function toTreeNodes(coll: string, nodes: ArticleNode[]): TreeNode[];
441
472
  declare function buildCollections(): Promise<CollectionInfo[]>;
442
473
  declare function buildNavTree(): Promise<CollectionTree[]>;
@@ -640,6 +671,16 @@ declare function resolveLogLevel(env?: NodeJS.ProcessEnv): DiffwikiLogLevel;
640
671
  * is the relative path without extension. Dirs-first, then alpha (case-insensitive).
641
672
  */
642
673
  declare function buildArticleTree(relPaths: string[]): ArticleNode[];
674
+ /**
675
+ * Read a collection's (or any directory's) audience + status from its index page
676
+ * (index.md / README). Each axis is present only at its non-default value
677
+ * (audience:'private' / status:'draft'), else absent. Cheap partial read — used
678
+ * to mark/skip whole collections the same way leaf articles are.
679
+ */
680
+ declare function collectionMeta(collDir: string): Promise<{
681
+ audience?: Audience;
682
+ status?: PublishStatus;
683
+ }>;
643
684
  /**
644
685
  * List the article tree of a collection. Walks the collection's registered
645
686
  * `entry.path` — so collections living OUTSIDE `~/.diffwiki/collections`
@@ -890,4 +931,4 @@ declare function findEnabledPlugin(kind: PluginKind, name?: string): Promise<Plu
890
931
  /** Find a plugin record by name (regardless of enabled state). */
891
932
  declare function findPluginRecord(name: string): Promise<PluginRecord | undefined>;
892
933
 
893
- 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 GraphEdge, type GraphNode, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type LinkGraph, type ListingEntry, type LocalGraph, 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, buildLinkGraph, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, invalidateLinkGraph, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, localGraph, 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 };
934
+ export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, type Audience, 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 GraphEdge, type GraphNode, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type LinkGraph, type ListingEntry, type LocalGraph, 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 PublishStatus, 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, buildLinkGraph, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionMeta, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, invalidateLinkGraph, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, localGraph, 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
@@ -2,6 +2,10 @@ import { SearchDoc } from './bm25.js';
2
2
  export { RankableFields, rankDocs, tokenize } from './bm25.js';
3
3
  import { LogLevel, Logger } from '@logtape/logtape';
4
4
 
5
+ /** Audience axis — who an article is for. Private is never published. Missing/invalid → 'public'. */
6
+ type Audience = 'public' | 'private';
7
+ /** Lifecycle axis — readiness. Draft is not yet published. Missing/invalid → 'published'. */
8
+ type PublishStatus = 'draft' | 'published';
5
9
  type CollectionType = 'global' | 'repo' | 'external';
6
10
  interface CollectionEntry {
7
11
  name: string;
@@ -47,6 +51,10 @@ interface ArticleNode {
47
51
  * leaves (the article) and on branches (the folder, navigable to its index).
48
52
  */
49
53
  slug?: string;
54
+ /** Audience, set only when 'private' (omitted for 'public'). */
55
+ audience?: Audience;
56
+ /** Lifecycle status, set only when 'draft' (omitted for 'published'). */
57
+ status?: PublishStatus;
50
58
  /** Branch only: nested nodes. */
51
59
  children?: ArticleNode[];
52
60
  }
@@ -56,6 +64,10 @@ interface ArticleContent {
56
64
  slug: string;
57
65
  title: string;
58
66
  tags: string[];
67
+ /** Audience from frontmatter ('public' when absent). */
68
+ audience: Audience;
69
+ /** Lifecycle status from frontmatter ('published' when absent). */
70
+ status: PublishStatus;
59
71
  /** Frontmatter-stripped body (md/mdx). */
60
72
  body: string;
61
73
  /** Absolute path to the file on disk. */
@@ -160,6 +172,10 @@ interface TreeNode {
160
172
  name: string;
161
173
  title?: string;
162
174
  path?: string;
175
+ /** Audience; present only when 'private' (drives the sidebar lock). */
176
+ audience?: Audience;
177
+ /** Lifecycle status; present only when 'draft' (drives the sidebar DRAFT tag). */
178
+ status?: PublishStatus;
163
179
  children?: TreeNode[];
164
180
  /** Set only on top-level collection rows: git branch/behind badge data. */
165
181
  meta?: NodeMeta;
@@ -170,6 +186,10 @@ interface CollectionInfo {
170
186
  path: string;
171
187
  branch?: string;
172
188
  behind?: number;
189
+ /** Audience from the collection's root index page; present only when 'private'. */
190
+ audience?: Audience;
191
+ /** Lifecycle status from the collection's root index page; present only when 'draft'. */
192
+ status?: PublishStatus;
173
193
  }
174
194
  interface CollectionTree {
175
195
  collection: CollectionInfo;
@@ -185,6 +205,10 @@ interface DocData {
185
205
  slug: string;
186
206
  title: string;
187
207
  tags: string[];
208
+ /** Audience; present only when 'private'. */
209
+ audience?: Audience;
210
+ /** Lifecycle status; present only when 'draft'. */
211
+ status?: PublishStatus;
188
212
  html: string;
189
213
  toc: TocItem[];
190
214
  path: string;
@@ -307,11 +331,15 @@ interface ParsedArticle {
307
331
  title: string;
308
332
  tags: string[];
309
333
  created?: string;
334
+ audience: Audience;
335
+ status: PublishStatus;
310
336
  body: string;
311
337
  }
312
338
  interface SerializableArticle {
313
339
  title: string;
314
340
  tags: string[];
341
+ audience?: Audience;
342
+ status?: PublishStatus;
315
343
  body: string;
316
344
  }
317
345
  declare function parseArticle(raw: string): ParsedArticle;
@@ -436,7 +464,10 @@ interface FileTimestamps {
436
464
  */
437
465
  declare function fileTimestamps(absPath: string): Promise<FileTimestamps>;
438
466
 
439
- /** Map core article nodes to UI nav nodes, assigning each its route path. */
467
+ /** Map core article nodes to UI nav nodes, assigning each its route path.
468
+ * In static builds, private (audience) or draft (status) articles are dropped
469
+ * entirely (no nav row, and — since routes + per-doc JSON derive from this tree
470
+ * — no route/JSON). */
440
471
  declare function toTreeNodes(coll: string, nodes: ArticleNode[]): TreeNode[];
441
472
  declare function buildCollections(): Promise<CollectionInfo[]>;
442
473
  declare function buildNavTree(): Promise<CollectionTree[]>;
@@ -640,6 +671,16 @@ declare function resolveLogLevel(env?: NodeJS.ProcessEnv): DiffwikiLogLevel;
640
671
  * is the relative path without extension. Dirs-first, then alpha (case-insensitive).
641
672
  */
642
673
  declare function buildArticleTree(relPaths: string[]): ArticleNode[];
674
+ /**
675
+ * Read a collection's (or any directory's) audience + status from its index page
676
+ * (index.md / README). Each axis is present only at its non-default value
677
+ * (audience:'private' / status:'draft'), else absent. Cheap partial read — used
678
+ * to mark/skip whole collections the same way leaf articles are.
679
+ */
680
+ declare function collectionMeta(collDir: string): Promise<{
681
+ audience?: Audience;
682
+ status?: PublishStatus;
683
+ }>;
643
684
  /**
644
685
  * List the article tree of a collection. Walks the collection's registered
645
686
  * `entry.path` — so collections living OUTSIDE `~/.diffwiki/collections`
@@ -890,4 +931,4 @@ declare function findEnabledPlugin(kind: PluginKind, name?: string): Promise<Plu
890
931
  /** Find a plugin record by name (regardless of enabled state). */
891
932
  declare function findPluginRecord(name: string): Promise<PluginRecord | undefined>;
892
933
 
893
- 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 GraphEdge, type GraphNode, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type LinkGraph, type ListingEntry, type LocalGraph, 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, buildLinkGraph, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, invalidateLinkGraph, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, localGraph, 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 };
934
+ export { type AddExternalOptions, type Article, type ArticleContent, type ArticleNode, ArticleNotFoundError, type Audience, 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 GraphEdge, type GraphNode, type InitOptions, type InstallOutcome, InvalidExportSelectionError, InvalidTargetError, type KnownPlugin, type LinkGraph, type ListingEntry, type LocalGraph, 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 PublishStatus, 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, buildLinkGraph, buildNavTree, buildSearchIndex, collectionDir, collectionGitStatus, collectionMeta, collectionsDir, configPath, createArticle, createCollection, currentBranch, deregisterPlugin, doctor, emitPluginEvent, ensureGitRepo, ensurePluginRoot, fileTimestamps, findCollection, findCollectionById, findEnabledPlugin, findPluginRecord, getConfigValue, getLogger, gitToplevel, initRepoWiki, installPlugin, invalidateLinkGraph, isLinkedWorktree, listArticleTree, listAvailablePlugins, listCollections, listEnabledPlugins, listPlugins, listSearchModes, loadSearchProvider, localGraph, 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
@@ -109,22 +109,39 @@ var YAML_ENGINE = {
109
109
  stringify: (data) => stringifyYaml(data)
110
110
  };
111
111
  var MATTER_OPTS = { engines: { yaml: YAML_ENGINE } };
112
+ function coerceAudience(value) {
113
+ return value === "private" ? "private" : "public";
114
+ }
115
+ function coerceStatus(value) {
116
+ return value === "draft" ? "draft" : "published";
117
+ }
112
118
  function parseFallback(raw) {
113
119
  const fenceRe = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*\n/;
114
120
  const fenceMatch = fenceRe.exec(raw);
115
121
  let title = "";
122
+ let audience = "public";
123
+ let status = "published";
116
124
  let body;
125
+ const clean = (s) => s.trim().replace(/^["']|["']$/g, "");
117
126
  if (fenceMatch) {
118
127
  const inner = fenceMatch[1];
119
128
  const titleMatch = /^title:\s*(.+)$/m.exec(inner);
120
129
  if (titleMatch) {
121
- title = titleMatch[1].trim().replace(/^["']|["']$/g, "");
130
+ title = clean(titleMatch[1]);
131
+ }
132
+ const audienceMatch = /^audience:\s*(.+)$/m.exec(inner);
133
+ if (audienceMatch) {
134
+ audience = coerceAudience(clean(audienceMatch[1]));
135
+ }
136
+ const statusMatch = /^status:\s*(.+)$/m.exec(inner);
137
+ if (statusMatch) {
138
+ status = coerceStatus(clean(statusMatch[1]));
122
139
  }
123
140
  body = raw.slice(fenceMatch[0].length);
124
141
  } else {
125
142
  body = raw;
126
143
  }
127
- return { title, tags: [], body: body.replace(/^\n+/, "") };
144
+ return { title, tags: [], audience, status, body: body.replace(/^\n+/, "") };
128
145
  }
129
146
  function parseArticle(raw) {
130
147
  try {
@@ -132,13 +149,17 @@ function parseArticle(raw) {
132
149
  const title = typeof data.title === "string" ? data.title : "";
133
150
  const tags = Array.isArray(data.tags) ? data.tags.map((t) => String(t)) : [];
134
151
  const created = typeof data.created === "string" ? data.created : void 0;
135
- return { title, tags, created, body: content.replace(/^\n+/, "") };
152
+ const audience = coerceAudience(data.audience);
153
+ const status = coerceStatus(data.status);
154
+ return { title, tags, created, audience, status, body: content.replace(/^\n+/, "") };
136
155
  } catch {
137
156
  return parseFallback(raw);
138
157
  }
139
158
  }
140
159
  function serializeArticle(article) {
141
160
  const front = { title: article.title, tags: article.tags };
161
+ if (article.audience === "private") front.audience = "private";
162
+ if (article.status === "draft") front.status = "draft";
142
163
  return matter.stringify(article.body, front, MATTER_OPTS);
143
164
  }
144
165
 
@@ -214,7 +235,17 @@ async function loadArticle(target) {
214
235
  async function updateArticleBody(target, body) {
215
236
  const { filePath, dir, raw, collection } = await loadArticle(target);
216
237
  const parsed = parseArticle(raw);
217
- await fs2.writeFile(filePath, serializeArticle({ title: parsed.title, tags: parsed.tags, body }), "utf8");
238
+ await fs2.writeFile(
239
+ filePath,
240
+ serializeArticle({
241
+ title: parsed.title,
242
+ tags: parsed.tags,
243
+ audience: parsed.audience,
244
+ status: parsed.status,
245
+ body
246
+ }),
247
+ "utf8"
248
+ );
218
249
  void fireHook("article-updated", collection, path.relative(dir, filePath));
219
250
  return { title: parsed.title, tags: parsed.tags, path: filePath, collection };
220
251
  }
@@ -222,7 +253,17 @@ async function mutateTags(target, fn) {
222
253
  const { filePath, dir, raw, collection } = await loadArticle(target);
223
254
  const parsed = parseArticle(raw);
224
255
  const tags = fn(parsed.tags);
225
- await fs2.writeFile(filePath, serializeArticle({ title: parsed.title, tags, body: parsed.body }), "utf8");
256
+ await fs2.writeFile(
257
+ filePath,
258
+ serializeArticle({
259
+ title: parsed.title,
260
+ tags,
261
+ audience: parsed.audience,
262
+ status: parsed.status,
263
+ body: parsed.body
264
+ }),
265
+ "utf8"
266
+ );
226
267
  void fireHook("article-updated", collection, path.relative(dir, filePath));
227
268
  return { title: parsed.title, tags, path: filePath, collection };
228
269
  }
@@ -534,20 +575,30 @@ function pickIndexFile(names) {
534
575
  };
535
576
  return names.filter((n) => INDEX_RE.test(n)).sort((a, b) => rank(a) - rank(b))[0];
536
577
  }
537
- async function cheapTitle(absPath) {
578
+ var CHEAP_META_BYTES = 8192;
579
+ async function cheapMeta(absPath) {
538
580
  let handle;
539
581
  try {
540
582
  handle = await fs5.open(absPath, "r");
541
- const buf = Buffer.alloc(512);
542
- const { bytesRead } = await handle.read(buf, 0, 512, 0);
583
+ const buf = Buffer.alloc(CHEAP_META_BYTES);
584
+ const { bytesRead } = await handle.read(buf, 0, CHEAP_META_BYTES, 0);
543
585
  const snippet = buf.subarray(0, bytesRead).toString("utf8");
544
- const m = snippet.match(/^---[\s\S]*?^title:\s*(.+)$/m);
545
- if (m) return m[1].trim().replace(/^["']|["']$/g, "");
586
+ const fence = /^---[ \t]*\n([\s\S]*?)(?:\n---[ \t]*\n|$)/.exec(snippet);
587
+ if (!fence) return {};
588
+ const inner = fence[1];
589
+ const clean = (s) => s.trim().replace(/^["']|["']$/g, "");
590
+ const titleM = /^title:\s*(.+)$/m.exec(inner);
591
+ const audienceM = /^audience:\s*(.+)$/m.exec(inner);
592
+ const statusM = /^status:\s*(.+)$/m.exec(inner);
593
+ const title = titleM ? clean(titleM[1]) : void 0;
594
+ const audience = audienceM && clean(audienceM[1]) === "private" ? "private" : void 0;
595
+ const status = statusM && clean(statusM[1]) === "draft" ? "draft" : void 0;
596
+ return { title, audience, status };
546
597
  } catch {
547
598
  } finally {
548
599
  await handle?.close();
549
600
  }
550
- return void 0;
601
+ return {};
551
602
  }
552
603
  async function enrichTitles(nodes, collDir) {
553
604
  return Promise.all(
@@ -555,21 +606,41 @@ async function enrichTitles(nodes, collDir) {
555
606
  if (node.children) {
556
607
  const children = await enrichTitles(node.children, collDir);
557
608
  let title = node.title;
609
+ let audience = node.audience;
610
+ let status = node.status;
558
611
  if (node.slug) {
559
612
  const dir = path4.join(collDir, node.slug);
560
613
  const idx = pickIndexFile(await readdirSafe(dir));
561
- if (idx) title = await cheapTitle(path4.join(dir, idx)) ?? title;
614
+ if (idx) {
615
+ const meta = await cheapMeta(path4.join(dir, idx));
616
+ title = meta.title ?? title;
617
+ audience = meta.audience ?? audience;
618
+ status = meta.status ?? status;
619
+ }
562
620
  }
563
- return { ...node, title, children };
621
+ return { ...node, title, audience, status, children };
564
622
  }
565
623
  for (const ext of [".mdx", ".md"]) {
566
- const title = await cheapTitle(path4.join(collDir, `${node.slug}${ext}`));
567
- if (title) return { ...node, title };
624
+ const meta = await cheapMeta(path4.join(collDir, `${node.slug}${ext}`));
625
+ if (meta.title || meta.audience || meta.status) {
626
+ return {
627
+ ...node,
628
+ title: meta.title ?? node.title,
629
+ audience: meta.audience ?? node.audience,
630
+ status: meta.status ?? node.status
631
+ };
632
+ }
568
633
  }
569
634
  return node;
570
635
  })
571
636
  );
572
637
  }
638
+ async function collectionMeta(collDir) {
639
+ const idx = pickIndexFile(await readdirSafe(collDir));
640
+ if (!idx) return {};
641
+ const meta = await cheapMeta(path4.join(collDir, idx));
642
+ return { audience: meta.audience, status: meta.status };
643
+ }
573
644
  async function listArticleTree(coll) {
574
645
  const entry = await findCollection(coll);
575
646
  if (!entry) throw new CollectionNotFoundError(coll);
@@ -611,7 +682,17 @@ async function readArticle(coll, slug) {
611
682
  if (!abs) throw new ArticleNotFoundError(`${coll}/${safeSlug}`);
612
683
  const parsed = parseArticle(await fs5.readFile(abs, "utf8"));
613
684
  const title = parsed.title?.trim() || (safeSlug ? safeSlug.split("/").pop() ?? safeSlug : coll);
614
- return { coll, slug: safeSlug, title, tags: parsed.tags, body: parsed.body, path: abs, isIndex };
685
+ return {
686
+ coll,
687
+ slug: safeSlug,
688
+ title,
689
+ tags: parsed.tags,
690
+ audience: parsed.audience,
691
+ status: parsed.status,
692
+ body: parsed.body,
693
+ path: abs,
694
+ isIndex
695
+ };
615
696
  }
616
697
 
617
698
  // src/render.ts
@@ -644,10 +725,15 @@ function rehypeRewriteLinks(ctx) {
644
725
  if (pathPart === "") return;
645
726
  const joined = path5.posix.join(slugDir === "." ? "" : slugDir, pathPart);
646
727
  const normalized = path5.posix.normalize(joined);
728
+ const toRoute = (p) => p.replace(/\.(mdx?)$/i, "").replace(/(^|\/)(index|readme)$/i, "$1").replace(/\/+$/, "").replace(/^\/+/, "");
729
+ if (normalized.startsWith("../")) {
730
+ const sibling = normalized.slice(3);
731
+ if (sibling === "" || sibling.startsWith("..")) return;
732
+ node.properties = { ...node.properties, href: `/${toRoute(sibling)}${suffix}` };
733
+ return;
734
+ }
647
735
  if (normalized.startsWith("..")) return;
648
- let resolved = normalized.replace(/\.(mdx?)$/i, "");
649
- resolved = resolved.replace(/(^|\/)(index|readme)$/i, "$1");
650
- resolved = resolved.replace(/\/+$/, "").replace(/^\/+/, "");
736
+ const resolved = toRoute(normalized);
651
737
  node.properties = {
652
738
  ...node.properties,
653
739
  href: `/${ctx.coll}${resolved ? `/${resolved}` : ""}${suffix}`
@@ -859,13 +945,22 @@ function localGraph(graph, focusId, include, siblingCap = 12) {
859
945
  // src/site.ts
860
946
  var isStatic = () => process.env.DIFFWIKI_STATIC === "1";
861
947
  function toTreeNodes(coll, nodes) {
862
- return nodes.map(
948
+ const visible = isStatic() ? nodes.filter((n) => n.audience !== "private" && n.status !== "draft") : nodes;
949
+ return visible.map(
863
950
  (n) => n.children ? {
864
951
  name: n.name,
865
952
  title: n.title ?? n.name,
866
953
  path: n.slug ? `/${coll}/${n.slug}` : void 0,
954
+ ...n.audience ? { audience: n.audience } : {},
955
+ ...n.status ? { status: n.status } : {},
867
956
  children: toTreeNodes(coll, n.children)
868
- } : { name: n.name, title: n.title ?? n.name, path: `/${coll}/${n.slug}` }
957
+ } : {
958
+ name: n.name,
959
+ title: n.title ?? n.name,
960
+ path: `/${coll}/${n.slug}`,
961
+ ...n.audience ? { audience: n.audience } : {},
962
+ ...n.status ? { status: n.status } : {}
963
+ }
869
964
  );
870
965
  }
871
966
  async function gitInfo(e) {
@@ -878,23 +973,40 @@ async function gitInfo(e) {
878
973
  }
879
974
  async function buildCollections() {
880
975
  const entries = await listCollections();
881
- return Promise.all(
882
- entries.map(async (e) => ({
883
- name: e.name,
884
- type: e.type,
885
- path: e.path,
886
- ...await gitInfo(e)
887
- }))
976
+ const infos = await Promise.all(
977
+ entries.map(async (e) => {
978
+ const { audience, status } = await collectionMeta(e.path);
979
+ return {
980
+ name: e.name,
981
+ type: e.type,
982
+ path: e.path,
983
+ ...await gitInfo(e),
984
+ ...audience ? { audience } : {},
985
+ ...status ? { status } : {}
986
+ };
987
+ })
888
988
  );
989
+ return isStatic() ? infos.filter((c) => c.audience !== "private" && c.status !== "draft") : infos;
889
990
  }
890
991
  async function buildNavTree() {
891
992
  const entries = await listCollections();
892
- return Promise.all(
893
- entries.map(async (e) => ({
894
- collection: { name: e.name, type: e.type, path: e.path, ...await gitInfo(e) },
895
- nodes: toTreeNodes(e.name, await listArticleTree(e.name))
896
- }))
993
+ const trees = await Promise.all(
994
+ entries.map(async (e) => {
995
+ const { audience, status } = await collectionMeta(e.path);
996
+ return {
997
+ collection: {
998
+ name: e.name,
999
+ type: e.type,
1000
+ path: e.path,
1001
+ ...await gitInfo(e),
1002
+ ...audience ? { audience } : {},
1003
+ ...status ? { status } : {}
1004
+ },
1005
+ nodes: toTreeNodes(e.name, await listArticleTree(e.name))
1006
+ };
1007
+ })
897
1008
  );
1009
+ return isStatic() ? trees.filter((t) => t.collection.audience !== "private" && t.collection.status !== "draft") : trees;
898
1010
  }
899
1011
  function findNode(nodes, slug) {
900
1012
  for (const n of nodes) {
@@ -922,6 +1034,8 @@ async function resolvePage(coll, slug, opts) {
922
1034
  slug: art.slug,
923
1035
  title: art.title,
924
1036
  tags: art.tags,
1037
+ ...art.audience === "private" ? { audience: art.audience } : {},
1038
+ ...art.status === "draft" ? { status: art.status } : {},
925
1039
  html,
926
1040
  toc,
927
1041
  path: `/${art.coll}/${art.slug}`,
@@ -1106,6 +1220,7 @@ async function query(term, opts) {
1106
1220
  // src/search.ts
1107
1221
  import fs7 from "fs/promises";
1108
1222
  import path7 from "path";
1223
+ var isStatic2 = () => process.env.DIFFWIKI_STATIC === "1";
1109
1224
  async function readArticleParts(collDir, slug) {
1110
1225
  for (const ext of [".mdx", ".md"]) {
1111
1226
  try {
@@ -1118,6 +1233,7 @@ async function readArticleParts(collDir, slug) {
1118
1233
  }
1119
1234
  async function flatten(coll, collDir, nodes, out) {
1120
1235
  for (const n of nodes) {
1236
+ if (isStatic2() && (n.audience === "private" || n.status === "draft")) continue;
1121
1237
  if (n.slug && !n.children) {
1122
1238
  const title = n.title ?? n.name;
1123
1239
  const { tags, body } = await readArticleParts(collDir, n.slug);
@@ -1144,6 +1260,10 @@ async function buildSearchIndex(collection) {
1144
1260
  try {
1145
1261
  const collEntry = await findCollection(e.name);
1146
1262
  const collDir = path7.resolve(collEntry?.path ?? e.path);
1263
+ if (isStatic2()) {
1264
+ const cm = await collectionMeta(collDir);
1265
+ if (cm.audience === "private" || cm.status === "draft") continue;
1266
+ }
1147
1267
  await flatten(e.name, collDir, await listArticleTree(e.name), out);
1148
1268
  } catch {
1149
1269
  }
@@ -1514,6 +1634,7 @@ export {
1514
1634
  buildSearchIndex,
1515
1635
  collectionDir,
1516
1636
  collectionGitStatus,
1637
+ collectionMeta,
1517
1638
  collectionsDir,
1518
1639
  configPath,
1519
1640
  createArticle,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffwiki-core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Core configuration, registry, and article management for diffwiki",
5
5
  "license": "MIT",
6
6
  "type": "module",