pandorga 1.14.0 → 2.0.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/DESIGN.md +15 -30
  4. data/_includes/content-runtime/43-blocks.html +0 -3
  5. data/_includes/content-runtime/60-fragments.html +83 -149
  6. data/_includes/content-runtime/90-bootstrap.html +3 -0
  7. data/_includes/cv/cv-box.html +10 -4
  8. data/_includes/home/band-index.html +22 -7
  9. data/_includes/home/band-listing.html +7 -3
  10. data/_includes/page/timeline-section.html +8 -3
  11. data/_includes/theme/theme-vars-block.liquid +3 -0
  12. data/_layouts/articles.html +0 -2
  13. data/_layouts/bibliography.html +0 -2
  14. data/_layouts/blog.html +0 -5
  15. data/_layouts/cv.html +27 -8
  16. data/_layouts/home.html +2 -13
  17. data/_layouts/projects.html +17 -9
  18. data/_layouts/resources.html +0 -9
  19. data/_layouts/shell.html +1 -7
  20. data/assets/css/article.css +10 -2
  21. data/assets/css/base.css +68 -44
  22. data/assets/css/ledger/bibliography.css +0 -31
  23. data/assets/css/ledger/chrome.css +51 -2
  24. data/assets/css/ledger/cv.css +0 -40
  25. data/assets/css/ledger/detail.css +20 -6
  26. data/assets/css/ledger/home.css +261 -402
  27. data/assets/css/ledger/projects.css +17 -19
  28. data/assets/css/ledger/rascunhos.css +0 -27
  29. data/assets/css/ledger/sources.css +0 -57
  30. data/assets/css/ledger/writing.css +0 -62
  31. data/lib/pandorga/commands/new.rb +2 -2
  32. data/lib/pandorga/commands/thumbnails.rb +3 -2
  33. data/lib/pandorga/registry/schema.yml +0 -3
  34. data/lib/pandorga/thumbnail_variants.rb +29 -3
  35. data/lib/pandorga/version.rb +1 -1
  36. data/scripts/check_home_lists.mjs +33 -5
  37. data/scripts/content/hero_portraits.rb +59 -11
  38. data/scripts/test/test-css-structure.rb +0 -1
  39. data/scripts/test/test-hero-16x9.rb +379 -0
  40. data/scripts/test/test-hero-portraits.mjs +10 -20
  41. data/scripts/test/test-hero-portraits.rb +31 -25
  42. data/scripts/test/test-highlighter-pills.rb +12 -2
  43. data/scripts/test/test-home-lists.rb +5 -0
  44. data/scripts/test/test-new.rb +6 -6
  45. data/scripts/test/test-shell-a11y.rb +2 -1
  46. data/scripts/test/test-studio-section-texts.rb +625 -0
  47. data/scripts/test/test-theme-contrast.rb +13 -2
  48. data/scripts/test/test-v3-layout.rb +559 -0
  49. data/scripts/validate.sh +3 -0
  50. data/studio/assets/{index-BJyN-kB3.js → index-B9h1WUYY.js} +1 -1
  51. data/studio/index.html +1 -1
  52. data/studio/schema.yml +126 -2
  53. data/studio-app/src/lib/yaml-doc.js +6 -0
  54. data/templates/network/studio.yml +10 -0
  55. metadata +5 -2
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PLT-AC-50. Section texts in Studio (docs/features/studio-section-texts.md).
5
+ #
6
+ # Each check names the acceptance item it covers (50.1 … 50.8). Pass item
7
+ # numbers to run a subset: `ruby scripts/test/test-studio-section-texts.rb 1 2`.
8
+ # Source-level gate: it reads the schema as YAML, the runtime and the Liquid
9
+ # templates as text, and runs `headerText`, the two hydrators (on a stub DOM)
10
+ # and Studio's `pruneEmptyFields` under Node when Node is
11
+ # present. Absent key -> theme default; empty string -> nothing rendered.
12
+
13
+ require "pathname"
14
+ require "set"
15
+ require "yaml"
16
+ require "date"
17
+ require "open3"
18
+ require "json"
19
+
20
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
21
+ ONLY_ITEMS = ARGV.map { |a| a.sub(/\A(PLT-AC-)?50\./, "") }.to_set
22
+
23
+ $failures = []
24
+
25
+ def item(id)
26
+ return if !ONLY_ITEMS.empty? && !ONLY_ITEMS.include?(id.to_s)
27
+
28
+ @item = id
29
+ yield
30
+ end
31
+
32
+ def fail!(msg)
33
+ $failures << "FAIL PLT-AC-50.#{@item}: #{msg}"
34
+ end
35
+
36
+ def read(rel)
37
+ path = ROOT.join(rel)
38
+ return path.read if path.file?
39
+
40
+ fail!("#{rel} is missing")
41
+ ""
42
+ end
43
+
44
+ def strip_js_comments(text)
45
+ text.gsub(%r{/\*.*?\*/}m, "").gsub(%r{^\s*//.*$}, "")
46
+ end
47
+
48
+ def strip_liquid_comments(text)
49
+ text.gsub(/\{%-?\s*comment\s*-?%\}.*?\{%-?\s*endcomment\s*-?%\}/m, "")
50
+ end
51
+
52
+ # Body of `function name(...) { ... }`, braces matched outside strings.
53
+ def js_function(source, name)
54
+ start = source.index(/function\s+#{Regexp.escape(name)}\s*\(/)
55
+ return nil unless start
56
+
57
+ open_at = source.index("{", start)
58
+ return nil unless open_at
59
+
60
+ depth = 0
61
+ quote = nil
62
+ i = open_at
63
+ while i < source.length
64
+ c = source[i]
65
+ if quote
66
+ i += 1 if c == "\\"
67
+ quote = nil if c == quote
68
+ elsif c == "'" || c == '"' || c == "`"
69
+ quote = c
70
+ elsif c == "{"
71
+ depth += 1
72
+ elsif c == "}"
73
+ depth -= 1
74
+ return source[start..i] if depth.zero?
75
+ end
76
+ i += 1
77
+ end
78
+ nil
79
+ end
80
+
81
+ def node_available?
82
+ return @node_available unless @node_available.nil?
83
+
84
+ out, = Open3.capture2e("node", "--version")
85
+ @node_available = !out.to_s.strip.empty?
86
+ rescue StandardError
87
+ @node_available = false
88
+ end
89
+
90
+ # Runs `script` under Node; returns [ok, output]. nil when Node is missing.
91
+ def run_node(script)
92
+ unless node_available?
93
+ warn "test-studio-section-texts: node not found; item #{@item} runtime cases skipped"
94
+ return nil
95
+ end
96
+ out, status = Open3.capture2e("node", "-e", script)
97
+ [status.success?, out.strip]
98
+ end
99
+
100
+ # Liquid: a field-backed text is replaced only when its key is not nil, never
101
+ # through `| default:` (default treats "" as absent).
102
+ def liquid_nil_override?(src, var, expr)
103
+ src.match?(/\{%-?\s*if #{Regexp.escape(expr)} != nil\s*-?%\}\s*\{%-?\s*assign #{Regexp.escape(var)} = #{Regexp.escape(expr)} \| strip\s*-?%\}/)
104
+ end
105
+
106
+ # 50.1 Schema --------------------------------------------------------------------
107
+ PAGE_FIELDS = {
108
+ "page_projects" => %w[title kicker note cta others_kicker others_title repositories_kicker repositories_title accent tags],
109
+ "page_articles" => %w[cta],
110
+ "page_rascunhos" => %w[cta],
111
+ "page_sources" => %w[note cta],
112
+ "page_network" => %w[note cta],
113
+ "page_bibliography" => %w[cta],
114
+ "page_cv" => %w[summary_title experience_title products_title education_title languages_title timeline_title contact_label]
115
+ }.freeze
116
+
117
+ # Fields held to the new-field rules (string, optional, described, keep_empty).
118
+ # The bibliography note already exists but follows the same rule.
119
+ NEW_FIELDS = {
120
+ "page_projects" => %w[cta others_kicker others_title repositories_kicker repositories_title],
121
+ "page_articles" => %w[cta],
122
+ "page_rascunhos" => %w[cta],
123
+ "page_sources" => %w[note cta],
124
+ "page_network" => %w[note cta],
125
+ "page_bibliography" => %w[note cta],
126
+ "page_cv" => %w[summary_title experience_title products_title education_title languages_title timeline_title contact_label]
127
+ }.freeze
128
+
129
+ def check_new_field(where, field)
130
+ fail!("#{where}.#{field['name']} must be type string") unless field["type"].to_s == "string"
131
+ fail!("#{where}.#{field['name']} must not be required") if field["required"]
132
+ fail!("#{where}.#{field['name']} needs a description") if field["description"].to_s.strip.empty?
133
+ fail!("#{where}.#{field['name']} must set keep_empty: true so an emptied field saves as \"\"") unless field["keep_empty"] == true
134
+ end
135
+
136
+ item(1) do
137
+ schema = YAML.safe_load(read("studio/schema.yml"), permitted_classes: [Date]) || {}
138
+ pages = Array(schema["content"]).find { |g| g.is_a?(Hash) && g["name"] == "pages" }
139
+ if pages.nil?
140
+ fail!("studio/schema.yml has no pages group")
141
+ else
142
+ items = Array(pages["items"]).to_h { |i| [i["name"], i] }
143
+ projects = items["page_projects"]
144
+ if projects
145
+ fail!("page_projects must edit content/pages/headers.yml") unless projects["path"] == "content/pages/headers.yml"
146
+ fail!("page_projects must be format yaml") unless projects["format"] == "yaml"
147
+ fail!("page_projects must use root_key projects") unless projects["root_key"] == "projects"
148
+ fail!("page_projects must be a file item") unless projects["type"] == "file"
149
+ end
150
+ PAGE_FIELDS.each do |name, wanted|
151
+ entry = items[name]
152
+ unless entry
153
+ fail!("pages group has no #{name}")
154
+ next
155
+ end
156
+ fields = Array(entry["fields"]).to_h { |f| [f["name"], f] }
157
+ wanted.each { |f| fail!("#{name} has no #{f} field") unless fields.key?(f) }
158
+ NEW_FIELDS.fetch(name, []).each { |f| check_new_field(name, fields[f]) if fields[f] }
159
+ end
160
+ home = items["page_home"]
161
+ fail!("page_home must keep root_key index") unless home && home["root_key"] == "index"
162
+ fail!("page_home must be labeled 'Home references band'") unless home && home["label"] == "Home references band"
163
+ end
164
+
165
+ fragment = YAML.safe_load(read("templates/network/studio.yml"), permitted_classes: [Date]) || {}
166
+ network = Array(fragment["files"]).find { |f| f["name"] == "page_network" }
167
+ if network.nil?
168
+ fail!("templates/network/studio.yml has no page_network file")
169
+ else
170
+ fields = Array(network["fields"]).to_h { |f| [f["name"], f] }
171
+ %w[note cta].each do |f|
172
+ if fields[f]
173
+ check_new_field("templates/network page_network", fields[f])
174
+ else
175
+ fail!("templates/network/studio.yml page_network has no #{f} field")
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ # A minimal DOM for the hydrators: only the members the spec lists.
182
+ STUB_DOM_JS = <<~'JS'
183
+ function attrSel(sel) {
184
+ var m = /^\[([a-z-]+)(?:="([^"]*)")?\]$/.exec(sel);
185
+ if (!m) throw new Error('stub DOM supports [attr] and [attr="v"] only, got ' + sel);
186
+ return function (el) { return el.hasAttribute(m[1]) && (m[2] === undefined || el.getAttribute(m[1]) === m[2]); };
187
+ }
188
+ function El(tag, attrs, kids) {
189
+ this.tagName = tag.toUpperCase();
190
+ this.attrs = Object.assign({}, attrs || {});
191
+ this.kids = [];
192
+ this.parentElement = null;
193
+ this._text = '';
194
+ this.hidden = 'hidden' in this.attrs;
195
+ var self = this;
196
+ this.classList = { remove: function () {}, add: function () {}, contains: function () { return false; } };
197
+ (kids || []).forEach(function (k) {
198
+ if (typeof k === 'string') self._text += k; else { k.parentElement = self; self.kids.push(k); }
199
+ });
200
+ }
201
+ El.prototype.getAttribute = function (n) { return n in this.attrs ? this.attrs[n] : null; };
202
+ El.prototype.hasAttribute = function (n) { return n in this.attrs; };
203
+ El.prototype.setAttribute = function (n, v) { this.attrs[n] = String(v); };
204
+ El.prototype.removeAttribute = function (n) { delete this.attrs[n]; };
205
+ Object.defineProperty(El.prototype, 'childElementCount', { get: function () { return this.kids.length; } });
206
+ Object.defineProperty(El.prototype, 'children', { get: function () { return this.kids.slice(); } });
207
+ Object.defineProperty(El.prototype, 'firstElementChild', { get: function () { return this.kids[0] || null; } });
208
+ Object.defineProperty(El.prototype, 'textContent', {
209
+ get: function () { return this._text + this.kids.map(function (k) { return k.textContent; }).join(''); },
210
+ set: function (v) { this.kids = []; this._text = String(v); }
211
+ });
212
+ Object.defineProperty(El.prototype, 'innerHTML', {
213
+ get: function () { return this._text + this.kids.map(function (k) { return k.innerHTML; }).join(''); },
214
+ set: function (v) { this.kids = []; this._text = String(v); }
215
+ });
216
+ El.prototype.remove = function () {
217
+ if (!this.parentElement) return;
218
+ var p = this.parentElement;
219
+ p.kids = p.kids.filter(function (k) { return k !== this; }, this);
220
+ this.parentElement = null;
221
+ };
222
+ El.prototype.querySelectorAll = function (sel) {
223
+ var test = attrSel(sel), out = [];
224
+ (function walk(n) { n.kids.forEach(function (k) { if (test(k)) out.push(k); walk(k); }); })(this);
225
+ return out;
226
+ };
227
+ El.prototype.querySelector = function (sel) { return this.querySelectorAll(sel)[0] || null; };
228
+ El.prototype.closest = function (sel) {
229
+ var test = attrSel(sel);
230
+ for (var n = this; n; n = n.parentElement) if (test(n)) return n;
231
+ return null;
232
+ };
233
+ El.prototype.contains = function (other) {
234
+ for (var n = other; n; n = n.parentElement) if (n === this) return true;
235
+ return false;
236
+ };
237
+ function h(tag, attrs, kids) { return new El(tag, attrs, kids); }
238
+ function parseInlineMarkdown(s) { return String(s); }
239
+ function stripSingleParagraph(s) { return String(s); }
240
+ var HEADERS = null;
241
+ var FETCH_FAILS = false;
242
+ var FETCHES = 0;
243
+ function loadPageHeaders() {
244
+ FETCHES += 1;
245
+ return FETCH_FAILS ? Promise.reject(new Error('offline')) : Promise.resolve(HEADERS || {});
246
+ }
247
+ console.error = function () {};
248
+ function page() {
249
+ var root = h('body', {}, [
250
+ h('div', { 'data-content-header-wrap': '', id: 'aside' }, [
251
+ h('a', { id: 'band-link' }, [h('span', { 'data-content-header-text': 'blog.cta', id: 'band-cta' }, ['View all'])])
252
+ ]),
253
+ h('article', { 'data-content-header-name': '', id: 'panel' }, [
254
+ h('div', { 'data-content-header-group': '', id: 'head' }, [
255
+ h('h3', { 'data-content-header-text': 'network.title', id: 'panel-title' }, ['Network']),
256
+ h('a', { 'data-content-header-wrap': '', id: 'panel-link' }, [h('span', { 'data-content-header-text': 'network.cta', id: 'panel-cta' }, ['Open'])])
257
+ ]),
258
+ h('p', { 'data-content-panel-note': 'network', hidden: '', id: 'note' }, []),
259
+ h('p', { 'data-content-panel-note': 'resources', id: 'built-note' }, ['Built note'])
260
+ ]),
261
+ h('section', { 'data-content-header-name': '', id: 'cv' }, [
262
+ h('div', { id: 'cv-rail' }, [
263
+ h('div', { 'data-content-header-wrap': '', id: 'cv-head' }, [
264
+ h('h2', { 'data-content-header-text': 'cv.summary_title', id: 'cv-title' }, ['Summary']),
265
+ h('span', { 'aria-hidden': 'true', id: 'cv-rule' }, [])
266
+ ])
267
+ ])
268
+ ]),
269
+ h('section', { 'data-content-header-name': '', 'aria-labelledby': 'x', id: 'named' }, [
270
+ h('h2', { 'data-content-header-text': 'cv.experience_title', id: 'named-title' }, ['Experience'])
271
+ ])
272
+ ]);
273
+ return root;
274
+ }
275
+ function byId(root, id) { return root.querySelectorAll('[id="' + id + '"]')[0] || null; }
276
+ JS
277
+
278
+ HYDRATOR_CASES_JS = <<~'JS'
279
+ var fails = [];
280
+ function expect(cond, msg) { if (!cond) fails.push(msg); }
281
+ function run(headers, failFetch) {
282
+ HEADERS = headers; FETCH_FAILS = !!failFetch;
283
+ var root = page();
284
+ return hydrateHeaderTexts(root).then(function () { return hydratePanelNotes(root); }).then(function () { return root; });
285
+ }
286
+ (async function () {
287
+ // Nothing to fill: no fetch.
288
+ FETCHES = 0;
289
+ await hydrateHeaderTexts(h('body', {}, [h('p', {}, ['x'])]));
290
+ await hydratePanelNotes(h('body', {}, [h('p', {}, ['x'])]));
291
+ expect(FETCHES === 0, 'hydrators fetched with no slot under root');
292
+
293
+ // Absent keys: built text stays, empty note goes, built note goes too.
294
+ var r = await run({ blog: {}, network: { cta: null }, cv: { summary_title: 7 } });
295
+ expect(byId(r, 'band-cta') && byId(r, 'band-cta').textContent === 'View all', 'absent blog.cta must keep "View all"');
296
+ expect(byId(r, 'panel-title') && byId(r, 'panel-title').textContent === 'Network', 'absent network.title must keep the built title');
297
+ expect(byId(r, 'panel-cta') && byId(r, 'panel-cta').textContent === 'Open', 'null network.cta must keep "Open"');
298
+ expect(byId(r, 'cv-title') && byId(r, 'cv-title').textContent === 'Summary', 'non-string cv.summary_title must keep "Summary"');
299
+ expect(byId(r, 'cv-rule'), 'a CV heading that stays must keep its rule');
300
+ expect(!byId(r, 'note'), 'absent network.note must remove the note');
301
+ expect(!byId(r, 'built-note'), 'absent resources.note must remove the note');
302
+ expect(!byId(r, 'panel').hasAttribute('aria-label'), 'a panel that keeps its title must not get aria-label');
303
+
304
+ // Values: filled and trimmed, note unhidden.
305
+ r = await run({ blog: { cta: ' Ver todos ' }, network: { title: '*Network* of Ideas', cta: 'Go', note: 'A map.' }, cv: { summary_title: 'Resumo' } });
306
+ expect(byId(r, 'band-cta').innerHTML === 'Ver todos', 'blog.cta must fill "Ver todos", got ' + byId(r, 'band-cta').innerHTML);
307
+ expect(byId(r, 'panel-title').innerHTML === '*Network* of Ideas', 'network.title must fill the panel title');
308
+ expect(byId(r, 'panel-cta').innerHTML === 'Go', 'network.cta must fill the link label');
309
+ expect(byId(r, 'cv-title').innerHTML === 'Resumo', 'cv.summary_title must fill the heading');
310
+ var note = byId(r, 'note');
311
+ expect(note && note.innerHTML === 'A map.' && note.hidden === false, 'network.note must fill and unhide the note');
312
+
313
+ // Empty values: element and wrapper gone, heading name kept.
314
+ r = await run({ blog: { cta: '' }, network: { title: ' ', cta: '' }, cv: { summary_title: '', experience_title: '' } });
315
+ expect(!byId(r, 'aside') && !byId(r, 'band-link') && !byId(r, 'band-cta'), 'empty blog.cta must remove the whole band aside');
316
+ expect(!byId(r, 'panel-title'), 'empty network.title must remove the panel title');
317
+ expect(!byId(r, 'panel-link'), 'empty network.cta must remove the whole link');
318
+ expect(!byId(r, 'head'), 'a panel head left with no child must be removed');
319
+ expect(byId(r, 'panel') && byId(r, 'panel').getAttribute('aria-label') === 'Network', 'a panel whose title is hidden must keep "Network" as aria-label');
320
+ expect(!byId(r, 'cv-title'), 'empty cv.summary_title must remove the heading');
321
+ expect(!byId(r, 'cv-rule') && byId(r, 'cv-rail'), 'empty cv.summary_title must remove the rule with its head, keeping the rail');
322
+ expect(byId(r, 'cv') && byId(r, 'cv').getAttribute('aria-label') === 'Summary', 'a CV section whose heading is hidden must get aria-label "Summary"');
323
+ expect(byId(r, 'named') && !byId(r, 'named').hasAttribute('aria-label'), 'a region named by aria-labelledby must not get aria-label');
324
+ expect(!byId(r, 'named-title'), 'empty cv.experience_title must remove the heading');
325
+
326
+ // Only the title empty: the head stays with the link.
327
+ r = await run({ network: { title: '', cta: 'Go' } });
328
+ expect(byId(r, 'head') && byId(r, 'panel-link') && !byId(r, 'panel-title'), 'empty title with a link must keep the head and the link');
329
+
330
+ // Fetch failure: everything as built, empty notes removed, built note kept.
331
+ r = await run({}, true);
332
+ expect(byId(r, 'band-cta') && byId(r, 'band-cta').textContent === 'View all', 'fetch failure must keep the built link label');
333
+ expect(byId(r, 'panel-title') && byId(r, 'cv-title'), 'fetch failure must keep the built headings');
334
+ expect(!byId(r, 'note'), 'fetch failure must remove an empty note');
335
+ expect(byId(r, 'built-note') && byId(r, 'built-note').textContent === 'Built note', 'fetch failure must keep a note that has text');
336
+
337
+ if (fails.length) { console.log(fails.join(' | ')); process.exit(1); }
338
+ })().catch(function (e) { console.log('threw: ' + (e && e.stack || e)); process.exit(1); });
339
+ JS
340
+
341
+ # 50.2 Runtime -------------------------------------------------------------------
342
+ item(2) do
343
+ fragments = strip_js_comments(read("_includes/content-runtime/60-fragments.html"))
344
+ bootstrap = strip_js_comments(read("_includes/content-runtime/90-bootstrap.html"))
345
+
346
+ texts = js_function(fragments, "hydrateHeaderTexts")
347
+ if texts.nil?
348
+ fail!("60-fragments.html defines no hydrateHeaderTexts(root)")
349
+ else
350
+ fail!("hydrateHeaderTexts must select [data-content-header-text]") unless texts.include?("data-content-header-text")
351
+ fail!("hydrateHeaderTexts must read the page headers") unless texts.include?("loadPageHeaders(")
352
+ %w[parseInlineMarkdown stripSingleParagraph].each do |fn|
353
+ fail!("hydrateHeaderTexts must render through #{fn}") unless texts.include?(fn)
354
+ end
355
+ fail!("hydrateHeaderTexts must remove an element whose field is empty") unless texts.match?(/\.remove\(\)/)
356
+ %w[data-content-header-wrap data-content-header-group data-content-header-name aria-label].each do |hook|
357
+ fail!("hydrateHeaderTexts must handle #{hook}") unless texts.include?(hook)
358
+ end
359
+ end
360
+
361
+ notes = js_function(fragments, "hydratePanelNotes")
362
+ if notes.nil?
363
+ fail!("60-fragments.html defines no hydratePanelNotes(root)")
364
+ else
365
+ fail!("hydratePanelNotes must select [data-content-panel-note]") unless notes.include?("data-content-panel-note")
366
+ fail!("hydratePanelNotes must read the page headers") unless notes.include?("loadPageHeaders(")
367
+ fail!("hydratePanelNotes must remove a blank note") unless notes.match?(/\.remove\(\)/)
368
+ fail!("hydratePanelNotes must unhide a filled note") unless notes.match?(/hidden\s*=\s*false|removeAttribute\(\s*['"]hidden['"]\s*\)/)
369
+ %w[parseInlineMarkdown stripSingleParagraph].each do |fn|
370
+ fail!("hydratePanelNotes must render through #{fn}") unless notes.include?(fn)
371
+ end
372
+ end
373
+
374
+ boot = js_function(fragments, "hydratePageFragments")
375
+ if boot.nil?
376
+ fail!("60-fragments.html lost hydratePageFragments")
377
+ else
378
+ tail = boot[(boot.index("hydrateHomeSections(") || 0)..] || ""
379
+ fail!("hydratePageFragments must call hydrateHeaderTexts") unless boot.include?("hydrateHeaderTexts(")
380
+ fail!("hydratePageFragments must call hydratePanelNotes") unless boot.include?("hydratePanelNotes(")
381
+ unless boot.include?("hydrateHomeSections(") && tail.include?("hydrateHeaderTexts(") && tail.include?("hydratePanelNotes(")
382
+ fail!("hydrateHeaderTexts and hydratePanelNotes must run alongside hydrateHomeSections, after the page headers")
383
+ end
384
+ end
385
+
386
+ %w[loadPageHeaders hydrateHeaderTexts headerText].each do |name|
387
+ fail!("90-bootstrap.html must export #{name}") unless bootstrap.match?(/\b#{name}\s*:\s*#{name}\b/)
388
+ end
389
+
390
+ helper = js_function(fragments, "headerText")
391
+ if helper.nil?
392
+ fail!("60-fragments.html defines no headerText(headers, key, field, fallback)")
393
+ else
394
+ # Absent, null or non-string -> fallback; a string -> trimmed, "" included.
395
+ cases = [
396
+ [{ "projects" => { "others_title" => " Mine " } }, "projects", "others_title", "Other projects", "Mine"],
397
+ [{ "projects" => { "others_title" => " " } }, "projects", "others_title", "Other projects", ""],
398
+ [{ "projects" => { "others_title" => "" } }, "projects", "others_title", "Other projects", ""],
399
+ [{ "projects" => { "others_title" => nil } }, "projects", "others_title", "Other projects", "Other projects"],
400
+ [{ "projects" => {} }, "projects", "others_title", "Other projects", "Other projects"],
401
+ [{ "projects" => "x" }, "projects", "others_title", "Other projects", "Other projects"],
402
+ [{}, "projects", "others_title", "Other projects", "Other projects"],
403
+ [nil, "projects", "others_title", "Other projects", "Other projects"],
404
+ [{ "cv" => { "summary_title" => 3 } }, "cv", "summary_title", "Summary", "Summary"],
405
+ [{ "cv" => { "summary_title" => ["x"] } }, "cv", "summary_title", "Summary", "Summary"],
406
+ [{ "cv" => {} }, "cv", "summary_title", nil, nil]
407
+ ]
408
+ script = "#{helper}\nconst cases = #{JSON.generate(cases)};\n" \
409
+ "const bad = cases.filter(c => headerText(c[0], c[1], c[2], c[3]) !== c[4]);\n" \
410
+ "if (bad.length) { console.log(JSON.stringify(bad)); process.exit(1); }\n"
411
+ ok, out = run_node(script)
412
+ fail!("headerText returned the wrong value for #{out}") if ok == false
413
+ end
414
+
415
+ # Behavior of the two hydrators on a stub DOM (absent -> built text kept,
416
+ # value -> filled, empty -> removed with its wrapper, heading name kept).
417
+ if helper && texts && notes
418
+ ok, out = run_node(<<~JS)
419
+ #{STUB_DOM_JS}
420
+ #{helper}
421
+ #{texts}
422
+ #{notes}
423
+ #{HYDRATOR_CASES_JS}
424
+ JS
425
+ fail!("hydrators on the stub DOM: #{out}") if ok == false
426
+ end
427
+ end
428
+
429
+ # 50.3 Home band call to action --------------------------------------------------
430
+ item(3) do
431
+ band = strip_liquid_comments(read("_includes/home/band-listing.html"))
432
+ fail!("band-listing CTA has no data-content-header-text=\"{{ band.section_key }}.cta\"") unless band.include?('data-content-header-text="{{ band.section_key }}.cta"')
433
+ fail!("band-listing CTA must read page_headers[band.section_key].cta") unless band.match?(/page_headers\[band\.section_key\]/) && band.include?("_section.cta")
434
+ fail!("band-listing CTA must still fall back to band.home.cta, then \"View all\"") unless band.match?(/assign _cta = band\.home\.cta \| default: "View all"/)
435
+ fail!("band-listing must not pass _section.cta through | default: (an empty cta must hide the link)") if band.match?(/_section\.cta\s*\|\s*default/)
436
+ fail!("band-listing CTA must take _section.cta (stripped) only when it is not nil") unless liquid_nil_override?(band, "_cta", "_section.cta")
437
+ default_at = band.index("assign _cta = band.home.cta")
438
+ override_at = band.index("if _section.cta != nil")
439
+ fail!("band-listing must assign the default before the headers override") unless default_at && override_at && default_at < override_at
440
+ fail!("band-listing must render .home-section-aside only when _cta is not empty") unless band.match?(/\{%-?\s*if _cta != ""\s*-?%\}\s*<div class="home-section-aside" data-content-header-wrap>/)
441
+ end
442
+
443
+ # 50.4 Home panels ---------------------------------------------------------------
444
+ item(4) do
445
+ band = strip_liquid_comments(read("_includes/home/band-index.html"))
446
+ fail!("panel title has no data-content-header-text=\"{{ panel.key }}.title\"") unless band.match?(/<h3 class="home-index-title"[^>]*data-content-header-text="\{\{ panel\.key \}\}\.title"/)
447
+ fail!("panel title must read page_headers[panel.key].title") unless band.match?(/page_headers\[panel\.key\]/) && band.match?(/\.title\b/)
448
+ fail!("Open label has no data-content-header-text=\"{{ panel.key }}.cta\"") unless band.include?('data-content-header-text="{{ panel.key }}.cta"')
449
+ fail!("Open label must default to \"Open\"") unless band.match?(/"Open"|>Open</)
450
+ fail!("band-index must not pass panel headers through | default: (empty must hide)") if band.match?(/_panel_head\.(title|cta|note)\s*\|\s*default/)
451
+ fail!("panel title must take _panel_head.title (stripped) only when it is not nil") unless liquid_nil_override?(band, "_panel_title", "_panel_head.title")
452
+ fail!("panel Open label must take _panel_head.cta (stripped) only when it is not nil") unless liquid_nil_override?(band, "_panel_cta", "_panel_head.cta")
453
+ fail!("panel title h3 must render only when _panel_title is not empty") unless band.match?(/\{%-?\s*if _panel_title != ""\s*-?%\}\s*<h3 class="home-index-title"/)
454
+ fail!("panel Open link must render only when _panel_cta is not empty, with data-content-header-wrap") unless band.match?(/\{%-?\s*if _panel_cta != ""\s*-?%\}\s*<a class="ledger-cta"[^>]*data-content-header-wrap/)
455
+ fail!("panel head must render only when the title or the link is not empty") unless band.match?(/\{%-?\s*if _panel_title != "" or _panel_cta != ""\s*-?%\}\s*<div class="home-index-panel-head" data-content-header-group>/)
456
+ article = band[/<article class="home-index-panel"[^>]*>/m].to_s
457
+ fail!("article.home-index-panel must carry data-content-header-name") unless article.include?("data-content-header-name")
458
+ fail!("article.home-index-panel must carry aria-label when its title is empty") unless article.match?(/\{%\s*if _panel_title == ""\s*%\}\s*aria-label="/)
459
+
460
+ note_at = band.index('data-content-panel-note="{{ panel.key }}"')
461
+ head_at = band.index("home-index-panel-head")
462
+ plate_at = band.index("home-index-plate")
463
+ if note_at.nil?
464
+ fail!("panel has no <p class=\"home-index-note font-body\" data-content-panel-note=\"{{ panel.key }}\">")
465
+ else
466
+ tag = band[band.rindex("<", note_at)..band.index(">", note_at)]
467
+ fail!("panel note must be p.home-index-note.font-body") unless tag.start_with?("<p") && tag.include?("home-index-note") && tag.include?("font-body")
468
+ fail!("panel note must be hidden when empty") unless tag.include?("hidden")
469
+ fail!("panel note must come after .home-index-panel-head") unless head_at && head_at < note_at
470
+ plate_after_head = head_at && band.index("home-index-plate", head_at)
471
+ fail!("panel note must come before figure.home-index-plate") unless plate_at && plate_after_head && note_at < plate_after_head
472
+ fail!("panel note must be prefilled from page_headers[panel.key].note") unless band.match?(/\.note\b/)
473
+ end
474
+
475
+ css = read("assets/css/ledger/home.css").gsub(%r{/\*.*?\*/}m, "")
476
+ # A panel with an empty title keeps its link on the right, as with a title.
477
+ cta_right = css.scan(/([^{}]*)\{([^{}]*)\}/).any? do |sel, body|
478
+ sel.split(",").any? { |s| s.strip.match?(/\A\.home-index-panel-head\s*>?\s*\.ledger-cta\z/) } &&
479
+ body.match?(/margin-(left|inline-start)\s*:\s*auto\b/)
480
+ end
481
+ fail!("home.css must right-align the panel link on its own (.home-index-panel-head > .ledger-cta { margin-left: auto })") unless cta_right
482
+ rules = css.scan(/([^{}]*\.home-index-note[^{}]*)\{([^{}]*)\}/)
483
+ fail!("home.css has no .home-index-note rule") if rules.empty?
484
+ rules.each do |sel, body|
485
+ fail!("#{sel.strip} uses a color literal") if body.match?(/#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/)
486
+ fail!("#{sel.strip} uses !important") if body.include?("!important")
487
+ end
488
+ end
489
+
490
+ # 50.5 Projects page banners -----------------------------------------------------
491
+ item(5) do
492
+ page = strip_js_comments(read("_layouts/projects.html"))
493
+ fail!("projects.html must load the headers with runtime.loadPageHeaders()") unless page.include?("runtime.loadPageHeaders(")
494
+ {
495
+ "others_kicker" => "Index", "others_title" => "Other projects",
496
+ "repositories_kicker" => "Open source", "repositories_title" => "Repositories"
497
+ }.each do |field, default|
498
+ pattern = /runtime\.headerText\([^;]*?['"]#{field}['"]\s*,\s*['"]#{Regexp.escape(default)}['"]\s*\)/m
499
+ fail!("projects.html must build #{field} with runtime.headerText(..., '#{field}', '#{default}')") unless page.match?(pattern)
500
+ end
501
+ fail!("projects.html must give section.projects-repos aria-label \"Repositories\" when its title is empty") unless page.match?(/projects-repos[^\n]*aria-label=[^\n]*Repositories/)
502
+ page.scan(/renderSectionBanner\(\{([^}]*)\}/m).each do |(args)|
503
+ if args.match?(/\b(kicker|title)\s*:\s*['"]/)
504
+ fail!("projects.html passes a literal kicker or title to renderSectionBanner: #{args.strip.gsub(/\s+/, ' ')}")
505
+ end
506
+ end
507
+ end
508
+
509
+ # 50.6 CV labels -----------------------------------------------------------------
510
+ item(6) do
511
+ box = strip_liquid_comments(read("_includes/cv/cv-box.html"))
512
+ unless box.match?(/<h2 class="section-title cv-register-title"[^>]*\{%-?\s*if include\.text_key\s*-?%\}\s*data-content-header-text="\{\{ include\.text_key \}\}"/)
513
+ fail!("cv-box.html must put include.text_key on h2.cv-register-title as data-content-header-text")
514
+ end
515
+ fail!("cv-box.html must render the h2 only when include.title is not empty") unless box.match?(/\{%-?\s*if include\.title != ""\s*-?%\}\s*(<div class="cv-register-head" data-content-header-wrap>\s*)?<h2 class="section-title cv-register-title"/)
516
+ # An empty title drops the decorative rule with the heading, at build time
517
+ # and when the runtime removes the heading (they share one header wrap).
518
+ head = box[/\{%-?\s*if include\.title != ""\s*-?%\}(.*?)^\s*\{%-?\s*endif\s*-?%\}/m, 1].to_s
519
+ unless head.match?(/\A\s*<div class="cv-register-head" data-content-header-wrap>\s*<h2 class="section-title cv-register-title".*?<\/h2>\s*<span class="cv-register-rule" aria-hidden="true"><\/span>\s*<\/div>\s*\z/m)
520
+ fail!("cv-box.html must wrap the h2 and span.cv-register-rule in div.cv-register-head[data-content-header-wrap] rendered only when include.title is not empty")
521
+ end
522
+ fail!("cv-box.html must render span.cv-register-rule only inside the title block") unless box.scan("cv-register-rule").size == 1
523
+ section = box[/<section class="cv-register"[^>]*>/m].to_s
524
+ fail!("section.cv-register must carry data-content-header-name") unless section.include?("data-content-header-name")
525
+ fail!("section.cv-register must carry aria-label=\"{{ include.label }}\" when include.title is empty") unless section.match?(/\{%\s*if include\.title == ""\s*%\}\s*aria-label="\{\{ include\.label \}\}"/)
526
+
527
+ cv = strip_liquid_comments(read("_layouts/cv.html"))
528
+ fail!("cv.html must read the CV headers from site.data.page_headers[page.key]") unless cv.include?("site.data.page_headers[page.key]")
529
+ fail!("cv.html must not pass CV header fields through | default: (empty must hide)") if cv.match?(/_cv_head\.\w+\s*\|\s*default/)
530
+ { "summary" => "Summary", "experience" => "Experience", "products" => "Products",
531
+ "education" => "Education", "languages" => "Languages" }.each do |name, default|
532
+ fail!("cv.html must default _#{name}_title to \"#{default}\"") unless cv.match?(/assign _#{name}_title = "#{default}"/)
533
+ fail!("cv.html must take #{name}_title (stripped) only when it is not nil") unless liquid_nil_override?(cv, "_#{name}_title", "_cv_head.#{name}_title")
534
+ include_tag = cv[/\{%\s*include cv\/cv-box\.html id="#{name}"[^%]*%\}/]
535
+ if include_tag.nil?
536
+ fail!("cv.html lost the #{name} cv-box include")
537
+ else
538
+ fail!("cv-box #{name} must pass a text_key ending in .#{name}_title") unless include_tag.match?(/text_key=\S*#{name}_title/)
539
+ fail!("cv-box #{name} must not pass the literal title \"#{default}\"") if include_tag.include?("title=\"#{default}\"")
540
+ fail!("cv-box #{name} must pass label=\"#{default}\"") unless include_tag.include?("label=\"#{default}\"")
541
+ end
542
+ end
543
+ fail!("cv.html Contact label must carry data-content-header-text=\"{{ page.key }}.contact_label\"") unless cv.match?(/data-content-header-text="\{\{ page\.key \}\}\.contact_label"[^>]*>[^<]*(Contact|contact_label)/m)
544
+ fail!("cv.html must default _contact_label to \"Contact\" and take contact_label only when not nil") unless cv.match?(/assign _contact_label = "Contact"/) && liquid_nil_override?(cv, "_contact_label", "_cv_head.contact_label")
545
+ fail!("cv.html must render the Contact label only when it is not empty") unless cv.match?(/\{%-?\s*if _contact_label != ""\s*-?%\}\s*<span class="cv-contact-cta-label"/)
546
+
547
+ timeline = strip_liquid_comments(read("_includes/page/timeline-section.html"))
548
+ unless timeline.match?(/<h2 class="section-title"[^>]*data-content-header-text="\{\{ page\.key \}\}\.timeline_title"[^>]*>[^<]*(Timeline|timeline_title)/m)
549
+ fail!("timeline-section.html heading must carry data-content-header-text=\"{{ page.key }}.timeline_title\" with Timeline as default")
550
+ end
551
+ fail!("timeline-section.html must not pass timeline_title through | default:") if timeline.match?(/timeline_title\s*\|\s*default/)
552
+ fail!("timeline-section.html must default _timeline_title to \"Timeline\" and take timeline_title only when not nil") unless timeline.match?(/assign _timeline_title = "Timeline"/) && timeline.match?(/\{%-?\s*if [\w.\[\]]+\.timeline_title != nil\s*-?%\}\s*\{%-?\s*assign _timeline_title = [\w.\[\]]+\.timeline_title \| strip\s*-?%\}/)
553
+ fail!("timeline-section.html must render .cv-band-head (data-content-header-wrap) only when the title is not empty") unless timeline.match?(/\{%-?\s*if _timeline_title != ""\s*-?%\}\s*<div class="cv-band-head" data-content-header-wrap>/)
554
+ band = timeline[/<section class="cv-band" id="timeline-section"[^>]*>/m].to_s
555
+ fail!("section#timeline-section must carry data-content-header-name and aria-label=\"Timeline\" when the title is empty") unless band.include?("data-content-header-name") && band.match?(/\{%\s*if _timeline_title == ""\s*%\}\s*aria-label="Timeline"/)
556
+ end
557
+
558
+ # 50.7 Docs and guards -----------------------------------------------------------
559
+ item(7) do
560
+ doc = read("docs/pages-and-templates.md")
561
+ %w[cta note others_kicker others_title repositories_kicker repositories_title
562
+ summary_title experience_title products_title education_title languages_title
563
+ timeline_title contact_label].each do |field|
564
+ fail!("docs/pages-and-templates.md does not document headers.yml field `#{field}`") unless doc.include?("`#{field}`")
565
+ end
566
+ fail!("docs/pages-and-templates.md must name content/pages/headers.yml") unless doc.include?("headers.yml")
567
+ fail!("docs/pages-and-templates.md must document `keep_empty`") unless doc.include?("`keep_empty`")
568
+ fail!("docs/pages-and-templates.md must state the absent and empty rule") unless doc.match?(/absent/i) && doc.match?(/empty/i)
569
+
570
+ %w[_includes/home/band-listing.html _includes/home/band-index.html _layouts/projects.html
571
+ _layouts/cv.html _includes/cv/cv-box.html _includes/page/timeline-section.html].each do |rel|
572
+ fail!("#{rel} carries an owner-specific string") if read(rel).match?(/Alexandre|Heinen|Loeblein/i)
573
+ end
574
+ home_css = read("assets/css/ledger/home.css").gsub(%r{/\*.*?\*/}m, "")
575
+ fail!("assets/css/ledger/home.css uses !important") if home_css.include?("!important")
576
+ end
577
+
578
+ # 50.8 Studio keeps an emptied field ---------------------------------------------
579
+ item(8) do
580
+ yaml_doc = strip_js_comments(read("studio-app/src/lib/yaml-doc.js"))
581
+ prune = js_function(yaml_doc, "pruneEmptyFields")
582
+ if prune.nil?
583
+ fail!("studio-app/src/lib/yaml-doc.js lost pruneEmptyFields")
584
+ else
585
+ fail!("pruneEmptyFields must read keep_empty") unless prune.include?("keep_empty")
586
+ ok, out = run_node(<<~JS)
587
+ #{prune}
588
+ var fields = [
589
+ { name: 'cta', type: 'string', keep_empty: true },
590
+ { name: 'others_title', type: 'string', keep_empty: true },
591
+ { name: 'gone', type: 'string', keep_empty: true },
592
+ { name: 'title', type: 'string' },
593
+ { name: 'tags', type: 'string', list: true }
594
+ ];
595
+ var out = pruneEmptyFields({ cta: '', others_title: ' ', gone: null, title: '', tags: [], extra: '' }, fields);
596
+ var fails = [];
597
+ if (!Object.prototype.hasOwnProperty.call(out, 'cta') || out.cta !== '') fails.push('keep_empty "" must be kept as ""');
598
+ if (out.others_title !== ' ') fails.push('keep_empty whitespace must be kept');
599
+ if (Object.prototype.hasOwnProperty.call(out, 'gone')) fails.push('keep_empty null must be dropped');
600
+ if (Object.prototype.hasOwnProperty.call(out, 'title')) fails.push('a field without keep_empty must still drop ""');
601
+ if (Object.prototype.hasOwnProperty.call(out, 'tags')) fails.push('an empty list must still be dropped');
602
+ if (out.extra !== '') fails.push('unknown keys must be kept');
603
+ var kept = pruneEmptyFields({ cta: 'Ver todos', title: 'T' }, fields);
604
+ if (kept.cta !== 'Ver todos' || kept.title !== 'T') fails.push('values must be kept');
605
+ if (fails.length) { console.log(fails.join(' | ')); process.exit(1); }
606
+ JS
607
+ fail!("pruneEmptyFields: #{out}") if ok == false
608
+ end
609
+
610
+ html = read("studio/index.html")
611
+ bundle = html[%r{/studio/assets/(index-[A-Za-z0-9_-]+\.js)}, 1]
612
+ if bundle.nil? || !ROOT.join("studio/assets", bundle).file?
613
+ fail!("studio/index.html does not load a committed bundle")
614
+ elsif !ROOT.join("studio/assets", bundle).read.include?("keep_empty")
615
+ fail!("the committed Studio bundle #{bundle} predates keep_empty; rebuild studio-app")
616
+ end
617
+ end
618
+
619
+ if $failures.empty?
620
+ puts "PLT-AC-50 studio section texts: OK"
621
+ else
622
+ puts $failures
623
+ warn "#{$failures.size} PLT-AC-50 check(s) failed"
624
+ exit 1
625
+ end