euparliamentmonitor 0.9.1 → 0.9.2

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/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.0 for accessing real EU Parliament data via the Model Context Protocol.
139
+ v1.3.2 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.0+, fully operational):
435
+ **Primary — European Parliament MCP Server** ([Hack23/European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server) v1.3.2+, 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.1",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -146,7 +146,7 @@
146
146
  "@playwright/test": "1.59.1",
147
147
  "@types/d3": "7.4.3",
148
148
  "@types/markdown-it": "^14.1.2",
149
- "@types/node": "25.6.0",
149
+ "@types/node": "25.6.2",
150
150
  "@types/papaparse": "5.5.2",
151
151
  "@typescript-eslint/eslint-plugin": "8.59.2",
152
152
  "@typescript-eslint/parser": "8.59.2",
@@ -165,7 +165,7 @@
165
165
  "husky": "9.1.7",
166
166
  "jscpd": "4.0.9",
167
167
  "knip": "^6.7.0",
168
- "lint-staged": "17.0.2",
168
+ "lint-staged": "17.0.3",
169
169
  "mermaid": "11.14.0",
170
170
  "papaparse": "5.5.3",
171
171
  "prettier": "3.8.3",
@@ -179,7 +179,7 @@
179
179
  "node": ">=26"
180
180
  },
