jekyll-theme-zer0 1.2.1 → 1.4.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.
@@ -0,0 +1,358 @@
1
+ /*
2
+ * obsidian-wiki-links.js
3
+ *
4
+ * Client-side fallback that resolves Obsidian-style wiki-links / embeds /
5
+ * inline tags on the rendered page. Used when the Jekyll plugin
6
+ * (_plugins/obsidian_links.rb) is NOT available — most importantly the
7
+ * default GitHub Pages remote_theme build, which only allows whitelisted
8
+ * Jekyll plugins.
9
+ *
10
+ * The plugin (when it runs) emits ready HTML and this script becomes a
11
+ * no-op for already-rewritten content. When the plugin is absent, the raw
12
+ * `[[Page]]` / `![[image.png]]` syntax survives kramdown and we rewrite it
13
+ * here in the DOM using assets/data/wiki-index.json.
14
+ *
15
+ * Lookup keys are normalized identically to the Ruby plugin:
16
+ * value.toLowerCase().trim().replace(/\s+/g, ' ')
17
+ *
18
+ * Loaded from _includes/components/js-cdn.html (deferred). Skips when
19
+ * `window.__OBSIDIAN_DISABLE_CLIENT__` is true (set this in dev to debug
20
+ * the server-side plugin output).
21
+ */
22
+ (function () {
23
+ 'use strict';
24
+
25
+ if (window.__OBSIDIAN_DISABLE_CLIENT__) {
26
+ return;
27
+ }
28
+
29
+ var RESOLVER_SCRIPT_PATH = 'assets/js/obsidian-wiki-links.js';
30
+ var OBSIDIAN_CONFIG = window.OBSIDIAN_CONFIG || {};
31
+
32
+ function trimTrailingSlash(value) {
33
+ return (value === null || value === undefined ? '' : String(value)).replace(/\/$/, '');
34
+ }
35
+
36
+ function assetPath(path) {
37
+ var script = document.currentScript || document.querySelector('script[src*="obsidian-wiki-links.js"]');
38
+ var src = script && script.getAttribute('src');
39
+ var escapedScriptPath = RESOLVER_SCRIPT_PATH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
+ var match = src && src.match(new RegExp('^(.*?)' + escapedScriptPath + '(?:[?#].*)?$'));
41
+ if (match) return trimTrailingSlash(match[1]) + path;
42
+
43
+ var baseHref = (document.querySelector('base') || {}).href;
44
+ return trimTrailingSlash(baseHref) + path;
45
+ }
46
+
47
+ var CONFIG = {
48
+ indexUrl: OBSIDIAN_CONFIG.wikiIndexUrl || window.OBSIDIAN_WIKI_INDEX_URL || assetPath('/assets/data/wiki-index.json'),
49
+ attachmentsPath: OBSIDIAN_CONFIG.attachmentsPath || window.OBSIDIAN_ATTACHMENTS_PATH || assetPath('/assets/images/notes'),
50
+ tagBase: OBSIDIAN_CONFIG.tagBase || window.OBSIDIAN_TAG_BASE || assetPath('/tags/'),
51
+ wikiLinkClass: 'wiki-link',
52
+ brokenLinkClass: 'wiki-link wiki-link-broken'
53
+ };
54
+
55
+ var IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.bmp'];
56
+
57
+ // Patterns kept conservative: we only mutate text nodes inside the main
58
+ // content container so navigation chrome / code samples stay untouched.
59
+ var EMBED_RE = /!\[\[([^\]\n|]+?)(?:\|([^\]\n]+))?\]\]/g;
60
+ var LINK_RE = /\[\[([^\]\n|]+?)(?:\|([^\]\n]+))?\]\]/g;
61
+ var TAG_RE = /(^|[^\w/#&])#([A-Za-z][\w/-]{0,63})/g;
62
+
63
+ function normalize(value) {
64
+ return String(value || '').toLowerCase().trim().replace(/\s+/g, ' ');
65
+ }
66
+
67
+ function escapeHtml(value) {
68
+ return String(value == null ? '' : value)
69
+ .replace(/&/g, '&')
70
+ .replace(/</g, '&lt;')
71
+ .replace(/>/g, '&gt;')
72
+ .replace(/"/g, '&quot;')
73
+ .replace(/'/g, '&#39;');
74
+ }
75
+
76
+ function splitAnchor(target) {
77
+ var blockMatch = target.match(/^(.+?)\^([\w-]+)$/);
78
+ if (blockMatch) return { page: blockMatch[1].trim(), anchor: blockMatch[2].trim() };
79
+ var headingMatch = target.match(/^(.+?)#(.+)$/);
80
+ if (headingMatch) return { page: headingMatch[1].trim(), anchor: headingMatch[2].trim() };
81
+ return { page: target.trim(), anchor: null };
82
+ }
83
+
84
+ function buildIndex(payload) {
85
+ var byKey = Object.create(null);
86
+ if (!payload || !Array.isArray(payload.entries)) return byKey;
87
+
88
+ payload.entries.forEach(function (entry) {
89
+ if (!entry || !entry.url) return;
90
+ var keys = [];
91
+ if (entry.title) keys.push(entry.title);
92
+ if (entry.basename) keys.push(entry.basename);
93
+ (entry.aliases || []).forEach(function (a) { if (a) keys.push(a); });
94
+ keys.forEach(function (k) {
95
+ var nk = normalize(k);
96
+ if (!nk || byKey[nk]) return; // first wins, mirrors plugin behaviour
97
+ byKey[nk] = entry;
98
+ });
99
+ });
100
+ return byKey;
101
+ }
102
+
103
+ function renderImageEmbed(target, modifier) {
104
+ var widthAttr = '';
105
+ var alt = target;
106
+ if (modifier) {
107
+ if (/^\d+$/.test(modifier)) widthAttr = ' width="' + modifier + '"';
108
+ else alt = modifier;
109
+ }
110
+ var src = target.charAt(0) === '/' ? target : (CONFIG.attachmentsPath.replace(/\/$/, '') + '/' + target);
111
+ return '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(alt) +
112
+ '" loading="lazy" class="obsidian-embed obsidian-embed-image"' + widthAttr + ' />';
113
+ }
114
+
115
+ function renderNoteEmbed(target, byKey) {
116
+ var parts = splitAnchor(target);
117
+ var info = byKey[normalize(parts.page)];
118
+ if (!info) {
119
+ return '<div class="obsidian-embed obsidian-embed-broken alert alert-warning" role="alert">' +
120
+ 'Embed not found: <code>' + escapeHtml(target) + '</code></div>';
121
+ }
122
+ var url = info.url + (parts.anchor ? '#' + parts.anchor.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-') : '');
123
+ return '<div class="obsidian-embed obsidian-embed-note">' +
124
+ '<div class="obsidian-embed-header"><a href="' + escapeHtml(url) + '">' +
125
+ escapeHtml(info.title || parts.page) + '</a></div>' +
126
+ '<div class="obsidian-embed-excerpt">' + escapeHtml(info.excerpt || '') + '</div>' +
127
+ '</div>';
128
+ }
129
+
130
+ function renderWikiLink(target, aliasText, byKey, currentUrl) {
131
+ var parts = splitAnchor(target);
132
+ var display = aliasText || (parts.anchor ? parts.page + ' \u203A ' + parts.anchor : parts.page);
133
+ var info = byKey[normalize(parts.page)];
134
+ if (!info) {
135
+ return '<a href="#" class="' + CONFIG.brokenLinkClass +
136
+ '" data-wiki-target="' + escapeHtml(parts.page) +
137
+ '" title="Unresolved wiki-link: ' + escapeHtml(parts.page) + '">' +
138
+ escapeHtml(display) + '</a>';
139
+ }
140
+ var url = info.url + (parts.anchor ? '#' + parts.anchor.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-') : '');
141
+ var currentAttr = currentUrl && info.url === currentUrl ? ' aria-current="page"' : '';
142
+ return '<a href="' + escapeHtml(url) + '" class="' + CONFIG.wikiLinkClass +
143
+ '" data-wiki-target="' + escapeHtml(parts.page) + '"' + currentAttr + '>' +
144
+ escapeHtml(display) + '</a>';
145
+ }
146
+
147
+ function rewriteHtml(html, byKey, currentUrl) {
148
+ // Embeds first (longer match `![[ … ]]` would otherwise be eaten by [[ … ]]).
149
+ html = html.replace(EMBED_RE, function (_match, target, modifier) {
150
+ target = target.trim();
151
+ modifier = (modifier || '').trim();
152
+ var ext = (target.match(/\.[A-Za-z0-9]+$/) || [''])[0].toLowerCase();
153
+ if (IMAGE_EXTENSIONS.indexOf(ext) !== -1) return renderImageEmbed(target, modifier);
154
+ return renderNoteEmbed(target, byKey);
155
+ });
156
+ html = html.replace(LINK_RE, function (_match, target, alias) {
157
+ return renderWikiLink(target.trim(), (alias || '').trim(), byKey, currentUrl);
158
+ });
159
+ html = html.replace(TAG_RE, function (_match, lead, tag) {
160
+ var url = (CONFIG.tagBase.replace(/\/$/, '') + '/#' + tag.toLowerCase().replace(/\//g, '-'));
161
+ return lead + '<a href="' + escapeHtml(url) + '" class="obsidian-tag">#' + escapeHtml(tag) + '</a>';
162
+ });
163
+ return html;
164
+ }
165
+
166
+ function eligibleNode(node) {
167
+ // Only walk text nodes whose parent isn't code / pre / a / script / style
168
+ // and that contain at least one of our markers.
169
+ if (node.nodeType !== Node.TEXT_NODE) return false;
170
+ var parent = node.parentNode;
171
+ while (parent && parent !== document.body) {
172
+ var tag = parent.nodeName;
173
+ if (tag === 'CODE' || tag === 'PRE' || tag === 'A' || tag === 'SCRIPT' || tag === 'STYLE') return false;
174
+ if (parent.classList && (parent.classList.contains('mermaid') || parent.classList.contains('obsidian-embed'))) return false;
175
+ parent = parent.parentNode;
176
+ }
177
+ var text = node.nodeValue;
178
+ return text && (text.indexOf('[[') !== -1 || text.indexOf('![[') !== -1 || /(^|[^\w/#&])#[A-Za-z]/.test(text));
179
+ }
180
+
181
+ function rewriteContainer(container, byKey, currentUrl) {
182
+ if (!container) return 0;
183
+ var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
184
+ var batch = [];
185
+ var node;
186
+ while ((node = walker.nextNode())) {
187
+ if (eligibleNode(node)) batch.push(node);
188
+ }
189
+ var rewrites = 0;
190
+ batch.forEach(function (textNode) {
191
+ var original = textNode.nodeValue;
192
+ var rewritten = rewriteHtml(original, byKey, currentUrl);
193
+ if (rewritten === original) return;
194
+
195
+ // The rewriter only inserts HTML for matched markers; everything else
196
+ // is the raw text-node content. Wrap the bits between matches in
197
+ // escaped fragments so injected `<` / `&` from the source stays inert.
198
+ var safe = '';
199
+ var lastIndex = 0;
200
+ var combined = /(!\[\[[^\]\n|]+(?:\|[^\]\n]+)?\]\])|(\[\[[^\]\n|]+(?:\|[^\]\n]+)?\]\])|((?:^|[^\w/#&])#[A-Za-z][\w/-]{0,63})/g;
201
+ var match;
202
+ while ((match = combined.exec(original)) !== null) {
203
+ safe += escapeHtml(original.slice(lastIndex, match.index));
204
+ safe += rewriteHtml(match[0], byKey, currentUrl);
205
+ lastIndex = combined.lastIndex;
206
+ }
207
+ safe += escapeHtml(original.slice(lastIndex));
208
+
209
+ var template = document.createElement('template');
210
+ template.innerHTML = safe;
211
+ textNode.parentNode.replaceChild(template.content, textNode);
212
+ rewrites += 1;
213
+ });
214
+ return rewrites;
215
+ }
216
+
217
+ function getCurrentUrl() {
218
+ return window.location.pathname;
219
+ }
220
+
221
+ // ---- Callouts (DOM-level, post-kramdown) -----------------------------
222
+ // Kramdown turns `> [!type] Title\n> body` into:
223
+ // <blockquote><p>[!type] Title\nbody</p></blockquote>
224
+ // We detect that pattern and rewrite the blockquote into a Bootstrap alert.
225
+ var CALLOUT_TYPES = {
226
+ note: { alert: 'primary', icon: 'bi-pencil-square' },
227
+ abstract: { alert: 'secondary', icon: 'bi-card-text' },
228
+ summary: { alert: 'secondary', icon: 'bi-card-text' },
229
+ tldr: { alert: 'secondary', icon: 'bi-card-text' },
230
+ info: { alert: 'info', icon: 'bi-info-circle' },
231
+ todo: { alert: 'info', icon: 'bi-check2-square' },
232
+ tip: { alert: 'success', icon: 'bi-lightbulb' },
233
+ hint: { alert: 'success', icon: 'bi-lightbulb' },
234
+ important: { alert: 'warning', icon: 'bi-exclamation-circle' },
235
+ success: { alert: 'success', icon: 'bi-check-circle' },
236
+ check: { alert: 'success', icon: 'bi-check-circle' },
237
+ done: { alert: 'success', icon: 'bi-check-circle' },
238
+ question: { alert: 'info', icon: 'bi-question-circle' },
239
+ help: { alert: 'info', icon: 'bi-question-circle' },
240
+ faq: { alert: 'info', icon: 'bi-question-circle' },
241
+ warning: { alert: 'warning', icon: 'bi-exclamation-triangle' },
242
+ caution: { alert: 'warning', icon: 'bi-exclamation-triangle' },
243
+ attention: { alert: 'warning', icon: 'bi-exclamation-triangle' },
244
+ failure: { alert: 'danger', icon: 'bi-x-octagon' },
245
+ fail: { alert: 'danger', icon: 'bi-x-octagon' },
246
+ missing: { alert: 'danger', icon: 'bi-x-octagon' },
247
+ danger: { alert: 'danger', icon: 'bi-shield-exclamation' },
248
+ error: { alert: 'danger', icon: 'bi-shield-exclamation' },
249
+ bug: { alert: 'danger', icon: 'bi-bug' },
250
+ example: { alert: 'secondary', icon: 'bi-code-slash' },
251
+ quote: { alert: 'secondary', icon: 'bi-chat-quote' },
252
+ cite: { alert: 'secondary', icon: 'bi-chat-quote' }
253
+ };
254
+
255
+ // Match the first line of the first <p> inside a blockquote, e.g.
256
+ // `[!warning]+ Foldable warning`.
257
+ var CALLOUT_HEAD_RE = /^\s*\[!([A-Za-z]+)\]([+-]?)\s*([^\n]*)/;
258
+
259
+ function rewriteCallouts(container) {
260
+ if (!container) return 0;
261
+ var quotes = container.querySelectorAll('blockquote');
262
+ var count = 0;
263
+ quotes.forEach(function (bq) {
264
+ if (bq.dataset.obsidianCallout) return; // already processed
265
+ var firstChild = bq.firstElementChild;
266
+ // Walk past whitespace text nodes
267
+ while (firstChild && firstChild.nodeName !== 'P' && firstChild.nodeType !== 1) {
268
+ firstChild = firstChild.nextElementSibling;
269
+ }
270
+ if (!firstChild || firstChild.nodeName !== 'P') return;
271
+
272
+ var rawText = firstChild.textContent || '';
273
+ var m = rawText.match(CALLOUT_HEAD_RE);
274
+ if (!m) return;
275
+
276
+ var type = m[1].toLowerCase();
277
+ var spec = CALLOUT_TYPES[type] || CALLOUT_TYPES.note;
278
+ var fold = m[2];
279
+ var titleText = (m[3] || '').trim() || (type.charAt(0).toUpperCase() + type.slice(1));
280
+
281
+ // Strip the "[!type]…" head from the first paragraph (keep any trailing text)
282
+ var headLength = m[0].length;
283
+ var trailing = rawText.slice(headLength).replace(/^\s*\n?/, '');
284
+ if (trailing) {
285
+ firstChild.textContent = trailing;
286
+ } else {
287
+ firstChild.remove();
288
+ }
289
+
290
+ var wrapper = document.createElement('div');
291
+ wrapper.className = 'alert alert-' + spec.alert + ' obsidian-callout obsidian-callout-' + type;
292
+ wrapper.setAttribute('role', 'alert');
293
+ wrapper.dataset.obsidianCallout = type;
294
+ if (fold === '-') wrapper.dataset.collapsed = 'true';
295
+
296
+ var titleEl = document.createElement('div');
297
+ titleEl.className = 'obsidian-callout-title';
298
+ titleEl.innerHTML = '<i class="bi ' + spec.icon + ' me-2" aria-hidden="true"></i>' + escapeHtml(titleText);
299
+ wrapper.appendChild(titleEl);
300
+
301
+ var bodyEl = document.createElement('div');
302
+ bodyEl.className = 'obsidian-callout-body';
303
+ // Move blockquote children into the body, preserving inner HTML
304
+ while (bq.firstChild) {
305
+ bodyEl.appendChild(bq.firstChild);
306
+ }
307
+ wrapper.appendChild(bodyEl);
308
+
309
+ bq.parentNode.replaceChild(wrapper, bq);
310
+ count += 1;
311
+ });
312
+ return count;
313
+ }
314
+
315
+ function init() {
316
+ var container = document.querySelector('#main-content, .bd-content, main, article') || document.body;
317
+ if (!container) return;
318
+
319
+ fetch(CONFIG.indexUrl, { credentials: 'same-origin', cache: 'force-cache' })
320
+ .then(function (r) { return r.ok ? r.json() : null; })
321
+ .then(function (payload) {
322
+ if (!payload) return;
323
+ var byKey = buildIndex(payload);
324
+ window.__OBSIDIAN_INDEX__ = byKey;
325
+ var rewrites = rewriteContainer(container, byKey, getCurrentUrl());
326
+ var calloutCount = rewriteCallouts(container);
327
+ if ((rewrites > 0 || calloutCount > 0) && window.console && console.debug) {
328
+ console.debug('[obsidian] rewrote', rewrites, 'text nodes,', calloutCount, 'callouts');
329
+ }
330
+ document.dispatchEvent(new CustomEvent('obsidian:ready', { detail: { count: payload.count || 0, calloutCount: calloutCount } }));
331
+ })
332
+ .catch(function (err) {
333
+ // Even if the index fails, we can still convert callouts (no index needed).
334
+ try {
335
+ var calloutCount = rewriteCallouts(container);
336
+ if (calloutCount > 0 && window.console && console.debug) {
337
+ console.debug('[obsidian] rewrote', calloutCount, 'callouts (index unavailable)');
338
+ }
339
+ } catch (e) { /* swallow */ }
340
+ if (window.console && console.warn) console.warn('[obsidian] wiki-index fetch failed:', err);
341
+ });
342
+ }
343
+
344
+ // Expose for testing / programmatic use
345
+ window.ObsidianResolver = {
346
+ rewriteHtml: rewriteHtml,
347
+ rewriteCallouts: rewriteCallouts,
348
+ rewriteContainer: rewriteContainer,
349
+ buildIndex: buildIndex,
350
+ normalize: normalize
351
+ };
352
+
353
+ if (document.readyState === 'loading') {
354
+ document.addEventListener('DOMContentLoaded', init);
355
+ } else {
356
+ init();
357
+ }
358
+ })();
data/scripts/README.md CHANGED
@@ -8,6 +8,7 @@ This directory contains automation scripts for managing the `jekyll-theme-zer0`
8
8
  scripts/
9
9
  ├── bin/ # Entry point commands (use these!)
10
10
  │ ├── build # Build gem without releasing
11
+ │ ├── validate # Preflight checks for local/CI validation
11
12
  │ ├── release # Full release workflow
12
13
  │ └── test # Run all test suites
13
14
  ├── lib/ # Shared libraries (sourced, not executed)
@@ -39,6 +40,10 @@ scripts/
39
40
  # Build gem
40
41
  ./scripts/bin/build
41
42
 
43
+ # Preflight validation
44
+ ./scripts/bin/validate --quick
45
+ ./scripts/bin/validate --start-docker
46
+
42
47
  # Full release workflow
43
48
  ./scripts/bin/release patch # or minor/major
44
49
 
@@ -57,6 +62,23 @@ Build the gem without the full release workflow.
57
62
  ./scripts/bin/build [--dry-run] [--verbose]
58
63
  ```
59
64
 
65
+ #### `bin/validate`
66
+ Run preflight validation before refactors, pull requests, and releases. The
67
+ quick path validates repository files, version consistency, YAML parsing, active
68
+ configuration contracts, config-file classification, and navigation data before
69
+ the Docker/local build stages run.
70
+
71
+ ```bash
72
+ ./scripts/bin/validate [options]
73
+
74
+ Options:
75
+ --quick Host-only checks for CI fast feedback
76
+ --full Include tests, Obsidian tests, and HTMLProofer
77
+ --start-docker Start the jekyll Docker Compose service if needed
78
+ --docker Require Docker Compose for Jekyll commands
79
+ --local Require local bundle exec for Jekyll commands
80
+ ```
81
+
60
82
  #### `bin/release`
61
83
  Full release workflow with changelog, version bump, and publishing.
62
84
 
data/scripts/bin/test CHANGED
@@ -12,6 +12,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
12
  SCRIPTS_ROOT="$SCRIPT_DIR/.."
13
13
  LIB_DIR="$SCRIPTS_ROOT/lib"
14
14
  TEST_DIR="$SCRIPTS_ROOT/test"
15
+ REPO_ROOT="$(cd "$SCRIPTS_ROOT/.." && pwd)"
16
+ THEME_TEST_RUNNER="$REPO_ROOT/test/test_runner.sh"
15
17
 
16
18
  # Source common library for logging
17
19
  source "$LIB_DIR/common.sh"
@@ -37,6 +39,10 @@ TEST_SUITES:
37
39
  integration Run integration tests only
38
40
  install Run installer e2e suites only (test/test_install_*.sh)
39
41
 
42
+ THEME SUITES:
43
+ core, deployment, quality, installation, site_generation, styling,
44
+ visual, full, or --suites <list> are forwarded to test/test_runner.sh.
45
+
40
46
  OPTIONS:
41
47
  --verbose, -v Show detailed test output
42
48
  --dry-run Preview what tests would run
@@ -54,6 +60,62 @@ TEST LOCATIONS:
54
60
  EOF
55
61
  }
56
62
 
63
+ # Pass the repository-level theme runner's interface through this canonical
64
+ # command so CI, docs, and local users can converge on scripts/bin/test without
65
+ # breaking existing --suites calls.
66
+ maybe_delegate_to_theme_runner() {
67
+ [[ -f "$THEME_TEST_RUNNER" ]] || return 0
68
+
69
+ local should_delegate=false
70
+ local positional_suites=""
71
+ local delegated_args=()
72
+
73
+ while [[ $# -gt 0 ]]; do
74
+ case "$1" in
75
+ --suites|-s)
76
+ should_delegate=true
77
+ delegated_args+=("$1")
78
+ shift
79
+ [[ $# -gt 0 ]] || error "--suites requires a value"
80
+ delegated_args+=("$1")
81
+ ;;
82
+ core|deployment|quality|installation|site_generation|styling|visual|full)
83
+ should_delegate=true
84
+ if [[ -n "$positional_suites" ]]; then
85
+ positional_suites="${positional_suites},$1"
86
+ else
87
+ positional_suites="$1"
88
+ fi
89
+ ;;
90
+ --skip-docker|--skip-remote|--coverage|-c|--parallel|-p|--retry-failed|-r|--fail-fast|--baseline-compare)
91
+ should_delegate=true
92
+ delegated_args+=("$1")
93
+ ;;
94
+ --format|-f|--timeout|-t|--environment|-e)
95
+ should_delegate=true
96
+ local option_name="$1"
97
+ delegated_args+=("$1")
98
+ shift
99
+ [[ $# -gt 0 ]] || error "$option_name requires a value"
100
+ delegated_args+=("$1")
101
+ ;;
102
+ --verbose|-v)
103
+ delegated_args+=("$1")
104
+ ;;
105
+ esac
106
+ shift
107
+ done
108
+
109
+ if [[ "$should_delegate" == "true" ]]; then
110
+ if [[ -n "$positional_suites" ]]; then
111
+ delegated_args+=("--suites" "$positional_suites")
112
+ fi
113
+ exec "$THEME_TEST_RUNNER" "${delegated_args[@]}"
114
+ fi
115
+ }
116
+
117
+ maybe_delegate_to_theme_runner "$@"
118
+
57
119
  # Parse arguments
58
120
  TEST_SUITE="all"
59
121
  for arg in "$@"; do