From ff49f1616d61a3601519f5132adb2db6572d7c4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 10:56:24 +0000 Subject: [PATCH 1/5] Initial plan From 9e691b6f8b155b51903db0e4917ac28023fd18b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 11:10:01 +0000 Subject: [PATCH 2/5] feat: decompose chrome.ts into focused sub-modules with comprehensive tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract chrome/types.ts (interfaces: ChromeOptions, SiteChrome, BreadcrumbItem) - Extract chrome/helpers.ts (depth, renderHreflangBlock, fallbackAlternateHref) - Extract chrome/head.ts (renderChromeHead — SEO, OG, JSON-LD, hreflang) - Extract chrome/header.ts (buildHeaderHtml — nav, CTA, breadcrumb, hero) - Extract chrome/footer.ts (buildFooterHtml — columns, badges, scripts) - Rewrite chrome.ts as thin façade delegating to sub-modules - Fix `any` type in chrome JSON-LD findIndex (replaced with typed JsonLdNode) - Add 59 comprehensive tests in chrome-decomposition.test.ts covering: - Sub-module isolation (no circular deps) - HTML5 structural correctness - WCAG 2.1 AA accessibility (skip-link, ARIA, roles) - RTL language handling (Arabic, Hebrew) - hreflang for all 14 languages - JSON-LD injection and XSS prevention - SEO meta tags (OG, Twitter Cards) - Language switcher (14 languages) - Hero banner control - Pagination rel links - All 430 existing tests pass (zero regressions) Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/e1a3c2db-9238-4874-8434-5932e8e4da99 Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/render-lib/chrome.ts | 753 ++------------------------- scripts/render-lib/chrome/footer.ts | 206 ++++++++ scripts/render-lib/chrome/head.ts | 191 +++++++ scripts/render-lib/chrome/header.ts | 161 ++++++ scripts/render-lib/chrome/helpers.ts | 73 +++ scripts/render-lib/chrome/index.ts | 24 + scripts/render-lib/chrome/types.ts | 123 +++++ tests/chrome-decomposition.test.ts | 507 ++++++++++++++++++ 8 files changed, 1322 insertions(+), 716 deletions(-) create mode 100644 scripts/render-lib/chrome/footer.ts create mode 100644 scripts/render-lib/chrome/head.ts create mode 100644 scripts/render-lib/chrome/header.ts create mode 100644 scripts/render-lib/chrome/helpers.ts create mode 100644 scripts/render-lib/chrome/index.ts create mode 100644 scripts/render-lib/chrome/types.ts create mode 100644 tests/chrome-decomposition.test.ts diff --git a/scripts/render-lib/chrome.ts b/scripts/render-lib/chrome.ts index de4b924fb9..ed62828126 100644 --- a/scripts/render-lib/chrome.ts +++ b/scripts/render-lib/chrome.ts @@ -4,739 +4,60 @@ * @name Shared HTML chrome (head / header / footer / SEO) for articles * * @description - * Pure, stateless string builder for the `…` plus - * `…
` plus `…` blocks - * wrapping every rendered article. **No filesystem access, no markdown - * parsing** — all inputs are plain strings / POJOs described by - * {@link ChromeOptions}. + * Façade module that delegates to the decomposed bounded-context modules + * in `./chrome/` (types, helpers, head, header, footer). Maintains the + * same public API as the original monolithic `chrome.ts` so all existing + * importers (`article.ts`, `generate-news-indexes/template.ts`, + * `sitemap-html/render/page.ts`, etc.) continue to work without changes. * - * ## SEO surface - * - `` · `<meta name="description">` · keywords · robots - * - Open Graph (incl. `og:locale:alternate` for the 13 non-current langs) - * - Twitter Card - * - JSON-LD blocks via {@link ChromeOptions.jsonLd} - * - `hreflang` link rel alternates via {@link renderHreflangBlock} - * (x-default always points at the English alternate or the canonical - * path if no English alternate is supplied) + * ## Architecture (Round-5 decomposition) + * ``` + * chrome.ts (this file — façade) + * └── chrome/ + * ├── types.ts — ChromeOptions, SiteChrome, BreadcrumbItem + * ├── helpers.ts — depth(), renderHreflangBlock(), fallbackAlternateHref() + * ├── head.ts — renderChromeHead() — SEO / OG / JSON-LD / hreflang + * ├── header.ts — buildHeaderHtml() — nav / CTA / breadcrumb / hero + * ├── footer.ts — buildFooterHtml() — columns / badges / scripts + * └── index.ts — barrel re-export + * ``` * - * ## Accessibility surface - * - Skip-link (`<a class="skip-link" href="#main">`) - * - Semantic `<header role="banner">` / `<main id="main" tabindex="-1">` - * / `<footer role="contentinfo">` - * - Breadcrumb row (`<nav class="rm-breadcrumb" aria-label="Breadcrumb">`) - * with ordered list + `aria-current="page"` on the current node - * - Language switcher (header dropdown) exposes `role="menuitem"` on - * each option - * - Secondary inline language switcher in the footer for discoverability - * without requiring the user to expand the header dropdown - * - * Round-4 architecture split: extracted from `render-lib/index.ts`. The - * module has zero cyclic dependencies on the aggregator or markdown - * modules, so it can be unit-tested in isolation. + * Each sub-module is independently testable in isolation. * * @author Hack23 AB (Infrastructure Team) * @license Apache-2.0 */ -import type { Language } from '../types/language.js'; -import type { FAQItem } from '../types/editorial.js'; -import { LANGUAGE_META, escapeHtml } from '../generate-sitemap-html.js'; -import { - BASE_URL, - GITHUB_BLOB, - LANGUAGES, -} from './constants.js'; -import { chromeStrings } from './chrome-i18n.js'; +// Re-export types for backward compatibility +export type { BreadcrumbItem, ChromeOptions, SiteChrome } from './chrome/types.js'; + +// Re-export sub-module functions +import type { ChromeOptions, SiteChrome } from './chrome/types.js'; +import { renderChromeHead as _renderChromeHead } from './chrome/head.js'; +import { buildHeaderHtml } from './chrome/header.js'; +import { buildFooterHtml } from './chrome/footer.js'; // --------------------------------------------------------------------------- -// Options + return shape +// Public API — preserves the exact same signatures as the original chrome.ts // --------------------------------------------------------------------------- /** - * One breadcrumb node. The last item in the array is rendered with - * `aria-current="page"` and no anchor (it is the current page); all - * other items must supply an `href`. + * Render the complete `<!DOCTYPE html><html…><head>…</head>` block. + * Delegates to `chrome/head.ts`. */ -export interface BreadcrumbItem { - readonly label: string; - readonly href?: string; -} - -export interface ChromeOptions { - readonly lang: Language; - readonly title: string; - readonly description: string; - readonly keywords?: string; - /** Canonical filename in the site root, e.g. `news/2026-04-23/propositions-en.html`. */ - readonly canonicalPath: string; - /** Per-language alternate paths. If omitted, chrome only emits the current one. */ - readonly hreflangAlternates?: Partial<Record<Language, string>>; - /** ISO-8601 date for `article:published_time`. */ - readonly publishedIso?: string; - /** ISO-8601 date for `article:modified_time` / `og:updated_time`. */ - readonly modifiedIso?: string; - /** JSON-LD blob(s) appended inside `<head>`. Already-stringified objects. */ - readonly jsonLd?: readonly unknown[]; - /** Extra `<meta>` / `<link>` lines to splice into `<head>`. */ - readonly extraHead?: string; - /** Inline `<style>` body, appended verbatim. */ - readonly extraStyle?: string; - /** Prebuilt breadcrumb nav HTML (skipped if empty). */ - readonly breadcrumbHtml?: string; - /** Section identifier (og:article:section). */ - readonly section?: string; - /** RSS feed URL, defaults to `/rss.xml`. */ - readonly rssHref?: string; - /** - * og:type. Defaults to `'article'` for backwards-compat with the article - * renderer. Index/sitemap/methodology pages should set `'website'` so - * that crawlers do not treat them as individual articles and the - * `article:*` meta block is suppressed. - */ - readonly ogType?: 'article' | 'website'; - /** - * Custom breadcrumb path used by `buildChrome` to render the sub-navigation - * row. The last item is the current page. When omitted, `buildChrome` - * falls back to the legacy 3-tier `Home > Political Intelligence > {title}` - * breadcrumb used by individual articles. - */ - readonly breadcrumb?: readonly BreadcrumbItem[]; - /** - * Filename used by the lang-switcher fallback (when an explicit - * `hreflangAlternates` entry for a given language is not supplied). For - * the English version, this is the literal file (e.g. `'sitemap.html'`, - * `'political-intelligence.html'`); other languages get the - * `_${lang}` suffix automatically. Defaults to `'index.html'` so the - * fallback always lands on a valid landing page even if the current - * page is not translated. - */ - readonly defaultAlternateBase?: string; - /** - * Extra space-separated CSS classes appended to the `<body>` after the - * canonical `rm-article-body` class. Used by the news-index renderer to - * opt back into the legacy `body.news-page .article-card` palette in - * `styles.css`, which provides the colour-coded card layout that the - * unified chrome would otherwise bypass. - */ - readonly bodyClass?: string; - /** - * When set to `false`, suppresses the always-visible horizontal - * `<nav class="language-switcher rm-lang-bar">` row that follows the - * sticky header. Defaults to `true` so every chromed page (article, - * news index, sitemap, political-intelligence) gets the horizontal - * row in addition to the compact `<details class="rm-lang-switcher">` - * dropdown. Articles/PI/Sitemap pre-PR2012 already exposed an inline - * row; restoring it here re-establishes parity. - */ - readonly languageBar?: boolean; - /** - * When `true` (default), emits the brand `.hero-banner` block immediately - * after `<header class="rm-site-header">`. The image is decorative - * (`alt=""`, `aria-hidden="true"`) so screen-readers skip it while - * sighted users get the same visual identity as the hand-authored - * `index.html`. Set `false` for chrome variants where a full-bleed - * banner conflicts with the page's own hero (e.g. dashboards that - * already render their own visualization above the fold). - */ - readonly heroBanner?: boolean; - /** - * Optional site-root-relative banner image used when `heroBanner` is enabled. - * Defaults to the platform-wide banner; section renderers can supply a - * dedicated asset while preserving the shared chrome structure. - */ - readonly heroBannerImage?: string; - /** - * Optional FAQ entries. When ≥2 well-formed entries are provided, chrome - * auto-emits a Schema.org `FAQPage` JSON-LD block (Google + Bing - * rich-result eligible) plus a `Question`/`Answer` graph. Arrays with - * fewer than 2 items are ignored (Google requires ≥2 for eligibility). - * The visible HTML rendering remains the caller's responsibility (use - * `<details>`/`<summary>` for crawlable progressive disclosure). - */ - readonly faqItems?: readonly FAQItem[]; - /** - * CSS selectors for {@link https://schema.org/SpeakableSpecification SpeakableSpecification}. - * When provided, chrome emits a `WebPage` JSON-LD self-node with a - * `speakable` selector list. Targets voice-assistant surfacing - * (Google Assistant Actions on Google for News). - */ - readonly speakableSelectors?: readonly string[]; - /** - * Optional `<link rel="prev">` absolute URL for paginated listing pages. - * Crawlers use this to discover paginated archives that are otherwise - * gated behind client JS. Must be a full absolute URL (e.g. - * `https://riksdagsmonitor.com/news/index.html?page=1`). - */ - readonly relPrev?: string; - /** - * Optional `<link rel="next">` absolute URL for paginated listing pages. - * Must be a full absolute URL. - */ - readonly relNext?: string; -} - -export interface SiteChrome { - /** Entire `<!DOCTYPE html>…<head>…</head>` block. */ - readonly head: string; - /** `<body>…<header>…</header>` block (skip-link + header + language switcher). */ - readonly headerHtml: string; - /** `<footer>…</footer></body></html>` block. */ - readonly footerHtml: string; -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** Compute the relative path prefix to reach the site root. */ -function depth(canonicalPath: string): string { - const clean = canonicalPath.replace(/^\/+/, ''); - const depthLevel = clean.split('/').length - 1; - return depthLevel > 0 ? '../'.repeat(depthLevel) : ''; -} - -function renderHreflangBlock( - current: Language, - canonicalPath: string, - alternates: Partial<Record<Language, string>> | undefined, -): string { - if (!alternates) { - return [ - ` <link rel="alternate" hreflang="${LANGUAGE_META[current].hreflang}" href="${BASE_URL}/${canonicalPath}">`, - ` <link rel="canonical" href="${BASE_URL}/${canonicalPath}">`, - ].join('\n'); - } - const lines: string[] = []; - for (const l of LANGUAGES) { - const href = alternates[l]; - if (!href) continue; - lines.push( - ` <link rel="alternate" hreflang="${LANGUAGE_META[l].hreflang}" href="${BASE_URL}/${href}">`, - ); - } - const enHref = alternates.en ?? canonicalPath; - lines.push(` <link rel="alternate" hreflang="x-default" href="${BASE_URL}/${enHref}">`); - lines.push(` <link rel="canonical" href="${BASE_URL}/${canonicalPath}">`); - return lines.join('\n'); -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - export function renderChromeHead(opts: ChromeOptions): string { - const meta = LANGUAGE_META[opts.lang]; - const keywords = opts.keywords ?? 'Riksdagsmonitor, Swedish Parliament, political intelligence, OSINT, Riksdagen'; - const published = opts.publishedIso ?? new Date().toISOString(); - const modified = opts.modifiedIso ?? published; - - // Auto-emit FAQPage JSON-LD when caller supplies ≥2 faqItems. - // Google / Bing FAQ rich-result panels require at least 2 well-formed - // Question / Answer pairs; single-item arrays are valid Schema.org but - // will not trigger the rich result, so we skip emission below that. - const autoJsonLd: unknown[] = []; - if (opts.faqItems && opts.faqItems.length >= 2) { - autoJsonLd.push({ - '@context': 'https://schema.org', - '@type': 'FAQPage', - mainEntity: opts.faqItems.map((f) => ({ - '@type': 'Question', - name: f.question, - acceptedAnswer: { - '@type': 'Answer', - text: f.answer, - }, - })), - }); - } - // Auto-emit a WebPage self-node with SpeakableSpecification when the - // caller supplies CSS selectors. Google's voice surfaces use this to - // identify the most relevant TTS-readable region of a listing page. - // If the caller already pushed a WebPage node in `opts.jsonLd`, we - // produce a merged clone with `speakable` added — the original - // caller-provided array is never mutated (pure, stateless contract). - let mergedJsonLd = opts.jsonLd ?? []; - if (opts.speakableSelectors && opts.speakableSelectors.length > 0) { - const speakableSpec = { - '@type': 'SpeakableSpecification' as const, - cssSelector: [...opts.speakableSelectors], - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const existingIdx = mergedJsonLd.findIndex((node: any) => node?.['@type'] === 'WebPage'); - if (existingIdx >= 0) { - // Clone the array + WebPage node, inject speakable + isPartOf - const cloned = [...mergedJsonLd]; - const clonedNode = { ...(cloned[existingIdx] as Record<string, unknown>) }; - clonedNode['speakable'] = speakableSpec; - if (!clonedNode['isPartOf']) { - clonedNode['isPartOf'] = { '@type': 'WebSite', '@id': `${BASE_URL}/#website` }; - } - cloned[existingIdx] = clonedNode; - mergedJsonLd = cloned; - } else { - autoJsonLd.push({ - '@context': 'https://schema.org', - '@type': 'WebPage', - url: `${BASE_URL}/${opts.canonicalPath}`, - inLanguage: meta.hreflang, - speakable: speakableSpec, - isPartOf: { '@type': 'WebSite', '@id': `${BASE_URL}/#website` }, - }); - } - } - const allJsonLd = [...mergedJsonLd, ...autoJsonLd]; - const jsonLdBlocks = allJsonLd - .map((b) => { - // Escape sequences that could break out of the <script> tag. - // JSON.stringify alone cannot guarantee the output won't contain - // a literal "</script>" or "<!--" sequence inside string values. - const raw = JSON.stringify(b); - const safe = raw.replace(/</g, '\\u003c'); - return ` <script type="application/ld+json">${safe}</script>`; - }) - .join('\n'); - - // Pagination link relations (rel="prev"/rel="next") give crawlers the - // archive structure that JS-side pagination would otherwise hide. - // Values are HTML-escaped for safety even though current callers pass - // trusted absolute URLs — prevents injection if future callers pass - // user-influenced strings. - const pagerLinks: string[] = []; - if (opts.relPrev) pagerLinks.push(` <link rel="prev" href="${escapeHtml(opts.relPrev)}">`); - if (opts.relNext) pagerLinks.push(` <link rel="next" href="${escapeHtml(opts.relNext)}">`); - const pagerLinksHtml = pagerLinks.length > 0 ? pagerLinks.join('\n') + '\n' : ''; - - // Title brand discipline (per `seo-metadata-contract.md` §2): append - // ` — Riksdagsmonitor` only when the title does not already contain - // the brand. Prevents double-branding like - // `Riksdagsmonitor report — Riksdagsmonitor`. - const brandedTitle = /riksdagsmonitor/i.test(opts.title) - ? opts.title - : `${opts.title} — Riksdagsmonitor`; - const escapedTitle = escapeHtml(opts.title); - const escapedBrandedTitle = escapeHtml(brandedTitle); - - const alternateLocalesHtml = LANGUAGES - .filter((l) => l !== opts.lang) - .map((l) => ` <meta property="og:locale:alternate" content="${LANGUAGE_META[l].locale}">`) - .join('\n'); - - const hreflangHtml = renderHreflangBlock(opts.lang, opts.canonicalPath, opts.hreflangAlternates); - - const ogType = opts.ogType ?? 'article'; - const articleMetaBlock = ogType === 'article' - ? ` <meta property="article:publisher" content="https://www.hack23.com"> - <meta property="article:section" content="${escapeHtml(opts.section ?? 'Political Intelligence')}"> - <meta property="article:modified_time" content="${modified}"> - <meta property="article:published_time" content="${published}"> -` - : ''; - - return `<!DOCTYPE html> -<html lang="${meta.hreflang}" dir="${meta.dir}"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>${escapedBrandedTitle} - - - - - - - - - - - - - - - - - - - - - -${hreflangHtml} - - - -${pagerLinksHtml} - - - - - - -${alternateLocalesHtml} - - - - - - -${articleMetaBlock} - - - - - - - - - - - - - -${jsonLdBlocks} -${opts.extraHead ?? ''} - - -${opts.extraStyle ? ` ` : ''} -`; + return _renderChromeHead(opts); } +/** + * Build the complete site chrome (head + header + footer) for a page. + * Delegates to the decomposed `chrome/head.ts`, `chrome/header.ts`, + * and `chrome/footer.ts` modules. + */ export function buildChrome(opts: ChromeOptions): SiteChrome { - const meta = LANGUAGE_META[opts.lang]; - const t = meta.translations; - const cs = chromeStrings(opts.lang); - const prefix = depth(opts.canonicalPath); - const indexFile = opts.lang === 'en' ? 'index.html' : `index_${opts.lang}.html`; - const sitemapFile = opts.lang === 'en' ? 'sitemap.html' : `sitemap_${opts.lang}.html`; - const piFile = opts.lang === 'en' ? 'political-intelligence.html' : `political-intelligence_${opts.lang}.html`; - const newsFile = opts.lang === 'en' ? 'news/index.html' : `news/index_${opts.lang}.html`; - const dashboardFile = opts.lang === 'en' ? 'dashboard/index.html' : `dashboard/index_${opts.lang}.html`; - const rssHref = opts.rssHref ?? (opts.lang === 'en' ? '/rss.xml' : `/rss_${opts.lang}.xml`); - - /** - * Resolve the lang-switcher fallback href for a given language. - * - * Caller-supplied `hreflangAlternates[lang]` always wins. Otherwise - * we use the configurable `defaultAlternateBase` (e.g. `'sitemap.html'` - * for the sitemap generator, `'political-intelligence.html'` for PI), - * defaulting to `'index.html'` so the legacy article behaviour is - * preserved when no explicit base is supplied. - */ - const altBase = opts.defaultAlternateBase ?? 'index.html'; - const altBaseStem = altBase.replace(/\.html$/i, ''); - const fallbackAltHref = (l: Language): string => - l === 'en' ? altBase : `${altBaseStem}_${l}.html`; - - // Header dropdown (compact "more languages") — excludes the current - // language (which is shown in the summary). When no explicit alternate - // is provided for a given lang, fall back to the language homepage — - // the article-renderer populates alternates for all 14 languages so - // in practice every link here lands on a sibling article. - const languageSwitcher = LANGUAGES - .filter((l) => l !== opts.lang) - .map((l) => { - const lm = LANGUAGE_META[l]; - const href = opts.hreflangAlternates?.[l] ?? fallbackAltHref(l); - return ` ${lm.nativeName}`; - }) - .join('\n'); - - // Footer inline lang-switcher (secondary, always-visible, not inside - //
) — same hrefs but rendered as a flat row for accessibility. - const footerLangRow = LANGUAGES - .filter((l) => l !== opts.lang) - .map((l) => { - const lm = LANGUAGE_META[l]; - const href = opts.hreflangAlternates?.[l] ?? fallbackAltHref(l); - const displayCode = l === 'no' ? 'NO' : lm.hreflang.toUpperCase(); - return ` ${displayCode}`; - }) - .join('\n'); - - const tagline = cs.headerTagline; - const apiDocsHref = 'https://riksdagsmonitor.com/docs/api/index.html'; - const issueHref = 'https://github.com/Hack23/riksdagsmonitor/issues/new/choose'; - const lastUpdatedIso = opts.modifiedIso ?? new Date().toISOString(); - const lastUpdatedDisplay = lastUpdatedIso.slice(0, 16).replace('T', ' ') + ' UTC'; - - // Render the breadcrumb sub-navigation. When the caller supplies a - // custom `breadcrumb` array, render it verbatim (last item gets - // `aria-current="page"` and no anchor). Otherwise fall back to the - // legacy 3-tier `Home > Political Intelligence > {title}` breadcrumb - // used by individual articles. - const breadcrumbItems: readonly BreadcrumbItem[] = opts.breadcrumb ?? [ - { label: t.home, href: `${prefix}${indexFile}` }, - { label: cs.politicalIntelligence, href: `${prefix}${piFile}` }, - { label: opts.title }, - ]; - const breadcrumbLis = breadcrumbItems - .map((item, idx) => { - const isLast = idx === breadcrumbItems.length - 1; - if (isLast || !item.href) { - return `
  • ${escapeHtml(item.label)}
  • `; - } - return `
  • ${escapeHtml(item.label)}
  • `; - }) - .join('\n'); - - // Inline horizontal language switcher row (always visible) — restores the - // pre-PR2012 `