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 +4 -2
- package/scripts/aggregator/article-html.js +4 -11
- package/scripts/constants/config.d.ts +6 -1
- package/scripts/constants/config.js +18 -1
- package/scripts/copy-vendor.js +164 -41
- package/scripts/generate-responsive-images.js +106 -0
- package/scripts/generators/build-info.js +12 -4
- package/scripts/generators/news-indexes.js +10 -16
- package/scripts/generators/political-intelligence/html.js +3 -12
- package/scripts/generators/sitemap/html.js +3 -12
- package/scripts/generators/sitemap.js +115 -52
- package/scripts/mcp/ep-open-data-client.d.ts +1 -1
- package/scripts/mcp/imf-mcp-client.d.ts +1 -1
- package/scripts/minify-assets.js +27 -12
- package/scripts/normalize-legacy-articles.js +238 -0
- package/scripts/optimize-css.js +4 -3
- package/scripts/templates/section-builders.d.ts +36 -0
- package/scripts/templates/section-builders.js +84 -9
- package/scripts/utils/file-utils.d.ts +18 -1
- package/scripts/utils/file-utils.js +36 -4
- package/scripts/utils/news-metadata.d.ts +7 -1
- 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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
/**
|
package/scripts/copy-vendor.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// `
|
|
88
|
-
//
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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(
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
67
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}">
|