euparliamentmonitor 0.8.44 β†’ 0.8.46

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
@@ -87,6 +87,10 @@ import {
87
87
  - πŸ“Š **Article Quality** β€” Score and validate generated content with comprehensive quality metrics
88
88
  - 🌍 **Multilingual** β€” Full i18n support: EN, SV, DA, NO, FI, DE, FR, ES, NL, AR, HE, JA, KO, ZH
89
89
 
90
+ **Explore the live platform** β€” [🌐 euparliamentmonitor.com](https://euparliamentmonitor.com) Β· [🧠 Political Intelligence Hub](https://euparliamentmonitor.com/political-intelligence.html) (methodology + artifact transparency, all 14 languages) Β· [πŸ—ΊοΈ Site Map](https://euparliamentmonitor.com/sitemap.html) (every page, every language) Β· [πŸ“” API Reference](https://euparliamentmonitor.com/docs/api/index.html)
91
+
92
+ > πŸ“¦ **About this package** β€” `euparliamentmonitor` is the open-source TypeScript library powering [euparliamentmonitor.com](https://euparliamentmonitor.com), an automated **European Parliament transparency platform** that publishes daily AI-generated, *Economist-style* political-intelligence articles in 14 languages. The package bundles the **EU Parliament MCP client** (60+ open-data tools β€” plenary sessions, committee reports, voting records, parliamentary questions, adopted texts, procedures, MEPs, declarations), the **deterministic article aggregator** (analysis-artifact β†’ Markdown β†’ HTML, with 14-language hreflang and shared site chrome), the **political-intelligence analytics** (voting-anomaly detection, coalition dynamics, MEP influence scoring, OSINT correlation), and the **multi-language renderer** with WCAG 2.1 AA accessibility, structured data, and SEO-ready sitemap generation. Designed for civic-tech, data-journalism, OSINT, and democratic-transparency use cases. ISO 27001 / NIST CSF 2.0 / CIS Controls v8.1 / GDPR / NIS2 / EU CRA aligned.
93
+
90
94
  > Published with [npm provenance](https://docs.npmjs.com/generating-provenance-statements) for supply chain security. [SLSA Level 3](https://github.com/Hack23/euparliamentmonitor/attestations) build attestations included.
91
95
 
92
96
  ## 🎯 Status Badges
@@ -105,9 +109,37 @@ import {
105
109
  [![E2E Report](https://img.shields.io/badge/E2E-Report-purple?logo=playwright)](https://euparliamentmonitor.com/playwright-report/index.html)
106
110
  [![SLSA 3](https://img.shields.io/badge/SLSA-Level%203-brightgreen?logo=github)](https://github.com/Hack23/euparliamentmonitor/attestations)
107
111
 
112
+ ## 🌐 Live Site β€” Explore the Platform
113
+
114
+ The published platform at **[euparliamentmonitor.com](https://euparliamentmonitor.com)** is the audience-facing companion to this npm package. Two hub pages give the fastest entry into the daily output and the underlying tradecraft:
115
+
116
+ <table>
117
+ <tr>
118
+ <td width="120" align="center" valign="top">
119
+ <a href="https://euparliamentmonitor.com/political-intelligence.html"><img src="https://img.shields.io/badge/🧠-Political%20Intelligence-003399?style=for-the-badge&logoColor=FFCC00" alt="Political Intelligence Hub"/></a>
120
+ </td>
121
+ <td>
122
+ <strong><a href="https://euparliamentmonitor.com/political-intelligence.html">🧠 Political Intelligence Hub</a></strong><br>
123
+ Audit-ready transparency layer behind every published article. Indexes the <strong>10-step AI-driven analysis protocol</strong>, the <strong>39 artifact templates</strong>, the per-artifact <strong>methodologies</strong> (BLUF, Admiralty WEP source-grading, SAT, Heuer's ACH, OSINT tradecraft), and links every run-level artifact under <code>analysis/daily/&lt;date&gt;/&lt;slug&gt;/</code> directly to GitHub so readers can <em>verify the analysis behind the prose</em>. Available in all 14 supported languages.
124
+ </td>
125
+ </tr>
126
+ <tr>
127
+ <td width="120" align="center" valign="top">
128
+ <a href="https://euparliamentmonitor.com/sitemap.html"><img src="https://img.shields.io/badge/πŸ—ΊοΈ-Site%20Map-0A66C2?style=for-the-badge&logoColor=FFCC00" alt="Site Map"/></a>
129
+ </td>
130
+ <td>
131
+ <strong><a href="https://euparliamentmonitor.com/sitemap.html">πŸ—ΊοΈ Site Map</a></strong><br>
132
+ Human-readable index of <strong>every page</strong> on the platform β€” landing pages, news articles, and technical documentation β€” across all <strong>14 languages</strong> (EN, SV, DA, NO, FI, DE, FR, ES, NL, AR, HE, JA, KO, ZH). Best starting point for SEO crawlers, audience navigation, and discovering the latest articles. Companion to the machine-readable <code>sitemap.xml</code> and per-language <code>sitemap_&lt;lang&gt;.html</code> variants.
133
+ </td>
134
+ </tr>
135
+ </table>
136
+
108
137
  ## πŸ“š Documentation Hub
109
138
 
110
139
  **πŸ“– Quick Links:**
140
+ - [🌐 Live Site](https://euparliamentmonitor.com) - Public news platform (14 languages, daily AI-generated articles)
141
+ - [🧠 Political Intelligence Hub](https://euparliamentmonitor.com/political-intelligence.html) - Methodology + artifact transparency layer
142
+ - [πŸ—ΊοΈ Site Map](https://euparliamentmonitor.com/sitemap.html) - All pages across all 14 languages
111
143
  - [πŸ“˜ Architecture Documentation](SECURITY_ARCHITECTURE.md) - Complete security architecture with C4 diagrams
112
144
  - [πŸ“— Security Flows](FLOWCHART.md) - Process flows with security controls
113
145
  - [πŸ“™ Data Model](DATA_MODEL.md) - Data structures and API integration
@@ -119,7 +151,7 @@ import {
119
151
  - [Agent Catalog](.github/agents/README.md) β€” custom Copilot agents (analysis producers / consumers / gh-aw infrastructure)
120
152
  - [Skills Library](.github/skills/README.md) β€” shared skills (security, compliance, intelligence, gh-aw)
121
153
  - [Prompt Library](.github/prompts/README.md) β€” 10-file bounded-context prompt set (`00`β†’`09`) + `npm run lint:prompts` drift-guard
122
- - [Workflows](.github/workflows/README.md) + [WORKFLOWS.md](WORKFLOWS.md) β€” 10 `news-*.md` agentic workflows + CI workflows
154
+ - [Workflows](.github/workflows/README.md) + [WORKFLOWS.md](WORKFLOWS.md) β€” 9 `news-*.md` agentic workflows (8 unified `news-<type>.md` + `news-translate.md`) + CI workflows
123
155
  - [Analysis Chain](analysis/README.md) β€” 5-stage pipeline (Data β†’ Analysis β†’ Completeness Gate β†’ Article β†’ Single PR), methodologies, 39 templates, quality thresholds
124
156
 
125
157
  **πŸ”’ ISMS Compliance:**
@@ -136,11 +168,12 @@ v1.2.13 for accessing real EU Parliament data via the Model Context Protocol.
136
168
  - **MCP Server Status**: βœ… Fully operational β€” 60+ EP data tools available
137
169
  (feeds, direct lookups, analytical tools, intelligence correlation)
138
170
  - **Agentic Workflows**: 9 unified gh-aw markdown workflows β€” 8 article types (`news-<type>.md`, Stages A β†’ B β†’ C β†’ D β†’ E in one session) + `news-translate.md` (14-language flush translation) β€” compiled with
139
- `gh-aw v0.69.3` to `.lock.yml` for automated news generation with AI-driven political
171
+ `gh-aw v0.69.3` (pin in `.github/workflows/compile-agentic-workflows.yml`) to `.lock.yml` for automated news generation with AI-driven political
140
172
  intelligence analysis. See [`.github/workflows/README.md`](.github/workflows/README.md).
141
173
  - **Analysis-Artifact-Driven Article Pipeline**: Agents author the full
142
- Stage-B analysis-artifact set (`analysis/daily/<date>/<slug>-run<NN>/`, 39
143
- structured templates per run) and commit it; the deterministic aggregator
174
+ Stage-B analysis-artifact set (`analysis/daily/<date>/<slug>/`, 39
175
+ structured templates per run; repeat same-day runs reuse the folder and
176
+ append to `manifest.json.history[]`) and commit it; the deterministic aggregator
144
177
  (`src/aggregator/**`, invoked via
145
178
  `npm run generate-article -- --run <analysis-run-dir>` or
146
179
  `npm run generate-article:all` for batch regen) then walks `manifest.json`,
@@ -598,6 +631,36 @@ npm run serve
598
631
  # Open http://localhost:8080 in your browser
599
632
  ```
600
633
 
634
+ #### Same-Origin JS Bundle (Mermaid + Chart.js + D3)
635
+
636
+ The static site loads **every** executable bundle from same-origin
637
+ `js/vendor/` under a strict `script-src 'self'` CSP β€” no external CDN. Vendored
638
+ libraries are copied from `node_modules/` at build time:
639
+
640
+ ```bash
641
+ npm run copy-vendor # writes js/vendor/{chart.umd.min.js,d3.min.js,…,mermaid/}
642
+ ```
643
+
644
+ The CI deploy workflow ([`deploy-s3.yml`](.github/workflows/deploy-s3.yml))
645
+ runs `copy-vendor` before `aws s3 sync` and includes both `*.js` and `*.mjs`
646
+ files (Mermaid 11 ships as code-split ESM under `js/vendor/mermaid/chunks/`).
647
+ If you add a new diagram type to a template, no extra wiring is needed β€” the
648
+ chunk loader is part of the vendored bundle.
649
+
650
+ #### Stage-C Analysis Validator
651
+
652
+ Before opening an article PR, validate the analysis run completeness:
653
+
654
+ ```bash
655
+ npm run validate-analysis -- analysis/daily/<date>/<run-dir>
656
+ ```
657
+
658
+ The validator enforces per-artifact line floors, mandatory Mermaid diagrams,
659
+ Admiralty/WEP/SAT/BLUF tradecraft signals, required H2 sections, and
660
+ placeholder leakage. RED output β‡’ Pass-3 the offending artifacts; never ship
661
+ an article without a green gate. See
662
+ [`.github/prompts/03-analysis-completeness-gate.md`](.github/prompts/03-analysis-completeness-gate.md).
663
+
601
664
  ## Project Structure
602
665
 
603
666
  ```
package/SECURITY.md CHANGED
@@ -130,7 +130,7 @@ EU Parliament Monitor aligns with:
130
130
 
131
131
  Current security posture (updated monthly):
132
132
 
133
- - **Zero** known vulnerabilities (npm audit clean)
133
+ - **Zero** known vulnerabilities (npm audit clean) β€” *except 2 documented accepted-risk transitive devDep advisories: see "Accepted Risks" below*
134
134
  - **82%+** code coverage with security tests
135
135
  - **100%** dependency scanning coverage
136
136
  - **0** CodeQL critical/high findings
@@ -138,6 +138,17 @@ Current security posture (updated monthly):
138
138
 
139
139
  See [SECURITY_ARCHITECTURE.md - Security Metrics](SECURITY_ARCHITECTURE.md#-security-metrics) for detailed metrics.
140
140
 
141
+ ### Accepted Risks (Documented False Positives)
142
+
143
+ The following advisories are detected by `npm audit` and explicitly allow-listed in `.github/workflows/test-and-report.yml` (Security Check job). They are accepted as residual risk because both are dev-only and do not reach end-user runtime:
144
+
145
+ | GHSA | Package | Severity | Path | Justification |
146
+ | --- | --- | --- | --- | --- |
147
+ | `GHSA-2g4f-4pwh-qvx6` | `ajv` (via ESLint) | moderate (ReDoS) | devDep | ESLint does not invoke ajv with the `$data` option; only triggered on attacker-controlled JSON schemas, which we never feed it. Resolves with the ESLint 10 upgrade. |
148
+ | `GHSA-w5hq-g745-h8pq` | `uuid <14.0.0` (via `mermaid`) | moderate (buffer bounds) | devDep | `mermaid` is a build-time-only dependency. The library is vendored to `js/vendor/mermaid/` and renders diagrams from analyst-authored markdown that goes through the Stage-C completeness gate; user input never reaches `uuid.v3/v5/v6` with an attacker-controlled `buf` argument. The site is fully static β€” no server-side `mermaid` execution. |
149
+
150
+ If `npm audit` reports any GHSA outside this list, the Security Check job MUST fail. Allow-listing requires a pull request that updates this table and the workflow allow-list together.
151
+
141
152
  ## Security Resources
142
153
 
143
154
  - **Threat Model**: [SECURITY_ARCHITECTURE.md - Threat Model](SECURITY_ARCHITECTURE.md#-threat-model)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.8.44",
3
+ "version": "0.8.46",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -60,7 +60,8 @@
60
60
  "build": "tsc",
61
61
  "build:check": "tsc --noEmit",
62
62
  "build:check-tests": "tsc --project tsconfig.test.json --noEmit",
63
- "copy-vendor": "mkdir -p js/vendor && cp node_modules/chart.js/dist/chart.umd.min.js js/vendor/ && cp node_modules/chartjs-plugin-annotation/dist/chartjs-plugin-annotation.min.js js/vendor/ && cp node_modules/d3/dist/d3.min.js js/vendor/ && (test -f node_modules/mermaid/dist/mermaid.esm.min.mjs && cp node_modules/mermaid/dist/mermaid.esm.min.mjs js/vendor/ || echo 'mermaid vendor asset not installed; skipping')",
63
+ "copy-vendor": "node scripts/copy-vendor.js",
64
+ "validate-analysis": "node scripts/validate-analysis-completeness.js",
64
65
  "generate-article": "node scripts/aggregator/article-generator.js",
65
66
  "generate-article:all": "node scripts/aggregator/article-generator.js --all",
66
67
  "generate-news-indexes": "node scripts/generators/news-indexes.js",
@@ -157,6 +158,7 @@
157
158
  "husky": "9.1.7",
158
159
  "jscpd": "4.0.9",
159
160
  "lint-staged": "16.4.0",
161
+ "mermaid": "^11.14.0",
160
162
  "papaparse": "5.5.3",
161
163
  "prettier": "3.8.3",
162
164
  "ts-api-utils": "2.5.0",
@@ -169,7 +171,7 @@
169
171
  "node": ">=25"
170
172
  },
171
173
  "dependencies": {
172
- "european-parliament-mcp-server": "1.2.13",
174
+ "european-parliament-mcp-server": "1.2.14",
173
175
  "markdown-it": "^14.1.1",
174
176
  "markdown-it-anchor": "^9.2.0",
175
177
  "markdown-it-attrs": "^4.3.1",
@@ -60,6 +60,10 @@ export interface IncludedArtifact {
60
60
  /** Id of the section this artifact belongs to. */
61
61
  readonly sectionId: string;
62
62
  }
63
+ /** Id of the generated reader guide section. */
64
+ export declare const READER_GUIDE_SECTION_ID = "reader-intelligence-guide";
65
+ /** Display title of the generated reader guide section. */
66
+ export declare const READER_GUIDE_SECTION_TITLE = "Reader Intelligence Guide";
63
67
  /** Options for {@link aggregateAnalysisRun}. */
64
68
  export interface AggregateOptions {
65
69
  /** Absolute path to the analysis run directory. */
@@ -153,6 +157,16 @@ export declare function renderTradecraftAppendix(files: readonly string[]): stri
153
157
  * @returns Markdown block with the index table
154
158
  */
155
159
  export declare function renderAnalysisIndex(included: readonly IncludedArtifact[], manifestRelPath: string): string;
160
+ /**
161
+ * Render the generated reader-intelligence guide that appears before the
162
+ * artifact sections. It gives readers a Riksdagsmonitor-style navigation layer
163
+ * without requiring agents to hand-author another artifact.
164
+ *
165
+ * @param sections - Emitted section TOC entries, in document order
166
+ * @param included - Included artifacts, used to name each section's source
167
+ * @returns Markdown block containing the guide table
168
+ */
169
+ export declare function renderReaderIntelligenceGuide(sections: readonly TocSection[], included: readonly IncludedArtifact[]): string;
156
170
  /**
157
171
  * Read, clean, and concatenate every artifact declared by the run's manifest
158
172
  * (with discovery fallback when manifest.files is missing), returning a
@@ -13,6 +13,10 @@ import fs from 'fs';
13
13
  import path from 'path';
14
14
  import { ARTIFACT_SECTIONS, MANIFEST_SECTION_ID, MANIFEST_SECTION_TITLE, SUPPLEMENTARY_SECTION_ID, SUPPLEMENTARY_SECTION_TITLE, TRADECRAFT_SECTION_ID, TRADECRAFT_SECTION_TITLE, } from './artifact-order.js';
15
15
  import { cleanArtifact, githubBlobUrl } from './clean-artifact.js';
16
+ /** Id of the generated reader guide section. */
17
+ export const READER_GUIDE_SECTION_ID = 'reader-intelligence-guide';
18
+ /** Display title of the generated reader guide section. */
19
+ export const READER_GUIDE_SECTION_TITLE = 'Reader Intelligence Guide';
16
20
  /**
17
21
  * Extract every string entry from a single `files` value (which may be an
18
22
  * array of strings or a `path β†’ description` object). Split out so
@@ -92,6 +96,11 @@ export function expandSectionArtifacts(section, available, consumed) {
92
96
  else if (available.has(entry) && !consumed.has(entry)) {
93
97
  out.push(entry);
94
98
  consumed.add(entry);
99
+ // `executive-brief.md` is the canonical Riksdagsmonitor-aligned path;
100
+ // `extended/executive-brief.md` remains a compatibility fallback. When
101
+ // both exist, render only the canonical root file.
102
+ if (section.id === 'executive-brief')
103
+ break;
95
104
  }
96
105
  }
97
106
  return out;
@@ -274,6 +283,74 @@ export function renderAnalysisIndex(included, manifestRelPath) {
274
283
  '',
275
284
  ].join('\n');
276
285
  }
286
+ /** Reader-guide copy for high-value intelligence sections. */
287
+ const READER_GUIDE_VALUES = {
288
+ 'section-executive-brief': {
289
+ need: 'BLUF and editorial decisions',
290
+ value: 'fast answer to what happened, why it matters, who is accountable, and the next dated trigger',
291
+ },
292
+ 'section-synthesis': {
293
+ need: 'Integrated thesis',
294
+ value: 'the lead political reading that connects facts, actors, risks, and confidence',
295
+ },
296
+ 'section-significance': {
297
+ need: 'Significance scoring',
298
+ value: 'why this story outranks or trails other same-day European Parliament signals',
299
+ },
300
+ 'section-coalitions-voting': {
301
+ need: 'Coalitions and voting',
302
+ value: 'political group alignment, voting evidence, and coalition pressure points',
303
+ },
304
+ 'section-stakeholder-map': {
305
+ need: 'Stakeholder impact',
306
+ value: 'who gains, who loses, and which institutions or citizens feel the policy effect',
307
+ },
308
+ 'section-economic-context': {
309
+ need: 'IMF-backed economic context',
310
+ value: 'macro, fiscal, trade, or monetary evidence that changes the political interpretation',
311
+ },
312
+ 'section-scenarios': {
313
+ need: 'Forward indicators',
314
+ value: 'dated watch items that let readers verify or falsify the assessment later',
315
+ },
316
+ 'section-risk': {
317
+ need: 'Risk assessment',
318
+ value: 'policy, institutional, coalition, communications, and implementation risk register',
319
+ },
320
+ };
321
+ /**
322
+ * Render the generated reader-intelligence guide that appears before the
323
+ * artifact sections. It gives readers a Riksdagsmonitor-style navigation layer
324
+ * without requiring agents to hand-author another artifact.
325
+ *
326
+ * @param sections - Emitted section TOC entries, in document order
327
+ * @param included - Included artifacts, used to name each section's source
328
+ * @returns Markdown block containing the guide table
329
+ */
330
+ export function renderReaderIntelligenceGuide(sections, included) {
331
+ const rows = sections
332
+ .map((section) => {
333
+ const copy = Object.getOwnPropertyDescriptor(READER_GUIDE_VALUES, section.id)?.value;
334
+ if (!copy)
335
+ return '';
336
+ const source = included.find((artifact) => artifact.sectionId === section.id)?.runRelPath;
337
+ const label = source ? `\`${source}\`` : section.title;
338
+ return `| [${copy.need}](#${section.id}) | ${copy.value} | ${label} |`;
339
+ })
340
+ .filter(Boolean);
341
+ if (rows.length === 0)
342
+ return '';
343
+ return [
344
+ `<h2 id="${READER_GUIDE_SECTION_ID}">${READER_GUIDE_SECTION_TITLE}</h2>`,
345
+ '',
346
+ 'Use this guide to read the article as a political-intelligence product rather than a raw artifact dump. High-value reader lenses appear first; technical provenance remains available in the audit appendices.',
347
+ '',
348
+ "| Reader need | What you'll get | Source artifact |",
349
+ '|---|---|---|',
350
+ ...rows,
351
+ '',
352
+ ].join('\n');
353
+ }
277
354
  /**
278
355
  * Read a single artifact, clean it, and return the Markdown lines that
279
356
  * should be appended to the aggregated document along with the provenance
@@ -455,8 +532,12 @@ export function aggregateAnalysisRun(options) {
455
532
  });
456
533
  const tradecraft = renderTradecraftAppendix(tradecraftFiles);
457
534
  const analysisIndex = renderAnalysisIndex(includedArtifacts, manifestRelPath);
535
+ const readerGuide = renderReaderIntelligenceGuide(emittedSections, includedArtifacts);
458
536
  // Both appendices emit their own <h2 id="…"> blocks β€” record them so the
459
537
  // article TOC mirrors the rendered document in document order.
538
+ if (readerGuide) {
539
+ emittedSections.unshift({ id: READER_GUIDE_SECTION_ID, title: READER_GUIDE_SECTION_TITLE });
540
+ }
460
541
  emittedSections.push({ id: TRADECRAFT_SECTION_ID, title: TRADECRAFT_SECTION_TITLE });
461
542
  emittedSections.push({ id: MANIFEST_SECTION_ID, title: MANIFEST_SECTION_TITLE });
462
543
  const markdown = [
@@ -464,6 +545,8 @@ export function aggregateAnalysisRun(options) {
464
545
  '',
465
546
  provenance,
466
547
  '',
548
+ readerGuide,
549
+ '',
467
550
  ...sectionMarkdown,
468
551
  '',
469
552
  tradecraft,
@@ -261,6 +261,46 @@ export function extractDefaultDescription(markdown) {
261
261
  const strong = extractStrongProseLine(markdown);
262
262
  return strong.length > 0 ? strong : FALLBACK_DESCRIPTION;
263
263
  }
264
+ /**
265
+ * Escape a string for a conservative double-quoted YAML scalar.
266
+ *
267
+ * @param value - Raw metadata value
268
+ * @returns YAML-safe quoted string content (without surrounding quotes)
269
+ */
270
+ function yamlEscape(value) {
271
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, ' ');
272
+ }
273
+ /**
274
+ * Build the Jekyll-compatible Markdown source committed as `article.md`.
275
+ * The renderer strips this front matter before HTML conversion, while the
276
+ * source file stays portable to Jekyll/GitHub Pages and aligned with the
277
+ * Riksdagsmonitor article contract.
278
+ *
279
+ * @param aggregated - Aggregated analysis body and run metadata
280
+ * @param metadata - English metadata resolved for SEO
281
+ * @param metadata.title - Resolved English article title
282
+ * @param metadata.description - Resolved English article description
283
+ * @param slug - Article slug used by generated news paths
284
+ * @param sourceFolder - Repo-relative analysis run directory
285
+ * @returns Markdown with YAML front matter followed by the aggregate body
286
+ */
287
+ function buildJekyllArticleMarkdown(aggregated, metadata, slug, sourceFolder) {
288
+ const frontMatter = [
289
+ '---',
290
+ `title: "${yamlEscape(metadata.title)}"`,
291
+ `description: "${yamlEscape(metadata.description)}"`,
292
+ `date: ${aggregated.date}`,
293
+ `article_type: ${aggregated.articleType}`,
294
+ `slug: ${slug}`,
295
+ `source_folder: ${sourceFolder}`,
296
+ `generated_at: ${aggregated.date}T00:00:00.000Z`,
297
+ 'language: en',
298
+ 'layout: article',
299
+ '---',
300
+ '',
301
+ ].join('\n');
302
+ return `${frontMatter}${aggregated.markdown}`;
303
+ }
264
304
  /**
265
305
  * Render a single language-variant article. Pulls from a pre-translated
266
306
  * `<slug>.<lang>.md` file when it exists, otherwise renders the English
@@ -397,13 +437,15 @@ export function generateArticle(opts, runSuffix, articleCountOverride) {
397
437
  const effectiveMetadata = opts.title || opts.description
398
438
  ? applyCliOverrides(resolvedMetadata, opts.title, opts.description)
399
439
  : resolvedMetadata;
440
+ const runDirRelPath = path.relative(opts.repoRoot, opts.runDir).split(path.sep).join('/');
441
+ const sourceMarkdown = buildJekyllArticleMarkdown(aggregated, getMetadataEntry(effectiveMetadata, 'en'), slug, runDirRelPath);
400
442
  // Write article.md INTO the analysis run directory β€” canonical Markdown
401
443
  // source that lives alongside the artifacts that produced it.
402
444
  // This mirrors the riksdagsmonitor pattern where `article.md` is committed
403
445
  // inside `analysis/daily/<date>/<type>/` so every run has a browsable,
404
446
  // version-controlled Markdown source in its own directory.
405
447
  const runArticleMdAbs = path.join(opts.runDir, 'article.md');
406
- fs.writeFileSync(runArticleMdAbs, aggregated.markdown, 'utf8');
448
+ fs.writeFileSync(runArticleMdAbs, sourceMarkdown, 'utf8');
407
449
  const runArticleMdRelPath = path
408
450
  .relative(opts.repoRoot, runArticleMdAbs)
409
451
  .split(path.sep)
@@ -413,10 +455,10 @@ export function generateArticle(opts, runSuffix, articleCountOverride) {
413
455
  ensureDir(opts.outDir);
414
456
  const sourceMdFilename = `${slug}.en.md`;
415
457
  const sourceMdAbs = path.join(opts.outDir, sourceMdFilename);
416
- fs.writeFileSync(sourceMdAbs, aggregated.markdown, 'utf8');
458
+ fs.writeFileSync(sourceMdAbs, sourceMarkdown, 'utf8');
417
459
  const written = [sourceMdFilename];
418
460
  if (!opts.markdownOnly) {
419
- const rendered = renderMarkdown(aggregated.markdown);
461
+ const rendered = renderMarkdown(sourceMarkdown);
420
462
  const chromeOptions = {
421
463
  metadata: effectiveMetadata,
422
464
  // Point the "View source Markdown" link at the canonical run-directory
@@ -181,7 +181,6 @@ ${hreflangLinks}
181
181
  <meta name="theme-color" content="#003399">
182
182
  <link rel="stylesheet" href="../styles.css">
183
183
  <script type="application/ld+json">${jsonLdString}</script>
184
- <script type="module" src="../js/vendor/mermaid.esm.min.mjs" defer></script>
185
184
  <script type="module" src="../js/mermaid-init.js" defer></script>
186
185
  </head>
187
186
  <body>
@@ -208,6 +207,12 @@ ${hreflangLinks}
208
207
 
209
208
  <main id="main" class="site-main article-main">
210
209
  ${tocHtml} <article class="article-body" lang="${safeLang}">
210
+ <header class="article-hero">
211
+ <p class="article-kicker">${escapeHTML(options.articleType.replace(/-/g, ' '))}</p>
212
+ <h1>${escapeHTML(options.title)}</h1>
213
+ <p class="article-dek">${escapeHTML(options.description)}</p>
214
+ <p class="article-meta"><time datetime="${options.date}">${options.date}</time> Β· EU Parliament Monitor</p>
215
+ </header>
211
216
  ${sourceMdLink}
212
217
  ${options.body}
213
218
  </article>
@@ -12,7 +12,7 @@ export const ARTIFACT_SECTIONS = [
12
12
  {
13
13
  id: 'executive-brief',
14
14
  title: 'Executive Brief',
15
- artifacts: ['extended/executive-brief.md'],
15
+ artifacts: ['executive-brief.md', 'extended/executive-brief.md'],
16
16
  },
17
17
  {
18
18
  id: 'synthesis',
@@ -51,6 +51,15 @@ export interface TocEntry {
51
51
  * deflist, mermaid fence override, and table wrapping installed
52
52
  */
53
53
  export declare function buildMarkdownIt(): MarkdownIt;
54
+ /**
55
+ * Strip a leading YAML front matter block from a Markdown document. Generated
56
+ * `article.md` files are Jekyll-compatible, but the deterministic HTML
57
+ * renderer must render the body, not the metadata fence.
58
+ *
59
+ * @param markdown - Markdown with optional `---` front matter at byte 0
60
+ * @returns Markdown body with the front matter removed
61
+ */
62
+ export declare function stripMarkdownFrontMatter(markdown: string): string;
54
63
  /**
55
64
  * Slugify a heading text into a stable URL fragment.
56
65
  *
@@ -47,6 +47,22 @@ export function buildMarkdownIt() {
47
47
  installTableWrapper(md);
48
48
  return md;
49
49
  }
50
+ /**
51
+ * Strip a leading YAML front matter block from a Markdown document. Generated
52
+ * `article.md` files are Jekyll-compatible, but the deterministic HTML
53
+ * renderer must render the body, not the metadata fence.
54
+ *
55
+ * @param markdown - Markdown with optional `---` front matter at byte 0
56
+ * @returns Markdown body with the front matter removed
57
+ */
58
+ export function stripMarkdownFrontMatter(markdown) {
59
+ if (!markdown.startsWith('---\n'))
60
+ return markdown;
61
+ const end = markdown.indexOf('\n---\n', 4);
62
+ if (end === -1)
63
+ return markdown;
64
+ return markdown.slice(end + 5).replace(/^\n+/, '');
65
+ }
50
66
  /**
51
67
  * Slugify a heading text into a stable URL fragment.
52
68
  *
@@ -122,7 +138,7 @@ export function renderMarkdown(markdown, options = {}) {
122
138
  const env = {};
123
139
  if (options.mermaidLabel)
124
140
  env.mermaidLabel = options.mermaidLabel;
125
- const tokens = md.parse(markdown, env);
141
+ const tokens = md.parse(stripMarkdownFrontMatter(markdown), env);
126
142
  const toc = harvestToc(tokens);
127
143
  const html = md.renderer.render(tokens, md.options, env);
128
144
  const mermaidCount = countMermaidTokens(tokens);
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ /**
6
+ * Copy vendored JS libraries from `node_modules/` into `js/vendor/` so the
7
+ * static site can serve every executable bundle from the same origin
8
+ * (S3 + CloudFront) under the strict `script-src 'self'` CSP. No external
9
+ * CDN is allowed (per the EU Parliament Monitor deployment contract).
10
+ *
11
+ * Vendored libraries:
12
+ * - chart.js β†’ js/vendor/chart.umd.min.js
13
+ * - chartjs-plugin-annotation β†’ js/vendor/chartjs-plugin-annotation.min.js
14
+ * - d3 β†’ js/vendor/d3.min.js
15
+ * - mermaid β†’ js/vendor/mermaid/ (entry + chunks/)
16
+ *
17
+ * Mermaid is special: v11+ ships as code-split ESM. The entry
18
+ * `mermaid.esm.min.mjs` does dynamic `import()` on diagram-specific chunks
19
+ * under `dist/chunks/mermaid.esm.min/*.mjs`. To make every diagram type render
20
+ * without external network calls, we copy the **entire mermaid `dist/`
21
+ * directory** (filtered to the `.esm.min` flavour to keep payload small).
22
+ *
23
+ * Idempotent: rerunning overwrites prior copies and leaves licenses in place.
24
+ *
25
+ * Failure modes:
26
+ * - Missing chart.js / d3 / chartjs-plugin-annotation β†’ hard error (these
27
+ * are pinned `devDependencies` and must always be present after `npm ci`).
28
+ * - Missing mermaid β†’ soft error (logged, exit 0). Mermaid is also a pinned
29
+ * `devDependency`, but optional installs (e.g. `npm ci --omit=dev`) may
30
+ * skip it; we want the deploy to succeed without diagrams rather than fail.
31
+ */
32
+
33
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
34
+ import path from 'node:path';
35
+ import process from 'node:process';
36
+
37
+ const ROOT = process.cwd();
38
+ const NODE_MODULES = path.join(ROOT, 'node_modules');
39
+ const VENDOR_DIR = path.join(ROOT, 'js', 'vendor');
40
+
41
+ function ensureDir(dir) {
42
+ mkdirSync(dir, { recursive: true });
43
+ }
44
+
45
+ function writeLicense(targetPath, copyrightText, licenseId) {
46
+ // REUSE-compliant sidecar β€” see REUSE.toml for path-level annotations.
47
+ writeFileSync(
48
+ `${targetPath}.license`,
49
+ `SPDX-FileCopyrightText: ${copyrightText}\nSPDX-License-Identifier: ${licenseId}\n`,
50
+ 'utf8',
51
+ );
52
+ }
53
+
54
+ function copyOrFail(label, srcRel, dstRel, license) {
55
+ const src = path.join(NODE_MODULES, srcRel);
56
+ const dst = path.join(VENDOR_DIR, dstRel);
57
+ if (!existsSync(src)) {
58
+ process.stderr.write(`error: ${label} not installed at ${srcRel}\n`);
59
+ process.exit(1);
60
+ }
61
+ ensureDir(path.dirname(dst));
62
+ copyFileSync(src, dst);
63
+ writeLicense(dst, license.copyright, license.spdx);
64
+ process.stdout.write(` βœ“ ${dstRel}\n`);
65
+ }
66
+
67
+ function copyMermaid() {
68
+ const mermaidDist = path.join(NODE_MODULES, 'mermaid', 'dist');
69
+ const target = path.join(VENDOR_DIR, 'mermaid');
70
+ if (!existsSync(mermaidDist)) {
71
+ process.stdout.write(
72
+ ' ⚠ mermaid not installed (devDependency); skipping diagram bundle.\n',
73
+ );
74
+ return;
75
+ }
76
+ // Idempotency: wipe the existing mermaid tree before copying so stale
77
+ // chunks from a previous mermaid version (or a previous filter set) cannot
78
+ // leak into the deployed bundle. cpSync({force:true}) only overwrites
79
+ // matching paths; it does not remove orphans.
80
+ if (existsSync(target)) {
81
+ rmSync(target, { recursive: true, force: true });
82
+ }
83
+ ensureDir(target);
84
+
85
+ // Copy the minified ESM entry plus its chunk directory. Skip the dev /
86
+ // unminified flavours (`mermaid.esm.mjs`, `mermaid.core.mjs`,
87
+ // `mermaid.js`, etc.) AND skip sourcemaps to keep the deployed payload
88
+ // small (saves ~6 MB and 60+ HTTP requests).
89
+ const wantedTopLevel = new Set(['mermaid.esm.min.mjs']);
90
+
91
+ cpSync(mermaidDist, target, {
92
+ recursive: true,
93
+ force: true,
94
+ filter: (src) => {
95
+ const rel = path.relative(mermaidDist, src);
96
+ if (rel === '') return true; // root dist dir
97
+ // Skip sourcemaps β€” we deploy minified-only.
98
+ if (src.endsWith('.map')) return false;
99
+ const segments = rel.split(path.sep);
100
+ const top = segments[0];
101
+ // Always allow the chunks directory tree we need.
102
+ if (top === 'chunks') {
103
+ if (segments.length === 1) return true;
104
+ const flavour = segments[1];
105
+ return flavour === 'mermaid.esm.min';
106
+ }
107
+ // Top-level: only allow the minified ESM entry.
108
+ if (segments.length === 1) {
109
+ return wantedTopLevel.has(top);
110
+ }
111
+ return false;
112
+ },
113
+ });
114
+
115
+ // REUSE sidecar for the entry file + flavour directory.
116
+ const entry = path.join(target, 'mermaid.esm.min.mjs');
117
+ if (existsSync(entry)) {
118
+ writeLicense(entry, '2014-2026 Mermaid contributors', 'MIT');
119
+ }
120
+ // Also drop a license file at the chunks dir so REUSE lint passes for the
121
+ // generated tree without us having to enumerate every chunk by name.
122
+ const chunksDir = path.join(target, 'chunks', 'mermaid.esm.min');
123
+ if (existsSync(chunksDir)) {
124
+ writeFileSync(
125
+ path.join(target, 'chunks', 'mermaid.esm.min.license'),
126
+ 'SPDX-FileCopyrightText: 2014-2026 Mermaid contributors\nSPDX-License-Identifier: MIT\n',
127
+ 'utf8',
128
+ );
129
+ }
130
+ process.stdout.write(` βœ“ mermaid/ (entry + ${countMjs(target)} mjs chunks)\n`);
131
+ }
132
+
133
+ function countMjs(dir) {
134
+ let n = 0;
135
+ function walk(d) {
136
+ if (!existsSync(d)) return;
137
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
138
+ const p = path.join(d, entry.name);
139
+ if (entry.isDirectory()) walk(p);
140
+ else if (entry.isFile() && entry.name.endsWith('.mjs')) n += 1;
141
+ }
142
+ }
143
+ walk(dir);
144
+ return n;
145
+ }
146
+
147
+ function main() {
148
+ ensureDir(VENDOR_DIR);
149
+ process.stdout.write(`Copying vendor JS libraries to ${path.relative(ROOT, VENDOR_DIR)}/\n`);
150
+
151
+ copyOrFail(
152
+ 'chart.js',
153
+ 'chart.js/dist/chart.umd.min.js',
154
+ 'chart.umd.min.js',
155
+ { copyright: '2014-2024 Chart.js Contributors', spdx: 'MIT' },
156
+ );
157
+ copyOrFail(
158
+ 'chartjs-plugin-annotation',
159
+ 'chartjs-plugin-annotation/dist/chartjs-plugin-annotation.min.js',
160
+ 'chartjs-plugin-annotation.min.js',
161
+ { copyright: '2016-2024 chartjs-plugin-annotation contributors', spdx: 'MIT' },
162
+ );
163
+ copyOrFail(
164
+ 'd3',
165
+ 'd3/dist/d3.min.js',
166
+ 'd3.min.js',
167
+ { copyright: '2010-2024 Mike Bostock', spdx: 'ISC' },
168
+ );
169
+
170
+ copyMermaid();
171
+
172
+ process.stdout.write('βœ… Vendor copy complete.\n');
173
+ }
174
+
175
+ main();