pandorga 1.6.6 → 1.6.8

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +3 -2
  4. data/_includes/home/band-listing.html +1 -1
  5. data/_includes/page/content-runtime.html +3 -0
  6. data/_includes/page/network-graph.html +5 -2
  7. data/_includes/page/timeline-section.html +13 -1
  8. data/_includes/theme/favicons.html +3 -1
  9. data/_includes/theme/font-loader.html +5 -9
  10. data/_layouts/articles.html +5 -2
  11. data/_layouts/bibliography.html +5 -2
  12. data/_layouts/blog.html +5 -2
  13. data/_layouts/cv.html +5 -2
  14. data/_layouts/home.html +10 -3
  15. data/_layouts/projects.html +5 -2
  16. data/_layouts/resources.html +5 -2
  17. data/_layouts/shell.html +85 -13
  18. data/assets/css/ledger/home.css +20 -0
  19. data/assets/css/ledger/rascunhos.css +40 -2
  20. data/assets/icons/apple-touch-icon.png +0 -0
  21. data/assets/icons/favicon-32.png +0 -0
  22. data/assets/icons/favicon.ico +0 -0
  23. data/assets/icons/favicon.svg +23 -0
  24. data/assets/icons/icon-192.png +0 -0
  25. data/assets/icons/icon-32.png +0 -0
  26. data/assets/icons/icon-512.png +0 -0
  27. data/assets/icons/icon-maskable-192.png +0 -0
  28. data/assets/icons/icon-maskable-512.png +0 -0
  29. data/assets/icons/studio-apple-touch-icon.png +0 -0
  30. data/assets/icons/studio-icon-192.png +0 -0
  31. data/assets/icons/studio-icon-512.png +0 -0
  32. data/assets/icons/studio-logo-32.png +0 -0
  33. data/assets/icons/studio-logo.ico +0 -0
  34. data/assets/icons/studio-logo.svg +25 -5
  35. data/assets/icons/studio-maskable-192.png +0 -0
  36. data/assets/icons/studio-maskable-512.png +0 -0
  37. data/examples/full/content/pages/cv/summary.md +1 -1
  38. data/lib/pandorga/jekyll/plugins/web_manifest.rb +81 -0
  39. data/lib/pandorga/version.rb +1 -1
  40. data/scripts/check_home_lists.mjs +209 -0
  41. data/scripts/lighthouse-home.sh +9 -1
  42. data/scripts/render-pwa-icons.py +141 -0
  43. data/scripts/test/test-home-articles.rb +30 -0
  44. data/scripts/test/test-home-lists.rb +74 -0
  45. data/scripts/test/test-lighthouse-policy.rb +13 -6
  46. data/scripts/test/test-pwa-icons.rb +280 -0
  47. data/scripts/test/test-rascunhos-licence.rb +43 -0
  48. data/scripts/validate.sh +4 -0
  49. data/studio/index.html +4 -1
  50. data/studio-app/index.html +4 -1
  51. metadata +24 -1
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+ require "json"
5
+
6
+ module Pandorga
7
+ module Jekyll
8
+ # Writes the web app manifests the shell and Studio link. The icons are
9
+ # theme assets. A site file at the same path overrides an icon; the
10
+ # manifest itself is generated so the name follows the site identity.
11
+ class WebManifestGenerator < ::Jekyll::Generator
12
+ safe true
13
+ priority :low
14
+
15
+ def generate(site)
16
+ emit(site, "site.webmanifest", site_manifest(site))
17
+ emit(site, "studio.webmanifest", studio_manifest)
18
+ end
19
+
20
+ private
21
+
22
+ def emit(site, name, payload)
23
+ page = ::Jekyll::PageWithoutAFile.new(site, site.source, "", name)
24
+ page.data["layout"] = nil
25
+ page.data["sitemap"] = false
26
+ page.content = "#{JSON.pretty_generate(payload)}\n"
27
+ site.pages << page
28
+ end
29
+
30
+ def site_manifest(site)
31
+ config = site.config
32
+ pandorga = config["pandorga"].is_a?(Hash) ? config["pandorga"] : {}
33
+ identity = pandorga["identity"].is_a?(Hash) ? pandorga["identity"] : {}
34
+ name = identity["name"].to_s.strip
35
+ name = config["title"].to_s.strip if name.empty?
36
+ name = "Site" if name.empty?
37
+ short = identity["short_name"].to_s.strip
38
+ short = name if short.empty?
39
+ manifest("/", name, short, studio: false)
40
+ end
41
+
42
+ def studio_manifest
43
+ manifest("/studio/", "Studio", "Studio", studio: true)
44
+ end
45
+
46
+ def manifest(start, name, short, studio:)
47
+ {
48
+ "id" => start,
49
+ "name" => name,
50
+ "short_name" => short,
51
+ "start_url" => start,
52
+ "scope" => start,
53
+ "display" => "standalone",
54
+ "background_color" => "#5e5d59",
55
+ "theme_color" => "#5e5d59",
56
+ "icons" => icons(studio: studio)
57
+ }
58
+ end
59
+
60
+ # Stems match scripts/render-pwa-icons.py. The site maskable file is
61
+ # icon-maskable-*.png. Studio's is studio-maskable-*.png.
62
+ def icons(studio:)
63
+ any_stem = studio ? "studio-icon" : "icon"
64
+ mask_stem = studio ? "studio-maskable" : "icon-maskable"
65
+ [
66
+ [512, any_stem, "any"],
67
+ [512, mask_stem, "maskable"],
68
+ [192, any_stem, "any"],
69
+ [192, mask_stem, "maskable"]
70
+ ].map do |size, stem, purpose|
71
+ {
72
+ "src" => "/assets/icons/#{stem}-#{size}.png",
73
+ "sizes" => "#{size}x#{size}",
74
+ "type" => "image/png",
75
+ "purpose" => purpose
76
+ }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.6.6"
4
+ VERSION = "1.6.8"
5
5
  end
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ // Load the built example home and fail when a listing band is still empty.
3
+ // The runtime fills those bands after the document loads. A score-only
4
+ // Lighthouse run treats an empty home as a fast page.
5
+
6
+ import { spawn } from 'node:child_process';
7
+ import { mkdtemp, rm } from 'node:fs/promises';
8
+ import { createServer } from 'node:net';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { setTimeout as delay } from 'node:timers/promises';
12
+
13
+ const args = process.argv.slice(2);
14
+
15
+ function flag(name) {
16
+ const index = args.indexOf(name);
17
+ if (index === -1 || !args[index + 1]) {
18
+ console.error(`ERROR: missing ${name}`);
19
+ process.exit(1);
20
+ }
21
+ return args[index + 1];
22
+ }
23
+
24
+ const chromePath = flag('--chrome');
25
+ const pageUrl = flag('--url');
26
+
27
+ const COUNTS = `({
28
+ projects: document.querySelectorAll('#home-projects .home-project-inventory-row').length,
29
+ articles: document.querySelectorAll('#home-articles .home-featured, #home-articles .ledger-card').length,
30
+ posts: document.querySelectorAll('#home-blog .home-draft-card').length,
31
+ projectsText: (document.querySelector('#home-projects') || {}).innerText || '',
32
+ articlesText: (document.querySelector('#home-articles') || {}).innerText || ''
33
+ })`;
34
+
35
+ const VIEWPORTS = [
36
+ { name: 'mobile', width: 390, height: 844, mobile: true, deviceScaleFactor: 2 },
37
+ { name: 'desktop', width: 1280, height: 800, mobile: false, deviceScaleFactor: 1 }
38
+ ];
39
+
40
+ function freePort() {
41
+ return new Promise((resolve, reject) => {
42
+ const server = createServer();
43
+ server.once('error', reject);
44
+ server.listen(0, '127.0.0.1', () => {
45
+ const { port } = server.address();
46
+ server.close(() => resolve(port));
47
+ });
48
+ });
49
+ }
50
+
51
+ class Cdp {
52
+ constructor(ws) {
53
+ this.ws = ws;
54
+ this.next = 1;
55
+ this.pending = new Map();
56
+ this.listeners = [];
57
+ ws.addEventListener('message', (event) => {
58
+ const message = JSON.parse(event.data);
59
+ if (message.id && this.pending.has(message.id)) {
60
+ const { resolve, reject } = this.pending.get(message.id);
61
+ this.pending.delete(message.id);
62
+ if (message.error) reject(new Error(message.error.message || JSON.stringify(message.error)));
63
+ else resolve(message.result || {});
64
+ return;
65
+ }
66
+ if (message.method) this.listeners.forEach((listen) => listen(message));
67
+ });
68
+ }
69
+
70
+ send(method, params = {}, sessionId) {
71
+ const id = this.next++;
72
+ const payload = { id, method, params };
73
+ if (sessionId) payload.sessionId = sessionId;
74
+ return new Promise((resolve, reject) => {
75
+ this.pending.set(id, { resolve, reject });
76
+ this.ws.send(JSON.stringify(payload));
77
+ });
78
+ }
79
+
80
+ once(method, sessionId) {
81
+ return new Promise((resolve) => {
82
+ const listen = (message) => {
83
+ if (message.method !== method) return;
84
+ if (sessionId && message.sessionId !== sessionId) return;
85
+ this.listeners = this.listeners.filter((entry) => entry !== listen);
86
+ resolve(message.params || {});
87
+ };
88
+ this.listeners.push(listen);
89
+ });
90
+ }
91
+ }
92
+
93
+ async function waitForDevtools(port) {
94
+ for (let attempt = 0; attempt < 50; attempt += 1) {
95
+ try {
96
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`);
97
+ if (response.ok) return response.json();
98
+ } catch (_error) {
99
+ // Chrome is still starting.
100
+ }
101
+ await delay(100);
102
+ }
103
+ throw new Error('Chrome DevTools did not answer');
104
+ }
105
+
106
+ async function openSocket(url) {
107
+ const ws = new WebSocket(url);
108
+ await new Promise((resolve, reject) => {
109
+ ws.addEventListener('open', resolve, { once: true });
110
+ ws.addEventListener('error', () => reject(new Error('DevTools socket failed')), { once: true });
111
+ });
112
+ return ws;
113
+ }
114
+
115
+ async function navigate(browser, sessionId, url) {
116
+ const loaded = browser.once('Page.loadEventFired', sessionId);
117
+ await browser.send('Page.navigate', { url }, sessionId);
118
+ await Promise.race([
119
+ loaded,
120
+ delay(20000).then(() => {
121
+ throw new Error('home did not finish loading');
122
+ })
123
+ ]);
124
+ }
125
+
126
+ async function counts(browser, sessionId) {
127
+ const deadline = Date.now() + 15000;
128
+ let last = { projects: 0, articles: 0, posts: 0, projectsText: '', articlesText: '' };
129
+ while (Date.now() < deadline) {
130
+ const evaluated = await browser.send('Runtime.evaluate', {
131
+ expression: COUNTS,
132
+ returnByValue: true
133
+ }, sessionId);
134
+ if (evaluated.exceptionDetails) {
135
+ throw new Error('home list probe failed');
136
+ }
137
+ last = (evaluated.result && evaluated.result.value) || last;
138
+ if (last.projects > 0 && last.articles > 0 && last.posts > 0) return last;
139
+ await delay(200);
140
+ }
141
+ return last;
142
+ }
143
+
144
+ function filled(result) {
145
+ return result.projects > 0 && result.articles > 0 && result.posts > 0;
146
+ }
147
+
148
+ async function main() {
149
+ const profile = await mkdtemp(join(tmpdir(), 'pandorga-home-'));
150
+ const port = await freePort();
151
+ const chrome = spawn(chromePath, [
152
+ '--headless=new',
153
+ '--no-sandbox',
154
+ '--disable-gpu',
155
+ '--disable-dev-shm-usage',
156
+ '--no-first-run',
157
+ '--remote-allow-origins=*',
158
+ `--remote-debugging-port=${port}`,
159
+ `--user-data-dir=${profile}`,
160
+ 'about:blank'
161
+ ], { stdio: 'ignore' });
162
+
163
+ let failed = false;
164
+ try {
165
+ const version = await waitForDevtools(port);
166
+ const ws = await openSocket(version.webSocketDebuggerUrl);
167
+ const browser = new Cdp(ws);
168
+ const created = await browser.send('Target.createTarget', { url: 'about:blank' });
169
+ const attached = await browser.send('Target.attachToTarget', {
170
+ targetId: created.targetId,
171
+ flatten: true
172
+ });
173
+ const sessionId = attached.sessionId;
174
+ await browser.send('Page.enable', {}, sessionId);
175
+ await browser.send('Runtime.enable', {}, sessionId);
176
+
177
+ for (const viewport of VIEWPORTS) {
178
+ await browser.send('Emulation.setDeviceMetricsOverride', {
179
+ width: viewport.width,
180
+ height: viewport.height,
181
+ deviceScaleFactor: viewport.deviceScaleFactor,
182
+ mobile: viewport.mobile
183
+ }, sessionId);
184
+ await navigate(browser, sessionId, pageUrl);
185
+ const result = await counts(browser, sessionId);
186
+ console.log(
187
+ `home lists (${viewport.name}): projects=${result.projects} articles=${result.articles} posts=${result.posts}`
188
+ );
189
+ if (!filled(result)) {
190
+ console.error(`ERROR: ${viewport.name} home is missing project, article, or post items`);
191
+ console.error(`projects band: ${JSON.stringify(String(result.projectsText).slice(0, 180))}`);
192
+ console.error(`articles band: ${JSON.stringify(String(result.articlesText).slice(0, 180))}`);
193
+ failed = true;
194
+ }
195
+ }
196
+ ws.close();
197
+ } finally {
198
+ chrome.kill('SIGKILL');
199
+ await delay(100);
200
+ await rm(profile, { recursive: true, force: true });
201
+ }
202
+
203
+ if (failed) process.exit(1);
204
+ }
205
+
206
+ main().catch((error) => {
207
+ console.error(`ERROR: ${error.message}`);
208
+ process.exit(1);
209
+ });
@@ -6,7 +6,7 @@
6
6
  set -euo pipefail
7
7
 
8
8
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9
- SITE="${ROOT}/examples/minimal/_site"
9
+ SITE="${ROOT}/examples/full/_site"
10
10
  PORT="${LIGHTHOUSE_PORT:-4173}"
11
11
  OUT="${LIGHTHOUSE_REPORT:-/tmp/lighthouse-home.json}"
12
12
  FAIL_BELOW=75
@@ -26,6 +26,8 @@ if [[ "${MODE}" != "enforce" && "${MODE}" != "warn" ]]; then
26
26
  fi
27
27
 
28
28
  [[ -f "${SITE}/index.html" ]] || fail "missing ${SITE}/index.html (run scripts/validate.sh first)"
29
+ grep -q 'id="home-cv-summary"[^>]*data-content-static' "${SITE}/index.html" \
30
+ || fail "example home has no static hero summary (data-content-static)"
29
31
 
30
32
  if [[ -n "${CHROME_PATH:-}" && -x "${CHROME_PATH}" ]]; then
31
33
  CHROME="${CHROME_PATH}"
@@ -55,6 +57,12 @@ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
55
57
  done
56
58
  [[ "${ready}" == "1" ]] || fail "example server did not answer on port ${PORT}"
57
59
 
60
+ # Empty bands still score well. This fails in warn mode and in enforce mode.
61
+ step "Home lists after load (mobile and desktop)"
62
+ node "${ROOT}/scripts/check_home_lists.mjs" \
63
+ --chrome "${CHROME}" \
64
+ --url "http://127.0.0.1:${PORT}/"
65
+
58
66
  step "Lighthouse mobile (one run)"
59
67
  rm -f "${OUT}"
60
68
  npx --yes lighthouse@13.5.0 "http://127.0.0.1:${PORT}/" \
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """Rasterize the site and Studio launcher icons from the site stamp.
3
+
4
+ Both icons use the square mark in `_includes/theme/site-logo.html` and the
5
+ light-on-dark plate of `assets/icons/favicon.svg`: dark frame, pastel triad.
6
+ Studio adds a small plate in the lower right. Nothing important sits outside
7
+ the maskable safe zone (the center 80% circle). File names match
8
+ `lib/pandorga/jekyll/plugins/web_manifest.rb`. Requires Pillow.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ from PIL import Image, ImageDraw
17
+
18
+ # 32×32 square site stamp.
19
+ PRIMARY = [(0, 0), (23, 0), (15, 17), (0, 20)]
20
+ SECONDARY = [(23, 0), (32, 0), (32, 32), (17, 21), (15, 17)]
21
+ TERTIARY = [(0, 20), (15, 17), (17, 21), (32, 32), (0, 32)]
22
+
23
+ PLATE = (0x5E, 0x5D, 0x59)
24
+ FILL_PRIMARY = (0xA4, 0xC8, 0xDA)
25
+ FILL_SECONDARY = (0x95, 0xC8, 0xA6)
26
+ FILL_TERTIARY = (0xDD, 0xD8, 0xA8)
27
+ BADGE = (0xB8, 0xB7, 0xAE)
28
+
29
+ # The mark's box is 52% of the canvas. Its corners stay inside the 80% circle
30
+ # (half-diagonal 0.367 of the canvas, safe radius 0.40).
31
+ MARK_SCALE = 0.52
32
+ # Badge center, as an offset from the canvas center, and its side. The badge
33
+ # corners stay inside the same circle.
34
+ BADGE_OFFSET = 0.18
35
+ BADGE_SIDE = 0.09
36
+
37
+
38
+ def _polygon(points: list[tuple[int, int]], size: int) -> list[tuple[float, float]]:
39
+ span = size * MARK_SCALE
40
+ offset = (size - span) / 2
41
+ return [(offset + x / 32 * span, offset + y / 32 * span) for x, y in points]
42
+
43
+
44
+ def _ink_bounds(image: Image.Image) -> tuple[int, int, int, int]:
45
+ pixels = image.load()
46
+ width, height = image.size
47
+ min_x, min_y = width, height
48
+ max_x, max_y = -1, -1
49
+ for y in range(height):
50
+ for x in range(width):
51
+ if pixels[x, y] == PLATE:
52
+ continue
53
+ min_x = min(min_x, x)
54
+ min_y = min(min_y, y)
55
+ max_x = max(max_x, x)
56
+ max_y = max(max_y, y)
57
+ return (min_x, min_y, max_x, max_y)
58
+
59
+
60
+ def render(size: int, studio: bool) -> Image.Image:
61
+ image = Image.new("RGB", (size, size), PLATE)
62
+ draw = ImageDraw.Draw(image)
63
+ draw.polygon(_polygon(PRIMARY, size), fill=FILL_PRIMARY)
64
+ draw.polygon(_polygon(SECONDARY, size), fill=FILL_SECONDARY)
65
+ draw.polygon(_polygon(TERTIARY, size), fill=FILL_TERTIARY)
66
+ if studio:
67
+ # The badge may only replace stamp pixels. Painting the plate would
68
+ # grow the art and make Studio a different size on the home screen.
69
+ stamped = image.copy()
70
+ side = size * BADGE_SIDE
71
+ center = size * (0.5 + BADGE_OFFSET)
72
+ origin = center - side / 2
73
+ draw.rectangle([origin, origin, origin + side, origin + side], fill=BADGE)
74
+ source = stamped.load()
75
+ painted = image.load()
76
+ for y in range(size):
77
+ for x in range(size):
78
+ if source[x, y] == PLATE:
79
+ painted[x, y] = PLATE
80
+ return image
81
+
82
+
83
+ def main() -> None:
84
+ parser = argparse.ArgumentParser(description=__doc__)
85
+ parser.add_argument(
86
+ "--repo-root",
87
+ type=Path,
88
+ default=Path(__file__).resolve().parents[1],
89
+ )
90
+ args = parser.parse_args()
91
+ icons = args.repo_root / "assets" / "icons"
92
+ icons.mkdir(parents=True, exist_ok=True)
93
+
94
+ site = {
95
+ 512: ["icon-512.png", "icon-maskable-512.png"],
96
+ 192: ["icon-192.png", "icon-maskable-192.png"],
97
+ 180: ["apple-touch-icon.png"],
98
+ 32: ["icon-32.png", "favicon-32.png"],
99
+ }
100
+ studio = {
101
+ 512: ["studio-icon-512.png", "studio-maskable-512.png"],
102
+ 192: ["studio-icon-192.png", "studio-maskable-192.png"],
103
+ 180: ["studio-apple-touch-icon.png"],
104
+ 32: ["studio-logo-32.png"],
105
+ }
106
+ for size in site:
107
+ plain = render(size, studio=False)
108
+ marked = render(size, studio=True)
109
+ if plain.size != marked.size or _ink_bounds(plain) != _ink_bounds(marked):
110
+ raise SystemExit(
111
+ f"{size}px site and Studio art bounds differ: "
112
+ f"{plain.size} {_ink_bounds(plain)} vs {marked.size} {_ink_bounds(marked)}"
113
+ )
114
+ for name in site[size]:
115
+ plain.save(icons / name, "PNG")
116
+ for name in studio[size]:
117
+ marked.save(icons / name, "PNG")
118
+ icon32 = render(32, studio=False)
119
+ icon16 = render(16, studio=False)
120
+ studio32 = render(32, studio=True)
121
+ studio16 = render(16, studio=True)
122
+ if _ink_bounds(icon16) != _ink_bounds(studio16):
123
+ raise SystemExit("16px site and Studio art bounds differ")
124
+ icon32.save(
125
+ icons / "favicon.ico",
126
+ format="ICO",
127
+ sizes=[(16, 16), (32, 32)],
128
+ append_images=[icon16],
129
+ )
130
+ studio32.save(
131
+ icons / "studio-logo.ico",
132
+ format="ICO",
133
+ sizes=[(16, 16), (32, 32)],
134
+ append_images=[studio16],
135
+ )
136
+
137
+ print(f"Wrote launcher icons under {icons.relative_to(args.repo_root)}")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PLT-AC-27. Home Articles band: titles are 2pt larger, and on a phone the
5
+ # featured card has more room above its text. Listed cards keep their padding.
6
+
7
+ require "pathname"
8
+
9
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
10
+
11
+ def fail!(message)
12
+ warn "FAIL test-home-articles: #{message}"
13
+ exit 1
14
+ end
15
+
16
+ home = ROOT.join("assets/css/ledger/home.css").read
17
+
18
+ fail!("featured article title is not 2pt larger") unless home.include?(
19
+ '[data-home-kind="articles"] .home-featured-title'
20
+ ) && home.include?("calc(var(--home-featured-title-size) + 2pt)")
21
+
22
+ fail!("listed article titles are not 2pt larger") unless home.include?(
23
+ "calc(var(--type-card) + 2pt)"
24
+ ) && home.include?('[data-home-kind="articles"] .home-card-grid .card-title')
25
+
26
+ phone = home[/^@media \(max-width: 767px\) \{\n \[data-home-kind="articles"\] \.home-featured \{.*?\n \}/m]
27
+ fail!("phone featured card has no extra top padding") unless phone && phone.include?("padding-top:")
28
+ fail!("phone featured padding changes the listed cards") if phone.include?("ledger-card") || phone.include?("home-card-grid")
29
+
30
+ puts "PASS test-home-articles"
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PLT-AC-26. Listing scripts follow the page, and the runtime stays after
5
+ # it. Those scripts have to start once the runtime is assigned. The
6
+ # Lighthouse script then loads the built example home and fails when the
7
+ # project, article, or post bands are still empty.
8
+
9
+ require "pathname"
10
+ require "open3"
11
+
12
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
13
+
14
+ def fail!(message)
15
+ warn "FAIL test-home-lists: #{message}"
16
+ exit 1
17
+ end
18
+
19
+ runtime = ROOT.join("_includes/page/content-runtime.html").read
20
+ assigned = runtime.index("window.ContentRuntime = (function () {")
21
+ dispatched = runtime.index("document.dispatchEvent(new CustomEvent('pandorga:runtime'))")
22
+ fail!("runtime does not assign ContentRuntime") unless assigned
23
+ fail!("runtime does not announce pandorga:runtime") unless dispatched
24
+ fail!("runtime announces itself before the assignment") unless dispatched > assigned
25
+ fail!("runtime moved back ahead of the page") unless ROOT.join("_layouts/shell.html").read.index("{{ content }}") <
26
+ ROOT.join("_layouts/shell.html").read.index("page/content-runtime.html")
27
+
28
+ pages = %w[
29
+ _layouts/home.html
30
+ _layouts/articles.html
31
+ _layouts/blog.html
32
+ _layouts/projects.html
33
+ _layouts/cv.html
34
+ _layouts/resources.html
35
+ _layouts/bibliography.html
36
+ _includes/page/network-graph.html
37
+ _includes/page/timeline-section.html
38
+ ]
39
+ pages.each do |relative|
40
+ text = ROOT.join(relative).read
41
+ fail!("#{relative} does not wait for pandorga:runtime") unless text.include?("pandorga:runtime")
42
+ end
43
+
44
+ home = ROOT.join("_layouts/home.html").read
45
+ fail!("home paint still runs before the runtime") unless home.scan("pandorga:runtime").length >= 2
46
+ fail!("home bands still return when the runtime is missing") if home.include?("if (!runtime) return")
47
+
48
+ marked = ROOT.join("_includes/content-runtime/00-config.html").read
49
+ fail!("markdown library no longer defers") unless marked.include?("<script defer ")
50
+
51
+ shell = ROOT.join("_layouts/shell.html").read
52
+ fail!("text faces no longer wait for the first paint") unless shell.scan("requestAnimationFrame").length >= 2
53
+ fail!("icon subset link was removed") unless shell.include?("__pandorgaIconsHref")
54
+
55
+ spec = ROOT.join("docs/spec.md").read
56
+ fail!("spec is missing PLT-AC-26") unless spec.include?("PLT-AC-26")
57
+ fail!("spec no longer keeps the runtime after the page") unless spec.include?("runtime stays after the page")
58
+
59
+ lighthouse = ROOT.join("scripts/lighthouse-home.sh").read
60
+ checker = lighthouse.index("check_home_lists.mjs")
61
+ score = lighthouse.index("Lighthouse mobile (one run)")
62
+ fail!("lighthouse script does not load the home lists") unless checker
63
+ fail!("list check runs only after the score") unless score && checker < score
64
+
65
+ probe = ROOT.join("scripts/check_home_lists.mjs").read
66
+ fail!("probe ignores the project inventory") unless probe.include?("#home-projects .home-project-inventory-row")
67
+ fail!("probe ignores article cards") unless probe.include?("#home-articles .home-featured, #home-articles .ledger-card")
68
+ fail!("probe ignores the phone viewport") unless probe.include?("name: 'mobile'")
69
+ fail!("probe ignores the desktop viewport") unless probe.include?("name: 'desktop'")
70
+
71
+ _out, err, status = Open3.capture3("node", "--check", ROOT.join("scripts/check_home_lists.mjs").to_s)
72
+ fail!("home list probe is not valid JavaScript\n#{err}") unless status.success?
73
+
74
+ puts "PASS test-home-lists"
@@ -64,22 +64,28 @@ fail!("icon face is preloaded") if shell.include?("rel=\"preload\" as=\"style\"
64
64
 
65
65
  screen = shell.sub(%r{<noscript>.*?</noscript>}m, "")
66
66
  fail!("Material Symbols sheet is missing") unless shell.include?("Material+Symbols+Outlined")
67
- fail!("icon sheet is not deferred until load") unless screen.include?("addEventListener('load'")
68
- fail!("screen still has a Material Symbols stylesheet link") if screen.match?(/<link\b[^>]*Material\+Symbols/)
69
67
  fail!("screen still has a flag stylesheet link") if screen.match?(/<link\b[^>]*flag-icons/)
70
68
  fail!("flag sheet is not deferred until load") unless screen.include?("flag-icons@7.2.3")
69
+ fail!("icon face is the full variable font") if shell.include?("0..1&display=swap")
70
+ fail!("icon face is not a named subset") unless shell.include?("icon_names=")
71
+ fail!("icon subset link has no id") unless screen.include?("pandorga-icons")
71
72
 
72
73
  fonts = ROOT.join("_includes/theme/font-loader.html").read
73
- font_screen = fonts.sub(%r{<noscript>.*?</noscript>}m, "")
74
74
  fail!("text faces are preloaded") if fonts.include?('rel="preload"')
75
- fail!("text face sheet is not deferred until load") unless font_screen.include?("addEventListener('load'")
76
- fail!("screen still has a text-face stylesheet link") if font_screen.match?(/<link\b/)
75
+ fail!("text face still waits for the load event") if fonts.include?("addEventListener('load'")
76
+ fail!("text face href is not handed to the shell") unless fonts.include?("__pandorgaFontsHref")
77
+ fail!("text face is not requested after first paint") unless screen.scan("requestAnimationFrame").length >= 2
77
78
 
78
79
  marked = ROOT.join("_includes/content-runtime/00-config.html").read
79
80
  fail!("markdown library still blocks parsing") unless marked.include?("<script defer ")
80
81
  fail!("markdown library source changed") unless marked.include?("marked@15.0.7/marked.min.js")
81
82
 
82
- fail!("spec still discovers text faces from the head") unless spec.include?("text-face stylesheet is not a head link")
83
+ fail!("spec still withholds the text face until load") unless spec.include?("after the first paint and before the load event")
84
+ fail!("lighthouse example is not the full site") unless script.include?("examples/full")
85
+ fail!("lighthouse example can ship without a static summary") unless script.include?("data-content-static")
86
+ summary = ROOT.join("examples/full/content/pages/cv/summary.md").read
87
+ fail!("full summary dropped the fixture sentence") unless summary.include?("Ada Example designs publishing frames")
88
+ fail!("full summary is too short to be the hero paint") unless summary.scan(".").length >= 3
83
89
  fail!("spec still lets the markdown library block parsing") unless spec.include?("markdown library loads with `defer`")
84
90
  fail!("lighthouse script hides the metric breakdown") unless script.include?("largest-contentful-paint")
85
91
 
@@ -92,6 +98,7 @@ fail!("content runtime still precedes the page") unless shell.index("{{ content
92
98
  band = File.read(File.expand_path("../../_includes/home/band-listing.html", __dir__))
93
99
  fail!("band title is still painted by script") unless band.include?('data-content-section-title>{{ _title }}')
94
100
  fail!("band note is still painted by script") unless band.include?("page_headers")
101
+ fail!("project inventory has a name and no role") unless band.include?('role="region"') && band.include?('aria-label="Project inventory"')
95
102
  headers = File.read(File.expand_path("../../_plugins/content_headers.rb", __dir__))
96
103
  fail!("page headers are not loaded at build time") unless headers.include?('site.data["page_headers"]')
97
104
  home = File.read(File.expand_path("../../_layouts/home.html", __dir__))