@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/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
+ }