euparliamentmonitor 0.9.6 → 0.9.8

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 (30) hide show
  1. package/package.json +9 -1
  2. package/scripts/aggregator/analysis-aggregator.js +48 -0
  3. package/scripts/aggregator/article-html.js +4 -11
  4. package/scripts/aggregator/article-metadata.js +69 -6
  5. package/scripts/aggregator/artifact-order.js +28 -5
  6. package/scripts/aggregator/reader-guide-constants.js +13 -1
  7. package/scripts/aggregator/reader-intelligence-guide.js +105 -0
  8. package/scripts/constants/config.d.ts +6 -1
  9. package/scripts/constants/config.js +18 -1
  10. package/scripts/copy-vendor.js +164 -41
  11. package/scripts/generate-responsive-images.js +106 -0
  12. package/scripts/generators/build-info.js +12 -4
  13. package/scripts/generators/news-indexes.d.ts +12 -0
  14. package/scripts/generators/news-indexes.js +101 -18
  15. package/scripts/generators/political-intelligence/html.js +3 -12
  16. package/scripts/generators/sitemap/html.js +3 -12
  17. package/scripts/generators/sitemap/rss.js +1 -0
  18. package/scripts/generators/sitemap/xml.js +1 -0
  19. package/scripts/generators/sitemap.js +115 -52
  20. package/scripts/mcp/ep-open-data-client.d.ts +1 -1
  21. package/scripts/mcp/imf-mcp-client.d.ts +1 -1
  22. package/scripts/minify-assets.js +253 -0
  23. package/scripts/normalize-legacy-articles.js +238 -0
  24. package/scripts/optimize-css.js +80 -0
  25. package/scripts/templates/section-builders.d.ts +36 -0
  26. package/scripts/templates/section-builders.js +84 -9
  27. package/scripts/utils/file-utils.d.ts +18 -1
  28. package/scripts/utils/file-utils.js +36 -4
  29. package/scripts/utils/news-metadata.d.ts +7 -1
  30. package/scripts/utils/news-metadata.js +36 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -70,6 +70,9 @@
70
70
  "generate-article:all": "node scripts/aggregator/article-generator.js --all",
71
71
  "generate-news-indexes": "node scripts/generators/news-indexes.js",
72
72
  "generate-sitemap": "node scripts/generators/sitemap.js",
73
+ "image:generate": "node scripts/generate-responsive-images.js",
74
+ "optimize-css": "node scripts/optimize-css.js",
75
+ "minify-assets": "node scripts/minify-assets.js",
73
76
  "validate-ep-api": "npx tsx src/utils/validate-ep-api.ts",
74
77
  "lint:prompts": "node scripts/lint-prompts.js",
75
78
  "htmlhint": "sh -c 'htmlhint *.html; set -- news/*.html; if [ -e \"$1\" ]; then htmlhint \"$@\"; else echo \"No news/*.html files to lint\"; fi'",
@@ -154,6 +157,7 @@
154
157
  "@vitest/ui": "4.1.6",
155
158
  "chart.js": "4.5.1",
156
159
  "chartjs-plugin-annotation": "3.1.0",
160
+ "clean-css": "^5.3.3",
157
161
  "d3": "7.9.0",
158
162
  "eslint": "10.3.0",
159
163
  "eslint-config-prettier": "10.1.8",
@@ -161,6 +165,7 @@
161
165
  "eslint-plugin-security": "4.0.0",
162
166
  "eslint-plugin-sonarjs": "4.0.3",
163
167
  "happy-dom": "20.9.0",
168
+ "html-minifier-terser": "^7.2.0",
164
169
  "htmlhint": "1.9.2",
165
170
  "husky": "9.1.7",
166
171
  "jscpd": "4.1.1",
@@ -169,6 +174,9 @@
169
174
  "mermaid": "11.15.0",
170
175
  "papaparse": "5.5.3",
171
176
  "prettier": "3.8.3",
177
+ "purgecss": "8.0.0",
178
+ "sharp": "^0.34.5",
179
+ "terser": "^5.47.1",
172
180
  "ts-api-utils": "2.5.0",
