includes/rules.php

<?php
declare(strict_types=1);

/*
|--------------------------------------------------------------------------
| Page identifiers
|--------------------------------------------------------------------------
*/

// Neutral placeholder used only when no language could be found at all
// (no lang param, no /xx/ in the URL, no /xx/ in the referrer). This is NOT
// a guessed language — 'xx' is not a real ISO 639-1 code. It mirrors
// Brivacia's existing "unknown" convention (see countryName()'s 'XX'
// handling and assets/images/flags/xx.png): the visit still counts, but the
// page shows up in the dashboard with the '?' flag instead of blending in
// silently — a visible alert, not a fix for why the language went missing.
const BRIVACIA_UNKNOWN_LANG = 'xx';

// Extracts a strict 2-letter ISO 639-1-style code from any raw language
// value: "en", "en-US", "en_US", "EN-us", "fr-CA"... all collapse to their
// primary subtag ("en", "fr"). Returns '' if nothing usable is found.
function brivaciaNormalizeLangCode(string $raw): string {
    $raw = strtolower(trim($raw));

    return preg_match('/^([a-z]{2})(?:[-_][a-z0-9]+)*$/', $raw, $m)
        ? $m[1]
        : '';
}

function brivaciaPageKey(string $site, string $page, string $trackedWebsiteLang = ''): string {
    $lang = brivaciaNormalizeLangCode($trackedWebsiteLang) ?: BRIVACIA_UNKNOWN_LANG;

    return $lang . ':' . $page;
}

function brivaciaRawPageKey(string $key): string {
    return str_contains($key, ':')
        ? substr($key, strpos($key, ':') + 1)
        : $key;
}


/*
|--------------------------------------------------------------------------
| Tracked website language
|--------------------------------------------------------------------------
|
| Tries to infer the tracked website language from a page URL or referrer.
| This is only about the tracked website, not the Brivacia dashboard language.
|
*/

function brivaciaInferTrackedWebsiteLang(string $url, string $ref = '', string $fallback = ''): string {
    $fallback = brivaciaNormalizeLangCode(substr(trim($fallback), 0, 10));

    if ($fallback !== '') {
        return $fallback;
    }

    foreach ([$url, $ref] as $source) {
        $path = parse_url($source, PHP_URL_PATH);

        if (!is_string($path) || $path === '') {
            continue;
        }

        $first = trim(explode('/', trim($path, '/'))[0] ?? '');
        $lang = brivaciaNormalizeLangCode($first);

        if ($lang !== '' && preg_match('~^[a-z]{2}(?:-[a-z]{2})?$~i', $first)) {
            return $lang;
        }
    }

    return '';
}


/*
|--------------------------------------------------------------------------
| Page URLs
|--------------------------------------------------------------------------
*/

function brivaciaCleanStoredPageUrl(string $url): string {
    $url = trim($url);

    if ($url === '') {
        return '';
    }

    $path = parse_url($url, PHP_URL_PATH);

    if (is_string($path) && $path !== '') {
        return '/' . ltrim($path, '/');
    }

    return str_starts_with($url, '/') ? '/' . ltrim($url, '/') : '';
}

function brivaciaPageUrl(string $site, string $pageKey, string $url = ''): string {
    $host = siteLabel($site);
    $url = brivaciaCleanStoredPageUrl($url);

    if ($url !== '') {
        return 'https://' . $host . '/' . ltrim($url, '/');
    }

    if (str_starts_with($pageKey, '/')) {
        return 'https://' . $host . $pageKey;
    }

    return 'https://' . $host . '/' . ltrim($pageKey, '/');
}


/*
|--------------------------------------------------------------------------
| Page titles
|--------------------------------------------------------------------------
*/

function brivaciaCleanPageTitle(string $title, string $site): string {
    return trim($title);
}


/*
|--------------------------------------------------------------------------
| Metadata extraction
|--------------------------------------------------------------------------
*/

function brivaciaExtractHtmlTitle(string $html): string {
    if (!preg_match('~<title[^>]*>(.*?)</title>~is', $html, $m)) {
        return '';
    }

    return html_entity_decode(
        strip_tags(trim($m[1])),
        ENT_QUOTES,
        'UTF-8'
    );
}

function brivaciaExtractCanonicalUrl(string $html): string {
    if (preg_match_all('~<link\b[^>]*>~i', $html, $links)) {
        foreach ($links[0] as $tag) {
            if (!preg_match('~\brel=["\']([^"\']+)["\']~i', $tag, $rel)) continue;
            if (!str_contains(strtolower($rel[1]), 'canonical')) continue;
            if (!preg_match('~\bhref=["\']([^"\']+)["\']~i', $tag, $href)) continue;

            return html_entity_decode(trim($href[1]), ENT_QUOTES, 'UTF-8');
        }
    }

    if (preg_match_all('~<meta\b[^>]*>~i', $html, $tags)) {
        foreach ($tags[0] as $tag) {
            $lower = strtolower($tag);
            if (!str_contains($lower, 'og:url')) continue;
            if (!preg_match('~\bcontent=["\']([^"\']+)["\']~i', $tag, $m)) continue;

            return html_entity_decode(trim($m[1]), ENT_QUOTES, 'UTF-8');
        }
    }

    return '';
}

function brivaciaExtractHtmlLang(string $html): string {
    if (!preg_match('~<html\b[^>]*\blang=["\']([^"\']+)["\']~i', $html, $m)) {
        return '';
    }

    return brivaciaNormalizeLangCode(
        html_entity_decode(trim($m[1]), ENT_QUOTES, 'UTF-8')
    );
}

function brivaciaPathFromAbsoluteOrRelativeUrl(string $url): string {
    return brivaciaCleanStoredPageUrl($url);
}


/*
|--------------------------------------------------------------------------
| Metadata fallback detection
|--------------------------------------------------------------------------
*/

function brivaciaShouldLogPageFallback(string $key, string $title, string $url): bool {
    $rawKey = brivaciaRawPageKey($key);

    return $title === ''
        || $title === $rawKey
        || $url === ''
        || $url === '/' . $rawKey
        || str_contains($title, $rawKey);
}


/*
|--------------------------------------------------------------------------
| Metadata resolution
|--------------------------------------------------------------------------
*/

function brivaciaResolvePageMetadata(string $site, string $pageKey, string $url = ''): array {
    $candidateUrl = brivaciaPageUrl($site, $pageKey, $url);
    $html = @file_get_contents($candidateUrl, false, brivaciaHttpContext(3));

    if (!$html) {
        return ['title' => '', 'url' => '', 'lang' => ''];
    }

    $title = brivaciaExtractHtmlTitle($html);
    $canonical = brivaciaExtractCanonicalUrl($html);
    $path = brivaciaPathFromAbsoluteOrRelativeUrl($canonical);
    $lang = brivaciaExtractHtmlLang($html);

    if ($path === '') {
        $path = brivaciaPathFromAbsoluteOrRelativeUrl($candidateUrl);
    }

    if (brivaciaShouldLogPageFallback($pageKey, $title, $path)) {
        return ['title' => '', 'url' => '', 'lang' => $lang];
    }

    return [
        'title' => $title,
        'url' => $path,
        'lang' => $lang,
    ];
}

Contribute