jekyll-theme-zer0 1.29.0 → 1.30.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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +715 -2
  3. data/_data/README.md +2 -0
  4. data/_data/ai.yml +5 -3
  5. data/_data/ai_pricing.yml +36 -0
  6. data/_data/backlog.yml +169 -18
  7. data/_data/consumers.yml +48 -5
  8. data/_data/features.yml +146 -20
  9. data/_data/feedback_types.yml +17 -12
  10. data/_data/landing.yml +5 -2
  11. data/_data/navigation/quickstart.yml +4 -0
  12. data/_data/site_builder.yml +874 -0
  13. data/_data/theme-manifest.yml +122 -114
  14. data/_data/ui-text.yml +18 -0
  15. data/_includes/README.md +11 -1
  16. data/_includes/analytics/posthog.html +2 -2
  17. data/_includes/components/admin-links.html +2 -2
  18. data/_includes/components/admin-tabs.html +2 -2
  19. data/_includes/components/ai-chat.html +14 -11
  20. data/_includes/components/analytics-dashboard.html +8 -8
  21. data/_includes/components/author-bio.html +1 -1
  22. data/_includes/components/author-card.html +10 -2
  23. data/_includes/components/author-eeat.html +4 -4
  24. data/_includes/components/background-customizer.html +8 -8
  25. data/_includes/components/background-image.html +114 -0
  26. data/_includes/components/background-settings.html +28 -15
  27. data/_includes/components/collection-manager.html +5 -5
  28. data/_includes/components/component-showcase.html +13 -13
  29. data/_includes/components/config-editor.html +12 -12
  30. data/_includes/components/config-viewer.html +8 -8
  31. data/_includes/components/cookie-consent.html +11 -11
  32. data/_includes/components/cta-button.html +7 -2
  33. data/_includes/components/dev-shortcuts.html +7 -7
  34. data/_includes/components/env-dashboard.html +8 -8
  35. data/_includes/components/env-switcher.html +9 -9
  36. data/_includes/components/feature-card.html +2 -2
  37. data/_includes/components/halfmoon.html +2 -2
  38. data/_includes/components/info-section.html +36 -36
  39. data/_includes/components/js-cdn.html +15 -15
  40. data/_includes/components/language-toggle.html +4 -4
  41. data/_includes/components/mermaid.html +72 -435
  42. data/_includes/components/nanobar.html +5 -5
  43. data/_includes/components/nav-editor.html +2 -2
  44. data/_includes/components/nav-export.html +2 -2
  45. data/_includes/components/nav-overview.html +2 -2
  46. data/_includes/components/page-feedback.html +45 -30
  47. data/_includes/components/page-views-init.html +1 -1
  48. data/_includes/components/post-card.html +22 -22
  49. data/_includes/components/post-type-badge.html +2 -2
  50. data/_includes/components/powered-by.html +2 -2
  51. data/_includes/components/preview-image.html +6 -0
  52. data/_includes/components/quick-index.html +2 -2
  53. data/_includes/components/search-modal.html +27 -2
  54. data/_includes/components/searchbar.html +2 -2
  55. data/_includes/components/svg-background.html +2 -2
  56. data/_includes/components/theme-customizer.html +2 -2
  57. data/_includes/components/theme-info.html +6 -6
  58. data/_includes/components/theme-preview-gallery.html +22 -22
  59. data/_includes/content/giscus.html +2 -2
  60. data/_includes/content/intro.html +8 -8
  61. data/_includes/content/jsonld-faq.html +2 -2
  62. data/_includes/content/jsonld-software.html +2 -2
  63. data/_includes/content/seo.html +4 -4
  64. data/_includes/content/sitemap.html +27 -27
  65. data/_includes/content/toc.html +183 -183
  66. data/_includes/core/branding.html +6 -6
  67. data/_includes/core/console-capture.html +32 -74
  68. data/_includes/core/favicon.html +49 -7
  69. data/_includes/core/footer-fabs.html +17 -3
  70. data/_includes/core/footer.html +31 -18
  71. data/_includes/core/head.html +102 -90
  72. data/_includes/core/header.html +71 -52
  73. data/_includes/docs/bootstrap-docs.html +8 -8
  74. data/_includes/landing/landing-install-cards.html +2 -2
  75. data/_includes/landing/landing-quick-links.html +1 -1
  76. data/_includes/navigation/admin-nav.html +2 -2
  77. data/_includes/navigation/nav-tree.html +8 -8
  78. data/_includes/navigation/navbar.html +12 -12
  79. data/_includes/navigation/section-sidebar.html +16 -16
  80. data/_includes/navigation/sidebar-config.html +36 -2
  81. data/_includes/navigation/sidebar-left.html +15 -15
  82. data/_includes/navigation/sidebar-right.html +6 -6
  83. data/_includes/obsidian/full-graph.html +2 -2
  84. data/_includes/setup/claude-session.html +72 -0
  85. data/_includes/setup/prereq-checklist.html +90 -0
  86. data/_includes/setup/wizard.html +906 -291
  87. data/_includes/stats/stats-categories.html +8 -8
  88. data/_includes/stats/stats-header.html +14 -14
  89. data/_includes/stats/stats-metrics.html +12 -12
  90. data/_includes/stats/stats-no-data.html +12 -12
  91. data/_includes/stats/stats-overview.html +6 -6
  92. data/_includes/stats/stats-tags.html +8 -8
  93. data/_layouts/404.html +38 -24
  94. data/_layouts/admin.html +22 -22
  95. data/_layouts/article.html +39 -34
  96. data/_layouts/author.html +20 -20
  97. data/_layouts/authors.html +2 -2
  98. data/_layouts/book-abc.html +12 -12
  99. data/_layouts/book-story.html +15 -15
  100. data/_layouts/book.html +12 -12
  101. data/_layouts/collection.html +33 -33
  102. data/_layouts/cookbook.html +12 -12
  103. data/_layouts/default.html +27 -24
  104. data/_layouts/home.html +23 -23
  105. data/_layouts/index.html +10 -10
  106. data/_layouts/landing.html +17 -17
  107. data/_layouts/news.html +44 -44
  108. data/_layouts/note.html +38 -38
  109. data/_layouts/notebook.html +34 -34
  110. data/_layouts/recipe.html +24 -24
  111. data/_layouts/root.html +73 -55
  112. data/_layouts/section.html +23 -23
  113. data/_layouts/setup.html +3 -3
  114. data/_layouts/sitemap-collection.html +49 -49
  115. data/_layouts/stats.html +40 -40
  116. data/_layouts/tag.html +12 -12
  117. data/_layouts/welcome.html +21 -21
  118. data/_sass/components/_mermaid.scss +375 -0
  119. data/_sass/components/_setup-wizard.scss +569 -40
  120. data/_sass/core/_navbar.scss +11 -31
  121. data/_sass/layouts/_navbar-extras.scss +14 -4
  122. data/assets/css/main.scss +1 -0
  123. data/assets/js/ai-chat.js +47 -5
  124. data/assets/js/fleet-feedback-capture.js +124 -0
  125. data/assets/js/fleet-feedback.js +853 -0
  126. data/assets/js/mermaid-diagrams.js +1267 -0
  127. data/assets/js/modules/navigation/config.js +9 -6
  128. data/assets/js/modules/navigation/scroll-spy.js +315 -80
  129. data/assets/js/modules/theme/appearance.js +8 -2
  130. data/assets/js/obsidian-wiki-links.js +8 -3
  131. data/assets/js/page-feedback.js +125 -192
  132. data/assets/js/search-modal.js +26 -0
  133. data/assets/js/setup-wizard.js +2112 -361
  134. data/assets/js/site-builder.js +1834 -0
  135. data/assets/js/ui-enhancements.js +11 -3
  136. data/scripts/README.md +15 -0
  137. data/scripts/ai/README.md +38 -0
  138. data/scripts/ai/api_call.rb +124 -0
  139. data/scripts/ai/usage.rb +314 -0
  140. data/scripts/ai/usage_report.rb +225 -0
  141. data/scripts/ci/test_visual_evidence_autogen.py +341 -0
  142. data/scripts/ci/visual_evidence_autogen.py +1060 -0
  143. data/scripts/content-review.rb +20 -1
  144. data/scripts/test/integration/mermaid +22 -8
  145. data/scripts/test/lib/run_tests.sh +1 -0
  146. data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
  147. data/scripts/translate.rb +23 -1
  148. metadata +19 -2
@@ -1,36 +1,40 @@
1
- // Feature: ZER0-067
1
+ // Feature: ZER0-067, ZER0-086, ZER0-087
2
2
  /**
3
3
  * ===================================================================
4
- * Setup Wizard — _config.yml Generator
4
+ * Site Builder — form, generators, drafts (setup-wizard.js)
5
5
  * ===================================================================
6
6
  *
7
- * Reads form inputs from the wizard include, builds a YAML string,
8
- * and offers Download / Copy actions. Pure vanilla JS, no deps
9
- * beyond Bootstrap 5 (already on the page).
7
+ * File: assets/js/setup-wizard.js
8
+ * Markup: _includes/setup/wizard.html (+ setup/prereq-checklist.html)
9
+ * Styles: _sass/components/_setup-wizard.scss
10
+ * Data: #siteBuilderData — _data/site_builder.yml serialised as JSON
10
11
  *
11
- * The step container's stable height is NOT handled here. `.wizard-panes` is a
12
- * CSS grid with every pane in one cell, so the browser sizes it to the tallest
13
- * pane on every reflow. An earlier measurePanes() did this in JS — un-hiding
14
- * each pane, reading offsetHeight, restoring it — and came back ~16px short,
15
- * which put the shortest step's Back/Next 16px above everyone else's.
12
+ * This half of the Site Builder is pure client-side and works everywhere the
13
+ * theme renders, GitHub Pages included. It owns:
16
14
  *
17
- * T-040 / #408 added, in the order the pieces depend on each other:
18
- * 1. updatePreview() — now fires on every input anywhere in the wizard,
19
- * not just on entering step 5, because the preview is
20
- * a persistent panel rather than a step-5 element.
21
- * 2. validateField() — email/URL checked on blur, `is-invalid` + a message;
22
- * cleared as soon as the user types again.
23
- * 3. saveDraft() — every value mirrored to localStorage under
24
- * `zer0-setup-draft`, debounced 300ms, restored on
25
- * load, acknowledged by the "Draft saved" chip, and
26
- * cleared when the file is downloaded.
27
- * 4. refreshStepper() — the vertical stepper's done/active/locked state. A
28
- * step is locked until every EARLIER step validates;
29
- * going back is always allowed.
15
+ * 1. The stepper (done / active / locked), Back / Next, URL hash → step.
16
+ * 2. Every form control, inline validation (email / URL on blur) and the
17
+ * Review warnings for recommended-but-empty fields.
18
+ * 3. The navigation editor and the "kind of site" quick-pick.
19
+ * 4. The generated project — _config.yml, _config_dev.yml, Gemfile,
20
+ * docker-compose.yml, index.md, navigation, about page, welcome post,
21
+ * collection index pages, .gitignore, zer0.install.yml, .env.example,
22
+ * README — regenerated on every input and shown in the file preview.
23
+ * 5. Per-file copy / download and a self-extracting bash bundle.
24
+ * 6. Draft persistence: every value mirrored to localStorage under
25
+ * `zer0-setup-draft` (debounced 300ms, flushed on pagehide), restored
26
+ * on load, cleared by "Start over" or a successful project write.
27
+ * 7. A small public API on window.Zer0SetupWizard that the Claude session
28
+ * (assets/js/site-builder.js) drives: read state, patch fields, override
29
+ * a generated file, jump steps, record prerequisite check results,
30
+ * subscribe to changes. Context therefore flows one way — the form is
31
+ * the single source of truth and everything else observes it.
30
32
  *
31
- * Everything degrades: localStorage failures (private mode, quota) are
32
- * swallowed, and a missing element short-circuits rather than throwing.
33
+ * Stable-height contract (T-040 / #408): `.wizard-panes` is a CSS grid with
34
+ * every pane in one cell, so no JS measurement is needed here — see the SCSS.
33
35
  *
36
+ * Everything degrades: localStorage failures are swallowed, a missing element
37
+ * short-circuits, and the wizard never throws on a partial DOM.
34
38
  * ===================================================================
35
39
  */
36
40
 
@@ -39,246 +43,1675 @@
39
43
 
40
44
  var DRAFT_KEY = 'zer0-setup-draft';
41
45
  var DRAFT_DEBOUNCE_MS = 300;
46
+ var DRAFT_VERSION = 2;
47
+
48
+ var wizard = document.getElementById('setup-wizard');
49
+ if (!wizard) return;
50
+
51
+ function readJSON(id, fallback) {
52
+ var el = document.getElementById(id);
53
+ if (!el) return fallback;
54
+ try { return JSON.parse(el.textContent); } catch (e) { return fallback; }
55
+ }
56
+
57
+ var DATA = readJSON('siteBuilderData', {}) || {};
58
+ var DEFAULT_PORT = Number(wizard.getAttribute('data-default-port')) || 4000;
42
59
 
43
60
  // Step order is the source of truth for "earlier" / "later" in the stepper.
44
- var STEP_IDS = ['tab-identity', 'tab-urls', 'tab-collections', 'tab-analytics', 'tab-review'];
61
+ var STEP_IDS = (DATA.steps || []).map(function (s) { return 'tab-' + s.id; });
62
+ if (!STEP_IDS.length) {
63
+ STEP_IDS = ['tab-connect', 'tab-prereqs', 'tab-identity', 'tab-urls', 'tab-structure',
64
+ 'tab-appearance', 'tab-voice', 'tab-integrations', 'tab-build'];
65
+ }
45
66
 
46
67
  var draftTimer = null;
47
68
  var chipTimer = null;
48
- var restoring = false; // suppresses draft writes while we replay a draft
69
+ var restoring = false; // suppresses draft writes while we replay a draft
70
+ var listeners = {}; // event name → [callbacks]
71
+ var overrides = {}; // generated path → content set by the user / Claude
72
+ var checks = {}; // prerequisite id → {ok, output, version, manual}
73
+ var activeFile = '_config.yml';
74
+ var skinPreviewOriginal = null;
75
+ var previewStyle = null; // <style> injected while "Preview on this page" is on
76
+ var previewFontLink = null; // Google Fonts <link> injected for the preview
77
+
78
+ // ── site plan ────────────────────────────────────────────────────
79
+ // The parts of the plan that have no form control of their own: the landing
80
+ // hero/sections, a navigation override, and the planned example pages. The
81
+ // form-backed parts (template, nav style, sidebar, palette, fonts, radius)
82
+ // live in the controls with `data-key="plan.…"`; getPlan() merges the two.
83
+ var planExtra = { landing: { hero: null, sections: null }, navigation: { items: null }, pages: [] };
84
+
85
+ function catalogItem(name, id) {
86
+ return catalog(name).filter(function (x) { return x.id === id; })[0] || null;
87
+ }
49
88
 
50
- // ── helpers ────────────────────────────────────────────────────────
89
+ function planSchema() { return DATA.plan_schema || { type: 'object' }; }
51
90
 
52
91
  /**
53
- * Escape a YAML scalar string value (wrap in quotes if needed).
54
- * Multi-line input (e.g., textarea newlines) is intentionally collapsed to
55
- * a single line with spaces so the generated _config.yml remains valid YAML.
56
- * All form fields in this wizard expect single-line values.
92
+ * Validate `value` against the JSON-Schema subset used by plan_schema.
93
+ * Supports: type, properties, required, additionalProperties:false, enum,
94
+ * enumFrom (ids of a catalog in the data file), items, maxItems, maxLength,
95
+ * pattern. Returns an array of "path: message" strings (empty = valid).
57
96
  */
58
- function yamlValue(val) {
59
- if (val === '' || val === null || val === undefined) return '""';
60
-
61
- // Normalize newlines and collapse whitespace to keep scalar values single-line
62
- var normalized = String(val)
63
- .replace(/\r\n|\r|\n/g, ' ')
64
- .replace(/\s{2,}/g, ' ')
65
- .trim();
97
+ function validateSchema(value, schema, path, errors) {
98
+ errors = errors || [];
99
+ path = path || 'plan';
100
+ if (!schema) return errors;
101
+ var type = schema.type;
102
+ var actual = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
103
+ if (type === 'integer') { if (typeof value !== 'number' || value % 1 !== 0) errors.push(path + ': expected an integer'); return errors; }
104
+ if (type && actual !== type) { errors.push(path + ': expected ' + type + ', got ' + actual); return errors; }
105
+ if (schema.enum && schema.enum.indexOf(value) === -1) errors.push(path + ': must be one of ' + schema.enum.join(', '));
106
+ if (schema.enumFrom) {
107
+ var ids = catalog(schema.enumFrom).map(function (x) { return x.id; });
108
+ if (schema.enumFrom === 'palettes') ids.push('custom');
109
+ if (ids.indexOf(value) === -1) errors.push(path + ': must be one of ' + ids.join(', '));
110
+ }
111
+ if (type === 'string') {
112
+ if (schema.maxLength && value.length > schema.maxLength) errors.push(path + ': longer than ' + schema.maxLength + ' characters');
113
+ if (schema.pattern && !(new RegExp(schema.pattern)).test(value)) errors.push(path + ': does not match ' + schema.pattern);
114
+ }
115
+ if (type === 'array') {
116
+ if (schema.maxItems && value.length > schema.maxItems) errors.push(path + ': more than ' + schema.maxItems + ' items');
117
+ if (schema.items) value.forEach(function (v, i) { validateSchema(v, schema.items, path + '[' + i + ']', errors); });
118
+ }
119
+ if (type === 'object') {
120
+ var props = schema.properties || {};
121
+ (schema.required || []).forEach(function (k) { if (value[k] === undefined || value[k] === null || value[k] === '') errors.push(path + '.' + k + ': required'); });
122
+ Object.keys(value).forEach(function (k) {
123
+ if (props[k]) validateSchema(value[k], props[k], path + '.' + k, errors);
124
+ else if (schema.additionalProperties === false) errors.push(path + '.' + k + ': unknown key');
125
+ });
126
+ }
127
+ return errors;
128
+ }
66
129
 
67
- if (normalized === '') return '""';
68
- if (normalized === 'true' || normalized === 'false') return normalized;
69
- if (/^[0-9]+$/.test(normalized)) return normalized;
70
- // Wrap in quotes if it contains special chars
71
- if (/[:#{}[\],&*?|>!%@`]/.test(normalized) || normalized.includes("'") || normalized.includes('"')) {
72
- return '"' + normalized.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
130
+ /**
131
+ * Minimal YAML emitter for the nested data files the plan produces.
132
+ *
133
+ * Every branch returns COMPLETE lines already indented to `indent`. A list
134
+ * item renders its value at indent + 2 and then overwrites the two spaces in
135
+ * front of the item's first line with "- ", which is the one place block
136
+ * sequences and block mappings have to agree. An earlier version stripped
137
+ * the leading indent from the first line and left the rest at the item's own
138
+ * level, which produced a mapping whose keys sat under the dash — Psych
139
+ * rejected the file ("did not find expected '-' indicator") and every
140
+ * generated site failed to build.
141
+ */
142
+ function toYaml(value, indent) {
143
+ indent = indent || 0;
144
+ var padStr = ' '.repeat(indent);
145
+ if (Array.isArray(value)) {
146
+ if (!value.length) return padStr + '[]\n';
147
+ return value.map(function (v) {
148
+ if (v !== null && typeof v === 'object' && Object.keys(v).length) {
149
+ var inner = toYaml(v, indent + 2);
150
+ return padStr + '- ' + inner.slice(indent + 2); // first line joins the dash
151
+ }
152
+ if (v !== null && typeof v === 'object') return padStr + '- {}\n';
153
+ return padStr + '- ' + yScalar(v) + '\n';
154
+ }).join('');
73
155
  }
74
- return '"' + normalized + '"';
156
+ if (value !== null && typeof value === 'object') {
157
+ var keys = Object.keys(value).filter(function (k) {
158
+ var v = value[k];
159
+ return v !== undefined && v !== null && !(typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).length);
160
+ });
161
+ if (!keys.length) return padStr + '{}\n';
162
+ return keys.map(function (k) {
163
+ var v = value[k];
164
+ if (Array.isArray(v)) return v.length ? padStr + k + ':\n' + toYaml(v, indent + 2) : padStr + k + ': []\n';
165
+ if (typeof v === 'object') return padStr + k + ':\n' + toYaml(v, indent + 2);
166
+ if (typeof v === 'string' && v.indexOf('\n') !== -1) {
167
+ return padStr + k + ': |-\n' + v.split('\n').map(function (line) { return padStr + ' ' + line; }).join('\n') + '\n';
168
+ }
169
+ return padStr + k + ': ' + yScalar(v) + '\n';
170
+ }).join('');
171
+ }
172
+ return padStr + yScalar(value) + '\n';
173
+ }
174
+
175
+ function yScalar(v) {
176
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
177
+ return y(v);
178
+ }
179
+
180
+ function hexToRgb(hex) {
181
+ var m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''));
182
+ return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)].join(', ') : '';
183
+ }
184
+
185
+ function shade(hex, amount) {
186
+ var m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''));
187
+ if (!m) return hex;
188
+ var out = [m[1], m[2], m[3]].map(function (h) {
189
+ var v = Math.round(parseInt(h, 16) * (1 + amount));
190
+ return ('0' + Math.max(0, Math.min(255, v)).toString(16)).slice(-2);
191
+ });
192
+ return '#' + out.join('');
193
+ }
194
+
195
+ // ── tiny event bus ────────────────────────────────────────────────
196
+
197
+ function on(event, cb) {
198
+ (listeners[event] = listeners[event] || []).push(cb);
199
+ return function () {
200
+ listeners[event] = (listeners[event] || []).filter(function (fn) { return fn !== cb; });
201
+ };
202
+ }
203
+
204
+ function emit(event, payload) {
205
+ (listeners[event] || []).forEach(function (cb) {
206
+ try { cb(payload); } catch (e) { console.warn('Site Builder listener failed', e); }
207
+ });
208
+ try { wizard.dispatchEvent(new CustomEvent('zer0:wizard:' + event, { detail: payload })); } catch (e) { /* old browsers */ }
209
+ }
210
+
211
+ // ── YAML / text helpers ──────────────────────────────────────────
212
+
213
+ /**
214
+ * Quote a YAML scalar. Multi-line input is collapsed to one line — every
215
+ * scalar the generators write is single-line by design (bodies go to
216
+ * Markdown files, not to YAML values).
217
+ */
218
+ function y(val) {
219
+ if (val === '' || val === null || val === undefined) return '""';
220
+ if (typeof val === 'boolean') return val ? 'true' : 'false';
221
+ if (typeof val === 'number') return String(val);
222
+ var s = String(val).replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
223
+ if (s === '') return '""';
224
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
75
225
  }
