Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"prebuild": "npx tsx scripts/optimize-images.ts && npx tsx scripts/generate-article-types-doc.ts && npx tsx scripts/copy-vendor-mermaid.ts --quiet && npx tsx scripts/build-csv-contracts-fixture.ts && npx tsx scripts/aggregate-analysis.ts --all --quiet && npx tsx scripts/render-articles.ts --all --lang all --quiet && npx tsx scripts/generate-news-indexes/index.ts && npx tsx scripts/extract-news-metadata.ts && npx tsx scripts/generate-sitemap-html.ts && npx tsx scripts/generate-political-intelligence.ts && npx tsx scripts/generate-rss.ts && npx tsx scripts/generate-sitemap.ts && npx tsx scripts/normalize-static-html-chrome.ts && npx tsx scripts/backfill-translated-chrome.ts && npx tsx scripts/strip-legacy-chrome-script-tags.ts && npx tsx scripts/fix-hreflang.ts --write",
"build": "vite build",
"build:lib": "tsc -p tsconfig.lib.json && tsc -p tsconfig.npm-scripts.json",
"postbuild": "cp rss.xml dist/rss.xml && cp sitemap.xml dist/sitemap.xml && cp -r cia-data dist/cia-data && cp manifest.json dist/manifest.json",
"postbuild": "cp rss.xml dist/rss.xml && (cp rss_*.xml dist/ 2>/dev/null; exit 0) && cp sitemap.xml dist/sitemap.xml && cp -r cia-data dist/cia-data && cp manifest.json dist/manifest.json",
"preview": "vite preview",
"test": "NODE_OPTIONS='--max-old-space-size=2048' vitest run",
"test:watch": "vitest",
Expand Down
1,185 changes: 1,185 additions & 0 deletions rss_ar.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_da.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_de.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_es.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_fi.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_fr.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_he.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_ja.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_ko.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_nl.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_no.xml

Large diffs are not rendered by default.

1,161 changes: 1,161 additions & 0 deletions rss_sv.xml

Large diffs are not rendered by default.

1,185 changes: 1,185 additions & 0 deletions rss_zh.xml

Large diffs are not rendered by default.

42 changes: 32 additions & 10 deletions scripts/generate-rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,33 +21,55 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

import { generateRss, validateRss } from './rss/index.js';
import type { Language } from './types/language.js';

import { generateRss, validateRss, getRssArticles } from './rss/index.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const ROOT_DIR = path.join(__dirname, '..');
const RSS_FILE = path.join(ROOT_DIR, 'rss.xml');

/** All supported feed languages. English maps to `rss.xml`; others to `rss_<lang>.xml`. */
const LANGUAGES: readonly Language[] = ['en', 'sv', 'da', 'no', 'fi', 'de', 'fr', 'es', 'nl', 'ar', 'he', 'ja', 'ko', 'zh'];

/** Resolve the on-disk feed path for a language. */
function feedPath(lang: Language): string {
return path.join(ROOT_DIR, lang === 'en' ? 'rss.xml' : `rss_${lang}.xml`);
}

console.log('📡 RSS Feed Generation Script');

