euparliamentmonitor 0.9.0 → 0.9.1
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/package.json +1 -1
- package/scripts/aggregator/analysis-aggregator.js +17 -15
- package/scripts/aggregator/article-generator.d.ts +10 -3
- package/scripts/aggregator/article-generator.js +33 -28
- package/scripts/aggregator/article-html.d.ts +10 -0
- package/scripts/aggregator/article-html.js +189 -8
- package/scripts/aggregator/cli/parse.d.ts +19 -1
- package/scripts/aggregator/cli/parse.js +27 -26
- package/scripts/aggregator/reader-intelligence-guide.js +17 -4
- package/scripts/constants/language-ui.d.ts +33 -0
- package/scripts/constants/language-ui.js +534 -0
- package/scripts/constants/languages.d.ts +1 -1
- package/scripts/constants/languages.js +1 -1
- package/scripts/generators/seo-copy.js +13 -0
- package/scripts/index.d.ts +1 -1
- package/scripts/index.js +1 -1
- package/scripts/mcp/fetch-proxy-server.js +10 -1
- package/scripts/mcp/imf-mcp-client.js +75 -9
- package/scripts/templates/sync-template-frontmatter.js +19 -1
package/package.json
CHANGED
|
@@ -550,30 +550,33 @@ export function aggregateAnalysisRun(options) {
|
|
|
550
550
|
const analysisIndex = renderAnalysisIndex(includedArtifacts, manifestRelPath);
|
|
551
551
|
const readerGuide = renderReaderIntelligenceGuide(emittedSections, includedArtifacts);
|
|
552
552
|
// Deterministic 3–7 bullet "Key takeaways" block, harvested from the
|
|
553
|
-
// synthesis-summary / intelligence-assessment artifacts.
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
553
|
+
// synthesis-summary / intelligence-assessment artifacts. Sits between
|
|
554
|
+
// the Reader Intelligence Guide and the deep sections: the reader gets
|
|
555
|
+
// the BLUF (Executive Brief) → a navigation map (Reader Guide) → a
|
|
556
|
+
// bullet digest of the strongest findings (Key Takeaways) → the deep
|
|
557
|
+
// analysis. This is the order requested for reader UX so navigation
|
|
558
|
+
// is established before the reader commits to scanning takeaways.
|
|
557
559
|
const keyTakeaways = buildKeyTakeaways({ runDir });
|
|
558
|
-
// TOC ordering
|
|
560
|
+
// TOC ordering must match the rendered Markdown body 1:1. Order:
|
|
559
561
|
// Executive Brief (already first in emittedSections via appendSection) →
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
+
// Reader Intelligence Guide (inserted right after the brief when present) →
|
|
563
|
+
// Key Takeaways (inserted right after the guide when present) →
|
|
564
|
+
// remaining sections → audit appendices.
|
|
562
565
|
let postBriefIdx = emittedSections.length > 0 &&
|
|
563
566
|
emittedSections[0]?.id === namespacedSectionId(execBriefSection?.id ?? '')
|
|
564
567
|
? 1
|
|
565
568
|
: 0;
|
|
566
|
-
if (
|
|
569
|
+
if (readerGuide) {
|
|
567
570
|
emittedSections.splice(postBriefIdx, 0, {
|
|
568
|
-
id:
|
|
569
|
-
title:
|
|
571
|
+
id: READER_GUIDE_SECTION_ID,
|
|
572
|
+
title: READER_GUIDE_SECTION_TITLE,
|
|
570
573
|
});
|
|
571
574
|
postBriefIdx += 1;
|
|
572
575
|
}
|
|
573
|
-
if (
|
|
576
|
+
if (keyTakeaways) {
|
|
574
577
|
emittedSections.splice(postBriefIdx, 0, {
|
|
575
|
-
id:
|
|
576
|
-
title:
|
|
578
|
+
id: KEY_TAKEAWAYS_SECTION_ID,
|
|
579
|
+
title: KEY_TAKEAWAYS_SECTION_TITLE,
|
|
577
580
|
});
|
|
578
581
|
}
|
|
579
582
|
emittedSections.push({ id: TRADECRAFT_SECTION_ID, title: TRADECRAFT_SECTION_TITLE });
|
|
@@ -583,9 +586,8 @@ export function aggregateAnalysisRun(options) {
|
|
|
583
586
|
'',
|
|
584
587
|
...execBriefMarkdown,
|
|
585
588
|
'',
|
|
589
|
+
...(readerGuide ? [readerGuide, ''] : []),
|
|
586
590
|
...(keyTakeaways ? [keyTakeaways, ''] : []),
|
|
587
|
-
readerGuide,
|
|
588
|
-
'',
|
|
589
591
|
...sectionMarkdown,
|
|
590
592
|
'',
|
|
591
593
|
provenance,
|
|
@@ -19,7 +19,11 @@ export interface CliOptions {
|
|
|
19
19
|
* is earlier are skipped.
|
|
20
20
|
*/
|
|
21
21
|
readonly since?: string;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Languages to render. Defaults to all 14. The CLI always populates
|
|
24
|
+
* this with `[...ALL_LANGUAGES]` (the `--lang/--language` flags have
|
|
25
|
+
* been removed); programmatic callers (tests) can override it.
|
|
26
|
+
*/
|
|
23
27
|
readonly langs: readonly LanguageCode[];
|
|
24
28
|
/** Output directory for HTML files (defaults to `news/`). */
|
|
25
29
|
readonly outDir: string;
|
|
@@ -30,8 +34,11 @@ export interface CliOptions {
|
|
|
30
34
|
/** Optional: override the auto-derived article description (single-run only). */
|
|
31
35
|
readonly description?: string;
|
|
32
36
|
/**
|
|
33
|
-
* When true, only the source Markdown is written (no HTML) —
|
|
34
|
-
*
|
|
37
|
+
* When true, only the source Markdown is written (no HTML) — preserved
|
|
38
|
+
* for programmatic callers (tests) that need to skip HTML emission for
|
|
39
|
+
* speed. The CLI no longer exposes a flag for this and always sets
|
|
40
|
+
* `false`; every workflow-driven invocation emits HTML for all 14
|
|
41
|
+
* languages.
|
|
35
42
|
*/
|
|
36
43
|
readonly markdownOnly: boolean;
|
|
37
44
|
}
|
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Usage:
|
|
12
12
|
* npm run generate-article -- --run analysis/daily/2026-01-15/breaking-run1
|
|
13
|
-
* npm run generate-article -- --run ... --lang en --lang sv
|
|
14
13
|
* npm run generate-article -- --run ... --out-dir news --title "Headline"
|
|
15
14
|
*
|
|
15
|
+
* **Always-14-languages-always-HTML contract**: every CLI invocation
|
|
16
|
+
* renders every supported language to HTML. The legacy `--lang` /
|
|
17
|
+
* `--language` / `--markdown-only` flags have been removed. The
|
|
18
|
+
* programmatic `generateArticle()` API still accepts `langs` and
|
|
19
|
+
* `markdownOnly` for tests that need to scope a render for speed.
|
|
20
|
+
*
|
|
16
21
|
* Designed to be idempotent: running again with no changes overwrites
|
|
17
22
|
* identical files byte-for-byte.
|
|
18
23
|
*/
|
|
@@ -24,7 +29,7 @@ import { resolveRunId as _resolveRunId } from './manifest/index.js';
|
|
|
24
29
|
import { resolveArticleMetadata, extractStrongProseLine, } from './article-metadata.js';
|
|
25
30
|
import { buildArticleMeta, serializeArticleMeta } from './article-meta.js';
|
|
26
31
|
import { renderMarkdown } from './markdown-renderer.js';
|
|
27
|
-
import { wrapArticleHtml, getArticleFilename } from './article-html.js';
|
|
32
|
+
import { wrapArticleHtml, getArticleFilename, localizeArticleBody } from './article-html.js';
|
|
28
33
|
import { buildReaderIntelligenceGuideHtml, stripInlineReaderGuide, } from './reader-intelligence-guide.js';
|
|
29
34
|
import { ALL_LANGUAGES } from '../constants/language-core.js';
|
|
30
35
|
import { blobUrl } from './infra/github-urls.js';
|
|
@@ -48,9 +53,6 @@ function applyFlagResult(acc, result) {
|
|
|
48
53
|
case 'since':
|
|
49
54
|
acc.since = result.value;
|
|
50
55
|
return;
|
|
51
|
-
case 'lang':
|
|
52
|
-
acc.langs.push(result.value);
|
|
53
|
-
return;
|
|
54
56
|
case 'outDir':
|
|
55
57
|
acc.outDir = result.value;
|
|
56
58
|
return;
|
|
@@ -60,9 +62,6 @@ function applyFlagResult(acc, result) {
|
|
|
60
62
|
case 'description':
|
|
61
63
|
acc.description = result.value;
|
|
62
64
|
return;
|
|
63
|
-
case 'markdownOnly':
|
|
64
|
-
acc.markdownOnly = true;
|
|
65
|
-
return;
|
|
66
65
|
default: {
|
|
67
66
|
// Exhaustiveness guard — if a new FlagResult kind is added without a
|
|
68
67
|
// matching case the compiler will surface the gap.
|
|
@@ -75,9 +74,7 @@ export function parseCliArgs(argv, repoRoot) {
|
|
|
75
74
|
const acc = {
|
|
76
75
|
runDir: null,
|
|
77
76
|
all: false,
|
|
78
|
-
langs: [],
|
|
79
77
|
outDir: path.join(repoRoot, 'news'),
|
|
80
|
-
markdownOnly: false,
|
|
81
78
|
};
|
|
82
79
|
for (let i = 0; i < argv.length; i++) {
|
|
83
80
|
const arg = argv[i] ?? '';
|
|
@@ -105,10 +102,14 @@ export function parseCliArgs(argv, repoRoot) {
|
|
|
105
102
|
const opts = {
|
|
106
103
|
runDir: acc.runDir,
|
|
107
104
|
all: acc.all,
|
|
108
|
-
|
|
105
|
+
// Always render every language — the `--lang/--language` flags have
|
|
106
|
+
// been removed in the always-14-languages contract.
|
|
107
|
+
langs: [...ALL_LANGUAGES],
|
|
109
108
|
outDir: acc.outDir,
|
|
110
109
|
repoRoot,
|
|
111
|
-
|
|
110
|
+
// Always emit HTML — the `--markdown-only` flag has been removed in
|
|
111
|
+
// the always-HTML contract.
|
|
112
|
+
markdownOnly: false,
|
|
112
113
|
...(acc.since !== undefined ? { since: acc.since } : {}),
|
|
113
114
|
...(acc.title !== undefined ? { title: acc.title } : {}),
|
|
114
115
|
...(acc.description !== undefined ? { description: acc.description } : {}),
|
|
@@ -117,7 +118,7 @@ export function parseCliArgs(argv, repoRoot) {
|
|
|
117
118
|
}
|
|
118
119
|
/**
|
|
119
120
|
* Resolve one CLI flag to a {@link FlagResult}. Throws `Error` for any
|
|
120
|
-
* unsupported flag
|
|
121
|
+
* unsupported flag.
|
|
121
122
|
*
|
|
122
123
|
* @param flag - Flag name (e.g. `--run`)
|
|
123
124
|
* @param takeValue - Lazily returns the value argument for value-bearing flags
|
|
@@ -137,14 +138,6 @@ function applyCliFlag(flag, takeValue) {
|
|
|
137
138
|
}
|
|
138
139
|
return { kind: 'since', value };
|
|
139
140
|
}
|
|
140
|
-
case '--lang':
|
|
141
|
-
case '--language': {
|
|
142
|
-
const value = takeValue();
|
|
143
|
-
if (!ALL_LANGUAGES.includes(value)) {
|
|
144
|
-
throw new Error(`Unsupported language code: ${value}`);
|
|
145
|
-
}
|
|
146
|
-
return { kind: 'lang', value: value };
|
|
147
|
-
}
|
|
148
141
|
case '--out-dir':
|
|
149
142
|
case '--output':
|
|
150
143
|
return { kind: 'outDir', value: path.resolve(takeValue()) };
|
|
@@ -152,13 +145,19 @@ function applyCliFlag(flag, takeValue) {
|
|
|
152
145
|
return { kind: 'title', value: takeValue() };
|
|
153
146
|
case '--description':
|
|
154
147
|
return { kind: 'description', value: takeValue() };
|
|
155
|
-
case '--markdown-only':
|
|
156
|
-
return { kind: 'markdownOnly' };
|
|
157
148
|
case '--help':
|
|
158
149
|
case '-h':
|
|
159
150
|
printHelp();
|
|
160
151
|
process.exit(0);
|
|
161
152
|
// eslint-disable-next-line no-fallthrough
|
|
153
|
+
case '--lang':
|
|
154
|
+
case '--language':
|
|
155
|
+
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
|
+
throw new Error(`Flag ${flag} has been removed. The CLI always renders all 14 languages with HTML output. ` +
|
|
160
|
+
`See Article-Generation.md § "CLI contract" for the new always-on contract.`);
|
|
162
161
|
default:
|
|
163
162
|
throw new Error(`Unknown argument: ${flag}`);
|
|
164
163
|
}
|
|
@@ -183,19 +182,22 @@ function printHelp() {
|
|
|
183
182
|
' generate-article --all [--since YYYY-MM-DD] [options]',
|
|
184
183
|
'',
|
|
185
184
|
'Aggregate analysis artifacts from an `analysis/daily/**/<run>` directory',
|
|
186
|
-
'into a canonical Markdown document and render it to HTML in all 14',
|
|
187
|
-
'languages
|
|
188
|
-
'
|
|
185
|
+
'into a canonical Markdown document and render it to HTML in **all 14',
|
|
186
|
+
'supported languages** (en, sv, da, no, fi, de, fr, es, nl, ar, he, ja,',
|
|
187
|
+
'ko, zh). The `--all` form walks every run under `analysis/daily/` and',
|
|
188
|
+
'regenerates the full historic catalogue in one pass.',
|
|
189
|
+
'',
|
|
190
|
+
'The 14-language HTML render is **always on** — there is no flag to',
|
|
191
|
+
'scope a render to a single language or to skip HTML emission. Every',
|
|
192
|
+
'article.md always produces 14 corresponding `<slug>-<lang>.html` files.',
|
|
189
193
|
'',
|
|
190
194
|
'Options:',
|
|
191
195
|
' --run <path> Analysis run directory (single-run mode)',
|
|
192
196
|
' --all Batch-regenerate every run under analysis/daily/',
|
|
193
197
|
' --since YYYY-MM-DD With --all: skip runs dated before this cut-off',
|
|
194
|
-
' --lang <code> Language to render (repeatable; default: all 14)',
|
|
195
198
|
' --out-dir <path> Output directory (default: news/)',
|
|
196
199
|
' --title <text> Override article title (single-run only)',
|
|
197
200
|
' --description <text> Override article meta description (single-run only)',
|
|
198
|
-
' --markdown-only Write only the source .md (skip HTML)',
|
|
199
201
|
' --help, -h Show this help',
|
|
200
202
|
'',
|
|
201
203
|
].join('\n'));
|
|
@@ -354,6 +356,9 @@ function writeLanguageVariant(lang, slug, aggregated, englishHtml, chromeOptions
|
|
|
354
356
|
// and avoids fragile in-body heading searches.
|
|
355
357
|
bodyHtml = guideHtml + '\n' + bodyHtml;
|
|
356
358
|
}
|
|
359
|
+
// Localize Tradecraft References, Analysis Index, and other appendix
|
|
360
|
+
// section headings and content into the target language.
|
|
361
|
+
bodyHtml = localizeArticleBody(bodyHtml, lang);
|
|
357
362
|
// When a per-language translated source exists, prefer a summary derived
|
|
358
363
|
// from it so the `<meta description>` matches the visible prose. The
|
|
359
364
|
// editorial title still comes from the English resolver (per-language
|
|
@@ -84,6 +84,16 @@ export declare function buildArticleHreflangLinks(articleSlug: string): string;
|
|
|
84
84
|
* @returns HTML fragment for the sidebar, or `""` when no TOC is needed
|
|
85
85
|
*/
|
|
86
86
|
export declare function buildArticleToc(entries: readonly ArticleTocEntry[], lang: LanguageCode): string;
|
|
87
|
+
/**
|
|
88
|
+
* Localize the Tradecraft References and Analysis Index sections in the
|
|
89
|
+
* rendered article body HTML. Replaces English headings, introductions,
|
|
90
|
+
* sub-headings, and table headers with translated equivalents.
|
|
91
|
+
*
|
|
92
|
+
* @param bodyHtml - The rendered HTML body (from Markdown)
|
|
93
|
+
* @param lang - Target language code
|
|
94
|
+
* @returns HTML body with localized appendix sections
|
|
95
|
+
*/
|
|
96
|
+
export declare function localizeArticleBody(bodyHtml: string, lang: LanguageCode): string;
|
|
87
97
|
/**
|
|
88
98
|
* Render the full article HTML document with the shared chrome.
|
|
89
99
|
*
|
|
@@ -20,11 +20,31 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { BASE_URL, BUILD_SHORT, MERMAID_VERSION } from '../constants/config.js';
|
|
22
22
|
import { buildHeadFreshnessTags } from '../constants/build-info-meta.js';
|
|
23
|
-
import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, SKIP_LINK_TEXTS, TOC_ARIA_LABELS, getLocalizedString, getTextDirection, } from '../constants/languages.js';
|
|
23
|
+
import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, SKIP_LINK_TEXTS, TOC_ARIA_LABELS, ARTICLE_TYPE_LABELS, VIEW_SOURCE_MARKDOWN_LABELS, ARTICLE_TYPE_ICONS, TRADECRAFT_HEADING_LABELS, TRADECRAFT_INTRO_LABELS, TRADECRAFT_METHODOLOGIES_LABELS, TRADECRAFT_TEMPLATES_LABELS, ANALYSIS_INDEX_HEADING_LABELS, ANALYSIS_INDEX_INTRO_LABELS, ANALYSIS_INDEX_COL_SECTION_LABELS, ANALYSIS_INDEX_COL_ARTIFACT_LABELS, ANALYSIS_INDEX_COL_PATH_LABELS, KEY_TAKEAWAYS_HEADING_LABELS, SUPPLEMENTARY_HEADING_LABELS, SECTION_TITLE_LABELS, getLocalizedString, getTextDirection, } from '../constants/languages.js';
|
|
24
|
+
import { ArticleCategory } from '../types/index.js';
|
|
24
25
|
import { escapeHTML } from '../utils/file-utils.js';
|
|
25
26
|
import { buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../templates/section-builders.js';
|
|
26
27
|
import { READER_GUIDE_SECTION_ID } from './reader-guide-constants.js';
|
|
27
28
|
import { READER_GUIDE_TITLE_LABELS } from './reader-intelligence-guide.js';
|
|
29
|
+
import { TRADECRAFT_SECTION_ID, MANIFEST_SECTION_ID, SUPPLEMENTARY_SECTION_ID, } from './artifact-order.js';
|
|
30
|
+
import { KEY_TAKEAWAYS_SECTION_ID } from './key-takeaways.js';
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a localized article type label with icon. Falls back to the
|
|
33
|
+
* humanised slug when a translation isn't available.
|
|
34
|
+
*
|
|
35
|
+
* @param slug - Raw article type slug (e.g. "motions", "week-ahead")
|
|
36
|
+
* @param lang - Target language code
|
|
37
|
+
* @returns Localized label with preceding emoji icon (e.g. "🗳️ Plenary Votes & Resolutions")
|
|
38
|
+
*/
|
|
39
|
+
function getLocalizedArticleType(slug, lang) {
|
|
40
|
+
const labels = getLocalizedString(ARTICLE_TYPE_LABELS, lang);
|
|
41
|
+
const label = labels[slug] ?? slug.replace(/-/g, ' ');
|
|
42
|
+
const categoryValues = Object.values(ArticleCategory);
|
|
43
|
+
const iconEmoji = categoryValues.includes(slug)
|
|
44
|
+
? ARTICLE_TYPE_ICONS[slug]
|
|
45
|
+
: '📄';
|
|
46
|
+
return `${iconEmoji} ${label}`;
|
|
47
|
+
}
|
|
28
48
|
/** Publisher organization name used in JSON-LD, meta tags. */
|
|
29
49
|
const PUBLISHER_NAME = 'Hack23 AB';
|
|
30
50
|
/** Site name used across meta tags and structured data. */
|
|
@@ -72,6 +92,44 @@ function buildLanguageSwitcher(articleSlug, current) {
|
|
|
72
92
|
return `<a href="${href}" class="lang-link${active}" hreflang="${code}" lang="${code}" title="${safeName}" aria-label="${safeName}"${ariaCurrent}>${flag} ${code.toUpperCase()}</a>`;
|
|
73
93
|
}).join('\n ');
|
|
74
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a localized title for a TOC entry based on its section ID.
|
|
97
|
+
* Falls back to the original English title if no translation is available.
|
|
98
|
+
*
|
|
99
|
+
* @param sectionId - The fragment identifier of the section
|
|
100
|
+
* @param fallbackTitle - The English title to fall back to
|
|
101
|
+
* @param lang - Target language code
|
|
102
|
+
* @returns Localized title string
|
|
103
|
+
*/
|
|
104
|
+
function getLocalizedTocTitle(sectionId, fallbackTitle, lang) {
|
|
105
|
+
// Reader Intelligence Guide
|
|
106
|
+
if (sectionId === READER_GUIDE_SECTION_ID) {
|
|
107
|
+
return getLocalizedString(READER_GUIDE_TITLE_LABELS, lang);
|
|
108
|
+
}
|
|
109
|
+
// Tradecraft References appendix
|
|
110
|
+
if (sectionId === TRADECRAFT_SECTION_ID) {
|
|
111
|
+
return getLocalizedString(TRADECRAFT_HEADING_LABELS, lang);
|
|
112
|
+
}
|
|
113
|
+
// Analysis Index appendix
|
|
114
|
+
if (sectionId === MANIFEST_SECTION_ID) {
|
|
115
|
+
return getLocalizedString(ANALYSIS_INDEX_HEADING_LABELS, lang);
|
|
116
|
+
}
|
|
117
|
+
// Key Takeaways
|
|
118
|
+
if (sectionId === KEY_TAKEAWAYS_SECTION_ID) {
|
|
119
|
+
return getLocalizedString(KEY_TAKEAWAYS_HEADING_LABELS, lang);
|
|
120
|
+
}
|
|
121
|
+
// Supplementary Intelligence
|
|
122
|
+
if (sectionId === SUPPLEMENTARY_SECTION_ID) {
|
|
123
|
+
return getLocalizedString(SUPPLEMENTARY_HEADING_LABELS, lang);
|
|
124
|
+
}
|
|
125
|
+
// Artifact section titles (strip the `section-` prefix to find the key)
|
|
126
|
+
const sectionKey = sectionId.replace(/^section-/, '');
|
|
127
|
+
const sectionLabels = SECTION_TITLE_LABELS[sectionKey];
|
|
128
|
+
if (sectionLabels) {
|
|
129
|
+
return getLocalizedString(sectionLabels, lang);
|
|
130
|
+
}
|
|
131
|
+
return fallbackTitle;
|
|
132
|
+
}
|
|
75
133
|
/**
|
|
76
134
|
* Build the article-level Table of Contents nav. Renders a labelled
|
|
77
135
|
* `<nav class="article-toc">` with one `<a>` per H2 section, keyed by the
|
|
@@ -92,17 +150,14 @@ export function buildArticleToc(entries, lang) {
|
|
|
92
150
|
const label = escapeHTML(getLocalizedString(TOC_ARIA_LABELS, lang));
|
|
93
151
|
const items = entries
|
|
94
152
|
.map((e) => {
|
|
95
|
-
|
|
96
|
-
const displayTitle = e.id === READER_GUIDE_SECTION_ID
|
|
97
|
-
? getLocalizedString(READER_GUIDE_TITLE_LABELS, lang)
|
|
98
|
-
: e.title;
|
|
153
|
+
const displayTitle = getLocalizedTocTitle(e.id, e.title, lang);
|
|
99
154
|
return ` <li><a href="#${escapeHTML(e.id)}">${escapeHTML(displayTitle)}</a></li>`;
|
|
100
155
|
})
|
|
101
156
|
.join('\n');
|
|
102
157
|
return [
|
|
103
158
|
` <aside class="article-toc-container" aria-label="${label}">`,
|
|
104
159
|
` <details class="article-toc-details" open>`,
|
|
105
|
-
` <summary class="article-toc-summary"
|
|
160
|
+
` <summary class="article-toc-summary"><span class="guide-icon" aria-hidden="true">📑</span> ${label}</summary>`,
|
|
106
161
|
` <nav class="article-toc">`,
|
|
107
162
|
` <ol class="article-toc-list">`,
|
|
108
163
|
items,
|
|
@@ -113,6 +168,131 @@ export function buildArticleToc(entries, lang) {
|
|
|
113
168
|
'',
|
|
114
169
|
].join('\n');
|
|
115
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Localize the Tradecraft References and Analysis Index sections in the
|
|
173
|
+
* rendered article body HTML. Replaces English headings, introductions,
|
|
174
|
+
* sub-headings, and table headers with translated equivalents.
|
|
175
|
+
*
|
|
176
|
+
* @param bodyHtml - The rendered HTML body (from Markdown)
|
|
177
|
+
* @param lang - Target language code
|
|
178
|
+
* @returns HTML body with localized appendix sections
|
|
179
|
+
*/
|
|
180
|
+
export function localizeArticleBody(bodyHtml, lang) {
|
|
181
|
+
if (lang === 'en')
|
|
182
|
+
return bodyHtml;
|
|
183
|
+
let html = bodyHtml;
|
|
184
|
+
// --- Tradecraft References heading ---
|
|
185
|
+
// Use simple string indexOf to avoid polynomial regex backtracking.
|
|
186
|
+
const tradecraftHeading = getLocalizedString(TRADECRAFT_HEADING_LABELS, lang);
|
|
187
|
+
html = replaceHeadingById(html, TRADECRAFT_SECTION_ID, 'Tradecraft References', tradecraftHeading);
|
|
188
|
+
// --- Tradecraft intro paragraph ---
|
|
189
|
+
// The rendered Markdown produces a <p> containing the intro text with an
|
|
190
|
+
// <a> link to Hack23. Replace only the known English sentence prefix.
|
|
191
|
+
// HTML-escape the localized text to prevent injection, then re-insert the
|
|
192
|
+
// intentional <a> tag via a placeholder split.
|
|
193
|
+
const tradecraftIntroRaw = getLocalizedString(TRADECRAFT_INTRO_LABELS, lang);
|
|
194
|
+
const introSentenceStart = 'This article is produced under the ';
|
|
195
|
+
const introIdx = html.indexOf(introSentenceStart);
|
|
196
|
+
if (introIdx !== -1) {
|
|
197
|
+
// Find the end of the sentence (next '</p>' or period followed by '<')
|
|
198
|
+
const sentenceEnd = html.indexOf('</p>', introIdx);
|
|
199
|
+
if (sentenceEnd !== -1) {
|
|
200
|
+
const escapedIntro = escapeHTML(tradecraftIntroRaw);
|
|
201
|
+
const localizedWithLink = escapedIntro.replace(escapeHTML('Hack23 AB'), '<a href="https://hack23.com">Hack23 AB</a>');
|
|
202
|
+
html = html.slice(0, introIdx) + localizedWithLink + html.slice(sentenceEnd);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// --- Methodologies sub-heading ---
|
|
206
|
+
const methodsLabel = getLocalizedString(TRADECRAFT_METHODOLOGIES_LABELS, lang);
|
|
207
|
+
html = html.replace(/<h3>Methodologies<\/h3>/, `<h3>${escapeHTML(methodsLabel)}</h3>`);
|
|
208
|
+
// --- Artifact templates sub-heading ---
|
|
209
|
+
const templatesLabel = getLocalizedString(TRADECRAFT_TEMPLATES_LABELS, lang);
|
|
210
|
+
html = html.replace(/<h3>Artifact templates<\/h3>/, `<h3>${escapeHTML(templatesLabel)}</h3>`);
|
|
211
|
+
// --- Analysis Index heading ---
|
|
212
|
+
const analysisIndexHeading = getLocalizedString(ANALYSIS_INDEX_HEADING_LABELS, lang);
|
|
213
|
+
html = replaceHeadingById(html, MANIFEST_SECTION_ID, 'Analysis Index', analysisIndexHeading);
|
|
214
|
+
// --- Analysis Index intro ---
|
|
215
|
+
const analysisIndexIntroRaw = getLocalizedString(ANALYSIS_INDEX_INTRO_LABELS, lang);
|
|
216
|
+
// Use indexOf to find the manifest.json link URL without polynomial regex
|
|
217
|
+
const manifestLinkPrefix = 'href="';
|
|
218
|
+
const manifestJsonLiteral = 'manifest.json';
|
|
219
|
+
const manifestLinkIdx = html.indexOf(manifestJsonLiteral);
|
|
220
|
+
let manifestUrl = '';
|
|
221
|
+
if (manifestLinkIdx !== -1) {
|
|
222
|
+
// Walk backward to find the preceding href="
|
|
223
|
+
const hrefIdx = html.lastIndexOf(manifestLinkPrefix, manifestLinkIdx);
|
|
224
|
+
if (hrefIdx !== -1 && manifestLinkIdx - hrefIdx < 200) {
|
|
225
|
+
const urlStart = hrefIdx + manifestLinkPrefix.length;
|
|
226
|
+
const urlEnd = html.indexOf('"', urlStart);
|
|
227
|
+
if (urlEnd !== -1) {
|
|
228
|
+
manifestUrl = html.slice(urlStart, urlEnd);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// HTML-escape the localized intro, then re-insert the <a> link
|
|
233
|
+
const escapedAnalysisIntro = escapeHTML(analysisIndexIntroRaw);
|
|
234
|
+
const localizedIntroWithLink = manifestUrl
|
|
235
|
+
? escapedAnalysisIntro.replace('manifest.json', `<a href="${escapeHTML(manifestUrl)}">manifest.json</a>`)
|
|
236
|
+
: escapedAnalysisIntro;
|
|
237
|
+
// Replace the known English intro sentence using indexOf
|
|
238
|
+
const analysisIntroStart = 'Every artifact below was read by the aggregator';
|
|
239
|
+
const analysisIntroIdx = html.indexOf(analysisIntroStart);
|
|
240
|
+
if (analysisIntroIdx !== -1) {
|
|
241
|
+
const analysisIntroEnd = html.indexOf('gate-result history.', analysisIntroIdx);
|
|
242
|
+
if (analysisIntroEnd !== -1) {
|
|
243
|
+
const endOffset = analysisIntroEnd + 'gate-result history.'.length;
|
|
244
|
+
html = html.slice(0, analysisIntroIdx) + localizedIntroWithLink + html.slice(endOffset);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// --- Analysis Index table headers ---
|
|
248
|
+
const colSection = getLocalizedString(ANALYSIS_INDEX_COL_SECTION_LABELS, lang);
|
|
249
|
+
const colArtifact = getLocalizedString(ANALYSIS_INDEX_COL_ARTIFACT_LABELS, lang);
|
|
250
|
+
const colPath = getLocalizedString(ANALYSIS_INDEX_COL_PATH_LABELS, lang);
|
|
251
|
+
html = html.replace('<th>Section</th><th>Artifact</th><th>Path</th>', `<th>${escapeHTML(colSection)}</th><th>${escapeHTML(colArtifact)}</th><th>${escapeHTML(colPath)}</th>`);
|
|
252
|
+
// --- Key Takeaways heading ---
|
|
253
|
+
const keyTakeawaysHeading = getLocalizedString(KEY_TAKEAWAYS_HEADING_LABELS, lang);
|
|
254
|
+
html = replaceHeadingById(html, KEY_TAKEAWAYS_SECTION_ID, 'Key Takeaways', keyTakeawaysHeading);
|
|
255
|
+
// --- Supplementary Intelligence heading ---
|
|
256
|
+
const supplementaryHeading = getLocalizedString(SUPPLEMENTARY_HEADING_LABELS, lang);
|
|
257
|
+
html = replaceHeadingById(html, SUPPLEMENTARY_SECTION_ID, 'Supplementary Intelligence', supplementaryHeading);
|
|
258
|
+
return html;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Replace an H2 heading's text content by locating it via its `id` attribute.
|
|
262
|
+
* Uses indexOf-based search to avoid polynomial regex backtracking (CodeQL).
|
|
263
|
+
*
|
|
264
|
+
* @param html - Full HTML string
|
|
265
|
+
* @param sectionId - The id attribute value of the target `<h2>`
|
|
266
|
+
* @param englishTitle - The English title text to replace
|
|
267
|
+
* @param localizedTitle - The localized title to insert
|
|
268
|
+
* @returns Updated HTML string
|
|
269
|
+
*/
|
|
270
|
+
function replaceHeadingById(html, sectionId, englishTitle, localizedTitle) {
|
|
271
|
+
// Find the id attribute in the HTML — this uniquely identifies the heading
|
|
272
|
+
const idMarker = `id="${sectionId}"`;
|
|
273
|
+
let idIdx = html.indexOf(idMarker);
|
|
274
|
+
if (idIdx === -1) {
|
|
275
|
+
// Try single-quoted variant
|
|
276
|
+
const idMarkerSingle = `id='${sectionId}'`;
|
|
277
|
+
idIdx = html.indexOf(idMarkerSingle);
|
|
278
|
+
}
|
|
279
|
+
if (idIdx === -1)
|
|
280
|
+
return html;
|
|
281
|
+
// Find the closing '>' of the opening tag after the id
|
|
282
|
+
const tagCloseIdx = html.indexOf('>', idIdx);
|
|
283
|
+
if (tagCloseIdx === -1)
|
|
284
|
+
return html;
|
|
285
|
+
// The title text starts immediately after '>'
|
|
286
|
+
const titleStart = tagCloseIdx + 1;
|
|
287
|
+
const titleEnd = html.indexOf('<', titleStart);
|
|
288
|
+
if (titleEnd === -1)
|
|
289
|
+
return html;
|
|
290
|
+
// Verify this is actually the English title we expect
|
|
291
|
+
const existingTitle = html.slice(titleStart, titleEnd);
|
|
292
|
+
if (existingTitle.trim() !== englishTitle)
|
|
293
|
+
return html;
|
|
294
|
+
return html.slice(0, titleStart) + escapeHTML(localizedTitle) + html.slice(titleEnd);
|
|
295
|
+
}
|
|
116
296
|
/**
|
|
117
297
|
* Render the full article HTML document with the shared chrome.
|
|
118
298
|
*
|
|
@@ -129,8 +309,9 @@ export function wrapArticleHtml(options) {
|
|
|
129
309
|
const indexHref = safeLang === 'en' ? '../index.html' : `../index-${safeLang}.html`;
|
|
130
310
|
const hreflangLinks = buildArticleHreflangLinks(options.articleSlug);
|
|
131
311
|
const langSwitcher = buildLanguageSwitcher(options.articleSlug, safeLang);
|
|
312
|
+
const sourceMdLabel = getLocalizedString(VIEW_SOURCE_MARKDOWN_LABELS, safeLang);
|
|
132
313
|
const sourceMdLink = options.sourceMarkdownRelPath
|
|
133
|
-
? `<p class="article-source-md"><a href="${BASE_URL}/${options.sourceMarkdownRelPath}" rel="alternate" type="text/markdown">
|
|
314
|
+
? `<p class="article-source-md"><a href="${BASE_URL}/${options.sourceMarkdownRelPath}" rel="alternate" type="text/markdown"><svg class="icon icon-inline" width="16" height="16" viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false"><path d="M9 5H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2M12 3h6a2 2 0 0 1 2 2v6M10 14 20 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg> ${escapeHTML(sourceMdLabel)}</a></p>`
|
|
134
315
|
: '';
|
|
135
316
|
const tocHtml = buildArticleToc(options.toc ?? [], safeLang);
|
|
136
317
|
const jsonLd = {
|
|
@@ -249,7 +430,7 @@ ${buildHeadFreshnessTags('../')}
|
|
|
249
430
|
<main id="main" class="site-main article-main">
|
|
250
431
|
${tocHtml} <article class="article-body" lang="${safeLang}">
|
|
251
432
|
<header class="article-hero">
|
|
252
|
-
<p class="article-kicker">${escapeHTML(options.articleType
|
|
433
|
+
<p class="article-kicker">${escapeHTML(getLocalizedArticleType(options.articleType, safeLang))}</p>
|
|
253
434
|
<h1>${escapeHTML(options.title)}</h1>
|
|
254
435
|
<p class="article-dek">${escapeHTML(options.description)}</p>
|
|
255
436
|
<p class="article-meta"><time datetime="${options.date}">${options.date}</time> · EU Parliament Monitor</p>
|
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
import type { LanguageCode } from '../../types/index.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Successful parse → ready-to-execute options.
|
|
4
|
+
*
|
|
5
|
+
* **Always-14-languages-always-HTML contract**: the CLI no longer accepts
|
|
6
|
+
* `--lang/--language` or `--markdown-only` flags — every invocation
|
|
7
|
+
* generates HTML for **all 14 supported languages**. The `langs` and
|
|
8
|
+
* `markdownOnly` fields are preserved on the parsed options solely so the
|
|
9
|
+
* programmatic `generateArticle()` API (driven directly from unit / integration
|
|
10
|
+
* tests for speed) can still scope a single test run to a subset of
|
|
11
|
+
* languages or skip HTML emission. They are **not** settable via argv.
|
|
12
|
+
*/
|
|
3
13
|
export interface ParsedOptions {
|
|
4
14
|
readonly kind: 'options';
|
|
5
15
|
readonly value: {
|
|
6
16
|
readonly runDir: string | null;
|
|
7
17
|
readonly all: boolean;
|
|
8
18
|
readonly since?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Always `[...ALL_LANGUAGES]` when produced by this CLI parser; tests
|
|
21
|
+
* may override the field when calling {@link generateArticle} directly.
|
|
22
|
+
*/
|
|
9
23
|
readonly langs: readonly LanguageCode[];
|
|
10
24
|
readonly outDir: string;
|
|
11
25
|
readonly repoRoot: string;
|
|
12
26
|
readonly title?: string;
|
|
13
27
|
readonly description?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Always `false` when produced by this CLI parser; tests may set
|
|
30
|
+
* `true` programmatically to skip HTML emission for speed.
|
|
31
|
+
*/
|
|
14
32
|
readonly markdownOnly: boolean;
|
|
15
33
|
};
|
|
16
34
|
}
|