jekyll-theme-zer0 1.21.0 → 1.22.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 +18 -0
- data/_data/backlog.yml +124 -2
- data/_includes/components/component-showcase.html +18 -10
- data/_includes/components/js-cdn.html +10 -2
- data/_includes/components/post-card.html +18 -4
- data/_includes/content/backlinks.html +47 -18
- data/_includes/content/giscus.html +30 -44
- data/_includes/core/footer-fabs.html +5 -2
- data/_includes/navigation/local-graph-fab.html +1 -1
- data/_includes/navigation/local-graph.html +4 -3
- data/_includes/navigation/section-sidebar.html +10 -3
- data/_includes/obsidian/full-graph.html +7 -5
- data/_layouts/article.html +23 -6
- data/_plugins/obsidian_links.rb +84 -15
- data/_sass/core/_obsidian.scss +59 -8
- data/assets/data/wiki-index.json +50 -3
- data/assets/js/obsidian-graph.js +66 -21
- data/assets/js/obsidian-local-graph.js +125 -32
- data/assets/js/obsidian-wiki-links.js +118 -21
- data/assets/js/search-modal.js +45 -9
- data/scripts/bin/giscus-discussions +509 -0
- metadata +3 -2
data/_layouts/article.html
CHANGED
|
@@ -160,16 +160,33 @@ layout: default
|
|
|
160
160
|
</div>
|
|
161
161
|
|
|
162
162
|
<!-- Category Link -->
|
|
163
|
-
{% comment %} Category base is configurable (`category_base`, default `/news`)
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
{% comment %} Category base is configurable (`category_base`, default `/news`).
|
|
164
|
+
Only link the badge when that category index page actually exists in the
|
|
165
|
+
build; on a remote-theme Pages consumer the category section is
|
|
166
|
+
plugin/page-generated and absent, so we render a plain badge instead of a
|
|
167
|
+
link that 404s (mirrors the tag-badge and footer Quick-Links guard).
|
|
168
|
+
See issue #204. {% endcomment %}
|
|
166
169
|
{% if page.categories.size > 0 %}
|
|
167
170
|
{% assign _category_base = site.category_base | default: '/news' %}
|
|
171
|
+
{% assign _primary_category = page.categories | first %}
|
|
172
|
+
{% capture _category_url %}{{ _category_base }}/{{ _primary_category | slugify }}/{% endcapture %}
|
|
173
|
+
{% comment %} Category index pages can live in site.pages (a regular page) or
|
|
174
|
+
site.posts (a dated section index), so check both. {% endcomment %}
|
|
175
|
+
{% assign _category_page = site.html_pages | where: "url", _category_url | first %}
|
|
176
|
+
{% unless _category_page %}
|
|
177
|
+
{% assign _category_page = site.posts | where: "url", _category_url | first %}
|
|
178
|
+
{% endunless %}
|
|
168
179
|
<div class="mb-3">
|
|
169
|
-
|
|
180
|
+
{% if _category_page %}
|
|
181
|
+
<a href="{{ site.baseurl }}{{ _category_url }}"
|
|
170
182
|
class="badge bg-primary text-decoration-none fs-6">
|
|
171
|
-
<i class="bi bi-folder me-1"></i>{{
|
|
183
|
+
<i class="bi bi-folder me-1"></i>{{ _primary_category }}
|
|
172
184
|
</a>
|
|
185
|
+
{% else %}
|
|
186
|
+
<span class="badge bg-primary fs-6">
|
|
187
|
+
<i class="bi bi-folder me-1"></i>{{ _primary_category }}
|
|
188
|
+
</span>
|
|
189
|
+
{% endif %}
|
|
173
190
|
</div>
|
|
174
191
|
{% endif %}
|
|
175
192
|
|
|
@@ -512,7 +529,7 @@ layout: default
|
|
|
512
529
|
<!-- ================================ -->
|
|
513
530
|
<!-- COMMENT SYSTEM -->
|
|
514
531
|
<!-- ================================ -->
|
|
515
|
-
{% if page.comments != false and site.giscus %}
|
|
532
|
+
{% if page.comments != false and site.giscus.enabled %}
|
|
516
533
|
<section class="post-comments mt-5 pt-4 border-top" id="comments">
|
|
517
534
|
<h2 class="h4 mb-4">
|
|
518
535
|
<i class="bi bi-chat-dots me-2"></i>Comments
|
data/_plugins/obsidian_links.rb
CHANGED
|
@@ -185,10 +185,20 @@ module Jekyll
|
|
|
185
185
|
# Converter — pure-string transformations on the raw markdown body.
|
|
186
186
|
# ---------------------------------------------------------------------
|
|
187
187
|
class Converter
|
|
188
|
-
# Match fenced code blocks
|
|
189
|
-
#
|
|
190
|
-
|
|
191
|
-
|
|
188
|
+
# Match fenced code blocks and inline code spans so we can skip them when
|
|
189
|
+
# rewriting wiki-link / tag syntax.
|
|
190
|
+
#
|
|
191
|
+
# FENCED_CODE_RE has two branches (closed fence first so it is not greedily
|
|
192
|
+
# swallowed by the EOF branch):
|
|
193
|
+
# 1. A variable-length fence (3+ backticks or tildes) closed by an
|
|
194
|
+
# equal-length run — `\2` requires the close to match the open exactly,
|
|
195
|
+
# so a 4-backtick fence is not closed by an inner ``` fence.
|
|
196
|
+
# 2. An unclosed fence runs to end-of-input (matches Obsidian/kramdown,
|
|
197
|
+
# which treat a dangling fence as code through EOF).
|
|
198
|
+
FENCED_CODE_RE = /(^[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n.*?^[ \t]{0,3}\2[^\n]*$)|(^[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n.*\z)/m
|
|
199
|
+
# Inline code: the delimiter is a run of N backticks; the span may contain
|
|
200
|
+
# shorter backtick runs (CommonMark), but not a newline or the closing run.
|
|
201
|
+
INLINE_CODE_RE = /(`+)(?:[^`\n]|(?!\1)`)*?\1/
|
|
192
202
|
|
|
193
203
|
# ![[target]] or ![[target|width-or-alias]]
|
|
194
204
|
EMBED_RE = /!\[\[([^\]\n|]+?)(?:\|([^\]\n]+))?\]\]/
|
|
@@ -209,6 +219,7 @@ module Jekyll
|
|
|
209
219
|
@site = site
|
|
210
220
|
@index = index
|
|
211
221
|
@config = config
|
|
222
|
+
@callout_seq = 0
|
|
212
223
|
end
|
|
213
224
|
|
|
214
225
|
def convert(markdown, current_url: nil)
|
|
@@ -246,26 +257,47 @@ module Jekyll
|
|
|
246
257
|
end
|
|
247
258
|
|
|
248
259
|
# > [!type] Title → Bootstrap alert. Body is dedented (leading "> ").
|
|
260
|
+
# Fold markers make the callout an accessible disclosure:
|
|
261
|
+
# `-` collapsed by default, `+` expanded; both get a <button> toggle.
|
|
262
|
+
# Plain callouts (no marker) stay a static heading.
|
|
249
263
|
def transform_callouts(text)
|
|
250
264
|
text.gsub(CALLOUT_RE) do
|
|
251
265
|
m = Regexp.last_match
|
|
252
266
|
type = m[:type].downcase
|
|
253
267
|
spec = CALLOUT_TYPES[type] || CALLOUT_TYPES['note']
|
|
254
268
|
fold = m[:fold]
|
|
269
|
+
foldable = fold == '+' || fold == '-'
|
|
270
|
+
collapsed = fold == '-'
|
|
255
271
|
title = m[:title].to_s.strip
|
|
256
272
|
title = type.capitalize if title.empty?
|
|
257
273
|
body = m[:body].to_s.gsub(/^[ \t]{0,3}>[ \t]?/, '').rstrip
|
|
258
274
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
275
|
+
prefix = @config['callout_class_prefix']
|
|
276
|
+
collapsed_attr = collapsed ? ' data-collapsed="true"' : ''
|
|
277
|
+
alert_class = "alert alert-#{spec[:alert]} #{prefix} #{prefix}-#{type}"
|
|
278
|
+
body_id = "#{prefix}-body-#{next_callout_id}"
|
|
279
|
+
body_hidden = collapsed ? ' hidden' : ''
|
|
280
|
+
|
|
281
|
+
icon = %(<i class="bi #{spec[:icon]} me-2" aria-hidden="true"></i>)
|
|
282
|
+
# Voice the callout type for screen readers (the icon is decorative).
|
|
283
|
+
sr_type = %(<span class="visually-hidden">#{escape_html(type.capitalize)}: </span>)
|
|
284
|
+
title_inner = "#{icon}#{sr_type}#{escape_html(title)}"
|
|
285
|
+
|
|
286
|
+
title_el =
|
|
287
|
+
if foldable
|
|
288
|
+
chevron = %(<i class="bi bi-chevron-down #{prefix}-chevron ms-auto" aria-hidden="true"></i>)
|
|
289
|
+
%(<button type="button" class="#{prefix}-title #{prefix}-toggle" aria-expanded="#{collapsed ? 'false' : 'true'}" aria-controls="#{body_id}">#{title_inner}#{chevron}</button>)
|
|
290
|
+
else
|
|
291
|
+
%(<div class="#{prefix}-title" role="heading" aria-level="3">#{title_inner}</div>)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Outer div is markdown="0" so kramdown leaves the chrome untouched;
|
|
295
|
+
# the body is markdown="1" so its content is still parsed.
|
|
264
296
|
<<~HTML
|
|
265
297
|
|
|
266
298
|
<div class="#{alert_class}" role="alert"#{collapsed_attr} markdown="0">
|
|
267
|
-
|
|
268
|
-
<div class="#{
|
|
299
|
+
#{title_el}
|
|
300
|
+
<div class="#{prefix}-body" id="#{body_id}"#{body_hidden} markdown="1">
|
|
269
301
|
|
|
270
302
|
#{body}
|
|
271
303
|
|
|
@@ -276,6 +308,10 @@ module Jekyll
|
|
|
276
308
|
end
|
|
277
309
|
end
|
|
278
310
|
|
|
311
|
+
def next_callout_id
|
|
312
|
+
@callout_seq += 1
|
|
313
|
+
end
|
|
314
|
+
|
|
279
315
|
def transform_embeds(text)
|
|
280
316
|
text.gsub(EMBED_RE) do
|
|
281
317
|
target = Regexp.last_match(1).to_s.strip
|
|
@@ -321,7 +357,7 @@ module Jekyll
|
|
|
321
357
|
# Use a Liquid include so the embedded note can be rendered with the
|
|
322
358
|
# transclude template (avoids re-running this converter on its body).
|
|
323
359
|
url = info[:url].to_s
|
|
324
|
-
url += "##{anchor}" unless anchor.nil? || anchor.empty?
|
|
360
|
+
url += "##{anchorize(anchor)}" unless anchor.nil? || anchor.empty?
|
|
325
361
|
%({% include content/transclude.html target="#{escape_attr(clean_target)}" url="#{escape_attr(url)}" %})
|
|
326
362
|
end
|
|
327
363
|
end
|
|
@@ -335,7 +371,9 @@ module Jekyll
|
|
|
335
371
|
|
|
336
372
|
info = @index.lookup(clean_target)
|
|
337
373
|
if info.nil?
|
|
338
|
-
|
|
374
|
+
# A broken link points nowhere — render a non-navigating <span> so a
|
|
375
|
+
# click can't scroll the page to the top (the old href="#" bug).
|
|
376
|
+
%(<span class="#{@config['broken_link_class']}" data-wiki-target="#{escape_attr(clean_target)}" title="Unresolved wiki-link: #{escape_attr(clean_target)}">#{escape_html(display)}</span>)
|
|
339
377
|
else
|
|
340
378
|
url = info[:url].to_s
|
|
341
379
|
url += "##{anchorize(anchor)}" unless anchor.nil? || anchor.empty?
|
|
@@ -345,9 +383,25 @@ module Jekyll
|
|
|
345
383
|
end
|
|
346
384
|
end
|
|
347
385
|
|
|
386
|
+
# Hex-colour-shaped tokens in prose (#ffffff, #fff, #1a2b3c) are not tags.
|
|
387
|
+
# Suppress standard CSS hex lengths (3/6/8) and any digit-containing hex;
|
|
388
|
+
# length 4/5/7 all-letter tokens stay tags so iconic hex-words (#cafe,
|
|
389
|
+
# #dead, #beef, #face) still link. Mirrors isColorLikeTag() in
|
|
390
|
+
# assets/js/obsidian-wiki-links.js byte-for-byte.
|
|
391
|
+
def color_like_tag?(tag)
|
|
392
|
+
return false unless tag =~ /\A[0-9a-fA-F]+\z/
|
|
393
|
+
|
|
394
|
+
len = tag.length
|
|
395
|
+
return true if [3, 6, 8].include?(len)
|
|
396
|
+
|
|
397
|
+
len.between?(3, 8) && tag =~ /\d/ ? true : false
|
|
398
|
+
end
|
|
399
|
+
|
|
348
400
|
def transform_inline_tags(text)
|
|
349
401
|
text.gsub(INLINE_TAG_RE) do
|
|
350
402
|
tag = Regexp.last_match(1)
|
|
403
|
+
next "##{tag}" if color_like_tag?(tag)
|
|
404
|
+
|
|
351
405
|
base = @config['tag_base_url'].to_s
|
|
352
406
|
base = "#{base}/" unless base.end_with?('/')
|
|
353
407
|
%(<a href="#{base}##{slugify(tag)}" class="obsidian-tag">##{escape_html(tag)}</a>)
|
|
@@ -366,14 +420,29 @@ module Jekyll
|
|
|
366
420
|
end
|
|
367
421
|
end
|
|
368
422
|
|
|
423
|
+
# Replicate kramdown's basic header-id algorithm so [[Page#Heading]]
|
|
424
|
+
# fragments land on the heading kramdown actually generated:
|
|
425
|
+
# strip leading non-letters → drop chars outside [a-zA-Z0-9 -] →
|
|
426
|
+
# spaces to dashes (consecutive spaces kept as consecutive dashes,
|
|
427
|
+
# matching kramdown) → downcase. Underscores are removed, not kept.
|
|
428
|
+
# Must stay byte-identical to anchorize() in assets/js/obsidian-wiki-links.js.
|
|
369
429
|
def anchorize(anchor)
|
|
370
430
|
return '' if anchor.nil?
|
|
371
431
|
|
|
372
|
-
anchor.
|
|
432
|
+
anchor.to_s.strip
|
|
433
|
+
.gsub(/^[^a-zA-Z]+/, '')
|
|
434
|
+
.gsub(/[^a-zA-Z0-9 -]/, '')
|
|
435
|
+
.tr(' ', '-')
|
|
436
|
+
.downcase
|
|
373
437
|
end
|
|
374
438
|
|
|
439
|
+
# Match Jekyll's default `slugify` filter (the same one pages/tags.md uses
|
|
440
|
+
# for its anchor ids) so inline #tags link to a real on-page anchor.
|
|
441
|
+
# Inline tags are ASCII-only by construction (INLINE_TAG_RE), so the
|
|
442
|
+
# ASCII-equivalent of Jekyll::Utils.slugify is byte-identical here and
|
|
443
|
+
# avoids a hard Jekyll dependency in the standalone unit tests.
|
|
375
444
|
def slugify(value)
|
|
376
|
-
value.downcase.gsub(/[
|
|
445
|
+
value.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
|
|
377
446
|
end
|
|
378
447
|
|
|
379
448
|
def escape_html(value)
|
data/_sass/core/_obsidian.scss
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
border-bottom-color: transparent;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
&:focus-visible {
|
|
25
|
+
outline: 2px solid var(--bs-primary);
|
|
26
|
+
outline-offset: 2px;
|
|
27
|
+
border-bottom-color: transparent;
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
&[aria-current="page"] {
|
|
25
31
|
background-color: rgba(var(--bs-primary-rgb), 0.08);
|
|
26
32
|
padding: 0 0.15rem;
|
|
@@ -46,16 +52,22 @@
|
|
|
46
52
|
margin: 0 0.1rem;
|
|
47
53
|
font-size: 0.85em;
|
|
48
54
|
font-weight: 500;
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
// Theme-aware tokens give legible contrast in both light and dark modes.
|
|
56
|
+
color: var(--bs-secondary-color);
|
|
57
|
+
background-color: var(--bs-secondary-bg);
|
|
51
58
|
border-radius: 0.5rem;
|
|
52
59
|
text-decoration: none;
|
|
53
|
-
transition: background-color 0.15s ease;
|
|
60
|
+
transition: background-color 0.15s ease, color 0.15s ease;
|
|
54
61
|
|
|
55
|
-
&:hover
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
&:hover {
|
|
63
|
+
background-color: var(--bs-tertiary-bg);
|
|
64
|
+
color: var(--bs-emphasis-color);
|
|
65
|
+
text-decoration: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&:focus-visible {
|
|
69
|
+
outline: 2px solid var(--bs-primary);
|
|
70
|
+
outline-offset: 1px;
|
|
59
71
|
text-decoration: none;
|
|
60
72
|
}
|
|
61
73
|
}
|
|
@@ -111,14 +123,47 @@
|
|
|
111
123
|
align-items: center;
|
|
112
124
|
}
|
|
113
125
|
|
|
126
|
+
// Foldable callouts render the title as a real <button> disclosure. Reset the
|
|
127
|
+
// button chrome so it reads like the static title, then add affordances.
|
|
128
|
+
.obsidian-callout-toggle {
|
|
129
|
+
width: 100%;
|
|
130
|
+
padding: 0;
|
|
131
|
+
border: 0;
|
|
132
|
+
background: none;
|
|
133
|
+
color: inherit;
|
|
134
|
+
font: inherit;
|
|
135
|
+
font-weight: 600;
|
|
136
|
+
text-align: left;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
|
|
139
|
+
&:focus-visible {
|
|
140
|
+
outline: 2px solid currentColor;
|
|
141
|
+
outline-offset: 2px;
|
|
142
|
+
border-radius: 0.2rem;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.obsidian-callout-chevron {
|
|
147
|
+
transition: transform 0.2s ease;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.obsidian-callout-toggle[aria-expanded="false"] .obsidian-callout-chevron {
|
|
151
|
+
transform: rotate(-90deg);
|
|
152
|
+
}
|
|
153
|
+
|
|
114
154
|
.obsidian-callout-body {
|
|
115
155
|
margin: 0;
|
|
116
156
|
|
|
117
157
|
> :last-child {
|
|
118
158
|
margin-bottom: 0;
|
|
119
159
|
}
|
|
160
|
+
|
|
161
|
+
&[hidden] {
|
|
162
|
+
display: none;
|
|
163
|
+
}
|
|
120
164
|
}
|
|
121
165
|
|
|
166
|
+
// Legacy / no-JS fallback: keep the CSS-only collapse working.
|
|
122
167
|
&[data-collapsed="true"] .obsidian-callout-body {
|
|
123
168
|
display: none;
|
|
124
169
|
}
|
|
@@ -157,7 +202,13 @@
|
|
|
157
202
|
// and _includes/navigation/local-graph-fab.html (footer) via
|
|
158
203
|
// assets/js/obsidian-local-graph.js.
|
|
159
204
|
// ----------------------------------------------------------------------------
|
|
160
|
-
|
|
205
|
+
// ID selector (not the class) is REQUIRED for the fixed position to stick:
|
|
206
|
+
// the FAB is a direct child of `.zer0-bg-body`, which matches the elevation
|
|
207
|
+
// rule `.zer0-bg-body > *:not(.fixed-top):not(.offcanvas):not(.modal)` in
|
|
208
|
+
// _sass/theme/_backgrounds.scss (specificity 0,4,0) and forces
|
|
209
|
+
// `position: relative; z-index: 1` — dropping the FAB into page flow at the
|
|
210
|
+
// bottom. An id (1,0,0) wins, mirroring `#aiChatToggle`. See ai-chat.html.
|
|
211
|
+
#obsidianLocalGraphFab.obsidian-local-graph-fab {
|
|
161
212
|
position: fixed;
|
|
162
213
|
left: var(--zer0-space-fab-offset, 1rem);
|
|
163
214
|
right: auto;
|
data/assets/data/wiki-index.json
CHANGED
|
@@ -18,7 +18,13 @@ sitemap: false
|
|
|
18
18
|
permalink, collection, tags, and the body's outgoing wiki-link targets.
|
|
19
19
|
The JS resolver normalizes lookup keys to lowercase + collapsed whitespace
|
|
20
20
|
(matching _plugins/obsidian_links.rb#normalize).
|
|
21
|
+
|
|
22
|
+
Honours `obsidian: { enabled: false }` in _config.yml: emit an empty (but
|
|
23
|
+
valid) index so the client resolver, backlinks, and graph all no-op.
|
|
21
24
|
{%- endcomment -%}
|
|
25
|
+
{%- if site.obsidian.enabled == false -%}
|
|
26
|
+
{"generated_at": {{ site.time | date_to_xmlschema | jsonify }}, "count": 0, "entries": []}
|
|
27
|
+
{%- else -%}
|
|
22
28
|
{%- assign all_docs = "" | split: "" -%}
|
|
23
29
|
{%- for collection in site.collections -%}
|
|
24
30
|
{%- for doc in collection.docs -%}
|
|
@@ -37,6 +43,13 @@ sitemap: false
|
|
|
37
43
|
{%- for doc in all_docs -%}
|
|
38
44
|
{%- assign basename = doc.path | split: "/" | last | replace: ".md", "" | replace: ".markdown", "" | replace: ".html", "" -%}
|
|
39
45
|
{%- assign body_source = doc.content | default: "" -%}
|
|
46
|
+
{%- comment -%}
|
|
47
|
+
Strip fenced code so Bash `[[ ... ]]` tests and literal examples don't
|
|
48
|
+
leak into excerpts or become graph edges. Both fence syntaxes are
|
|
49
|
+
handled: ``` first, then ~~~. (Structural limits: unbalanced fences,
|
|
50
|
+
indented code, and fence-in-fence are not perfectly handled here — the
|
|
51
|
+
server-side Ruby plugin's FENCED_CODE_RE is stricter.)
|
|
52
|
+
{%- endcomment -%}
|
|
40
53
|
{%- assign body_without_fences = "" -%}
|
|
41
54
|
{%- assign fence_parts = body_source | split: "```" -%}
|
|
42
55
|
{%- for fence_part in fence_parts -%}
|
|
@@ -45,8 +58,16 @@ sitemap: false
|
|
|
45
58
|
{%- assign body_without_fences = body_without_fences | append: fence_part -%}
|
|
46
59
|
{%- endif -%}
|
|
47
60
|
{%- endfor -%}
|
|
61
|
+
{%- assign body_without_tilde = "" -%}
|
|
62
|
+
{%- assign tilde_parts = body_without_fences | split: "~~~" -%}
|
|
63
|
+
{%- for tilde_part in tilde_parts -%}
|
|
64
|
+
{%- assign tilde_mod = forloop.index0 | modulo: 2 -%}
|
|
65
|
+
{%- if tilde_mod == 0 -%}
|
|
66
|
+
{%- assign body_without_tilde = body_without_tilde | append: tilde_part -%}
|
|
67
|
+
{%- endif -%}
|
|
68
|
+
{%- endfor -%}
|
|
48
69
|
{%- assign body_without_code = "" -%}
|
|
49
|
-
{%- assign inline_code_parts =
|
|
70
|
+
{%- assign inline_code_parts = body_without_tilde | split: "`" -%}
|
|
50
71
|
{%- for inline_code_part in inline_code_parts -%}
|
|
51
72
|
{%- assign inline_code_mod = forloop.index0 | modulo: 2 -%}
|
|
52
73
|
{%- if inline_code_mod == 0 -%}
|
|
@@ -77,7 +98,6 @@ sitemap: false
|
|
|
77
98
|
{%- if target_raw and target_raw != "" -%}
|
|
78
99
|
{%- assign target = target_raw | split: "|" | first | split: "#" | first | split: "^" | first | strip | downcase -%}
|
|
79
100
|
{%- assign target_is_code = false -%}
|
|
80
|
-
{%- assign first_char = target | slice: 0 -%}
|
|
81
101
|
{%- if target contains "$" or target contains "==" or target contains "!=" or target contains "=~" -%}
|
|
82
102
|
{%- assign target_is_code = true -%}
|
|
83
103
|
{%- endif -%}
|
|
@@ -87,7 +107,9 @@ sitemap: false
|
|
|
87
107
|
{%- if target contains " -eq " or target contains " -ne " or target contains " -lt " or target contains " -le " or target contains " -gt " or target contains " -ge " -%}
|
|
88
108
|
{%- assign target_is_code = true -%}
|
|
89
109
|
{%- endif -%}
|
|
90
|
-
{%-
|
|
110
|
+
{%- comment -%} Keep braces (template/code), but allow parentheses,
|
|
111
|
+
quotes, and leading dashes — real page titles use those. {%- endcomment -%}
|
|
112
|
+
{%- if target contains "{" or target contains "}" -%}
|
|
91
113
|
{%- assign target_is_code = true -%}
|
|
92
114
|
{%- endif -%}
|
|
93
115
|
{%- if target != "" and target.size < 200 and target_is_code == false -%}
|
|
@@ -95,6 +117,30 @@ sitemap: false
|
|
|
95
117
|
{%- endif -%}
|
|
96
118
|
{%- endif -%}
|
|
97
119
|
{%- endfor -%}
|
|
120
|
+
{%- comment -%}
|
|
121
|
+
Note transclusions ![[Note Title]] are outgoing edges too. Image embeds
|
|
122
|
+
(![[file.png]]) are not — skip known image/binary extensions via an
|
|
123
|
+
exact comma-delimited match so titles ending in a short token aren't
|
|
124
|
+
dropped.
|
|
125
|
+
{%- endcomment -%}
|
|
126
|
+
{%- assign embed_chunks = body | split: "![[" -%}
|
|
127
|
+
{%- for embed_chunk in embed_chunks offset: 1 -%}
|
|
128
|
+
{%- assign etarget_raw = embed_chunk | split: "]]" | first -%}
|
|
129
|
+
{%- if etarget_raw and etarget_raw != "" -%}
|
|
130
|
+
{%- assign etarget = etarget_raw | split: "|" | first | split: "#" | first | split: "^" | first | strip | downcase -%}
|
|
131
|
+
{%- assign is_image = false -%}
|
|
132
|
+
{%- if etarget contains "." -%}
|
|
133
|
+
{%- assign eext = etarget | split: "." | last -%}
|
|
134
|
+
{%- assign eext_token = "," | append: eext | append: "," -%}
|
|
135
|
+
{%- if ",png,jpg,jpeg,gif,svg,webp,avif,bmp,pdf," contains eext_token -%}
|
|
136
|
+
{%- assign is_image = true -%}
|
|
137
|
+
{%- endif -%}
|
|
138
|
+
{%- endif -%}
|
|
139
|
+
{%- if etarget != "" and etarget.size < 200 and is_image == false -%}
|
|
140
|
+
{%- assign outgoing = outgoing | push: etarget -%}
|
|
141
|
+
{%- endif -%}
|
|
142
|
+
{%- endif -%}
|
|
143
|
+
{%- endfor -%}
|
|
98
144
|
{%- assign outgoing = outgoing | uniq -%}
|
|
99
145
|
{
|
|
100
146
|
"title": {{ doc.title | default: basename | jsonify }},
|
|
@@ -110,3 +156,4 @@ sitemap: false
|
|
|
110
156
|
{%- endfor -%}
|
|
111
157
|
]
|
|
112
158
|
}
|
|
159
|
+
{%- endif -%}
|
data/assets/js/obsidian-graph.js
CHANGED
|
@@ -34,6 +34,48 @@
|
|
|
34
34
|
return String(value || '').toLowerCase().trim().replace(/\s+/g, ' ');
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function prefersReducedMotion() {
|
|
38
|
+
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Cytoscape is vendored under assets/vendor/ (no runtime CDN). Path comes from
|
|
42
|
+
// window.OBSIDIAN_CONFIG.cytoscapeUrl (set by Liquid), with a base fallback.
|
|
43
|
+
function cytoscapeSrc() {
|
|
44
|
+
var cfg = window.OBSIDIAN_CONFIG || {};
|
|
45
|
+
if (cfg.cytoscapeUrl) return cfg.cytoscapeUrl;
|
|
46
|
+
var base = (document.querySelector('base') || {}).href || '/';
|
|
47
|
+
return base.replace(/\/$/, '') + '/assets/vendor/cytoscape/cytoscape.min.js';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Load cytoscape, coordinating with obsidian-local-graph.js via the shared
|
|
51
|
+
// window.__obsidianCytoscapeLoading queue. cb(true) on success, cb(false) on
|
|
52
|
+
// failure so the caller can show a recovery message.
|
|
53
|
+
function loadCytoscape(cb) {
|
|
54
|
+
if (typeof window.cytoscape === 'function') return cb(true);
|
|
55
|
+
if (window.__obsidianCytoscapeLoading) {
|
|
56
|
+
window.__obsidianCytoscapeLoading.push(cb);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
var queue = window.__obsidianCytoscapeLoading = [cb];
|
|
60
|
+
function flush(ok) {
|
|
61
|
+
window.__obsidianCytoscapeLoading = null;
|
|
62
|
+
queue.forEach(function (fn) { try { fn(ok); } catch (e) { /* ignore */ } });
|
|
63
|
+
}
|
|
64
|
+
var existing = document.querySelector('script[src*="cytoscape"]');
|
|
65
|
+
if (existing) {
|
|
66
|
+
if (typeof window.cytoscape === 'function') return flush(true);
|
|
67
|
+
existing.addEventListener('load', function () { flush(true); });
|
|
68
|
+
existing.addEventListener('error', function () { flush(false); });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
var s = document.createElement('script');
|
|
72
|
+
s.src = cytoscapeSrc();
|
|
73
|
+
s.defer = true;
|
|
74
|
+
s.onload = function () { flush(true); };
|
|
75
|
+
s.onerror = function () { flush(false); };
|
|
76
|
+
document.head.appendChild(s);
|
|
77
|
+
}
|
|
78
|
+
|
|
37
79
|
function buildLookup(entries) {
|
|
38
80
|
var byKey = Object.create(null);
|
|
39
81
|
entries.forEach(function (entry) {
|
|
@@ -211,6 +253,8 @@
|
|
|
211
253
|
|
|
212
254
|
var theme = readTheme();
|
|
213
255
|
container.style.backgroundColor = theme.canvasBg;
|
|
256
|
+
var nodeDur = prefersReducedMotion() ? '0ms' : '160ms';
|
|
257
|
+
var edgeDur = prefersReducedMotion() ? '0ms' : '150ms';
|
|
214
258
|
|
|
215
259
|
var cy = window.cytoscape({
|
|
216
260
|
container: container,
|
|
@@ -245,7 +289,7 @@
|
|
|
245
289
|
'border-color': theme.nodeBorder,
|
|
246
290
|
'opacity': 0.88,
|
|
247
291
|
'transition-property': 'background-color, border-color, width, height, text-opacity, opacity',
|
|
248
|
-
'transition-duration':
|
|
292
|
+
'transition-duration': nodeDur
|
|
249
293
|
}
|
|
250
294
|
},
|
|
251
295
|
{
|
|
@@ -279,7 +323,7 @@
|
|
|
279
323
|
'arrow-scale': 0.75,
|
|
280
324
|
'opacity': 0.55,
|
|
281
325
|
'transition-property': 'line-color, width, opacity',
|
|
282
|
-
'transition-duration':
|
|
326
|
+
'transition-duration': edgeDur
|
|
283
327
|
}
|
|
284
328
|
},
|
|
285
329
|
{
|
|
@@ -409,18 +453,9 @@
|
|
|
409
453
|
function applyOrphansVisibility(cy, show) {
|
|
410
454
|
if (!cy) return;
|
|
411
455
|
var orphans = cy.nodes().filter(function (n) { return n.degree(false) === 0; });
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
orphans.style('display', 'none');
|
|
416
|
-
}
|
|
417
|
-
// Re-run a quick layout pass on visible elements so the connected
|
|
418
|
-
// cluster expands into the freed space.
|
|
419
|
-
cy.layout(Object.assign({}, COSE_LAYOUT, {
|
|
420
|
-
randomize: false,
|
|
421
|
-
numIter: 1400,
|
|
422
|
-
eles: cy.elements(':visible')
|
|
423
|
-
})).run();
|
|
456
|
+
orphans.style('display', show ? 'element' : 'none');
|
|
457
|
+
// The initial COSE pass already positioned every node, so just refit the
|
|
458
|
+
// visible set — avoids a jarring full relayout of the stable core on toggle.
|
|
424
459
|
cy.fit(cy.elements(':visible'), 80);
|
|
425
460
|
}
|
|
426
461
|
|
|
@@ -467,13 +502,23 @@
|
|
|
467
502
|
var byKey = buildLookup(entries);
|
|
468
503
|
var elements = buildElements(entries, byKey);
|
|
469
504
|
computeNodeDegree(elements);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
505
|
+
loadCytoscape(function (ok) {
|
|
506
|
+
if (!ok) {
|
|
507
|
+
container.innerHTML =
|
|
508
|
+
'<div class="alert alert-danger" role="alert">' +
|
|
509
|
+
'Graph view failed to load: the <code>cytoscape</code> library could ' +
|
|
510
|
+
'not be fetched. Check your network connection or content security policy.' +
|
|
511
|
+
'</div>';
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
container.innerHTML = '';
|
|
515
|
+
setStats(entries, elements);
|
|
516
|
+
var cy = renderGraph(container, elements);
|
|
517
|
+
wireSearch(cy, byKey);
|
|
518
|
+
wireFitButton(cy);
|
|
519
|
+
wireOrphansToggle(cy);
|
|
520
|
+
window.ObsidianGraph = { cy: cy, byKey: byKey, entries: entries };
|
|
521
|
+
});
|
|
477
522
|
})
|
|
478
523
|
.catch(function (err) {
|
|
479
524
|
container.innerHTML =
|