@forwardimpact/pathway 0.1.0 → 0.2.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 (131) hide show
  1. package/app/commands/agent.js +109 -21
  2. package/app/commands/command-factory.js +3 -3
  3. package/app/commands/interview.js +14 -7
  4. package/app/commands/job.js +43 -29
  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 +111 -27
  10. package/app/css/components/surfaces.css +16 -0
  11. package/app/formatters/agent/profile.js +113 -87
  12. package/app/formatters/agent/skill.js +64 -31
  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 +3 -0
  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 +5 -3
  25. package/app/formatters/json-ld.js +242 -0
  26. package/app/formatters/microdata-shared.js +184 -0
  27. package/app/formatters/progress/shared.js +14 -11
  28. package/app/formatters/skill/dom.js +3 -0
  29. package/app/formatters/skill/microdata.js +151 -0
  30. package/app/formatters/stage/dom.js +3 -18
  31. package/app/formatters/stage/microdata.js +110 -0
  32. package/app/formatters/stage/shared.js +0 -27
  33. package/app/formatters/track/dom.js +5 -30
  34. package/app/formatters/track/markdown.js +2 -25
  35. package/app/formatters/track/microdata.js +111 -0
  36. package/app/formatters/track/shared.js +6 -58
  37. package/app/handout-main.js +26 -12
  38. package/app/index.html +11 -0
  39. package/app/lib/card-mappers.js +17 -12
  40. package/app/lib/job-cache.js +12 -9
  41. package/app/lib/template-loader.js +66 -0
  42. package/app/lib/yaml-loader.js +25 -8
  43. package/app/main.js +8 -4
  44. package/app/model/agent.js +158 -130
  45. package/app/model/checklist.js +57 -91
  46. package/app/model/derivation.js +135 -68
  47. package/app/model/index-generator.js +1 -7
  48. package/app/model/job.js +19 -13
  49. package/app/model/levels.js +20 -12
  50. package/app/model/loader.js +41 -17
  51. package/app/model/matching.js +33 -3
  52. package/app/model/profile.js +38 -45
  53. package/app/model/schema-validation.js +438 -0
  54. package/app/model/validation.js +747 -68
  55. package/app/pages/agent-builder.js +119 -25
  56. package/app/pages/assessment-results.js +10 -4
  57. package/app/pages/discipline.js +36 -6
  58. package/app/pages/driver.js +9 -47
  59. package/app/pages/interview-builder.js +3 -1
  60. package/app/pages/interview.js +15 -4
  61. package/app/pages/job-builder.js +4 -1
  62. package/app/pages/job.js +15 -4
  63. package/app/pages/landing.js +10 -10
  64. package/app/pages/progress-builder.js +3 -1
  65. package/app/pages/progress.js +72 -21
  66. package/app/pages/stage.js +3 -126
  67. package/app/slide-main.js +45 -17
  68. package/app/slides/index.js +3 -1
  69. package/app/slides/overview.js +40 -4
  70. package/app/slides/progress.js +4 -2
  71. package/bin/pathway.js +18 -64
  72. package/examples/agents/.claude/skills/architecture-design/SKILL.md +58 -16
  73. package/examples/agents/.claude/skills/cloud-platforms/SKILL.md +59 -18
  74. package/examples/agents/.claude/skills/code-quality-review/SKILL.md +58 -17
  75. package/examples/agents/.claude/skills/devops-cicd/SKILL.md +64 -18
  76. package/examples/agents/.claude/skills/full-stack-development/SKILL.md +59 -15
  77. package/examples/agents/.claude/skills/sre-practices/SKILL.md +64 -18
  78. package/examples/agents/.claude/skills/technical-debt-management/SKILL.md +58 -17
  79. package/examples/agents/.github/agents/se-platform-code.agent.md +39 -88
  80. package/examples/agents/.github/agents/se-platform-plan.agent.md +41 -88
  81. package/examples/agents/.github/agents/se-platform-review.agent.md +38 -15
  82. package/examples/agents/.vscode/settings.json +1 -1
  83. package/examples/behaviours/outcome_ownership.yaml +1 -2
  84. package/examples/behaviours/polymathic_knowledge.yaml +1 -2
  85. package/examples/behaviours/precise_communication.yaml +1 -2
  86. package/examples/behaviours/relentless_curiosity.yaml +1 -2
  87. package/examples/behaviours/systems_thinking.yaml +1 -2
  88. package/examples/capabilities/business.yaml +80 -142
  89. package/examples/capabilities/delivery.yaml +155 -219
  90. package/examples/capabilities/people.yaml +2 -34
  91. package/examples/capabilities/reliability.yaml +161 -80
  92. package/examples/capabilities/scale.yaml +234 -252
  93. package/examples/copilot-setup-steps.yaml +25 -0
  94. package/examples/devcontainer.yaml +21 -0
  95. package/examples/disciplines/_index.yaml +1 -0
  96. package/examples/disciplines/data_engineering.yaml +14 -12
  97. package/examples/disciplines/engineering_management.yaml +63 -0
  98. package/examples/disciplines/software_engineering.yaml +14 -12
  99. package/examples/drivers.yaml +1 -4
  100. package/examples/framework.yaml +1 -2
  101. package/examples/grades.yaml +1 -3
  102. package/examples/questions/behaviours/outcome_ownership.yaml +1 -2
  103. package/examples/questions/behaviours/polymathic_knowledge.yaml +1 -2
  104. package/examples/questions/behaviours/precise_communication.yaml +1 -2
  105. package/examples/questions/behaviours/relentless_curiosity.yaml +1 -2
  106. package/examples/questions/behaviours/systems_thinking.yaml +1 -2
  107. package/examples/questions/skills/architecture_design.yaml +1 -2
  108. package/examples/questions/skills/cloud_platforms.yaml +1 -2
  109. package/examples/questions/skills/code_quality.yaml +1 -2
  110. package/examples/questions/skills/data_modeling.yaml +1 -2
  111. package/examples/questions/skills/devops.yaml +1 -2
  112. package/examples/questions/skills/full_stack_development.yaml +1 -2
  113. package/examples/questions/skills/sre_practices.yaml +1 -2
  114. package/examples/questions/skills/stakeholder_management.yaml +1 -2
  115. package/examples/questions/skills/team_collaboration.yaml +1 -2
  116. package/examples/questions/skills/technical_writing.yaml +1 -2
  117. package/examples/self-assessments.yaml +1 -3
  118. package/examples/stages.yaml +101 -46
  119. package/examples/tracks/_index.yaml +0 -1
  120. package/examples/tracks/platform.yaml +8 -13
  121. package/examples/tracks/sre.yaml +8 -18
  122. package/examples/vscode-settings.yaml +2 -7
  123. package/package.json +9 -3
  124. package/templates/agent.template.md +65 -0
  125. package/templates/skill.template.md +28 -0
  126. package/examples/agents/.claude/skills/data-modeling/SKILL.md +0 -99
  127. package/examples/agents/.claude/skills/developer-experience/SKILL.md +0 -99
  128. package/examples/agents/.claude/skills/knowledge-management/SKILL.md +0 -100
  129. package/examples/agents/.claude/skills/pattern-generalization/SKILL.md +0 -102
  130. package/examples/agents/.claude/skills/technical-writing/SKILL.md +0 -129
  131. package/examples/tracks/manager.yaml +0 -53
