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.
- package/package.json +3 -3
- package/scripts/aggregator/analysis-aggregator.d.ts +11 -0
- package/scripts/aggregator/analysis-aggregator.js +25 -6
- package/scripts/aggregator/article-generator.js +7 -1
- package/scripts/aggregator/article-html.d.ts +44 -2
- package/scripts/aggregator/article-html.js +631 -6
- package/scripts/aggregator/reader-intelligence-guide.d.ts +23 -2
- package/scripts/aggregator/reader-intelligence-guide.js +344 -9
- package/scripts/mcp/fetch-proxy-server.d.ts +1 -13
- package/scripts/mcp/fetch-proxy-server.js +148 -32
- package/scripts/mcp/imf-mcp-client.d.ts +68 -19
- package/scripts/mcp/imf-mcp-client.js +471 -100
- package/scripts/types/imf.d.ts +3 -3
|
@@ -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
|
|
140
|
-
* `<details>` disclosure on narrow viewports via
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
*
|