@forwardimpact/pathway 0.1.0 → 0.3.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 (140) hide show
  1. package/app/commands/agent.js +119 -31
  2. package/app/commands/command-factory.js +3 -3
  3. package/app/commands/interview.js +14 -7
  4. package/app/commands/job.js +52 -33
  5. package/app/commands/progress.js +14 -7
  6. package/app/commands/serve.js +5 -0
  7. package/app/commands/stage.js +0 -10
  8. package/app/commands/track.js +5 -8
  9. package/app/components/builder.js +117 -30
  10. package/app/css/components/surfaces.css +16 -0
  11. package/app/formatters/agent/profile.js +30 -115
  12. package/app/formatters/agent/skill.js +23 -44
  13. package/app/formatters/behaviour/dom.js +3 -0
  14. package/app/formatters/behaviour/microdata.js +106 -0
  15. package/app/formatters/discipline/dom.js +28 -1
  16. package/app/formatters/discipline/microdata.js +117 -0
  17. package/app/formatters/discipline/shared.js +49 -8
  18. package/app/formatters/driver/dom.js +3 -0
  19. package/app/formatters/driver/microdata.js +91 -0
  20. package/app/formatters/grade/dom.js +5 -4
  21. package/app/formatters/grade/microdata.js +151 -0
  22. package/app/formatters/index.js +32 -1
  23. package/app/formatters/interview/shared.js +13 -8
  24. package/app/formatters/job/description.js +70 -81
  25. package/app/formatters/job/dom.js +40 -113
  26. package/app/formatters/job/markdown.js +17 -13
  27. package/app/formatters/json-ld.js +242 -0
  28. package/app/formatters/microdata-shared.js +184 -0
  29. package/app/formatters/progress/shared.js +14 -11
  30. package/app/formatters/shared.js +7 -2
  31. package/app/formatters/skill/dom.js +3 -0
  32. package/app/formatters/skill/microdata.js +151 -0
  33. package/app/formatters/stage/dom.js +3 -18
  34. package/app/formatters/stage/microdata.js +110 -0
  35. package/app/formatters/stage/shared.js +0 -27
  36. package/app/formatters/track/dom.js +5 -30
  37. package/app/formatters/track/markdown.js +2 -25
  38. package/app/formatters/track/microdata.js +111 -0
  39. package/app/formatters/track/shared.js +6 -58
  40. package/app/handout-main.js +26 -12
  41. package/app/handout.html +7 -0
  42. package/app/index.html +11 -0
  43. package/app/lib/card-mappers.js +17 -12
  44. package/app/lib/form-controls.js +64 -1
  45. package/app/lib/job-cache.js +12 -9
  46. package/app/lib/render.js +8 -1
  47. package/app/lib/template-loader.js +75 -0
  48. package/app/lib/yaml-loader.js +25 -8
  49. package/app/main.js +8 -4
  50. package/app/model/agent.js +158 -130
  51. package/app/model/checklist.js +57 -91
  52. package/app/model/derivation.js +135 -68
  53. package/app/model/index-generator.js +1 -7
  54. package/app/model/job.js +19 -13
  55. package/app/model/levels.js +20 -12
  56. package/app/model/loader.js +41 -17
  57. package/app/model/matching.js +33 -3
  58. package/app/model/profile.js +38 -45
  59. package/app/model/schema-validation.js +438 -0
  60. package/app/model/validation.js +747 -68
  61. package/app/pages/agent-builder.js +125 -28
  62. package/app/pages/assessment-results.js +10 -4
  63. package/app/pages/discipline.js +36 -6
  64. package/app/pages/driver.js +9 -47
  65. package/app/pages/interview-builder.js +3 -1
  66. package/app/pages/interview.js +15 -4
  67. package/app/pages/job-builder.js +4 -1
  68. package/app/pages/job.js +43 -8
  69. package/app/pages/landing.js +10 -10
  70. package/app/pages/progress-builder.js +3 -1
  71. package/app/pages/progress.js +78 -26
  72. package/app/pages/self-assessment.js +3 -3
  73. package/app/pages/stage.js +3 -126
  74. package/app/slide-main.js +45 -17
  75. package/app/slides/index.js +3 -1
  76. package/app/slides/overview.js +40 -4
  77. package/app/slides/progress.js +4 -2
  78. package/app/slides.html +7 -0
  79. package/bin/pathway.js +28 -75
  80. package/examples/agents/.claude/skills/architecture-design/SKILL.md +58 -16
  81. package/examples/agents/.claude/skills/cloud-platforms/SKILL.md +59 -18
  82. package/examples/agents/.claude/skills/code-quality-review/SKILL.md +58 -17
  83. package/examples/agents/.claude/skills/devops-cicd/SKILL.md +64 -18
  84. package/examples/agents/.claude/skills/full-stack-development/SKILL.md +59 -15
  85. package/examples/agents/.claude/skills/sre-practices/SKILL.md +64 -18
  86. package/examples/agents/.claude/skills/technical-debt-management/SKILL.md +58 -17
  87. package/examples/agents/.github/agents/se-platform-code.agent.md +39 -88
  88. package/examples/agents/.github/agents/se-platform-plan.agent.md +41 -88
  89. package/examples/agents/.github/agents/se-platform-review.agent.md +38 -15
  90. package/examples/agents/.vscode/settings.json +1 -1
  91. package/examples/behaviours/outcome_ownership.yaml +1 -2
  92. package/examples/behaviours/polymathic_knowledge.yaml +1 -2
  93. package/examples/behaviours/precise_communication.yaml +1 -2
  94. package/examples/behaviours/relentless_curiosity.yaml +1 -2
  95. package/examples/behaviours/systems_thinking.yaml +1 -2
  96. package/examples/capabilities/business.yaml +80 -142
  97. package/examples/capabilities/delivery.yaml +155 -219
  98. package/examples/capabilities/people.yaml +2 -34
  99. package/examples/capabilities/reliability.yaml +161 -80
  100. package/examples/capabilities/scale.yaml +234 -252
  101. package/examples/copilot-setup-steps.yaml +25 -0
  102. package/examples/devcontainer.yaml +21 -0
  103. package/examples/disciplines/_index.yaml +1 -0
  104. package/examples/disciplines/data_engineering.yaml +14 -12
  105. package/examples/disciplines/engineering_management.yaml +63 -0
  106. package/examples/disciplines/software_engineering.yaml +14 -12
  107. package/examples/drivers.yaml +1 -4
  108. package/examples/framework.yaml +1 -2
  109. package/examples/grades.yaml +14 -15
  110. package/examples/questions/behaviours/outcome_ownership.yaml +1 -2
  111. package/examples/questions/behaviours/polymathic_knowledge.yaml +1 -2
  112. package/examples/questions/behaviours/precise_communication.yaml +1 -2
  113. package/examples/questions/behaviours/relentless_curiosity.yaml +1 -2
  114. package/examples/questions/behaviours/systems_thinking.yaml +1 -2
  115. package/examples/questions/skills/architecture_design.yaml +1 -2
  116. package/examples/questions/skills/cloud_platforms.yaml +1 -2
  117. package/examples/questions/skills/code_quality.yaml +1 -2
  118. package/examples/questions/skills/data_modeling.yaml +1 -2
  119. package/examples/questions/skills/devops.yaml +1 -2
  120. package/examples/questions/skills/full_stack_development.yaml +1 -2
  121. package/examples/questions/skills/sre_practices.yaml +1 -2
  122. package/examples/questions/skills/stakeholder_management.yaml +1 -2
  123. package/examples/questions/skills/team_collaboration.yaml +1 -2
  124. package/examples/questions/skills/technical_writing.yaml +1 -2
  125. package/examples/self-assessments.yaml +1 -3
  126. package/examples/stages.yaml +101 -46
  127. package/examples/tracks/_index.yaml +0 -1
  128. package/examples/tracks/platform.yaml +8 -13
  129. package/examples/tracks/sre.yaml +8 -18
  130. package/examples/vscode-settings.yaml +2 -7
  131. package/package.json +9 -3
  132. package/templates/agent.template.md +65 -0
  133. package/templates/job.template.md +47 -0
  134. package/templates/skill.template.md +28 -0
  135. package/examples/agents/.claude/skills/data-modeling/SKILL.md +0 -99
  136. package/examples/agents/.claude/skills/developer-experience/SKILL.md +0 -99
  137. package/examples/agents/.claude/skills/knowledge-management/SKILL.md +0 -100
  138. package/examples/agents/.claude/skills/pattern-generalization/SKILL.md +0 -102
  139. package/examples/agents/.claude/skills/technical-writing/SKILL.md +0 -129
  140. package/examples/tracks/manager.yaml +0 -53
