euparliamentmonitor 0.9.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.9.7",
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,7 @@
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",
73
74
  "optimize-css": "node scripts/optimize-css.js",
74
75
  "minify-assets": "node scripts/minify-assets.js",
75
76
  "validate-ep-api": "npx tsx src/utils/validate-ep-api.ts",
@@ -173,7 +174,8 @@
173
174
  "mermaid": "11.15.0",
174
175
  "papaparse": "5.5.3",
175
176
  "prettier": "3.8.3",
176
- "purgecss": "7.0.2",
177
+ "purgecss": "8.0.0",
178
+ "sharp": "^0.34.5",
177
179
  "terser": "^5.47.1",
178
180
  "ts-api-utils": "2.5.0",
179
181
  "tsx": "4.21.0",
@@ -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)">
@@ -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
  /**
@@ -30,7 +30,17 @@
30
30
  * skip it; we want the deploy to succeed without diagrams rather than fail.
31
31
  */
32
32
 
33
- import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
33
+ import {
34
+ copyFileSync,
35
+ cpSync,
36
+ existsSync,
37
+ mkdirSync,
38
+ readdirSync,
39
+ readFileSync,
40
+ rmSync,
41
+ statSync,
42
+ writeFileSync,
43
+ } from 'node:fs';
34
44
  import path from 'node:path';
35
45
  import process from 'node:process';
36
46
 
@@ -42,12 +52,55 @@ function ensureDir(dir) {
42
52
  mkdirSync(dir, { recursive: true });
43
53
  }
44
54
 
55
+ /**
56
+ * Copy `src` → `dst` only when their bytes differ. Returns `true` when an
57
+ * actual copy happened, `false` when the destination already had identical
58
+ * content and was left untouched (mtime preserved). This keeps
59
+ * `aws s3 sync` (which compares size + mtime) from re-uploading vendor
60
+ * bundles that the prebuild step regenerated identically.
61
+ */
62
+ function copyFileIfChanged(src, dst) {
63
+ if (existsSync(dst)) {
64
+ try {
65
+ const srcStat = statSync(src);
66
+ const dstStat = statSync(dst);
67
+ if (srcStat.size === dstStat.size) {
68
+ const srcBuf = readFileSync(src);
69
+ const dstBuf = readFileSync(dst);
70
+ if (srcBuf.equals(dstBuf)) {
71
+ return false;
72
+ }
73
+ }
74
+ } catch {
75
+ // Fall through to copy — read failures must not block deploy.
76
+ }
77
+ }
78
+ copyFileSync(src, dst);
79
+ return true;
80
+ }
81
+
82
+ function writeIfChanged(dst, content) {
83
+ const desired = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
84
+ if (existsSync(dst)) {
85
+ try {
86
+ const existing = readFileSync(dst);
87
+ if (existing.equals(desired)) {
88
+ return false;
89
+ }
90
+ } catch {
91
+ // Fall through to overwrite.
92
+ }
93
+ }
94
+ writeFileSync(dst, desired);
95
+ return true;
96
+ }
97
+
45
98
  function writeLicense(targetPath, copyrightText, licenseId) {
46
99
  // REUSE-compliant sidecar — see REUSE.toml for path-level annotations.
47
- writeFileSync(
100
+ // Idempotent: don't touch the sidecar's mtime when content is unchanged.
101
+ writeIfChanged(
48
102
  `${targetPath}.license`,
49
103
  `SPDX-FileCopyrightText: ${copyrightText}\nSPDX-License-Identifier: ${licenseId}\n`,
50
- 'utf8',
51
104
  );
52
105
  }
53
106
 
@@ -59,9 +112,9 @@ function copyOrFail(label, srcRel, dstRel, license) {
59
112
  process.exit(1);
60
113
  }
61
114
  ensureDir(path.dirname(dst));
62
- copyFileSync(src, dst);
115
+ const wrote = copyFileIfChanged(src, dst);
63
116
  writeLicense(dst, license.copyright, license.spdx);
64
- process.stdout.write(` ✓ ${dstRel}\n`);
117
+ process.stdout.write(` ${wrote ? '' : '·'} ${dstRel}${wrote ? '' : ' (unchanged)'}\n`);
65
118
  }