173
181
  "tsx": "4.21.0",
174
182
  "typedoc": "0.28.19",
@@ -284,6 +284,10 @@ const READER_GUIDE_EN = {
284
284
  need: 'Significance scoring',
285
285
  value: 'why this story outranks or trails other same-day European Parliament signals',
286
286
  },
287
+ 'section-actors-forces': {
288
+ need: 'Actors and forces',
289
+ value: 'who is driving the story, what political forces line up behind them, and which institutional levers they can pull',
290
+ },
287
291
  'section-coalitions-voting': {
288
292
  need: 'Coalitions and voting',
289
293
  value: 'political group alignment, voting evidence, and coalition pressure points',
@@ -304,6 +308,50 @@ const READER_GUIDE_EN = {
304
308
  need: 'Risk assessment',
305
309
  value: 'policy, institutional, coalition, communications, and implementation risk register',
306
310
  },
311
+ 'section-threat': {
312
+ need: 'Threat landscape',
313
+ value: 'hostile actors, attack vectors, consequence trees, and legislative-disruption pathways',
314
+ },
315
+ 'section-forward-projection': {
316
+ need: 'What to watch',
317
+ value: 'dated trigger events, calendar dependencies, and legislative-pipeline forecasts',
318
+ },
319
+ 'section-electoral-arc': {
320
+ need: 'Electoral arc and mandate',
321
+ value: 'where the story sits in the EP term, mandate fulfilment, seat projection, and presidency-trio context',
322
+ },
323
+ 'section-pestle-context': {
324
+ need: 'PESTLE and structural context',
325
+ value: 'political, economic, social, technological, legal, and environmental forces plus the historical baseline',
326
+ },
327
+ 'section-continuity': {
328
+ need: 'Cross-run continuity',
329
+ value: 'what changed since prior sessions and how confidence shifted between runs',
330
+ },
331
+ 'section-deep-analysis': {
332
+ need: 'Deep analysis',
333
+ value: 'long-form Economist-style explanation for readers who want the full argument',
334
+ },
335
+ 'section-documents': {
336
+ need: 'Document trail',
337
+ value: 'the document index and per-file analysis behind the public judgement',
338
+ },
339
+ 'section-extended-intel': {
340
+ need: 'Extended intelligence',
341
+ value: "devil's-advocate critique, comparative parallels, historical precedents, and media framing",
342
+ },
343
+ 'section-mcp-reliability': {
344
+ need: 'MCP data reliability',
345
+ value: 'which feeds were healthy, which were degraded, and how data limits bound conclusions',
346
+ },
347
+ 'section-quality-reflection': {
348
+ need: 'Analytical quality and reflection',
349
+ value: 'self-assessment scores, methodology audit, structured analytic techniques, and known limitations',
350
+ },
351
+ 'section-supplementary-intelligence': {
352
+ need: 'Supplementary intelligence',
353
+ value: 'additional markdown discovered in the run that has not yet been assigned to a canonical section',
354
+ },
307
355
  };
