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
753 changes: 37 additions & 716 deletions scripts/render-lib/chrome.ts

Large diffs are not rendered by default.

205 changes: 205 additions & 0 deletions scripts/render-lib/chrome/footer.ts

Large diffs are not rendered by default.

191 changes: 191 additions & 0 deletions scripts/render-lib/chrome/head.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* @module Infrastructure/RenderLib/Chrome/Head
* @category Intelligence Operations / Supporting Infrastructure
* @name HTML `<head>` builder (SEO, OpenGraph, JSON-LD, hreflang)
*
* @description
* Pure, stateless string builder for the `<!DOCTYPE html>…<head>…</head>`
* block. Handles SEO meta tags, Open Graph, Twitter Cards, JSON-LD
* injection, hreflang alternate links, and pagination relations.
*
* @author Hack23 AB (Infrastructure Team)
* @license Apache-2.0
*/

import { LANGUAGE_META, escapeHtml } from '../../sitemap-html/index.js';
import { BASE_URL, LANGUAGES } from '../constants.js';
import type { ChromeOptions } from './types.js';
import { depth, renderHreflangBlock } from './helpers.js';

/** JSON-LD node shape — typed replacement for `any` in findIndex. */
interface JsonLdNode {
readonly '@type'?: string;
readonly [key: string]: unknown;
}

/**
* Render the complete `<!DOCTYPE html><html…><head>…</head>` block.
*
* This function is synchronous and deterministic for identical inputs
* (modulo the current timestamp used as `publishedIso` fallback).
*/
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.
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 WebPage self-node with SpeakableSpecification.
let mergedJsonLd = opts.jsonLd ?? [];
if (opts.speakableSelectors && opts.speakableSelectors.length > 0) {
const speakableSpec = {
'@type': 'SpeakableSpecification' as const,
cssSelector: [...opts.speakableSelectors],
};
const existingIdx = mergedJsonLd.findIndex(
(node) => (node as JsonLdNode)?.['@type'] === 'WebPage',
);
if (existingIdx >= 0) {
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) => {
const raw = JSON.stringify(b);
const safe = raw.replace(/</g, '\\u003c');
return ` <script type="application/ld+json">${safe}</script>`;
})
.join('\n');

// Pagination link relations.
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.
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}</title>
<meta name="description" content="${escapeHtml(opts.description)}">
<meta name="keywords" content="${escapeHtml(keywords)}">
<meta name="news_keywords" content="${escapeHtml(keywords)}">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
<meta name="googlebot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
<meta name="bingbot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
<meta name="author" content="James Pether Sörling, CISSP, CISM">
<meta name="publisher" content="Hack23 AB">
<meta name="theme-color" content="#0a0e27">
<meta name="color-scheme" content="dark light">
<meta name="generator" content="riksdagsmonitor:scripts/render-lib">
<meta name="referrer" content="strict-origin-when-cross-origin">
<meta name="format-detection" content="telephone=no">
<meta http-equiv="Content-Language" content="${meta.hreflang}">

<link rel="preconnect" href="https://github.com" crossorigin>
<link rel="dns-prefetch" href="https://github.com">
<link rel="preconnect" href="https://www.hack23.com" crossorigin>

<link rel="stylesheet" type="text/css" href="${depth(opts.canonicalPath)}styles.css">

${hreflangHtml}

<link rel="sitemap" type="application/xml" href="/sitemap.xml">
<link rel="alternate" type="application/rss+xml" title="Riksdagsmonitor news (${escapeHtml(meta.nativeName)})" href="${opts.rssHref ?? (opts.lang === 'en' ? '/rss.xml' : `/rss_${opts.lang}.xml`)}">
${pagerLinksHtml}
Comment on lines +150 to +154
<meta property="og:type" content="${ogType}">
<meta property="og:site_name" content="Riksdagsmonitor">
<meta property="og:title" content="${escapedBrandedTitle}">
<meta property="og:description" content="${escapeHtml(opts.description)}">
<meta property="og:url" content="${BASE_URL}/${opts.canonicalPath}">
<meta property="og:locale" content="${meta.locale}">
${alternateLocalesHtml}
<meta property="og:image" content="${BASE_URL}/images/og-image.webp">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Riksdagsmonitor ${escapedTitle}">
<meta property="og:updated_time" content="${modified}">

${articleMetaBlock}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@riksdagsmonitor">
<meta name="twitter:creator" content="@hack23ab">
<meta name="twitter:title" content="${escapedBrandedTitle}">
<meta name="twitter:description" content="${escapeHtml(opts.description)}">
<meta name="twitter:image" content="${BASE_URL}/images/og-image.webp">
<meta name="twitter:image:alt" content="Riksdagsmonitor ${escapedTitle}">