66
119
 
67
120
  function copyMermaid() {
@@ -73,44 +126,113 @@ function copyMermaid() {
73
126
  );
74
127
  return;
75
128
  }
76
- // Idempotency: wipe the existing mermaid tree before copying so stale
77
- // chunks from a previous mermaid version (or a previous filter set) cannot
78
- // leak into the deployed bundle. cpSync({force:true}) only overwrites
79
- // matching paths; it does not remove orphans.
80
- if (existsSync(target)) {
81
- rmSync(target, { recursive: true, force: true });
82
- }
83
129
  ensureDir(target);
84
130
 
85
- // Copy the minified ESM entry plus its chunk directory. Skip the dev /
86
- // unminified flavours (`mermaid.esm.mjs`, `mermaid.core.mjs`,
87
- // `mermaid.js`, etc.) AND skip sourcemaps to keep the deployed payload
88
- // small (saves ~6 MB and 60+ HTTP requests).
131
+ // Per-file idempotency: walk the source tree and only copy files whose
132
+ // bytes differ from what's already in `js/vendor/mermaid/`. Replaces the
133
+ // earlier `rmSync` + `cpSync` approach which always touched every chunk's
134
+ // mtime `aws s3 sync` (size+mtime by default) then re-uploaded the
135
+ // entire mermaid bundle on every deploy even though the bundle is byte-
136
+ // identical until the pinned mermaid version in package.json changes.
137
+ //
138
+ // Filename contract preserved exactly: entry stays at
139
+ // `js/vendor/mermaid/mermaid.esm.min.mjs` and chunks stay at
140
+ // `js/vendor/mermaid/chunks/mermaid.esm.min/*.mjs` so every existing
141
+ // `<script type="module" src="../js/vendor/mermaid/mermaid.esm.min.mjs">`
142
+ // and dynamic `import()` from the entry continues to resolve.
143
+
144
+ // Build the set of source files we want to ship (filter mirrors the
145
+ // previous cpSync filter exactly).
89
146
  const wantedTopLevel = new Set(['mermaid.esm.min.mjs']);
147
+ const wantedFiles = []; // { src, rel } — `rel` is relative to mermaidDist
90
148
 
