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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Sample Quartz v4 markdown transformer for brain vaults.
|
|
2
|
+
//
|
|
3
|
+
// This file is a TEMPLATE. It is installed at
|
|
4
|
+
// `<vault>/.quartz/quartz/plugins/transformers/derivedFenceMark.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 mdast
|
|
12
|
+
// types it uses, pull the latest transformer reference from
|
|
13
|
+
// https://github.com/jackyzha0/quartz/tree/v4/quartz/plugins/transformers
|
|
14
|
+
// and re-apply the brain tweaks flagged below — `// brain:` for
|
|
15
|
+
// value/structural choices on upstream-supported fields, and
|
|
16
|
+
// `// brain-extension:` for keys/types that don't exist in stock
|
|
17
|
+
// Quartz.
|
|
18
|
+
//
|
|
19
|
+
// What this transformer does, and why: Phase D
|
|
20
|
+
// (`src/brain/vault/derived_links/fence.py`) writes a fenced section
|
|
21
|
+
// at the bottom of every `_ingested/` mirror file:
|
|
22
|
+
//
|
|
23
|
+
// <!-- BRAIN_DERIVED_START -->
|
|
24
|
+
// ## Related (auto-generated, do not edit)
|
|
25
|
+
// - [[partner-stem|Partner Title]] *(same_day_participant)*
|
|
26
|
+
// - [[partner-stem|Partner Title]] *(shared_thread)*
|
|
27
|
+
// <!-- BRAIN_DERIVED_END -->
|
|
28
|
+
//
|
|
29
|
+
// The brain graph renderer needs to distinguish these "evidence"
|
|
30
|
+
// edges from authored wiki-links so it can apply dashed/translucent
|
|
31
|
+
// styling and surface the rule in hover tooltips. This transformer
|
|
32
|
+
// scans the mdast tree for the BRAIN_DERIVED_START / END html-comment
|
|
33
|
+
// markers and stamps each `<a>` link inside the fenced range with
|
|
34
|
+
// `data-brain-derived="true"` + `data-brain-rule="<rule>"` (and
|
|
35
|
+
// `data-brain-weight` if a weight is encoded — see parser below).
|
|
36
|
+
// The custom contentIndex emitter
|
|
37
|
+
// (`quartz_overrides/plugins/emitters/contentIndex.ts`) reads these
|
|
38
|
+
// HTML attributes back when emitting `static/contentIndex.json` so
|
|
39
|
+
// each link record carries `kind: "derived"` + structured rule /
|
|
40
|
+
// weight metadata.
|
|
41
|
+
//
|
|
42
|
+
// brain (Lane B 2026-05-02): also writes a `title` attribute on each
|
|
43
|
+
// derived link so the `cursor: help` styling Lane B's `_links.scss`
|
|
44
|
+
// applies to derived links surfaces a real tooltip on hover. Format:
|
|
45
|
+
// `"Derived: <rule>"` when the bullet emphasis parsed cleanly,
|
|
46
|
+
// `"Derived (related)"` as a fallback when no rule was extracted.
|
|
47
|
+
// Cross-lane edit (Lane B's CSS depends on this transformer's data).
|
|
48
|
+
//
|
|
49
|
+
// Registration: this transformer must run AFTER
|
|
50
|
+
// `Plugin.ObsidianFlavoredMarkdown()` so that `[[wiki-link]]` syntax
|
|
51
|
+
// has already been converted into mdast `link` nodes before we walk
|
|
52
|
+
// the tree. The brain `quartz.config.ts` template wires it up in the
|
|
53
|
+
// correct slot — see the `transformers: [...]` list there.
|
|
54
|
+
//
|
|
55
|
+
// brain: marker constants are mirrored verbatim from
|
|
56
|
+
// `src/brain/vault/derived_links/fence.py` (the canonical Phase D
|
|
57
|
+
// source). TypeScript can't import from Python, so they live in two
|
|
58
|
+
// places — keep them in sync. If Phase D ever changes the marker
|
|
59
|
+
// shape, update both files together and re-run the overlay's
|
|
60
|
+
// parse-smoke test against a freshly-rendered fixture vault.
|
|
61
|
+
|
|
62
|
+
import type { Root, RootContent, Link, Paragraph, Emphasis } from "mdast"
|
|
63
|
+
|
|
64
|
+
import { QuartzTransformerPlugin } from "../types"
|
|
65
|
+
|
|
66
|
+
// brain: kept identical to ``FENCE_START_MARKER`` /
|
|
67
|
+
// ``FENCE_END_MARKER`` in ``src/brain/vault/derived_links/fence.py``.
|
|
68
|
+
// Detection uses ``includes`` (not equality) so leading/trailing
|
|
69
|
+
// whitespace introduced by markdown parsers around the html-comment
|
|
70
|
+
// node doesn't break the match.
|
|
71
|
+
const FENCE_START_MARKER = "<!-- BRAIN_DERIVED_START -->"
|
|
72
|
+
const FENCE_END_MARKER = "<!-- BRAIN_DERIVED_END -->"
|
|
73
|
+
|
|
74
|
+
// brain-extension: the structured shape we extract from each bullet's
|
|
75
|
+
// trailing emphasis. ``rule`` is what fence.py writes today
|
|
76
|
+
// (``same_day_participant`` / ``shared_thread``). It defaults to
|
|
77
|
+
// undefined so the emitter's classifier can omit the
|
|
78
|
+
// ``data-brain-rule`` attribute when the parser couldn't pin a value
|
|
79
|
+
// down. Note: a missing ``rule`` does NOT downgrade the link's
|
|
80
|
+
// classification — see ``stampDerived`` for the fence-membership
|
|
81
|
+
// truth-source rule.
|
|
82
|
+
interface BulletMeta {
|
|
83
|
+
rule?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// brain-extension: parses the trailing-emphasis text of a Phase D
|
|
87
|
+
// bullet. Strict — only the shape fence.py actually writes today
|
|
88
|
+
// (``*(<rule>)*`` where ``<rule>`` is an identifier) is accepted;
|
|
89
|
+
// anything else returns ``{}``. Identifier-shape guard prevents
|
|
90
|
+
// matching whole sentences if a future fence accidentally mixes
|
|
91
|
+
// prose with the metadata convention.
|
|
92
|
+
//
|
|
93
|
+
// brain: the fence membership — not this parser's success — is the
|
|
94
|
+
// truth-source for ``kind: "derived"``. Links inside the fence whose
|
|
95
|
+
// emphasis fails to parse (missing, malformed, mixed prose) still
|
|
96
|
+
// get ``data-brain-derived="true"`` stamped on them by
|
|
97
|
+
// ``stampDerived``; only ``data-brain-rule`` is omitted. If Phase E
|
|
98
|
+
// ever adds a structured ``weight`` to the bullet shape, extend
|
|
99
|
+
// this parser and ``BulletMeta`` together — don't pre-build the
|
|
100
|
+
// hook.
|
|
101
|
+
function parseBulletEmphasis(text: string): BulletMeta {
|
|
102
|
+
const match = text.trim().match(/^\(([A-Za-z_][A-Za-z0-9_-]*)\)$/)
|
|
103
|
+
if (!match) return {}
|
|
104
|
+
return { rule: match[1] }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// brain: recursive text collector. mdast emphasis/strong nodes can
|
|
108
|
+
// contain mixed text + nested formatting; flattening to a plain
|
|
109
|
+
// string is safer than reading ``.children[0].value`` blindly when
|
|
110
|
+
// fence.py's output is hand-edited or rendered through alternate
|
|
111
|
+
// markdown processors.
|
|
112
|
+
function collectText(node: RootContent): string {
|
|
113
|
+
if (node.type === "text") return node.value
|
|
114
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
115
|
+
return (node.children as RootContent[]).map(collectText).join("")
|
|
116
|
+
}
|
|
117
|
+
return ""
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// brain: in-place attribute stamper. mdast's standard extension
|
|
121
|
+
// point for "attach HTML attributes that survive into hast" is the
|
|
122
|
+
// ``data.hProperties`` object — remark-rehype copies its keys onto
|
|
123
|
+
// the corresponding hast element's properties, which then become
|
|
124
|
+
// real HTML attributes after stringification. The brain emitter
|
|
125
|
+
// reads these attributes back via ``classifyLink`` to flag derived
|
|
126
|
+
// links in ``contentIndex.json``.
|
|
127
|
+
//
|
|
128
|
+
// brain: ``data-brain-derived`` is stamped unconditionally for
|
|
129
|
+
// every link inside the fence range — fence membership is the
|
|
130
|
+
// truth-source for "this is a derived edge," NOT the bullet
|
|
131
|
+
// emphasis parse. ``data-brain-rule`` is added only when the parser
|
|
132
|
+
// pinned a rule down; bullets with missing/malformed metadata still
|
|
133
|
+
// produce ``kind: "derived"`` records (just without a rule field).
|
|
134
|
+
//
|
|
135
|
+
// brain (Lane B 2026-05-02): also stamp ``title`` so the
|
|
136
|
+
// ``cursor: help`` styling on derived links in
|
|
137
|
+
// ``brain/styles/_links.scss`` actually surfaces a tooltip on hover.
|
|
138
|
+
// Without the title write, the SCSS rule promised an affordance the
|
|
139
|
+
// DOM didn't deliver. The fallback string for the rule-less case is
|
|
140
|
+
// the literal word "Derived (related)" so screen readers and
|
|
141
|
+
// browser tooltips both read sensibly even when the bullet emphasis
|
|
142
|
+
// failed to parse.
|
|
143
|
+
function stampDerived(link: Link, meta: BulletMeta): void {
|
|
144
|
+
const data = (link.data ??= {}) as Record<string, unknown>
|
|
145
|
+
const props = (data.hProperties ??= {}) as Record<string, string>
|
|
146
|
+
props["data-brain-derived"] = "true"
|
|
147
|
+
if (meta.rule !== undefined) {
|
|
148
|
+
props["data-brain-rule"] = meta.rule
|
|
149
|
+
props["title"] = `Derived: ${meta.rule}`
|
|
150
|
+
} else {
|
|
151
|
+
props["title"] = "Derived (related)"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// brain: walks a Phase D bullet's paragraph children to find the
|
|
156
|
+
// (link, trailing-emphasis) pair fence.py writes. The bullet shape
|
|
157
|
+
// after Obsidian-flavored-markdown processing is:
|
|
158
|
+
//
|
|
159
|
+
// listItem
|
|
160
|
+
// └─ paragraph
|
|
161
|
+
// ├─ link ← [[stem|title]]
|
|
162
|
+
// ├─ text " "
|
|
163
|
+
// └─ emphasis ← *(<rule>)*
|
|
164
|
+
//
|
|
165
|
+
// We walk left-to-right so a paragraph that mistakenly contains
|
|
166
|
+
// multiple links gets paired correctly: each link inherits the
|
|
167
|
+
// emphasis that immediately follows it, falling back to "no rule"
|
|
168
|
+
// if none does. Returns the map keyed by mdast link node identity.
|
|
169
|
+
function pairLinksAndRules(paragraph: Paragraph): Map<Link, BulletMeta> {
|
|
170
|
+
const result = new Map<Link, BulletMeta>()
|
|
171
|
+
let pendingLink: Link | null = null
|
|
172
|
+
for (const child of paragraph.children) {
|
|
173
|
+
if (child.type === "link") {
|
|
174
|
+
if (pendingLink !== null && !result.has(pendingLink)) {
|
|
175
|
+
// Previous link had no trailing emphasis; commit it as
|
|
176
|
+
// "derived but rule unknown" so the emitter still flags it.
|
|
177
|
+
result.set(pendingLink, {})
|
|
178
|
+
}
|
|
179
|
+
pendingLink = child
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (child.type === "emphasis" && pendingLink !== null) {
|
|
183
|
+
const text = collectText(child as Emphasis)
|
|
184
|
+
result.set(pendingLink, parseBulletEmphasis(text))
|
|
185
|
+
pendingLink = null
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (pendingLink !== null && !result.has(pendingLink)) {
|
|
189
|
+
result.set(pendingLink, {})
|
|
190
|
+
}
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// brain: top-level mdast walker. Tracks ``inFence`` state across the
|
|
195
|
+
// root's direct children (where the BRAIN_DERIVED_START / END html
|
|
196
|
+
// comments live as siblings of the bullet list). Within the fence
|
|
197
|
+
// range, recurses into descendants and:
|
|
198
|
+
//
|
|
199
|
+
// 1. Records every link node so we know which links are derived
|
|
200
|
+
// (kind="derived") regardless of whether their bullet's
|
|
201
|
+
// emphasis was parseable.
|
|
202
|
+
// 2. Detects paragraph nodes (the bullet's content node) and pairs
|
|
203
|
+
// each link with its trailing emphasis to extract rule/weight
|
|
204
|
+
// metadata.
|
|
205
|
+
//
|
|
206
|
+
// Two-pass shape (collect, then stamp) keeps the recursion side-
|
|
207
|
+
// effect-free and lets us merge rule/weight metadata onto the
|
|
208
|
+
// minimal ``derived=true`` baseline in one place.
|
|
209
|
+
function annotateFence(tree: Root): void {
|
|
210
|
+
let inFence = false
|
|
211
|
+
const linksInFence = new Set<Link>()
|
|
212
|
+
const ruleByLink = new Map<Link, BulletMeta>()
|
|
213
|
+
|
|
214
|
+
const visit = (node: RootContent | Root): void => {
|
|
215
|
+
if (node.type === "html" && typeof node.value === "string") {
|
|
216
|
+
// brain: html-comment markers are root-level siblings of the
|
|
217
|
+
// bullet list, so toggling ``inFence`` here (rather than on
|
|
218
|
+
// descent) gives the correct enclosing-range semantics. The
|
|
219
|
+
// markers themselves don't carry links, so there's nothing to
|
|
220
|
+
// miss by skipping the recursion below.
|
|
221
|
+
//
|
|
222
|
+
// brain: literal ``<!-- BRAIN_DERIVED_START -->`` text in body
|
|
223
|
+
// prose would also trigger this — vault docs almost never
|
|
224
|
+
// self-document the fence format (the canonical doc lives in
|
|
225
|
+
// ``docs/specs/`` outside the vault), so we accept the
|
|
226
|
+
// false-positive risk rather than wrap the marker in a unique
|
|
227
|
+
// sentinel that fence.py would also need to emit.
|
|
228
|
+
if (!inFence && node.value.includes(FENCE_START_MARKER)) {
|
|
229
|
+
inFence = true
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if (inFence && node.value.includes(FENCE_END_MARKER)) {
|
|
233
|
+
inFence = false
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (inFence) {
|
|
239
|
+
if (node.type === "link") {
|
|
240
|
+
linksInFence.add(node)
|
|
241
|
+
}
|
|
242
|
+
// brain: paragraph-only — fence.py emits bullets, which become
|
|
243
|
+
// ``listItem > paragraph`` after mdast parsing. If a future
|
|
244
|
+
// fence shape ever inserts a heading or blockquote inside the
|
|
245
|
+
// marker range, links there would still get
|
|
246
|
+
// ``kind: "derived"`` (via ``linksInFence`` above) but without
|
|
247
|
+
// a ``rule`` field. Revisit the pairing logic at that point.
|
|
248
|
+
if (node.type === "paragraph") {
|
|
249
|
+
for (const [link, meta] of pairLinksAndRules(node)) {
|
|
250
|
+
ruleByLink.set(link, meta)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
256
|
+
for (const child of node.children as RootContent[]) {
|
|
257
|
+
visit(child)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
visit(tree)
|
|
263
|
+
|
|
264
|
+
if (inFence) {
|
|
265
|
+
// brain: defensive — fence.py always emits matched START/END
|
|
266
|
+
// pairs (and ``extract_fence`` in fence.py guards against the
|
|
267
|
+
// same corruption from the other side), but a hand-edited file
|
|
268
|
+
// could leave a doc in-fence forever. Drop everything we
|
|
269
|
+
// collected and surface a console warning rather than silently
|
|
270
|
+
// mis-tag every link below the orphaned START marker.
|
|
271
|
+
console.warn(
|
|
272
|
+
"[derivedFenceMark] unmatched BRAIN_DERIVED_START — discarding fence annotations for this document",
|
|
273
|
+
)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const link of linksInFence) {
|
|
278
|
+
stampDerived(link, ruleByLink.get(link) ?? {})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// brain-extension: the transformer plugin itself. Empty options
|
|
283
|
+
// object is intentional — the only knobs would be the marker strings
|
|
284
|
+
// (which are sourced from fence.py) and the matched-rule allowlist
|
|
285
|
+
// (which is enforced upstream by fence.py's ``FENCE_RULES``).
|
|
286
|
+
// Keeping the signature ``(opts?: never)`` documents that no
|
|
287
|
+
// configuration is expected; if a knob is genuinely needed later,
|
|
288
|
+
// widen the type at that point rather than guessing now.
|
|
289
|
+
export const DerivedFenceMark: QuartzTransformerPlugin = () => {
|
|
290
|
+
return {
|
|
291
|
+
name: "DerivedFenceMark",
|
|
292
|
+
markdownPlugins() {
|
|
293
|
+
return [
|
|
294
|
+
() => {
|
|
295
|
+
return (tree: Root) => {
|
|
296
|
+
annotateFence(tree)
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
]
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Brain wiki redesign — email-thread reading-mode transformer plugin
|
|
2
|
+
// (P4.4 of the Wiki UX Overhaul).
|
|
3
|
+
//
|
|
4
|
+
// This file is a TEMPLATE. It is installed at
|
|
5
|
+
// `<vault>/.quartz/quartz/plugins/transformers/emailThread.ts`
|
|
6
|
+
// by `brain vault render --overlay`. It does NOT compile or run from
|
|
7
|
+
// the brain repo itself; the imports below resolve against the
|
|
8
|
+
// dependencies Quartz pulls into the cloned workspace via
|
|
9
|
+
// `npm install`, not against any package brain ships.
|
|
10
|
+
//
|
|
11
|
+
// Tested against Quartz v4.5.x (April 2026). If a future Quartz
|
|
12
|
+
// version restructures the transformer plugin shape or the
|
|
13
|
+
// `externalResources()` hook contract, pull the latest transformer
|
|
14
|
+
// reference from
|
|
15
|
+
// https://github.com/jackyzha0/quartz/tree/v4/quartz/plugins/transformers
|
|
16
|
+
// and re-apply the brain tweaks flagged below — `// brain:` for
|
|
17
|
+
// value/structural choices on upstream-supported fields, and
|
|
18
|
+
// `// brain-extension:` for keys/types that don't exist in stock
|
|
19
|
+
// Quartz.
|
|
20
|
+
//
|
|
21
|
+
// What this transformer does, and why: P4.4 ships an "Email thread
|
|
22
|
+
// reading mode" — every per-message section in an `email_thread`
|
|
23
|
+
// body becomes a collapsible `<details>` block (older messages
|
|
24
|
+
// closed, latest expanded), and a "Show only my replies" filter
|
|
25
|
+
// button hides every section whose `From:` header doesn't equal the
|
|
26
|
+
// owner's email. The runtime that wires these affordances lives in
|
|
27
|
+
// `quartz/static/emailThread.js`; this transformer's job is to (a)
|
|
28
|
+
// inject that script into every page and (b) bake the owner's email
|
|
29
|
+
// (read from `process.env.BRAIN_USER_EMAIL` at build time) into a
|
|
30
|
+
// tiny inline `<script>window.BRAIN_USER_EMAIL = "..."</script>`
|
|
31
|
+
// snippet so the runtime knows whose `From:` to match against.
|
|
32
|
+
//
|
|
33
|
+
// brain: this transformer ALWAYS injects the runtime — same pattern
|
|
34
|
+
// as `LinkSourceTag` and `CodeCopy`, both of which are part of the
|
|
35
|
+
// production redesign and don't gate on env vars. The user-email
|
|
36
|
+
// global is emitted only when `BRAIN_USER_EMAIL` is set; without it
|
|
37
|
+
// the runtime renders the toggle button anyway but the filter
|
|
38
|
+
// matches no message (correct "you forgot to set the env var" cue).
|
|
39
|
+
//
|
|
40
|
+
// brain: cross-references for the email-thread reading-mode contract:
|
|
41
|
+
// * `quartz_overrides/quartz/static/emailThread.js` — the runtime
|
|
42
|
+
// this transformer points page `<head>` at.
|
|
43
|
+
// * `quartz_overrides/quartz/styles/brain/_email_thread.scss` — the
|
|
44
|
+
// SCSS partial that paints `<details>` and the filter button,
|
|
45
|
+
// and reads the body class (`brain-replies-only`) plus per-
|
|
46
|
+
// section attributes (`data-brain-is-mine`) the runtime stamps.
|
|
47
|
+
// * `src/brain/ingest/gmail.py` — `to_extracted_thread()`, which
|
|
48
|
+
// emits the `## YYYY-MM-DD HH:MM — <from>` headings + per-message
|
|
49
|
+
// `<details>` markup the runtime parses.
|
|
50
|
+
// * `src/brain/config.py` — the `Config.user_email` field and
|
|
51
|
+
// `BRAIN_USER_EMAIL` env-var contract this transformer mirrors.
|
|
52
|
+
//
|
|
53
|
+
// Registration: this transformer has NO ordering requirement among
|
|
54
|
+
// the other transformers — it doesn't touch the mdast tree, only
|
|
55
|
+
// contributes resources via `externalResources()`. Place it anywhere
|
|
56
|
+
// in the `transformers: [...]` list; the brain `quartz.config.ts`
|
|
57
|
+
// template wires it next to `LinkSourceTag` / `CodeCopy` so the
|
|
58
|
+
// brain script-only transformers stay grouped.
|
|
59
|
+
|
|
60
|
+
import { QuartzTransformerPlugin } from "../types"
|
|
61
|
+
|
|
62
|
+
// brain-extension: the path the page `<script src=...>` tag points
|
|
63
|
+
// at. Leading slash is intentional — Quartz emits pages at arbitrary
|
|
64
|
+
// slug depths (e.g. `/_ingested/gmail/<thread>/index.html`) and a
|
|
65
|
+
// relative `static/emailThread.js` would break for any non-root
|
|
66
|
+
// page. Server-rooted `/static/emailThread.js` resolves consistently
|
|
67
|
+
// regardless of which page the user landed on. This works because
|
|
68
|
+
// Caddy's `file_server` (and Quartz's stock `file_server` in dev)
|
|
69
|
+
// serves the build dir as the site root
|
|
70
|
+
// (`<vault>/.quartz/current/static/emailThread.js`).
|
|
71
|
+
const SCRIPT_SRC = "/static/emailThread.js"
|
|
72
|
+
|
|
73
|
+
// brain-extension: build-time env var carrying the owner's email.
|
|
74
|
+
// Read inside `externalResources()` so each plugin instance evaluates
|
|
75
|
+
// the value fresh per build (matters for tests + for `bin/brain-up`
|
|
76
|
+
// re-invocations where the env may differ between runs).
|
|
77
|
+
const USER_EMAIL_ENV_VAR = "BRAIN_USER_EMAIL"
|
|
78
|
+
|
|
79
|
+
// brain: minimal escape pass for the user-email value before it gets
|
|
80
|
+
// interpolated into the inline `<script>` body. The email comes from
|
|
81
|
+
// the user's own `.env` so it's not adversarial input, but a stray
|
|
82
|
+
// `</script>` (malformed entry) or `\` would break the script tag
|
|
83
|
+
// boundary. Escaping `<`, `>`, `\`, and `"` keeps the inline-script
|
|
84
|
+
// stream parseable. Symmetric with how stock Quartz escapes inline
|
|
85
|
+
// JSON in its `description.ts` transformer.
|
|
86
|
+
function escapeForJsString(value: string): string {
|
|
87
|
+
return value
|
|
88
|
+
.replace(/\\/g, "\\\\")
|
|
89
|
+
.replace(/"/g, '\\"')
|
|
90
|
+
.replace(/</g, "\\u003c")
|
|
91
|
+
.replace(/>/g, "\\u003e")
|
|
92
|
+
.replace(/\r/g, "\\r")
|
|
93
|
+
.replace(/\n/g, "\\n")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// brain-extension: the transformer plugin itself. Empty options
|
|
97
|
+
// shape — the only knobs would be the script path and the env var
|
|
98
|
+
// name, both of which are part of the contract documented above.
|
|
99
|
+
// Keeping the signature `(opts?: never)` documents that no
|
|
100
|
+
// configuration is expected; if a knob is genuinely needed later
|
|
101
|
+
// (e.g. a per-vault user-allowlist override), widen the type at that
|
|
102
|
+
// point rather than guessing now.
|
|
103
|
+
export const EmailThreadReader: QuartzTransformerPlugin = (_opts?: never) => {
|
|
104
|
+
return {
|
|
105
|
+
name: "EmailThreadReader",
|
|
106
|
+
externalResources() {
|
|
107
|
+
// brain: read the env var fresh per build. `.trim()` so a
|
|
108
|
+
// trailing newline from a `.env` quirk doesn't bleed into the
|
|
109
|
+
// JS global.
|
|
110
|
+
const rawEmail = (process.env[USER_EMAIL_ENV_VAR] ?? "").trim()
|
|
111
|
+
const escapedEmail = escapeForJsString(rawEmail)
|
|
112
|
+
|
|
113
|
+
// brain: emit two JS resources:
|
|
114
|
+
//
|
|
115
|
+
// 1. inline — sets `window.BRAIN_USER_EMAIL` BEFORE the runtime
|
|
116
|
+
// script reads it. `loadTime: "beforeDOMReady"` ensures the
|
|
117
|
+
// global is in place before the runtime's
|
|
118
|
+
// `DOMContentLoaded` listener fires. Always emitted (even
|
|
119
|
+
// when the env var is unset) so the runtime always sees
|
|
120
|
+
// `window.BRAIN_USER_EMAIL` defined as `""` rather than
|
|
121
|
+
// `undefined` — simpler null handling on the runtime side.
|
|
122
|
+
//
|
|
123
|
+
// 2. external — the runtime itself, served from
|
|
124
|
+
// `/static/emailThread.js`. `loadTime: "afterDOMReady"`
|
|
125
|
+
// because the runtime walks the article DOM at boot, and
|
|
126
|
+
// `afterDOMReady` guarantees the article is in place.
|
|
127
|
+
//
|
|
128
|
+
// `spaPreserve` left unset on both — Quartz's SPA reuses the
|
|
129
|
+
// existing `<script>` between navigations only when set; we want
|
|
130
|
+
// both scripts to re-run on each `nav` event so the runtime can
|
|
131
|
+
// re-detect the page and re-inject the toggle.
|
|
132
|
+
return {
|
|
133
|
+
js: [
|
|
134
|
+
{
|
|
135
|
+
loadTime: "beforeDOMReady",
|
|
136
|
+
contentType: "inline",
|
|
137
|
+
script: `;window.BRAIN_USER_EMAIL = "${escapedEmail}";`,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
loadTime: "afterDOMReady",
|
|
141
|
+
contentType: "external",
|
|
142
|
+
src: SCRIPT_SRC,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Brain hide-if-empty door filter — Phase 4.1 of the Wiki UX Overhaul.
|
|
2
|
+
//
|
|
3
|
+
// This file is a TEMPLATE. It is installed at
|
|
4
|
+
// `<vault>/.quartz/quartz/plugins/transformers/emptyDoorFilter.ts` by
|
|
5
|
+
// `brain vault render --overlay`. It does NOT compile or run from the
|
|
6
|
+
// brain repo itself; the imports below resolve against the
|
|
7
|
+
// dependencies Quartz pulls into the cloned workspace via `npm
|
|
8
|
+
// install`.
|
|
9
|
+
//
|
|
10
|
+
// What this transformer does, and why: the home note (`index.md`) ships
|
|
11
|
+
// a hand-curated set of "doors" — folder-style links like
|
|
12
|
+
// `[Daily notes](daily/)` that take the user into a top-level subdir
|
|
13
|
+
// of the vault. When the destination subdir is empty, the door
|
|
14
|
+
// resolves to a 404. The fix has two layers:
|
|
15
|
+
//
|
|
16
|
+
// 1. A brain CLI step that auto-generates `<subdir>/index.md` when
|
|
17
|
+
// the subdir has notes (see `src/brain/vault/daily_index.py` for
|
|
18
|
+
// the daily case). When the subdir is non-empty the door
|
|
19
|
+
// resolves cleanly.
|
|
20
|
+
//
|
|
21
|
+
// 2. This transformer. When the subdir is empty (no `*.md` files
|
|
22
|
+
// other than a stale `index.md` itself), strip the door's
|
|
23
|
+
// enclosing `<li>` from the rendered HTML so the user never
|
|
24
|
+
// sees a link to a 404. Generic — a single `folders` option
|
|
25
|
+
// controls which subdirs to check, so a future "Workbench"
|
|
26
|
+
// door (or any other future top-level subdir) gets the same
|
|
27
|
+
// treatment by appending its name to the option.
|
|
28
|
+
//
|
|
29
|
+
// Restricted to the home page by default (`pageSlugs: ["index"]`) —
|
|
30
|
+
// stripping random `<li>`s on every page would be too aggressive.
|
|
31
|
+
// The brief case is the home navigation; if a future PR needs the
|
|
32
|
+
// behaviour elsewhere, extend the option.
|
|
33
|
+
//
|
|
34
|
+
// Tested against Quartz v4.5.x (April 2026). The rehype tree shape
|
|
35
|
+
// (hast `Element` with `tagName` / `properties` / `children`) is
|
|
36
|
+
// stable across versions; the `Plugin.CrawlLinks` integration this
|
|
37
|
+
// depends on (resolving folder refs to relative URLs) has been
|
|
38
|
+
// in place since v4.0.
|
|
39
|
+
|
|
40
|
+
import * as fs from "node:fs"
|
|
41
|
+
import * as path from "node:path"
|
|
42
|
+
|
|
43
|
+
import type { Element, Root, RootContent } from "hast"
|
|
44
|
+
|
|
45
|
+
import { QuartzTransformerPlugin } from "../types"
|
|
46
|
+
|
|
47
|
+
export interface EmptyDoorFilterOptions {
|
|
48
|
+
// brain: list of folder names (relative to the vault root) to check
|
|
49
|
+
// for emptiness. The transformer compares each link's resolved href
|
|
50
|
+
// against this list and strips the enclosing `<li>` if the folder
|
|
51
|
+
// has zero matchable `.md` files. `daily` is the only case that
|
|
52
|
+
// motivated this transformer; future top-level subdirs (e.g. a
|
|
53
|
+
// hypothetical `workbench/`) get the same treatment by appending
|
|
54
|
+
// their name here.
|
|
55
|
+
folders: string[]
|
|
56
|
+
// brain: page slugs the transformer fires on. Default `["index"]`
|
|
57
|
+
// — the home note. Restricting prevents accidentally stripping
|
|
58
|
+
// legitimate navigation `<li>`s from other pages.
|
|
59
|
+
pageSlugs: string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const defaultOptions: EmptyDoorFilterOptions = {
|
|
63
|
+
folders: ["daily"],
|
|
64
|
+
pageSlugs: ["index"],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// brain: a folder counts as "non-empty" if it contains at least one
|
|
68
|
+
// markdown file whose name is NOT `index.md`. The `index.md` exclusion
|
|
69
|
+
// stops a stale auto-generated index from masking an otherwise-empty
|
|
70
|
+
// folder. Recursive walk so a year-folded ``daily/<YYYY>/<note>.md``
|
|
71
|
+
// layout is handled (the brain CLI nests dailies under year folders).
|
|
72
|
+
function folderHasNotes(folderPath: string): boolean {
|
|
73
|
+
if (!fs.existsSync(folderPath)) return false
|
|
74
|
+
let stack: string[] = [folderPath]
|
|
75
|
+
while (stack.length > 0) {
|
|
76
|
+
const dir = stack.pop()
|
|
77
|
+
if (dir === undefined) break
|
|
78
|
+
let entries: fs.Dirent[]
|
|
79
|
+
try {
|
|
80
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
81
|
+
} catch {
|
|
82
|
+
// Permission errors / race deletes — treat as empty for the
|
|
83
|
+
// affected subtree rather than crashing the build.
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const full = path.join(dir, entry.name)
|
|
88
|
+
if (entry.isDirectory()) {
|
|
89
|
+
stack.push(full)
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (!entry.isFile()) continue
|
|
93
|
+
if (!entry.name.endsWith(".md")) continue
|
|
94
|
+
if (entry.name === "index.md") continue
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// brain: tolerant href→folder match. `Plugin.CrawlLinks` rewrites
|
|
102
|
+
// internal links to relative URLs; the home note's `daily/` link
|
|
103
|
+
// might resolve to `./daily/`, `daily/`, or `daily/index` depending
|
|
104
|
+
// on `markdownLinkResolution`. We strip a leading `./`, drop a
|
|
105
|
+
// trailing `/`, and trim a trailing `/index` so the comparator
|
|
106
|
+
// against the bare folder name catches every shape.
|
|
107
|
+
function hrefMatchesFolder(href: string, emptyFolders: Set<string>): boolean {
|
|
108
|
+
if (href.length === 0) return false
|
|
109
|
+
// Skip absolute / external URLs cheaply — folder refs are always
|
|
110
|
+
// relative.
|
|
111
|
+
if (/^[a-z]+:/i.test(href)) return false
|
|
112
|
+
let cleaned = href.split("#")[0]
|
|
113
|
+
cleaned = cleaned.replace(/^\.\//, "")
|
|
114
|
+
cleaned = cleaned.replace(/\/$/, "")
|
|
115
|
+
cleaned = cleaned.replace(/\/index$/, "")
|
|
116
|
+
// Folder name is the last path segment for nested doors (none today,
|
|
117
|
+
// but a future `tools/scratch/` would need this).
|
|
118
|
+
return emptyFolders.has(cleaned)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// brain: walks an `<li>` subtree to determine if any descendant `<a>`
|
|
122
|
+
// points at one of the empty folders. Hast doesn't ship a visitor
|
|
123
|
+
// helper this targeted (`hast-util-visit` would work but adds an
|
|
124
|
+
// import for what's a 12-line walk), so we recurse manually.
|
|
125
|
+
function listItemPointsAtEmptyFolder(
|
|
126
|
+
li: Element,
|
|
127
|
+
emptyFolders: Set<string>,
|
|
128
|
+
): boolean {
|
|
129
|
+
const stack: RootContent[] = [...li.children]
|
|
130
|
+
while (stack.length > 0) {
|
|
131
|
+
const node = stack.pop()
|
|
132
|
+
if (node === undefined) break
|
|
133
|
+
if (node.type !== "element") continue
|
|
134
|
+
const el = node as Element
|
|
135
|
+
if (el.tagName === "a") {
|
|
136
|
+
const href = (el.properties ?? {})["href"]
|
|
137
|
+
if (typeof href === "string" && hrefMatchesFolder(href, emptyFolders)) {
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(el.children)) {
|
|
142
|
+
for (const child of el.children) {
|
|
143
|
+
stack.push(child)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// brain: in-place tree edit — splice every `<li>` that points at an
|
|
151
|
+
// empty folder. Walks recursively so a `<li>` nested inside a `<ul>`
|
|
152
|
+
// inside a `<details>` (or any wrapper) is still reachable. We mutate
|
|
153
|
+
// `node.children` to a filtered list; preact reconciles fine with
|
|
154
|
+
// hast tree mutation since rehype-stringify reads the final shape
|
|
155
|
+
// after every transformer plugin runs.
|
|
156
|
+
function stripEmptyDoorListItems(
|
|
157
|
+
tree: Root | Element,
|
|
158
|
+
emptyFolders: Set<string>,
|
|
159
|
+
): void {
|
|
160
|
+
const children = tree.children
|
|
161
|
+
if (!Array.isArray(children)) return
|
|
162
|
+
const filtered: typeof children = []
|
|
163
|
+
for (const child of children) {
|
|
164
|
+
if (child.type === "element") {
|
|
165
|
+
const el = child as Element
|
|
166
|
+
if (el.tagName === "li" && listItemPointsAtEmptyFolder(el, emptyFolders)) {
|
|
167
|
+
// Drop the entire `<li>` — including any nested children we
|
|
168
|
+
// would otherwise have recursed into. The whole door (link +
|
|
169
|
+
// surrounding context — emoji, em-dash subtitle, etc.) is
|
|
170
|
+
// expected to live inside the same `<li>`, which matches the
|
|
171
|
+
// home note's authored shape (`- 📅 [Daily notes](daily/) — …`).
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
stripEmptyDoorListItems(el, emptyFolders)
|
|
175
|
+
}
|
|
176
|
+
filtered.push(child)
|
|
177
|
+
}
|
|
178
|
+
tree.children = filtered as typeof tree.children
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const EmptyDoorFilter: QuartzTransformerPlugin<
|
|
182
|
+
Partial<EmptyDoorFilterOptions>
|
|
183
|
+
> = (userOpts) => {
|
|
184
|
+
const opts: EmptyDoorFilterOptions = { ...defaultOptions, ...userOpts }
|
|
185
|
+
return {
|
|
186
|
+
name: "EmptyDoorFilter",
|
|
187
|
+
htmlPlugins(ctx) {
|
|
188
|
+
// brain: cache empty-folder set per build. The vault filesystem
|
|
189
|
+
// is read once per `htmlPlugins` invocation (Quartz calls it once
|
|
190
|
+
// per build); rendering 1000+ pages doesn't re-stat the same
|
|
191
|
+
// dirs. The `Set` is the comparator the per-page walker hits.
|
|
192
|
+
const vaultRoot = ctx.argv.directory
|
|
193
|
+
const emptyFolders = new Set<string>()
|
|
194
|
+
for (const folder of opts.folders) {
|
|
195
|
+
if (!folderHasNotes(path.join(vaultRoot, folder))) {
|
|
196
|
+
emptyFolders.add(folder)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const pageSlugSet = new Set(opts.pageSlugs)
|
|
200
|
+
return [
|
|
201
|
+
() => async (tree: Root, file) => {
|
|
202
|
+
// brain: short-circuit when nothing's empty — the vast
|
|
203
|
+
// majority of builds will land here, since the home note's
|
|
204
|
+
// doors point at populated dirs.
|
|
205
|
+
if (emptyFolders.size === 0) return
|
|
206
|
+
const slug = (file.data as { slug?: string }).slug
|
|
207
|
+
if (typeof slug !== "string" || !pageSlugSet.has(slug)) return
|
|
208
|
+
stripEmptyDoorListItems(tree, emptyFolders)
|
|
209
|
+
},
|
|
210
|
+
]
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|