euparliamentmonitor 0.8.49 → 0.8.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +5 -9
  2. package/scripts/aggregator/analysis-aggregator.d.ts +4 -26
  3. package/scripts/aggregator/analysis-aggregator.js +2 -2
  4. package/scripts/aggregator/article-generator.d.ts +2 -2
  5. package/scripts/aggregator/article-generator.js +1 -1
  6. package/scripts/aggregator/article-metadata.d.ts +4 -4
  7. package/scripts/aggregator/article-metadata.js +2 -2
  8. package/scripts/aggregator/cli/parse.d.ts +1 -1
  9. package/scripts/aggregator/cli/parse.js +2 -2
  10. package/scripts/aggregator/infra/github-urls.d.ts +1 -1
  11. package/scripts/aggregator/infra/github-urls.js +1 -1
  12. package/scripts/aggregator/manifest/resolver.d.ts +2 -2
  13. package/scripts/aggregator/manifest/resolver.js +2 -2
  14. package/scripts/aggregator/manifest/types.d.ts +10 -8
  15. package/scripts/aggregator/runs/discover.d.ts +1 -1
  16. package/scripts/aggregator/runs/discover.js +1 -1
  17. package/scripts/backport-article-seo.js +9 -9
  18. package/scripts/constants/analysis-constants.d.ts +1 -1
  19. package/scripts/constants/analysis-constants.js +1 -1
  20. package/scripts/generators/political-intelligence-descriptions.d.ts +5 -3
  21. package/scripts/generators/political-intelligence-descriptions.js +2 -2
  22. package/scripts/generators/sitemap/html.js +1 -1
  23. package/scripts/generators/sitemap/rss.js +2 -0
  24. package/scripts/generators/sitemap/xml.js +3 -1
  25. package/scripts/lint-prompts.js +19 -0
  26. package/scripts/mcp/ep-mcp-client.d.ts +1 -1
  27. package/scripts/mcp/ep-mcp-client.js +4 -4
  28. package/scripts/mcp/imf-mcp-client.d.ts +1 -1
  29. package/scripts/mcp/mcp-connection.js +1 -1
  30. package/scripts/types/imf.d.ts +1 -1
  31. package/scripts/types/parliament.d.ts +1 -1
  32. package/scripts/types/world-bank.d.ts +1 -1
  33. package/scripts/utils/file-utils.d.ts +2 -2
  34. package/scripts/utils/file-utils.js +2 -2
  35. package/scripts/validate-analysis-completeness.js +89 -0
  36. package/scripts/index.old.js +0 -125
  37. package/scripts/utils/migrate-legacy-articles.js +0 -225
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.8.49",
3
+ "version": "0.8.51",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -33,14 +33,6 @@
33
33
  "./generators/*": {
34
34
  "import": "./scripts/generators/*.js",
35
35
  "types": "./scripts/generators/*.d.ts"
36
- },
37
- "./generators/pipeline/*": {
38
- "import": "./scripts/generators/pipeline/*.js",
39
- "types": "./scripts/generators/pipeline/*.d.ts"
40
- },
41
- "./generators/strategies/*": {
42
- "import": "./scripts/generators/strategies/*.js",
43
- "types": "./scripts/generators/strategies/*.d.ts"
44
36
  }
45
37
  },
