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 | 8x 11x 11x 11x 11x 11x 11x 11x 11x 16x 3x 13x 16x 9x 9x 9x 7x 7x 7x 2x 2x 16x 4x 4x 4x 4x 13x 5x 5x 13x 16x 16x 16x 13x 13x 13x | // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
/**
* @module Utils/Intelligence/Html
* @description Localised HTML rendering for the "Related Analysis" section
* embedded in every generated article.
*/
import type { RelationshipLabels } from '../../constants/languages.js';
import { RELATED_ANALYSIS_LABELS, getLocalizedString } from '../../constants/languages.js';
import type { ArticleCrossReference, ArticleIndexEntry, TrendDetection } from './types.js';
import { escapeAttr, escapeText } from './internals.js';
/** Map from 2-letter language codes to BCP 47 locale tags for date formatting */
const LANG_TO_LOCALE: Record<string, string> = {
en: 'en-GB',
sv: 'sv-SE',
da: 'da-DK',
no: 'nb-NO',
fi: 'fi-FI',
de: 'de-DE',
fr: 'fr-FR',
es: 'es-ES',
nl: 'nl-NL',
ar: 'ar-SA',
he: 'he-IL',
ja: 'ja-JP',
ko: 'ko-KR',
zh: 'zh-CN',
};
/**
* Format an ISO date string as a human-readable date in the given locale.
*
* @param date - ISO date string (YYYY-MM-DD)
* @param lang - Language code (defaults to 'en')
* @returns Formatted date string
*/
function formatDisplayDate(date: string, lang?: string): string {
const parts = date.split('-');
const year = parts[0] ?? '';
const month = parts[1] ?? '';
const day = parts[2] ?? '';
Iif (!year || !month || !day) return date;
const d = new Date(Date.UTC(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10)));
const locale = LANG_TO_LOCALE[lang ?? 'en'] ?? 'en-GB';
return d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' });
}
/**
* Generate an HTML `<section>` listing related articles, cross-references, and
* emerging trends for embedding in a generated article.
*
* Produces accessible markup with `aria-label` and `rel="noopener noreferrer"`.
* UI strings and date formatting are localised based on the `lang` parameter.
*
* @param relatedArticles - Articles related to the current article
* @param crossRefs - Cross-references from the current article
* @param trends - Trends relevant to the current article
* @param lang - Language code for localisation (defaults to 'en')
* @returns HTML string for the "Related Analysis" section, or empty string if nothing to show
*/
export function buildRelatedArticlesHTML(
relatedArticles: ArticleIndexEntry[],
crossRefs: ArticleCrossReference[],
trends: TrendDetection[],
lang?: string
): string {
if (relatedArticles.length === 0 && crossRefs.length === 0 && trends.length === 0) {
return '';
}
const strings = getLocalizedString(RELATED_ANALYSIS_LABELS, lang ?? 'en');
const listItems = crossRefs
.map((ref) => {
const article = relatedArticles.find((a) => a.id === ref.targetArticleId);
const label =
strings.relationships[ref.relationship as keyof RelationshipLabels] ??
strings.relatedArticle;
if (article) {
const displayDate = formatDisplayDate(article.date, lang);
const filename = `${article.id}.html`;
return ` <li><a href="${escapeAttr(filename)}" rel="noopener noreferrer">${escapeText(label)}: ${escapeText(ref.context)} (${escapeText(displayDate)})</a></li>`;
}
const filename = `${ref.targetArticleId}.html`;
return ` <li><a href="${escapeAttr(filename)}" rel="noopener noreferrer">${escapeText(label)}: ${escapeText(ref.context)}</a></li>`;
})
.filter(Boolean);
if (listItems.length === 0 && relatedArticles.length > 0) {
for (const article of relatedArticles) {
const displayDate = formatDisplayDate(article.date, lang);
const filename = `${article.id}.html`;
listItems.push(
` <li><a href="${escapeAttr(filename)}" rel="noopener noreferrer">${escapeText(strings.relatedArticle)}: ${escapeText(article.type)} (${escapeText(displayDate)})</a></li>`
);
}
}
const trendBlocks = trends
.map((trend) => {
const count = trend.articleReferences.length;
return ` <div class="emerging-trends">
<h4>${escapeText(strings.emergingTrend)}: ${escapeText(trend.name)}</h4>
<p>${count} ${escapeText(strings.trendTracking)} ${escapeText(trend.name.toLowerCase())} (${escapeText(strings.confidence)}: ${escapeText(trend.confidence)})</p>
</div>`;
})
.join('\n');
const listSection = listItems.length > 0 ? ` <ul>\n${listItems.join('\n')}\n </ul>` : '';
const parts = [
`<section class="related-articles" aria-label="${escapeAttr(strings.sectionLabel)}">`,
];
parts.push(` <h3>${escapeText(strings.heading)}</h3>`);
if (listSection) parts.push(listSection);
if (trendBlocks) parts.push(trendBlocks);
parts.push('</section>');
return parts.join('\n');
}
|