euparliamentmonitor 0.9.4 → 0.9.6

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 (98) hide show
  1. package/README.md +2 -2
  2. package/package.json +11 -11
  3. package/scripts/aggregator/analysis-aggregator.d.ts +1 -1
  4. package/scripts/aggregator/analysis-aggregator.js +1 -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 +283 -140
  13. package/scripts/aggregator/clean-artifact.js +0 -13
  14. package/scripts/aggregator/cli/parse.d.ts +1 -1
  15. package/scripts/aggregator/cli/parse.js +0 -16
  16. package/scripts/aggregator/content/types.d.ts +1 -1
  17. package/scripts/aggregator/key-takeaways.d.ts +1 -1
  18. package/scripts/aggregator/key-takeaways.js +2 -2
  19. package/scripts/aggregator/lead-extractor.js +1 -6
  20. package/scripts/aggregator/manifest/manifest-writer.d.ts +1 -2
  21. package/scripts/aggregator/manifest/manifest-writer.js +1 -5
  22. package/scripts/aggregator/manifest/resolver.d.ts +7 -5
  23. package/scripts/aggregator/manifest/resolver.js +6 -2
  24. package/scripts/aggregator/manifest/types.d.ts +10 -5
  25. package/scripts/aggregator/markdown-renderer.d.ts +40 -2
  26. package/scripts/aggregator/markdown-renderer.js +162 -1
  27. package/scripts/aggregator/metadata/types.d.ts +1 -1
  28. package/scripts/aggregator/reader-intelligence-guide.d.ts +1 -1
  29. package/scripts/aggregator/reader-intelligence-guide.js +1 -2
  30. package/scripts/aggregator/runs/discover.d.ts +1 -1
  31. package/scripts/aggregator/runs/discover.js +1 -1
  32. package/scripts/constants/build-info-meta.js +0 -2
  33. package/scripts/constants/committee-indicator-map.d.ts +1 -1
  34. package/scripts/constants/committee-indicator-map.js +1 -1
  35. package/scripts/constants/config.d.ts +1 -1
  36. package/scripts/constants/config.js +1 -7
  37. package/scripts/constants/language-articles.d.ts +12 -0
  38. package/scripts/constants/language-articles.js +12 -0
  39. package/scripts/generators/news-indexes.js +163 -11
  40. package/scripts/generators/political-intelligence/copy.d.ts +5 -0
  41. package/scripts/generators/political-intelligence/copy.js +5 -0
  42. package/scripts/generators/political-intelligence/data.js +0 -6
  43. package/scripts/generators/political-intelligence/html.d.ts +1 -1
  44. package/scripts/generators/political-intelligence/html.js +1 -24
  45. package/scripts/generators/political-intelligence/index.d.ts +6 -6
  46. package/scripts/generators/political-intelligence/markdown.js +1 -1
  47. package/scripts/generators/political-intelligence-descriptions.d.ts +1 -1
  48. package/scripts/generators/political-intelligence-descriptions.js +0 -35
  49. package/scripts/generators/seo-copy.d.ts +8 -0
  50. package/scripts/generators/shared/html-escape.js +0 -1
  51. package/scripts/generators/shared/template-helpers.js +0 -1
  52. package/scripts/generators/shared/types.d.ts +3 -3
  53. package/scripts/generators/sitemap/html.js +0 -8
  54. package/scripts/generators/sitemap/index.d.ts +5 -5
  55. package/scripts/generators/sitemap/index.js +5 -5
  56. package/scripts/generators/sitemap/rss.js +0 -1
  57. package/scripts/generators/sitemap/xml.js +0 -3
  58. package/scripts/generators/sitemap.js +1 -12
  59. package/scripts/mcp/ep-mcp-client.d.ts +16 -9
  60. package/scripts/mcp/ep-mcp-client.js +18 -61
  61. package/scripts/mcp/ep-open-data-client.d.ts +11 -1
  62. package/scripts/mcp/ep-open-data-client.js +12 -12
  63. package/scripts/mcp/fetch-proxy-server.d.ts +14 -0
  64. package/scripts/mcp/fetch-proxy-server.js +14 -9
  65. package/scripts/mcp/html-lang-patcher.js +0 -10
  66. package/scripts/mcp/imf-mcp-client.d.ts +22 -10
  67. package/scripts/mcp/imf-mcp-client.js +19 -57
  68. package/scripts/mcp/mcp-config-reader.d.ts +1 -2
  69. package/scripts/mcp/mcp-config-reader.js +0 -4
  70. package/scripts/mcp/mcp-connection.d.ts +23 -0
  71. package/scripts/mcp/mcp-connection.js +24 -32
  72. package/scripts/mcp/mcp-retry.d.ts +7 -0
  73. package/scripts/mcp/mcp-retry.js +7 -2
  74. package/scripts/mcp/pending-documents.js +0 -4
  75. package/scripts/mcp/procedure-seen-cache.d.ts +7 -0
  76. package/scripts/mcp/procedure-seen-cache.js +1 -1
  77. package/scripts/mcp/wb-mcp-client.d.ts +9 -0
  78. package/scripts/mcp/wb-mcp-client.js +9 -0
  79. package/scripts/templates/icons.d.ts +3 -0
  80. package/scripts/templates/section-builders.d.ts +2 -2
  81. package/scripts/templates/section-builders.js +2 -20
  82. package/scripts/types/imf.d.ts +1 -1
  83. package/scripts/types/mcp.d.ts +2 -2
  84. package/scripts/types/quality.d.ts +1 -1
  85. package/scripts/utils/content-metadata.d.ts +2 -2
  86. package/scripts/utils/content-metadata.js +1 -19
  87. package/scripts/utils/copy-test-reports.js +0 -7
  88. package/scripts/utils/file-utils.js +0 -31
  89. package/scripts/utils/html-sanitize.d.ts +9 -0
  90. package/scripts/utils/html-sanitize.js +9 -8
  91. package/scripts/utils/intelligence-index.d.ts +1 -1
  92. package/scripts/utils/intelligence-index.js +1 -10
  93. package/scripts/utils/metadata-utils.d.ts +1 -1
  94. package/scripts/utils/metadata-utils.js +1 -1
  95. package/scripts/utils/news-metadata.js +0 -10
  96. package/scripts/workflows/completeness-gate/validators.js +1 -11
  97. package/scripts/workflows/infrastructure/shell-safety.js +0 -1
  98. 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.4",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -143,15 +143,15 @@