46
38
  "files": [
@@ -84,6 +76,9 @@
84
76
  "test:e2e:report": "playwright show-report",
85
77
  "lint": "eslint src/",
86
78
  "lint:fix": "eslint src/ --fix",
79
+ "knip": "knip",
80
+ "knip:production": "knip --production",
81
+ "knip:fix": "knip --fix",
87
82
  "lint:report": "eslint src/ --format json --output-file builds/test-results/eslint-report.json",
88
83
  "lint:report:html": "eslint src/ --format html --output-file builds/test-results/eslint-report.html",
89
84
  "format": "prettier --write \"src/**/*.ts\"",
@@ -158,6 +153,7 @@
158
153
  "htmlhint": "1.9.2",
159
154
  "husky": "9.1.7",
160
155
  "jscpd": "4.0.9",
156
+ "knip": "^6.7.0",
161
157
  "lint-staged": "16.4.0",
162
158
  "mermaid": "11.14.0",
163
159
  "papaparse": "5.5.3",
@@ -1,27 +1,5 @@
1
1
  import { type ArtifactSection } from './artifact-order.js';
2
- import { type Manifest, type ManifestFiles as _ManifestFiles, type ManifestHistoryEntry as _ManifestHistoryEntry } from './manifest/index.js';
3
- /**
4
- * Raw manifest shape as committed by the analysis pipeline.
5
- *
6
- * @deprecated Use {@link Manifest} from `aggregator/manifest/index.js`.
7
- * This alias is preserved for back-compat with the existing test suite
8
- * and external curators that import `AnalysisManifest` from this module.
9
- */
10
- export type AnalysisManifest = Manifest;
11
- /**
12
- * `manifest.files` can be nested category → paths or flat path → description.
13
- *
14
- * @deprecated Use {@link _ManifestFiles} (`ManifestFiles`) from
15
- * `aggregator/manifest/index.js`.
16
- */
17
- export type ManifestFiles = _ManifestFiles;
18
- /**
19
- * One entry in `manifest.history[]`; only fields we read are typed.
20
- *
21
- * @deprecated Use {@link _ManifestHistoryEntry} (`ManifestHistoryEntry`) from
22
- * `aggregator/manifest/index.js`.
23
- */
24
- export type ManifestHistoryEntry = _ManifestHistoryEntry;
2
+ import { type Manifest, type ManifestFiles } from './manifest/index.js';
25
3
  /** Result of {@link aggregateAnalysisRun}. */
26
4
  export interface AggregatedRun {
27
5
  /** Final Markdown document (provenance + sections + appendices). */
@@ -100,7 +78,7 @@ export declare function flattenManifestFiles(files: ManifestFiles | undefined):
100
78
  * @param manifest - Parsed manifest object
101
79
  * @returns The latest non-PENDING gate result, or `"PENDING"` when none found
102
80
  */
103
- export declare function latestGateResult(manifest: AnalysisManifest): string;
81
+ export declare function latestGateResult(manifest: Manifest): string;
104
82
  /**
105
83
  * Expand an `artifacts` entry from {@link ArtifactSection} into a list of
106
84
  * concrete artifact paths. Exact paths are kept as-is; directory prefixes
@@ -174,7 +152,7 @@ export declare function renderAnalysisIndex(included: readonly IncludedArtifact[
174
152
  */
175
153
  export declare function renderReaderIntelligenceGuide(sections: readonly TocSection[], included: readonly IncludedArtifact[]): string;
176
154
  /**
177
- * Resolve the article-type slug from a manifest, tolerating legacy schemas.
155
+ * Resolve the article-type slug from a manifest, tolerating historic schemas.
178
156
  *
179
157
  * Thin re-export of {@link _resolveArticleType} from
180
158
  * `aggregator/manifest/index.js`. Resolution order: `articleType` →
@@ -183,7 +161,7 @@ export declare function renderReaderIntelligenceGuide(sections: readonly TocSect
183
161
  * @param manifest - Parsed manifest (any of the supported schemas)
184
162
  * @returns Article-type slug usable as a filename component
185
163
  */
186
- export declare function resolveArticleTypeFromManifest(manifest: AnalysisManifest): string;
164
+ export declare function resolveArticleTypeFromManifest(manifest: Manifest): string;
187
165
  /**
188
166
  * Read, clean, and concatenate every artifact declared by the run's manifest
189
167
  * (with discovery fallback when manifest.files is missing), returning a
@@ -141,7 +141,7 @@ function collectRunArtifacts(runDir) {
141
141
  const full = path.join(dir, entry.name);
142
142
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
143
143
  if (entry.isDirectory()) {
144
- // Skip raw payloads, legacy run snapshots, and Pass-1 work-in-progress
144
+ // Skip raw payloads, prior-run snapshots, and Pass-1 work-in-progress
145
145
  // snapshots so they are not rendered as supplementary artifacts.
146
146
  if (entry.name === 'data' || entry.name === 'runs' || entry.name === 'pass1')
147
147
  continue;
@@ -448,7 +448,7 @@ function appendSection(runDir, runDirRelPath, sectionId, sectionTitle, paths, se
448
448
  sectionMarkdown.push('');
449
449
  }
450
450
  /**
451
- * Resolve the article-type slug from a manifest, tolerating legacy schemas.
451
+ * Resolve the article-type slug from a manifest, tolerating historic schemas.
452
452
  *
453
453
  * Thin re-export of {@link _resolveArticleType} from
454
454
  * `aggregator/manifest/index.js`. Resolution order: `articleType` →
@@ -102,11 +102,11 @@ export declare function extractDefaultDescription(markdown: string): string;
102
102
  * @returns Summary of the generated artefacts ({@link GenerateResult})
103
103
  */
104
104
  export declare function generateArticle(opts: CliOptions, runSuffix?: string, articleCountOverride?: number): GenerateResult;
105
- /** Candidate run discovered under `analysis/daily/`. */
106
105
  /**
107
106
  * One run discovered by {@link discoverAnalysisRuns}.
108
107
  *
109
- * @deprecated Re-exported from `aggregator/runs/index.js` for back-compat.
108
+ * Thin re-export of {@link _DiscoveredRun} from `aggregator/runs/index.js`,
109
+ * preserved here as the public type for `article-generator` consumers.
110
110
  */
111
111
  export type DiscoveredRun = _DiscoveredRun;
112
112
  /**
@@ -261,7 +261,7 @@ const FALLBACK_DESCRIPTION = 'EU Parliament intelligence summary derived from co
261
261
  */
262
262
  export function extractDefaultDescription(markdown) {
263
263
  // Suppress unused warning: keep `shouldSkipDescriptionLine` for any
264
- // legacy consumer importing it transitively.
264
+ // historic consumer importing it transitively.
265
265
  void shouldSkipDescriptionLine;
266
266
  const strong = extractStrongProseLine(markdown);
267
267
  return strong.length > 0 ? strong : FALLBACK_DESCRIPTION;
@@ -8,9 +8,9 @@ export interface ResolvedMetadataEntry {
8
8
  export type ResolvedMetadata = LanguageMap<ResolvedMetadataEntry>;
9
9
  /**
10
10
  * Raw manifest subset consumed by the resolver. Deliberately narrower
11
- * than the full {@link AnalysisManifest} shape so the resolver stays
12
- * usable for backport (which only has the manifest in text form) and for
13
- * callers that don't need the full typed structure.
11
+ * than the full {@link import('./manifest/types.js').Manifest} shape so
12
+ * the resolver stays usable for backport (which only has the manifest in
13
+ * text form) and for callers that don't need the full typed structure.
14
14
  */
15
15
  export interface MetadataManifest {
16
16
  readonly articleType?: string;
@@ -40,7 +40,7 @@ export interface ResolveMetadataOptions {
40
40
  readonly date: string;
41
41
  /** Aggregated Markdown document body (after provenance/header). */
42
42
  readonly markdown: string;
43
- /** Parsed analysis manifest (may be empty for legacy/backport callers). */
43
+ /** Parsed analysis manifest (may be empty for historic/backport callers). */
44
44
  readonly manifest?: MetadataManifest;
45
45
  /**
46
46
  * Absolute path to the analysis run directory so the resolver can
@@ -24,7 +24,7 @@
24
24
  * 3. **Aggregated-markdown H1** — the first `# …` heading in the aggregator
25
25
  * output, accepted under the same non-generic rule. In practice this
26
26
  * tier rarely fires because the aggregator itself writes the generic
27
- * default, but it covers hand-edited or legacy aggregates.
27
+ * default, but it covers hand-edited or historic aggregates.
28
28
  * 4. **First strong prose paragraph** — the first line of the aggregated
29
29
  * Markdown that survives {@link shouldSkipDescriptionLine}. Used for
30
30
  * `description`; also used for `title` as a last editorial-content
@@ -313,7 +313,7 @@ export function isGenericHeading(heading, articleType, date) {
313
313
  `${human} ${date}`,
314
314
  ];
315
315
  // Also accept the collision-suffix pattern (e.g. `Breaking Breaking — …`)
316
- // and the auto-generated "EU Parliament <Type> — <date>" legacy form.
316
+ // and the auto-generated "EU Parliament <Type> — <date>" historic form.
317
317
  const humanRedundant = `${human} ${human}`;
318
318
  for (const p of patterns) {
319
319
  if (normalized === p)
@@ -39,7 +39,7 @@ export declare const HELP_TEXT: string;
39
39
  * - `{kind:'options', value}` — argv parsed cleanly; `value` is ready to
40
40
  * pass to `generateArticle` / `generateAllArticles`.
41
41
  *
42
- * Compared to the legacy `parseCliArgs` in `article-generator.ts` (which
42
+ * Compared to the original `parseCliArgs` in `article-generator.ts` (which
43
43
  * throws and calls `process.exit` on `--help`), this entry point keeps
44
44
  * tests self-contained.
45
45
  *
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * @module Aggregator/Cli/Parse
5
5
  * @description Pure CLI parser that returns a discriminated union instead
6
- * of calling `process.exit` mid-parse. The legacy `parseCliArgs` entry
6
+ * of calling `process.exit` mid-parse. The original `parseCliArgs` entry
7
7
  * point in `article-generator.ts` is preserved for backward compatibility
8
8
  * with existing callers and tests; new callers and unit tests should
9
9
  * prefer {@link parseCliArgsSafe} so the `--help` and error branches are
@@ -202,7 +202,7 @@ function processArgvToken(argv, index, acc) {
202
202
  * - `{kind:'options', value}` — argv parsed cleanly; `value` is ready to
203
203
  * pass to `generateArticle` / `generateAllArticles`.
204
204
  *
205
- * Compared to the legacy `parseCliArgs` in `article-generator.ts` (which
205
+ * Compared to the original `parseCliArgs` in `article-generator.ts` (which
206
206
  * throws and calls `process.exit` on `--help`), this entry point keeps
207
207
  * tests self-contained.
208
208
  *
@@ -7,7 +7,7 @@
7
7
  * `githubRawUrl`) and `article-generator.ts` (which embedded the same slug
8
8
  * literally inside an `isBasedOn` template string).
9
9
  *
10
- * Every consumer should import from here; the legacy entry points in
10
+ * Every consumer should import from here; the original entry points in
11
11
  * `clean-artifact.ts` are preserved as thin re-export shims for back-compat.
12
12
  */
13
13
  /** Hack23 repo slug used when building blob/raw/tree URLs. */
@@ -9,7 +9,7 @@
9
9
  * `githubRawUrl`) and `article-generator.ts` (which embedded the same slug
10
10
  * literally inside an `isBasedOn` template string).
11
11
  *
12
- * Every consumer should import from here; the legacy entry points in
12
+ * Every consumer should import from here; the original entry points in
13
13
  * `clean-artifact.ts` are preserved as thin re-export shims for back-compat.
14
14
  */
15
15
  /** Hack23 repo slug used when building blob/raw/tree URLs. */
@@ -10,12 +10,12 @@ import type { Manifest, ManifestFiles } from './types.js';
10
10
  /** Sentinel used when no schema variant supplies a usable article type. */
11
11
  export declare const UNKNOWN_ARTICLE_TYPE = "unknown";
12
12
  /**
13
- * Resolve the article-type slug from a manifest, tolerating legacy schemas.
13
+ * Resolve the article-type slug from a manifest, tolerating historic schemas.
14
14
  *
15
15
  * Resolution order (highest precedence first):
16
16
  * 1. `articleType` — canonical singular field
17
17
  * 2. `articleTypes[0]` — pre-aggregator-pipeline plural array
18
- * 3. `runType` — legacy field on older breaking-run manifests
18
+ * 3. `runType` — historic field on older breaking-run manifests
19
19
  *
20
20
  * Falls back to `'unknown'` when none of the above is a non-empty string.
21
21
  *
@@ -3,12 +3,12 @@
3
3
  /** Sentinel used when no schema variant supplies a usable article type. */
4
4
  export const UNKNOWN_ARTICLE_TYPE = 'unknown';
5
5
  /**
6
- * Resolve the article-type slug from a manifest, tolerating legacy schemas.
6
+ * Resolve the article-type slug from a manifest, tolerating historic schemas.
7
7
  *
8
8
  * Resolution order (highest precedence first):
9
9
  * 1. `articleType` — canonical singular field
10
10
  * 2. `articleTypes[0]` — pre-aggregator-pipeline plural array
11
- * 3. `runType` — legacy field on older breaking-run manifests
11
+ * 3. `runType` — historic field on older breaking-run manifests
12
12
  *
13
13
  * Falls back to `'unknown'` when none of the above is a non-empty string.
14
14
  *
@@ -2,8 +2,8 @@
2
2
  * @module Aggregator/Manifest/Types
3
3
  * @description Canonical manifest schema for analysis runs and the narrower
4
4
  * projection consumed by the editorial-metadata resolver. Centralises every
5
- * historic schema variant (canonical `articleType`, legacy plural
6
- * `articleTypes[]`, very-legacy `runType`) into one type that downstream
5
+ * historic schema variant (canonical `articleType`, plural
6
+ * `articleTypes[]`, original `runType`) into one type that downstream
7
7
  * modules can read against.
8
8
  */
9
9
  import type { LanguageCode } from '../../types/index.js';
@@ -28,20 +28,22 @@ export type ManifestMetadataOverride = string | Partial<Record<LanguageCode, str
28
28
  /**
29
29
  * Raw manifest shape as committed by the analysis pipeline. Matches every
30
30
  * schema variant the pipeline has ever emitted; readers consult
31
- * {@link resolveArticleType} rather than `articleType` directly so legacy
31
+ * {@link resolveArticleType} rather than `articleType` directly so historic
32
32
  * runs stay readable.
33
33
  */
34
34
  export interface Manifest {
35
35
  /** Canonical singular form (current pipeline). */
36
36
  readonly articleType?: string;
37
37
  /**
38
- * Legacy plural form emitted by some pre-aggregator-pipeline workflows.
39
- * When present, `articleTypes[0]` is treated as the article type.
38
+ * Plural form emitted by some pre-aggregator-pipeline workflows (historic
39
+ * schema variant). When present, `articleTypes[0]` is treated as the
40
+ * article type.
40
41
  */
41
42
  readonly articleTypes?: readonly string[];
42
43
  /**
43
- * Very-legacy field on older breaking-run manifests. Used as the last
44
- * fallback when neither `articleType` nor `articleTypes` is present.
44
+ * Original field on older breaking-run manifests (historic schema variant).
45
+ * Used as the last fallback when neither `articleType` nor `articleTypes`
46
+ * is present.
45
47
  */
46
48
  readonly runType?: string;
47
49
  /** Stable run identifier; falls back to the run-dir basename. */
@@ -65,7 +67,7 @@ export interface Manifest {
65
67
  * Narrower manifest projection consumed by {@link resolveArticleMetadata}
66
68
  * in `aggregator/article-metadata.ts`. The metadata resolver only needs a
67
69
  * subset; keeping this projection separate means string-only callers
68
- * (backport, legacy curators) don't have to construct a full {@link Manifest}.
70
+ * (backport, historic curators) don't have to construct a full {@link Manifest}.
69
71
  */
70
72
  export interface MetadataManifest {
71
73
  readonly articleType?: string;
@@ -34,7 +34,7 @@ export declare function readRunCandidate(runDir: string): DiscoveredRun | null;
34
34
  * The walk stops descending into a directory the moment it sees a
35
35
  * `manifest.json`, so nested artifact subdirectories never get reported
36
36
  * as separate runs. Results are sorted by date ascending then by path
37
- * lexically — the same order used by the legacy implementation in
37
+ * lexically — the same order used by the previous in-line implementation in
38
38
  * `article-generator.ts`.
39
39
  *
40
40
  * @param repoRoot - Absolute repository root
@@ -52,7 +52,7 @@ export function readRunCandidate(runDir) {
52
52
  * The walk stops descending into a directory the moment it sees a
53
53
  * `manifest.json`, so nested artifact subdirectories never get reported
54
54
  * as separate runs. Results are sorted by date ascending then by path
55
- * lexically — the same order used by the legacy implementation in
55
+ * lexically — the same order used by the previous in-line implementation in
56
56
  * `article-generator.ts`.
57
57
  *
58
58
  * @param repoRoot - Absolute repository root
@@ -388,7 +388,7 @@ function extractBodyFirstProse(articleHtml) {
388
388
  * 1. If a manifest.json for the run exists (aggregator cohort), use the
389
389
  * full {@link resolveArticleMetadata} pipeline — this picks up manifest
390
390
  * overrides and artefact H1s.
391
- * 2. Otherwise (legacy cohort), derive from the rendered body:
391
+ * 2. Otherwise (historic cohort), derive from the rendered body:
392
392
  * - Title = non-generic `<h1>` from the body, else first sentence of
393
393
  * the first strong prose paragraph.
394
394
  * - Description = first strong prose paragraph (full, not the same
@@ -412,7 +412,7 @@ function deriveMetadataForFile(file, html) {
412
412
  // fallback for `committee-reports` renders realistic abbreviations
413
413
  // (`ENVI, ECON, AFET, LIBE, AGRI`) instead of the placeholder
414
414
  // `Main Committees`. This keeps the localized template consistent
415
- // with the legacy format even when the manifest is missing.
415
+ // with the historic format even when the manifest is missing.
416
416
  const committee = extractCommitteeCodes(bodyH1) || extractCommitteeCodes(bodyProse);
417
417
 
418
418
  const resolved = resolveArticleMetadata({
@@ -425,7 +425,7 @@ function deriveMetadataForFile(file, html) {
425
425
 
426
426
  if (file.lang !== 'en') {
427
427
  // NON-ENGLISH files: The article body may be in a different language
428
- // than the file claims to be — legacy files have localized H1/chrome
428
+ // than the file claims to be — historic files have localized H1/chrome
429
429
  // but English body prose; aggregator PR#1404 files have English H1
430
430
  // AND English body in every language variant. We accept body content
431
431
  // only when it is plausibly in the file's language, and fall back to
@@ -658,9 +658,9 @@ function buildSyntheticMarkdown(h1, prose) {
658
658
 
659
659
  /**
660
660
  * Choose the final title text. Prefers a non-generic body H1. When the
661
- * H1 is generic (e.g. legacy "Legislative Procedures: European Parliament
661
+ * H1 is generic (e.g. historic "Legislative Procedures: European Parliament
662
662
  * Monitor"), falls back to the first sentence of body prose — this is
663
- * the single biggest SEO win for legacy files.
663
+ * the single biggest SEO win for historic files.
664
664
  *
665
665
  * @param {string} bodyH1 - First H1 from the body
666
666
  * @param {string} bodyProse - First strong prose paragraph
@@ -681,7 +681,7 @@ function chooseTitle(bodyH1, bodyProse, templateTitle, file) {
681
681
 
682
682
  /**
683
683
  * Extend {@link isGenericHeading} with a few extra patterns specific to
684
- * legacy-era titles (pre-aggregator pipeline) so those files get
684
+ * historic-era titles (pre-aggregator pipeline) so those files get
685
685
  * replaced during backport. Also catches the pure `<Title-Case-Phrase>
686
686
  * — <ISO-date>` form that the default aggregator title emits when the
687
687
  * articleType slug has a run suffix (e.g. `breaking-190`) that
@@ -695,7 +695,7 @@ function chooseTitle(bodyH1, bodyProse, templateTitle, file) {
695
695
  function isGenericBodyH1(h1, articleType, date) {
696
696
  if (isGenericHeading(h1, articleType, date)) return true;
697
697
  const normalized = h1.trim();
698
- const legacyTemplates = [
698
+ const historicTemplates = [
699
699
  'Legislative Procedures: European Parliament Monitor',
700
700
  'EU Parliament Committee Activity Report',
701
701
  'EU Parliament Breaking',
@@ -703,7 +703,7 @@ function isGenericBodyH1(h1, articleType, date) {
703
703
  'Plenary Votes & Resolutions',
704
704
  'Plenary Votes and Resolutions',
705
705
  ];
706
- for (const t of legacyTemplates) {
706
+ for (const t of historicTemplates) {
707
707
  if (normalized === t || normalized.startsWith(`${t} `) || normalized.startsWith(`${t}:`)) {
708
708
  return true;
709
709
  }
@@ -814,7 +814,7 @@ function rewriteHtml(html, metadata) {
814
814
  /**
815
815
  * Replace `<meta name="<name>" content="…">` in-place. When absent the
816
816
  * document is returned unchanged — we never inject new tags during
817
- * backport so legacy files retain their original meta-tag order.
817
+ * backport so historic files retain their original meta-tag order.
818
818
  *
819
819
  * The `content` match is quote-aware: the content of a double-quoted
820
820
  * attribute value may contain apostrophes (e.g. `Parliament's`), so the
@@ -27,7 +27,7 @@ export declare const AI_MARKER = "[AI_ANALYSIS_REQUIRED]";
27
27
  *
28
28
  * Recognises three marker formats:
29
29
  * - `[AI_ANALYSIS_REQUIRED]` — the current standard marker (v3.0+)
30
- * - `[REQUIRED]` — legacy marker used in template stubs before v3.0
30
+ * - `[REQUIRED]` — historic marker used in template stubs before v3.0
31
31
  * - `[?]` — shorthand used in some early methodology templates
32
32
  *
33
33
  * @param text - Text to test
@@ -29,7 +29,7 @@ export const AI_MARKER = '[AI_ANALYSIS_REQUIRED]';
29
29
  *
30
30
  * Recognises three marker formats:
31
31
  * - `[AI_ANALYSIS_REQUIRED]` — the current standard marker (v3.0+)
32
- * - `[REQUIRED]` — legacy marker used in template stubs before v3.0
32
+ * - `[REQUIRED]` — historic marker used in template stubs before v3.0
33
33
  * - `[?]` — shorthand used in some early methodology templates
34
34
  *
35
35
  * @param text - Text to test
@@ -25,8 +25,10 @@ import type { LanguageCode } from '../types/index.js';
25
25
  /** Per-language text overlay keyed by 2-letter language code. */
26
26
  export type TextI18n = Partial<Record<LanguageCode, string>>;
27
27
  /**
28
- * @deprecated use {@link TextI18n}. Kept as an alias so consumers outside
29
- * this module don't break across the title-localization refactor.
28
+ * Back-compat alias for {@link TextI18n}. Preserved so downstream
29
+ * TypeScript consumers that import this name from
30
+ * `euparliamentmonitor/generators/political-intelligence-descriptions`
31
+ * keep compiling. Prefer `TextI18n` for new code.
30
32
  */
31
33
  export type DescriptionI18n = TextI18n;
32
34
  /** One curated entry for a methodology / template / reference file. */
@@ -122,7 +124,7 @@ export declare function hasCuratedTitle(relPath: string): boolean;
122
124
  * this is where all 14-language localization is maintained)
123
125
  * 2. Curated English title from {@link CURATED_TITLES} (`.en` overlay)
124
126
  * 3. Per-entry `titleI18n[lang]` on a `CURATED_DESCRIPTIONS` entry
125
- * (legacy path; retained so future entries can colocate title + desc)
127
+ * (historic path; retained so future entries can colocate title + desc)
126
128
  * 4. Per-entry `title` on a `CURATED_DESCRIPTIONS` entry
127
129
  * 5. `fallback` — the H1-extracted title from the source Markdown
128
130
  *
@@ -2047,7 +2047,7 @@ export function hasCuratedTitle(relPath) {
2047
2047
  * this is where all 14-language localization is maintained)
2048
2048
  * 2. Curated English title from {@link CURATED_TITLES} (`.en` overlay)
2049
2049
  * 3. Per-entry `titleI18n[lang]` on a `CURATED_DESCRIPTIONS` entry
2050
- * (legacy path; retained so future entries can colocate title + desc)
2050
+ * (historic path; retained so future entries can colocate title + desc)
2051
2051
  * 4. Per-entry `title` on a `CURATED_DESCRIPTIONS` entry
2052
2052
  * 5. `fallback` — the H1-extracted title from the source Markdown
2053
2053
  *
@@ -2073,7 +2073,7 @@ export function getCuratedTitle(relPath, lang, fallback) {
2073
2073
  if (titleEntry.en)
2074
2074
  return titleEntry.en;
2075
2075
  }
2076
- // 3 + 4: legacy colocated title on CURATED_DESCRIPTIONS entry
2076
+ // 3 + 4: historic colocated title on CURATED_DESCRIPTIONS entry
2077
2077
  // eslint-disable-next-line security/detect-object-injection
2078
2078
  const descEntry = CURATED_DESCRIPTIONS[key];
2079
2079
  if (descEntry) {
@@ -13,7 +13,7 @@
13
13
  * future renderer (Atom-feed metadata, OG-card builder, etc.) without
14
14
  * having to import `sitemap.ts` and pull in the entire CLI surface.
15
15
  *
16
- * Output is byte-identical to the legacy in-line implementation that
16
+ * Output is byte-identical to the previous in-line implementation that
17
17
  * lived in `sitemap.ts` between Apr-2026 and the bounded-context
18
18
  * refactor — verified by the regression test in
19
19
  * `test/unit/sitemap-byte-equality.test.js` (compares against the
@@ -37,6 +37,7 @@ export function generateRssFeed(articleInfos, buildDate = new Date().toUTCString
37
37
  <dc:language>${escapeXML(item.lang)}</dc:language>
38
38
  </item>`)
39
39
  .join('\n');
40
+ // REUSE-IgnoreStart
40
41
  return `<?xml version="1.0" encoding="UTF-8"?>
41
42
  <!-- SPDX-FileCopyrightText: 2024-2026 Hack23 AB -->
42
43
  <!-- SPDX-License-Identifier: Apache-2.0 -->
@@ -51,5 +52,6 @@ export function generateRssFeed(articleInfos, buildDate = new Date().toUTCString
51
52
  ${items}
52
53
  </channel>
53
54
  </rss>`;
55
+ // REUSE-IgnoreEnd
54
56
  }
55
57
  //# sourceMappingURL=rss.js.map
@@ -14,7 +14,7 @@
14
14
  * pure (no HTML chrome dependencies), and so future XML output formats
15
15
  * (news-sitemap, video-sitemap) can reuse the same URL builders.
16
16
  *
17
- * Output is byte-identical to the legacy in-line implementation that
17
+ * Output is byte-identical to the previous in-line implementation that
18
18
  * lived in `sitemap.ts`, verified by the byte-equality regression test.
19
19
  */
20
20
  import fs from 'fs';
@@ -86,12 +86,14 @@ export function generateSitemap(articles, docsFiles = []) {
86
86
  ...buildArticleUrls(articles),
87
87
  ...buildDocsUrls(docsFiles, today),
88
88
  ];
89
+ // REUSE-IgnoreStart
89
90
  return `<?xml version="1.0" encoding="UTF-8"?>
90
91
  <!-- SPDX-FileCopyrightText: 2024-2026 Hack23 AB -->
91
92
  <!-- SPDX-License-Identifier: Apache-2.0 -->
92
93
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
93
94
  ${urls.map(renderSitemapUrl).join('\n')}
94
95
  </urlset>`;
96
+ // REUSE-IgnoreEnd
95
97
  }
96
98
  /**
97
99
  * Build the absolute URL for a language-specific index page.
@@ -96,6 +96,25 @@ const FORBIDDEN_PHRASES = [
96
96
  /\bnews-<type>-analysis\.md\b/i,
97
97
  /\bnews-<type>-article\.md\b/i,
98
98
  /\bgenerate-news\b(?!-indexes\b)/i,
99
+ // IMF-primary editorial policy: IMF is the SOLE authoritative source
100
+ // for every economic / fiscal / monetary / trade / FDI / exchange-rate /
101
+ // banking-soundness claim. World Bank is for non-economic domains.
102
+ // Workflow prompts must use IMF for economic context. We catch the
103
+ // forbidden listings:
104
+ //
105
+ // - "World Bank **or** IMF" (bold or plain)
106
+ // - "IMF **or** World Bank" (bold or plain)
107
+ // - "WB or IMF" / "IMF or WB"
108
+ // - "WB/IMF" / "IMF/WB" inside an economic context phrase
109
+ //
110
+ // See .github/skills/imf-data-integration.md and
111
+ // analysis/methodologies/imf-indicator-mapping.md §8.
112
+ /\bWorld\s+Bank\s+(?:\*\*)?or(?:\*\*)?\s+IMF\b/i,
113
+ /\bIMF\s+(?:\*\*)?or(?:\*\*)?\s+World\s+Bank\b/i,
114
+ /\bWB\s+or\s+IMF\b/i,
115
+ /\bIMF\s+or\s+WB\b/i,
116
+ /economic\s+context[^.\n]{0,40}\bWB\s*\/\s*IMF\b/i,
117
+ /economic\s+context[^.\n]{0,40}\bIMF\s*\/\s*WB\b/i,
99
118
  // Note: the AI_MARKER / [AI_ANALYSIS_REQUIRED] / FALLBACK_TEMPLATE_PATTERNS
100
119
  // string tokens are NOT banned — workflow prompts legitimately instruct the
101
120
  // agent "no [AI_ANALYSIS_REQUIRED] markers may remain in committed
@@ -27,7 +27,7 @@ export declare const EP_MCP_TOOLS: readonly string[];
27
27
  * Hack23/European-Parliament-MCP-Server#301 and extended to
28
28
  * `get_events_feed`/`get_procedures_feed` by
29
29
  * Hack23/European-Parliament-MCP-Server#380 (which closed #378).
30
- * 2. **Legacy raw upstream 404 shape** (historically emitted pre-v1.2.13 by
30
+ * 2. **Pre-v1.2.13 raw upstream 404 shape** (historically emitted pre-v1.2.13 by
31
31
  * `get_events_feed` / `get_procedures_feed`, fixed upstream in PR #380) —
32
32
  * `{"@id":"https://data.europarl.europa.eu/eli/dl/...","error":"404 N..."}`.
33
33
  * Retained purely as defense-in-depth for older pinned server versions or
@@ -194,7 +194,7 @@ function _parseResultPayload(result) {
194
194
  * Hack23/European-Parliament-MCP-Server#301 and extended to
195
195
  * `get_events_feed`/`get_procedures_feed` by
196
196
  * Hack23/European-Parliament-MCP-Server#380 (which closed #378).
197
- * 2. **Legacy raw upstream 404 shape** (historically emitted pre-v1.2.13 by
197
+ * 2. **Pre-v1.2.13 raw upstream 404 shape** (historically emitted pre-v1.2.13 by
198
198
  * `get_events_feed` / `get_procedures_feed`, fixed upstream in PR #380) —
199
199
  * `{"@id":"https://data.europarl.europa.eu/eli/dl/...","error":"404 N..."}`.
200
200
  * Retained purely as defense-in-depth for older pinned server versions or
@@ -214,7 +214,7 @@ export function isFeedUnavailable(result) {
214
214
  // Shape 1 — uniform {status:"unavailable"} envelope (#301 / #380).
215
215
  if (envelope['status'] === 'unavailable')
216
216
  return true;
217
- // Shape 2 — legacy raw upstream 404 leak (historically pre-v1.2.13, #378).
217
+ // Shape 2 — pre-v1.2.13 raw upstream 404 leak (historically pre-v1.2.13, #378).
218
218
  const error = envelope['error'];
219
219
  const idField = envelope['@id'];
220
220
  if (typeof error === 'string' &&
@@ -404,7 +404,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
404
404
  return this._recordToolFailure(toolName, result.content?.[0]?.text ?? '', fallbackText);
405
405
  }
406
406
  // Detect the unavailable-feed envelope — uniform `{status:"unavailable"}`
407
- // (all feeds as of v1.2.13, #301/#380) as well as the legacy raw upstream
407
+ // (all feeds as of v1.2.13, #301/#380) as well as the pre-v1.2.13 raw upstream
408
408
  // 404 shape `{"@id":..., "error":"404 ..."}` that pre-v1.2.13
409
409
  // get_events_feed / get_procedures_feed emitted
410
410
  // (Hack23/European-Parliament-MCP-Server#378, closed by PR #380). The
@@ -1269,7 +1269,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
1269
1269
  this._slowFeedWarnings.delete('get_events_feed');
1270
1270
  return this._recordToolFailure('get_events_feed', result.content?.[0]?.text ?? '', EuropeanParliamentMCPClient.FEED_FALLBACK);
1271
1271
  }
1272
- // Detect unavailable-feed envelope (uniform {status:"unavailable"} or legacy 404)
1272
+ // Detect unavailable-feed envelope (uniform {status:"unavailable"} or pre-v1.2.13 404)
1273
1273
  if (isFeedUnavailable(result)) {
1274
1274
  this._slowFeedWarnings.delete('get_events_feed');
1275
1275
  return this._recordToolFailure('get_events_feed', `UPSTREAM_404: ${result.content?.[0]?.text?.slice(0, 200) ?? 'feed unavailable'}`, EuropeanParliamentMCPClient.FEED_FALLBACK);
@@ -44,7 +44,7 @@
44
44
  * `https://dataservices.imf.org/REST/SDMX_3.0`).
45
45
  * - `IMF_API_TIMEOUT_MS` — per-request timeout (default `30000`).
46
46
  *
47
- * Legacy env vars (`IMF_MCP_GATEWAY_URL`, `IMF_MCP_GATEWAY_API_KEY`,
47
+ * Historic env vars (`IMF_MCP_GATEWAY_URL`, `IMF_MCP_GATEWAY_API_KEY`,
48
48
  * `IMF_MCP_SERVER_PATH`) are no longer consulted — no gateway is needed
49
49
  * because the IMF SDMX 3.0 API is an unauthenticated public endpoint.
50
50
  */
@@ -114,7 +114,7 @@ export function isRetriableError(error) {
114
114
  const msg = error.message?.toLowerCase() ?? '';
115
115
  // Never retry rate-limit errors — callers must honour the Retry-After delay.
116
116
  // `instanceof MCPRateLimitError` is the primary guard for typed errors;
117
- // the string prefix fallback handles any legacy plain Error with a rate-limit message.
117
+ // the string prefix fallback handles any untyped plain Error with a rate-limit message.
118
118
  if (error instanceof MCPRateLimitError || msg.startsWith(RATE_LIMIT_MSG.toLowerCase())) {
119
119
  return false;
120
120
  }
@@ -46,7 +46,7 @@ import type { MCPClientOptions } from './mcp.js';
46
46
  * - `timeoutMs` — per-request timeout in milliseconds.
47
47
  * - `fetchImpl` — optional `fetch` injection for tests.
48
48
  *
49
- * The inherited legacy MCP transport fields (`serverPath`, `gatewayUrl`,
49
+ * The inherited historic MCP transport fields (`serverPath`, `gatewayUrl`,
50
50
  * `gatewayApiKey`, `maxConnectionAttempts`, `connectionRetryDelay`) are
51
51
  * accepted for backwards compatibility but **ignored** by the native
52
52
  * client — they date from the earlier `c-cf/imf-data-mcp` proxy
@@ -346,7 +346,7 @@ export interface CorporateBodyFeedItem {
346
346
  identifier?: string | undefined;
347
347
  label?: string | undefined;
348
348
  }
349
- /** Aggregated feed data for breaking news articles (legacy compat) */
349
+ /** Aggregated feed data for breaking news articles (back-compat shape) */
350
350
  export interface BreakingNewsFeedData {
351
351
  adoptedTexts: readonly AdoptedTextFeedItem[];
352
352
  events: readonly EventFeedItem[];
@@ -129,7 +129,7 @@ export interface PolicyRelevantIndicators {
129
129
  *
130
130
  * **Note:** The codebase also has a `WorldBankMCPClient.getIndicatorForCountry()`
131
131
  * wrapper (in `src/mcp/wb-mcp-client.ts`) that calls the `get_indicator_for_country`
132
- * tool — this is a legacy convenience method not listed in this type union since it
132
+ * tool — this is a back-compat convenience method not listed in this type union since it
133
133
  * is not part of the standard worldbank-mcp tool surface.
134
134
  */
135
135
  export type WBMCPToolName = 'get-economic-data' | 'get-social-data' | 'get-health-data' | 'get-education-data' | 'get-country-info' | 'get-countries' | 'search-indicators';
@@ -198,7 +198,7 @@ export declare function checkArticleExists(slug: string, lang: string, newsDir?:
198
198
  *
199
199
  * Title resolution order:
200
200
  * 1. `<head><title>` value with the trailing ` — EU Parliament Monitor`
201
- * (or legacy ` | EU Parliament Monitor`) site-suffix stripped.
201
+ * (or historic ` | EU Parliament Monitor`) site-suffix stripped.
202
202
  * This is where the editorial-highlights resolver + SEO backport
203
203
  * script write their output, so using it as the primary source
204
204
  * surfaces the strongest headline on index cards and sitemaps.
@@ -243,7 +243,7 @@ export interface ArticleValidationResult {
243
243
  * Validate that generated article HTML includes all required structural elements.
244
244
  *
245
245
  * This is the primary validation gate — articles must be generated correctly
246
- * by the template. The fix-articles script is only a fallback for legacy articles.
246
+ * by the template. The fix-articles script is only a fallback for historic articles.
247
247
  *
248
248
  * @param html - Complete HTML string of the article
249
249
  * @returns Validation result with errors list (empty if valid)
@@ -508,7 +508,7 @@ function decodeHtmlEntities(str) {
508
508
  *
509
509
  * Title resolution order:
510
510
  * 1. `<head><title>` value with the trailing ` — EU Parliament Monitor`
511
- * (or legacy ` | EU Parliament Monitor`) site-suffix stripped.
511
+ * (or historic ` | EU Parliament Monitor`) site-suffix stripped.
512
512
  * This is where the editorial-highlights resolver + SEO backport
513
513
  * script write their output, so using it as the primary source
514
514
  * surfaces the strongest headline on index cards and sitemaps.
@@ -609,7 +609,7 @@ const REQUIRED_ARTICLE_ELEMENTS = [
609
609
  * Validate that generated article HTML includes all required structural elements.
610
610
  *
611
611
  * This is the primary validation gate — articles must be generated correctly
612
- * by the template. The fix-articles script is only a fallback for legacy articles.
612
+ * by the template. The fix-articles script is only a fallback for historic articles.
613
613
  *
614
614
  * @param html - Complete HTML string of the article
615
615
  * @returns Validation result with errors list (empty if valid)
@@ -96,6 +96,35 @@ const IMF_SOURCE_FIELD_RE =
96
96
  const IMF_FIGURE_CLAIM_RE =
97
97
  /\bIMF\b[\s\S]{0,160}\b\d+(?:\.\d+)?\s*(?:%|pp|percentage points|GDP|EUR|USD|billion|trillion|million)/i;
98
98
 
99
+ // IMF-primary editorial policy: IMF is the sole authoritative source for
100
+ // economic / fiscal / monetary / trade / FDI / exchange-rate /
101
+ // banking-soundness claims inside economic-context.md. World Bank is
102
+ // used for non-economic domains. Two complementary detectors:
103
+ //
104
+ // 1. WB economic indicator codes — surface the offending SDMX-style
105
+ // identifier when an economic-context artifact still cites raw WB
106
+ // economic series (NY.GDP.*, FP.CPI.*, SL.UEM.*, GC.DOD.*, NE.EXP.*,
107
+ // NE.TRD.*, BX.KLT.*, NY.GNP.*, GC.TAX.*, NE.CON.GOVT.*).
108
+ // 2. WB economic prose claim — match "World Bank" within 120 chars of an
109
+ // economic noun (GDP, inflation, unemployment, fiscal balance, debt,
110
+ // trade, FDI, exchange rate). The window is intentionally narrow so
111
+ // a sentence like "World Bank WGI governance index" (legitimate
112
+ // non-economic domain) does not trigger.
113
+ //
114
+ // Both detectors deliberately exclude the narrative "Retired from WB
115
+ // (now IMF-primary…)" and "legacy WB economic codes (… retained for
116
+ // backward compatibility but MUST NOT…)" wording that appears in the
117
+ // methodology files themselves — those files are not validated as run
118
+ // artifacts. The detectors only run against `intelligence/economic-
119
+ // context.md` and only when the artifact is also flagged as making
120
+ // numeric IMF claims (i.e. the artifact actually carries economic
121
+ // content), see callers in `evaluateArtifact`.
122
+ const WB_ECONOMIC_INDICATOR_CODE_RE =
123
+ /\b(NY\.GDP\.[A-Z0-9.]+|NY\.GNP\.[A-Z0-9.]+|FP\.CPI\.[A-Z0-9.]+|SL\.UEM\.[A-Z0-9.]+|GC\.DOD\.[A-Z0-9.]+|GC\.TAX\.[A-Z0-9.]+|NE\.EXP\.[A-Z0-9.]+|NE\.IMP\.[A-Z0-9.]+|NE\.TRD\.[A-Z0-9.]+|NE\.CON\.GOVT\.[A-Z0-9.]+|BX\.KLT\.[A-Z0-9.]+|BN\.KLT\.[A-Z0-9.]+|FR\.INR\.[A-Z0-9.]+)\b/;
124
+
125
+ const WB_ECONOMIC_CLAIM_RE =
126
+ /\bWorld\s+Bank\b[\s\S]{0,120}\b(?:GDP(?:\s+growth|\s+per\s+capita)?|inflation|CPI|unemployment(?:\s+rate)?|fiscal\s+balance|primary\s+balance|government\s+debt|public\s+debt|current\s+account|trade(?:\s+balance)?|FDI|foreign\s+direct\s+investment|exchange\s+rate|REER|policy\s+rate|reserve\s+assets|capital\s+adequacy|NPL\s+ratio)\b/i;
127
+
99
128
  // Bypass placeholder scan only on template-instruction blocks themselves —
100
129
  // NOT on every artifact that happens to link to a methodology document.
101
130
  // Matching `methodology` here would suppress placeholder detection for any
@@ -285,6 +314,48 @@ function claimsImfFigures(content) {
285
314
  return IMF_FIGURE_CLAIM_RE.test(content);
286
315
  }
287
316
 
317
+ /**
318
+ * IMF-primary editorial policy.
319
+ *
320
+ * Detect WB economic-policy violations inside `intelligence/economic-
321
+ * context.md`:
322
+ *
323
+ * - Any WB economic indicator code (NY.GDP.*, FP.CPI.*, SL.UEM.*,
324
+ * GC.DOD.*, NE.EXP.*, NE.TRD.*, BX.KLT.*, NY.GNP.*, GC.TAX.*,
325
+ * NE.CON.GOVT.*, FR.INR.*) — these belong in IMF SDMX form (NGDP,
326
+ * PCPIPCH, LUR, GGXWDG_NGDP, BCA_NGDPD, …).
327
+ * - Any "World Bank … <economic noun>" prose claim within 120 chars
328
+ * (GDP, inflation, unemployment, fiscal balance, debt, trade, FDI,
329
+ * exchange rate, policy rate, banking soundness).
330
+ *
331
+ * The detector deliberately runs only on `intelligence/economic-
332
+ * context.md`. Other artifacts may legitimately reference WB for non-
333
+ * economic context (governance WGI, demographics, social, environment,
334
+ * defence-spending, agriculture, innovation, education, health) and
335
+ * the WB methodology files themselves describe legacy codes for
336
+ * backward-compatibility — neither path is validated here.
337
+ *
338
+ * Returns `{ codes, prose }` arrays of offending excerpts. Empty arrays
339
+ * mean clean.
340
+ */
341
+ function detectWorldBankEconomicViolations(content) {
342
+ // Use matchAll() so callers get every offending excerpt, not just the
343
+ // first hit — an artifact that cites several WB economic series
344
+ // ("NY.GDP.MKTP.KD.ZG and FP.CPI.TOTL.ZG and SL.UEM.TOTL.ZS") must
345
+ // surface all three to the editor in a single Stage-C pass.
346
+ const codes = [];
347
+ const codeRe = new RegExp(WB_ECONOMIC_INDICATOR_CODE_RE.source, 'gi');
348
+ for (const m of content.matchAll(codeRe)) {
349
+ codes.push(m[1]);
350
+ }
351
+ const prose = [];
352
+ const proseRe = new RegExp(WB_ECONOMIC_CLAIM_RE.source, 'gi');
353
+ for (const m of content.matchAll(proseRe)) {
354
+ prose.push(m[0].replace(/\s+/g, ' ').trim().slice(0, 100));
355
+ }
356
+ return { codes, prose };
357
+ }
358
+
288
359
  // Stage C IMF evidence gate. The probe always writes a summary JSON even
289
360
  // when `available:false`, so a generic "any .json file" check is insufficient
290
361
  // — it would let a failed probe satisfy the gate. Require:
@@ -476,6 +547,24 @@ function validateArtifact({
476
547
  result.issues.push('imf-cache:missing');
477
548
  }
478
549
  }
550
+ // IMF-primary editorial policy: economic-context.md must not
551
+ // cite World Bank for economic claims regardless of whether IMF prose
552
+ // is also present. Run on every economic-context artifact (not gated
553
+ // on claimsImfFigures) so an article that drops IMF entirely and
554
+ // tries to satisfy economic context with WB alone is caught.
555
+ if (isEconomicContextArtifact(relativePath)) {
556
+ const { codes: wbCodes, prose: wbProse } =
557
+ detectWorldBankEconomicViolations(content);
558
+ // Surface every offending code (de-duplicated to keep the issue
559
+ // list concise when the same series is cited many times in one
560
+ // artifact). Stage-C editors get the full picture in one pass.
561
+ for (const code of [...new Set(wbCodes)]) {
562
+ result.issues.push(`economic-context:wb-economic-code:${code}`);
563
+ }
564
+ if (wbProse.length > 0) {
565
+ result.issues.push('economic-context:wb-economic-claim');
566
+ }
567
+ }
479
568
 
480
569
  return result;
481
570
  }
@@ -1,125 +0,0 @@
1
- // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * @module euparliamentmonitor
5
- * @description European Parliament Intelligence Platform — npm package entry point.
6
- *
7
- * This barrel re-exports the full library surface of the EU Parliament
8
- * Monitor so that other projects can consume types, MCP clients, analysis
9
- * utilities, templates, generators, strategies, and language constants
10
- * without duplicating code.
11
- *
12
- * @example
13
- * ```ts
14
- * import {
15
- * EuropeanParliamentMCPClient,
16
- * getEPMCPClient,
17
- * ALL_LANGUAGES,
18
- * scoreVotingAnomaly,
19
- * generateArticleHTML,
20
- * BreakingNewsStrategy,
21
- * buildBreakingNewsContent,
22
- * } from 'euparliamentmonitor';
23
- * ```
24
- *
25
- * @see {@link https://github.com/Hack23/euparliamentmonitor | GitHub Repository}
26
- * @see {@link https://github.com/Hack23/ISMS-PUBLIC/blob/main/Secure_Development_Policy.md | Secure Development Policy}
27
- * @see {@link https://github.com/Hack23/euparliamentmonitor/blob/main/ARCHITECTURE.md | Architecture}
28
- * @see {@link https://github.com/Hack23/euparliamentmonitor/blob/main/SECURITY_ARCHITECTURE.md | Security Architecture}
29
- *
30
- * **Dead-code removal (v0.8.27+):** The following previously-exported symbols
31
- * were removed as unused dead code: `buildSankeySection`,
32
- * `sankey-content`, `buildMindmapSection`,
33
- * `buildMultiDimensionalSwotSection`, `build*MultiDimensionalSwot`
34
- * builders, `MULTI_DIMENSIONAL_SWOT_STRINGS`, and related visualization
35
- * types. Use `buildIntelligenceMindmapSection`, `buildSwotSection`, and
36
- * domain-specific builders instead.
37
- */
38
- // ─── Types ───────────────────────────────────────────────────────────────────
39
- export * from './types/index.js';
40
- // ─── MCP Clients ─────────────────────────────────────────────────────────────
41
- export { MCPConnection, MCPSessionExpiredError, MCPRateLimitError, isRetriableError, formatRetryAfter, parseSSEResponse, } from './mcp/mcp-connection.js';
42
- export { EuropeanParliamentMCPClient, getEPMCPClient, closeEPMCPClient, } from './mcp/ep-mcp-client.js';
43
- export { WorldBankMCPClient, getWBMCPClient, closeWBMCPClient } from './mcp/wb-mcp-client.js';
44
- export { IMFMCPClient, IMF_MCP_TOOLS, getIMFMCPClient, closeIMFMCPClient, } from './mcp/imf-mcp-client.js';
45
- export { CircuitBreaker, withRetry, } from './mcp/mcp-retry.js';
46
- export { MCPHealthMonitor } from './mcp/mcp-health.js';
47
- // ─── Intelligence Analysis ───────────────────────────────────────────────────
48
- export { scoreVotingAnomaly, analyzeCoalitionCohesion, scoreMEPInfluence, calculateLegislativeVelocity, rankBySignificance, buildIntelligenceSection, buildDefaultStakeholderPerspectives, scoreStakeholderInfluence, buildStakeholderOutcomeMatrix, rankStakeholdersByInfluence, computeVotingIntensity, detectCoalitionShifts, computePolarizationIndex, detectVotingTrends, computeCrossSessionCoalitionStability, rankMEPInfluenceByTopic, buildLegislativeVelocityReport, } from './utils/intelligence-analysis.js';
49
- // ─── Intelligence Index ──────────────────────────────────────────────────────
50
- export { createEmptyIndex, addArticleToIndex, buildIndexFromEntries, findRelatedArticles, generateCrossReferences, detectTrends, findOrCreateSeries, buildRelatedArticlesHTML, } from './utils/intelligence-index.js';
51
- // ─── Article Quality ─────────────────────────────────────────────────────────
52
- export { assessAnalysisDepth, assessStakeholderCoverage, assessVisualizationQuality, calculateOverallScore, generateRecommendations, scoreArticleQuality, } from './utils/article-quality-scorer.js';
53
- // ─── Significance Scoring ────────────────────────────────────────────────────
54
- export { scoreSignificance, scoreBatch, clampScore, deriveDecision, formatScoreMarkdown, formatBatchMarkdown, WEIGHT_PARLIAMENTARY, WEIGHT_POLICY, WEIGHT_PUBLIC_INTEREST, WEIGHT_URGENCY, WEIGHT_INSTITUTIONAL, THRESHOLD_PUBLISH, THRESHOLD_HOLD, } from './utils/significance-scoring.js';
55
- // ─── Synthesis Summary ───────────────────────────────────────────────────────
56
- export { parseFrontmatter, aggregateSWOT, aggregateRisks, extractSummaryLine, aggregateConfidence, findMarkdownFiles, generateEditorialRecommendations, buildSynthesisSummary, formatSynthesisMarkdown, } from './generators/synthesis-summary.js';
57
- // ─── Content Validation ──────────────────────────────────────────────────────
58
- export { validateArticleContent, validateTranslationCompleteness, } from './utils/content-validator.js';
59
- // ─── Economic-context evidence helpers (Wave 1 dual-source) ──────────────────
60
- export { WORLD_BANK_STRONG_FINGERPRINTS, WORLD_BANK_INDICATOR_CODES, WORLD_BANK_FINGERPRINTS, hasWorldBankEvidence, articlePolicyHasWorldBank, IMF_STRONG_FINGERPRINTS, IMF_INDICATOR_CODES, hasIMFEvidence, articlePolicyHasEconomicContext, } from './utils/content-validator.js';
61
- // ─── Content Metadata ────────────────────────────────────────────────────────
62
- export { enrichMetadataFromContent } from './utils/content-metadata.js';
63
- // ─── News Metadata ───────────────────────────────────────────────────────────
64
- export { buildMetadataDatabase, writeMetadataDatabase, readMetadataDatabase, updateMetadataDatabase, updateIntelligenceIndex, } from './utils/news-metadata.js';
65
- // ─── Metadata Utilities ──────────────────────────────────────────────────────
66
- export { pl, pl as pluralizeCount } from './utils/metadata-utils.js';
67
- // ─── Political Threat Assessment ─────────────────────────────────────────────
68
- export { assessPoliticalThreats, buildActorThreatProfiles, buildConsequenceTree, analyzeLegislativeDisruption, generateThreatAssessmentMarkdown, ALL_THREAT_LANDSCAPE_DIMENSIONS, } from './utils/political-threat-assessment.js';
69
- // ─── HTML Utilities ──────────────────────────────────────────────────────────
70
- export { stripHtmlTags, stripScriptBlocks } from './utils/html-sanitize.js';
71
- export { parseArticleFilename, formatSlug, calculateReadTime, escapeHTML, isSafeURL, validateArticleHTML, } from './utils/file-utils.js';
72
- // ─── Article Category Detection ──────────────────────────────────────────────
73
- export { detectCategory } from './utils/article-category.js';
74
- // ─── World Bank Data Utilities ───────────────────────────────────────────────
75
- export { EU_COUNTRY_CODES, EU_AGGREGATE_CODE, COMPARISON_COUNTRIES, WB_AGGREGATE_LABELS, POLICY_INDICATORS, parseWorldBankCSV, formatIndicatorValue, getMostRecentValue, buildEconomicContext, getWorldBankCountryCode, isEUMemberState, isMCPSupportedWBCountryCode, buildEconomicContextHTML, } from './utils/world-bank-data.js';
76
- // ─── IMF Data Utilities ──────────────────────────────────────────────────────
77
- export { IMF_EU_COUNTRY_CODES, IMF_COUNTRY_CODE_OVERRIDES, IMF_EURO_AREA_CODE, IMF_AGGREGATE_LABELS, IMF_POLICY_INDICATORS, IMF_INDICATOR_SDMX_CODES, getIMFCountryCode, isIMFEUMemberState, parseSDMXJSON, getMostRecentObservation, getForecastPoints, formatIMFValue, buildIMFEconomicContext, buildIMFEconomicContextHTML, } from './utils/imf-data.js';
78
- // ─── Templates ───────────────────────────────────────────────────────────────
79
- export { generateArticleHTML } from './templates/article-template.js';
80
- export { computeArticleQualityScore, buildTableOfContents, buildQualityScoreBadge, } from './templates/section-builders.js';
81
- // ─── Constants & Languages ───────────────────────────────────────────────────
82
- export { ALL_LANGUAGES, LANGUAGE_PRESETS, LANGUAGE_FLAGS, LANGUAGE_NAMES, getLocalizedString, isSupportedLanguage, getTextDirection, } from './constants/language-core.js';
83
- export { WB_INDICATORS, COMMITTEE_INDICATOR_MAP, CATEGORY_INDICATOR_MAP, getCommitteeIndicators, getCommitteePrimaryIndicators, getCategoryIndicators, getIndicatorIdsForCommittees, getAllCategoryIndicatorIds, } from './constants/committee-indicator-map.js';
84
- // ─── Configuration Constants ─────────────────────────────────────────────────
85
- export { PROJECT_ROOT, NEWS_DIR, METADATA_DIR, BASE_URL, ARTICLE_FILENAME_PATTERN, WORDS_PER_MINUTE, VALID_ARTICLE_CATEGORIES, ARTICLE_TYPE_WEEK_AHEAD, ARTICLE_TYPE_BREAKING, ARTICLE_TYPE_COMMITTEE_REPORTS, ARTICLE_TYPE_PROPOSITIONS, ARTICLE_TYPE_MOTIONS, ARTICLE_TYPE_MONTH_AHEAD, ARTICLE_TYPE_WEEK_IN_REVIEW, ARTICLE_TYPE_MONTH_IN_REVIEW, ARG_SEPARATOR, APP_VERSION, createThemeToggleButton, THEME_TOGGLE_SCRIPT_CONTENT, THEME_TOGGLE_SCRIPT, } from './constants/config.js';
86
- // ─── Analysis Constants ──────────────────────────────────────────────────────
87
- export { AI_MARKER } from './constants/analysis-constants.js';
88
- // ─── Language UI Strings ─────────────────────────────────────────────────────
89
- export { PAGE_TITLES, PAGE_DESCRIPTIONS, SECTION_HEADINGS, NO_ARTICLES_MESSAGES, SKIP_LINK_TEXTS, ARTICLE_TYPE_LABELS, READ_TIME_LABELS, BACK_TO_NEWS_LABELS, ARTICLE_NAV_LABELS, AI_SECTION_CONTENT, FILTER_LABELS, SOURCES_HEADING_LABELS, HEADER_SUBTITLE_LABELS, THEME_TOGGLE_LABELS, FOOTER_ABOUT_HEADING_LABELS, FOOTER_ABOUT_TEXT_LABELS, FOOTER_QUICK_LINKS_LABELS, FOOTER_BUILT_BY_LABELS, FOOTER_LANGUAGES_LABELS, TOC_ARIA_LABELS, RELATED_ANALYSIS_LABELS, } from './constants/language-ui.js';
90
- // ─── Language Article Strings ────────────────────────────────────────────────
91
- export { LOCALIZED_KEYWORDS, WEEK_AHEAD_TITLES, MONTH_AHEAD_TITLES, WEEKLY_REVIEW_TITLES, MONTHLY_REVIEW_TITLES, MOTIONS_TITLES, BREAKING_NEWS_TITLES, COMMITTEE_REPORTS_TITLES, PROPOSITIONS_TITLES, PROPOSITIONS_STRINGS, EDITORIAL_STRINGS, DEEP_ANALYSIS_STRINGS, MOTIONS_STRINGS, WEEK_AHEAD_STRINGS, WEEK_AHEAD_STAKEHOLDER_STRINGS, BREAKING_STRINGS, COMMITTEE_ANALYSIS_CONTENT_STRINGS, SWOT_STRINGS, DASHBOARD_STRINGS, SWOT_BUILDER_STRINGS, DASHBOARD_BUILDER_STRINGS, MONTH_IN_REVIEW_STRINGS, ANALYSIS_QUALITY_LABELS, } from './constants/language-articles.js';
92
- // ─── Political Risk Assessment ───────────────────────────────────────────────
93
- export { calculatePoliticalRiskScore, assessPoliticalCapitalAtRisk, buildQuantitativeSWOT, assessLegislativeVelocityRisk, runAgentRiskAssessment, generateRiskAssessmentMarkdown, generatePoliticalRiskSummary, createScoredSWOTItem, createScoredOpportunityOrThreat, createRiskDriver, } from './utils/political-risk-assessment.js';
94
- // ─── Analysis Pipeline Stages ─────────────────────────────────────────────────
95
- export { ALL_ANALYSIS_METHODS, VALID_ANALYSIS_METHODS, runAnalysisStage, } from './generators/pipeline/analysis-stage.js';
96
- export { mcpCircuitBreaker, computeRollingDateRange, initializeMCPClient, loadFeedDataFromFile, loadEPFeedDataFromFile, fetchWeekAheadData, fetchVotingAnomalies, fetchCoalitionDynamics, fetchVotingReport, fetchMEPInfluence, loadCommitteeDataFromFile, fetchCommitteeInfoFromEPAPI, fetchCommitteeData, fetchVotingRecords, fetchVotingPatterns, fetchMotionsAnomalies, fetchParliamentaryQuestionsForMotions, fetchMotionsData, fetchProposalsFromMCP, fetchPipelineFromMCP, fetchProcedureStatusFromMCP, fetchAdoptedTextsFeed, fetchEventsFeed, fetchProceduresFeed, fetchMEPsFeed, fetchMEPsFeedWithTotal, fetchDocumentsFeed, fetchPlenaryDocumentsFeed, fetchCommitteeDocumentsFeed, fetchPlenarySessionDocumentsFeed, fetchExternalDocumentsFeed, fetchQuestionsFeed, fetchDeclarationsFeed, fetchCorporateBodiesFeed, fetchBreakingNewsFeedData, fetchEPFeedData, } from './generators/pipeline/fetch-stage.js';
97
- export { validateMCPResponse, normalizeISO8601Date, sanitizeText, isValidCountryCode, isValidLanguageCode, } from './generators/pipeline/transform-stage.js';
98
- export { createStrategyRegistry, generateArticleForStrategy, } from './generators/pipeline/generate-stage.js';
99
- export { writeArticleFile, writeSingleArticle, writeGenerationMetadata, } from './generators/pipeline/output-stage.js';
100
- export { loadAnalysisContext, extractAnalysisSummary, buildAnalysisInsightsSection, } from './generators/strategies/article-strategy.js';
101
- export { BreakingNewsStrategy, breakingNewsStrategy, } from './generators/strategies/breaking-news-strategy.js';
102
- export { AFET_KEYWORDS, LIBE_KEYWORDS, AGRI_KEYWORDS, ENVI_KEYWORDS, ECON_KEYWORDS, categorizeAdoptedText, CommitteeReportsStrategy, committeeReportsStrategy, } from './generators/strategies/committee-reports-strategy.js';
103
- export { MonthAheadStrategy, monthAheadStrategy, } from './generators/strategies/month-ahead-strategy.js';
104
- export { MonthlyReviewStrategy, monthlyReviewStrategy, } from './generators/strategies/monthly-review-strategy.js';
105
- export { MotionsStrategy, motionsStrategy, } from './generators/strategies/motions-strategy.js';
106
- export { PropositionsStrategy, propositionsStrategy, } from './generators/strategies/propositions-strategy.js';
107
- export { WeekAheadStrategy, weekAheadStrategy, } from './generators/strategies/week-ahead-strategy.js';
108
- export { WeeklyReviewStrategy, weeklyReviewStrategy, } from './generators/strategies/weekly-review-strategy.js';
109
- // ─── Content Generators ──────────────────────────────────────────────────────
110
- export { buildVotingAnalysis, buildProspectiveAnalysis, buildBreakingAnalysis, buildPropositionsAnalysis, buildCommitteeAnalysis, buildVotingSwot, buildProspectiveSwot, buildBreakingSwot, buildPropositionsSwot, buildCommitteeSwot, buildVotingDashboard, buildProspectiveDashboard, buildBreakingDashboard, buildPropositionsDashboard, buildCommitteeDashboard, buildVotingMindmap, buildProspectiveMindmap, buildBreakingMindmap, buildPropositionsMindmap, buildCommitteeMindmap, } from './generators/analysis-builders.js';
111
- export { SIGNIFICANCE_THRESHOLD, scoreBreakingNewsSignificance, buildBreakingNewsContent, } from './generators/breaking-content.js';
112
- export { FEATURED_COMMITTEES, PLACEHOLDER_CHAIR, PLACEHOLDER_MEMBERS, applyCommitteeInfo, applyDocuments, isPlaceholderCommitteeData, applyEffectiveness, } from './generators/committee-helpers.js';
113
- export { buildCoalitionPanel, buildPipelinePanel, buildTrendPanel, buildStakeholderScorecardPanel, buildDashboardSection, dashboardHasCharts, buildEconomicContextPanel, } from './generators/dashboard-content.js';
114
- export { buildDeepAnalysisSection } from './generators/deep-analysis-content.js';
115
- export { buildIntelligenceMindmapSection } from './generators/mindmap-content.js';
116
- export { PLACEHOLDER_MARKER, getMotionsFallbackData, generateMotionsContent, buildPoliticalAlignmentSection, buildAdoptedTextsSection, } from './generators/motions-content.js';
117
- export { buildSwotSection } from './generators/swot-content.js';
118
- export { PLACEHOLDER_EVENTS, parsePlenarySessions, parseEPEvents, parseCommitteeMeetings, parseLegislativeDocuments, parseLegislativePipeline, parseParliamentaryQuestions, computeWeekPoliticalTemperature, buildStakeholderImpactMatrix, buildWeekAheadContent, buildKeywords, buildWhatToWatchSection, } from './generators/week-ahead-content.js';
119
- export { buildPropositionsContent } from './generators/propositions-content.js';
120
- // ─── Index & Sitemap Generators ──────────────────────────────────────────────
121
- export { getIndexFilename, generateIndexHTML } from './generators/news-indexes.js';
122
- export { collectDocsHtmlFiles, generateSitemap, getSitemapFilename, generateSitemapHTML, generateRssFeed, } from './generators/sitemap.js';
123
- // ─── Political Intelligence Classification ────────────────────────────────────
124
- export { FRAMEWORK_VERSION, assessPoliticalSignificance, buildImpactMatrix, classifyPoliticalActors, analyzePoliticalForces, initializeAnalysisDirectory, serializeFrontmatter, writeAnalysisFile, writeAnalysisManifest, compareSignificance, maxSignificance, } from './utils/political-classification.js';
125
- //# sourceMappingURL=index.old.js.map
@@ -1,225 +0,0 @@
1
- // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
- // SPDX-License-Identifier: Apache-2.0
3
-
4
- /**
5
- * One-shot migration script to normalize legacy news/*.html files to the
6
- * current canonical header/footer/script layout.
7
- *
8
- * Applied operations per file (idempotent — safe to re-run):
9
- * 1. Upgrade the `<header class="site-header">` block to the current markup
10
- * (header-logo.png/webp at 72×48, theme-toggle, in-header language
11
- * switcher `<nav class="site-header__langs">`).
12
- * 2. Remove any standalone legacy `<nav class="language-switcher">` block
13
- * that sits outside the header.
14
- * 3. Replace the two inline `<script>` blocks (reading-progress + theme
15
- * toggle) with a single `<script src="../js/article-runtime.js" defer>`.
16
- * 4. Replace the `<footer class="site-footer">` with a freshly rendered
17
- * fully-localized footer built via `buildSiteFooter` — so Quick Links,
18
- * Built by, contact, disclaimer etc. are all in the correct language
19
- * for all 14 languages.
20
- *
21
- * Preserved as-is:
22
- * - All JSON-LD schema scripts (`<script type="application/ld+json">`).
23
- * - The `<article>` body and all analysis sections.
24
- * - Chart.js / D3 vendor + init scripts when already present.
25
- * - Hreflang link alternates, canonical link, meta tags.
26
- *
27
- * Usage: `node scripts/utils/migrate-legacy-articles.js [--dry-run]`
28
- */
29
-
30
- import { promises as fs } from 'node:fs';
31
- import path from 'node:path';
32
- import { fileURLToPath } from 'node:url';
33
-
34
- import { buildSiteFooter } from '../templates/section-builders.js';
35
- import {
36
- ALL_LANGUAGES,
37
- LANGUAGE_FLAGS,
38
- LANGUAGE_NAMES,
39
- THEME_TOGGLE_LABELS,
40
- HEADER_SUBTITLE_LABELS,
41
- getLocalizedString,
42
- } from '../constants/languages.js';
43
- import { createThemeToggleButton } from '../constants/config.js';
44
-
45
- const __filename = fileURLToPath(import.meta.url);
46
- const __dirname = path.dirname(__filename);
47
- const REPO_ROOT = path.resolve(__dirname, '..', '..');
48
- const NEWS_DIR = path.join(REPO_ROOT, 'news');
49
-
50
- /** Simple HTML-entity encode for attribute values. */
51
- function escapeHTML(s) {
52
- return String(s)
53
- .replace(/&/g, '&amp;')
54
- .replace(/</g, '&lt;')
55
- .replace(/>/g, '&gt;')
56
- .replace(/"/g, '&quot;')
57
- .replace(/'/g, '&#39;');
58
- }
59
-
60
- /** Parse `YYYY-MM-DD-slug-[runNN-]LL.html` → { date, slug, lang }. */
61
- function parseFilename(filename) {
62
- const m = filename.match(/^(\d{4}-\d{2}-\d{2})-([a-z0-9-]+?)(?:-run\d+)?-([a-z]{2})\.html$/);
63
- if (!m) return null;
64
- const [, date, slug, lang] = m;
65
- if (!ALL_LANGUAGES.includes(lang)) return null;
66
- return { date, slug, lang };
67
- }
68
-
69
- /** Build the in-header language switcher anchor list (no wrapping <nav>). */
70
- function buildLangSwitcherAnchors(date, slug, currentLang, availableLanguages) {
71
- const langs = availableLanguages.length > 0 ? availableLanguages : ALL_LANGUAGES;
72
- return langs
73
- .map((code) => {
74
- const flag = getLocalizedString(LANGUAGE_FLAGS, code);
75
- const name = getLocalizedString(LANGUAGE_NAMES, code);
76
- const active = code === currentLang ? ' active' : '';
77
- const href = `${date}-${slug}-${code}.html`;
78
- return `<a href="${escapeHTML(href)}" class="lang-link${active}" hreflang="${code}" lang="${code}" title="${escapeHTML(name)}">${flag} ${code.toUpperCase()}</a>`;
79
- })
80
- .join('\n ');
81
- }
82
-
83
- /** Build the canonical `<header class="site-header">…</header>` block. */
84
- function buildCanonicalHeader(date, slug, lang, availableLanguages) {
85
- const themeLabel = escapeHTML(getLocalizedString(THEME_TOGGLE_LABELS, lang));
86
- const subtitle = escapeHTML(getLocalizedString(HEADER_SUBTITLE_LABELS, lang));
87
- const langs = buildLangSwitcherAnchors(date, slug, lang, availableLanguages);
88
- const indexHref = lang === 'en' ? '../index.html' : `../index-${lang}.html`;
89
- return `<header class="site-header" role="banner">
90
- <div class="site-header__inner">
91
- <a href="${escapeHTML(indexHref)}" class="site-header__brand" aria-label="EU Parliament Monitor">
92
- <picture class="site-header__logo-picture">
93
- <source srcset="../images/header-logo.webp" type="image/webp">
94
- <img class="site-header__logo site-header__logo--header" src="../images/header-logo.png" alt="" width="72" height="48" aria-hidden="true">
95
- </picture>
96
- <span>
97
- <span class="site-header__title">EU Parliament Monitor</span>
98
- <span class="site-header__subtitle">${subtitle}</span>
99
- </span>
100
- </a>
101
- ${createThemeToggleButton(themeLabel)}
102
- <nav class="site-header__langs" role="navigation" aria-label="Language selection">
103
- ${langs}
104
- </nav>
105
- </div>
106
- </header>`;
107
- }
108
-
109
- /** Detect which languages a given {date}-{slug} article has. */
110
- function discoverAvailableLanguages(allFilenames, date, slug) {
111
- const set = new Set();
112
- for (const f of allFilenames) {
113
- // Match both suffixed (-runNN-LL) and plain (-LL) patterns
114
- const m = f.match(
115
- new RegExp(`^${date}-${slug.replace(/[-.]/g, '\\$&')}(?:-run\\d+)?-([a-z]{2})\\.html$`)
116
- );
117
- if (m && ALL_LANGUAGES.includes(m[1])) set.add(m[1]);
118
- }
119
- return ALL_LANGUAGES.filter((l) => set.has(l));
120
- }
121
-
122
- /**
123
- * Apply in-place migrations to one HTML document string.
124
- * Returns the new content (same string if no changes).
125
- */
126
- function migrateDocument(html, date, slug, lang, availableLanguages) {
127
- let out = html;
128
-
129
- // ── 1. Replace the `<header class="site-header">…</header>` block
130
- // with the canonical version (handles both legacy and "modern" headers)
131
- const canonicalHeader = buildCanonicalHeader(date, slug, lang, availableLanguages);
132
- out = out.replace(
133
- /<header class="site-header"[\s\S]*?<\/header>/,
134
- () => canonicalHeader
135
- );
136
-
137
- // ── 2. Remove any standalone legacy `<nav class="language-switcher">…</nav>`
138
- // (legacy files put lang-switcher nav OUTSIDE the header; modern
139
- // files put it INSIDE the header as site-header__langs — we already
140
- // injected that in step 1, so any remaining standalone nav is redundant).
141
- out = out.replace(
142
- /\s*<nav class="language-switcher"[\s\S]*?<\/nav>\s*/g,
143
- '\n\n '
144
- );
145
-
146
- // ── 3. Replace inline `<script>…</script>` blocks that handle
147
- // reading-progress or theme-toggle with nothing. The external
148
- // `<script src="../js/article-runtime.js" defer>` is injected if
149
- // not already present. JSON-LD scripts (type="application/ld+json")
150
- // are left untouched.
151
- out = out.replace(/<script(?![^>]*\bsrc=)(?![^>]*\btype="application\/ld\+json")[^>]*>[\s\S]*?<\/script>\s*/g,
152
- (block) => {
153
- // Preserve non-theme/non-reading-progress inline scripts (very rare but
154
- // possible for chart-init/d3-init fallbacks)
155
- if (/reading-progress/.test(block) || /ep-theme/.test(block) || /theme-toggle/.test(block)) {
156
- return '';
157
- }
158
- return block;
159
- }
160
- );
161
-
162
- // Ensure article-runtime.js is referenced exactly once before </body>.
163
- if (!/<script\s[^>]*src="\.\.\/js\/article-runtime\.js"/i.test(out)) {
164
- out = out.replace(
165
- /<\/body>/i,
166
- ' <script src="../js/article-runtime.js" defer></script>\n</body>'
167
- );
168
- }
169
-
170
- // ── 4. Replace the <footer class="site-footer">…</footer> block with a
171
- // freshly-rendered, fully-localized footer.
172
- const newFooter = buildSiteFooter({ lang, pathPrefix: '../' });
173
- out = out.replace(/<footer class="site-footer"[\s\S]*?<\/footer>/, () => newFooter);
174
-
175
- return out;
176
- }
177
-
178
- async function main() {
179
- const dryRun = process.argv.includes('--dry-run');
180
- const filesArg = process.argv.find((a) => a.startsWith('--files='));
181
- const allFiles = (await fs.readdir(NEWS_DIR)).filter((f) => f.endsWith('.html'));
182
- const files = filesArg
183
- ? filesArg.slice('--files='.length).split(',').filter((f) => f.endsWith('.html'))
184
- : allFiles;
185
-
186
- let changed = 0;
187
- let skipped = 0;
188
- let errors = 0;
189
-
190
- for (const f of files) {
191
- const parsed = parseFilename(f);
192
- if (!parsed) {
193
- skipped++;
194
- continue;
195
- }
196
- const { date, slug, lang } = parsed;
197
- const available = discoverAvailableLanguages(allFiles, date, slug);
198
-
199
- const p = path.join(NEWS_DIR, f);
200
- try {
201
- const before = await fs.readFile(p, 'utf8');
202
- const after = migrateDocument(before, date, slug, lang, available);
203
- if (after !== before) {
204
- if (!dryRun) await fs.writeFile(p, after, 'utf8');
205
- changed++;
206
- } else {
207
- skipped++;
208
- }
209
- } catch (err) {
210
- console.error(`✗ ${f}: ${err.message}`);
211
- errors++;
212
- }
213
- }
214
-
215
- console.log(
216
- `${dryRun ? '[dry-run] ' : ''}migrated: ${changed}, unchanged: ${skipped}, errors: ${errors}, total: ${files.length}`
217
- );
218
-
219
- if (errors > 0) process.exit(1);
220
- }
221
-
222
- main().catch((err) => {
223
- console.error(err);
224
- process.exit(1);
225
- });