jekyll-theme-zer0 1.2.1 → 1.4.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.
@@ -1,91 +1,104 @@
1
- ---
2
- layout: root
3
- ---
4
- <!--
5
- ===================================================================
6
- DEFAULT LAYOUT - Standard content layout with sidebars
7
- ===================================================================
8
-
9
- File: default.html
10
- Path: _layouts/default.html
11
- Inherits: root.html
12
- Purpose: Primary content layout with sidebar navigation and table of contents
13
-
14
- Template Logic:
15
- - Creates a responsive three-column layout using Bootstrap grid
16
- - Left sidebar: Site navigation and content outline (collapsible on mobile)
17
- - Center: Main content area with page title and body content
18
- - Right sidebar: Table of contents and page shortcuts (hidden on mobile)
19
- - Implements scroll spy for navigation highlighting
20
- - Responsive design that stacks vertically on mobile devices
21
-
22
- Layout Structure:
23
- 1. Container wrapper with Bootstrap responsive classes
24
- 2. Left sidebar (bd-sidebar) - Navigation and outline
25
- 3. Main content area (bd-main) with:
26
- - Intro section (page title, metadata)
27
- - Right sidebar (bd-toc) - Table of contents
28
- - Content area (bd-content) - Main page content
29
-
30
- Dependencies:
31
- - sidebar-left.html: Site navigation and content outline
32
- - intro.html: Page title, breadcrumbs, and metadata
33
- - sidebar-right.html: Table of contents and page shortcuts
34
-
35
- Bootstrap Classes Used:
36
- - container-xxl: Extra large responsive container
37
- - bd-gutter: Custom Bootstrap spacing
38
- - bd-layout: Custom layout utility class
39
- - bd-sidebar: Custom sidebar styling
40
- - bd-main: Main content area
41
- - bd-toc: Table of contents styling
42
- - bd-content: Content area styling
43
- ===================================================================
44
- -->
45
-
46
- <!-- ================================================ -->
47
- <!-- MAIN LAYOUT CONTAINER -->
48
- <!-- Bootstrap responsive container with custom grid -->
49
- <!-- ================================================ -->
50
- <div class="container-xxl bd-gutter mt-3 my-md-4 bd-layout">
51
-
52
- <!-- ================================ -->
53
- <!-- LEFT SIDEBAR - Navigation -->
54
- <!-- ================================ -->
55
- <!-- Site navigation, content outline, and offcanvas menu for mobile -->
56
- <!-- Sidebar visibility controlled by page.sidebar front matter -->
57
- {% unless page.sidebar == false %}
58
- <aside class="bd-sidebar">
59
- {% include navigation/sidebar-left.html %}
60
- </aside>
61
- {% endunless %}
62
-
63
- <!-- ================================ -->
64
- <!-- MAIN CONTENT AREA -->
65
- <!-- ================================ -->
66
- <!-- Primary content section with scroll spy for table of contents navigation -->
67
- <main class="bd-main order-1" data-bs-spy="scroll" data-bs-target="#TableOfContents" data-bs-offset="100" data-bs-smooth-scroll="true">
68
-
69
- <!-- Page introduction: title, breadcrumbs, metadata -->
70
- {% include content/intro.html %}
71
-
72
- <!-- =============================== -->
73
- <!-- RIGHT SIDEBAR - Table of Contents -->
74
- <!-- =============================== -->
75
- <!-- Page outline, shortcuts, and related links (hidden on mobile) -->
76
- <!-- Right sidebar visibility also controlled by page.sidebar -->
77
- {% unless page.sidebar == false %}
78
- <div class="bd-toc text-body-secondary">
79
- {% include navigation/sidebar-right.html %}
80
- </div>
81
- {% endunless %}
82
-
83
- <!-- =============================== -->
84
- <!-- MAIN CONTENT BODY -->
85
- <!-- =============================== -->
86
- <!-- Where the actual page content is rendered -->
87
- <div class="bd-content ps-lg-2">
88
- {{ content }}
89
- </div>
90
- </main>
91
- </div>
1
+ ---
2
+ layout: root
3
+ ---
4
+ <!--
5
+ ===================================================================
6
+ DEFAULT LAYOUT - Standard content layout with sidebars
7
+ ===================================================================
8
+
9
+ File: default.html
10
+ Path: _layouts/default.html
11
+ Inherits: root.html
12
+ Purpose: Primary content layout with sidebar navigation and table of contents
13
+
14
+ Template Logic:
15
+ - Creates a responsive three-column layout using Bootstrap grid
16
+ - Left sidebar: Site navigation and content outline (collapsible on mobile)
17
+ - Local graph: Independent Obsidian side panel with its own collapsible control
18
+ - Center: Main content area with page title and body content
19
+ - Right sidebar: Table of contents and page shortcuts (hidden on mobile)
20
+ - Implements scroll spy for navigation highlighting
21
+ - Responsive design that stacks vertically on mobile devices
22
+
23
+ Layout Structure:
24
+ 1. Container wrapper with Bootstrap responsive classes
25
+ 2. Left sidebar (bd-sidebar) - Navigation and outline
26
+ 3. Main content area (bd-main) with:
27
+ - Intro section (page title, metadata)
28
+ - Right sidebar (bd-toc) - Table of contents
29
+ - Content area (bd-content) - Main page content
30
+
31
+ Dependencies:
32
+ - sidebar-left.html: Site navigation and content outline
33
+ - intro.html: Page title, breadcrumbs, and metadata
34
+ - sidebar-right.html: Table of contents and page shortcuts
35
+
36
+ Bootstrap Classes Used:
37
+ - container-xxl: Extra large responsive container
38
+ - bd-gutter: Custom Bootstrap spacing
39
+ - bd-layout: Custom layout utility class
40
+ - bd-sidebar: Custom sidebar styling
41
+ - bd-main: Main content area
42
+ - bd-toc: Table of contents styling
43
+ - bd-content: Content area styling
44
+ ===================================================================
45
+ -->
46
+
47
+ <!-- ================================================ -->
48
+ <!-- MAIN LAYOUT CONTAINER -->
49
+ <!-- Bootstrap responsive container with custom grid -->
50
+ <!-- ================================================ -->
51
+ <div class="container-xxl bd-gutter mt-3 my-md-4 bd-layout">
52
+
53
+ <!-- ================================ -->
54
+ <!-- LEFT SIDEBAR - Navigation -->
55
+ <!-- ================================ -->
56
+ <!-- Site navigation, content outline, and offcanvas menu for mobile -->
57
+ <!-- Sidebar visibility controlled by page.sidebar front matter -->
58
+ {% unless page.sidebar == false %}
59
+ <aside class="bd-sidebar">
60
+ {% include navigation/sidebar-left.html %}
61
+ </aside>
62
+
63
+ <!-- Separate Obsidian local graph panel; intentionally outside bdSidebar. -->
64
+ {% include navigation/local-graph.html %}
65
+ {% endunless %}
66
+
67
+ <!-- ================================ -->
68
+ <!-- MAIN CONTENT AREA -->
69
+ <!-- ================================ -->
70
+ <!-- Primary content section with scroll spy for table of contents navigation -->
71
+ <main class="bd-main order-1" data-bs-spy="scroll" data-bs-target="#TableOfContents" data-bs-offset="100" data-bs-smooth-scroll="true">
72
+
73
+ <!-- Page introduction: title, breadcrumbs, metadata -->
74
+ {% include content/intro.html %}
75
+
76
+ <!-- =============================== -->
77
+ <!-- RIGHT SIDEBAR - Table of Contents -->
78
+ <!-- =============================== -->
79
+ <!-- Page outline, shortcuts, and related links (hidden on mobile) -->
80
+ <!-- Right sidebar visibility also controlled by page.sidebar -->
81
+ {% unless page.sidebar == false %}
82
+ <div class="bd-toc text-body-secondary">
83
+ {% include navigation/sidebar-right.html %}
84
+ </div>
85
+ {% endunless %}
86
+
87
+ <!-- =============================== -->
88
+ <!-- MAIN CONTENT BODY -->
89
+ <!-- =============================== -->
90
+ <!-- Where the actual page content is rendered -->
91
+ <div class="bd-content ps-lg-2">
92
+ {{ content }}
93
+
94
+ {%- comment -%}
95
+ Optional Obsidian-style backlinks panel. Opt-in for the generic
96
+ default layout via `backlinks: true` in front matter (the `note`
97
+ layout enables it by default).
98
+ {%- endcomment -%}
99
+ {% if page.backlinks == true %}
100
+ {% include content/backlinks.html %}
101
+ {% endif %}
102
+ </div>
103
+ </main>
104
+ </div>
data/_layouts/note.html CHANGED
@@ -222,7 +222,13 @@ layout: default
222
222
  {% include content/giscus.html %}
223
223
  </div>
224
224
  {% endif %}
225
-
225
+ <!-- ================================ -->
226
+ <!-- BACKLINKS — "Linked mentions" -->
227
+ <!-- ================================ -->
228
+ <!-- Obsidian-style panel of pages that wiki-link or markdown-link to this note. -->
229
+ <!-- Disable per-page with `backlinks: false` in front matter. -->
230
+ {% include content/backlinks.html %}
231
+
226
232
  </article>
227
233
 
228
234
  <!-- ================================ -->
@@ -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