<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="manifest" href="/site.webmanifest">

${jsonLdBlocks}
${opts.extraHead ?? ''}
<!-- Anti-flash theme bootstrap: applies the user's saved/preferred
theme to <html data-theme> before first paint. Same storage key
(\`riksdagsmonitor-theme\`) and resolution rules as the legacy
article pages so the toggle button stays in sync. -->
<script>(function(){var k='riksdagsmonitor-theme';var t=null;try{t=localStorage.getItem(k);}catch(e){}if(t!=='dark'&&t!=='light'){if(t!==null){try{localStorage.removeItem(k);}catch(e){}}t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}());</script>
${opts.extraStyle ? ` <style>${opts.extraStyle}</style>` : ''}
</head>`;
}
160 changes: 160 additions & 0 deletions scripts/render-lib/chrome/header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @module Infrastructure/RenderLib/Chrome/Header
* @category Intelligence Operations / Supporting Infrastructure
* @name HTML site header builder (nav, language switcher, CTAs, breadcrumb)
*
* @description
* Pure, stateless string builder for the `<body>…<header>…</header>` block
* including skip-link, site navigation, language switcher dropdown,
* CTA buttons, breadcrumb row, hero banner, and horizontal language bar.
*
* @author Hack23 AB (Infrastructure Team)
* @license Apache-2.0
*/

import { LANGUAGE_META, escapeHtml } from '../../sitemap-html/index.js';
import { GITHUB_BLOB, LANGUAGES } from '../constants.js';
import { chromeStrings } from '../chrome-i18n.js';
import type { BreadcrumbItem, ChromeOptions } from './types.js';
import { depth, fallbackAlternateHref } from './helpers.js';

/**
* Build the complete `<body>…<header>…</header>` + hero + language-bar
* + breadcrumb + `<main>` opening tag.
*/
export function buildHeaderHtml(opts: ChromeOptions): string {
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 apiDocsHref = 'https://riksdagsmonitor.com/docs/api/index.html';

const altBase = opts.defaultAlternateBase ?? 'index.html';

// Header dropdown language switcher
const languageSwitcher = LANGUAGES
.filter((l) => l !== opts.lang)
.map((l) => {
const lm = LANGUAGE_META[l];
const href = opts.hreflangAlternates?.[l] ?? fallbackAlternateHref(l, altBase);
return ` <a href="${prefix}${href}" lang="${lm.hreflang}" title="${escapeHtml(lm.nativeName)}" role="menuitem"><span aria-hidden="true">${lm.flag}</span> ${lm.nativeName}</a>`;
})
.join('\n');

const tagline = cs.headerTagline;

// Breadcrumb sub-navigation
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 ` <li aria-current="page">${escapeHtml(item.label)}</li>`;
}
return ` <li><a href="${item.href}">${escapeHtml(item.label)}</a></li>`;
})
.join('\n');

// Horizontal language bar
const horizontalLangBar = LANGUAGES
.map((l) => {
const lm = LANGUAGE_META[l];
const isCurrent = l === opts.lang;
if (isCurrent) {
return ` <span class="lang-link active" lang="${lm.hreflang}" title="${escapeHtml(lm.nativeName)}" aria-current="page"><span aria-hidden="true">${lm.flag}</span> ${escapeHtml(lm.nativeName)}</span>`;
}
const href = `${prefix}${opts.hreflangAlternates?.[l] ?? fallbackAlternateHref(l, altBase)}`;
return ` <a href="${href}" class="lang-link" hreflang="${lm.hreflang}" lang="${lm.hreflang}" title="${escapeHtml(lm.nativeName)}"><span aria-hidden="true">${lm.flag}</span> ${escapeHtml(lm.nativeName)}</a>`;
})
.join('\n');

