euparliamentmonitor 0.9.5 → 0.9.7

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.
Files changed (102) hide show
  1. package/README.md +2 -2
  2. package/package.json +17 -11
  3. package/scripts/aggregator/analysis-aggregator.d.ts +1 -1
  4. package/scripts/aggregator/analysis-aggregator.js +49 -42
  5. package/scripts/aggregator/article-generator.d.ts +14 -1
  6. package/scripts/aggregator/article-generator.js +27 -72
  7. package/scripts/aggregator/article-html.d.ts +3 -1
  8. package/scripts/aggregator/article-html.js +6 -71
  9. package/scripts/aggregator/article-meta.d.ts +6 -6
  10. package/scripts/aggregator/article-meta.js +7 -14
  11. package/scripts/aggregator/article-metadata.d.ts +21 -7
  12. package/scripts/aggregator/article-metadata.js +352 -146
  13. package/scripts/aggregator/artifact-order.js +28 -5
  14. package/scripts/aggregator/clean-artifact.js +0 -13
  15. package/scripts/aggregator/cli/parse.d.ts +1 -1
  16. package/scripts/aggregator/cli/parse.js +0 -16
  17. package/scripts/aggregator/content/types.d.ts +1 -1
  18. package/scripts/aggregator/key-takeaways.d.ts +1 -1
  19. package/scripts/aggregator/key-takeaways.js +2 -2
  20. package/scripts/aggregator/lead-extractor.js +1 -6
  21. package/scripts/aggregator/manifest/manifest-writer.d.ts +1 -2
  22. package/scripts/aggregator/manifest/manifest-writer.js +1 -5
  23. package/scripts/aggregator/manifest/resolver.d.ts +7 -5
  24. package/scripts/aggregator/manifest/resolver.js +6 -2
  25. package/scripts/aggregator/manifest/types.d.ts +10 -5
  26. package/scripts/aggregator/markdown-renderer.d.ts +40 -2
  27. package/scripts/aggregator/markdown-renderer.js +162 -1
  28. package/scripts/aggregator/metadata/types.d.ts +1 -1
  29. package/scripts/aggregator/reader-guide-constants.js +13 -1
  30. package/scripts/aggregator/reader-intelligence-guide.d.ts +1 -1
  31. package/scripts/aggregator/reader-intelligence-guide.js +106 -2
  32. package/scripts/aggregator/runs/discover.d.ts +1 -1
  33. package/scripts/aggregator/runs/discover.js +1 -1
  34. package/scripts/constants/build-info-meta.js +0 -2
  35. package/scripts/constants/committee-indicator-map.d.ts +1 -1
  36. package/scripts/constants/committee-indicator-map.js +1 -1
  37. package/scripts/constants/config.d.ts +1 -1
  38. package/scripts/constants/config.js +1 -7
  39. package/scripts/constants/language-articles.d.ts +12 -0
  40. package/scripts/constants/language-articles.js +12 -0
  41. package/scripts/generators/news-indexes.d.ts +12 -0
  42. package/scripts/generators/news-indexes.js +254 -13
  43. package/scripts/generators/political-intelligence/copy.d.ts +5 -0
  44. package/scripts/generators/political-intelligence/copy.js +5 -0
  45. package/scripts/generators/political-intelligence/data.js +0 -6
  46. package/scripts/generators/political-intelligence/html.d.ts +1 -1
  47. package/scripts/generators/political-intelligence/html.js +1 -24
  48. package/scripts/generators/political-intelligence/index.d.ts +6 -6
  49. package/scripts/generators/political-intelligence/markdown.js +1 -1
  50. package/scripts/generators/political-intelligence-descriptions.d.ts +1 -1
  51. package/scripts/generators/political-intelligence-descriptions.js +0 -35
  52. package/scripts/generators/seo-copy.d.ts +8 -0
  53. package/scripts/generators/shared/html-escape.js +0 -1
  54. package/scripts/generators/shared/template-helpers.js +0 -1
  55. package/scripts/generators/shared/types.d.ts +3 -3
  56. package/scripts/generators/sitemap/html.js +0 -8
  57. package/scripts/generators/sitemap/index.d.ts +5 -5
  58. package/scripts/generators/sitemap/index.js +5 -5
  59. package/scripts/generators/sitemap/xml.js +0 -2
  60. package/scripts/generators/sitemap.js +1 -12
  61. package/scripts/mcp/ep-mcp-client.d.ts +16 -9
  62. package/scripts/mcp/ep-mcp-client.js +18 -61
  63. package/scripts/mcp/ep-open-data-client.d.ts +11 -1
  64. package/scripts/mcp/ep-open-data-client.js +12 -12
  65. package/scripts/mcp/fetch-proxy-server.d.ts +14 -0
  66. package/scripts/mcp/fetch-proxy-server.js +14 -9
  67. package/scripts/mcp/html-lang-patcher.js +0 -10
  68. package/scripts/mcp/imf-mcp-client.d.ts +22 -10
  69. package/scripts/mcp/imf-mcp-client.js +19 -57
  70. package/scripts/mcp/mcp-config-reader.d.ts +1 -2
  71. package/scripts/mcp/mcp-config-reader.js +0 -4
  72. package/scripts/mcp/mcp-connection.d.ts +23 -0
  73. package/scripts/mcp/mcp-connection.js +24 -32
  74. package/scripts/mcp/mcp-retry.d.ts +7 -0
  75. package/scripts/mcp/mcp-retry.js +7 -2
  76. package/scripts/mcp/pending-documents.js +0 -4
  77. package/scripts/mcp/procedure-seen-cache.d.ts +7 -0
  78. package/scripts/mcp/procedure-seen-cache.js +1 -1
  79. package/scripts/mcp/wb-mcp-client.d.ts +9 -0
  80. package/scripts/mcp/wb-mcp-client.js +9 -0
  81. package/scripts/minify-assets.js +238 -0
  82. package/scripts/optimize-css.js +79 -0
  83. package/scripts/templates/icons.d.ts +3 -0
  84. package/scripts/templates/section-builders.d.ts +2 -2
  85. package/scripts/templates/section-builders.js +2 -20
  86. package/scripts/types/imf.d.ts +1 -1
  87. package/scripts/types/mcp.d.ts +2 -2
  88. package/scripts/types/quality.d.ts +1 -1
  89. package/scripts/utils/content-metadata.d.ts +2 -2
  90. package/scripts/utils/content-metadata.js +1 -19
  91. package/scripts/utils/copy-test-reports.js +0 -7
  92. package/scripts/utils/file-utils.js +0 -31
  93. package/scripts/utils/html-sanitize.d.ts +9 -0
  94. package/scripts/utils/html-sanitize.js +9 -8
  95. package/scripts/utils/intelligence-index.d.ts +1 -1
  96. package/scripts/utils/intelligence-index.js +1 -10
  97. package/scripts/utils/metadata-utils.d.ts +1 -1
  98. package/scripts/utils/metadata-utils.js +1 -1
  99. package/scripts/utils/news-metadata.js +0 -10
  100. package/scripts/workflows/completeness-gate/validators.js +1 -11
  101. package/scripts/workflows/infrastructure/shell-safety.js +0 -1
  102. package/scripts/workflows/types.d.ts +1 -1
