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 +67 -4
- package/SECURITY.md +12 -1
- package/package.json +5 -3
- package/scripts/aggregator/analysis-aggregator.d.ts +14 -0
- package/scripts/aggregator/analysis-aggregator.js +83 -0
- package/scripts/aggregator/article-generator.js +45 -3
- package/scripts/aggregator/article-html.js +6 -1
- package/scripts/aggregator/artifact-order.js +1 -1
- package/scripts/aggregator/markdown-renderer.d.ts +9 -0
- package/scripts/aggregator/markdown-renderer.js +17 -1
- package/scripts/copy-vendor.js +175 -0
- package/scripts/mcp/ep-mcp-client.d.ts +142 -9
- package/scripts/mcp/ep-mcp-client.js +277 -17
- package/scripts/mcp/pending-documents.d.ts +157 -0
- package/scripts/mcp/pending-documents.js +278 -0
- package/scripts/mcp/procedure-seen-cache.d.ts +82 -0
- package/scripts/mcp/procedure-seen-cache.js +166 -0
- package/scripts/types/index.d.ts +1 -1
- package/scripts/types/mcp.d.ts +71 -0
- package/scripts/validate-analysis-completeness.js +614 -0
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
|
[](https://euparliamentmonitor.com/playwright-report/index.html)
|
|
106
110
|
[](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/<date>/<slug>/</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_<lang>.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) β
|
|
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
|
|
143
|
-
structured templates per run
|
|
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.
|
|
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": "
|
|
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.
|
|
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,
|
|
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,
|
|
458
|
+
fs.writeFileSync(sourceMdAbs, sourceMarkdown, 'utf8');
|
|
417
459
|
const written = [sourceMdFilename];
|
|
418
460
|
if (!opts.markdownOnly) {
|
|
419
|
-
const rendered = renderMarkdown(
|
|
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>
|
|
@@ -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();
|