76
226
 
77
- /** Pad a YAML key to 25 chars for alignment. */
78
227
  function pad(key, width) {
79
228
  width = width || 25;
80
- return (key + ' '.repeat(width)).slice(0, width);
229
+ return (key + ' '.repeat(width)).slice(0, Math.max(width, key.length + 1));
81
230
  }
82
231
 
83
- /** Return the permalink pattern for a given collection name. */
84
- function collectionPermalink(col) {
85
- switch (col) {
86
- case 'posts': return '/posts/:title/';
87
- case 'docs': return '/docs/:path/';
88
- case 'about': return '/about/:path/';
89
- default: return '/' + col + '/:path/';
90
- }
232
+ function slugify(text) {
233
+ return String(text || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
234
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
91
235
  }
92
236
 
93
- // ── YAML builder ──────────────────────────────────────────────────
237
+ /**
238
+ * A timestamp for generated content. It must never be in the FUTURE: Jekyll
239
+ * refuses to publish future-dated documents unless `future: true`, and
240
+ * GitHub Pages builds with the default. Stamping a fixed "09:00Z" meant
241
+ * every post and note a site was generated with stayed invisible on Pages
242
+ * until 09:00 UTC — or a whole day, west of the meridian.
243
+ */
244
+ function nowISO() {
245
+ return new Date().toISOString().replace(/\.\d{3}Z$/, '.000Z');
246
+ }
94
247
 
95
- function buildYAML() {
96
- var fields = {};
248
+ function todayISO() {
249
+ try { return new Date().toISOString().slice(0, 10); } catch (e) { return '2026-01-01'; }
250
+ }
251
+
252
+ function titleCase(id) {
253
+ return String(id || '').replace(/[-_]/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
254
+ }
255
+
256
+ // ── reading the form ─────────────────────────────────────────────
257
+
258
+ function fieldEls() { return wizard.querySelectorAll('.cfg-field[data-key]'); }
97
259
 
98
- // Read simple fields
99
- document.querySelectorAll('.cfg-field').forEach(function (el) {
260
+ function readFields() {
261
+ var fields = {};
262
+ fieldEls().forEach(function (el) {
100
263
  var key = el.getAttribute('data-key');
101
264
  if (!key) return;
102
- var val = (el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')
103
- ? el.value.trim()
104
- : el.value.trim();
105
- if (val) fields[key] = val;
265
+ var val = el.type === 'number' ? el.value.trim() : el.value.trim();
266
+ if (val !== '') fields[key] = val;
267
+ });
268
+ wizard.querySelectorAll('.cfg-radio:checked').forEach(function (el) {
269
+ var key = el.getAttribute('data-key');
270
+ if (key) fields[key] = el.value;
106
271
  });
272
+ wizard.querySelectorAll('.cfg-switch[data-key]').forEach(function (el) {
273
+ fields[el.getAttribute('data-key')] = !!el.checked;
274
+ });
275
+ return fields;
276
+ }
107
277
 
108
- // Read collection toggles
109
- var collections = [];
110
- document.querySelectorAll('.cfg-collection').forEach(function (el) {
278
+ function readCollections() {
279
+ var out = [];
280
+ wizard.querySelectorAll('.cfg-collection').forEach(function (el) {
111
281
  if (el.checked) {
112
- collections.push(el.getAttribute('data-col'));
282
+ out.push({
283
+ id: el.getAttribute('data-col'),
284
+ layout: el.getAttribute('data-layout') || 'default',
285
+ permalink: el.getAttribute('data-permalink') || ('/' + el.getAttribute('data-col') + '/:path/')
286
+ });
113
287
  }
114
288
  });
289
+ return out;
290
+ }
291
+
292
+ function readNavigation() {
293
+ var rows = [];
294
+ wizard.querySelectorAll('#nav-editor .nav-row').forEach(function (row) {
295
+ var title = (row.querySelector('.nav-title') || {}).value || '';
296
+ var url = (row.querySelector('.nav-url') || {}).value || '';
297
+ var icon = (row.querySelector('.nav-icon') || {}).value || '';
298
+ if (title.trim() || url.trim()) rows.push({ title: title.trim(), url: url.trim(), icon: icon.trim() });
299
+ });
300
+ return rows;
301
+ }
302
+
303
+ function catalog(name) { return Array.isArray(DATA[name]) ? DATA[name] : []; }
304
+
305
+ function labelFor(list, id) {
306
+ var hit = list.filter(function (x) { return x.id === id; })[0];
307
+ return hit ? hit.label : titleCase(id);
308
+ }
309
+
310
+ function activeStepId() {
311
+ var active = wizard.querySelector('#wizardTabs .wizard-step.is-active');
312
+ return active ? active.id : STEP_IDS[0];
313
+ }
314
+
315
+ /** The merged site plan: form-backed choices + the AI/user-supplied extras. */
316
+ function getPlan() {
317
+ var f = readFields();
318
+ var palette = { preset: f['plan.theme.palette.preset'] || 'skin' };
319
+ if (palette.preset === 'custom') {
320
+ palette.primary = f['plan.theme.palette.primary'] || '#0d6efd';
321
+ palette.secondary = f['plan.theme.palette.secondary'] || '#6c757d';
322
+ palette.accent = f['plan.theme.palette.accent'] || '#20c997';
323
+ }
324
+ return {
325
+ landing: {
326
+ template: f['plan.landing.template'] || 'hero',
327
+ hero: planExtra.landing.hero || undefined,
328
+ sections: planExtra.landing.sections || undefined
329
+ },
330
+ navigation: {
331
+ style: f['plan.navigation.style'] || 'flat',
332
+ sidebar: f['plan.navigation.sidebar'] || 'none',
333
+ items: planExtra.navigation.items || undefined
334
+ },
335
+ theme: {
336
+ palette: palette,
337
+ fonts: f['plan.theme.fonts'] || 'system',
338
+ radius: f['plan.theme.radius'] || 'soft'
339
+ },
340
+ pages: planExtra.pages.slice()
341
+ };
342
+ }
115
343
 
116
- // Read PostHog toggle
117
- var posthogEnabled = document.getElementById('cfg-posthog-enabled');
118
- var posthogOn = posthogEnabled && posthogEnabled.checked;
344
+ /**
345
+ * Apply a (partial) plan. Form-backed keys go through the controls; the
346
+ * extras are merged. Validates against plan_schema first — an invalid plan
347
+ * changes nothing and the errors come back for the caller (Claude) to fix.
348
+ */
349
+ function setPlan(patch, opts) {
350
+ opts = opts || {};
351
+ if (!patch || typeof patch !== 'object') return { ok: false, errors: ['plan must be an object'] };
352
+ var errors = validateSchema(patch, planSchema(), 'plan');
353
+ if (errors.length) return { ok: false, errors: errors };
354
+
355
+ var fieldPatch = {};
356
+ if (patch.landing) {
357
+ if (patch.landing.template) fieldPatch['plan.landing.template'] = patch.landing.template;
358
+ // A hero patch MERGES field by field. An agent that comes back later to
359
+ // add an image must not silently drop the headline, subheadline and CTAs
360
+ // it set two turns earlier. (sections and pages REPLACE — they are lists,
361
+ // not a bag of settings, so a shorter list has to mean a shorter list.)
362
+ if (patch.landing.hero !== undefined) {
363
+ planExtra.landing.hero = Object.assign({}, planExtra.landing.hero || {}, patch.landing.hero);
364
+ }
365
+ if (patch.landing.sections !== undefined) planExtra.landing.sections = patch.landing.sections;
366
+ }
367
+ if (patch.navigation) {
368
+ if (patch.navigation.style) fieldPatch['plan.navigation.style'] = patch.navigation.style;
369
+ if (patch.navigation.sidebar) fieldPatch['plan.navigation.sidebar'] = patch.navigation.sidebar;
370
+ if (patch.navigation.items !== undefined) planExtra.navigation.items = patch.navigation.items;
371
+ }
372
+ if (patch.theme) {
373
+ if (patch.theme.palette) {
374
+ var p = patch.theme.palette;
375
+ if (p.primary || p.secondary || p.accent) {
376
+ fieldPatch['plan.theme.palette.preset'] = 'custom';
377
+ if (p.primary) fieldPatch['plan.theme.palette.primary'] = p.primary;
378
+ if (p.secondary) fieldPatch['plan.theme.palette.secondary'] = p.secondary;
379
+ if (p.accent) fieldPatch['plan.theme.palette.accent'] = p.accent;
380
+ } else if (p.preset) fieldPatch['plan.theme.palette.preset'] = p.preset;
381
+ }
382
+ if (patch.theme.fonts) fieldPatch['plan.theme.fonts'] = patch.theme.fonts;
383
+ if (patch.theme.radius) fieldPatch['plan.theme.radius'] = patch.theme.radius;
384
+ }
385
+ if (Array.isArray(patch.pages)) {
386
+ var pages = patch.pages.map(normalizePage);
387
+ planExtra.pages = opts.mergePages ? mergePages(planExtra.pages, pages) : pages;
388
+ renderPagesPlanner();
389
+ }
390
+ var applied = Object.keys(fieldPatch).length ? applyFields(fieldPatch, { source: opts.source || 'plan', silent: true }) : { applied: [], ignored: [] };
391
+ refreshAll();
392
+ saveDraft();
393
+ emit('change', { source: opts.source || 'plan', keys: ['plan'] });
394
+ return { ok: true, applied: applied.applied.concat(Object.keys(patch)), ignored: applied.ignored };
395
+ }
119
396
 
120
- // Build YAML
397
+ function normalizePage(p) {
398
+ var page = Object.assign({}, p);
399
+ page.collection = page.collection || 'posts';
400
+ page.title = String(page.title || 'Untitled').trim();
401
+ page.slug = slugify(page.slug || page.title) || 'page';
402
+ if (page.collection === 'posts' && !page.date) page.date = todayISO();
403
+ return page;
404
+ }
405
+
406
+ function mergePages(existing, incoming) {
407
+ var out = existing.slice();
408
+ incoming.forEach(function (p) {
409
+ var i = out.findIndex(function (e) { return e.collection === p.collection && e.slug === p.slug; });
410
+ if (i === -1) out.push(p); else out[i] = Object.assign({}, out[i], p);
411
+ });
412
+ return out.slice(0, 12);
413
+ }
414
+
415
+ function planSummary(plan) {
121
416
  var lines = [];
122
- lines.push('# =============================================================================');
123
- lines.push('# Site Configuration — generated by Setup Wizard');
124
- lines.push('# Docs: https://jekyllrb.com/docs/configuration/');
125
- lines.push('# =============================================================================');
126
- lines.push('');
127
-
128
- // Site Identity
129
- lines.push('# ── Site Identity ──────────────────────────────────────────────────');
130
- lines.push(pad('title') + ': ' + yamlValue(fields.title || 'My Site'));
131
- if (fields.subtitle) {
132
- lines.push(pad('subtitle') + ': ' + yamlValue(fields.subtitle));
133
- }
134
- lines.push(pad('description') + ': ' + yamlValue(fields.description || ''));
135
- lines.push(pad('founder') + ': ' + yamlValue(fields.founder || ''));
136
- if (fields.email) {
137
- lines.push(pad('email') + ': ' + yamlValue(fields.email));
138
- }
139
- lines.push('');
140
-
141
- // GitHub
142
- lines.push('# ── GitHub ─────────────────────────────────────────────────────────');
143
- var ghUser = fields.github_user || 'your-username';
144
- var repoName = fields.repository_name || (ghUser !== 'your-username' ? ghUser + '-site' : 'my-site');
145
- lines.push(pad('github_user') + ': &github_user ' + yamlValue(ghUser));
146
- lines.push(pad('repository_name') + ': &github_repository ' + yamlValue(repoName));
147
- lines.push('');
148
-
149
- // URLs
150
- lines.push('# ── URLs & Deployment ──────────────────────────────────────────────');
151
- lines.push(pad('url') + ': ' + yamlValue(fields.url || 'https://' + ghUser + '.github.io'));
152
- lines.push(pad('baseurl') + ': ' + yamlValue(fields.baseurl || ''));
153
- lines.push(pad('remote_theme') + ': ' + yamlValue(fields.remote_theme || 'bamr87/zer0-mistakes'));
154
- lines.push(pad('permalink') + ': ' + (fields.permalink || '/:categories/:title/'));
155
- lines.push(pad('port') + ': 4000');
156
- lines.push('');
157
-
158
- // Collections
159
- lines.push('# ── Collections ────────────────────────────────────────────────────');
160
- lines.push('collections:');
161
- collections.forEach(function (col) {
162
- lines.push(' ' + col + ':');
163
- lines.push(' output: true');
164
- lines.push(' permalink: ' + collectionPermalink(col));
165
- });
166
- lines.push('');
167
-
168
- // Defaults
169
- lines.push('# ── Default Front Matter ───────────────────────────────────────────');
170
- lines.push('defaults:');
171
- if (collections.indexOf('posts') !== -1) {
172
- lines.push(' - scope:');
173
- lines.push(' path: ""');
174
- lines.push(' type: "posts"');
175
- lines.push(' values:');
176
- lines.push(' layout: "article"');
177
- lines.push(' author: "default"');
178
- }
179
- if (collections.indexOf('docs') !== -1) {
180
- lines.push(' - scope:');
181
- lines.push(' path: ""');
182
- lines.push(' type: "docs"');
183
- lines.push(' values:');
184
- lines.push(' layout: "default"');
185
- }
186
- if (collections.indexOf('about') !== -1) {
187
- lines.push(' - scope:');
188
- lines.push(' path: ""');
189
- lines.push(' type: "about"');
190
- lines.push(' values:');
191
- lines.push(' layout: "default"');
192
- }
193
- lines.push(' - scope:');
194
- lines.push(' path: ""');
195
- lines.push(' type: "pages"');
196
- lines.push(' values:');
197
- lines.push(' layout: "default"');
198
- lines.push('');
199
-
200
- // Build settings
201
- lines.push('# ── Build ──────────────────────────────────────────────────────────');
202
- lines.push(pad('markdown') + ': kramdown');
203
- lines.push(pad('highlighter') + ': rouge');
204
- lines.push(pad('public_folder') + ': assets');
205
- lines.push('');
206
-
207
- // Plugins
208
- lines.push('# ── Plugins ────────────────────────────────────────────────────────');
209
- lines.push('plugins:');
210
- lines.push(' - jekyll-feed');
211
- lines.push(' - jekyll-sitemap');
212
- lines.push(' - jekyll-seo-tag');
213
- lines.push(' - jekyll-remote-theme');
214
- lines.push('');
215
-
216
- // Analytics
217
- lines.push('# ── Analytics ──────────────────────────────────────────────────────');
218
- if (fields.google_analytics) {
219
- lines.push(pad('google_analytics') + ': ' + yamlValue(fields.google_analytics));
417
+ lines.push('Landing: template=' + plan.landing.template + (plan.landing.hero ? ' (custom hero)' : ' (default hero)') + (plan.landing.sections ? ', ' + plan.landing.sections.length + ' custom sections' : ', default sections'));
418
+ lines.push('Navigation: style=' + plan.navigation.style + ' sidebar=' + plan.navigation.sidebar + (plan.navigation.items ? ' (custom items)' : ''));
419
+ var pal = plan.theme.palette;
420
+ lines.push('Theme overrides: palette=' + pal.preset + (pal.primary ? ' ' + pal.primary + '/' + pal.secondary + '/' + pal.accent : '') + ' fonts=' + plan.theme.fonts + ' radius=' + plan.theme.radius);
421
+ lines.push('Planned pages (' + plan.pages.length + '): ' + (plan.pages.map(function (p) { return p.collection + '/' + p.slug + (p.body ? '' : ' (no body yet)'); }).join(', ') || 'none'));
422
+ return lines.join('\n');
423
+ }
424
+
425
+ /** The full, serialisable wizard state — what Claude reads every turn. */
426
+ function getState() {
427
+ var f = readFields();
428
+ var collections = readCollections();
429
+ var slug = slugify(f.target || f.repository_name || f.title) || 'my-site';
430
+ return {
431
+ step: activeStepId().replace(/^tab-/, ''),
432
+ fields: f,
433
+ plan: getPlan(),
434
+ collections: collections.map(function (c) { return c.id; }),
435
+ navigation: readNavigation(),
436
+ checks: JSON.parse(JSON.stringify(checks)),
437
+ target: f.target || slug,
438
+ port: Number(f.port) || DEFAULT_PORT,
439
+ overrides: Object.keys(overrides),
440
+ recommendedMissing: unfilledRecommended()
441
+ };
442
+ }
443
+
444
+ /** Compact prose summary for the Claude system prompt. */
445
+ function getSummary() {
446
+ var s = getState();
447
+ var f = s.fields;
448
+ var lines = [];
449
+ lines.push('Current step: ' + s.step);
450
+ lines.push('Title: ' + (f.title || '(empty)') + ' | Subtitle: ' + (f.subtitle || '(empty)') + ' | Tagline: ' + (f.tagline || '(empty)'));
451
+ lines.push('Description: ' + (f.description || '(empty)'));
452
+ lines.push('Brief: ' + (f.brief || '(empty)'));
453
+ lines.push('Owner: ' + (f.founder || '(empty)') + ' <' + (f.email || 'no email') + '>');
454
+ lines.push('GitHub: ' + (f.github_user || '(user?)') + '/' + (f.repository_name || '(repo?)') + ' | url: ' + (f.url || '(empty)') + ' | baseurl: ' + (f.baseurl || '""') + ' | theme via: ' + (f.deploy_target || 'github-pages') + ' | port: ' + s.port);
455
+ lines.push('Site type: ' + (f.site_type || '(unset)') + ' | Collections: ' + (s.collections.join(', ') || 'none'));
456
+ lines.push('Navigation: ' + (s.navigation.map(function (n) { return n.title + ' → ' + n.url; }).join('; ') || '(empty)'));
457
+ lines.push('Appearance: skin=' + (f.theme_skin || 'air') + ' colour_mode=' + (f.color_mode_default || 'auto') + ' lock=' + !!f.color_mode_lock + ' backgrounds=' + (f.theme_background !== false) + ' icon=' + (f.title_icon || 'robot'));
458
+ lines.push('Voice: tone=' + (f.tone || 'friendly') + ' audience=' + (f.audience || 'developers') + ' | welcome title: ' + (f.welcome_title || '(empty)') + ' | welcome body: ' + (f.welcome_body ? f.welcome_body.length + ' chars' : 'empty') + ' | about body: ' + (f.about_body ? f.about_body.length + ' chars' : 'empty'));
459
+ var ints = catalog('integrations').map(function (i) { return i.id + '=' + (f['integration.' + i.id] ? 'on' : 'off'); });
460
+ lines.push('Integrations: ' + ints.join(', ') + ' | GA: ' + (f.google_analytics || 'none') + ' | giscus ids: ' + (f['giscus.repo_id'] ? 'set' : 'unset'));
461
+ lines.push('AI in the generated site: chat provider=' + (f.ai_provider || 'anthropic') + ' | preview-image renderer=' + (f.image_provider || 'none'));
462
+ var checkLines = Object.keys(s.checks).map(function (id) {
463
+ var c = s.checks[id];
464
+ return id + ':' + (c.manual ? 'done(manual)' : c.ok === true ? 'ok' + (c.version ? ' v' + c.version : '') : c.ok === false ? 'FAIL' : '?');
465
+ });
466
+ lines.push('Prerequisites: ' + (checkLines.join(', ') || 'not checked'));
467
+ lines.push('Target folder: ' + s.target + ' | overwrite: ' + !!f.overwrite + ' | file overrides: ' + (s.overrides.join(', ') || 'none'));
468
+ if (s.recommendedMissing.length) lines.push('Recommended fields still empty: ' + s.recommendedMissing.join(', '));
469
+ lines.push('Site plan:\n' + planSummary(s.plan));
470
+ return lines.join('\n');
471
+ }
472
+
473
+ /** What Claude may set: every data-key with its type, label and options. */
474
+ function schema() {
475
+ var out = [];
476
+ var seen = {};
477
+ function labelOf(el) {
478
+ var lab = el.id ? wizard.querySelector('label[for="' + el.id + '"]') : null;
479
+ if (lab) return lab.textContent.replace(/\s+/g, ' ').trim();
480
+ var fs = el.closest('fieldset');
481
+ var legend = fs ? fs.querySelector('legend') : null;
482
+ return legend ? legend.textContent.replace(/\s+/g, ' ').trim() : el.getAttribute('data-key');
483
+ }
484
+ fieldEls().forEach(function (el) {
485
+ var key = el.getAttribute('data-key');
486
+ if (seen[key]) return;
487
+ seen[key] = true;
488
+ var entry = { key: key, label: labelOf(el), type: el.tagName === 'TEXTAREA' ? 'text (multi-line Markdown allowed)' : el.tagName === 'SELECT' ? 'choice' : (el.type || 'text') };
489
+ if (el.tagName === 'SELECT') entry.options = Array.prototype.map.call(el.options, function (o) { return o.value; });
490
+ out.push(entry);
491
+ });
492
+ var radioGroups = {};
493
+ wizard.querySelectorAll('.cfg-radio[data-key]').forEach(function (el) {
494
+ var key = el.getAttribute('data-key');
495
+ if (!radioGroups[key]) {
496
+ radioGroups[key] = { key: key, label: labelOf(el), type: 'choice', options: [] };
497
+ out.push(radioGroups[key]);
498
+ }
499
+ radioGroups[key].options.push(el.value);
500
+ });
501
+ wizard.querySelectorAll('.cfg-switch[data-key]').forEach(function (el) {
502
+ var key = el.getAttribute('data-key');
503
+ if (seen[key]) return;
504
+ seen[key] = true;
505
+ out.push({ key: key, label: labelOf(el), type: 'boolean' });
506
+ });
507
+ out.push({ key: 'collections', label: 'Enabled collections', type: 'array of ids', options: catalog('collections').map(function (c) { return c.id; }) });
508
+ out.push({ key: 'navigation', label: 'Main navigation', type: 'array of {title, url, icon?} (icon = Bootstrap icon class like bi-book)' });
509
+ return out.filter(function (e) { return e.key.indexOf('plan.') !== 0; }); // plan.* keys are set through set_site_plan
510
+ }
511
+
512
+ // ── writing the form (Claude → wizard) ───────────────────────────
513
+
514
+ function setRadio(key, value) {
515
+ var radios = wizard.querySelectorAll('.cfg-radio[data-key="' + key + '"]');
516
+ var hit = false;
517
+ radios.forEach(function (r) {
518
+ var match = String(r.value) === String(value);
519
+ r.checked = match;
520
+ if (match) hit = true;
521
+ });
522
+ if (hit && key === 'site_type') applySiteType(value);
523
+ return hit;
524
+ }
525
+
526
+ function setCollections(ids) {
527
+ if (!Array.isArray(ids)) return false;
528
+ var wanted = ids.map(String);
529
+ wizard.querySelectorAll('.cfg-collection').forEach(function (el) {
530
+ el.checked = wanted.indexOf(el.getAttribute('data-col')) !== -1;
531
+ });
532
+ return true;
533
+ }
534
+
535
+ function applyFields(patch, opts) {
536
+ opts = opts || {};
537
+ var applied = [];
538
+ var ignored = [];
539
+ if (!patch || typeof patch !== 'object') return { applied: applied, ignored: ['(no fields)'] };
540
+
541
+ Object.keys(patch).forEach(function (key) {
542
+ var value = patch[key];
543
+ if (key === 'collections') {
544
+ if (setCollections(value)) applied.push(key); else ignored.push(key);
545
+ return;
546
+ }
547
+ if (key === 'navigation') {
548
+ if (Array.isArray(value)) { renderNavEditor(value); applied.push(key); } else ignored.push(key);
549
+ return;
550
+ }
551
+ var sw = wizard.querySelector('.cfg-switch[data-key="' + key + '"]');
552
+ if (sw) { sw.checked = value === true || value === 'true' || value === 'on' || value === 1; applied.push(key); return; }
553
+ if (wizard.querySelector('.cfg-radio[data-key="' + key + '"]')) {
554
+ if (setRadio(key, value)) applied.push(key); else ignored.push(key + ' (unknown option ' + value + ')');
555
+ return;
556
+ }
557
+ var el = wizard.querySelector('.cfg-field[data-key="' + key + '"]');
558
+ if (!el) { ignored.push(key); return; }
559
+ if (el.tagName === 'SELECT') {
560
+ var ok = Array.prototype.some.call(el.options, function (o) { return o.value === String(value); });
561
+ if (!ok) { ignored.push(key + ' (unknown option ' + value + ')'); return; }
562
+ }
563
+ el.value = value === null || value === undefined ? '' : String(value);
564
+ el.classList.remove('is-invalid');
565
+ applied.push(key);
566
+ });
567
+
568
+ refreshAll();
569
+ if (!opts.silent) saveDraft();
570
+ emit('change', { source: opts.source || 'api', keys: applied });
571
+ return { applied: applied, ignored: ignored };
572
+ }
573
+
574
+ function setOverride(path, content) {
575
+ if (!path) return false;
576
+ if (content === null || content === undefined) delete overrides[path];
577
+ else overrides[path] = String(content);
578
+ refreshAll();
579
+ saveDraft();
580
+ emit('change', { source: 'override', keys: [path] });
581
+ return true;
582
+ }
583
+
584
+ function setCheckResult(id, result) {
585
+ if (!id) return;
586
+ checks[id] = Object.assign({}, checks[id] || {}, result || {});
587
+ renderPrereqs();
588
+ saveDraft();
589
+ emit('checks', { id: id, result: checks[id] });
590
+ }
591
+
592
+ // ── site type → collections ──────────────────────────────────────
593
+
594
+ function applySiteType(id) {
595
+ var radio = wizard.querySelector('.cfg-radio[data-key="site_type"][value="' + id + '"]');
596
+ var list = radio ? (radio.getAttribute('data-collections') || '').split(',').filter(Boolean) : null;
597
+ if (list && list.length) {
598
+ setCollections(list);
599
+ renderNavEditor(defaultNavigation());
600
+ }
601
+ }
602
+
603
+ // ── navigation editor ────────────────────────────────────────────
604
+
605
+ var COLLECTION_NAV = {
606
+ posts: { title: 'Posts', url: '/posts/', icon: 'bi-journal-text' },
607
+ docs: { title: 'Docs', url: '/docs/', icon: 'bi-book' },
608
+ about: { title: 'About', url: '/about/', icon: 'bi-info-circle' },
609
+ notes: { title: 'Notes', url: '/notes/', icon: 'bi-sticky' },
610
+ quickstart: { title: 'Quickstart', url: '/quickstart/', icon: 'bi-flag' },
611
+ notebooks: { title: 'Notebooks', url: '/notebooks/', icon: 'bi-journal-code' },
612
+ quests: { title: 'Quests', url: '/quests/', icon: 'bi-controller' },
613
+ recipes: { title: 'Recipes', url: '/recipes/', icon: 'bi-egg-fried' }
614
+ };
615
+
616
+ function defaultNavigation() {
617
+ var nav = [{ title: 'Home', url: '/', icon: 'bi-house' }];
618
+ readCollections().forEach(function (c) {
619
+ var item = COLLECTION_NAV[c.id];
620
+ if (item && c.id !== 'about') nav.push(Object.assign({}, item));
621
+ });
622
+ if (readCollections().some(function (c) { return c.id === 'about'; })) nav.push(Object.assign({}, COLLECTION_NAV.about));
623
+ return nav;
624
+ }
625
+
626
+ function navRow(item) {
627
+ var row = document.createElement('div');
628
+ row.className = 'nav-row input-group input-group-sm mb-2';
629
+ var handle = document.createElement('span');
630
+ handle.className = 'input-group-text';
631
+ handle.innerHTML = '<i class="bi bi-grip-vertical" aria-hidden="true"></i>';
632
+ var title = document.createElement('input');
633
+ title.type = 'text';
634
+ title.className = 'form-control nav-title';
635
+ title.placeholder = 'Label';
636
+ title.setAttribute('aria-label', 'Menu label');
637
+ title.value = item.title || '';
638
+ var url = document.createElement('input');
639
+ url.type = 'text';
640
+ url.className = 'form-control nav-url';
641
+ url.placeholder = '/path/';
642
+ url.setAttribute('aria-label', 'Menu URL');
643
+ url.value = item.url || '';
644
+ var icon = document.createElement('input');
645
+ icon.type = 'text';
646
+ icon.className = 'form-control nav-icon';
647
+ icon.placeholder = 'bi-icon';
648
+ icon.setAttribute('aria-label', 'Bootstrap icon class (optional)');
649
+ icon.value = item.icon || '';
650
+ var remove = document.createElement('button');
651
+ remove.type = 'button';
652
+ remove.className = 'btn btn-outline-danger';
653
+ remove.setAttribute('aria-label', 'Remove menu item');
654
+ remove.innerHTML = '<i class="bi bi-x-lg" aria-hidden="true"></i>';
655
+ remove.addEventListener('click', function () {
656
+ row.remove();
657
+ refreshAll();
658
+ saveDraft();
659
+ emit('change', { source: 'nav', keys: ['navigation'] });
660
+ });
661
+ row.appendChild(handle);
662
+ row.appendChild(title);
663
+ row.appendChild(url);
664
+ row.appendChild(icon);
665
+ row.appendChild(remove);
666
+ return row;
667
+ }
668
+
669
+ // ── example-page planner ─────────────────────────────────────────
670
+
671
+ function pageRow(p, index) {
672
+ var row = document.createElement('div');
673
+ row.className = 'page-row input-group input-group-sm';
674
+ row.setAttribute('data-index', String(index));
675
+ var select = document.createElement('select');
676
+ select.className = 'form-select page-collection';
677
+ select.setAttribute('aria-label', 'Collection');
678
+ readCollections().forEach(function (col) {
679
+ var opt = document.createElement('option');
680
+ opt.value = col.id;
681
+ opt.textContent = labelFor(catalog('collections'), col.id);
682
+ if (col.id === p.collection) opt.selected = true;
683
+ select.appendChild(opt);
684
+ });
685
+ if (!Array.prototype.some.call(select.options, function (o) { return o.value === p.collection; })) {
686
+ var extra = document.createElement('option');
687
+ extra.value = p.collection;
688
+ extra.textContent = titleCase(p.collection) + ' (off)';
689
+ extra.selected = true;
690
+ select.appendChild(extra);
691
+ }
692
+ var title = document.createElement('input');
693
+ title.type = 'text';
694
+ title.className = 'form-control page-title';
695
+ title.placeholder = 'Page title';
696
+ title.setAttribute('aria-label', 'Page title');
697
+ title.value = p.title || '';
698
+ var state = document.createElement('span');
699
+ state.className = 'input-group-text small';
700
+ state.title = p.body ? 'Body written' : 'Body will be drafted from the title';
701
+ state.innerHTML = p.body ? '<i class="bi bi-file-earmark-check text-success" aria-hidden="true"></i>' : '<i class="bi bi-file-earmark text-body-secondary" aria-hidden="true"></i>';
702
+ var remove = document.createElement('button');
703
+ remove.type = 'button';
704
+ remove.className = 'btn btn-outline-danger page-remove';
705
+ remove.setAttribute('aria-label', 'Remove planned page');
706
+ remove.innerHTML = '<i class="bi bi-x-lg" aria-hidden="true"></i>';
707
+ row.appendChild(select);
708
+ row.appendChild(title);
709
+ row.appendChild(state);
710
+ row.appendChild(remove);
711
+ return row;
712
+ }
713
+
714
+ function renderPagesPlanner() {
715
+ var host = document.getElementById('pages-planner');
716
+ if (!host) return;
717
+ host.innerHTML = '';
718
+ planExtra.pages.forEach(function (p, i) { host.appendChild(pageRow(p, i)); });
719
+ if (!planExtra.pages.length) {
720
+ var empty = document.createElement('p');
721
+ empty.className = 'small text-body-secondary mb-0';
722
+ empty.textContent = 'No example pages planned yet — add one, or ask Claude to plan them.';
723
+ host.appendChild(empty);
724
+ }
725
+ var count = document.getElementById('pages-count');
726
+ if (count) count.textContent = String(planExtra.pages.length);
727
+ }
728
+
729
+ function updateLandingSummary() {
730
+ var host = document.getElementById('landing-summary');
731
+ if (!host) return;
732
+ var lp = landingPlan(ctx());
733
+ host.textContent = 'Hero: “' + (lp.hero.headline || '') + '” · sections: ' + lp.sections.map(function (s) { return s.type.replace('_', ' '); }).join(', ') + (planExtra.landing.sections ? ' (designed)' : ' (template defaults)');
734
+ }
735
+
736
+ function ensureFontLink(fp) {
737
+ if (!fp || !fp.google) return;
738
+ var id = 'wizard-font-' + fp.id;
739
+ if (document.getElementById(id)) return;
740
+ var link = document.createElement('link');
741
+ link.id = id;
742
+ link.rel = 'stylesheet';
743
+ link.href = 'https://fonts.googleapis.com/css2?' + fp.google + '&display=swap';
744
+ document.head.appendChild(link);
745
+ }
746
+
747
+ function updateFontPreview() {
748
+ var host = document.getElementById('font-preview');
749
+ if (!host) return;
750
+ var fp = catalogItem('font_pairings', readFields()['plan.theme.fonts']);
751
+ if (fp && fp.google) {
752
+ ensureFontLink(fp);
753
+ host.style.fontFamily = fp.body;
754
+ var strong = host.querySelector('strong');
755
+ if (strong) strong.style.fontFamily = fp.heading;
220
756
  } else {
221
- lines.push(pad('google_analytics') + ': ""');
222
- }
223
- lines.push('posthog:');
224
- lines.push(' enabled: ' + (posthogOn ? 'true' : 'false'));
225
- lines.push(' api_key: ' + yamlValue(fields['posthog.api_key'] || ''));
226
- lines.push('');
227
-
228
- // Social
229
- if (fields.twitter_username || fields.linkedin_username) {
230
- lines.push('# ── Social ─────────────────────────────────────────────────────────');
231
- if (fields.twitter_username) {
232
- lines.push(pad('twitter_username') + ': ' + yamlValue(fields.twitter_username));
757
+ host.style.fontFamily = '';
758
+ var s2 = host.querySelector('strong');
759
+ if (s2) s2.style.fontFamily = '';
760
+ }
761
+ }
762
+
763
+ function renderNavEditor(items) {
764
+ var host = document.getElementById('nav-editor');
765
+ if (!host) return;
766
+ host.innerHTML = '';
767
+ (items || []).forEach(function (item) { host.appendChild(navRow(item)); });
768
+ if (!host.children.length) {
769
+ var empty = document.createElement('p');
770
+ empty.className = 'small text-body-secondary mb-0';
771
+ empty.textContent = 'No menu items yet — add one or rebuild from the collections.';
772
+ host.appendChild(empty);
773
+ }
774
+ }
775
+
776
+ // ── generators ───────────────────────────────────────────────────
777
+
778
+ function ctx() {
779
+ var s = getState();
780
+ var f = s.fields;
781
+ var ghUser = f.github_user || 'your-username';
782
+ var repo = f.repository_name || s.target || 'my-site';
783
+ var isUserSite = /\.github\.io$/i.test(repo);
784
+ var remote = f.deploy_target !== 'gem';
785
+ return {
786
+ s: s, f: f, ghUser: ghUser, repo: repo, remote: remote, plan: s.plan,
787
+ title: f.title || 'My Site',
788
+ url: f.url || ('https://' + ghUser + '.github.io'),
789
+ baseurl: f.baseurl !== undefined ? f.baseurl : (isUserSite ? '' : '/' + repo),
790
+ port: s.port,
791
+ date: todayISO(),
792
+ slug: s.target || slugify(repo) || 'my-site',
793
+ collections: readCollections(),
794
+ nav: s.navigation.length ? s.navigation : defaultNavigation(),
795
+ on: function (id) { return !!f['integration.' + id]; }
796
+ };
797
+ }
798
+
799
+ function genConfig(c) {
800
+ var f = c.f;
801
+ var L = [];
802
+ L.push('# =============================================================================');
803
+ L.push('# ' + c.title + ' — Jekyll configuration');
804
+ L.push('# Generated by the zer0-mistakes Site Builder on ' + c.date);
805
+ L.push('# Theme: https://github.com/bamr87/zer0-mistakes · Docs: https://jekyllrb.com/docs/configuration/');
806
+ L.push('# =============================================================================');
807
+ L.push('');
808
+ L.push('# ── Setup flag ─────────────────────────────────────────────────────');
809
+ L.push('# true hides the welcome / setup screens the theme shows to fresh sites.');
810
+ L.push(pad('site_configured') + ': true');
811
+ L.push('');
812
+ L.push('# ── Site identity ──────────────────────────────────────────────────');
813
+ L.push(pad('title') + ': ' + y(c.title));
814
+ L.push(pad('subtitle') + ': ' + y(f.subtitle || ''));
815
+ if (f.tagline) L.push(pad('tagline') + ': ' + y(f.tagline));
816
+ L.push(pad('description') + ': ' + y(f.description || ''));
817
+ L.push(pad('founder') + ': ' + y(f.founder || ''));
818
+ L.push(pad('email') + ': ' + y(f.email || ''));
819
+ L.push(pad('title_icon') + ': ' + y(f.title_icon || 'robot'));
820
+ L.push(pad('locale') + ': "en-US"');
821
+ L.push('# Generated monogram — replace with your own image under assets/images/.');
822
+ L.push(pad('logo') + ': /assets/images/logo.svg');
823
+ L.push('');
824
+ L.push('# ── GitHub ─────────────────────────────────────────────────────────');
825
+ L.push(pad('github_user') + ': &github_user ' + y(c.ghUser));
826
+ L.push(pad('repository_name') + ': &github_repository ' + y(c.repo));
827
+ L.push(pad('repository') + ': [*github_user, "/", *github_repository]');
828
+ L.push('');
829
+ L.push('# ── URLs & deployment ──────────────────────────────────────────────');
830
+ L.push('# user/org site → url: https://<user>.github.io baseurl: ""');
831
+ L.push('# project site → url: https://<user>.github.io baseurl: "/repo"');
832
+ L.push(pad('url') + ': ' + y(c.url));
833
+ L.push(pad('baseurl') + ': ' + y(c.baseurl));
834
+ if (c.remote) {
835
+ L.push(pad('remote_theme') + ': ' + y(f.remote_theme || 'bamr87/zer0-mistakes'));
836
+ } else {
837
+ L.push(pad('theme') + ': "jekyll-theme-zer0"');
838
+ }
839
+ L.push(pad('permalink') + ': ' + (f.permalink || '/:categories/:title/'));
840
+ L.push(pad('port') + ': ' + c.port);
841
+ L.push('');
842
+ L.push('# ── Build ──────────────────────────────────────────────────────────');
843
+ L.push(pad('markdown') + ': kramdown');
844
+ L.push(pad('highlighter') + ': rouge');
845
+ L.push(pad('public_folder') + ': assets');
846
+ L.push('# Collections live under pages/_<name>/ — collections_dir is the key Jekyll');
847
+ L.push('# reads; pages_dir is the theme\'s own alias for the same folder.');
848
+ L.push(pad('collections_dir') + ': pages');
849
+ L.push(pad('pages_dir') + ': pages');
850
+ L.push('kramdown:');
851
+ L.push(' input: GFM');
852
+ L.push(' syntax_highlighter: rouge');
853
+ L.push('');
854
+ L.push('# ── Collections (pages/_<name>/) ───────────────────────────────────');
855
+ L.push('collections:');
856
+ c.collections.forEach(function (col) {
857
+ L.push(' ' + col.id + ':');
858
+ L.push(' output: true');
859
+ L.push(' permalink: ' + col.permalink);
860
+ if (col.id === 'docs' && c.plan.navigation.sidebar === 'docs') {
861
+ L.push(' sidebar: # curated tree in _data/navigation/docs.yml');
862
+ L.push(' nav: docs');
863
+ L.push(' title: "Docs"');
864
+ L.push(' icon: bi-book');
865
+ }
866
+ });
867
+ L.push('');
868
+ L.push('# ── Default front matter ───────────────────────────────────────────');
869
+ L.push('defaults:');
870
+ c.collections.forEach(function (col) {
871
+ L.push(' - scope:');
872
+ L.push(' path: ""');
873
+ L.push(' type: "' + col.id + '"');
874
+ L.push(' values:');
875
+ L.push(' layout: "' + col.layout + '"');
876
+ if (col.id === 'posts') L.push(' author: "default"');
877
+ });
878
+ L.push(' - scope:');
879
+ L.push(' path: ""');
880
+ L.push(' type: "pages"');
881
+ L.push(' values:');
882
+ L.push(' layout: "default"');
883
+ L.push('');
884
+ L.push('# ── Plugins (all GitHub Pages–safe) ────────────────────────────────');
885
+ L.push('plugins:');
886
+ L.push(' - jekyll-feed');
887
+ L.push(' - jekyll-sitemap');
888
+ L.push(' - jekyll-seo-tag');
889
+ L.push(' - jekyll-include-cache');
890
+ L.push(' - jekyll-paginate');
891
+ if (c.remote) L.push(' - jekyll-remote-theme');
892
+ L.push('');
893
+ if (c.plan.navigation.sidebar === 'auto') {
894
+ L.push('# ── Sidebar ────────────────────────────────────────────────────────');
895
+ L.push('# auto = the theme picks the best tree per collection (curated file, folder tree, categories).');
896
+ L.push('sidebar:');
897
+ L.push(' nav: auto');
898
+ L.push('');
899
+ }
900
+ L.push('# ── Appearance ─────────────────────────────────────────────────────');
901
+ L.push('# Skin = base palette + backgrounds; assets/css/user-overrides.css layers the');
902
+ L.push('# palette, fonts and corner radius chosen in the Site Builder on top.');
903
+ L.push('# user_overrides is what makes the theme LINK that stylesheet (core/head.html);');
904
+ L.push('# without it the file ships but nothing on the site changes.');
905
+ L.push(pad('user_overrides') + ': true');
906
+ L.push(pad('theme_skin') + ': ' + y(f.theme_skin || 'air') + ' # air | aqua | dirt | neon | mint | plum | sunrise');
907
+ L.push(pad('color_mode_default') + ': ' + (f.color_mode_default || 'auto') + ' # auto | light | dark');
908
+ L.push(pad('color_mode_lock') + ': ' + (f.color_mode_lock ? 'true' : 'false'));
909
+ L.push('theme_background:');
910
+ if (c.remote) {
911
+ L.push(' enabled: ' + (f.theme_background === false ? 'false' : 'true'));
912
+ } else {
913
+ L.push(' # The published gem ships no assets/backgrounds/ (kept small); the SVG');
914
+ L.push(' # layers are only available with remote_theme. Off to avoid 404s.');
915
+ L.push(' enabled: false');
916
+ }
917
+ L.push('');
918
+ L.push('# ── Author (used by article layouts) ───────────────────────────────');
919
+ L.push('author:');
920
+ L.push(' name: ' + y(f.founder || c.title));
921
+ L.push(' email: ' + y(f.email || ''));
922
+ L.push(' bio: ' + y(f.tagline || ''));
923
+ if (f.twitter_username) L.push(' twitter: ' + y(f.twitter_username));
924
+ if (f.linkedin_username) L.push(' linkedin: ' + y(f.linkedin_username));
925
+ L.push('');
926
+ L.push('# ── Integrations ───────────────────────────────────────────────────');
927
+ L.push('# Keys never live here. Analytics run only in production after consent.');
928
+ L.push(pad('google_analytics') + ': ' + y(f.google_analytics || ''));
929
+ L.push('posthog:');
930
+ L.push(' enabled: ' + (c.on('posthog') && f['posthog.api_key'] ? 'true' : 'false'));
931
+ L.push(' api_key: ' + y(f['posthog.api_key'] || ''));
932
+ L.push('cookie_consent:');
933
+ L.push(' enabled: true');
934
+ L.push(' expiry_days: 365');
935
+ L.push('giscus:');
936
+ L.push(' enabled: ' + (c.on('giscus') && f['giscus.repo_id'] && f['giscus.category_id'] ? 'true' : 'false'));
937
+ L.push(' data-repo-id: ' + y(f['giscus.repo_id'] || ''));
938
+ L.push(' data-category-id: ' + y(f['giscus.category_id'] || ''));
939
+ L.push('obsidian:');
940
+ L.push(' enabled: ' + (c.on('obsidian') ? 'true' : 'false'));
941
+ L.push('page_feedback:');
942
+ L.push(' enabled: ' + (c.on('page_feedback') ? 'true' : 'false'));
943
+ L.push(' mode: url # pre-filled github.com issue forms; no token needed');
944
+ L.push(' default_labels: ["page-feedback"]');
945
+ L.push('ai_chat:');
946
+ L.push(' enabled: ' + (c.on('ai_chat') ? 'true' : 'false'));
947
+ L.push(' provider: ' + (f.ai_provider || 'anthropic') + ' # anthropic (Claude) | xai (Grok) — the proxy needs the matching key');
948
+ L.push(' auth_mode: proxy');
949
+ L.push(' proxy_ready: false # set true once templates/deploy/chat-proxy is deployed');
950
+ L.push(' endpoint: "/api/chat"');
951
+ if (f.image_provider) {
952
+ var ip = catalogItem('image_providers', f.image_provider) || {};
953
+ L.push('# Preview images for posts (theme generator; the key lives in .env).');
954
+ L.push('preview_images:');
955
+ L.push(' enabled: true');
956
+ L.push(' provider: ' + f.image_provider + ' # ' + (ip.label || f.image_provider) + ' — ' + (ip.env || 'API key') + ' in .env');
957
+ L.push(' model: ' + y(ip.model || ''));
958
+ L.push(' output_dir: assets/images/previews');
959
+ }
960
+ L.push('');
961
+ if (f.twitter_username || f.linkedin_username) {
962
+ L.push('# ── Social ─────────────────────────────────────────────────────────');
963
+ if (f.twitter_username) L.push(pad('twitter_username') + ': ' + y(f.twitter_username));
964
+ if (f.linkedin_username) L.push(pad('linkedin_username') + ': ' + y(f.linkedin_username));
965
+ L.push('');
966
+ }
967
+ L.push('# ── Exclude from build ─────────────────────────────────────────────');
968
+ L.push('exclude:');
969
+ ['.sass-cache/', '.jekyll-cache/', '.jekyll-metadata', 'node_modules/', 'vendor/', 'Gemfile.lock',
970
+ 'docker-compose.yml', 'zer0.install.yml', '.env', '.env.example', 'README.md', '"*.log"'].forEach(function (e) {
971
+ L.push(' - ' + e);
972
+ });
973
+ return L.join('\n') + '\n';
974
+ }
975
+
976
+ function genDevConfig(c) {
977
+ var L = [];
978
+ L.push('# Development overrides — layered on top of _config.yml by docker-compose.yml:');
979
+ L.push('# jekyll serve --config _config.yml,_config_dev.yml');
980
+ L.push('');
981
+ if (c.remote) {
982
+ L.push('# Keep the remote theme in dev too (no theme gem needed locally).');
983
+ L.push('remote_theme: ' + y(c.f.remote_theme || 'bamr87/zer0-mistakes'));
984
+ } else {
985
+ L.push('remote_theme: false');
986
+ L.push('theme: "jekyll-theme-zer0"');
987
+ }
988
+ L.push('');
989
+ L.push('url: "http://localhost:' + c.port + '"');
990
+ L.push('baseurl: ""');
991
+ L.push('host: "0.0.0.0"');
992
+ L.push('port: ' + c.port);
993
+ L.push('livereload: true');
994
+ // Incremental regeneration is still experimental in Jekyll and only
995
+ // rebuilds documents whose own source changed — so adding a post leaves
996
+ // every LISTING page (the collection index, the home page's latest-posts
997
+ // section, the feed) stale, showing a site that no longer exists. These
998
+ // sites are small: a full rebuild is a second or two, and always right.
999
+ L.push('incremental: false');
1000
+ L.push('show_drafts: true');
1001
+ L.push('future: true');
1002
+ L.push('');
1003
+ L.push('# Never send analytics from a dev build.');
1004
+ L.push('google_analytics: null');
1005
+ L.push('posthog:');
1006
+ L.push(' enabled: false');
1007
+ L.push('');
1008
+ L.push('# Jekyll REPLACES (does not merge) `exclude` and `plugins` when layering configs,');
1009
+ L.push('# so this list must include everything from _config.yml.');
1010
+ L.push('exclude:');
1011
+ ['.sass-cache/', '.jekyll-cache/', '.jekyll-metadata', 'node_modules/', 'vendor/', 'Gemfile.lock',
1012
+ 'docker-compose.yml', 'zer0.install.yml', '.env', '.env.example', 'README.md', '"*.log"'].forEach(function (e) {
1013
+ L.push(' - ' + e);
1014
+ });
1015
+ return L.join('\n') + '\n';
1016
+ }
1017
+
1018
+ function genGemfile(c) {
1019
+ var L = ['source "https://rubygems.org"', ''];
1020
+ if (c.remote) {
1021
+ L.push('# Current Jekyll + the remote-theme plugin (loads ' + (c.f.remote_theme || 'bamr87/zer0-mistakes') + ' from GitHub).');
1022
+ L.push('gem "jekyll", "~> 4.3"');
1023
+ L.push('gem "jekyll-remote-theme"');
1024
+ } else {
1025
+ L.push('# Jekyll and the theme gem, pinned to the installed major.');
1026
+ L.push('gem "jekyll", "~> 4.3"');
1027
+ L.push('gem "jekyll-theme-zer0"');
1028
+ }
1029
+ L.push('');
1030
+ L.push('group :jekyll_plugins do');
1031
+ L.push(' gem "jekyll-feed"');
1032
+ L.push(' gem "jekyll-sitemap"');
1033
+ L.push(' gem "jekyll-seo-tag"');
1034
+ L.push(' gem "jekyll-include-cache"');
1035
+ L.push(' gem "jekyll-paginate"');
1036
+ L.push('end');
1037
+ L.push('');
1038
+ L.push('# Ruby 3+ no longer bundles webrick (needed by `jekyll serve`).');
1039
+ L.push('gem "webrick", "~> 1.8"');
1040
+ L.push('');
1041
+ L.push('platforms :windows, :jruby do');
1042
+ L.push(' gem "tzinfo"');
1043
+ L.push(' gem "tzinfo-data"');
1044
+ L.push('end');
1045
+ return L.join('\n') + '\n';
1046
+ }
1047
+
1048
+ function genCompose(c) {
1049
+ var L = [];
1050
+ L.push('# ' + c.title + ' — development server');
1051
+ L.push('# Generated by the zer0-mistakes Site Builder. Start with: docker compose up');
1052
+ L.push('services:');
1053
+ L.push(' jekyll:');
1054
+ L.push(' image: ruby:3.3');
1055
+ L.push(' working_dir: /site');
1056
+ var lr = c.port + 1;
1057
+ // A folded block (>-) joins these lines with spaces ONLY while every
1058
+ // continuation line keeps the same indentation as the first. Indenting
1059
+ // them further makes YAML preserve the line breaks, and `sh -c` then runs
1060
+ // the trailing options as a separate command — Jekyll starts without the
1061
+ // dev config and binds to 127.0.0.1, unreachable from the host.
1062
+ L.push(' command: >-');
1063
+ L.push(' sh -c "bundle config set --local path vendor/bundle');
1064
+ L.push(' && (bundle check || bundle install --jobs 4 --retry 3)');
1065
+ L.push(' && bundle exec jekyll serve --watch --livereload --livereload-port ' + lr + ' --force_polling');
1066
+ L.push(' --config _config.yml,_config_dev.yml --host 0.0.0.0 --port ' + c.port + '"');
1067
+ L.push(' ports:');
1068
+ L.push(' - "' + c.port + ':' + c.port + '"');
1069
+ L.push(' - "' + lr + ':' + lr + '" # LiveReload on port+1, so two Jekyll sites can run side by side');
1070
+ L.push(' volumes:');
1071
+ L.push(' - .:/site');
1072
+ L.push(' - bundle_cache:/site/vendor/bundle');
1073
+ L.push(' environment:');
1074
+ L.push(' JEKYLL_ENV: development');
1075
+ L.push(' PAGES_REPO_NWO: ' + c.ghUser + '/' + c.repo);
1076
+ L.push(' stdin_open: true');
1077
+ L.push(' tty: true');
1078
+ L.push('');
1079
+ L.push('volumes:');
1080
+ L.push(' bundle_cache:');
1081
+ L.push(' # Shared across every site generated by the Site Builder, so the second');
1082
+ L.push(' # site does not reinstall the same gems. Drop the name for a private cache.');
1083
+ L.push(' name: zer0-sites-bundle-cache');
1084
+ return L.join('\n') + '\n';
1085
+ }
1086
+
1087
+ // ── landing page (plan-driven) ───────────────────────────────────
1088
+
1089
+ var COLLECTION_BLURB = {
1090
+ posts: { title: 'Posts', text: 'Dated writing, newest first, with an RSS feed.', icon: 'bi-journal-text' },
1091
+ docs: { title: 'Guides', text: 'Evergreen documentation with a reading sidebar.', icon: 'bi-book' },
1092
+ notes: { title: 'Notes', text: 'Short, interlinked notes that grow over time.', icon: 'bi-sticky' },
1093
+ quickstart: { title: 'Quickstart', text: 'Ordered steps to get going in minutes.', icon: 'bi-flag' },
1094
+ recipes: { title: 'Recipes', text: 'Structured recipes that scale to your table.', icon: 'bi-egg-fried' },
1095
+ notebooks: { title: 'Notebooks', text: 'Rendered Jupyter notebooks.', icon: 'bi-journal-code' },
1096
+ quests: { title: 'Quests', text: 'Learning paths with levels and badges.', icon: 'bi-controller' },
1097
+ about: { title: 'About', text: 'Who is behind this site and how to reach them.', icon: 'bi-info-circle' }
1098
+ };
1099
+
1100
+ function hasCollection(c, id) { return c.collections.some(function (x) { return x.id === id; }); }
1101
+
1102
+ function firstSentence(text) {
1103
+ var m = String(text || '').trim().match(/^[^.!?]+[.!?]?/);
1104
+ return m ? m[0].trim() : String(text || '').trim();
1105
+ }
1106
+
1107
+ function defaultCtas(c) {
1108
+ var ctas = [];
1109
+ var lead = c.collections.filter(function (x) { return x.id !== 'about'; })[0];
1110
+ if (lead) {
1111
+ var verb = lead.id === 'docs' ? 'Read the docs' : lead.id === 'recipes' ? 'Browse the recipes' : lead.id === 'notes' ? 'Wander the notes' : lead.id === 'quickstart' ? 'Start here' : 'Read the latest';
1112
+ ctas.push({ label: verb, url: '/' + lead.id + '/', variant: 'primary', icon: (COLLECTION_BLURB[lead.id] || {}).icon });
1113
+ }
1114
+ if (hasCollection(c, 'about')) ctas.push({ label: 'About', url: '/about/', variant: 'outline', icon: 'bi-info-circle' });
1115
+ return ctas;
1116
+ }
1117
+
1118
+ function defaultSection(type, c) {
1119
+ var cols = c.collections.filter(function (x) { return x.id !== 'about'; });
1120
+ var brief = c.f.brief || c.f.description || '';
1121
+ var pages = c.plan.pages || [];
1122
+ switch (type) {
1123
+ case 'features': {
1124
+ var items = cols.slice(0, 3).map(function (col) { return Object.assign({}, COLLECTION_BLURB[col.id] || { title: titleCase(col.id), text: '', icon: 'bi-folder' }); });
1125
+ var fill = [{ title: 'Written to last', text: 'Evergreen pages that stay useful, revised when the facts change.', icon: 'bi-hourglass-split' },
1126
+ { title: 'Easy to follow', text: 'Plain language, worked examples, and no assumed jargon.', icon: 'bi-signpost-2' },
1127
+ { title: 'Made in the open', text: 'Every page has a source link; corrections are welcome.', icon: 'bi-github' }];
1128
+ while (items.length < 3) items.push(fill[items.length]);
1129
+ return { type: 'features', id: 'features', heading: 'What you\'ll find here', lead: firstSentence(brief), variant: 'muted', items: items };
233
1130
  }
234
- if (fields.linkedin_username) {
235
- lines.push(pad('linkedin_username') + ': ' + yamlValue(fields.linkedin_username));
1131
+ case 'cards': {
1132
+ var cards = pages.slice(0, 6).map(function (p) { return { title: p.title, text: p.description || '', icon: (COLLECTION_BLURB[p.collection] || {}).icon, url: pageUrl(p) }; });
1133
+ if (!cards.length) cards = cols.slice(0, 3).map(function (col) { var b = COLLECTION_BLURB[col.id] || {}; return { title: b.title || titleCase(col.id), text: b.text || '', icon: b.icon, url: '/' + col.id + '/' }; });
1134
+ return { type: 'cards', id: 'highlights', heading: 'Start with these', lead: '', variant: 'default', items: cards };
236
1135
  }
237
- lines.push('');
238
- }
239
-
240
- // Exclude
241
- lines.push('# ── Exclude from build ─────────────────────────────────────────────');
242
- lines.push('exclude:');
243
- lines.push(' - .sass-cache/');
244
- lines.push(' - .jekyll-cache/');
245
- lines.push(' - node_modules/');
246
- lines.push(' - vendor/');
247
- lines.push(' - Gemfile.lock');
248
- lines.push(' - scripts/');
249
- lines.push(' - test/');
250
- lines.push(' - templates/');
251
- lines.push(' - "*.log"');
1136
+ case 'steps': {
1137
+ var lead = cols[0];
1138
+ return { type: 'steps', id: 'get-started', heading: 'Get started in three steps', lead: '', variant: 'default', items: [
1139
+ { title: 'Start here', text: lead ? 'Open the ' + (COLLECTION_BLURB[lead.id] || {}).title.toLowerCase() + ' and read the first page.' : 'Read the first page.' },
1140
+ { title: 'Follow along', text: 'Each page links to the next; the sidebar keeps your place.' },
1141
+ { title: 'Say hello', text: 'Questions and corrections are welcome on the About page.' }] };
1142
+ }
1143
+ case 'stats':
1144
+ return { type: 'stats', id: 'by-the-numbers', heading: 'By the numbers', variant: 'muted', items: [
1145
+ { value: String(cols.length), label: cols.length === 1 ? 'collection' : 'collections' },
1146
+ { value: String(Math.max(pages.length, 1)), label: pages.length === 1 ? 'page to start with' : 'pages to start with' },
1147
+ { value: '1', label: 'author who reads every comment' }] };
1148
+ case 'faq':
1149
+ return { type: 'faq', id: 'faq', heading: 'Questions people ask', variant: 'default', items: [
1150
+ { title: 'Who is this for?', text: brief || 'Anyone curious about the subject.' },
1151
+ { title: 'How often is it updated?', text: 'Whenever there is something worth writing down. Subscribe to the feed to hear about it.' },
1152
+ { title: 'Can I suggest a correction?', text: 'Yes — every page has a source link and an Improve-this-page button.' }] };
1153
+ case 'cta':
1154
+ return hasCollection(c, 'posts')
1155
+ ? { type: 'cta', id: 'subscribe', heading: 'Stay in the loop', lead: 'New posts land in the feed first.', variant: 'inverse', cta: { label: 'Subscribe via RSS', url: '/feed.xml' } }
1156
+ : { type: 'cta', id: 'contact', heading: 'Questions?', lead: 'Get in touch — details are on the About page.', variant: 'inverse', cta: { label: 'Get in touch', url: '/about/' } };
1157
+ case 'latest_posts':
1158
+ return { type: 'latest_posts', id: 'latest', heading: 'Latest', limit: 3, variant: 'default' };
1159
+ case 'quote':
1160
+ return { type: 'quote', id: 'why', variant: 'muted', items: [{ quote: firstSentence(brief) || 'Made with care.', author: c.f.founder || '' }] };
1161
+ case 'text':
1162
+ default:
1163
+ return { type: 'text', id: 'about-this-site', heading: 'About this site', variant: 'default', body: brief || 'Edit `_data/landing.yml` to change this text.' };
1164
+ }
1165
+ }
252
1166
 
253
- return lines.join('\n');
1167
+ function pageUrl(p) {
1168
+ if (p.collection === 'posts') return '/posts/' + p.slug + '/';
1169
+ return '/' + p.collection + '/' + p.slug + '/';
1170
+ }
1171
+
1172
+ /** Resolve the landing plan: template defaults filled in wherever the plan is silent. */
1173
+ function landingPlan(c) {
1174
+ var tpl = catalogItem('landing_templates', c.plan.landing.template) || catalogItem('landing_templates', 'hero') || { id: 'hero', sections: ['features', 'latest_posts', 'cta'] };
1175
+ var hero = Object.assign({
1176
+ eyebrow: c.f.subtitle || '',
1177
+ headline: c.f.tagline || c.title,
1178
+ subheadline: c.f.description || firstSentence(c.f.brief),
1179
+ align: tpl.id === 'editorial' ? 'start' : 'center',
1180
+ variant: tpl.id === 'showcase' ? 'inverse' : 'default',
1181
+ ctas: defaultCtas(c)
1182
+ }, c.plan.landing.hero || {});
1183
+ var sections = (c.plan.landing.sections || (tpl.sections || []).map(function (t) { return defaultSection(t, c); }))
1184
+ .filter(function (s) { return !(s.type === 'latest_posts' && !hasCollection(c, 'posts')); })
1185
+ .map(function (s, i) { return Object.assign({ id: (s.type || 'section') + '-' + (i + 1) }, s); });
1186
+ return { template: tpl.id, hero: hero, sections: sections };
1187
+ }
1188
+
1189
+ function genLandingData(c) {
1190
+ var lp = landingPlan(c);
1191
+ var head = ['# _data/landing.yml — content for the landing page (index.md renders it).',
1192
+ '# Generated by the zer0-mistakes Site Builder from your site plan. Edit freely:',
1193
+ '# hero.{eyebrow,headline,subheadline,align,variant,ctas[]} and sections[] of type',
1194
+ '# ' + catalog('section_types').map(function (s) { return s.id; }).join(' | ') + '.', ''];
1195
+ return head.join('\n') + toYaml({ template: lp.template, hero: lp.hero, sections: lp.sections });
1196
+ }
1197
+
1198
+ function genIndex(c) {
1199
+ var lp = landingPlan(c);
1200
+ if (lp.template === 'minimal') {
1201
+ var L = ['---', 'layout: home', 'title: ' + y(c.title), 'description: ' + y(c.f.description || ''), 'permalink: /', '---', ''];
1202
+ if (c.f.tagline) { L.push('## ' + c.f.tagline); L.push(''); }
1203
+ L.push(c.f.brief ? c.f.brief.trim() : 'Welcome. This home page is `index.md` — edit it to say hello your way.');
1204
+ L.push('');
1205
+ if (hasCollection(c, 'posts')) {
1206
+ L.push('## Latest', '', '{% for post in site.posts limit: 5 %}', '- [{{ post.title }}]({{ post.url | relative_url }}) — {{ post.date | date: "%B %-d, %Y" }}', '{% endfor %}', '');
1207
+ }
1208
+ return L.join('\n');
1209
+ }
1210
+ // The landing engine: index.md reads _data/landing.yml so the copy can be
1211
+ // edited without touching markup. Only theme includes and Bootstrap classes.
1212
+ return ['---', 'layout: home', 'title: ' + y(c.title), 'description: ' + y(c.f.description || ''), 'permalink: /', 'hide_title: true', 'rss_subscribe: false', '---',
1213
+ '{%- assign landing = site.data.landing -%}',
1214
+ '{%- assign hero = landing.hero -%}',
1215
+ '<section class="zer0-landing-hero py-5{% if hero.variant == "inverse" %} bg-primary text-white{% elsif hero.variant == "muted" %} bg-body-tertiary{% endif %}" data-landing-template="{{ landing.template }}">',
1216
+ ' <div class="container-xl text-{{ hero.align | default: "center" }}">',
1217
+ ' {% if hero.eyebrow %}<p class="text-uppercase small fw-semibold mb-2 opacity-75">{{ hero.eyebrow }}</p>{% endif %}',
1218
+ ' <h1 class="display-4 fw-bold mb-3">{{ hero.headline | default: site.title }}</h1>',
1219
+ ' {% if hero.subheadline %}<p class="lead mb-4 mx-auto" style="max-width: 44rem;">{{ hero.subheadline }}</p>{% endif %}',
1220
+ ' {% if hero.ctas and hero.ctas.size > 0 %}',
1221
+ ' <div class="d-flex flex-wrap gap-2 justify-content-{{ hero.align | default: "center" }}">',
1222
+ ' {%- for cta in hero.ctas -%}',
1223
+ ' {%- assign cta_variant = cta.variant | default: "primary" -%}',
1224
+ ' {%- comment -%} An outlined button is white on an inverse hero and primary-coloured everywhere else. {%- endcomment -%}',
1225
+ ' {%- if cta_variant == "outline" and hero.variant == "inverse" %}{% assign cta_variant = "outline-light" %}{% endif -%}',
1226
+ ' {% include components/cta-button.html label=cta.label url=cta.url variant=cta_variant icon=cta.icon size="lg" %}',
1227
+ ' {%- endfor -%}',
1228
+ ' </div>',
1229
+ ' {% endif %}',
1230
+ // A hero illustration sits beside the headline it illustrates, so it is
1231
+ // decorative: empty alt keeps a screen reader from hearing the headline
1232
+ // twice. Swap in a real description if the picture carries information.
1233
+ ' {% if hero.image %}<img src="{{ hero.image | relative_url }}" alt="" class="zer0-landing-hero-image img-fluid rounded-3 shadow-sm mt-4" loading="lazy" decoding="async">{% endif %}',
1234
+ ' </div>',
1235
+ '</section>',
1236
+ '',
1237
+ '{% for s in landing.sections %}',
1238
+ '{%- capture body -%}',
1239
+ ' {%- case s.type -%}',
1240
+ ' {%- when "features" -%}',
1241
+ ' <div class="row g-4">{% for it in s.items %}<div class="col-md-4"><div class="card h-100 border-0 shadow-sm"><div class="card-body"><i class="bi {{ it.icon | default: "bi-check-circle" }} fs-2 text-primary" aria-hidden="true"></i><h3 class="h5 mt-2">{{ it.title }}</h3><p class="mb-0 text-body-secondary">{{ it.text }}</p></div></div></div>{% endfor %}</div>',
1242
+ ' {%- when "cards" -%}',
1243
+ ' <div class="row g-4">{% for it in s.items %}<div class="col-md-6 col-lg-4"><a class="card h-100 text-decoration-none" href="{{ it.url | default: "#" | relative_url }}"><div class="card-body"><i class="bi {{ it.icon | default: "bi-arrow-right-circle" }} fs-3 text-primary" aria-hidden="true"></i><h3 class="h5 mt-2 card-title">{{ it.title }}</h3><p class="card-text text-body-secondary mb-0">{{ it.text }}</p></div></a></div>{% endfor %}</div>',
1244
+ ' {%- when "steps" -%}',
1245
+ ' <ol class="list-unstyled row g-4 mb-0">{% for it in s.items %}<li class="col-md-4 d-flex gap-3"><span class="badge rounded-pill text-bg-primary fs-6 align-self-start">{{ forloop.index }}</span><div><h3 class="h5 mb-1">{{ it.title }}</h3><p class="mb-0 text-body-secondary">{{ it.text }}</p></div></li>{% endfor %}</ol>',
1246
+ ' {%- when "stats" -%}',
1247
+ ' <div class="row g-4 text-center">{% for it in s.items %}<div class="col-6 col-md-3"><div class="display-5 fw-bold text-primary">{{ it.value }}</div><div class="text-body-secondary">{{ it.label }}</div></div>{% endfor %}</div>',
1248
+ ' {%- when "faq" -%}',
1249
+ ' <div class="accordion" id="faq-{{ forloop.index }}">{% for it in s.items %}<div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button{% unless forloop.first %} collapsed{% endunless %}" type="button" data-bs-toggle="collapse" data-bs-target="#faq-{{ forloop.parentloop.index }}-{{ forloop.index }}">{{ it.title }}</button></h3><div id="faq-{{ forloop.parentloop.index }}-{{ forloop.index }}" class="accordion-collapse collapse{% if forloop.first %} show{% endif %}" data-bs-parent="#faq-{{ forloop.parentloop.index }}"><div class="accordion-body">{{ it.text }}</div></div></div>{% endfor %}</div>',
1250
+ ' {%- when "cta" -%}',
1251
+ ' <div class="text-center">{% if s.cta %}{% include components/cta-button.html label=s.cta.label url=s.cta.url variant="light" size="lg" %}{% endif %}</div>',
1252
+ ' {%- when "latest_posts" -%}',
1253
+ ' <div class="row g-4">{% for post in site.posts limit: s.limit | default: 3 %}<div class="col-md-4"><a class="card h-100 text-decoration-none" href="{{ post.url | relative_url }}"><div class="card-body"><p class="small text-body-secondary mb-1">{{ post.date | date: "%B %-d, %Y" }}</p><h3 class="h5 card-title">{{ post.title }}</h3><p class="card-text text-body-secondary mb-0">{{ post.description | default: post.excerpt | strip_html | truncate: 140 }}</p></div></a></div>{% endfor %}</div>',
1254
+ ' {%- when "quote" -%}',
1255
+ ' {% assign q = s.items | first %}<figure class="text-center mb-0"><blockquote class="blockquote fs-3"><p>“{{ q.quote }}”</p></blockquote>{% if q.author %}<figcaption class="blockquote-footer mt-2">{{ q.author }}</figcaption>{% endif %}</figure>',
1256
+ ' {%- else -%}',
1257
+ ' <div class="mx-auto" style="max-width: 44rem;">{{ s.body | markdownify }}</div>',
1258
+ ' {%- endcase -%}',
1259
+ '{%- endcapture -%}',
1260
+ '{% include components/section.html id=s.id variant=s.variant heading=s.heading lead=s.lead content=body %}',
1261
+ '{% endfor %}', ''].join('\n');
254
1262
  }
255
1263
 
256
- // ── inline validation ──────────────────────────────────────────────
1264
+ // ── theme overrides (palette · fonts · corners) ─────────────────
1265
+
1266
+ function resolvedPalette(c) {
1267
+ var p = c.plan.theme.palette || { preset: 'skin' };
1268
+ if (p.preset === 'custom') return { id: 'custom', primary: p.primary, secondary: p.secondary, accent: p.accent };
1269
+ var preset = catalogItem('palettes', p.preset);
1270
+ return preset && preset.primary ? preset : null;
1271
+ }
1272
+
1273
+ function genUserOverrides(c) {
1274
+ var L = ['/* assets/css/user-overrides.css — loaded LAST by the theme, so anything here wins.',
1275
+ ' Generated by the zer0-mistakes Site Builder from your site plan (palette,',
1276
+ ' fonts, corners). Edit freely; the theme never overwrites this file. */', ''];
1277
+ var pal = resolvedPalette(c);
1278
+ if (pal) {
1279
+ L.push('/* Palette "' + pal.id + '" layered over the "' + (c.f.theme_skin || 'air') + '" skin. */');
1280
+ L.push(':root, html[data-theme-skin], html[data-theme-skin][data-bs-theme] {');
1281
+ L.push(' --bs-primary: ' + pal.primary + ' !important;');
1282
+ L.push(' --bs-primary-rgb: ' + hexToRgb(pal.primary) + ' !important;');
1283
+ L.push(' --zer0-color-primary: ' + pal.primary + ' !important;');
1284
+ L.push(' --bs-link-color: ' + pal.primary + ' !important;');
1285
+ L.push(' --bs-link-color-rgb: ' + hexToRgb(pal.primary) + ' !important;');
1286
+ L.push(' --bs-link-hover-color: ' + shade(pal.primary, -0.18) + ' !important;');
1287
+ L.push(' --bs-secondary: ' + pal.secondary + ' !important;');
1288
+ L.push(' --bs-secondary-rgb: ' + hexToRgb(pal.secondary) + ' !important;');
1289
+ L.push(' --zer0-color-accent: ' + pal.accent + ' !important;');
1290
+ L.push('}');
1291
+ L.push('.btn-primary, .badge.text-bg-primary, .bg-primary {');
1292
+ L.push(' --bs-btn-bg: ' + pal.primary + '; --bs-btn-border-color: ' + pal.primary + ';');
1293
+ L.push(' --bs-btn-hover-bg: ' + shade(pal.primary, -0.12) + '; --bs-btn-hover-border-color: ' + shade(pal.primary, -0.18) + ';');
1294
+ L.push(' --bs-btn-active-bg: ' + shade(pal.primary, -0.2) + '; --bs-btn-active-border-color: ' + shade(pal.primary, -0.25) + ';');
1295
+ L.push(' background-color: ' + pal.primary + ';');
1296
+ L.push('}');
1297
+ L.push('.btn-outline-primary { --bs-btn-color: ' + pal.primary + '; --bs-btn-border-color: ' + pal.primary + '; --bs-btn-hover-bg: ' + pal.primary + '; --bs-btn-hover-border-color: ' + pal.primary + '; --bs-btn-active-bg: ' + pal.primary + '; }');
1298
+ L.push('');
1299
+ }
1300
+ var fp = catalogItem('font_pairings', c.plan.theme.fonts);
1301
+ if (fp && fp.google) {
1302
+ L.push('/* Fonts: ' + fp.label + ' (loaded from Google Fonts by _includes/custom/head.html). */');
1303
+ L.push(':root {');
1304
+ L.push(' --zer0-font-sans: ' + fp.body + ';');
1305
+ L.push(' --bs-body-font-family: ' + fp.body + ';');
1306
+ L.push(' --zer0-font-heading: ' + fp.heading + ';');
1307
+ L.push('}');
1308
+ L.push('body { font-family: var(--bs-body-font-family); }');
1309
+ L.push('h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6, .display-1, .display-2, .display-3, .display-4, .display-5, .display-6, .navbar-brand { font-family: var(--zer0-font-heading); }');
1310
+ L.push('');
1311
+ }
1312
+ var r = catalogItem('radius_options', c.plan.theme.radius);
1313
+ if (r && r.id !== 'soft') {
1314
+ L.push('/* Corners: ' + r.label + '. */');
1315
+ L.push(':root {');
1316
+ L.push(' --zer0-radius-sm: ' + r.sm + '; --zer0-radius: ' + r.base + '; --zer0-radius-md: ' + r.base + '; --zer0-radius-lg: ' + r.lg + '; --zer0-radius-xl: ' + r.xl + ';');
1317
+ L.push(' --bs-border-radius-sm: ' + r.sm + '; --bs-border-radius: ' + r.base + '; --bs-border-radius-lg: ' + r.lg + '; --bs-border-radius-xl: ' + r.xl + ';');
1318
+ L.push('}');
1319
+ L.push('.btn, .card, .form-control, .form-select, .alert, .badge:not(.rounded-pill), .accordion-item, .nav-pills .nav-link { border-radius: var(--bs-border-radius); }');
1320
+ L.push('');
1321
+ }
1322
+ if (!pal && !(fp && fp.google) && !(r && r.id !== 'soft')) L.push('/* Nothing overridden yet — the skin\'s own palette, system fonts and soft corners. */');
1323
+ return L.join('\n') + '\n';
1324
+ }
1325
+
1326
+ /**
1327
+ * `user_overrides: true` makes the theme load BOTH assets/css/user-overrides.css
1328
+ * and assets/js/user-overrides.js (see _includes/components/js-cdn.html). We
1329
+ * always generate the CSS, so without this stub every page of every generated
1330
+ * site fired a 404 for the JS half.
1331
+ */
1332
+ function genUserOverridesJs(c) {
1333
+ return ['// assets/js/user-overrides.js — loaded last on every page of ' + c.title + '.',
1334
+ '//',
1335
+ '// The theme loads this file because `user_overrides: true` is set in',
1336
+ '// _config.yml (the same flag loads assets/css/user-overrides.css, which',
1337
+ '// carries your palette, fonts and corner radius).',
1338
+ '//',
1339
+ '// Put site-specific behaviour here. It runs after the theme\'s own scripts,',
1340
+ '// so you can safely read anything they set up.',
1341
+ '',
1342
+ '(function () {',
1343
+ " 'use strict';",
1344
+ ' // Example: log which skin is active, once, in development.',
1345
+ ' // if (location.hostname === "localhost") {',
1346
+ ' // console.info("skin:", document.documentElement.dataset.themeSkin);',
1347
+ ' // }',
1348
+ '})();',
1349
+ ''].join('\n');
1350
+ }
1351
+
1352
+ function genCustomHead(c) {
1353
+ var fp = catalogItem('font_pairings', c.plan.theme.fonts);
1354
+ if (!fp || !fp.google) return null;
1355
+ return ['{% comment %}', ' _includes/custom/head.html — end-of-<head> hook the theme leaves empty for the site.',
1356
+ ' Generated by the Site Builder to load the "' + fp.label + '" font pairing; assets/css/user-overrides.css applies it.',
1357
+ '{% endcomment %}',
1358
+ '<link rel="preconnect" href="https://fonts.googleapis.com">',
1359
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
1360
+ '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?' + fp.google + '&display=swap">', ''].join('\n');
1361
+ }
1362
+
1363
+ // ── navigation (plan-driven) ─────────────────────────────────────
1364
+
1365
+ function plannedPagesFor(c, collection) {
1366
+ return (c.plan.pages || []).filter(function (p) { return p.collection === collection; });
1367
+ }
1368
+
1369
+ function genNav(c) {
1370
+ var L = ['# Main navigation — rendered by the theme navbar.', '# title, url and an optional Bootstrap icon class; nest `children:` for dropdowns.', ''];
1371
+ if (c.plan.navigation.items) return L.join('\n') + toYaml(c.plan.navigation.items);
1372
+ var grouped = c.plan.navigation.style === 'grouped';
1373
+ var items = c.nav.map(function (item) {
1374
+ var out = { title: item.title || item.url, url: item.url || '/' };
1375
+ if (item.icon) out.icon = item.icon;
1376
+ if (grouped) {
1377
+ var col = (item.url || '').replace(/^\/|\/$/g, '');
1378
+ var children = [];
1379
+ if (col === 'docs') children.push({ title: 'Getting started', url: '/docs/getting-started/' });
1380
+ if (col === 'quickstart') children.push({ title: 'First steps', url: '/quickstart/first-steps/' });
1381
+ plannedPagesFor(c, col).slice(0, 6).forEach(function (p) { children.push({ title: p.title, url: pageUrl(p) }); });
1382
+ if (children.length) out.children = children;
1383
+ }
1384
+ return out;
1385
+ });
1386
+ return L.join('\n') + toYaml(items);
1387
+ }
1388
+
1389
+ function genDocsNav(c) {
1390
+ var children = [{ title: 'Getting started', url: '/docs/getting-started/', icon: 'bi-play-circle' }];
1391
+ plannedPagesFor(c, 'docs').forEach(function (p) { children.push({ title: p.title, url: pageUrl(p), description: p.description || undefined }); });
1392
+ return ['# Curated docs sidebar (used when a docs page has sidebar.nav: docs — set for the whole', '# collection in _config.yml by the Site Builder). Add entries as you write pages.', ''].join('\n')
1393
+ + toYaml([{ title: 'Documentation', icon: 'bi-book', url: '/docs/', expanded: true, children: children }]);
1394
+ }
1395
+
1396
+ // ── planned example pages ────────────────────────────────────────
1397
+
1398
+ function pagePath(p) {
1399
+ if (p.collection === 'posts') return 'pages/_posts/' + (p.date || todayISO()) + '-' + p.slug + '.md';
1400
+ return 'pages/_' + p.collection + '/' + p.slug + '.md';
1401
+ }
1402
+
1403
+ function genPlanPage(c, p) {
1404
+ var fm = ['---', 'title: ' + y(p.title)];
1405
+ if (p.description) fm.push('description: ' + y(p.description));
1406
+ // A plan may pin a date (a back-dated post); anything else is stamped now.
1407
+ fm.push('lastmod: ' + nowISO());
1408
+ if (p.collection === 'posts' || p.collection === 'notes') {
1409
+ fm.splice(fm.length - 1, 0, 'date: ' + (p.date && p.date !== c.date ? p.date + 'T09:00:00.000Z' : nowISO()));
1410
+ }
1411
+ if (p.collection === 'recipes') fm.push('layout: recipe', 'cookbook: ' + c.slug);
1412
+ if (p.collection === 'posts') fm.push('author: ' + y(c.f.founder || 'default'));
1413
+ if (p.categories && p.categories.length) fm.push('categories: [' + p.categories.map(function (x) { return y(x); }).join(', ') + ']');
1414
+ if (p.tags && p.tags.length) fm.push('tags: [' + p.tags.map(function (x) { return y(x); }).join(', ') + ']');
1415
+ fm.push('---', '');
1416
+ var body = (p.body || '').trim() || ('# ' + p.title + '\n\n' + (p.description || firstSentence(c.f.brief) || 'Write this page.') + '\n\n_This page was planned by the Site Builder; replace this draft with the real thing._');
1417
+ return fm.join('\n') + body + '\n';
1418
+ }
1419
+
1420
+ function genAbout(c) {
1421
+ var body = (c.f.about_body || '').trim() || (
1422
+ 'Hi, I am ' + (c.f.founder || 'the author of this site') + '.\n\n' +
1423
+ (c.f.brief ? c.f.brief.trim() + '\n\n' : '') +
1424
+ 'This site is built with [zer0-mistakes](https://github.com/bamr87/zer0-mistakes), a Docker-first Jekyll theme. ' +
1425
+ 'Edit `pages/_about/index.md` to tell your own story.'
1426
+ );
1427
+ var L = ['---', 'title: About', 'description: ' + y('About ' + c.title + (c.f.founder ? ' and ' + c.f.founder : '')), 'permalink: /about/', 'lastmod: ' + c.date, '---', '', '# About', '', body, ''];
1428
+ if (c.f.email) L.push('\nContact: <' + c.f.email + '>\n');
1429
+ return L.join('\n');
1430
+ }
1431
+
1432
+ function genWelcome(c) {
1433
+ var title = c.f.welcome_title || ('Welcome to ' + c.title);
1434
+ var body = (c.f.welcome_body || '').trim() || (
1435
+ 'This is the first post on **' + c.title + '**.' +
1436
+ (c.f.brief ? '\n\n' + c.f.brief.trim() : '') +
1437
+ '\n\nPosts live in `pages/_posts/` and are named `YYYY-MM-DD-title.md`. Delete this one whenever you like.'
1438
+ );
1439
+ return ['---', 'title: ' + y(title), 'description: ' + y(c.f.description || ('The first post on ' + c.title)),
1440
+ 'date: ' + nowISO(), 'lastmod: ' + nowISO(), 'categories: [general]', 'tags: [welcome]',
1441
+ 'author: ' + y(c.f.founder || 'default'), 'preview: /assets/images/previews/welcome.png', '---', '', body, ''].join('\n');
1442
+ }
1443
+
1444
+ function genCollectionIndex(c, col) {
1445
+ var label = labelFor(catalog('collections'), col.id);
1446
+ if (col.id === 'recipes') {
1447
+ // The theme's cookbook layout lists site.recipes grouped by course.
1448
+ var cbTitle = /cookbook|recipes|kitchen/i.test(c.title) ? c.title : c.title + ' Cookbook';
1449
+ return ['---', 'layout: cookbook', 'cookbook: ' + c.slug, 'title: ' + y(cbTitle),
1450
+ 'subtitle: ' + y(c.f.tagline || 'Recipes, scaled to your table'), 'author: ' + y(c.f.founder || ''),
1451
+ 'description: ' + y('Every recipe on ' + c.title + ', with serving scaling and US/metric conversion.'),
1452
+ 'permalink: /recipes/', 'lastmod: ' + c.date, '---', '',
1453
+ 'Recipes live in `pages/_recipes/`. Give each one `layout: recipe`, `cookbook: ' + c.slug + '` and the structured front matter shown in the starter recipe.', ''].join('\n');
1454
+ }
1455
+ return ['---', 'layout: collection', 'collection: ' + col.id, 'title: ' + y(label),
1456
+ 'description: ' + y(label + ' on ' + c.title), 'permalink: /' + col.id + '/', 'sort_order: reverse', '---', '',
1457
+ '# ' + label, '', 'Everything in `pages/_' + col.id + '/`, newest first.', ''].join('\n');
1458
+ }
1459
+
1460
+ // ── sample content, one starter document per enabled collection ─────
1461
+ // Each is a real, valid document for its layout so the very first build has
1462
+ // something on every route and shows the front matter that collection needs.
1463
+
1464
+ function genSampleDoc(c) {
1465
+ return ['---', 'title: "Getting Started"', 'description: ' + y('How to run, edit and publish ' + c.title + '.'),
1466
+ 'lastmod: ' + c.date, 'categories: [docs]', 'tags: [getting-started]', '---', '',
1467
+ '# Getting started', '', 'This is the first document in `pages/_docs/`. Docs render on the `default` layout with the reading sidebar.', '',
1468
+ '## Run the site', '', '```bash', 'docker compose up', '```', '',
1469
+ 'Open <http://localhost:' + c.port + '/>. Edit any file under `pages/` and the page reloads.', '',
1470
+ '## Add a doc', '', 'Create `pages/_docs/<slug>.md` with `title`, `description` and `lastmod` front matter. It appears at `/docs/<slug>/` and in the docs index.', ''].join('\n');
1471
+ }
1472
+
1473
+ function genSampleQuickstart(c) {
1474
+ return ['---', 'title: "First Steps"', 'description: ' + y('The first quickstart step for ' + c.title + '.'),
1475
+ 'lastmod: ' + c.date, 'quickstart:', ' step: 1', ' next: null', ' prev: /quickstart/', '---', '',
1476
+ '# First steps', '', 'Quickstart pages are ordered walkthroughs. Set `quickstart.step`, `quickstart.next` and `quickstart.prev` in the front matter to chain them.', '',
1477
+ '1. Run `docker compose up`.', '2. Edit `_config.yml` and restart the server.', '3. Publish with `gh repo create ' + c.repo + ' --public --source=. --push`.', ''].join('\n');
1478
+ }
1479
+
1480
+ function genSampleNote(c) {
1481
+ return ['---', 'title: "Welcome Note"', 'description: ' + y('The first note in the ' + c.title + ' garden.'),
1482
+ 'date: ' + nowISO(), 'lastmod: ' + nowISO(), 'tags: [meta, welcome]', '---', '',
1483
+ 'Notes are short, evergreen and interlinked. Link to another note with a wiki-link, like [[Getting Started]], and the theme resolves it and shows backlinks on both pages.', '',
1484
+ '> [!tip] Obsidian users', '> Open `pages/_notes/` as a vault; callouts, embeds and `[[links]]` render the same on the site.', ''].join('\n');
1485
+ }
1486
+
1487
+ function genSampleRecipe(c) {
1488
+ return ['---', 'title: "Starter Recipe: Weeknight Tomato Pasta"', 'layout: recipe', 'cookbook: ' + c.slug,
1489
+ 'course: mains', 'cuisine: Italian', 'difficulty: easy',
1490
+ 'description: ' + y('A twenty-minute pantry pasta that shows every field a recipe on ' + c.title + ' can carry.'),
1491
+ 'lastmod: ' + c.date, 'tags: [pasta, quick, vegetarian]', '',
1492
+ 'yield:', ' amount: 2', ' unit: servings', ' singular: serving', '',
1493
+ 'times:', ' prep: 5', ' cook: 15', '',
1494
+ 'equipment:', ' - Large pot', ' - Wide skillet', '',
1495
+ 'ingredients:', ' - group: Pasta', ' items:', ' - item: dried spaghetti', ' qty: 200', ' unit: g',
1496
+ ' - item: salt', ' qty: 1', ' unit: tbsp', ' note: For the pasta water.',
1497
+ ' - group: Sauce', ' items:', ' - item: olive oil', ' qty: 2', ' unit: tbsp',
1498
+ ' - item: garlic cloves', ' singular: garlic clove', ' qty: 2', ' prep: thinly sliced',
1499
+ ' - item: canned whole tomatoes', ' qty: 400', ' unit: g', ' prep: crushed by hand',
1500
+ ' - item: fresh basil', ' qty: 0.25', ' unit: cup', ' prep: torn', '',
1501
+ 'steps:', ' - title: Boil', ' text: >-', ' Bring a large pot of water to the boil, salt it well, and cook the spaghetti one minute short of the packet time.', ' time: 10',
1502
+ ' - title: Sauce', ' text: >-', ' Warm the oil in a wide skillet, soften the garlic without colouring it, add the tomatoes and simmer until glossy.', ' time: 8',
1503
+ ' - title: Finish', ' text: >-', ' Drag the pasta into the sauce with a splash of its water, toss over high heat for a minute, and finish with basil.', ' time: 2', '',
1504
+ 'notes:', ' - Swap the basil for parsley in winter; add chilli flakes with the garlic for heat.', '---', '',
1505
+ 'This starter recipe exists so the cookbook has something to render on day one. Replace it, or copy its front matter for your own recipes — `yield`, `times`, `ingredients` groups and `steps` drive the scaler, the unit switch and the timings bar.', ''].join('\n');
1506
+ }
1507
+
1508
+ /** A one-letter monogram in the skin's colours, so the navbar logo is never a broken image. */
1509
+ function genLogoSvg(c) {
1510
+ var skin = catalog('skins').filter(function (s) { return s.id === (c.f.theme_skin || 'air'); })[0] || { primary: '#e8f4f8', accent: '#6fa8dc' };
1511
+ var letter = (String(c.title).trim().charAt(0) || 'Z').toUpperCase().replace(/[^A-Z0-9]/, 'Z');
1512
+ var dark = /^#([0-9a-f]{2})/i.test(skin.primary) && parseInt(skin.primary.slice(1, 3), 16) < 0x80;
1513
+ return ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="' + escapeXml(c.title) + ' logo">',
1514
+ ' <rect width="64" height="64" rx="14" fill="' + skin.accent + '"/>',
1515
+ ' <text x="32" y="43" text-anchor="middle" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="34" font-weight="700" fill="' + (dark ? '#f8f9fa' : '#1b1f24') + '">' + letter + '</text>',
1516
+ '</svg>', ''].join('\n');
1517
+ }
1518
+
1519
+ function escapeXml(s) {
1520
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1521
+ }
1522
+
1523
+ function genGitignore() {
1524
+ return ['_site/', '.sass-cache/', '.jekyll-cache/', '.jekyll-metadata', 'vendor/', 'Gemfile.lock', 'node_modules/', '.env', '.env.*', '!.env.example', '*.log', '.DS_Store', ''].join('\n');
1525
+ }
1526
+
1527
+ function genInstallYml(c) {
1528
+ var f = c.f;
1529
+ var L = ['# zer0.install.yml — your Site Builder answers, replayable by scripts/bin/install.', '# Keys are never read from this file; they live in your environment / .env.', ''];
1530
+ L.push('profile: ' + (c.remote ? 'github-pages' : 'default'));
1531
+ L.push('site:');
1532
+ L.push(' title: ' + y(c.title));
1533
+ L.push(' description: ' + y(f.description || ''));
1534
+ L.push(' author: ' + y(f.founder || ''));
1535
+ L.push(' email: ' + y(f.email || ''));
1536
+ L.push(' url: ' + y(c.url));
1537
+ L.push('github:');
1538
+ L.push(' user: ' + y(c.ghUser));
1539
+ L.push(' repo: ' + y(c.repo));
1540
+ L.push('theme:');
1541
+ L.push(' source: ' + (c.remote ? 'remote' : 'gem'));
1542
+ L.push(' skin: ' + (f.theme_skin || 'air'));
1543
+ L.push(' color_mode: ' + (f.color_mode_default || 'auto'));
1544
+ L.push('deploy:');
1545
+ L.push(' - github-pages');
1546
+ L.push('collections: [' + c.collections.map(function (x) { return x.id; }).join(', ') + ']');
1547
+ L.push('voice:');
1548
+ L.push(' tone: ' + (f.tone || 'friendly'));
1549
+ L.push(' audience: ' + (f.audience || 'developers'));
1550
+ L.push('agents: [copilot, claude]');
1551
+ L.push('ai:');
1552
+ L.push(' provider: ' + (f.ai_provider === 'xai' ? 'xai' : 'auto'));
1553
+ if (f.image_provider) L.push(' image_provider: ' + f.image_provider);
1554
+ return L.join('\n') + '\n';
1555
+ }
1556
+
1557
+ function genEnvExample(c) {
1558
+ var f = c.f;
1559
+ var L = ['# Copy to .env (git-ignored). Credentials live here, never in _config.yml.', '',
1560
+ '# jekyll-github-metadata', 'PAGES_REPO_NWO=' + c.ghUser + '/' + c.repo, ''];
1561
+ L.push('# AI chat assistant / Site Builder — provider: ' + (f.ai_provider || 'anthropic'));
1562
+ if (f.ai_provider === 'xai') {
1563
+ L.push('# xAI API key from https://console.x.ai/ (Grok chat; also Grok Imagine images)');
1564
+ L.push('# XAI_API_KEY=xai-...');
1565
+ L.push('# CHAT_PROVIDER=xai');
1566
+ } else {
1567
+ L.push('# Claude Code OAuth token from `claude setup-token`, or an Anthropic API key');
1568
+ L.push('# CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...');
1569
+ L.push('# ANTHROPIC_API_KEY=sk-ant-...');
1570
+ }
1571
+ L.push('');
1572
+ if (f.image_provider) {
1573
+ var ip = catalogItem('image_providers', f.image_provider) || {};
1574
+ L.push('# Preview-image renderer (' + (ip.label || f.image_provider) + ')');
1575
+ if (!(f.ai_provider === 'xai' && f.image_provider === 'xai')) L.push('# ' + (ip.env || 'IMAGE_API_KEY') + '=...');
1576
+ else L.push('# (uses XAI_API_KEY above)');
1577
+ L.push('');
1578
+ }
1579
+ L.push('# PostHog (only if enabled in _config.yml)', '# POSTHOG_API_KEY=phc_...', '');
1580
+ return L.join('\n');
1581
+ }
1582
+
1583
+ function genReadme(c) {
1584
+ var L = ['# ' + c.title, ''];
1585
+ if (c.f.description) { L.push(c.f.description); L.push(''); }
1586
+ L.push('Built with [zer0-mistakes](https://github.com/bamr87/zer0-mistakes) (' + (c.remote ? 'remote theme' : 'jekyll-theme-zer0 gem') + '). Generated by the Site Builder on ' + c.date + '.');
1587
+ L.push('');
1588
+ L.push('## Run locally');
1589
+ L.push('');
1590
+ L.push('```bash');
1591
+ L.push('docker compose up # http://localhost:' + c.port + '/');
1592
+ L.push('```');
1593
+ L.push('');
1594
+ L.push('Edit anything under `pages/` and the browser reloads. Site-wide settings live in `_config.yml` (restart the server after changing it).');
1595
+ L.push('');
1596
+ L.push('## Publish to GitHub Pages');
1597
+ L.push('');
1598
+ L.push('```bash');
1599
+ L.push('git init && git add -A && git commit -m "feat: initial site"');
1600
+ L.push('gh repo create ' + c.repo + ' --public --source=. --push');
1601
+ L.push('```');
1602
+ L.push('');
1603
+ L.push('Then in the repository: **Settings → Pages → Source: GitHub Actions** (or the `main` branch). The site appears at `' + c.url + (c.baseurl || '') + '/`.');
1604
+ L.push('');
1605
+ L.push('## Layout');
1606
+ L.push('');
1607
+ L.push('| Path | Purpose |');
1608
+ L.push('| --- | --- |');
1609
+ L.push('| `_config.yml` | production settings |');
1610
+ L.push('| `_config_dev.yml` | local overrides |');
1611
+ L.push('| `_data/navigation/main.yml` | navbar |');
1612
+ c.collections.forEach(function (col) { L.push('| `pages/_' + col.id + '/` | ' + labelFor(catalog('collections'), col.id) + ' |'); });
1613
+ L.push('| `zer0.install.yml` | your Site Builder answers |');
1614
+ L.push('');
1615
+ return L.join('\n');
1616
+ }
1617
+
1618
+ /** Every generated file, in preview order, with overrides applied. */
1619
+ function getFiles() {
1620
+ var c = ctx();
1621
+ var manifest = catalog('generated_files');
1622
+ var files = [];
1623
+ function add(path, content, meta) {
1624
+ var m = meta || {};
1625
+ files.push({
1626
+ path: path,
1627
+ label: m.label || path,
1628
+ description: m.description || '',
1629
+ required: m.required !== false,
1630
+ content: overrides[path] !== undefined ? overrides[path] : content,
1631
+ overridden: overrides[path] !== undefined
1632
+ });
1633
+ }
1634
+ function meta(p) { return manifest.filter(function (m) { return m.path === p; })[0] || { label: p }; }
1635
+
1636
+ add('_config.yml', genConfig(c), meta('_config.yml'));
1637
+ add('_config_dev.yml', genDevConfig(c), meta('_config_dev.yml'));
1638
+ add('Gemfile', genGemfile(c), meta('Gemfile'));
1639
+ add('docker-compose.yml', genCompose(c), meta('docker-compose.yml'));
1640
+ add('index.md', genIndex(c), meta('index.md'));
1641
+ add('_data/navigation/main.yml', genNav(c), meta('_data/navigation/main.yml'));
1642
+ var hasAbout = c.collections.some(function (x) { return x.id === 'about'; });
1643
+ var hasPosts = c.collections.some(function (x) { return x.id === 'posts'; });
1644
+ if (hasAbout) add('pages/_about/index.md', genAbout(c), meta('pages/_about/index.md'));
1645
+ if (hasPosts) add('pages/_posts/' + c.date + '-welcome.md', genWelcome(c), Object.assign({}, meta('pages/_posts/{date}-welcome.md'), { label: 'posts/welcome.md' }));
1646
+ c.collections.forEach(function (col) {
1647
+ if (col.id === 'about') return;
1648
+ add('pages/' + col.id + '.md', genCollectionIndex(c, col), { label: col.id + '.md', description: 'Index page listing the ' + col.id + ' collection.', required: true });
1649
+ if (col.id === 'docs') add('pages/_docs/getting-started.md', genSampleDoc(c), { label: 'docs/getting-started.md', description: 'Starter document for the docs collection.', required: false });
1650
+ if (col.id === 'quickstart') add('pages/_quickstart/first-steps.md', genSampleQuickstart(c), { label: 'quickstart/first-steps.md', description: 'Starter quickstart step.', required: false });
1651
+ if (col.id === 'notes') add('pages/_notes/welcome-note.md', genSampleNote(c), { label: 'notes/welcome-note.md', description: 'Starter note with a wiki-link and a callout.', required: false });
1652
+ if (col.id === 'recipes') add('pages/_recipes/starter-recipe.md', genSampleRecipe(c), { label: 'recipes/starter-recipe.md', description: 'Starter recipe with the structured front matter the recipe layout needs.', required: false });
1653
+ });
1654
+ (c.plan.pages || []).forEach(function (p) {
1655
+ if (!c.collections.some(function (col) { return col.id === p.collection; })) return; // planned for a collection that is off
1656
+ add(pagePath(p), genPlanPage(c, p), { label: p.collection + '/' + p.slug + '.md', description: 'Planned example page' + (p.body ? '.' : ' (draft body — ask the assistant to write it).'), required: false });
1657
+ });
1658
+ if (landingPlan(c).template !== 'minimal') add('_data/landing.yml', genLandingData(c), { label: 'landing.yml', description: 'Hero and sections the landing page renders — edit the copy here.', required: true });
1659
+ if (c.plan.navigation.sidebar === 'docs' && hasCollection(c, 'docs')) add('_data/navigation/docs.yml', genDocsNav(c), { label: 'navigation/docs.yml', description: 'Curated docs sidebar tree.', required: true });
1660
+ add('assets/css/user-overrides.css', genUserOverrides(c), { label: 'user-overrides.css', description: 'Palette, fonts and corners layered over the skin. The theme links this file on every page.', required: true });
1661
+ add('assets/js/user-overrides.js', genUserOverridesJs(c), { label: 'user-overrides.js', description: 'Site-specific JavaScript. The theme loads it alongside the CSS whenever user_overrides is on.', required: true });
1662
+ var customHead = genCustomHead(c);
1663
+ if (customHead) add('_includes/custom/head.html', customHead, { label: 'custom/head.html', description: 'Loads the chosen web fonts through the theme\'s head hook.', required: false });
1664
+ add('assets/images/logo.svg', genLogoSvg(c), { label: 'logo.svg', description: 'Monogram logo in the skin colours; replace with your own.', required: false });
1665
+ add('.gitignore', genGitignore(), meta('.gitignore'));
1666
+ add('zer0.install.yml', genInstallYml(c), meta('zer0.install.yml'));
1667
+ add('.env.example', genEnvExample(c), meta('.env.example'));
1668
+ add('README.md', genReadme(c), meta('README.md'));
1669
+ return files;
1670
+ }
1671
+
1672
+ function getFile(path) {
1673
+ return getFiles().filter(function (f) { return f.path === path; })[0] || null;
1674
+ }
1675
+
1676
+ /** Self-extracting bash bundle: recreates every file under $1 (default slug). */
1677
+ function buildBundle() {
1678
+ var c = ctx();
1679
+ var L = ['#!/usr/bin/env bash', '# ' + c.title + ' — site bundle generated by the zer0-mistakes Site Builder (' + c.date + ')',
1680
+ '# Usage: bash zer0-site-bundle.sh [folder] → creates the folder and every file, then prints next steps.',
1681
+ 'set -euo pipefail', 'TARGET="${1:-' + c.slug + '}"', 'mkdir -p "$TARGET" && cd "$TARGET"', ''];
1682
+ getFiles().forEach(function (f) {
1683
+ var dir = f.path.indexOf('/') !== -1 ? f.path.slice(0, f.path.lastIndexOf('/')) : '';
1684
+ if (dir) L.push('mkdir -p ' + shellQuote(dir));
1685
+ L.push('cat > ' + shellQuote(f.path) + " <<'ZER0_EOF'");
1686
+ L.push(f.content.replace(/\n$/, ''));
1687
+ L.push('ZER0_EOF');
1688
+ L.push('');
1689
+ });
1690
+ L.push('echo "Created $(ls -1A | wc -l | tr -d \' \') entries in $TARGET"');
1691
+ L.push('echo "Next: cd $TARGET && docker compose up → http://localhost:' + c.port + '/"');
1692
+ return L.join('\n') + '\n';
1693
+ }
1694
+
1695
+ function shellQuote(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
1696
+
1697
+ // ── inline validation ────────────────────────────────────────────
257
1698
 
258
1699
  var VALIDATION_MESSAGES = {
259
1700
  email: 'Enter a valid email address, e.g. you@example.com.',
260
1701
  url: 'Enter a full URL including https://, e.g. https://example.com.'
261
1702
  };
262
1703
 
263
- /**
264
- * Validate one field. Empty is always OK — nothing in this wizard is
265
- * required, and flagging an untouched field as an error would be wrong.
266
- * Returns true when the field is acceptable.
267
- */
268
1704
  function validateField(el) {
269
1705
  if (!el || !el.classList.contains('cfg-field')) return true;
270
1706
  var type = (el.getAttribute('type') || '').toLowerCase();
271
1707
  if (type !== 'email' && type !== 'url') return true;
272
-
273
1708
  var ok = el.value.trim() === '' || el.checkValidity();
274
1709
  el.classList.toggle('is-invalid', !ok);
275
-
276
1710
  var feedback = document.getElementById(el.id + '-feedback');
277
1711
  if (feedback) feedback.textContent = ok ? '' : VALIDATION_MESSAGES[type];
278
1712
  return ok;
279
1713
  }
280
1714
 
281
- /** Is every field inside this pane currently acceptable? */
282
1715
  function paneIsValid(pane) {
283
1716
  if (!pane) return true;
284
1717
  var fields = pane.querySelectorAll('.cfg-field');
@@ -291,57 +1724,45 @@
291
1724
  return true;
292
1725
  }
293
1726
 
294
- /** The recommended-but-empty fields, by their human label. */
295
1727
  function unfilledRecommended() {
296
1728
  var missing = [];
297
- document.querySelectorAll('[data-recommended]').forEach(function (el) {
1729
+ wizard.querySelectorAll('[data-recommended]').forEach(function (el) {
298
1730
  if (el.value.trim() === '') missing.push(el.getAttribute('data-recommended'));
299
1731
  });
300
1732
  return missing;
301
1733
  }
302
1734
 
303
- /** Render the Review step's warning list. */
304
1735
  function renderReviewWarnings() {
305
1736
  var host = document.getElementById('wizard-review-warnings');
306
1737
  if (!host) return;
307
1738
  var missing = unfilledRecommended();
308
-
1739
+ host.innerHTML = '';
1740
+ var alert = document.createElement('div');
1741
+ alert.setAttribute('role', 'alert');
309
1742
  if (!missing.length) {
310
- host.innerHTML = '<div class="alert alert-success mb-0" role="alert">' +
311
- '<i class="bi bi-check2-circle"></i> Every recommended field is filled in.</div>';
1743
+ alert.className = 'alert alert-success mb-0';
1744
+ alert.innerHTML = '<i class="bi bi-check2-circle" aria-hidden="true"></i> Every recommended field is filled in. The project below is ready to write.';
1745
+ host.appendChild(alert);
312
1746
  return;
313
1747
  }
314
-
315
- var items = missing.map(function (name) {
316
- // Labels come from our own markup, but build the DOM through
317
- // textContent-equivalent escaping anyway rather than interpolating.
1748
+ alert.className = 'alert alert-warning mb-0';
1749
+ alert.innerHTML = '<i class="bi bi-exclamation-triangle" aria-hidden="true"></i> <strong>Recommended fields still empty.</strong> The files are valid without them, but filling them in improves SEO and the site chrome:';
1750
+ var ul = document.createElement('ul');
1751
+ ul.className = 'mb-0 mt-2';
1752
+ missing.forEach(function (name) {
318
1753
  var li = document.createElement('li');
319
1754
  li.textContent = name;
320
- return li.outerHTML;
321
- }).join('');
322
-
323
- host.innerHTML = '<div class="alert alert-warning mb-0" role="alert">' +
324
- '<i class="bi bi-exclamation-triangle"></i> ' +
325
- '<strong>Recommended fields still empty.</strong> The file is valid without them, ' +
326
- 'but filling them in improves SEO and the site chrome:' +
327
- '<ul class="mb-0 mt-2">' + items + '</ul></div>';
1755
+ ul.appendChild(li);
1756
+ });
1757
+ alert.appendChild(ul);
1758
+ host.appendChild(alert);
328
1759
  }
329
1760
 
330
- // ── stepper state ──────────────────────────────────────────────────
1761
+ // ── stepper state ────────────────────────────────────────────────
331
1762
 
332
- /**
333
- * Recompute done / active / locked across the vertical stepper.
334
- *
335
- * Rule: a step is reachable when every EARLIER step validates. That makes
336
- * "Back is always allowed" fall out for free (earlier steps are, by
337
- * definition, behind a prefix that already validated) while stopping the
338
- * user from carrying a malformed email or URL forward.
339
- */
340
1763
  function refreshStepper() {
341
1764
  var buttons = STEP_IDS.map(function (id) { return document.getElementById(id); });
342
- var activeIndex = buttons.findIndex(function (b) {
343
- return b && b.classList.contains('is-active');
344
- });
1765
+ var activeIndex = buttons.findIndex(function (b) { return b && b.classList.contains('is-active'); });
345
1766
  if (activeIndex < 0) activeIndex = 0;
346
1767
 
347
1768
  var blocked = false;
@@ -349,43 +1770,214 @@
349
1770
  if (!btn) return;
350
1771
  var pane = document.querySelector(btn.getAttribute('data-bs-target'));
351
1772
  var valid = paneIsValid(pane);
352
-
353
1773
  var locked = blocked && i > activeIndex;
354
1774
  btn.classList.toggle('is-active', i === activeIndex);
355
1775
  btn.classList.toggle('is-done', i < activeIndex && valid);
356
1776
  btn.classList.toggle('is-locked', locked);
357
1777
  btn.disabled = locked;
358
1778
  btn.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
359
-
360
1779
  var state = btn.querySelector('.wizard-step-state');
361
1780
  if (state) {
362
1781
  state.textContent = i === activeIndex ? 'current step'
363
1782
  : locked ? 'locked until earlier steps are valid'
364
1783
  : i < activeIndex && valid ? 'completed' : '';
365
1784
  }
366
-
367
1785
  if (!valid) blocked = true;
368
1786
  });
369
1787
 
370
- // A Next button cannot skip past a step that has not validated.
371
- document.querySelectorAll('.btn-next').forEach(function (btn) {
1788
+ wizard.querySelectorAll('.btn-next').forEach(function (btn) {
372
1789
  var target = document.getElementById(btn.getAttribute('data-next'));
373
1790
  btn.disabled = !!(target && target.disabled);
374
1791
  });
1792
+
1793
+ var bar = document.getElementById('wizard-progress-bar');
1794
+ var text = document.getElementById('wizard-progress-text');
1795
+ var pct = Math.round(((activeIndex + 1) / STEP_IDS.length) * 100);
1796
+ if (bar) bar.style.width = pct + '%';
1797
+ if (text) text.textContent = 'Step ' + (activeIndex + 1) + ' of ' + STEP_IDS.length;
375
1798
  }
376
1799
 
377
- // ── draft persistence ──────────────────────────────────────────────
1800
+ function showStep(btn, opts) {
1801
+ if (typeof btn === 'string') btn = document.getElementById(/^tab-/.test(btn) ? btn : 'tab-' + btn);
1802
+ if (!btn || btn.disabled) return false;
1803
+ wizard.querySelectorAll('#wizardTabs .wizard-step').forEach(function (b) { b.classList.remove('is-active'); });
1804
+ btn.classList.add('is-active');
1805
+ if (window.bootstrap && window.bootstrap.Tab) new bootstrap.Tab(btn).show();
1806
+ else {
1807
+ wizard.querySelectorAll('.wizard-panes .tab-pane').forEach(function (p) { p.classList.remove('show', 'active'); });
1808
+ var pane = document.querySelector(btn.getAttribute('data-bs-target'));
1809
+ if (pane) pane.classList.add('show', 'active');
1810
+ }
1811
+ refreshStepper();
1812
+ if (btn.id === 'tab-build') renderReviewWarnings();
1813
+ if (!(opts && opts.silent)) {
1814
+ try { history.replaceState(null, '', '#' + btn.getAttribute('data-bs-target').slice(1)); } catch (e) { /* ignore */ }
1815
+ }
1816
+ emit('step', { step: btn.id.replace(/^tab-/, '') });
1817
+ saveDraft();
1818
+ return true;
1819
+ }
378
1820
 
379
- function collectDraft() {
380
- var data = { fields: {}, checks: {}, step: null };
381
- document.querySelectorAll('#setup-wizard .cfg-field').forEach(function (el) {
382
- if (el.id) data.fields[el.id] = el.value;
1821
+ // ── prerequisites UI ─────────────────────────────────────────────
1822
+
1823
+ function detectOS() {
1824
+ var p = (navigator.platform || '') + ' ' + (navigator.userAgent || '');
1825
+ if (/Mac|iPhone|iPad/i.test(p)) return 'macos';
1826
+ if (/Win/i.test(p)) return 'windows';
1827
+ return 'linux';
1828
+ }
1829
+
1830
+ function applyOS(os) {
1831
+ wizard.querySelectorAll('.prereq-install .wizard-cmd[data-os]').forEach(function (el) {
1832
+ el.hidden = el.getAttribute('data-os') !== os;
1833
+ });
1834
+ var radio = document.getElementById('prereq-os-' + os);
1835
+ if (radio) radio.checked = true;
1836
+ }
1837
+
1838
+ function renderPrereqs() {
1839
+ var items = wizard.querySelectorAll('.prereq-item');
1840
+ var requiredTotal = 0;
1841
+ var requiredReady = 0;
1842
+ items.forEach(function (li) {
1843
+ var id = li.getAttribute('data-prereq');
1844
+ var c = checks[id] || {};
1845
+ var manual = document.getElementById('prereq-' + id);
1846
+ var isManual = !!(c.manual || (manual && manual.checked));
1847
+ var state = isManual ? 'manual' : c.ok === true ? 'ok' : c.ok === false ? 'fail' : 'unknown';
1848
+ li.setAttribute('data-state', state);
1849
+ var badge = li.querySelector('.prereq-state-badge');
1850
+ if (badge) {
1851
+ badge.className = 'badge rounded-pill prereq-state-badge ' + (
1852
+ state === 'ok' ? 'text-bg-success' : state === 'fail' ? 'text-bg-danger' : state === 'manual' ? 'text-bg-info' : 'text-bg-light border');
1853
+ badge.textContent = state === 'ok' ? ('ready' + (c.version ? ' · v' + c.version : ''))
1854
+ : state === 'fail' ? (c.installed === false ? 'not installed' : 'not ready')
1855
+ : state === 'manual' ? 'done (manual)' : 'not checked';
1856
+ }
1857
+ var out = li.querySelector('.prereq-output');
1858
+ if (out) {
1859
+ var text = c.output ? String(c.output).split('\n').slice(0, 3).join(' · ') : '';
1860
+ out.textContent = text;
1861
+ out.hidden = !text;
1862
+ }
1863
+ if (li.getAttribute('data-required') === 'true') {
1864
+ requiredTotal += 1;
1865
+ if (state === 'ok' || state === 'manual') requiredReady += 1;
1866
+ }
1867
+ });
1868
+ var summary = document.getElementById('prereq-summary');
1869
+ if (summary) summary.textContent = requiredReady + ' of ' + requiredTotal + ' required ready';
1870
+ }
1871
+
1872
+ // ── file preview ─────────────────────────────────────────────────
1873
+
1874
+ /**
1875
+ * Keep the file tabs in sync with the generated set.
1876
+ *
1877
+ * Updates the existing buttons IN PLACE whenever the list of paths is
1878
+ * unchanged. Detaching and re-appending them (the obvious rebuild) breaks a
1879
+ * real click: pressing a tab blurs a text field, the field's `change` event
1880
+ * regenerates the preview, and if that rebuild removes the button under the
1881
+ * pointer between mousedown and mouseup the browser never fires `click`.
1882
+ */
1883
+ function renderFileTabs(files) {
1884
+ var host = document.getElementById('wizard-file-tabs');
1885
+ if (!host) return;
1886
+ var current = Array.prototype.map.call(host.querySelectorAll('.wizard-file-tab'), function (b) { return b.getAttribute('data-file'); });
1887
+ var wanted = files.map(function (f) { return f.path; });
1888
+ var samePaths = current.length === wanted.length && current.every(function (p, i) { return p === wanted[i]; });
1889
+
1890
+ function decorate(b, f) {
1891
+ b.type = 'button';
1892
+ b.className = 'nav-link wizard-file-tab' + (f.path === activeFile ? ' active' : '') + (f.overridden ? ' is-overridden' : '');
1893
+ b.setAttribute('role', 'tab');
1894
+ b.setAttribute('data-file', f.path);
1895
+ b.setAttribute('title', f.description || f.path);
1896
+ b.setAttribute('aria-selected', f.path === activeFile ? 'true' : 'false');
1897
+ if (b.textContent !== f.label) b.textContent = f.label;
1898
+ }
1899
+
1900
+ if (samePaths) {
1901
+ var buttons = host.querySelectorAll('.wizard-file-tab');
1902
+ files.forEach(function (f, i) { decorate(buttons[i], f); });
1903
+ return;
1904
+ }
1905
+
1906
+ var existing = {};
1907
+ host.querySelectorAll('.wizard-file-tab').forEach(function (b) { existing[b.getAttribute('data-file')] = b; });
1908
+ host.innerHTML = '';
1909
+ files.forEach(function (f) {
1910
+ var b = existing[f.path] || document.createElement('button');
1911
+ decorate(b, f);
1912
+ host.appendChild(b);
383
1913
  });
384
- document.querySelectorAll('#setup-wizard input[type="checkbox"]').forEach(function (el) {
385
- if (el.id) data.checks[el.id] = el.checked;
1914
+ }
1915
+
1916
+ function updatePreview() {
1917
+ var files = getFiles();
1918
+ if (!files.some(function (f) { return f.path === activeFile; })) activeFile = files[0].path;
1919
+ renderFileTabs(files);
1920
+ var file = files.filter(function (f) { return f.path === activeFile; })[0];
1921
+ var preview = document.getElementById('yaml-preview');
1922
+ if (preview && file) {
1923
+ preview.textContent = file.content;
1924
+ preview.parentElement.setAttribute('data-file', file.path);
1925
+ }
1926
+ var desc = document.getElementById('wizard-file-description');
1927
+ if (desc && file) desc.textContent = (file.overridden ? 'Edited by hand or by Claude. ' : '') + (file.description || '');
1928
+ emit('files', { active: activeFile, count: files.length });
1929
+ }
1930
+
1931
+ function refreshAll() {
1932
+ syncSubfields();
1933
+ syncCommands();
1934
+ updatePreview();
1935
+ refreshStepper();
1936
+ renderPrereqs();
1937
+ updateLandingSummary();
1938
+ updateFontPreview();
1939
+ applyPreview();
1940
+ var count = document.getElementById('pages-count');
1941
+ if (count) count.textContent = String(planExtra.pages.length);
1942
+ if (activeStepId() === 'tab-build') renderReviewWarnings();
1943
+ }
1944
+
1945
+ function syncSubfields() {
1946
+ wizard.querySelectorAll('.wizard-subfields[data-for]').forEach(function (block) {
1947
+ var sw = document.getElementById(block.getAttribute('data-for'));
1948
+ block.hidden = !(sw && sw.checked);
386
1949
  });
387
- var active = document.querySelector('#wizardTabs .wizard-step.is-active');
388
- if (active) data.step = active.id;
1950
+ var iconInput = document.getElementById('cfg-title-icon');
1951
+ var iconPreview = document.getElementById('cfg-title-icon-preview');
1952
+ if (iconInput && iconPreview) {
1953
+ var name = (iconInput.value || 'robot').replace(/^bi-/, '').replace(/[^a-z0-9-]/gi, '');
1954
+ iconPreview.className = 'bi bi-' + (name || 'robot');
1955
+ }
1956
+ }
1957
+
1958
+ function syncCommands() {
1959
+ var c = ctx();
1960
+ var set = function (id, text) { var el = document.getElementById(id); if (el) el.textContent = text; };
1961
+ set('cmd-bundle', 'bash zer0-site-bundle.sh ' + c.slug);
1962
+ set('cmd-up', 'cd ' + c.slug + ' && docker compose up');
1963
+ set('cmd-publish', 'gh repo create ' + c.repo + ' --public --source=. --push');
1964
+ var local = 'http://localhost:' + c.port + '/';
1965
+ ['link-local', 'btn-open-site'].forEach(function (id) {
1966
+ var a = document.getElementById(id);
1967
+ if (a) { a.href = local; if (id === 'link-local') a.textContent = local; }
1968
+ });
1969
+ var target = document.getElementById('cfg-target');
1970
+ if (target && !target.value && !restoring) target.placeholder = c.slug;
1971
+ }
1972
+
1973
+ // ── draft persistence ────────────────────────────────────────────
1974
+
1975
+ function collectDraft() {
1976
+ var data = { v: DRAFT_VERSION, fields: {}, checks: {}, radios: {}, step: null, navigation: readNavigation(), overrides: overrides, prereqs: checks, activeFile: activeFile, planExtra: planExtra };
1977
+ wizard.querySelectorAll('.cfg-field').forEach(function (el) { if (el.id) data.fields[el.id] = el.value; });
1978
+ wizard.querySelectorAll('input[type="checkbox"]').forEach(function (el) { if (el.id) data.checks[el.id] = el.checked; });
1979
+ wizard.querySelectorAll('.cfg-radio:checked').forEach(function (el) { data.radios[el.name] = el.value; });
1980
+ data.step = activeStepId();
389
1981
  return data;
390
1982
  }
391
1983
 
@@ -399,10 +1991,7 @@
399
1991
  }
400
1992
 
401
1993
  function writeDraft() {
402
- try {
403
- localStorage.setItem(DRAFT_KEY, JSON.stringify(collectDraft()));
404
- return true;
405
- } catch (e) { /* private mode or quota — the wizard still works */ }
1994
+ try { localStorage.setItem(DRAFT_KEY, JSON.stringify(collectDraft())); return true; } catch (e) { /* private mode / quota */ }
406
1995
  return false;
407
1996
  }
408
1997
 
@@ -415,18 +2004,6 @@
415
2004
  }, DRAFT_DEBOUNCE_MS);
416
2005
  }
417
2006
 
418
- /**
419
- * Write a PENDING debounced draft out immediately.
420
- *
421
- * The 300ms debounce means the last edit before the page goes away is still
422
- * only a queued timer, and a timer does not survive navigation: check a
423
- * collection box and hit reload inside that window and the change is simply
424
- * lost, even though the chip may already have acknowledged an EARLIER save.
425
- * `pagehide` and the hidden `visibilitychange` are the two events that still
426
- * fire reliably on reload, tab close and bfcache eviction, so the flush hangs
427
- * off both. No-op when nothing is queued, so it can never resurrect a draft
428
- * that `clearDraft()` has just removed.
429
- */
430
2007
  function flushDraft() {
431
2008
  if (draftTimer === null || restoring) return;
432
2009
  clearTimeout(draftTimer);
@@ -436,7 +2013,7 @@
436
2013
 
437
2014
  function clearDraft() {
438
2015
  clearTimeout(draftTimer);
439
- draftTimer = null; // …so a later flush cannot write the draft back
2016
+ draftTimer = null;
440
2017
  try { localStorage.removeItem(DRAFT_KEY); } catch (e) { /* ignore */ }
441
2018
  var chip = document.getElementById('wizard-draft-chip');
442
2019
  if (chip) { chip.classList.remove('is-visible'); chip.hidden = true; }
@@ -446,7 +2023,6 @@
446
2023
  var raw;
447
2024
  try { raw = localStorage.getItem(DRAFT_KEY); } catch (e) { return; }
448
2025
  if (!raw) return;
449
-
450
2026
  var data;
451
2027
  try { data = JSON.parse(raw); } catch (e) { clearDraft(); return; }
452
2028
  if (!data || typeof data !== 'object') return;
@@ -460,150 +2036,325 @@
460
2036
  var el = document.getElementById(id);
461
2037
  if (el && el.type === 'checkbox') el.checked = !!data.checks[id];
462
2038
  });
2039
+ Object.keys(data.radios || {}).forEach(function (name) {
2040
+ var el = wizard.querySelector('.cfg-radio[name="' + name + '"][value="' + String(data.radios[name]).replace(/"/g, '') + '"]');
2041
+ if (el) el.checked = true;
2042
+ });
2043
+ if (Array.isArray(data.navigation) && data.navigation.length) renderNavEditor(data.navigation);
2044
+ if (data.overrides && typeof data.overrides === 'object') overrides = data.overrides;
2045
+ if (data.prereqs && typeof data.prereqs === 'object') checks = data.prereqs;
2046
+ if (typeof data.activeFile === 'string') activeFile = data.activeFile;
2047
+ if (data.planExtra && typeof data.planExtra === 'object') {
2048
+ planExtra = {
2049
+ landing: Object.assign({ hero: null, sections: null }, data.planExtra.landing || {}),
2050
+ navigation: Object.assign({ items: null }, data.planExtra.navigation || {}),
2051
+ pages: Array.isArray(data.planExtra.pages) ? data.planExtra.pages.map(normalizePage) : []
2052
+ };
2053
+ renderPagesPlanner();
2054
+ }
463
2055
 
464
- // Re-enter the step the user left off on, but only if the draft still
465
- // validates up to there — otherwise a saved bad value could strand them
466
- // on a step the stepper would refuse to unlock.
467
- var target = data.step && STEP_IDS.indexOf(data.step) !== -1
468
- ? document.getElementById(data.step) : null;
469
- if (target && !target.disabled) showStep(target);
470
-
2056
+ var target = data.step && STEP_IDS.indexOf(data.step) !== -1 ? document.getElementById(data.step) : null;
2057
+ if (target && !target.disabled) showStep(target, { silent: true });
471
2058
  restoring = false;
472
2059
  }
473
2060
 
474
- // ── actions ────────────────────────────────────────────────────────
475
-
476
- function updatePreview() {
477
- var preview = document.getElementById('yaml-preview');
478
- if (preview) {
479
- preview.textContent = buildYAML();
480
- }
2061
+ function resetAll() {
2062
+ overrides = {};
2063
+ checks = {};
2064
+ planExtra = { landing: { hero: null, sections: null }, navigation: { items: null }, pages: [] };
2065
+ renderPagesPlanner();
2066
+ var previewBtn = document.getElementById('btn-skin-preview');
2067
+ if (previewBtn && previewBtn.getAttribute('aria-pressed') === 'true') toggleSkinPreview(previewBtn);
2068
+ wizard.querySelectorAll('.cfg-field').forEach(function (el) {
2069
+ if (el.tagName === 'SELECT') el.selectedIndex = 0;
2070
+ else if (el.id === 'cfg-port') el.value = String(DEFAULT_PORT);
2071
+ else if (el.id === 'cfg-remote-theme') el.value = 'bamr87/zer0-mistakes';
2072
+ else if (el.id === 'cfg-title-icon') el.value = 'robot';
2073
+ else el.value = '';
2074
+ el.classList.remove('is-invalid');
2075
+ });
2076
+ wizard.querySelectorAll('.cfg-collection').forEach(function (el) { el.checked = ['posts', 'docs', 'about'].indexOf(el.getAttribute('data-col')) !== -1; });
2077
+ wizard.querySelectorAll('.cfg-switch').forEach(function (el) { el.checked = el.hasAttribute('checked'); });
2078
+ wizard.querySelectorAll('.prereq-manual').forEach(function (el) { el.checked = false; });
2079
+ wizard.querySelectorAll('.cfg-radio').forEach(function (el) { el.checked = el.hasAttribute('checked'); });
2080
+ renderNavEditor(defaultNavigation());
2081
+ activeFile = '_config.yml';
2082
+ showStep(STEP_IDS[0]);
2083
+ refreshAll();
2084
+ // Last, so the step change above cannot queue a fresh draft write.
2085
+ clearDraft();
2086
+ emit('reset', {});
481
2087
  }
482
2088
 
483
- /** Activate a step button, updating stepper state alongside Bootstrap's. */
484
- function showStep(btn) {
485
- if (!btn || btn.disabled) return;
486
- document.querySelectorAll('#wizardTabs .wizard-step').forEach(function (b) {
487
- b.classList.remove('is-active');
488
- });
489
- btn.classList.add('is-active');
490
- if (window.bootstrap && window.bootstrap.Tab) new bootstrap.Tab(btn).show();
491
- refreshStepper();
492
- if (btn.id === 'tab-review') renderReviewWarnings();
2089
+ // ── actions ──────────────────────────────────────────────────────
2090
+
2091
+ function toast(message) {
2092
+ var el = document.getElementById('wizard-toast');
2093
+ var body = document.getElementById('wizard-toast-body');
2094
+ if (body) body.textContent = message;
2095
+ if (el && window.bootstrap && window.bootstrap.Toast) bootstrap.Toast.getOrCreateInstance(el, { delay: 1800 }).show();
493
2096
  }
494
2097
 
495
- function downloadYAML() {
496
- var yaml = buildYAML();
497
- var blob = new Blob([yaml], { type: 'text/yaml;charset=utf-8' });
2098
+ function downloadText(name, text, mime) {
2099
+ var blob = new Blob([text], { type: (mime || 'text/plain') + ';charset=utf-8' });
498
2100
  var url = URL.createObjectURL(blob);
499
2101
  var a = document.createElement('a');
500
2102
  a.href = url;
501
- a.download = '_config.yml';
2103
+ a.download = name;
502
2104
  document.body.appendChild(a);
503
2105
  a.click();
504
2106
  document.body.removeChild(a);
505
2107
  URL.revokeObjectURL(url);
506
- // The draft exists to survive a reload mid-wizard. Once the file is in the
507
- // user's hands the wizard is finished, so the draft is stale, not useful.
508
- clearDraft();
509
2108
  }
510
2109
 
511
- function copyYAML() {
512
- var yaml = buildYAML();
513
- navigator.clipboard.writeText(yaml).then(function () {
514
- var btn = document.getElementById('btn-copy-full') || document.getElementById('btn-copy');
2110
+ function downloadActive() {
2111
+ var file = getFile(activeFile);
2112
+ if (!file) return;
2113
+ downloadText(file.path.split('/').pop(), file.content, /\.ya?ml$/.test(file.path) ? 'text/yaml' : 'text/plain');
2114
+ toast('Downloaded ' + file.path);
2115
+ }
2116
+
2117
+ function downloadBundle() {
2118
+ downloadText('zer0-site-bundle.sh', buildBundle(), 'text/x-shellscript');
2119
+ toast('Bundle downloaded — run it with bash');
2120
+ }
2121
+
2122
+ function copyText(text, label) {
2123
+ if (!navigator.clipboard) return Promise.reject(new Error('clipboard unavailable'));
2124
+ return navigator.clipboard.writeText(text).then(function () { toast(label || 'Copied'); });
2125
+ }
2126
+
2127
+ function copyActive() {
2128
+ var file = getFile(activeFile);
2129
+ if (!file) return;
2130
+ copyText(file.content, 'Copied ' + file.path).then(function () {
2131
+ var btn = document.getElementById('btn-copy-full');
515
2132
  if (btn) {
516
2133
  var orig = btn.innerHTML;
517
- btn.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
2134
+ btn.innerHTML = '<i class="bi bi-check-lg" aria-hidden="true"></i> Copied!';
518
2135
  setTimeout(function () { btn.innerHTML = orig; }, 2000);
519
2136
  }
520
- });
2137
+ }).catch(function () { /* no clipboard */ });
521
2138
  }
522
2139
 
523
- // ── wiring ─────────────────────────────────────────────────────────
2140
+ function suggestUrls() {
2141
+ var f = readFields();
2142
+ var user = f.github_user || '';
2143
+ var repo = f.repository_name || '';
2144
+ if (!user) return;
2145
+ var patch = { url: 'https://' + user + '.github.io' };
2146
+ patch.baseurl = repo && !/\.github\.io$/i.test(repo) ? '/' + repo : '';
2147
+ applyFields(patch, { source: 'suggest' });
2148
+ toast('URL and base path filled from GitHub fields');
2149
+ }
524
2150
 
525
- document.addEventListener('DOMContentLoaded', function () {
526
- var wizard = document.getElementById('setup-wizard');
527
- if (!wizard) return;
2151
+ /**
2152
+ * "Preview on this page": apply the chosen skin AND the generated
2153
+ * user-overrides.css (palette, fonts, corners) to the wizard page itself,
2154
+ * so the appearance step shows exactly what the new site will ship.
2155
+ */
2156
+ function toggleSkinPreview(btn) {
2157
+ var html = document.documentElement;
2158
+ var active = btn.getAttribute('aria-pressed') === 'true';
2159
+ if (!active) {
2160
+ if (skinPreviewOriginal === null) skinPreviewOriginal = html.getAttribute('data-theme-skin') || 'air';
2161
+ btn.setAttribute('aria-pressed', 'true');
2162
+ btn.innerHTML = '<i class="bi bi-eye-slash" aria-hidden="true"></i> Stop preview';
2163
+ applyPreview();
2164
+ } else {
2165
+ if (skinPreviewOriginal !== null) html.setAttribute('data-theme-skin', skinPreviewOriginal);
2166
+ if (previewStyle) { previewStyle.remove(); previewStyle = null; }
2167
+ btn.setAttribute('aria-pressed', 'false');
2168
+ btn.innerHTML = '<i class="bi bi-eye" aria-hidden="true"></i> Preview on this page';
2169
+ }
2170
+ }
2171
+
2172
+ function applyPreview() {
2173
+ var btn = document.getElementById('btn-skin-preview');
2174
+ if (!btn || btn.getAttribute('aria-pressed') !== 'true') return;
2175
+ var c = ctx();
2176
+ document.documentElement.setAttribute('data-theme-skin', c.f.theme_skin || 'air');
2177
+ var fp = catalogItem('font_pairings', c.plan.theme.fonts);
2178
+ if (fp && fp.google) ensureFontLink(fp);
2179
+ if (!previewStyle) {
2180
+ previewStyle = document.createElement('style');
2181
+ previewStyle.id = 'wizard-preview-overrides';
2182
+ document.head.appendChild(previewStyle);
2183
+ }
2184
+ previewStyle.textContent = genUserOverrides(c);
2185
+ }
2186
+
2187
+ // ── wiring ───────────────────────────────────────────────────────
528
2188
 
529
- // Next / Prev buttons. Back is never blocked; Next is disabled by
530
- // refreshStepper() when the step it points at has not unlocked.
2189
+ document.addEventListener('DOMContentLoaded', function () {
531
2190
  wizard.querySelectorAll('.btn-next').forEach(function (btn) {
532
- btn.addEventListener('click', function () {
533
- showStep(document.getElementById(btn.getAttribute('data-next')));
534
- });
2191
+ btn.addEventListener('click', function () { showStep(document.getElementById(btn.getAttribute('data-next'))); });
535
2192
  });
536
-
537
2193
  wizard.querySelectorAll('.btn-prev').forEach(function (btn) {
538
- btn.addEventListener('click', function () {
539
- showStep(document.getElementById(btn.getAttribute('data-prev')));
540
- });
2194
+ btn.addEventListener('click', function () { showStep(document.getElementById(btn.getAttribute('data-prev'))); });
541
2195
  });
542
-
543
- // Clicking a stepper entry directly.
544
2196
  wizard.querySelectorAll('#wizardTabs .wizard-step').forEach(function (btn) {
545
2197
  btn.addEventListener('click', function () { showStep(btn); });
546
2198
  });
547
2199
 
548
- // The preview is persistent now, so it regenerates on EVERY input rather
549
- // than on entering step 5. Delegated, so fields added later are covered.
550
2200
  wizard.addEventListener('input', function (event) {
551
2201
  var el = event.target;
552
- updatePreview();
553
- // Typing clears a previous error rather than nagging mid-entry; the
554
- // re-check happens on blur.
555
2202
  if (el.classList && el.classList.contains('is-invalid')) validateField(el);
2203
+ refreshAll();
556
2204
  saveDraft();
2205
+ emit('change', { source: 'user', keys: [el.getAttribute('data-key') || el.id] });
557
2206
  });
558
2207
 
559
- wizard.addEventListener('change', function () {
560
- updatePreview();
561
- refreshStepper();
2208
+ wizard.addEventListener('change', function (event) {
2209
+ var el = event.target;
2210
+ if (el.classList && el.classList.contains('cfg-radio') && el.getAttribute('data-key') === 'site_type' && el.checked) {
2211
+ applySiteType(el.value);
2212
+ }
2213
+ if (el.name === 'prereq-os') applyOS(el.value);
2214
+ if (el.classList && el.classList.contains('prereq-manual')) {
2215
+ var id = el.getAttribute('data-prereq');
2216
+ checks[id] = Object.assign({}, checks[id] || {}, { manual: el.checked });
2217
+ }
2218
+ refreshAll();
562
2219
  saveDraft();
2220
+ emit('change', { source: 'user', keys: [el.getAttribute('data-key') || el.getAttribute('data-col') || el.id] });
563
2221
  });
564
2222
 
565
- // Validate email / URL on blur (capture: `blur` does not bubble).
566
2223
  wizard.addEventListener('blur', function (event) {
567
2224
  if (!event.target.classList || !event.target.classList.contains('cfg-field')) return;
568
2225
  validateField(event.target);
569
2226
  refreshStepper();
570
2227
  }, true);
571
2228
 
572
- // Description char count
2229
+ // Copy buttons: data-copy="text" or data-copy-target="#id".
2230
+ wizard.addEventListener('click', function (event) {
2231
+ var btn = event.target.closest('[data-copy], [data-copy-target]');
2232
+ if (!btn) return;
2233
+ var text = btn.getAttribute('data-copy');
2234
+ if (!text) {
2235
+ var target = document.querySelector(btn.getAttribute('data-copy-target') || '');
2236
+ text = target ? target.textContent : '';
2237
+ }
2238
+ if (text) copyText(text).catch(function () { /* ignore */ });
2239
+ });
2240
+
2241
+ // File tabs.
2242
+ var tabs = document.getElementById('wizard-file-tabs');
2243
+ if (tabs) {
2244
+ tabs.addEventListener('click', function (event) {
2245
+ var b = event.target.closest('.wizard-file-tab');
2246
+ if (!b) return;
2247
+ activeFile = b.getAttribute('data-file');
2248
+ updatePreview();
2249
+ saveDraft();
2250
+ });
2251
+ }
2252
+
573
2253
  var descField = document.getElementById('cfg-description');
574
2254
  var descCount = document.getElementById('desc-count');
575
- if (descField && descCount) {
576
- var syncCount = function () { descCount.textContent = descField.value.length; };
577
- descField.addEventListener('input', syncCount);
578
- descCount.textContent = descField.value.length;
2255
+ var syncCount = function () { if (descField && descCount) descCount.textContent = descField.value.length; };
2256
+ if (descField) descField.addEventListener('input', syncCount);
2257
+
2258
+ var bind = function (id, fn) { var el = document.getElementById(id); if (el) el.addEventListener('click', fn); };
2259
+ bind('btn-download', downloadActive);
2260
+ bind('btn-download-all', downloadBundle);
2261
+ bind('btn-copy', copyActive);
2262
+ bind('btn-copy-full', copyActive);
2263
+ bind('btn-suggest-urls', suggestUrls);
2264
+ bind('btn-nav-add', function () {
2265
+ var host = document.getElementById('nav-editor');
2266
+ if (!host) return;
2267
+ var empty = host.querySelector('p');
2268
+ if (empty) empty.remove();
2269
+ host.appendChild(navRow({ title: '', url: '/', icon: '' }));
2270
+ host.lastElementChild.querySelector('.nav-title').focus();
2271
+ });
2272
+ bind('btn-nav-regenerate', function () { renderNavEditor(defaultNavigation()); refreshAll(); saveDraft(); });
2273
+
2274
+ // Example-page planner: rows are rendered from planExtra.pages (the source
2275
+ // of truth) and edited in place.
2276
+ bind('btn-page-add', function () {
2277
+ var col = (readCollections().filter(function (x) { return x.id !== 'about'; })[0] || { id: 'posts' }).id;
2278
+ planExtra.pages.push(normalizePage({ collection: col, title: 'New page ' + (planExtra.pages.length + 1) }));
2279
+ renderPagesPlanner();
2280
+ var last = document.querySelector('#pages-planner .page-row:last-child .page-title');
2281
+ if (last) { last.focus(); last.select(); }
2282
+ refreshAll();
2283
+ saveDraft();
2284
+ });
2285
+ var planner = document.getElementById('pages-planner');
2286
+ if (planner) {
2287
+ planner.addEventListener('input', function (event) {
2288
+ var row = event.target.closest('.page-row');
2289
+ if (!row) return;
2290
+ var p = planExtra.pages[Number(row.getAttribute('data-index'))];
2291
+ if (!p) return;
2292
+ if (event.target.classList.contains('page-title')) { p.title = event.target.value; p.slug = slugify(p.title) || p.slug; }
2293
+ if (event.target.classList.contains('page-collection')) p.collection = event.target.value;
2294
+ var count = document.getElementById('pages-count');
2295
+ if (count) count.textContent = String(planExtra.pages.length);
2296
+ });
2297
+ planner.addEventListener('click', function (event) {
2298
+ var btn = event.target.closest('.page-remove');
2299
+ if (!btn) return;
2300
+ var row = btn.closest('.page-row');
2301
+ planExtra.pages.splice(Number(row.getAttribute('data-index')), 1);
2302
+ renderPagesPlanner();
2303
+ refreshAll();
2304
+ saveDraft();
2305
+ emit('change', { source: 'user', keys: ['plan.pages'] });
2306
+ });
579
2307
  }
2308
+ bind('btn-skin-preview', function (e) { toggleSkinPreview(e.currentTarget); });
2309
+ bind('btn-reset', function () {
2310
+ if (window.confirm('Clear every answer and start the Site Builder again?')) resetAll();
2311
+ });
580
2312
 
581
- // Download / Copy — always enabled, at every step.
582
- var dlBtn = document.getElementById('btn-download');
583
- if (dlBtn) dlBtn.addEventListener('click', downloadYAML);
584
-
585
- var copyBtn = document.getElementById('btn-copy');
586
- if (copyBtn) copyBtn.addEventListener('click', copyYAML);
587
-
588
- var copyFullBtn = document.getElementById('btn-copy-full');
589
- if (copyFullBtn) copyFullBtn.addEventListener('click', copyYAML);
590
-
591
- // A queued draft write must not be lost to a reload or a tab close.
592
2313
  window.addEventListener('pagehide', flushDraft);
593
- document.addEventListener('visibilitychange', function () {
594
- if (document.visibilityState === 'hidden') flushDraft();
595
- });
2314
+ document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flushDraft(); });
596
2315
 
597
- // Order matters: restore values first so the initial preview and stepper
598
- // state both reflect the real content.
2316
+ // Order matters: restore first so the initial preview and stepper reflect
2317
+ // the real content; hash wins over the draft's remembered step.
2318
+ applyOS(detectOS());
2319
+ renderNavEditor(defaultNavigation());
2320
+ renderPagesPlanner();
599
2321
  restoreDraft();
600
- if (descField && descCount) descCount.textContent = descField.value.length;
601
- updatePreview();
602
- refreshStepper();
2322
+ if (!readNavigation().length) renderNavEditor(defaultNavigation());
2323
+ var hashStep = (location.hash || '').replace(/^#step-/, '');
2324
+ if (hashStep && STEP_IDS.indexOf('tab-' + hashStep) !== -1) showStep('tab-' + hashStep, { silent: true });
2325
+ syncCount();
2326
+ refreshAll();
603
2327
 
604
- // No pane measurement and no resize listener: the grid in
605
- // _setup-wizard.scss keeps `#wizardTabContent` as tall as the tallest pane
606
- // at every width, without JS having to be told the width changed.
2328
+ emit('ready', {});
607
2329
  });
608
2330
 
2331
+ // ── public API (consumed by assets/js/site-builder.js) ───────────
2332
+
2333
+ window.Zer0SetupWizard = {
2334
+ version: 2,
2335
+ data: DATA,
2336
+ steps: STEP_IDS.map(function (id) { return id.replace(/^tab-/, ''); }),
2337
+ getState: getState,
2338
+ getSummary: getSummary,
2339
+ schema: schema,
2340
+ applyFields: applyFields,
2341
+ getPlan: getPlan,
2342
+ setPlan: setPlan,
2343
+ planSchema: planSchema,
2344
+ validatePlan: function (plan) { return validateSchema(plan, planSchema(), 'plan'); },
2345
+ landingPlan: function () { return landingPlan(ctx()); },
2346
+ catalog: catalog,
2347
+ getFiles: getFiles,
2348
+ getFile: getFile,
2349
+ setOverride: setOverride,
2350
+ getStep: function () { return activeStepId().replace(/^tab-/, ''); },
2351
+ setStep: function (id) { return showStep(id); },
2352
+ setCheckResult: setCheckResult,
2353
+ getChecks: function () { return JSON.parse(JSON.stringify(checks)); },
2354
+ setActiveFile: function (path) { activeFile = path; updatePreview(); },
2355
+ buildBundle: buildBundle,
2356
+ clearDraft: clearDraft,
2357
+ toast: toast,
2358
+ on: on
2359
+ };
609
2360
  })();