/**
* Host-aware URL pattern matching for Elly block/allow lists.
* Prefer this over bare `url.includes(pattern)` for http(s) hosts.
*/
/**
* @param {string} url
* @returns {string}
*/
export function extractHostname(url) {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return '';
}
}
/**
* Strip leading www. for domain comparisons.
* @param {string} host
* @returns {string}
*/
export function bareHostname(host) {
return String(host || '').toLowerCase().replace(/^www\./, '');
}
/**
* True when host matches domain or is a subdomain of domain.
* @param {string} host
* @param {string} domain
* @returns {boolean}
*/
export function hostMatchesDomain(host, domain) {
const h = bareHostname(host);
const d = bareHostname(domain);
if (!h || !d) {
return false;
}
return h === d || h.endsWith(`.${d}`);
}
/**
* Match a single block/allow pattern against a URL.
* Supports:
* - Exact host / domain (e.g. example.com, www.example.com)
* - `*.example.com` → host === example.com OR host.endsWith('.example.com')
* - Scheme patterns like `chrome://extensions` via startsWith (careful, not bare includes for hosts)
* - Pathful https patterns when pattern includes `/`
* @param {string} url
* @param {string} pattern
* @returns {boolean}
*/
export function matchesUrlPattern(url, pattern) {
if (!url || !pattern) {
return false;
}
const urlStr = String(url);
const patternStr = String(pattern).trim();
if (!patternStr) {
return false;
}
const lowerUrl = urlStr.toLowerCase();
const lowerPat = patternStr.toLowerCase();
// Internal / scheme-prefixed patterns (chrome://, opera://, edge://, about:)
if (
lowerPat.startsWith('chrome://')
|| lowerPat.startsWith('opera://')
|| lowerPat.startsWith('edge://')
|| lowerPat.startsWith('about:')
) {
return lowerUrl.startsWith(lowerPat) || lowerUrl.includes(lowerPat);
}
// Wildcard host patterns: *.example.com
if (patternStr.startsWith('*.')) {
const domain = patternStr.slice(2).toLowerCase().replace(/\/.*$/, '');
try {
const host = extractHostname(urlStr);
if (!host) {
return false;
}
return hostMatchesDomain(host, domain);
} catch {
return false;
}
}
// Full URL or path-containing pattern
if (patternStr.includes('://') || patternStr.includes('/')) {
if (lowerUrl.startsWith(lowerPat) || lowerUrl.includes(lowerPat)) {
return true;
}
// Fall through to host match on the hostname portion when parseable
}
// Host / domain pattern (no bare url.includes for google.com-style hosts)
try {
const host = extractHostname(urlStr);
if (!host) {
return lowerUrl.includes(lowerPat);
}
let domainPart = lowerPat;
if (domainPart.includes('://')) {
try {
domainPart = new URL(patternStr).hostname.toLowerCase();
} catch {
domainPart = lowerPat.replace(/^https?:\/\//, '').split('/')[0];
}
} else if (domainPart.includes('/')) {
domainPart = domainPart.split('/')[0];
}
domainPart = domainPart.replace(/^\*\./, '');
return hostMatchesDomain(host, domainPart);
} catch {
return false;
}
}
/**
* @param {string} url
* @param {string[] | null | undefined} list
* @returns {boolean}
*/
export function isUrlInList(url, list) {
if (!url || !list || !Array.isArray(list)) {
return false;
}
return list.some((pattern) => matchesUrlPattern(url, pattern));
}