From 3821a483c2b16b902977b3722600625a145cf253 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 02:27:21 +0000 Subject: [PATCH 1/5] feat: enable all:true coverage tracking and migrate fix-article-navigation to TypeScript Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- .github/workflows/news-article-generator.md | 2 +- .github/workflows/news-committee-reports.md | 2 +- .github/workflows/news-evening-analysis.md | 2 +- .github/workflows/news-month-ahead.md | 2 +- .github/workflows/news-monthly-review.md | 2 +- .github/workflows/news-motions.md | 2 +- .github/workflows/news-propositions.md | 2 +- .github/workflows/news-week-ahead.md | 2 +- .github/workflows/news-weekly-review.md | 2 +- scripts/fix-article-navigation.ts | 241 ++++++++++++++++++++ vitest.config.js | 19 +- 11 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 scripts/fix-article-navigation.ts diff --git a/.github/workflows/news-article-generator.md b/.github/workflows/news-article-generator.md index 154585651..4a9beb3f6 100644 --- a/.github/workflows/news-article-generator.md +++ b/.github/workflows/news-article-generator.md @@ -1483,7 +1483,7 @@ Remember: You are producing world-class political journalism that informs Swedis **Article Maintenance & Fixing:** | Script | Usage | Description | |--------|-------|-------------| -| `scripts/fix-article-navigation.py` | `python3 scripts/fix-article-navigation.py [--dry-run]` | **Fallback only** — adds language switcher + article-top-nav to articles missing them (idempotent) | +| `scripts/fix-article-navigation.ts` | `npx tsx scripts/fix-article-navigation.ts [--dry-run]` | **Fallback only** — adds language switcher + article-top-nav to articles missing them (idempotent) | | `scripts/fix-language-switchers-and-css.py` | `python3 scripts/fix-language-switchers-and-css.py` | Updates switchers to show only existing languages, removes embedded CSS | | `scripts/fix-mixed-language-descriptions.py` | `python3 scripts/fix-mixed-language-descriptions.py` | Fixes articles with mixed-language meta descriptions | diff --git a/.github/workflows/news-committee-reports.md b/.github/workflows/news-committee-reports.md index 7d71345d5..cf51f8c27 100644 --- a/.github/workflows/news-committee-reports.md +++ b/.github/workflows/news-committee-reports.md @@ -228,7 +228,7 @@ npx tsx scripts/generate-news-enhanced.ts \ These elements are validated by `bash scripts/validate-news-generation.sh` (Checks 8–10). The fix script is a **fallback only** — do not run it by default: ```bash # FALLBACK ONLY — use if validate-news-generation.sh reports missing navigation elements -python3 scripts/fix-article-navigation.py +npx tsx scripts/fix-article-navigation.ts ``` ### Step 4: Translate Swedish Content & Verify Analysis Quality diff --git a/.github/workflows/news-evening-analysis.md b/.github/workflows/news-evening-analysis.md index 2bf9fe979..aa758902e 100644 --- a/.github/workflows/news-evening-analysis.md +++ b/.github/workflows/news-evening-analysis.md @@ -1120,7 +1120,7 @@ For deeper analysis, combine MCP tools: `search_voteringar` → `get_voting_grou | Script | Usage | Description | |--------|-------|-------------| | `scripts/generate-news-enhanced.ts` | `npx tsx scripts/generate-news-enhanced.ts --types=evening-analysis --languages=LANGS` | Main article generator | -| `scripts/fix-article-navigation.py` | `python3 scripts/fix-article-navigation.py` | **Fallback only** — fix missing language switcher + article-top-nav (idempotent) | +| `scripts/fix-article-navigation.ts` | `npx tsx scripts/fix-article-navigation.ts` | **Fallback only** — fix missing language switcher + article-top-nav (idempotent) | | `scripts/validate-news-generation.sh` | `bash scripts/validate-news-generation.sh` | Validate generated article structure | | `scripts/mcp-setup.sh` | `source scripts/mcp-setup.sh` | Set MCP environment variables | | `scripts/mcp-query-cli.ts` | `npx tsx scripts/mcp-query-cli.ts ''` | Query individual MCP tools | diff --git a/.github/workflows/news-month-ahead.md b/.github/workflows/news-month-ahead.md index 882deda0e..fc4a77a2b 100644 --- a/.github/workflows/news-month-ahead.md +++ b/.github/workflows/news-month-ahead.md @@ -227,7 +227,7 @@ npx tsx scripts/generate-news-enhanced.ts \ These elements are validated by `bash scripts/validate-news-generation.sh` (Checks 8–10). The fix script is a **fallback only** — do not run it by default: ```bash # FALLBACK ONLY — use if validate-news-generation.sh reports missing navigation elements -python3 scripts/fix-article-navigation.py +npx tsx scripts/fix-article-navigation.ts ``` ### Step 4: Translate, Validate & Verify Analysis Quality diff --git a/.github/workflows/news-monthly-review.md b/.github/workflows/news-monthly-review.md index f17bfe3e3..99934bc8e 100644 --- a/.github/workflows/news-monthly-review.md +++ b/.github/workflows/news-monthly-review.md @@ -235,7 +235,7 @@ npx tsx scripts/generate-news-enhanced.ts \ These elements are validated by `bash scripts/validate-news-generation.sh` (Checks 8–10). The fix script is a **fallback only** — do not run it by default: ```bash # FALLBACK ONLY — use if validate-news-generation.sh reports missing navigation elements -python3 scripts/fix-article-navigation.py +npx tsx scripts/fix-article-navigation.ts ``` ### Step 4: Translate, Validate & Verify Analysis Quality diff --git a/.github/workflows/news-motions.md b/.github/workflows/news-motions.md index d02147e32..2d6e25b30 100644 --- a/.github/workflows/news-motions.md +++ b/.github/workflows/news-motions.md @@ -226,7 +226,7 @@ npx tsx scripts/generate-news-enhanced.ts \ These elements are validated by `bash scripts/validate-news-generation.sh` (Checks 8–10). The fix script is a **fallback only** — do not run it by default: ```bash # FALLBACK ONLY — use if validate-news-generation.sh reports missing navigation elements -python3 scripts/fix-article-navigation.py +npx tsx scripts/fix-article-navigation.ts ``` ### Step 4: Translate, Validate & Verify Analysis Quality diff --git a/.github/workflows/news-propositions.md b/.github/workflows/news-propositions.md index abd1b2a37..6007906fc 100644 --- a/.github/workflows/news-propositions.md +++ b/.github/workflows/news-propositions.md @@ -225,7 +225,7 @@ npx tsx scripts/generate-news-enhanced.ts \ These elements are validated by `bash scripts/validate-news-generation.sh` (Checks 8–10). The fix script is a **fallback only** — do not run it by default: ```bash # FALLBACK ONLY — use if validate-news-generation.sh reports missing navigation elements -python3 scripts/fix-article-navigation.py +npx tsx scripts/fix-article-navigation.ts ``` ### Step 4: Translate, Validate & Verify Analysis Quality diff --git a/.github/workflows/news-week-ahead.md b/.github/workflows/news-week-ahead.md index 02e3d4c28..27ba4b06b 100644 --- a/.github/workflows/news-week-ahead.md +++ b/.github/workflows/news-week-ahead.md @@ -225,7 +225,7 @@ npx tsx scripts/generate-news-enhanced.ts \ These elements are validated by `bash scripts/validate-news-generation.sh` (Checks 8–10). The fix script is a **fallback only** — do not run it by default: ```bash # FALLBACK ONLY — use if validate-news-generation.sh reports missing navigation elements -python3 scripts/fix-article-navigation.py +npx tsx scripts/fix-article-navigation.ts ``` ### Step 4: Translate, Validate & Verify Analysis Quality diff --git a/.github/workflows/news-weekly-review.md b/.github/workflows/news-weekly-review.md index 0154f88b2..d1701ee0c 100644 --- a/.github/workflows/news-weekly-review.md +++ b/.github/workflows/news-weekly-review.md @@ -230,7 +230,7 @@ npx tsx scripts/generate-news-enhanced.ts \ These elements are validated by `bash scripts/validate-news-generation.sh` (Checks 8–10). The fix script is a **fallback only** — do not run it by default: ```bash # FALLBACK ONLY — use if validate-news-generation.sh reports missing navigation elements -python3 scripts/fix-article-navigation.py +npx tsx scripts/fix-article-navigation.ts ``` ### Step 4: Translate, Validate & Verify Analysis Quality diff --git a/scripts/fix-article-navigation.ts b/scripts/fix-article-navigation.ts new file mode 100644 index 000000000..3409fd539 --- /dev/null +++ b/scripts/fix-article-navigation.ts @@ -0,0 +1,241 @@ +#!/usr/bin/env node +/** + * Fix Article Navigation: Language Switcher + Back-to-News Top Nav + * + * This script ensures ALL news articles have: + * 1. A language switcher nav (14 languages) after + * 2. An article-top-nav div with a localized back-to-news link before the article + * + * It auto-discovers all articles in the news/ directory and processes them + * idempotently — safe to run multiple times. + * + * Usage: + * npx tsx scripts/fix-article-navigation.ts + * npx tsx scripts/fix-article-navigation.ts --dry-run + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// ── Language configuration ──────────────────────────────────────────────── + +const LANGUAGES = ['en', 'sv', 'da', 'no', 'fi', 'de', 'fr', 'es', 'nl', 'ar', 'he', 'ja', 'ko', 'zh'] as const; +type Lang = typeof LANGUAGES[number]; + +const LANG_DISPLAY: Readonly> = { + en: ['🇬🇧', 'English'], + sv: ['🇸🇪', 'Svenska'], + da: ['🇩🇰', 'Dansk'], + no: ['🇳🇴', 'Norsk'], + fi: ['🇫🇮', 'Suomi'], + de: ['🇩🇪', 'Deutsch'], + fr: ['🇫🇷', 'Français'], + es: ['🇪🇸', 'Español'], + nl: ['🇳🇱', 'Nederlands'], + ar: ['🇸🇦', 'العربية'], + he: ['🇮🇱', 'עברית'], + ja: ['🇯🇵', '日本語'], + ko: ['🇰🇷', '한국어'], + zh: ['🇨🇳', '中文'], +} as const; + +const LANG_SWITCHER_ARIA: Readonly> = { + en: 'Language', sv: 'Språk', da: 'Sprog', no: 'Språk', + fi: 'Kieli', de: 'Sprache', fr: 'Langue', es: 'Idioma', + nl: 'Taal', ar: 'اللغة', he: 'שפה', ja: '言語', + ko: '언어', zh: '语言', +} as const; + +const BACK_TO_NEWS: Readonly> = { + en: 'Back to News', sv: 'Tillbaka till nyheter', + da: 'Tilbage til nyheder', no: 'Tilbake til nyheter', + fi: 'Takaisin uutisiin', de: 'Zurück zu Nachrichten', + fr: 'Retour aux actualités', es: 'Volver a noticias', + nl: 'Terug naar nieuws', ar: 'العودة إلى الأخبار', + he: 'חזרה לחדשות', ja: 'ニュースに戻る', + ko: '뉴스로 돌아가기', zh: '返回新闻', +} as const; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function extractLang(filename: string): Lang | null { + const name = filename.replace(/\.html$/, ''); + for (const lang of LANGUAGES) { + if (name.endsWith(`-${lang}`)) return lang; + } + return null; +} + +function extractBase(filename: string): string | null { + const name = filename.replace(/\.html$/, ''); + for (const lang of LANGUAGES) { + if (name.endsWith(`-${lang}`)) return name.slice(0, -(lang.length + 1)); + } + return null; +} + +function newsIndexFor(lang: Lang): string { + return lang === 'en' ? 'index.html' : `index_${lang}.html`; +} + +function generateLanguageSwitcher(baseSlug: string, currentLang: Lang): string { + const aria = LANG_SWITCHER_ARIA[currentLang]; + const lines: string[] = [` '); + return lines.join('\n'); +} + +function generateTopNav(lang: Lang): string { + const label = BACK_TO_NEWS[lang]; + const index = newsIndexFor(lang); + return `\n
\n \n ← ${label}\n \n
\n`; +} + +// ── Processing ──────────────────────────────────────────────────────────── + +interface ProcessResult { + addedSwitcher: boolean; + addedTopnav: boolean; +} + +function processArticle(filepath: string, baseSlug: string, lang: Lang, dryRun: boolean): ProcessResult { + const original = fs.readFileSync(filepath, 'utf-8'); + let content = original; + let addedSwitcher = false; + let addedTopnav = false; + + // ── 1. Language switcher ────────────────────────────────────────── + const hasSwitcher = content.includes('language-switcher'); + if (!hasSwitcher) { + const switcherHtml = generateLanguageSwitcher(baseSlug, lang); + content = content.replace(/()/, `$1\n${switcherHtml}`); + addedSwitcher = true; + } else { + // Update existing switcher to have all 14 languages + const newSwitcher = generateLanguageSwitcher(baseSlug, lang); + content = content.replace(/ of language-switcher, before article/div.news-article + if (content.includes('')) { + // Capture groups: (1)=full outer match, (2)=, (3)=whitespace, (4)=article/div opening tag + const navPattern = /((<\/nav>)([\s]*)(<(?:article|div)\s+class="(?:news-article|container)"))/s; + const match = navPattern.exec(content); + if (match) { + const endOfNav = match.index + match[2].length; // position right after + const whitespace = match[3]; + const articleTag = match[4]; + const afterFull = content.slice(match.index + match[0].length); + content = content.slice(0, endOfNav) + topNavHtml + whitespace + articleTag + afterFull; + inserted = true; + } + } + + // Pattern B: insert directly before
or
+ if (!inserted) { + const articlePattern = /(<(?:article|div)\s+class="(?:news-article|container)")/; + const match = articlePattern.exec(content); + if (match) { + content = content.slice(0, match.index) + topNavHtml + '\n' + content.slice(match.index); + inserted = true; + } + } + + if (inserted) addedTopnav = true; + } + + // ── Write if changed ────────────────────────────────────────────── + if (content !== original && !dryRun) { + fs.writeFileSync(filepath, content, 'utf-8'); + } + + return { addedSwitcher, addedTopnav }; +} + +interface ArticleMap { + [baseSlug: string]: Partial>; +} + +function discoverArticles(newsDir: string): ArticleMap { + const articles: ArticleMap = {}; + const files = fs.readdirSync(newsDir).sort(); + for (const name of files) { + if (!name.endsWith('.html') || name.startsWith('index')) continue; + const lang = extractLang(name); + const base = extractBase(name); + if (lang && base) { + if (!articles[base]) articles[base] = {}; + articles[base][lang] = path.join(newsDir, name); + } + } + return articles; +} + +function main(): void { + const dryRun = process.argv.includes('--dry-run'); + if (dryRun) { + console.log('=== DRY RUN — no files will be modified ===\n'); + } + + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + const newsDir = path.resolve(scriptDir, '..', 'news'); + + if (!fs.existsSync(newsDir)) { + console.error(`ERROR: news directory not found at ${newsDir}`); + process.exit(1); + } + + console.log('=== Fix Article Navigation ==='); + console.log(`News directory: ${newsDir}\n`); + + const articles = discoverArticles(newsDir); + const slugs = Object.keys(articles).sort(); + console.log(`Discovered ${slugs.length} unique article slugs\n`); + + let total = 0; + let switchersAdded = 0; + let topnavsAdded = 0; + + for (const baseSlug of slugs) { + const langFiles = articles[baseSlug]; + for (const lang of LANGUAGES) { + const filepath = langFiles[lang]; + if (!filepath) continue; + total++; + const { addedSwitcher, addedTopnav } = processArticle(filepath, baseSlug, lang, dryRun); + if (addedSwitcher) switchersAdded++; + if (addedTopnav) topnavsAdded++; + } + } + + console.log('=== Summary ==='); + console.log(`Total files processed: ${total}`); + console.log(`Language switchers added/updated: ${switchersAdded}`); + console.log(`Top nav (article-top-nav) added: ${topnavsAdded}`); + if (dryRun) { + console.log('\n(Dry run — no files were modified)'); + } + console.log('\n✓ Done!'); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/vitest.config.js b/vitest.config.js index 521ea255a..9c4b725c0 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -26,14 +26,19 @@ export default defineConfig({ reporter: ['text', 'html', 'lcov', 'json'], reportsDirectory: './builds/coverage', - // Disabled: tests don't import dashboard modules (by design) - all: false, + // Enabled: include all source files so zero-coverage modules are visible + all: true, - // Coverage thresholds - lines: 70, - functions: 70, - branches: 60, - statements: 70, + // Coverage thresholds — intentionally low during transition while all:true + // exposes previously-hidden zero-coverage files. Raise incrementally as + // tests are added for the dashboard and browser modules. + // Target thresholds (raise as coverage improves): lines:70, functions:70, branches:60, statements:70 + thresholds: { + lines: 25, + functions: 20, + branches: 25, + statements: 25, + }, // Include patterns include: [ From 41bce96db31fd0c277fae6df8c2e75ae5f54130d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:38:07 +0000 Subject: [PATCH 2/5] fix: correct shebang and switcher summary label in fix-article-navigation.ts Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/fix-article-navigation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/fix-article-navigation.ts b/scripts/fix-article-navigation.ts index 3409fd539..1e9a463e9 100644 --- a/scripts/fix-article-navigation.ts +++ b/scripts/fix-article-navigation.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env -S npx tsx /** * Fix Article Navigation: Language Switcher + Back-to-News Top Nav * @@ -228,7 +228,7 @@ function main(): void { console.log('=== Summary ==='); console.log(`Total files processed: ${total}`); - console.log(`Language switchers added/updated: ${switchersAdded}`); + console.log(`Language switchers added: ${switchersAdded}`); console.log(`Top nav (article-top-nav) added: ${topnavsAdded}`); if (dryRun) { console.log('\n(Dry run — no files were modified)'); From 5d66764822fecce56ccdc82026d9eeaf00777274 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:28:22 +0000 Subject: [PATCH 3/5] refactor: reuse shared constants, fix back-to-news repair, add tests for fix-article-navigation.ts Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/fix-article-navigation.ts | 160 ++++++++---------- tests/fix-article-navigation.test.ts | 244 +++++++++++++++++++++++++++ 2 files changed, 316 insertions(+), 88 deletions(-) create mode 100644 tests/fix-article-navigation.test.ts diff --git a/scripts/fix-article-navigation.ts b/scripts/fix-article-navigation.ts index 1e9a463e9..31aa3ec33 100644 --- a/scripts/fix-article-navigation.ts +++ b/scripts/fix-article-navigation.ts @@ -20,51 +20,15 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; - -// ── Language configuration ──────────────────────────────────────────────── - -const LANGUAGES = ['en', 'sv', 'da', 'no', 'fi', 'de', 'fr', 'es', 'nl', 'ar', 'he', 'ja', 'ko', 'zh'] as const; -type Lang = typeof LANGUAGES[number]; - -const LANG_DISPLAY: Readonly> = { - en: ['🇬🇧', 'English'], - sv: ['🇸🇪', 'Svenska'], - da: ['🇩🇰', 'Dansk'], - no: ['🇳🇴', 'Norsk'], - fi: ['🇫🇮', 'Suomi'], - de: ['🇩🇪', 'Deutsch'], - fr: ['🇫🇷', 'Français'], - es: ['🇪🇸', 'Español'], - nl: ['🇳🇱', 'Nederlands'], - ar: ['🇸🇦', 'العربية'], - he: ['🇮🇱', 'עברית'], - ja: ['🇯🇵', '日本語'], - ko: ['🇰🇷', '한국어'], - zh: ['🇨🇳', '中文'], -} as const; - -const LANG_SWITCHER_ARIA: Readonly> = { - en: 'Language', sv: 'Språk', da: 'Sprog', no: 'Språk', - fi: 'Kieli', de: 'Sprache', fr: 'Langue', es: 'Idioma', - nl: 'Taal', ar: 'اللغة', he: 'שפה', ja: '言語', - ko: '언어', zh: '语言', -} as const; - -const BACK_TO_NEWS: Readonly> = { - en: 'Back to News', sv: 'Tillbaka till nyheter', - da: 'Tilbage til nyheder', no: 'Tilbake til nyheter', - fi: 'Takaisin uutisiin', de: 'Zurück zu Nachrichten', - fr: 'Retour aux actualités', es: 'Volver a noticias', - nl: 'Terug naar nieuws', ar: 'العودة إلى الأخبار', - he: 'חזרה לחדשות', ja: 'ニュースに戻る', - ko: '뉴스로 돌아가기', zh: '返回新闻', -} as const; +import type { Language } from './types/language.js'; +import { ALL_LANG_CODES, FOOTER_LABELS } from './article-template/constants.js'; +import { getNewsIndexFilename, generateArticleLanguageSwitcher } from './article-template/helpers.js'; // ── Helpers ─────────────────────────────────────────────────────────────── -function extractLang(filename: string): Lang | null { +function extractLang(filename: string): Language | null { const name = filename.replace(/\.html$/, ''); - for (const lang of LANGUAGES) { + for (const lang of ALL_LANG_CODES) { if (name.endsWith(`-${lang}`)) return lang; } return null; @@ -72,89 +36,99 @@ function extractLang(filename: string): Lang | null { function extractBase(filename: string): string | null { const name = filename.replace(/\.html$/, ''); - for (const lang of LANGUAGES) { + for (const lang of ALL_LANG_CODES) { if (name.endsWith(`-${lang}`)) return name.slice(0, -(lang.length + 1)); } return null; } -function newsIndexFor(lang: Lang): string { - return lang === 'en' ? 'index.html' : `index_${lang}.html`; -} - -function generateLanguageSwitcher(baseSlug: string, currentLang: Lang): string { - const aria = LANG_SWITCHER_ARIA[currentLang]; - const lines: string[] = [` '); - return lines.join('\n'); -} - -function generateTopNav(lang: Lang): string { - const label = BACK_TO_NEWS[lang]; - const index = newsIndexFor(lang); +function generateTopNav(lang: Language): string { + const label = FOOTER_LABELS[lang].backToNews; + const index = getNewsIndexFilename(lang); return `\n
\n \n ← ${label}\n \n
\n`; } // ── Processing ──────────────────────────────────────────────────────────── -interface ProcessResult { +export interface ProcessResult { addedSwitcher: boolean; addedTopnav: boolean; + fixedTopnav: boolean; } -function processArticle(filepath: string, baseSlug: string, lang: Lang, dryRun: boolean): ProcessResult { - const original = fs.readFileSync(filepath, 'utf-8'); - let content = original; +/** + * Transform article HTML string: ensures language-switcher and article-top-nav + * with a back-to-news link are present. Idempotent — safe to call multiple times. + * + * @param content Original HTML string + * @param baseSlug Article base slug (e.g. "news/2026-01-01-article") + * @param lang Language code for this variant + * @returns Updated HTML string and flags indicating what changed + */ +export function transformContent( + content: string, + baseSlug: string, + lang: Language, +): { content: string } & ProcessResult { + let result = content; let addedSwitcher = false; let addedTopnav = false; + let fixedTopnav = false; // ── 1. Language switcher ────────────────────────────────────────── - const hasSwitcher = content.includes('language-switcher'); + const hasSwitcher = result.includes('language-switcher'); if (!hasSwitcher) { - const switcherHtml = generateLanguageSwitcher(baseSlug, lang); - content = content.replace(/()/, `$1\n${switcherHtml}`); + const switcherHtml = generateArticleLanguageSwitcher(baseSlug, lang); + result = result.replace(/()/, `$1\n${switcherHtml}`); addedSwitcher = true; } else { - // Update existing switcher to have all 14 languages - const newSwitcher = generateLanguageSwitcher(baseSlug, lang); - content = content.replace(/, (3)=whitespace, (4)=article/div opening tag + if (result.includes('')) { const navPattern = /((<\/nav>)([\s]*)(<(?:article|div)\s+class="(?:news-article|container)"))/s; - const match = navPattern.exec(content); + const match = navPattern.exec(result); if (match) { - const endOfNav = match.index + match[2].length; // position right after + const endOfNav = match.index + match[2].length; const whitespace = match[3]; const articleTag = match[4]; - const afterFull = content.slice(match.index + match[0].length); - content = content.slice(0, endOfNav) + topNavHtml + whitespace + articleTag + afterFull; + const afterFull = result.slice(match.index + match[0].length); + result = result.slice(0, endOfNav) + topNavHtml + whitespace + articleTag + afterFull; inserted = true; } } - // Pattern B: insert directly before
or
+ // Pattern B: insert directly before
or
if (!inserted) { const articlePattern = /(<(?:article|div)\s+class="(?:news-article|container)")/; - const match = articlePattern.exec(content); + const match = articlePattern.exec(result); if (match) { - content = content.slice(0, match.index) + topNavHtml + '\n' + content.slice(match.index); + result = result.slice(0, match.index) + topNavHtml + '\n' + result.slice(match.index); inserted = true; } } @@ -162,16 +136,23 @@ function processArticle(filepath: string, baseSlug: string, lang: Lang, dryRun: if (inserted) addedTopnav = true; } + return { content: result, addedSwitcher, addedTopnav, fixedTopnav }; +} + +function processArticle(filepath: string, baseSlug: string, lang: Language, dryRun: boolean): ProcessResult { + const original = fs.readFileSync(filepath, 'utf-8'); + const { content, addedSwitcher, addedTopnav, fixedTopnav } = transformContent(original, baseSlug, lang); + // ── Write if changed ────────────────────────────────────────────── if (content !== original && !dryRun) { fs.writeFileSync(filepath, content, 'utf-8'); } - return { addedSwitcher, addedTopnav }; + return { addedSwitcher, addedTopnav, fixedTopnav }; } interface ArticleMap { - [baseSlug: string]: Partial>; + [baseSlug: string]: Partial>; } function discoverArticles(newsDir: string): ArticleMap { @@ -213,16 +194,18 @@ function main(): void { let total = 0; let switchersAdded = 0; let topnavsAdded = 0; + let topnavsFixed = 0; for (const baseSlug of slugs) { const langFiles = articles[baseSlug]; - for (const lang of LANGUAGES) { + for (const lang of ALL_LANG_CODES) { const filepath = langFiles[lang]; if (!filepath) continue; total++; - const { addedSwitcher, addedTopnav } = processArticle(filepath, baseSlug, lang, dryRun); + const { addedSwitcher, addedTopnav, fixedTopnav } = processArticle(filepath, baseSlug, lang, dryRun); if (addedSwitcher) switchersAdded++; if (addedTopnav) topnavsAdded++; + if (fixedTopnav) topnavsFixed++; } } @@ -230,6 +213,7 @@ function main(): void { console.log(`Total files processed: ${total}`); console.log(`Language switchers added: ${switchersAdded}`); console.log(`Top nav (article-top-nav) added: ${topnavsAdded}`); + console.log(`Top nav fixed (missing back-to-news link): ${topnavsFixed}`); if (dryRun) { console.log('\n(Dry run — no files were modified)'); } diff --git a/tests/fix-article-navigation.test.ts b/tests/fix-article-navigation.test.ts new file mode 100644 index 000000000..be71999b2 --- /dev/null +++ b/tests/fix-article-navigation.test.ts @@ -0,0 +1,244 @@ +/** + * Unit Tests for fix-article-navigation.ts transformContent() + * + * Tests the idempotency and insertion logic for: + * - Language switcher insertion when missing + * - Language switcher update when present + * - article-top-nav insertion via Pattern A (after ) + * - article-top-nav insertion via Pattern B (before
) + * - Fixing article-top-nav that lacks a back-to-news link + * - Dry-run: transformContent is pure (no file I/O) + */ + +import { describe, it, expect } from 'vitest'; +import { transformContent } from '../scripts/fix-article-navigation.js'; + +// --------------------------------------------------------------------------- +// Minimal HTML fixtures +// --------------------------------------------------------------------------- + +/** Article with no navigation at all */ +const BARE_ARTICLE = ` + +Test + +
+

