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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +28 -4
- data/_includes/components/js-cdn.html +12 -1
- data/_includes/content/backlinks.html +107 -0
- data/_includes/content/transclude.html +62 -0
- data/_includes/navigation/local-graph.html +79 -0
- data/_includes/navigation/sidebar-left.html +6 -0
- data/_layouts/default.html +104 -91
- data/_layouts/note.html +7 -1
- data/_plugins/obsidian_links.rb +427 -0
- data/_sass/core/_obsidian.scss +242 -0
- data/_sass/core/_offcanvas-panels.scss +8 -4
- 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 +434 -0
- data/assets/js/obsidian-wiki-links.js +358 -0
- data/scripts/README.md +22 -0
- data/scripts/bin/test +62 -0
- data/scripts/bin/validate +596 -0
- data/scripts/lib/README.md +16 -0
- data/scripts/lint-pages +6 -0
- data/scripts/validate +11 -0
- metadata +13 -2
data/_layouts/default.html
CHANGED
|
@@ -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
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
-
|
|
38
|
-
- bd-
|
|
39
|
-
- bd-
|
|
40
|
-
- bd-
|
|
41
|
-
- bd-
|
|
42
|
-
- bd-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
<!--
|
|
48
|
-
<!--
|
|
49
|
-
<!--
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<!--
|
|
54
|
-
<!--
|
|
55
|
-
<!--
|
|
56
|
-
<!--
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<!--
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<!--
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
<!--
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{%
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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('&', '&')
|
|
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
|