308
356
  /**
309
357
  * Render the generated reader-intelligence guide that appears before the
@@ -23,7 +23,7 @@ import { buildHeadFreshnessTags } from '../constants/build-info-meta.js';
23
23
  import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, SKIP_LINK_TEXTS, TOC_ARIA_LABELS, ARTICLE_TYPE_LABELS, BACK_TO_NEWS_LABELS, ARTICLE_NAV_LABELS, VIEW_SOURCE_MARKDOWN_LABELS, ARTICLE_TYPE_ICONS, FOOTER_SITEMAP_LABELS, FOOTER_POLITICAL_INTELLIGENCE_LABELS, 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
24
  import { ArticleCategory } from '../types/index.js';
25
25
  import { escapeHTML } from '../utils/file-utils.js';
26
- import { buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../templates/section-builders.js';
26
+ import { buildResponsiveIconLinks, buildResponsiveSocialImageMeta, buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../templates/section-builders.js';
27
27
  import { READER_GUIDE_SECTION_ID } from './reader-guide-constants.js';
28
28
  import { READER_GUIDE_TITLE_LABELS, getReaderGuideSectionIcon, } from './reader-intelligence-guide.js';
29
29
  import { TRADECRAFT_SECTION_ID, MANIFEST_SECTION_ID, SUPPLEMENTARY_SECTION_ID, } from './artifact-order.js';
@@ -888,7 +888,7 @@ export function wrapArticleHtml(options) {
888
888
  dateModified: options.date,
889
889
  inLanguage: safeLang,
890
890
  url: canonicalUrl,
891
- image: `${BASE_URL}/images/og-image.jpg`,
891
+ image: `${BASE_URL}/images/og-image-1200.jpg`,
892
892
  author: { '@type': 'Organization', name: PUBLISHER_NAME, url: 'https://hack23.com' },
893
893
  publisher: {
894
894
  '@type': 'Organization',
@@ -969,18 +969,11 @@ ${hreflangLinks}
969
969
  <meta property="og:url" content="${canonicalUrl}">
970
970
  <meta property="og:site_name" content="EU Parliament Monitor">
971
971
  <meta property="og:locale" content="${safeLang}">
972
- <meta property="og:image" content="${BASE_URL}/images/og-image.jpg">
973
- <meta property="og:image:alt" content="${escapeHTML(options.title)} — EU Parliament Monitor">
974
- <meta property="og:image:width" content="1200">
975
- <meta property="og:image:height" content="630">
972
+ ${buildResponsiveSocialImageMeta(`${options.title} — EU Parliament Monitor`)}
976
973
  <meta name="twitter:card" content="summary_large_image">
977
974
  <meta name="twitter:title" content="${escapeHTML(options.title)}">
978
975
  <meta name="twitter:description" content="${escapeHTML(options.description)}">
979
- <meta name="twitter:image" content="${BASE_URL}/images/og-image.jpg">
980
- <link rel="icon" type="image/x-icon" href="../favicon.ico">
981
- <link rel="icon" type="image/png" sizes="32x32" href="../images/favicon-32x32.png">
982
- <link rel="icon" type="image/png" sizes="16x16" href="../images/favicon-16x16.png">
983
- <link rel="apple-touch-icon" sizes="180x180" href="../images/apple-touch-icon.png">
976
+ ${buildResponsiveIconLinks('../')}
984
977
  <link rel="manifest" href="../site.webmanifest">
985
978
  <meta name="color-scheme" content="light dark">
986
979
  <meta name="theme-color" content="#003399" media="(prefers-color-scheme: light)">
@@ -455,6 +455,56 @@ export function stripInlineMarkdown(raw) {
455
455
  .replace(/\s+/g, ' ')
456
456
  .trim();
457
457
  }
458
+ /** Connector / determiner words that read as broken copy when they are
459
+ * the final token before a truncation ellipsis. */
460
+ const TRAILING_STOP_WORDS = new Set([
461
+ 'the',
462
+ 'a',
463
+ 'an',
464
+ 'of',
465
+ 'to',
466
+ 'for',
467
+ 'in',
468
+ 'on',
469
+ 'at',
470
+ 'by',
471
+ 'and',
472
+ 'or',
473
+ 'with',
474
+ 'from',
475
+ ]);
476
+ /** Trailing characters we always strip before appending our own ellipsis,
477
+ * so we never emit double-ellipsis or stray punctuation. */
478
+ const TRAILING_PUNCT = /[.,;:—\-…\s]/u;
479
+ /**
480
+ * Repeatedly strip trailing stop-words (separated by a single space) and
481
+ * trailing punctuation (including any pre-existing ellipsis). Implemented
482
+ * imperatively to avoid super-linear regex backtracking on the
483
+ * `(?:\s+stop-word)+$` pattern flagged by `security/detect-unsafe-regex`.
484
+ *
485
+ * @param input - Pre-clipped string to clean up
486
+ * @returns Cleaned string with no trailing stop-words or punctuation
487
+ */
488
+ function stripTrailingStopWordsAndPunctuation(input) {
489
+ let result = input;
490
+ let changed = true;
491
+ while (changed) {
492
+ changed = false;
493
+ while (result.length > 0 && TRAILING_PUNCT.test(result.charAt(result.length - 1))) {
494
+ result = result.slice(0, -1);
495
+ changed = true;
496
+ }
497
+ const lastSpace = result.lastIndexOf(' ');
498
+ if (lastSpace >= 0) {
499
+ const tail = result.slice(lastSpace + 1).toLowerCase();
500
+ if (TRAILING_STOP_WORDS.has(tail)) {
501
+ result = result.slice(0, lastSpace);
502
+ changed = true;
503
+ }
504
+ }
505
+ }
506
+ return result;
507
+ }
458
508
  /**
459
509
  * Clamp a string to `DESCRIPTION_MAX_LENGTH` characters, appending
460
510
  * an ellipsis when truncation actually happens. Does not break words if
@@ -467,10 +517,22 @@ export function stripInlineMarkdown(raw) {
467
517
  export function truncateDescription(text) {
468
518
  if (text.length <= DESCRIPTION_MAX_LENGTH)
469
519
  return text;
470
- const cut = text.slice(0, DESCRIPTION_MAX_LENGTH - 3);
520
+ const cut = text.slice(0, DESCRIPTION_MAX_LENGTH - 1);
521
+ // Prefer the last full sentence terminator within the cut so we don't
522
+ // end on a dangling determiner ("…year. The"). Period/!/? followed by
523
+ // a space marks a clean boundary. Only honour the boundary when it
524
+ // sits past the soft minimum so we keep enough body text to be useful.
525
+ const sentenceEnd = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
526
+ if (sentenceEnd >= DESCRIPTION_MIN_LENGTH) {
527
+ return cut.slice(0, sentenceEnd + 1).replace(/\s+$/, '');
528
+ }
471
529
  const lastSpace = cut.lastIndexOf(' ');
472
- const safe = lastSpace > DESCRIPTION_MAX_LENGTH - 60 ? cut.slice(0, lastSpace) : cut;
473
- return `${safe.replace(/[.,;:—-]+$/, '')}…`;
530
+ let safe = lastSpace > DESCRIPTION_MAX_LENGTH - 60 ? cut.slice(0, lastSpace) : cut;
531
+ // Drop dangling stop-words and trailing punctuation/ellipsis so we
532
+ // never emit broken copy ("…year. The" → "…year.") or double-ellipsis
533
+ // ("The……") when the upstream input already carried an ellipsis.
534
+ safe = stripTrailingStopWordsAndPunctuation(safe);
535
+ return `${safe}…`;
474
536
  }
475
537
  /**
476
538
  * Clamp a title to `TITLE_MAX_LENGTH` characters in the same
@@ -482,10 +544,11 @@ export function truncateDescription(text) {
482
544
  export function truncateTitle(text) {
483
545
  if (text.length <= TITLE_MAX_LENGTH)
484
546
  return text;
485
- const cut = text.slice(0, TITLE_MAX_LENGTH - 3);
547
+ const cut = text.slice(0, TITLE_MAX_LENGTH - 1);
486
548
  const lastSpace = cut.lastIndexOf(' ');
487
- const safe = lastSpace > TITLE_MAX_LENGTH - 40 ? cut.slice(0, lastSpace) : cut;
488
- return `${safe.replace(/[.,;:—-]+$/, '')}…`;
549
+ let safe = lastSpace > TITLE_MAX_LENGTH - 40 ? cut.slice(0, lastSpace) : cut;
550
+ safe = stripTrailingStopWordsAndPunctuation(safe);
551
+ return `${safe}…`;
489
552
  }
490
553
  /**
491
554
  * Return the first Markdown H1 (`# …`) in the supplied text, stripped of
@@ -29,7 +29,7 @@ export const ARTIFACT_SECTIONS = [
29
29
  {
30
30
  id: 'synthesis',
31
31
  title: 'Synthesis Summary',
32
- artifacts: ['intelligence/synthesis-summary.md'],
32
+ artifacts: ['intelligence/synthesis-summary.md', 'synthesis.md'],
33
33
  },
34
34
  {
35
35
  id: 'significance',
@@ -40,6 +40,7 @@ export const ARTIFACT_SECTIONS = [
40
40
  'classification/priority-matrix.md',
41
41
  'classification/issue-classification.md',
42
42
  'intelligence/significance-scoring.md',
43
+ 'significance-assessment.md',
43
44
  ],
44
45
  },
45
46
  {
@@ -50,6 +51,9 @@ export const ARTIFACT_SECTIONS = [
50
51
  'classification/forces-analysis.md',
51
52
  'classification/impact-matrix.md',
52
53
  'classification/stakeholder-classification.md',
54
+ 'actor-mapping.md',
55
+ 'political-forces.md',
56
+ 'impact-assessment.md',
53
57
  // Catch-all for any other classification/*.md not consumed above
54
58
  // (keeps non-canonical artifact names out of the Supplementary bucket
55
59
  // and inside their journalist-correct section).
@@ -68,7 +72,11 @@ export const ARTIFACT_SECTIONS = [
68
72
  {
69
73
  id: 'stakeholder-map',
70
74
  title: 'Stakeholder Map',
71
- artifacts: ['intelligence/stakeholder-map.md', 'existing/stakeholder-impact.md'],
75
+ artifacts: [
76
+ 'intelligence/stakeholder-map.md',
77
+ 'existing/stakeholder-impact.md',
78
+ 'stakeholder-perspectives.md',
79
+ ],
72
80
  },
73
81
  {
74
82
  id: 'economic-context',
@@ -87,6 +95,8 @@ export const ARTIFACT_SECTIONS = [
87
95
  'risk-scoring/legislative-risk.md',
88
96
  'risk-scoring/economic-risk.md',
89
97
  'risk-scoring/institutional-risk.md',
98
+ 'risk-matrix.md',
99
+ 'quantitative-swot.md',
90
100
  // Catch-all for any other risk-scoring/*.md (e.g. naming variants) so
91
101
  // they render under Risk Assessment instead of Supplementary.
92
102
  'risk-scoring/',
@@ -104,7 +114,11 @@ export const ARTIFACT_SECTIONS = [
104
114
  {
105
115
  id: 'scenarios',
106
116
  title: 'Scenarios & Wildcards',
107
- artifacts: ['intelligence/scenario-forecast.md', 'intelligence/wildcards-blackswans.md'],
117
+ artifacts: [
118
+ 'intelligence/scenario-forecast.md',
119
+ 'intelligence/wildcards-blackswans.md',
120
+ 'scenario-forecast.md',
121
+ ],
108
122
  },
109
123
  {
110
124
  id: 'forward-projection',
@@ -113,6 +127,9 @@ export const ARTIFACT_SECTIONS = [
113
127
  'intelligence/forward-projection.md',
114
128
  'intelligence/legislative-pipeline-forecast.md',
115
129
  'intelligence/parliamentary-calendar-projection.md',
130
+ 'forward/forward-projection.md',
131
+ 'forward/legislative-pipeline-forecast.md',
132
+ 'forward/parliamentary-calendar-projection.md',
116
133
  'extended/forward-indicators.md',
117
134
  ],
118
135
  },
@@ -130,7 +147,11 @@ export const ARTIFACT_SECTIONS = [
130
147
  {
131
148
  id: 'pestle-context',
132
149
  title: 'PESTLE & Context',
133
- artifacts: ['intelligence/pestle-analysis.md', 'intelligence/historical-baseline.md'],
150
+ artifacts: [
151
+ 'intelligence/pestle-analysis.md',
152
+ 'intelligence/historical-baseline.md',
153
+ 'pestle-analysis.md',
154
+ ],
134
155
  },
135
156
  {
136
157
  id: 'continuity',
@@ -162,7 +183,7 @@ export const ARTIFACT_SECTIONS = [
162
183
  {
163
184
  id: 'extended-intel',
164
185
  title: 'Extended Intelligence',
165
- artifacts: ['extended/'],
186
+ artifacts: ['extended/', 'media-framing.md'],
166
187
  },
167
188
  {
168
189
  id: 'mcp-reliability',
@@ -177,6 +198,8 @@ export const ARTIFACT_SECTIONS = [
177
198
  'intelligence/reference-analysis-quality.md',
178
199
  'intelligence/workflow-audit.md',
179
200
  'intelligence/methodology-reflection.md',
201
+ 'article-index.md',
202
+ 'methodology-reflection.md',
180
203
  ],
181
204
  },
182
205
  ];
@@ -14,10 +14,22 @@ export const READER_GUIDE_SECTION_IDS = [
14
14
  'section-executive-brief',
15
15
  'section-synthesis',
16
16
  'section-significance',
17
+ 'section-actors-forces',
17
18
  'section-coalitions-voting',
18
19
  'section-stakeholder-map',
19
20
  'section-economic-context',
20
- 'section-scenarios',
21
21
  'section-risk',
22
+ 'section-threat',
23
+ 'section-scenarios',
24
+ 'section-forward-projection',
25
+ 'section-electoral-arc',
26
+ 'section-pestle-context',
27
+ 'section-continuity',
28
+ 'section-deep-analysis',
29
+ 'section-documents',
30
+ 'section-extended-intel',
31
+ 'section-mcp-reliability',
32
+ 'section-quality-reflection',
33
+ 'section-supplementary-intelligence',
22
34
  ];
23
35
  //# sourceMappingURL=reader-guide-constants.js.map
@@ -567,6 +567,74 @@ const READER_GUIDE_ROWS = {
567
567
  zh: '本次运行如何与先前会话关联、变化了什么以及置信度在运行之间如何变化',
568
568
  },
569
569
  },
570
+ 'section-deep-analysis': {
571
+ need: {
572
+ en: 'Deep analysis',
573
+ sv: 'Djupanalys',
574
+ da: 'Dybdegående analyse',
575
+ no: 'Dybdeanalyse',
576
+ fi: 'Syväanalyysi',
577
+ de: 'Tiefenanalyse',
578
+ fr: 'Analyse approfondie',
579
+ es: 'Análisis profundo',
580
+ nl: 'Diepteanalyse',
581
+ ar: 'تحليل معمق',
582
+ he: 'ניתוח עומק',
583
+ ja: '詳細分析',
584
+ ko: '심층 분석',
585
+ zh: '深度分析',
586
+ },
587
+ value: {
588
+ en: 'long-form Economist-style explanation for readers who want the full argument',
589
+ sv: 'lång Economist-liknande förklaring för läsare som vill ha hela argumentet',
590
+ da: 'lang Economist-lignende forklaring for læsere der ønsker hele argumentet',
591
+ no: 'lang Economist-lignende forklaring for lesere som ønsker hele argumentet',
592
+ fi: 'pitkä Economist-tyylinen selitys lukijoille, jotka haluavat koko perustelun',
593
+ de: 'lange, Economist-artige Erklärung für Leser, die das ganze Argument wollen',
594
+ fr: "explication longue de style Economist pour les lecteurs qui veulent l'argument complet",
595
+ es: 'explicación extensa de estilo Economist para lectores que quieren el argumento completo',
596
+ nl: 'lange uitleg in Economist-stijl voor lezers die het volledige argument willen',
597
+ ar: 'شرح مطول بأسلوب إيكونوميست للقراء الذين يريدون الحجة كاملة',
598
+ he: 'הסבר ארוך בסגנון האקונומיסט לקוראים שרוצים את הטיעון המלא',
599
+ ja: '全体の論旨を求める読者向けのエコノミスト風長文解説',
600
+ ko: '전체 논지를 원하는 독자를 위한 이코노미스트식 장문 설명',
601
+ zh: '为希望了解完整论证的读者提供的《经济学人》式长篇解释',
602
+ },
603
+ },
604
+ 'section-documents': {
605
+ need: {
606
+ en: 'Document trail',
607
+ sv: 'Dokumentspår',
608
+ da: 'Dokumentspor',
609
+ no: 'Dokumentspor',
610
+ fi: 'Asiakirjapolku',
611
+ de: 'Dokumentenspur',
612
+ fr: 'Piste documentaire',
613
+ es: 'Rastro documental',
614
+ nl: 'Documentspoor',
615
+ ar: 'مسار الوثائق',
616
+ he: 'מסלול מסמכים',
617
+ ja: '文書トレイル',
618
+ ko: '문서 추적',
619
+ zh: '文件线索',
620
+ },
621
+ value: {
622
+ en: 'the document index and per-file analysis behind the public judgement',
623
+ sv: 'dokumentindexet och analysen per fil bakom den offentliga bedömningen',
624
+ da: 'dokumentindekset og analyse pr. fil bag den offentlige vurdering',
625
+ no: 'dokumentindeksen og analyse per fil bak den offentlige vurderingen',
626
+ fi: 'asiakirjahakemisto ja tiedostokohtainen analyysi julkisen arvion taustalla',
627
+ de: 'Dokumentenindex und Einzeldateianalyse hinter der öffentlichen Bewertung',
628
+ fr: "l'index des documents et l'analyse fichier par fichier derrière le jugement public",
629
+ es: 'el índice documental y el análisis por archivo detrás del juicio público',
630
+ nl: 'de documentenindex en analyse per bestand achter het publieke oordeel',
631
+ ar: 'فهرس الوثائق والتحليل لكل ملف خلف الحكم العام',
632
+ he: 'אינדקס המסמכים וניתוח לפי קובץ שמאחורי השיפוט הציבורי',
633
+ ja: '公開判断の背後にある文書索引とファイル別分析',
634
+ ko: '공개 판단 뒤에 있는 문서 색인과 파일별 분석',
635
+ zh: '公共判断背后的文件索引和逐文件分析',
636
+ },
637
+ },
570
638
  'section-extended-intel': {
571
639
  need: {
572
640
  en: 'Extended intelligence',
@@ -669,6 +737,40 @@ const READER_GUIDE_ROWS = {
669
737
  zh: '自我评估分数、方法论审计、使用的结构化分析技术和已知限制',
670
738
  },
671
739
  },
740
+ 'section-supplementary-intelligence': {
741
+ need: {
742
+ en: 'Supplementary intelligence',
743
+ sv: 'Kompletterande underrättelse',
744
+ da: 'Supplerende efterretning',
745
+ no: 'Supplerende etterretning',
746
+ fi: 'Täydentävä tiedustelu',
747
+ de: 'Ergänzende Aufklärung',
748
+ fr: 'Renseignement supplémentaire',
749
+ es: 'Inteligencia suplementaria',
750
+ nl: 'Aanvullende inlichtingen',
751
+ ar: 'استخبارات تكميلية',
752
+ he: 'מודיעין משלים',
753
+ ja: '補足インテリジェンス',
754
+ ko: '보충 인텔리전스',
755
+ zh: '补充情报',
756
+ },
757
+ value: {
758
+ en: 'additional markdown discovered in the run that has not yet been assigned to a canonical section',
759
+ sv: 'ytterligare markdown som hittats i körningen och ännu inte tilldelats en kanonisk sektion',
760
+ da: 'yderligere markdown fundet i kørslen som endnu ikke er tildelt en kanonisk sektion',
761
+ no: 'ytterligere markdown funnet i kjøringen som ennå ikke er tilordnet en kanonisk seksjon',
762
+ fi: 'ajossa löydetty lisämarkdown, jota ei vielä ole liitetty kanoniseen osioon',
763
+ de: 'zusätzliches Markdown aus dem Lauf, das noch keinem kanonischen Abschnitt zugeordnet ist',
764
+ fr: "markdown supplémentaire découvert dans l'exécution et pas encore affecté à une section canonique",
765
+ es: 'markdown adicional descubierto en la ejecución que aún no se ha asignado a una sección canónica',
766
+ nl: 'extra markdown gevonden in de run dat nog niet aan een canonieke sectie is toegewezen',
767
+ ar: 'ملفات ماركداون إضافية اكتُشفت في التشغيل ولم تُسند بعد إلى قسم معياري',
768
+ he: 'מרקדאון נוסף שהתגלה בהרצה ועדיין לא שובץ למדור קנוני',
769
+ ja: '実行内で見つかったがまだ正規セクションに割り当てられていない追加Markdown',
770
+ ko: '실행에서 발견되었지만 아직 표준 섹션에 할당되지 않은 추가 마크다운',
771
+ zh: '运行中发现但尚未分配到规范章节的附加Markdown',
772
+ },
773
+ },
672
774
  };
673
775
  /* ─── Section icons ─────────────────────────────────────────────── */
