All files / templates section-builders.ts

97.56% Statements 40/41
88.88% Branches 24/27
100% Functions 7/7
97.5% Lines 39/40

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 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162                                            39x 39x                             39x 39x 33x 33x 7x     39x                           13x   13x         1386x       13x         13x 13x 13x 13x 13x     13x         13x             13x 1x 12x 1x 11x     11x     13x                     6x 1x     5x   5x   6x   6x 6x 6x       5x                                   3x 1x     2x 2x              
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module Templates/SectionBuilders
 * @description Reusable section builder utilities for article template architecture.
 * Provides quality scoring, table of contents generation, and quality badge rendering.
 */
 
import { escapeHTML } from '../utils/file-utils.js';
import type { ArticleQualityScore, TOCEntry, LanguageCode } from '../types/index.js';
import { getLocalizedString, TOC_ARIA_LABELS } from '../constants/languages.js';
import { stripScriptBlocks } from '../utils/html-sanitize.js';
 
/**
 * Count occurrences of a regex pattern in a string.
 *
 * @param content - String to search.
 * @param pattern - Global regex pattern to match.
 * @returns Number of matches found.
 */
function countMatches(content: string, pattern: RegExp): number {
  const matches = content.match(pattern);
  return matches !== null ? matches.length : 0;
}
 
/**
 * Count elements whose `class` attribute contains a given CSS class token.
 *
 * Extracts every `class="…"` attribute, splits the value into tokens, and
 * checks for an exact match — so `"dashboard"` will NOT match nested
 * classes like `"dashboard-grid"` or `"dashboard-panel"`.
 *
 * @param content - HTML string to search.
 * @param token - Exact CSS class name to look for.
 * @returns Number of elements that have the given class token.
 */
function countClassToken(content: string, token: string): number {
  let count = 0;
  for (const m of content.matchAll(/class="([^"]*)"/g)) {
    const value = m[1] ?? '';
    if (value.split(/\s+/).includes(token)) {
      count += 1;
    }
  }
  return count;
}
 
// stripScriptBlocks is imported from html-sanitize.ts
 
/**
 * Compute an article quality score by analysing the rendered HTML content.
 *
 * @param content - Full HTML content string of the article body.
 * @returns {@link ArticleQualityScore} with word count, section counts, and overall rating.
 */
export function computeArticleQualityScore(content: string): ArticleQualityScore {
  // Remove script blocks before tag-stripping to avoid inflating word count.
  // Uses iterative scanning instead of regex to avoid CodeQL js/bad-tag-filter.
  const noScripts = stripScriptBlocks(content);
  // Strip HTML tags to get plain text, then count words
  const plainText = noScripts
    .replace(/<[^>]*>/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  const wordCount =
    plainText.length > 0 ? plainText.split(' ').filter((w) => w.length > 0).length : 0;
 
  // All further counting uses script-stripped HTML to avoid false positives
  // from embedded JSON-LD or interactive script blocks.
  const totalSections = countMatches(noScripts, /<section\b/g);
 
  // Count data visualizations using exact class-token matching.
  // countClassToken splits the class attribute value into tokens, so nested
  // classes like "dashboard-grid" or "dashboard-panel" are NOT counted.
  const chartCount = countMatches(noScripts, /data-chart-config/g);
  const dashboardCount = countClassToken(noScripts, 'dashboard');
  const mindmapCount = countClassToken(noScripts, 'mindmap-section');
  const swotCount = countClassToken(noScripts, 'swot-analysis');
  const visualizationCount = chartCount + dashboardCount + mindmapCount + swotCount;
 
  // Exclude visualization sections from analysis section count
  const analysisSections = totalSections - dashboardCount - mindmapCount - swotCount;
 
  // Count EP document links (with a real path, not just the bare homepage).
  // This excludes the generic footer link `https://www.europarl.europa.eu/`
  // while counting links to specific EP resources like /doceo/, /plenary/, etc.
  const evidenceReferences = countMatches(
    noScripts,
    /href="https:\/\/www\.europarl\.europa\.eu\/\w[^"]*"/g
  );
 
  // Determine overall quality score
  let overallScore: ArticleQualityScore['overallScore'];
  if (wordCount >= 800 && analysisSections >= 3 && visualizationCount >= 2) {
    overallScore = 'excellent';
  } else if (wordCount >= 500 && analysisSections >= 2) {
    overallScore = 'good';
  } else Iif (wordCount >= 200 && analysisSections >= 1) {
    overallScore = 'adequate';
  } else {
    overallScore = 'needs-improvement';
  }
 
  return { wordCount, analysisSections, visualizationCount, evidenceReferences, overallScore };
}
 
/**
 * Build an HTML table of contents navigation element from a list of entries.
 *
 * @param entries - Ordered list of {@link TOCEntry} items to render.
 * @param lang - Language code used for the localised aria-label.
 * @returns HTML string for the TOC `<nav>` element, or empty string when entries is empty.
 */
export function buildTableOfContents(entries: TOCEntry[], lang: LanguageCode): string {
  if (entries.length === 0) {
    return '';
  }
 
  const ariaLabel = escapeHTML(getLocalizedString(TOC_ARIA_LABELS, lang));
 
  const items = entries
    .map((entry) => {
      const safeLabel = escapeHTML(entry.label);
      // Strip leading # to prevent href="##foo"
      const safeId = escapeHTML(entry.id.replace(/^#/, ''));
      const classAttr = entry.level === 2 ? ' class="toc-sub"' : '';
      return `<li${classAttr}><a href="#${safeId}">${safeLabel}</a></li>`;
    })
    .join('\n      ');
 
  return `<nav class="article-toc" aria-label="${ariaLabel}">
  <ol>
      ${items}
  </ol>
</nav>`;
}
 
/**
 * Build an HTML quality score badge element for an article.
 *
 * The badge is `aria-hidden` since it conveys metadata, not primary content.
 * Returns an empty string for articles with a 'needs-improvement' score to avoid
 * surfacing poor-quality signals to readers.
 *
 * @param score - {@link ArticleQualityScore} to render.
 * @returns HTML string for the badge `<div>`, or empty string for needs-improvement.
 */
export function buildQualityScoreBadge(score: ArticleQualityScore): string {
  if (score.overallScore === 'needs-improvement') {
    return '';
  }
 
  const safeScore = escapeHTML(score.overallScore);
  return `<div class="article-quality-score" data-score="${safeScore}" aria-hidden="true">
  <span class="qs-words">${score.wordCount}</span>
  <span class="qs-sections">${score.analysisSections}</span>
  <span class="qs-visuals">${score.visualizationCount}</span>
  <span class="qs-evidence">${score.evidenceReferences}</span>
</div>`;
}