jekyll-theme-zer0 1.18.1 → 1.19.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.
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ # =============================================================================
4
+ # scripts/lib/hub.rb — shared helpers for the org content hub tooling
5
+ # =============================================================================
6
+ #
7
+ # Used by:
8
+ # scripts/sync-hub-metadata.rb dashboard data refresh (_data/hub_index.yml)
9
+ # scripts/provision-org-sites.rb Pages scaffold rollout to org repos
10
+ #
11
+ # The registry (_data/hub.yml) is the single source of truth; see its header
12
+ # for the architecture. Everything here is read-only with respect to this
13
+ # repository — callers decide what to write.
14
+ # =============================================================================
15
+
16
+ require 'yaml'
17
+ require 'json'
18
+ require 'date'
19
+ require 'base64'
20
+ require 'open3'
21
+
22
+ module Hub
23
+ ROOT = File.expand_path('../..', __dir__)
24
+ CONFIG_FILE = File.join(ROOT, '_data', 'hub.yml')
25
+
26
+ GENERATED_HEADER = <<~HEADER
27
+ # =============================================================================
28
+ # GENERATED FILE — do not edit by hand.
29
+ # Regenerate with: ./scripts/sync-hub-metadata.sh
30
+ # Source registry: _data/hub.yml
31
+ # =============================================================================
32
+ HEADER
33
+
34
+ module_function
35
+
36
+ def log_info(msg)
37
+ puts "[INFO] #{msg}"
38
+ end
39
+
40
+ def log_warn(msg)
41
+ warn "[WARN] #{msg}"
42
+ end
43
+
44
+ def log_error(msg)
45
+ warn "[ERROR] #{msg}"
46
+ end
47
+
48
+ # Permit Date/Time; fall back for the older macOS system Ruby (2.6) whose
49
+ # safe loader signature differs.
50
+ def load_yaml(path)
51
+ YAML.load_file(path, permitted_classes: [Date, Time])
52
+ rescue ArgumentError
53
+ YAML.load_file(path)
54
+ end
55
+
56
+ def run_cmd(*cmd)
57
+ out, err, status = Open3.capture3(*cmd)
58
+ raise "command failed: #{cmd.join(' ')}\n#{err}" unless status.success?
59
+
60
+ out
61
+ end
62
+
63
+ # gh api wrapper returning parsed JSON, or nil on a 404.
64
+ def gh_api(path)
65
+ out, err, status = Open3.capture3('gh', 'api', path)
66
+ return JSON.parse(out) if status.success?
67
+ return nil if err.include?('404') || out.include?('"status": "404"')
68
+
69
+ raise "gh api #{path} failed:\n#{err}"
70
+ end
71
+
72
+ def load_registry
73
+ raise "registry not found: #{CONFIG_FILE}" unless File.exist?(CONFIG_FILE)
74
+
75
+ load_yaml(CONFIG_FILE)
76
+ end
77
+
78
+ def validate_registry(cfg)
79
+ return ['registry is not a YAML mapping'] unless cfg.is_a?(Hash)
80
+
81
+ errors = []
82
+ errors << 'org: must be a non-empty string' unless cfg['org'].is_a?(String) && !cfg['org'].strip.empty?
83
+
84
+ if cfg.key?('auto_discover') && ![true, false].include?(cfg['auto_discover'])
85
+ errors << 'auto_discover: must be true or false'
86
+ end
87
+
88
+ if cfg.key?('exclude_repos') && !(cfg['exclude_repos'].is_a?(Array) && cfg['exclude_repos'].all? { |r| r.is_a?(String) })
89
+ errors << 'exclude_repos: must be a list of strings'
90
+ end
91
+
92
+ pages = cfg['pages'] || {}
93
+ errors << 'pages: must be a mapping' unless pages.is_a?(Hash)
94
+ if pages.is_a?(Hash) && !(pages['theme_repo'].is_a?(String) && pages['theme_repo'].include?('/'))
95
+ errors << 'pages.theme_repo: must be an <owner>/<repo> string'
96
+ end
97
+
98
+ defaults = cfg['defaults'] || {}
99
+ errors << 'defaults: must be a mapping' unless defaults.is_a?(Hash)
100
+ if defaults.is_a?(Hash) && defaults.key?('exclude') &&
101
+ !(defaults['exclude'].is_a?(Array) && defaults['exclude'].all? { |p| p.is_a?(String) })
102
+ errors << 'defaults.exclude: must be a list of glob strings'
103
+ end
104
+
105
+ repos = cfg['repos'] || []
106
+ errors << 'repos: must be a list' unless repos.is_a?(Array)
107
+ if repos.is_a?(Array)
108
+ repos.each_with_index do |repo, i|
109
+ unless repo.is_a?(Hash) && repo['name'].is_a?(String) && !repo['name'].strip.empty?
110
+ errors << "repos[#{i}]: needs a non-empty `name`"
111
+ next
112
+ end
113
+ errors << "repos[#{i}] (#{repo['name']}): name contains unsafe characters" unless repo['name'].match?(%r{\A[\w.-]+\z})
114
+ end
115
+ end
116
+
117
+ if cfg['auto_discover'] == false && repos.empty?
118
+ errors << 'auto_discover is false but repos: is empty — nothing to include'
119
+ end
120
+
121
+ errors
122
+ end
123
+
124
+ # Org repos that are hub content sites, with per-repo registry overrides
125
+ # merged on top. Requires `gh` unless auto_discover is false.
126
+ def discover_repos(cfg)
127
+ org = cfg['org']
128
+ excluded = cfg['exclude_repos'] || []
129
+ manual = (cfg['repos'] || []).map { |r| [r['name'], r] }.to_h
130
+
131
+ discovered =
132
+ if cfg.fetch('auto_discover', true)
133
+ json = run_cmd('gh', 'repo', 'list', org, '--limit', '200',
134
+ '--json', 'name,description,defaultBranchRef,isArchived,pushedAt')
135
+ JSON.parse(json).reject { |r| r['isArchived'] }.map do |r|
136
+ {
137
+ 'name' => r['name'],
138
+ 'description' => r['description'].to_s,
139
+ 'branch' => r.dig('defaultBranchRef', 'name') || 'main',
140
+ 'pushed_at' => r['pushedAt'].to_s
141
+ }
142
+ end
143
+ else
144
+ manual.values.map { |r| { 'branch' => 'main' }.merge(r) }
145
+ end
146
+
147
+ discovered
148
+ .reject { |r| excluded.include?(r['name']) }
149
+ .map { |r| r.merge(manual[r['name']] || {}) { |_k, auto, over| (over.nil? || over == '') ? auto : over } }
150
+ .sort_by { |r| r['name'] }
151
+ end
152
+
153
+ def excluded?(rel, patterns)
154
+ patterns.any? do |pat|
155
+ rel == pat ||
156
+ File.fnmatch?(pat, rel, File::FNM_PATHNAME) ||
157
+ (pat.end_with?('/**') && rel.start_with?("#{pat[0..-4]}/"))
158
+ end
159
+ end
160
+
161
+ def slugify(segment)
162
+ segment.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
163
+ end
164
+
165
+ def humanize(name)
166
+ name.tr('-_', ' ').split.map(&:capitalize).join(' ')
167
+ end
168
+
169
+ FRONT_MATTER_RE = /\A---\s*\n(.*?\n?)^---\s*\n/m
170
+
171
+ def split_front_matter(raw)
172
+ match = raw.match(FRONT_MATTER_RE)
173
+ return [nil, raw] unless match
174
+
175
+ fm = begin
176
+ YAML.safe_load(match[1], permitted_classes: [Date, Time]) || {}
177
+ rescue StandardError
178
+ nil
179
+ end
180
+ return [nil, raw] unless fm.is_a?(Hash)
181
+
182
+ [fm, match.post_match]
183
+ end
184
+
185
+ # Title of a markdown document: front matter `title`, else first H1, else
186
+ # the humanized fallback.
187
+ def title_of(raw, fallback)
188
+ fm, body = split_front_matter(raw)
189
+ (fm && fm['title']) || body[/^\#\s+(.+?)\s*$/, 1] || humanize(fallback)
190
+ end
191
+
192
+ # The markdown content files of a repo tree (array of path strings),
193
+ # filtered through the registry's exclude patterns.
194
+ def content_paths(paths, exclude_patterns)
195
+ paths.select { |p| p.end_with?('.md') }
196
+ .reject { |p| p.split('/').any? { |seg| seg.start_with?('.') } }
197
+ .reject { |p| excluded?(p, exclude_patterns) }
198
+ .sort
199
+ end
200
+
201
+ # Pretty-permalink URL of a repo-root or nested page on the published site.
202
+ # site_url must end with '/'. README/index map to their directory root.
203
+ def page_url(site_url, rel)
204
+ segments = rel.sub(/\.md\z/i, '').split('/')
205
+ segments.pop if %w[index readme].include?(segments.last.downcase)
206
+ segments.empty? ? site_url : "#{site_url}#{segments.join('/')}/"
207
+ end
208
+ end
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # =============================================================================
5
+ # provision-org-sites.rb
6
+ # =============================================================================
7
+ #
8
+ # Rolls the GitHub Pages scaffold out to the org repos registered in
9
+ # `_data/hub.yml`, so each publishes its own project site at
10
+ # https://<org>.github.io/<repo>/ rendered with the zer0-mistakes theme
11
+ # (`remote_theme`) — content never leaves the source repo.
12
+ #
13
+ # Per repo it:
14
+ # 1. clones the repo and analyzes its content layout (sections, timeline),
15
+ # 2. renders templates/org-site/* (_config.yml + _data/navigation/main.yml),
16
+ # 3. opens a pull request with the scaffold (branch: zer0/pages-scaffold) —
17
+ # or pushes directly with --direct,
18
+ # 4. with --enable-pages, enables GitHub Pages ("deploy from branch").
19
+ #
20
+ # Safety:
21
+ # - A repo whose _config.yml was NOT generated by this script is skipped
22
+ # (never clobbers a hand-rolled site config).
23
+ # - Re-running is idempotent: identical scaffold -> no new PR/commit.
24
+ #
25
+ # Usage:
26
+ # ruby scripts/provision-org-sites.rb --dry-run # preview everything
27
+ # ruby scripts/provision-org-sites.rb # open scaffold PRs
28
+ # ruby scripts/provision-org-sites.rb --enable-pages # PRs + enable Pages
29
+ # ruby scripts/provision-org-sites.rb --repos 2005 --direct
30
+ # ruby scripts/provision-org-sites.rb --stage /tmp/out # render files locally only
31
+ # ruby scripts/provision-org-sites.rb --check # validate registry + templates
32
+ #
33
+ # Requires `gh` (repo + pages write for the real run) and `git`.
34
+ # =============================================================================
35
+
36
+ require 'optparse'
37
+ require 'fileutils'
38
+ require 'tmpdir'
39
+ require_relative 'lib/hub'
40
+
41
+ TEMPLATE_DIR = File.join(Hub::ROOT, 'templates', 'org-site')
42
+ CONFIG_TEMPLATE = File.join(TEMPLATE_DIR, '_config.yml.template')
43
+ NAV_TEMPLATE = File.join(TEMPLATE_DIR, 'navigation-main.yml.template')
44
+ SCAFFOLD_MARKER = 'zer0-mistakes org hub scaffold'
45
+ BRANCH_NAME = 'zer0/pages-scaffold'
46
+
47
+ def render(template, vars)
48
+ vars.reduce(File.read(template, encoding: 'utf-8')) do |text, (key, value)|
49
+ text.gsub("{{#{key}}}", value.to_s)
50
+ end
51
+ end
52
+
53
+ def yaml_quote(text)
54
+ text.to_s.gsub('"', '\"')
55
+ end
56
+
57
+ # Nav entries for the repo's root content files and sections, rendered as
58
+ # site-relative URLs (the theme's relative_url filter prefixes the baseurl).
59
+ def nav_items(clone, files)
60
+ items = +''
61
+ files.reject { |f| f.include?('/') }
62
+ .reject { |f| %w[index readme].include?(File.basename(f, '.md').downcase) }
63
+ .each do |f|
64
+ base = File.basename(f, '.md')
65
+ items << "- title: #{Hub.title_of(File.read(File.join(clone, f), encoding: 'utf-8'), base)}\n" \
66
+ " icon: bi-clock-history\n" \
67
+ " url: /#{base}/\n"
68
+ end
69
+ files.select { |f| f.include?('/') }.map { |f| f.split('/').first }.uniq.sort.each do |dir|
70
+ index = File.join(clone, dir, 'index.md')
71
+ title = File.exist?(index) ? Hub.title_of(File.read(index, encoding: 'utf-8'), dir) : Hub.humanize(dir)
72
+ items << "- title: #{title}\n" \
73
+ " icon: bi-folder\n" \
74
+ " url: /#{dir}/\n"
75
+ end
76
+ items.chomp
77
+ end
78
+
79
+ def scaffold_files(repo, cfg, clone)
80
+ excludes = (cfg['defaults'] || {})['exclude'] || []
81
+ files = Hub.content_paths(Dir.chdir(clone) { Dir.glob('**/*.md') }, excludes)
82
+
83
+ vars = {
84
+ 'ORG' => cfg['org'],
85
+ 'REPO' => repo['name'],
86
+ 'REPO_TITLE' => yaml_quote(repo['title'] || repo['name']),
87
+ 'REPO_DESCRIPTION' => yaml_quote(repo['description']),
88
+ 'THEME_REPO' => cfg.dig('pages', 'theme_repo'),
89
+ 'BRANCH' => repo['branch'],
90
+ 'NAV_ITEMS' => nav_items(clone, files)
91
+ }
92
+
93
+ {
94
+ '_config.yml' => render(CONFIG_TEMPLATE, vars),
95
+ '_data/navigation/main.yml' => render(NAV_TEMPLATE, vars)
96
+ }
97
+ end
98
+
99
+ def write_scaffold(base_dir, rendered)
100
+ changed = []
101
+ rendered.each do |rel, content|
102
+ path = File.join(base_dir, rel)
103
+ next if File.exist?(path) && File.read(path, encoding: 'utf-8') == content
104
+
105
+ FileUtils.mkdir_p(File.dirname(path))
106
+ File.write(path, content)
107
+ changed << rel
108
+ end
109
+ changed
110
+ end
111
+
112
+ def open_pr(repo, cfg, clone, changed, direct:)
113
+ org = cfg['org']
114
+ slug = "#{org}/#{repo['name']}"
115
+
116
+ Dir.chdir(clone) do
117
+ if direct
118
+ Hub.run_cmd('git', 'add', *changed)
119
+ Hub.run_cmd('git', 'commit', '-m', 'feat(pages): publish via GitHub Pages with the zer0-mistakes theme')
120
+ Hub.run_cmd('git', 'push', 'origin', repo['branch'])
121
+ Hub.log_info " pushed scaffold directly to #{slug}@#{repo['branch']}"
122
+ return
123
+ end
124
+
125
+ Hub.run_cmd('git', 'checkout', '-B', BRANCH_NAME)
126
+ Hub.run_cmd('git', 'add', *changed)
127
+ Hub.run_cmd('git', 'commit', '-m', 'feat(pages): publish via GitHub Pages with the zer0-mistakes theme')
128
+ Hub.run_cmd('git', 'push', '--force', 'origin', BRANCH_NAME)
129
+
130
+ existing = JSON.parse(Hub.run_cmd('gh', 'pr', 'list', '--repo', slug, '--head', BRANCH_NAME,
131
+ '--state', 'open', '--json', 'url'))
132
+ if existing.empty?
133
+ body = <<~BODY
134
+ Adds the GitHub Pages scaffold generated by
135
+ [zer0-mistakes/scripts/provision-org-sites.rb](https://github.com/#{cfg.dig('pages', 'theme_repo')}/blob/main/scripts/provision-org-sites.rb):
136
+
137
+ - `_config.yml` — publishes this repo at https://#{org}.github.io/#{repo['name']}/ using `remote_theme: #{cfg.dig('pages', 'theme_repo')}`. Content stays plain markdown; the GitHub Pages default plugins render it.
138
+ - `_data/navigation/main.yml` — navbar/sidebar entries for the repo's sections.
139
+
140
+ Once merged (with Pages enabled, "deploy from branch"), the site builds automatically on every push.
141
+
142
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
143
+ BODY
144
+ url = Hub.run_cmd('gh', 'pr', 'create', '--repo', slug, '--base', repo['branch'], '--head', BRANCH_NAME,
145
+ '--title', 'feat(pages): publish via GitHub Pages with the zer0-mistakes theme',
146
+ '--body', body).strip
147
+ Hub.log_info " opened PR: #{url}"
148
+ else
149
+ Hub.log_info " updated existing PR: #{existing.first['url']}"
150
+ end
151
+ end
152
+ end
153
+
154
+ def enable_pages(repo, cfg)
155
+ org = cfg['org']
156
+ out, err, status = Open3.capture3(
157
+ 'gh', 'api', '-X', 'POST', "repos/#{org}/#{repo['name']}/pages",
158
+ '-f', "source[branch]=#{repo['branch']}", '-f', 'source[path]=/'
159
+ )
160
+ if status.success?
161
+ Hub.log_info " Pages enabled: #{JSON.parse(out)['html_url']}"
162
+ elsif err.include?('409') || out.include?('already exists')
163
+ Hub.log_info ' Pages already enabled'
164
+ else
165
+ Hub.log_warn " could not enable Pages: #{err.lines.first&.strip}"
166
+ end
167
+ end
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Main
171
+ # ---------------------------------------------------------------------------
172
+
173
+ options = { mode: :provision, dry_run: false, repos: nil, direct: false, enable_pages: false, stage: nil }
174
+ OptionParser.new do |opts|
175
+ opts.banner = 'Usage: ruby scripts/provision-org-sites.rb [options]'
176
+ opts.on('--check', 'validate registry and templates, no network') { options[:mode] = :check }
177
+ opts.on('--dry-run', 'show planned changes without pushing anything') { options[:dry_run] = true }
178
+ opts.on('--repos LIST', 'comma-separated subset of repos') { |v| options[:repos] = v.split(',').map(&:strip) }
179
+ opts.on('--direct', 'commit to the default branch instead of a PR') { options[:direct] = true }
180
+ opts.on('--enable-pages', 'enable GitHub Pages (deploy from branch)') { options[:enable_pages] = true }
181
+ opts.on('--stage DIR', 'render scaffold files into DIR/<repo>/ only') { |v| options[:stage] = v }
182
+ end.parse!
183
+
184
+ cfg = Hub.load_registry
185
+ errors = Hub.validate_registry(cfg)
186
+ [CONFIG_TEMPLATE, NAV_TEMPLATE].each do |t|
187
+ errors << "missing template: #{t.sub("#{Hub::ROOT}/", '')}" unless File.exist?(t)
188
+ end
189
+
190
+ unless errors.empty?
191
+ errors.each { |e| Hub.log_error(e) }
192
+ exit 1
193
+ end
194
+
195
+ if options[:mode] == :check
196
+ Hub.log_info 'hub registry and org-site templates are valid'
197
+ exit 0
198
+ end
199
+
200
+ repos = Hub.discover_repos(cfg)
201
+ repos.select! { |r| options[:repos].include?(r['name']) } if options[:repos]
202
+ if repos.empty?
203
+ Hub.log_warn 'no repos to provision (check org, exclude_repos, or --repos filter)'
204
+ exit 0
205
+ end
206
+
207
+ Hub.log_info "Provisioning #{repos.size} repo(s) in #{cfg['org']}: #{repos.map { |r| r['name'] }.join(', ')}"
208
+
209
+ Dir.mktmpdir('hub-provision') do |tmp|
210
+ repos.each do |repo|
211
+ slug = "#{cfg['org']}/#{repo['name']}"
212
+ clone = File.join(tmp, repo['name'])
213
+ Hub.log_info "#{slug}:"
214
+ Hub.run_cmd('git', 'clone', '--quiet', '--branch', repo['branch'],
215
+ "https://github.com/#{slug}.git", clone)
216
+
217
+ existing_config = File.join(clone, '_config.yml')
218
+ if File.exist?(existing_config) && !File.read(existing_config, encoding: 'utf-8').include?(SCAFFOLD_MARKER)
219
+ Hub.log_warn ' has a hand-rolled _config.yml — skipping (manage it in the repo itself)'
220
+ next
221
+ end
222
+
223
+ rendered = scaffold_files(repo, cfg, clone)
224
+
225
+ if options[:stage]
226
+ stage_dir = File.join(options[:stage], repo['name'])
227
+ write_scaffold(stage_dir, rendered)
228
+ Hub.log_info " staged scaffold in #{stage_dir}"
229
+ next
230
+ end
231
+
232
+ changed = options[:dry_run] ? rendered.keys.reject { |rel| File.exist?(File.join(clone, rel)) && File.read(File.join(clone, rel), encoding: 'utf-8') == rendered[rel] } : write_scaffold(clone, rendered)
233
+
234
+ if changed.empty?
235
+ Hub.log_info ' scaffold already up to date'
236
+ elsif options[:dry_run]
237
+ Hub.log_info " DRY: would update #{changed.join(', ')} and open a PR"
238
+ else
239
+ open_pr(repo, cfg, clone, changed, direct: options[:direct])
240
+ end
241
+
242
+ if options[:enable_pages]
243
+ if options[:dry_run]
244
+ Hub.log_info ' DRY: would enable GitHub Pages (deploy from branch)'
245
+ else
246
+ enable_pages(repo, cfg)
247
+ end
248
+ end
249
+ end
250
+ end
251
+
252
+ Hub.log_info 'Provisioning complete'
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # provision-org-sites.sh
4
+ # =============================================================================
5
+ #
6
+ # Thin wrapper around scripts/provision-org-sites.rb.
7
+ #
8
+ # Rolls the GitHub Pages scaffold (templates/org-site/*) out to the org repos
9
+ # registered in _data/hub.yml so each publishes at https://<org>.github.io/<repo>/
10
+ # using this theme via remote_theme. Content never leaves the source repo.
11
+ #
12
+ # Usage:
13
+ # ./scripts/provision-org-sites.sh --dry-run # preview everything
14
+ # ./scripts/provision-org-sites.sh # open scaffold PRs
15
+ # ./scripts/provision-org-sites.sh --enable-pages # PRs + enable Pages
16
+ # ./scripts/provision-org-sites.sh --check # validate registry + templates
17
+ #
18
+ # =============================================================================
19
+
20
+ set -euo pipefail
21
+
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ exec ruby "${SCRIPT_DIR}/provision-org-sites.rb" "$@"
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # =============================================================================
5
+ # sync-hub-metadata.rb
6
+ # =============================================================================
7
+ #
8
+ # Refreshes the org content hub dashboard data. Content stays in the source
9
+ # repos (each publishes its own GitHub Pages site at
10
+ # https://<org>.github.io/<repo>/ — see scripts/provision-org-sites.rb); this
11
+ # script only gathers METADATA about them via the GitHub API:
12
+ #
13
+ # _data/hub_index.yml — per-repo stats for the /hub/ dashboard
14
+ # (page counts, sections, Pages status, links)
15
+ # _data/navigation/hub.yml — sidebar tree for the hub pages (sidebar.nav: hub)
16
+ #
17
+ # Nothing is cloned and no content is copied into this repository. Output is
18
+ # deterministic for unchanged sources, so the scheduled workflow only commits
19
+ # real changes.
20
+ #
21
+ # Usage:
22
+ # ruby scripts/sync-hub-metadata.rb # refresh dashboard data
23
+ # ruby scripts/sync-hub-metadata.rb --check # validate registry/output only (CI gate)
24
+ # ruby scripts/sync-hub-metadata.rb --dry-run # print planned writes, change nothing
25
+ #
26
+ # Requires the `gh` CLI (read scope). `--check` needs only the Ruby stdlib.
27
+ # =============================================================================
28
+
29
+ require 'optparse'
30
+ require 'fileutils'
31
+ require_relative 'lib/hub'
32
+
33
+ INDEX_FILE = File.join(Hub::ROOT, '_data', 'hub_index.yml')
34
+ NAV_FILE = File.join(Hub::ROOT, '_data', 'navigation', 'hub.yml')
35
+
36
+ def check_generated_output
37
+ errors = []
38
+ if File.exist?(INDEX_FILE)
39
+ index = Hub.load_yaml(INDEX_FILE)
40
+ if index.is_a?(Hash) && index['repos'].is_a?(Array)
41
+ index['repos'].each do |repo|
42
+ errors << "hub_index repo #{repo['name']}: missing site_url" unless repo['site_url'].to_s.start_with?('https://')
43
+ end
44
+ else
45
+ errors << '_data/hub_index.yml: missing repos list'
46
+ end
47
+ end
48
+ errors
49
+ end
50
+
51
+ # Gathers everything the dashboard needs about one repo from the GitHub API:
52
+ # the markdown tree (for counts/sections), the Pages site status, and whether
53
+ # the Pages scaffold (_config.yml) has landed.
54
+ def inspect_repo(repo, cfg)
55
+ org = cfg['org']
56
+ excludes = (cfg['defaults'] || {})['exclude'] || []
57
+
58
+ tree = Hub.gh_api("repos/#{org}/#{repo['name']}/git/trees/#{repo['branch']}?recursive=1")
59
+ paths = (tree ? tree['tree'] : []).select { |e| e['type'] == 'blob' }.map { |e| e['path'] }
60
+ files = Hub.content_paths(paths, excludes)
61
+
62
+ pages = Hub.gh_api("repos/#{org}/#{repo['name']}/pages")
63
+ site_url = (pages && pages['html_url']) || "https://#{org}.github.io/#{repo['name']}/"
64
+ site_url += '/' unless site_url.end_with?('/')
65
+
66
+ sections = files.select { |f| f.include?('/') }.group_by { |f| f.split('/').first }.sort.map do |dir, pages_in|
67
+ {
68
+ 'name' => dir,
69
+ 'title' => section_title(org, repo, dir),
70
+ 'url' => "#{site_url}#{dir}/",
71
+ 'count' => pages_in.count { |f| File.basename(f, '.md').downcase != 'index' }
72
+ }
73
+ end
74
+
75
+ root_pages = files.reject { |f| f.include?('/') }
76
+ .reject { |f| %w[index readme].include?(File.basename(f, '.md').downcase) }
77
+ .map { |f| { 'title' => Hub.humanize(File.basename(f, '.md')), 'url' => Hub.page_url(site_url, f) } }
78
+
79
+ {
80
+ 'name' => repo['name'],
81
+ 'title' => repo['title'] || repo['name'],
82
+ 'description' => repo['description'].to_s,
83
+ 'url' => "https://github.com/#{org}/#{repo['name']}",
84
+ 'site_url' => site_url,
85
+ 'pages_enabled' => !pages.nil?,
86
+ 'scaffolded' => paths.include?('_config.yml'),
87
+ 'branch' => repo['branch'],
88
+ 'pushed_at' => repo['pushed_at'].to_s,
89
+ 'page_count' => files.size,
90
+ 'sections' => sections,
91
+ 'root_pages' => root_pages
92
+ }
93
+ end
94
+
95
+ # Title of a section's index page (one contents API call), falling back to a
96
+ # humanized directory name.
97
+ def section_title(org, repo, dir)
98
+ doc = Hub.gh_api("repos/#{org}/#{repo['name']}/contents/#{dir}/index.md?ref=#{repo['branch']}")
99
+ return Hub.humanize(dir) unless doc && doc['content']
100
+
101
+ Hub.title_of(Base64.decode64(doc['content']).force_encoding('utf-8'), dir)
102
+ rescue StandardError
103
+ Hub.humanize(dir)
104
+ end
105
+
106
+ def build_index(cfg, repos)
107
+ {
108
+ 'org' => cfg['org'],
109
+ 'totals' => { 'repos' => repos.size, 'pages' => repos.sum { |r| r['page_count'] } },
110
+ 'repos' => repos
111
+ }
112
+ end
113
+
114
+ def build_nav(repos)
115
+ items = [{ 'title' => 'Hub Dashboard', 'icon' => 'bi-grid-1x2', 'url' => '/hub/' }]
116
+ repos.each do |repo|
117
+ children = [{ 'title' => 'Site Home', 'url' => repo['site_url'] }]
118
+ repo['root_pages'].each { |p| children << { 'title' => p['title'], 'url' => p['url'] } }
119
+ repo['sections'].each { |s| children << { 'title' => s['title'], 'url' => s['url'] } }
120
+ items << {
121
+ 'title' => repo['title'],
122
+ 'icon' => 'bi-journal-richtext',
123
+ 'url' => repo['site_url'],
124
+ 'children' => children
125
+ }
126
+ end
127
+ items
128
+ end
129
+
130
+ def write_generated_yaml(path, data, dry_run:)
131
+ content = Hub::GENERATED_HEADER + data.to_yaml.sub(/\A---\n/, '')
132
+ if File.exist?(path) && File.read(path, encoding: 'utf-8') == content
133
+ Hub.log_info "unchanged: #{path.sub("#{Hub::ROOT}/", '')}"
134
+ elsif dry_run
135
+ Hub.log_info "DRY: would write #{path.sub("#{Hub::ROOT}/", '')}"
136
+ else
137
+ FileUtils.mkdir_p(File.dirname(path))
138
+ File.write(path, content)
139
+ Hub.log_info "wrote #{path.sub("#{Hub::ROOT}/", '')}"
140
+ end
141
+ end
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Main
145
+ # ---------------------------------------------------------------------------
146
+
147
+ options = { mode: :sync, dry_run: false }
148
+ OptionParser.new do |opts|
149
+ opts.banner = 'Usage: ruby scripts/sync-hub-metadata.rb [--check|--dry-run]'
150
+ opts.on('--check', 'validate registry and generated data, no writes') { options[:mode] = :check }
151
+ opts.on('--dry-run', 'print planned actions without writing') { options[:dry_run] = true }
152
+ end.parse!
153
+
154
+ cfg = Hub.load_registry
155
+ errors = Hub.validate_registry(cfg)
156
+ errors += check_generated_output if options[:mode] == :check && errors.empty?
157
+
158
+ unless errors.empty?
159
+ errors.each { |e| Hub.log_error(e) }
160
+ exit 1
161
+ end
162
+
163
+ if options[:mode] == :check
164
+ Hub.log_info 'hub registry and generated data are valid'
165
+ exit 0
166
+ end
167
+
168
+ repos = Hub.discover_repos(cfg)
169
+ if repos.empty?
170
+ Hub.log_warn 'no repos found (check org and exclude_repos)'
171
+ exit 0
172
+ end
173
+
174
+ Hub.log_info "Inspecting #{repos.size} repo(s) in #{cfg['org']}: #{repos.map { |r| r['name'] }.join(', ')}"
175
+ inspected = repos.map do |repo|
176
+ info = inspect_repo(repo, cfg)
177
+ state = info['pages_enabled'] ? "live at #{info['site_url']}" : 'Pages NOT enabled'
178
+ Hub.log_info " #{info['name']}: #{info['page_count']} pages, #{info['sections'].size} sections — #{state}"
179
+ info
180
+ end
181
+
182
+ write_generated_yaml(INDEX_FILE, build_index(cfg, inspected), dry_run: options[:dry_run])
183
+ write_generated_yaml(NAV_FILE, build_nav(inspected), dry_run: options[:dry_run])
184
+ Hub.log_info 'Hub metadata refresh complete'