@@ -0,0 +1,242 @@
1
+ /**
2
+ * JSON-LD structured data generation
3
+ *
4
+ * Generates JSON-LD for entity pages to enable machine-readable data.
5
+ * Aligns with the RDF schema at https://schema.forwardimpact.team/rdf/
6
+ */
7
+
8
+ const VOCAB_BASE = "https://schema.forwardimpact.team/rdf/";
9
+
10
+ /**
11
+ * Create a JSON-LD script element
12
+ * @param {Object} data - JSON-LD data object
13
+ * @returns {HTMLScriptElement}
14
+ */
15
+ export function createJsonLdScript(data) {
16
+ const script = document.createElement("script");
17
+ script.type = "application/ld+json";
18
+ script.textContent = JSON.stringify(data, null, 2);
19
+ return script;
20
+ }
21
+
22
+ /**
23
+ * Build base JSON-LD context and type
24
+ * @param {string} type - Entity type (without vocab prefix)
25
+ * @param {string} id - Entity ID
26
+ * @returns {Object}
27
+ */
28
+ function baseJsonLd(type, id) {
29
+ return {
30
+ "@context": VOCAB_BASE,
31
+ "@type": type,
32
+ "@id": `${VOCAB_BASE}${type}/${id}`,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Generate JSON-LD for a skill entity
38
+ * @param {Object} skill - Raw skill entity
39
+ * @param {Object} context - Additional context
40
+ * @param {Array} [context.capabilities] - Capability entities
41
+ * @returns {Object}
42
+ */
43
+ export function skillToJsonLd(skill, { capabilities = [] } = {}) {
44
+ const capability = capabilities.find((c) => c.id === skill.capability);
45
+
46
+ return {
47
+ ...baseJsonLd("Skill", skill.id),
48
+ identifier: skill.id,
49
+ name: skill.name,
50
+ description: skill.description,
51
+ capability: skill.capability,
52
+ ...(capability && { capabilityName: capability.name }),
53
+ ...(skill.isHumanOnly && { isHumanOnly: true }),
54
+ levelDescriptions: Object.entries(skill.levelDescriptions || {}).map(
55
+ ([level, description]) => ({
56
+ "@type": "SkillLevelDescription",
57
+ level: `${VOCAB_BASE}${level}`,
58
+ description,
59
+ }),
60
+ ),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Generate JSON-LD for a behaviour entity
66
+ * @param {Object} behaviour - Raw behaviour entity
67
+ * @returns {Object}
68
+ */
69
+ export function behaviourToJsonLd(behaviour) {
70
+ return {
71
+ ...baseJsonLd("Behaviour", behaviour.id),
72
+ identifier: behaviour.id,
73
+ name: behaviour.name,
74
+ description: behaviour.description,
75
+ maturityDescriptions: Object.entries(
76
+ behaviour.maturityDescriptions || {},
77
+ ).map(([maturity, description]) => ({
78
+ "@type": "BehaviourMaturityDescription",
79
+ maturity: `${VOCAB_BASE}${maturity}`,
80
+ description,
81
+ })),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Generate JSON-LD for a discipline entity
87
+ * @param {Object} discipline - Raw discipline entity
88
+ * @param {Object} context - Additional context
89
+ * @param {Array} [context.skills] - All skills
90
+ * @returns {Object}
91
+ */
92
+ export function disciplineToJsonLd(discipline, { skills = [] } = {}) {
93
+ const resolveSkillNames = (skillIds) =>
94
+ (skillIds || [])
95
+ .map((id) => {
96
+ const skill = skills.find((s) => s.id === id);
97
+ return skill
98
+ ? { "@id": `${VOCAB_BASE}Skill/${id}`, name: skill.name }
99
+ : null;
100
+ })
101
+ .filter(Boolean);
102
+
103
+ return {
104
+ ...baseJsonLd("Discipline", discipline.id),
105
+ identifier: discipline.id,
106
+ name: discipline.name,
107
+ ...(discipline.specialization && {
108
+ specialization: discipline.specialization,
109
+ }),
110
+ description: discipline.description,
111
+ coreSkills: resolveSkillNames(discipline.coreSkills),
112
+ supportingSkills: resolveSkillNames(discipline.supportingSkills),
113
+ broadSkills: resolveSkillNames(discipline.broadSkills),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Generate JSON-LD for a track entity
119
+ * @param {Object} track - Raw track entity
120
+ * @returns {Object}
121
+ */
122
+ export function trackToJsonLd(track) {
123
+ return {
124
+ ...baseJsonLd("Track", track.id),
125
+ identifier: track.id,
126
+ name: track.name,
127
+ description: track.description,
128
+ ...(track.skillModifiers && {
129
+ skillModifiers: Object.entries(track.skillModifiers).map(
130
+ ([capability, modifier]) => ({
131
+ "@type": "SkillModifier",
132
+ capability,
133
+ modifier,
134
+ }),
135
+ ),
136
+ }),
137
+ ...(track.behaviourModifiers && {
138
+ behaviourModifiers: Object.entries(track.behaviourModifiers).map(
139
+ ([behaviour, modifier]) => ({
140
+ "@type": "BehaviourModifier",
141
+ behaviour,
142
+ modifier,
143
+ }),
144
+ ),
145
+ }),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Generate JSON-LD for a grade entity
151
+ * @param {Object} grade - Raw grade entity
152
+ * @returns {Object}
153
+ */
154
+ export function gradeToJsonLd(grade) {
155
+ return {
156
+ ...baseJsonLd("Grade", grade.id),
157
+ identifier: grade.id,
158
+ name: grade.displayName || grade.name,
159
+ ...(grade.ordinalRank && { ordinalRank: grade.ordinalRank }),
160
+ ...(grade.typicalExperienceRange && {
161
+ typicalExperienceRange: grade.typicalExperienceRange,
162
+ }),
163
+ ...(grade.baseSkillLevels && {
164
+ baseSkillLevels: {
165
+ "@type": "BaseSkillLevels",
166
+ primary: `${VOCAB_BASE}${grade.baseSkillLevels.primary}`,
167
+ secondary: `${VOCAB_BASE}${grade.baseSkillLevels.secondary}`,
168
+ broad: `${VOCAB_BASE}${grade.baseSkillLevels.broad}`,
169
+ },
170
+ }),
171
+ ...(grade.baseBehaviourMaturity && {
172
+ baseBehaviourMaturity: `${VOCAB_BASE}${grade.baseBehaviourMaturity}`,
173
+ }),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Generate JSON-LD for a driver entity
179
+ * @param {Object} driver - Raw driver entity
180
+ * @param {Object} context - Additional context
181
+ * @param {Array} [context.skills] - All skills
182
+ * @param {Array} [context.behaviours] - All behaviours
183
+ * @returns {Object}
184
+ */
185
+ export function driverToJsonLd(driver, { skills = [], behaviours = [] } = {}) {
186
+ const resolveSkills = (skillIds) =>
187
+ (skillIds || [])
188
+ .map((id) => {
189
+ const skill = skills.find((s) => s.id === id);
190
+ return skill
191
+ ? { "@id": `${VOCAB_BASE}Skill/${id}`, name: skill.name }
192
+ : null;
193
+ })
194
+ .filter(Boolean);
195
+
196
+ const resolveBehaviours = (behaviourIds) =>
197
+ (behaviourIds || [])
198
+ .map((id) => {
199
+ const behaviour = behaviours.find((b) => b.id === id);
200
+ return behaviour
201
+ ? { "@id": `${VOCAB_BASE}Behaviour/${id}`, name: behaviour.name }
202
+ : null;
203
+ })
204
+ .filter(Boolean);
205
+
206
+ return {
207
+ ...baseJsonLd("Driver", driver.id),
208
+ identifier: driver.id,
209
+ name: driver.name,
210
+ description: driver.description,
211
+ ...(driver.contributingSkills?.length > 0 && {
212
+ contributingSkills: resolveSkills(driver.contributingSkills),
213
+ }),
214
+ ...(driver.contributingBehaviours?.length > 0 && {
215
+ contributingBehaviours: resolveBehaviours(driver.contributingBehaviours),
216
+ }),
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Generate JSON-LD for a stage entity
222
+ * @param {Object} stage - Raw stage entity
223
+ * @returns {Object}
224
+ */
225
+ export function stageToJsonLd(stage) {
226
+ return {
227
+ ...baseJsonLd("Stage", stage.id),
228
+ identifier: stage.id,
229
+ name: stage.name,
230
+ description: stage.description,
231
+ ...(stage.emoji && { emoji: stage.emoji }),
232
+ ...(stage.tools?.length > 0 && { tools: stage.tools }),
233
+ ...(stage.constraints?.length > 0 && { constraints: stage.constraints }),
234
+ ...(stage.handoffs && {
235
+ handoffs: Object.entries(stage.handoffs).map(([targetStage, config]) => ({
236
+ "@type": "StageHandoff",
237
+ targetStage: `${VOCAB_BASE}Stage/${targetStage}`,
238
+ ...(config.prompt && { prompt: config.prompt }),
239
+ })),
240
+ }),
241
+ };
242
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Shared microdata HTML utilities
3
+ *
4
+ * Helper functions for generating clean, class-less HTML with microdata attributes
5
+ * aligned with the RDF schema at https://schema.forwardimpact.team/rdf/
6
+ */
7
+
8
+ const VOCAB_BASE = "https://schema.forwardimpact.team/rdf/";
9
+
10
+ /**
11
+ * Create an opening tag with microdata attributes
12
+ * @param {string} tag - HTML tag name
13
+ * @param {Object} [attrs] - Optional attributes
14
+ * @param {string} [attrs.itemtype] - Microdata type (without vocab prefix)
15
+ * @param {string} [attrs.itemprop] - Microdata property name
16
+ * @param {string} [attrs.itemid] - Microdata item ID
17
+ * @returns {string}
18
+ */
19
+ export function openTag(tag, attrs = {}) {
20
+ const parts = [tag];
21
+
22
+ if (attrs.itemtype) {
23
+ parts.push(`itemscope`);
24
+ parts.push(`itemtype="${VOCAB_BASE}${attrs.itemtype}"`);
25
+ }
26
+
27
+ if (attrs.itemprop) {
28
+ parts.push(`itemprop="${attrs.itemprop}"`);
29
+ }
30
+
31
+ if (attrs.itemid) {
32
+ parts.push(`itemid="${attrs.itemid}"`);
33
+ }
34
+
35
+ return `<${parts.join(" ")}>`;
36
+ }
37
+
38
+ /**
39
+ * Create a self-closing meta element with microdata
40
+ * @param {string} itemprop - Property name
41
+ * @param {string} content - Content value
42
+ * @returns {string}
43
+ */
44
+ export function metaTag(itemprop, content) {
45
+ return `<meta itemprop="${itemprop}" content="${escapeAttr(content)}">`;
46
+ }
47
+
48
+ /**
49
+ * Create a link element with microdata
50
+ * @param {string} itemprop - Property name
51
+ * @param {string} href - Link target
52
+ * @returns {string}
53
+ */
54
+ export function linkTag(itemprop, href) {
55
+ return `<link itemprop="${itemprop}" href="${escapeAttr(href)}">`;
56
+ }
57
+
58
+ /**
59
+ * Wrap content in an element with itemprop
60
+ * @param {string} tag - HTML tag name
61
+ * @param {string} itemprop - Property name
62
+ * @param {string} content - Content to wrap
63
+ * @returns {string}
64
+ */
65
+ export function prop(tag, itemprop, content) {
66
+ return `<${tag} itemprop="${itemprop}">${escapeHtml(content)}</${tag}>`;
67
+ }
68
+
69
+ /**
70
+ * Wrap raw HTML content in an element with itemprop (no escaping)
71
+ * @param {string} tag - HTML tag name
72
+ * @param {string} itemprop - Property name
73
+ * @param {string} html - HTML content to wrap
74
+ * @returns {string}
75
+ */
76
+ export function propRaw(tag, itemprop, html) {
77
+ return `<${tag} itemprop="${itemprop}">${html}</${tag}>`;
78
+ }
79
+
80
+ /**
81
+ * Create a section with optional heading
82
+ * @param {string} heading - Section heading text
83
+ * @param {string} content - Section content
84
+ * @param {number} [level=2] - Heading level (2-6)
85
+ * @returns {string}
86
+ */
87
+ export function section(heading, content, level = 2) {
88
+ const hTag = `h${Math.min(Math.max(level, 1), 6)}`;
89
+ return `<section>
90
+ <${hTag}>${escapeHtml(heading)}</${hTag}>
91
+ ${content}
92
+ </section>`;
93
+ }
94
+
95
+ /**
96
+ * Create an unordered list
97
+ * @param {string[]} items - List items (already HTML)
98
+ * @param {string} [itemprop] - Optional property for list items
99
+ * @returns {string}
100
+ */
101
+ export function ul(items, itemprop) {
102
+ if (!items.length) return "";
103
+ const lis = items
104
+ .map((item) =>
105
+ itemprop ? `<li itemprop="${itemprop}">${item}</li>` : `<li>${item}</li>`,
106
+ )
107
+ .join("\n");
108
+ return `<ul>\n${lis}\n</ul>`;
109
+ }
110
+
111
+ /**
112
+ * Create a definition list from key-value pairs
113
+ * @param {Array<{term: string, definition: string, itemprop?: string}>} pairs
114
+ * @returns {string}
115
+ */
116
+ export function dl(pairs) {
117
+ if (!pairs.length) return "";
118
+ const content = pairs
119
+ .map(({ term, definition, itemprop }) => {
120
+ const dd = itemprop
121
+ ? `<dd itemprop="${itemprop}">${escapeHtml(definition)}</dd>`
122
+ : `<dd>${escapeHtml(definition)}</dd>`;
123
+ return `<dt>${escapeHtml(term)}</dt>\n${dd}`;
124
+ })
125
+ .join("\n");
126
+ return `<dl>\n${content}\n</dl>`;
127
+ }
128
+
129
+ /**
130
+ * Escape HTML special characters
131
+ * @param {string} str
132
+ * @returns {string}
133
+ */
134
+ export function escapeHtml(str) {
135
+ if (str == null) return "";
136
+ return String(str)
137
+ .replace(/&/g, "&amp;")
138
+ .replace(/</g, "&lt;")
139
+ .replace(/>/g, "&gt;");
140
+ }
141
+
142
+ /**
143
+ * Escape attribute value
144
+ * @param {string} str
145
+ * @returns {string}
146
+ */
147
+ export function escapeAttr(str) {
148
+ if (str == null) return "";
149
+ return String(str)
150
+ .replace(/&/g, "&amp;")
151
+ .replace(/"/g, "&quot;")
152
+ .replace(/</g, "&lt;")
153
+ .replace(/>/g, "&gt;");
154
+ }
155
+
156
+ /**
157
+ * Format level name for display (capitalize, replace underscores)
158
+ * @param {string} level
159
+ * @returns {string}
160
+ */
161
+ export function formatLevelName(level) {
162
+ if (!level) return "";
163
+ return level.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
164
+ }
165
+
166
+ /**
167
+ * Generate a full microdata HTML document
168
+ * @param {string} title - Document title
169
+ * @param {string} body - Body content
170
+ * @returns {string}
171
+ */
172
+ export function htmlDocument(title, body) {
173
+ return `<!DOCTYPE html>
174
+ <html lang="en">
175
+ <head>
176
+ <meta charset="UTF-8">
177
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
178
+ <title>${escapeHtml(title)}</title>
179
+ </head>
180
+ <body>
181
+ ${body}
182
+ </body>
183
+ </html>`;
184
+ }
@@ -67,7 +67,7 @@ export function prepareCurrentJob({
67
67
  behaviours,
68
68
  capabilities,
69
69
  }) {
70
- if (!discipline || !grade || !track) return null;
70
+ if (!discipline || !grade) return null;
71
71
 
72
72
  const job = getOrCreateJob({
73
73
  discipline,
@@ -117,7 +117,8 @@ export function prepareCareerProgressPreview({
117
117
  grades,
118
118
  tracks,
119
119
  }) {
120
- if (!discipline || !grade || !track) {
120
+ // Track is optional (null = generalist)
121
+ if (!discipline || !grade) {
121
122
  return {
122
123
  isValid: false,
123
124
  title: null,
@@ -135,12 +136,13 @@ export function prepareCareerProgressPreview({
135
136
  });
136
137
 
137
138
  if (!validCombination) {
139
+ const reason = track
140
+ ? `The ${track.name} track is not available for ${discipline.specialization}.`
141
+ : `${discipline.specialization} requires a track specialization.`;
138
142
  return {
139
143
  isValid: false,
140
144
  title: null,
141
- invalidReason: track.validDisciplines
142
- ? `The ${track.name} track is only available for certain disciplines.`
143
- : "This combination is not valid.",
145
+ invalidReason: reason,
144
146
  nextGrade: null,
145
147
  validTracks: [],
146
148
  };
@@ -149,10 +151,10 @@ export function prepareCareerProgressPreview({
149
151
  const title = generateJobTitle(discipline, grade, track);
150
152
  const nextGrade = getNextGrade(grade, grades);
151
153
 
152
- // Find other valid tracks for comparison
154
+ // Find other valid tracks for comparison (exclude current track if any)
153
155
  const validTracks = tracks.filter(
154
156
  (t) =>
155
- t.id !== track.id &&
157
+ (!track || t.id !== track.id) &&
156
158
  isValidJobCombination({ discipline, grade, track: t, grades }),
157
159
  );
158
160
 
@@ -203,8 +205,9 @@ export function prepareProgressDetail({
203
205
  behaviours,
204
206
  capabilities,
205
207
  }) {
206
- if (!fromDiscipline || !fromGrade || !fromTrack) return null;
207
- if (!toDiscipline || !toGrade || !toTrack) return null;
208
+ // Track is optional (null = generalist)
209
+ if (!fromDiscipline || !fromGrade) return null;
210
+ if (!toDiscipline || !toGrade) return null;
208
211
 
209
212
  const fromJob = getOrCreateJob({
210
213
  discipline: fromDiscipline,
@@ -263,12 +266,12 @@ export function prepareProgressDetail({
263
266
  fromJob: {
264
267
  disciplineId: fromDiscipline.id,
265
268
  gradeId: fromGrade.id,
266
- trackId: fromTrack.id,
269
+ trackId: fromTrack?.id || null,
267
270
  },
268
271
  toJob: {
269
272
  disciplineId: toDiscipline.id,
270
273
  gradeId: toGrade.id,
271
- trackId: toTrack.id,
274
+ trackId: toTrack?.id || null,
272
275
  },
273
276
  skillChanges,
274
277
  behaviourChanges,
@@ -36,7 +36,7 @@ export function tableToMarkdown(headers, rows) {
36
36
  export function objectToMarkdownList(obj, indent = 0) {
37
37
  const prefix = " ".repeat(indent);
38
38
  return Object.entries(obj)
39
- .map(([key, value]) => `${prefix}- **${key}**: ${value}`)
39
+ .map(([key, value]) => `${prefix}- **${capitalize(key)}**: ${value}`)
40
40
  .join("\n");
41
41
  }
42
42
 
@@ -51,12 +51,17 @@ export function formatPercent(value) {
51
51
 
52
52
  /**
53
53
  * Capitalize first letter of each word
54
+ * Handles both snake_case and camelCase
54
55
  * @param {string} str
55
56
  * @returns {string}
56
57
  */
57
58
  export function capitalize(str) {
58
59
  if (!str) return "";
59
- return str.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
60
+ // Insert space before uppercase letters (for camelCase), then handle snake_case
61
+ return str
62
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
63
+ .replace(/_/g, " ")
64
+ .replace(/\b\w/g, (c) => c.toUpperCase());
60
65
  }
61
66
 
62
67
  /**
@@ -20,6 +20,7 @@ import { createBackLink } from "../../components/nav.js";
20
20
  import { createLevelCell } from "../../components/detail.js";
21
21
  import { SKILL_LEVEL_ORDER } from "../../model/levels.js";
22
22
  import { prepareSkillDetail, formatCapability } from "./shared.js";
23
+ import { createJsonLdScript, skillToJsonLd } from "../json-ld.js";
23
24
 
24
25
  /**
25
26
  * Format skill detail as DOM elements
@@ -44,6 +45,8 @@ export function skillToDOM(
44
45
  });
45
46
  return div(
46
47
  { className: "detail-page skill-detail" },
48
+ // JSON-LD structured data
49
+ createJsonLdScript(skillToJsonLd(skill, { capabilities })),
47
50
  // Header
48
51
  div(
49
52
  { className: "page-header" },
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Skill formatting for microdata HTML output
3
+ *
4
+ * Generates clean, class-less HTML with microdata aligned with capability.schema.json
5
+ * RDF vocab: https://schema.forwardimpact.team/rdf/
6
+ */
7
+
8
+ import {
9
+ openTag,
10
+ prop,
11
+ propRaw,
12
+ metaTag,
13
+ section,
14
+ dl,
15
+ ul,
16
+ escapeHtml,
17
+ formatLevelName,
18
+ htmlDocument,
19
+ } from "../microdata-shared.js";
20
+ import { prepareSkillsList, prepareSkillDetail } from "./shared.js";
21
+
22
+ /**
23
+ * Format skill list as microdata HTML
24
+ * @param {Array} skills - Raw skill entities
25
+ * @param {Array} capabilities - Capability entities
26
+ * @returns {string} HTML with microdata
27
+ */
28
+ export function skillListToMicrodata(skills, capabilities) {
29
+ const { groups, groupOrder } = prepareSkillsList(skills, capabilities);
30
+
31
+ const content = groupOrder
32
+ .map((capability) => {
33
+ const capabilitySkills = groups[capability];
34
+ const skillItems = capabilitySkills
35
+ .map(
36
+ (
37
+ skill,
38
+ ) => `${openTag("article", { itemtype: "Skill", itemid: `#${skill.id}` })}
39
+ ${prop("h3", "name", skill.name)}
40
+ ${prop("p", "description", skill.truncatedDescription)}
41
+ ${metaTag("capability", capability)}
42
+ </article>`,
43
+ )
44
+ .join("\n");
45
+
46
+ return section(formatLevelName(capability), skillItems, 2);
47
+ })
48
+ .join("\n");
49
+
50
+ return htmlDocument(
51
+ "Skills",
52
+ `<main>
53
+ <h1>Skills</h1>
54
+ ${content}
55
+ </main>`,
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Format skill detail as microdata HTML
61
+ * @param {Object} skill - Raw skill entity
62
+ * @param {Object} context - Additional context
63
+ * @param {Array} context.disciplines - All disciplines
64
+ * @param {Array} context.tracks - All tracks
65
+ * @param {Array} context.drivers - All drivers
66
+ * @param {Array} context.capabilities - Capability entities
67
+ * @returns {string} HTML with microdata
68
+ */
69
+ export function skillToMicrodata(
70
+ skill,
71
+ { disciplines, tracks, drivers, capabilities },
72
+ ) {
73
+ const view = prepareSkillDetail(skill, {
74
+ disciplines,
75
+ tracks,
76
+ drivers,
77
+ capabilities,
78
+ });
79
+
80
+ if (!view) return "";
81
+
82
+ const sections = [];
83
+
84
+ // Human-only badge
85
+ if (view.isHumanOnly) {
86
+ sections.push(`<p><strong>Human-Only</strong> — Requires interpersonal skills; excluded from agents</p>
87
+ ${metaTag("isHumanOnly", "true")}`);
88
+ }
89
+
90
+ // Level descriptions - uses LevelDescriptions itemtype
91
+ const levelPairs = Object.entries(view.levelDescriptions).map(
92
+ ([level, desc]) => ({
93
+ term: formatLevelName(level),
94
+ definition: desc,
95
+ itemprop: `${level}Description`,
96
+ }),
97
+ );
98
+ sections.push(
99
+ section(
100
+ "Level Descriptions",
101
+ `${openTag("div", { itemtype: "LevelDescriptions", itemprop: "levelDescriptions" })}
102
+ ${dl(levelPairs)}
103
+ </div>`,
104
+ 2,
105
+ ),
106
+ );
107
+
108
+ // Related disciplines
109
+ if (view.relatedDisciplines.length > 0) {
110
+ const disciplineItems = view.relatedDisciplines.map(
111
+ (d) =>
112
+ `<a href="#${escapeHtml(d.id)}">${escapeHtml(d.name)}</a> (${escapeHtml(d.skillType)})`,
113
+ );
114
+ sections.push(section("Used in Disciplines", ul(disciplineItems), 2));
115
+ }
116
+
117
+ // Related tracks with modifiers
118
+ if (view.relatedTracks.length > 0) {
119
+ const trackItems = view.relatedTracks.map((t) => {
120
+ const modifierStr = t.modifier > 0 ? `+${t.modifier}` : `${t.modifier}`;
121
+ return `<a href="#${escapeHtml(t.id)}">${escapeHtml(t.name)}</a>: ${modifierStr}`;
122
+ });
123
+ sections.push(section("Modified by Tracks", ul(trackItems), 2));
124
+ }
125
+
126
+ // Related drivers
127
+ if (view.relatedDrivers.length > 0) {
128
+ const driverItems = view.relatedDrivers.map(
129
+ (d) => `<a href="#${escapeHtml(d.id)}">${escapeHtml(d.name)}</a>`,
130
+ );
131
+ sections.push(section("Linked to Drivers", ul(driverItems), 2));
132
+ }
133
+
134
+ const body = `<main>
135
+ ${openTag("article", { itemtype: "Skill", itemid: `#${view.id}` })}
136
+ ${prop("h1", "name", view.name)}
137
+ ${metaTag("id", view.id)}
138
+ ${metaTag("capability", view.capability)}
139
+ ${propRaw(
140
+ "div",
141
+ "human",
142
+ `${openTag("div", { itemtype: "SkillHumanSection" })}
143
+ ${prop("p", "description", view.description)}
144
+ ${sections.join("\n")}
145
+ </div>`,
146
+ )}
147
+ </article>
148
+ </main>`;
149
+
150
+ return htmlDocument(view.name, body);
151
+ }