euparliamentmonitor 0.9.2 → 0.9.3
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euparliamentmonitor",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
|
|
6
6
|
"main": "scripts/index.js",
|
|
@@ -163,9 +163,9 @@
|
|
|
163
163
|
"happy-dom": "20.9.0",
|
|
164
164
|
"htmlhint": "1.9.2",
|
|
165
165
|
"husky": "9.1.7",
|
|
166
|
-
"jscpd": "4.0
|
|
166
|
+
"jscpd": "4.1.0",
|
|
167
167
|
"knip": "^6.7.0",
|
|
168
|
-
"lint-staged": "17.0.
|
|
168
|
+
"lint-staged": "17.0.4",
|
|
169
169
|
"mermaid": "11.14.0",
|
|
170
170
|
"papaparse": "5.5.3",
|
|
171
171
|
"prettier": "3.8.3",
|
|
@@ -115,6 +115,17 @@ export declare function renderProvenanceBlock(params: {
|
|
|
115
115
|
* @returns Markdown block with two subsections (methodologies, templates)
|
|
116
116
|
*/
|
|
117
117
|
export declare function renderTradecraftAppendix(files: readonly string[]): string;
|
|
118
|
+
/**
|
|
119
|
+
* Public re-export of the internal `humanize` helper so other aggregator
|
|
120
|
+
* modules (in particular `article-html.ts`) can derive the same display
|
|
121
|
+
* title from a file stem when no curated title is available. Keeping the
|
|
122
|
+
* single canonical implementation here avoids duplicate humanisation
|
|
123
|
+
* rules drifting across modules.
|
|
124
|
+
*
|
|
125
|
+
* @param stem - File stem (e.g. `electoral-cycle-methodology`)
|
|
126
|
+
* @returns Humanised title (e.g. `Electoral Cycle Methodology`)
|
|
127
|
+
*/
|
|
128
|
+
export declare function humanizeStem(stem: string): string;
|
|
118
129
|
/**
|
|
119
130
|
* Render the analysis-index appendix — a compact table of every included
|
|
120
131
|
* artifact and its section, plus a direct link to the manifest.
|
|
@@ -213,19 +213,25 @@ export function renderTradecraftAppendix(files) {
|
|
|
213
213
|
'This article is produced under the [Hack23 AB](https://hack23.com) intelligence tradecraft library. Every methodology and artifact template applied to this run is linked below.',
|
|
214
214
|
'',
|
|
215
215
|
];
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
// Order: Artifact templates first (concrete deliverables readers
|
|
217
|
+
// recognise from the article body), then Methodologies (the underlying
|
|
218
|
+
// tradecraft library). This matches the natural reader flow — the
|
|
219
|
+
// article is built from artifacts, and the methodologies explain how
|
|
220
|
+
// each artifact is produced — and pairs with the contextual, kind-
|
|
221
|
+
// aware "View …" CTAs rendered in `enhanceTradecraftCards`.
|
|
222
|
+
if (templates.length > 0) {
|
|
223
|
+
block.push('### Artifact templates');
|
|
218
224
|
block.push('');
|
|
219
|
-
for (const rel of
|
|
225
|
+
for (const rel of templates) {
|
|
220
226
|
const stem = rel.split('/').pop()?.replace(/\.md$/i, '') ?? rel;
|
|
221
227
|
block.push(`- [${humanize(stem)}](${githubBlobUrl(rel)})`);
|
|
222
228
|
}
|
|
223
229
|
block.push('');
|
|
224
230
|
}
|
|
225
|
-
if (
|
|
226
|
-
block.push('###
|
|
231
|
+
if (methods.length > 0) {
|
|
232
|
+
block.push('### Methodologies');
|
|
227
233
|
block.push('');
|
|
228
|
-
for (const rel of
|
|
234
|
+
for (const rel of methods) {
|
|
229
235
|
const stem = rel.split('/').pop()?.replace(/\.md$/i, '') ?? rel;
|
|
230
236
|
block.push(`- [${humanize(stem)}](${githubBlobUrl(rel)})`);
|
|
231
237
|
}
|
|
@@ -233,6 +239,19 @@ export function renderTradecraftAppendix(files) {
|
|
|
233
239
|
}
|
|
234
240
|
return block.join('\n');
|
|
235
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Public re-export of the internal `humanize` helper so other aggregator
|
|
244
|
+
* modules (in particular `article-html.ts`) can derive the same display
|
|
245
|
+
* title from a file stem when no curated title is available. Keeping the
|
|
246
|
+
* single canonical implementation here avoids duplicate humanisation
|
|
247
|
+
* rules drifting across modules.
|
|
248
|
+
*
|
|
249
|
+
* @param stem - File stem (e.g. `electoral-cycle-methodology`)
|
|
250
|
+
* @returns Humanised title (e.g. `Electoral Cycle Methodology`)
|
|
251
|
+
*/
|
|
252
|
+
export function humanizeStem(stem) {
|
|
253
|
+
return humanize(stem);
|
|
254
|
+
}
|
|
236
255
|
/**
|
|
237
256
|
* Render the analysis-index appendix — a compact table of every included
|
|
238
257
|
* artifact and its section, plus a direct link to the manifest.
|
|
@@ -29,7 +29,7 @@ import { resolveRunId as _resolveRunId } from './manifest/index.js';
|
|
|
29
29
|
import { resolveArticleMetadata, extractStrongProseLine, } from './article-metadata.js';
|
|
30
30
|
import { buildArticleMeta, serializeArticleMeta } from './article-meta.js';
|
|
31
31
|
import { renderMarkdown } from './markdown-renderer.js';
|
|
32
|
-
import { wrapArticleHtml, getArticleFilename, localizeArticleBody } from './article-html.js';
|
|
32
|
+
import { wrapArticleHtml, getArticleFilename, localizeArticleBody, enhanceTradecraftCards, enhanceAnalysisIndexCards, } from './article-html.js';
|
|
33
33
|
import { buildReaderIntelligenceGuideHtml, stripInlineReaderGuide, } from './reader-intelligence-guide.js';
|
|
34
34
|
import { ALL_LANGUAGES } from '../constants/language-core.js';
|
|
35
35
|
import { blobUrl } from './infra/github-urls.js';
|
|
@@ -364,6 +364,12 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
|
|
|
364
364
|
// Localize Tradecraft References, Analysis Index, and other appendix
|
|
365
365
|
// section headings and content into the target language.
|
|
366
366
|
bodyHtml = localizeArticleBody(bodyHtml, lang);
|
|
367
|
+
// Replace the plain Tradecraft References bullet lists and the
|
|
368
|
+
// Analysis Index table with `pi-card-grid` cards. Runs for every
|
|
369
|
+
// language (including English) so the "much nicer" rendering matches
|
|
370
|
+
// the political-intelligence.html visual vocabulary site-wide.
|
|
371
|
+
bodyHtml = enhanceTradecraftCards(bodyHtml, lang);
|
|
372
|
+
bodyHtml = enhanceAnalysisIndexCards(bodyHtml, lang);
|
|
367
373
|
// When a per-language translated source exists, prefer a summary derived
|
|
368
374
|
// from it so the `<meta description>` matches the visible prose. The
|
|
369
375
|
// editorial title still comes from the English resolver (per-language
|
|
@@ -73,8 +73,10 @@ export declare function buildArticleHreflangLinks(articleSlug: string): string;
|
|
|
73
73
|
* Build the article-level Table of Contents nav. Renders a labelled
|
|
74
74
|
* `<nav class="article-toc">` with one `<a>` per H2 section, keyed by the
|
|
75
75
|
* stable fragment ids produced by the aggregator. The containing `<aside>`
|
|
76
|
-
* is styled as a sticky sidebar on wide viewports and
|
|
77
|
-
* `<details>` disclosure on narrow viewports via
|
|
76
|
+
* is styled as a sticky, full-height sidebar on wide viewports and
|
|
77
|
+
* collapses into a `<details>` disclosure on narrow viewports via
|
|
78
|
+
* `styles.css`. Each entry is prefixed with a contextual emoji icon so
|
|
79
|
+
* readers can scan the navigation visually as well as textually.
|
|
78
80
|
*
|
|
79
81
|
* Returns an empty string when `entries` is empty so low-signal
|
|
80
82
|
* `ANALYSIS_ONLY` articles (few sections, no value in a TOC) stay compact.
|
|
@@ -94,6 +96,46 @@ export declare function buildArticleToc(entries: readonly ArticleTocEntry[], lan
|
|
|
94
96
|
* @returns HTML body with localized appendix sections
|
|
95
97
|
*/
|
|
96
98
|
export declare function localizeArticleBody(bodyHtml: string, lang: LanguageCode): string;
|
|
99
|
+
/**
|
|
100
|
+
* Replace the rendered Tradecraft References bullet lists with a
|
|
101
|
+
* `pi-card-grid` of richly described cards (icon, curated title,
|
|
102
|
+
* curated description, kind-aware CTA). The cards reuse the exact same
|
|
103
|
+
* class hooks as `political-intelligence.html`, so the site-wide CSS
|
|
104
|
+
* already styles them — no additional CSS is required.
|
|
105
|
+
*
|
|
106
|
+
* After the April-2026 reorder the rendered Markdown emits Artifact
|
|
107
|
+
* templates as the first sub-heading and Methodologies as the second,
|
|
108
|
+
* matching how readers encounter the run (artifacts first, methodology
|
|
109
|
+
* library second). The card upgrade follows the same order so the H3
|
|
110
|
+
* positions stay aligned with the kind-aware CTA labels.
|
|
111
|
+
*
|
|
112
|
+
* Falls back to the original Markdown-rendered list when the expected
|
|
113
|
+
* structure (H2 → intro paragraph → Artifact-templates sub-heading →
|
|
114
|
+
* `<ul>` → Methodologies sub-heading → `<ul>`) is missing, so partially
|
|
115
|
+
* stripped or unusual articles are not silently corrupted.
|
|
116
|
+
*
|
|
117
|
+
* @param bodyHtml - The (already-localised) article body HTML
|
|
118
|
+
* @param lang - Target language code for curated titles/descriptions
|
|
119
|
+
* @returns Body HTML with the tradecraft section upgraded to cards
|
|
120
|
+
*/
|
|
121
|
+
export declare function enhanceTradecraftCards(bodyHtml: string, lang: LanguageCode): string;
|
|
122
|
+
/**
|
|
123
|
+
* Replace the Analysis Index `<table>` with a `pi-card-grid` of cards,
|
|
124
|
+
* one per included artifact. Each card renders the artifact's curated
|
|
125
|
+
* localised title + description, the section it contributed to, the
|
|
126
|
+
* run-relative path as inline `<code>`, and a "View on GitHub" CTA.
|
|
127
|
+
*
|
|
128
|
+
* Strategy: parse the rendered table's `<tbody>` rows (each row carries
|
|
129
|
+
* `[sectionId, <a href="…">stem</a>, runRelPath]`) and re-render the
|
|
130
|
+
* region between the table's opening wrapper and `</table>` as a card
|
|
131
|
+
* grid. The wrapping `<div class="table-scroll">` is dropped because
|
|
132
|
+
* the card grid handles its own responsive layout via flex/grid.
|
|
133
|
+
*
|
|
134
|
+
* @param bodyHtml - Article body HTML
|
|
135
|
+
* @param lang - Target language code
|
|
136
|
+
* @returns Body HTML with the Analysis Index upgraded to a card grid
|
|
137
|
+
*/
|
|
138
|
+
export declare function enhanceAnalysisIndexCards(bodyHtml: string, lang: LanguageCode): string;
|
|
97
139
|
/**
|
|
98
140
|
* Render the full article HTML document with the shared chrome.
|
|
99
141
|
*
|
|
@@ -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
|
*
|
|
@@ -22,17 +22,38 @@ export declare const READER_GUIDE_COL_NEED_LABELS: LanguageMap;
|
|
|
22
22
|
export declare const READER_GUIDE_COL_VALUE_LABELS: LanguageMap;
|
|
23
23
|
/** Table header: "Source artifact" */
|
|
24
24
|
export declare const READER_GUIDE_COL_SOURCE_LABELS: LanguageMap;
|
|
25
|
+
/**
|
|
26
|
+
* Look up the visual icon for a known article section.
|
|
27
|
+
*
|
|
28
|
+
* Exposed so the article-level Table-of-Contents (`buildArticleToc`)
|
|
29
|
+
* can render the same emoji that the Reader Intelligence Guide uses
|
|
30
|
+
* for each section, keeping the two navigation surfaces visually
|
|
31
|
+
* consistent. Unknown section IDs (e.g. ad-hoc `supplementary-…` or
|
|
32
|
+
* appendix anchors) fall back to a generic 📎 paperclip.
|
|
33
|
+
*
|
|
34
|
+
* @param sectionId - Anchor id of the section (e.g. `section-risk`)
|
|
35
|
+
* @returns Single emoji glyph used as a `guide-icon`
|
|
36
|
+
*/
|
|
37
|
+
export declare function getReaderGuideSectionIcon(sectionId: string): string;
|
|
25
38
|
/**
|
|
26
39
|
* Build a translated Reader Intelligence Guide as an HTML section.
|
|
27
40
|
* Emits exactly one component with `data-component="reader-intelligence-guide"`
|
|
28
41
|
* for de-duplication detection by E2E tests.
|
|
29
42
|
*
|
|
43
|
+
* The guide renders one row per emitted article section that has a
|
|
44
|
+
* curated reader-need translation (see {@link READER_GUIDE_ROWS}). The
|
|
45
|
+
* `included` list is no longer surfaced — the previous "source artifact"
|
|
46
|
+
* column duplicated the per-section navigation that the Analysis Index
|
|
47
|
+
* appendix already presents, and clutters the headline reader lens. The
|
|
48
|
+
* parameter is kept on the signature for backward compatibility with
|
|
49
|
+
* callers that may pre-compute the run manifest.
|
|
50
|
+
*
|
|
30
51
|
* @param lang - Target language code
|
|
31
52
|
* @param sections - Emitted section TOC entries, in document order
|
|
32
|
-
* @param
|
|
53
|
+
* @param _included - (Unused) Included artifacts; kept for API stability
|
|
33
54
|
* @returns HTML fragment for the guide, or empty string if no rows match
|
|
34
55
|
*/
|
|
35
|
-
export declare function buildReaderIntelligenceGuideHtml(lang: LanguageCode, sections: readonly TocSection[],
|
|
56
|
+
export declare function buildReaderIntelligenceGuideHtml(lang: LanguageCode, sections: readonly TocSection[], _included?: readonly IncludedArtifact[]): string;
|
|
36
57
|
/**
|
|
37
58
|
* Strip an AI-authored Reader Intelligence Guide section from rendered HTML.
|
|
38
59
|
* Looks for H2 headings with id="reader-intelligence-guide" and removes
|
|
@@ -363,6 +363,312 @@ const READER_GUIDE_ROWS = {
|
|
|
363
363
|
zh: '政策、机构、联盟、沟通和执行风险登记册',
|
|
364
364
|
},
|
|
365
365
|
},
|
|
366
|
+
'section-actors-forces': {
|
|
367
|
+
need: {
|
|
368
|
+
en: 'Actors & forces',
|
|
369
|
+
sv: 'Aktörer & krafter',
|
|
370
|
+
da: 'Aktører & kræfter',
|
|
371
|
+
no: 'Aktører & krefter',
|
|
372
|
+
fi: 'Toimijat & voimat',
|
|
373
|
+
de: 'Akteure & Kräfte',
|
|
374
|
+
fr: 'Acteurs & forces',
|
|
375
|
+
es: 'Actores & fuerzas',
|
|
376
|
+
nl: 'Actoren & krachten',
|
|
377
|
+
ar: 'الفاعلون والقوى',
|
|
378
|
+
he: 'שחקנים וכוחות',
|
|
379
|
+
ja: 'アクターと力学',
|
|
380
|
+
ko: '행위자 & 세력',
|
|
381
|
+
zh: '行动者与力量',
|
|
382
|
+
},
|
|
383
|
+
value: {
|
|
384
|
+
en: 'who is driving the story, what political forces line up behind them, and which institutional levers they can pull',
|
|
385
|
+
sv: 'vem som driver händelsen, vilka politiska krafter står bakom och vilka institutionella spakar de kan dra',
|
|
386
|
+
da: 'hvem der driver historien, hvilke politiske kræfter står bag, og hvilke institutionelle håndtag de kan trække',
|
|
387
|
+
no: 'hvem som driver saken, hvilke politiske krefter står bak, og hvilke institusjonelle spaker de kan trekke',
|
|
388
|
+
fi: 'kuka ohjaa tarinaa, mitkä poliittiset voimat ovat takana ja mitä institutionaalisia vipuja he voivat käyttää',
|
|
389
|
+
de: 'wer die Geschichte vorantreibt, welche politischen Kräfte dahinterstehen und welche institutionellen Hebel sie ziehen können',
|
|
390
|
+
fr: "qui pilote l'histoire, quelles forces politiques sont alignées derrière, et quels leviers institutionnels ils peuvent actionner",
|
|
391
|
+
es: 'quién impulsa la historia, qué fuerzas políticas están detrás y qué palancas institucionales pueden accionar',
|
|
392
|
+
nl: 'wie het verhaal aandrijft, welke politieke krachten erachter staan en welke institutionele hefbomen ze kunnen overhalen',
|
|
393
|
+
ar: 'من يقود القصة، وما القوى السياسية المصطفة خلفه، وأي روافع مؤسسية يمكنهم تحريكها',
|
|
394
|
+
he: 'מי מניע את הסיפור, אילו כוחות פוליטיים מאחוריו, ואילו מנופים מוסדיים הם יכולים להפעיל',
|
|
395
|
+
ja: 'ストーリーを動かしているのは誰か、その背後にある政治的勢力、そして彼らが引ける制度的レバー',
|
|
396
|
+
ko: '누가 이야기를 주도하는지, 그 뒤에 어떤 정치적 세력이 있는지, 그리고 어떤 제도적 지렛대를 당길 수 있는지',
|
|
397
|
+
zh: '谁在推动故事、哪些政治力量在其背后、以及他们可以拉动哪些制度杠杆',
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
'section-threat': {
|
|
401
|
+
need: {
|
|
402
|
+
en: 'Threat landscape',
|
|
403
|
+
sv: 'Hotlandskap',
|
|
404
|
+
da: 'Trussellandskab',
|
|
405
|
+
no: 'Trussellandskap',
|
|
406
|
+
fi: 'Uhkamaisema',
|
|
407
|
+
de: 'Bedrohungslandschaft',
|
|
408
|
+
fr: 'Paysage des menaces',
|
|
409
|
+
es: 'Panorama de amenazas',
|
|
410
|
+
nl: 'Dreigingslandschap',
|
|
411
|
+
ar: 'مشهد التهديدات',
|
|
412
|
+
he: 'נוף האיומים',
|
|
413
|
+
ja: '脅威ランドスケープ',
|
|
414
|
+
ko: '위협 환경',
|
|
415
|
+
zh: '威胁态势',
|
|
416
|
+
},
|
|
417
|
+
value: {
|
|
418
|
+
en: 'hostile actors, attack vectors, consequence trees, and the legislative-disruption pathways the article tracks',
|
|
419
|
+
sv: 'fientliga aktörer, attackvektorer, konsekvensträd och de lagstiftningsstörningsvägar artikeln spårar',
|
|
420
|
+
da: 'fjendtlige aktører, angrebsvektorer, konsekvenstræer og de lovgivningsforstyrrelsesveje artiklen følger',
|
|
421
|
+
no: 'fiendtlige aktører, angrepsvektorer, konsekvenstrær og lovgivningsforstyrrelsesveiene artikkelen sporer',
|
|
422
|
+
fi: 'vihamieliset toimijat, hyökkäysvektorit, seurauspuut ja lainsäädännön häiriöpolut, joita artikkeli seuraa',
|
|
423
|
+
de: 'feindliche Akteure, Angriffsvektoren, Konsequenzbäume und die Gesetzgebungsstörungspfade, die der Artikel verfolgt',
|
|
424
|
+
fr: "acteurs hostiles, vecteurs d'attaque, arbres de conséquences et voies de perturbation législative que l'article suit",
|
|
425
|
+
es: 'actores hostiles, vectores de ataque, árboles de consecuencias y las vías de disrupción legislativa que sigue el artículo',
|
|
426
|
+
nl: 'vijandige actoren, aanvalsvectoren, gevolgenbomen en de wetgevingsverstoringspaden die het artikel volgt',
|
|
427
|
+
ar: 'الجهات المعادية وناقلات الهجوم وأشجار العواقب ومسارات التعطيل التشريعي التي يتتبعها المقال',
|
|
428
|
+
he: 'שחקנים עוינים, ווקטורי תקיפה, עצי השלכה ונתיבי שיבוש החקיקה שהמאמר עוקב אחריהם',
|
|
429
|
+
ja: '敵対的アクター、攻撃ベクトル、結果ツリー、および記事が追跡する立法阻害経路',
|
|
430
|
+
ko: '적대적 행위자, 공격 벡터, 결과 트리, 그리고 기사가 추적하는 입법 교란 경로',
|
|
431
|
+
zh: '敌对行为者、攻击向量、后果树以及文章追踪的立法干扰路径',
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
'section-forward-projection': {
|
|
435
|
+
need: {
|
|
436
|
+
en: 'What to watch',
|
|
437
|
+
sv: 'Vad att bevaka',
|
|
438
|
+
da: 'Hvad man skal følge',
|
|
439
|
+
no: 'Hva å følge med på',
|
|
440
|
+
fi: 'Mitä seurata',
|
|
441
|
+
de: 'Was zu beobachten ist',
|
|
442
|
+
fr: 'À surveiller',
|
|
443
|
+
es: 'Qué vigilar',
|
|
444
|
+
nl: 'Wat te volgen',
|
|
445
|
+
ar: 'ما يجب مراقبته',
|
|
446
|
+
he: 'מה לעקוב אחריו',
|
|
447
|
+
ja: '注目ポイント',
|
|
448
|
+
ko: '주목할 사항',
|
|
449
|
+
zh: '关注要点',
|
|
450
|
+
},
|
|
451
|
+
value: {
|
|
452
|
+
en: 'dated trigger events, parliamentary-calendar dependencies, and the legislative-pipeline forecast',
|
|
453
|
+
sv: 'daterade triggers, beroenden i parlamentskalendern och prognosen för lagstiftningspipelinen',
|
|
454
|
+
da: 'daterede triggers, parlamentskalender-afhængigheder og prognosen for lovgivningspipelinen',
|
|
455
|
+
no: 'daterte triggers, parlamentskalender-avhengigheter og prognosen for lovgivningspipelinen',
|
|
456
|
+
fi: 'päivätyt laukaisimet, parlamentin kalenterin riippuvuudet ja lainsäädäntöputken ennuste',
|
|
457
|
+
de: 'datierte Auslöseereignisse, Abhängigkeiten vom Parlamentskalender und die Prognose der Gesetzgebungspipeline',
|
|
458
|
+
fr: 'événements déclencheurs datés, dépendances du calendrier parlementaire et prévision du pipeline législatif',
|
|
459
|
+
es: 'eventos desencadenantes fechados, dependencias del calendario parlamentario y previsión del pipeline legislativo',
|
|
460
|
+
nl: 'gedateerde triggergebeurtenissen, afhankelijkheden van de parlementaire agenda en de voorspelling van de wetgevingspijplijn',
|
|
461
|
+
ar: 'أحداث محفزة مؤرخة، تبعيات الجدول البرلماني، وتوقعات خط الأنابيب التشريعي',
|
|
462
|
+
he: 'אירועי טריגר מתוארכים, תלויות לוח הפרלמנט ותחזית צינור החקיקה',
|
|
463
|
+
ja: '日付付きのトリガーイベント、議会カレンダーの依存関係、立法パイプラインの予測',
|
|
464
|
+
ko: '날짜가 지정된 트리거 이벤트, 의회 일정 의존성, 입법 파이프라인 예측',
|
|
465
|
+
zh: '标注日期的触发事件、议会日历依赖关系以及立法流程预测',
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
'section-electoral-arc': {
|
|
469
|
+
need: {
|
|
470
|
+
en: 'Electoral arc & mandate',
|
|
471
|
+
sv: 'Valbåge & mandat',
|
|
472
|
+
da: 'Valgbue & mandat',
|
|
473
|
+
no: 'Valgbue & mandat',
|
|
474
|
+
fi: 'Vaalikaari & mandaatti',
|
|
475
|
+
de: 'Wahlbogen & Mandat',
|
|
476
|
+
fr: 'Arc électoral & mandat',
|
|
477
|
+
es: 'Arco electoral & mandato',
|
|
478
|
+
nl: 'Verkiezingsboog & mandaat',
|
|
479
|
+
ar: 'القوس الانتخابي والتفويض',
|
|
480
|
+
he: 'קשת בחירות ומנדט',
|
|
481
|
+
ja: '選挙アークとマンデート',
|
|
482
|
+
ko: '선거 아크 & 위임',
|
|
483
|
+
zh: '选举弧线与任期',
|
|
484
|
+
},
|
|
485
|
+
value: {
|
|
486
|
+
en: 'where in the term the story sits, mandate-fulfilment scoring, seat projection, and the presidency-trio context',
|
|
487
|
+
sv: 'var i mandatperioden händelsen befinner sig, mandatuppfyllelsescoring, mandatprojektion och ordförandetrio-kontexten',
|
|
488
|
+
da: 'hvor i valgperioden historien ligger, mandatopfyldelsesscoring, mandatprojektion og formandstrio-konteksten',
|
|
489
|
+
no: 'hvor i valgperioden saken ligger, mandatoppfyllelsesscoring, mandatprojeksjon og formannskapstrio-konteksten',
|
|
490
|
+
fi: 'mihin kohtaan kautta tarina sijoittuu, mandaatin täyttymisen pisteytys, paikkaennuste ja puheenjohtajatrion konteksti',
|
|
491
|
+
de: 'wo im Mandat die Geschichte liegt, Mandatserfüllungs-Scoring, Sitzprojektion und Präsidentschaftstrio-Kontext',
|
|
492
|
+
fr: "où en est l'histoire dans le mandat, notation de l'exécution du mandat, projection des sièges et contexte du trio présidentiel",
|
|
493
|
+
es: 'dónde se sitúa la historia en el mandato, puntuación de cumplimiento del mandato, proyección de escaños y contexto del trío presidencial',
|
|
494
|
+
nl: 'waar het verhaal zich in het mandaat bevindt, scoring mandaatuitvoering, zetelprojectie en context van de voorzittersdrieluik',
|
|
495
|
+
ar: 'موقع القصة في الولاية، تقييم تنفيذ التفويض، توقعات المقاعد، وسياق الترويكا الرئاسية',
|
|
496
|
+
he: 'איפה בכהונה הסיפור ממוקם, ניקוד מילוי המנדט, תחזית מושבים, והקשר של שלישיית הנשיאות',
|
|
497
|
+
ja: '物語が任期のどこに位置するか、マンデート遂行スコア、議席予測、議長トリオの文脈',
|
|
498
|
+
ko: '이야기가 임기의 어디에 위치하는지, 위임 이행 점수, 의석 예측, 의장 트리오 맥락',
|
|
499
|
+
zh: '故事在任期中所处的位置、任期履行评分、席位预测以及主席三人组的背景',
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
'section-pestle-context': {
|
|
503
|
+
need: {
|
|
504
|
+
en: 'PESTLE & structural context',
|
|
505
|
+
sv: 'PESTLE & strukturell kontext',
|
|
506
|
+
da: 'PESTLE & strukturel kontekst',
|
|
507
|
+
no: 'PESTLE & strukturell kontekst',
|
|
508
|
+
fi: 'PESTLE & rakenteellinen konteksti',
|
|
509
|
+
de: 'PESTLE & struktureller Kontext',
|
|
510
|
+
fr: 'PESTLE & contexte structurel',
|
|
511
|
+
es: 'PESTLE & contexto estructural',
|
|
512
|
+
nl: 'PESTLE & structurele context',
|
|
513
|
+
ar: 'PESTLE والسياق الهيكلي',
|
|
514
|
+
he: 'PESTLE והקשר מבני',
|
|
515
|
+
ja: 'PESTLEと構造的コンテキスト',
|
|
516
|
+
ko: 'PESTLE & 구조적 맥락',
|
|
517
|
+
zh: 'PESTLE与结构性背景',
|
|
518
|
+
},
|
|
519
|
+
value: {
|
|
520
|
+
en: 'political, economic, social, technological, legal, and environmental forces plus the historical baseline',
|
|
521
|
+
sv: 'politiska, ekonomiska, sociala, tekniska, juridiska och miljömässiga krafter samt historisk baslinje',
|
|
522
|
+
da: 'politiske, økonomiske, sociale, teknologiske, juridiske og miljømæssige kræfter samt historisk baseline',
|
|
523
|
+
no: 'politiske, økonomiske, sosiale, teknologiske, juridiske og miljømessige krefter pluss historisk grunnlinje',
|
|
524
|
+
fi: 'poliittiset, taloudelliset, sosiaaliset, teknologiset, juridiset ja ympäristötekijät sekä historiallinen lähtötaso',
|
|
525
|
+
de: 'politische, wirtschaftliche, soziale, technologische, rechtliche und Umweltkräfte plus historische Baseline',
|
|
526
|
+
fr: 'forces politiques, économiques, sociales, technologiques, juridiques et environnementales plus la base historique',
|
|
527
|
+
es: 'fuerzas políticas, económicas, sociales, tecnológicas, legales y ambientales más la línea base histórica',
|
|
528
|
+
nl: 'politieke, economische, sociale, technologische, juridische en milieukrachten plus de historische basislijn',
|
|
529
|
+
ar: 'القوى السياسية والاقتصادية والاجتماعية والتكنولوجية والقانونية والبيئية بالإضافة إلى الأساس التاريخي',
|
|
530
|
+
he: 'כוחות פוליטיים, כלכליים, חברתיים, טכנולוגיים, משפטיים וסביבתיים בתוספת קו הבסיס ההיסטורי',
|
|
531
|
+
ja: '政治・経済・社会・技術・法律・環境の各要因と歴史的ベースライン',
|
|
532
|
+
ko: '정치, 경제, 사회, 기술, 법률, 환경 요인과 역사적 기준선',
|
|
533
|
+
zh: '政治、经济、社会、技术、法律和环境力量加上历史基准',
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
'section-continuity': {
|
|
537
|
+
need: {
|
|
538
|
+
en: 'Cross-run continuity',
|
|
539
|
+
sv: 'Kontinuitet mellan körningar',
|
|
540
|
+
da: 'Kryds-kørsels-kontinuitet',
|
|
541
|
+
no: 'Kontinuitet mellom kjøringer',
|
|
542
|
+
fi: 'Ajojen välinen jatkuvuus',
|
|
543
|
+
de: 'Laufübergreifende Kontinuität',
|
|
544
|
+
fr: 'Continuité inter-exécutions',
|
|
545
|
+
es: 'Continuidad entre ejecuciones',
|
|
546
|
+
nl: 'Continuïteit tussen runs',
|
|
547
|
+
ar: 'استمرارية عبر التشغيلات',
|
|
548
|
+
he: 'רציפות בין הרצות',
|
|
549
|
+
ja: 'クロスラン継続性',
|
|
550
|
+
ko: '교차 실행 연속성',
|
|
551
|
+
zh: '跨运行连续性',
|
|
552
|
+
},
|
|
553
|
+
value: {
|
|
554
|
+
en: 'how this run links to prior sessions, what changed, and how confidence shifted between runs',
|
|
555
|
+
sv: 'hur denna körning kopplar till tidigare sessioner, vad som förändrats och hur förtroendet skiftat mellan körningar',
|
|
556
|
+
da: 'hvordan denne kørsel forbinder til tidligere sessioner, hvad der er ændret, og hvordan tilliden har skiftet mellem kørsler',
|
|
557
|
+
no: 'hvordan denne kjøringen kobler til tidligere økter, hva som er endret, og hvordan tilliten har skiftet mellom kjøringer',
|
|
558
|
+
fi: 'miten tämä ajo kytkeytyy aiempiin istuntoihin, mikä on muuttunut ja miten luottamus on siirtynyt ajojen välillä',
|
|
559
|
+
de: 'wie dieser Lauf mit früheren Sitzungen verknüpft ist, was sich geändert hat und wie sich das Vertrauen zwischen Läufen verschoben hat',
|
|
560
|
+
fr: "comment cette exécution se relie aux sessions précédentes, ce qui a changé, et comment la confiance s'est déplacée entre les exécutions",
|
|
561
|
+
es: 'cómo se vincula esta ejecución con sesiones anteriores, qué cambió y cómo se desplazó la confianza entre ejecuciones',
|
|
562
|
+
nl: 'hoe deze run aansluit op eerdere sessies, wat er is veranderd en hoe het vertrouwen tussen runs is verschoven',
|
|
563
|
+
ar: 'كيفية ارتباط هذا التشغيل بالجلسات السابقة، وما الذي تغير، وكيف تحولت الثقة بين عمليات التشغيل',
|
|
564
|
+
he: 'כיצד הרצה זו מתקשרת להפעלות קודמות, מה השתנה, וכיצד הביטחון השתנה בין הרצות',
|
|
565
|
+
ja: 'この実行が以前のセッションとどう繋がるか、何が変わったか、実行間で信頼性がどう変動したか',
|
|
566
|
+
ko: '이 실행이 이전 세션과 어떻게 연결되는지, 무엇이 변경되었는지, 실행 간에 신뢰도가 어떻게 변화했는지',
|
|
567
|
+
zh: '本次运行如何与先前会话关联、变化了什么以及置信度在运行之间如何变化',
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
'section-extended-intel': {
|
|
571
|
+
need: {
|
|
572
|
+
en: 'Extended intelligence',
|
|
573
|
+
sv: 'Utökad underrättelse',
|
|
574
|
+
da: 'Udvidet efterretning',
|
|
575
|
+
no: 'Utvidet etterretning',
|
|
576
|
+
fi: 'Laajennettu tiedustelu',
|
|
577
|
+
de: 'Erweiterte Aufklärung',
|
|
578
|
+
fr: 'Renseignement étendu',
|
|
579
|
+
es: 'Inteligencia ampliada',
|
|
580
|
+
nl: 'Uitgebreide inlichtingen',
|
|
581
|
+
ar: 'استخبارات موسعة',
|
|
582
|
+
he: 'מודיעין מורחב',
|
|
583
|
+
ja: '拡張インテリジェンス',
|
|
584
|
+
ko: '확장 인텔리전스',
|
|
585
|
+
zh: '扩展情报',
|
|
586
|
+
},
|
|
587
|
+
value: {
|
|
588
|
+
en: "devil's-advocate critique, comparative international parallels, historical precedents, and media-framing analysis",
|
|
589
|
+
sv: 'djävulens-advokat-kritik, jämförande internationella paralleller, historiska prejudikat och mediaframing-analys',
|
|
590
|
+
da: 'djævlens-advokat-kritik, sammenlignende internationale paralleller, historiske præcedenser og medieframing-analyse',
|
|
591
|
+
no: 'djevelens advokat-kritikk, sammenlignende internasjonale paralleller, historiske presedenser og mediaframing-analyse',
|
|
592
|
+
fi: 'paholaisen asianajaja -kritiikki, kansainväliset vertailut, historialliset ennakkotapaukset ja media-analyysi',
|
|
593
|
+
de: 'Devil-Advocate-Kritik, vergleichende internationale Parallelen, historische Präzedenzfälle und Medien-Framing-Analyse',
|
|
594
|
+
fr: "critique de l'avocat du diable, parallèles internationaux comparatifs, précédents historiques et analyse du cadrage médiatique",
|
|
595
|
+
es: 'crítica de abogado del diablo, paralelismos internacionales comparativos, precedentes históricos y análisis de encuadre mediático',
|
|
596
|
+
nl: 'devils-advocate-kritiek, vergelijkende internationale parallellen, historische precedenten en media-framinganalyse',
|
|
597
|
+
ar: 'نقد محامي الشيطان، توازيات دولية مقارنة، سوابق تاريخية، وتحليل التأطير الإعلامي',
|
|
598
|
+
he: 'ביקורת פרקליט השטן, מקבילות בינלאומיות השוואתיות, תקדימים היסטוריים וניתוח מסגור תקשורתי',
|
|
599
|
+
ja: '悪魔の代弁者批評、比較国際パラレル、歴史的先例、メディアフレーミング分析',
|
|
600
|
+
ko: '악마의 변호인 비판, 비교 국제 평행 사례, 역사적 선례, 미디어 프레이밍 분석',
|
|
601
|
+
zh: '魔鬼代言人批评、比较国际平行案例、历史先例和媒体框架分析',
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
'section-mcp-reliability': {
|
|
605
|
+
need: {
|
|
606
|
+
en: 'MCP data reliability',
|
|
607
|
+
sv: 'MCP-datatillförlitlighet',
|
|
608
|
+
da: 'MCP-datapålidelighed',
|
|
609
|
+
no: 'MCP-datapålitelighet',
|
|
610
|
+
fi: 'MCP-datan luotettavuus',
|
|
611
|
+
de: 'MCP-Datenzuverlässigkeit',
|
|
612
|
+
fr: 'Fiabilité des données MCP',
|
|
613
|
+
es: 'Fiabilidad de datos MCP',
|
|
614
|
+
nl: 'Betrouwbaarheid MCP-gegevens',
|
|
615
|
+
ar: 'موثوقية بيانات MCP',
|
|
616
|
+
he: 'אמינות נתוני MCP',
|
|
617
|
+
ja: 'MCPデータ信頼性',
|
|
618
|
+
ko: 'MCP 데이터 신뢰성',
|
|
619
|
+
zh: 'MCP数据可靠性',
|
|
620
|
+
},
|
|
621
|
+
value: {
|
|
622
|
+
en: 'which feeds were healthy, which were degraded, and how the data limitations bound the conclusions',
|
|
623
|
+
sv: 'vilka flöden var friska, vilka var degraderade och hur databegränsningar binder slutsatserna',
|
|
624
|
+
da: 'hvilke feeds var sunde, hvilke var forringede, og hvordan databegrænsningerne binder konklusionerne',
|
|
625
|
+
no: 'hvilke feeds var sunne, hvilke var degradert, og hvordan databegrensninger binder konklusjonene',
|
|
626
|
+
fi: 'mitkä syötteet olivat terveitä, mitkä huonontuneita ja miten datarajoitukset rajaavat johtopäätöksiä',
|
|
627
|
+
de: 'welche Feeds gesund waren, welche degradiert, und wie die Datengrenzen die Schlussfolgerungen binden',
|
|
628
|
+
fr: 'quels flux étaient sains, lesquels étaient dégradés et comment les limites de données contraignent les conclusions',
|
|
629
|
+
es: 'qué fuentes estaban sanas, cuáles degradadas y cómo las limitaciones de datos restringen las conclusiones',
|
|
630
|
+
nl: 'welke feeds gezond waren, welke gedegradeerd, en hoe databeperkingen de conclusies inperken',
|
|
631
|
+
ar: 'أي الموجزات كانت صحية، وأيها متدهورة، وكيف تقيد قيود البيانات الاستنتاجات',
|
|
632
|
+
he: 'אילו פידים היו תקינים, אילו היו פגומים, וכיצד מגבלות הנתונים תוחמות את המסקנות',
|
|
633
|
+
ja: 'どのフィードが健全だったか、どれが劣化していたか、そしてデータの制約が結論をどう制限するか',
|
|
634
|
+
ko: '어떤 피드가 건강했고, 어떤 피드가 저하되었으며, 데이터 제약이 결론을 어떻게 제한하는지',
|
|
635
|
+
zh: '哪些数据源健康、哪些已降级,以及数据限制如何约束结论',
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
'section-quality-reflection': {
|
|
639
|
+
need: {
|
|
640
|
+
en: 'Analytical quality & reflection',
|
|
641
|
+
sv: 'Analytisk kvalitet & reflektion',
|
|
642
|
+
da: 'Analytisk kvalitet & refleksion',
|
|
643
|
+
no: 'Analytisk kvalitet & refleksjon',
|
|
644
|
+
fi: 'Analyyttinen laatu & pohdinta',
|
|
645
|
+
de: 'Analytische Qualität & Reflexion',
|
|
646
|
+
fr: 'Qualité analytique & réflexion',
|
|
647
|
+
es: 'Calidad analítica & reflexión',
|
|
648
|
+
nl: 'Analytische kwaliteit & reflectie',
|
|
649
|
+
ar: 'الجودة التحليلية والتأمل',
|
|
650
|
+
he: 'איכות אנליטית ורפלקציה',
|
|
651
|
+
ja: '分析品質と内省',
|
|
652
|
+
ko: '분석 품질 & 성찰',
|
|
653
|
+
zh: '分析质量与反思',
|
|
654
|
+
},
|
|
655
|
+
value: {
|
|
656
|
+
en: 'self-assessment scores, methodology audit, structured-analytic-techniques used, and known limitations',
|
|
657
|
+
sv: 'självvärderingspoäng, metodologirevision, strukturerade analystekniker som använts och kända begränsningar',
|
|
658
|
+
da: 'selvevalueringsresultater, metoderevision, anvendte strukturerede analyseteknikker og kendte begrænsninger',
|
|
659
|
+
no: 'selvvurderingsskår, metoderevisjon, brukte strukturerte analyseteknikker og kjente begrensninger',
|
|
660
|
+
fi: 'itsearviointipisteet, metodologian auditointi, käytetyt strukturoidut analyysitekniikat ja tunnetut rajoitukset',
|
|
661
|
+
de: 'Selbsteinschätzungs-Scores, Methodologie-Audit, eingesetzte strukturierte Analysetechniken und bekannte Einschränkungen',
|
|
662
|
+
fr: "scores d'auto-évaluation, audit méthodologique, techniques analytiques structurées utilisées et limitations connues",
|
|
663
|
+
es: 'puntuaciones de autoevaluación, auditoría metodológica, técnicas analíticas estructuradas utilizadas y limitaciones conocidas',
|
|
664
|
+
nl: 'zelfevaluatiescores, methodologie-audit, gebruikte gestructureerde analytische technieken en bekende beperkingen',
|
|
665
|
+
ar: 'درجات التقييم الذاتي، تدقيق المنهجية، تقنيات التحليل المنظمة المستخدمة، والقيود المعروفة',
|
|
666
|
+
he: 'ציוני הערכה עצמית, ביקורת מתודולוגית, טכניקות אנליטיות מובנות שנעשה בהן שימוש ומגבלות ידועות',
|
|
667
|
+
ja: '自己評価スコア、方法論監査、使用された構造化分析技法、および既知の制約',
|
|
668
|
+
ko: '자가 평가 점수, 방법론 감사, 사용된 구조화된 분석 기법 및 알려진 한계',
|
|
669
|
+
zh: '自我评估分数、方法论审计、使用的结构化分析技术和已知限制',
|
|
670
|
+
},
|
|
671
|
+
},
|
|
366
672
|
};
|
|
367
673
|
/* ─── Section icons ─────────────────────────────────────────────── */
|
|
368
674
|
/** Visual icons for each reader guide section to improve scannability. */
|
|
@@ -370,24 +676,56 @@ const SECTION_ICONS = {
|
|
|
370
676
|
'section-executive-brief': '📋',
|
|
371
677
|
'section-synthesis': '🔗',
|
|
372
678
|
'section-significance': '⚖️',
|
|
679
|
+
'section-actors-forces': '🎭',
|
|
373
680
|
'section-coalitions-voting': '🤝',
|
|
374
681
|
'section-stakeholder-map': '👥',
|
|
375
682
|
'section-economic-context': '💶',
|
|
376
|
-
'section-scenarios': '🔮',
|
|
377
683
|
'section-risk': '⚠️',
|
|
684
|
+
'section-threat': '🛡️',
|
|
685
|
+
'section-scenarios': '🔮',
|
|
686
|
+
'section-forward-projection': '🔭',
|
|
687
|
+
'section-electoral-arc': '🗳️',
|
|
688
|
+
'section-pestle-context': '🌍',
|
|
689
|
+
'section-continuity': '🔁',
|
|
690
|
+
'section-extended-intel': '🧠',
|
|
691
|
+
'section-mcp-reliability': '📡',
|
|
692
|
+
'section-quality-reflection': '🪞',
|
|
378
693
|
};
|
|
694
|
+
/**
|
|
695
|
+
* Look up the visual icon for a known article section.
|
|
696
|
+
*
|
|
697
|
+
* Exposed so the article-level Table-of-Contents (`buildArticleToc`)
|
|
698
|
+
* can render the same emoji that the Reader Intelligence Guide uses
|
|
699
|
+
* for each section, keeping the two navigation surfaces visually
|
|
700
|
+
* consistent. Unknown section IDs (e.g. ad-hoc `supplementary-…` or
|
|
701
|
+
* appendix anchors) fall back to a generic 📎 paperclip.
|
|
702
|
+
*
|
|
703
|
+
* @param sectionId - Anchor id of the section (e.g. `section-risk`)
|
|
704
|
+
* @returns Single emoji glyph used as a `guide-icon`
|
|
705
|
+
*/
|
|
706
|
+
export function getReaderGuideSectionIcon(sectionId) {
|
|
707
|
+
return SECTION_ICONS[sectionId] ?? '📎';
|
|
708
|
+
}
|
|
379
709
|
/* ─── HTML builder ───────────────────────────────────────────────── */
|
|
380
710
|
/**
|
|
381
711
|
* Build a translated Reader Intelligence Guide as an HTML section.
|
|
382
712
|
* Emits exactly one component with `data-component="reader-intelligence-guide"`
|
|
383
713
|
* for de-duplication detection by E2E tests.
|
|
384
714
|
*
|
|
715
|
+
* The guide renders one row per emitted article section that has a
|
|
716
|
+
* curated reader-need translation (see {@link READER_GUIDE_ROWS}). The
|
|
717
|
+
* `included` list is no longer surfaced — the previous "source artifact"
|
|
718
|
+
* column duplicated the per-section navigation that the Analysis Index
|
|
719
|
+
* appendix already presents, and clutters the headline reader lens. The
|
|
720
|
+
* parameter is kept on the signature for backward compatibility with
|
|
721
|
+
* callers that may pre-compute the run manifest.
|
|
722
|
+
*
|
|
385
723
|
* @param lang - Target language code
|
|
386
724
|
* @param sections - Emitted section TOC entries, in document order
|
|
387
|
-
* @param
|
|
725
|
+
* @param _included - (Unused) Included artifacts; kept for API stability
|
|
388
726
|
* @returns HTML fragment for the guide, or empty string if no rows match
|
|
389
727
|
*/
|
|
390
|
-
export function buildReaderIntelligenceGuideHtml(lang, sections,
|
|
728
|
+
export function buildReaderIntelligenceGuideHtml(lang, sections, _included = []) {
|
|
391
729
|
const dir = getTextDirection(lang);
|
|
392
730
|
const rows = [];
|
|
393
731
|
for (const section of sections) {
|
|
@@ -396,10 +734,8 @@ export function buildReaderIntelligenceGuideHtml(lang, sections, included) {
|
|
|
396
734
|
continue;
|
|
397
735
|
const need = getLocalizedString(rowData.need, lang);
|
|
398
736
|
const value = getLocalizedString(rowData.value, lang);
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
const sectionIcon = SECTION_ICONS[section.id] ?? '📎';
|
|
402
|
-
rows.push(`<tr><td><span class="guide-icon" aria-hidden="true">${sectionIcon}</span> <a href="#${escapeHTML(section.id)}">${escapeHTML(need)}</a></td><td>${escapeHTML(value)}</td><td>${sourceLabel}</td></tr>`);
|
|
737
|
+
const sectionIcon = getReaderGuideSectionIcon(section.id);
|
|
738
|
+
rows.push(`<tr><td><span class="guide-icon" aria-hidden="true">${sectionIcon}</span> <a href="#${escapeHTML(section.id)}">${escapeHTML(need)}</a></td><td>${escapeHTML(value)}</td></tr>`);
|
|
403
739
|
}
|
|
404
740
|
if (rows.length === 0)
|
|
405
741
|
return '';
|
|
@@ -407,14 +743,13 @@ export function buildReaderIntelligenceGuideHtml(lang, sections, included) {
|
|
|
407
743
|
const intro = getLocalizedString(READER_GUIDE_INTRO_LABELS, lang);
|
|
408
744
|
const colNeed = getLocalizedString(READER_GUIDE_COL_NEED_LABELS, lang);
|
|
409
745
|
const colValue = getLocalizedString(READER_GUIDE_COL_VALUE_LABELS, lang);
|
|
410
|
-
const colSource = getLocalizedString(READER_GUIDE_COL_SOURCE_LABELS, lang);
|
|
411
746
|
return `<section id="${READER_GUIDE_SECTION_ID}" data-component="reader-intelligence-guide" aria-label="${escapeHTML(title)}"${dir === 'rtl' ? ' dir="rtl"' : ''}>
|
|
412
747
|
<h2 id="${READER_GUIDE_SECTION_ID}-heading"><span class="guide-icon" aria-hidden="true">🧭</span> ${escapeHTML(title)}</h2>
|
|
413
748
|
<p class="reader-guide-intro">${escapeHTML(intro)}</p>
|
|
414
749
|
<div class="table-scroll" role="region" tabindex="0" aria-labelledby="${READER_GUIDE_SECTION_ID}-heading">
|
|
415
750
|
<table class="reader-guide-table">
|
|
416
751
|
<caption class="sr-only">${escapeHTML(title)}</caption>
|
|
417
|
-
<thead><tr><th scope="col">${escapeHTML(colNeed)}</th><th scope="col">${escapeHTML(colValue)}</th
|
|
752
|
+
<thead><tr><th scope="col">${escapeHTML(colNeed)}</th><th scope="col">${escapeHTML(colValue)}</th></tr></thead>
|
|
418
753
|
<tbody>
|
|
419
754
|
${rows.join('\n')}
|
|
420
755
|
</tbody>
|