Repository navigation
Expand file tree
/
Copy pathRakefile
More file actions
386 lines (341 loc) · 15.2 KB
/
Copy pathRakefile
File metadata and controls
386 lines (341 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
require "rubygems"
require "bundler/setup"
require "stringex"
require 'net/http'
require 'json'
require 'time'
require 'fileutils'
## -- Misc Configs -- ##
public_dir = "public/" # compiled site directory
source_dir = "source" # source file directory
server_port = "4000" # port for preview server eg. localhost:4000
sass_dir = "sass"
sass_compile = "sass #{sass_dir}/:#{source_dir}/stylesheets/ --style=compressed --no-source-map --load-path=#{sass_dir} --quiet-deps"
if (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil
puts '## Set the codepage to 65001 for Windows machines'
`chcp 65001`
end
#######################
# Working with Jekyll #
#######################
desc "Compile SCSS files to CSS"
task :compile_sass do
success = system sass_compile
abort("Compiling SCSS failed") unless success
end
desc "Generate jekyll site"
task :generate do
raise "### You haven't set anything up yet. First run `rake install`." unless File.directory?(source_dir)
puts "## Generating Site with Jekyll"
success = system sass_compile
abort("Generating CSS failed") unless success
success = system "rake analytics_data"
abort("Generating analytics data failed") unless success
success = system "rake alerts_data"
abort("Generating alerts data failed") unless success
success = system "rake version_data"
abort("Generating version data failed") unless success
success = system "rake language_scores_data"
abort("Generating language scores data failed") unless success
success = system "rake codeowners_data"
abort("Extracting codeowners") unless success
success = system "rake wwha_data"
abort("Generating WWHA device data failed") unless success
success = system "rake allowed_referrers_data"
abort("Generating allowed referrers data failed") unless success
success = system "rake meetups_data"
abort("Generating community meetups data failed") unless success
success = system "rake newsletter_data"
abort("Generating newsletter data failed") unless success
success = system "jekyll build"
abort("Generating site failed") unless success
# The Astro build runs on every deploy (see astro/README.md). Jekyll
# builds every route; the routes in astro/served-routes.json are then
# replaced with Astro's pages, and the whole Astro output is also
# browsable under the unlinked, noindexed /astro-preview/ path.
# Previews and CI abort on failure (that is what gates merges), while
# production deploys only warn: a failed Astro build then publishes
# Jekyll's version of the served routes instead of blocking the site.
# pnpm comes from the root devDependencies (npx resolves it there),
# since Node.js 25+ no longer bundles Corepack.
astro_env = { "ASTRO_TELEMETRY_DISABLED" => "1" }
astro_success = system(astro_env, "npx pnpm install --frozen-lockfile", chdir: "astro") &&
system(astro_env, "npx pnpm run build", chdir: "astro")
if ENV["CONTEXT"] == 'production'
puts "## WARNING: Astro build failed, continuing" unless astro_success
else
abort("Generating Astro site failed") unless astro_success
end
if astro_success
# Make the Astro output browsable at /astro-preview/ on every
# deploy, production included. It is not linked from anywhere and
# _headers marks the whole path noindex.
FileUtils.rm_rf("#{public_dir}astro-preview")
FileUtils.cp_r("astro/dist", "#{public_dir}astro-preview")
# Astro pages link their bundled stylesheets, scripts and images
# with root-absolute /_astro/ paths, which is where they belong
# once Astro serves real routes. Publish that directory at the
# site root too, or the preview pages lose their styles. The file
# names are content-hashed, so nothing can clash with Jekyll's
# output.
FileUtils.rm_rf("#{public_dir}_astro")
FileUtils.cp_r("astro/dist/_astro", "#{public_dir}_astro") if File.directory?("astro/dist/_astro")
# Serve the routes listed in astro/served-routes.json from Astro:
# their pages replace the ones Jekyll built at the same URL. If the
# Astro build failed (production only warns), Jekyll's pages stay.
JSON.parse(File.read("astro/served-routes.json")).each do |route|
FileUtils.cp("astro/dist/#{route}index.html", "#{public_dir}#{route}index.html")
end
end
if ENV["CONTEXT"] != 'production'
File.open("#{public_dir}robots.txt", 'w') do |f|
f.write "User-agent: *\n"
f.write "Disallow: /\n"
end
end
public_dir
end
desc "Watch the site and regenerate when it changes"
task :watch do
raise "### You haven't set anything up yet. First run `rake install`." unless File.directory?(source_dir)
puts "Starting to watch source with Jekyll and Sass."
system sass_compile unless File.exist?("#{source_dir}/stylesheets/screen.css")
jekyllPid = Process.spawn({"OCTOPRESS_ENV"=>"preview"}, "jekyll build --watch --incremental")
sassPid = Process.spawn("#{sass_compile} --watch")
trap("INT") {
[jekyllPid, sassPid].each { |pid| Process.kill(9, pid) rescue Errno::ESRCH }
exit 0
}
[jekyllPid, sassPid].each { |pid| Process.wait(pid) }
end
desc "preview the site in a web browser"
task :preview, :listen do |t, args|
listen_addr = args[:listen] || '127.0.0.1'
listen_addr = '0.0.0.0' unless ENV['DEVCONTAINER'].nil?
raise "### You haven't set anything up yet. First run `rake install`." unless File.directory?(source_dir)
puts "Starting to watch source with Jekyll and Sass."
puts "Now listening on http://localhost:#{server_port}"
# Always compile all SCSS files before starting Jekyll
system sass_compile
system "rake analytics_data"
system "rake version_data"
system "rake language_scores_data"
system "rake codeowners_data"
system "rake alerts_data"
system "rake wwha_data"
system "rake allowed_referrers_data"
system "rake meetups_data"
system "rake newsletter_data"
jekyllPid = Process.spawn({"OCTOPRESS_ENV"=>"preview"}, "jekyll build -t --watch --incremental")
sassPid = Process.spawn("#{sass_compile} --watch")
rackupPid = Process.spawn("rackup --port #{server_port} --host #{listen_addr}")
trap("INT") {
[jekyllPid, sassPid, rackupPid].each { |pid| Process.kill(9, pid) rescue Errno::ESRCH }
exit 0
}
[jekyllPid, sassPid, rackupPid].each { |pid| Process.wait(pid) }
end
desc "Download data from analytics.home-assistant.io"
task :analytics_data do
uri = URI('https://analytics.home-assistant.io/data.json')
remote_data = JSON.parse(Net::HTTP.get(uri))
File.open("#{source_dir}/_data/analytics_data.json", "w") do |file|
file.write(JSON.generate(remote_data['current']))
end
end
desc "Download data from alerts.home-assistant.io"
task :alerts_data do
uri = URI('https://alerts.home-assistant.io/alerts.json')
remote_data = JSON.parse(Net::HTTP.get(uri))
File.open("#{source_dir}/_data/alerts_data.json", "w") do |file|
file.write(JSON.generate(remote_data))
end
end
desc "Download version data from version.home-assistant.io"
task :version_data do
uri = URI('https://version.home-assistant.io/stable.json')
remote_data = JSON.parse(Net::HTTP.get(uri))
File.open("#{source_dir}/_data/version_data.json", "w") do |file|
file.write(JSON.generate(remote_data))
end
end
desc "Download supported language data from ohf-voice.github.io"
task :language_scores_data do
uri = URI('https://ohf-voice.github.io/intents/language_scores.json')
remote_data = JSON.parse(Net::HTTP.get(uri))
File.open("#{source_dir}/_data/language_scores.json", "w") do |file|
file.write(JSON.generate(remote_data))
end
end
desc "Download device data from works-with.home-assistant.io"
task :wwha_data do
uri = URI('https://works-with.home-assistant.io/devices.json')
remote_data = JSON.parse(Net::HTTP.get(uri))
File.open("#{source_dir}/_data/wwha_devices.json", "w") do |file|
file.write(JSON.generate(remote_data))
end
end
desc "Download upcoming community meetups from web-api.openhomefoundation.org"
task :meetups_data do
output_file = "#{source_dir}/_data/meetups_data.json"
# Both Luma calendars end up on the same map on the community page. The
# calendar key is kept on every event so the map can colour and filter them.
# Community Day comes first: an event listed on both calendars is a
# Community Day event (see the uniq below).
calendars = {
'ohf-community-day' => 'https://web-api.openhomefoundation.org/events/ohf-community-day',
'home-assistant-meetups' => 'https://web-api.openhomefoundation.org/events/home-assistant-meetups',
}
begin
now = Time.now.utc
upcoming = []
calendars.each do |calendar, url|
remote_data = JSON.parse(Net::HTTP.get(URI(url)))
events = remote_data['events']
raise "#{calendar} payload does not contain an events array" unless events.is_a?(Array)
upcoming.concat(
events
# Only keep web links: the URL ends up in an href on the community page,
# so schemes such as javascript: must never make it into the data file.
.select { |event| event['url'].is_a?(String) && event['url'].downcase.start_with?('https://', 'http://') }
# The map shows a start time for every meetup, so an event without a
# usable start is dropped rather than rendered as an epoch date. An
# event that has already begun but hasn't ended yet still counts as
# upcoming, hence the end date in the comparison.
.select do |event|
begin
Time.parse(event['start'])
Time.parse(event['end'] || event['start']).utc >= now
rescue StandardError
false
end
end
.map do |event|
{
'calendar' => calendar,
'summary' => event['summary'],
'start' => event['start'],
# The map expects a list of address lines; upstream occasionally
# sends a single string instead.
'address' => (event['address'].is_a?(Array) ? event['address'] : [event['address']])
.map { |line| line.to_s.strip }
.reject(&:empty?),
'url' => event['url'],
'latitude' => event['latitude'],
'longitude' => event['longitude'],
}
end
)
end
# A Luma event can be listed on both calendars. uniq keeps the first copy,
# which comes from the Community Day calendar.
upcoming = upcoming
.uniq { |event| event['url'] }
.sort_by { |event| event['start'].to_s }
File.open(output_file, "w") do |file|
file.write(JSON.generate(upcoming))
end
puts "## Wrote #{upcoming.length} upcoming community meetups"
rescue StandardError => e
# Never fail the build over the meetups: fall back to the existing file,
# or an empty list if none exists yet. The map section falls back to its
# "host a meetup" state when the list is empty.
warn "## Downloading community meetups failed, keeping existing file. #{e}"
File.write(output_file, "[]") unless File.exist?(output_file)
end
end
desc "Download the latest Open Home Foundation newsletter editions"
task :newsletter_data do
output_file = "#{source_dir}/_data/newsletter_data.json"
begin
require 'rexml/document'
uri = URI('https://newsletter.openhomefoundation.org/rss/')
feed = REXML::Document.new(Net::HTTP.get(uri))
items = REXML::XPath.match(feed, '//channel/item')
raise "feed does not contain any items" if items.empty?
# The feed lists the editions newest first; keep that order in the file.
editions = items.filter_map do |item|
title = item.elements['title']&.text.to_s.strip
image = item.elements['media:content']&.attributes&.[]('url').to_s.strip
# The image URL ends up in the community page markup, so an edition
# without a web link for its cover is dropped rather than rendered.
next unless image.start_with?('https://')
# Ghost serves the original artwork from its storage domain. The resized
# variants are only available through the newsletter site itself, so
# point the image at the w960 variant there, which is plenty for the
# stacked covers on the community page.
if image.include?('/content/images/')
image = 'https://newsletter.openhomefoundation.org/content/images/size/w960/' +
image.split('/content/images/', 2).last
end
{ 'title' => title, 'image' => image }
end.first(3)
raise "feed does not contain any usable editions" if editions.empty?
File.open(output_file, "w") do |file|
file.write(JSON.generate(editions))
end
puts "## Wrote #{editions.length} newsletter editions"
rescue StandardError => e
# Never fail the build over the newsletter: fall back to the existing file,
# or to the editions that were current when this task was written, so the
# community page always has artwork to show.
warn "## Downloading newsletter editions failed, keeping existing file. #{e}"
unless File.exist?(output_file)
fallback = [
{
'title' => 'Thirteen years of Home Assistant: Leading the way back home',
'image' => 'https://newsletter.openhomefoundation.org/content/images/size/w960/2026/09/OHF_Newsletter2609_1200x630.png',
},
{
'title' => 'We sow the seeds, you grow the (open) home',
'image' => 'https://newsletter.openhomefoundation.org/content/images/size/w960/2026/08/OHF_Newsletter2608_1200x630.png',
},
{
'title' => 'The walled garden that’s breaking down barriers',
'image' => 'https://newsletter.openhomefoundation.org/content/images/size/w960/2026/07/OHF_Newsletter2607_1200x630.png',
},
]
File.write(output_file, JSON.generate(fallback))
end
end
end
desc "Download referrer allow list from openhomefoundation.org"
task :allowed_referrers_data do
output_file = "#{source_dir}/_data/allowed_referrers.json"
begin
uri = URI('https://www.openhomefoundation.org/allowed-referrers.json')
remote_data = JSON.parse(Net::HTTP.get(uri))
raise "payload is not an array of strings" unless remote_data.is_a?(Array) && remote_data.all? { |d| d.is_a?(String) }
referrers = remote_data
.map { |d| d.strip.downcase.delete_suffix('.') }
.reject(&:empty?)
File.open(output_file, "w") do |file|
file.write(JSON.generate(referrers))
end
puts "## Wrote #{referrers.length} allowed referrer domains"
rescue StandardError => e
# Never fail the build over the allow list: fall back to the existing
# file, or an empty list if none exists yet.
warn "## Downloading allowed referrers failed, keeping existing file. #{e}"
File.write(output_file, "[]") unless File.exist?(output_file)
end
end
desc "Extract CODEOWNERS and output to _data/codeowners.json"
task :codeowners_data do
codeowners = []
File.readlines("CODEOWNERS").each do |line|
next if line.start_with?("#") || line.strip.empty?
parts = line.split
next if parts.length < 2
owners = parts[1..-1]
owners.each do |owner|
owner = owner.delete_prefix('@')
next if owner.include?('/')
codeowners << owner unless codeowners.include?(owner)
end
end
codeowners.sort!
File.open("#{source_dir}/_data/codeowners.json", "w") do |file|
file.write(JSON.generate(codeowners))
end
end