jekyll-theme-zer0 1.17.1 → 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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +4 -4
- data/_data/backlog.yml +31 -3
- data/scripts/content-review.rb +452 -0
- data/scripts/test/lib/run_tests.sh +1 -0
- data/scripts/test/lib/test_migrate.sh +88 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b19b74478bb62df4aa7a1026d96ff2b27b831fb7fd9ebc4a4459005a153dcf15
|
|
4
|
+
data.tar.gz: 6af11d4a01e9079b49bfe82dde4c99745e2727ca7f022e8a1d41696d3190cd13
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2f8d8bb9e6027311f55121534ca77675c30c5994e064f04f9c1e8d56bf2fcce5eb0447da5b8aefd8a6dcd094599379d5f1487f2d140f4a117fb82f806360a34
|
|
7
|
+
data.tar.gz: 79222f85a38198dba78968785880982b3be2cc3594826d0fe04cd6d84a7530a96d46c487673fa50f6f79b616104e44e0ed92a580ea996090f33e04107ffb6e85
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ 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
|
+
|
|
8
38
|
## [1.17.1] - 2026-06-13
|
|
9
39
|
|
|
10
40
|
### Changed
|
|
@@ -14,6 +44,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
14
44
|
- f2657b68 fix(a11y): resolve all navbar & site WCAG 2.1 AA violations (T-007) (#149)
|
|
15
45
|
- 30d836cb fix(admin): sync config-page copy with live _config.yml and redact the Raw tab (T-018) (#148)
|
|
16
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
|
+
|
|
17
52
|
### Fixed
|
|
18
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)
|
|
19
54
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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:
|
|
59
|
+
next_id: 22
|
|
60
60
|
|
|
61
61
|
tasks:
|
|
62
62
|
# --- Housekeeping (seeded so the loop has work on day one) ------------------
|
|
@@ -504,7 +504,7 @@ tasks:
|
|
|
504
504
|
|
|
505
505
|
- id: T-019
|
|
506
506
|
title: "Unit tests for migrate.sh and theme_version.rb (coverage rank #1)"
|
|
507
|
-
status:
|
|
507
|
+
status: done
|
|
508
508
|
priority: P2
|
|
509
509
|
area: tests
|
|
510
510
|
risk: standard
|
|
@@ -516,13 +516,18 @@ tasks:
|
|
|
516
516
|
detection, theme connection, admin-page install/verify, version-gap
|
|
517
517
|
detection) plus _plugins/theme_version.rb (88 lines) as the largest
|
|
518
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).
|
|
519
524
|
acceptance:
|
|
520
525
|
- "scripts/test/lib/test_migrate.sh exercises detect_jekyll_site, validate_theme_connection, and detect_version_gap against fixture directories."
|
|
521
526
|
- "test/test_plugins.rb (or a sibling spec) covers theme_version.rb's version resolution."
|
|
522
527
|
- "./scripts/bin/test stays green with the new specs included."
|
|
523
528
|
links: { issue: null, pr: null, roadmap: "1.14" }
|
|
524
529
|
created: 2026-06-12
|
|
525
|
-
updated: 2026-06-
|
|
530
|
+
updated: 2026-06-13
|
|
526
531
|
|
|
527
532
|
- id: T-020
|
|
528
533
|
title: "CI coverage for installer interactive wizard and upgrade path (coverage rank #2)"
|
|
@@ -545,3 +550,26 @@ tasks:
|
|
|
545
550
|
links: { issue: null, pr: null, roadmap: "1.14" }
|
|
546
551
|
created: 2026-06-12
|
|
547
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
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jekyll-theme-zer0
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Amr Abdel
|
|
@@ -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
|