All files / src/generators/news-indexes backfill-hreflang.ts

91.42% Statements 32/35
78.57% Branches 11/14
100% Functions 7/7
96.66% Lines 29/30

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                                            10x 10x                             10x   140x   10x     10x                     7x                     1x       1x                             9x 9x 11x   9x                   11x 11x 10x 10x 10x   10x 10x 10x     10x 7x   3x     3x 1x     8x 8x 8x    
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module Generators/NewsIndexes/BackfillHreflang
 * @description Hreflang alternate-link backfill for article HTML files.
 * Extracted from `backfill.ts` to keep source files ≤600 lines.
 */
 
import path from 'path';
import fs from 'fs';
import { NEWS_DIR, BASE_URL } from '../../constants/config.js';
import { ALL_LANGUAGES } from '../../constants/languages.js';
import { parseArticleFilename, atomicWrite } from '../../utils/file-utils.js';
 
/**
 * Read an article HTML file, returning an empty string when unavailable.
 *
 * @param filepath - Absolute HTML file path
 * @returns File content or empty string
 */
function readArticleHtml(filepath: string): string {
  try {
    return path.isAbsolute(filepath) ? fs.readFileSync(filepath, 'utf8') : '';
  } catch {
    return '';
  }
}
 
/**
 * Build hreflang `<link rel="alternate">` tags for an article slug.
 * Produces one tag per supported language plus an `x-default` pointing at
 * the English variant, all using absolute URLs.
 *
 * @param articleSlug - Slug without language suffix (e.g. `2026-02-24-propositions`)
 * @returns Newline-joined `<link>` tags
 */
function buildArticleHreflang(articleSlug: string): string {
  const entries = ALL_LANGUAGES.map(
    (code) =>
      `  <link rel="alternate" hreflang="${code}" href="${BASE_URL}/news/${articleSlug}-${code}.html">`
  );
  entries.push(
    `  <link rel="alternate" hreflang="x-default" href="${BASE_URL}/news/${articleSlug}-en.html">`
  );
  return entries.join('\n');
}
 
/**
 * Inject hreflang links into an article that has none.
 *
 * @param html - Article HTML content
 * @param hreflangBlock - Pre-built hreflang link block
 * @returns Updated HTML, or original if no change needed
 */
function injectHreflangLinks(html: string, hreflangBlock: string): string {
  return html.replace(/(<\/head>)/u, `${hreflangBlock}\n$1`);
}
 
/**
 * Replace existing relative hreflang links with absolute URLs.
 *
 * @param html - Article HTML content
 * @param hreflangBlock - Pre-built hreflang link block with absolute URLs
 * @returns Updated HTML, or original if no change needed
 */
function fixRelativeHreflangLinks(html: string, hreflangBlock: string): string {
  const stripped = html.replace(
    /\s*<link\s+rel="alternate"\s+hreflang="[^"]*"\s+href="[^"]*">\n?/gu,
    ''
  );
  return stripped.replace(/(<\/head>)/u, `${hreflangBlock}\n$1`);
}
 
/**
 * Backfill hreflang alternate links for all article HTML files.
 *
 * Handles three cases:
 * 1. Articles with no hreflang links at all → inject the full block before `</head>`
 * 2. Articles with relative hreflang URLs → replace with absolute URLs
 * 3. Articles already correct → skip
 *
 * @param filenames - News article filenames
 * @returns Number of HTML files updated
 */
export function backfillArticleHreflang(filenames: readonly string[]): number {
  let updated = 0;
  for (const filename of filenames) {
    if (backfillOneArticleHreflang(filename)) updated++;
  }
  return updated;
}
 
/**
 * Backfill hreflang for a single article file.
 *
 * @param filename - News article filename
 * @returns True when the file was updated
 */
function backfillOneArticleHreflang(filename: string): boolean {
  const parsed = parseArticleFilename(filename);
  if (!parsed) return false;
  const filepath = path.join(NEWS_DIR, filename);
  const html = readArticleHtml(filepath);
  Iif (!html) return false;
 
  const articleSlug = `${parsed.date}-${parsed.slug}`;
  const hreflangBlock = buildArticleHreflang(articleSlug);
  const hasHreflang = /<link\s+rel="alternate"\s+hreflang="/u.test(html);
 
  let next: string;
  if (!hasHreflang) {
    next = injectHreflangLinks(html, hreflangBlock);
  } else {
    const hasRelative = /<link\s+rel="alternate"\s+hreflang="[^"]*"\s+href="(?!https?:\/\/)/u.test(
      html
    );
    if (!hasRelative) return false;
    next = fixRelativeHreflangLinks(html, hreflangBlock);
  }
 
  Iif (next === html) return false;
  atomicWrite(filepath, next);
  return true;
}