Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 | 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 249x 249x 249x 249x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x | // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
/**
* @module Constants/Config
* @description Shared configuration constants
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { ArticleCategory } from '../types/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/** Project root directory */
export const PROJECT_ROOT: string = path.resolve(__dirname, '..', '..');
/** News directory */
export const NEWS_DIR: string = path.join(PROJECT_ROOT, 'news');
/** Metadata directory */
export const METADATA_DIR: string = path.join(NEWS_DIR, 'metadata');
/** Base URL for the production site */
export const BASE_URL = 'https://euparliamentmonitor.com';
/** Article filename pattern regex */
export const ARTICLE_FILENAME_PATTERN = /^(\d{4}-\d{2}-\d{2})-(.+)-([a-z]{2})\.html$/;
/** Words per minute for read time calculation */
export const WORDS_PER_MINUTE = 250;
/** Valid article categories for generation — all values of the ArticleCategory enum */
export const VALID_ARTICLE_CATEGORIES: readonly ArticleCategory[] = Object.values(
ArticleCategory
) as ArticleCategory[];
/** Week ahead article category constant */
export const ARTICLE_TYPE_WEEK_AHEAD = ArticleCategory.WEEK_AHEAD;
/** Breaking news article category constant */
export const ARTICLE_TYPE_BREAKING = ArticleCategory.BREAKING_NEWS;
/** Committee reports article category constant */
export const ARTICLE_TYPE_COMMITTEE_REPORTS = ArticleCategory.COMMITTEE_REPORTS;
/** Propositions article category constant */
export const ARTICLE_TYPE_PROPOSITIONS = ArticleCategory.PROPOSITIONS;
/** Motions article category constant */
export const ARTICLE_TYPE_MOTIONS = ArticleCategory.MOTIONS;
/** Month ahead article category constant */
export const ARTICLE_TYPE_MONTH_AHEAD = ArticleCategory.MONTH_AHEAD;
/** Week in review article category constant */
export const ARTICLE_TYPE_WEEK_IN_REVIEW = ArticleCategory.WEEK_IN_REVIEW;
/** Month in review article category constant */
export const ARTICLE_TYPE_MONTH_IN_REVIEW = ArticleCategory.MONTH_IN_REVIEW;
/** CLI argument separator */
export const ARG_SEPARATOR = '=';
/** Application version read from package.json */
export const APP_VERSION: string = (() => {
try {
const pkgPath = path.join(PROJECT_ROOT, 'package.json');
const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
Eif (typeof parsed === 'object' && parsed !== null && 'version' in parsed) {
const versionValue = (parsed as { version: unknown }).version;
Eif (typeof versionValue === 'string' && versionValue.trim() !== '') {
return versionValue;
}
}
console.warn('Invalid or missing "version" in package.json, falling back to default 0.0.0');
return '0.0.0';
} catch (err) {
console.warn('Failed to read version from package.json:', err);
return '0.0.0';
}
})();
/**
* Pinned Mermaid bundle version, read from `devDependencies.mermaid` in
* `package.json`. Used as a cache-busting query parameter on the
* `mermaid-init.js` script tag in generated article HTML so a Mermaid
* version bump in `package.json` automatically invalidates browser /
* CloudFront caches the next time articles are regenerated. Any leading
* semver range character (`^`, `~`, `>=`) is stripped — the contract for
* this repo is a fixed pin (e.g. `"mermaid": "11.15.0"`), but stripping
* keeps us robust if the pin is briefly relaxed during a dependency update.
*/
export const MERMAID_VERSION: string = (() => {
try {
const pkgPath = path.join(PROJECT_ROOT, 'package.json');
const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
Eif (typeof parsed === 'object' && parsed !== null && 'devDependencies' in parsed) {
const devDeps = (parsed as { devDependencies: unknown }).devDependencies;
Eif (typeof devDeps === 'object' && devDeps !== null && 'mermaid' in devDeps) {
const raw = (devDeps as { mermaid: unknown }).mermaid;
Eif (typeof raw === 'string' && raw.trim() !== '') {
return raw.replace(/^[\^~><=\s]+/, '').trim();
}
}
}
console.warn(
'Invalid or missing "devDependencies.mermaid" in package.json, falling back to 0.0.0'
);
return '0.0.0';
} catch (err) {
console.warn('Failed to read mermaid version from package.json:', err);
return '0.0.0';
}
})();
/**
* Generate theme toggle HTML button markup with a localized aria-label.
* Renders a moon (light→dark) and sun (dark→light) icon as crisp inline
* SVGs (with `currentColor`) so the icon renders identically across
* platforms — emoji rendering varies wildly between OSes. The legacy
* emoji `<span>`s remain as a low-priority fallback for environments
* where SVG is suppressed or stylesheets fail to load.
*
* The button announces its current state via `aria-pressed` so screen
* readers describe it as a toggle rather than a generic action button.
*
* @param ariaLabel - Localized accessible label for the theme toggle button
* @returns HTML string for the theme toggle button
*/
export function createThemeToggleButton(ariaLabel: string): string {
const moonSvg =
'<svg class="icon icon-inline theme-toggle__svg theme-toggle__svg--light" width="20" height="20" viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false"><path d="M21 13a9 9 0 1 1-10-10 7 7 0 0 0 10 10Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>';
const sunSvg =
'<svg class="icon icon-inline theme-toggle__svg theme-toggle__svg--dark" width="20" height="20" viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.93 4.93l2.12 2.12M16.95 16.95l2.12 2.12M4.93 19.07l2.12-2.12M16.95 7.05l2.12-2.12" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
const safeLabel = ariaLabel
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
return `<button type="button" class="theme-toggle" aria-label="${safeLabel}" aria-pressed="false" title="${safeLabel}">${moonSvg}${sunSvg}<span class="theme-toggle__icon--light theme-toggle__emoji" aria-hidden="true">🌙</span><span class="theme-toggle__icon--dark theme-toggle__emoji" aria-hidden="true">☀️</span></button>`;
}
/**
* Raw theme toggle script content (without wrapping `<script>` tags).
* Used as single source of truth for both the injected `<script>` block
* and the CSP hash computation in article-template.ts.
*/
export const THEME_TOGGLE_SCRIPT_CONTENT = `
(function(){
var docEl=document.documentElement;
var t=localStorage.getItem('ep-theme');
var storedTheme=t==='light'?'light':t==='dark'?'dark':null;
if(storedTheme){
docEl.setAttribute('data-theme',storedTheme);
}else if(t){
localStorage.removeItem('ep-theme');
}
var btn=document.querySelector('.theme-toggle');
if(!btn)return;
function syncPressed(){
var cur=docEl.getAttribute('data-theme');
if(!cur){
cur=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';
}
btn.setAttribute('aria-pressed',cur==='dark'?'true':'false');
}
syncPressed();
btn.addEventListener('click',function(){
var cur=docEl.getAttribute('data-theme');
if(!cur){
cur=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';
}
var next=cur==='dark'?'light':'dark';
docEl.setAttribute('data-theme',next);
localStorage.setItem('ep-theme',next);
btn.setAttribute('aria-pressed',next==='dark'?'true':'false');
});
if(window.matchMedia){
var mq=window.matchMedia('(prefers-color-scheme: dark)');
if(mq.addEventListener){mq.addEventListener('change',syncPressed);}
}
})();
`;
/**
* Theme toggle inline script block (complete `<script>…</script>` markup).
* Reads/writes localStorage key "ep-theme" and sets `data-theme` on `<html>`.
* Detects system theme on first click when no explicit preference is saved.
*/
export const THEME_TOGGLE_SCRIPT = `
<script>${THEME_TOGGLE_SCRIPT_CONTENT}</script>`;
/**
* Resolve the current build commit SHA. Precedence:
* 1. `process.env.BUILD_ID` (CI sets this from `${{ github.sha }}`)
* 2. `git rev-parse HEAD` (works in dev clones / local builds)
* 3. `'0'.repeat(40)` (deterministic, never throws)
*
* Always returns a 40-char lowercase hex string. Never throws — generator
* scripts must be safe to run on machines without git installed.
*
* @returns 40-char lowercase hex commit SHA, or `'0'.repeat(40)` placeholder.
*/
function resolveBuildId(): string {
const fromEnv = (process.env.BUILD_ID ?? '').trim();
Iif (/^[0-9a-f]{40}$/i.test(fromEnv)) {
return fromEnv.toLowerCase();
}
try {
const fromGit = execSync('git rev-parse HEAD', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
cwd: PROJECT_ROOT,
}).trim();
Eif (/^[0-9a-f]{40}$/i.test(fromGit)) {
return fromGit.toLowerCase();
}
} catch {
/* git unavailable or not a repo — fall through to placeholder */
}
return '0'.repeat(40);
}
/**
* Full git commit SHA (40 chars) for the running build. Resolved via env
* (`BUILD_ID`), then `git rev-parse HEAD`, then a deterministic placeholder
* (`'0'.repeat(40)`). Never empty, never throws.
*/
export const BUILD_ID: string = resolveBuildId();
/** First 7 chars of {@link BUILD_ID} — the conventional short SHA. */
export const BUILD_SHORT: string = BUILD_ID.slice(0, 7);
/**
* ISO 8601 timestamp for when this build was produced. Precedence:
* 1. `process.env.BUILD_TIME` (CI sets this in the workflow)
* 2. Commit timestamp of {@link BUILD_ID} (`git log -1 --format=%cI`) —
* deterministic for a given commit, so workflow_dispatch re-runs of the
* same SHA produce a byte-identical `build-info.json` and `sw.js` and
* `aws s3 sync` correctly skips them as unchanged.
* 3. `new Date().toISOString()` fallback (only hits when both env and git
* are unavailable, e.g. tarball builds outside a git checkout).
*/
export const BUILD_TIME: string = (() => {
const fromEnv = (process.env.BUILD_TIME ?? '').trim();
Iif (fromEnv) return fromEnv;
try {
const fromGit = execSync('git log -1 --format=%cI', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
cwd: PROJECT_ROOT,
}).trim();
Eif (fromGit) return fromGit;
} catch {
/* git unavailable or not a repo — fall through to wall-clock fallback */
}
return new Date().toISOString();
})();
/**
* Optional release tag (e.g. `v0.8.51`). Empty string when no tag was
* supplied via `process.env.RELEASE_TAG`. Surfaced in `build-info.json`
* for clients that want a human-readable label.
*/
export const RELEASE_TAG: string = (process.env.RELEASE_TAG ?? '').trim();
// ─── EP Election Calendar Constants ────────────────────────────────────────
/**
* Start of the next European Parliament election window (EP10 → EP11 transition).
* Council Decision (EU) 2018/767 sets the election in the second week of June.
* ISO 8601 date string.
*/
export const EP_NEXT_ELECTION_START = '2029-06-04';
/**
* End of the next European Parliament election window (EP10 → EP11 transition).
* ISO 8601 date string.
*/
export const EP_NEXT_ELECTION_END = '2029-06-09';
/** Current parliamentary term identifier */
export const EP_CURRENT_TERM: 'EP10' | 'EP11' = 'EP10';
/** Next parliamentary term identifier */
export const EP_NEXT_TERM: 'EP10' | 'EP11' = 'EP11';
|