bearings 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --ink: #17211f;
4
+ --muted: #5b6662;
5
+ --paper: #f1efe7;
6
+ --card: #fffefa;
7
+ --line: #d2cec1;
8
+ --pine: #164b3c;
9
+ --coral: #c64e32;
10
+ --gold: #a87013;
11
+ --danger: #9d3027;
12
+ --blocked: #684b88;
13
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ }
15
+
16
+ * { box-sizing: border-box; }
17
+ body { margin: 0; color: var(--ink); background: var(--paper); }
18
+ button, select, input { font: inherit; }
19
+ button, select { min-height: 40px; border: 1px solid var(--pine); border-radius: 4px; }
20
+ button { padding: 8px 13px; color: var(--pine); background: transparent; font-weight: 750; cursor: pointer; }
21
+ button:hover { color: white; background: var(--pine); }
22
+ button:focus-visible, select:focus-visible, a:focus-visible { outline: 3px solid #e5952f; outline-offset: 3px; }
23
+
24
+ .masthead { color: white; background: var(--pine); border-bottom: 6px solid var(--coral); }
25
+ .masthead-inner, .toolbar-inner, main, .storage-warning, .status { width: min(1100px, calc(100% - 32px)); margin-inline: auto; }
26
+ .masthead-inner { padding: 34px 0 29px; }
27
+ .eyebrow { margin: 0 0 9px; color: #c7ded6; font: 800 11px/1.2 ui-monospace, monospace; letter-spacing: .13em; text-transform: uppercase; }
28
+ h1 { max-width: 860px; margin: 0; font: 750 clamp(31px, 6vw, 58px)/1.04 Georgia, serif; letter-spacing: -.02em; overflow-wrap: anywhere; }
29
+ .summary { max-width: 780px; margin: 12px 0 0; color: #e0ece8; line-height: 1.55; overflow-wrap: anywhere; }
30
+
31
+ .toolbar { position: sticky; top: 0; z-index: 5; border-bottom: 1px solid var(--line); background: rgb(241 239 231 / 96%); backdrop-filter: blur(8px); }
32
+ .toolbar-inner { min-height: 70px; display: flex; align-items: center; gap: 10px; }
33
+ .picker-label { color: var(--muted); font: 800 11px/1 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
34
+ #checklist-picker { min-width: 230px; max-width: 360px; padding: 8px 30px 8px 10px; color: var(--ink); background: var(--card); }
35
+ .progress { min-width: 180px; flex: 1; display: grid; gap: 5px; color: var(--muted); font-size: 13px; font-weight: 700; }
36
+ .progress-track { height: 7px; overflow: hidden; background: #d9d4c7; border-radius: 99px; }
37
+ #progress-fill { display: block; width: 0; height: 100%; background: var(--pine); transition: width .2s ease; }
38
+ .storage-warning, .status { margin-top: 14px; margin-bottom: 0; }
39
+ .storage-warning { padding: 12px 15px; color: #6a3d00; background: #fff4d8; border: 1px solid #d9af58; }
40
+ .status { min-height: 1.5em; color: var(--muted); font-size: 14px; }
41
+
42
+ main { padding: 22px 0 80px; }
43
+ .empty, .error { padding: 28px; background: var(--card); border: 1px dashed var(--line); text-align: center; white-space: pre-wrap; }
44
+ .error { color: #751f18; background: #fff0ed; border-color: #d88f86; }
45
+ .intro, .callout, .conventions { max-width: 880px; margin: 0 0 18px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
46
+ .callout { padding: 17px 19px; background: #fff0e9; border-left: 6px solid var(--coral); }
47
+ .conventions { padding: 17px 19px; background: #e4ede9; border-left: 5px solid var(--pine); }
48
+ section { margin-top: 48px; }
49
+ .section-heading { display: grid; grid-template-columns: auto 1fr; gap: 13px; align-items: baseline; padding-bottom: 11px; border-bottom: 2px solid var(--pine); }
50
+ .section-number { color: var(--coral); font: 850 13px/1 ui-monospace, monospace; }
51
+ h2 { margin: 0; font: 750 clamp(23px, 4vw, 32px)/1.1 Georgia, serif; overflow-wrap: anywhere; }
52
+ .section-intro { max-width: 840px; color: var(--muted); line-height: 1.55; white-space: pre-wrap; }
53
+ .items { display: grid; gap: 15px; margin-top: 20px; }
54
+ .item { padding: 20px; background: var(--card); border: 1px solid var(--line); border-left: 6px solid var(--line); border-radius: 5px; box-shadow: 0 3px 0 rgb(23 33 31 / 5%); overflow-wrap: anywhere; }
55
+ .item[data-state="complete"] { border-left-color: var(--pine); background: #f3faf6; }
56
+ .item[data-state="failed"] { border-left-color: var(--danger); background: #fff5f3; }
57
+ .item[data-state="blocked"] { border-left-color: var(--blocked); background: #f8f4fc; }
58
+ .item[data-state="not-applicable"] { border-left-color: var(--muted); opacity: .76; }
59
+ .item.info { border-left-color: var(--gold); }
60
+ .item-head { display: flex; gap: 13px; justify-content: space-between; align-items: start; }
61
+ .item-heading { min-width: 0; }
62
+ h3, h4 { margin: 0; font-size: 18px; line-height: 1.35; }
63
+ h4 { font-size: 16px; }
64
+ .item-id { margin-right: 9px; color: var(--coral); font: 800 12px/1 ui-monospace, monospace; }
65
+ .optional { display: inline-block; margin-left: 8px; padding: 2px 6px; color: var(--muted); border: 1px solid var(--line); border-radius: 3px; font: 750 10px/1.4 ui-monospace, monospace; text-transform: uppercase; }
66
+ .state-label { flex: 0 0 auto; color: var(--muted); font-size: 12px; font-weight: 750; }
67
+ .state-select { display: block; min-width: 150px; margin-top: 4px; padding: 6px 27px 6px 8px; color: var(--ink); background: white; }
68
+ .instruction { margin: 14px 0 0; line-height: 1.6; white-space: pre-wrap; }
69
+ .warning, .expected, .failure { margin: 15px 0 0; padding: 13px 15px; white-space: pre-wrap; }
70
+ .warning { color: #692c1d; background: #fff0e9; border-left: 4px solid var(--coral); }
71
+ .expected { background: #eaf4ef; border-left: 4px solid var(--pine); }
72
+ .failure { background: #f4edf9; border-left: 4px solid var(--blocked); }
73
+ .field-label { display: block; margin-bottom: 5px; font: 850 10px/1 ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; }
74
+ pre { overflow-x: auto; margin: 14px 0 0; padding: 15px; color: #e8f0ed; background: #13211d; border-radius: 4px; }
75
+ pre code { font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; }
76
+ .language { display: block; margin-bottom: 8px; color: #9ebdb2; font: 750 10px/1 ui-monospace, monospace; letter-spacing: .09em; text-transform: uppercase; }
77
+ .links { display: flex; flex-wrap: wrap; gap: 8px 15px; margin: 14px 0 0; padding: 0; list-style: none; }
78
+ a { color: #075f7b; }
79
+ .sub-items { display: grid; gap: 10px; margin-top: 16px; padding-left: 18px; border-left: 2px solid var(--line); }
80
+ .sub-items .item { padding: 15px; box-shadow: none; }
81
+
82
+ @media (max-width: 760px) {
83
+ .masthead-inner, .toolbar-inner, main, .storage-warning, .status { width: min(100% - 22px, 1100px); }
84
+ .toolbar-inner { flex-wrap: wrap; padding: 10px 0; }
85
+ .picker-label { width: 100%; }
86
+ #checklist-picker { max-width: none; flex: 1 1 100%; }
87
+ .progress { flex-basis: 100%; }
88
+ .item { padding: 16px 14px; }
89
+ .item-head { display: grid; }
90
+ .state-label { width: 100%; }
91
+ .state-select { width: 100%; }
92
+ section { margin-top: 37px; }
93
+ }
@@ -0,0 +1,340 @@
1
+ (function () {
2
+ 'use strict';
3
+
4
+ var STATES = ['not-started', 'complete', 'failed', 'blocked', 'not-applicable'];
5
+ var STATE_LABELS = {
6
+ 'not-started': 'Not started',
7
+ complete: 'Complete',
8
+ failed: 'Failed',
9
+ blocked: 'Blocked',
10
+ 'not-applicable': 'Not applicable',
11
+ };
12
+ var payload = window.BEARINGS_CHECKLISTS;
13
+ var currentEntry = null;
14
+ var currentStates = {};
15
+ var storageAvailable = true;
16
+ var refreshPending = false;
17
+
18
+ var picker = document.getElementById('checklist-picker');
19
+ var content = document.getElementById('content');
20
+ var status = document.getElementById('status');
21
+ var warning = document.getElementById('storage-warning');
22
+
23
+ function element(tag, className, text) {
24
+ var node = document.createElement(tag);
25
+ if (className) node.className = className;
26
+ if (text !== undefined) node.textContent = text;
27
+ return node;
28
+ }
29
+
30
+ function setStatus(message) {
31
+ status.textContent = message;
32
+ }
33
+
34
+ function reportStorageFailure() {
35
+ storageAvailable = false;
36
+ warning.hidden = false;
37
+ warning.textContent = 'Browser storage is unavailable. Progress works in this tab only; use Export to keep a copy.';
38
+ }
39
+
40
+ function storageKey(entry) {
41
+ return 'bearings-checklists:' + payload.repositoryId + ':' + entry.identity;
42
+ }
43
+
44
+ function executableIds(entry) {
45
+ var ids = [];
46
+ entry.checklist.sections.forEach(function (section) {
47
+ if (section.kind === 'info') return;
48
+ section.items.forEach(function (item) {
49
+ ids.push(item.id);
50
+ (item.subItems || []).forEach(function (subItem) { ids.push(subItem.id); });
51
+ });
52
+ });
53
+ return ids;
54
+ }
55
+
56
+ function normalizedStates(entry, candidate) {
57
+ var states = {};
58
+ executableIds(entry).forEach(function (id) {
59
+ if (candidate && STATES.indexOf(candidate[id]) !== -1 && candidate[id] !== 'not-started') {
60
+ states[id] = candidate[id];
61
+ }
62
+ });
63
+ return states;
64
+ }
65
+
66
+ function loadProgress(entry) {
67
+ if (!storageAvailable) return {};
68
+ try {
69
+ var saved = JSON.parse(localStorage.getItem(storageKey(entry)) || 'null');
70
+ if (!saved || saved.contentHash !== entry.contentHash) return {};
71
+ return normalizedStates(entry, saved.states);
72
+ } catch (error) {
73
+ reportStorageFailure();
74
+ return {};
75
+ }
76
+ }
77
+
78
+ function progressRecord() {
79
+ return {
80
+ repositoryId: payload.repositoryId,
81
+ checklistIdentity: currentEntry.identity,
82
+ contentHash: currentEntry.contentHash,
83
+ states: currentStates,
84
+ updatedAt: new Date().toISOString(),
85
+ };
86
+ }
87
+
88
+ function saveProgress() {
89
+ if (!currentEntry || !storageAvailable) return;
90
+ try {
91
+ localStorage.setItem(storageKey(currentEntry), JSON.stringify(progressRecord()));
92
+ setStatus('Progress saved in this browser.');
93
+ } catch (error) {
94
+ reportStorageFailure();
95
+ setStatus('Progress changed, but browser storage could not save it.');
96
+ }
97
+ }
98
+
99
+ function updateProgress() {
100
+ var ids = currentEntry ? executableIds(currentEntry) : [];
101
+ var resolved = ids.filter(function (id) {
102
+ return currentStates[id] && currentStates[id] !== 'not-started';
103
+ }).length;
104
+ document.getElementById('progress-copy').textContent = resolved + ' / ' + ids.length + ' resolved';
105
+ document.getElementById('progress-fill').style.width = (ids.length ? resolved / ids.length * 100 : 0) + '%';
106
+ }
107
+
108
+ function textBlock(className, label, value) {
109
+ var block = element('div', className);
110
+ block.append(element('span', 'field-label', label), document.createTextNode(value));
111
+ return block;
112
+ }
113
+
114
+ function safeLink(link) {
115
+ var item = element('li');
116
+ var anchor = element('a', '', link.label);
117
+ try {
118
+ var parsed = new URL(link.url);
119
+ if (['http:', 'https:', 'mailto:'].indexOf(parsed.protocol) === -1) throw new Error('unsupported protocol');
120
+ anchor.href = parsed.href;
121
+ anchor.rel = 'noreferrer';
122
+ if (parsed.protocol !== 'mailto:') anchor.target = '_blank';
123
+ } catch (error) {
124
+ anchor = element('span', '', link.label + ' (unsafe link omitted)');
125
+ }
126
+ item.append(anchor);
127
+ return item;
128
+ }
129
+
130
+ function stateControl(item, article) {
131
+ var label = element('label', 'state-label', 'State');
132
+ var select = element('select', 'state-select');
133
+ select.setAttribute('aria-label', 'State for ' + item.id + ': ' + item.title);
134
+ STATES.forEach(function (stateName) {
135
+ var option = element('option', '', STATE_LABELS[stateName]);
136
+ option.value = stateName;
137
+ select.append(option);
138
+ });
139
+ select.value = currentStates[item.id] || 'not-started';
140
+ article.dataset.state = select.value;
141
+ select.addEventListener('change', function () {
142
+ if (select.value === 'not-started') delete currentStates[item.id];
143
+ else currentStates[item.id] = select.value;
144
+ article.dataset.state = select.value;
145
+ saveProgress();
146
+ updateProgress();
147
+ });
148
+ label.append(select);
149
+ return label;
150
+ }
151
+
152
+ function renderItem(item, kind, isSubItem) {
153
+ var article = element('article', 'item' + (kind === 'info' ? ' info' : ''));
154
+ var head = element('div', 'item-head');
155
+ var headingWrap = element('div', 'item-heading');
156
+ var heading = element(isSubItem ? 'h4' : 'h3');
157
+ heading.append(element('span', 'item-id', item.id), document.createTextNode(item.title));
158
+ if (item.optional) heading.append(element('span', 'optional', 'Optional'));
159
+ headingWrap.append(heading);
160
+ head.append(headingWrap);
161
+ if (kind === 'checklist') head.append(stateControl(item, article));
162
+ article.append(head);
163
+
164
+ if (item.warning) article.append(textBlock('warning', 'Warning', item.warning));
165
+ if (item.instruction) article.append(element('p', 'instruction', item.instruction));
166
+ (item.codeBlocks || []).forEach(function (block) {
167
+ var pre = element('pre');
168
+ pre.append(element('span', 'language', block.language), element('code', '', block.content));
169
+ article.append(pre);
170
+ });
171
+ if (item.expectedResult) article.append(textBlock('expected', 'Expected result', item.expectedResult));
172
+ if (item.failureGuidance) article.append(textBlock('failure', 'If this fails', item.failureGuidance));
173
+ if (item.links && item.links.length) {
174
+ var links = element('ul', 'links');
175
+ item.links.forEach(function (link) { links.append(safeLink(link)); });
176
+ article.append(links);
177
+ }
178
+ if (item.subItems && item.subItems.length) {
179
+ var subItems = element('div', 'sub-items');
180
+ item.subItems.forEach(function (subItem) { subItems.append(renderItem(subItem, kind, true)); });
181
+ article.append(subItems);
182
+ }
183
+ return article;
184
+ }
185
+
186
+ function renderChecklist(entry, inMemoryStates) {
187
+ currentEntry = entry;
188
+ currentStates = inMemoryStates === undefined ? loadProgress(entry) : normalizedStates(entry, inMemoryStates);
189
+ var checklist = entry.checklist;
190
+ document.title = checklist.title + ' - Checklist';
191
+ document.getElementById('title').textContent = checklist.title;
192
+ document.getElementById('summary').textContent = checklist.summary;
193
+ content.className = '';
194
+ content.replaceChildren();
195
+ if (checklist.intro) content.append(element('p', 'intro', checklist.intro));
196
+ if (checklist.callout) content.append(element('div', 'callout', checklist.callout));
197
+ if (checklist.conventions) content.append(element('div', 'conventions', checklist.conventions));
198
+
199
+ checklist.sections.forEach(function (section) {
200
+ var sectionElement = element('section');
201
+ var heading = element('div', 'section-heading');
202
+ heading.append(element('span', 'section-number', section.number), element('h2', '', section.title));
203
+ sectionElement.append(heading);
204
+ if (section.intro) sectionElement.append(element('p', 'section-intro', section.intro));
205
+ var items = element('div', 'items');
206
+ section.items.forEach(function (item) { items.append(renderItem(item, section.kind, false)); });
207
+ sectionElement.append(items);
208
+ content.append(sectionElement);
209
+ });
210
+ updateProgress();
211
+ setStatus('Opened ' + entry.identity + '.');
212
+ }
213
+
214
+ function showMessage(message, isError) {
215
+ currentEntry = null;
216
+ currentStates = {};
217
+ document.title = 'Checklist library';
218
+ document.getElementById('title').textContent = 'Choose a checklist';
219
+ document.getElementById('summary').textContent = 'Browse the checklists generated from this repository.';
220
+ content.className = isError ? 'error' : 'empty';
221
+ content.textContent = message;
222
+ updateProgress();
223
+ }
224
+
225
+ function renderLibrary(selectedIdentity) {
226
+ document.documentElement.dataset.viewerReady = 'false';
227
+ picker.replaceChildren(element('option', '', payload && payload.checklists.length ? 'Choose a checklist...' : 'No checklists found'));
228
+ picker.firstChild.value = '';
229
+ if (!payload || !Array.isArray(payload.checklists)) {
230
+ showMessage('No generated payload was loaded. Run the checklist build script, then reload this page.', true);
231
+ document.documentElement.dataset.viewerReady = 'true';
232
+ return;
233
+ }
234
+
235
+ payload.categories.forEach(function (category) {
236
+ var group = element('optgroup');
237
+ group.label = category;
238
+ payload.checklists.filter(function (entry) { return entry.category === category; }).forEach(function (entry) {
239
+ var option = element('option', '', entry.checklist.title);
240
+ option.value = entry.identity;
241
+ group.append(option);
242
+ });
243
+ picker.append(group);
244
+ });
245
+ var selected = payload.checklists.find(function (entry) { return entry.identity === selectedIdentity; });
246
+ if (selected) {
247
+ picker.value = selected.identity;
248
+ renderChecklist(selected);
249
+ } else if (payload.checklists.length === 1) {
250
+ picker.value = payload.checklists[0].identity;
251
+ renderChecklist(payload.checklists[0]);
252
+ } else {
253
+ showMessage(payload.checklists.length ? 'Choose a checklist from the menu above.' : 'No checklist JSON files were found.');
254
+ }
255
+ document.documentElement.dataset.viewerReady = 'true';
256
+ }
257
+
258
+ function exportProgress() {
259
+ if (!currentEntry) return setStatus('Choose a checklist before exporting progress.');
260
+ var blob = new Blob([JSON.stringify(progressRecord(), null, 2) + '\n'], { type: 'application/json' });
261
+ var anchor = document.createElement('a');
262
+ anchor.href = URL.createObjectURL(blob);
263
+ anchor.download = currentEntry.identity.replaceAll('/', '-') + '.progress.json';
264
+ anchor.click();
265
+ URL.revokeObjectURL(anchor.href);
266
+ setStatus('Progress export created.');
267
+ }
268
+
269
+ function importProgress(file) {
270
+ if (!currentEntry || !file) return;
271
+ var reader = new FileReader();
272
+ reader.addEventListener('load', function () {
273
+ try {
274
+ var imported = JSON.parse(String(reader.result));
275
+ if (imported.repositoryId !== payload.repositoryId || imported.checklistIdentity !== currentEntry.identity) {
276
+ throw new Error('This progress file belongs to a different repository or checklist.');
277
+ }
278
+ if (imported.contentHash !== currentEntry.contentHash) throw new Error('This progress file is for different checklist content.');
279
+ currentStates = normalizedStates(currentEntry, imported.states);
280
+ saveProgress();
281
+ renderChecklist(currentEntry, currentStates);
282
+ setStatus('Progress imported.');
283
+ } catch (error) {
284
+ setStatus('Progress import failed: ' + error.message);
285
+ }
286
+ });
287
+ reader.addEventListener('error', function () { setStatus('Progress import failed: the file could not be read.'); });
288
+ reader.readAsText(file);
289
+ }
290
+
291
+ function refreshPayload() {
292
+ if (refreshPending) return;
293
+ refreshPending = true;
294
+ var previousGeneratedAt = payload && payload.generatedAt;
295
+ var selectedIdentity = currentEntry && currentEntry.identity;
296
+ var script = document.createElement('script');
297
+ script.src = './data.generated.js?' + Date.now();
298
+ script.addEventListener('load', function () {
299
+ refreshPending = false;
300
+ var refreshed = window.BEARINGS_CHECKLISTS;
301
+ if (refreshed && refreshed.generatedAt !== previousGeneratedAt) {
302
+ payload = refreshed;
303
+ renderLibrary(selectedIdentity);
304
+ setStatus('Checklist library refreshed.');
305
+ }
306
+ script.remove();
307
+ });
308
+ script.addEventListener('error', function () {
309
+ refreshPending = false;
310
+ script.remove();
311
+ setStatus('Could not refresh the generated checklist payload.');
312
+ });
313
+ document.head.append(script);
314
+ }
315
+
316
+ picker.addEventListener('change', function () {
317
+ var selected = payload.checklists.find(function (entry) { return entry.identity === picker.value; });
318
+ if (selected) renderChecklist(selected);
319
+ else showMessage('Choose a checklist from the menu above.');
320
+ });
321
+ document.getElementById('export-progress').addEventListener('click', exportProgress);
322
+ document.getElementById('import-progress').addEventListener('click', function () {
323
+ if (!currentEntry) return setStatus('Choose a checklist before importing progress.');
324
+ document.getElementById('import-file').click();
325
+ });
326
+ document.getElementById('import-file').addEventListener('change', function (event) {
327
+ importProgress(event.target.files[0]);
328
+ event.target.value = '';
329
+ });
330
+ document.getElementById('reset-progress').addEventListener('click', function () {
331
+ if (!currentEntry || !window.confirm('Clear progress for this checklist?')) return;
332
+ currentStates = {};
333
+ saveProgress();
334
+ renderChecklist(currentEntry);
335
+ setStatus('Progress reset.');
336
+ });
337
+ window.addEventListener('focus', refreshPayload);
338
+
339
+ renderLibrary('');
340
+ }());
@@ -0,0 +1,90 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://bearings.dev/schemas/checklist.json",
4
+ "title": "Checklist",
5
+ "$comment": "validate.mjs is the executable authority and additionally enforces globally unique item and sub-item IDs.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["title", "summary", "sections"],
9
+ "properties": {
10
+ "title": { "type": "string", "pattern": "\\S" },
11
+ "summary": { "type": "string", "pattern": "\\S" },
12
+ "intro": { "type": "string" },
13
+ "callout": { "type": "string" },
14
+ "conventions": { "type": "string" },
15
+ "sections": {
16
+ "type": "array",
17
+ "minItems": 1,
18
+ "items": { "$ref": "#/$defs/section" }
19
+ }
20
+ },
21
+ "$defs": {
22
+ "codeBlock": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["language", "content"],
26
+ "properties": {
27
+ "language": { "type": "string", "pattern": "\\S" },
28
+ "content": { "type": "string" }
29
+ }
30
+ },
31
+ "link": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "required": ["label", "url"],
35
+ "properties": {
36
+ "label": { "type": "string", "pattern": "\\S" },
37
+ "url": { "type": "string", "pattern": "\\S" }
38
+ }
39
+ },
40
+ "subItem": {
41
+ "type": "object",
42
+ "additionalProperties": false,
43
+ "required": ["id", "title"],
44
+ "properties": {
45
+ "id": { "type": "string", "pattern": "\\S" },
46
+ "title": { "type": "string", "pattern": "\\S" },
47
+ "instruction": { "type": "string" },
48
+ "expectedResult": { "type": "string" },
49
+ "codeBlocks": { "type": "array", "items": { "$ref": "#/$defs/codeBlock" } },
50
+ "links": { "type": "array", "items": { "$ref": "#/$defs/link" } },
51
+ "optional": { "type": "boolean" },
52
+ "warning": { "type": "string" },
53
+ "failureGuidance": { "type": "string" }
54
+ }
55
+ },
56
+ "item": {
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "required": ["id", "title"],
60
+ "properties": {
61
+ "id": { "type": "string", "pattern": "\\S" },
62
+ "title": { "type": "string", "pattern": "\\S" },
63
+ "instruction": { "type": "string" },
64
+ "expectedResult": { "type": "string" },
65
+ "codeBlocks": { "type": "array", "items": { "$ref": "#/$defs/codeBlock" } },
66
+ "links": { "type": "array", "items": { "$ref": "#/$defs/link" } },
67
+ "optional": { "type": "boolean" },
68
+ "warning": { "type": "string" },
69
+ "failureGuidance": { "type": "string" },
70
+ "subItems": { "type": "array", "items": { "$ref": "#/$defs/subItem" } }
71
+ }
72
+ },
73
+ "section": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "required": ["number", "title", "kind", "items"],
77
+ "properties": {
78
+ "number": { "type": "string", "pattern": "\\S" },
79
+ "title": { "type": "string", "pattern": "\\S" },
80
+ "kind": { "enum": ["checklist", "info"] },
81
+ "intro": { "type": "string" },
82
+ "items": {
83
+ "type": "array",
84
+ "minItems": 1,
85
+ "items": { "$ref": "#/$defs/item" }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdir, readdir, realpath, writeFile } from 'node:fs/promises';
5
+ import { dirname, join, relative, resolve, sep } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { loadAndValidateChecklist } from './validate.mjs';
8
+
9
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
+
11
+ async function checklistFiles(root) {
12
+ const files = [];
13
+ async function visit(directory) {
14
+ let entries;
15
+ try {
16
+ entries = await readdir(directory, { withFileTypes: true });
17
+ } catch (error) {
18
+ if (error.code === 'ENOENT' && directory === root) return;
19
+ throw error;
20
+ }
21
+ for (const entry of entries) {
22
+ const path = join(directory, entry.name);
23
+ if (entry.isDirectory()) await visit(path);
24
+ else if (entry.isFile() && entry.name.endsWith('.json')) files.push(path);
25
+ }
26
+ }
27
+ await visit(root);
28
+ return files.sort((left, right) => left.localeCompare(right));
29
+ }
30
+
31
+ function sha256(value) {
32
+ return createHash('sha256').update(value).digest('hex');
33
+ }
34
+
35
+ export async function buildChecklists(repoDir = process.cwd(), outputPath) {
36
+ const canonicalRepo = await realpath(repoDir);
37
+ const root = join(canonicalRepo, 'checklists');
38
+ const files = await checklistFiles(root);
39
+ const checklists = [];
40
+ const failures = [];
41
+
42
+ for (const path of files) {
43
+ const source = relative(root, path).split(sep).join('/');
44
+ const identity = source.slice(0, -'.json'.length);
45
+ const segments = identity.split('/');
46
+ if (segments.length < 2 || segments.some((segment) => !SLUG.test(segment))) {
47
+ failures.push(`${source}: path must be <category>/<slug>.json using lowercase slugs`);
48
+ continue;
49
+ }
50
+ const { checklist, errors } = await loadAndValidateChecklist(path);
51
+ if (errors.length > 0) {
52
+ failures.push(...errors.map((error) => `${source}: ${error}`));
53
+ continue;
54
+ }
55
+ const category = segments.slice(0, -1).join('/');
56
+ checklists.push({
57
+ identity,
58
+ category,
59
+ contentHash: `sha256:${sha256(JSON.stringify(checklist))}`,
60
+ checklist,
61
+ });
62
+ }
63
+
64
+ if (failures.length > 0) {
65
+ throw new Error(`Checklist build failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}`);
66
+ }
67
+
68
+ const payload = {
69
+ generatedAt: new Date().toISOString(),
70
+ repositoryId: `sha256:${sha256(canonicalRepo)}`,
71
+ categories: [...new Set(checklists.map((entry) => entry.category))],
72
+ checklists,
73
+ };
74
+ const serialized = JSON.stringify(payload).replaceAll('<', '\\u003c');
75
+ const target = outputPath ?? resolve(dirname(fileURLToPath(import.meta.url)), '../previewer/data.generated.js');
76
+ await mkdir(dirname(target), { recursive: true });
77
+ await writeFile(target, `// Generated by bearings. Do not edit.\nwindow.BEARINGS_CHECKLISTS = ${serialized};\n`);
78
+ return payload;
79
+ }
80
+
81
+ async function main() {
82
+ const repoDir = process.argv[2] ?? process.cwd();
83
+ const outputPath = process.argv[3];
84
+ if (process.argv.length > 4) {
85
+ console.error('Usage: node build.mjs [repository] [output]');
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ try {
90
+ const payload = await buildChecklists(repoDir, outputPath);
91
+ console.log(`Built ${payload.checklists.length} checklist(s).`);
92
+ } catch (error) {
93
+ console.error(error.message);
94
+ process.exitCode = 1;
95
+ }
96
+ }
97
+
98
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();