674
776
  /** Visual icons for each reader guide section to improve scannability. */
@@ -687,9 +789,12 @@ const SECTION_ICONS = {
687
789
  'section-electoral-arc': '🗳️',
688
790
  'section-pestle-context': '🌍',
689
791
  'section-continuity': '🔁',
792
+ 'section-deep-analysis': '🔬',
793
+ 'section-documents': '📄',
690
794
  'section-extended-intel': '🧠',
691
795
  'section-mcp-reliability': '📡',
692
796
  'section-quality-reflection': '🪞',
797
+ 'section-supplementary-intelligence': '📎',
693
798
  };
694
799
  /**
695
800
  * Look up the visual icon for a known article section.
@@ -82,7 +82,12 @@ export declare const BUILD_SHORT: string;
82
82
  /**
83
83
  * ISO 8601 timestamp for when this build was produced. Precedence:
84
84
  * 1. `process.env.BUILD_TIME` (CI sets this in the workflow)
85
- * 2. `new Date().toISOString()` fallback
85
+ * 2. Commit timestamp of {@link BUILD_ID} (`git log -1 --format=%cI`) —
86
+ * deterministic for a given commit, so workflow_dispatch re-runs of the
87
+ * same SHA produce a byte-identical `build-info.json` and `sw.js` and
88
+ * `aws s3 sync` correctly skips them as unchanged.
89
+ * 3. `new Date().toISOString()` fallback (only hits when both env and git
90
+ * are unavailable, e.g. tarball builds outside a git checkout).
86
91
  */
