@forwardimpact/libsyntheticrender 0.1.1
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/LICENSE +201 -0
- package/format.js +84 -0
- package/index.js +7 -0
- package/package.json +37 -0
- package/render/dataset-renderers.js +187 -0
- package/render/enricher.js +384 -0
- package/render/html.js +458 -0
- package/render/industry-data.js +434 -0
- package/render/link-assigner.js +350 -0
- package/render/markdown.js +126 -0
- package/render/pathway.js +124 -0
- package/render/raw.js +465 -0
- package/render/renderer.js +122 -0
- package/render/validate-links.js +329 -0
- package/templates/article.html +31 -0
- package/templates/blog-post.html +34 -0
- package/templates/blog.html +27 -0
- package/templates/briefing.md +20 -0
- package/templates/comments.html +15 -0
- package/templates/courses.html +37 -0
- package/templates/departments.html +29 -0
- package/templates/drugs.html +23 -0
- package/templates/events.html +38 -0
- package/templates/faq.html +22 -0
- package/templates/howto.html +8 -0
- package/templates/leadership.html +8 -0
- package/templates/ontology.md +34 -0
- package/templates/page.html +12 -0
- package/templates/platforms.html +23 -0
- package/templates/project-note.md +19 -0
- package/templates/projects.html +42 -0
- package/templates/readme.md +28 -0
- package/templates/reviews.html +19 -0
- package/templates/roles.html +11 -0
- package/templates/skill-reflection.md +18 -0
- package/templates/weekly.md +18 -0
- package/test/dataset-renderers.test.js +214 -0
- package/test/validate.test.js +396 -0
- package/validate.js +535 -0
package/render/html.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Renderer — generates HTML microdata files for Guide.
|
|
3
|
+
*
|
|
4
|
+
* Uses TemplateLoader from libtemplate for all output.
|
|
5
|
+
* Pass 1: Deterministic templates produce complete HTML with structural microdata.
|
|
6
|
+
* Pass 2: LLM enricher rewrites prose blocks in-place (handled by enricher.js).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { generateDrugs, generatePlatforms } from "./industry-data.js";
|
|
10
|
+
import { assignLinks } from "./link-assigner.js";
|
|
11
|
+
|
|
12
|
+
/** Wrap inner HTML in the page shell. */
|
|
13
|
+
function page(templates, title, body, domain) {
|
|
14
|
+
return templates.render("page.html", {
|
|
15
|
+
title,
|
|
16
|
+
body,
|
|
17
|
+
domain: `https://${domain}`,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function titleCase(str) {
|
|
22
|
+
return str.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Render HTML microdata files from entities and prose.
|
|
27
|
+
* @param {object} entities
|
|
28
|
+
* @param {Map<string,string>} prose
|
|
29
|
+
* @param {import('@forwardimpact/libtemplate/loader').TemplateLoader} templates - Template loader
|
|
30
|
+
* @returns {{ files: Map<string,string>, linked: import('./link-assigner.js').LinkedEntities }}
|
|
31
|
+
*/
|
|
32
|
+
export function renderHTML(entities, prose, templates) {
|
|
33
|
+
if (!templates) throw new Error("templates is required");
|
|
34
|
+
const files = new Map();
|
|
35
|
+
const domain = entities.domain;
|
|
36
|
+
|
|
37
|
+
// Generate industry data
|
|
38
|
+
const drugs = generateDrugs(domain);
|
|
39
|
+
const platforms = generatePlatforms(domain);
|
|
40
|
+
|
|
41
|
+
// Content config
|
|
42
|
+
const gc = entities.content.find((c) => c.id === "guide_html");
|
|
43
|
+
const courseCount = gc?.courses || 0;
|
|
44
|
+
const eventCount = gc?.events || 0;
|
|
45
|
+
const blogCount = gc?.blogs || 0;
|
|
46
|
+
const articleTopics = gc?.article_topics || [];
|
|
47
|
+
|
|
48
|
+
// Assign cross-links deterministically
|
|
49
|
+
const linked = assignLinks({
|
|
50
|
+
drugs,
|
|
51
|
+
platforms,
|
|
52
|
+
projects: entities.projects,
|
|
53
|
+
people: entities.people,
|
|
54
|
+
teams: entities.teams,
|
|
55
|
+
departments: entities.departments,
|
|
56
|
+
domain,
|
|
57
|
+
courseCount,
|
|
58
|
+
eventCount,
|
|
59
|
+
blogCount,
|
|
60
|
+
articleTopics,
|
|
61
|
+
seed: entities.activity?.seed || 42,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Enrich platform and drug entities with reverse links
|
|
65
|
+
const enrichedPlatforms = enrichPlatformsWithLinks(linked);
|
|
66
|
+
const enrichedDrugs = enrichDrugsWithLinks(linked);
|
|
67
|
+
|
|
68
|
+
// --- Structural pages (unchanged) ---
|
|
69
|
+
const leadershipBody = templates.render("leadership.html", {
|
|
70
|
+
managers: entities.people
|
|
71
|
+
.filter((p) => p.is_manager)
|
|
72
|
+
.map((m) => {
|
|
73
|
+
const team = entities.teams.find((t) => t.id === m.team_id);
|
|
74
|
+
const dept = team
|
|
75
|
+
? entities.departments.find((d) => d.id === team.department)
|
|
76
|
+
: null;
|
|
77
|
+
return {
|
|
78
|
+
...m,
|
|
79
|
+
teamName: team?.name || "",
|
|
80
|
+
departmentIri: dept?.iri || "",
|
|
81
|
+
};
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
files.set(
|
|
85
|
+
"organization-leadership.html",
|
|
86
|
+
page(templates, "Organization Leadership", leadershipBody, domain),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const deptBody = templates.render("departments.html", {
|
|
90
|
+
departments: entities.departments.map((d) => ({
|
|
91
|
+
...d,
|
|
92
|
+
teams: entities.teams
|
|
93
|
+
.filter((t) => t.department === d.id)
|
|
94
|
+
.map((t) => ({
|
|
95
|
+
...t,
|
|
96
|
+
members: entities.people
|
|
97
|
+
.filter((p) => p.team_id === t.id)
|
|
98
|
+
.map((p) => ({
|
|
99
|
+
iri: p.iri,
|
|
100
|
+
name: p.name,
|
|
101
|
+
jobTitle: `${p.level} ${p.discipline ? titleCase(p.discipline) : "Engineer"}`,
|
|
102
|
+
teamIri: t.iri,
|
|
103
|
+
})),
|
|
104
|
+
})),
|
|
105
|
+
})),
|
|
106
|
+
});
|
|
107
|
+
files.set(
|
|
108
|
+
"organization-departments-teams.html",
|
|
109
|
+
page(templates, "Organization Departments & Teams", deptBody, domain),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const rolesBody = templates.render("roles.html", {
|
|
113
|
+
domain: `https://${domain}`,
|
|
114
|
+
levels: ["L1", "L2", "L3", "L4", "L5"].map((id) => ({
|
|
115
|
+
id,
|
|
116
|
+
count: entities.people.filter((p) => p.level === id).length,
|
|
117
|
+
})),
|
|
118
|
+
});
|
|
119
|
+
files.set(
|
|
120
|
+
"roles.html",
|
|
121
|
+
page(templates, "Engineering Roles", rolesBody, domain),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// --- New linked document types ---
|
|
125
|
+
|
|
126
|
+
// Projects cross-functional
|
|
127
|
+
files.set(
|
|
128
|
+
"projects-cross-functional.html",
|
|
129
|
+
page(
|
|
130
|
+
templates,
|
|
131
|
+
"Cross-Functional Projects",
|
|
132
|
+
templates.render("projects.html", { projects: linked.projects }),
|
|
133
|
+
domain,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Technology platforms dependencies
|
|
138
|
+
files.set(
|
|
139
|
+
"technology-platforms-dependencies.html",
|
|
140
|
+
page(
|
|
141
|
+
templates,
|
|
142
|
+
"Technology Platforms",
|
|
143
|
+
templates.render("platforms.html", { platforms: enrichedPlatforms }),
|
|
144
|
+
domain,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Drugs development pipeline
|
|
149
|
+
files.set(
|
|
150
|
+
"drugs-development-pipeline.html",
|
|
151
|
+
page(
|
|
152
|
+
templates,
|
|
153
|
+
"Drug Development Pipeline",
|
|
154
|
+
templates.render("drugs.html", { drugs: enrichedDrugs }),
|
|
155
|
+
domain,
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// --- Content-driven pages ---
|
|
160
|
+
if (gc) {
|
|
161
|
+
for (const article of linked.articles || []) {
|
|
162
|
+
const body = templates.render("article.html", {
|
|
163
|
+
articles: [
|
|
164
|
+
{
|
|
165
|
+
...article,
|
|
166
|
+
prose:
|
|
167
|
+
prose.get(`article_${article.topic}`) ||
|
|
168
|
+
`Article about ${article.topic.replace(/_/g, " ")}.`,
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
files.set(
|
|
173
|
+
`articles-${article.topic.replace(/_/g, "-")}.html`,
|
|
174
|
+
page(templates, `${article.title} - Article`, body, domain),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Blog posts — individual files + index with blog collection wrapper
|
|
179
|
+
const blogIri = `https://${domain}/id/blog`;
|
|
180
|
+
const blogPosts = linked.blogPosts.map((post) => ({
|
|
181
|
+
...post,
|
|
182
|
+
blogIri,
|
|
183
|
+
body:
|
|
184
|
+
prose.get(`blog_${post.index - 1}`) ||
|
|
185
|
+
`Blog post about ${post.headline.toLowerCase()}.`,
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
// Individual blog post files
|
|
189
|
+
for (const post of blogPosts) {
|
|
190
|
+
files.set(
|
|
191
|
+
`blog-${post.index}.html`,
|
|
192
|
+
page(
|
|
193
|
+
templates,
|
|
194
|
+
post.headline,
|
|
195
|
+
templates.render("blog-post.html", post),
|
|
196
|
+
domain,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Blog index page
|
|
202
|
+
files.set(
|
|
203
|
+
"blog-posts.html",
|
|
204
|
+
page(
|
|
205
|
+
templates,
|
|
206
|
+
"Engineering Blog",
|
|
207
|
+
templates.render("blog.html", { blogIri, posts: blogPosts }),
|
|
208
|
+
domain,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
files.set(
|
|
213
|
+
"faq-pages.html",
|
|
214
|
+
page(
|
|
215
|
+
templates,
|
|
216
|
+
"Frequently Asked Questions",
|
|
217
|
+
templates.render("faq.html", {
|
|
218
|
+
faqs: Array.from({ length: gc.faqs || 0 }, (_, i) => {
|
|
219
|
+
const entityPool = [
|
|
220
|
+
...linked.drugs,
|
|
221
|
+
...linked.platforms,
|
|
222
|
+
...linked.projects,
|
|
223
|
+
];
|
|
224
|
+
const aboutLinks = [
|
|
225
|
+
entityPool[i % entityPool.length],
|
|
226
|
+
entityPool[(i + 3) % entityPool.length],
|
|
227
|
+
].filter(Boolean);
|
|
228
|
+
return {
|
|
229
|
+
iri: `https://${domain}/id/faq/faq-${i + 1}`,
|
|
230
|
+
question: `FAQ Question ${i + 1}`,
|
|
231
|
+
answer: prose.get(`faq_${i}`) || `Answer to FAQ ${i + 1}.`,
|
|
232
|
+
aboutLinks,
|
|
233
|
+
};
|
|
234
|
+
}),
|
|
235
|
+
}),
|
|
236
|
+
domain,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
for (const topic of gc.howto_topics || []) {
|
|
241
|
+
const body = templates.render("howto.html", {
|
|
242
|
+
domain: `https://${domain}`,
|
|
243
|
+
topic,
|
|
244
|
+
title: titleCase(topic),
|
|
245
|
+
prose:
|
|
246
|
+
prose.get(`howto_${topic}`) ||
|
|
247
|
+
`How-to guide for ${topic.replace(/_/g, " ")}.`,
|
|
248
|
+
});
|
|
249
|
+
files.set(
|
|
250
|
+
`howto-${topic.replace(/_/g, "-")}.html`,
|
|
251
|
+
page(templates, `How-To: ${titleCase(topic)}`, body, domain),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
files.set(
|
|
256
|
+
"reviews.html",
|
|
257
|
+
page(
|
|
258
|
+
templates,
|
|
259
|
+
"Reviews",
|
|
260
|
+
templates.render("reviews.html", {
|
|
261
|
+
reviews: Array.from({ length: gc.reviews || 0 }, (_, i) => {
|
|
262
|
+
const person = entities.people[i % entities.people.length];
|
|
263
|
+
const reviewPool = [
|
|
264
|
+
...linked.courses,
|
|
265
|
+
...linked.events,
|
|
266
|
+
...linked.platforms,
|
|
267
|
+
];
|
|
268
|
+
const reviewed = reviewPool[i % reviewPool.length];
|
|
269
|
+
return {
|
|
270
|
+
iri: `https://${domain}/id/review/review-${i + 1}`,
|
|
271
|
+
rating: 1 + ((i * 7 + 3) % 5),
|
|
272
|
+
author: person?.name || "Anonymous",
|
|
273
|
+
authorIri: person?.iri || "",
|
|
274
|
+
body:
|
|
275
|
+
prose.get(`review_${i}`) || "Good work on this implementation.",
|
|
276
|
+
reviewedIri: reviewed?.iri || "",
|
|
277
|
+
};
|
|
278
|
+
}),
|
|
279
|
+
}),
|
|
280
|
+
domain,
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
files.set(
|
|
285
|
+
"comments.html",
|
|
286
|
+
page(
|
|
287
|
+
templates,
|
|
288
|
+
"Discussion Comments",
|
|
289
|
+
templates.render("comments.html", {
|
|
290
|
+
comments: Array.from({ length: gc.comments || 0 }, (_, i) => {
|
|
291
|
+
const person = entities.people[i % entities.people.length];
|
|
292
|
+
const parentPool = [
|
|
293
|
+
...linked.blogPosts,
|
|
294
|
+
...(linked.articles || []),
|
|
295
|
+
];
|
|
296
|
+
const parent = parentPool[i % parentPool.length];
|
|
297
|
+
return {
|
|
298
|
+
iri: `https://${domain}/id/comment/comment-${i + 1}`,
|
|
299
|
+
author: person?.name || "Anonymous",
|
|
300
|
+
authorIri: person?.iri || "",
|
|
301
|
+
body:
|
|
302
|
+
prose.get(`comment_${i}`) || "Interesting discussion point.",
|
|
303
|
+
date: `2025-${String((i % 12) + 1).padStart(2, "0")}-${String((i % 28) + 1).padStart(2, "0")}`,
|
|
304
|
+
aboutIri: parent?.iri || "",
|
|
305
|
+
};
|
|
306
|
+
}),
|
|
307
|
+
}),
|
|
308
|
+
domain,
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Courses — enriched with IDs, prereqs, attendees, platform/drug links
|
|
313
|
+
files.set(
|
|
314
|
+
"courses-learning-catalog.html",
|
|
315
|
+
page(
|
|
316
|
+
templates,
|
|
317
|
+
"Learning Catalog",
|
|
318
|
+
templates.render("courses.html", { courses: linked.courses }),
|
|
319
|
+
domain,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Events — enriched with organizer, attendees, about links
|
|
324
|
+
files.set(
|
|
325
|
+
"events-program-calendar.html",
|
|
326
|
+
page(
|
|
327
|
+
templates,
|
|
328
|
+
"Event Calendar",
|
|
329
|
+
templates.render("events.html", { events: linked.events }),
|
|
330
|
+
domain,
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { files, linked };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Enrich platforms with reverse links from projects and drugs.
|
|
340
|
+
* @param {import('./link-assigner.js').LinkedEntities} linked
|
|
341
|
+
* @returns {object[]}
|
|
342
|
+
*/
|
|
343
|
+
function enrichPlatformsWithLinks(linked) {
|
|
344
|
+
return linked.platforms.map((plat) => {
|
|
345
|
+
// Resolve dependency objects for template rendering
|
|
346
|
+
const depObjects = (plat.dependencies || [])
|
|
347
|
+
.map((depId) => linked.platforms.find((p) => p.id === depId))
|
|
348
|
+
.filter(Boolean);
|
|
349
|
+
|
|
350
|
+
// Reverse: projects that link to this platform
|
|
351
|
+
const projectLinks = linked.projects
|
|
352
|
+
.filter((proj) => proj.platformLinks.some((pl) => pl.id === plat.id))
|
|
353
|
+
.slice(0, 3);
|
|
354
|
+
|
|
355
|
+
// Reverse: drugs that use this platform
|
|
356
|
+
const drugLinks = linked.drugs
|
|
357
|
+
.filter(
|
|
358
|
+
(d) =>
|
|
359
|
+
d.platformLinks && d.platformLinks.some((pl) => pl.id === plat.id),
|
|
360
|
+
)
|
|
361
|
+
.slice(0, 2);
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
...plat,
|
|
365
|
+
dependencies: depObjects,
|
|
366
|
+
projectLinks,
|
|
367
|
+
drugLinks,
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Enrich drugs with reverse links from projects, platforms, events.
|
|
374
|
+
* @param {import('./link-assigner.js').LinkedEntities} linked
|
|
375
|
+
* @returns {object[]}
|
|
376
|
+
*/
|
|
377
|
+
function enrichDrugsWithLinks(linked) {
|
|
378
|
+
const base = linked.drugs[0]?.iri?.replace(/\/id\/drug\/.*/, "") || "";
|
|
379
|
+
|
|
380
|
+
return linked.drugs.map((drug) => {
|
|
381
|
+
// Reverse: projects that reference this drug
|
|
382
|
+
const projectLinks = linked.projects
|
|
383
|
+
.filter((proj) => proj.drugLinks.some((dl) => dl.id === drug.id))
|
|
384
|
+
.slice(0, 3);
|
|
385
|
+
|
|
386
|
+
// Platforms associated via projects
|
|
387
|
+
const platformIds = new Set();
|
|
388
|
+
for (const proj of projectLinks) {
|
|
389
|
+
for (const pl of proj.platformLinks) platformIds.add(pl.id);
|
|
390
|
+
}
|
|
391
|
+
const platformLinks = linked.platforms
|
|
392
|
+
.filter((p) => platformIds.has(p.id))
|
|
393
|
+
.slice(0, 3);
|
|
394
|
+
|
|
395
|
+
// Events about this drug
|
|
396
|
+
const eventLinks = linked.events
|
|
397
|
+
.filter((e) => e.aboutDrugs.some((d) => d.id === drug.id))
|
|
398
|
+
.slice(0, 2);
|
|
399
|
+
|
|
400
|
+
const parentDrugIri = drug.parentDrug
|
|
401
|
+
? `${base}/id/drug/${drug.parentDrug}`
|
|
402
|
+
: null;
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
...drug,
|
|
406
|
+
projectLinks,
|
|
407
|
+
platformLinks,
|
|
408
|
+
eventLinks,
|
|
409
|
+
parentDrugIri,
|
|
410
|
+
};
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Render organization README.
|
|
416
|
+
* @param {object} entities
|
|
417
|
+
* @param {Map<string,string>} prose
|
|
418
|
+
* @param {import('@forwardimpact/libtemplate/loader').TemplateLoader} templates - Template loader
|
|
419
|
+
* @returns {string}
|
|
420
|
+
*/
|
|
421
|
+
export function renderREADME(entities, prose, templates) {
|
|
422
|
+
if (!templates) throw new Error("templates is required");
|
|
423
|
+
const orgName = entities.orgs[0]?.name || "Organization";
|
|
424
|
+
return templates.render("readme.md", {
|
|
425
|
+
orgName,
|
|
426
|
+
overview:
|
|
427
|
+
prose.get("org_readme") || `${orgName} is a pharmaceutical company.`,
|
|
428
|
+
departments: entities.departments.map((d) => ({
|
|
429
|
+
...d,
|
|
430
|
+
teams: entities.teams.filter((t) => t.department === d.id),
|
|
431
|
+
})),
|
|
432
|
+
projects: entities.projects.map((p) => ({
|
|
433
|
+
...p,
|
|
434
|
+
prose: prose.get(`project_${p.id}`) || p.prose_topic || "",
|
|
435
|
+
})),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Render ONTOLOGY.md with entity IRIs.
|
|
441
|
+
* @param {object} entities
|
|
442
|
+
* @param {import('@forwardimpact/libtemplate/loader').TemplateLoader} templates - Template loader
|
|
443
|
+
* @returns {string}
|
|
444
|
+
*/
|
|
445
|
+
export function renderONTOLOGY(entities, templates) {
|
|
446
|
+
if (!templates) throw new Error("templates is required");
|
|
447
|
+
const people = entities.people.slice(0, 30);
|
|
448
|
+
return templates.render("ontology.md", {
|
|
449
|
+
domain: entities.domain,
|
|
450
|
+
orgs: entities.orgs,
|
|
451
|
+
departments: entities.departments,
|
|
452
|
+
teams: entities.teams,
|
|
453
|
+
people,
|
|
454
|
+
hasMore: entities.people.length > 30,
|
|
455
|
+
moreCount: entities.people.length - 30,
|
|
456
|
+
projects: entities.projects,
|
|
457
|
+
});
|
|
458
|
+
}
|