@@ -2,11 +2,12 @@
2
2
  * Formatter Layer
3
3
  *
4
4
  * Export all formatters for easy importing.
5
- * Formatters transform presenter output into specific formats (DOM, markdown)
5
+ * Formatters transform presenter output into specific formats (DOM, markdown, microdata)
6
6
  */
7
7
 
8
8
  // Shared utilities
9
9
  export * from "./shared.js";
10
+ export * from "./microdata-shared.js";
10
11
 
11
12
  // Job formatters
12
13
  export { jobToMarkdown } from "./job/markdown.js";
@@ -22,10 +23,15 @@ export { progressToDOM } from "./progress/dom.js";
22
23
 
23
24
  // Driver formatters
24
25
  export { driverToDOM } from "./driver/dom.js";
26
+ export {
27
+ driverListToMicrodata,
28
+ driverToMicrodata,
29
+ } from "./driver/microdata.js";
25
30
 
26
31
  // Skill formatters
27
32
  export { skillListToMarkdown, skillToMarkdown } from "./skill/markdown.js";
28
33
  export { skillToDOM } from "./skill/dom.js";
34
+ export { skillListToMicrodata, skillToMicrodata } from "./skill/microdata.js";
29
35
 
30
36
  // Behaviour formatters
31
37
  export {
@@ -33,6 +39,10 @@ export {
33
39
  behaviourToMarkdown,
34
40
  } from "./behaviour/markdown.js";
35
41
  export { behaviourToDOM } from "./behaviour/dom.js";
42
+ export {
43
+ behaviourListToMicrodata,
44
+ behaviourToMicrodata,
45
+ } from "./behaviour/microdata.js";
36
46
 
37
47
  // Discipline formatters
38
48
  export {
@@ -40,11 +50,32 @@ export {
40
50
  disciplineToMarkdown,
41
51
  } from "./discipline/markdown.js";
42
52
  export { disciplineToDOM } from "./discipline/dom.js";
53
+ export {
54
+ disciplineListToMicrodata,
55
+ disciplineToMicrodata,
56
+ } from "./discipline/microdata.js";
43
57
 
44
58
  // Grade formatters
45
59
  export { gradeListToMarkdown, gradeToMarkdown } from "./grade/markdown.js";
46
60
  export { gradeToDOM } from "./grade/dom.js";
61
+ export { gradeListToMicrodata, gradeToMicrodata } from "./grade/microdata.js";
47
62
 
48
63
  // Track formatters
49
64
  export { trackListToMarkdown, trackToMarkdown } from "./track/markdown.js";
50
65
  export { trackToDOM } from "./track/dom.js";
66
+ export { trackListToMicrodata, trackToMicrodata } from "./track/microdata.js";
67
+
68
+ // Stage formatters
69
+ export { stageListToMicrodata, stageToMicrodata } from "./stage/microdata.js";
70
+
71
+ // JSON-LD formatters
72
+ export {
73
+ createJsonLdScript,
74
+ skillToJsonLd,
75
+ behaviourToJsonLd,
76
+ disciplineToJsonLd,
77
+ trackToJsonLd,
78
+ gradeToJsonLd,
79
+ driverToJsonLd,
80
+ stageToJsonLd,
81
+ } from "./json-ld.js";
@@ -116,7 +116,7 @@ export function prepareInterviewDetail({
116
116
  questions,
117
117
  interviewType = "full",
118
118
  }) {
119
- if (!discipline || !grade || !track) return null;
119
+ if (!discipline || !grade) return null;
120
120
 
121
121
  const job = getOrCreateJob({
122
122
  discipline,
@@ -171,8 +171,8 @@ export function prepareInterviewDetail({
171
171
  disciplineId: discipline.id,
172
172
  disciplineName: discipline.specialization || discipline.name,
173
173
  gradeId: grade.id,
174
- trackId: track.id,
175
- trackName: track.name,
174
+ trackId: track?.id || null,
175
+ trackName: track?.name || null,
176
176
  sections: allSections,
177
177
  totalQuestions,
178
178
  expectedDurationMinutes: typeConfig.expectedDurationMinutes,
@@ -206,7 +206,8 @@ export function prepareInterviewBuilderPreview({
206
206
  behaviourCount,
207
207
  grades,
208
208
  }) {
209
- if (!discipline || !grade || !track) {
209
+ // Track is optional (null = generalist)
210
+ if (!discipline || !grade) {
210
211
  return {
211
212
  isValid: false,
212
213
  title: null,
@@ -224,12 +225,15 @@ export function prepareInterviewBuilderPreview({
224
225
  });
225
226
 
226
227
  if (!validCombination) {
228
+ const reason = track
229
+ ? `The ${track.name} track is not available for ${discipline.specialization}.`
230
+ : `${discipline.specialization} requires a track specialization.`;
227
231
  return {
228
232
  isValid: false,
229
233
  title: null,
230
234
  totalSkills: 0,
231
235
  totalBehaviours: 0,
232
- invalidReason: `The ${track.name} track is not available for ${discipline.specialization}.`,
236
+ invalidReason: reason,
233
237
  };
234
238
  }
235
239
 
@@ -275,7 +279,8 @@ export function prepareAllInterviews({
275
279
  behaviours,
276
280
  questions,
277
281
  }) {
278
- if (!discipline || !grade || !track) return null;
282
+ // Track is optional (null = generalist)
283
+ if (!discipline || !grade) return null;
279
284
 
280
285
  const job = getOrCreateJob({
281
286
  discipline,
@@ -313,8 +318,8 @@ export function prepareAllInterviews({
313
318
  disciplineId: discipline.id,
314
319
  disciplineName: discipline.specialization || discipline.name,
315
320
  gradeId: grade.id,
316
- trackId: track.id,
317
- trackName: track.name,
321
+ trackId: track?.id || null,
322
+ trackName: track?.name || null,
318
323
  interviews: {
319
324
  short: {
320
325
  ...shortInterview,
@@ -29,7 +29,9 @@ export function formatJobDescription({ job, discipline, grade, track }) {
29
29
  // Meta information
30
30
  lines.push(`- **Level:** ${grade.id}`);
31
31
  lines.push(`- **Experience:** ${grade.typicalExperienceRange}`);
32
- lines.push(`- **Track:** ${track.name}`);
32
+ if (track) {
33
+ lines.push(`- **Track:** ${track.name}`);
34
+ }
33
35
  lines.push("");
34
36
 
35
37
  // Role Summary
@@ -37,7 +39,7 @@ export function formatJobDescription({ job, discipline, grade, track }) {
37
39
  lines.push("");
38
40
 
39
41
  // Build role summary from discipline - use manager version if applicable
40
- const isManagement = track.isManagement === true;
42
+ const isManagement = discipline.isManagement === true;
41
43
  let roleSummary =
42
44
  isManagement && discipline.managementRoleSummary
43
45
  ? discipline.managementRoleSummary
@@ -50,7 +52,7 @@ export function formatJobDescription({ job, discipline, grade, track }) {
50
52
  lines.push("");
51
53
 
52
54
  // Add track context
53
- if (track.roleContext) {
55
+ if (track?.roleContext) {
54
56
  lines.push(track.roleContext);
55
57
  lines.push("");
56
58
  }
@@ -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,
@@ -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" },