jekyll-theme-zer0 1.18.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +8 -4
- data/_data/backlog.yml +135 -2
- data/_data/features.yml +31 -10
- data/_data/hub.yml +68 -0
- data/_data/hub_index.yml +203 -0
- data/_data/navigation/hub.yml +110 -0
- data/_data/navigation/main.yml +7 -0
- data/_includes/analytics/google-analytics.html +14 -6
- data/_includes/analytics/google-tag-manager-head.html +13 -5
- data/_includes/components/ai-chat.html +143 -346
- data/_includes/core/head.html +4 -2
- data/_layouts/home.html +17 -4
- data/assets/js/ai-chat.js +853 -0
- data/scripts/content-review.rb +22 -4
- data/scripts/lib/hub.rb +208 -0
- data/scripts/provision-org-sites.rb +252 -0
- data/scripts/provision-org-sites.sh +23 -0
- data/scripts/sync-hub-metadata.rb +184 -0
- data/scripts/sync-hub-metadata.sh +22 -0
- metadata +11 -2
data/scripts/content-review.rb
CHANGED
|
@@ -198,6 +198,13 @@ def strip_code_fences(body)
|
|
|
198
198
|
body.gsub(/^```.*?^```/m, '').gsub(/`[^`]*`/, '')
|
|
199
199
|
end
|
|
200
200
|
|
|
201
|
+
# Remove Liquid {% raw %}...{% endraw %} regions. Content inside them is a
|
|
202
|
+
# literal display example (often showing ``` fences or {{ }} tags), not real
|
|
203
|
+
# page structure, so it must not be counted by the quality/style checks.
|
|
204
|
+
def strip_liquid_raw(body)
|
|
205
|
+
body.gsub(/\{%-?\s*raw\s*-?%\}.*?\{%-?\s*endraw\s*-?%\}/m, '')
|
|
206
|
+
end
|
|
207
|
+
|
|
201
208
|
# --- Collection detection ----------------------------------------------------
|
|
202
209
|
def detect_collection(path, schema)
|
|
203
210
|
collections = schema['collections'] || {}
|
|
@@ -286,6 +293,8 @@ end
|
|
|
286
293
|
|
|
287
294
|
def check_quality(body, quality)
|
|
288
295
|
issues = []
|
|
296
|
+
# Liquid {% raw %} examples are display-only — exclude them from every check.
|
|
297
|
+
body = strip_liquid_raw(body)
|
|
289
298
|
prose = strip_code_fences(body)
|
|
290
299
|
words = prose.split(/\s+/).reject(&:empty?)
|
|
291
300
|
wc = words.length
|
|
@@ -314,9 +323,18 @@ def check_quality(body, quality)
|
|
|
314
323
|
|
|
315
324
|
# Code fences must declare a language.
|
|
316
325
|
if quality['require_code_fence_language']
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
326
|
+
# Only opening fences need a language; toggle state so the matching closing
|
|
327
|
+
# fence (a bare ```) is not counted.
|
|
328
|
+
in_fence = false
|
|
329
|
+
body.each_line do |line|
|
|
330
|
+
next unless line =~ /^\s*```(.*)$/
|
|
331
|
+
|
|
332
|
+
if in_fence
|
|
333
|
+
in_fence = false # closing fence — no language required
|
|
334
|
+
else
|
|
335
|
+
in_fence = true
|
|
336
|
+
issues << Issue.new('info', 'quality', 'Code fence without a language (use ```bash, ```ruby, …)') if Regexp.last_match(1).strip.empty?
|
|
337
|
+
end
|
|
320
338
|
end
|
|
321
339
|
end
|
|
322
340
|
|
|
@@ -337,7 +355,7 @@ end
|
|
|
337
355
|
|
|
338
356
|
def check_style(body, style)
|
|
339
357
|
issues = []
|
|
340
|
-
prose = strip_code_fences(body)
|
|
358
|
+
prose = strip_code_fences(strip_liquid_raw(body))
|
|
341
359
|
(style['terminology'] || {}).each do |wrong, right|
|
|
342
360
|
next if wrong.to_s.empty?
|
|
343
361
|
|
data/scripts/lib/hub.rb
ADDED
|
@@ -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" "$@"
|