jekyll-theme-zer0 1.2.1 → 1.3.0

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.
@@ -0,0 +1,427 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # File: obsidian_links.rb
5
+ # Path: _plugins/obsidian_links.rb
6
+ # Purpose: Convert Obsidian-flavoured Markdown to GitHub-Pages-compatible HTML
7
+ # at build time (server-side path).
8
+ #
9
+ # ⚠️ IMPORTANT — Plugin loading on GitHub Pages
10
+ #
11
+ # The default repository build uses the `github-pages` gem, which forces
12
+ # `safe: true` and overrides `plugins_dir` to a random hash. That means
13
+ # THIS PLUGIN DOES NOT RUN on the default GH Pages remote_theme build.
14
+ #
15
+ # For those deployments, the equivalent transformations happen in the
16
+ # browser via assets/js/obsidian-wiki-links.js using
17
+ # assets/data/wiki-index.json. That is the primary, supported path.
18
+ #
19
+ # This Ruby plugin is kept as an opt-in for forks that:
20
+ # - Build their site with vanilla Jekyll (no `github-pages` gem), or
21
+ # - Use a custom GitHub Actions workflow (Strategy 1 in the integration
22
+ # plan) that bypasses the GH Pages plugin whitelist.
23
+ #
24
+ # In those setups, server-side rewrites give better SEO and avoid the
25
+ # client-side flash before the JS resolver runs.
26
+ #
27
+ # Toggle: set `obsidian: enabled: false` in _config.yml to disable everything.
28
+ #
29
+ # Handled syntax (no-op when not present, so plain Markdown is unaffected):
30
+ # - Wiki-links: [[Page Title]] → <a class="wiki-link">…</a>
31
+ # [[Page Title|Alias]] with custom display text
32
+ # [[Page Title#Heading]] anchor preserved
33
+ # [[Page Title^block-id]] degrades gracefully (anchor stripped)
34
+ # - Embeds: ![[image.png]] → <img …>
35
+ # ![[image.png|400]] width hint
36
+ # ![[Note Title]] → transcluded note content
37
+ # - Callouts: > [!note] Title → Bootstrap alert component
38
+ # Supported types: note, tip, info, success, warning, danger,
39
+ # question, quote, abstract, example, bug, todo, important,
40
+ # caution, failure
41
+ # - Inline tags: #tag (outside code blocks/links) → linked tag badge
42
+ #
43
+ # A site-wide title → permalink index is also exposed as `site.obsidian.index`
44
+ # for use by Liquid (e.g. assets/data/wiki-index.json) and by the client-side
45
+ # fallback resolver in assets/js/obsidian-wiki-links.js (used by the
46
+ # remote_theme GitHub Pages build, where this plugin does not run).
47
+ #
48
+ # Runs as a :pre_render hook on every document and page; transforms only the
49
+ # raw markdown body before kramdown converts it.
50
+ #
51
+ # Toggle: set `obsidian: enabled: false` in _config.yml to disable everything.
52
+ #
53
+ module Jekyll
54
+ module Obsidian
55
+ DEFAULT_CONFIG = {
56
+ 'enabled' => true,
57
+ 'attachments_path' => '/assets/images/notes',
58
+ 'tag_base_url' => '/tags/',
59
+ 'callout_class_prefix' => 'obsidian-callout',
60
+ 'wiki_link_class' => 'wiki-link',
61
+ 'broken_link_class' => 'wiki-link wiki-link-broken'
62
+ }.freeze
63
+
64
+ # Bootstrap alert mapping per Obsidian callout type.
65
+ # https://help.obsidian.md/Editing+and+formatting/Callouts
66
+ CALLOUT_TYPES = {
67
+ 'note' => { alert: 'primary', icon: 'bi-pencil-square' },
68
+ 'abstract' => { alert: 'secondary', icon: 'bi-card-text' },
69
+ 'summary' => { alert: 'secondary', icon: 'bi-card-text' },
70
+ 'tldr' => { alert: 'secondary', icon: 'bi-card-text' },
71
+ 'info' => { alert: 'info', icon: 'bi-info-circle' },
72
+ 'todo' => { alert: 'info', icon: 'bi-check2-square' },
73
+ 'tip' => { alert: 'success', icon: 'bi-lightbulb' },
74
+ 'hint' => { alert: 'success', icon: 'bi-lightbulb' },
75
+ 'important' => { alert: 'warning', icon: 'bi-exclamation-circle' },
76
+ 'success' => { alert: 'success', icon: 'bi-check-circle' },
77
+ 'check' => { alert: 'success', icon: 'bi-check-circle' },
78
+ 'done' => { alert: 'success', icon: 'bi-check-circle' },
79
+ 'question' => { alert: 'info', icon: 'bi-question-circle' },
80
+ 'help' => { alert: 'info', icon: 'bi-question-circle' },
81
+ 'faq' => { alert: 'info', icon: 'bi-question-circle' },
82
+ 'warning' => { alert: 'warning', icon: 'bi-exclamation-triangle' },
83
+ 'caution' => { alert: 'warning', icon: 'bi-exclamation-triangle' },
84
+ 'attention' => { alert: 'warning', icon: 'bi-exclamation-triangle' },
85
+ 'failure' => { alert: 'danger', icon: 'bi-x-octagon' },
86
+ 'fail' => { alert: 'danger', icon: 'bi-x-octagon' },
87
+ 'missing' => { alert: 'danger', icon: 'bi-x-octagon' },
88
+ 'danger' => { alert: 'danger', icon: 'bi-shield-exclamation' },
89
+ 'error' => { alert: 'danger', icon: 'bi-shield-exclamation' },
90
+ 'bug' => { alert: 'danger', icon: 'bi-bug' },
91
+ 'example' => { alert: 'secondary', icon: 'bi-code-slash' },
92
+ 'quote' => { alert: 'secondary', icon: 'bi-chat-quote' },
93
+ 'cite' => { alert: 'secondary', icon: 'bi-chat-quote' }
94
+ }.freeze
95
+
96
+ # Image extensions that trigger the embed → <img> path.
97
+ IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .svg .webp .avif .bmp].freeze
98
+
99
+ # ---------------------------------------------------------------------
100
+ # Index — built once per build, keyed by lowercase title and basename.
101
+ # ---------------------------------------------------------------------
102
+ class Index
103
+ attr_reader :entries
104
+
105
+ def initialize(site)
106
+ @entries = {}
107
+ build(site)
108
+ end
109
+
110
+ def build(site)
111
+ # All renderable docs across collections + standalone pages.
112
+ items = []
113
+ items.concat(site.documents) if site.respond_to?(:documents)
114
+ items.concat(site.pages.select { |p| p.output_ext == '.html' })
115
+
116
+ items.each do |doc|
117
+ title = doc.data['title'].to_s.strip
118
+ basename = File.basename(doc.relative_path, File.extname(doc.relative_path))
119
+ url = doc.url
120
+
121
+ register(title, url, doc) unless title.empty?
122
+ register(basename, url, doc) unless basename.empty?
123
+
124
+ # Aliases: Obsidian frontmatter `aliases:` — also wired to redirect_from.
125
+ Array(doc.data['aliases']).each do |alias_name|
126
+ register(alias_name.to_s.strip, url, doc)
127
+ end
128
+ end
129
+ end
130
+
131
+ def register(key, url, doc)
132
+ slug = normalize(key)
133
+ return if slug.empty?
134
+
135
+ # First registration wins; subsequent collisions produce a deterministic
136
+ # warning so authors can disambiguate (e.g. via `aliases:`).
137
+ if @entries.key?(slug) && @entries[slug][:url] != url
138
+ Jekyll.logger.warn(
139
+ 'Obsidian:',
140
+ "wiki-link target collision for #{key.inspect} — keeping #{@entries[slug][:url]}, ignoring #{url}"
141
+ )
142
+ return
143
+ end
144
+
145
+ @entries[slug] = {
146
+ url: url,
147
+ title: doc.data['title'].to_s.strip,
148
+ collection: (doc.respond_to?(:collection) && doc.collection ? doc.collection.label : nil)
149
+ }
150
+ end
151
+
152
+ def lookup(key)
153
+ @entries[normalize(key)]
154
+ end
155
+
156
+ def to_h
157
+ @entries.each_with_object({}) do |(slug, info), out|
158
+ out[slug] = { 'url' => info[:url], 'title' => info[:title], 'collection' => info[:collection] }
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ def normalize(value)
165
+ value.to_s.downcase.strip.gsub(/\s+/, ' ')
166
+ end
167
+ end
168
+
169
+ # ---------------------------------------------------------------------
170
+ # Converter — pure-string transformations on the raw markdown body.
171
+ # ---------------------------------------------------------------------
172
+ class Converter
173
+ # Match fenced code blocks (``` or ~~~), indented code lines, and inline
174
+ # code spans so we can skip them when rewriting wiki-link / tag syntax.
175
+ FENCED_CODE_RE = /(^[ \t]{0,3}(```|~~~)[^\n]*\n.*?^[ \t]{0,3}\2[^\n]*$)/m
176
+ INLINE_CODE_RE = /(`+)[^`\n]*?\1/
177
+
178
+ # ![[target]] or ![[target|width-or-alias]]
179
+ EMBED_RE = /!\[\[([^\]\n|]+?)(?:\|([^\]\n]+))?\]\]/
180
+
181
+ # [[target]] or [[target|alias]] (target may include #heading or ^block)
182
+ LINK_RE = /\[\[([^\]\n|]+?)(?:\|([^\]\n]+))?\]\]/
183
+
184
+ # Inline #tag — letters, digits, slashes, dashes, underscores. Skips
185
+ # leading-position fragments (markdown headings) and anything inside
186
+ # links (handled separately via masking).
187
+ INLINE_TAG_RE = /(?<![\w\/#&])#([A-Za-z][\w\/-]{0,63})/
188
+
189
+ # Callout block: > [!type] optional title followed by zero+ continuation
190
+ # lines starting with `>`. Multiline match is scoped per blockquote group.
191
+ CALLOUT_RE = /^(?<indent>[ \t]{0,3})>\s*\[!(?<type>[A-Za-z]+)\](?<fold>[+-]?)\s*(?<title>[^\n]*)\n(?<body>(?:^[ \t]{0,3}>(?:[^\n]*)\n?)*)/
192
+
193
+ def initialize(site, index, config)
194
+ @site = site
195
+ @index = index
196
+ @config = config
197
+ end
198
+
199
+ def convert(markdown, current_url: nil)
200
+ return markdown if markdown.nil? || markdown.empty?
201
+
202
+ # Mask code spans / fences so syntax inside them is left untouched.
203
+ masked, restorer = mask_code_blocks(markdown)
204
+
205
+ masked = transform_callouts(masked)
206
+ masked = transform_embeds(masked)
207
+ masked = transform_wiki_links(masked, current_url: current_url)
208
+ masked = transform_inline_tags(masked)
209
+
210
+ restorer.call(masked)
211
+ end
212
+
213
+ private
214
+
215
+ def mask_code_blocks(text)
216
+ placeholders = []
217
+ masked = text.gsub(FENCED_CODE_RE) do
218
+ placeholders << Regexp.last_match(0)
219
+ "\u0000FENCE#{placeholders.length - 1}\u0000"
220
+ end
221
+ masked = masked.gsub(INLINE_CODE_RE) do
222
+ placeholders << Regexp.last_match(0)
223
+ "\u0000CODE#{placeholders.length - 1}\u0000"
224
+ end
225
+
226
+ restorer = ->(t) {
227
+ t.gsub(/\u0000(?:FENCE|CODE)(\d+)\u0000/) { placeholders[Regexp.last_match(1).to_i] }
228
+ }
229
+
230
+ [masked, restorer]
231
+ end
232
+
233
+ # > [!type] Title → Bootstrap alert. Body is dedented (leading "> ").
234
+ def transform_callouts(text)
235
+ text.gsub(CALLOUT_RE) do
236
+ m = Regexp.last_match
237
+ type = m[:type].downcase
238
+ spec = CALLOUT_TYPES[type] || CALLOUT_TYPES['note']
239
+ fold = m[:fold]
240
+ title = m[:title].to_s.strip
241
+ title = type.capitalize if title.empty?
242
+ body = m[:body].to_s.gsub(/^[ \t]{0,3}>[ \t]?/, '').rstrip
243
+
244
+ # Allow inner Markdown by emitting a span with `markdown="1"` so
245
+ # kramdown still parses the body content.
246
+ collapsed_attr = fold == '-' ? ' data-collapsed="true"' : ''
247
+ alert_class = "alert alert-#{spec[:alert]} #{@config['callout_class_prefix']} #{@config['callout_class_prefix']}-#{type}"
248
+
249
+ <<~HTML
250
+
251
+ <div class="#{alert_class}" role="alert"#{collapsed_attr} markdown="0">
252
+ <div class="#{@config['callout_class_prefix']}-title"><i class="bi #{spec[:icon]} me-2" aria-hidden="true"></i>#{escape_html(title)}</div>
253
+ <div class="#{@config['callout_class_prefix']}-body" markdown="1">
254
+
255
+ #{body}
256
+
257
+ </div>
258
+ </div>
259
+
260
+ HTML
261
+ end
262
+ end
263
+
264
+ def transform_embeds(text)
265
+ text.gsub(EMBED_RE) do
266
+ target = Regexp.last_match(1).to_s.strip
267
+ modifier = Regexp.last_match(2).to_s.strip
268
+ ext = File.extname(target).downcase
269
+
270
+ if IMAGE_EXTENSIONS.include?(ext)
271
+ render_image_embed(target, modifier)
272
+ else
273
+ render_note_embed(target, modifier)
274
+ end
275
+ end
276
+ end
277
+
278
+ def render_image_embed(target, modifier)
279
+ width_attr = ''
280
+ alt = target
281
+ if !modifier.empty?
282
+ if modifier =~ /\A\d+\z/
283
+ width_attr = %( width="#{modifier}")
284
+ else
285
+ alt = modifier
286
+ end
287
+ end
288
+
289
+ # Resolve absolute paths (/foo/bar.png) verbatim, otherwise prefix the
290
+ # configured attachments path.
291
+ src = if target.start_with?('/')
292
+ target
293
+ else
294
+ "#{@config['attachments_path'].chomp('/')}/#{target}"
295
+ end
296
+
297
+ %(<img src="#{src}" alt="#{escape_html(alt)}" loading="lazy" class="obsidian-embed obsidian-embed-image"#{width_attr} />)
298
+ end
299
+
300
+ def render_note_embed(target, _modifier)
301
+ clean_target, anchor = split_anchor(target)
302
+ info = @index.lookup(clean_target)
303
+ if info.nil?
304
+ %(<div class="obsidian-embed obsidian-embed-broken alert alert-warning" role="alert">Embed not found: <code>#{escape_html(target)}</code></div>)
305
+ else
306
+ # Use a Liquid include so the embedded note can be rendered with the
307
+ # transclude template (avoids re-running this converter on its body).
308
+ url = info[:url].to_s
309
+ url += "##{anchor}" unless anchor.nil? || anchor.empty?
310
+ %({% include content/transclude.html target="#{escape_attr(clean_target)}" url="#{escape_attr(url)}" %})
311
+ end
312
+ end
313
+
314
+ def transform_wiki_links(text, current_url: nil)
315
+ text.gsub(LINK_RE) do
316
+ target = Regexp.last_match(1).to_s.strip
317
+ alias_text = Regexp.last_match(2).to_s.strip
318
+ clean_target, anchor = split_anchor(target)
319
+ display = alias_text.empty? ? (anchor.nil? ? clean_target : "#{clean_target} › #{anchor}") : alias_text
320
+
321
+ info = @index.lookup(clean_target)
322
+ if info.nil?
323
+ %(<a href="#" class="#{@config['broken_link_class']}" data-wiki-target="#{escape_attr(clean_target)}" title="Unresolved wiki-link: #{escape_attr(clean_target)}">#{escape_html(display)}</a>)
324
+ else
325
+ url = info[:url].to_s
326
+ url += "##{anchorize(anchor)}" unless anchor.nil? || anchor.empty?
327
+ current_attr = current_url && info[:url] == current_url ? ' aria-current="page"' : ''
328
+ %(<a href="#{url}" class="#{@config['wiki_link_class']}" data-wiki-target="#{escape_attr(clean_target)}"#{current_attr}>#{escape_html(display)}</a>)
329
+ end
330
+ end
331
+ end
332
+
333
+ def transform_inline_tags(text)
334
+ text.gsub(INLINE_TAG_RE) do
335
+ tag = Regexp.last_match(1)
336
+ base = @config['tag_base_url'].to_s
337
+ base = "#{base}/" unless base.end_with?('/')
338
+ %(<a href="#{base}##{slugify(tag)}" class="obsidian-tag">##{escape_html(tag)}</a>)
339
+ end
340
+ end
341
+
342
+ # Split "[[Page#Heading]]" or "[[Page^block-id]]" into [page, anchor].
343
+ # Block refs (^id) are degraded to a heading-style anchor.
344
+ def split_anchor(target)
345
+ if target =~ /\A(.+?)\^([\w-]+)\z/
346
+ [Regexp.last_match(1).strip, Regexp.last_match(2).strip]
347
+ elsif target =~ /\A(.+?)#(.+)\z/
348
+ [Regexp.last_match(1).strip, Regexp.last_match(2).strip]
349
+ else
350
+ [target, nil]
351
+ end
352
+ end
353
+
354
+ def anchorize(anchor)
355
+ return '' if anchor.nil?
356
+
357
+ anchor.downcase.strip.gsub(/[^\w\s-]/, '').gsub(/\s+/, '-')
358
+ end
359
+
360
+ def slugify(value)
361
+ value.downcase.gsub(/[^\w\/-]+/, '-').gsub(/-+/, '-').gsub(%r{/}, '-')
362
+ end
363
+
364
+ def escape_html(value)
365
+ value.to_s
366
+ .gsub('&', '&amp;')
367
+ .gsub('<', '&lt;')
368
+ .gsub('>', '&gt;')
369
+ .gsub('"', '&quot;')
370
+ end
371
+
372
+ def escape_attr(value)
373
+ escape_html(value).gsub("'", '&#39;')
374
+ end
375
+ end
376
+
377
+ # ---------------------------------------------------------------------
378
+ # Hook registration — runs once per build, then once per document.
379
+ # ---------------------------------------------------------------------
380
+ class << self
381
+ attr_accessor :index, :config
382
+
383
+ def site_config(site)
384
+ DEFAULT_CONFIG.merge(site.config['obsidian'] || {})
385
+ end
386
+
387
+ def install_hooks
388
+ Jekyll::Hooks.register :site, :pre_render do |site|
389
+ self.config = site_config(site)
390
+ if config['enabled']
391
+ self.index = Index.new(site)
392
+ site.config['obsidian'] = config.merge('index' => index.to_h)
393
+ Jekyll.logger.info('Obsidian:', "indexed #{index.entries.size} wiki-link targets")
394
+ else
395
+ Jekyll.logger.info('Obsidian:', 'integration disabled via _config.yml (obsidian.enabled = false)')
396
+ end
397
+ end
398
+
399
+ Jekyll::Hooks.register :documents, :pre_render do |doc|
400
+ rewrite_document(doc) if obsidian_enabled?(doc)
401
+ end
402
+
403
+ Jekyll::Hooks.register :pages, :pre_render do |page|
404
+ rewrite_document(page) if obsidian_enabled?(page) && markdown?(page)
405
+ end
406
+ end
407
+
408
+ def obsidian_enabled?(doc)
409
+ config && config['enabled'] && doc.respond_to?(:content) && doc.content.is_a?(String)
410
+ end
411
+
412
+ def markdown?(doc)
413
+ ext = File.extname(doc.relative_path).downcase
414
+ %w[.md .markdown].include?(ext)
415
+ end
416
+
417
+ def rewrite_document(doc)
418
+ return unless index
419
+
420
+ converter = Converter.new(nil, index, config)
421
+ doc.content = converter.convert(doc.content, current_url: doc.url)
422
+ end
423
+ end
424
+ end
425
+ end
426
+
427
+ Jekyll::Obsidian.install_hooks
@@ -0,0 +1,169 @@
1
+ // ============================================================================
2
+ // _obsidian.scss — Styles for Obsidian-flavoured Markdown features.
3
+ // Imported from _sass/custom.scss.
4
+ //
5
+ // Covers:
6
+ // - Wiki-links (resolved + broken state)
7
+ // - Inline tags
8
+ // - Note transclusion / image embeds
9
+ // - Callout cards beyond what Bootstrap's `.alert` provides
10
+ // - Backlinks panel
11
+ // ============================================================================
12
+
13
+ // --- Wiki-links ------------------------------------------------------------
14
+ .wiki-link {
15
+ text-decoration: none;
16
+ border-bottom: 1px dashed currentColor;
17
+ font-weight: 500;
18
+
19
+ &:hover {
20
+ text-decoration: underline;
21
+ border-bottom-color: transparent;
22
+ }
23
+
24
+ &[aria-current="page"] {
25
+ background-color: rgba(var(--bs-primary-rgb), 0.08);
26
+ padding: 0 0.15rem;
27
+ border-radius: 0.2rem;
28
+ }
29
+ }
30
+
31
+ .wiki-link-broken {
32
+ color: var(--bs-danger);
33
+ border-bottom-style: dotted;
34
+ cursor: help;
35
+
36
+ &:hover {
37
+ color: var(--bs-danger);
38
+ background-color: rgba(var(--bs-danger-rgb), 0.05);
39
+ }
40
+ }
41
+
42
+ // --- Inline tags -----------------------------------------------------------
43
+ .obsidian-tag {
44
+ display: inline-block;
45
+ padding: 0.05rem 0.45rem;
46
+ margin: 0 0.1rem;
47
+ font-size: 0.85em;
48
+ font-weight: 500;
49
+ color: var(--bs-secondary);
50
+ background-color: rgba(var(--bs-secondary-rgb), 0.1);
51
+ border-radius: 0.5rem;
52
+ text-decoration: none;
53
+ transition: background-color 0.15s ease;
54
+
55
+ &:hover,
56
+ &:focus {
57
+ background-color: rgba(var(--bs-secondary-rgb), 0.2);
58
+ color: var(--bs-primary);
59
+ text-decoration: none;
60
+ }
61
+ }
62
+
63
+ // --- Embeds (image + note transclusion) -----------------------------------
64
+ .obsidian-embed-image {
65
+ display: block;
66
+ max-width: 100%;
67
+ height: auto;
68
+ margin: 1rem auto;
69
+ border-radius: 0.375rem;
70
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
71
+ }
72
+
73
+ .obsidian-embed-note {
74
+ border-left: 3px solid var(--bs-primary);
75
+
76
+ .obsidian-embed-source {
77
+ font-size: 0.875rem;
78
+ color: var(--bs-secondary);
79
+ }
80
+
81
+ .obsidian-embed-body {
82
+ background-color: rgba(var(--bs-light-rgb), 0.5);
83
+ }
84
+
85
+ [data-bs-theme="dark"] & .obsidian-embed-body {
86
+ background-color: rgba(var(--bs-dark-rgb), 0.4);
87
+ }
88
+ }
89
+
90
+ .obsidian-embed-broken {
91
+ font-size: 0.9rem;
92
+
93
+ code {
94
+ background-color: rgba(0, 0, 0, 0.06);
95
+ padding: 0.1rem 0.3rem;
96
+ border-radius: 0.25rem;
97
+ }
98
+ }
99
+
100
+ // --- Callouts -------------------------------------------------------------
101
+ .obsidian-callout {
102
+ border-left-width: 4px;
103
+ border-left-style: solid;
104
+ margin: 1.25rem 0;
105
+ padding: 0.85rem 1rem;
106
+
107
+ .obsidian-callout-title {
108
+ font-weight: 600;
109
+ margin-bottom: 0.4rem;
110
+ display: flex;
111
+ align-items: center;
112
+ }
113
+
114
+ .obsidian-callout-body {
115
+ margin: 0;
116
+
117
+ > :last-child {
118
+ margin-bottom: 0;
119
+ }
120
+ }
121
+
122
+ &[data-collapsed="true"] .obsidian-callout-body {
123
+ display: none;
124
+ }
125
+ }
126
+
127
+ // --- Backlinks panel -------------------------------------------------------
128
+ .obsidian-backlinks {
129
+ .obsidian-backlinks-list {
130
+ margin-bottom: 0;
131
+ }
132
+
133
+ .obsidian-backlink {
134
+ padding: 0.5rem 0.75rem;
135
+ border-left: 2px solid transparent;
136
+ border-radius: 0 0.25rem 0.25rem 0;
137
+ transition: background-color 0.15s ease, border-color 0.15s ease;
138
+
139
+ &:hover {
140
+ background-color: rgba(var(--bs-primary-rgb), 0.05);
141
+ border-left-color: var(--bs-primary);
142
+ }
143
+ }
144
+
145
+ .obsidian-backlink-link {
146
+ text-decoration: none;
147
+ }
148
+
149
+ .obsidian-backlink-excerpt {
150
+ margin-top: 0.2rem;
151
+ }
152
+ }
153
+
154
+ // ----------------------------------------------------------------------------
155
+ // Local graph widget (left sidebar) — small Obsidian-style local graph view.
156
+ // Rendered by _includes/navigation/local-graph.html via assets/js/obsidian-local-graph.js.
157
+ // ----------------------------------------------------------------------------
158
+ .obsidian-local-graph-widget {
159
+ #obsidian-local-graph {
160
+ width: 100%;
161
+ height: 220px;
162
+ min-height: 180px;
163
+ border: 1px solid var(--bs-border-color, #dee2e6);
164
+ border-radius: var(--bs-border-radius, 0.375rem);
165
+ background: var(--bs-tertiary-bg, #f8f9fa);
166
+ overflow: hidden;
167
+ position: relative;
168
+ }
169
+ }
data/_sass/custom.scss CHANGED
@@ -12,6 +12,9 @@
12
12
  @import "core/navbar";
13
13
  @import "core/offcanvas-panels";
14
14
 
15
+ // Obsidian-flavoured markdown features (wiki-links, callouts, backlinks, …)
16
+ @import "core/obsidian";
17
+
15
18
  html, body {
16
19
  max-width: 100%;
17
20
  // overflow-x: hidden;
@@ -0,0 +1,74 @@
1
+ ---
2
+ # Generated wiki-link index for Obsidian-style [[…]] resolution on the
3
+ # rendered site. Consumed by:
4
+ # - assets/js/obsidian-wiki-links.js (client-side fallback used on the
5
+ # GitHub Pages remote_theme build, where _plugins/obsidian_links.rb
6
+ # does not run)
7
+ # - _includes/content/backlinks.html (per-page backlinks panel)
8
+ #
9
+ # Output: assets/data/wiki-index.json
10
+ #
11
+ # Layout: null so Jekyll just renders the body verbatim.
12
+ layout: null
13
+ permalink: /assets/data/wiki-index.json
14
+ sitemap: false
15
+ ---
16
+ {%- comment -%}
17
+ Build a flat array of every renderable document with title, basename,
18
+ permalink, collection, tags, and the body's outgoing wiki-link targets.
19
+ The JS resolver normalizes lookup keys to lowercase + collapsed whitespace
20
+ (matching _plugins/obsidian_links.rb#normalize).
21
+ {%- endcomment -%}
22
+ {%- assign all_docs = "" | split: "" -%}
23
+ {%- for collection in site.collections -%}
24
+ {%- for doc in collection.docs -%}
25
+ {%- assign all_docs = all_docs | push: doc -%}
26
+ {%- endfor -%}
27
+ {%- endfor -%}
28
+ {%- for page in site.pages -%}
29
+ {%- if page.output_ext == ".html" and page.url -%}
30
+ {%- assign all_docs = all_docs | push: page -%}
31
+ {%- endif -%}
32
+ {%- endfor -%}
33
+ {
34
+ "generated_at": {{ site.time | date_to_xmlschema | jsonify }},
35
+ "count": {{ all_docs.size | jsonify }},
36
+ "entries": [
37
+ {%- for doc in all_docs -%}
38
+ {%- assign basename = doc.path | split: "/" | last | replace: ".md", "" | replace: ".markdown", "" | replace: ".html", "" -%}
39
+ {%- assign body = doc.content | default: "" | strip_html -%}
40
+ {%- assign body_excerpt = body | strip_newlines | truncate: 240 -%}
41
+ {%- comment -%}
42
+ Extract outgoing wiki-link targets so the graph view
43
+ (assets/js/obsidian-graph.js) can render edges without re-parsing
44
+ every page client-side. We mask `![[…]]` embeds first so they don't
45
+ also get split as `[[…]]`, then split on `[[`, take everything
46
+ before `]]`, and strip the optional `|alias` and `#anchor` parts.
47
+ {%- endcomment -%}
48
+ {%- assign masked = body | replace: "![[", "@@OBSEMBED@@" -%}
49
+ {%- assign chunks = masked | split: "[[" -%}
50
+ {%- assign outgoing = "" | split: "" -%}
51
+ {%- for chunk in chunks offset: 1 -%}
52
+ {%- assign target_raw = chunk | split: "]]" | first -%}
53
+ {%- if target_raw and target_raw != "" -%}
54
+ {%- assign target = target_raw | split: "|" | first | split: "#" | first | split: "^" | first | strip | downcase -%}
55
+ {%- if target != "" and target.size < 200 -%}
56
+ {%- assign outgoing = outgoing | push: target -%}
57
+ {%- endif -%}
58
+ {%- endif -%}
59
+ {%- endfor -%}
60
+ {%- assign outgoing = outgoing | uniq -%}
61
+ {
62
+ "title": {{ doc.title | default: basename | jsonify }},
63
+ "basename": {{ basename | jsonify }},
64
+ "url": {{ doc.url | jsonify }},
65
+ "collection": {{ doc.collection | default: nil | jsonify }},
66
+ "tags": {{ doc.tags | default: empty | jsonify }},
67
+ "categories": {{ doc.categories | default: empty | jsonify }},
68
+ "aliases": {{ doc.aliases | default: empty | jsonify }},
69
+ "outgoing": {{ outgoing | jsonify }},
70
+ "excerpt": {{ body_excerpt | jsonify }}
71
+ }{% unless forloop.last %},{% endunless %}
72
+ {%- endfor -%}
73
+ ]
74
+ }