pandorga 1.6.8 → 1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 355770a826a6483d7fbc1e014c76fc0ef865d23bba44674cc2153d25ed840a9c
4
- data.tar.gz: 6fd8451064d1a3bc58a8ff4ed53b90606183644756c28984018ff20ae485a543
3
+ metadata.gz: b57cef517472b9cc15b3c3dcd4c13f119d495df666d167f9c741fff760b0e02b
4
+ data.tar.gz: '08c03b31d4b06e92df419ad20b091e63c31537fef06354a3af52abda07d5371a'
5
5
  SHA512:
6
- metadata.gz: 331cf7a4687f241ee1b653e2435986bd511536ac3d1bfd21b65c91deef8969994999d8d65696e54a5725e7be41169d10ff9abac70475844a018480869cb19c1c
7
- data.tar.gz: 88fc4c23d1cb5680b054c2e7028e374caab861977114ae235de52547b482de74b0c282957fc5bdf31764c9c3f11e39a65bbd4372ee7ee8fbf397b238b41f1c66
6
+ metadata.gz: c60041b89ca661282fbfbc53052e55b46405a23eb914b3aa35ead456628b35c6834efe9f369e3cb301f311e4fd7f82932cf57c403e7afbfb5c36bdf525c0ed2c
7
+ data.tar.gz: fbf2fc58bd6578caba218953e8203a393dae30b2913e8d0ed6beb051ff0b795397807b697d1ec72bcdda4b20f222c11e8f7ef1654b472a84e090c0378bd448de
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0] — 2026-10-10
4
+
5
+ ### Added
6
+
7
+ - A site can name its typefaces under `pandorga.fonts`. `title`, `body`,
8
+ and `mono` take a Google Fonts family, or a map with `family` and
9
+ optional `weights` and `italic`. `label` and `subtitle` are optional.
10
+ Omitted roles keep Cinzel Decorative, Newsreader, and Courier Prime.
11
+ The text faces still load after the first paint.
12
+
13
+ ## [1.6.9] — 2026-10-10
14
+
15
+ ### Fixed
16
+
17
+ - Home list hydration no longer fills every band in one turn. The project
18
+ specimen measurement yields between projects, each band starts on its
19
+ own turn, and each home section introduction yields before it is
20
+ parsed. Dates reuse one formatter. The lists still render.
21
+
22
+ No `pandorga:` config keys were added or renamed.
23
+
3
24
  ## [1.6.8] — 2026-10-10
4
25
 
5
26
  ### Fixed
@@ -113,15 +113,23 @@
113
113
  return textExcerpt((item && item.body_markdown) || '');
114
114
  }
115
115
 
116
+ var dateFormatters = Object.create(null);
117
+
116
118
  function formatDate(value, locale) {
117
119
  if (!value) return '';
118
120
  var parts = String(value).slice(0, 10).split('-').map(Number);
119
121
  if (parts.length !== 3 || parts.some(isNaN)) return value;
120
- return new Intl.DateTimeFormat(locale || 'en', {
121
- day: '2-digit',
122
- month: 'short',
123
- year: 'numeric'
124
- }).format(new Date(parts[0], parts[1] - 1, parts[2]));
122
+ var key = locale || 'en';
123
+ var formatter = dateFormatters[key];
124
+ if (!formatter) {
125
+ formatter = new Intl.DateTimeFormat(key, {
126
+ day: '2-digit',
127
+ month: 'short',
128
+ year: 'numeric'
129
+ });
130
+ dateFormatters[key] = formatter;
131
+ }
132
+ return formatter.format(new Date(parts[0], parts[1] - 1, parts[2]));
125
133
  }
126
134
 
127
135
  function chronologySortKey(item) {
@@ -353,15 +353,31 @@
353
353
  });
354
354
  }
355
355
 
