jekyll-theme-zer0 1.19.1 → 1.20.2

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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +395 -0
  3. data/README.md +27 -19
  4. data/_data/authors.yml +154 -5
  5. data/_data/backlog.yml +5 -5
  6. data/_data/content_statistics.yml +273 -297
  7. data/_data/features.yml +4 -25
  8. data/_data/navigation/README.md +24 -0
  9. data/_data/navigation/about.yml +2 -0
  10. data/_data/navigation/main.yml +2 -7
  11. data/_data/roadmap.yml +86 -12
  12. data/_includes/components/author-avatar-url.html +28 -0
  13. data/_includes/components/author-bio.html +86 -0
  14. data/_includes/components/author-card.html +184 -121
  15. data/_includes/components/author-eeat.html +10 -4
  16. data/_includes/components/info-section.html +1 -1
  17. data/_includes/components/mermaid.html +0 -3
  18. data/_includes/components/post-card.html +19 -9
  19. data/_includes/content/giscus.html +3 -2
  20. data/_includes/core/footer-fabs.html +28 -0
  21. data/_includes/core/footer.html +7 -17
  22. data/_includes/core/head.html +2 -2
  23. data/_includes/navigation/breadcrumbs.html +20 -2
  24. data/_includes/navigation/local-graph.html +18 -2
  25. data/_includes/obsidian/full-graph.html +4 -6
  26. data/_layouts/article.html +44 -74
  27. data/_layouts/author.html +274 -0
  28. data/_layouts/authors.html +55 -0
  29. data/_layouts/news.html +3 -3
  30. data/_layouts/note.html +21 -6
  31. data/_layouts/notebook.html +21 -6
  32. data/_layouts/root.html +31 -17
  33. data/_layouts/section.html +3 -3
  34. data/_plugins/author_pages_generator.rb +121 -0
  35. data/_sass/components/_author.scss +219 -0
  36. data/_sass/components/_content-tables.scss +16 -1
  37. data/_sass/components/_notes-index.scss +102 -0
  38. data/_sass/components/_search-modal.scss +40 -0
  39. data/_sass/components/_ui-enhancements.scss +570 -0
  40. data/_sass/core/_docs-code-examples.scss +463 -0
  41. data/_sass/core/_docs-layout.scss +0 -453
  42. data/_sass/core/_navbar.scss +253 -0
  43. data/_sass/core/_sidebar-extras.scss +79 -0
  44. data/_sass/core/_toc.scss +87 -0
  45. data/_sass/core/_variables.scss +7 -142
  46. data/_sass/custom.scss +24 -1122
  47. data/_sass/layouts/_global-chrome.scss +59 -0
  48. data/assets/css/main.scss +19 -2
  49. data/assets/js/author-profile.js +190 -0
  50. data/assets/js/modules/navigation/navbar.js +104 -0
  51. data/assets/js/obsidian-graph.js +2 -2
  52. data/assets/js/obsidian-local-graph.js +11 -5
  53. data/assets/vendor/cytoscape/cytoscape.min.js +32 -0
  54. data/scripts/README.md +39 -0
  55. data/scripts/bin/validate +11 -1
  56. data/scripts/dev/css-diff.sh +49 -0
  57. data/scripts/dev/shot.js +37 -0
  58. data/scripts/features/generate-preview-images +110 -6
  59. data/scripts/features/pixelate-preview-images +126 -0
  60. data/scripts/features/pixelate_images.py +662 -0
  61. data/scripts/github-setup.sh +0 -0
  62. data/scripts/lib/preview_generator.py +47 -3
  63. data/scripts/pixelate-preview-images.sh +12 -0
  64. data/scripts/test/integration/auto-version +10 -8
  65. data/scripts/test/lib/run_tests.sh +2 -0
  66. data/scripts/test/lib/test_content_review.sh +205 -0
  67. data/scripts/test/lib/test_pixelate_images.sh +108 -0
  68. metadata +25 -20
  69. data/_data/hub.yml +0 -68
  70. data/_data/hub_index.yml +0 -203
  71. data/_data/navigation/hub.yml +0 -110
  72. data/assets/vendor/font-awesome/css/all.min.css +0 -9
  73. data/assets/vendor/font-awesome/webfonts/fa-brands-400.ttf +0 -0
  74. data/assets/vendor/font-awesome/webfonts/fa-brands-400.woff2 +0 -0
  75. data/assets/vendor/font-awesome/webfonts/fa-regular-400.ttf +0 -0
  76. data/assets/vendor/font-awesome/webfonts/fa-regular-400.woff2 +0 -0
  77. data/assets/vendor/font-awesome/webfonts/fa-solid-900.ttf +0 -0
  78. data/assets/vendor/font-awesome/webfonts/fa-solid-900.woff2 +0 -0
  79. data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.ttf +0 -0
  80. data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.woff2 +0 -0
  81. data/assets/vendor/jquery/jquery-3.7.1.min.js +0 -2
  82. data/scripts/lib/hub.rb +0 -208
  83. data/scripts/provision-org-sites.rb +0 -252
  84. data/scripts/provision-org-sites.sh +0 -23
  85. data/scripts/sync-hub-metadata.rb +0 -184
  86. data/scripts/sync-hub-metadata.sh +0 -22
