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