All files / src/utils/intelligence html.ts

97.5% Statements 39/40
83.33% Branches 30/36
100% Functions 5/5
100% Lines 36/36

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');
}