-
Notifications
You must be signed in to change notification settings - Fork 0
/
static_site_gen.py
426 lines (346 loc) · 14.4 KB
/
static_site_gen.py
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
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
"""
This module generates the static website
"""
import json
import os
import random
from dataclasses import dataclass
from urllib.parse import urljoin
from jinja2 import Environment, PackageLoader, Template, select_autoescape, pass_eval_context
from jinja2.nodes import EvalContext
from markupsafe import Markup
from os import makedirs, mkdir
from pathlib import Path
from shutil import rmtree
from typing import List, Dict, Tuple, Set
from . import CONFIG
from .atom_feed import create_package_atom_feed
from .package import Package, PackageApplicationCategory
from datetime import datetime
import importlib.resources as resources
from .progress import begin_step, step_progress
from .repo_loader import RepoInfo
ALPHABET = "abcdefghijklmnopqrstuvwxyz"
@dataclass
class PackageIndex:
id: str
display: str
page_title: str
file: str
pkgs: List[Package]
def as_dict(self):
# dataclasses.asdict() converts the pkgs to dicts as well, which is not what I want, hence the hand-typed version
return {
"id": self.id,
"display": self.display,
"page_title": self.page_title,
"file": self.file,
"pkgs": self.pkgs
}
@dataclass
class CategoryPage:
name: str
categories: Set[str]
def __hash__(self):
return self.name.__hash__()
@dataclass
class Feed:
title: str
path: str
pkgs: List[Package]
def __getattr__(self, item):
if item == "url":
return CONFIG.public_url + self.path
CATEGORY_PAGES = [
CategoryPage("Accessibility", {PackageApplicationCategory.accessibility}),
CategoryPage("Development", {PackageApplicationCategory.development}),
CategoryPage("Education", {PackageApplicationCategory.education}),
CategoryPage("Games", {PackageApplicationCategory.game}),
CategoryPage("Graphics", {PackageApplicationCategory.graphics}),
CategoryPage("Libraries", {PackageApplicationCategory.library}),
CategoryPage("Location and Navigation", {PackageApplicationCategory.maps}),
CategoryPage("Multimedia", {PackageApplicationCategory.audio, PackageApplicationCategory.video,
PackageApplicationCategory.audiovideo}),
CategoryPage("Office", {PackageApplicationCategory.office}),
CategoryPage("Science", {PackageApplicationCategory.science}),
CategoryPage("Utilities", {PackageApplicationCategory.system, PackageApplicationCategory.utility}),
CategoryPage("Other", {PackageApplicationCategory.other}),
]
def gen_site(repo_info: RepoInfo, out_dir: Path):
"""
Generates the static website given a list of packages
:param repo_info: The repository info and packages to generate the website for
:param out_dir: The directory to output the generated website in
"""
sitegen_step = begin_step("Generating site")
www_path = out_dir.joinpath("www")
www_pkgs_path = www_path.joinpath("pkgs")
www_apps_path = www_path.joinpath("apps")
updated = datetime.today()
pkg_filters = ["pkgs", "apps"]
sorted_pkgs = sorted([pkg for pkg in repo_info.packages if not pkg.is_debug()],
key=lambda pkg: str(pkg.title).lower())
recently_updated_apps = sorted(
[pkg for pkg in repo_info.packages if pkg.is_app()],
reverse=True, key=lambda pkg: pkg.updated
)
recently_updated_pkgs = sorted(
[pkg for pkg in repo_info.packages if not pkg.is_debug()],
reverse=True, key=lambda pkg: pkg.updated
)
feeds = [
Feed("Recently updated apps", "apps/updates.atom", recently_updated_apps),
Feed("Recently updated packages", "pkgs/updates.atom", recently_updated_pkgs)
]
def render_template(template: Template, out_file: str | Path, **kwargs):
kwargs["updated"] = updated
kwargs["chum_installer"] = "sailfishos-chum-gui-installer"
kwargs["config"] = CONFIG
kwargs["repo_version"] = repo_info.version
kwargs["recently_updated_pkgs"] = recently_updated_apps[:CONFIG.updated_apps_count]
kwargs["feeds"] = feeds
template.stream(**kwargs).dump(str(out_file))
def _copy_dir(source, dest: Path):
"""
Copies a resource directory, obtained via `resource.files()`, to the specified destination `dest` on the filesystem
"""
child = dest.joinpath(source.name)
if source.is_dir():
mkdir(child)
for entry in source.iterdir():
_copy_dir(entry, child)
else:
# source is a file
child.write_bytes(source.read_bytes())
def copy_static_dirs() -> None:
static_dir = resources.files(__package__ + ".www.static")
_copy_dir(static_dir, www_path)
def recreate_directory_skeleton() -> None:
rmtree(www_path, onerror=print)
makedirs(www_path, exist_ok=True)
makedirs(www_apps_path)
makedirs(www_pkgs_path)
def pkgs_letter_buckets() -> Dict[str, List[Package]]:
dict: Dict[str, List[Package]] = {}
for letter in ALPHABET + "?":
dict[letter] = []
for pkg in sorted_pkgs:
first_letter = pkg.title.lower()[0]
if first_letter.isalpha():
dict[first_letter].append(pkg)
else:
dict["?"].append(pkg)
return dict
def pkg_letter_indexes() -> Tuple[List[PackageIndex], List[PackageIndex]]:
pkgs_by_letter = pkgs_letter_buckets()
app_lists = []
pkg_lists = []
for pkg_filter in pkg_filters:
for letter in ALPHABET + "?":
if pkg_filter == "apps":
filtered_pkg_list = filter(lambda pkg: pkg.is_app(), pkgs_by_letter[letter])
filter_name = "apps"
else:
filtered_pkg_list = pkgs_by_letter[letter]
filter_name = "packages"
other = "other"
disp = f"'{letter.upper()}'" if letter.isalpha() else "other characters"
pkg_index = PackageIndex(
letter,
letter.upper(),
f"All {filter_name} starting with {disp}",
f"{pkg_filter}/index-{letter if letter.isalpha() else other}.html",
filtered_pkg_list
)
if pkg_filter == "apps":
app_lists.append(pkg_index)
else:
pkg_lists.append(pkg_index)
app_lists.append(
PackageIndex(
"*",
"ALL",
"All apps",
"apps/index.html",
filter(lambda pkg: pkg.is_app(), sorted_pkgs)
)
)
pkg_lists.append(
PackageIndex(
"*",
"ALL",
"All packages",
"pkgs/index.html",
sorted_pkgs
)
)
return pkg_lists, app_lists
def pkgs_category_buckets() -> Dict[PackageApplicationCategory, List[Package]]:
dict: Dict[PackageApplicationCategory, List[Package]] = {}
for category in CATEGORY_PAGES:
dict[category] = [pkg for pkg in sorted_pkgs if len(category.categories.intersection(pkg.categories))]
return dict
def pkg_category_indexes() -> Tuple[List[PackageIndex], List[PackageIndex]]:
pkgs_by_category = pkgs_category_buckets()
pkg_lists = []
app_lists = []
for pkg_filter in pkg_filters:
for category in CATEGORY_PAGES:
if category.name == "Libraries" and pkg_filter == "apps":
# This does not make sense to display, libraries aren't apps by definition
continue
if pkg_filter == "apps":
filtered_pkg_list = filter(lambda pkg: pkg.is_app(), pkgs_by_category[category])
filter_name = "apps"
else:
filtered_pkg_list = pkgs_by_category[category]
filter_name = "packages"
pkg_index = PackageIndex(
category.name.lower(),
category.name,
f"All {category.name.lower()} {filter_name}",
f"{pkg_filter}/index-category-{category.name.replace(' ', '-').lower()}.html",
filtered_pkg_list
)
if pkg_filter == "apps":
app_lists.append(pkg_index)
else:
pkg_lists.append(pkg_index)
return pkg_lists, app_lists
def create_package_index_pages(index_list: List[PackageIndex]):
for index in index_list:
pkg_index_template = env.get_template("pages/package-index.html")
pkg_index_template_args = index.as_dict()
pkg_index_template_args["index_list"] = index_list
render_template(pkg_index_template, www_path.joinpath(index.file), **pkg_index_template_args)
def create_package_page(pkg: Package):
pkg_template = env.get_template("pages/package.html")
pkg_dir = www_pkgs_path.joinpath(pkg.name)
out_file = pkg_dir.joinpath("index.html")
os.makedirs(pkg_dir, exist_ok=True)
if pkg.is_app():
app_dir = www_apps_path.joinpath(pkg.name)
os.symlink(pkg_dir.absolute(), app_dir.absolute(), True)
render_template(pkg_template, str(out_file), pkg=pkg)
total_sitegen_steps = 6
step_progress(sitegen_step, "Creating directory structure", 1, total_sitegen_steps)
recreate_directory_skeleton()
copy_static_dirs()
env = Environment(
loader=PackageLoader(__package__ + ".www", "views"),
autoescape=select_autoescape(),
)
env.filters["bytes"] = _bytes_filter
env.filters["paragraphise"] = _paragraphise_filter
env.filters["fallback_icon"] = _fallback_icon_filter
env.filters["format_datetime"] = _format_datetime
env.filters["format_date"] = _format_date
env.filters["to_public_url"] = _to_absolute_url_filter
step_progress(sitegen_step, "Generating static pages", 2, total_sitegen_steps)
home_template = env.get_template("pages/index.html")
featured_apps = random.sample([pkg for pkg in sorted_pkgs if pkg.is_app()], CONFIG.featured_apps_count)
render_template(home_template, www_path.joinpath("index.html"), featured_apps=featured_apps)
about_template = env.get_template("pages/about.html")
render_template(about_template, www_path.joinpath("about.html"))
about_generator = env.get_template("pages/about-generator.html")
render_template(about_generator, www_path.joinpath("about-generator.html"),
pkgs=[pkg for pkg in repo_info.packages if pkg.caused_requests()])
search_generator = env.get_template("pages/search.html")
render_template(search_generator, www_path.joinpath("search.html"))
step_progress(sitegen_step, "Generating package pages", 3, total_sitegen_steps)
for pkg in repo_info.packages:
create_package_page(pkg)
step_progress(sitegen_step, "Generating package lists", 4, total_sitegen_steps)
for idx in pkg_letter_indexes() + pkg_category_indexes():
create_package_index_pages(idx)
# Generate search index
step_progress(sitegen_step, "Generating search index", 5, total_sitegen_steps)
search_index, search_documents = create_search_index(sorted_pkgs)
with open(www_path.joinpath("packages-index.json"), "w") as search_index_file:
json.dump(search_index.serialize(), search_index_file)
with open(www_path.joinpath("packages.json"), "w") as packages_file:
json.dump(search_documents, packages_file)
# Write Atom feeds
step_progress(sitegen_step, "Generating atom feeds", 6, total_sitegen_steps)
for feed in feeds:
xml = create_package_atom_feed(feed.pkgs[:CONFIG.feed_updated_apps_count], feed.url, feed.title)
with open(www_path.joinpath(feed.path), "w") as atom_file:
xml.writexml(atom_file)
def _bytes_filter(size: str) -> str:
"""
Converts `size` in bytes to a human-readable unit, such as KiB, MiB and GiB
"""
from math import log2
amount = 0
unit = "bytes"
try:
amount = int(size)
except (ValueError, TypeError):
return "??? bytes"
order_of_magnitude = log2(amount) / 10 if amount > 0 else 0
if order_of_magnitude >= 3:
amount /= 1024 ** 3
unit = "GiB"
elif order_of_magnitude >= 2:
amount /= 1024 ** 2
unit = "MiB"
elif order_of_magnitude >= 1:
amount /= 1024
unit = "KiB"
else:
unit = "bytes"
amount = round(amount, 1)
return f"{amount} {unit}"
def create_search_index(pkgs: List[Package]):
"""
Generates a search index that can be used in the front-end with the Lunr library
:param pkgs:
:return:
"""
import lunr
documents = [pkg.to_search_dict() for pkg in pkgs]
index = lunr.lunr(
ref="name",
fields=(
{"field_name": "name", "boost": 5},
{"field_name": "title", "boost": 3},
{"field_name": "summary", "boost": 2},
{"field_name": "description", "boost": 1}
),
documents=documents
)
return index, documents
@pass_eval_context
def _paragraphise_filter(eval_ctx: EvalContext, value: str):
"""
Converts paragraphs in plain-text seperated by double newlines into p tags
"""
result = Markup("<p>\n")
empty_lines: int = 0
for line in value.splitlines(True):
if len(line) == 0:
empty_lines += 1
if empty_lines >= 2:
result += Markup("\n</p><p>\n")
empty_lines = 0
else:
result += Markup.escape(line)
result += Markup("</p>")
return Markup(result) if eval_ctx.autoescape else result
def _fallback_icon_filter(value: str):
if value and value.strip():
return value
else:
return urljoin(CONFIG.public_url, "static/img/pkg-fallback.png")
def _format_datetime(value: datetime, format_str=None):
if format_str:
return value.strftime(format_str)
return value.strftime("%Y-%m-%d %H:%M:%S")
def _format_date(value: datetime):
return _format_datetime(value, format_str="%Y-%m-%d")
def _to_absolute_url_filter(path: str) -> str:
"""
Resolves a path to an absolute URL based on the public URL in the configuration.
This way, we do not care whether the site gets deployed on a subdirectory or not
"""
return urljoin(CONFIG.public_url, path)