euparliamentmonitor 0.9.2 → 0.9.4

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.
@@ -25,11 +25,13 @@ import { ArticleCategory } from '../types/index.js';
25
25
  import { escapeHTML } from '../utils/file-utils.js';
26
26
  import { buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../templates/section-builders.js';
27
27
  import { READER_GUIDE_SECTION_ID } from './reader-guide-constants.js';
28
- import { READER_GUIDE_TITLE_LABELS } from './reader-intelligence-guide.js';
28
+ import { READER_GUIDE_TITLE_LABELS, getReaderGuideSectionIcon, } from './reader-intelligence-guide.js';
29
29
  import { TRADECRAFT_SECTION_ID, MANIFEST_SECTION_ID, SUPPLEMENTARY_SECTION_ID, } from './artifact-order.js';
30
+ import { humanizeStem } from './analysis-aggregator.js';
30
31
  import { KEY_TAKEAWAYS_SECTION_ID } from './key-takeaways.js';
31
32
  import { getPoliticalIntelligenceFilename } from '../generators/political-intelligence.js';
32
33
  import { getSitemapFilename } from '../generators/sitemap/index.js';
34
+ import { getCuratedTitle, getCuratedDescription, getArtifactInfo, } from '../generators/political-intelligence-descriptions.js';
33
35
  /**
34
36
  * Resolve a localized article type label with icon. Falls back to the
35
37
  * humanised slug when a translation isn't available.
@@ -132,12 +134,38 @@ function getLocalizedTocTitle(sectionId, fallbackTitle, lang) {
132
134
  }
133
135
  return fallbackTitle;
134
136
  }
137
+ /**
138
+ * Resolve the visual icon glyph used as the Table-of-Contents bullet for
139
+ * a given section. Reuses {@link getReaderGuideSectionIcon} for the
140
+ * canonical artifact sections (so the TOC and the Reader Intelligence
141
+ * Guide share the same visual vocabulary), and adds dedicated icons for
142
+ * the aggregator-owned appendix anchors that the guide does not list.
143
+ *
144
+ * @param sectionId - Anchor id of the section (e.g. `section-risk`,
145
+ * `aggregator-tradecraft-references`)
146
+ * @returns Single emoji glyph used as the `guide-icon` for that entry
147
+ */
148
+ function getTocSectionIcon(sectionId) {
149
+ if (sectionId === READER_GUIDE_SECTION_ID)
150
+ return '🧭';
151
+ if (sectionId === KEY_TAKEAWAYS_SECTION_ID)
152
+ return '🔑';
153
+ if (sectionId === SUPPLEMENTARY_SECTION_ID)
154
+ return '🗂️';
155
+ if (sectionId === TRADECRAFT_SECTION_ID)
156
+ return '🛠️';
157
+ if (sectionId === MANIFEST_SECTION_ID)
158
+ return '📚';
159
+ return getReaderGuideSectionIcon(sectionId);
160
+ }
135
161
  /**
136
162
  * Build the article-level Table of Contents nav. Renders a labelled
137
163
  * `<nav class="article-toc">` with one `<a>` per H2 section, keyed by the
138
164
  * stable fragment ids produced by the aggregator. The containing `<aside>`
139
- * is styled as a sticky sidebar on wide viewports and collapses into a
140
- * `<details>` disclosure on narrow viewports via `styles.css`.
165
+ * is styled as a sticky, full-height sidebar on wide viewports and
166
+ * collapses into a `<details>` disclosure on narrow viewports via
167
+ * `styles.css`. Each entry is prefixed with a contextual emoji icon so
168
+ * readers can scan the navigation visually as well as textually.
141
169
  *
142
170
  * Returns an empty string when `entries` is empty so low-signal
143
171
  * `ANALYSIS_ONLY` articles (few sections, no value in a TOC) stay compact.
@@ -153,7 +181,8 @@ export function buildArticleToc(entries, lang) {
153
181
  const items = entries
154
182
  .map((e) => {
155
183
  const displayTitle = getLocalizedTocTitle(e.id, e.title, lang);
156
- return ` <li><a href="#${escapeHTML(e.id)}">${escapeHTML(displayTitle)}</a></li>`;
184
+ const icon = getTocSectionIcon(e.id);
185
+ return ` <li><a href="#${escapeHTML(e.id)}"><span class="article-toc-icon" aria-hidden="true">${icon}</span> <span class="article-toc-text">${escapeHTML(displayTitle)}</span></a></li>`;
157
186
  })
158
187
  .join('\n');
159
188
  return [
@@ -205,11 +234,20 @@ export function localizeArticleBody(bodyHtml, lang) {
205
234
  }
206
235
  }
207
236
  // --- Methodologies sub-heading ---
237
+ // markdown-it's anchor plugin renders sub-headings as
238
+ // `<h3 id="methodologies" tabindex="-1"><a class="header-anchor"
239
+ // href="#methodologies"><span>Methodologies</span></a></h3>`.
240
+ // Localise the inner `<span>Methodologies</span>` text without using
241
+ // regular expressions to avoid catastrophic backtracking on long
242
+ // inputs. We accept either the anchor-prefixed form above or the
243
+ // bare `<h3>Methodologies</h3>` form some renderers emit.
208
244
  const methodsLabel = getLocalizedString(TRADECRAFT_METHODOLOGIES_LABELS, lang);
209
- html = html.replace(/<h3>Methodologies<\/h3>/, `<h3>${escapeHTML(methodsLabel)}</h3>`);
245
+ html = replaceFirstStringIn(html, '<span>Methodologies</span>', `<span>${escapeHTML(methodsLabel)}</span>`);
246
+ html = replaceFirstStringIn(html, '<h3>Methodologies</h3>', `<h3>${escapeHTML(methodsLabel)}</h3>`);
210
247
  // --- Artifact templates sub-heading ---
211
248
  const templatesLabel = getLocalizedString(TRADECRAFT_TEMPLATES_LABELS, lang);
212
- html = html.replace(/<h3>Artifact templates<\/h3>/, `<h3>${escapeHTML(templatesLabel)}</h3>`);
249
+ html = replaceFirstStringIn(html, '<span>Artifact templates</span>', `<span>${escapeHTML(templatesLabel)}</span>`);
250
+ html = replaceFirstStringIn(html, '<h3>Artifact templates</h3>', `<h3>${escapeHTML(templatesLabel)}</h3>`);
213
251
  // --- Analysis Index heading ---
214
252
  const analysisIndexHeading = getLocalizedString(ANALYSIS_INDEX_HEADING_LABELS, lang);
215
253
  html = replaceHeadingById(html, MANIFEST_SECTION_ID, 'Analysis Index', analysisIndexHeading);
@@ -259,6 +297,24 @@ export function localizeArticleBody(bodyHtml, lang) {
259
297
  html = replaceHeadingById(html, SUPPLEMENTARY_SECTION_ID, 'Supplementary Intelligence', supplementaryHeading);
260
298
  return html;
261
299
  }
300
+ /**
301
+ * Replace the first literal occurrence of `needle` in `haystack` with
302
+ * `replacement`. Uses `indexOf` rather than `String.prototype.replace`
303
+ * with a regex so we don't fall foul of the security/detect-unsafe-regex
304
+ * lint rule, and so we never accidentally interpret regex metacharacters
305
+ * inside `needle` or `$1`-style references inside `replacement`.
306
+ *
307
+ * @param haystack - String to search in
308
+ * @param needle - Literal substring to replace
309
+ * @param replacement - Literal replacement text (no `$` escaping needed)
310
+ * @returns Modified string, or `haystack` unchanged when `needle` is absent
311
+ */
312
+ function replaceFirstStringIn(haystack, needle, replacement) {
313
+ const idx = haystack.indexOf(needle);
314
+ if (idx === -1)
315
+ return haystack;
316
+ return haystack.slice(0, idx) + replacement + haystack.slice(idx + needle.length);
317
+ }
262
318
  /**
263
319
  * Replace an H2 heading's text content by locating it via its `id` attribute.
264
320
  * Uses indexOf-based search to avoid polynomial regex backtracking (CodeQL).
@@ -295,6 +351,575 @@ function replaceHeadingById(html, sectionId, englishTitle, localizedTitle) {
295
351
  return html;
296
352
  return html.slice(0, titleStart) + escapeHTML(localizedTitle) + html.slice(titleEnd);
297
353
  }
354
+ /* ─── Tradecraft & Analysis Index card-grid enhancement ─────────── */
355
+ /**
356
+ * Default emoji used for cards that do not have a curated icon mapped to
357
+ * their stem. Mirrors the fallback used in
358
+ * {@link political-intelligence/html.buildPiCard}.
359
+ */
360
+ const DEFAULT_CARD_ICON = '🧭';
361
+ /**
362
+ * Curated icon overrides keyed by the methodology / template stem (the
363
+ * filename without the `.md` extension). Mirrors a subset of the icon
364
+ * map used by `generators/political-intelligence/html.ts` so the cards
365
+ * embedded inside news articles match the icons on the dedicated
366
+ * Political Intelligence index page.
367
+ */
368
+ const STEM_ICONS = {
369
+ README: '📘',
370
+ 'ai-driven-analysis-guide': '🧭',
371
+ 'analytical-supplementary-methodology': '🧭',
372
+ 'artifact-catalog': '📚',
373
+ 'electoral-cycle-methodology': '🗳️',
374
+ 'electoral-domain-methodology': '🗳️',
375
+ 'forward-projection-methodology': '🔭',
376
+ 'imf-indicator-mapping': '💶',
377
+ 'osint-tradecraft-standards': '🛳️',
378
+ 'per-artifact-methodologies': '🧭',
379
+ 'per-document-methodology': '🧭',
380
+ 'political-classification-guide': '🏷️',
381
+ 'political-risk-methodology': '⚠️',
382
+ 'political-style-guide': '✒️',
383
+ 'political-swot-framework': '⚖️',
384
+ 'political-threat-framework': '🛡️',
385
+ 'strategic-extensions-methodology': '🧭',
386
+ 'structural-metadata-methodology': '🧭',
387
+ 'synthesis-methodology': '🔗',
388
+ 'worldbank-indicator-mapping': '🌍',
389
+ 'actor-mapping': '🎭',
390
+ 'actor-threat-profiles': '🛡️',
391
+ 'analysis-index': '📚',
392
+ 'coalition-dynamics': '🤝',
393
+ 'coalition-mathematics': '🧮',
394
+ 'commission-wp-alignment': '📋',
395
+ 'comparative-international': '🌐',
396
+ 'consequence-trees': '🌳',
397
+ 'cross-reference-map': '🗺️',
398
+ 'cross-run-diff': '🔁',
399
+ 'cross-session-intelligence': '🔁',
400
+ 'data-download-manifest': '📦',
401
+ 'deep-analysis': '🔍',
402
+ 'devils-advocate-analysis': '🪞',
403
+ 'economic-context': '💶',
404
+ 'executive-brief': '📋',
405
+ 'forces-analysis': '⚙️',
406
+ 'forward-indicators': '🔭',
407
+ 'forward-projection': '🔭',
408
+ 'historical-baseline': '📜',
409
+ 'historical-parallels': '📜',
410
+ 'imf-vintage-audit': '💶',
411
+ 'impact-matrix': '📊',
412
+ 'implementation-feasibility': '🔧',
413
+ 'intelligence-assessment': '🧠',
414
+ 'legislative-disruption': '🛡️',
415
+ 'legislative-pipeline-forecast': '🛤️',
416
+ 'legislative-velocity-risk': '⏱️',
417
+ 'mandate-fulfilment-scorecard': '📋',
418
+ 'mcp-reliability-audit': '📡',
419
+ 'media-framing-analysis': '📰',
420
+ 'methodology-reflection': '🪞',
421
+ 'parliamentary-calendar-projection': '📅',
422
+ 'per-file-political-intelligence': '🧭',
423
+ 'pestle-analysis': '🌍',
424
+ 'political-capital-risk': '💼',
425
+ 'political-classification': '🏷️',
426
+ 'political-threat-landscape': '🛡️',
427
+ 'presidency-trio-context': '🇪🇺',
428
+ 'quantitative-swot': '⚖️',
429
+ 'reference-analysis-quality': '✅',
430
+ 'risk-assessment': '⚠️',
431
+ 'risk-matrix': '⚠️',
432
+ 'scenario-forecast': '🔮',
433
+ 'seat-projection': '🪑',
434
+ 'session-baseline': '📊',
435
+ 'significance-classification': '⚖️',
436
+ 'significance-scoring': '⚖️',
437
+ 'stakeholder-impact': '👥',
438
+ 'stakeholder-map': '👥',
439
+ 'swot-analysis': '⚖️',
440
+ 'synthesis-summary': '🔗',
441
+ 'term-arc': '🗳️',
442
+ 'threat-analysis': '🛡️',
443
+ 'threat-model': '🛡️',
444
+ 'voter-segmentation': '👥',
445
+ 'voting-patterns': '🤝',
446
+ 'wildcards-blackswans': '⚡',
447
+ 'workflow-audit': '🔧',
448
+ };
449
+ /**
450
+ * Resolve the icon for a tradecraft / artifact card by file stem.
451
+ *
452
+ * @param stem - File stem (filename without `.md`)
453
+ * @returns Single emoji glyph for the card icon
454
+ */
455
+ function getStemIcon(stem) {
456
+ return STEM_ICONS[stem] ?? DEFAULT_CARD_ICON;
457
+ }
458
+ /**
459
+ * Extract `<a href="…">label</a>` link tokens from the slice of HTML
460
+ * between two indices. Used to harvest the methodology / template list
461
+ * the Markdown renderer emitted as `<ul><li><a>…</a></li>…</ul>` so we
462
+ * can re-render it as a card grid.
463
+ *
464
+ * Anchors whose `href` does not point at an `analysis/<methodologies|
465
+ * templates>/<…>.md` blob URL are silently skipped — the tradecraft
466
+ * appendix only contains those, and any stray external link (e.g. the
467
+ * Hack23 URL inside the intro paragraph) must not be promoted to a card.
468
+ *
469
+ * @param html - HTML slice to scan
470
+ * @param expectedPrefix - Path prefix (e.g. `analysis/methodologies/`)
471
+ * @returns List of extracted `{ href, repoRelPath }` tuples
472
+ */
473
+ function extractTradecraftLinks(html, expectedPrefix) {
474
+ const out = [];
475
+ // Walk anchor tags one at a time using indexOf to avoid catastrophic
476
+ // backtracking on long inputs (CodeQL js/polynomial-redos).
477
+ let cursor = 0;
478
+ while (cursor < html.length) {
479
+ const aIdx = html.indexOf('<a ', cursor);
480
+ if (aIdx === -1)
481
+ break;
482
+ const hrefIdx = html.indexOf('href="', aIdx);
483
+ if (hrefIdx === -1)
484
+ break;
485
+ const urlStart = hrefIdx + 'href="'.length;
486
+ const urlEnd = html.indexOf('"', urlStart);
487
+ if (urlEnd === -1)
488
+ break;
489
+ const href = html.slice(urlStart, urlEnd);
490
+ const closeIdx = html.indexOf('>', urlEnd);
491
+ if (closeIdx === -1)
492
+ break;
493
+ const endIdx = html.indexOf('</a>', closeIdx);
494
+ if (endIdx === -1)
495
+ break;
496
+ cursor = endIdx + '</a>'.length;
497
+ // Only keep links pointing at the expected analysis/* path under the
498
+ // GitHub blob URL — every other anchor is intro chrome.
499
+ const blobMarker = `/blob/main/${expectedPrefix}`;
500
+ const blobIdx = href.indexOf(blobMarker);
501
+ if (blobIdx === -1)
502
+ continue;
503
+ const repoRelPath = href.slice(blobIdx + '/blob/main/'.length);
504
+ out.push({ href, repoRelPath });
505
+ }
506
+ return out;
507
+ }
508
+ /**
509
+ * Render a single tradecraft / artifact card. Mirrors the structure
510
+ * used on `political-intelligence.html` so the visual vocabulary stays
511
+ * consistent (same `.pi-card-grid`, `.pi-card`, `.pi-card__icon`,
512
+ * `.pi-card__body`, `.pi-card__title`, `.pi-card__desc`,
513
+ * `.pi-card__cta` class hooks).
514
+ *
515
+ * The `.pi-card__path` filename row that the political-intelligence
516
+ * page emits is intentionally omitted here — inside an article body the
517
+ * curated title plus the curated description already convey the
518
+ * artifact's purpose, and the raw `analysis/.../foo.md` filename adds
519
+ * visual noise without providing reader-relevant context. Readers who
520
+ * need the path can hover the card link or click through.
521
+ *
522
+ * The CTA is kind-aware ("View methodology" for the Methodologies
523
+ * sub-section, "View artifact template" for the Artifact templates
524
+ * sub-section) — the older generic "View on GitHub" leaked workflow
525
+ * jargon into a reader-facing surface and provided no context about
526
+ * what the link targets.
527
+ *
528
+ * @param link - Extracted link with absolute href + repo-relative path
529
+ * @param lang - Target language code for title/description lookup
530
+ * @param ctaLabel - Pre-resolved localised CTA text (kind-aware)
531
+ * @returns HTML fragment for one `<li class="pi-card">…</li>` element
532
+ */
533
+ function renderTradecraftCard(link, lang, ctaLabel) {
534
+ const stem = link.repoRelPath.split('/').pop()?.replace(/\.md$/i, '') ?? link.repoRelPath;
535
+ // Use the humanised stem (e.g. "Electoral Cycle Methodology") as the
536
+ // fallback title, matching how the political-intelligence page
537
+ // resolves titles for files without a curated entry. The previous
538
+ // raw-stem fallback ("electoral-cycle-methodology") leaked filename
539
+ // noise into reader-facing card titles.
540
+ const fallbackTitle = humanizeStem(stem);
541
+ const title = getCuratedTitle(link.repoRelPath, lang, fallbackTitle);
542
+ const description = getCuratedDescription(link.repoRelPath, lang, fallbackTitle);
543
+ const icon = getStemIcon(stem);
544
+ return [
545
+ ` <li class="pi-card">`,
546
+ ` <a class="pi-card__link" href="${escapeHTML(link.href)}" rel="noopener external" target="_blank">`,
547
+ ` <span class="pi-card__icon" aria-hidden="true">${icon}</span>`,
548
+ ` <span class="pi-card__body">`,
549
+ ` <span class="pi-card__title">${escapeHTML(title)}</span>`,
550
+ ` <span class="pi-card__desc">${escapeHTML(description)}</span>`,
551
+ ` <span class="pi-card__cta">${escapeHTML(ctaLabel)} <span aria-hidden="true">↗</span></span>`,
552
+ ` </span>`,
553
+ ` </a>`,
554
+ ` </li>`,
555
+ ].join('\n');
556
+ }
557
+ /**
558
+ * Localised "View methodology" CTA used on every Tradecraft References
559
+ * methodology card. Tells the reader exactly what the link surface
560
+ * targets — a methodology guide — so the call-to-action is informative
561
+ * even when the card is read in isolation.
562
+ *
563
+ * @param lang - Target language code
564
+ * @returns Localised CTA text
565
+ */
566
+ function getViewMethodologyLabel(lang) {
567
+ const labels = {
568
+ en: 'View methodology',
569
+ sv: 'Visa metodologi',
570
+ da: 'Se metode',
571
+ no: 'Se metodologi',
572
+ fi: 'Näytä metodologia',
573
+ de: 'Methodologie ansehen',
574
+ fr: 'Voir la méthodologie',
575
+ es: 'Ver metodología',
576
+ nl: 'Methodologie bekijken',
577
+ ar: 'عرض المنهجية',
578
+ he: 'הצג מתודולוגיה',
579
+ ja: '方法論を表示',
580
+ ko: '방법론 보기',
581
+ zh: '查看方法论',
582
+ };
583
+ return labels[lang] ?? labels.en ?? 'View methodology';
584
+ }
585
+ /**
586
+ * Localised "View artifact template" CTA used on every Tradecraft
587
+ * References artifact-template card. Tells the reader exactly what the
588
+ * link surface targets — a structured template that defines the shape
589
+ * of the artifact behind the article.
590
+ *
591
+ * @param lang - Target language code
592
+ * @returns Localised CTA text
593
+ */
594
+ function getViewTemplateLabel(lang) {
595
+ const labels = {
596
+ en: 'View artifact template',
597
+ sv: 'Visa artefaktmall',
598
+ da: 'Se artefaktskabelon',
599
+ no: 'Se artefaktmal',
600
+ fi: 'Näytä artefaktipohja',
601
+ de: 'Artefaktvorlage ansehen',
602
+ fr: 'Voir le modèle d’artefact',
603
+ es: 'Ver plantilla de artefacto',
604
+ nl: 'Artefactsjabloon bekijken',
605
+ ar: 'عرض قالب القطعة',
606
+ he: 'הצג תבנית פריט',
607
+ ja: 'アーティファクト テンプレートを表示',
608
+ ko: '아티팩트 템플릿 보기',
609
+ zh: '查看构件模板',
610
+ };
611
+ return labels[lang] ?? labels.en ?? 'View artifact template';
612
+ }
613
+ /**
614
+ * Localised "View artifact" CTA used on every Analysis Index card.
615
+ * Tells the reader the link opens a specific committed artifact from
616
+ * this article's analysis run on GitHub, providing audit context that
617
+ * the older generic "View on GitHub" CTA lacked.
618
+ *
619
+ * @param lang - Target language code
620
+ * @returns Localised CTA text
621
+ */
622
+ function getViewArtifactLabel(lang) {
623
+ const labels = {
624
+ en: 'View artifact',
625
+ sv: 'Visa artefakt',
626
+ da: 'Se artefakt',
627
+ no: 'Se artefakt',
628
+ fi: 'Näytä artefakti',
629
+ de: 'Artefakt ansehen',
630
+ fr: 'Voir l’artefact',
631
+ es: 'Ver artefacto',
632
+ nl: 'Artefact bekijken',
633
+ ar: 'عرض القطعة',
634
+ he: 'הצג פריט',
635
+ ja: 'アーティファクトを表示',
636
+ ko: '아티팩트 보기',
637
+ zh: '查看构件',
638
+ };
639
+ return labels[lang] ?? labels.en ?? 'View artifact';
640
+ }
641
+ /**
642
+ * Replace the `<ul>` block that follows a sub-heading with a card-grid
643
+ * `<ul class="pi-card-grid">` rendering. Matching is bounded to the
644
+ * first `<ul>` that appears after `searchFromIdx` and that closes with
645
+ * `</ul>` — so the function is safe to call on partial HTML.
646
+ *
647
+ * @param html - HTML to transform
648
+ * @param searchFromIdx - Index after which the first `<ul>` is located
649
+ * @param cardsHtml - Pre-rendered `<li class="pi-card">…</li>` joined by `\n`
650
+ * @returns `{ html, endIdx }` with the new HTML and the index just after
651
+ * the inserted card grid (so successive calls can chain)
652
+ */
653
+ function replaceFollowingUlWithCardGrid(html, searchFromIdx, cardsHtml) {
654
+ const ulIdx = html.indexOf('<ul>', searchFromIdx);
655
+ if (ulIdx === -1)
656
+ return { html, endIdx: searchFromIdx };
657
+ const ulEnd = html.indexOf('</ul>', ulIdx);
658
+ if (ulEnd === -1)
659
+ return { html, endIdx: searchFromIdx };
660
+ const replacement = `<ul class="pi-card-grid">\n${cardsHtml}\n </ul>`;
661
+ const closeIdx = ulEnd + '</ul>'.length;
662
+ const next = html.slice(0, ulIdx) + replacement + html.slice(closeIdx);
663
+ return { html: next, endIdx: ulIdx + replacement.length };
664
+ }
665
+ /**
666
+ * Replace the rendered Tradecraft References bullet lists with a
667
+ * `pi-card-grid` of richly described cards (icon, curated title,
668
+ * curated description, kind-aware CTA). The cards reuse the exact same
669
+ * class hooks as `political-intelligence.html`, so the site-wide CSS
670
+ * already styles them — no additional CSS is required.
671
+ *
672
+ * After the April-2026 reorder the rendered Markdown emits Artifact
673
+ * templates as the first sub-heading and Methodologies as the second,
674
+ * matching how readers encounter the run (artifacts first, methodology
675
+ * library second). The card upgrade follows the same order so the H3
676
+ * positions stay aligned with the kind-aware CTA labels.
677
+ *
678
+ * Falls back to the original Markdown-rendered list when the expected
679
+ * structure (H2 → intro paragraph → Artifact-templates sub-heading →
680
+ * `<ul>` → Methodologies sub-heading → `<ul>`) is missing, so partially
681
+ * stripped or unusual articles are not silently corrupted.
682
+ *
683
+ * @param bodyHtml - The (already-localised) article body HTML
684
+ * @param lang - Target language code for curated titles/descriptions
685
+ * @returns Body HTML with the tradecraft section upgraded to cards
686
+ */
687
+ export function enhanceTradecraftCards(bodyHtml, lang) {
688
+ const anchorIdx = bodyHtml.indexOf(`id="${TRADECRAFT_SECTION_ID}"`);
689
+ if (anchorIdx === -1)
690
+ return bodyHtml;
691
+ // The next H2 marks the end of the tradecraft section.
692
+ const nextH2 = bodyHtml.indexOf('<h2 ', anchorIdx + 1);
693
+ const sectionEnd = nextH2 === -1 ? bodyHtml.length : nextH2;
694
+ const section = bodyHtml.slice(anchorIdx, sectionEnd);
695
+ // Harvest the methodology + template links from the rendered HTML.
696
+ const methodLinks = extractTradecraftLinks(section, 'analysis/methodologies/');
697
+ const templateLinks = extractTradecraftLinks(section, 'analysis/templates/');
698
+ if (methodLinks.length === 0 && templateLinks.length === 0)
699
+ return bodyHtml;
700
+ const methodCta = getViewMethodologyLabel(lang);
701
+ const templateCta = getViewTemplateLabel(lang);
702
+ let next = bodyHtml;
703
+ // Replace Artifact-templates <ul> first, then the Methodologies <ul>.
704
+ // Use the returned end index from the first replacement to seed the
705
+ // search for the second <ul> so we never double-replace.
706
+ // Note: markdown-it adds `id` and `tabindex` attributes to headings
707
+ // (`<h3 id="artifact-templates" tabindex="-1">…`), so we search for
708
+ // `<h3` (no terminator) rather than `<h3>` to match either form.
709
+ const firstHeadingIdx = next.indexOf('<h3', anchorIdx);
710
+ if (firstHeadingIdx !== -1 && templateLinks.length > 0) {
711
+ const templateCards = templateLinks
712
+ .map((l) => renderTradecraftCard(l, lang, templateCta))
713
+ .join('\n');
714
+ const result = replaceFollowingUlWithCardGrid(next, firstHeadingIdx, templateCards);
715
+ next = result.html;
716
+ }
717
+ const secondHeadingSearchStart = next.indexOf(`id="${TRADECRAFT_SECTION_ID}"`);
718
+ if (secondHeadingSearchStart !== -1 && methodLinks.length > 0) {
719
+ // Find the second <h3 after the tradecraft anchor (Artifact templates
720
+ // is the first; Methodologies is the second).
721
+ const firstH3 = next.indexOf('<h3', secondHeadingSearchStart);
722
+ if (firstH3 !== -1) {
723
+ const secondH3 = next.indexOf('<h3', firstH3 + 1);
724
+ if (secondH3 !== -1) {
725
+ const methodCards = methodLinks
726
+ .map((l) => renderTradecraftCard(l, lang, methodCta))
727
+ .join('\n');
728
+ const result = replaceFollowingUlWithCardGrid(next, secondH3, methodCards);
729
+ next = result.html;
730
+ }
731
+ }
732
+ }
733
+ return next;
734
+ }
735
+ /**
736
+ * Replace the Analysis Index `<table>` with a `pi-card-grid` of cards,
737
+ * one per included artifact. Each card renders the artifact's curated
738
+ * localised title + description, the section it contributed to, the
739
+ * run-relative path as inline `<code>`, and a "View on GitHub" CTA.
740
+ *
741
+ * Strategy: parse the rendered table's `<tbody>` rows (each row carries
742
+ * `[sectionId, <a href="…">stem</a>, runRelPath]`) and re-render the
743
+ * region between the table's opening wrapper and `</table>` as a card
744
+ * grid. The wrapping `<div class="table-scroll">` is dropped because
745
+ * the card grid handles its own responsive layout via flex/grid.
746
+ *
747
+ * @param bodyHtml - Article body HTML
748
+ * @param lang - Target language code
749
+ * @returns Body HTML with the Analysis Index upgraded to a card grid
750
+ */
751
+ export function enhanceAnalysisIndexCards(bodyHtml, lang) {
752
+ const anchorIdx = bodyHtml.indexOf(`id="${MANIFEST_SECTION_ID}"`);
753
+ if (anchorIdx === -1)
754
+ return bodyHtml;
755
+ // Locate the table and its end.
756
+ const tableIdx = bodyHtml.indexOf('<table>', anchorIdx);
757
+ if (tableIdx === -1)
758
+ return bodyHtml;
759
+ const tableEnd = bodyHtml.indexOf('</table>', tableIdx);
760
+ if (tableEnd === -1)
761
+ return bodyHtml;
762
+ const tableHtml = bodyHtml.slice(tableIdx, tableEnd + '</table>'.length);
763
+ const rows = parseAnalysisIndexRows(tableHtml);
764
+ if (rows.length === 0)
765
+ return bodyHtml;
766
+ const cards = rows.map((row) => renderAnalysisIndexCard(row, lang)).join('\n');
767
+ // Walk back to the start of the wrapping <div class="table-scroll"> if
768
+ // present, so we replace the responsive wrapper too.
769
+ const wrapperOpen = bodyHtml.lastIndexOf('<div class="table-scroll"', tableIdx);
770
+ const replaceFrom = wrapperOpen !== -1 && wrapperOpen > anchorIdx ? wrapperOpen : tableIdx;
771
+ // Walk forward past the matching </div> when we kicked off from the
772
+ // wrapper, otherwise stop right after </table>.
773
+ let replaceTo = tableEnd + '</table>'.length;
774
+ if (wrapperOpen !== -1 && wrapperOpen > anchorIdx) {
775
+ const wrapperClose = bodyHtml.indexOf('</div>', tableEnd);
776
+ if (wrapperClose !== -1)
777
+ replaceTo = wrapperClose + '</div>'.length;
778
+ }
779
+ const replacement = `<ul class="pi-card-grid analysis-index-grid">\n${cards}\n </ul>`;
780
+ return bodyHtml.slice(0, replaceFrom) + replacement + bodyHtml.slice(replaceTo);
781
+ }
782
+ /**
783
+ * Parse the `<tbody>` rows of the rendered Analysis Index table. Each
784
+ * row has the shape `<tr><td>section-id</td><td><a href="…">stem</a>
785
+ * </td><td><code>relPath</code></td></tr>`.
786
+ *
787
+ * @param tableHtml - Slice of HTML from `<table>` to `</table>`
788
+ * @returns Parsed rows (skipping malformed ones)
789
+ */
790
+ function parseAnalysisIndexRows(tableHtml) {
791
+ const out = [];
792
+ let cursor = 0;
793
+ while (cursor < tableHtml.length) {
794
+ const trIdx = tableHtml.indexOf('<tr>', cursor);
795
+ if (trIdx === -1)
796
+ break;
797
+ const trEnd = tableHtml.indexOf('</tr>', trIdx);
798
+ if (trEnd === -1)
799
+ break;
800
+ const row = tableHtml.slice(trIdx, trEnd);
801
+ cursor = trEnd + '</tr>'.length;
802
+ // Skip the header row (which only has <th> not <td>).
803
+ if (row.indexOf('<td>') === -1)
804
+ continue;
805
+ const cells = parseRowCells(row);
806
+ if (cells.length < 3)
807
+ continue;
808
+ const anchorMatch = parseAnchor(cells[1] ?? '');
809
+ if (!anchorMatch)
810
+ continue;
811
+ const runRelPath = stripCodeWrapper(cells[2] ?? '');
812
+ out.push({
813
+ sectionId: (cells[0] ?? '').trim(),
814
+ anchorText: anchorMatch.text,
815
+ href: anchorMatch.href,
816
+ runRelPath,
817
+ });
818
+ }
819
+ return out;
820
+ }
821
+ /**
822
+ * Extract the `<td>…</td>` cell contents from a single `<tr>…</tr>`
823
+ * fragment. Uses `indexOf` to avoid backtracking.
824
+ *
825
+ * @param row - HTML for one row (no `</tr>` terminator required)
826
+ * @returns Array of inner-HTML strings, one per `<td>`
827
+ */
828
+ function parseRowCells(row) {
829
+ const cells = [];
830
+ let cursor = 0;
831
+ while (cursor < row.length) {
832
+ const tdIdx = row.indexOf('<td>', cursor);
833
+ if (tdIdx === -1)
834
+ break;
835
+ const tdEnd = row.indexOf('</td>', tdIdx);
836
+ if (tdEnd === -1)
837
+ break;
838
+ cells.push(row.slice(tdIdx + '<td>'.length, tdEnd));
839
+ cursor = tdEnd + '</td>'.length;
840
+ }
841
+ return cells;
842
+ }
843
+ /**
844
+ * Parse a single `<a href="…">text</a>` token out of a cell. Returns
845
+ * `null` when the cell does not contain an anchor (e.g. a plain string).
846
+ *
847
+ * @param cell - Inner-HTML of the `<td>` cell
848
+ * @returns Parsed anchor or `null`
849
+ */
850
+ function parseAnchor(cell) {
851
+ const aIdx = cell.indexOf('<a ');
852
+ if (aIdx === -1)
853
+ return null;
854
+ const hrefIdx = cell.indexOf('href="', aIdx);
855
+ if (hrefIdx === -1)
856
+ return null;
857
+ const urlStart = hrefIdx + 'href="'.length;
858
+ const urlEnd = cell.indexOf('"', urlStart);
859
+ if (urlEnd === -1)
860
+ return null;
861
+ const closeOpenTag = cell.indexOf('>', urlEnd);
862
+ if (closeOpenTag === -1)
863
+ return null;
864
+ const closeIdx = cell.indexOf('</a>', closeOpenTag);
865
+ if (closeIdx === -1)
866
+ return null;
867
+ return {
868
+ href: cell.slice(urlStart, urlEnd),
869
+ text: cell.slice(closeOpenTag + 1, closeIdx),
870
+ };
871
+ }
872
+ /**
873
+ * Strip the `<code>…</code>` wrapper added by the Markdown renderer
874
+ * around the run-relative path cell.
875
+ *
876
+ * @param cell - Cell inner-HTML (possibly `<code>foo.md</code>`)
877
+ * @returns Plain text without code formatting
878
+ */
879
+ function stripCodeWrapper(cell) {
880
+ const start = cell.indexOf('<code>');
881
+ if (start === -1)
882
+ return cell.trim();
883
+ const end = cell.indexOf('</code>', start);
884
+ if (end === -1)
885
+ return cell.trim();
886
+ return cell.slice(start + '<code>'.length, end).trim();
887
+ }
888
+ /**
889
+ * Render one Analysis Index card. Reuses the `pi-card` class hooks so
890
+ * the card-grid sits naturally next to the methodology / template
891
+ * cards in the same article.
892
+ *
893
+ * @param row - Parsed Analysis Index row
894
+ * @param lang - Target language code
895
+ * @returns HTML fragment for one `<li class="pi-card">…</li>`
896
+ */
897
+ function renderAnalysisIndexCard(row, lang) {
898
+ const info = getArtifactInfo(row.runRelPath, lang);
899
+ const stem = row.runRelPath.split('/').pop()?.replace(/\.md$/i, '') ?? row.runRelPath;
900
+ const icon = getStemIcon(stem);
901
+ // Reuse the section-title localisation already used by the TOC so the
902
+ // "Section: …" badge reads naturally in every language.
903
+ const sectionLabel = getLocalizedTocTitle(row.sectionId, row.sectionId, lang);
904
+ // Drop the redundant `<code>analysis/.../foo.md</code>` filename row —
905
+ // the curated title + curated description plus the section badge
906
+ // already convey what the artifact is and where it sits in the
907
+ // article. The kind-aware "View artifact" CTA tells the reader the
908
+ // link opens the underlying committed artifact on GitHub.
909
+ return [
910
+ ` <li class="pi-card">`,
911
+ ` <a class="pi-card__link" href="${escapeHTML(row.href)}" rel="noopener external" target="_blank">`,
912
+ ` <span class="pi-card__icon" aria-hidden="true">${icon}</span>`,
913
+ ` <span class="pi-card__body">`,
914
+ ` <span class="pi-card__title">${escapeHTML(info.title)}</span>`,
915
+ ` <span class="pi-card__desc">${escapeHTML(info.description)}</span>`,
916
+ ` <span class="pi-card__meta"><span class="pi-card__section-badge">${escapeHTML(sectionLabel)}</span></span>`,
917
+ ` <span class="pi-card__cta">${escapeHTML(getViewArtifactLabel(lang))} <span aria-hidden="true">↗</span></span>`,
918
+ ` </span>`,
919
+ ` </a>`,
920
+ ` </li>`,
921
+ ].join('\n');
922
+ }
298
923
  /**
299
924
  * Render the full article HTML document with the shared chrome.
300
925
  *