356
+ function yieldToMain() {
357
+ return new Promise(function (resolve) {
358
+ if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') {
359
+ scheduler.yield().then(resolve);
360
+ return;
361
+ }
362
+ window.setTimeout(resolve, 0);
363
+ });
364
+ }
365
+
356
366
  function hydrateHomeSections() {
357
367
  var roots = Array.prototype.slice.call(
358
368
  document.querySelectorAll('[data-content-home-section]')
359
369
  );
360
370
  if (!roots.length) return Promise.resolve();
361
371
  return loadPageHeaders().then(function (sections) {
372
+ var chain = Promise.resolve();
362
373
  roots.forEach(function (root) {
363
- applyHomeSection(root, sections[root.getAttribute('data-content-home-section')]);
374
+ chain = chain.then(function () {
375
+ return yieldToMain().then(function () {
376
+ applyHomeSection(root, sections[root.getAttribute('data-content-home-section')]);
377
+ });
378
+ });
364
379
  });
380
+ return chain;
365
381
  }).catch(function (error) {
366
382
  console.error(error);
367
383
  roots.forEach(function (root) { applyHomeSection(root, null); });
@@ -605,21 +621,31 @@
605
621
  }
606
622
 
607
623
  function hydratePageFragments() {
608
- document.querySelectorAll('[data-content-page-header]').forEach(function (el) {
609
- hydratePageHeader(el);
624
+ var headers = Array.prototype.slice.call(document.querySelectorAll('[data-content-page-header]'));
625
+ var chain = Promise.resolve();
626
+ headers.forEach(function (el) {
627
+ chain = chain.then(function () {
628
+ return yieldToMain().then(function () { return hydratePageHeader(el); });
629
+ });
610
630
  });
611
631
  document.querySelectorAll('[data-content-fragment]').forEach(function (el) {
612
632
  var path = el.getAttribute('data-content-fragment');
613
633
  if (!path) return;
614
- mountMarkdownFragment(path, el, {
615
- loadingText: el.getAttribute('data-content-loading') || 'Loading...',
616
- errorText: el.getAttribute('data-content-error') || 'Content could not be loaded right now.'
634
+ chain = chain.then(function () {
635
+ return yieldToMain().then(function () {
636
+ return mountMarkdownFragment(path, el, {
637
+ loadingText: el.getAttribute('data-content-loading') || 'Loading...',
638
+ errorText: el.getAttribute('data-content-error') || 'Content could not be loaded right now.'
639
+ });
640
+ });
617
641
  });
618
642
  });
619
- hydrateHomeSections();
620
- hydrateBandArt();
621
- hydrateContacts();
622
- hydrateHeroPortrait();
643
+ chain.then(function () {
644
+ hydrateHomeSections();
645
+ hydrateBandArt();
646
+ hydrateContacts();
647
+ hydrateHeroPortrait();
648
+ });
623
649
  }
624
650
 
625
651
  function schedulePageFragmentHydration() {
@@ -1,62 +1,10 @@
1
- {% include theme/theme-context.liquid %}
2
- {% assign font_title = sf.title | default: "Lora" %}
3
- {% assign font_subtitle = sf.subtitle | default: "Lato" %}
4
- {% assign font_label = sf.label | default: "Ysabeau SC" %}
5
- {% assign font_body = sf.body | default: "Newsreader" %}
6
- {% assign font_mono = sf.mono %}
7
- {% assign font_code = sf.code %}
8
- {% assign font_cv_body = sf.cv_body %}
9
- {% assign title_slug = font_title | replace: ' ', '+' %}
10
- {% assign subtitle_slug = font_subtitle | replace: ' ', '+' %}
11
- {% assign label_slug = font_label | replace: ' ', '+' %}
12
- {% assign body_slug = font_body | replace: ' ', '+' %}
13
- {% assign mono_slug = font_mono | replace: ' ', '+' %}
14
- {% assign code_slug = font_code | replace: ' ', '+' %}
15
- {% assign cv_body_slug = font_cv_body | replace: ' ', '+' %}
16
- {% assign title_spec = sf.google_title_spec | default: "ital,wght@0,400;0,500;0,700;1,400;1,500" %}
17
- {% assign subtitle_spec = sf.google_subtitle_spec | default: "ital,wght@0,300;0,400;0,500;0,700;1,400;1,500" %}
18
- {% assign label_spec = sf.google_label_spec | default: "ital,wght@0,400;0,500;0,700;1,400;1,500" %}
19
- {% assign body_spec = sf.google_body_spec | default: "ital,wght@0,300;0,400;0,500;0,700;1,400;1,500;1,700" %}
20
- {% assign mono_spec = sf.google_mono_spec | default: "wght@400;500;700" %}
21
- {% assign code_spec = sf.google_code_spec | default: "wght@300;400;500;600;700" %}
22
- {% assign cv_body_spec = sf.google_cv_body_spec | default: "wght@300;400;500;600;700" %}
23
- {% comment %}
24
- Build the Google Fonts URL without repeating a family. Title is always
25
- requested. Body / label / subtitle / mono / code append only when their
26
- family name differs from ones already on the URL.
27
- {% endcomment %}
28
- {% assign fonts_href = "https://fonts.googleapis.com/css2?family=" | append: title_slug | append: ":" | append: title_spec %}
29
- {% unless font_label == font_title %}
30
- {% assign fonts_href = fonts_href | append: "&family=" | append: label_slug | append: ":" | append: label_spec %}
31
- {% endunless %}
32
- {% unless font_body == font_title or font_body == font_label %}
33
- {% assign fonts_href = fonts_href | append: "&family=" | append: body_slug | append: ":" | append: body_spec %}
34
- {% endunless %}
35
- {% if font_mono and font_mono != '' %}
36
- {% unless font_mono == font_title or font_mono == font_label or font_mono == font_body %}
37
- {% assign fonts_href = fonts_href | append: "&family=" | append: mono_slug | append: ":" | append: mono_spec %}
38
- {% endunless %}
39
- {% endif %}
40
- {% if font_code and font_code != '' %}
41
- {% unless font_code == font_title or font_code == font_label or font_code == font_body or font_code == font_mono %}
42
- {% assign fonts_href = fonts_href | append: "&family=" | append: code_slug | append: ":" | append: code_spec %}
43
- {% endunless %}
44
- {% endif %}
45
- {% if font_subtitle != font_body and font_subtitle != font_label and font_subtitle != font_title %}
46
- {% assign fonts_href = fonts_href | append: "&family=" | append: subtitle_slug | append: ":" | append: subtitle_spec %}
47
- {% endif %}
48
- {% if font_cv_body and font_cv_body != '' %}
49
- {% unless font_cv_body == font_title or font_cv_body == font_label or font_cv_body == font_body or font_cv_body == font_subtitle %}
50
- {% assign fonts_href = fonts_href | append: "&family=" | append: cv_body_slug | append: ":" | append: cv_body_spec %}
51
- {% endunless %}
52
- {% endif %}
53
- {% assign fonts_href = fonts_href | append: "&display=swap" %}
54
1
  {% comment %}
55
2
  Not a render-blocking head link, and not the load event. A blocking
56
3
  sheet holds first paint on the font host. The load event holds the hero
57
4
  summary until images finish. The shell inserts this sheet after the
58
- first paint. display=swap stays.
5
+ first paint. The URL always ends in &display=swap.
59
6
  {% endcomment %}
7
+ {% assign fonts_href = site | pandorga_font_stylesheet %}
60
8
  <script>
61
9
  window.__pandorgaFontsHref = {{ fonts_href | jsonify }};
62
10
  </script>
@@ -1,6 +1,7 @@
1
1
  {% comment %}
2
2
  Emits CSS custom properties for one palette. Expects `theme_name` (e.g. light, dark).
3
- Colour roles come from _data/themes/<name>.yml; fonts / motion / geometry from shared.yml.
3
+ Colour roles come from _data/themes/<name>.yml. Type stacks come from
4
+ pandorga.fonts, then shared.yml. Motion and geometry come from shared.yml.
4
5
  {% endcomment %}
5
6
  {% assign theme_profile = site.data.themes[theme_name] %}
6
7
  {% if theme_profile == nil %}
@@ -17,34 +18,25 @@
17
18
  {% assign color_primary = tc.primary | default: "#1c5a7a" %}
18
19
  {% assign color_secondary = tc.secondary | default: "#287445" %}
19
20
  {% assign color_tertiary = tc.tertiary | default: "#746f30" %}
20
- {% assign font_title = sf.title | default: "Lora" %}
21
- {% assign font_subtitle = sf.subtitle | default: "Lato" %}
22
- {% assign font_label = sf.label | default: "Ysabeau SC" %}
23
- {% assign font_body = sf.body | default: "Newsreader" %}
24
- {% assign font_cv_body = sf.cv_body | default: "Newsreader" %}
25
21
  {% assign font_cv_body_weight = sf.cv_body_weight | default: 400 %}
26
- {% assign font_title_fallback = sf.title_fallback | default: "serif" %}
27
- {% assign font_subtitle_fallback = sf.subtitle_fallback | default: "serif" %}
28
- {% assign font_label_fallback = sf.label_fallback | default: "serif" %}
29
- {% assign font_body_fallback = sf.body_fallback | default: "Georgia, serif" %}
30
22
  {% assign bg_secondary = tn.background_secondary | default: tn.surface_container_low | default: "#f5f5f5" %}
31
23
  {% assign surface_value = tn.surface | default: "#ffffff" %}
32
24
  {% assign surface_variant_value = tn.surface_variant | default: "#e2e2e2" %}
33
25
  {% if theme_name == "light" %}:root,{% endif %}
34
26
  [data-theme="{{ theme_name }}"] {
35
- --font-title: '{{ font_title }}', {{ font_title_fallback }};
36
- --font-subtitle: '{{ font_subtitle }}', {{ font_subtitle_fallback }};
37
- --font-label: '{{ font_label }}', {{ font_label_fallback }};
38
- --font-body: '{{ font_body }}', {{ font_body_fallback }};
39
- --font-cv-body: '{{ font_cv_body }}', {{ font_body_fallback }};
27
+ --font-title: {{ site | pandorga_font_stack: "title" }};
28
+ --font-subtitle: {{ site | pandorga_font_stack: "subtitle" }};
29
+ --font-label: {{ site | pandorga_font_stack: "label" }};
30
+ --font-body: {{ site | pandorga_font_stack: "body" }};
31
+ --font-cv-body: {{ site | pandorga_font_stack: "cv_body" }};
40
32
  --font-cv-body-weight: {{ font_cv_body_weight }};
41
- --font-serif: '{{ font_title }}', {{ font_title_fallback }};
33
+ --font-serif: {{ site | pandorga_font_stack: "title" }};
42
34
  /* Content titles share the body face at 600 — Cinzel Decorative is
43
35
  reserved for page/display titles. */
44
36
  --font-content-title: var(--font-body);
45
37
  --font-sans: var(--font-body);
46
- --font-mono: {% if sf.mono and sf.mono != '' %}'{{ sf.mono }}', {% endif %}ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace;
47
- --font-code: {% if sf.code and sf.code != '' %}'{{ sf.code }}', {% elsif sf.mono and sf.mono != '' %}'{{ sf.mono }}', {% endif %}ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace;
38
+ --font-mono: {{ site | pandorga_font_stack: "mono" }};
39
+ --font-code: {{ site | pandorga_font_stack: "code" }};
48
40
 
49
41
  /* Motion. Named for what they are for: state change, poster filter
50
42
  warm-up, poster/plate scale. Shared across palettes. */
data/_layouts/home.html CHANGED
@@ -121,7 +121,21 @@ layout: shell
121
121
  }
122
122
 
123
123
  function byDateDesc(a, b) {
124
- return String(b.date || '').localeCompare(String(a.date || ''));
124
+ var left = String(b.date || '');
125
+ var right = String(a.date || '');
126
+ if (left > right) return 1;
127
+ if (left < right) return -1;
128
+ return 0;
129
+ }
130
+
131
+ function afterPaint() {
132
+ return new Promise(function (resolve) {
133
+ if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') {
134
+ scheduler.yield().then(resolve);
135
+ return;
136
+ }
137
+ window.setTimeout(resolve, 0);
138
+ });
125
139
  }
126
140
 
127
141
  function tagHrefFrom(element) {
@@ -175,8 +189,13 @@ layout: shell
175
189
  function draftFolios(posts) {
176
190
  var folios = Object.create(null);
177
191
  posts.slice().sort(function (a, b) {
178
- var byDate = String(a.date || '').localeCompare(String(b.date || ''));
179
- return byDate !== 0 ? byDate : String(a.slug || '').localeCompare(String(b.slug || ''));
192
+ var byDate = String(a.date || '') < String(b.date || '') ? -1 : (String(a.date || '') > String(b.date || '') ? 1 : 0);
193
+ if (byDate !== 0) return byDate;
194
+ var left = String(a.slug || '');
195
+ var right = String(b.slug || '');
196
+ if (left < right) return -1;
197
+ if (left > right) return 1;
198
+ return 0;
180
199
  }).forEach(function (entry, index) {
181
200
  folios[entry.slug] = index + 1;
182
201
  });
@@ -221,7 +240,8 @@ layout: shell
221
240
  element.appendChild(fragment);
222
241
  }
223
242
 
224
- function fillBand(section, index) {
243
+ async function fillBand(section, index) {
244
+ await afterPaint();
225
245
  var kind = section.getAttribute('data-home-kind');
226
246
  var collection = section.getAttribute('data-home-collection');
227
247
  var limit = parseInt(section.getAttribute('data-home-limit') || '3', 10) || 3;
@@ -316,7 +336,7 @@ layout: shell
316
336
  fitted(best);
317
337
  }
318
338
 
319
- function paintSpecimen(entry) {
339
+ async function paintSpecimen(entry) {
320
340
  specimenSlot.innerHTML = '';
321
341
  specimenSlot.appendChild(runtime.renderProjectSpecimen(asItem(entry), {
322
342
  href: entry.url || runtime.projectUrl(entry.slug),
@@ -324,10 +344,11 @@ layout: shell
324
344
  folio: positionOf(entry.slug) + 1,
325
345
  openLabel: 'Open project'
326
346
  }));
347
+ await afterPaint();
327
348
  ellipsizeSpecimenCopy(specimenSlot);
328
349
  }
329
350
 
330
- function measureTallestSpecimen() {
351
+ async function measureTallestSpecimen() {
331
352
  var bench = specimenSlot.closest('.home-projects-bench');
332
353
  if (!bench) return 0;
333
354
  var width = specimenSlot.getBoundingClientRect().width;
@@ -348,7 +369,8 @@ layout: shell
348
369
  bench.appendChild(probe);
349
370
 
350
371
  var max = 0;
351
- portfolio.forEach(function (entry, i) {
372
+ for (var i = 0; i < portfolio.length; i += 1) {
373
+ var entry = portfolio[i];
352
374
  probe.innerHTML = '';
353
375
  var card = runtime.renderProjectSpecimen(asItem(entry), {
354
376
  href: entry.url || runtime.projectUrl(entry.slug),
@@ -360,15 +382,16 @@ layout: shell
360
382
  card.style.minHeight = '0';
361
383
  probe.appendChild(card);
362
384
  max = Math.max(max, Math.ceil(card.getBoundingClientRect().height));
363
- });
385
+ await afterPaint();
386
+ }
364
387
  probe.remove();
365
388
  return max;
366
389
  }
367
390
 
368
- function lockSpecimenHeight() {
391
+ async function lockSpecimenHeight() {
369
392
  var bench = specimenSlot.closest('.home-projects-bench');
370
393
  if (!bench) return;
371
- var tallest = measureTallestSpecimen();
394
+ var tallest = await measureTallestSpecimen();
372
395
  /* Floor at the CSS inventory minimum so six rows always clear
373
396
  three stacked resource icons (see --home-inventory-min-height). */
374
397
  var inventoryMin = 0;
@@ -425,8 +448,9 @@ layout: shell
425
448
  });
426
449
  }
427
450
 
428
- lockSpecimenHeight();
429
- paintSpecimen(entryBySlug(selectedSlug));
451
+ await lockSpecimenHeight();
452
+ await paintSpecimen(entryBySlug(selectedSlug));
453
+ await afterPaint();
430
454
  paintInventory();
431
455
 
432
456
  var resizeTimer = null;
@@ -437,8 +461,9 @@ layout: shell
437
461
  var nextWidth = Math.round(specimenSlot.getBoundingClientRect().width);
438
462
  if (nextWidth === lastMeasureWidth) return;
439
463
  lastMeasureWidth = nextWidth;
440
- lockSpecimenHeight();
441
- paintSpecimen(entryBySlug(selectedSlug));
464
+ lockSpecimenHeight().then(function () {
465
+ return paintSpecimen(entryBySlug(selectedSlug));
466
+ });
442
467
  }, 120);
443
468
  });
444
469
  return;
@@ -484,7 +509,11 @@ layout: shell
484
509
 
485
510
  runtime.loadJson('data/writing_index.json').then(function (entries) {
486
511
  var index = Array.isArray(entries) ? entries : [];
487
- sections.forEach(function (section) { fillBand(section, index); });
512
+ var chain = Promise.resolve();
513
+ sections.forEach(function (section) {
514
+ chain = chain.then(function () { return fillBand(section, index); });
515
+ });
516
+ return chain;
488
517
  }).catch(function (error) {
489
518
  console.error(error);
490
519
  sections.forEach(function (section) {
@@ -18,6 +18,15 @@ pandorga:
18
18
  base_url: ""
19
19
  home:
20
20
  hero_art: page/hero-default.html
21
+ fonts:
22
+ title: "Cinzel Decorative"
23
+ body:
24
+ family: "Newsreader"
25
+ weights: [300, 400, 500, 600, 700]
26
+ mono:
27
+ family: "Courier Prime"
28
+ weights: [400, 700]
29
+ italic: true
21
30
  taxonomies:
22
31
  articles:
23
32
  tags: [Writing]
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Spec: docs/features/fonts.md
4
+ # Refs: PLT-AC-30
5
+ module Pandorga
6
+ # Google Fonts families for one site. An omitted role keeps the
7
+ # Architectural Ledger face. A set role replaces that face only.
8
+ module Fonts
9
+ module_function
10
+
11
+ ROLES = %i[title label body mono code subtitle cv_body].freeze
12
+ # These follow another role when the theme used one family for both
13
+ # and the site did not name them. Changing the body then changes the
14
+ # tagline, instead of leaving Newsreader behind.
15
+ FOLLOW = { subtitle: :body, code: :mono, cv_body: :body }.freeze
16
+ MONO_FALLBACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace"
17
+ FAMILY_NAME = /\A[\p{L}\p{N}][\p{L}\p{N} .+\-]*\z/
18
+
19
+ DEFAULTS = {
20
+ title: { family: "Cinzel Decorative", spec: "wght@400;700;900", fallback: "serif" },
21
+ subtitle: {
22
+ family: "Newsreader",
23
+ spec: "ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500",
24
+ fallback: "serif"
25
+ },
26
+ label: { family: "Marcellus SC", spec: "wght@400;700", fallback: "serif" },
27
+ body: { family: "Newsreader", spec: "wght@300;400;500;600;700", fallback: "Georgia, serif" },
28
+ mono: { family: "Courier Prime", spec: "ital,wght@0,400;0,700;1,400;1,700", fallback: MONO_FALLBACK },
29
+ code: { family: "Courier Prime", spec: "ital,wght@0,300;0,400;0,700;1,400;1,700", fallback: MONO_FALLBACK },
30
+ cv_body: { family: "Newsreader", spec: "wght@300;400;500;600;700", fallback: "Georgia, serif" }
31
+ }.freeze
32
+
33
+ def resolve(site_fonts, theme_fonts = nil)
34
+ site = string_keys(site_fonts)
35
+ theme = string_keys(theme_fonts)
36
+ resolved = {}
37
+ ROLES.each do |role|
38
+ resolved[role] = resolve_role(role, site[role.to_s], theme)
39
+ end
40
+ FOLLOW.each do |role, source|
41
+ next if named?(site[role.to_s])
42
+ next unless same_family?(theme, role, source)
43
+
44
+ resolved[role] = resolved[role].merge(
45
+ family: resolved[source][:family],
46
+ spec: resolved[source][:spec]
47
+ )
48
+ end
49
+ resolved
50
+ end
51
+
52
+ def from_site(site)
53
+ pandorga = lookup(site, "pandorga")
54
+ fonts = lookup(pandorga, "fonts")
55
+ data = lookup(site, "data")
56
+ themes = lookup(data, "themes")
57
+ shared = lookup(themes, "shared")
58
+ resolve(fonts, lookup(shared, "fonts"))
59
+ end
60
+
61
+ def stylesheet_href(resolved)
62
+ seen = []
63
+ parts = []
64
+ ROLES.each do |role|
65
+ face = resolved[role]
66
+ family = face[:family].to_s
67
+ next if family.empty? || seen.include?(family)
68
+
69
+ seen << family
70
+ parts << "#{family.tr(' ', '+')}:#{face[:spec]}"
71
+ end
72
+ return "" if parts.empty?
73
+
74
+ "https://fonts.googleapis.com/css2?family=#{parts.join('&family=')}&display=swap"
75
+ end
76
+
77
+ def css_stack(resolved, role)
78
+ face = resolved[role.to_sym]
79
+ return "" unless face
80
+
81
+ quoted = face[:family].to_s.empty? ? nil : "'#{escape_css(face[:family])}'"
82
+ [quoted, face[:fallback].to_s.strip].compact.reject(&:empty?).join(", ")
83
+ end
84
+
85
+ def resolve_role(role, override, theme)
86
+ base = DEFAULTS.fetch(role)
87
+ family = text(theme[role.to_s])
88
+ family = base[:family] if family.empty?
89
+ spec = text(theme["google_#{role}_spec"])
90
+ spec = base[:spec] if spec.empty?
91
+ fallback = fallback_for(role, theme, base)
92
+
93
+ parsed = parse_override(override)
94
+ return { family: family, spec: spec, fallback: fallback } unless parsed
95
+
96
+ {
97
+ family: parsed[:family],
98
+ spec: spec_for(parsed, family, spec, base[:family]),
99
+ fallback: fallback
100
+ }
101
+ end
102
+
103
+ def fallback_for(role, theme, base)
104
+ if role == :cv_body
105
+ body = text(theme["body_fallback"])
106
+ return body unless body.empty?
107
+ end
108
+ own = text(theme["#{role}_fallback"])
109
+ own.empty? ? base[:fallback] : own
110
+ end
111
+
112
+ def spec_for(parsed, theme_family, theme_spec, default_family)
113
+ if parsed[:weights] || !parsed[:italic].nil?
114
+ weights = parsed[:weights] || [400, 700]
115
+ if parsed[:italic]
116
+ # Roman tuples first, then italic, so the axis list stays sorted.
117
+ tuples = weights.map { |weight| "0,#{weight}" } + weights.map { |weight| "1,#{weight}" }
118
+ "ital,wght@#{tuples.join(';')}"
119
+ else
120
+ "wght@#{weights.join(';')}"
121
+ end
122
+ elsif parsed[:family] == theme_family || parsed[:family] == default_family
123
+ theme_spec
124
+ else
125
+ "wght@400;700"
126
+ end
127
+ end
128
+
129
+ def parse_override(value)
130
+ case value
131
+ when String
132
+ family = clean_family(value)
133
+ family && { family: family, weights: nil, italic: nil }
134
+ when Hash
135
+ fields = string_keys(value)
136
+ family = clean_family(fields["family"] || fields["name"])
137
+ return nil unless family
138
+
139
+ { family: family, weights: parse_weights(fields["weights"]), italic: italic_flag(fields) }
140
+ end
141
+ end
142
+
143
+ def italic_flag(fields)
144
+ return nil unless fields.key?("italic")
145
+
146
+ value = fields["italic"]
147
+ case value
148
+ when true, "true", "yes", 1, "1" then true
149
+ else false
150
+ end
151
+ end
152
+
153
+ def parse_weights(value)
154
+ return nil if value.nil?
155
+
156
+ list = case value
157
+ when Array then value
158
+ when Integer then [value]
159
+ when String then value.split(",")
160
+ else return nil
161
+ end
162
+ numbers = []
163
+ list.each do |item|
164
+ text = item.to_s.strip
165
+ next if text.empty?
166
+ return nil unless text.match?(/\A\d+\z/)
167
+
168
+ number = text.to_i
169
+ return nil unless number.between?(1, 1000)
170
+
171
+ numbers << number
172
+ end
173
+ return nil if numbers.empty?
174
+
175
+ numbers.uniq.sort
176
+ end
177
+
178
+ def clean_family(value)
179
+ family = value.to_s.strip
180
+ return nil if family.empty? || !FAMILY_NAME.match?(family)
181
+
182
+ family
183
+ end
184
+
185
+ def named?(value)
186
+ !parse_override(value).nil?
187
+ end
188
+
189
+ def same_family?(theme, role, source)
190
+ role_family = text(theme[role.to_s])
191
+ source_family = text(theme[source.to_s])
192
+ role_family = DEFAULTS.fetch(role)[:family] if role_family.empty?
193
+ source_family = DEFAULTS.fetch(source)[:family] if source_family.empty?
194
+ role_family == source_family
195
+ end
196
+
197
+ def text(value)
198
+ value.to_s.strip
199
+ end
200
+
201
+ def string_keys(value)
202
+ return {} unless value.is_a?(Hash)
203
+
204
+ value.each_with_object({}) { |(key, item), out| out[key.to_s] = item }
205
+ end
206
+
207
+ def lookup(object, key)
208
+ return nil if object.nil?
209
+
210
+ string_key = key.to_s
211
+ if object.is_a?(Hash)
212
+ return object[string_key] if object.key?(string_key)
213
+ return object[string_key.to_sym] if object.key?(string_key.to_sym)
214
+
215
+ return nil
216
+ end
217
+
218
+ if object.respond_to?(:[])
219
+ begin
220
+ found = object[string_key]
221
+ return found unless found.nil?
222
+ rescue StandardError
223
+ nil
224
+ end
225
+ end
226
+ return object.public_send(string_key) if object.respond_to?(string_key)
227
+
228
+ nil
229
+ end
230
+
231
+ def escape_css(family)
232
+ family.to_s.gsub("\\", "\\\\\\\\").gsub("'", "\\\\'").gsub(/[\r\n]/, "")
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../fonts"
4
+
5
+ # Liquid filters for pandorga.fonts. The stylesheet URL and the CSS stacks
6
+ # come from one resolver so the head and the tokens cannot drift.
7
+ module PandorgaFontFilter
8
+ def pandorga_font_stylesheet(site)
9
+ Pandorga::Fonts.stylesheet_href(Pandorga::Fonts.from_site(site))
10
+ end
11
+
12
+ def pandorga_font_stack(site, role)
13
+ Pandorga::Fonts.css_stack(Pandorga::Fonts.from_site(site), role)
14
+ end
15
+ end
16
+
17
+ Liquid::Template.register_filter(PandorgaFontFilter) if defined?(Liquid::Template)
@@ -50,6 +50,27 @@ home:
50
50
  type: string
51
51
  notes: Include path for hero decoration (default `page/hero-default.html`)
52
52
 
53
+ fonts:
54
+ title: "`pandorga.fonts`"
55
+ intro: |
56
+ Each value is a Google Fonts family name, or a map with `family` plus optional `weights` (integers) and `italic` (boolean). An omitted role keeps the Architectural Ledger face. `code` follows `mono`, and the tagline and CV body follow `body`, when the theme used one family for both. Text faces still load after the first paint, with `display=swap`. A `_data/themes/shared.yml` file remains the fallback under any role this map does not set.
57
+ keys:
58
+ title:
59
+ type: string or map
60
+ notes: Display face. Default family `Cinzel Decorative`, weights 400, 700, and 900
61
+ body:
62
+ type: string or map
63
+ notes: Reading face. Default family `Newsreader`, weights 300, 400, 500, 600, and 700
64
+ mono:
65
+ type: string or map
66
+ notes: Monospace face. Default family `Courier Prime`, roman and italic at 400 and 700
67
+ label:
68
+ type: string or map
69
+ notes: Optional chrome face. Default family `Marcellus SC`, weights 400 and 700
70
+ subtitle:
71
+ type: string or map
72
+ notes: Optional tagline face. Omitted, it follows `body` when the theme used the same family
73
+
53
74
  taxonomies:
54
75
  title: "`pandorga.taxonomies`"
55
76
  prose: |
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.6.8"
4
+ VERSION = "1.7.0"
5
5
  end
@@ -4,7 +4,7 @@
4
4
  // Lighthouse run treats an empty home as a fast page.
5
5
 
6
6
  import { spawn } from 'node:child_process';
7
- import { mkdtemp, rm } from 'node:fs/promises';
7
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
8
8
  import { createServer } from 'node:net';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
@@ -90,17 +90,44 @@ class Cdp {
90
90
  }
91
91
  }
92
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.
93
+ async function activePort(profile) {
94
+ try {
95
+ const text = await readFile(join(profile, 'DevToolsActivePort'), 'utf8');
96
+ const value = Number(text.split('\n')[0]);
97
+ return Number.isInteger(value) && value > 0 ? value : null;
98
+ } catch (_error) {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ async function waitForDevtools(port, chrome, profile) {
104
+ let stderr = '';
105
+ if (chrome.stderr) {
106
+ chrome.stderr.on('data', (chunk) => {
107
+ stderr += chunk;
108
+ });
109
+ }
110
+ for (let attempt = 0; attempt < 200; attempt += 1) {
111
+ if (chrome.exitCode !== null) {
112
+ throw new Error(`Chrome exited ${chrome.exitCode}: ${stderr.slice(-500)}`);
113
+ }
114
+ const opened = await activePort(profile);
115
+ const candidates = opened && opened !== port ? [port, opened] : [port];
116
+ for (const candidate of candidates) {
117
+ try {
118
+ const response = await fetch(`http://127.0.0.1:${candidate}/json/version`);
119
+ if (response.ok) return response.json();
120
+ } catch (_error) {
121
+ // Chrome is still starting.
122
+ }
100
123
  }
101
124
  await delay(100);
102
125
  }
103
- throw new Error('Chrome DevTools did not answer');
126
+ const opened = await activePort(profile);
127
+ throw new Error(
128
+ `Chrome DevTools did not answer on ${port}` +
129
+ `${opened ? ` (DevToolsActivePort ${opened})` : ''}. ${stderr.slice(-500)}`
130
+ );
104
131
  }
105
132
 
106
133
  async function openSocket(url) {
@@ -155,14 +182,15 @@ async function main() {
155
182
  '--disable-dev-shm-usage',
156
183
  '--no-first-run',
157
184
  '--remote-allow-origins=*',
185
+ '--remote-debugging-address=127.0.0.1',
158
186
  `--remote-debugging-port=${port}`,
159
187
  `--user-data-dir=${profile}`,
160
188
  'about:blank'
161
- ], { stdio: 'ignore' });
189
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
162
190
 
163
191
  let failed = false;
164
192
  try {
165
- const version = await waitForDevtools(port);
193
+ const version = await waitForDevtools(port, chrome, profile);
166
194
  const ws = await openSocket(version.webSocketDebuggerUrl);
167
195
  const browser = new Cdp(ws);
168
196
  const created = await browser.send('Target.createTarget', { url: 'about:blank' });
@@ -66,7 +66,7 @@ def generate(schema)
66
66
  out << "via `scripts/generate-configuration-doc.rb` (`PLT-AC-11`)."
67
67
  out << ""
68
68
 
69
- %w[identity content home].each do |section|
69
+ %w[identity content home fonts].each do |section|
70
70
  data = schema.fetch(section)
71
71
  out << "## #{data.fetch('title')}"
72
72
  out << ""
@@ -90,6 +90,15 @@ end
90
90
  warn "FAIL test-examples-build: #{name} home is missing JSON-LD"
91
91
  exit 1
92
92
  end
93
+ ledger_faces = "family=Cinzel+Decorative:wght@400;700;900"
94
+ unless html.include?(ledger_faces) && html.include?("&display=swap") && html.include?("__pandorgaFontsHref")
95
+ warn "FAIL test-examples-build: #{name} home dropped the ledger font request"
96
+ exit 1
97
+ end
98
+ unless html.include?("--font-title: 'Cinzel Decorative', serif")
99
+ warn "FAIL test-examples-build: #{name} home dropped the title face"
100
+ exit 1
101
+ end
93
102
  end
94
103
 
95
104
  puts "PASS test-examples-build"
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PLT-AC-30. A site names its Google Fonts families in pandorga.fonts.
5
+ # Omitted roles keep Cinzel Decorative, Newsreader, and Courier Prime.
6
+
7
+ require "pathname"
8
+ require "yaml"
9
+
10
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
11
+
12
+ def fail!(message)
13
+ warn "FAIL test-fonts: #{message}"
14
+ exit 1
15
+ end
16
+
17
+ require_relative "../../lib/pandorga/fonts"
18
+
19
+ DEFAULT_HREF = "https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700;900" \
20
+ "&family=Marcellus+SC:wght@400;700" \
21
+ "&family=Newsreader:wght@300;400;500;600;700" \
22
+ "&family=Courier+Prime:ital,wght@0,400;0,700;1,400;1,700" \
23
+ "&display=swap"
24
+
25
+ theme = YAML.safe_load(ROOT.join("_data/themes/shared.yml").read).fetch("fonts")
26
+ bare = Pandorga::Fonts.resolve(nil, nil)
27
+ from_theme = Pandorga::Fonts.resolve(nil, theme)
28
+ fail!("unset config dropped the ledger faces") unless Pandorga::Fonts.stylesheet_href(bare) == DEFAULT_HREF
29
+ fail!("theme tokens drifted from the ledger faces") unless Pandorga::Fonts.stylesheet_href(from_theme) == DEFAULT_HREF
30
+
31
+ fail!("title stack dropped Cinzel") unless Pandorga::Fonts.css_stack(from_theme, "title") == "'Cinzel Decorative', serif"
32
+ fail!("body stack dropped the serif fallback") unless Pandorga::Fonts.css_stack(from_theme, "body") == "'Newsreader', Georgia, serif"
33
+ fail!("mono stack dropped Courier Prime") unless Pandorga::Fonts.css_stack(from_theme, "mono").start_with?("'Courier Prime', ui-monospace")
34
+
35
+ custom = Pandorga::Fonts.resolve(
36
+ {
37
+ "title" => "Fraunces",
38
+ "body" => { "family" => "Source Serif 4", "weights" => [600, 400, 400] },
39
+ "mono" => { "family" => "IBM Plex Mono", "weights" => [400, 500], "italic" => true }
40
+ },
41
+ theme
42
+ )
43
+ custom_href = Pandorga::Fonts.stylesheet_href(custom)
44
+ fail!("custom title was not requested") unless custom_href.include?("family=Fraunces:wght@400;700")
45
+ fail!("custom title kept the Cinzel axis") if custom_href.include?("Cinzel")
46
+ fail!("body weights were not sorted") unless custom_href.include?("family=Source+Serif+4:wght@400;600")
47
+ fail!("mono italic axis is wrong") unless custom_href.include?("family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400;1,500")
48
+ fail!("unset label left Marcellus") unless custom_href.include?("family=Marcellus+SC:wght@400;700")
49
+ fail!("body change left the tagline on Newsreader") unless Pandorga::Fonts.css_stack(custom, "subtitle").start_with?("'Source Serif 4'")
50
+ fail!("code face did not follow mono") unless Pandorga::Fonts.css_stack(custom, "code").start_with?("'IBM Plex Mono'")
51
+
52
+ kept = Pandorga::Fonts.resolve({ "title" => "Cinzel Decorative" }, theme)
53
+ fail!("naming the default title changed the stylesheet") unless Pandorga::Fonts.stylesheet_href(kept) == DEFAULT_HREF
54
+
55
+ ignored = Pandorga::Fonts.resolve({ "title" => "Fraunces</style>" }, theme)
56
+ fail!("a broken family name was accepted") unless Pandorga::Fonts.stylesheet_href(ignored) == DEFAULT_HREF
57
+
58
+ example = YAML.safe_load(ROOT.join("examples/full/_config.yml").read)
59
+ example_fonts = example.dig("pandorga", "fonts")
60
+ fail!("full example has no pandorga.fonts") unless example_fonts.is_a?(Hash)
61
+ fail!("full example changed the measured faces") unless Pandorga::Fonts.stylesheet_href(Pandorga::Fonts.resolve(example_fonts, theme)) == DEFAULT_HREF
62
+
63
+ loader = ROOT.join("_includes/theme/font-loader.html").read
64
+ shell = ROOT.join("_layouts/shell.html").read
65
+ fail!("loader no longer hands the href to the shell") unless loader.include?("__pandorgaFontsHref")
66
+ fail!("loader preloads the text faces") if loader.include?('rel="preload"')
67
+ fail!("text faces wait for the load event") if loader.include?("addEventListener('load'")
68
+ preconnect_at = shell.index("fonts.googleapis.com")
69
+ script_at = shell.index("theme/critical-preload.html")
70
+ fail!("font host is not preconnected before the preload script") unless preconnect_at && script_at && preconnect_at < script_at
71
+ fail!("text faces are not requested after the first paint") unless shell.include?("requestAnimationFrame") && shell.include?("__pandorgaFontsHref")
72
+
73
+ spec = ROOT.join("docs/spec.md").read
74
+ fail!("spec is missing PLT-AC-30") unless spec.include?("PLT-AC-30")
75
+ fail!("configuration reference omits pandorga.fonts") unless ROOT.join("docs/configuration.md").read.include?("pandorga.fonts")
76
+
77
+ puts "PASS test-fonts"
@@ -43,7 +43,14 @@ end
43
43
 
44
44
  home = ROOT.join("_layouts/home.html").read
45
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")
46
+ fail!("home bands still fill in one turn") unless home.include?("scheduler.yield") && home.include?("await afterPaint()")
47
+ fail!("specimen measurement does not yield") unless home.include?("await measureTallestSpecimen()")
48
+ fail!("home still fills every band inside one callback") if home.include?("sections.forEach(function (section) { fillBand(section, index); })")
49
+ fragments = ROOT.join("_includes/content-runtime/60-fragments.html").read
50
+ fail!("home section markdown still runs in one turn") unless fragments.include?("function yieldToMain()") &&
51
+ fragments.include?("return yieldToMain().then(function () {")
52
+ listing = ROOT.join("_includes/content-runtime/40-listing.html").read
53
+ fail!("date formatting builds a new formatter per card") unless listing.include?("dateFormatters")
47
54
 
48
55
  marked = ROOT.join("_includes/content-runtime/00-config.html").read
49
56
  fail!("markdown library no longer defers") unless marked.include?("<script defer ")
@@ -67,6 +74,8 @@ fail!("probe ignores the project inventory") unless probe.include?("#home-projec
67
74
  fail!("probe ignores article cards") unless probe.include?("#home-articles .home-featured, #home-articles .ledger-card")
68
75
  fail!("probe ignores the phone viewport") unless probe.include?("name: 'mobile'")
69
76
  fail!("probe ignores the desktop viewport") unless probe.include?("name: 'desktop'")
77
+ fail!("probe does not pin DevTools to IPv4") unless probe.include?("--remote-debugging-address=127.0.0.1")
78
+ fail!("probe gives Chrome only a few seconds to open DevTools") unless probe.include?("attempt < 200")
70
79
 
71
80
  _out, err, status = Open3.capture3("node", "--check", ROOT.join("scripts/check_home_lists.mjs").to_s)
72
81
  fail!("home list probe is not valid JavaScript\n#{err}") unless status.success?
data/scripts/validate.sh CHANGED
@@ -64,6 +64,7 @@ TESTS=(
64
64
  scripts/test/test-home-articles.rb
65
65
  scripts/test/test-rascunhos-licence.rb
66
66
  scripts/test/test-pwa-icons.rb
67
+ scripts/test/test-fonts.rb
67
68
  scripts/test/test-css-delivery.rb
68
69
  scripts/test/test-discovery.rb
69
70
  )
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pandorga
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.8
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -288,6 +288,7 @@ files:
288
288
  - lib/pandorga/commands/serve.rb
289
289
  - lib/pandorga/commands/studio_schema.rb
290
290
  - lib/pandorga/discovery.rb
291
+ - lib/pandorga/fonts.rb
291
292
  - lib/pandorga/hero_summary.rb
292
293
  - lib/pandorga/jekyll.rb
293
294
  - lib/pandorga/jekyll/generator.rb
@@ -303,6 +304,7 @@ files:
303
304
  - lib/pandorga/jekyll/plugins/content_writing_index.rb
304
305
  - lib/pandorga/jekyll/plugins/content_writing_paths.rb
305
306
  - lib/pandorga/jekyll/plugins/discovery.rb
307
+ - lib/pandorga/jekyll/plugins/fonts.rb
306
308
  - lib/pandorga/jekyll/plugins/force_utf8_encoding.rb
307
309
  - lib/pandorga/jekyll/plugins/git_chronology.rb
308
310
  - lib/pandorga/jekyll/plugins/git_metadata.rb
@@ -422,6 +424,7 @@ files:
422
424
  - scripts/test/test-export-content-json-incremental.rb
423
425
  - scripts/test/test-export.rb
424
426
  - scripts/test/test-exported-fields-reach-the-view.rb
427
+ - scripts/test/test-fonts.rb
425
428
  - scripts/test/test-form-control-theming.rb
426
429
  - scripts/test/test-git-chronology.rb
427
430
  - scripts/test/test-hero-name.rb