Headline

+

Content here.

+
+ +`; + +/** Article that already has both language-switcher (with all 14 links for SLUG) and article-top-nav */ +function buildCompleteArticle(slug: string): string { + return ` + +Test + + +
+ + ← Back to News + +
+
+

Headline

+

Content here.

+
+ +`; +} + +/** Article with language-switcher (single link, slug-agnostic) but no article-top-nav */ +const SWITCHER_NO_TOPNAV = ` + +Test + + +
+

Headline

+
+ +`; + +/** Article with article-top-nav but missing back-to-news link */ +const TOPNAV_NO_BACKLINK = ` + +Test + + +
+ +
+
+

Headline

+
+ +`; + +/** Article with no before the article element (Pattern B path) */ +const BARE_ARTICLE_NO_NAV = ` + +Test + +
+

Headline

+
+ +`; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +const SLUG = 'news/2026-01-01-test'; + +// --------------------------------------------------------------------------- +// Language switcher insertion +// --------------------------------------------------------------------------- + +describe('transformContent — language switcher', () => { + it('inserts language-switcher immediately after ', () => { + const { content } = transformContent(BARE_ARTICLE, SLUG, 'en'); + // The switcher nav must appear right after (possibly with a newline) + expect(content).toMatch(/\s*) +// --------------------------------------------------------------------------- + +describe('transformContent — article-top-nav Pattern A (after )', () => { + it('inserts article-top-nav after language-switcher nav', () => { + const { content, addedTopnav } = transformContent(SWITCHER_NO_TOPNAV, SLUG, 'en'); + expect(addedTopnav).toBe(true); + expect(content).toContain('article-top-nav'); + expect(content).toContain('back-to-news'); + // article-top-nav must come before news-article + expect(content.indexOf('article-top-nav')).toBeLessThan(content.indexOf('news-article')); + }); + + it('inserts localized back-to-news text for Swedish', () => { + const { content } = transformContent(SWITCHER_NO_TOPNAV, SLUG, 'sv'); + expect(content).toContain('Tillbaka till nyheter'); + expect(content).toContain('index_sv.html'); + }); + + it('uses index.html (not index_en.html) for English back-to-news', () => { + const { content } = transformContent(SWITCHER_NO_TOPNAV, SLUG, 'en'); + expect(content).toContain('href="index.html"'); + expect(content).not.toContain('href="index_en.html"'); + }); +}); + +// --------------------------------------------------------------------------- +// article-top-nav insertion — Pattern B (no nav element present) +// --------------------------------------------------------------------------- + +describe('transformContent — article-top-nav Pattern B (before container div)', () => { + it('inserts article-top-nav before
when no nav present', () => { + const { content, addedTopnav } = transformContent(BARE_ARTICLE_NO_NAV, SLUG, 'en'); + expect(addedTopnav).toBe(true); + expect(content).toContain('article-top-nav'); + expect(content.indexOf('article-top-nav')).toBeLessThan(content.indexOf('class="container"')); + }); +}); + +// --------------------------------------------------------------------------- +// Fixing article-top-nav that lacks back-to-news +// --------------------------------------------------------------------------- + +describe('transformContent — fix article-top-nav missing back-to-news', () => { + it('replaces top-nav that has no back-to-news link', () => { + const { content, addedTopnav, fixedTopnav } = transformContent(TOPNAV_NO_BACKLINK, SLUG, 'en'); + expect(fixedTopnav).toBe(true); + expect(addedTopnav).toBe(false); // fixed, not newly added + expect(content).toContain('back-to-news'); + expect(content).toContain('Back to News'); + }); + + it('does not set fixedTopnav when back-to-news is already present', () => { + const { fixedTopnav } = transformContent(buildCompleteArticle(SLUG), SLUG, 'en'); + expect(fixedTopnav).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Idempotency +// --------------------------------------------------------------------------- + +describe('transformContent — idempotency', () => { + it('produces identical output on second call (bare article)', () => { + const { content: pass1 } = transformContent(BARE_ARTICLE, SLUG, 'en'); + const { content: pass2 } = transformContent(pass1, SLUG, 'en'); + expect(pass2).toBe(pass1); + }); + + it('produces identical output on second call (switcher-only article)', () => { + const { content: pass1 } = transformContent(SWITCHER_NO_TOPNAV, SLUG, 'en'); + const { content: pass2 } = transformContent(pass1, SLUG, 'en'); + expect(pass2).toBe(pass1); + }); + + it('produces identical output on second call (top-nav with missing back link)', () => { + const { content: pass1 } = transformContent(TOPNAV_NO_BACKLINK, SLUG, 'en'); + const { content: pass2 } = transformContent(pass1, SLUG, 'en'); + expect(pass2).toBe(pass1); + }); + + it('returns unchanged content for already-complete article', () => { + const complete = buildCompleteArticle(SLUG); + const { content, addedSwitcher, addedTopnav, fixedTopnav } = transformContent(complete, SLUG, 'en'); + expect(content).toBe(complete); + expect(addedSwitcher).toBe(false); + expect(addedTopnav).toBe(false); + expect(fixedTopnav).toBe(false); + }); +}); From 629e127377327614cd6af1d7bfd7db55aec56e63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:53:22 +0000 Subject: [PATCH 4/5] fix: correct baseSlug doc, use bare slug in tests, resilient active-link assertions Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/fix-article-navigation.ts | 2 +- tests/fix-article-navigation.test.ts | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/scripts/fix-article-navigation.ts b/scripts/fix-article-navigation.ts index 31aa3ec33..7b65a5973 100644 --- a/scripts/fix-article-navigation.ts +++ b/scripts/fix-article-navigation.ts @@ -61,7 +61,7 @@ export interface ProcessResult { * with a back-to-news link are present. Idempotent — safe to call multiple times. * * @param content Original HTML string - * @param baseSlug Article base slug (e.g. "news/2026-01-01-article") + * @param baseSlug Article base slug without directory prefix (e.g. "2026-01-01-article") * @param lang Language code for this variant * @returns Updated HTML string and flags indicating what changed */ diff --git a/tests/fix-article-navigation.test.ts b/tests/fix-article-navigation.test.ts index be71999b2..51022df26 100644 --- a/tests/fix-article-navigation.test.ts +++ b/tests/fix-article-navigation.test.ts @@ -70,7 +70,7 @@ const SWITCHER_NO_TOPNAV = ` Test