/**
* Build, validate, and write `rss.xml`. Returns 0 on success, 1 on
* failure (matches the legacy CLI exit-code contract).
* Build, validate, and write `rss.xml` plus one localized `rss_<lang>.xml`
* per supported language. Returns 0 on success, 1 on failure (matches the
* legacy CLI exit-code contract).
*/
function main(): number {
try {
console.log('🚀 Starting RSS feed generation...\n');

const rss = generateRss();
validateRss(rss);
for (const lang of LANGUAGES) {
// A localized language may not have any translated articles yet.
// Skipping keeps the build green and — together with the sitemap's
// existence checks — ensures we never advertise an empty/missing
// feed. English always emits to preserve the legacy contract.
if (lang !== 'en' && getRssArticles(lang).length === 0) {
console.log(`⏭️ rss_${lang}.xml skipped (no ${lang} articles)`);
continue;
}

const rss = generateRss(lang);
validateRss(rss);

fs.writeFileSync(RSS_FILE, rss, 'utf8');
console.log(`\n✅ RSS feed written to: ${RSS_FILE}`);
const file = feedPath(lang);
fs.writeFileSync(file, rss, 'utf8');
Comment on lines +65 to +66

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. The postbuild script in package.json now copies all rss_*.xml files to dist/ after rss.xml:

"postbuild": "cp rss.xml dist/rss.xml && (cp rss_*.xml dist/ 2>/dev/null; exit 0) && cp sitemap.xml dist/sitemap.xml && ..."

The subshell with exit 0 ensures the step is always treated as optional — if no localized feed files exist yet, the build still succeeds.


const stats = fs.statSync(RSS_FILE);
console.log(` File size: ${(stats.size / 1024).toFixed(2)} KB`);
const stats = fs.statSync(file);
console.log(`✅ ${path.basename(file)} written (${(stats.size / 1024).toFixed(2)} KB)`);
}

console.log(`\n✅ RSS feeds written to: ${ROOT_DIR}`);
return 0;
} catch (error: unknown) {
console.error('❌ Error generating RSS feed:', (error as Error).message);
Expand Down
59 changes: 50 additions & 9 deletions scripts/rss/render/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
* @license Apache-2.0
*/

import type { Language } from '../../types/language.js';

import { getRssArticles } from '../scanner.js';
import { escapeXml } from '../escape.js';
import { hreflangCode } from '../hreflang.js';
import { getBySubfolder } from '../../render-lib/article-types.js';
import { LANGUAGE_META } from '../../sitemap-html/i18n.js';

const BASE_URL = 'https://riksdagsmonitor.com';

Expand All @@ -32,13 +35,51 @@ function subfolderFromBaseSlug(baseSlug: string): string | null {
return m ? m[1]! : null;
}

/** Localized channel title/description/self-href for one feed language. */
interface ChannelStrings {
title: string;
description: string;
language: string;
selfHref: string;
}

/**
* Resolve the localized channel strings for a feed language. English
* keeps its established branded title/description verbatim so the legacy
* `rss.xml` output is byte-stable; every other language reuses the
* localized strings already maintained in `LANGUAGE_META`.
*/
function channelStrings(feedLang: Language): ChannelStrings {
if (feedLang === 'en') {
return {
title: 'Riksdagsmonitor - Swedish Parliament Intelligence',
description:
'Real-time monitoring, analysis, and intelligence from the Swedish Parliament (Riksdag) and Government. Covering legislative activity, voting patterns, coalition dynamics, and election forecasts.',
language: 'en',
selfHref: `${BASE_URL}/rss.xml`,
};
}

const meta = LANGUAGE_META[feedLang];
const t = meta.translations;
return {
title: `Riksdagsmonitor — ${t.newsAnalysis} (${meta.nativeName})`,
description: t.newsDesc,
language: meta.hreflang,
selfHref: `${BASE_URL}/rss_${feedLang}.xml`,
};
}

/**
* Generate RSS 2.0 XML feed.
* Generate RSS 2.0 XML feed for the requested `feedLang` (defaults to
* English). The channel header is localized via `LANGUAGE_META` and each
* item carries the localized title/description of its language variant.
*/
export function generateRss(): string {
console.log('🔨 Generating RSS feed...');
export function generateRss(feedLang: Language = 'en'): string {
console.log(`🔨 Generating RSS feed (${feedLang})...`);

const articles = getRssArticles();
const channel = channelStrings(feedLang);
const articles = getRssArticles(feedLang);
const now = new Date().toUTCString();

const lastBuildDate = articles.length > 0
Expand All @@ -51,10 +92,10 @@ export function generateRss(): string {
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Riksdagsmonitor - Swedish Parliament Intelligence</title>
<title>${escapeXml(channel.title)}</title>
<link>${BASE_URL}</link>
<description>Real-time monitoring, analysis, and intelligence from the Swedish Parliament (Riksdag) and Government. Covering legislative activity, voting patterns, coalition dynamics, and election forecasts.</description>
<language>en</language>
<description>${escapeXml(channel.description)}</description>
<language>${channel.language}</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<pubDate>${lastBuildDate}</pubDate>
<ttl>60</ttl>
Expand All @@ -71,7 +112,7 @@ export function generateRss(): string {
<height>144</height>
<description>Riksdagsmonitor - Swedish Parliament Intelligence Platform</description>
</image>
<atom:link href="${BASE_URL}/rss.xml" rel="self" type="application/rss+xml"/>`;
<atom:link href="${channel.selfHref}" rel="self" type="application/rss+xml"/>`;

const channelCategories = [
'Swedish Politics', 'Parliament', 'Riksdag', 'Political Intelligence',
Expand All @@ -98,7 +139,7 @@ export function generateRss(): string {
<guid isPermaLink="true">${escapeXml(article.link)}</guid>
<dc:creator>${escapeXml(article.author)}</dc:creator>
<category>${escapeXml(categoryLabel)}</category>
<atom:link href="${escapeXml(article.link)}" rel="alternate" type="text/html" hreflang="en"/>`;
<atom:link href="${escapeXml(article.link)}" rel="alternate" type="text/html" hreflang="${hreflangCode(feedLang)}"/>`;

for (const alt of article.alternateLanguages) {
xml += `
Expand Down
46 changes: 30 additions & 16 deletions scripts/rss/scanner.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
/**
* @module Infrastructure/Rss/Scanner
* @category Intelligence Operations / Supporting Infrastructure
* @name News article scanner — English-primary with hreflang alternates
* @name News article scanner — language-aware with hreflang alternates
*
* @description
* Scans `news/` (top level only — does **not** recurse into date-partitioned
* subdirectories, matching legacy behaviour), groups files by base slug,
* keeps only those that have an English variant, builds the alternate-
* language map for hreflang link tags, sorts by pub date descending, and
* caps at `MAX_ITEMS` (50). Returns the list ready to be rendered into
* RSS `<item>` blocks.
* keeps only those that have a variant in the requested feed language
* (defaulting to English), builds the alternate-language map for hreflang
* link tags, sorts by pub date descending, and caps at `MAX_ITEMS` (50).
* Title/description are read from the per-language article HTML so each
* localized feed carries localized item metadata. Returns the list ready
* to be rendered into RSS `<item>` blocks.
*
* Round-6 split: extracted from `scripts/generate-rss.ts`.
*
Expand Down Expand Up @@ -47,10 +49,20 @@ export interface RssArticle {
}

/**
* Get news articles for RSS feed, primarily English with multi-language alternates.
* Get news articles for an RSS feed in the requested `feedLang`.
*
* Each returned item is anchored on the article variant that actually
* exists in `feedLang` (so the `<link>`/`<guid>` always point at a real
* file) and carries the localized title/description extracted from that
* variant's HTML. Article groups without a `feedLang` variant are
* skipped. The other language variants present for the same base slug
* become the `alternateLanguages` hreflang siblings.
*
* Defaults to English (`'en'`) so the legacy `rss.xml` output is
* unchanged.
*/
export function getRssArticles(): RssArticle[] {
console.log('📰 Scanning news directory for RSS articles...');
export function getRssArticles(feedLang: Language = 'en'): RssArticle[] {
console.log(`📰 Scanning news directory for RSS articles (${feedLang})...`);

if (!fs.existsSync(NEWS_DIR)) {
console.warn('⚠️ News directory not found');
Expand All @@ -77,15 +89,17 @@ export function getRssArticles(): RssArticle[] {
const articles: RssArticle[] = [];

for (const [baseSlug, langMap] of articleGroups) {
const enFile = langMap.get('en');
if (!enFile) continue;
const primaryFile = langMap.get(feedLang);
// Only emit an item when the requested language variant exists on
// disk — guarantees the feed never links to a missing page.
if (!primaryFile) continue;

const filePath = path.join(NEWS_DIR, enFile);
const filePath = path.join(NEWS_DIR, primaryFile);
const meta = extractArticleMeta(filePath);

const alternates: Array<{ lang: Language; href: string }> = [];
for (const [lang, altFile] of langMap) {
if (lang !== 'en') {
if (lang !== feedLang) {
alternates.push({
lang,
href: `${BASE_URL}/news/${altFile}`,
Expand All @@ -94,13 +108,13 @@ export function getRssArticles(): RssArticle[] {
}

articles.push({
file: enFile,
file: primaryFile,
title: meta.title,
description: meta.description,
link: `${BASE_URL}/news/${enFile}`,
link: `${BASE_URL}/news/${primaryFile}`,
pubDate: meta.pubDate,
baseSlug,
lang: 'en',
lang: feedLang,
author: meta.author,
category: meta.category,
alternateLanguages: alternates,
Expand All @@ -109,7 +123,7 @@ export function getRssArticles(): RssArticle[] {

articles.sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime());

console.log(` Found ${articles.length} English articles with multi-language alternates`);
console.log(` Found ${articles.length} ${feedLang} articles with multi-language alternates`);

return articles.slice(0, MAX_ITEMS);
}
Loading
Loading