143
143
  "devDependencies": {
144
144
  "@axe-core/playwright": "4.11.3",
145
145
  "@eslint/js": "10.0.1",
146
- "@playwright/test": "1.59.1",
146
+ "@playwright/test": "1.60.0",
147
147
  "@types/d3": "7.4.3",
148
148
  "@types/markdown-it": "^14.1.2",
149
- "@types/node": "25.6.2",
149
+ "@types/node": "25.7.0",
150
150
  "@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",
151
+ "@typescript-eslint/eslint-plugin": "8.59.3",
152
+ "@typescript-eslint/parser": "8.59.3",
153
+ "@vitest/coverage-v8": "4.1.6",
154
+ "@vitest/ui": "4.1.6",
155
155
  "chart.js": "4.5.1",
156
156
  "chartjs-plugin-annotation": "3.1.0",
157
157
  "d3": "7.9.0",
@@ -163,23 +163,23 @@
163
163
  "happy-dom": "20.9.0",
164
164
  "htmlhint": "1.9.2",
165
165
  "husky": "9.1.7",
166
- "jscpd": "4.1.0",
166
+ "jscpd": "4.1.1",
167
167
  "knip": "^6.7.0",
168
168
  "lint-staged": "17.0.4",
169
- "mermaid": "11.14.0",
169
+ "mermaid": "11.15.0",
170
170
  "papaparse": "5.5.3",
171
171
  "prettier": "3.8.3",
172
172
  "ts-api-utils": "2.5.0",
173
173
  "tsx": "4.21.0",
174
174
  "typedoc": "0.28.19",
175
175
  "typescript": "6.0.3",
176
- "vitest": "4.1.5"
176
+ "vitest": "4.1.6"
177
177
  },
178
178
  "engines": {
179
179
  "node": ">=26"
180
180
  },
