Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 61 additions & 4 deletions src/background/style-manager/matcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const compileExclusion = createCompiler(buildExclusion);

function buildExclusion(text) {
// match pattern
const match = text.match(/^(\*|[\w-]+):\/\/(\*\.)?([\w.]+\/.*)/);
const match = text.match(/^(\*|[\w-]+):\/\/(\*\.)?([\w.-]+\/.*)/);
if (!match) {
return '^' + compileGlob(text) + '$';
}
Expand Down Expand Up @@ -62,7 +62,7 @@ export function urlMatchStyle(query, style) {
}

export function urlMatchSection(query, section, skipEmptyGlobal) {
let dd, ddL, pp, ppL, rr, rrL, uu, uuL;
let dd, ddL, pp, ppL, rr, rrL, uu, uuL, mm, mmL;
if (
(dd = section.domains) && (ddL = dd.length) && dd.some(urlMatchDomain, query) ||
(pp = section.urlPrefixes) && (ppL = pp.length) && pp.some(urlMatchPrefix, query) ||
Expand All @@ -74,7 +74,8 @@ export function urlMatchSection(query, section, skipEmptyGlobal) {
uu.includes(query.url) ||
uu.includes(query.urlWithoutHash ??= query.url.split('#', 1)[0])
) ||
(rr = section.regexps) && (rrL = rr.length) && rr.some(urlMatchRegexp, query)
(rr = section.regexps) && (rrL = rr.length) && rr.some(urlMatchRegexp, query) ||
(mm = section.matches) && (mmL = mm.length) && mm.some(urlMatchPattern, query)
) {
return true;
}
Expand All @@ -88,7 +89,7 @@ export function urlMatchSection(query, section, skipEmptyGlobal) {
return 'sloppy';
}
// TODO: check for invalid regexps?
return !rrL && !ppL && !uuL && !ddL &&
return !rrL && !ppL && !uuL && !ddL && !mmL &&
// We allow only intentionally targeted sections for own pages
!(query.isOwnPage ??= query.url.startsWith(ownRoot)) &&
(!skipEmptyGlobal || !styleCodeEmpty(section));
Expand Down Expand Up @@ -117,3 +118,59 @@ function urlMatchRegexpSloppy(r) {
return (!(this.isOwnPage ??= this.url.startsWith(ownRoot)) || EXT_RE.test(r)) &&
compileSloppyRe(r).test(this.url);
}

/** @this {MatchQuery} */
function urlMatchPattern(pattern) {
// Convert @match pattern to regex (similar to Tampermonkey)
// Examples: *://*.example.com/*, *://example.com/*, https://example.com/*

try {
const url = new URL(this.url);
const urlWithoutParams = this.urlWithoutParams ??= this.url.split(/[?#]/, 1)[0];

// Parse the pattern
const match = pattern.match(/^(\*|[\w-]+):\/\/(\*\.)?([\w.-]+\/.*)$/);
if (!match) {
// If pattern doesn't match expected format, try exact match
return urlWithoutParams === pattern;
}

const [, protocol, subdomainWildcard, hostAndPath] = match;

// Check protocol
if (protocol !== '*' && url.protocol !== protocol + ':') {
return false;
}

// Check hostname
const hostname = url.hostname;
if (subdomainWildcard) {
// Pattern like *.example.com
const domain = hostAndPath.split('/')[0];
if (!hostname.endsWith('.' + domain) && hostname !== domain) {
return false;
}
} else {
// Exact hostname match
const domain = hostAndPath.split('/')[0];
if (hostname !== domain) {
return false;
}
}

// Check path
const pathPattern = hostAndPath.substring(hostAndPath.indexOf('/'));
if (pathPattern === '/*') {
return true; // Any path
} else if (pathPattern.endsWith('/*')) {
// Prefix match
const prefix = pathPattern.slice(0, -2);
return url.pathname.startsWith(prefix);
} else {
// Exact path match
return url.pathname === pathPattern;
}
} catch {
return false;
}
}
10 changes: 8 additions & 2 deletions src/background/usercss-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,14 @@ export async function build({
}

export async function buildCode(style) {
const {sourceCode: code, [UCD]: {vars, preprocessor}} = style;
const {sections, errors, log} = await worker.compileUsercss(preprocessor, code, vars);
const {sourceCode: code, [UCD]: {vars, preprocessor, match}} = style;

// Pass @match data to compiler
const varsWithMatch = vars
? {...vars, _usercssData: {match}}
: {_usercssData: {match}};

const {sections, errors, log} = await worker.compileUsercss(preprocessor, code, varsWithMatch);
const recoverable = errors.every(e => e.recoverable);
if (!sections.length || !recoverable) {
throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.';
Expand Down
34 changes: 31 additions & 3 deletions src/install-usercss/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {htmlToTemplate, tBody} from '@/js/localization';
import {API} from '@/js/msg-api';
import * as prefs from '@/js/prefs';
import {styleCodeEmpty} from '@/js/sections-util';
import {isLocalhost} from '@/js/urls';
import {isLocalhost, favicon} from '@/js/urls';
import {clipString, debounce, deepEqual, sessionStore, t, tryURL} from '@/js/util';
import {closeCurrentTab} from '@/js/util-webext';
import DirectDownloader from './direct-downloader';
Expand Down Expand Up @@ -229,7 +229,35 @@ function updateMeta(newStyle) {
replaceChildren($('.meta-license'), data.license, true);
replaceChildren($('.external-link'), makeExternalLink());
getAppliesTo().then(list =>
replaceChildren($('.applies-to'), list.map(s => $create('li', s))));
replaceChildren($('.applies-to'), list.map(s => {
const li = $create('li');
// Add favicon for @match patterns and clean up display
if (s.startsWith('*://') && s.includes('/*')) {
const matchDomain = s.match(/:\/\/(\*\.)?([^/*]+)/);
if (matchDomain) {
const domain = matchDomain[2];
const subdomain = matchDomain[1] ? '*.' : '';
const cleanDisplay = subdomain + domain + '/*';
const img = $create('img', {
src: favicon(domain),
width: 16,
height: 16,
style: 'margin-right: 4px; vertical-align: middle;',
onerror: function() {
// If favicon fails to load, hide the image
this.style.display = 'none';
}
});
li.appendChild(img);
li.appendChild(document.createTextNode(cleanDisplay));
} else {
li.textContent = s;
}
} else {
li.textContent = s;
}
return li;
})));

Object.assign($('.configure-usercss'), {
hidden: !data.vars,
Expand Down Expand Up @@ -378,7 +406,7 @@ async function getAppliesTo() {
}
let numGlobals = 0;
const res = [];
const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps'];
const TARGETS = ['urls', 'urlPrefixes', 'domains', 'regexps', 'matches'];
for (const section of style.sections) {
const targets = [].concat(...TARGETS.map(_ => section[_]).filter(Boolean));
res.push(...targets);
Expand Down
117 changes: 116 additions & 1 deletion src/js/meta-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,100 @@ import {createParser, ParseError} from 'usercss-meta';
import {importScriptsOnce} from './worker-util';

const PREPROCESSORS = new Set(['default', 'uso', 'stylus', 'less']);

// Custom parser for @match directive
function parseMatch(state) {
// Parse single match pattern (one per @match directive)
let currentMatch = '';
let inQuotes = false;
let quoteChar = '';

while (state.lastIndex < state.text.length) {
const char = state.text[state.lastIndex];

if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
state.lastIndex++;
continue;
}

if (inQuotes && char === quoteChar) {
inQuotes = false;
quoteChar = '';
state.lastIndex++;
continue;
}

if (!inQuotes && char === '\n') {
break;
}

currentMatch += char;
state.lastIndex++;
}

// Clean up the match pattern
const cleanMatch = currentMatch.trim().replace(/^["']|["']$/g, '');

// Initialize matches array if it doesn't exist
if (!Array.isArray(state.usercssData.match)) {
state.usercssData.match = [];
}

// Add this match pattern to the array
if (cleanMatch) {
state.usercssData.match.push(cleanMatch);
}

// Return the current pattern for validation
state.value = cleanMatch;
}

// Post-process to collect all @match patterns
function collectAllMatches(metadata) {
const allMatches = [];

// Find all @match directives in the original text
const matchRegex = /@match\s+([^\n]+)/g;
let match;
while ((match = matchRegex.exec(metadata)) !== null) {
const pattern = match[1].trim().replace(/^["']|["']$/g, '');
if (pattern) {
allMatches.push(pattern);
}
}

return allMatches;
}

// Validate @match pattern (similar to Tampermonkey patterns)
function isValidMatchPattern(pattern) {
// Basic validation for @match patterns
// Pattern should be in format: protocol://host/path
// Examples: *://*.example.com/*, *://example.com/*, https://example.com/*

if (!pattern || typeof pattern !== 'string') {
return false;
}

// Check if it's a valid URL pattern
try {
// Replace wildcards with placeholder values for URL validation
const testPattern = pattern
.replace(/\*/g, 'example')
.replace(/\/\*$/, '/test');

new URL(testPattern);
return true;
} catch {
return false;
}
}
const options = {
parseKey: {
match: parseMatch,
},
validateKey: {
preprocessor: state => {
if (!PREPROCESSORS.has(state.value)) {
Expand All @@ -13,7 +106,19 @@ const options = {
});
}
},
match: state => {
// Validate single match pattern
if (!isValidMatchPattern(state.value)) {
throw new ParseError({
code: 'invalidMatchPattern',
args: [state.value],
index: state.valueIndex,
});
}
},
},
// Don't overwrite match values, collect them instead
unknownKey: 'assign',
validateVar: {
select: state => {
if (state.varResult.options.every(o => o.name !== state.value)) {
Expand Down Expand Up @@ -46,7 +151,17 @@ const looseParser = createParser(Object.assign({}, options, {
const metaParser = {

lint: looseParser.parse,
parse: parser.parse,
parse: text => {
const result = parser.parse(text);
// Post-process to collect all @match patterns
if (result.metadata) {
const allMatches = collectAllMatches(text);
if (allMatches.length > 0) {
result.metadata.match = allMatches;
}
}
return result;
},

nullifyInvalidVars(vars) {
for (const va of Object.values(vars)) {
Expand Down
Loading