@@ -17,7 +17,7 @@
17
17
  - Renders the current page as a single-node graph when it has no neighbors
18
18
  - Hides itself if the current page is not in the wiki-index or the index
19
19
  can't be loaded
20
- - Lazily loads cytoscape.js from CDN with SRI + crossorigin
20
+ - Lazily loads the vendored cytoscape.js (assets/vendor/cytoscape/, no CDN)
21
21
 
22
22
  Opt-out: set `local_graph: false` in page front matter.
23
23
 
@@ -53,13 +53,29 @@
53
53
  </div>
54
54
  <div class="offcanvas-body">
55
55
  <div class="obsidian-local-graph-widget" aria-label="Local graph">
56
+ {%- comment -%}
57
+ Only show the "Full graph" link when that docs page exists in the build
58
+ (configurable via `obsidian_graph_url`, default `/docs/obsidian/graph/`).
59
+ The page lives under pages/ and isn't delivered by remote_theme, so a
60
+ Pages consumer would otherwise get a 404 link. See issue #204.
61
+ {%- endcomment -%}
62
+ {%- assign _graph_url = site.obsidian_graph_url | default: '/docs/obsidian/graph/' -%}
63
+ {%- assign _graph_page = site.html_pages | where: "url", _graph_url | first -%}
64
+ {%- unless _graph_page -%}
65
+ {%- for col in site.collections -%}
66
+ {%- assign _graph_page = col.docs | where: "url", _graph_url | first -%}
67
+ {%- if _graph_page -%}{%- break -%}{%- endif -%}
68
+ {%- endfor -%}
69
+ {%- endunless -%}
56
70
  <div class="obsidian-local-graph-meta d-flex align-items-center justify-content-between gap-2 mb-3">
57
71
  <span class="badge text-bg-secondary">Depth {{ lg_depth }}</span>
58
- <a href="{{ '/docs/obsidian/graph/' | relative_url }}"
72
+ {%- if _graph_page -%}
73
+ <a href="{{ _graph_url | relative_url }}"
59
74
  class="btn btn-outline-primary btn-sm"
60
75
  title="Open the full site graph">
61
76
  <i class="bi bi-arrows-fullscreen me-1" aria-hidden="true"></i>Full graph
62
77
  </a>
78
+ {%- endif -%}
63
79
  </div>
64
80
  <div id="obsidian-local-graph"
65
81
  data-depth="{{ lg_depth }}"
