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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +28 -4
- data/_includes/components/js-cdn.html +5 -1
- data/_includes/content/backlinks.html +103 -0
- data/_includes/content/transclude.html +62 -0
- data/_includes/navigation/local-graph.html +44 -0
- data/_includes/navigation/sidebar-left.html +6 -0
- data/_layouts/default.html +9 -0
- data/_layouts/note.html +7 -1
- data/_plugins/obsidian_links.rb +427 -0
- data/_sass/core/_obsidian.scss +169 -0
- data/_sass/custom.scss +3 -0
- data/assets/data/wiki-index.json +74 -0
- data/assets/js/obsidian-graph.js +475 -0
- data/assets/js/obsidian-local-graph.js +382 -0
- data/assets/js/obsidian-wiki-links.js +341 -0
- data/scripts/lint-pages +6 -0
- metadata +11 -2
|
@@ -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('&', '&')
|
|
367
|
+
.gsub('<', '<')
|
|
368
|
+
.gsub('>', '>')
|
|
369
|
+
.gsub('"', '"')
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def escape_attr(value)
|
|
373
|
+
escape_html(value).gsub("'", ''')
|
|
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
|
@@ -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
|
+
}
|