91
- cpSync(mermaidDist, target, {
92
- recursive: true,
93
- force: true,
94
- filter: (src) => {
95
- const rel = path.relative(mermaidDist, src);
96
- if (rel === '') return true; // root dist dir
97
- // Skip sourcemaps — we deploy minified-only.
98
- if (src.endsWith('.map')) return false;
99
- const segments = rel.split(path.sep);
100
- const top = segments[0];
101
- // Always allow the chunks directory tree we need.
102
- if (top === 'chunks') {
103
- if (segments.length === 1) return true;
104
- const flavour = segments[1];
105
- return flavour === 'mermaid.esm.min';
149
+ function shouldShip(rel) {
150
+ if (rel.endsWith('.map')) return false;
151
+ const segments = rel.split(path.sep);
152
+ const top = segments[0];
153
+ if (top === 'chunks') {
154
+ if (segments.length === 1) return false; // directory itself, not a file
155
+ const flavour = segments[1];
156
+ return flavour === 'mermaid.esm.min';
157
+ }
158
+ if (segments.length === 1) {
159
+ return wantedTopLevel.has(top);
160
+ }
161
+ return false;
162
+ }
163
+
164
+ function walkSource(dir) {
165
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
166
+ const full = path.join(dir, entry.name);
167
+ const rel = path.relative(mermaidDist, full);
168
+ if (entry.isDirectory()) {
169
+ walkSource(full);
170
+ } else if (entry.isFile() && shouldShip(rel)) {
171
+ wantedFiles.push({ src: full, rel });
106
172
  }
107
- // Top-level: only allow the minified ESM entry.
108
- if (segments.length === 1) {
109
- return wantedTopLevel.has(top);
173
+ }
174
+ }
175
+ walkSource(mermaidDist);
176
+
177
+ // Copy only-if-changed.
178
+ let copied = 0;
179
+ let unchanged = 0;
180
+ for (const { src, rel } of wantedFiles) {
181
+ const dst = path.join(target, rel);
182
+ ensureDir(path.dirname(dst));
183
+ if (copyFileIfChanged(src, dst)) {
184
+ copied++;
185
+ } else {
186
+ unchanged++;
187
+ }
188
+ }
189
+
190
+ // Remove orphaned files in the destination tree that no longer have a
191
+ // matching wanted source — this preserves the "no stale chunks from a
192
+ // previous mermaid version" guarantee that the old `rmSync` provided,
193
+ // without touching any current chunk's mtime.
194
+ const wantedDstSet = new Set(
195
+ wantedFiles.map(({ rel }) => path.join(target, rel)),
196
+ );
197
+ // Allow our REUSE sidecar files alongside their primary file.
198
+ function isAllowedSidecar(absPath) {
199
+ if (!absPath.endsWith('.license')) return false;
200
+ const primary = absPath.slice(0, -'.license'.length);
201
+ return wantedDstSet.has(primary);
202
+ }
203
+ // Also allow the chunks-dir flavour-level license sidecar we drop below.
204
+ const flavourLicensePath = path.join(
205
+ target,
206
+ 'chunks',
207
+ 'mermaid.esm.min.license',
208
+ );
209
+
210
+ function pruneOrphans(dir) {
211
+ if (!existsSync(dir)) return;
212
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
213
+ const full = path.join(dir, entry.name);
214
+ if (entry.isDirectory()) {
215
+ pruneOrphans(full);
216
+ // Remove now-empty directories so a flavour rename leaves no shell.
217
+ try {
218
+ if (readdirSync(full).length === 0) {
219
+ rmSync(full, { recursive: true, force: true });
220
+ }
221
+ } catch {
222
+ // best-effort
223
+ }
224
+ } else if (entry.isFile()) {
225
+ if (
226
+ !wantedDstSet.has(full) &&
227
+ !isAllowedSidecar(full) &&
228
+ full !== flavourLicensePath
229
+ ) {
230
+ rmSync(full, { force: true });
231
+ }
110
232
  }
111
- return false;
112
- },
113
- });
233
+ }
234
+ }
235
+ pruneOrphans(target);
114
236
 
115
237
  // REUSE sidecar for the entry file + flavour directory.
116
238
  const entry = path.join(target, 'mermaid.esm.min.mjs');
