secondbrain-py 0.2.1__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 (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,546 @@
1
+ // Sample Quartz v4 content-index emitter for brain vaults.
2
+ //
3
+ // This file is a TEMPLATE. It is installed at
4
+ // `<vault>/.quartz/quartz/plugins/emitters/contentIndex.ts` by
5
+ // `brain vault render --overlay`. As part of the same overlay step,
6
+ // the stock Quartz emitter shipped at
7
+ // `<vault>/.quartz/quartz/plugins/emitters/contentIndex.tsx` is
8
+ // renamed to `_upstreamContentIndex.tsx` (idempotent — a no-op on
9
+ // repeat runs). That rename is what lets the `./_upstreamContentIndex`
10
+ // import below resolve; without it the wrapper has nothing to wrap.
11
+ // If you are copying this file into a Quartz workspace by hand
12
+ // instead of going through `brain vault render`, do the rename
13
+ // first.
14
+ //
15
+ // It does NOT compile or run from the brain repo itself; the imports
16
+ // below resolve against the dependencies Quartz pulls into the
17
+ // cloned workspace via `npm install`, not against any package brain
18
+ // ships.
19
+ //
20
+ // Tested against Quartz v4.5.x (April 2026). If a future Quartz
21
+ // version restructures the `ContentIndex` plugin, the on-disk JSON
22
+ // artifact path, or the `ContentDetails` shape, pull the latest
23
+ // emitter from
24
+ // https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/emitters/contentIndex.tsx
25
+ // and re-apply the brain tweaks flagged below — `// brain:` for
26
+ // value/structural choices on upstream-supported fields, and
27
+ // `// brain-extension:` for keys/types that don't exist in stock
28
+ // Quartz.
29
+ //
30
+ // What this wrapper does, and why: stock Quartz emits a
31
+ // `static/contentIndex.json` artifact keyed by slug, where each
32
+ // entry carries `{slug, filePath, title, links, tags, content}`. The
33
+ // brain graph renderer needs two extra signals — a `tier` and
34
+ // `source` facet from the doc's frontmatter (used for color
35
+ // clustering), and a structured per-link record carrying
36
+ // `kind: "wiki" | "derived"` plus optional `rule` / `weight`
37
+ // metadata for derived edges (used for dashed-edge styling and
38
+ // hover tooltips). Rather than duplicate upstream's emit body, this
39
+ // file calls `UpstreamContentIndex(opts)`, lets it write the JSON
40
+ // to disk, then reads the artifact back and grafts the
41
+ // brain-extension fields onto each entry before rewriting it. Tiny
42
+ // diff, survives upstream churn around sitemap/RSS handling.
43
+
44
+ import * as fs from "node:fs/promises"
45
+ import * as path from "node:path"
46
+
47
+ import type { Root, Element } from "hast"
48
+ import type { VFile } from "vfile"
49
+
50
+ import { ContentIndex as UpstreamContentIndex } from "./_upstreamContentIndex"
51
+ import { SimpleSlug } from "../../util/path"
52
+ import { QuartzEmitterPlugin } from "../types"
53
+
54
+ // brain: defensive guard for the case where this file was copied by
55
+ // hand without the matching upstream rename. With the overlay
56
+ // applied correctly, `UpstreamContentIndex` is a function. A bare
57
+ // undefined here means the rename step was skipped — surface that
58
+ // as a clear error at module load instead of letting the build die
59
+ // further down the call stack.
60
+ if (typeof UpstreamContentIndex !== "function") {
61
+ throw new Error(
62
+ "brain contentIndex wrapper expects upstream emitter at `./_upstreamContentIndex` (the overlay-renamed stock `contentIndex.tsx`); was the overlay rename step applied?",
63
+ )
64
+ }
65
+
66
+ // brain-extension: structured per-link record. Stock Quartz emits a
67
+ // flat `SimpleSlug[]` for each doc; the custom graph renderer needs
68
+ // to know whether a link is an authored wiki-link or a Phase D
69
+ // derived-edge so it can apply the dashed/translucent styling and
70
+ // surface `rule` / `weight` in the hover tooltip.
71
+ export interface BrainLinkRecord {
72
+ target: SimpleSlug
73
+ kind: "wiki" | "derived"
74
+ // Populated only for `kind: "derived"`. Mirrors the
75
+ // `*(rule:... weight=...)*` suffix Phase D writes inside the
76
+ // BRAIN_DERIVED fence in `_ingested/` bodies; the
77
+ // `derivedFenceMark` transformer surfaces these as AST attributes
78
+ // that `classifyLink` reads.
79
+ rule?: string
80
+ weight?: number
81
+ }
82
+
83
+ // brain-extension: shape of the augmented JSON entries written to
84
+ // `static/contentIndex.json`. Upstream Quartz fields pass through
85
+ // the index signature unchanged so this template tolerates minor
86
+ // upstream additions without code changes; the brain delta is the
87
+ // three explicit optionals.
88
+ export type BrainContentDetails = Record<string, unknown> & {
89
+ tier?: string
90
+ source?: string
91
+ // brain-extension: ISO date string lifted from frontmatter
92
+ // (`date` > `created` > `published` > `updated`). Consumed by
93
+ // `Search.tsx` (P3.2) for the date column and `TagContent.tsx`
94
+ // (P3.3) for the per-row date stamp. Optional — missing-frontmatter
95
+ // docs leave the field unset and consumers render an empty column.
96
+ date?: string
97
+ linkRecords?: BrainLinkRecord[]
98
+ // brain-extension: 240-char snippet of the body, populated by the
99
+ // P3.1 slim transform. Search.tsx prefers this over `content` when
100
+ // building the result-row preview; TagContent.tsx renders its own
101
+ // tag-aware snippet from `description`, so this field is informative
102
+ // for it (not authoritative).
103
+ snippet?: string
104
+ }
105
+
106
+ // brain-extension: classification context handed to `classifyLink`.
107
+ // The current implementation ignores it (every link is "wiki"); the
108
+ // next iteration of this helper inspects `tree` / `file.data` to
109
+ // detect derived-fenced links.
110
+ export interface ClassifyContext {
111
+ tree: Root
112
+ file: VFile
113
+ }
114
+
115
+ // brain-extension: the single localized point where a link's
116
+ // classification is computed. The `derivedFenceMark` transformer
117
+ // (see `quartz_overrides/plugins/transformers/derivedFenceMark.ts`)
118
+ // stamps `data-brain-derived="true"` / `data-brain-rule` attributes
119
+ // onto `<a>` tags inside Phase D fences; this helper walks the
120
+ // post-rendered hast tree to find each link by its `href` (the
121
+ // slug-normalized target) and reads the attributes back. Keeping
122
+ // the rule in one function (and only one function) makes future
123
+ // format tweaks (e.g. a third "rule" kind, or adding `weight` once
124
+ // Phase E surfaces it) a localized change rather than a sweep
125
+ // through the wrapper.
126
+ //
127
+ // brain: fence membership is the truth-source for "this link is
128
+ // derived" — links whose `data-brain-rule` is absent (because the
129
+ // transformer's strict parser couldn't pin a rule down) still come
130
+ // back as `kind: "derived"`, just without the `rule` field. The
131
+ // `BrainLinkRecord` type's optional `weight` field is reserved for
132
+ // a future Phase E enhancement; nothing in the current pipeline
133
+ // produces it, so this classifier doesn't read it. When Phase E
134
+ // lands, extend the transformer and this classifier together.
135
+ //
136
+ // brain: lookup is "first <a> whose href matches the target slug".
137
+ // A doc that links the same partner twice (e.g. once authored,
138
+ // once via the Phase D fence) will resolve both records to the
139
+ // first match — but for the brain graph, the union shape is the
140
+ // same (one edge per (src, dst) pair), so the duplicate's
141
+ // classification doesn't actually change graph behavior. If a
142
+ // future renderer needs per-occurrence classification, switch to
143
+ // the index-based pairing approach (parallel walk of `details.links`
144
+ // and the hast tree's `<a>` elements in order).
145
+ export function classifyLink(target: SimpleSlug, ctx: ClassifyContext): BrainLinkRecord {
146
+ const anchor = findAnchor(ctx.tree, target)
147
+ if (anchor === null) {
148
+ return { target, kind: "wiki" }
149
+ }
150
+ const props = (anchor.properties ?? {}) as Record<string, unknown>
151
+ // brain-extension: hast property names are camelCased by hast-util-
152
+ // from-html (``data-brain-derived`` → ``dataBrainDerived``); we
153
+ // read both shapes pending empirical verification of which form
154
+ // Quartz's rehype pipeline actually emits in practice. The
155
+ // overlay's parse-smoke test confirms the live shape against a
156
+ // freshly-rendered fixture vault; the unused branch can be
157
+ // dropped at that point. Until then the dual-read is correct
158
+ // documentation, not redundancy.
159
+ if (props["dataBrainDerived"] !== "true" && props["data-brain-derived"] !== "true") {
160
+ return { target, kind: "wiki" }
161
+ }
162
+ const record: BrainLinkRecord = { target, kind: "derived" }
163
+ const rule = props["dataBrainRule"] ?? props["data-brain-rule"]
164
+ if (typeof rule === "string" && rule.length > 0) {
165
+ record.rule = rule
166
+ }
167
+ return record
168
+ }
169
+
170
+ // brain: depth-first hast walker that returns the first `<a>` whose
171
+ // `href` matches the slug-normalized target. The brain emitter feeds
172
+ // us a `SimpleSlug` (Quartz's resolved-and-normalized internal slug
173
+ // type) that already corresponds to the value remark-rehype writes
174
+ // into the rendered link's `href`. Recursion stops at the first
175
+ // match — see the duplicate-link note on `classifyLink` above.
176
+ function findAnchor(tree: Root, target: SimpleSlug): Element | null {
177
+ const wanted = String(target)
178
+ const stack: (Root | Element)[] = [tree]
179
+ while (stack.length > 0) {
180
+ const node = stack.pop()
181
+ if (node === undefined) break
182
+ if ("children" in node && Array.isArray(node.children)) {
183
+ for (const child of node.children) {
184
+ if (child.type === "element") {
185
+ if (child.tagName === "a") {
186
+ const href = (child.properties ?? {})["href"]
187
+ if (typeof href === "string" && hrefMatchesSlug(href, wanted)) {
188
+ return child
189
+ }
190
+ }
191
+ stack.push(child)
192
+ }
193
+ }
194
+ }
195
+ }
196
+ return null
197
+ }
198
+
199
+ // brain: tolerant href↔slug match. Quartz rewrites internal links
200
+ // to relative URLs that may include leading `./` or `../` segments
201
+ // and a trailing fragment (`#section`); the `SimpleSlug` we receive
202
+ // from the emitter is the bare slug. Strip both ends before
203
+ // comparing so matches survive the relative-resolver pass.
204
+ function hrefMatchesSlug(href: string, slug: string): boolean {
205
+ const fragmentless = href.split("#")[0]
206
+ // Quartz resolves wiki-links to relative paths like
207
+ // `../partner-stem` or `./partner-stem`; trim leading `./` or
208
+ // `../` segments before comparing the tail.
209
+ const trimmed = fragmentless.replace(/^(?:\.\.\/)+/, "").replace(/^\.\//, "")
210
+ if (trimmed === slug) return true
211
+ // Slug may not include the doc's own folder prefix; allow a
212
+ // suffix match so `folder/partner-stem` still matches a
213
+ // `partner-stem` slug. Anchored on `/` to avoid matching
214
+ // `other-partner-stem`.
215
+ return trimmed.endsWith(`/${slug}`)
216
+ }
217
+
218
+ // brain: thin compat helper for downstream Quartz code that only
219
+ // cares about the slug list. Stock consumers (sitemap, RSS, third-
220
+ // party plugins) read `doc.links` directly as `SimpleSlug[]` — that
221
+ // upstream shape is preserved on disk, and this helper exists for
222
+ // callers that prefer to start from the record array. Co-located
223
+ // with the extension types so future maintainers see the trade-off
224
+ // in one place.
225
+ export function linkSlugs(records: BrainLinkRecord[] | undefined): SimpleSlug[] {
226
+ return (records ?? []).map((r) => r.target)
227
+ }
228
+
229
+ // brain: relative path of the upstream contentIndex.json artifact
230
+ // inside Quartz's output directory. Pinned as a constant so a
231
+ // future upstream reshuffle (e.g. moving the file out of `static/`)
232
+ // is a single-line fix rather than a hunt through this file.
233
+ const CONTENT_INDEX_RELPATH = path.join("static", "contentIndex.json")
234
+
235
+ // brain-extension: relative directory where the slim post-processor
236
+ // writes one `<slug>.json` body file per surviving entry. The Search
237
+ // component (P3.2) lazy-fetches `static/contentBodies/<slug>.json`
238
+ // when a result is selected for the preview pane; the slim
239
+ // `contentIndex.json` only carries a 240-char snippet. Splitting body
240
+ // out cuts the index from ~19 MB → well under 2 MB gzipped, which is
241
+ // the budget enforced by `scripts/check_index_size.py`.
242
+ const CONTENT_BODIES_RELDIR = path.join("static", "contentBodies")
243
+
244
+ // brain-extension: snippet character budget. The slim
245
+ // `contentIndex.json` keeps a `snippet` (and rewrites `details.content`
246
+ // to the same 240-char prefix as a backwards-compat fallback) so the
247
+ // search popover can render result rows without round-tripping to the
248
+ // per-slug body file. 240 chars ≈ 2-3 lines of result preview, which
249
+ // matches the upstream Search component's render budget.
250
+ const SNIPPET_LENGTH = 240
251
+
252
+ // brain-extension (P3.6 fix-3): allowlist for slug values that are safe
253
+ // to interpolate into a filesystem path (`contentBodies/<slug>.json`)
254
+ // or a fetch URL. Slugs come from Quartz's trusted slugify, so practical
255
+ // exploitability is near-zero — but defense in depth: a future upstream
256
+ // change that loosened slug normalisation, or a malicious frontmatter
257
+ // `permalink` override that bypasses slugify, would otherwise leak path
258
+ // traversal into both the emitter's `fs.writeFile` and the inline
259
+ // script's `fetch`. The character allowlist alone permits `..` (both
260
+ // `.` and the path separator are individually safe), so `isSafeSlug`
261
+ // also rejects any `..` segment to close the path-traversal loophole.
262
+ // The same regex + helper are duplicated in
263
+ // `quartz_overrides/quartz/components/scripts/search.inline.ts` (rather
264
+ // than imported) because the inline script is bundled separately and
265
+ // can't take a runtime import from this server-side emitter.
266
+ //
267
+ // brain: allowlist composition — each char was justified against the
268
+ // real live-vault corpus, NOT picked from RFC 3986 wholesale:
269
+ // * a-zA-Z0-9 bulk of slug chars
270
+ // * `_` underscore prefix on `_ingested/`
271
+ // * `.` file extension separators (`README.md` style)
272
+ // * `-` the canonical word separator slugify emits
273
+ // * `/` path component separator
274
+ // * `,` e.g. `_ingested/gmail/Tue,-7-Apr-...` (subject dates)
275
+ // * `:` e.g. `_ingested/krisp/2026-05-02-krisp:au-auto`
276
+ // What's NOT in the list and why:
277
+ // * `<>"'` `&` HTML metachars — would let titles/snippets break out
278
+ // of the row markup
279
+ // * ` ` spaces — every URL-safe slug encodes them as `-`
280
+ // * `?#&=` query/fragment delimiters — would corrupt the
281
+ // `fetch(url)` shape
282
+ // * `\` Windows path separator + escape sequence on POSIX
283
+ // * `\x00..1F` control chars + null bytes — header smuggling
284
+ // * `;` shell metachar (defense in depth on path joins)
285
+ // Adding a new char here should require a documented live-vault slug
286
+ // shape that needs it AND a check it can't enable path traversal or
287
+ // HTML/URL injection downstream.
288
+ export const SAFE_SLUG_RE = /^[a-zA-Z0-9._/,:-]+$/
289
+
290
+ // brain (P3.6 fix-3): char-allowlist plus segment-shape rejection. A
291
+ // bare `..` segment in a slug would let the joined path
292
+ // (`<output>/static/contentBodies/../etc/passwd.json`) escape the
293
+ // `contentBodies/` directory, even though every character in
294
+ // `../etc/passwd` is individually in the allowlist. Empty segments
295
+ // (leading slash, double slash) are also rejected — Quartz's slugify
296
+ // never produces them, and a leading slash on the URL side would
297
+ // resolve against the site root rather than the static dir.
298
+ export function isSafeSlug(slug: string): boolean {
299
+ if (!SAFE_SLUG_RE.test(slug)) return false
300
+ const segments = slug.split("/")
301
+ for (const segment of segments) {
302
+ if (segment === "" || segment === "..") return false
303
+ }
304
+ return true
305
+ }
306
+
307
+ // brain-extension (P3.6 fix-1): normalise a frontmatter date value to an
308
+ // ISO `YYYY-MM-DD` string. gray-matter / js-yaml parses bare YAML dates
309
+ // (`date: 2026-04-12`) into JS `Date` objects, so the previous
310
+ // `typeof X === "string"` check silently missed them. We accept both
311
+ // shapes — strings pass through verbatim (so an ISO datetime keeps its
312
+ // time/zone suffix); `Date` instances render as the date-only `YYYY-MM-
313
+ // DD` slice (the time-of-day component is meaningless when the source
314
+ // value was a date-only YAML literal). Invalid Date instances and other
315
+ // non-{string, Date} values return `undefined` so the lookup chain can
316
+ // fall through to the next candidate field.
317
+ export function liftDate(v: unknown): string | undefined {
318
+ if (typeof v === "string" && v.length > 0) return v
319
+ if (v instanceof Date && !Number.isNaN(v.getTime())) {
320
+ return v.toISOString().slice(0, 10)
321
+ }
322
+ return undefined
323
+ }
324
+
325
+ type Opts = Parameters<typeof UpstreamContentIndex>[0]
326
+
327
+ export const ContentIndex: QuartzEmitterPlugin<Opts> = (opts) => {
328
+ const upstream = UpstreamContentIndex(opts)
329
+
330
+ // brain: drop `partialEmit` from the upstream pass-through. Quartz's
331
+ // watch-mode incremental emit, when wired up upstream, would call
332
+ // `partialEmit` directly and bypass our wrapped `emit` — meaning
333
+ // the brain post-processor never runs and the JSON loses its
334
+ // tier/source/linkRecords augmentation. Forcing watch mode through
335
+ // `emit` keeps the augmentation consistent across full-build and
336
+ // watch paths. The leading-underscore name signals "intentionally
337
+ // discarded".
338
+ const { partialEmit: _partialEmit, ...rest } = upstream
339
+
340
+ return {
341
+ ...rest,
342
+ // brain: keep upstream's registered name so Quartz's plugin
343
+ // lookup, watch handler, and any third-party code referring to
344
+ // "ContentIndex" still resolve to this wrapper after we spread
345
+ // upstream's other fields.
346
+ name: "ContentIndex",
347
+ async *emit(ctx, content) {
348
+ // Pass every artifact upstream emits — sitemap.xml, the RSS
349
+ // feed, and the contentIndex.json — through unaltered. The
350
+ // post-processor below rewrites the JSON in place once the
351
+ // upstream generator has finished.
352
+ for await (const fp of upstream.emit(ctx, content)) {
353
+ yield fp
354
+ }
355
+
356
+ // brain: post-processor. Read back the JSON upstream just
357
+ // wrote, graft brain-extension fields onto each entry, and
358
+ // rewrite. Operating on the on-disk artifact (rather than
359
+ // reimplementing upstream's emit body) keeps the wrapper tiny
360
+ // and immune to upstream churn around sitemap/RSS handling.
361
+ // The whole sequence is wrapped in try/catch so any I/O or
362
+ // parse failure surfaces as a brain-attributable error
363
+ // (`ENOENT`, `SyntaxError`, etc. by themselves give the user
364
+ // no clue the failure came from this wrapper).
365
+ const targetPath = path.join(ctx.argv.output, CONTENT_INDEX_RELPATH)
366
+ try {
367
+ const raw = await fs.readFile(targetPath, "utf-8")
368
+ const parsed = JSON.parse(raw) as Record<string, BrainContentDetails>
369
+
370
+ // Pair each JSON entry with its source `[Root, VFile]` so
371
+ // the augmentation has access to frontmatter (for
372
+ // tier/source) and the rendered AST (for derived-edge
373
+ // classification).
374
+ const sourceBySlug = new Map<string, [Root, VFile]>()
375
+ for (const [tree, file] of content) {
376
+ const slug = file.data?.slug as string | undefined
377
+ if (slug) {
378
+ sourceBySlug.set(slug, [tree, file])
379
+ }
380
+ }
381
+
382
+ for (const [slug, details] of Object.entries(parsed)) {
383
+ const source = sourceBySlug.get(slug)
384
+ const fm = (source?.[1].data?.frontmatter ?? {}) as Record<string, unknown>
385
+
386
+ // brain-extension: draft / seed quarantine. When the source
387
+ // file's frontmatter carries `draft: true` (set by `brain
388
+ // mark-draft <id>` and mirrored via `vault.export`), drop the
389
+ // entry from `contentIndex.json` entirely. The doc still
390
+ // exists on disk and in the DB; this only hides it from the
391
+ // wiki — Explorer tree, graph view, full-text search — by
392
+ // removing the slug from the index. Minimum-blast-radius
393
+ // filter: every consumer (Search component, Graph component,
394
+ // Explorer) reads from `contentIndex.json`, so dropping the
395
+ // entry quarantines the doc across the whole site without
396
+ // touching individual components.
397
+ if (fm.draft === true) {
398
+ delete parsed[slug]
399
+ continue
400
+ }
401
+
402
+ // brain-extension: surface the vault `tier`
403
+ // (vault | ingested) and ingest `source`
404
+ // (krisp / slack / gmail / manual) so the graph renderer
405
+ // can color-cluster nodes without round-tripping through
406
+ // the brain CLI. Both are optional; missing-frontmatter
407
+ // docs simply skip the field rather than emit `undefined`.
408
+ //
409
+ // Brain vault frontmatter persists tier under `kind:`
410
+ // (with values `vault` / `ingested`); we still accept a
411
+ // legacy `tier:` key for any pre-2026-04-29 export that
412
+ // wrote it under that name.
413
+ if (typeof fm.kind === "string") {
414
+ details.tier = fm.kind
415
+ } else if (typeof fm.tier === "string") {
416
+ details.tier = fm.tier
417
+ }
418
+ if (typeof fm.source === "string") {
419
+ details.source = fm.source
420
+ }
421
+
422
+ // brain: backstop — infer tier + source from the slug when
423
+ // frontmatter is missing them. Symmetric: the path-based
424
+ // classifier in `brain.vault.sync` does the same — anything
425
+ // under `_ingested/...` is ingested-tier, everything else is
426
+ // vault-tier. This guarantees the graph's tier filter has a
427
+ // populated value on every node so toggling `vault` /
428
+ // `ingested` chips actually filters; without this, missing-
429
+ // frontmatter docs silently survive every tier filter.
430
+ if (!details.tier && typeof slug === "string") {
431
+ details.tier = slug.startsWith("_ingested/") ? "ingested" : "vault"
432
+ }
433
+ if (!details.source && typeof slug === "string") {
434
+ const match = slug.match(/^_ingested\/([^/]+)\//)
435
+ if (match) {
436
+ details.source = match[1]
437
+ }
438
+ }
439
+
440
+ // brain-extension: surface the doc's date so the Search row
441
+ // (P3.2) and tag-content row (P3.3) can render
442
+ // `… · 2026-04-12 · …` without each consumer re-deriving the
443
+ // date from `dates` / `frontmatter`. Lookup order mirrors
444
+ // the brain frontmatter writer (`src/brain/vault/export.py:
445
+ // _build_frontmatter`) which always writes `created` /
446
+ // `updated` (ISO strings) and accepts a forward-looking
447
+ // `date` / `published` for any future authoring tool that
448
+ // wants to override the export-derived value. We pick the
449
+ // most user-meaningful field first (`date` if explicitly
450
+ // authored, then `created` as the canonical ingest time,
451
+ // then `published` as a legacy alias) and fall back to
452
+ // `updated` only as a last resort. Missing dates leave
453
+ // `details.date` undefined — Search.tsx and TagContent
454
+ // already handle that gracefully.
455
+ //
456
+ // brain (P3.6 fix-1): YAML `date: 2026-04-12` is parsed by
457
+ // gray-matter / js-yaml's default schema as a JS `Date`
458
+ // object, NOT a string. The original `typeof X === "string"`
459
+ // checks silently dropped Date instances, leaving
460
+ // `details.date` empty for every doc whose authored
461
+ // frontmatter used the bare YAML date form. The `liftDate`
462
+ // helper accepts both shapes (string or Date) and normalises
463
+ // to an ISO `YYYY-MM-DD` string for consumers.
464
+ const lifted =
465
+ liftDate(fm.date) ??
466
+ liftDate(fm.created) ??
467
+ liftDate(fm.published) ??
468
+ liftDate(fm.updated)
469
+ if (lifted !== undefined) {
470
+ details.date = lifted
471
+ }
472
+
473
+ // brain: kept upstream's `links: SimpleSlug[]` shape
474
+ // unchanged; added `linkRecords` as a parallel field
475
+ // carrying the kind/rule/weight metadata. The plan
476
+ // originally proposed replacing `links` with records, but
477
+ // additive is safer for cross-version compat with
478
+ // downstream Quartz consumers (sitemap, RSS, third-party
479
+ // plugins) that read `doc.links` directly as `string[]`.
480
+ // `details.links` is typed `unknown` via the index
481
+ // signature; runtime-narrow with Array.isArray rather than
482
+ // a blind cast.
483
+ const slugs: SimpleSlug[] = Array.isArray(details.links)
484
+ ? (details.links as SimpleSlug[])
485
+ : []
486
+ const ctxClassify: ClassifyContext | undefined = source
487
+ ? { tree: source[0], file: source[1] }
488
+ : undefined
489
+ details.linkRecords = slugs.map((s) =>
490
+ ctxClassify ? classifyLink(s, ctxClassify) : { target: s, kind: "wiki" },
491
+ )
492
+
493
+ // brain-extension: slim transform. Capture the full body,
494
+ // write it out to `static/contentBodies/<slug>.json` so the
495
+ // Search component (P3.2) can lazy-fetch on selection, then
496
+ // overwrite `details.content` with a snippet so the index
497
+ // itself stays small. We keep `content` populated with the
498
+ // snippet (rather than dropping the field) as the
499
+ // backwards-compat fallback documented in the plan — any
500
+ // consumer that hasn't been taught about lazy-fetching still
501
+ // sees a usable preview, just truncated. `details.snippet` is
502
+ // the canonical name for forward-looking consumers (P3.2's
503
+ // Search.tsx will branch on `snippet ?? content`). Slugs may
504
+ // contain `/` separators (e.g. `_ingested/gmail/<id>`); the
505
+ // mkdir-recursive call ensures the nested directory exists
506
+ // before each write.
507
+ //
508
+ // brain (P3.6 fix-3): defense-in-depth slug guard. Slugs that
509
+ // fall outside `isSafeSlug` skip both the body-file write
510
+ // and the slim transform — the entry stays in the index with
511
+ // its full `content`, which the search popover already knows
512
+ // how to render as a fallback. We log a warning so a
513
+ // legitimate slug shape we forgot to whitelist surfaces as
514
+ // build noise rather than silent data loss.
515
+ if (!isSafeSlug(slug)) {
516
+ console.warn(
517
+ `brain contentIndex: skipping unsafe slug ${JSON.stringify(slug)} ` +
518
+ `(failed isSafeSlug check) — body file not written, ` +
519
+ `entry retained with full content`,
520
+ )
521
+ continue
522
+ }
523
+ const body = typeof details.content === "string" ? details.content : ""
524
+ const snippet = body.slice(0, SNIPPET_LENGTH)
525
+ const bodyTarget = path.join(
526
+ ctx.argv.output,
527
+ CONTENT_BODIES_RELDIR,
528
+ `${slug}.json`,
529
+ )
530
+ await fs.mkdir(path.dirname(bodyTarget), { recursive: true })
531
+ await fs.writeFile(bodyTarget, JSON.stringify({ slug, content: body }))
532
+ details.content = snippet
533
+ details.snippet = snippet
534
+ }
535
+
536
+ await fs.writeFile(targetPath, JSON.stringify(parsed))
537
+ } catch (err) {
538
+ const message = err instanceof Error ? err.message : String(err)
539
+ throw new Error(
540
+ `brain contentIndex post-processor failed at ${targetPath}: ${message}`,
541
+ { cause: err },
542
+ )
543
+ }
544
+ },
545
+ }
546
+ }
@@ -0,0 +1,94 @@
1
+ // Brain wiki redesign — code-block copy-button transformer plugin (Lane C).
2
+ //
3
+ // This file is a TEMPLATE. It is installed at
4
+ // `<vault>/.quartz/quartz/plugins/transformers/codeCopy.ts`
5
+ // by `brain vault render --overlay`. It does NOT compile or run from
6
+ // the brain repo itself; the imports below resolve against the
7
+ // dependencies Quartz pulls into the cloned workspace via
8
+ // `npm install`, not against any package brain ships.
9
+ //
10
+ // Tested against Quartz v4.5.x (April 2026). If a future Quartz
11
+ // version restructures the transformer plugin shape or the
12
+ // `externalResources()` hook contract, pull the latest transformer
13
+ // reference from
14
+ // https://github.com/jackyzha0/quartz/tree/v4/quartz/plugins/transformers
15
+ // and re-apply the brain tweaks flagged below — `// brain:` for
16
+ // value/structural choices on upstream-supported fields, and
17
+ // `// brain-extension:` for keys/types that don't exist in stock
18
+ // Quartz.
19
+ //
20
+ // What this transformer does, and why: the Lane C redesign supersedes
21
+ // stock Quartz's `.clipboard-button` (wired by `Body.tsx`) with a
22
+ // brain-themed `.brain-code-copy` pill that matches the redesign
23
+ // palette and adds a small-caps language label. The injection logic
24
+ // lives in `quartz/static/codeCopy.js` (a tiny vanilla-JS script);
25
+ // this transformer's only job is to emit a `<script src=...>` tag in
26
+ // the page `<head>` so every page picks it up.
27
+ //
28
+ // brain: this transformer ALWAYS injects the script — it's part of
29
+ // the production redesign and must run whether the build is dev
30
+ // (`bin/brain-up`) or prod (`brain vault render`). The injected
31
+ // script is small (~50 lines, ~2 KB) and runs once per page load +
32
+ // once per SPA navigation (re-injects after the article DOM gets
33
+ // swapped by micromorph).
34
+ //
35
+ // brain: cross-references for the code-copy contract:
36
+ // * `quartz_overrides/quartz/static/codeCopy.js` — the runtime
37
+ // script this transformer points page `<head>` at.
38
+ // * `quartz_overrides/quartz/styles/brain/_code.scss` — the SCSS
39
+ // consumer that styles `.brain-code-copy` + the language label.
40
+ // Also hides the stock `.clipboard-button` so the two don't
41
+ // render side-by-side.
42
+ //
43
+ // Registration: this transformer has NO ordering requirement among
44
+ // the other transformers — it doesn't touch the mdast tree, only
45
+ // contributes a `<script>` tag via `externalResources()`. Place it
46
+ // anywhere in the `transformers: [...]` list; the brain
47
+ // `quartz.config.ts` template wires it next to `LinkSourceTag` so
48
+ // the brain script-only transformers stay co-located.
49
+
50
+ import { QuartzTransformerPlugin } from "../types"
51
+
52
+ // brain-extension: the path the page `<script src=...>` tag points
53
+ // at. Leading slash is intentional — Quartz emits pages at arbitrary
54
+ // slug depths (e.g. `/notes/foo/index.html`) and a relative
55
+ // `static/codeCopy.js` would break for any non-root page.
56
+ // Server-rooted `/static/codeCopy.js` resolves consistently
57
+ // regardless of which page the user landed on. This works because
58
+ // Caddy's `file_server` (and Quartz's stock `file_server` in dev)
59
+ // serves the build dir as the site root
60
+ // (`<vault>/.quartz/current/static/codeCopy.js`).
61
+ const SCRIPT_SRC = "/static/codeCopy.js"
62
+
63
+ // brain-extension: the transformer plugin itself. Empty options
64
+ // shape — the script path is part of the contract documented above
65
+ // and the script body is fixed at the static-asset level. Keeping
66
+ // the signature `(opts?: never)` documents that no configuration is
67
+ // expected; if a knob is genuinely needed later (e.g. a per-vault
68
+ // disable flag), widen the type at that point rather than guessing
69
+ // now.
70
+ export const CodeCopy: QuartzTransformerPlugin = (_opts?: never) => {
71
+ return {
72
+ name: "CodeCopy",
73
+ externalResources() {
74
+ return {
75
+ js: [
76
+ {
77
+ // brain: `afterDOMReady` so the script runs after
78
+ // `</body>` (see Quartz's `renderPage.tsx` for the
79
+ // dispatch). The injector reads + writes DOM and must
80
+ // see the article element; afterDOMReady guarantees that.
81
+ loadTime: "afterDOMReady",
82
+ // brain-extension: external script (URL src) rather than
83
+ // inline. The codeCopy.js file is bundled into every
84
+ // build's `static/` dir by Quartz's stock `Plugin.Static()`
85
+ // emitter; pointing at it by URL keeps the script body
86
+ // out of every page's HTML and lets the browser cache it.
87
+ contentType: "external",
88
+ src: SCRIPT_SRC,
89
+ },
90
+ ],
91
+ }
92
+ },
93
+ }
94
+ }