181
181
  "dependencies": {
182
- "european-parliament-mcp-server": "1.3.2",
182
+ "european-parliament-mcp-server": "1.3.3",
183
183
  "markdown-it": "^14.1.1",
184
184
  "markdown-it-anchor": "^9.2.0",
185
185
  "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('');
@@ -332,7 +321,6 @@ const READER_GUIDE_EN = {
332
321
  export function renderReaderIntelligenceGuide(sections, included) {
333
322
  const rows = sections
334
323
  .map((section) => {
335
- // Guard: only include sections whose IDs are in the canonical list
336
324
  if (!READER_GUIDE_SECTION_IDS.includes(section.id))
337
325
  return '';
338
326
  const copy = Object.getOwnPropertyDescriptor(READER_GUIDE_EN, section.id)?.value;
@@ -386,10 +374,6 @@ function renderArtifactFragment(runDir, runRel, runDirRelPath, seenMermaid, sect
386
374
  });
387
375
  const stem = runRel.split('/').pop()?.replace(/\.md$/i, '') ?? runRel;
388
376
  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
377
  const lines = [...headerLines, '', cleaned.markdown];
394
378
  const included = {
395
379
  runRelPath: runRel,
@@ -467,9 +451,6 @@ function appendSection(runDir, runDirRelPath, sectionId, sectionTitle, paths, se
467
451
  fragments.push(...fragment.lines);
468
452
  included.push(fragment.included);
469
453
  }
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
454
  if (fragments.length === 0)
474
455
  return;
475
456
  sectionMarkdown.push(`<h2 id="${emittedId}">${sectionTitle}</h2>`);
@@ -482,7 +463,7 @@ function appendSection(runDir, runDirRelPath, sectionId, sectionTitle, paths, se
482
463
  *
483
464
  * Thin re-export of {@link _resolveArticleType} from
484
465
  * `aggregator/manifest/index.js`. Resolution order: `articleType` →
485
- * `articleTypes[0]` → `runType` → `'unknown'`.
466
+ * `articleTypeSlug` → `articleTypes[0]` → `runType` → `'unknown'`.
486
467
  *
487
468
  * @param manifest - Parsed manifest (any of the supported schemas)
488
469
  * @returns Article-type slug usable as a filename component
@@ -513,11 +494,6 @@ export function aggregateAnalysisRun(options) {
513
494
  }
514
495
  const manifestFiles = flattenManifestFiles(manifest.files);
515
496
  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
497
  const availableSet = new Set([...manifestFiles, ...discovered].filter((p) => p.endsWith('.md') &&
522
498
  !p.startsWith('data/') &&
523
499
  !p.startsWith('runs/') &&
@@ -529,10 +505,6 @@ export function aggregateAnalysisRun(options) {
529
505
  const sectionMarkdown = [];
530
506
  const seenMermaid = new Set();
531
507
  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
508
  const execBriefMarkdown = [];
537
509
  const [execBriefSection, ...remainingSections] = ARTIFACT_SECTIONS;
538
510
  if (execBriefSection) {
@@ -543,7 +515,6 @@ export function aggregateAnalysisRun(options) {
543
515
  const paths = expandSectionArtifacts(section, new Set(available), consumed);
544
516
  appendSection(runDir, runDirRelPath, section.id, section.title, paths, seenMermaid, sectionMarkdown, includedArtifacts, emittedSections);
545
517
  }
546
- // Supplementary bucket: anything that didn't match a declared section
547
518
  const leftovers = available.filter((p) => !consumed.has(p));
548
519
  if (leftovers.length > 0) {
549
520
  appendSection(runDir, runDirRelPath, SUPPLEMENTARY_SECTION_ID, SUPPLEMENTARY_SECTION_TITLE, leftovers, seenMermaid, sectionMarkdown, includedArtifacts, emittedSections);
@@ -568,19 +539,7 @@ export function aggregateAnalysisRun(options) {
568
539
  const tradecraft = renderTradecraftAppendix(tradecraftFiles);
569
540
  const analysisIndex = renderAnalysisIndex(includedArtifacts, manifestRelPath);
570
541
  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
542
  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
543
  let postBriefIdx = emittedSections.length > 0 &&
585
544
  emittedSections[0]?.id === namespacedSectionId(execBriefSection?.id ?? '')
586
545
  ? 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`). */