@@ -121,13 +243,14 @@ function copyMermaid() {
121
243
  // generated tree without us having to enumerate every chunk by name.
122
244
  const chunksDir = path.join(target, 'chunks', 'mermaid.esm.min');
123
245
  if (existsSync(chunksDir)) {
124
- writeFileSync(
125
- path.join(target, 'chunks', 'mermaid.esm.min.license'),
246
+ writeIfChanged(
247
+ flavourLicensePath,
126
248
  'SPDX-FileCopyrightText: 2014-2026 Mermaid contributors\nSPDX-License-Identifier: MIT\n',
127
- 'utf8',
128
249
  );
129
250
  }
130
- process.stdout.write(` ✓ mermaid/ (entry + ${countMjs(target)} mjs chunks)\n`);
251
+ process.stdout.write(
252
+ ` ✓ mermaid/ (${copied} copied, ${unchanged} unchanged; ${countMjs(target)} total mjs chunks)\n`,
253
+ );
131
254
  }
132
255
 
133
256
  function countMjs(dir) {
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+
3
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
4
+ // SPDX-License-Identifier: Apache-2.0
5
+
6
+ /**
7
+ * Generate responsive image variants for the static site.
8
+ *
9
+ * Source assets remain the canonical artwork; this script derives optimized
10
+ * AVIF/WebP/JPEG renditions used by the HTML `srcset` markup and deploy
11
+ * workflow. It is intentionally deterministic so deploys can regenerate
12
+ * variants before upload and local contributors can refresh them after artwork
13
+ * changes with `npm run image:generate`.
14
+ */
15
+
16
+ import { mkdir } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import sharp from 'sharp';
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+ const repoRoot = path.resolve(__dirname, '..');
24
+
25
+ // Quality targets keep banner/social images below the 500 KB page-image budget
26
+ // while preserving sharp text and logo edges: AVIF 55/WebP 78 are visually close
27
+ // to mozjpeg 82 for this artwork, with materially smaller transfer sizes.
28
+ const jpegOptions = { quality: 82, mozjpeg: true };
29
+ const webpOptions = { quality: 78, effort: 6 };
30
+ const avifOptions = { quality: 55, effort: 7 };
31
+
32
+ const assets = [
33
+ {
34
+ source: 'images/banner.png',
35
+ basename: 'banner',
36
+ aspectRatio: 3 / 1,
37
+ widths: [320, 480, 768, 1200],
38
+ formats: ['avif', 'webp', 'jpg'],
39
+ },
40
+ {
41
+ source: 'images/og-image.png',
42
+ basename: 'og-image',
43
+ aspectRatio: 1200 / 630,
44
+ widths: [600, 1200],
45
+ formats: ['avif', 'webp', 'jpg'],
46
+ },
47
+ {
48
+ source: 'images/twitter-card.png',
49
+ basename: 'twitter-card',
50
+ aspectRatio: 2 / 1,
51
+ widths: [600, 1200],
52
+ formats: ['avif', 'webp', 'jpg'],
53
+ },
54
+ {
55
+ source: 'images/logo-full.png',
56
+ basename: 'logo-full',
57
+ aspectRatio: 3 / 2,
58
+ widths: [384, 768, 1536],
59
+ formats: ['avif', 'webp'],
60
+ },
61
+ ];
62
+
63
+ function outputPathFor(asset, width, format) {
64
+ const extension = format === 'jpg' ? 'jpg' : format;
65
+ return path.join(repoRoot, 'images', `${asset.basename}-${width}.${extension}`);
66
+ }
67
+
68
+ function applyFormat(pipeline, format) {
69
+ if (format === 'avif') {
70
+ return pipeline.avif(avifOptions);
71
+ }
72
+
73
+ if (format === 'webp') {
74
+ return pipeline.webp(webpOptions);
75
+ }
76
+
77
+ return pipeline.jpeg(jpegOptions);
78
+ }
79
+
80
+ async function generateAsset(asset) {
81
+ const sourcePath = path.join(repoRoot, asset.source);
82
+ for (const width of asset.widths) {
83
+ const height = Math.round(width / asset.aspectRatio);
84
+ for (const format of asset.formats) {
85
+ const target = outputPathFor(asset, width, format);
86
+ const pipeline = sharp(sourcePath)
87
+ .rotate()
88
+ .resize({
89
+ width,
90
+ height,
91
+ fit: 'cover',
92
+ position: 'centre',
93
+ withoutEnlargement: true,
94
+ });
95
+
96
+ await applyFormat(pipeline, format).toFile(target);
97
+ console.log(`Generated ${path.relative(repoRoot, target)} (${width}x${height})`);
98
+ }
99
+ }
100
+ }
101
+
102
+ await mkdir(path.join(repoRoot, 'images'), { recursive: true });
103
+
104
+ for (const asset of assets) {
105
+ await generateAsset(asset);
106
+ }
@@ -36,6 +36,7 @@ import {
36
36
  BUILD_TIME,
37
37
  RELEASE_TAG,
38
38
  } from '../constants/config.js';
39
+ import { writeFileIfChanged } from '../utils/file-utils.js';
39
40
 
40
41
  const __filename = fileURLToPath(import.meta.url);
41
42
  const __dirname = path.dirname(__filename);
@@ -51,8 +52,13 @@ function main() {
51
52
  };
52
53
 
53
54
  const outPath = path.join(PROJECT_ROOT, 'build-info.json');
54
- fs.writeFileSync(outPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
55
- console.log(`✅ Wrote build-info.json (buildShort=${BUILD_SHORT}, appVersion=${APP_VERSION})`);
55
+ // build-info.json content is BUILD_ID-keyed so it changes per commit by
56
+ // design; using writeFileIfChanged still pays off on workflow_dispatch
57
+ // re-runs of the same SHA — same BUILD_ID, same payload, no mtime drift.
58
+ const wrote = writeFileIfChanged(outPath, JSON.stringify(payload, null, 2) + '\n');
59
+ console.log(
60
+ `${wrote ? '✅' : '·'} ${wrote ? 'Wrote' : 'Unchanged'} build-info.json (buildShort=${BUILD_SHORT}, appVersion=${APP_VERSION})`,
61
+ );
56
62
 
57
63
  // Render the service-worker from its template — substitutes the build id
58
64
  // into `CACHE_VERSION` so old caches are evicted on every deploy.
@@ -63,8 +69,10 @@ function main() {
63
69
  const rendered = tpl
64
70
  .replace(/__BUILD_ID__/g, BUILD_ID)
65
71
  .replace(/__BUILD_SHORT__/g, BUILD_SHORT);
66
- fs.writeFileSync(swPath, rendered, 'utf-8');
67
- console.log(`✅ Rendered sw.js from template (CACHE_VERSION=${BUILD_SHORT})`);
72
+ const swWrote = writeFileIfChanged(swPath, rendered);
73
+ console.log(
74
+ `${swWrote ? '✅' : '·'} ${swWrote ? 'Rendered' : 'Unchanged'} sw.js from template (CACHE_VERSION=${BUILD_SHORT})`,
75
+ );
68
76
  } else {
69
77
  console.warn(`⚠️ sw.js.template not found at ${tplPath} — skipping sw.js render`);
70
78
  }
@@ -14,7 +14,7 @@ import { PROJECT_ROOT, APP_VERSION, BUILD_SHORT, NEWS_DIR, BASE_URL } from '../c
14
14
  import { getNewsIndexSeo } from './seo-copy.js';
15
15
  import { buildHeadFreshnessTags } from '../constants/build-info-meta.js';
16
16
  import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, PAGE_DESCRIPTIONS, SECTION_HEADINGS, NO_ARTICLES_MESSAGES, SKIP_LINK_TEXTS, AI_SECTION_CONTENT, FILTER_LABELS, ARTICLE_TYPE_LABELS, HEADER_SUBTITLE_LABELS, getLocalizedString, getTextDirection, } from '../constants/languages.js';
17
- import { buildSiteFooter, buildSiteHeader } from '../templates/section-builders.js';
17
+ import { buildResponsiveBannerPicture, buildResponsiveIconLinks, buildResponsiveSocialImageMeta, buildSiteFooter, buildSiteHeader, } from '../templates/section-builders.js';
18
18
  import { getNewsArticles, groupArticlesByLanguage, formatSlug, parseArticleFilename, extractArticleMeta, escapeHTML, atomicWrite, } from '../utils/file-utils.js';
19
19
  import { writeMetadataDatabase } from '../utils/news-metadata.js';
20
20
  import { detectCategory } from '../utils/article-category.js';
@@ -400,7 +400,6 @@ export function generateIndexHTML(lang, articles, metaMap = new Map()) {
400
400
  : '';
401
401
  const seo = getNewsIndexSeo(lang);
402
402
  const canonicalUrl = `${BASE_URL}/${selfHref}`;
403
- const ogImage = `${BASE_URL}/images/og-image.jpg`;
404
403
  const websiteJsonLd = JSON.stringify({
405
404
  '@context': SCHEMA_ORG,
406
405
  '@type': 'WebSite',
@@ -511,21 +510,13 @@ export function generateIndexHTML(lang, articles, metaMap = new Map()) {
511
510
  <meta property="og:url" content="${canonicalUrl}">
512
511
  <meta property="og:site_name" content="EU Parliament Monitor">
513
512
  <meta property="og:locale" content="${lang}">
514
- <meta property="og:image" content="${ogImage}">
515
- <meta property="og:image:width" content="1200">
516
- <meta property="og:image:height" content="630">
517
- <meta property="og:image:alt" content="${escapeHTML(seo.ogImageAlt)}">
513
+ ${buildResponsiveSocialImageMeta(seo.ogImageAlt)}
518
514
  <meta name="twitter:card" content="summary_large_image">
519
515
  <meta name="twitter:title" content="${heroTitle}">
520
516
  <meta name="twitter:description" content="${description}">
521
- <meta name="twitter:image" content="${ogImage}">
522
- <meta name="twitter:image:alt" content="${escapeHTML(seo.ogImageAlt)}">
523
517
  ${buildHreflangTags()}
524
518
  <!-- Favicons -->
525
- <link rel="icon" type="image/x-icon" href="favicon.ico">
526
- <link rel="icon" type="image/png" sizes="32x32" href="images/favicon-32x32.png">
527
- <link rel="icon" type="image/png" sizes="16x16" href="images/favicon-16x16.png">
528
- <link rel="apple-touch-icon" sizes="180x180" href="images/apple-touch-icon.png">
519
+ ${buildResponsiveIconLinks('')}
529
520
  <link rel="manifest" href="site.webmanifest">
530
521
  <meta name="color-scheme" content="light dark">
531
522
  <meta name="theme-color" content="#003399" media="(prefers-color-scheme: light)">
@@ -552,10 +543,13 @@ ${buildHeadFreshnessTags('')}
552
543
  <h1 class="hero__title">${heroTitle}</h1>
553
544
  <p class="hero__description">${description}</p>
554
545
  </div>
555
- <picture class="hero__banner">
556
- <source srcset="images/banner.webp" type="image/webp">
557
- <img src="images/banner.jpg" alt="EU Parliament Monitor — AI-Disrupted Parliamentary Intelligence" class="hero__banner-img" width="1200" height="400" loading="eager">
558
- </picture>
546
+ ${buildResponsiveBannerPicture({
547
+ pathPrefix: '',
548
+ pictureClass: 'hero__banner',
549
+ imageClass: 'hero__banner-img',
550
+ alt: 'EU Parliament Monitor — AI-Disrupted Parliamentary Intelligence',
551
+ sizes: '100vw',
552
+ })}
559
553
  </div>
560
554
  </section>
561
555
 
@@ -16,7 +16,7 @@ import { BASE_URL, BUILD_SHORT, THEME_TOGGLE_SCRIPT } from '../../constants/conf
16
16
  import { buildHeadFreshnessTags } from '../../constants/build-info-meta.js';
17
17
  import { ALL_LANGUAGES, LANGUAGE_FLAGS, LANGUAGE_NAMES, PAGE_TITLES, SKIP_LINK_TEXTS, getLocalizedString, getTextDirection, } from '../../constants/languages.js';
18
18
  import { FOOTER_SITEMAP_LABELS } from '../../constants/language-ui.js';
19
- import { buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../../templates/section-builders.js';
19
+ import { buildResponsiveIconLinks, buildResponsiveSocialImageMeta, buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../../templates/section-builders.js';
20
20
  import { escapeHTML } from '../../utils/file-utils.js';
21
21
  import { blobUrl, treeUrl } from '../../aggregator/infra/github-urls.js';
22
22
  import { getCuratedDescription, getCuratedTitle, getRunTypeInfo, getArtifactInfo, } from '../political-intelligence-descriptions.js';
@@ -250,7 +250,6 @@ export function generatePoliticalIntelligenceHTML(lang, data) {
250
250
  ? ` <p class="pi-source-note" role="note">${escapeHTML(copy.sourceInEnglishNote)}</p>`
251
251
  : '';
252
252
  const seo = getPoliticalIntelligenceSeo(safeLang);
253
- const ogImage = `${BASE_URL}/images/og-image.jpg`;
254
253
  const publisher = {
255
254
  '@type': 'Organization',
256
255
  '@id': `${BASE_URL}/#organization`,
@@ -379,20 +378,12 @@ ${hreflangLinks}
379
378
  <meta property="og:url" content="${canonicalUrl}">
380
379
  <meta property="og:site_name" content="EU Parliament Monitor">
381
380
  <meta property="og:locale" content="${safeLang}">
382
- <meta property="og:image" content="${ogImage}">
383
- <meta property="og:image:alt" content="${escapeHTML(seo.ogImageAlt)}">
384
- <meta property="og:image:width" content="1200">
385
- <meta property="og:image:height" content="630">
381
+ ${buildResponsiveSocialImageMeta(seo.ogImageAlt)}
386
382
  <meta name="twitter:card" content="summary_large_image">
387
383
  <meta name="twitter:title" content="${escapeHTML(copy.title)}">
388
384
  <meta name="twitter:description" content="${escapeHTML(description)}">
389
- <meta name="twitter:image" content="${ogImage}">
390
- <meta name="twitter:image:alt" content="${escapeHTML(seo.ogImageAlt)}">
391
385
  <!-- Favicons -->
392
- <link rel="icon" type="image/x-icon" href="favicon.ico">
393
- <link rel="icon" type="image/png" sizes="32x32" href="images/favicon-32x32.png">
394
- <link rel="icon" type="image/png" sizes="16x16" href="images/favicon-16x16.png">
395
- <link rel="apple-touch-icon" sizes="180x180" href="images/apple-touch-icon.png">
386
+ ${buildResponsiveIconLinks('')}
396
387
  <link rel="manifest" href="site.webmanifest">
397
388
  <meta name="theme-color" content="#003399">
398
389
  <link rel="stylesheet" href="styles.css?v=${BUILD_SHORT}">
@@ -26,7 +26,7 @@ import { ALL_LANGUAGES, LANGUAGE_NAMES, LANGUAGE_FLAGS, PAGE_TITLES, PAGE_DESCRI
26
26
  import { escapeHTML } from '../../utils/file-utils.js';
27
27
  import { detectCategory } from '../../utils/article-category.js';
28
28
  import { ARTICLE_TYPE_LABELS, FOOTER_POLITICAL_INTELLIGENCE_LABELS, } from '../../constants/language-ui.js';
29
- import { buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../../templates/section-builders.js';
29
+ import { buildResponsiveIconLinks, buildResponsiveSocialImageMeta, buildSiteFooter, buildSiteHeader, buildPageBanner, } from '../../templates/section-builders.js';
30
30
  import { getPoliticalIntelligenceFilename } from '../political-intelligence.js';
31
31
  import { SITEMAP_TITLES, SITEMAP_SECTIONS, DOCS_LABELS, CATEGORY_ORDER, DEFAULT_SITEMAP_TITLE, getSitemapCopy, } from './copy.js';
32
32
  /**
@@ -178,7 +178,6 @@ ${items}
178
178
  </section>`
179
179
  : '';
180
180
  const seo = getSitemapSeo(lang);
181
- const ogImage = `${BASE_URL}/images/og-image.jpg`;
182
181
  const jsonLd = {
183
182
  '@context': SCHEMA_ORG,
184
183
  '@type': 'CollectionPage',
@@ -287,20 +286,12 @@ ${hreflangLinks}
287
286
  <meta property="og:url" content="${canonicalUrl}">
288
287
  <meta property="og:site_name" content="EU Parliament Monitor">
289
288
  <meta property="og:locale" content="${lang}">
290
- <meta property="og:image" content="${ogImage}">
291
- <meta property="og:image:width" content="1200">
292
- <meta property="og:image:height" content="630">
293
- <meta property="og:image:alt" content="${escapeHTML(seo.ogImageAlt)}">
289
+ ${buildResponsiveSocialImageMeta(seo.ogImageAlt)}
294
290
  <meta name="twitter:card" content="summary_large_image">
295
291
  <meta name="twitter:title" content="${escapeHTML(sitemapTitle)}">
296
292
  <meta name="twitter:description" content="${escapeHTML(description)}">
297
- <meta name="twitter:image" content="${ogImage}">
298
- <meta name="twitter:image:alt" content="${escapeHTML(seo.ogImageAlt)}">
299
293
  <!-- Favicons -->
300
- <link rel="icon" type="image/x-icon" href="favicon.ico">
301
- <link rel="icon" type="image/png" sizes="32x32" href="images/favicon-32x32.png">
302
- <link rel="icon" type="image/png" sizes="16x16" href="images/favicon-16x16.png">
303
- <link rel="apple-touch-icon" sizes="180x180" href="images/apple-touch-icon.png">
294
+ ${buildResponsiveIconLinks('')}
304
295
  <link rel="manifest" href="site.webmanifest">
305
296
  <meta name="theme-color" content="#003399">
306
297
  <link rel="stylesheet" href="styles.css?v=${BUILD_SHORT}">