return `<body class="rm-article-body${opts.bodyClass ? ' ' + escapeHtml(opts.bodyClass) : ''}">
<a class="skip-link" href="#main">${escapeHtml(cs.skipToMain)}</a>
<header class="rm-site-header" role="banner">
<div class="rm-site-header-inner">
<a class="rm-logo" href="${prefix}${indexFile}" aria-label="Riksdagsmonitor ${escapeHtml(t.home)}">
<img class="rm-logo-img" data-rm-logo-img="true" src="${prefix}images/riksdagsmonitor-logo.webp" alt="" width="48" height="48" loading="eager" decoding="async">
<span class="rm-logo-glyph" aria-hidden="true">🇸🇪</span>
<span class="rm-logo-text">
<span class="rm-logo-brand">Riksdagsmonitor</span>
<span class="rm-logo-tagline">${escapeHtml(tagline)}</span>
</span>
</a>
<nav class="rm-site-nav" aria-label="${escapeHtml(cs.mainNav)}">
<a href="${prefix}${indexFile}">${escapeHtml(t.home)}</a>
<a href="${prefix}${newsFile}">${escapeHtml(cs.news)}</a>
<a href="${prefix}${dashboardFile}">${escapeHtml(cs.dashboard)}</a>
<a href="${prefix}${piFile}">🧠 ${escapeHtml(cs.politicalIntelligence)}</a>
<a href="${prefix}${sitemapFile}">${escapeHtml(t.siteMap)}</a>
<a href="${apiDocsHref}">${escapeHtml(t.apiDocs)}</a>
</nav>
<details class="rm-lang-switcher">
<summary aria-label="${escapeHtml(cs.switchLanguage)}">
<span aria-hidden="true">${meta.flag}</span>
<span class="rm-lang-current-label">${escapeHtml(meta.nativeName)}</span>
<span class="rm-lang-switcher-caret" aria-hidden="true">▾</span>
</summary>
<div class="rm-lang-switcher-dropdown" role="menu">
${languageSwitcher}
</div>
</details>
<a class="rm-header-cta rm-header-cta-pi"
href="${prefix}${piFile}"
title="${escapeHtml(cs.politicalIntelligenceTitle)}"
aria-label="${escapeHtml(cs.politicalIntelligenceTitle)}">
<span class="rm-header-cta-icon" aria-hidden="true">🧠</span>
<span class="rm-header-cta-label">${escapeHtml(cs.politicalIntelligenceLabel)}</span>
</a>
<a class="rm-header-cta rm-header-cta-transparency"
href="${GITHUB_BLOB}/SECURITY.md"
target="_blank" rel="noopener noreferrer"
title="${escapeHtml(cs.transparencyTitle)}"
aria-label="${escapeHtml(cs.transparencyTitle)}">
<span class="rm-header-cta-icon" aria-hidden="true">🔐</span>
<span class="rm-header-cta-label">${cs.transparencyLabel}</span>
</a>
<a class="rm-header-cta rm-header-cta-sponsor"
href="https://github.com/sponsors/Hack23"
target="_blank" rel="noopener noreferrer"
title="${escapeHtml(cs.sponsorTitle)}"
aria-label="${escapeHtml(cs.sponsorTitle)}">
<span class="rm-header-cta-icon" aria-hidden="true">💖</span>
<span class="rm-header-cta-label">${escapeHtml(cs.sponsorLabel)}</span>
</a>
<button id="theme-toggle" class="rm-theme-toggle" type="button"
aria-pressed="false"
aria-label="${escapeHtml(cs.themeAria)}"
title="${escapeHtml(cs.themeAria)}"
data-label-dark="${escapeHtml(cs.themeToLight)}"
data-label-light="${escapeHtml(cs.themeToDark)}">
<span class="rm-theme-toggle-icon" aria-hidden="true">🌓</span>
<span class="rm-theme-toggle-label">${escapeHtml(cs.themeLabel)}</span>
</button>
</div>
<div class="rm-site-subnav" aria-label="${escapeHtml(cs.pageContext)}">
<nav class="rm-breadcrumb" aria-label="${escapeHtml(cs.breadcrumb)}">
<ol>
${breadcrumbLis}
</ol>
</nav>
${opts.publishedIso ? `<time class="rm-article-published" datetime="${opts.publishedIso}">${opts.publishedIso.slice(0, 10)}</time>` : ''}
</div>
</header>${(opts.heroBanner ?? true) ? `
<div class="hero-banner" aria-hidden="true">
<img src="${prefix}${opts.heroBannerImage ?? 'images/riksdagsmonitor-banner.webp'}" alt="" class="hero-banner-bg" width="1536" height="1024" loading="eager" decoding="async">
</div>` : ''}${(opts.languageBar ?? true) ? `
<nav class="language-switcher rm-lang-bar" role="navigation" aria-label="${escapeHtml(cs.thisPageInOtherLanguages)}">
${horizontalLangBar}
</nav>` : ''}
${opts.breadcrumbHtml ?? ''}
<main id="main" class="rm-article-main" tabindex="-1">`;
}
Loading
Loading