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>`;
}
|