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,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
|
+
}
|