jekyll-theme-zer0 1.17.0 → 1.18.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: addc113b917ada3a80a134299c5a37cf05109133f628a4195fac8fdbb402102d
4
- data.tar.gz: b07960a50b9812125d083310ba4afbf8f4d670d2fe69e901b4e3fca33010260a
3
+ metadata.gz: b19b74478bb62df4aa7a1026d96ff2b27b831fb7fd9ebc4a4459005a153dcf15
4
+ data.tar.gz: 6af11d4a01e9079b49bfe82dde4c99745e2727ca7f022e8a1d41696d3190cd13
5
5
  SHA512:
6
- metadata.gz: 0a37703e0433fc6bdceb50b35fdcfe37157d9b863bc62eff91190948881eb4c092476376d4c76b5807b0faec54334ebf3028249f004df7d4bbe3ce72019f2af3
7
- data.tar.gz: 2a57166a4b747c174111c778822d65e0fcc4a955480add45118fb449f54ca37a113ca2995490d7e90e4bdb0028c227ea5377a6447b735cbe1d8784d936edcb54
6
+ metadata.gz: d2f8d8bb9e6027311f55121534ca77675c30c5994e064f04f9c1e8d56bf2fcce5eb0447da5b8aefd8a6dcd094599379d5f1487f2d140f4a117fb82f806360a34
7
+ data.tar.gz: 79222f85a38198dba78968785880982b3be2cc3594826d0fe04cd6d84a7530a96d46c487673fa50f6f79b616104e44e0ed92a580ea996090f33e04107ffb6e85
data/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.18.0] - 2026-06-13
9
+
10
+ ### Changed
11
+ - Version bump: minor release
12
+
13
+ ### Commits in this release
14
+ - e7c8e33c feat(content-review): AI content reviewer framework with Claude Code agent (#153)
15
+ - c78433f1 fix(content): render mermaid on 12 pages, restore Obsidian graph, migration tests (T-019) (#150)
16
+
17
+ ### Added
18
+ - **AI content reviewer framework**: a two-tier reviewer that runs on every PR
19
+ touching `pages/**/*.md` and integrates with Claude Code agents to ensure SEO
20
+ is met and content is consistent, polished, and styled to the collection's
21
+ guidelines.
22
+ - **Deterministic tier** — `scripts/content-review.rb` (Ruby, stdlib-only, no
23
+ API key, works on fork PRs) scores each file 0–100 for front matter, SEO
24
+ (title/description length, keywords), structure (headings, code-fence
25
+ languages, image alt text, bare URLs), and terminology. Thresholds are
26
+ derived **per collection** (posts as articles, docs under the documentation
27
+ guidelines, notes/notebooks as short-form, etc.).
28
+ - **Claude Code agent tier** — `.claude/agents/content-reviewer.md` reviews
29
+ tone, clarity, consistency, accessibility, and technical accuracy, loading
30
+ each file's governing instruction files (baseline + collection-specific).
31
+ - **Automation** — `.github/workflows/ai-content-review.yml` posts the
32
+ deterministic summary as a sticky PR comment (always) and runs the Claude
33
+ Code agent when `ANTHROPIC_API_KEY` is configured.
34
+ - **Config & guidance** — `.github/config/content_review.yml` (per-collection
35
+ thresholds + assigned skills/prompts), `.github/instructions/content-review.instructions.md`,
36
+ the `/content-review` prompt + Cursor command, and the `content-review` skill.
37
+
38
+ ## [1.17.1] - 2026-06-13
39
+
40
+ ### Changed
41
+ - Version bump: patch release
42
+
43
+ ### Commits in this release
44
+ - f2657b68 fix(a11y): resolve all navbar & site WCAG 2.1 AA violations (T-007) (#149)
45
+ - 30d836cb fix(admin): sync config-page copy with live _config.yml and redact the Raw tab (T-018) (#148)
46
+
47
+ ### Added
48
+ - **Mermaid diagrams now render on 12 more pages**: pages with ```mermaid``` code fences but no `mermaid: true` front-matter flag (about, several feature/dev docs, all four quickstart guides) were showing raw code instead of diagrams — the flag gates the renderer include. Added the flag; verified all 34 diagrams across the site parse with valid Mermaid syntax and render to SVG in a browser
49
+ - **Obsidian graph view restored**: the `/docs/obsidian/graph/` page (roadmap v1.4 force-directed knowledge graph) had been deleted as a "stub" but its `full-graph.html` include, `obsidian-graph.js`, the docs index link, and 5 inbound wiki-links all still referenced it — a 404 to a shipped feature. Restored the page (it renders 161 nodes / 269 edges from the live wiki-index with zero console errors)
50
+ - **Migration & theme-version coverage (T-019)**: `scripts/test/lib/test_migrate.sh` (14 assertions for Jekyll-site detection, theme-connection classification, and version-gap logic) and `ThemeVersionGeneratorTest` in `test/test_plugins.rb` — the two largest remaining zero-coverage subsystems from the T-005 baseline
51
+
52
+ ### Fixed
53
+ - **Navbar & site accessibility (T-007)**: resolved all WCAG 2.1 AA violations that kept three axe-core audits frozen — dropped the redundant ARIA `menubar`/`menuitem` roles from the nav (the nav landmark already provides semantics; menubars require menuitem children the search/settings buttons weren't), added an `aria-label` to the site-subtitle home link, kept the admin/footer separator a list item, gave the theme-preview disabled tab a `role="tab"` and an icon-only button an `aria-label`, made code blocks a single keyboard-focusable scroll region, and underlined prose links so they're distinguishable without color. The three `test.fixme` blocks in `test/visual/accessibility.spec.js` are now live `test()` calls — verified 0 violations across the homepage, FAQ, and all 8 admin pages (23/23 a11y, 223/223 smoke tier)
54
+
8
55
  ## [1.17.0] - 2026-06-12
9
56
 
10
57
  ### Changed
@@ -13,6 +60,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
13
60
  ### Commits in this release
14
61
  - ac36e1a3 feat(tests): plugin unit specs and coverage baseline — T-011, T-005 (#145)
15
62
 
63
+ ### Fixed
64
+ - **Admin config page sync (T-018)**: the page's config copy is now byte-synced with the live `_config.yml` (raw-wrapped so Liquid-looking comment text renders literally) and `validate` fails on drift; the **visible Raw-YAML tab** now applies the same sensitive-line redaction as the hidden copy element (it previously showed the raw file — the stale copy was the only thing keeping the live PostHog key off that tab); the raw-tab security test targets the real `code#cfg-raw-yaml` element and asserts presence instead of silently skipping
65
+
16
66
  ### Added
17
67
  - **Plugin unit specs (T-011)**: 19 Minitest specs for the previously-untested `preview_image_generator.rb`, `content_statistics_generator.rb`, and `admin_page_urls.rb` plugins (config merge, path normalization, index dedupe by relative path, hook output, edge cases); wired into the core suite as "Plugin Unit Specs"
18
68
  - **Coverage baseline (T-005)**: structural survey recorded at `docs/development/coverage-baseline.md` — 10/10 suites green; the two remaining zero-coverage subsystems filed as T-019 (migrate.sh + theme_version.rb) and T-020 (installer wizard/upgrade)
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  title: zer0-mistakes
3
3
  sub-title: AI-Native Jekyll Theme
4
4
  description: AI-native Jekyll theme for GitHub Pages — Docker-first development, AI-powered installation, multi-agent integration (Copilot, Codex, Cursor, Claude), AI preview-image generation, and AIEO content optimization with Bootstrap 5.3.
5
- version: 1.17.0
5
+ version: 1.18.0
6
6
  layout: landing
7
7
  tags:
8
8
  - jekyll
@@ -20,7 +20,7 @@ categories:
20
20
  - bootstrap
21
21
  - ai-tooling
22
22
  created: 2024-02-10T23:51:11.480Z
23
- lastmod: 2026-06-12T20:08:02.000Z
23
+ lastmod: 2026-06-13T21:56:50.000Z
24
24
  draft: false
25
25
  permalink: /
26
26
  slug: zer0
@@ -911,7 +911,7 @@ git push origin feature/awesome-feature
911
911
 
912
912
  | Metric | Value |
913
913
  |--------|-------|
914
- | **Current Version** | 1.17.0 ([RubyGems](https://rubygems.org/gems/jekyll-theme-zer0), [CHANGELOG](/CHANGELOG)) |
914
+ | **Current Version** | 1.18.0 ([RubyGems](https://rubygems.org/gems/jekyll-theme-zer0), [CHANGELOG](/CHANGELOG)) |
915
915
  | **Documented Features** | 43 ([Feature Registry](https://github.com/bamr87/zer0-mistakes/blob/main/_data/features.yml)) |
916
916
  | **Setup Time** | 2-5 minutes ([install.sh benchmarks](https://github.com/bamr87/zer0-mistakes/blob/main/install.sh)) |
917
917
  | **Documentation Pages** | 70+ ([browse docs](https://zer0-mistakes.com/pages/)) |
@@ -966,6 +966,6 @@ And these AI partners that make zer0-mistakes truly AI-native:
966
966
 
967
967
  **Built with ❤️ — and a little help from our AI partners — for the Jekyll community**
968
968
 
969
- **v1.17.0** • [Changelog](CHANGELOG.md) • [License](LICENSE) • [Contributing](CONTRIBUTING.md) • [AI Agent Guide](AGENTS.md)
969
+ **v1.18.0** • [Changelog](CHANGELOG.md) • [License](LICENSE) • [Contributing](CONTRIBUTING.md) • [AI Agent Guide](AGENTS.md)
970
970
 
971
971
 
data/_data/backlog.yml CHANGED
@@ -56,7 +56,7 @@
56
56
  meta:
57
57
  title: "zer0-mistakes Backlog"
58
58
  updated: 2026-06-12
59
- next_id: 21
59
+ next_id: 22
60
60
 
61
61
  tasks:
62
62
  # --- Housekeeping (seeded so the loop has work on day one) ------------------
@@ -182,7 +182,7 @@ tasks:
182
182
 
183
183
  - id: T-007
184
184
  title: "Fix global navbar WCAG 2.1 AA violations to enable axe-core full-audit tests"
185
- status: open
185
+ status: done
186
186
  priority: P1
187
187
  area: a11y
188
188
  risk: standard
@@ -194,6 +194,14 @@ tasks:
194
194
  `aria-required-children` (menubar/button nesting), `link-name` (icon-only nav links),
195
195
  `list` (footer list structure), and `scrollable-region-focusable` (code blocks).
196
196
  Fix the HTML/ARIA in `_includes/` and `_layouts/` to make the full axe-core audit green.
197
+ Done 2026-06-13: all WCAG 2.1 AA violations resolved and verified with a
198
+ live axe-core run plus the unfrozen Playwright a11y spec (23/23 pass,
199
+ 223/223 smoke tier). The actual violations differed from the stale TODO:
200
+ dropped redundant ARIA menubar/menuitem roles (navbar.html), aria-label
201
+ on the site-subtitle home link, listitem-preserving admin separator,
202
+ role="tab" on the disabled preview tab + aria-label on an icon-only
203
+ button, single focusable scroll region for code blocks (code-copy.js +
204
+ .scss), and underlined prose links across content wrappers.
197
205
  acceptance:
198
206
  - "All four violations listed in the `test.fixme` comment are resolved in `_includes/` / `_layouts/`."
199
207
  - "The three `test.fixme` blocks in `test/visual/accessibility.spec.js` are changed to live `test()` calls and pass in CI."
@@ -201,7 +209,7 @@ tasks:
201
209
  - "No visual regression in smoke snapshot tests."
202
210
  links: { issue: null, pr: null, roadmap: "1.9" }
203
211
  created: 2026-06-01
204
- updated: 2026-06-01
212
+ updated: 2026-06-13
205
213
 
206
214
  - id: T-008
207
215
  title: "Fix theme-customizer YAML export to quote hex color values"
@@ -465,7 +473,7 @@ tasks:
465
473
 
466
474
  - id: T-018
467
475
  title: "Admin config page displays a stale copy of _config.yml — keep it in sync safely"
468
- status: open
476
+ status: done
469
477
  priority: P2
470
478
  area: feat
471
479
  risk: standard
@@ -479,17 +487,24 @@ tasks:
479
487
  refresh would inject the live `phc_` api_key into the *visible* Raw-YAML
480
488
  tab — any sync mechanism must sanitize the visible tab the same way
481
489
  T-009 sanitizes the hidden copy element, or redact at sync time.
490
+ Done 2026-06-12: copy synced (wrapped in Liquid raw markers so config
491
+ comments mentioning Liquid render literally); scripts/bin/validate
492
+ gains a drift check that fails when the copy diverges; the visible
493
+ Raw-YAML tab now renders the same sanitized capture as the hidden
494
+ element (live phc_ key verified redacted in both); the raw-tab
495
+ Playwright test targets code#cfg-raw-yaml and asserts presence
496
+ instead of silently skipping.
482
497
  acceptance:
483
498
  - "The config shown at /about/config/ matches the live `_config.yml` (automated sync step or build-time check that fails on drift)."
484
499
  - "The visible Raw-YAML tab applies the same sensitive-line redaction as the hidden `cfg-full-yaml` element."
485
500
  - "`test/visual/security.spec.js` raw-tab test passes without its silent skip path (locator matches the actual `code#cfg-raw-yaml` element)."
486
501
  links: { issue: null, pr: null, roadmap: "1.13" }
487
502
  created: 2026-06-11
488
- updated: 2026-06-11
503
+ updated: 2026-06-12
489
504
 
490
505
  - id: T-019
491
506
  title: "Unit tests for migrate.sh and theme_version.rb (coverage rank #1)"
492
- status: open
507
+ status: done
493
508
  priority: P2
494
509
  area: tests
495
510
  risk: standard
@@ -501,13 +516,18 @@ tasks:
501
516
  detection, theme connection, admin-page install/verify, version-gap
502
517
  detection) plus _plugins/theme_version.rb (88 lines) as the largest
503
518
  remaining zero-coverage surface (353 lines).
519
+ Done 2026-06-13: scripts/test/lib/test_migrate.sh (14 assertions:
520
+ detect_jekyll_site, validate_theme_connection classification for
521
+ remote/gem/local, detect_version_gap min-version logic) and
522
+ ThemeVersionGeneratorTest in test_plugins.rb (remote/unknown-gem/none
523
+ branches, gem scan stubbed for determinism).
504
524
  acceptance:
505
525
  - "scripts/test/lib/test_migrate.sh exercises detect_jekyll_site, validate_theme_connection, and detect_version_gap against fixture directories."
506
526
  - "test/test_plugins.rb (or a sibling spec) covers theme_version.rb's version resolution."
507
527
  - "./scripts/bin/test stays green with the new specs included."
508
528
  links: { issue: null, pr: null, roadmap: "1.14" }
509
529
  created: 2026-06-12
510
- updated: 2026-06-12
530
+ updated: 2026-06-13
511
531
 
512
532
  - id: T-020
513
533
  title: "CI coverage for installer interactive wizard and upgrade path (coverage rank #2)"
@@ -530,3 +550,26 @@ tasks:
530
550
  links: { issue: null, pr: null, roadmap: "1.14" }
531
551
  created: 2026-06-12
532
552
  updated: 2026-06-12
553
+
554
+ - id: T-021
555
+ title: "Vendor cytoscape.js so the Obsidian graph works without a CDN"
556
+ status: open
557
+ priority: P3
558
+ area: feat
559
+ risk: standard
560
+ effort: S
561
+ source: audit
562
+ summary: >-
563
+ The Obsidian full-graph page (_includes/obsidian/full-graph.html) loads
564
+ cytoscape.js from cdn.jsdelivr.net — the only runtime CDN dependency left
565
+ in the theme, contradicting the vendored-assets principle (Bootstrap,
566
+ Icons, Mermaid are all under assets/vendor/). It also makes the graph
567
+ fail under strict CSP or offline. Vendor cytoscape@3.30.0 under
568
+ assets/vendor/ and reference it locally, matching the Mermaid pattern.
569
+ acceptance:
570
+ - "cytoscape.min.js committed under assets/vendor/ and referenced via a local path."
571
+ - "full-graph.html no longer references cdn.jsdelivr.net."
572
+ - "The graph page renders nodes/edges with the vendored file (no network)."
573
+ links: { issue: null, pr: null, roadmap: null }
574
+ created: 2026-06-13
575
+ updated: 2026-06-13
@@ -146,7 +146,7 @@
146
146
  <button class="nav-link" type="button" role="tab" aria-selected="false">Hover me</button>
147
147
  </li>
148
148
  <li class="nav-item" role="presentation">
149
- <button class="nav-link disabled" type="button" disabled>Disabled</button>
149
+ <button class="nav-link disabled" type="button" role="tab" aria-selected="false" disabled>Disabled</button>
150
150
  </li>
151
151
  </ul>
152
152
  </section>
@@ -528,7 +528,7 @@ docker-compose up
528
528
  <div class="d-flex flex-wrap gap-3 mt-3">
529
529
  <span class="badge bg-primary p-2"><i class="bi bi-lightning-charge me-1"></i>Badge icon</span>
530
530
  <button class="btn btn-outline-primary btn-sm"><i class="bi bi-download me-1"></i>Icon button</button>
531
- <button class="btn btn-primary btn-sm"><i class="bi bi-arrow-right"></i></button>
531
+ <button class="btn btn-primary btn-sm" aria-label="Continue"><i class="bi bi-arrow-right" aria-hidden="true"></i></button>
532
532
  </div>
533
533
  </div>
534
534
  </div>
@@ -44,11 +44,12 @@
44
44
  </div>
45
45
 
46
46
  <!-- If a subtitle exists -->
47
- {%- if site.subtitle -%}
47
+ {%- assign _subtitle = site.subtitle | strip -%}
48
+ {%- if _subtitle != "" -%}
48
49
  <div class="navbar-brand site-subtitle d-none d-lg-inline">
49
- <a class="nav-link" href="{{ '/' | relative_url }}">
50
+ <a class="nav-link" href="{{ '/' | relative_url }}" aria-label="{{ _subtitle }} — {{ site.title }} home">
50
51
  <span class="site-subtitle-text">
51
- {{ site.subtitle }}
52
+ {{ _subtitle }}
52
53
  </span>
53
54
  </a>
54
55
  </div>
@@ -26,7 +26,7 @@
26
26
 
27
27
  {% if item.external and rendered_separator == false %}
28
28
  {% assign rendered_separator = true %}
29
- <li role="separator"><hr class="my-2"></li>
29
+ <li class="nav-item" aria-hidden="true"><hr class="my-2"></li>
30
30
  {% endif %}
31
31
 
32
32
  {% comment %} Active detection: exact URL match or admin_section match {% endcomment %}
@@ -19,9 +19,9 @@
19
19
  <div class="offcanvas-body">
20
20
  <!-- Desktop: width is the grid middle track; labels ellipsis or icon-only via container query -->
21
21
  <div class="bd-navbar-nav-viewport">
22
- <ul class="navbar-nav justify-content-lg-center text-start flex-grow-1" role="menubar">
23
- <li class="nav-item d-lg-none" role="none">
24
- <a class="nav-link" href="{{ '/' | relative_url }}" role="menuitem" {% if page.url == '/' %}aria-current="page"{% endif %}>
22
+ <ul class="navbar-nav justify-content-lg-center text-start flex-grow-1">
23
+ <li class="nav-item d-lg-none">
24
+ <a class="nav-link" href="{{ '/' | relative_url }}" {% if page.url == '/' %}aria-current="page"{% endif %}>
25
25
  <i class="{{site.default_icon}} bi-house" aria-hidden="true"></i>
26
26
  Home
27
27
  </a>
@@ -41,12 +41,12 @@
41
41
  {%- assign items_remaining = nav_main.size | minus: forloop.index -%}
42
42
 
43
43
  {%- if has_children -%}
44
- <li class="nav-item dropdown d-flex align-items-center nav-hover-dropdown" role="none">
44
+ <li class="nav-item dropdown d-flex align-items-center nav-hover-dropdown">
45
45
  <!-- Parent link navigates directly -->
46
46
  <a
47
47
  class="nav-link"
48
48
  href="{{ link.url | relative_url }}"
49
- role="menuitem"
49
+ aria-label="{{ link.title }}"
50
50
  {%- if link.url == page.url -%} aria-current="page"{%- endif -%}
51
51
  title="{{ link.title }}"
52
52
  >
@@ -70,13 +70,13 @@
70
70
  <span class="visually-hidden">Toggle {{ link.title }} submenu</span>
71
71
  </button>
72
72
 
73
- <ul class="dropdown-menu {% if items_remaining < 2 %}dropdown-menu-end{% else %}dropdown-menu-start{% endif %}" aria-labelledby="dropdown-{{ link.title | slugify }}" role="menu">
73
+ <ul class="dropdown-menu {% if items_remaining < 2 %}dropdown-menu-end{% else %}dropdown-menu-start{% endif %}" aria-labelledby="dropdown-{{ link.title | slugify }}">
74
74
  {%- for child in link.children -%}
75
- <li role="none">
75
+ <li>
76
76
  <a
77
77
  class="dropdown-item"
78
78
  href="{{ child.url | relative_url }}"
79
- role="menuitem"
79
+
80
80
  {%- if child.url == page.url -%} aria-current="page"{%- endif -%}
81
81
  >
82
82
  {%- if child.icon -%}
@@ -89,11 +89,11 @@
89
89
  </ul>
90
90
  </li>
91
91
  {%- else -%}
92
- <li class="nav-item" role="none">
92
+ <li class="nav-item">
93
93
  <a
94
94
  class="nav-link"
95
95
  href="{{ link.url | relative_url }}"
96
- role="menuitem"
96
+ aria-label="{{ link.title }}"
97
97
  {%- if link.url == page.url -%} aria-current="page"{%- endif -%}
98
98
  title="{{ link.title }}"
99
99
  >
@@ -117,11 +117,11 @@
117
117
  {%- if collection.docs.size > 0 -%}
118
118
  {%- assign col_title = collection.label | replace: "-", " " | replace: "_", " " | capitalize -%}
119
119
  {%- assign col_url = "/" | append: collection.label | append: "/" -%}
120
- <li class="nav-item" role="none">
120
+ <li class="nav-item">
121
121
  <a
122
122
  class="nav-link"
123
123
  href="{{ col_url | relative_url }}"
124
- role="menuitem"
124
+ aria-label="{{ col_title }}"
125
125
  title="{{ col_title }}"
126
126
  {%- if page.collection == collection.label -%} aria-current="page"{%- endif -%}
127
127
  >
@@ -133,7 +133,7 @@
133
133
  {%- endfor -%}
134
134
 
135
135
  {%- endif -%}
136
- <li class="nav-item d-lg-none" role="none">
136
+ <li class="nav-item d-lg-none">
137
137
  <button
138
138
  class="nav-link btn btn-link text-start w-100"
139
139
  type="button"
@@ -145,7 +145,7 @@
145
145
  Search
146
146
  </button>
147
147
  </li>
148
- <li class="nav-item d-lg-none" role="none">
148
+ <li class="nav-item d-lg-none">
149
149
  <button
150
150
  class="nav-link btn btn-link text-start w-100"
151
151
  type="button"
@@ -641,6 +641,17 @@ a.bd-intro-badge--tag:focus {
641
641
  min-width: 1px
642
642
  }
643
643
 
644
+ /* a11y (WCAG 1.4.1 link-in-text-block): prose links must be distinguishable
645
+ without relying on color. Underline inline content links; exclude links
646
+ that already carry their own non-color affordance (buttons, badges, nav
647
+ pills/tabs, card and icon links). */
648
+ .bd-content :is(p, li, blockquote, td, dd) a:not(.btn):not(.badge):not(.nav-link):not(.dropdown-item):not(.page-link):not(.card-link):not([class*="text-decoration"]),
649
+ .post-content :is(p, li, blockquote, td, dd) a:not(.btn):not(.badge):not(.nav-link):not(.dropdown-item):not(.page-link):not(.card-link):not([class*="text-decoration"]),
650
+ .landing-content-body :is(p, li, blockquote, td, dd) a:not(.btn):not(.badge):not(.nav-link):not(.dropdown-item):not(.page-link):not(.card-link):not([class*="text-decoration"]),
651
+ #admin-content :is(p, li, blockquote, td, dd, .alert) a:not(.btn):not(.badge):not(.nav-link):not(.dropdown-item):not(.page-link):not(.card-link):not(.alert-link):not([class*="text-decoration"]) {
652
+ text-decoration: underline;
653
+ }
654
+
644
655
  @media (min-width: 992px) {
645
656
  .bd-toc {
646
657
  position:-webkit-sticky;
@@ -89,7 +89,7 @@
89
89
  display: block;
90
90
  padding: 0;
91
91
  border: 0;
92
- overflow-x: auto;
92
+ // overflow on the parent <pre> only (single focusable scroll region, WCAG 2.1.1)
93
93
  white-space: pre;
94
94
  font-family: var(--zer0-font-mono);
95
95
  font-size: 0.8125rem;
@@ -254,7 +254,7 @@ pre.highlight,
254
254
  border: 0;
255
255
  padding: 0;
256
256
  padding-right: calc(var(--zer0-space-3) + var(--zer0-code-copy-width));
257
- overflow-x: auto;
257
+ // overflow on the parent <pre> only (single focusable scroll region, WCAG 2.1.1)
258
258
  white-space: pre;
259
259
  background-color: transparent;
260
260
  font-family: var(--zer0-font-mono);
@@ -108,6 +108,17 @@ document.addEventListener('DOMContentLoaded', function () {
108
108
 
109
109
  ensureLineNumbers(preElement, codeElement);
110
110
 
111
+ // a11y (WCAG 2.1.1 scrollable-region-focusable): a code block that can
112
+ // scroll horizontally must be reachable by keyboard. Make every <pre> a
113
+ // focusable region with a discernible name.
114
+ if (!preElement.hasAttribute('tabindex')) {
115
+ preElement.setAttribute('tabindex', '0');
116
+ if (!preElement.hasAttribute('role')) preElement.setAttribute('role', 'region');
117
+ if (!preElement.hasAttribute('aria-label')) {
118
+ preElement.setAttribute('aria-label', (getLanguageLabel(preElement) || 'Code') + ' code block');
119
+ }
120
+ }
121
+
111
122
  if (preElement.querySelector('.copy')) return;
112
123
 
113
124
  var rougeWrapper = preElement.closest('.highlighter-rouge > .highlight');
data/scripts/bin/validate CHANGED
@@ -404,6 +404,23 @@ classified_files = %w[
404
404
  pages/_about/settings/_config.yml
405
405
  .github/ISSUE_TEMPLATE/config.yml
406
406
  ]
407
+
408
+ # T-018: the admin config page renders pages/_about/settings/_config.yml via
409
+ # include_relative (Liquid cannot reach the repo root). Fail on drift so the
410
+ # page never shows a stale copy; refresh with:
411
+ # cp _config.yml pages/_about/settings/_config.yml
412
+ settings_copy = 'pages/_about/settings/_config.yml'
413
+ if File.file?(settings_copy)
414
+ live = File.read('_config.yml', encoding: 'UTF-8')
415
+ copy_lines = File.read(settings_copy, encoding: 'UTF-8').lines
416
+ # The copy is wrapped in {% raw %}/{% endraw %} so include_relative renders
417
+ # the config text literally (the live file mentions Liquid tags in comments).
418
+ refresh = "{ echo '{% raw %}'; cat _config.yml; echo '{% endraw %}'; } > #{settings_copy}"
419
+ assert(copy_lines.first.to_s.strip == '{% raw %}' && copy_lines.last.to_s.strip == '{% endraw %}',
420
+ "#{settings_copy} must be wrapped in raw/endraw; refresh it: #{refresh}")
421
+ assert(copy_lines[1..-2].join == live,
422
+ "#{settings_copy} has drifted from _config.yml; refresh it: #{refresh}")
423
+ end
407
424
  classified_files.concat(Dir.glob('_data/**/*config*.{yml,yaml}'))
408
425
 
409
426
  unclassified_files = config_like_files.sort - classified_files.select { |file| File.file?(file) }
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # =============================================================================
5
+ # content-review.rb
6
+ # =============================================================================
7
+ #
8
+ # Deterministic (no-API) tier of the AI content reviewer framework.
9
+ #
10
+ # Scores Jekyll content files for SEO and content quality using the thresholds
11
+ # in .github/config/content_review.yml and the required-field schema in
12
+ # .github/config/frontmatter_schema.yml. Designed to run anywhere — locally,
13
+ # in CI, and on fork PRs without secrets — so contributors get fast, mechanical
14
+ # feedback before (and independently of) the Claude Code agent tier.
15
+ #
16
+ # Usage:
17
+ # ruby scripts/content-review.rb --changed --base origin/main # diff vs base
18
+ # ruby scripts/content-review.rb --files "a.md b.md" # explicit files
19
+ # ruby scripts/content-review.rb --files a.md --json out.json --summary out.md
20
+ # ruby scripts/content-review.rb --changed --strict # non-zero on fail
21
+ #
22
+ # Options:
23
+ # --files LIST Space/newline-separated markdown files to review
24
+ # --changed Review files changed vs --base (git diff)
25
+ # --base REF Base git ref for --changed (default: origin/main)
26
+ # --config PATH content_review.yml (default: .github/config/content_review.yml)
27
+ # --schema PATH frontmatter_schema.yml (default: .github/config/frontmatter_schema.yml)
28
+ # --json PATH Write machine-readable JSON results
29
+ # --summary PATH Write a Markdown summary (suitable for a PR comment)
30
+ # --strict Exit non-zero if any file scores below the fail threshold
31
+ # --quiet Suppress the human-readable stdout table
32
+ # --help Show this help
33
+ #
34
+ # Exit codes:
35
+ # 0 clean (or warn mode)
36
+ # 1 one or more files below fail threshold (only when --strict)
37
+ # 2 invalid arguments / no files
38
+ # 3 config or parse error
39
+ #
40
+ # No gem dependencies beyond the Ruby stdlib.
41
+ # =============================================================================
42
+
43
+ require 'yaml'
44
+ require 'json'
45
+ require 'optparse'
46
+ require 'date'
47
+
48
+ # Content is UTF-8; force it so checks don't trip on emoji/accents under a
49
+ # US-ASCII locale (common on minimal CI runners).
50
+ Encoding.default_external = Encoding::UTF_8
51
+ Encoding.default_internal = Encoding::UTF_8
52
+
53
+ ROOT = File.expand_path('..', __dir__)
54
+
55
+ # --- Severity helpers --------------------------------------------------------
56
+ SEVERITIES = %w[error warning info].freeze
57
+
58
+ Issue = Struct.new(:severity, :category, :message)
59
+
60
+ # --- Options -----------------------------------------------------------------
61
+ options = {
62
+ files: nil,
63
+ changed: false,
64
+ base: ENV['CONTENT_REVIEW_BASE'] || 'origin/main',
65
+ config: File.join(ROOT, '.github', 'config', 'content_review.yml'),
66
+ schema: File.join(ROOT, '.github', 'config', 'frontmatter_schema.yml'),
67
+ json: nil,
68
+ summary: nil,
69
+ strict: false,
70
+ quiet: false
71
+ }
72
+
73
+ parser = OptionParser.new do |o|
74
+ o.banner = 'Usage: ruby scripts/content-review.rb [options]'
75
+ o.on('--files LIST', 'Files to review (space/newline separated)') { |v| options[:files] = v }
76
+ o.on('--changed', 'Review files changed vs --base') { options[:changed] = true }
77
+ o.on('--base REF', 'Base git ref for --changed') { |v| options[:base] = v }
78
+ o.on('--config PATH', 'content_review.yml path') { |v| options[:config] = v }
79
+ o.on('--schema PATH', 'frontmatter_schema.yml path') { |v| options[:schema] = v }
80
+ o.on('--json PATH', 'Write JSON results') { |v| options[:json] = v }
81
+ o.on('--summary PATH', 'Write Markdown summary') { |v| options[:summary] = v }
82
+ o.on('--strict', 'Exit non-zero on failing files') { options[:strict] = true }
83
+ o.on('--quiet', 'Suppress stdout table') { options[:quiet] = true }
84
+ o.on('-h', '--help', 'Show help') { puts o; exit 0 }
85
+ end
86
+
87
+ begin
88
+ parser.parse!(ARGV)
89
+ rescue OptionParser::ParseError => e
90
+ warn "Error: #{e.message}"
91
+ warn parser
92
+ exit 2
93
+ end
94
+
95
+ # --- Load configuration ------------------------------------------------------
96
+ def load_yaml(path)
97
+ YAML.safe_load(File.read(path), permitted_classes: [Date, Time], aliases: true) || {}
98
+ rescue StandardError => e
99
+ warn "Failed to read #{path}: #{e.message}"
100
+ exit 3
101
+ end
102
+
103
+ unless File.exist?(options[:config])
104
+ warn "Config not found: #{options[:config]}"
105
+ exit 3
106
+ end
107
+
108
+ CONFIG = load_yaml(options[:config])
109
+ SCHEMA = File.exist?(options[:schema]) ? load_yaml(options[:schema]) : {}
110
+
111
+ # Backward-compatible: v2 uses `defaults:`, but fall back to top-level keys.
112
+ DEFAULTS = CONFIG['defaults'] || CONFIG
113
+ COLLECTION_CFG = CONFIG['collections'] || {}
114
+ DEFAULT_FAIL_UNDER = ((DEFAULTS['scoring'] || {})['fail_under'] || 70).to_i
115
+
116
+ # Recursively merge `override` onto `base` (override wins; hashes merge deep).
117
+ def deep_merge(base, override)
118
+ return base if override.nil?
119
+ return override unless base.is_a?(Hash) && override.is_a?(Hash)
120
+
121
+ merged = base.dup
122
+ override.each do |k, v|
123
+ merged[k] = merged.key?(k) ? deep_merge(merged[k], v) : v
124
+ end
125
+ merged
126
+ end
127
+
128
+ # Effective rules for a collection = deep-merge(defaults, collections[name]).
129
+ # `collections.*` in frontmatter_schema.yml may differ from content_review.yml;
130
+ # here we only merge the review config, not the schema.
131
+ def effective_config(collection)
132
+ base = DEFAULTS
133
+ override = collection ? (COLLECTION_CFG[collection] || {}) : {}
134
+ deep_merge(base, override)
135
+ end
136
+
137
+ # --- Resolve the list of files ----------------------------------------------
138
+ def git_changed_files(base)
139
+ merge_base = `git merge-base #{base} HEAD 2>/dev/null`.strip
140
+ ref = merge_base.empty? ? base : merge_base
141
+ out = `git diff --name-only --diff-filter=ACMR #{ref} HEAD 2>/dev/null`
142
+ out.split("\n").map(&:strip).reject(&:empty?)
143
+ end
144
+
145
+ def excluded?(path, config)
146
+ patterns = (config['scope'] || {})['exclude'] || []
147
+ patterns.any? { |g| File.fnmatch(g, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
148
+ end
149
+
150
+ def included?(path, config)
151
+ patterns = (config['scope'] || {})['include'] || []
152
+ patterns.any? { |g| File.fnmatch(g, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
153
+ end
154
+
155
+ raw_files =
156
+ if options[:files]
157
+ options[:files].split(/[\s\n]+/).map(&:strip).reject(&:empty?)
158
+ elsif options[:changed]
159
+ git_changed_files(options[:base])
160
+ else
161
+ warn 'Nothing to review: pass --files or --changed.'
162
+ exit 2
163
+ end
164
+
165
+ files = raw_files
166
+ .select { |f| f.end_with?('.md') }
167
+ .select { |f| included?(f, CONFIG) && !excluded?(f, CONFIG) }
168
+ .select { |f| File.exist?(File.join(ROOT, f)) || File.exist?(f) }
169
+ .uniq
170
+
171
+ if files.empty?
172
+ puts 'No in-scope content files to review.'
173
+ # Still emit empty artifacts so downstream steps don't choke.
174
+ File.write(options[:json], JSON.pretty_generate({ 'files' => [], 'average_score' => nil })) if options[:json]
175
+ File.write(options[:summary], "## 📝 Content Review\n\nNo in-scope content files changed.\n") if options[:summary]
176
+ exit 0
177
+ end
178
+
179
+ # --- Front matter + body parsing --------------------------------------------
180
+ def split_front_matter(text)
181
+ if text =~ /\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m
182
+ [Regexp.last_match(1), Regexp.last_match(2)]
183
+ else
184
+ [nil, text]
185
+ end
186
+ end
187
+
188
+ def parse_front_matter(yaml_text)
189
+ return {} if yaml_text.nil? || yaml_text.strip.empty?
190
+
191
+ YAML.safe_load(yaml_text, permitted_classes: [Date, Time], aliases: true) || {}
192
+ rescue StandardError
193
+ nil # signals a parse error
194
+ end
195
+
196
+ # Remove fenced code blocks so prose checks don't trip on code.
197
+ def strip_code_fences(body)
198
+ body.gsub(/^```.*?^```/m, '').gsub(/`[^`]*`/, '')
199
+ end
200
+
201
+ # --- Collection detection ----------------------------------------------------
202
+ def detect_collection(path, schema)
203
+ collections = schema['collections'] || {}
204
+ # Match specific collections before the generic top-level `pages` pattern.
205
+ ordered = collections.sort_by { |name, _| name == 'pages' ? 1 : 0 }
206
+ ordered.each do |name, spec|
207
+ pattern = spec['path_pattern']
208
+ next unless pattern
209
+
210
+ return name if File.fnmatch(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
211
+ end
212
+ nil
213
+ end
214
+
215
+ # --- Individual checks -------------------------------------------------------
216
+ def check_frontmatter(fm, collection, schema)
217
+ issues = []
218
+ return [Issue.new('error', 'frontmatter', 'No front matter block found')] if fm.nil?
219
+ return [Issue.new('error', 'frontmatter', 'Front matter is not valid YAML')] if fm == :parse_error
220
+
221
+ spec = (schema['collections'] || {})[collection]
222
+ required = spec ? (spec['required'] || []) : []
223
+ required.each do |field|
224
+ val = fm[field]
225
+ if val.nil? || (val.respond_to?(:empty?) && val.empty?)
226
+ issues << Issue.new('error', 'frontmatter', "Missing required field: `#{field}`")
227
+ end
228
+ end
229
+
230
+ # categories/tags should be lists, not bare strings.
231
+ %w[categories tags].each do |field|
232
+ next unless fm.key?(field)
233
+
234
+ issues << Issue.new('warning', 'frontmatter', "`#{field}` should be a YAML list, not a bare string") if fm[field].is_a?(String)
235
+ end
236
+ issues
237
+ end
238
+
239
+ def check_seo(fm, seo)
240
+ issues = []
241
+ return issues if fm.nil? || fm == :parse_error
242
+
243
+ title = fm['title'].to_s
244
+ unless title.empty?
245
+ t = (seo['title'] || {})
246
+ min = (t['min_length'] || 30).to_i
247
+ max = (t['max_length'] || 60).to_i
248
+ if title.length < min
249
+ issues << Issue.new('warning', 'seo', "Title is #{title.length} chars (aim for #{min}–#{max})")
250
+ elsif title.length > max
251
+ issues << Issue.new('warning', 'seo', "Title is #{title.length} chars — over #{max}, will truncate in search results")
252
+ end
253
+ end
254
+
255
+ desc = fm['description'].to_s
256
+ d = (seo['description'] || {})
257
+ dmin = (d['min_length'] || 120).to_i
258
+ dmax = (d['max_length'] || 160).to_i
259
+ if desc.empty?
260
+ issues << Issue.new('warning', 'seo', 'No meta description — add a 120–160 char `description`')
261
+ elsif desc.length < dmin
262
+ issues << Issue.new('warning', 'seo', "Description is #{desc.length} chars (aim for #{dmin}–#{dmax})")
263
+ elsif desc.length > dmax
264
+ issues << Issue.new('warning', 'seo', "Description is #{desc.length} chars — over #{dmax}, will truncate")
265
+ end
266
+
267
+ kw = (seo['keywords'] || {})
268
+ if kw['recommended']
269
+ keywords = fm['keywords']
270
+ if keywords.nil?
271
+ issues << Issue.new('info', 'seo', 'No `keywords` — a 3–10 item list aids AI-engine optimization (AIEO)')
272
+ elsif keywords.is_a?(Array)
273
+ kmin = (kw['min'] || 3).to_i
274
+ kmax = (kw['max'] || 10).to_i
275
+ issues << Issue.new('info', 'seo', "Only #{keywords.length} keyword(s) — aim for #{kmin}–#{kmax}") if keywords.length < kmin
276
+ issues << Issue.new('info', 'seo', "#{keywords.length} keywords — trim to #{kmax} most relevant") if keywords.length > kmax
277
+ end
278
+ end
279
+
280
+ if seo['require_preview_image']
281
+ has_image = %w[preview image og_image].any? { |k| !fm[k].to_s.empty? }
282
+ issues << Issue.new('info', 'seo', 'No preview/social image set') unless has_image
283
+ end
284
+ issues
285
+ end
286
+
287
+ def check_quality(body, quality)
288
+ issues = []
289
+ prose = strip_code_fences(body)
290
+ words = prose.split(/\s+/).reject(&:empty?)
291
+ wc = words.length
292
+
293
+ min_wc = (quality['min_word_count'] || 100).to_i
294
+ max_wc = (quality['max_word_count'] || 3500).to_i
295
+ if wc < min_wc
296
+ issues << Issue.new('warning', 'quality', "Only ~#{wc} words — below the #{min_wc}-word minimum (reads as a stub)")
297
+ elsif wc > max_wc
298
+ issues << Issue.new('info', 'quality', "~#{wc} words — over #{max_wc}; consider splitting into multiple pages")
299
+ end
300
+
301
+ # Heading structure (ignore headings inside code fences).
302
+ headings = body.gsub(/^```.*?^```/m, '').scan(/^(#{'#'}{1,6})\s+\S/).map { |m| m[0].length }
303
+ h2_plus = headings.count { |lvl| lvl >= 2 }
304
+ min_h2 = (quality['min_h2_headings'] || 1).to_i
305
+ issues << Issue.new('info', 'quality', "Fewer than #{min_h2} H2 heading(s) — add sections for scannability") if h2_plus < min_h2
306
+
307
+ max_skip = (quality['max_heading_skip'] || 1).to_i
308
+ headings.each_cons(2) do |a, b|
309
+ if b - a > max_skip
310
+ issues << Issue.new('warning', 'quality', "Heading level jumps from H#{a} to H#{b} — don't skip levels")
311
+ break
312
+ end
313
+ end
314
+
315
+ # Code fences must declare a language.
316
+ if quality['require_code_fence_language']
317
+ body.scan(/^```([^\n]*)\n/).each do |m|
318
+ info = m[0].strip
319
+ issues << Issue.new('info', 'quality', 'Code fence without a language (use ```bash, ```ruby, …)') if info.empty?
320
+ end
321
+ end
322
+
323
+ # Images must have alt text.
324
+ if quality['require_image_alt_text']
325
+ body.scan(/!\[(.*?)\]\(([^)]*)\)/).each do |alt, src|
326
+ issues << Issue.new('warning', 'accessibility', "Image missing alt text: `#{src}`") if alt.strip.empty?
327
+ end
328
+ end
329
+
330
+ # Bare URLs.
331
+ if quality['flag_bare_urls']
332
+ bare = strip_code_fences(body).scan(%r{(?<![("<\]])\bhttps?://[^\s)>\]]+}).reject { |u| u.include?('](') }
333
+ issues << Issue.new('info', 'quality', "#{bare.length} bare URL(s) — wrap as [text](url)") unless bare.empty?
334
+ end
335
+ issues
336
+ end
337
+
338
+ def check_style(body, style)
339
+ issues = []
340
+ prose = strip_code_fences(body)
341
+ (style['terminology'] || {}).each do |wrong, right|
342
+ next if wrong.to_s.empty?
343
+
344
+ if prose.match?(/\b#{Regexp.escape(wrong)}\b/)
345
+ issues << Issue.new('info', 'style', "Use \"#{right}\" instead of \"#{wrong}\"")
346
+ end
347
+ end
348
+ issues
349
+ end
350
+
351
+ # --- Scoring -----------------------------------------------------------------
352
+ def score_for(issues, weights)
353
+ penalty = issues.sum { |i| (weights[i.severity] || 0).to_i }
354
+ [100 - penalty, 0].max
355
+ end
356
+
357
+ def verdict(score, scoring)
358
+ th = scoring['thresholds'] || {}
359
+ return '🟢 excellent' if score >= (th['excellent'] || 90).to_i
360
+ return '🟡 acceptable' if score >= (th['acceptable'] || 70).to_i
361
+
362
+ '🔴 needs work'
363
+ end
364
+
365
+ # --- Run ---------------------------------------------------------------------
366
+ results = []
367
+
368
+ files.each do |rel|
369
+ abs = File.exist?(rel) ? rel : File.join(ROOT, rel)
370
+ text = File.read(abs, encoding: 'UTF-8')
371
+ fm_text, body = split_front_matter(text)
372
+ fm = parse_front_matter(fm_text)
373
+ fm = :parse_error if fm.nil? && !fm_text.nil?
374
+
375
+ collection = detect_collection(rel, SCHEMA)
376
+ eff = effective_config(collection)
377
+ weights = (eff['scoring'] || {})['weights'] || { 'error' => 15, 'warning' => 5, 'info' => 1 }
378
+ fail_under = ((eff['scoring'] || {})['fail_under'] || DEFAULT_FAIL_UNDER).to_i
379
+
380
+ issues = []
381
+ issues.concat(check_frontmatter(fm == :parse_error ? :parse_error : fm, collection, SCHEMA))
382
+ issues.concat(check_seo(fm, eff['seo'] || {}))
383
+ issues.concat(check_quality(body, eff['quality'] || {}))
384
+ issues.concat(check_style(body, eff['style'] || {}))
385
+
386
+ score = score_for(issues, weights)
387
+ results << {
388
+ 'file' => rel,
389
+ 'collection' => collection,
390
+ 'score' => score,
391
+ 'fail_under' => fail_under,
392
+ 'verdict' => verdict(score, eff['scoring'] || {}),
393
+ 'instructions' => eff['instructions'] || [],
394
+ 'issues' => issues.map { |i| { 'severity' => i.severity, 'category' => i.category, 'message' => i.message } }
395
+ }
396
+ end
397
+
398
+ avg = results.empty? ? nil : (results.sum { |r| r['score'] } / results.length.to_f).round(1)
399
+ failing = results.select { |r| r['score'] < r['fail_under'] }
400
+
401
+ # --- Markdown summary --------------------------------------------------------
402
+ def render_summary(results, avg, fail_under)
403
+ lines = []
404
+ lines << '## 📝 AI Content Review — deterministic checks'
405
+ lines << ''
406
+ lines << "Reviewed **#{results.length}** file(s) · average score **#{avg}/100** · pass threshold **#{fail_under}** (per-collection)."
407
+ lines << ''
408
+ lines << '| File | Collection | Score | Verdict |'
409
+ lines << '| --- | --- | ---: | --- |'
410
+ results.sort_by { |r| r['score'] }.each do |r|
411
+ lines << "| `#{r['file']}` | #{r['collection'] || '—'} | #{r['score']}/#{r['fail_under']} | #{r['verdict']} |"
412
+ end
413
+ lines << ''
414
+
415
+ emoji = { 'error' => '❌', 'warning' => '⚠️', 'info' => 'ℹ️' }
416
+ results.each do |r|
417
+ next if r['issues'].empty?
418
+
419
+ lines << "<details><summary><code>#{r['file']}</code> — #{r['issues'].length} item(s), score #{r['score']}</summary>"
420
+ lines << ''
421
+ r['issues'].sort_by { |i| SEVERITIES.index(i['severity']) || 9 }.each do |i|
422
+ lines << "- #{emoji[i['severity']] || '•'} **#{i['category']}** — #{i['message']}"
423
+ end
424
+ lines << ''
425
+ lines << '</details>'
426
+ lines << ''
427
+ end
428
+
429
+ lines << '---'
430
+ lines << '_Deterministic tier (frontmatter + SEO + structure). The Claude Code'
431
+ lines << 'content-reviewer agent covers tone, clarity, and accuracy separately._'
432
+ lines.join("\n") + "\n"
433
+ end
434
+
435
+ summary_md = render_summary(results, avg, DEFAULT_FAIL_UNDER)
436
+
437
+ # --- Outputs -----------------------------------------------------------------
438
+ File.write(options[:json], JSON.pretty_generate({ 'files' => results, 'average_score' => avg, 'default_fail_under' => DEFAULT_FAIL_UNDER })) if options[:json]
439
+ File.write(options[:summary], summary_md) if options[:summary]
440
+
441
+ unless options[:quiet]
442
+ results.sort_by { |r| r['score'] }.each do |r|
443
+ puts "#{r['verdict'].ljust(14)} #{r['score'].to_s.rjust(3)} #{r['file']}"
444
+ r['issues'].each { |i| puts " [#{i['severity']}] #{i['category']}: #{i['message']}" }
445
+ end
446
+ puts ''
447
+ puts "Average: #{avg}/100 (#{results.length} file(s), default threshold #{DEFAULT_FAIL_UNDER})"
448
+ puts "Failing: #{failing.map { |r| r['file'] }.join(', ')}" unless failing.empty?
449
+ end
450
+
451
+ exit 1 if options[:strict] && !failing.empty?
452
+ exit 0
@@ -128,6 +128,7 @@ main() {
128
128
  source "$TEST_DIR/test_gem.sh"
129
129
  source "$TEST_DIR/test_analyze_commits.sh"
130
130
  source "$TEST_DIR/test_locale_independence.sh"
131
+ source "$TEST_DIR/test_migrate.sh"
131
132
 
132
133
  # Summary
133
134
  echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
@@ -0,0 +1,88 @@
1
+ #!/bin/bash
2
+
3
+ # Unit tests for migrate.sh library (T-019)
4
+ #
5
+ # Drives the pure-logic migration helpers against throwaway fixture
6
+ # directories: site detection, theme-connection classification, and
7
+ # version-gap detection. The template-rendering functions
8
+ # (install_admin_pages / verify_admin_pages) need the full template
9
+ # pipeline and are exercised by the installer e2e suites; these unit
10
+ # tests focus on the detection logic that has had zero coverage.
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ LIB_DIR="$(cd "$SCRIPT_DIR/../../lib" && pwd)"
14
+
15
+ export DRY_RUN=true
16
+ export INTERACTIVE=false
17
+ export VERBOSE=false
18
+
19
+ source "$LIB_DIR/common.sh"
20
+ source "$LIB_DIR/migrate.sh"
21
+
22
+ set +e
23
+
24
+ print_suite_header "migrate.sh"
25
+
26
+ _mktmp() { mktemp -d "${TMPDIR:-/tmp}/migrate-test-XXXXXX"; }
27
+
28
+ # --- detect_jekyll_site ---------------------------------------------------
29
+ echo "Testing detect_jekyll_site..."
30
+
31
+ d=$(_mktmp)
32
+ assert_false "detect_jekyll_site '$d'" "No _config.yml → not a Jekyll site"
33
+
34
+ touch "$d/_config.yml"
35
+ assert_false "detect_jekyll_site '$d'" "_config.yml alone is not enough"
36
+
37
+ touch "$d/Gemfile"
38
+ assert_true "detect_jekyll_site '$d'" "_config.yml + Gemfile → Jekyll site"
39
+ rm -rf "$d"
40
+
41
+ d=$(_mktmp); touch "$d/_config.yml"; mkdir -p "$d/_layouts"
42
+ assert_true "detect_jekyll_site '$d'" "_config.yml + _layouts/ → Jekyll site"
43
+ rm -rf "$d"
44
+
45
+ # --- validate_theme_connection -------------------------------------------
46
+ echo "Testing validate_theme_connection..."
47
+
48
+ d=$(_mktmp)
49
+ printf 'remote_theme: "bamr87/zer0-mistakes"\n' > "$d/_config.yml"
50
+ assert_true "validate_theme_connection '$d'" "remote_theme detected"
51
+ validate_theme_connection "$d" >/dev/null 2>&1
52
+ assert_equals "remote" "$THEME_CONNECTION_TYPE" "remote connection type set"
53
+ rm -rf "$d"
54
+
55
+ d=$(_mktmp)
56
+ printf 'theme: jekyll-theme-zer0\n' > "$d/_config.yml"
57
+ assert_true "validate_theme_connection '$d'" "gem theme detected"
58
+ validate_theme_connection "$d" >/dev/null 2>&1
59
+ assert_equals "gem" "$THEME_CONNECTION_TYPE" "gem connection type set"
60
+ rm -rf "$d"
61
+
62
+ d=$(_mktmp)
63
+ printf 'title: My Site\n' > "$d/_config.yml"
64
+ printf 'gem "jekyll-theme-zer0", path: "../theme"\n' > "$d/Gemfile"
65
+ assert_true "validate_theme_connection '$d'" "local path gem detected"
66
+ validate_theme_connection "$d" >/dev/null 2>&1
67
+ assert_equals "local" "$THEME_CONNECTION_TYPE" "local connection type set"
68
+ rm -rf "$d"
69
+
70
+ d=$(_mktmp)
71
+ printf 'title: Unrelated Site\n' > "$d/_config.yml"
72
+ assert_false "validate_theme_connection '$d'" "no theme connection → 1"
73
+ rm -rf "$d"
74
+
75
+ # --- detect_version_gap ---------------------------------------------------
76
+ echo "Testing detect_version_gap..."
77
+
78
+ d=$(_mktmp)
79
+ assert_true "detect_version_gap '$d'" "no Gemfile.lock → pass (cannot check)"
80
+
81
+ printf ' jekyll-theme-zer0 (1.14.0)\n' > "$d/Gemfile.lock"
82
+ assert_true "detect_version_gap '$d'" "modern version >= min → pass"
83
+
84
+ printf ' jekyll-theme-zer0 (0.22.5)\n' > "$d/Gemfile.lock"
85
+ assert_false "detect_version_gap '$d'" "version below 0.22.10 min → gap detected"
86
+ rm -rf "$d"
87
+
88
+ echo -e "\n${GREEN}migrate.sh tests complete${NC}"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-theme-zer0
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.17.0
4
+ version: 1.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amr Abdel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-12 00:00:00.000000000 Z
11
+ date: 2026-06-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -374,6 +374,7 @@ files:
374
374
  - scripts/bin/test
375
375
  - scripts/bin/validate
376
376
  - scripts/build
377
+ - scripts/content-review.rb
377
378
  - scripts/convert-notebooks.sh
378
379
  - scripts/docker-publish
379
380
  - scripts/docs/check-freshness.sh
@@ -490,6 +491,7 @@ files:
490
491
  - scripts/test/lib/test_gem.sh
491
492
  - scripts/test/lib/test_git.sh
492
493
  - scripts/test/lib/test_locale_independence.sh
494
+ - scripts/test/lib/test_migrate.sh
493
495
  - scripts/test/lib/test_validation.sh
494
496
  - scripts/test/lib/test_version.sh
495
497
  - scripts/test/theme/validate