@forwardimpact/pathway 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/commands/agent.js +20 -20
- package/app/commands/index.js +4 -3
- package/app/commands/job.js +9 -4
- package/app/commands/skill.js +56 -2
- package/app/commands/tool.js +112 -0
- package/app/components/builder.js +6 -3
- package/app/components/checklist.js +6 -4
- package/app/components/markdown-textarea.js +132 -0
- package/app/css/components/forms.css +45 -0
- package/app/css/components/layout.css +12 -0
- package/app/css/components/surfaces.css +22 -0
- package/app/css/pages/detail.css +50 -0
- package/app/css/pages/job-builder.css +0 -42
- package/app/formatters/agent/profile.js +61 -120
- package/app/formatters/agent/skill.js +48 -60
- package/app/formatters/grade/dom.js +2 -4
- package/app/formatters/job/description.js +74 -82
- package/app/formatters/job/dom.js +45 -179
- package/app/formatters/job/markdown.js +17 -13
- package/app/formatters/shared.js +65 -2
- package/app/formatters/skill/dom.js +57 -2
- package/app/formatters/skill/markdown.js +18 -0
- package/app/formatters/skill/shared.js +12 -4
- package/app/formatters/stage/microdata.js +1 -1
- package/app/formatters/stage/shared.js +1 -1
- package/app/formatters/tool/shared.js +72 -0
- package/app/handout-main.js +7 -7
- package/app/handout.html +7 -0
- package/app/index.html +10 -3
- package/app/lib/card-mappers.js +64 -17
- package/app/lib/form-controls.js +64 -1
- package/app/lib/render.js +12 -1
- package/app/lib/template-loader.js +9 -0
- package/app/lib/yaml-loader.js +12 -1
- package/app/main.js +4 -0
- package/app/model/agent.js +26 -18
- package/app/model/derivation.js +3 -3
- package/app/model/levels.js +2 -0
- package/app/model/loader.js +12 -1
- package/app/model/validation.js +74 -8
- package/app/pages/agent-builder.js +8 -5
- package/app/pages/job.js +28 -4
- package/app/pages/landing.js +34 -14
- package/app/pages/progress.js +6 -5
- package/app/pages/self-assessment.js +10 -8
- package/app/pages/skill.js +5 -17
- package/app/pages/stage.js +10 -6
- package/app/pages/tool.js +50 -0
- package/app/slides/index.js +25 -25
- package/app/slides.html +7 -0
- package/bin/pathway.js +41 -27
- package/examples/capabilities/business.yaml +17 -17
- package/examples/capabilities/delivery.yaml +51 -36
- package/examples/capabilities/reliability.yaml +127 -114
- package/examples/capabilities/scale.yaml +38 -36
- package/examples/disciplines/engineering_management.yaml +1 -1
- package/examples/framework.yaml +12 -0
- package/examples/grades.yaml +18 -19
- package/examples/self-assessments.yaml +1 -1
- package/package.json +1 -1
- package/templates/job.template.md +47 -0
- package/templates/skill.template.md +31 -12
|
@@ -2,18 +2,7 @@
|
|
|
2
2
|
* Job formatting for DOM/web output
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
div,
|
|
7
|
-
h1,
|
|
8
|
-
h2,
|
|
9
|
-
p,
|
|
10
|
-
a,
|
|
11
|
-
span,
|
|
12
|
-
button,
|
|
13
|
-
section,
|
|
14
|
-
details,
|
|
15
|
-
summary,
|
|
16
|
-
} from "../../lib/render.js";
|
|
5
|
+
import { div, h1, h2, p, a, span, section } from "../../lib/render.js";
|
|
17
6
|
import { createBackLink } from "../../components/nav.js";
|
|
18
7
|
import {
|
|
19
8
|
createDetailSection,
|
|
@@ -25,6 +14,7 @@ import {
|
|
|
25
14
|
} from "../../components/radar-chart.js";
|
|
26
15
|
import { createSkillMatrix } from "../../components/skill-matrix.js";
|
|
27
16
|
import { createBehaviourProfile } from "../../components/behaviour-profile.js";
|
|
17
|
+
import { createMarkdownTextarea } from "../../components/markdown-textarea.js";
|
|
28
18
|
import { markdownToHtml } from "../../lib/markdown.js";
|
|
29
19
|
import { formatJobDescription } from "./description.js";
|
|
30
20
|
|
|
@@ -39,6 +29,7 @@ import { formatJobDescription } from "./description.js";
|
|
|
39
29
|
* @param {Object} [options.discipline] - Discipline entity for job description
|
|
40
30
|
* @param {Object} [options.grade] - Grade entity for job description
|
|
41
31
|
* @param {Object} [options.track] - Track entity for job description
|
|
32
|
+
* @param {string} [options.jobTemplate] - Mustache template for job description
|
|
42
33
|
* @returns {HTMLElement}
|
|
43
34
|
*/
|
|
44
35
|
export function jobToDOM(view, options = {}) {
|
|
@@ -50,9 +41,10 @@ export function jobToDOM(view, options = {}) {
|
|
|
50
41
|
discipline,
|
|
51
42
|
grade,
|
|
52
43
|
track,
|
|
44
|
+
jobTemplate,
|
|
53
45
|
} = options;
|
|
54
46
|
|
|
55
|
-
const hasEntities = discipline && grade &&
|
|
47
|
+
const hasEntities = discipline && grade && jobTemplate;
|
|
56
48
|
|
|
57
49
|
return div(
|
|
58
50
|
{ className: "job-detail-page" },
|
|
@@ -108,6 +100,7 @@ export function jobToDOM(view, options = {}) {
|
|
|
108
100
|
discipline,
|
|
109
101
|
grade,
|
|
110
102
|
track,
|
|
103
|
+
template: jobTemplate,
|
|
111
104
|
})
|
|
112
105
|
: null,
|
|
113
106
|
|
|
@@ -140,14 +133,6 @@ export function jobToDOM(view, options = {}) {
|
|
|
140
133
|
),
|
|
141
134
|
})
|
|
142
135
|
: null,
|
|
143
|
-
|
|
144
|
-
// Handoff Checklists
|
|
145
|
-
view.checklists && hasChecklistItems(view.checklists)
|
|
146
|
-
? createDetailSection({
|
|
147
|
-
title: "📋 Handoff Checklists",
|
|
148
|
-
content: createChecklistSections(view.checklists),
|
|
149
|
-
})
|
|
150
|
-
: null,
|
|
151
136
|
)
|
|
152
137
|
: null,
|
|
153
138
|
|
|
@@ -164,6 +149,7 @@ export function jobToDOM(view, options = {}) {
|
|
|
164
149
|
discipline,
|
|
165
150
|
grade,
|
|
166
151
|
track,
|
|
152
|
+
template: jobTemplate,
|
|
167
153
|
})
|
|
168
154
|
: null,
|
|
169
155
|
);
|
|
@@ -211,84 +197,6 @@ function getScoreColor(score) {
|
|
|
211
197
|
return "#ef4444"; // Red
|
|
212
198
|
}
|
|
213
199
|
|
|
214
|
-
/**
|
|
215
|
-
* Check if any checklist has items
|
|
216
|
-
* @param {Object} checklists - Checklists object keyed by handoff type
|
|
217
|
-
* @returns {boolean}
|
|
218
|
-
*/
|
|
219
|
-
function hasChecklistItems(checklists) {
|
|
220
|
-
for (const items of Object.values(checklists)) {
|
|
221
|
-
if (items && items.length > 0) {
|
|
222
|
-
return true;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
return false;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Create collapsible checklist sections for all handoffs
|
|
230
|
-
* @param {Object} checklists - Checklists object keyed by handoff type
|
|
231
|
-
* @returns {HTMLElement}
|
|
232
|
-
*/
|
|
233
|
-
function createChecklistSections(checklists) {
|
|
234
|
-
const handoffLabels = {
|
|
235
|
-
plan_to_code: "📋 → 💻 Plan → Code",
|
|
236
|
-
code_to_review: "💻 → ✅ Code → Review",
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const sections = Object.entries(checklists)
|
|
240
|
-
.filter(([_, items]) => items && items.length > 0)
|
|
241
|
-
.map(([handoff, groups]) => {
|
|
242
|
-
const label = handoffLabels[handoff] || handoff;
|
|
243
|
-
const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0);
|
|
244
|
-
|
|
245
|
-
return details(
|
|
246
|
-
{ className: "checklist-section" },
|
|
247
|
-
summary(
|
|
248
|
-
{ className: "checklist-section-header" },
|
|
249
|
-
span({ className: "checklist-section-label" }, label),
|
|
250
|
-
span({ className: "badge badge-default" }, `${totalItems} items`),
|
|
251
|
-
),
|
|
252
|
-
div(
|
|
253
|
-
{ className: "checklist-section-content" },
|
|
254
|
-
...groups.map((group) => createChecklistGroup(group)),
|
|
255
|
-
),
|
|
256
|
-
);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
return div({ className: "checklist-sections" }, ...sections);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Create a checklist group for a capability
|
|
264
|
-
* @param {Object} group - Group with capability, level, and items
|
|
265
|
-
* @returns {HTMLElement}
|
|
266
|
-
*/
|
|
267
|
-
function createChecklistGroup(group) {
|
|
268
|
-
const emoji = group.capability.emoji || "📌";
|
|
269
|
-
const capabilityName = group.capability.name || group.capability.id;
|
|
270
|
-
|
|
271
|
-
return div(
|
|
272
|
-
{ className: "checklist-group" },
|
|
273
|
-
div(
|
|
274
|
-
{ className: "checklist-group-header" },
|
|
275
|
-
span({ className: "checklist-emoji" }, emoji),
|
|
276
|
-
span({ className: "checklist-capability" }, capabilityName),
|
|
277
|
-
span({ className: "badge badge-secondary" }, group.level),
|
|
278
|
-
),
|
|
279
|
-
div(
|
|
280
|
-
{ className: "checklist-items" },
|
|
281
|
-
...group.items.map((item) =>
|
|
282
|
-
div(
|
|
283
|
-
{ className: "checklist-item" },
|
|
284
|
-
span({ className: "checklist-checkbox" }, "☐"),
|
|
285
|
-
span({}, item),
|
|
286
|
-
),
|
|
287
|
-
),
|
|
288
|
-
),
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
200
|
/**
|
|
293
201
|
* Create the job description section with copy button
|
|
294
202
|
* @param {Object} params
|
|
@@ -296,87 +204,35 @@ function createChecklistGroup(group) {
|
|
|
296
204
|
* @param {Object} params.discipline - The discipline
|
|
297
205
|
* @param {Object} params.grade - The grade
|
|
298
206
|
* @param {Object} params.track - The track
|
|
207
|
+
* @param {string} params.template - Mustache template for job description
|
|
299
208
|
* @returns {HTMLElement} The job description section element
|
|
300
209
|
*/
|
|
301
|
-
export function createJobDescriptionSection({
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const copyButton = button(
|
|
210
|
+
export function createJobDescriptionSection({
|
|
211
|
+
job,
|
|
212
|
+
discipline,
|
|
213
|
+
grade,
|
|
214
|
+
track,
|
|
215
|
+
template,
|
|
216
|
+
}) {
|
|
217
|
+
const markdown = formatJobDescription(
|
|
310
218
|
{
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
copyButton.textContent = "✓ Copied!";
|
|
316
|
-
copyButton.classList.add("copied");
|
|
317
|
-
setTimeout(() => {
|
|
318
|
-
copyButton.textContent = "Copy Markdown";
|
|
319
|
-
copyButton.classList.remove("copied");
|
|
320
|
-
}, 2000);
|
|
321
|
-
} catch (err) {
|
|
322
|
-
console.error("Failed to copy:", err);
|
|
323
|
-
copyButton.textContent = "Copy failed";
|
|
324
|
-
setTimeout(() => {
|
|
325
|
-
copyButton.textContent = "Copy Markdown";
|
|
326
|
-
}, 2000);
|
|
327
|
-
}
|
|
328
|
-
},
|
|
219
|
+
job,
|
|
220
|
+
discipline,
|
|
221
|
+
grade,
|
|
222
|
+
track,
|
|
329
223
|
},
|
|
330
|
-
|
|
224
|
+
template,
|
|
331
225
|
);
|
|
332
226
|
|
|
333
|
-
const copyHtmlButton = button(
|
|
334
|
-
{
|
|
335
|
-
className: "btn btn-secondary copy-btn",
|
|
336
|
-
onClick: async () => {
|
|
337
|
-
try {
|
|
338
|
-
const html = markdownToHtml(markdown);
|
|
339
|
-
// Use ClipboardItem with text/html MIME type for rich text pasting in Word
|
|
340
|
-
const blob = new Blob([html], { type: "text/html" });
|
|
341
|
-
const clipboardItem = new ClipboardItem({ "text/html": blob });
|
|
342
|
-
await navigator.clipboard.write([clipboardItem]);
|
|
343
|
-
copyHtmlButton.textContent = "✓ Copied!";
|
|
344
|
-
copyHtmlButton.classList.add("copied");
|
|
345
|
-
setTimeout(() => {
|
|
346
|
-
copyHtmlButton.textContent = "Copy as HTML";
|
|
347
|
-
copyHtmlButton.classList.remove("copied");
|
|
348
|
-
}, 2000);
|
|
349
|
-
} catch (err) {
|
|
350
|
-
console.error("Failed to copy:", err);
|
|
351
|
-
copyHtmlButton.textContent = "Copy failed";
|
|
352
|
-
setTimeout(() => {
|
|
353
|
-
copyHtmlButton.textContent = "Copy as HTML";
|
|
354
|
-
}, 2000);
|
|
355
|
-
}
|
|
356
|
-
},
|
|
357
|
-
},
|
|
358
|
-
"Copy as HTML",
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
const textarea = document.createElement("textarea");
|
|
362
|
-
textarea.className = "job-description-textarea";
|
|
363
|
-
textarea.readOnly = true;
|
|
364
|
-
textarea.value = markdown;
|
|
365
|
-
|
|
366
227
|
return createDetailSection({
|
|
367
228
|
title: "Job Description",
|
|
368
|
-
content:
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
),
|
|
376
|
-
div({ className: "button-group" }, copyButton, copyHtmlButton),
|
|
377
|
-
),
|
|
378
|
-
textarea,
|
|
379
|
-
),
|
|
229
|
+
content: createMarkdownTextarea({
|
|
230
|
+
markdown,
|
|
231
|
+
description:
|
|
232
|
+
"Copy this markdown-formatted job description for use in job postings, documentation, or sharing.",
|
|
233
|
+
toHtml: markdownToHtml,
|
|
234
|
+
minHeight: 450,
|
|
235
|
+
}),
|
|
380
236
|
});
|
|
381
237
|
}
|
|
382
238
|
|
|
@@ -388,15 +244,25 @@ export function createJobDescriptionSection({ job, discipline, grade, track }) {
|
|
|
388
244
|
* @param {Object} params.discipline - The discipline
|
|
389
245
|
* @param {Object} params.grade - The grade
|
|
390
246
|
* @param {Object} params.track - The track
|
|
247
|
+
* @param {string} params.template - Mustache template for job description
|
|
391
248
|
* @returns {HTMLElement} The job description HTML element (print-only)
|
|
392
249
|
*/
|
|
393
|
-
export function createJobDescriptionHtml({
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
250
|
+
export function createJobDescriptionHtml({
|
|
251
|
+
job,
|
|
252
|
+
discipline,
|
|
253
|
+
grade,
|
|
254
|
+
track,
|
|
255
|
+
template,
|
|
256
|
+
}) {
|
|
257
|
+
const markdown = formatJobDescription(
|
|
258
|
+
{
|
|
259
|
+
job,
|
|
260
|
+
discipline,
|
|
261
|
+
grade,
|
|
262
|
+
track,
|
|
263
|
+
},
|
|
264
|
+
template,
|
|
265
|
+
);
|
|
400
266
|
|
|
401
267
|
const html = markdownToHtml(markdown);
|
|
402
268
|
|
|
@@ -15,9 +15,10 @@ import { SKILL_LEVEL_ORDER } from "../../model/levels.js";
|
|
|
15
15
|
* Format job detail as markdown
|
|
16
16
|
* @param {Object} view - Job detail view from presenter
|
|
17
17
|
* @param {Object} [entities] - Original entities (for job description)
|
|
18
|
+
* @param {string} [jobTemplate] - Mustache template for job description
|
|
18
19
|
* @returns {string}
|
|
19
20
|
*/
|
|
20
|
-
export function jobToMarkdown(view, entities = {}) {
|
|
21
|
+
export function jobToMarkdown(view, entities = {}, jobTemplate) {
|
|
21
22
|
const lines = [
|
|
22
23
|
`# ${view.title}`,
|
|
23
24
|
"",
|
|
@@ -77,23 +78,26 @@ export function jobToMarkdown(view, entities = {}) {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
// Job Description (copyable markdown)
|
|
80
|
-
if (entities.discipline && entities.grade &&
|
|
81
|
+
if (entities.discipline && entities.grade && jobTemplate) {
|
|
81
82
|
lines.push("---", "");
|
|
82
83
|
lines.push("## Job Description", "");
|
|
83
84
|
lines.push("```markdown");
|
|
84
85
|
lines.push(
|
|
85
|
-
formatJobDescription(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
formatJobDescription(
|
|
87
|
+
{
|
|
88
|
+
job: {
|
|
89
|
+
title: view.title,
|
|
90
|
+
skillMatrix: view.skillMatrix,
|
|
91
|
+
behaviourProfile: view.behaviourProfile,
|
|
92
|
+
expectations: view.expectations,
|
|
93
|
+
derivedResponsibilities: view.derivedResponsibilities,
|
|
94
|
+
},
|
|
95
|
+
discipline: entities.discipline,
|
|
96
|
+
grade: entities.grade,
|
|
97
|
+
track: entities.track,
|
|
92
98
|
},
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
track: entities.track,
|
|
96
|
-
}),
|
|
99
|
+
jobTemplate,
|
|
100
|
+
),
|
|
97
101
|
);
|
|
98
102
|
lines.push("```");
|
|
99
103
|
}
|
package/app/formatters/shared.js
CHANGED
|
@@ -4,6 +4,64 @@
|
|
|
4
4
|
* Common formatting functions used across different output formats (CLI, DOM, markdown)
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Trim trailing newlines from a string value
|
|
9
|
+
* Used by template prepare functions for consistent output formatting.
|
|
10
|
+
* @param {string|null|undefined} value - Value to trim
|
|
11
|
+
* @returns {string|null} Trimmed value or null if empty
|
|
12
|
+
*/
|
|
13
|
+
export function trimValue(value) {
|
|
14
|
+
if (value == null) return null;
|
|
15
|
+
const trimmed = value.replace(/\n+$/, "");
|
|
16
|
+
return trimmed || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Trim a required field, preserving original if trim would result in empty
|
|
21
|
+
* Use for fields that must have a value.
|
|
22
|
+
* @param {string|null|undefined} value - Value to trim
|
|
23
|
+
* @returns {string} Trimmed value or original
|
|
24
|
+
*/
|
|
25
|
+
export function trimRequired(value) {
|
|
26
|
+
return trimValue(value) || value || "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Trim and split a string into lines
|
|
31
|
+
* @param {string|null|undefined} value - Value to process
|
|
32
|
+
* @returns {string[]} Array of lines (empty array if no value)
|
|
33
|
+
*/
|
|
34
|
+
export function splitLines(value) {
|
|
35
|
+
const trimmed = trimValue(value);
|
|
36
|
+
return trimmed ? trimmed.split("\n") : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Transform an array of objects by applying trimValue to specified fields
|
|
41
|
+
* @param {Array<Object>} array - Array of objects to transform
|
|
42
|
+
* @param {Object<string, 'optional'|'required'|'array'>} fieldSpec - Fields to trim and their type
|
|
43
|
+
* - 'optional': use trimValue (returns null if empty)
|
|
44
|
+
* - 'required': use trimRequired (preserves original if empty)
|
|
45
|
+
* - 'array': trim each element in array field
|
|
46
|
+
* @returns {Array<Object>} Transformed array
|
|
47
|
+
*/
|
|
48
|
+
export function trimFields(array, fieldSpec) {
|
|
49
|
+
if (!array) return [];
|
|
50
|
+
return array.map((item) => {
|
|
51
|
+
const result = { ...item };
|
|
52
|
+
for (const [field, type] of Object.entries(fieldSpec)) {
|
|
53
|
+
if (type === "optional") {
|
|
54
|
+
result[field] = trimValue(item[field]);
|
|
55
|
+
} else if (type === "required") {
|
|
56
|
+
result[field] = trimRequired(item[field]);
|
|
57
|
+
} else if (type === "array") {
|
|
58
|
+
result[field] = (item[field] || []).map((v) => trimRequired(v));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
7
65
|
/**
|
|
8
66
|
* Format level as text with dots (for CLI/markdown)
|
|
9
67
|
* @param {number} level - 1-5
|
|
@@ -36,7 +94,7 @@ export function tableToMarkdown(headers, rows) {
|
|
|
36
94
|
export function objectToMarkdownList(obj, indent = 0) {
|
|
37
95
|
const prefix = " ".repeat(indent);
|
|
38
96
|
return Object.entries(obj)
|
|
39
|
-
.map(([key, value]) => `${prefix}- **${key}**: ${value}`)
|
|
97
|
+
.map(([key, value]) => `${prefix}- **${capitalize(key)}**: ${value}`)
|
|
40
98
|
.join("\n");
|
|
41
99
|
}
|
|
42
100
|
|
|
@@ -51,12 +109,17 @@ export function formatPercent(value) {
|
|
|
51
109
|
|
|
52
110
|
/**
|
|
53
111
|
* Capitalize first letter of each word
|
|
112
|
+
* Handles both snake_case and camelCase
|
|
54
113
|
* @param {string} str
|
|
55
114
|
* @returns {string}
|
|
56
115
|
*/
|
|
57
116
|
export function capitalize(str) {
|
|
58
117
|
if (!str) return "";
|
|
59
|
-
|
|
118
|
+
// Insert space before uppercase letters (for camelCase), then handle snake_case
|
|
119
|
+
return str
|
|
120
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
121
|
+
.replace(/_/g, " ")
|
|
122
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
60
123
|
}
|
|
61
124
|
|
|
62
125
|
/**
|
|
@@ -18,8 +18,9 @@ import {
|
|
|
18
18
|
} from "../../lib/render.js";
|
|
19
19
|
import { createBackLink } from "../../components/nav.js";
|
|
20
20
|
import { createLevelCell } from "../../components/detail.js";
|
|
21
|
+
import { createMarkdownTextarea } from "../../components/markdown-textarea.js";
|
|
21
22
|
import { SKILL_LEVEL_ORDER } from "../../model/levels.js";
|
|
22
|
-
import { prepareSkillDetail
|
|
23
|
+
import { prepareSkillDetail } from "./shared.js";
|
|
23
24
|
import { createJsonLdScript, skillToJsonLd } from "../json-ld.js";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -61,7 +62,7 @@ export function skillToDOM(
|
|
|
61
62
|
{ className: "page-meta" },
|
|
62
63
|
span(
|
|
63
64
|
{ className: "badge badge-default" },
|
|
64
|
-
|
|
65
|
+
`${view.capabilityEmoji} ${view.capability.toUpperCase()}`,
|
|
65
66
|
),
|
|
66
67
|
view.isHumanOnly
|
|
67
68
|
? span(
|
|
@@ -97,6 +98,60 @@ export function skillToDOM(
|
|
|
97
98
|
),
|
|
98
99
|
),
|
|
99
100
|
|
|
101
|
+
// Recommended Tools
|
|
102
|
+
view.toolReferences.length > 0
|
|
103
|
+
? div(
|
|
104
|
+
{ className: "detail-section" },
|
|
105
|
+
heading2({ className: "section-title" }, "Recommended Tools"),
|
|
106
|
+
table(
|
|
107
|
+
{ className: "tools-table" },
|
|
108
|
+
thead({}, tr({}, th({}, "Tool"), th({}, "Use When"))),
|
|
109
|
+
tbody(
|
|
110
|
+
{},
|
|
111
|
+
...view.toolReferences.map((tool) =>
|
|
112
|
+
tr(
|
|
113
|
+
{},
|
|
114
|
+
td(
|
|
115
|
+
{},
|
|
116
|
+
tool.url
|
|
117
|
+
? a(
|
|
118
|
+
{
|
|
119
|
+
href: tool.url,
|
|
120
|
+
target: "_blank",
|
|
121
|
+
rel: "noopener noreferrer",
|
|
122
|
+
},
|
|
123
|
+
tool.name,
|
|
124
|
+
)
|
|
125
|
+
: tool.name,
|
|
126
|
+
),
|
|
127
|
+
td({}, tool.useWhen),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
showBackLink
|
|
133
|
+
? p(
|
|
134
|
+
{ className: "see-all-link" },
|
|
135
|
+
a({ href: "#/tool" }, "See all tools →"),
|
|
136
|
+
)
|
|
137
|
+
: null,
|
|
138
|
+
)
|
|
139
|
+
: null,
|
|
140
|
+
|
|
141
|
+
// Implementation Reference
|
|
142
|
+
view.implementationReference
|
|
143
|
+
? div(
|
|
144
|
+
{ className: "detail-section" },
|
|
145
|
+
heading2({ className: "section-title" }, "Implementation Patterns"),
|
|
146
|
+
createMarkdownTextarea({
|
|
147
|
+
markdown: view.implementationReference,
|
|
148
|
+
description:
|
|
149
|
+
"Project-specific implementation guidance for this skill.",
|
|
150
|
+
minHeight: 450,
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
: null,
|
|
154
|
+
|
|
100
155
|
// Used in Disciplines and Linked to Drivers in two columns
|
|
101
156
|
view.relatedDisciplines.length > 0 || view.relatedDrivers.length > 0
|
|
102
157
|
? div(
|
|
@@ -105,5 +105,23 @@ export function skillToMarkdown(
|
|
|
105
105
|
lines.push("");
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Recommended tools
|
|
109
|
+
if (view.toolReferences.length > 0) {
|
|
110
|
+
lines.push("## Recommended Tools", "");
|
|
111
|
+
const toolRows = view.toolReferences.map((tool) => [
|
|
112
|
+
tool.url ? `[${tool.name}](${tool.url})` : tool.name,
|
|
113
|
+
tool.useWhen,
|
|
114
|
+
]);
|
|
115
|
+
lines.push(tableToMarkdown(["Tool", "Use When"], toolRows));
|
|
116
|
+
lines.push("");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Implementation reference
|
|
120
|
+
if (view.implementationReference) {
|
|
121
|
+
lines.push("## Implementation Patterns", "");
|
|
122
|
+
lines.push(view.implementationReference);
|
|
123
|
+
lines.push("");
|
|
124
|
+
}
|
|
125
|
+
|
|
108
126
|
return lines.join("\n");
|
|
109
127
|
}
|
|
@@ -13,12 +13,12 @@ import { truncate } from "../shared.js";
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Format capability name for display
|
|
16
|
-
* @param {string} capability
|
|
16
|
+
* @param {string} capabilityName - The capability name to display
|
|
17
17
|
* @returns {string}
|
|
18
18
|
*/
|
|
19
|
-
export function formatCapability(
|
|
20
|
-
if (!
|
|
21
|
-
return
|
|
19
|
+
export function formatCapability(capabilityName) {
|
|
20
|
+
if (!capabilityName) return "";
|
|
21
|
+
return capabilityName;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -66,12 +66,15 @@ export function prepareSkillsList(
|
|
|
66
66
|
* @property {string} name
|
|
67
67
|
* @property {string} description
|
|
68
68
|
* @property {string} capability
|
|
69
|
+
* @property {string} capabilityName
|
|
69
70
|
* @property {boolean} isHumanOnly
|
|
70
71
|
* @property {string} capabilityEmoji
|
|
71
72
|
* @property {Object<string, string>} levelDescriptions
|
|
72
73
|
* @property {Array<{id: string, name: string, skillType: string}>} relatedDisciplines
|
|
73
74
|
* @property {Array<{id: string, name: string, modifier: number}>} relatedTracks
|
|
74
75
|
* @property {Array<{id: string, name: string}>} relatedDrivers
|
|
76
|
+
* @property {Array<{name: string, url?: string, description: string, useWhen: string}>} toolReferences
|
|
77
|
+
* @property {string|null} implementationReference
|
|
75
78
|
*/
|
|
76
79
|
|
|
77
80
|
/**
|
|
@@ -110,16 +113,21 @@ export function prepareSkillDetail(
|
|
|
110
113
|
.filter((d) => d.contributingSkills?.includes(skill.id))
|
|
111
114
|
.map((d) => ({ id: d.id, name: d.name }));
|
|
112
115
|
|
|
116
|
+
const capabilityEntity = capabilities.find((c) => c.id === skill.capability);
|
|
117
|
+
|
|
113
118
|
return {
|
|
114
119
|
id: skill.id,
|
|
115
120
|
name: skill.name,
|
|
116
121
|
description: skill.description,
|
|
117
122
|
capability: skill.capability,
|
|
123
|
+
capabilityName: capabilityEntity?.name || skill.capability,
|
|
118
124
|
isHumanOnly: skill.isHumanOnly || false,
|
|
119
125
|
capabilityEmoji: getCapabilityEmoji(capabilities, skill.capability),
|
|
120
126
|
levelDescriptions: skill.levelDescriptions,
|
|
121
127
|
relatedDisciplines,
|
|
122
128
|
relatedTracks,
|
|
123
129
|
relatedDrivers,
|
|
130
|
+
toolReferences: skill.toolReferences || [],
|
|
131
|
+
implementationReference: skill.implementationReference || null,
|
|
124
132
|
};
|
|
125
133
|
}
|
|
@@ -32,7 +32,7 @@ export function stageListToMicrodata(stages) {
|
|
|
32
32
|
? `→ ${stage.handoffs.map((h) => h.target).join(", ")}`
|
|
33
33
|
: "";
|
|
34
34
|
return `${openTag("article", { itemtype: "Stage", itemid: `#${stage.id}` })}
|
|
35
|
-
${prop("h2", "name", `${stage.emoji
|
|
35
|
+
${prop("h2", "name", `${stage.emoji} ${stage.name}`)}
|
|
36
36
|
${prop("p", "description", stage.truncatedDescription)}
|
|
37
37
|
${handoffText ? `<p>Handoffs: ${handoffText}</p>` : ""}
|
|
38
38
|
</article>`;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool presentation helpers
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for formatting tool data across DOM and CLI outputs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} ToolUsage
|
|
9
|
+
* @property {string} skillId
|
|
10
|
+
* @property {string} skillName
|
|
11
|
+
* @property {string} capabilityId
|
|
12
|
+
* @property {string} useWhen
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} AggregatedTool
|
|
17
|
+
* @property {string} name
|
|
18
|
+
* @property {string} [url]
|
|
19
|
+
* @property {string} description
|
|
20
|
+
* @property {ToolUsage[]} usages
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Aggregate tools from all skills, deduplicating by name
|
|
25
|
+
* @param {Array} skills - All skills with toolReferences
|
|
26
|
+
* @returns {AggregatedTool[]}
|
|
27
|
+
*/
|
|
28
|
+
export function aggregateTools(skills) {
|
|
29
|
+
const toolMap = new Map();
|
|
30
|
+
|
|
31
|
+
for (const skill of skills) {
|
|
32
|
+
if (!skill.toolReferences) continue;
|
|
33
|
+
|
|
34
|
+
for (const tool of skill.toolReferences) {
|
|
35
|
+
const usage = {
|
|
36
|
+
skillId: skill.id,
|
|
37
|
+
skillName: skill.name,
|
|
38
|
+
capabilityId: skill.capability,
|
|
39
|
+
useWhen: tool.useWhen,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const existing = toolMap.get(tool.name);
|
|
43
|
+
if (existing) {
|
|
44
|
+
existing.usages.push(usage);
|
|
45
|
+
} else {
|
|
46
|
+
toolMap.set(tool.name, {
|
|
47
|
+
name: tool.name,
|
|
48
|
+
url: tool.url,
|
|
49
|
+
description: tool.description,
|
|
50
|
+
usages: [usage],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Array.from(toolMap.values()).sort((a, b) =>
|
|
57
|
+
a.name.localeCompare(b.name),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Prepare tools list view data
|
|
63
|
+
* @param {Array} skills - All skills
|
|
64
|
+
* @returns {{ tools: AggregatedTool[], totalCount: number }}
|
|
65
|
+
*/
|
|
66
|
+
export function prepareToolsList(skills) {
|
|
67
|
+
const tools = aggregateTools(skills);
|
|
68
|
+
return {
|
|
69
|
+
tools,
|
|
70
|
+
totalCount: tools.length,
|
|
71
|
+
};
|
|
72
|
+
}
|