euparliamentmonitor 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -550,30 +550,33 @@ export function aggregateAnalysisRun(options) {
550
550
  const analysisIndex = renderAnalysisIndex(includedArtifacts, manifestRelPath);
551
551
  const readerGuide = renderReaderIntelligenceGuide(emittedSections, includedArtifacts);
552
552
  // Deterministic 3–7 bullet "Key takeaways" block, harvested from the
553
- // synthesis-summary / intelligence-assessment artifacts. Placed
554
- // immediately after the Executive Brief so the reader gets the BLUF
555
- // followed by a digest of the strongest findings before being handed
556
- // off to the Reader Intelligence Guide and the deeper sections.
553
+ // synthesis-summary / intelligence-assessment artifacts. Sits between
554
+ // the Reader Intelligence Guide and the deep sections: the reader gets
555
+ // the BLUF (Executive Brief) a navigation map (Reader Guide) → a
556
+ // bullet digest of the strongest findings (Key Takeaways) the deep
557
+ // analysis. This is the order requested for reader UX so navigation
558
+ // is established before the reader commits to scanning takeaways.
557
559
  const keyTakeaways = buildKeyTakeaways({ runDir });
558
- // TOC ordering reflects the rendered document:
560
+ // TOC ordering must match the rendered Markdown body 1:1. Order:
559
561
  // Executive Brief (already first in emittedSections via appendSection) →
560
- // Key Takeaways (inserted right after the brief when present) →
561
- // Reader Intelligence Guide remaining sections audit appendices.
562
+ // Reader Intelligence Guide (inserted right after the brief when present) →
563
+ // Key Takeaways (inserted right after the guide when present) →
564
+ // remaining sections → audit appendices.
562
565
  let postBriefIdx = emittedSections.length > 0 &&
563
566
  emittedSections[0]?.id === namespacedSectionId(execBriefSection?.id ?? '')
564
567
  ? 1
565
568
  : 0;
566
- if (keyTakeaways) {
569
+ if (readerGuide) {
567
570
  emittedSections.splice(postBriefIdx, 0, {
568
- id: KEY_TAKEAWAYS_SECTION_ID,
569
- title: KEY_TAKEAWAYS_SECTION_TITLE,
571
+ id: READER_GUIDE_SECTION_ID,
572
+ title: READER_GUIDE_SECTION_TITLE,
570
573
  });
571
574
  postBriefIdx += 1;
572
575
  }
573
- if (readerGuide) {
576
+ if (keyTakeaways) {
574
577
  emittedSections.splice(postBriefIdx, 0, {
575
- id: READER_GUIDE_SECTION_ID,
576
- title: READER_GUIDE_SECTION_TITLE,
578
+ id: KEY_TAKEAWAYS_SECTION_ID,
579
+ title: KEY_TAKEAWAYS_SECTION_TITLE,
577
580
  });
578
581
  }
579
582
  emittedSections.push({ id: TRADECRAFT_SECTION_ID, title: TRADECRAFT_SECTION_TITLE });
@@ -583,9 +586,8 @@ export function aggregateAnalysisRun(options) {
583
586
  '',
584
587
  ...execBriefMarkdown,
585
588
  '',
589
+ ...(readerGuide ? [readerGuide, ''] : []),
586
590
  ...(keyTakeaways ? [keyTakeaways, ''] : []),
587
- readerGuide,
588
- '',
589
591
  ...sectionMarkdown,
590
592
  '',
591
593
  provenance,
@@ -19,7 +19,11 @@ export interface CliOptions {
19
19
  * is earlier are skipped.
20
20
  */
21
21
  readonly since?: string;
22
- /** Languages to render (defaults to all 14). */
22
+ /**
23
+ * Languages to render. Defaults to all 14. The CLI always populates
24
+ * this with `[...ALL_LANGUAGES]` (the `--lang/--language` flags have
25
+ * been removed); programmatic callers (tests) can override it.
26
+ */
23
27
  readonly langs: readonly LanguageCode[];
24
28
  /** Output directory for HTML files (defaults to `news/`). */
25
29
  readonly outDir: string;
@@ -30,8 +34,11 @@ export interface CliOptions {
30
34
  /** Optional: override the auto-derived article description (single-run only). */
31
35
  readonly description?: string;
32
36
  /**
33
- * When true, only the source Markdown is written (no HTML) — useful for
34
- * upstream pipelines that translate first and then batch-render.
37
+ * When true, only the source Markdown is written (no HTML) — preserved
38
+ * for programmatic callers (tests) that need to skip HTML emission for
39
+ * speed. The CLI no longer exposes a flag for this and always sets
40
+ * `false`; every workflow-driven invocation emits HTML for all 14
41
+ * languages.
35
42
  */
36
43
  readonly markdownOnly: boolean;
37
44
  }
@@ -10,9 +10,14 @@
10
10
  *
11
11
  * Usage:
12
12
  * npm run generate-article -- --run analysis/daily/2026-01-15/breaking-run1
13
- * npm run generate-article -- --run ... --lang en --lang sv
14
13
  * npm run generate-article -- --run ... --out-dir news --title "Headline"
15
14
  *
15
+ * **Always-14-languages-always-HTML contract**: every CLI invocation
16
+ * renders every supported language to HTML. The legacy `--lang` /
17
+ * `--language` / `--markdown-only` flags have been removed. The
18
+ * programmatic `generateArticle()` API still accepts `langs` and
19
+ * `markdownOnly` for tests that need to scope a render for speed.
20
+ *
16
21
  * Designed to be idempotent: running again with no changes overwrites
17
22
  * identical files byte-for-byte.
18
23
  */
@@ -24,7 +29,7 @@ import { resolveRunId as _resolveRunId } from './manifest/index.js';
24
29
  import { resolveArticleMetadata, extractStrongProseLine, } from './article-metadata.js';
25
30
  import { buildArticleMeta, serializeArticleMeta } from './article-meta.js';
26
31
  import { renderMarkdown } from './markdown-renderer.js';
27
- import { wrapArticleHtml, getArticleFilename } from './article-html.js';
32
+ import { wrapArticleHtml, getArticleFilename, localizeArticleBody } from './article-html.js';
28
33
  import { buildReaderIntelligenceGuideHtml, stripInlineReaderGuide, } from './reader-intelligence-guide.js';
29
34
  import { ALL_LANGUAGES } from '../constants/language-core.js';
30
35
  import { blobUrl } from './infra/github-urls.js';
@@ -48,9 +53,6 @@ function applyFlagResult(acc, result) {
48
53
  case 'since':
49
54
  acc.since = result.value;
50
55
  return;
51
- case 'lang':
52
- acc.langs.push(result.value);
53
- return;
54
56
  case 'outDir':
55
57
  acc.outDir = result.value;
56
58
  return;
@@ -60,9 +62,6 @@ function applyFlagResult(acc, result) {
60
62
  case 'description':
61
63
  acc.description = result.value;
62
64
  return;
63
- case 'markdownOnly':
64
- acc.markdownOnly = true;
65
- return;
66
65
  default: {
67
66
  // Exhaustiveness guard — if a new FlagResult kind is added without a
68
67
  // matching case the compiler will surface the gap.
@@ -75,9 +74,7 @@ export function parseCliArgs(argv, repoRoot) {
75
74
  const acc = {
76
75
  runDir: null,
77
76
  all: false,
78
- langs: [],
79
77
  outDir: path.join(repoRoot, 'news'),
80
- markdownOnly: false,
81
78
  };
82
79
  for (let i = 0; i < argv.length; i++) {
83
80
  const arg = argv[i] ?? '';
@@ -105,10 +102,14 @@ export function parseCliArgs(argv, repoRoot) {
105
102
  const opts = {
106
103
  runDir: acc.runDir,
107
104
  all: acc.all,
108
- langs: acc.langs.length > 0 ? acc.langs : [...ALL_LANGUAGES],
105
+ // Always render every language the `--lang/--language` flags have
106
+ // been removed in the always-14-languages contract.
107
+ langs: [...ALL_LANGUAGES],
109
108
  outDir: acc.outDir,
110
109
  repoRoot,
111
- markdownOnly: acc.markdownOnly,
110
+ // Always emit HTML — the `--markdown-only` flag has been removed in
111
+ // the always-HTML contract.
112
+ markdownOnly: false,
112
113
  ...(acc.since !== undefined ? { since: acc.since } : {}),
113
114
  ...(acc.title !== undefined ? { title: acc.title } : {}),
114
115
  ...(acc.description !== undefined ? { description: acc.description } : {}),
@@ -117,7 +118,7 @@ export function parseCliArgs(argv, repoRoot) {
117
118
  }
118
119
  /**
119
120
  * Resolve one CLI flag to a {@link FlagResult}. Throws `Error` for any
120
- * unsupported flag or language code.
121
+ * unsupported flag.
121
122
  *
122
123
  * @param flag - Flag name (e.g. `--run`)
123
124
  * @param takeValue - Lazily returns the value argument for value-bearing flags
@@ -137,14 +138,6 @@ function applyCliFlag(flag, takeValue) {
137
138
  }
138
139
  return { kind: 'since', value };
139
140
  }
140
- case '--lang':
141
- case '--language': {
142
- const value = takeValue();
143
- if (!ALL_LANGUAGES.includes(value)) {
144
- throw new Error(`Unsupported language code: ${value}`);
145
- }
146
- return { kind: 'lang', value: value };
147
- }
148
141
  case '--out-dir':
149
142
  case '--output':
150
143
  return { kind: 'outDir', value: path.resolve(takeValue()) };
@@ -152,13 +145,19 @@ function applyCliFlag(flag, takeValue) {
152
145
  return { kind: 'title', value: takeValue() };
153
146
  case '--description':
154
147
  return { kind: 'description', value: takeValue() };
155
- case '--markdown-only':
156
- return { kind: 'markdownOnly' };
157
148
  case '--help':
158
149
  case '-h':
159
150
  printHelp();
160
151
  process.exit(0);
161
152
  // eslint-disable-next-line no-fallthrough
153
+ case '--lang':
154
+ case '--language':
155
+ case '--markdown-only':
156
+ // Removed in the always-14-languages-always-HTML contract — every
157
+ // article.md now always renders to all 14 supported languages and
158
+ // HTML emission cannot be skipped from the CLI.
159
+ throw new Error(`Flag ${flag} has been removed. The CLI always renders all 14 languages with HTML output. ` +
160
+ `See Article-Generation.md § "CLI contract" for the new always-on contract.`);
162
161
  default:
163
162
  throw new Error(`Unknown argument: ${flag}`);
164
163
  }
@@ -183,19 +182,22 @@ function printHelp() {
183
182
  ' generate-article --all [--since YYYY-MM-DD] [options]',
184
183
  '',
185
184
  'Aggregate analysis artifacts from an `analysis/daily/**/<run>` directory',
186
- 'into a canonical Markdown document and render it to HTML in all 14',
187
- 'languages. The `--all` form walks every run under `analysis/daily/`',
188
- 'and regenerates the full historic catalogue in one pass.',
185
+ 'into a canonical Markdown document and render it to HTML in **all 14',
186
+ 'supported languages** (en, sv, da, no, fi, de, fr, es, nl, ar, he, ja,',
187
+ 'ko, zh). The `--all` form walks every run under `analysis/daily/` and',
188
+ 'regenerates the full historic catalogue in one pass.',
189
+ '',
190
+ 'The 14-language HTML render is **always on** — there is no flag to',
191
+ 'scope a render to a single language or to skip HTML emission. Every',
192
+ 'article.md always produces 14 corresponding `<slug>-<lang>.html` files.',
189
193
  '',
190
194
  'Options:',
191
195
  ' --run <path> Analysis run directory (single-run mode)',
192
196
  ' --all Batch-regenerate every run under analysis/daily/',
193
197
  ' --since YYYY-MM-DD With --all: skip runs dated before this cut-off',
194
- ' --lang <code> Language to render (repeatable; default: all 14)',
195
198
  ' --out-dir <path> Output directory (default: news/)',
196
199
  ' --title <text> Override article title (single-run only)',
197
200
  ' --description <text> Override article meta description (single-run only)',
198
- ' --markdown-only Write only the source .md (skip HTML)',
199
201
  ' --help, -h Show this help',
200
202
  '',
201
203
  ].join('\n'));
@@ -354,6 +356,9 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
354
356
  // and avoids fragile in-body heading searches.
355
357
  bodyHtml = guideHtml + '\n' + bodyHtml;
356
358
  }
359
+ // Localize Tradecraft References, Analysis Index, and other appendix
360
+ // section headings and content into the target language.
361
+ bodyHtml = localizeArticleBody(bodyHtml, lang);
357
362
  // When a per-language translated source exists, prefer a summary derived
358
363
  // from it so the `<meta description>` matches the visible prose. The
359
364
  // editorial title still comes from the English resolver (per-language
@@ -84,6 +84,16 @@ export declare function buildArticleHreflangLinks(articleSlug: string): string;
84
84
  * @returns HTML fragment for the sidebar, or `""` when no TOC is needed
85
85
  */
86
86
  export declare function buildArticleToc(entries: readonly ArticleTocEntry[], lang: LanguageCode): string;
87
+ /**
88
+ * Localize the Tradecraft References and Analysis Index sections in the
89
+ * rendered article body HTML. Replaces English headings, introductions,
90
+ * sub-headings, and table headers with translated equivalents.
91
+ *
92
+ * @param bodyHtml - The rendered HTML body (from Markdown)
93
+ * @param lang - Target language code
94
+ * @returns HTML body with localized appendix sections
95
+ */
96
+ export declare function localizeArticleBody(bodyHtml: string, lang: LanguageCode): string;
87
97
  /**
88
98
  * Render the full article HTML document with the shared chrome.
89
99
  *
@@ -20,11 +20,31 @@
20
20
  */
21
21
  import { BASE_URL, BUILD_SHORT, MERMAID_VERSION } from '../constants/config.js';
22
22
  import { buildHeadFreshnessTags } from '../constants/build-info-meta.js';
23
- import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, SKIP_LINK_TEXTS, TOC_ARIA_LABELS, getLocalizedString, getTextDirection, } from '../constants/languages.js';
23
+ import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, SKIP_LINK_TEXTS, TOC_ARIA_LABELS, ARTICLE_TYPE_LABELS, VIEW_SOURCE_MARKDOWN_LABELS, ARTICLE_TYPE_ICONS, TRADECRAFT_HEADING_LABELS, TRADECRAFT_INTRO_LABELS, TRADECRAFT_METHODOLOGIES_LABELS, TRADECRAFT_TEMPLATES_LABELS, ANALYSIS_INDEX_HEADING_LABELS, ANALYSIS_INDEX_INTRO_LABELS, ANALYSIS_INDEX_COL_SECTION_LABELS, ANALYSIS_INDEX_COL_ARTIFACT_LABELS, ANALYSIS_INDEX_COL_PATH_LABELS, KEY_TAKEAWAYS_HEADING_LABELS, SUPPLEMENTARY_HEADING_LABELS, SECTION_TITLE_LABELS, getLocalizedString, getTextDirection, } from '../constants/languages.js';
24
+ import { ArticleCategory } from '../types/index.js';
24
25
  import { escapeHTML } from '../utils/file-utils.js';
25
26
  import { buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../templates/section-builders.js';
26
27
  import { READER_GUIDE_SECTION_ID } from './reader-guide-constants.js';
27
28
  import { READER_GUIDE_TITLE_LABELS } from './reader-intelligence-guide.js';
29
+ import { TRADECRAFT_SECTION_ID, MANIFEST_SECTION_ID, SUPPLEMENTARY_SECTION_ID, } from './artifact-order.js';
30
+ import { KEY_TAKEAWAYS_SECTION_ID } from './key-takeaways.js';
31
+ /**
32
+ * Resolve a localized article type label with icon. Falls back to the
33
+ * humanised slug when a translation isn't available.
34
+ *
35
+ * @param slug - Raw article type slug (e.g. "motions", "week-ahead")
36
+ * @param lang - Target language code
37
+ * @returns Localized label with preceding emoji icon (e.g. "🗳️ Plenary Votes & Resolutions")
38
+ */
39
+ function getLocalizedArticleType(slug, lang) {
40
+ const labels = getLocalizedString(ARTICLE_TYPE_LABELS, lang);
41
+ const label = labels[slug] ?? slug.replace(/-/g, ' ');
42
+ const categoryValues = Object.values(ArticleCategory);
43
+ const iconEmoji = categoryValues.includes(slug)
44
+ ? ARTICLE_TYPE_ICONS[slug]
45
+ : '📄';
46
+ return `${iconEmoji} ${label}`;
47
+ }
28
48
  /** Publisher organization name used in JSON-LD, meta tags. */
29
49
  const PUBLISHER_NAME = 'Hack23 AB';
30
50
  /** Site name used across meta tags and structured data. */
@@ -72,6 +92,44 @@ function buildLanguageSwitcher(articleSlug, current) {
72
92
  return `<a href="${href}" class="lang-link${active}" hreflang="${code}" lang="${code}" title="${safeName}" aria-label="${safeName}"${ariaCurrent}>${flag} ${code.toUpperCase()}</a>`;
73
93
  }).join('\n ');
74
94
  }
95
+ /**
96
+ * Resolve a localized title for a TOC entry based on its section ID.
97
+ * Falls back to the original English title if no translation is available.
98
+ *
99
+ * @param sectionId - The fragment identifier of the section
100
+ * @param fallbackTitle - The English title to fall back to
101
+ * @param lang - Target language code
102
+ * @returns Localized title string
103
+ */
104
+ function getLocalizedTocTitle(sectionId, fallbackTitle, lang) {
105
+ // Reader Intelligence Guide
106
+ if (sectionId === READER_GUIDE_SECTION_ID) {
107
+ return getLocalizedString(READER_GUIDE_TITLE_LABELS, lang);
108
+ }
109
+ // Tradecraft References appendix
110
+ if (sectionId === TRADECRAFT_SECTION_ID) {
111
+ return getLocalizedString(TRADECRAFT_HEADING_LABELS, lang);
112
+ }
113
+ // Analysis Index appendix
114
+ if (sectionId === MANIFEST_SECTION_ID) {
115
+ return getLocalizedString(ANALYSIS_INDEX_HEADING_LABELS, lang);
116
+ }
117
+ // Key Takeaways
118
+ if (sectionId === KEY_TAKEAWAYS_SECTION_ID) {
119
+ return getLocalizedString(KEY_TAKEAWAYS_HEADING_LABELS, lang);
120
+ }
121
+ // Supplementary Intelligence
122
+ if (sectionId === SUPPLEMENTARY_SECTION_ID) {
123
+ return getLocalizedString(SUPPLEMENTARY_HEADING_LABELS, lang);
124
+ }
125
+ // Artifact section titles (strip the `section-` prefix to find the key)
126
+ const sectionKey = sectionId.replace(/^section-/, '');
127
+ const sectionLabels = SECTION_TITLE_LABELS[sectionKey];
128
+ if (sectionLabels) {
129
+ return getLocalizedString(sectionLabels, lang);
130
+ }
131
+ return fallbackTitle;
132
+ }
75
133
  /**
76
134
  * Build the article-level Table of Contents nav. Renders a labelled
77
135
  * `<nav class="article-toc">` with one `<a>` per H2 section, keyed by the
@@ -92,17 +150,14 @@ export function buildArticleToc(entries, lang) {
92
150
  const label = escapeHTML(getLocalizedString(TOC_ARIA_LABELS, lang));
93
151
  const items = entries
94
152
  .map((e) => {
95
- // Translate the Reader Intelligence Guide title into the target language
96
- const displayTitle = e.id === READER_GUIDE_SECTION_ID
97
- ? getLocalizedString(READER_GUIDE_TITLE_LABELS, lang)
98
- : e.title;
153
+ const displayTitle = getLocalizedTocTitle(e.id, e.title, lang);
99
154
  return ` <li><a href="#${escapeHTML(e.id)}">${escapeHTML(displayTitle)}</a></li>`;
100
155
  })
101
156
  .join('\n');
102
157
  return [
103
158
  ` <aside class="article-toc-container" aria-label="${label}">`,
104
159
  ` <details class="article-toc-details" open>`,
105
- ` <summary class="article-toc-summary">${label}</summary>`,
160
+ ` <summary class="article-toc-summary"><span class="guide-icon" aria-hidden="true">📑</span> ${label}</summary>`,
106
161
  ` <nav class="article-toc">`,
107
162
  ` <ol class="article-toc-list">`,
108
163
  items,
@@ -113,6 +168,131 @@ export function buildArticleToc(entries, lang) {
113
168
  '',
114
169
  ].join('\n');
115
170
  }
171
+ /**
172
+ * Localize the Tradecraft References and Analysis Index sections in the
173
+ * rendered article body HTML. Replaces English headings, introductions,
174
+ * sub-headings, and table headers with translated equivalents.
175
+ *
176
+ * @param bodyHtml - The rendered HTML body (from Markdown)
177
+ * @param lang - Target language code
178
+ * @returns HTML body with localized appendix sections
179
+ */
180
+ export function localizeArticleBody(bodyHtml, lang) {
181
+ if (lang === 'en')
182
+ return bodyHtml;
183
+ let html = bodyHtml;
184
+ // --- Tradecraft References heading ---
185
+ // Use simple string indexOf to avoid polynomial regex backtracking.
186
+ const tradecraftHeading = getLocalizedString(TRADECRAFT_HEADING_LABELS, lang);
187
+ html = replaceHeadingById(html, TRADECRAFT_SECTION_ID, 'Tradecraft References', tradecraftHeading);
188
+ // --- Tradecraft intro paragraph ---
189
+ // The rendered Markdown produces a <p> containing the intro text with an
190
+ // <a> link to Hack23. Replace only the known English sentence prefix.
191
+ // HTML-escape the localized text to prevent injection, then re-insert the
192
+ // intentional <a> tag via a placeholder split.
193
+ const tradecraftIntroRaw = getLocalizedString(TRADECRAFT_INTRO_LABELS, lang);
194
+ const introSentenceStart = 'This article is produced under the ';
195
+ const introIdx = html.indexOf(introSentenceStart);
196
+ if (introIdx !== -1) {
197
+ // Find the end of the sentence (next '</p>' or period followed by '<')
198
+ const sentenceEnd = html.indexOf('</p>', introIdx);
199
+ if (sentenceEnd !== -1) {
200
+ const escapedIntro = escapeHTML(tradecraftIntroRaw);
201
+ const localizedWithLink = escapedIntro.replace(escapeHTML('Hack23 AB'), '<a href="https://hack23.com">Hack23 AB</a>');
202
+ html = html.slice(0, introIdx) + localizedWithLink + html.slice(sentenceEnd);
203
+ }
204
+ }
205
+ // --- Methodologies sub-heading ---
206
+ const methodsLabel = getLocalizedString(TRADECRAFT_METHODOLOGIES_LABELS, lang);
207
+ html = html.replace(/<h3>Methodologies<\/h3>/, `<h3>${escapeHTML(methodsLabel)}</h3>`);
208
+ // --- Artifact templates sub-heading ---
209
+ const templatesLabel = getLocalizedString(TRADECRAFT_TEMPLATES_LABELS, lang);
210
+ html = html.replace(/<h3>Artifact templates<\/h3>/, `<h3>${escapeHTML(templatesLabel)}</h3>`);
211
+ // --- Analysis Index heading ---
212
+ const analysisIndexHeading = getLocalizedString(ANALYSIS_INDEX_HEADING_LABELS, lang);
213
+ html = replaceHeadingById(html, MANIFEST_SECTION_ID, 'Analysis Index', analysisIndexHeading);
214
+ // --- Analysis Index intro ---
215
+ const analysisIndexIntroRaw = getLocalizedString(ANALYSIS_INDEX_INTRO_LABELS, lang);
216
+ // Use indexOf to find the manifest.json link URL without polynomial regex
217
+ const manifestLinkPrefix = 'href="';
218
+ const manifestJsonLiteral = 'manifest.json';
219
+ const manifestLinkIdx = html.indexOf(manifestJsonLiteral);
220
+ let manifestUrl = '';
221
+ if (manifestLinkIdx !== -1) {
222
+ // Walk backward to find the preceding href="
223
+ const hrefIdx = html.lastIndexOf(manifestLinkPrefix, manifestLinkIdx);
224
+ if (hrefIdx !== -1 && manifestLinkIdx - hrefIdx < 200) {
225
+ const urlStart = hrefIdx + manifestLinkPrefix.length;
226
+ const urlEnd = html.indexOf('"', urlStart);
227
+ if (urlEnd !== -1) {
228
+ manifestUrl = html.slice(urlStart, urlEnd);
229
+ }
230
+ }
231
+ }
232
+ // HTML-escape the localized intro, then re-insert the <a> link
233
+ const escapedAnalysisIntro = escapeHTML(analysisIndexIntroRaw);
234
+ const localizedIntroWithLink = manifestUrl
235
+ ? escapedAnalysisIntro.replace('manifest.json', `<a href="${escapeHTML(manifestUrl)}">manifest.json</a>`)
236
+ : escapedAnalysisIntro;
237
+ // Replace the known English intro sentence using indexOf
238
+ const analysisIntroStart = 'Every artifact below was read by the aggregator';
239
+ const analysisIntroIdx = html.indexOf(analysisIntroStart);
240
+ if (analysisIntroIdx !== -1) {
241
+ const analysisIntroEnd = html.indexOf('gate-result history.', analysisIntroIdx);
242
+ if (analysisIntroEnd !== -1) {
243
+ const endOffset = analysisIntroEnd + 'gate-result history.'.length;
244
+ html = html.slice(0, analysisIntroIdx) + localizedIntroWithLink + html.slice(endOffset);
245
+ }
246
+ }
247
+ // --- Analysis Index table headers ---
248
+ const colSection = getLocalizedString(ANALYSIS_INDEX_COL_SECTION_LABELS, lang);
249
+ const colArtifact = getLocalizedString(ANALYSIS_INDEX_COL_ARTIFACT_LABELS, lang);
250
+ const colPath = getLocalizedString(ANALYSIS_INDEX_COL_PATH_LABELS, lang);
251
+ html = html.replace('<th>Section</th><th>Artifact</th><th>Path</th>', `<th>${escapeHTML(colSection)}</th><th>${escapeHTML(colArtifact)}</th><th>${escapeHTML(colPath)}</th>`);
252
+ // --- Key Takeaways heading ---
253
+ const keyTakeawaysHeading = getLocalizedString(KEY_TAKEAWAYS_HEADING_LABELS, lang);
254
+ html = replaceHeadingById(html, KEY_TAKEAWAYS_SECTION_ID, 'Key Takeaways', keyTakeawaysHeading);
255
+ // --- Supplementary Intelligence heading ---
256
+ const supplementaryHeading = getLocalizedString(SUPPLEMENTARY_HEADING_LABELS, lang);
257
+ html = replaceHeadingById(html, SUPPLEMENTARY_SECTION_ID, 'Supplementary Intelligence', supplementaryHeading);
258
+ return html;
259
+ }
260
+ /**
261
+ * Replace an H2 heading's text content by locating it via its `id` attribute.
262
+ * Uses indexOf-based search to avoid polynomial regex backtracking (CodeQL).
263
+ *
264
+ * @param html - Full HTML string
265
+ * @param sectionId - The id attribute value of the target `<h2>`
266
+ * @param englishTitle - The English title text to replace
267
+ * @param localizedTitle - The localized title to insert
268
+ * @returns Updated HTML string
269
+ */
270
+ function replaceHeadingById(html, sectionId, englishTitle, localizedTitle) {
271
+ // Find the id attribute in the HTML — this uniquely identifies the heading
272
+ const idMarker = `id="${sectionId}"`;
273
+ let idIdx = html.indexOf(idMarker);
274
+ if (idIdx === -1) {
275
+ // Try single-quoted variant
276
+ const idMarkerSingle = `id='${sectionId}'`;
277
+ idIdx = html.indexOf(idMarkerSingle);
278
+ }
279
+ if (idIdx === -1)
280
+ return html;
281
+ // Find the closing '>' of the opening tag after the id
282
+ const tagCloseIdx = html.indexOf('>', idIdx);
283
+ if (tagCloseIdx === -1)
284
+ return html;
285
+ // The title text starts immediately after '>'
286
+ const titleStart = tagCloseIdx + 1;
287
+ const titleEnd = html.indexOf('<', titleStart);
288
+ if (titleEnd === -1)
289
+ return html;
290
+ // Verify this is actually the English title we expect
291
+ const existingTitle = html.slice(titleStart, titleEnd);
292
+ if (existingTitle.trim() !== englishTitle)
293
+ return html;
294
+ return html.slice(0, titleStart) + escapeHTML(localizedTitle) + html.slice(titleEnd);
295
+ }
116
296
  /**
117
297
  * Render the full article HTML document with the shared chrome.
118
298
  *
@@ -129,8 +309,9 @@ export function wrapArticleHtml(options) {
129
309
  const indexHref = safeLang === 'en' ? '../index.html' : `../index-${safeLang}.html`;
130
310
  const hreflangLinks = buildArticleHreflangLinks(options.articleSlug);
131
311
  const langSwitcher = buildLanguageSwitcher(options.articleSlug, safeLang);
312
+ const sourceMdLabel = getLocalizedString(VIEW_SOURCE_MARKDOWN_LABELS, safeLang);
132
313
  const sourceMdLink = options.sourceMarkdownRelPath
133
- ? `<p class="article-source-md"><a href="${BASE_URL}/${options.sourceMarkdownRelPath}" rel="alternate" type="text/markdown">View source Markdown</a></p>`
314
+ ? `<p class="article-source-md"><a href="${BASE_URL}/${options.sourceMarkdownRelPath}" rel="alternate" type="text/markdown"><svg class="icon icon-inline" width="16" height="16" viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false"><path d="M9 5H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2M12 3h6a2 2 0 0 1 2 2v6M10 14 20 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg> ${escapeHTML(sourceMdLabel)}</a></p>`
134
315
  : '';
135
316
  const tocHtml = buildArticleToc(options.toc ?? [], safeLang);
136
317
  const jsonLd = {
@@ -249,7 +430,7 @@ ${buildHeadFreshnessTags('../')}
249
430
  <main id="main" class="site-main article-main">
250
431
  ${tocHtml} <article class="article-body" lang="${safeLang}">
251
432
  <header class="article-hero">
252
- <p class="article-kicker">${escapeHTML(options.articleType.replace(/-/g, ' '))}</p>
433
+ <p class="article-kicker">${escapeHTML(getLocalizedArticleType(options.articleType, safeLang))}</p>
253
434
  <h1>${escapeHTML(options.title)}</h1>
254
435
  <p class="article-dek">${escapeHTML(options.description)}</p>
255
436
  <p class="article-meta"><time datetime="${options.date}">${options.date}</time> · EU Parliament Monitor</p>
@@ -1,16 +1,34 @@
1
1
  import type { LanguageCode } from '../../types/index.js';
2
- /** Successful parse → ready-to-execute options. */
2
+ /**
3
+ * Successful parse → ready-to-execute options.
4
+ *
5
+ * **Always-14-languages-always-HTML contract**: the CLI no longer accepts
6
+ * `--lang/--language` or `--markdown-only` flags — every invocation
7
+ * generates HTML for **all 14 supported languages**. The `langs` and
8
+ * `markdownOnly` fields are preserved on the parsed options solely so the
9
+ * programmatic `generateArticle()` API (driven directly from unit / integration
10
+ * tests for speed) can still scope a single test run to a subset of
11
+ * languages or skip HTML emission. They are **not** settable via argv.
12
+ */
3
13
  export interface ParsedOptions {
4
14
  readonly kind: 'options';
5
15
  readonly value: {
6
16
  readonly runDir: string | null;
7
17
  readonly all: boolean;
8
18
  readonly since?: string;
19
+ /**
20
+ * Always `[...ALL_LANGUAGES]` when produced by this CLI parser; tests
21
+ * may override the field when calling {@link generateArticle} directly.
22
+ */
9
23
  readonly langs: readonly LanguageCode[];
10
24
  readonly outDir: string;
11
25
  readonly repoRoot: string;
12
26
  readonly title?: string;
13
27
  readonly description?: string;
28
+ /**
29
+ * Always `false` when produced by this CLI parser; tests may set
30
+ * `true` programmatically to skip HTML emission for speed.
31
+ */
14
32
  readonly markdownOnly: boolean;
15
33
  };
16
34
  }