sessionwiki 2.0.0__py3-none-any.whl

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.
Files changed (118) hide show
  1. sessionwiki/__init__.py +74 -0
  2. sessionwiki/__main__.py +6 -0
  3. sessionwiki/_frontmatter.py +118 -0
  4. sessionwiki/_system_pages.py +44 -0
  5. sessionwiki/adapter_config.py +124 -0
  6. sessionwiki/adapters/__init__.py +134 -0
  7. sessionwiki/adapters/base.py +191 -0
  8. sessionwiki/adapters/claude_code.py +77 -0
  9. sessionwiki/adapters/codex_cli.py +226 -0
  10. sessionwiki/adapters/contrib/__init__.py +1 -0
  11. sessionwiki/adapters/contrib/chatgpt.py +252 -0
  12. sessionwiki/adapters/contrib/copilot_chat.py +121 -0
  13. sessionwiki/adapters/contrib/copilot_cli.py +115 -0
  14. sessionwiki/adapters/contrib/cursor.py +100 -0
  15. sessionwiki/adapters/contrib/gemini_cli.py +74 -0
  16. sessionwiki/adapters/contrib/obsidian.py +121 -0
  17. sessionwiki/adapters/opencode.py +169 -0
  18. sessionwiki/adapters/status.py +58 -0
  19. sessionwiki/adapters/zcode.py +45 -0
  20. sessionwiki/audit.py +174 -0
  21. sessionwiki/backlinks.py +224 -0
  22. sessionwiki/build.py +2786 -0
  23. sessionwiki/cache.py +401 -0
  24. sessionwiki/candidates.py +309 -0
  25. sessionwiki/categories.py +158 -0
  26. sessionwiki/changelog_timeline.py +422 -0
  27. sessionwiki/cli.py +1687 -0
  28. sessionwiki/compare.py +404 -0
  29. sessionwiki/completion.py +191 -0
  30. sessionwiki/confidence.py +179 -0
  31. sessionwiki/config.py +74 -0
  32. sessionwiki/config_schedule.py +77 -0
  33. sessionwiki/context_md.py +182 -0
  34. sessionwiki/convert.py +1746 -0
  35. sessionwiki/docs_pages.py +725 -0
  36. sessionwiki/doctor.py +113 -0
  37. sessionwiki/exporters.py +680 -0
  38. sessionwiki/freshness.py +153 -0
  39. sessionwiki/graph.py +1058 -0
  40. sessionwiki/graphify_bridge.py +509 -0
  41. sessionwiki/ingest_queue.py +101 -0
  42. sessionwiki/lifecycle.py +167 -0
  43. sessionwiki/link_checker.py +148 -0
  44. sessionwiki/lint/__init__.py +170 -0
  45. sessionwiki/lint/rules/__init__.py +69 -0
  46. sessionwiki/lint/rules/_helpers.py +119 -0
  47. sessionwiki/lint/rules/claim_verification.py +44 -0
  48. sessionwiki/lint/rules/content_freshness.py +41 -0
  49. sessionwiki/lint/rules/contradiction_detection.py +45 -0
  50. sessionwiki/lint/rules/duplicate_detection.py +164 -0
  51. sessionwiki/lint/rules/entity_consistency.py +29 -0
  52. sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
  53. sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
  54. sessionwiki/lint/rules/frontmatter_validity.py +71 -0
  55. sessionwiki/lint/rules/index_sync.py +68 -0
  56. sessionwiki/lint/rules/link_integrity.py +34 -0
  57. sessionwiki/lint/rules/orphan_detection.py +43 -0
  58. sessionwiki/lint/rules/stale_candidates.py +59 -0
  59. sessionwiki/lint/rules/stale_reference_detection.py +42 -0
  60. sessionwiki/lint/rules/summary_accuracy.py +36 -0
  61. sessionwiki/lint/rules/tags_topics_convention.py +58 -0
  62. sessionwiki/lint/rules/tools_consistency.py +65 -0
  63. sessionwiki/lock.py +126 -0
  64. sessionwiki/log_reader.py +129 -0
  65. sessionwiki/manifest.py +132 -0
  66. sessionwiki/mcp/__init__.py +29 -0
  67. sessionwiki/mcp/__main__.py +5 -0
  68. sessionwiki/mcp/server.py +1061 -0
  69. sessionwiki/models_page.py +291 -0
  70. sessionwiki/obsidian_output.py +208 -0
  71. sessionwiki/opencode/__init__.py +1 -0
  72. sessionwiki/opencode/autosync.py +102 -0
  73. sessionwiki/opencode/export.py +419 -0
  74. sessionwiki/opencode/install.py +152 -0
  75. sessionwiki/pipeline.py +150 -0
  76. sessionwiki/project_topics.py +213 -0
  77. sessionwiki/py.typed +0 -0
  78. sessionwiki/quarantine.py +273 -0
  79. sessionwiki/queue.py +29 -0
  80. sessionwiki/references.py +224 -0
  81. sessionwiki/render/__init__.py +20 -0
  82. sessionwiki/render/css.py +988 -0
  83. sessionwiki/render/docs_css.py +463 -0
  84. sessionwiki/render/js.py +1337 -0
  85. sessionwiki/schema.py +270 -0
  86. sessionwiki/search_facets.py +150 -0
  87. sessionwiki/search_tree.py +184 -0
  88. sessionwiki/serve.py +91 -0
  89. sessionwiki/session_export.py +333 -0
  90. sessionwiki/skill_installer.py +112 -0
  91. sessionwiki/sync/__init__.py +13 -0
  92. sessionwiki/sync/status.py +120 -0
  93. sessionwiki/synth/__init__.py +14 -0
  94. sessionwiki/synth/agent_delegate.py +327 -0
  95. sessionwiki/synth/base.py +125 -0
  96. sessionwiki/synth/cli_helpers.py +98 -0
  97. sessionwiki/synth/estimate.py +146 -0
  98. sessionwiki/synth/ollama.py +379 -0
  99. sessionwiki/synth/openai_compat.py +325 -0
  100. sessionwiki/synth/pipeline.py +1489 -0
  101. sessionwiki/tag_utils.py +62 -0
  102. sessionwiki/tags.py +359 -0
  103. sessionwiki/vault.py +347 -0
  104. sessionwiki/viz_heatmap.py +305 -0
  105. sessionwiki/viz_tokens.py +429 -0
  106. sessionwiki/viz_tools.py +241 -0
  107. sessionwiki/watch.py +154 -0
  108. sessionwiki/zcode/__init__.py +43 -0
  109. sessionwiki/zcode/autosync.py +150 -0
  110. sessionwiki/zcode/export.py +568 -0
  111. sessionwiki/zcode/install.py +386 -0
  112. sessionwiki-2.0.0.dist-info/METADATA +303 -0
  113. sessionwiki-2.0.0.dist-info/RECORD +118 -0
  114. sessionwiki-2.0.0.dist-info/WHEEL +5 -0
  115. sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
  116. sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
  117. sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
  118. sessionwiki-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,309 @@