Headline

@@ -84,7 +84,7 @@ const TOPNAV_NO_BACKLINK = ` Test
@@ -110,7 +110,7 @@ const BARE_ARTICLE_NO_NAV = ` // Helper // --------------------------------------------------------------------------- -const SLUG = 'news/2026-01-01-test'; +const SLUG = '2026-01-01-test'; // --------------------------------------------------------------------------- // Language switcher insertion @@ -146,8 +146,17 @@ describe('transformContent — language switcher', () => { it('marks current language as active with aria-current', () => { const { content } = transformContent(BARE_ARTICLE, SLUG, 'sv'); expect(content).toContain('aria-current="page"'); - expect(content).toContain(`href="${SLUG}-sv.html" class="lang-link active"`); - expect(content).not.toContain(`href="${SLUG}-en.html" class="lang-link active"`); + // sv link must have active class and aria-current (order-independent) + const svLink = content.match(new RegExp(`]*href="${SLUG}-sv\\.html"[^>]*>`))?.[0]; + expect(svLink).toBeDefined(); + expect(svLink).toMatch(/\blang-link\b/); + expect(svLink).toMatch(/\bactive\b/); + expect(svLink).toMatch(/aria-current="page"/); + // en link must not be active + const enLink = content.match(new RegExp(`]*href="${SLUG}-en\\.html"[^>]*>`))?.[0]; + expect(enLink).toBeDefined(); + expect(enLink).not.toMatch(/\bactive\b/); + expect(enLink).not.toMatch(/aria-current/); }); }); From 6afac64de0174d03c26bb535e51926170660dcac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:43:52 +0000 Subject: [PATCH 5/5] fix: insert language switcher after skip-link to preserve keyboard nav order Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/fix-article-navigation.ts | 8 +++++++- tests/fix-article-navigation.test.ts | 24 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/scripts/fix-article-navigation.ts b/scripts/fix-article-navigation.ts index 7b65a5973..ef188112c 100644 --- a/scripts/fix-article-navigation.ts +++ b/scripts/fix-article-navigation.ts @@ -79,7 +79,13 @@ export function transformContent( const hasSwitcher = result.includes('language-switcher'); if (!hasSwitcher) { const switcherHtml = generateArticleLanguageSwitcher(baseSlug, lang); - result = result.replace(/()/, `$1\n${switcherHtml}`); + // Prefer inserting AFTER the skip-link so it remains the first focusable element. + const skipLinkPattern = /(]*class="skip-link"[^>]*>[\s\S]*?<\/a>)/; + if (skipLinkPattern.test(result)) { + result = result.replace(skipLinkPattern, `$1\n${switcherHtml}`); + } else { + result = result.replace(/(]*>)/, `$1\n${switcherHtml}`); + } addedSwitcher = true; } else { // Update existing switcher to have all 14 languages. diff --git a/tests/fix-article-navigation.test.ts b/tests/fix-article-navigation.test.ts index 51022df26..8646658e1 100644 --- a/tests/fix-article-navigation.test.ts +++ b/tests/fix-article-navigation.test.ts @@ -106,6 +106,19 @@ const BARE_ARTICLE_NO_NAV = ` `; +/** Article with a skip-link as the first element after */ +const ARTICLE_WITH_SKIP_LINK = ` + +Test + + +
+

Headline

+

Content here.

+
+ +`; + // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- @@ -117,12 +130,21 @@ const SLUG = '2026-01-01-test'; // --------------------------------------------------------------------------- describe('transformContent — language switcher', () => { - it('inserts language-switcher immediately after ', () => { + it('inserts language-switcher immediately after when no skip-link present', () => { const { content } = transformContent(BARE_ARTICLE, SLUG, 'en'); // The switcher nav must appear right after (possibly with a newline) expect(content).toMatch(/\s*