package/README.md CHANGED
@@ -136,7 +136,7 @@ The published site is the audience-facing companion to this npm/TypeScript packa
136
136
 
137
137
  **MCP Server Integration**: The project uses the
138
138
  [European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server)
139
- v1.3.2 for accessing real EU Parliament data via the Model Context Protocol.
139
+ v1.3.3 for accessing real EU Parliament data via the Model Context Protocol.
140
140
 
141
141
  - **MCP Server Status**: ✅ Fully operational — 60+ EP data tools available
142
142
  (feeds, direct lookups, analytical tools, intelligence correlation)
@@ -432,7 +432,7 @@ import type { ArticleCategory, LanguageCode } from 'euparliamentmonitor/types';
432
432
 
433
433
  ## 🔌 Data Sources
434
434
 
435
- **Primary — European Parliament MCP Server** ([Hack23/European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server) v1.3.2+, fully operational):
435
+ **Primary — European Parliament MCP Server** ([Hack23/European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server) v1.3.3+, fully operational):
436
436
 
437
437
  - 🗳️ Plenary sessions, voting records, roll-call votes
438
438
  - 📜 Adopted texts, motions, resolutions, urgency files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -70,6 +70,8 @@
70
70
  "generate-article:all": "node scripts/aggregator/article-generator.js --all",
71
71
  "generate-news-indexes": "node scripts/generators/news-indexes.js",
72
72
  "generate-sitemap": "node scripts/generators/sitemap.js",
73
+ "optimize-css": "node scripts/optimize-css.js",
74
+ "minify-assets": "node scripts/minify-assets.js",
73
75
  "validate-ep-api": "npx tsx src/utils/validate-ep-api.ts",
74
76
  "lint:prompts": "node scripts/lint-prompts.js",
75
77
  "htmlhint": "sh -c 'htmlhint *.html; set -- news/*.html; if [ -e \"$1\" ]; then htmlhint \"$@\"; else echo \"No news/*.html files to lint\"; fi'",
@@ -143,17 +145,18 @@
143
145
  "devDependencies": {
144
146
  "@axe-core/playwright": "4.11.3",
145
147
  "@eslint/js": "10.0.1",
146
- "@playwright/test": "1.59.1",
148
+ "@playwright/test": "1.60.0",
147
149
  "@types/d3": "7.4.3",
148
150
  "@types/markdown-it": "^14.1.2",
149
- "@types/node": "25.6.2",
151
+ "@types/node": "25.7.0",
150
152
  "@types/papaparse": "5.5.2",
151
- "@typescript-eslint/eslint-plugin": "8.59.2",
152
- "@typescript-eslint/parser": "8.59.2",
153
- "@vitest/coverage-v8": "4.1.5",
154
- "@vitest/ui": "4.1.5",
153
+ "@typescript-eslint/eslint-plugin": "8.59.3",
154
+ "@typescript-eslint/parser": "8.59.3",
155
+ "@vitest/coverage-v8": "4.1.6",
156
+ "@vitest/ui": "4.1.6",
155
157
  "chart.js": "4.5.1",
156
158
  "chartjs-plugin-annotation": "3.1.0",
159
+ "clean-css": "^5.3.3",
157
160
  "d3": "7.9.0",
158
161
  "eslint": "10.3.0",
159
162
  "eslint-config-prettier": "10.1.8",
@@ -161,25 +164,28 @@
161
164
  "eslint-plugin-security": "4.0.0",
162
165
  "eslint-plugin-sonarjs": "4.0.3",
163
166
  "happy-dom": "20.9.0",
167
+ "html-minifier-terser": "^7.2.0",
164
168
  "htmlhint": "1.9.2",
165
169
  "husky": "9.1.7",
166
- "jscpd": "4.1.0",
170
+ "jscpd": "4.1.1",
167
171
  "knip": "^6.7.0",
168
172
  "lint-staged": "17.0.4",
169
- "mermaid": "11.14.0",
173
+ "mermaid": "11.15.0",
170
174
  "papaparse": "5.5.3",
171
175
  "prettier": "3.8.3",
176
+ "purgecss": "7.0.2",
177
+ "terser": "^5.47.1",
172
178
  "ts-api-utils": "2.5.0",
173
179
  "tsx": "4.21.0",
174
180
  "typedoc": "0.28.19",
175
181
  "typescript": "6.0.3",
176
- "vitest": "4.1.5"
182
+ "vitest": "4.1.6"
177
183
  },