87
92
  export declare const BUILD_TIME: string;
88
93
  /**
@@ -207,12 +207,29 @@ export const BUILD_SHORT = BUILD_ID.slice(0, 7);
207
207
  /**
208
208
  * ISO 8601 timestamp for when this build was produced. Precedence:
209
209
  * 1. `process.env.BUILD_TIME` (CI sets this in the workflow)
210
- * 2. `new Date().toISOString()` fallback
210
+ * 2. Commit timestamp of {@link BUILD_ID} (`git log -1 --format=%cI`) —
211
+ * deterministic for a given commit, so workflow_dispatch re-runs of the
212
+ * same SHA produce a byte-identical `build-info.json` and `sw.js` and
213
+ * `aws s3 sync` correctly skips them as unchanged.
214
+ * 3. `new Date().toISOString()` fallback (only hits when both env and git
215
+ * are unavailable, e.g. tarball builds outside a git checkout).
211
216
  */
212
217
  export const BUILD_TIME = (() => {
213
218
  const fromEnv = (process.env.BUILD_TIME ?? '').trim();
214
219
  if (fromEnv)
215
220
  return fromEnv;
221
+ try {
222
+ const fromGit = execSync('git log -1 --format=%cI', {
223
+ encoding: 'utf-8',
224
+ stdio: ['ignore', 'pipe', 'ignore'],
225
+ cwd: PROJECT_ROOT,
226
+ }).trim();
227
+ if (fromGit)
228
+ return fromGit;
229
+ }
230
+ catch {
231
+ /* git unavailable or not a repo — fall through to wall-clock fallback */
232
+ }
216
233
  return new Date().toISOString();
217
234
  })();
218
235
  /**