1
+ """Candidate approval workflow (v1.1.0 · #51).
2
+
3
+ New entity/concept pages created by `/wiki-ingest` land in
4
+ ``wiki/candidates/`` first with ``status: candidate`` frontmatter.
5
+ A human then runs `/wiki-candidates` to promote, merge, or discard each
6
+ one. Promoted pages move into ``wiki/entities/`` or ``wiki/concepts/``.
7
+ Discarded candidates are archived under ``wiki/archive/candidates/``
8
+ for audit.
9
+
10
+ Rationale: hallucinated entities ("CompanyX" that doesn't exist) should
11
+ not land in the trusted wiki layer without human review.
12
+
13
+ Public API:
14
+ - ``list_candidates(wiki_dir)`` → list of Candidate dicts
15
+ - ``promote(slug, wiki_dir, dest)`` → move candidate into trusted area
16
+ - ``merge(slug, wiki_dir, into_slug)`` → fold candidate into an existing page
17
+ - ``discard(slug, wiki_dir, reason)`` → move to archive/
18
+ - ``stale_candidates(wiki_dir, threshold_days=30)`` → list pages flagged stale
19
+ - ``is_candidate(page_path)`` → bool
20
+
21
+ Design choices:
22
+ - Separate ``candidates/`` mirror tree (vs status field only) so the
23
+ build step can cleanly exclude them from the public site by default.
24
+ - ``## Connections`` links from candidates stay as-is when promoted;
25
+ callers run `sessionwiki lint` afterward to catch any stale pointers.
26
+ - Discard is non-destructive: pages move to ``wiki/archive/candidates/``
27
+ with a timestamped reason file so you can recover them later.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ import shutil
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+ from typing import Any, Optional, TypedDict
37
+
38
+ # ─── constants ─────────────────────────────────────────────────────────
39
+
40
+ CANDIDATES_DIR_NAME = "candidates"
41
+ ARCHIVE_DIR_NAME = "archive"
42
+ ARCHIVED_CANDIDATES_SUBDIR = "candidates"
43
+
44
+ # Subfolders mirrored under wiki/candidates/
45
+ MIRRORED_SUBDIRS = ["entities", "concepts", "sources", "syntheses"]
46
+
47
+ # Default staleness threshold (days)
48
+ DEFAULT_STALE_DAYS = 30
49
+
50
+ FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL)
51
+
52
+
53
+ # ─── types ─────────────────────────────────────────────────────────────
54
+
55
+ class Candidate(TypedDict):
56
+ """Info about one candidate page waiting for review."""
57
+
58
+ slug: str # bare filename stem (e.g. "NewEntity")
59
+ rel_path: str # path relative to wiki/ (e.g. "candidates/entities/NewEntity.md")
60
+ abs_path: Path # absolute path to the file
61
+ kind: str # "entities" | "concepts" | "sources" | "syntheses"
62
+ title: str # frontmatter title
63
+ created: Optional[str] # frontmatter created/last_updated date (YYYY-MM-DD)
64
+ age_days: int # days since `created`
65
+ body_preview: str # first 200 chars of body
66
+
67
+
68
+ # ─── helpers ───────────────────────────────────────────────────────────
69
+
70
+ def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
71
+ """Return (meta_dict, body)."""
72
+ m = FRONTMATTER_RE.match(text)
73
+ if not m:
74
+ return {}, text
75
+ out: dict[str, str] = {}
76
+ for line in m.group(1).splitlines():
77
+ if ":" not in line:
78
+ continue
79
+ k, _, v = line.partition(":")
80
+ out[k.strip()] = v.strip().strip('"')
81
+ return out, m.group(2)
82
+
83
+
84
+ def _age_days(date_str: Optional[str], *, now: Optional[datetime] = None) -> int:
85
+ """Compute days between ``date_str`` (YYYY-MM-DD) and now."""
86
+ if not date_str:
87
+ return 0
88
+ try:
89
+ dt = datetime.fromisoformat(date_str)
90
+ if dt.tzinfo is None:
91
+ dt = dt.replace(tzinfo=timezone.utc)
92
+ except (ValueError, TypeError):
93
+ return 0
94
+ ref = now or datetime.now(timezone.utc)
95
+ return max(0, (ref - dt).days)
96
+
97
+
98
+ def is_candidate(page_path: Path) -> bool:
99
+ """True if the path is inside wiki/candidates/ subtree."""
100
+ parts = page_path.parts
101
+ return CANDIDATES_DIR_NAME in parts
102
+
103
+
104
+ def candidates_dir(wiki_dir: Path) -> Path:
105
+ """Return wiki/candidates/ (creates parent if needed)."""
106
+ return wiki_dir / CANDIDATES_DIR_NAME
107
+
108
+
109
+ def archive_dir(wiki_dir: Path) -> Path:
110
+ """Return wiki/archive/candidates/."""
111
+ return wiki_dir / ARCHIVE_DIR_NAME / ARCHIVED_CANDIDATES_SUBDIR
112
+
113
+
114
+ # ─── public API ────────────────────────────────────────────────────────
115
+
116
+
117
+ def list_candidates(
118
+ wiki_dir: Path,
119
+ *,
120
+ now: Optional[datetime] = None,
121
+ ) -> list[Candidate]:
122
+ """Walk wiki/candidates/ and return one entry per pending page."""
123
+ root = candidates_dir(wiki_dir)
124
+ if not root.is_dir():
125
+ return []
126
+
127
+ out: list[Candidate] = []
128
+ for sub in MIRRORED_SUBDIRS:
129
+ sub_dir = root / sub
130
+ if not sub_dir.is_dir():
131
+ continue
132
+ for path in sorted(sub_dir.glob("*.md")):
133
+ if path.name == "_context.md":
134
+ continue
135
+ try:
136
+ text = path.read_text(encoding="utf-8")
137
+ except OSError:
138
+ continue
139
+ meta, body = _parse_frontmatter(text)
140
+ created = meta.get("last_updated") or meta.get("date")
141
+ out.append({
142
+ "slug": path.stem,
143
+ "rel_path": str(path.relative_to(wiki_dir)),
144
+ "abs_path": path,
145
+ "kind": sub,
146
+ "title": meta.get("title", path.stem),
147
+ "created": created,
148
+ "age_days": _age_days(created, now=now),
149
+ "body_preview": body.strip()[:200],
150
+ })
151
+ return out
152
+
153
+
154
+ def promote(
155
+ slug: str,
156
+ wiki_dir: Path,
157
+ *,
158
+ kind: Optional[str] = None,
159
+ ) -> Path:
160
+ """Move ``wiki/candidates/<kind>/<slug>.md`` → ``wiki/<kind>/<slug>.md``.
161
+
162
+ If ``kind`` is omitted, infers from where the candidate lives. Rewrites
163
+ the frontmatter ``status:`` from ``candidate`` → ``reviewed`` so the
164
+ lifecycle rule picks it up.
165
+
166
+ Returns the new (promoted) path. Raises FileNotFoundError if the
167
+ candidate does not exist.
168
+ """
169
+ candidate = _find_candidate(slug, wiki_dir, kind)
170
+ inferred_kind = candidate.parent.name
171
+ target_dir = wiki_dir / inferred_kind
172
+ target_dir.mkdir(parents=True, exist_ok=True)
173
+ target = target_dir / candidate.name
174
+
175
+ text = candidate.read_text(encoding="utf-8")
176
+ text = _rewrite_status(text, old="candidate", new="reviewed")
177
+ target.write_text(text, encoding="utf-8")
178
+ candidate.unlink()
179
+ return target
180
+
181
+
182
+ def merge(
183
+ slug: str,
184
+ wiki_dir: Path,
185
+ *,
186
+ into_slug: str,
187
+ kind: Optional[str] = None,
188
+ ) -> Path:
189
+ """Append the candidate's body under a ``## Candidate merge — <date>``
190
+ heading into the existing wiki page ``<into_slug>.md``, then discard
191
+ the candidate.
192
+
193
+ Returns the path of the target page. Raises FileNotFoundError if either
194
+ page is missing.
195
+ """
196
+ candidate = _find_candidate(slug, wiki_dir, kind)
197
+ inferred_kind = candidate.parent.name
198
+ target = wiki_dir / inferred_kind / f"{into_slug}.md"
199
+ if not target.is_file():
200
+ raise FileNotFoundError(
201
+ f"merge target not found: {target} (candidate={candidate})"
202
+ )
203
+
204
+ candidate_text = candidate.read_text(encoding="utf-8")
205
+ _, candidate_body = _parse_frontmatter(candidate_text)
206
+
207
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
208
+ appended = (
209
+ target.read_text(encoding="utf-8").rstrip() +
210
+ f"\n\n## Candidate merge — {today}\n\n" +
211
+ f"Merged from `{candidate.relative_to(wiki_dir)}`:\n\n" +
212
+ candidate_body.strip() + "\n"
213
+ )
214
+ target.write_text(appended, encoding="utf-8")
215
+
216
+ # Discard candidate by moving it to archive with a merge-reason file
217
+ _archive_candidate(candidate, wiki_dir, reason=f"merged into {into_slug}")
218
+ return target
219
+
220
+
221
+ def discard(
222
+ slug: str,
223
+ wiki_dir: Path,
224
+ *,
225
+ reason: str = "",
226
+ kind: Optional[str] = None,
227
+ ) -> Path:
228
+ """Move the candidate to ``wiki/archive/candidates/<timestamp>/<slug>.md``
229
+ with an adjacent ``<slug>.reason.txt`` capturing why.
230
+
231
+ Returns the archived path.
232
+ """
233
+ candidate = _find_candidate(slug, wiki_dir, kind)
234
+ return _archive_candidate(candidate, wiki_dir, reason=reason)
235
+
236
+
237
+ def stale_candidates(
238
+ wiki_dir: Path,
239
+ *,
240
+ threshold_days: int = DEFAULT_STALE_DAYS,
241
+ now: Optional[datetime] = None,
242
+ ) -> list[Candidate]:
243
+ """Return candidates older than ``threshold_days``."""
244
+ return [
245
+ c for c in list_candidates(wiki_dir, now=now)
246
+ if c["age_days"] >= threshold_days
247
+ ]
248
+
249
+
250
+ # ─── internals ─────────────────────────────────────────────────────────
251
+
252
+
253
+ def _find_candidate(
254
+ slug: str,
255
+ wiki_dir: Path,
256
+ kind: Optional[str],
257
+ ) -> Path:
258
+ """Locate ``<slug>.md`` under wiki/candidates/, optionally filtered by kind."""
259
+ root = candidates_dir(wiki_dir)
260
+ subs = [kind] if kind else MIRRORED_SUBDIRS
261
+ for sub in subs:
262
+ path = root / sub / f"{slug}.md"
263
+ if path.is_file():
264
+ return path
265
+ raise FileNotFoundError(
266
+ f"candidate not found: {slug!r} under {root}"
267
+ + (f" (kind={kind})" if kind else "")
268
+ )
269
+
270
+
271
+ def _rewrite_status(text: str, *, old: str, new: str) -> str:
272
+ """Replace ``status: <old>`` with ``status: <new>`` in frontmatter."""
273
+ pattern = re.compile(
274
+ rf"^(status:\s*){re.escape(old)}(\s*)$",
275
+ re.MULTILINE,
276
+ )
277
+ if pattern.search(text):
278
+ return pattern.sub(rf"\g<1>{new}\g<2>", text)
279
+ # Add status line to frontmatter if missing
280
+ m = FRONTMATTER_RE.match(text)
281
+ if m:
282
+ new_fm = m.group(1) + f"\nstatus: {new}"
283
+ return f"---\n{new_fm}\n---\n{m.group(2)}"
284
+ return text
285
+
286
+
287
+ def _archive_candidate(
288
+ candidate: Path,
289
+ wiki_dir: Path,
290
+ *,
291
+ reason: str,
292
+ ) -> Path:
293
+ """Move candidate into archive with reason file."""
294
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
295
+ dest_dir = archive_dir(wiki_dir) / stamp
296
+ dest_dir.mkdir(parents=True, exist_ok=True)
297
+
298
+ dest = dest_dir / candidate.name
299
+ shutil.move(str(candidate), str(dest))
300
+
301
+ if reason:
302
+ reason_file = dest.with_suffix(".reason.txt")
303
+ reason_file.write_text(
304
+ f"Discarded at: {datetime.now(timezone.utc).isoformat()}\n"
305
+ f"Reason: {reason}\n"
306
+ f"Original path: candidates/{candidate.parent.name}/{candidate.name}\n",
307
+ encoding="utf-8",
308
+ )
309
+ return dest
@@ -0,0 +1,158 @@
1
+ """Category page generator (v1.0 · #154).
2
+
3
+ Generates per-tag index pages (`wiki/categories/<tag>.md`) from the
4
+ `tags:` field in wiki page frontmatter.
5
+
6
+ Two output modes:
7
+
8
+ - **Dataview** (``generate_dataview_categories``) — pages contain a
9
+ live Dataview query that Obsidian renders dynamically.
10
+ - **Static** (``generate_static_categories``) — pages contain a
11
+ pre-rendered markdown list for use in the static HTML site.
12
+
13
+ Both modes work from the same page scan; the user picks which mode
14
+ fits their workflow (Obsidian users → Dataview; GitHub Pages users →
15
+ static).
16
+
17
+ Usage::
18
+
19
+ from sessionwiki.categories import scan_tags, generate_static_categories
20
+ pages = load_pages()
21
+ counts = scan_tags(pages)
22
+ generate_static_categories(pages, out_dir, min_count=2)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from pathlib import Path
29
+ from typing import Any, Optional
30
+
31
+ # Shared tag parser + NOISE_TAGS live in sessionwiki.tag_utils so
32
+ # sessionwiki/search_facets.py uses the same implementation.
33
+ from sessionwiki.tag_utils import NOISE_TAGS, parse_tags_field as _parse_tags, scan_tags
34
+
35
+
36
+ # ─── Dataview output ──────────────────────────────────────────────────
37
+
38
+ def dataview_page(tag: str) -> str:
39
+ """Render the Dataview query page body for a tag."""
40
+ return f"""---
41
+ title: "Category: {tag}"
42
+ type: navigation
43
+ tag: {tag}
44
+ ---
45
+
46
+ # Category: {tag}
47
+
48
+ Pages tagged with `{tag}`.
49
+
50
+ ```dataview
51
+ TABLE type, confidence, last_updated
52
+ FROM "sources" OR "entities" OR "concepts" OR "syntheses"
53
+ WHERE contains(tags, "{tag}")
54
+ SORT last_updated DESC
55
+ ```
56
+
57
+ ## Connections
58
+
59
+ - [[index]] — full catalog
60
+ - [[dashboard]] — live overview
61
+ """
62
+
63
+
64
+ # ─── Static output ────────────────────────────────────────────────────
65
+
66
+ def _page_slug(rel: str) -> str:
67
+ return rel.rsplit("/", 1)[-1].removesuffix(".md")
68
+
69
+
70
+ def _page_title(pages: dict[str, dict[str, Any]], rel: str) -> str:
71
+ return pages[rel]["meta"].get("title", _page_slug(rel))
72
+
73
+
74
+ def static_page(
75
+ tag: str,
76
+ page_rels: list[str],
77
+ pages: dict[str, dict[str, Any]],
78
+ ) -> str:
79
+ """Render a static category page body for GitHub Pages."""
80
+ lines = [
81
+ "---",
82
+ f'title: "Category: {tag}"',
83
+ "type: navigation",
84
+ f"tag: {tag}",
85
+ "---",
86
+ "",
87
+ f"# Category: {tag}",
88
+ "",
89
+ f"{len(page_rels)} pages tagged with `{tag}`.",
90
+ "",
91
+ ]
92
+ # Group by top-level folder
93
+ by_folder: dict[str, list[str]] = {}
94
+ for rel in page_rels:
95
+ folder = rel.split("/", 1)[0] if "/" in rel else "root"
96
+ by_folder.setdefault(folder, []).append(rel)
97
+
98
+ for folder in sorted(by_folder):
99
+ lines.append(f"## {folder}")
100
+ lines.append("")
101
+ for rel in by_folder[folder]:
102
+ slug = _page_slug(rel)
103
+ title = _page_title(pages, rel)
104
+ lines.append(f"- [[{slug}|{title}]]")
105
+ lines.append("")
106
+
107
+ lines.extend([
108
+ "## Connections",
109
+ "",
110
+ "- [[index]] — full catalog",
111
+ "- [[dashboard]] — live overview",
112
+ "",
113
+ ])
114
+ return "\n".join(lines)
115
+
116
+
117
+ # ─── Generators ──────────────────────────────────────────────────────
118
+
119
+ def generate_dataview_categories(
120
+ tags: dict[str, list[str]],
121
+ out_dir: Path,
122
+ *,
123
+ min_count: int = 2,
124
+ ) -> list[Path]:
125
+ """Write Dataview category pages for every tag with >= ``min_count`` pages.
126
+
127
+ Returns the list of written paths.
128
+ """
129
+ out_dir.mkdir(parents=True, exist_ok=True)
130
+ written: list[Path] = []
131
+ for tag, page_rels in sorted(tags.items()):
132
+ if len(page_rels) < min_count:
133
+ continue
134
+ slug = re.sub(r"[^a-z0-9_-]+", "-", tag)
135
+ path = out_dir / f"{slug}.md"
136
+ path.write_text(dataview_page(tag), encoding="utf-8")
137
+ written.append(path)
138
+ return written
139
+
140
+
141
+ def generate_static_categories(
142
+ pages: dict[str, dict[str, Any]],
143
+ out_dir: Path,
144
+ *,
145
+ min_count: int = 2,
146
+ ) -> list[Path]:
147
+ """Write static category pages (no Dataview) for every tag."""
148
+ tags = scan_tags(pages)
149
+ out_dir.mkdir(parents=True, exist_ok=True)
150
+ written: list[Path] = []
151
+ for tag, page_rels in sorted(tags.items()):
152
+ if len(page_rels) < min_count:
153
+ continue
154
+ slug = re.sub(r"[^a-z0-9_-]+", "-", tag)
155
+ path = out_dir / f"{slug}.md"
156
+ path.write_text(static_page(tag, page_rels, pages), encoding="utf-8")
157
+ written.append(path)
158
+ return written