@@ -160,18 +160,16 @@ and [backlinks panel]({{ "/docs/obsidian/syntax-reference/#backlinks-panel" | re
160
160
  | --- | --- |
161
161
  | Build-time index (nodes + outgoing edges) | [`assets/data/wiki-index.json`]({{ "/assets/data/wiki-index.json" | relative_url }}) (Liquid template at [`assets/data/wiki-index.json`](https://github.com/bamr87/zer0-mistakes/blob/main/assets/data/wiki-index.json)) |
162
162
  | Renderer | `assets/js/obsidian-graph.js` |
163
- | Layout engine | [cytoscape.js](https://js.cytoscape.org/) (loaded from CDN, only on this page) |
163
+ | Layout engine | [cytoscape.js](https://js.cytoscape.org/) (vendored under `assets/vendor/`, loaded only on this page) |
164
164
 
165
165
  Outgoing edges come from the same `[[…]]` syntax the resolver handles —
166
166
  unresolved targets show up as dashed red nodes so you can find dangling
167
167
  links at a glance. The graph is regenerated every Jekyll build; nothing
168
168
  runs client-side except cytoscape's force layout.
169
169
 
170
- <!-- Cytoscape.js (only loaded on this page). -->
171
- <script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.0/dist/cytoscape.min.js"
172
- integrity="sha384-kpMsYllYzyaWU69Piok08rPNktpnjqAoDMdB00fjqUkEk3lkuUbSuwJ+oXrjvN6B"
173
- crossorigin="anonymous"
174
- defer></script>
170
+ <!-- Cytoscape.js (vendored, only loaded on this page) — no CDN so the graph
171
+ works under strict CSP and offline. See assets/vendor/cytoscape/. -->
172
+ <script src="{{ '/assets/vendor/cytoscape/cytoscape.min.js' | relative_url }}" defer></script>
175
173
  <script src="{{ '/assets/js/obsidian-graph.js' | relative_url }}" defer></script>
176
174
 
177
175
  ## See also
@@ -126,8 +126,7 @@ layout: default
126
126
  <!-- Author -->
127
127
  {% if page.author %}
128
128
  <span class="post-author" itemprop="author" itemscope itemtype="https://schema.org/Person">
129
- <i class="bi bi-person me-1"></i>
130
- <span itemprop="name">{{ page.author }}</span>
129
+ {% include components/author-card.html author=page.author style="inline" name_itemprop="name" %}
131
130
  </span>
132
131
  <span class="mx-2">•</span>
133
132
  {% endif %}
@@ -161,31 +160,59 @@ layout: default
161
160
  </div>
162
161
 
163
162
  <!-- Category Link -->
163
+ {% comment %} Category base is configurable (`category_base`, default `/news`)
164
+ so remote-theme consumers whose category index lives elsewhere
165
+ (e.g. `/categories`) don't get a hardcoded 404. See issue #204. {% endcomment %}
164
166
  {% if page.categories.size > 0 %}
167
+ {% assign _category_base = site.category_base | default: '/news' %}
165
168
  <div class="mb-3">
166
- <a href="{{ site.baseurl }}/news/{{ page.categories | first | slugify }}/"
169
+ <a href="{{ site.baseurl }}{{ _category_base }}/{{ page.categories | first | slugify }}/"
167
170
  class="badge bg-primary text-decoration-none fs-6">
168
171
  <i class="bi bi-folder me-1"></i>{{ page.categories | first }}
169
172
  </a>
170
173
  </div>
171
174
  {% endif %}
172
-
175
+
173
176
  <!-- Tags -->
177
+ {% comment %} Only link tag badges when a tags index page actually exists in
178
+ the build (configurable via `tags_page`, default `/tags/`). On a
179
+ remote-theme Pages consumer the tags page is plugin/page-generated and
180
+ absent, so we render plain badges instead of links that 404. See #204. {% endcomment %}
174
181
  {% if page.tags and page.tags.size > 0 %}
182
+ {% assign _tags_base = site.tags_page | default: '/tags/' %}
183
+ {% assign _tags_page = site.html_pages | where: "url", _tags_base | first %}
175
184
  <div class="post-tags mb-3">
176
185
  {% for tag in page.tags %}
177
- <a href="{{ site.baseurl }}/tags/#{{ tag | slugify }}" class="badge bg-secondary text-decoration-none me-1">
178
- <i class="bi bi-tag me-1"></i>{{ tag }}
179
- </a>
186
+ {% if _tags_page %}
187
+ <a href="{{ site.baseurl }}{{ _tags_base }}#{{ tag | slugify }}" class="badge bg-secondary text-decoration-none me-1">
188
+ <i class="bi bi-tag me-1"></i>{{ tag }}
189
+ </a>
190
+ {% else %}
191
+ <span class="badge bg-secondary me-1"><i class="bi bi-tag me-1"></i>{{ tag }}</span>
192
+ {% endif %}
180
193
  {% endfor %}
181
194
  </div>
182
195
  {% endif %}
183
196
  </header>
184
197
 
185
198
  {% comment %} ================================ {% endcomment %}
186
- {% comment %} FEATURED HERO IMAGE {% endcomment %}
199
+ {% comment %} FEATURED / BREAKING HERO IMAGE {% endcomment %}
187
200
  {% comment %} ================================ {% endcomment %}
188
- {% if post_type == "featured" and page.preview %}
201
+ {% comment %}
202
+ Render the hero image for prominent post types (featured + breaking) when a
203
+ preview image is set. Breaking stories were previously skipped because the
204
+ condition checked only post_type == "featured"; since breaking is mapped
205
+ before featured (see top of file), breaking+featured posts showed no hero.
206
+ Nested ifs keep the "only when page.preview exists" guard intact (Liquid has
207
+ no parentheses, so a flat `a or b and c` would misgroup).
208
+ {% endcomment %}
209
+ {% assign show_hero = false %}
210
+ {% if page.preview %}
211
+ {% if post_type == "featured" or post_type == "breaking" %}
212
+ {% assign show_hero = true %}
213
+ {% endif %}
214
+ {% endif %}
215
+ {% if show_hero %}
189
216
  <figure class="featured-hero mb-5">
190
217
  {% include components/preview-image.html
191
218
  src=page.preview
@@ -366,71 +393,14 @@ layout: default
366
393
  <!-- ================================ -->
367
394
  <!-- AUTHOR BIO SECTION -->
368
395
  <!-- ================================ -->
369
- {% if page.author %}
370
- {% assign author_key = page.author %}
371
- {% assign author_data = site.data.authors[author_key] %}
372
- {% unless author_data %}
373
- {% assign author_data = site.data.authors.default %}
374
- {% assign author_name = page.author %}
375
- {% else %}
376
- {% assign author_name = author_data.name %}
377
- {% endunless %}
378
-
379
- <aside class="author-section my-5 pt-4 border-top">
380
- <h3 class="h5 mb-3">
381
- <i class="bi bi-person-badge me-2"></i>About the Author
382
- </h3>
383
- <div class="author-card card border-0 shadow-sm">
384
- <div class="card-body">
385
- <div class="d-flex align-items-start">
386
- {% if author_data.avatar %}
387
- <img src="{{ site.baseurl }}/{{ site.public_folder }}{{ author_data.avatar }}"
388
- alt="{{ author_name }}"
389
- class="rounded-circle me-3"
390
- width="80" height="80"
391
- style="object-fit: cover;">
392
- {% else %}
393
- <div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-3"
394
- style="width: 80px; height: 80px;">
395
- <i class="bi bi-person fs-1"></i>
396
- </div>
397
- {% endif %}
398
- <div class="flex-grow-1">
399
- <h5 class="card-title mb-1">{{ author_name }}</h5>
400
- {% if author_data.role %}
401
- <p class="text-primary mb-2">{{ author_data.role }}</p>
402
- {% endif %}
403
- {% if author_data.bio %}
404
- <p class="card-text text-muted">{{ author_data.bio }}</p>
405
- {% endif %}
406
- <div class="author-social d-flex gap-2 mt-2">
407
- {% if author_data.github %}
408
- <a href="https://github.com/{{ author_data.github }}"
409
- class="btn btn-sm btn-outline-secondary"
410
- target="_blank" rel="noopener" title="GitHub">
411
- <i class="bi bi-github"></i>
412
- </a>
413
- {% endif %}
414
- {% if author_data.twitter %}
415
- <a href="https://x.com/{{ author_data.twitter }}"
416
- class="btn btn-sm btn-outline-secondary"
417
- target="_blank" rel="noopener" title="X" aria-label="X">
418
- <i class="bi bi-twitter-x"></i>
419
- </a>
420
- {% endif %}
421
- {% if author_data.website %}
422
- <a href="{{ author_data.website }}"
423
- class="btn btn-sm btn-outline-secondary"
424
- target="_blank" rel="noopener" title="Website">
425
- <i class="bi bi-globe"></i>
426
- </a>
427
- {% endif %}
428
- </div>
429
- </div>
430
- </div>
431
- </div>
432
- </div>
433
- </aside>
396
+ {% comment %}
397
+ "About the Author" is honored when the post has an author and the
398
+ `author_profile` front-matter flag is not false (defaults to true for the
399
+ posts collection in _config.yml). Rendering is delegated to the shared
400
+ components/author-bio.html components/author-card.html primitives.
401
+ {% endcomment %}
402
+ {% if page.author and page.author_profile != false %}
403
+ {% include components/author-bio.html author=page.author %}
434
404
  {% endif %}
435
405
 
436
406
  <!-- ================================ -->
@@ -0,0 +1,274 @@
1
+ ---
2
+ layout: default
3
+ hide_intro: true
4
+ ---
5
+ {% comment %}
6
+ ===================================================================
7
+ AUTHOR LAYOUT - Interactive author profile page (content hub)
8
+ ===================================================================
9
+
10
+ File: author.html
11
+ Path: _layouts/author.html
12
+ Inherits: default.html (which inherits root.html)
13
+ Purpose: A dedicated, interactive profile for one author — bio/blurb, social,
14
+ expertise, an at-a-glance stats dashboard, and a filterable /
15
+ searchable / sortable grid of every piece of content they've written
16
+ across ALL collections (posts, docs, notes, notebooks, quickstart,
17
+ about, quests, hobbies, …).
18
+
19
+ Front Matter Variables:
20
+ - author_key (required): Key into _data/authors.yml. Set automatically by
21
+ _plugins/author_pages_generator.rb, or by hand for a committed page.
22
+ - hide_intro: true (set above) — this layout renders its own hero/breadcrumb,
23
+ so the generic content/intro.html header is suppressed (no duplicate H1).
24
+
25
+ Interactivity is progressive-enhancement: with JS off, all content renders as
26
+ a plain grid (every item visible, crawlable). assets/js/author-profile.js
27
+ activates on the [data-author-profile] container to power the filters.
28
+
29
+ Dependencies:
30
+ - _data/authors.yml: Author data
31
+ - components/post-card.html (col=false): Content grid cards
32
+ - assets/js/author-profile.js: filter / search / sort controller
33
+ - _sass/components/_author.scss: styling
34
+ ===================================================================
35
+ {% endcomment %}
36
+
37
+ {% assign author_key = page.author_key %}
38
+ {% assign author_data = site.data.authors[author_key] %}
39
+ {% assign author_name = author_data.name | default: author_key %}
40
+ {% comment %} Resolve avatar: full URL (e.g. GitHub) as-is, relative path prefixed,
41
+ or derived from the github handle. See components/author-avatar-url.html {% endcomment %}
42
+ {% capture avatar_url %}{% include components/author-avatar-url.html data=author_data %}{% endcapture %}
43
+ {% assign avatar_url = avatar_url | strip %}
44
+ {% assign has_avatar = false %}{% if avatar_url != "" %}{% assign has_avatar = true %}{% endif %}
45
+
46
+ {% comment %} ---------------------------------------------------------------
47
+ Aggregate this author's content across every collection. A document matches
48
+ when its `author` equals the key, the name, or the display_name.
49
+ --------------------------------------------------------------------------- {% endcomment %}
50
+ {% assign aliases = "" | split: "" %}
51
+ {% assign aliases = aliases | push: author_key %}
52
+ {% if author_data.name %}{% assign aliases = aliases | push: author_data.name %}{% endif %}
53
+ {% if author_data.display_name %}{% assign aliases = aliases | push: author_data.display_name %}{% endif %}
54
+ {% assign author_docs = site.documents | where_exp: "d", "aliases contains d.author" | where_exp: "d", "d.title" | sort: "date" | reverse %}
55
+
56
+ {% comment %} Distinct collection labels present, unique tags, latest dated item {% endcomment %}
57
+ {% assign collection_labels = "" | split: "" %}
58
+ {% assign all_tags = "" | split: "" %}
59
+ {% for doc in author_docs %}
60
+ {% unless collection_labels contains doc.collection %}{% assign collection_labels = collection_labels | push: doc.collection %}{% endunless %}
61
+ {% for t in doc.tags %}{% assign all_tags = all_tags | push: t %}{% endfor %}
62
+ {% endfor %}
63
+ {% assign collection_labels = collection_labels | sort %}
64
+ {% assign unique_tags = all_tags | uniq | sort %}
65
+ {% assign latest_date = nil %}
66
+ {% for doc in author_docs %}{% if doc.date %}{% assign latest_date = doc.date %}{% break %}{% endif %}{% endfor %}
67
+
68
+ <!-- ========================== -->
69
+ <!-- BREADCRUMB -->
70
+ <!-- ========================== -->
71
+ <nav aria-label="breadcrumb" class="mb-3">
72
+ <ol class="breadcrumb">
73
+ <li class="breadcrumb-item"><a href="{{ '/' | relative_url }}">Home</a></li>
74
+ <li class="breadcrumb-item"><a href="{{ '/authors/' | relative_url }}">Authors</a></li>
75
+ <li class="breadcrumb-item active" aria-current="page">{{ author_name }}</li>
76
+ </ol>
77
+ </nav>
78
+
79
+ <!-- ========================== -->
80
+ <!-- PROFILE HERO -->
81
+ <!-- ========================== -->
82
+ <header class="author-hero card border-0 shadow-sm mb-4" itemscope itemtype="https://schema.org/Person">
83
+ <div class="card-body p-4 p-md-5">
84
+ <div class="d-flex flex-column flex-sm-row align-items-center align-items-sm-start gap-4">
85
+ {% if has_avatar %}
86
+ <img src="{{ avatar_url }}" alt="{{ author_name }}" class="author-hero__avatar rounded-circle flex-shrink-0" width="128" height="128" style="object-fit: cover;" itemprop="image">
87
+ {% else %}
88
+ <div class="author-avatar-fallback rounded-circle bg-primary text-white d-flex align-items-center justify-content-center flex-shrink-0" style="width: 128px; height: 128px;">
89
+ <i class="bi bi-person fs-1" aria-hidden="true"></i>
90
+ </div>
91
+ {% endif %}
92
+
93
+ <div class="flex-grow-1 text-center text-sm-start">
94
+ <h1 class="author-hero__name h2 mb-1" itemprop="name">{{ author_name }}{% if author_data.ai %} <span class="badge author-ai-badge align-middle"><i class="bi bi-robot me-1" aria-hidden="true"></i>AI author</span>{% endif %}</h1>
95
+ {% if author_data.role %}<p class="text-primary fw-semibold mb-2" itemprop="jobTitle">{{ author_data.role }}</p>{% endif %}
96
+ {% if author_data.tagline %}<p class="author-hero__tagline lead mb-2" itemprop="slogan">{{ author_data.tagline }}</p>{% endif %}
97
+ {% if author_data.bio %}<p class="text-body-secondary mb-3" itemprop="description">{{ author_data.bio }}</p>{% endif %}
98
+
99
+ <!-- Hero meta: location + latest activity -->
100
+ <div class="author-hero__meta d-flex flex-wrap justify-content-center justify-content-sm-start gap-3 text-body-secondary small mb-3">
101
+ {% if author_data.location %}<span itemprop="homeLocation"><i class="bi bi-geo-alt me-1" aria-hidden="true"></i>{{ author_data.location }}</span>{% endif %}
102
+ <span><i class="bi bi-collection me-1" aria-hidden="true"></i>{{ author_docs.size }} contribution{% unless author_docs.size == 1 %}s{% endunless %}</span>
103
+ {% if latest_date %}<span><i class="bi bi-clock-history me-1" aria-hidden="true"></i>Last active {{ latest_date | date: "%b %Y" }}</span>{% endif %}
104
+ </div>
105
+
106
+ <!-- Expertise chips -->
107
+ {% assign hero_chips = author_data.topics | default: author_data.expertise %}
108
+ {% if hero_chips %}
109
+ <div class="author-expertise d-flex flex-wrap justify-content-center justify-content-sm-start gap-1 mb-3">
110
+ {% for skill in hero_chips %}
111
+ <span class="badge rounded-pill text-bg-light border" itemprop="knowsAbout">{{ skill }}</span>
112
+ {% endfor %}
113
+ </div>
114
+ {% endif %}
115
+
116
+ <!-- Social links -->
117
+ <div class="author-social d-flex flex-wrap justify-content-center justify-content-sm-start gap-2">
118
+ {% if author_data.github %}<a href="https://github.com/{{ author_data.github }}" class="btn btn-sm btn-outline-secondary" target="_blank" rel="noopener" itemprop="sameAs"><i class="bi bi-github me-1" aria-hidden="true"></i>GitHub</a>{% endif %}
119
+ {% if author_data.twitter %}<a href="https://x.com/{{ author_data.twitter }}" class="btn btn-sm btn-outline-secondary" target="_blank" rel="noopener" aria-label="X" itemprop="sameAs"><i class="bi bi-twitter-x me-1" aria-hidden="true"></i>X</a>{% endif %}
120
+ {% if author_data.linkedin %}<a href="https://linkedin.com/in/{{ author_data.linkedin }}" class="btn btn-sm btn-outline-secondary" target="_blank" rel="noopener" itemprop="sameAs"><i class="bi bi-linkedin me-1" aria-hidden="true"></i>LinkedIn</a>{% endif %}
121
+ {% if author_data.website %}<a href="{{ author_data.website }}" class="btn btn-sm btn-outline-secondary" target="_blank" rel="noopener" itemprop="url"><i class="bi bi-globe me-1" aria-hidden="true"></i>Website</a>{% endif %}
122
+ {% if author_data.email %}<a href="mailto:{{ author_data.email }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-envelope me-1" aria-hidden="true"></i>Email</a>{% endif %}
123
+ </div>
124
+
125
+ {% if author_data.ai and author_data.persona.disclosure %}
126
+ <p class="author-ai-disclosure small text-body-secondary mt-3 mb-0 d-flex align-items-start">
127
+ <i class="bi bi-robot me-2 mt-1 flex-shrink-0" aria-hidden="true"></i><span>{{ author_data.persona.disclosure }}</span>
128
+ </p>
129
+ {% endif %}
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </header>
134
+
135
+ {% if author_docs.size > 0 %}
136
+ <!-- ========================== -->
137
+ <!-- INTERACTIVE CONTENT HUB -->
138
+ <!-- ========================== -->
139
+ <section class="author-content" data-author-profile>
140
+
141
+ <h2 class="visually-hidden">Contributions by {{ author_name }}</h2>
142
+
143
+ <!-- Stats dashboard = interactive type filters (each card filters the grid) -->
144
+ <div class="author-stats" role="group" aria-label="Filter contributions by type">
145
+ <button type="button" class="author-stat is-active" data-filter="all" aria-pressed="true">
146
+ <span class="author-stat__num">{{ author_docs.size }}</span>
147
+ <span class="author-stat__label"><i class="bi bi-grid-3x3-gap me-1" aria-hidden="true"></i>All</span>
148
+ </button>
149
+ {% for clabel in collection_labels %}
150
+ {% assign cdocs = author_docs | where_exp: "d", "d.collection == clabel" %}
151
+ {% case clabel %}
152
+ {% when 'posts' %}{% assign cicon = 'bi-newspaper' %}
153
+ {% when 'docs' %}{% assign cicon = 'bi-book' %}
154
+ {% when 'notes' %}{% assign cicon = 'bi-journal-text' %}
155
+ {% when 'notebooks' %}{% assign cicon = 'bi-journal-code' %}
156
+ {% when 'quickstart' %}{% assign cicon = 'bi-rocket-takeoff' %}
157
+ {% when 'about' %}{% assign cicon = 'bi-person-lines-fill' %}
158
+ {% when 'quests' %}{% assign cicon = 'bi-compass' %}
159
+ {% when 'hobbies' %}{% assign cicon = 'bi-controller' %}
160
+ {% else %}{% assign cicon = 'bi-file-earmark-text' %}
161
+ {% endcase %}
162
+ <button type="button" class="author-stat" data-filter="{{ clabel }}" aria-pressed="false">
163
+ <span class="author-stat__num">{{ cdocs.size }}</span>
164
+ <span class="author-stat__label"><i class="bi {{ cicon }} me-1" aria-hidden="true"></i>{{ clabel | capitalize }}</span>
165
+ </button>
166
+ {% endfor %}
167
+ </div>
168
+
169
+ <!-- Controls: search + sort + live result count -->
170
+ <div class="author-controls d-flex flex-column flex-md-row gap-2 align-items-stretch align-items-md-center mt-3">
171
+ <div class="author-search flex-grow-1 position-relative">
172
+ <i class="bi bi-search author-search__icon" aria-hidden="true"></i>
173
+ <label class="visually-hidden" for="author-search-input">Search {{ author_name }}'s contributions</label>
174
+ <input type="search" id="author-search-input" class="form-control" placeholder="Search titles and tags…" data-author-search autocomplete="off" aria-controls="author-grid">
175
+ </div>
176
+ <div class="d-flex gap-2">
177
+ <label class="visually-hidden" for="author-sort">Sort contributions</label>
178
+ <select id="author-sort" class="form-select w-auto" data-author-sort aria-label="Sort contributions">
179
+ <option value="newest">Newest first</option>
180
+ <option value="oldest">Oldest first</option>
181
+ <option value="az">Title A–Z</option>
182
+ </select>
183
+ <button type="button" class="btn btn-outline-secondary" data-author-clear hidden>
184
+ <i class="bi bi-x-lg me-1" aria-hidden="true"></i>Clear
185
+ </button>
186
+ </div>
187
+ </div>
188
+
189
+ <!-- Topic / tag cloud (clickable filters) -->
190
+ {% if unique_tags.size > 0 %}
191
+ <div class="author-tagcloud mt-3" role="group" aria-label="Filter by topic">
192
+ <span class="author-tagcloud__label text-body-secondary small me-1"><i class="bi bi-tags me-1" aria-hidden="true"></i>Topics:</span>
193
+ {% for tag in unique_tags limit: 24 %}
194
+ <button type="button" class="badge rounded-pill author-tag" data-tag-filter="{{ tag | downcase | escape }}" aria-pressed="false">{{ tag }}</button>
195
+ {% endfor %}
196
+ </div>
197
+ {% endif %}
198
+
199
+ <!-- Live result count (announced to screen readers) -->
200
+ <p class="author-resultcount text-body-secondary small mt-3 mb-2" data-author-count aria-live="polite">
201
+ Showing {{ author_docs.size }} of {{ author_docs.size }}
202
+ </p>
203
+
204
+ <!-- Content grid -->
205
+ <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4" id="author-grid" data-author-grid>
206
+ {% for doc in author_docs %}
207
+ {% assign dtags = doc.tags | join: "|" | downcase %}
208
+ <div class="col author-item"
209
+ data-collection="{{ doc.collection }}"
210
+ data-date="{{ doc.date | date: '%s' }}"
211
+ data-title="{{ doc.title | downcase | escape }}"
212
+ data-tags="{{ dtags | escape }}">
213
+ {% include components/post-card.html post=doc show_author=false col=false %}
214
+ </div>
215
+ {% endfor %}
216
+ </div>
217
+
218
+ <!-- No-results state (shown by JS when filters match nothing) -->
219
+ <div class="author-noresults text-center py-5 d-none" data-author-noresults>
220
+ <i class="bi bi-search fs-1 text-muted" aria-hidden="true"></i>
221
+ <p class="text-muted mt-3 mb-2">No contributions match your filters.</p>
222
+ <button type="button" class="btn btn-outline-primary btn-sm" data-author-clear>Clear filters</button>
223
+ </div>
224
+ </section>
225
+
226
+ <!-- Structured data: this author's contributions as a CollectionPage / ItemList -->
227
+ <script type="application/ld+json">
228
+ {
229
+ "@context": "https://schema.org",
230
+ "@type": "CollectionPage",
231
+ "name": {{ page.title | default: author_name | jsonify }},
232
+ "url": {{ page.url | absolute_url | jsonify }},
233
+ "about": {
234
+ "@type": "Person",
235
+ "name": {{ author_name | jsonify }}{% if author_data.website %},
236
+ "url": {{ author_data.website | jsonify }}{% endif %}
237
+ },
238
+ "mainEntity": {
239
+ "@type": "ItemList",
240
+ "numberOfItems": {{ author_docs.size }},
241
+ "itemListElement": [
242
+ {% for doc in author_docs limit: 100 %}{
243
+ "@type": "ListItem",
244
+ "position": {{ forloop.index }},
245
+ "url": {{ doc.url | absolute_url | jsonify }},
246
+ "name": {{ doc.title | jsonify }}
247
+ }{% unless forloop.last %},{% endunless %}
248
+ {% endfor %}
249
+ ]
250
+ }
251
+ }
252
+ </script>
253
+
254
+ <script defer src="{{ '/assets/js/author-profile.js' | relative_url }}?v={{ site.time | date: '%s' }}"></script>
255
+
256
+ {% else %}
257
+ {% comment %} Suppress the generic empty state when the page supplies its own
258
+ body content (e.g. the Guest profile, which is a contribution guide). {% endcomment %}
259
+ {% assign page_body = content | strip %}
260
+ {% if page_body == "" %}
261
+ <!-- Empty state -->
262
+ <div class="text-center py-5">
263
+ <i class="bi bi-journal-x fs-1 text-muted" aria-hidden="true"></i>
264
+ <p class="text-muted mt-3">No published content from this author yet.</p>
265
+ <a href="{{ '/' | relative_url }}" class="btn btn-outline-primary">Back to home</a>
266
+ </div>
267
+ {% endif %}
268
+ {% endif %}
269
+
270
+ {% if content and content != "" %}
271
+ <section class="author-page-body mt-4">
272
+ {{ content }}
273
+ </section>
274
+ {% endif %}
@@ -0,0 +1,55 @@
1
+ ---
2
+ layout: default
3
+ ---
4
+ <!--
5
+ ===================================================================
6
+ AUTHORS INDEX LAYOUT - Directory of all author profiles
7
+ ===================================================================
8
+
9
+ File: authors.html
10
+ Path: _layouts/authors.html
11
+ Inherits: default.html (which inherits root.html)
12
+ Purpose: List every author defined in _data/authors.yml, each linking to
13
+ their individual profile page (/authors/:key/).
14
+
15
+ Front Matter Variables:
16
+ - title (optional): Page title (default: "Authors")
17
+ - description (optional): Intro text
18
+
19
+ Opt-out: an author entry with `profile: false` in _data/authors.yml is
20
+ hidden from this directory (and skipped by the page generator).
21
+
22
+ Dependencies:
23
+ - _data/authors.yml
24
+ - components/author-card.html (style="compact")
25
+ ===================================================================
26
+ -->
27
+
28
+ <header class="authors-index-header mb-5">
29
+ <div class="d-flex align-items-center mb-3">
30
+ <i class="bi bi-people-fill fs-1 me-3 text-primary" aria-hidden="true"></i>
31
+ <div>
32
+ <h1 class="display-5 mb-0">{{ page.title | default: "Authors" }}</h1>
33
+ <p class="text-muted mb-0">{{ page.description | default: "Meet the people behind the content." }}</p>
34
+ </div>
35
+ </div>
36
+ {{ content }}
37
+ </header>
38
+
39
+ <section class="authors-index">
40
+ <div class="row row-cols-1 row-cols-md-2 g-4">
41
+ {% for author in site.data.authors %}
42
+ {% assign author_key = author[0] %}
43
+ {% assign author_data = author[1] %}
44
+ {% unless author_data.profile == false %}
45
+ <div class="col">
46
+ <div class="card h-100 border-0 shadow-sm">
47
+ <div class="card-body">
48
+ {% include components/author-card.html author=author_key style="compact" show_bio=true avatar_size=64 %}
49
+ </div>
50
+ </div>
51
+ </div>
52
+ {% endunless %}
53
+ {% endfor %}
54
+ </div>
55
+ </section>
data/_layouts/news.html CHANGED
@@ -121,7 +121,7 @@ source: "https://getbootstrap.com/docs/5.3/examples/blog/#"
121
121
  </small>
122
122
  {% if hero_post.author %}
123
123
  <small class="text-muted">
124
- <i class="bi bi-person me-1"></i>{{ hero_post.author }}
124
+ {% include components/author-card.html author=hero_post.author style="inline" show_avatar=false %}
125
125
  </small>
126
126
  {% endif %}
127
127
  {% if hero_post.categories.size > 0 %}
@@ -218,7 +218,7 @@ source: "https://getbootstrap.com/docs/5.3/examples/blog/#"
218
218
  <i class="bi bi-calendar me-1"></i>{{ main_featured.date | date: "%b %d, %Y" }}
219
219
  {% if main_featured.author %}
220
220
  <span class="mx-2">•</span>
221
- <i class="bi bi-person me-1"></i>{{ main_featured.author }}
221
+ {% include components/author-card.html author=main_featured.author style="inline" show_avatar=false %}
222
222
  {% endif %}
223
223
  </small>
224
224
  </div>
@@ -414,7 +414,7 @@ source: "https://getbootstrap.com/docs/5.3/examples/blog/#"
414
414
  <div class="d-flex gap-3 text-muted small">
415
415
  <span><i class="bi bi-calendar me-1"></i>{{ lpost.date | date: "%B %d, %Y" }}</span>
416
416
  {% if lpost.author %}
417
- <span><i class="bi bi-person me-1"></i>{{ lpost.author }}</span>
417
+ <span>{% include components/author-card.html author=lpost.author style="inline" show_avatar=false %}</span>
418
418
  {% endif %}
419
419
  </div>
420
420
  </div>
data/_layouts/note.html CHANGED
@@ -52,8 +52,7 @@ layout: default
52
52
  <!-- Author -->
53
53
  {% if page.author %}
54
54
  <span class="note-author" itemprop="author" itemscope itemtype="https://schema.org/Person">
55
- <i class="bi bi-person me-1"></i>
56
- <span itemprop="name">{{ page.author }}</span>
55
+ {% include components/author-card.html author=page.author style="inline" name_itemprop="name" %}
57
56
  </span>
58
57
  <span class="mx-2">•</span>
59
58
  {% endif %}
@@ -104,12 +103,21 @@ layout: default
104
103
  </div>
105
104
 
106
105
  <!-- Tags -->
106
+ {% comment %} Link tag badges only when a tags index exists (configurable via
107
+ `tags_page`); otherwise render plain badges so remote-theme consumers
108
+ without a /tags/ page don't get 404 links. See issue #204. {% endcomment %}
107
109
  {% if page.tags and page.tags.size > 0 %}
110
+ {% assign _tags_base = site.tags_page | default: '/tags/' %}
111
+ {% assign _tags_page = site.html_pages | where: "url", _tags_base | first %}
108
112
  <div class="note-tags mb-3">
109
113
  {% for tag in page.tags %}
110
- <a href="{{ '/tags/' | relative_url }}#{{ tag | slugify }}" class="badge bg-secondary text-decoration-none me-1">
111
- {{ tag }}
112
- </a>
114
+ {% if _tags_page %}
115
+ <a href="{{ _tags_base | relative_url }}#{{ tag | slugify }}" class="badge bg-secondary text-decoration-none me-1">
116
+ {{ tag }}
117
+ </a>
118
+ {% else %}
119
+ <span class="badge bg-secondary me-1">{{ tag }}</span>
120
+ {% endif %}
113
121
  {% endfor %}
114
122
  </div>
115
123
  {% endif %}
@@ -141,7 +149,14 @@ layout: default
141
149
  <div class="note-content e-content" itemprop="articleBody">
142
150
  {{ content }}
143
151
  </div>
144
-
152
+
153
+ <!-- ================================ -->
154
+ <!-- AUTHOR BIO SECTION -->
155
+ <!-- ================================ -->
156
+ {% if page.author and page.author_profile != false %}
157
+ {% include components/author-bio.html author=page.author %}
158
+ {% endif %}
159
+
145
160
  <!-- ================================ -->
146
161
  <!-- NOTE FOOTER -->
147
162
  <!-- ================================ -->