181
181
  "dependencies": {
182
- "european-parliament-mcp-server": "1.3.0",
182
+ "european-parliament-mcp-server": "1.3.2",
183
183
  "markdown-it": "^14.1.1",
184
184
  "markdown-it-anchor": "^9.2.0",
185
185
  "markdown-it-attrs": "^4.3.1",
@@ -100,6 +100,24 @@ export declare function sanitizeRunSuffix(runId: string): string;
100
100
  * @returns Plain-text description, truncated to ≤300 characters
101
101
  */
102
102
  export declare function extractDefaultDescription(markdown: string): string;
103
+ /**
104
+ * Insert the regenerated Reader Intelligence Guide HTML immediately after
105
+ * the Executive Brief section so the rendered article body matches the
106
+ * documented order (Executive Brief → Reader Intelligence Guide → Key
107
+ * Takeaways → deep sections). The Executive Brief section ends where the
108
+ * next H2 begins; we splice at that boundary. When the brief is absent
109
+ * (sparse runs) we fall back to prepending so the guide still appears
110
+ * at the top of the body.
111
+ *
112
+ * Implementation uses `indexOf` rather than a regex so the splice point
113
+ * is deterministic and immune to polynomial-regex backtracking on
114
+ * pathological input.
115
+ *
116
+ * @param bodyHtml - Rendered article body
117
+ * @param guideHtml - Reader Intelligence Guide HTML fragment
118
+ * @returns Body HTML with the guide spliced after the Executive Brief
119
+ */
120
+ export declare function insertReaderGuideAfterExecutiveBrief(bodyHtml: string, guideHtml: string): string;
103
121
  /**
104
122
  * Run the full aggregate → render → write pipeline for one run.
105
123
  *
@@ -349,12 +349,17 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
349
349
  bodyHtml = bodyHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, '');
350
350
  const guideHtml = buildReaderIntelligenceGuideHtml(lang, aggregated.sectionToc, aggregated.includedArtifacts);
351
351
  if (guideHtml) {
352
- // Prepend the guide to the body so it always appears at the top of
353
- // the rendered content, immediately after the chrome header. The
354
- // article chrome in wrapArticleHtml wraps the body in an <article>
355
- // with its own <header>/<h1>, so prepending here is deterministic
356
- // and avoids fragile in-body heading searches.
357
- bodyHtml = guideHtml + '\n' + bodyHtml;
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
+ bodyHtml = insertReaderGuideAfterExecutiveBrief(bodyHtml, guideHtml);
358
363
  }
359
364
  // Localize Tradecraft References, Analysis Index, and other appendix
360
365
  // section headings and content into the target language.
@@ -385,6 +390,60 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
385
390
  fs.writeFileSync(path.join(opts.outDir, filename), html, 'utf8');
386
391
  return filename;
387
392
  }
393
+ /**
394
+ * Insert the regenerated Reader Intelligence Guide HTML immediately after
395
+ * the Executive Brief section so the rendered article body matches the
396
+ * documented order (Executive Brief → Reader Intelligence Guide → Key
397
+ * Takeaways → deep sections). The Executive Brief section ends where the
398
+ * next H2 begins; we splice at that boundary. When the brief is absent
399
+ * (sparse runs) we fall back to prepending so the guide still appears
400
+ * at the top of the body.
401
+ *
402
+ * Implementation uses `indexOf` rather than a regex so the splice point
403
+ * is deterministic and immune to polynomial-regex backtracking on
404
+ * pathological input.
405
+ *
406
+ * @param bodyHtml - Rendered article body
407
+ * @param guideHtml - Reader Intelligence Guide HTML fragment
408
+ * @returns Body HTML with the guide spliced after the Executive Brief
409
+ */
410
+ export function insertReaderGuideAfterExecutiveBrief(bodyHtml, guideHtml) {
411
+ const execBriefAnchor = 'id="section-executive-brief"';
412
+ const briefIdx = bodyHtml.indexOf(execBriefAnchor);
413
+ if (briefIdx === -1) {
414
+ return guideHtml + '\n' + bodyHtml;
415
+ }
416
+ // Skip the Executive Brief opening tag itself, then walk forward to the
417
+ // next H2 — that's where the next section starts and where we want to
418
+ // splice the guide. `<h2 ` matches a tag with attributes; `<h2>` matches
419
+ // a bare tag (defensive).
420
+ const afterBrief = briefIdx + execBriefAnchor.length;
421
+ const nextH2Tagged = bodyHtml.indexOf('<h2 ', afterBrief);
422
+ const nextH2Bare = bodyHtml.indexOf('<h2>', afterBrief);
423
+ const nextH2 = pickEarliestIndex(nextH2Tagged, nextH2Bare);
424
+ if (nextH2 === -1) {
425
+ // Executive Brief is the only section — append the guide at the end.
426
+ return bodyHtml + '\n' + guideHtml;
427
+ }
428
+ return bodyHtml.slice(0, nextH2) + guideHtml + '\n' + bodyHtml.slice(nextH2);
429
+ }
430
+ /**
431
+ * Return the smaller of two `indexOf` results, treating `-1` as "not
432
+ * found" so the caller gets `-1` only when both probes failed. Extracted
433
+ * to keep {@link insertReaderGuideAfterExecutiveBrief} under the
434
+ * useless-assignment lint.
435
+ *
436
+ * @param a - First `indexOf` result
437
+ * @param b - Second `indexOf` result
438
+ * @returns Smaller non-negative index, or `-1` when both are `-1`
439
+ */
440
+ function pickEarliestIndex(a, b) {
441
+ if (a === -1)
442
+ return b;
443
+ if (b === -1)
444
+ return a;
445
+ return Math.min(a, b);
446
+ }
388
447
  /**
389
448
  * Safely look up one language entry in a {@link ResolvedMetadata} map.
390
449
  * The runtime shape is always complete (one entry per language), but the
@@ -20,7 +20,7 @@
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, 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';
23
+ import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, SKIP_LINK_TEXTS, TOC_ARIA_LABELS, ARTICLE_TYPE_LABELS, BACK_TO_NEWS_LABELS, ARTICLE_NAV_LABELS, VIEW_SOURCE_MARKDOWN_LABELS, ARTICLE_TYPE_ICONS, FOOTER_SITEMAP_LABELS, FOOTER_POLITICAL_INTELLIGENCE_LABELS, 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
24
  import { ArticleCategory } from '../types/index.js';
25
25
  import { escapeHTML } from '../utils/file-utils.js';
26
26
  import { buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../templates/section-builders.js';
@@ -28,6 +28,8 @@ import { READER_GUIDE_SECTION_ID } from './reader-guide-constants.js';
28
28
  import { READER_GUIDE_TITLE_LABELS } from './reader-intelligence-guide.js';
29
29
  import { TRADECRAFT_SECTION_ID, MANIFEST_SECTION_ID, SUPPLEMENTARY_SECTION_ID, } from './artifact-order.js';
30
30
  import { KEY_TAKEAWAYS_SECTION_ID } from './key-takeaways.js';
31
+ import { getPoliticalIntelligenceFilename } from '../generators/political-intelligence.js';
32
+ import { getSitemapFilename } from '../generators/sitemap/index.js';
31
33
  /**
32
34
  * Resolve a localized article type label with icon. Falls back to the
33
35
  * humanised slug when a translation isn't available.
@@ -155,10 +157,10 @@ export function buildArticleToc(entries, lang) {
155
157
  })
156
158
  .join('\n');
157
159
  return [
158
- ` <aside class="article-toc-container" aria-label="${label}">`,
160
+ ` <aside class="article-toc-container" aria-labelledby="article-toc-heading">`,
159
161
  ` <details class="article-toc-details" open>`,
160
- ` <summary class="article-toc-summary"><span class="guide-icon" aria-hidden="true">📑</span> ${label}</summary>`,
161
- ` <nav class="article-toc">`,
162
+ ` <summary class="article-toc-summary" id="article-toc-heading"><span class="guide-icon" aria-hidden="true">📑</span> ${label}</summary>`,
163
+ ` <nav class="article-toc" aria-labelledby="article-toc-heading">`,
162
164
  ` <ol class="article-toc-list">`,
163
165
  items,
164
166
  ` </ol>`,
@@ -310,10 +312,17 @@ export function wrapArticleHtml(options) {
310
312
  const hreflangLinks = buildArticleHreflangLinks(options.articleSlug);
311
313
  const langSwitcher = buildLanguageSwitcher(options.articleSlug, safeLang);
312
314
  const sourceMdLabel = getLocalizedString(VIEW_SOURCE_MARKDOWN_LABELS, safeLang);
315
+ const articleNavLabel = getLocalizedString(ARTICLE_NAV_LABELS, safeLang);
316
+ const backToNewsLabel = getLocalizedString(BACK_TO_NEWS_LABELS, safeLang);
317
+ const politicalIntelligenceLabel = getLocalizedString(FOOTER_POLITICAL_INTELLIGENCE_LABELS, safeLang);
318
+ const sitemapLabel = getLocalizedString(FOOTER_SITEMAP_LABELS, safeLang);
319
+ const politicalIntelligenceHref = `../${getPoliticalIntelligenceFilename(safeLang)}`;
320
+ const sitemapHref = `../${getSitemapFilename(safeLang)}`;
313
321
  const sourceMdLink = options.sourceMarkdownRelPath
314
322
  ? `<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>`
315
323
  : '';
316
324
  const tocHtml = buildArticleToc(options.toc ?? [], safeLang);
325
+ const articleMainClass = tocHtml.length > 0 ? 'article-main--with-toc' : 'article-main--no-toc';
317
326
  const jsonLd = {
318
327
  '@context': 'https://schema.org',
319
328
  '@type': 'NewsArticle',
@@ -413,7 +422,9 @@ ${hreflangLinks}
413
422
  <link rel="icon" type="image/png" sizes="16x16" href="../images/favicon-16x16.png">
414
423
  <link rel="apple-touch-icon" sizes="180x180" href="../images/apple-touch-icon.png">
415
424
  <link rel="manifest" href="../site.webmanifest">
416
- <meta name="theme-color" content="#003399">
425
+ <meta name="color-scheme" content="light dark">
426
+ <meta name="theme-color" content="#003399" media="(prefers-color-scheme: light)">
427
+ <meta name="theme-color" content="#0a1a38" media="(prefers-color-scheme: dark)">
417
428
  <link rel="stylesheet" href="../styles.css?v=${BUILD_SHORT}">
418
429
  ${buildHeadFreshnessTags('../')}
419
430
  <script type="application/ld+json">${jsonLdString}</script>
@@ -422,12 +433,18 @@ ${buildHeadFreshnessTags('../')}
422
433
  </head>
423
434
  <body>
424
435
  <a href="#main" class="skip-link">${escapeHTML(skipLinkText)}</a>
436
+ <div class="reading-progress" aria-hidden="true"></div>
425
437
 
426
438
  ${header}
427
439
 
428
440
  ${buildPageBanner('../')}
429
441
 
430
- <main id="main" class="site-main article-main">
442
+ <main id="main" class="site-main article-main ${articleMainClass}">
443
+ <nav class="article-top-nav" aria-label="${escapeHTML(articleNavLabel)}">
444
+ <a class="article-top-nav__link article-top-nav__link--primary" href="${indexHref}">${escapeHTML(backToNewsLabel)}</a>
445
+ <a class="article-top-nav__link" href="${politicalIntelligenceHref}">🧠 ${escapeHTML(politicalIntelligenceLabel)}</a>
446
+ <a class="article-top-nav__link" href="${sitemapHref}">🗺️ ${escapeHTML(sitemapLabel)}</a>
447
+ </nav>
431
448
  ${tocHtml} <article class="article-body" lang="${safeLang}">
432
449
  <header class="article-hero">
433
450
  <p class="article-kicker">${escapeHTML(getLocalizedArticleType(options.articleType, safeLang))}</p>
@@ -59,6 +59,31 @@ export interface ResolveMetadataOptions {
59
59
  * @returns `true` when the line is not prose and should be skipped
60
60
  */
61
61
  export declare function shouldSkipDescriptionLine(line: string): boolean;
62
+ /**
63
+ * Strip inline Markdown decorations so we can use the remaining text as
64
+ * plain-text meta-tag content. Removes link syntax, emphasis, inline code
65
+ * backticks, and HTML-entity fragments that the Markdown source sometimes
66
+ * smuggles in. Keeps the visible text readable.
67
+ *
68
+ * @param raw - Trimmed Markdown line
69
+ * @returns Plain-text variant
70
+ */
71
+ /**
72
+ * Strip a leading all-caps prose label (e.g. `SITUATION:`, `KEY MOTION:`,
73
+ * `BLUF:`, `BOTTOM LINE:`, `TIER-1:`) from a prose line. These labels
74
+ * are common in BLUF-style editorial writing — they survive
75
+ * {@link stripInlineMarkdown} (which strips the `**bold**` wrapper but
76
+ * keeps the literal text) and would otherwise leak into the SEO
77
+ * description as a confusing all-caps shout.
78
+ *
79
+ * Matches up to 4 hyphenated all-caps tokens, optionally followed by a
80
+ * digit suffix (`TIER-1`), terminating at a colon. Returns the original
81
+ * line when no opener is present.
82
+ *
83
+ * @param line - Plain prose line (post-{@link stripInlineMarkdown})
84
+ * @returns Line with the all-caps opener removed
85
+ */
86
+ export declare function stripLeadingProseLabel(line: string): string;
62
87
  /**
63
88
  * Strip inline Markdown decorations so we can use the remaining text as
64
89
  * plain-text meta-tag content. Removes link syntax, emphasis, inline code
@@ -105,6 +130,56 @@ export declare function extractFirstH1(markdown: string): string;
105
130
  * @returns Prose description, or empty string when nothing qualifies
106
131
  */
107
132
  export declare function extractStrongProseLine(markdown: string): string;
133
+ /**
134
+ * Walk the body of an editorial artefact and, when it contains a `## …`
135
+ * heading whose text matches one of {@link EDITORIAL_LEDE_HEADINGS},
136
+ * return the first prose paragraph that follows that heading. This is
137
+ * the journalist's lede ("60-Second Read", "TL;DR", "BLUF — …", …) and
138
+ * is exactly the sentence that should power `<meta description>` and
139
+ * the OG/Twitter description fields.
140
+ *
141
+ * Returns the empty string when no lede heading is found or no qualifying
142
+ * prose follows it. Inline Markdown is stripped and the result is
143
+ * truncated to fit `<meta description>`.
144
+ *
145
+ * @param markdown - Editorial artefact source
146
+ * @returns Lede paragraph, or empty string when none matched
147
+ */
148
+ export declare function extractLedeAfterHeading(markdown: string): string;
149
+ /**
150
+ * Return `true` when an artefact-H1 begins with one of the
151
+ * {@link ARTIFACT_CATEGORY_PREFIXES} followed by a separator. Such H1s
152
+ * carry the artefact's structural label rather than a journalist's
153
+ * headline (e.g. `# Synthesis Summary — Week in Review (3 Apr – 1 May
154
+ * 2026)`) and must not leak into the article `<title>`.
155
+ *
156
+ * @param heading - Plain-text H1 (after `stripInlineMarkdown`)
157
+ * @returns `true` when the heading is an artefact-category label
158
+ */
159
+ export declare function isArtifactCategoryHeading(heading: string): boolean;
160
+ /**
161
+ * Strip a leading or trailing artifact-category label from a heading and
162
+ * return the editorial-topic core. When neither end carries a category
163
+ * label, the heading is returned unchanged. When the category label is
164
+ * the **entire** heading (e.g. `# Executive Brief`) the result is the
165
+ * empty string.
166
+ *
167
+ * Examples:
168
+ * - `Executive Brief — EU Parliament Motions` → `EU Parliament Motions`
169
+ * - `EU Parliament Propositions — Executive Brief` → `EU Parliament Propositions`
170
+ * - `EP10 Term Outlook — Executive Brief` → `EP10 Term Outlook`
171
+ * - `Key Legislative Developments — Deep Analysis (2026-05-08)` → `Key Legislative Developments`
172
+ * - `Synthesis Summary — EP Motions & Adopted Texts` → `EP Motions & Adopted Texts`
173
+ *
174
+ * Trailing parenthesised metadata (`(2026-05-08)`, `(May 2026)`) is also
175
+ * stripped because it functions as a date stamp rather than editorial
176
+ * copy. The returned core is trimmed of whitespace and trailing
177
+ * punctuation.
178
+ *
179
+ * @param heading - Raw heading text (post-{@link stripInlineMarkdown})
180
+ * @returns Editorial-topic core, or empty string when only the category survived
181
+ */
182
+ export declare function stripArtifactCategoryAffix(heading: string): string;
108
183
  /**
109
184
  * Humanise an `article-type` slug the same way the aggregator does (see
110
185
  * `src/aggregator/analysis-aggregator.ts:humanize`). Kept in sync by value
@@ -52,6 +52,14 @@ const DESCRIPTION_MAX_LENGTH = 300;
52
52
  const TITLE_MAX_LENGTH = 140;
53
53
  /** Ordered list of artefact filenames that typically carry the editorial H1. */
54
54
  const EDITORIAL_ARTEFACT_CANDIDATES = [
55
+ // `executive-brief.md` is the canonical Riksdagsmonitor-aligned editorial
56
+ // artefact (see `analysis/methodologies/ai-driven-analysis-guide.md`).
57
+ // It always carries the journalist's BLUF and a `## 60-Second Read`
58
+ // paragraph that is the lede — preferring it over `synthesis-summary.md`
59
+ // keeps Stage-B internal vocabulary ("Purpose: This artifact provides …")
60
+ // out of the SEO-critical `<title>` and `<meta description>` surfaces.
61
+ 'executive-brief.md',
62
+ 'extended/executive-brief.md',
55
63
  'intelligence/synthesis-summary.md',
56
64
  'intelligence/executive-summary.md',
57
65
  'intelligence/intelligence-briefing.md',
@@ -68,6 +76,107 @@ const EDITORIAL_ARTEFACT_CANDIDATES = [
68
76
  'motions-analysis.md',
69
77
  'propositions-analysis.md',
70
78
  ];
79
+ /**
80
+ * Headings inside an editorial artefact that carry the journalist's lede
81
+ * paragraph (a one-paragraph summary of "what happened, why it matters").
82
+ * When the resolver sees one of these as a `## …` heading inside the
83
+ * editorial artefact, it prefers the first prose paragraph that follows
84
+ * it as the description (and as a title fallback) over a generic line
85
+ * walk. Names are matched case-insensitively against the heading text
86
+ * (after stripping inline Markdown).
87
+ */
88
+ const EDITORIAL_LEDE_HEADINGS = [
89
+ '60-second read',
90
+ '60 second read',
91
+ 'sixty-second read',
92
+ 'lede',
93
+ 'lead',
94
+ 'tl;dr',
95
+ 'tldr',
96
+ 'synopsis',
97
+ 'in brief',
98
+ 'at a glance',
99
+ 'bottom line',
100
+ 'bluf',
101
+ 'bluf — bottom line up front',
102
+ 'bottom line up front',
103
+ 'executive summary',
104
+ 'executive briefing',
105
+ 'master narrative',
106
+ 'overview',
107
+ 'headline judgement',
108
+ 'headline judgment',
109
+ 'key findings',
110
+ 'key judgements',
111
+ 'key judgments',
112
+ 'situation summary',
113
+ 'situation report',
114
+ 'situation update',
115
+ ];
116
+ /**
117
+ * Artifact-category prefixes that appear inside editorial-artefact H1s as
118
+ * a structural label rather than an editorial headline (e.g. `# Synthesis
119
+ * Summary — Week in Review (3 Apr – 1 May 2026)`). When a candidate H1
120
+ * starts with one of these prefixes followed by a separator (em/en dash,
121
+ * hyphen, or colon), the resolver treats it as **generic** so it does
122
+ * not leak into the article `<title>`. Compared lower-case, with leading
123
+ * punctuation stripped.
124
+ */
125
+ const ARTIFACT_CATEGORY_PREFIXES = [
126
+ 'actor mapping',
127
+ 'analytical quality',
128
+ 'breaking news analysis',
129
+ 'coalition dynamics',
130
+ 'commission wp alignment',
131
+ 'committee activity report',
132
+ 'cross run continuity',
133
+ 'deep analysis',
134
+ 'economic context',
135
+ 'executive brief',
136
+ 'executive briefing',
137
+ 'executive summary',
138
+ 'forward indicators',
139
+ 'historical baseline',
140
+ 'impact matrix',
141
+ 'intelligence assessment',
142
+ 'intelligence briefing',
143
+ 'intelligence synthesis summary',
144
+ 'legislative output analysis',
145
+ 'legislative pipeline analysis',
146
+ 'legislative pipeline forecast',
147
+ 'mandate fulfilment scorecard',
148
+ 'master intelligence synthesis',
149
+ 'mcp reliability audit',
150
+ 'methodology reflection',
151
+ 'monthly outlook',
152
+ 'motions analysis',
153
+ 'parliamentary calendar projection',
154
+ 'pestle analysis',
155
+ 'political intelligence brief',
156
+ 'political risk',
157
+ 'political threat landscape',
158
+ 'presidency trio context',
159
+ 'propositions analysis',
160
+ 'quantitative swot',
161
+ 'risk assessment',
162
+ 'risk matrix',
163
+ 'risk scoring',
164
+ 'scenario forecast',
165
+ 'seat projection',
166
+ 'significance classification',
167
+ 'situation report',
168
+ 'situation summary',
169
+ 'stakeholder analysis',
170
+ 'stakeholder impact',
171
+ 'stakeholder map',
172
+ 'swot analysis',
173
+ 'synthesis summary',
174
+ 'threat assessment',
175
+ 'threat model',
176
+ 'voting patterns',
177
+ 'weekly outlook',
178
+ 'wildcards blackswans',
179
+ ];
71
180
  /**
72
181
  * Emoji-banner prefixes that Stage-B agents use to decorate metadata rows
73
182
  * (e.g. `📋 Analysis Owner:`). Any line starting with one of these is
@@ -96,27 +205,47 @@ const EMOJI_BANNER_CHARS = [
96
205
  * by optional space and a colon.
97
206
  */
98
207
  const METADATA_LINE_PREFIXES = [
208
+ 'Admiralty Grade',
99
209
  'Analysis Date',
100
210
  'Analysis Owner',
101
211
  'Article Type',
212
+ 'Article Window',
102
213
  'Assessment Date',
214
+ 'Briefing',
215
+ 'Briefing Date',
103
216
  'Classification',
104
217
  'Classification Date',
105
218
  'Confidence',
219
+ 'Confidence in Evidence',
106
220
  'Data Sources',
221
+ 'Date',
107
222
  'Document Type',
223
+ 'Filing Date',
108
224
  'Generated',
225
+ 'Horizon',
226
+ 'IMF Status',
109
227
  'Last Updated',
110
228
  'Parliamentary Status',
111
229
  'Parliamentary Term',
112
230
  'Period',
231
+ 'Prepared',
232
+ 'Purpose',
233
+ 'Region',
234
+ 'Reporting',
235
+ 'Reporting Period',
236
+ 'Reporting Window',
113
237
  'Run',
114
238
  'Run ID',
115
239
  'Series',
116
240
  'Series Run',
241
+ 'Source',
242
+ 'Sources',
117
243
  'SPDX-FileCopyrightText',
118
244
  'SPDX-License-Identifier',
245
+ 'Topic',
119
246
  'Type',
247
+ 'WEP Band',
248
+ 'WEP Grade',
120
249
  'Window',
121
250
  ];
122
251
  /**
@@ -170,6 +299,53 @@ export function shouldSkipDescriptionLine(line) {
170
299
  return true;
171
300
  return false;
172
301
  }
302
+ /**
303
+ * Strip inline Markdown decorations so we can use the remaining text as
304
+ * plain-text meta-tag content. Removes link syntax, emphasis, inline code
305
+ * backticks, and HTML-entity fragments that the Markdown source sometimes
306
+ * smuggles in. Keeps the visible text readable.
307
+ *
308
+ * @param raw - Trimmed Markdown line
309
+ * @returns Plain-text variant
310
+ */
311
+ /**
312
+ * Strip a leading all-caps prose label (e.g. `SITUATION:`, `KEY MOTION:`,
313
+ * `BLUF:`, `BOTTOM LINE:`, `TIER-1:`) from a prose line. These labels
314
+ * are common in BLUF-style editorial writing — they survive
315
+ * {@link stripInlineMarkdown} (which strips the `**bold**` wrapper but
316
+ * keeps the literal text) and would otherwise leak into the SEO
317
+ * description as a confusing all-caps shout.
318
+ *
319
+ * Matches up to 4 hyphenated all-caps tokens, optionally followed by a
320
+ * digit suffix (`TIER-1`), terminating at a colon. Returns the original
321
+ * line when no opener is present.
322
+ *
323
+ * @param line - Plain prose line (post-{@link stripInlineMarkdown})
324
+ * @returns Line with the all-caps opener removed
325
+ */
326
+ export function stripLeadingProseLabel(line) {
327
+ // Use a single-pass regex with no nested quantifiers and no overlapping
328
+ // character classes — keeps `security/detect-unsafe-regex` happy. The
329
+ // pattern matches a label of 2-80 contiguous chars from a closed set
330
+ // (uppercase letters, digits, hyphen, single internal spaces),
331
+ // terminated by `:` and at least one whitespace before the prose body.
332
+ const colonIdx = line.indexOf(': ');
333
+ if (colonIdx < 2 || colonIdx > 80)
334
+ return line;
335
+ const label = line.slice(0, colonIdx);
336
+ const rest = line.slice(colonIdx + 2).trim();
337
+ if (rest.length < 20)
338
+ return line;
339
+ // Validate the label: ALL chars must be uppercase A-Z, digit 0-9, space,
340
+ // or hyphen; the first char must be a letter.
341
+ if (!/^[A-Z][A-Z0-9 -]{1,79}$/.test(label))
342
+ return line;
343
+ // Reject single-word labels shorter than 3 chars (`OK:` would be a
344
+ // false positive against legitimate sentence openers).
345
+ if (label.length < 3)
346
+ return line;
347
+ return rest;
348
+ }
173
349
  /**
174
350
  * Strip inline Markdown decorations so we can use the remaining text as
175
351
  * plain-text meta-tag content. Removes link syntax, emphasis, inline code
@@ -266,13 +442,204 @@ export function extractStrongProseLine(markdown) {
266
442
  const line = raw.trim();
267
443
  if (shouldSkipDescriptionLine(line))
268
444
  continue;
269
- const plain = stripInlineMarkdown(line);
445
+ const plain = stripLeadingProseLabel(stripInlineMarkdown(line));
270
446
  if (plain.length < 40)
271
447
  continue;
272
448
  return truncateDescription(plain);
273
449
  }
274
450
  return '';
275
451
  }
452
+ /**
453
+ * Walk the body of an editorial artefact and, when it contains a `## …`
454
+ * heading whose text matches one of {@link EDITORIAL_LEDE_HEADINGS},
455
+ * return the first prose paragraph that follows that heading. This is
456
+ * the journalist's lede ("60-Second Read", "TL;DR", "BLUF — …", …) and
457
+ * is exactly the sentence that should power `<meta description>` and
458
+ * the OG/Twitter description fields.
459
+ *
460
+ * Returns the empty string when no lede heading is found or no qualifying
461
+ * prose follows it. Inline Markdown is stripped and the result is
462
+ * truncated to fit `<meta description>`.
463
+ *
464
+ * @param markdown - Editorial artefact source
465
+ * @returns Lede paragraph, or empty string when none matched
466
+ */
467
+ export function extractLedeAfterHeading(markdown) {
468
+ const lines = markdown.split('\n');
469
+ let inLede = false;
470
+ for (let i = 0; i < lines.length; i++) {
471
+ const raw = lines[i] ?? '';
472
+ const line = raw.trim();
473
+ // Detect the start of a lede section — accept any H2/H3 whose plain
474
+ // text (after stripping leading hashes, inline decorations, and any
475
+ // leading emoji/punctuation) matches one of the canonical headings.
476
+ if (/^#{2,3}\s+/.test(line)) {
477
+ const headingText = normaliseHeadingText(line.replace(/^#{2,3}\s+/, ''));
478
+ inLede = EDITORIAL_LEDE_HEADINGS.some((h) => headingText === h || headingText.startsWith(`${h} `) || headingText.startsWith(`${h}:`));
479
+ continue;
480
+ }
481
+ if (!inLede)
482
+ continue;
483
+ // Inside the lede section: skip non-prose lines, then return the first
484
+ // qualifying paragraph. Strip a leading all-caps prose label
485
+ // (`SITUATION:`, `KEY MOTION:`, `BLUF:`, …) so SEO descriptions read
486
+ // as natural sentences rather than BLUF shouts.
487
+ if (shouldSkipDescriptionLine(line))
488
+ continue;
489
+ const plain = stripLeadingProseLabel(stripInlineMarkdown(line));
490
+ if (plain.length < 40)
491
+ continue;
492
+ return truncateDescription(plain);
493
+ }
494
+ return '';
495
+ }
496
+ /**
497
+ * Normalise a Markdown heading's text for comparison against the
498
+ * editorial-lede heading whitelist. Strips inline Markdown decorations
499
+ * (`*`, `_`, `` ` ``, `#`), then strips any leading non-alphanumeric
500
+ * characters (emoji, punctuation, spaces) so a heading like
501
+ * `🎯 Headline Judgement` compares equal to `headline judgement`.
502
+ *
503
+ * @param raw - Raw heading text (no leading hashes)
504
+ * @returns Lower-cased, decoration-stripped heading text
505
+ */
506
+ function normaliseHeadingText(raw) {
507
+ return stripInlineMarkdown(raw)
508
+ .replace(/[*_`#]+/g, '')
509
+ .replace(/^[^A-Za-z0-9]+/, '')
510
+ .trim()
511
+ .toLowerCase();
512
+ }
513
+ /**
514
+ * Return `true` when an artefact-H1 begins with one of the
515
+ * {@link ARTIFACT_CATEGORY_PREFIXES} followed by a separator. Such H1s
516
+ * carry the artefact's structural label rather than a journalist's
517
+ * headline (e.g. `# Synthesis Summary — Week in Review (3 Apr – 1 May
518
+ * 2026)`) and must not leak into the article `<title>`.
519
+ *
520
+ * @param heading - Plain-text H1 (after `stripInlineMarkdown`)
521
+ * @returns `true` when the heading is an artefact-category label
522
+ */
523
+ export function isArtifactCategoryHeading(heading) {
524
+ const normalized = normaliseCategoryHeading(heading);
525
+ if (normalized === '')
526
+ return false;
527
+ for (const prefix of ARTIFACT_CATEGORY_PREFIXES) {
528
+ if (normalized === prefix)
529
+ return true;
530
+ // Accept any of: "<prefix> — …", "<prefix> – …", "<prefix> - …",
531
+ // "<prefix>: …" — every separator commonly used in artefact H1s.
532
+ if (normalized.startsWith(`${prefix} —`) ||
533
+ normalized.startsWith(`${prefix} –`) ||
534
+ normalized.startsWith(`${prefix} -`) ||
535
+ normalized.startsWith(`${prefix}:`)) {
536
+ return true;
537
+ }
538
+ // Also accept "<topic> — <prefix>" / "<topic>: <prefix>" so suffix-form
539
+ // category labels (`# EU Parliament Propositions — Executive Brief`,
540
+ // `# Key Legislative Developments — Deep Analysis`) are flagged the
541
+ // same as prefix-form ones. The "topic" is rescued by the affix
542
+ // stripper before this rejection takes effect.
543
+ if (normalized.endsWith(` — ${prefix}`) ||
544
+ normalized.endsWith(` – ${prefix}`) ||
545
+ normalized.endsWith(` - ${prefix}`) ||
546
+ normalized.endsWith(`: ${prefix}`)) {
547
+ return true;
548
+ }
549
+ }
550
+ return false;
551
+ }
552
+ /**
553
+ * Strip a leading or trailing artifact-category label from a heading and
554
+ * return the editorial-topic core. When neither end carries a category
555
+ * label, the heading is returned unchanged. When the category label is
556
+ * the **entire** heading (e.g. `# Executive Brief`) the result is the
557
+ * empty string.
558
+ *
559
+ * Examples:
560
+ * - `Executive Brief — EU Parliament Motions` → `EU Parliament Motions`
561
+ * - `EU Parliament Propositions — Executive Brief` → `EU Parliament Propositions`
562
+ * - `EP10 Term Outlook — Executive Brief` → `EP10 Term Outlook`
563
+ * - `Key Legislative Developments — Deep Analysis (2026-05-08)` → `Key Legislative Developments`
564
+ * - `Synthesis Summary — EP Motions & Adopted Texts` → `EP Motions & Adopted Texts`
565
+ *
566
+ * Trailing parenthesised metadata (`(2026-05-08)`, `(May 2026)`) is also
567
+ * stripped because it functions as a date stamp rather than editorial
568
+ * copy. The returned core is trimmed of whitespace and trailing
569
+ * punctuation.
570
+ *
571
+ * @param heading - Raw heading text (post-{@link stripInlineMarkdown})
572
+ * @returns Editorial-topic core, or empty string when only the category survived
573
+ */
574
+ export function stripArtifactCategoryAffix(heading) {
575
+ const trimmed = heading.trim();
576
+ if (trimmed === '')
577
+ return '';
578
+ const sortedPrefixes = [...ARTIFACT_CATEGORY_PREFIXES].sort((a, b) => b.length - a.length);
579
+ const normalized = normaliseCategoryHeading(trimmed);
580
+ const skip = trimmed.length - normalized.length;
581
+ const visible = trimmed.slice(skip < 0 ? 0 : skip);
582
+ // Pre-strip trailing parenthesised metadata (`(2026-05-08)`,
583
+ // `(May 2026)`) so the suffix matcher works on `… — deep analysis`
584
+ // rather than `… — deep analysis (2026-05-08)`.
585
+ const visibleClean = visible.replace(/\s*\([^)]{1,80}\)\s*$/u, '').trim();
586
+ const normalizedClean = normaliseCategoryHeading(visibleClean);
587
+ for (const prefix of sortedPrefixes) {
588
+ // Prefix-form: `Executive Brief — <topic>`
589
+ for (const sep of [' — ', ' – ', ' - ', ': ']) {
590
+ const candidate = `${prefix}${sep}`;
591
+ if (normalizedClean.startsWith(candidate)) {
592
+ const core = visibleClean.slice(candidate.length).trim();
593
+ return cleanupAffixCore(core);
594
+ }
595
+ }
596
+ // Suffix-form: `<topic> — Executive Brief`
597
+ for (const sep of [' — ', ' – ', ' - ', ': ']) {
598
+ const candidate = `${sep}${prefix}`;
599
+ if (normalizedClean.endsWith(candidate)) {
600
+ const core = visibleClean.slice(0, visibleClean.length - candidate.length).trim();
601
+ return cleanupAffixCore(core);
602
+ }
603
+ }
604
+ // Whole-heading match: `Executive Brief`
605
+ if (normalizedClean === prefix)
606
+ return '';
607
+ }
608
+ // No category label detected — return the heading unchanged.
609
+ return trimmed;
610
+ }
611
+ /**
612
+ * Tidy the editorial-topic core returned by
613
+ * {@link stripArtifactCategoryAffix}: drop trailing parenthesised
614
+ * metadata (`(2026-05-08)`, `(May 2026)`) and trailing punctuation. When
615
+ * stripping leaves the string too short to be meaningful (<5 chars),
616
+ * return the empty string so callers fall through to lower tiers.
617
+ *
618
+ * @param core - Heading with the category label already stripped
619
+ * @returns Cleaned editorial-topic core, or empty string when too short
620
+ */
621
+ function cleanupAffixCore(core) {
622
+ const withoutTrailingParens = core.replace(/\s*\([^)]{1,80}\)\s*$/u, '').trim();
623
+ const withoutTrailingPunct = withoutTrailingParens.replace(/[—–:;,.\s-]+$/u, '').trim();
624
+ if (withoutTrailingPunct.length < 5)
625
+ return '';
626
+ return withoutTrailingPunct;
627
+ }
628
+ /**
629
+ * Lower-case, decoration-stripped form used by the artifact-category
630
+ * matchers. Strips inline Markdown, leading non-alphanumeric runs (emoji,
631
+ * decoration), and collapses whitespace to a single space.
632
+ *
633
+ * @param raw - Raw heading text
634
+ * @returns Lower-case normalised form
635
+ */
636
+ function normaliseCategoryHeading(raw) {
637
+ return stripInlineMarkdown(raw)
638
+ .trim()
639
+ .toLowerCase()
640
+ .replace(/^[^a-z0-9]+/, '')
641
+ .replace(/\s+/g, ' ');
642
+ }
276
643
  /**
277
644
  * Humanise an `article-type` slug the same way the aggregator does (see
278
645
  * `src/aggregator/analysis-aggregator.ts:humanize`). Kept in sync by value
@@ -304,6 +671,12 @@ export function isGenericHeading(heading, articleType, date) {
304
671
  const normalized = heading.trim().replace(/\s+/g, ' ');
305
672
  if (normalized === '')
306
673
  return true;
674
+ // Artefact-category H1s (e.g. `Synthesis Summary — …`, `Executive Brief
675
+ // — …`) are structural labels, not journalist headlines. Treat them as
676
+ // generic so the resolver falls through to the localized template tier
677
+ // and the SEO `<title>` stays clean.
678
+ if (isArtifactCategoryHeading(normalized))
679
+ return true;
307
680
  const human = humanizeSlug(articleType);
308
681
  const patterns = [
309
682
  `${human} — ${date}`,
@@ -353,38 +726,102 @@ function escapeRegex(input) {
353
726
  export function extractArtifactHighlight(runDir, articleType, date) {
354
727
  if (!runDir || !fs.existsSync(runDir))
355
728
  return null;
356
- // Direct candidate lookup — cheap and deterministic.
357
- for (const rel of EDITORIAL_ARTEFACT_CANDIDATES) {
358
- const abs = path.join(runDir, rel);
359
- if (!fs.existsSync(abs))
360
- continue;
361
- const body = readArtefactBody(abs);
362
- const headline = extractFirstH1(body);
363
- if (!headline)
364
- continue;
365
- if (isGenericHeading(headline, articleType, date))
366
- continue;
367
- const summary = extractStrongProseLine(body);
368
- return { headline: truncateTitle(headline), summary };
369
- }
729
+ // Direct candidate lookup — cheap and deterministic. We collect the
730
+ // first artefact whose body yields a usable lede summary even when its
731
+ // H1 is a structural artefact-category label, so the description tier
732
+ // benefits from the editorial 60-Second Read paragraph in
733
+ // `executive-brief.md` even though its H1 (`Executive Brief — …`) is
734
+ // generic.
735
+ const direct = scanCandidatesForHighlight(runDir, EDITORIAL_ARTEFACT_CANDIDATES, articleType, date);
736
+ if (direct.headline)
737
+ return { headline: direct.headline, summary: direct.summary };
370
738
  // Fallback: walk the top-level `.md` files in the run dir once, looking
371
739
  // for any that starts with `#` and has a non-generic headline.
372
- const topLevel = safeReaddir(runDir).filter((f) => f.endsWith('.md'));
373
- for (const rel of topLevel) {
374
- if (rel === 'manifest.json')
375
- continue;
376
- const abs = path.join(runDir, rel);
377
- const body = readArtefactBody(abs);
378
- const headline = extractFirstH1(body);
379
- if (!headline)
380
- continue;
381
- if (isGenericHeading(headline, articleType, date))
382
- continue;
383
- const summary = extractStrongProseLine(body);
384
- return { headline: truncateTitle(headline), summary };
740
+ const topLevel = safeReaddir(runDir).filter((f) => f.endsWith('.md') && f !== 'manifest.json');
741
+ const fallback = scanCandidatesForHighlight(runDir, topLevel, articleType, date);
742
+ if (fallback.headline)
743
+ return { headline: fallback.headline, summary: fallback.summary };
744
+ // No editorial headline was found, but we may have harvested a strong
745
+ // lede summary from one of the editorial artefacts. Returning a
746
+ // headline-less highlight lets `resolveEditorialContent` keep the
747
+ // editorial summary while falling back to the localized title template.
748
+ const summaryOnly = direct.summary || fallback.summary;
749
+ if (summaryOnly) {
750
+ return { headline: '', summary: summaryOnly };
385
751
  }
386
752
  return null;
387
753
  }
754
+ /**
755
+ * Walk a list of candidate artefact paths and return the first
756
+ * non-generic headline + summary pair, plus the first usable lede
757
+ * summary seen along the way. Extracted from
758
+ * {@link extractArtifactHighlight} to keep its cognitive complexity
759
+ * within the SonarJS budget.
760
+ *
761
+ * @param runDir - Absolute run directory path
762
+ * @param candidates - Run-relative candidate filenames to probe
763
+ * @param articleType - Article-type slug (used by {@link isGenericHeading})
764
+ * @param date - ISO run date (used by {@link isGenericHeading})
765
+ * @returns `{headline, summary}` where either field may be empty
766
+ */
767
+ function scanCandidatesForHighlight(runDir, candidates, articleType, date) {
768
+ let bestSummaryOnly = '';
769
+ for (const rel of candidates) {
770
+ const probe = probeCandidateForHighlight(runDir, rel, articleType, date);
771
+ // Both clean and stripped highlights win the loop — they come from
772
+ // the highest-priority artefact that yielded usable text. This
773
+ // preserves the priority order of {@link EDITORIAL_ARTEFACT_CANDIDATES}
774
+ // (executive-brief > synthesis-summary > …) so a stripped headline
775
+ // from `executive-brief.md` beats a clean H1 from a lower-priority
776
+ // artefact like `intelligence/synthesis-summary.md`.
777
+ if (probe.cleanHighlight)
778
+ return probe.cleanHighlight;
779
+ if (probe.strippedHeadline) {
780
+ return { headline: probe.strippedHeadline, summary: probe.summary ?? bestSummaryOnly };
781
+ }
782
+ if (!bestSummaryOnly && probe.summary) {
783
+ bestSummaryOnly = probe.summary;
784
+ }
785
+ }
786
+ return { headline: '', summary: bestSummaryOnly };
787
+ }
788
+ /**
789
+ * Read a single candidate artefact and classify what it can contribute
790
+ * to the highlight resolver. Extracted from
791
+ * {@link scanCandidatesForHighlight} to keep its cognitive complexity
792
+ * within the SonarJS budget.
793
+ *
794
+ * @param runDir - Absolute run directory
795
+ * @param rel - Run-relative artefact path
796
+ * @param articleType - Article-type slug for {@link isGenericHeading}
797
+ * @param date - ISO run date for {@link isGenericHeading}
798
+ * @returns
799
+ * - `cleanHighlight` when the artefact has a non-generic H1 (caller may
800
+ * return it directly)
801
+ * - `strippedHeadline` when the H1 is generic but yields an editorial
802
+ * core after {@link stripArtifactCategoryAffix}
803
+ * - `summary` when the artefact carries a usable lede or strong prose
804
+ * line (independent of the headline outcome)
805
+ */
806
+ function probeCandidateForHighlight(runDir, rel, articleType, date) {
807
+ const abs = path.join(runDir, rel);
808
+ if (!fs.existsSync(abs))
809
+ return {};
810
+ const body = readArtefactBody(abs);
811
+ const headline = extractFirstH1(body);
812
+ const lede = extractLedeAfterHeading(body);
813
+ const summary = lede || extractStrongProseLine(body);
814
+ if (headline && !isGenericHeading(headline, articleType, date)) {
815
+ return { cleanHighlight: { headline: truncateTitle(headline), summary } };
816
+ }
817
+ if (headline) {
818
+ const stripped = stripArtifactCategoryAffix(headline);
819
+ if (stripped && !isGenericHeading(stripped, articleType, date)) {
820
+ return { strippedHeadline: truncateTitle(stripped), summary };
821
+ }
822
+ }
823
+ return { summary };
824
+ }
388
825
  /**
389
826
  * Read an artefact file, skipping any SPDX HTML-comment header rows so the
390
827
  * first-H1 / first-prose logic is never derailed by the REUSE preamble.
@@ -763,7 +1200,13 @@ function manifestOverrideFor(value, lang) {
763
1200
  */
764
1201
  function resolveEditorialContent(opts) {
765
1202
  const { articleType, date, markdown, runDir } = opts;
766
- // Tier 2: first non-generic H1 in the first substantive artefact.
1203
+ // Tier 2: first non-generic H1 in the first substantive artefact. We
1204
+ // also remember any editorial summary harvested from a category-only
1205
+ // artefact (e.g. `executive-brief.md` whose H1 is the structural
1206
+ // `Executive Brief — …` label but whose `## 60-Second Read` paragraph
1207
+ // is the journalist's lede) so the description tier can still benefit
1208
+ // from real editorial copy when the headline tier falls through.
1209
+ let artefactSummary = '';
767
1210
  if (runDir) {
768
1211
  const highlight = extractArtifactHighlight(runDir, articleType, date);
769
1212
  if (highlight?.headline) {
@@ -772,6 +1215,9 @@ function resolveEditorialContent(opts) {
772
1215
  summary: highlight.summary,
773
1216
  };
774
1217
  }
1218
+ if (highlight?.summary) {
1219
+ artefactSummary = highlight.summary;
1220
+ }
775
1221
  }
776
1222
  // Tier 3: first non-generic H1 in the aggregated Markdown itself.
777
1223
  const aggregatedH1 = extractFirstH1(markdown);
@@ -779,12 +1225,16 @@ function resolveEditorialContent(opts) {
779
1225
  if (aggregatedH1 && !isGenericHeading(aggregatedH1, articleType, date)) {
780
1226
  return {
781
1227
  headline: truncateTitle(aggregatedH1),
782
- summary: aggregatedSummary,
1228
+ summary: artefactSummary || aggregatedSummary,
783
1229
  };
784
1230
  }
785
1231
  // Tier 4: first strong prose paragraph (title = same prose clipped).
786
- if (aggregatedSummary) {
787
- return { headline: truncateTitle(aggregatedSummary), summary: aggregatedSummary };
1232
+ // Prefer the artefact-derived editorial summary when available so the
1233
+ // description carries the journalist's lede rather than the
1234
+ // aggregator-walk leftover.
1235
+ const summary = artefactSummary || aggregatedSummary;
1236
+ if (summary) {
1237
+ return { headline: truncateTitle(summary), summary };
788
1238
  }
789
1239
  return { headline: '', summary: '' };
790
1240
  }
@@ -58,6 +58,7 @@ const A_COMPARATIVE_INTL = 'extended/comparative-international.md';
58
58
  const A_EXEC_BRIEF = 'extended/executive-brief.md';
59
59
  const A_DEVILS_ADVOCATE = 'extended/devils-advocate-analysis.md';
60
60
  const A_INTEL_ASSESSMENT = 'extended/intelligence-assessment.md';
61
+ const A_MEDIA_FRAMING = 'extended/media-framing-analysis.md';
61
62
  const A_DEEP_ANALYSIS_EXISTING = 'existing/deep-analysis.md';
62
63
  /** Stage budgets shared by the four short/mid prospective horizons.
63
64
  * Sum 35 (A=5, B=22, C=4, D=2, E=2). Per-family B1→B2,
@@ -86,7 +87,13 @@ const STANDARD_FEEDS = [
86
87
  'get_external_documents',
87
88
  ];
88
89
  /** Mandatory artifacts shared by every prospective horizon. Long-horizon
89
- * variants additionally require `forward-projection.md`. */
90
+ * variants additionally require `forward-projection.md`.
91
+ *
92
+ * `extended/media-framing-analysis.md` is mandatory for every horizon —
93
+ * see [`analytical-supplementary-methodology.md` §AS4](../../analysis/methodologies/analytical-supplementary-methodology.md)
94
+ * and [`per-artifact-methodologies.md` §media-framing-analysis](../../analysis/methodologies/per-artifact-methodologies.md).
95
+ * Agents produce it during Pass 2 (or late Pass 1) once the rest of the
96
+ * context is in place. */
90
97
  const PROSPECTIVE_MANDATORY = [
91
98
  A_SIGNIFICANCE,
92
99
  A_ACTOR_MAP,
@@ -105,9 +112,15 @@ const PROSPECTIVE_MANDATORY = [
105
112
  A_THREAT,
106
113
  A_MCP_AUDIT,
107
114
  A_INDEX,
115
+ A_MEDIA_FRAMING,
108
116
  A_REFLECTION,
109
117
  ];
110
- /** Mandatory artifacts shared by every retrospective horizon. */
118
+ /** Mandatory artifacts shared by every retrospective horizon.
119
+ *
120
+ * `extended/media-framing-analysis.md` is mandatory across every horizon
121
+ * (see PROSPECTIVE_MANDATORY) — review runs build it from the same Pass-2
122
+ * read-back so framing analysis lands after the underlying voting,
123
+ * stakeholder and coalition artifacts are stable. */
111
124
  const RETROSPECTIVE_MANDATORY = [
112
125
  A_SIGNIFICANCE,
113
126
  A_ACTOR_MAP,
@@ -125,6 +138,7 @@ const RETROSPECTIVE_MANDATORY = [
125
138
  A_THREAT,
126
139
  A_MCP_AUDIT,
127
140
  A_INDEX,
141
+ A_MEDIA_FRAMING,
128
142
  A_REFLECTION,
129
143
  ];
130
144
  /** Mandatory artifacts unique to long-horizon prospective runs. */
@@ -345,6 +359,7 @@ export const ARTICLE_HORIZONS = {
345
359
  A_THREAT,
346
360
  A_MCP_AUDIT,
347
361
  A_INDEX,
362
+ A_MEDIA_FRAMING,
348
363
  A_REFLECTION,
349
364
  ],
350
365
  optionalArtifacts: [A_EXEC_BRIEF],
@@ -292,7 +292,9 @@ export function generateIndexHTML(lang, articles, metaMap = new Map()) {
292
292
  <link rel="icon" type="image/png" sizes="16x16" href="images/favicon-16x16.png">
293
293
  <link rel="apple-touch-icon" sizes="180x180" href="images/apple-touch-icon.png">
294
294
  <link rel="manifest" href="site.webmanifest">
295
- <meta name="theme-color" content="#003399">
295
+ <meta name="color-scheme" content="light dark">
296
+ <meta name="theme-color" content="#003399" media="(prefers-color-scheme: light)">
297
+ <meta name="theme-color" content="#0a1a38" media="(prefers-color-scheme: dark)">
296
298
  <link rel="alternate" type="application/rss+xml" title="EU Parliament Monitor RSS" href="rss.xml">
297
299
  <link rel="stylesheet" href="styles.css?v=${BUILD_SHORT}">
298
300
  ${buildHeadFreshnessTags('')}
@@ -7,7 +7,7 @@ import { MCPConnection } from './mcp-connection.js';
7
7
  import type { MCPClientOptions, MCPToolResult, GetMEPsOptions, GetPlenarySessionsOptions, SearchDocumentsOptions, GetParliamentaryQuestionsOptions, GetCommitteeInfoOptions, MonitorLegislativePipelineOptions, AssessMEPInfluenceOptions, AnalyzeCoalitionDynamicsOptions, DetectVotingAnomaliesOptions, ComparePoliticalGroupsOptions, VotingRecordsOptions, VotingPatternsOptions, GenerateReportOptions, AnalyzeLegislativeEffectivenessOptions, AnalyzeCommitteeActivityOptions, TrackMEPAttendanceOptions, AnalyzeCountryDelegationOptions, GeneratePoliticalLandscapeOptions, GetCurrentMEPsOptions, GetSpeechesOptions, GetProceduresOptions, GetAdoptedTextsOptions, GetEventsOptions, GetMeetingActivitiesOptions, GetMeetingDecisionsOptions, GetMEPDeclarationsOptions, GetIncomingMEPsOptions, GetOutgoingMEPsOptions, GetHomonymMEPsOptions, GetLatestVotesOptions, GetPlenaryDocumentsOptions, GetCommitteeDocumentsOptions, GetPlenarySessionDocumentsOptions, GetPlenarySessionDocumentItemsOptions, GetControlledVocabulariesOptions, GetExternalDocumentsOptions, GetMeetingForeseenActivitiesOptions, GetProcedureEventsOptions, GetMeetingPlenarySessionDocumentsOptions, GetMeetingPlenarySessionDocumentItemsOptions, NetworkAnalysisOptions, SentimentTrackerOptions, EarlyWarningSystemOptions, ComparativeIntelligenceOptions, CorrelateIntelligenceOptions, GetAllGeneratedStatsOptions, GetMEPsFeedOptions, GetEventsFeedOptions, GetProceduresFeedOptions, GetAdoptedTextsFeedOptions, GetMEPDeclarationsFeedOptions, GetDocumentsFeedOptions, GetPlenaryDocumentsFeedOptions, GetCommitteeDocumentsFeedOptions, GetPlenarySessionDocumentsFeedOptions, GetExternalDocumentsFeedOptions, GetParliamentaryQuestionsFeedOptions, GetCorporateBodiesFeedOptions, GetControlledVocabulariesFeedOptions, GetProcedureEventByIdOptions, GetFreshProceduresOptions } from '../types/index.js';
8
8
  /**
9
9
  * Canonical list of tools exposed by the European Parliament MCP gateway
10
- * (`european-parliament-mcp-server@1.3.0`). The news workflows, prompt
10
+ * (`european-parliament-mcp-server@1.3.2`). The news workflows, prompt
11
11
  * library (`.github/prompts/07-mcp-reference.md`), and the integration test
12
12
  * suite all reference this list so a regression that adds/removes a tool
13
13
  * fails a single drift guard
@@ -22,7 +22,7 @@ export declare const EP_MCP_TOOLS: readonly string[];
22
22
  * covering the two shapes historically emitted by the EP MCP server.
23
23
  *
24
24
  * 1. **Uniform envelope** (all feeds as of
25
- * `european-parliament-mcp-server@1.3.0`) —
25
+ * `european-parliament-mcp-server@1.3.2`) —
26
26
  * `{status:"unavailable", items:[], generatedAt:"..."}` established by
27
27
  * Hack23/European-Parliament-MCP-Server#301 and extended to
28
28
  * `get_events_feed`/`get_procedures_feed` by
@@ -206,9 +206,9 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
206
206
  *
207
207
  * @remarks
208
208
  * This repository is currently documented/configured against
209
- * `european-parliament-mcp-server@1.3.0`.
209
+ * `european-parliament-mcp-server@1.3.2`.
210
210
  *
211
- * **Upstream date-filter contract (v1.2.14+, active on the pinned v1.3.0 server):** the upstream server
211
+ * **Upstream date-filter contract (v1.2.14+, active on the pinned v1.3.2 server):** the upstream server
212
212
  * applies a server-side post-filter on `dateFrom`/`dateTo` before serialisation, because the
213
213
  * EP Open Data Portal `/meetings` endpoint silently ignores its `date-from`/`date-to` query
214
214
  * parameters (Defect #5). Under this contract:
@@ -217,7 +217,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
217
217
  * - Per-window session counts are reproducible because the EP-side regression is masked by
218
218
  * the upstream post-filter.
219
219
  *
220
- * No local post-filter is applied here. The repository is pinned to v1.3.0, so the
220
+ * No local post-filter is applied here. The repository is pinned to v1.3.2, so the
221
221
  * date-filter guarantees above apply; consumers running against an older server image
222
222
  * (pre-v1.2.14) must not assume them.
223
223
  */
@@ -529,7 +529,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
529
529
  * dataSource: EP_DOCEO_XML) — typically available within minutes of a
530
530
  * plenary vote, unlike the regular EP API which has a multi-week delay.
531
531
  *
532
- * New in `european-parliament-mcp-server@1.3.0`.
532
+ * New in `european-parliament-mcp-server@1.3.1`.
533
533
  *
534
534
  * @param options - Pagination options
535
535
  * @returns Latest plenary vote records with NEAR_REALTIME freshness
@@ -11,7 +11,7 @@ import { recordPendingDocument, markDocumentResolved, getPendingDocumentsForRepr
11
11
  import { EP_NEXT_ELECTION_START, EP_NEXT_ELECTION_END, EP_CURRENT_TERM, EP_NEXT_TERM, } from '../constants/config.js';
12
12
  /**
13
13
  * Canonical list of tools exposed by the European Parliament MCP gateway
14
- * (`european-parliament-mcp-server@1.3.0`). The news workflows, prompt
14
+ * (`european-parliament-mcp-server@1.3.2`). The news workflows, prompt
15
15
  * library (`.github/prompts/07-mcp-reference.md`), and the integration test
16
16
  * suite all reference this list so a regression that adds/removes a tool
17
17
  * fails a single drift guard
@@ -116,7 +116,7 @@ const CONTENT_NOT_YET_AVAILABLE_SUBSTRING = 'document indexed but content not ye
116
116
  /**
117
117
  * Classify an error message into a diagnostic error category.
118
118
  *
119
- * Maps EP MCP Server v1.3.0 structured error codes and generic HTTP/network
119
+ * Maps EP MCP Server v1.3.2 structured error codes and generic HTTP/network
120
120
  * errors into one of six broad categories used for logging and retry decisions:
121
121
  *
122
122
  * Returned categories (priority order):
@@ -132,7 +132,7 @@ const CONTENT_NOT_YET_AVAILABLE_SUBSTRING = 'document indexed but content not ye
132
132
  */
133
133
  function classifyToolError(message) {
134
134
  const lowerMsg = message.toLowerCase();
135
- // EP MCP Server v1.3.0 structured error codes (matched case-insensitively)
135
+ // EP MCP Server v1.3.2 structured error codes (matched case-insensitively)
136
136
  if (lowerMsg.includes('internal_error')) {
137
137
  return 'INTERNAL_ERROR';
138
138
  }
@@ -191,7 +191,7 @@ function _parseResultPayload(result) {
191
191
  * covering the two shapes historically emitted by the EP MCP server.
192
192
  *
193
193
  * 1. **Uniform envelope** (all feeds as of
194
- * `european-parliament-mcp-server@1.3.0`) —
194
+ * `european-parliament-mcp-server@1.3.2`) —
195
195
  * `{status:"unavailable", items:[], generatedAt:"..."}` established by
196
196
  * Hack23/European-Parliament-MCP-Server#301 and extended to
197
197
  * `get_events_feed`/`get_procedures_feed` by
@@ -592,9 +592,9 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
592
592
  *
593
593
  * @remarks
594
594
  * This repository is currently documented/configured against
595
- * `european-parliament-mcp-server@1.3.0`.
595
+ * `european-parliament-mcp-server@1.3.2`.
596
596
  *
597
- * **Upstream date-filter contract (v1.2.14+, active on the pinned v1.3.0 server):** the upstream server
597
+ * **Upstream date-filter contract (v1.2.14+, active on the pinned v1.3.2 server):** the upstream server
598
598
  * applies a server-side post-filter on `dateFrom`/`dateTo` before serialisation, because the
599
599
  * EP Open Data Portal `/meetings` endpoint silently ignores its `date-from`/`date-to` query
600
600
  * parameters (Defect #5). Under this contract:
@@ -603,7 +603,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
603
603
  * - Per-window session counts are reproducible because the EP-side regression is masked by
604
604
  * the upstream post-filter.
605
605
  *
606
- * No local post-filter is applied here. The repository is pinned to v1.3.0, so the
606
+ * No local post-filter is applied here. The repository is pinned to v1.3.2, so the
607
607
  * date-filter guarantees above apply; consumers running against an older server image
608
608
  * (pre-v1.2.14) must not assume them.
609
609
  */
@@ -1111,7 +1111,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
1111
1111
  * dataSource: EP_DOCEO_XML) — typically available within minutes of a
1112
1112
  * plenary vote, unlike the regular EP API which has a multi-week delay.
1113
1113
  *
1114
- * New in `european-parliament-mcp-server@1.3.0`.
1114
+ * New in `european-parliament-mcp-server@1.3.1`.
1115
1115
  *
1116
1116
  * @param options - Pagination options
1117
1117
  * @returns Latest plenary vote records with NEAR_REALTIME freshness
@@ -122,9 +122,8 @@ export declare function buildSiteHeader(options: SiteHeaderOptions): string;
122
122
  /**
123
123
  * Build the full-width page banner shown below the sticky site header on every page.
124
124
  *
125
- * The banner image (`banner.webp` / `banner.jpg`) is 1200×400. CSS renders it with
126
- * `object-fit: cover; object-position: center` so the middle 80% of the image is
127
- * always visible and the uninteresting top/bottom 10% may be cropped.
125
+ * The banner image (`banner.webp` / `banner.jpg`) is 1200×400. CSS renders it
126
+ * at its native 3:1 ratio so the full artwork remains visible on every viewport.
128
127
  *
129
128
  * @param pathPrefix - Asset path prefix: `''` for root pages, `'../'` for `news/` pages.
130
129
  * @returns HTML string for the `.page-banner` element.
@@ -300,7 +300,7 @@ export function buildSiteHeader(options) {
300
300
  <a href="${escapeHTML(homeHref)}" class="site-header__brand" aria-label="${safeTitle}">
301
301
  <picture class="site-header__logo-picture">
302
302
  <source srcset="${pathPrefix}images/banner.webp" type="image/webp">
303
- <img class="site-header__logo site-header__logo--banner" src="${pathPrefix}images/banner.jpg" alt="${safeTitle}" width="180" height="60" loading="eager">
303
+ <img class="site-header__logo site-header__logo--banner" src="${pathPrefix}images/banner.jpg" alt="${safeTitle}" width="240" height="80" loading="eager">
304
304
  </picture>
305
305
  <span class="site-header__brand-text">
306
306
  <span class="site-header__title">${safeTitle}</span>
@@ -308,10 +308,14 @@ export function buildSiteHeader(options) {
308
308
  </span>
309
309
  </a>
310
310
  <div class="site-header__actions">
311
- ${piCta}${cta('site-header__cta--sponsor', 'https://github.com/sponsors/Hack23', 'heart', sponsorLabel)}
312
- ${cta('', 'https://www.hack23.com', 'sponsor', becomeSponsorLabel)}
313
- ${cta('site-header__cta--security', 'https://github.com/Hack23/euparliamentmonitor/blob/main/SECURITY.md', ICON_SECURITY, securityLabel)}
314
- ${createThemeToggleButton(themeToggleLabel)}
311
+ <div class="site-header__cta-group">
312
+ ${piCta}${cta('site-header__cta--sponsor', 'https://github.com/sponsors/Hack23', 'heart', sponsorLabel)}
313
+ ${cta('', 'https://www.hack23.com', 'sponsor', becomeSponsorLabel)}
314
+ ${cta('site-header__cta--security', 'https://github.com/Hack23/euparliamentmonitor/blob/main/SECURITY.md', ICON_SECURITY, securityLabel)}
315
+ </div>
316
+ <div class="site-header__theme-toggle-slot">
317
+ ${createThemeToggleButton(themeToggleLabel)}
318
+ </div>
315
319
  </div>
316
320
  <nav class="site-header__langs" role="navigation" aria-label="${langSelectionLabel}">
317
321
  ${languageSwitcherHtml}
@@ -322,9 +326,8 @@ export function buildSiteHeader(options) {
322
326
  /**
323
327
  * Build the full-width page banner shown below the sticky site header on every page.
324
328
  *
325
- * The banner image (`banner.webp` / `banner.jpg`) is 1200×400. CSS renders it with
326
- * `object-fit: cover; object-position: center` so the middle 80% of the image is
327
- * always visible and the uninteresting top/bottom 10% may be cropped.
329
+ * The banner image (`banner.webp` / `banner.jpg`) is 1200×400. CSS renders it
330
+ * at its native 3:1 ratio so the full artwork remains visible on every viewport.
328
331
  *
329
332
  * @param pathPrefix - Asset path prefix: `''` for root pages, `'../'` for `news/` pages.
330
333
  * @returns HTML string for the `.page-banner` element.
@@ -412,12 +415,12 @@ export function buildSiteFooter(options) {
412
415
  return `<footer class="site-footer" role="contentinfo">
413
416
  <div class="footer-content">
414
417
  <div class="footer-section">
415
- <h3>${aboutHeading}</h3>
418
+ <h3 class="footer-section__heading">${aboutHeading}</h3>
416
419
  <p>${aboutText}</p>${articlesLine}
417
420
  <p class="footer-company-summary">${companyTagline}</p>
418
421
  </div>
419
422
  <div class="footer-section">
420
- <h3>${quickLinksHeading}</h3>
423
+ <h3 class="footer-section__heading">${quickLinksHeading}</h3>
421
424
  <ul>
422
425
  <li><a href="${homeHref}">${icon('home')}<span>${homeLabel}</span></a></li>
423
426
  <li><a href="${homeHref}#main">${icon('news')}<span>${newsLabel}</span></a></li>
@@ -438,7 +441,7 @@ export function buildSiteFooter(options) {
438
441
  </ul>
439
442
  </div>
440
443
  <div class="footer-section">
441
- <h3>${builtByHeading}</h3>
444
+ <h3 class="footer-section__heading">${builtByHeading}</h3>
442
445
  <div class="footer-badges" aria-label="${escapeHTML(getLocalizedString(FOOTER_TRUST_BADGES_ARIA_LABELS, lang))}">
443
446
  <a href="https://www.npmjs.com/package/euparliamentmonitor" aria-label="npm package version"><img src="https://img.shields.io/npm/v/euparliamentmonitor.svg" alt="npm package version"></a>
444
447
  <a href="https://scorecard.dev/viewer/?uri=github.com/Hack23/euparliamentmonitor" aria-label="OpenSSF Scorecard"><img src="https://api.securityscorecards.dev/projects/github.com/Hack23/euparliamentmonitor/badge" alt="OpenSSF Scorecard"></a>
@@ -468,7 +471,7 @@ export function buildSiteFooter(options) {
468
471
  </ul>
469
472
  </div>
470
473
  <div class="footer-section">
471
- <h3>${icon('lang')} ${languagesHeading}</h3>
474
+ <h3 class="footer-section__heading">${icon('lang')} ${languagesHeading}</h3>
472
475
  <div class="language-grid">
473
476
  ${langGrid}
474
477
  </div>
@@ -287,7 +287,7 @@ export interface GetHomonymMEPsOptions {
287
287
  limit?: number | undefined;
288
288
  offset?: number | undefined;
289
289
  }
290
- /** Options for getLatestVotes (DOCEO-backed near-real-time vote enrichment, new in v1.3.0) */
290
+ /** Options for getLatestVotes (DOCEO-backed near-real-time vote enrichment, new in v1.3.1) */
291
291
  export interface GetLatestVotesOptions {
292
292
  /** Maximum number of recent vote records to return (default: 50) */
293
293
  limit?: number | undefined;