178
184
  "engines": {
179
185
  "node": ">=26"
180
186
  },
181
187
  "dependencies": {
182
- "european-parliament-mcp-server": "1.3.2",
188
+ "european-parliament-mcp-server": "1.3.3",
183
189
  "markdown-it": "^14.1.1",
184
190
  "markdown-it-anchor": "^9.2.0",
185
191
  "markdown-it-attrs": "^4.3.1",
@@ -154,7 +154,7 @@ export declare function renderReaderIntelligenceGuide(sections: readonly TocSect
154
154
  *
155
155
  * Thin re-export of {@link _resolveArticleType} from
156
156
  * `aggregator/manifest/index.js`. Resolution order: `articleType` →
157
- * `articleTypes[0]` → `runType` → `'unknown'`.
157
+ * `articleTypeSlug` → `articleTypes[0]` → `runType` → `'unknown'`.
158
158
  *
159
159
  * @param manifest - Parsed manifest (any of the supported schemas)
160
160
  * @returns Article-type slug usable as a filename component
@@ -72,9 +72,6 @@ export function expandSectionArtifacts(section, available, consumed) {
72
72
  else if (available.has(entry) && !consumed.has(entry)) {
73
73
  out.push(entry);
74
74
  consumed.add(entry);
75
- // `executive-brief.md` is the canonical Riksdagsmonitor-aligned path;
76
- // `extended/executive-brief.md` remains a compatibility fallback. When
77
- // both exist, render only the canonical root file.
78
75
  if (section.id === 'executive-brief')
79
76
  break;
80
77
  }
@@ -140,8 +137,6 @@ function collectRunArtifacts(runDir) {
140
137
  const full = path.join(dir, entry.name);
141
138
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
142
139
  if (entry.isDirectory()) {
143
- // Skip raw payloads, prior-run snapshots, and Pass-1 work-in-progress
144
- // snapshots so they are not rendered as supplementary artifacts.
145
140
  if (entry.name === 'data' || entry.name === 'runs' || entry.name === 'pass1')
146
141
  continue;
147
142
  walk(full, rel);
@@ -213,12 +208,6 @@ export function renderTradecraftAppendix(files) {
213
208
  '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
209
  '',
215
210
  ];
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
211
  if (templates.length > 0) {
223
212
  block.push('### Artifact templates');
224
213
  block.push('');
@@ -295,6 +284,10 @@ const READER_GUIDE_EN = {
295
284
  need: 'Significance scoring',
296
285
  value: 'why this story outranks or trails other same-day European Parliament signals',
297
286
  },
287
+ 'section-actors-forces': {
288
+ need: 'Actors and forces',
289
+ value: 'who is driving the story, what political forces line up behind them, and which institutional levers they can pull',
290
+ },
298
291
  'section-coalitions-voting': {
299
292
  need: 'Coalitions and voting',
300
293
  value: 'political group alignment, voting evidence, and coalition pressure points',
@@ -315,6 +308,50 @@ const READER_GUIDE_EN = {
315
308
  need: 'Risk assessment',
316
309
  value: 'policy, institutional, coalition, communications, and implementation risk register',
317
310
  },
311
+ 'section-threat': {
312
+ need: 'Threat landscape',
313
+ value: 'hostile actors, attack vectors, consequence trees, and legislative-disruption pathways',
314
+ },
315
+ 'section-forward-projection': {
316
+ need: 'What to watch',
317
+ value: 'dated trigger events, calendar dependencies, and legislative-pipeline forecasts',
318
+ },
319
+ 'section-electoral-arc': {
320
+ need: 'Electoral arc and mandate',
321
+ value: 'where the story sits in the EP term, mandate fulfilment, seat projection, and presidency-trio context',
322
+ },
323
+ 'section-pestle-context': {
324
+ need: 'PESTLE and structural context',
325
+ value: 'political, economic, social, technological, legal, and environmental forces plus the historical baseline',
326
+ },
327
+ 'section-continuity': {
328
+ need: 'Cross-run continuity',
329
+ value: 'what changed since prior sessions and how confidence shifted between runs',
330
+ },
331
+ 'section-deep-analysis': {
332
+ need: 'Deep analysis',
333
+ value: 'long-form Economist-style explanation for readers who want the full argument',
334
+ },
335
+ 'section-documents': {
336
+ need: 'Document trail',
337
+ value: 'the document index and per-file analysis behind the public judgement',
338
+ },
339
+ 'section-extended-intel': {
340
+ need: 'Extended intelligence',
341
+ value: "devil's-advocate critique, comparative parallels, historical precedents, and media framing",
342
+ },
343
+ 'section-mcp-reliability': {
344
+ need: 'MCP data reliability',
345
+ value: 'which feeds were healthy, which were degraded, and how data limits bound conclusions',
346
+ },
347
+ 'section-quality-reflection': {
348
+ need: 'Analytical quality and reflection',
349
+ value: 'self-assessment scores, methodology audit, structured analytic techniques, and known limitations',
350
+ },
351
+ 'section-supplementary-intelligence': {
352
+ need: 'Supplementary intelligence',
353
+ value: 'additional markdown discovered in the run that has not yet been assigned to a canonical section',
354
+ },
318
355
  };
319
356
  /**
320
357
  * Render the generated reader-intelligence guide that appears before the
@@ -332,7 +369,6 @@ const READER_GUIDE_EN = {
332
369
  export function renderReaderIntelligenceGuide(sections, included) {
333
370
  const rows = sections
334
371
  .map((section) => {
335
- // Guard: only include sections whose IDs are in the canonical list
336
372
  if (!READER_GUIDE_SECTION_IDS.includes(section.id))
337
373
  return '';
338
374
  const copy = Object.getOwnPropertyDescriptor(READER_GUIDE_EN, section.id)?.value;
@@ -386,10 +422,6 @@ function renderArtifactFragment(runDir, runRel, runDirRelPath, seenMermaid, sect
386
422
  });
387
423
  const stem = runRel.split('/').pop()?.replace(/\.md$/i, '') ?? runRel;
388
424
  const headerLines = suppressHeader ? [] : ['', `### ${humanize(stem)}`];
389
- // Per-section "View source" links are intentionally omitted — references
390
- // are consolidated in the end-of-document Analysis Index appendix so the
391
- // body reads as a journalistic / political-intelligence narrative rather
392
- // than as a per-paragraph artifact dump.
393
425
  const lines = [...headerLines, '', cleaned.markdown];
394
426
  const included = {
395
427
  runRelPath: runRel,
@@ -467,9 +499,6 @@ function appendSection(runDir, runDirRelPath, sectionId, sectionTitle, paths, se
467
499
  fragments.push(...fragment.lines);
468
500
  included.push(fragment.included);
469
501
  }
470
- // Only emit the section H2 + body when at least one artifact was rendered;
471
- // an empty heading with no content is a workflow-metadata leak (used to
472
- // happen for the Supplementary bucket when leftovers were missing on disk).
473
502
  if (fragments.length === 0)
474
503
  return;
475
504
  sectionMarkdown.push(`<h2 id="${emittedId}">${sectionTitle}</h2>`);
@@ -482,7 +511,7 @@ function appendSection(runDir, runDirRelPath, sectionId, sectionTitle, paths, se
482
511
  *
483
512
  * Thin re-export of {@link _resolveArticleType} from
484
513
  * `aggregator/manifest/index.js`. Resolution order: `articleType` →
485
- * `articleTypes[0]` → `runType` → `'unknown'`.
514
+ * `articleTypeSlug` → `articleTypes[0]` → `runType` → `'unknown'`.
486
515
  *
487
516
  * @param manifest - Parsed manifest (any of the supported schemas)
488
517
  * @returns Article-type slug usable as a filename component
@@ -513,11 +542,6 @@ export function aggregateAnalysisRun(options) {
513
542
  }
514
543
  const manifestFiles = flattenManifestFiles(manifest.files);
515
544
  const discovered = collectRunArtifacts(runDir);
516
- // Merge manifest-declared files with discovered files; manifest gives order
517
- // priority, discovery ensures nothing is missed. Filter to renderable
518
- // markdown only and exclude raw payload directories (`data/`, `runs/`,
519
- // `pass1/`) — these are workflow-internal and would leak into the
520
- // Supplementary bucket as JSON dumps if the manifest declared them.
521
545
  const availableSet = new Set([...manifestFiles, ...discovered].filter((p) => p.endsWith('.md') &&
522
546
  !p.startsWith('data/') &&
523
547
  !p.startsWith('runs/') &&
@@ -529,10 +553,6 @@ export function aggregateAnalysisRun(options) {
529
553
  const sectionMarkdown = [];
530
554
  const seenMermaid = new Set();
531
555
  const runDirRelPath = path.relative(repoRoot, runDir).split(path.sep).join('/');
532
- // Render the Executive Brief section first into a dedicated buffer so it
533
- // can be placed BEFORE the Reader Intelligence Guide — analysts and
534
- // journalists need the BLUF up front; the TOC-style guide then orients
535
- // the reader for the deeper sections that follow.
536
556
  const execBriefMarkdown = [];
537
557
  const [execBriefSection, ...remainingSections] = ARTIFACT_SECTIONS;
538
558
  if (execBriefSection) {
@@ -543,7 +563,6 @@ export function aggregateAnalysisRun(options) {
543
563
  const paths = expandSectionArtifacts(section, new Set(available), consumed);
544
564
  appendSection(runDir, runDirRelPath, section.id, section.title, paths, seenMermaid, sectionMarkdown, includedArtifacts, emittedSections);
545
565
  }
546
- // Supplementary bucket: anything that didn't match a declared section
547
566
  const leftovers = available.filter((p) => !consumed.has(p));
548
567
  if (leftovers.length > 0) {
549
568
  appendSection(runDir, runDirRelPath, SUPPLEMENTARY_SECTION_ID, SUPPLEMENTARY_SECTION_TITLE, leftovers, seenMermaid, sectionMarkdown, includedArtifacts, emittedSections);
@@ -568,19 +587,7 @@ export function aggregateAnalysisRun(options) {
568
587
  const tradecraft = renderTradecraftAppendix(tradecraftFiles);
569
588
  const analysisIndex = renderAnalysisIndex(includedArtifacts, manifestRelPath);
570
589
  const readerGuide = renderReaderIntelligenceGuide(emittedSections, includedArtifacts);
571
- // Deterministic 3–7 bullet "Key takeaways" block, harvested from the
572
- // synthesis-summary / intelligence-assessment artifacts. Sits between
573
- // the Reader Intelligence Guide and the deep sections: the reader gets
574
- // the BLUF (Executive Brief) → a navigation map (Reader Guide) → a
575
- // bullet digest of the strongest findings (Key Takeaways) → the deep
576
- // analysis. This is the order requested for reader UX so navigation
577
- // is established before the reader commits to scanning takeaways.
578
590
  const keyTakeaways = buildKeyTakeaways({ runDir });
579
- // TOC ordering must match the rendered Markdown body 1:1. Order:
580
- // Executive Brief (already first in emittedSections via appendSection) →
581
- // Reader Intelligence Guide (inserted right after the brief when present) →
582
- // Key Takeaways (inserted right after the guide when present) →
583
- // remaining sections → audit appendices.
584
591
  let postBriefIdx = emittedSections.length > 0 &&
585
592
  emittedSections[0]?.id === namespacedSectionId(execBriefSection?.id ?? '')
586
593
  ? 1
@@ -42,7 +42,7 @@ export interface CliOptions {
42
42
  */
43
43
  readonly markdownOnly: boolean;
44
44
  }
45
- /** Result summary returned by {@link generateArticle}. */
45
+ /** Result summary returned by `generateArticle`. */
46
46
  export interface GenerateResult {
47
47
  /** Repo-relative path of the English source Markdown that was written. */
48
48
  readonly sourceMarkdownRelPath: string;
@@ -63,6 +63,19 @@ export interface GenerateResult {
63
63
  /** Metadata from {@link aggregateAnalysisRun}. */
64
64
  readonly aggregated: AggregatedRun;
65
65
  }
66
+ /**
67
+ * Parse the article-generator CLI argv into a {@link CliOptions} struct.
68
+ *
69
+ * Recognises `--run-dir <dir>` (or `--run-dir=<dir>`), `--all`, `--out-dir <dir>`,
70
+ * `--title <s>`, `--description <s>`, `--help`/`-h`, and rejects the historical
71
+ * `--lang` / `--language` / `--markdown-only` flags now that the CLI always
72
+ * renders all 14 languages with HTML output.
73
+ *
74
+ * @param argv - Argument vector excluding `node` and the script path.
75
+ * @param repoRoot - Absolute path to the repository root, used to default `outDir`.
76
+ * @returns Parsed CLI options ready to feed into `generateArticle`.
77
+ * @throws {Error} On unknown flags, missing values, or removed flags.
78
+ */
66
79
  export declare function parseCliArgs(argv: readonly string[], repoRoot: string): CliOptions;
67
80
  /**
68
81
  * Build the article slug `YYYY-MM-DD-<article-type>[-<runSuffix>]`.
@@ -63,13 +63,24 @@ function applyFlagResult(acc, result) {
63
63
  acc.description = result.value;
64
64
  return;
65
65
  default: {
66
- // Exhaustiveness guard — if a new FlagResult kind is added without a
67
- // matching case the compiler will surface the gap.
68
66
  const exhaustive = result;
69
67
  throw new Error(`Unhandled flag result: ${JSON.stringify(exhaustive)}`);
70
68
  }
71
69
  }
72
70
  }
71
+ /**
72
+ * Parse the article-generator CLI argv into a {@link CliOptions} struct.
73
+ *
74
+ * Recognises `--run-dir <dir>` (or `--run-dir=<dir>`), `--all`, `--out-dir <dir>`,
75
+ * `--title <s>`, `--description <s>`, `--help`/`-h`, and rejects the historical
76
+ * `--lang` / `--language` / `--markdown-only` flags now that the CLI always
77
+ * renders all 14 languages with HTML output.
78
+ *
79
+ * @param argv - Argument vector excluding `node` and the script path.
80
+ * @param repoRoot - Absolute path to the repository root, used to default `outDir`.
81
+ * @returns Parsed CLI options ready to feed into `generateArticle`.
82
+ * @throws {Error} On unknown flags, missing values, or removed flags.
83
+ */
73
84
  export function parseCliArgs(argv, repoRoot) {
74
85
  const acc = {
75
86
  runDir: null,
@@ -102,13 +113,9 @@ export function parseCliArgs(argv, repoRoot) {
102
113
  const opts = {
103
114
  runDir: acc.runDir,
104
115
  all: acc.all,
105
- // Always render every language — the `--lang/--language` flags have
106
- // been removed in the always-14-languages contract.
107
116
  langs: [...ALL_LANGUAGES],
108
117
  outDir: acc.outDir,
109
118
  repoRoot,
110
- // Always emit HTML — the `--markdown-only` flag has been removed in
111
- // the always-HTML contract.
112
119
  markdownOnly: false,
113
120
  ...(acc.since !== undefined ? { since: acc.since } : {}),
114
121
  ...(acc.title !== undefined ? { title: acc.title } : {}),
@@ -153,9 +160,6 @@ function applyCliFlag(flag, takeValue) {
153
160
  case '--lang':
154
161
  case '--language':
155
162
  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
163
  throw new Error(`Flag ${flag} has been removed. The CLI always renders all 14 languages with HTML output. ` +
160
164
  `See Article-Generation.md § "CLI contract" for the new always-on contract.`);
161
165
  default:
@@ -265,8 +269,6 @@ const FALLBACK_DESCRIPTION = 'EU Parliament intelligence summary derived from co
265
269
  * @returns Plain-text description, truncated to ≤300 characters
266
270
  */
267
271
  export function extractDefaultDescription(markdown) {
268
- // Suppress unused warning: keep `shouldSkipDescriptionLine` for any
269
- // historic consumer importing it transitively.
270
272
  void shouldSkipDescriptionLine;
271
273
  const strong = extractStrongProseLine(markdown);
272
274
  return strong.length > 0 ? strong : FALLBACK_DESCRIPTION;
@@ -290,15 +292,20 @@ function yamlEscape(value) {
290
292
  * @param metadata - English metadata resolved for SEO
291
293
  * @param metadata.title - Resolved English article title
292
294
  * @param metadata.description - Resolved English article description
295
+ * @param metadata.keywords - Resolved English SEO keywords
293
296
  * @param slug - Article slug used by generated news paths
294
297
  * @param sourceFolder - Repo-relative analysis run directory
295
298
  * @returns Markdown with YAML front matter followed by the aggregate body
296
299
  */
297
300
  function buildJekyllArticleMarkdown(aggregated, metadata, slug, sourceFolder) {
301
+ const keywords = metadata.keywords?.length
302
+ ? `keywords: [${metadata.keywords.map((keyword) => `"${yamlEscape(keyword)}"`).join(', ')}]`
303
+ : 'keywords: []';
298
304
  const frontMatter = [
299
305
  '---',
300
306
  `title: "${yamlEscape(metadata.title)}"`,
301
307
  `description: "${yamlEscape(metadata.description)}"`,
308
+ keywords,
302
309
  `date: ${aggregated.date}`,
303
310
  `article_type: ${aggregated.articleType}`,
304
311
  `slug: ${slug}`,
@@ -314,7 +321,7 @@ function buildJekyllArticleMarkdown(aggregated, metadata, slug, sourceFolder) {
314
321
  /**
315
322
  * Render a single language-variant article. Pulls from a pre-translated
316
323
  * `<slug>.<lang>.md` file when it exists, otherwise renders the English
317
- * aggregate. Extracted from {@link generateArticle} so the outer function
324
+ * aggregate. Extracted from `generateArticle` so the outer function
318
325
  * stays under the cognitive-complexity budget.
319
326
  *
320
327
  * @param lang - Target language code
@@ -323,7 +330,7 @@ function buildJekyllArticleMarkdown(aggregated, metadata, slug, sourceFolder) {
323
330
  * @param englishHtml - Pre-rendered HTML of the English aggregate
324
331
  * @param chromeOptions - Shared chrome options
325
332
  * @param chromeOptions.metadata - Per-language `{title, description}` map
326
- * resolved by {@link resolveArticleMetadata}
333
+ * resolved by `resolveArticleMetadata`
327
334
  * @param chromeOptions.sourceMarkdownRelPath - Repo-relative path of the
328
335
  * canonical English Markdown source written by the same run
329
336
  * @param chromeOptions.articleCount - Total article count surfaced in the
@@ -340,41 +347,15 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
340
347
  metaSource = fs.readFileSync(langMdAbs, 'utf8');
341
348
  bodyHtml = renderMarkdown(metaSource).html;
342
349
  }
343
- // Strip any AI-authored inline Reader Intelligence Guide and inject the
344
- // renderer-owned, language-aware version so exactly one guide appears.
345
350
  bodyHtml = stripInlineReaderGuide(bodyHtml);
346
- // The article chrome (wrapArticleHtml) renders its own <h1> in the hero
347
- // header. Strip the in-body <h1> emitted from the Markdown `# Title` to
348
- // avoid a duplicate H1 and broken heading hierarchy (H2 preceding H1).
349
351
  bodyHtml = bodyHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, '');
350
352
  const guideHtml = buildReaderIntelligenceGuideHtml(lang, aggregated.sectionToc, aggregated.includedArtifacts);
351
353
  if (guideHtml) {
352
- // Insert the guide IMMEDIATELY AFTER the Executive Brief section so
353
- // the rendered HTML body order matches the documented article
354
- // skeleton (Article-Generation.md §"Article skeleton"):
355
- // 1. Executive Brief
356
- // 2. Reader Intelligence Guide
357
- // 3. Key Takeaways
358
- // 4. … deep sections
359
- // We splice at the start of the next H2 after the Executive Brief
360
- // anchor; when the brief is missing (sparse runs) we fall back to
361
- // prepending so the guide still appears at the top of the body.
362
354
  bodyHtml = insertReaderGuideAfterExecutiveBrief(bodyHtml, guideHtml);
363
355
  }
364
- // Localize Tradecraft References, Analysis Index, and other appendix
365
- // section headings and content into the target language.
366
356
  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
357
  bodyHtml = enhanceTradecraftCards(bodyHtml, lang);
372
358
  bodyHtml = enhanceAnalysisIndexCards(bodyHtml, lang);
373
- // When a per-language translated source exists, prefer a summary derived
374
- // from it so the `<meta description>` matches the visible prose. The
375
- // editorial title still comes from the English resolver (per-language
376
- // translations of the headline are a future enhancement tracked as
377
- // out-of-scope).
378
359
  const entry = getMetadataEntry(chromeOptions.metadata, lang);
379
360
  const perLangDescription = lang !== 'en' && metaSource !== aggregated.markdown
380
361
  ? extractStrongProseLine(metaSource) || entry.description
@@ -385,6 +366,7 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
385
366
  body: bodyHtml,
386
367
  title: entry.title,
387
368
  description: perLangDescription,
369
+ keywords: entry.keywords,
388
370
  date: aggregated.date,
389
371
  articleType: aggregated.articleType,
390
372
  sourceMarkdownRelPath: chromeOptions.sourceMarkdownRelPath,
@@ -419,16 +401,11 @@ export function insertReaderGuideAfterExecutiveBrief(bodyHtml, guideHtml) {
419
401
  if (briefIdx === -1) {
420
402
  return guideHtml + '\n' + bodyHtml;
421
403
  }
422
- // Skip the Executive Brief opening tag itself, then walk forward to the
423
- // next H2 — that's where the next section starts and where we want to
424
- // splice the guide. `<h2 ` matches a tag with attributes; `<h2>` matches
425
- // a bare tag (defensive).
426
404
  const afterBrief = briefIdx + execBriefAnchor.length;
427
405
  const nextH2Tagged = bodyHtml.indexOf('<h2 ', afterBrief);
428
406
  const nextH2Bare = bodyHtml.indexOf('<h2>', afterBrief);
429
407
  const nextH2 = pickEarliestIndex(nextH2Tagged, nextH2Bare);
430
408
  if (nextH2 === -1) {
431
- // Executive Brief is the only section — append the guide at the end.
432
409
  return bodyHtml + '\n' + guideHtml;
433
410
  }
434
411
  return bodyHtml.slice(0, nextH2) + guideHtml + '\n' + bodyHtml.slice(nextH2);
@@ -459,7 +436,7 @@ function pickEarliestIndex(a, b) {
459
436
  * @param map - Resolved per-language metadata
460
437
  * @param lang - Target language code
461
438
  * @returns The entry for `lang` (always populated by
462
- * {@link resolveArticleMetadata})
439
+ * `resolveArticleMetadata`)
463
440
  */
464
441
  function getMetadataEntry(map, lang) {
465
442
  const descriptor = Object.getOwnPropertyDescriptor(map, lang);
@@ -467,7 +444,7 @@ function getMetadataEntry(map, lang) {
467
444
  return descriptor.value;
468
445
  }
469
446
  const en = Object.getOwnPropertyDescriptor(map, 'en')?.value;
470
- return en ?? { title: '', description: '' };
447
+ return en ?? { title: '', description: '', keywords: [] };
471
448
  }
472
449
  /**
473
450
  * Count the number of articles the site currently publishes, derived
@@ -475,7 +452,7 @@ function getMetadataEntry(map, lang) {
475
452
  * set that `npm run generate-article:all` would materialise. Using the
476
453
  * analysis-run catalogue (rather than the `<outDir>` filesystem) keeps
477
454
  * the derived count stable across repeated invocations of
478
- * {@link generateArticle}, preserving determinism for reproducible-build
455
+ * `generateArticle`, preserving determinism for reproducible-build
479
456
  * tests and preventing the footer from drifting as a batch run
480
457
  * progresses.
481
458
  *
@@ -513,11 +490,6 @@ export function generateArticle(opts, runSuffix, articleCountOverride) {
513
490
  repoRoot: opts.repoRoot,
514
491
  });
515
492
  const slug = buildArticleSlug(aggregated.date, aggregated.articleType, runSuffix);
516
- // Resolve per-language {title, description} from the real article
517
- // content (manifest override → artefact H1 → aggregated H1 → strong
518
- // prose → localized template). This replaces the previous
519
- // `defaultTitle()` + `extractDefaultDescription()` approach which
520
- // produced boring, repeated metadata.
521
493
  const manifestMetadata = readManifestMetadata(opts.runDir);
522
494
  const resolvedMetadata = resolveArticleMetadata({
523
495
  articleType: aggregated.articleType,
@@ -526,28 +498,17 @@ export function generateArticle(opts, runSuffix, articleCountOverride) {
526
498
  manifest: manifestMetadata,
527
499
  runDir: opts.runDir,
528
500
  });
529
- // CLI `--title` / `--description` overrides still win over everything
530
- // (used by ad-hoc curator runs and by the existing test suite).
531
501
  const effectiveMetadata = opts.title || opts.description
532
502
  ? applyCliOverrides(resolvedMetadata, opts.title, opts.description)
533
503
  : resolvedMetadata;
534
504
  const runDirRelPath = path.relative(opts.repoRoot, opts.runDir).split(path.sep).join('/');
535
505
  const sourceMarkdown = buildJekyllArticleMarkdown(aggregated, getMetadataEntry(effectiveMetadata, 'en'), slug, runDirRelPath);
536
- // Write article.md INTO the analysis run directory — canonical Markdown
537
- // source that lives alongside the artifacts that produced it.
538
- // This mirrors the riksdagsmonitor pattern where `article.md` is committed
539
- // inside `analysis/daily/<date>/<type>/` so every run has a browsable,
540
- // version-controlled Markdown source in its own directory.
541
506
  const runArticleMdAbs = path.join(opts.runDir, 'article.md');
542
507
  fs.writeFileSync(runArticleMdAbs, sourceMarkdown, 'utf8');
543
508
  const runArticleMdRelPath = path
544
509
  .relative(opts.repoRoot, runArticleMdAbs)
545
510
  .split(path.sep)
546
511
  .join('/');
547
- // Emit `article-meta.json` next to `article.md` — a deterministic
548
- // structured-data sidecar consumed by HTML SEO, news indexes, and RSS.
549
- // Same artifact bytes in → same JSON bytes out (asserted by the
550
- // determinism test).
551
512
  const runArticleMetaAbs = path.join(opts.runDir, 'article-meta.json');
552
513
  const articleMeta = buildArticleMeta({
553
514
  runDir: opts.runDir,
@@ -563,8 +524,6 @@ export function generateArticle(opts, runSuffix, articleCountOverride) {
563
524
  .relative(opts.repoRoot, runArticleMetaAbs)
564
525
  .split(path.sep)
565
526
  .join('/');
566
- // Also write source Markdown under <outDir>/<slug>.en.md for search
567
- // indexing and backwards compatibility with existing news-index scripts.
568
527
  ensureDir(opts.outDir);
569
528
  const sourceMdFilename = `${slug}.en.md`;
570
529
  const sourceMdAbs = path.join(opts.outDir, sourceMdFilename);
@@ -574,8 +533,6 @@ export function generateArticle(opts, runSuffix, articleCountOverride) {
574
533
  const rendered = renderMarkdown(sourceMarkdown);
575
534
  const chromeOptions = {
576
535
  metadata: effectiveMetadata,
577
- // Point the "View source Markdown" link at the canonical run-directory
578
- // article.md so readers can trace the HTML back to the analysis tree.
579
536
  sourceMarkdownRelPath: runArticleMdRelPath,
580
537
  articleCount: articleCountOverride ?? countPublishedArticles(opts.repoRoot),
581
538
  };
@@ -632,9 +589,6 @@ export function generateAllArticles(opts) {
632
589
  const filtered = opts.since ? allRuns.filter((r) => r.date >= opts.since) : allRuns;
633
590
  const groups = groupRunsForCollision(filtered);
634
591
  const results = [];
635
- // Pre-compute the total article count so every footer in the batch
636
- // surfaces a stable number rather than the directory size at the moment
637
- // each run is rendered (which would grow from 0 → N during the batch).
638
592
  const articleCountOverride = filtered.length;
639
593
  for (const run of filtered) {
640
594
  const key = `${run.date}|${run.articleType}`;
@@ -670,7 +624,7 @@ function readManifestRunId(runDir, defaultRunId) {
670
624
  }
671
625
  /**
672
626
  * Read the raw manifest.json from a run directory and return the subset
673
- * of fields consumed by {@link resolveArticleMetadata}. Returns an empty
627
+ * of fields consumed by `resolveArticleMetadata`. Returns an empty
674
628
  * object when the manifest is missing or unreadable so the resolver
675
629
  * simply falls through to the artefact / aggregator tiers.
676
630
  *
@@ -744,6 +698,7 @@ function applyCliOverrides(base, titleOverride, descriptionOverride) {
744
698
  value: {
745
699
  title: titleOverride ?? entry.title,
746
700
  description: descriptionOverride ?? entry.description,
701
+ keywords: entry.keywords,
747
702
  },
748
703
  enumerable: true,
749
704
  writable: true,
@@ -755,7 +710,7 @@ function applyCliOverrides(base, titleOverride, descriptionOverride) {
755
710
  /**
756
711
  * Derive a default article title from the aggregated run metadata.
757
712
  * Preserved as a thin back-compat wrapper — production callers now go
758
- * through {@link resolveArticleMetadata}.
713
+ * through `resolveArticleMetadata`.
759
714
  *
760
715
  * @param run - Aggregated run metadata
761
716
  * @returns Human-readable title like `EU Parliament Breaking — 2026-01-15`
@@ -15,12 +15,14 @@ export interface WrapArticleOptions {
15
15
  * `2026-01-15-breaking`. Used to build the `<link rel="alternate">` set.
16
16
  */
17
17
  readonly articleSlug: string;
18
- /** Pre-rendered HTML body fragment (from {@link renderMarkdown}). */
18
+ /** Pre-rendered HTML body fragment (from `renderMarkdown`). */
19
19
  readonly body: string;
20
20
  /** Article title — shown in `<title>`, breadcrumb, OG/Twitter meta. */
21
21
  readonly title: string;
22
22
  /** Article description — shown in `<meta name="description">` and OG. */
23
23
  readonly description: string;
24
+ /** SEO keywords — shown in `<meta name="keywords">`. */
25
+ readonly keywords?: readonly string[];
24
26
  /** Canonical ISO date of the run (YYYY-MM-DD). */
25
27
  readonly date: string;
26
28
  /** Article type slug (e.g. `breaking`, `motions`). */