See on ssp.sh/brain.
This is a fork of the Quartz repo (v3 with Hugo). I added some additional features such as:
- Tagging with
#publishautomatically copies the note from my private second brain in Obsidian to this public second brain - Converts the first header (
# my title) into frontmatter and removes it (as Quartz expects) - Smart description extraction from first paragraph with automatic cleaning:
- Removes wikilinks, markdown formatting, list markers
- Intelligent sentence truncation for complete thoughts
- Displays on OG images as text overlay (Kanagawa color scheme)
- Used in meta tags for SEO and social media previews
- BASE file support for publishing Obsidian database views:
- Publishes Database Folder plugin
.basefiles as standalone Hugo pages - Generates HTML tables from database entries with wikilinks
- Supports folder filters and exclusion patterns
- Recursive subdirectory scanning
- Examples: Coffee Beans, Books
- Publishes Database Folder plugin
- YouTube links in Obsidian image syntax (
) render as embedded video players instead of broken images - Callout blocks are normalized so compact and spaced forms render identically
- Mermaid → OG image: set
ogimage: mermaid(ormermaid2,mermaid3, …) in a note's frontmatter to render the Nth```mermaidblock as the social-media preview image (rendered viammdc+ ImageMagick to a 1200×630 WebP using a dark theme that matches the site's OG template)
The content/notes themselves are not published in this repo, only on ssp.sh/brain.
Explore with RAG → explore.ssp.sh
Semantic search, hidden connections, and graph traversal powered by obsidian-note-taking-assistant.
Rust CLI tool that processes Obsidian vault notes and outputs Hugo-compatible markdown. Handles frontmatter, tags, images, OG image generation, callout normalization, BASE database views, and more.
Key features:
- Markdown publishing: Processes notes tagged with
#publish - BASE database views: Publishes Obsidian Database Folder plugin
.basefiles as HTML tables - Filter expressions: Supports folder filters and exclusion patterns (
!file.path.contains) - Smart descriptions: Auto-extracts clean descriptions from first paragraph
- OG image generation: Creates social media preview images with SVG→WebP conversion
- Mermaid OG images: Renders a note's Mermaid diagram as its OG image via
ogimage: mermaid/mermaid<N>(usesmmdc+ ImageMagick, dark theme matches the site's OG template)
See utils/obsidian-quartz/README.md for details.
The tool used is hugo-obsidian, a small Go program written by Jacky. Here's the source. It is not maintained anymore (as there is now a v4 without it) and it had bugs and didn't show all my backlinks. That's why I forked it and fixed the backlinks. You can find it here: sspaeti/hugo-obsidian.
It scans the content/ folder for wikilinks and emits two artifacts Hugo consumes to render the interactive graph and per-note backlink lists:
assets/indices/linkIndex.json— every[[wikilink]]as asource → targetedge, lowercased and de-duplicated (powers the graph and "Links to this note" sections)assets/indices/contentIndex.json— slug → title/content map used for search and link previews
Key fork additions over upstream: case-insensitive link matching, block-reference (^hash) handling, slash-in-title normalization, and performance tuning for large vaults.
Note
sspaeti/hugo-obsidian has been integrated directly in this repository at utils/hugo-obsidian. See utils/hugo-obsidian/README.md for installation, CLI flags, and the full changelog.
Custom render hooks in layouts/_default/_markup/:
- render-image.html - Detects YouTube URLs and renders responsive iframe embeds (with timestamp support); all other images pass through normally
Find these in .htaccess
A fetch-based hover popover replaces the legacy popover.js. Hovering any internal link opens a scrollable preview of the destination note (title, meta line including reading time, full content) rendered from the actual brain HTML — no precomputed JSON index, so notes always show current content. The same JS/CSS is consumed by the blog and the DEDP book via build-time copy, giving readers on those sites the same brain-link hover experience.
- Popover implementation (
assets/js/popover-v2.js,assets/css/popover-v2.css):- Selector parametrized via
window.initPopoverV2({selector: "..."}). Brain uses the defaulta.internal-link[href]; blog/book passa[href*="ssp.sh/brain"]. - Branches on
Content-Type: HTML extracts elements with classpopover-hint(title, meta, content body — marked insingle.html/textprocessing.htmlso site chrome, TOC, tags, footer, graph, backlinks are excluded); image responses render<img>; PDF responses embed<iframe>. - Anchor scroll: links to
#headingscroll the inner div to the prefixed#popover-internal-<heading>. - Per-pathname cache; outer 1rem padding +
:hoverkeeps the popover open while the cursor crosses from the link to the popover for scrolling. - Behind feature flag
enableLinkPreviewV2indata/config.yaml. The v1 popover code path remains available for rollback. - Light + dark mode rules cover brain (
[saved-theme="dark"]), blog (uBloggerbody[theme="dark"]), and all 9 book themes (html.ayu,html.burgundy,html.coal,html.kanagawa,html.light,html.navy,html.pinkrose,html.rust,html.tokyonight) — book usesvar(--bg)/var(--fg)/var(--links)/var(--inline-code-color)so the popover adapts to whichever theme the reader picks.
- Selector parametrized via
- CORS (
static/.htaccess):SetEnvIf Origin "^https://(www\.)?(ssp\.sh|dedp\.online)$"+Header always set Access-Control-Allow-Origin+Header always merge Vary "Origin". Thealwayskeyword is required so headers apply to 3xx canonical-host redirects (otherwise the browser blocks the redirect before the final 200). After deploy, the Bunny brain pull-zone cache must be purged once to evict pre-CORS entries. - Image wikilinks (
layouts/partials/textprocessing.html,utils/obsidian-quartz/src/file_utils.rs):[[image.ext]](without!) now renders as a working<a href>(was a broken<a>with no href). The rust util's regex relaxed from\[\[...\]\]requiring!to!?\[\[...\]\], so the asset gets copied to public output regardless of which form is used. Combined with the popover's image branch, hovering an[[img.webp]]link shows the image directly in the popover. - Reading time:
layouts/_default/single.htmlnow renders{{ .ReadingTime }} min readin the meta line for both the page and the popover preview. - Files:
assets/js/popover-v2.js,assets/css/popover-v2.css— canonical popover (copied verbatim to blog and book by theirsync-popoverMakefile targets, alongsidefloating-ui.core.umd.min.jsandfloating-ui.dom.umd.min.js)layouts/partials/head.html— flag-gated v1/v2 toggle, loads CSS + JS, calls initlayouts/partials/textprocessing.html,layouts/_default/single.html—popover-hintmarkers on title/meta/content; non-!image wikilink handling; reading time in metastatic/.htaccess— scoped CORS for(www.)?(ssp.sh|dedp.online)withHeader alwaysutils/obsidian-quartz/src/file_utils.rs— image-copy regex catches non-!wikilinksdata/config.yaml—enableLinkPreviewV2: true
The local graph on every brain note now surfaces connections to two sister sites — the blog and the DEDP book — alongside brain↔brain wikilinks. This makes the second brain a true hub: every note shows what posts cite it and which book chapters reference it.
- Node types (legend shown on every local graph that has connections):
- Current (rose) — the page you're on
- Note (orange) — brain note linked via wikilink
- Blog (
#60a5fablue) — blog post atssp.sh/blog/<slug> - Book (
#a78bfapurple) — DEDP book chapter atdedp.online/<path>.html ···Outgoing — dotted line marks brain→external edges (e.g., the brain note has[text](https://ssp.sh/blog/X)). Incoming edges from blog/book stay solid.
- Click behavior: blog and book nodes open in a new tab, brain nodes use the existing SPA navigation. Search (
Ctrl+K) excludes blog and book entries — they live in the graph only. - Edge direction styling: outgoing brain→external is dotted, incoming external→brain is solid, brain↔brain is solid.
- Data flow:
- Blog repo's
helper-scripts/enrich-link-index.pyalready populatesstatic/indices/linkIndex.jsonwith brain↔blog edges (both directions). - DEDP book's
utils/dedp-link-indexRust CLI generateslinkIndex.js(window.DEDP_LINK_INDEX = {...}) with chapter→brain edges (type: "brain", targetbrain:<slug>). - Two new
obsidian-quartzsubcommands import these into the brain's indices:enrich-with-blogandenrich-with-book.
- Blog repo's
- Frontmatter URL resolution for blog: blog posts use
url: /blog/Xfrontmatter overrides — the script parses eachindex.en.md/index.mdand extracts the actual URL instead of guessing from the folder name. - Idempotency: re-running
make prepareadds 0 new edges if nothing has changed; nodes with the same slug as a brain note are never overwritten (Map.entry().or_insert_withsemantics). - Files:
utils/obsidian-quartz/src/enrich_with_blog.rs,enrich_with_book.rs— the importersassets/js/graph.js— click routing, dasharray on outgoing edges, conditional legendassets/js/full-text-search.js— skip/blog/and/book/keysdata/graphConfig.yaml— node colors viapaths:Makefile— chainsmake -C ../sspaeti-hugo-blog prepareandmake -C ../../book/dedp link-indexbefore each enrich step (leading-so a missing sister repo is non-fatal)
- Mermaid OG rendering (
utils/obsidian-quartz/src/svg_generator.rs,file_utils.rs):- Set
ogimage: mermaidin note frontmatter to use the first```mermaidblock as the social-media preview image. Usemermaid2,mermaid3, … to target later blocks. - Pipeline: extract block → render via
mmdc(dark theme,#1F1F28background matching the OG template, 3× puppeteer scale for crisp anti-aliasing) → composite onto a 1200×630 canvas with 50px padding via ImageMagick (Lanczos filter, WebP quality 92). - Output written to
content/_img/feature/mermaid/<slug>.webpand the frontmatterogimagevalue is rewritten to the resolved path so the existinghead.htmlresolver works unchanged. - On failure (block missing,
mmdcerrors) theogimagekey is dropped so the title-based generator takes over as a fallback. - Existing-file cache: re-renders only when the target WebP doesn't already exist (delete it to force a refresh).
- Set
- Hugo template (
layouts/partials/head.html):- Added
/mermaid/to thesummary_large_imageTwitter-card detection (alongside the existing/gen/rule).
- Added
- External dependencies:
@mermaid-js/mermaid-cli(mmdc, Node + Chromium); ImageMagick (magick).
- BASE File Publishing (
utils/obsidian-quartz/src/base_*.rs):- Added support for Obsidian Database Folder plugin
.basefiles - Parses BASE YAML files with filters, views, properties, and formulas
- Implements temporary staging workflow to avoid private vault scanning:
- Copies source files to
/content/BASES/<base-name>/during processing - Queries from staging folder to build tables
- Cleans up staging folder after generation
- Copies source files to
- Filter Support:
- Folder filters:
file.path.contains("path") - Extension filters:
file.ext.contains("md") - Exclusion patterns:
!file.path.contains("path")to skip folders - Recursive subdirectory scanning
- Folder filters:
- Table Generation:
- Renders HTML tables with proper styling (
base-table-container,base-table) - Generates wikilinks (
[[Name]]) that resolve to published content - Supports multiple columns with custom properties
- Includes description/intro content before tables
- Renders HTML tables with proper styling (
- Frontmatter:
- Sets
enableToc: false,enableBacklinks: false,enableGraph: false - Auto-generates title from BASE filename
- Sets
- Examples: Coffee Beans (46 entries), Books (184 entries, excluding 1256 Study books)
- Added support for Obsidian Database Folder plugin
- Code Structure:
base_parser.rs: Parse BASE YAML into Rust structs (serde)base_query.rs: Query notes with filter expressions, extract folder/extension/exclusion patternsbase_renderer.rs: Render notes as HTML tables with formatted values (ratings, prices)file_utils.rs: Orchestrate BASE processing workflow with staging and cleanup
- Description Extraction (
utils/obsidian-quartz/src/file_utils.rs):- Automatically extracts clean descriptions from first paragraph after frontmatter
- Removes wikilinks (
[[Link]]→Link), markdown links ([Text](URL)→Text) - Strips formatting, list markers, blockquotes
- Smart sentence truncation (prefers complete sentences, max 180 chars)
- Stores as quoted
description: "..."in frontmatter for proper YAML syntax - Manual override via
desc:frontmatter field
- OG Image Enhancement (
utils/obsidian-quartz/src/svg_generator.rs):- Added description text overlay (24px, Kanagawa oldWhite #C8C093)
- Reduced title font sizes (44-60px) to make room for description
- Optimized text wrapping (title: 25 chars/line, description: 60 chars/line)
- Removed fixed accent line for cleaner layout
- Hugo Template Updates (
layouts/partials/head.html):- Created
$cleanDescriptionvariable with priority: manual → auto-extracted → .Summary - Applied wikilink/markdown cleaning to all meta tags
- Updated og:description, twitter:description, and JSON-LD schema
- Created
- Modified
assets/js/router.jsto intercept clicks between/brain/and other sections, forcing full page loads instead of SPA navigation - Prevents "null" page errors when navigating from brain to main blog
- Compatible with
assets/js/external-links.jswhich handles truly external links
Brain note no meetings (async).md → URL slug /no-meetings-async. Five files do slug work. Two can disagree. One is source of truth.
Canonical: utils/hugo-obsidian/util.go::UnicodeSanitize. Strips (), &, @, –, ', etc. Collapses -/whitespace runs to one -.
| # | File | Lang | Role |
|---|---|---|---|
| 1 | utils/hugo-obsidian/util.go::UnicodeSanitize |
Go | Canonical. Brain + blog hugo-obsidian use it. |
| 2 | utils/obsidian-quartz/src/enrich_with_blog.rs::unicode_sanitize |
Rust | Mirror of #1. Tight loop, no shell-out. |
| 3 | utils/obsidian-quartz/src/file_utils.rs:826 |
Rust | Filename lowercase only. #1 slugifies after. |
| 4 | sspaeti-hugo-blog/helper-scripts/enrich-link-index.py:41 |
Python | Divergent. Only .lower().replace(" ", "-"). Keeps parens. Source of paren-drop bugs. |
| 5 | utils/obsidian-quartz/src/file_utils.rs:925 |
Rust | BASE-page filename. Same as #3. |
Real disagreement: #1 vs #4. #2 compensates by re-canonicalising before matching brain IDs. #3 + #5 not slug generators — feed into #1.
Future cleanup: add sluggify subcommand to hugo-obsidian (Go). Blog Python shell out. Collapses #4 into #1. Kills need for #2.
Pending symmetric bug: utils/obsidian-quartz/src/enrich_with_book.rs:154. Same brain_ids.contains() pattern as fixed blog enricher. Same silent-drop on paren'd notes until same fix applied.
In-code pointers exist between #1 and #2 (MIRRORED IN / KEEP IN SYNC WITH).