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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/DESIGN.md +15 -30
- data/_includes/content-runtime/43-blocks.html +0 -3
- data/_includes/content-runtime/60-fragments.html +83 -149
- data/_includes/content-runtime/90-bootstrap.html +3 -0
- data/_includes/cv/cv-box.html +10 -4
- data/_includes/home/band-index.html +22 -7
- data/_includes/home/band-listing.html +7 -3
- data/_includes/page/timeline-section.html +8 -3
- data/_includes/theme/theme-vars-block.liquid +3 -0
- data/_layouts/articles.html +0 -2
- data/_layouts/bibliography.html +0 -2
- data/_layouts/blog.html +0 -5
- data/_layouts/cv.html +27 -8
- data/_layouts/home.html +2 -13
- data/_layouts/projects.html +17 -9
- data/_layouts/resources.html +0 -9
- data/_layouts/shell.html +1 -7
- data/assets/css/article.css +10 -2
- data/assets/css/base.css +68 -44
- data/assets/css/ledger/bibliography.css +0 -31
- data/assets/css/ledger/chrome.css +51 -2
- data/assets/css/ledger/cv.css +0 -40
- data/assets/css/ledger/detail.css +20 -6
- data/assets/css/ledger/home.css +261 -402
- data/assets/css/ledger/projects.css +17 -19
- data/assets/css/ledger/rascunhos.css +0 -27
- data/assets/css/ledger/sources.css +0 -57
- data/assets/css/ledger/writing.css +0 -62
- data/lib/pandorga/commands/new.rb +2 -2
- data/lib/pandorga/commands/thumbnails.rb +3 -2
- data/lib/pandorga/registry/schema.yml +0 -3
- data/lib/pandorga/thumbnail_variants.rb +29 -3
- data/lib/pandorga/version.rb +1 -1
- data/scripts/check_home_lists.mjs +33 -5
- data/scripts/content/hero_portraits.rb +59 -11
- data/scripts/test/test-css-structure.rb +0 -1
- data/scripts/test/test-hero-16x9.rb +379 -0
- data/scripts/test/test-hero-portraits.mjs +10 -20
- data/scripts/test/test-hero-portraits.rb +31 -25
- data/scripts/test/test-highlighter-pills.rb +12 -2
- data/scripts/test/test-home-lists.rb +5 -0
- data/scripts/test/test-new.rb +6 -6
- data/scripts/test/test-shell-a11y.rb +2 -1
- data/scripts/test/test-studio-section-texts.rb +625 -0
- data/scripts/test/test-theme-contrast.rb +13 -2
- data/scripts/test/test-v3-layout.rb +559 -0
- data/scripts/validate.sh +3 -0
- data/studio/assets/{index-BJyN-kB3.js → index-B9h1WUYY.js} +1 -1
- data/studio/index.html +1 -1
- data/studio/schema.yml +126 -2
- data/studio-app/src/lib/yaml-doc.js +6 -0
- data/templates/network/studio.yml +10 -0
- 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
|