From fc69a53505ce0395fd8ef2e5c3dc6dcd75b4566d Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 9 Apr 2026 20:24:50 -0400 Subject: [PATCH] Add DiffKit browser redirect extension --- extensions/diffkit-redirect/README.md | 81 ++++ extensions/diffkit-redirect/content.js | 22 + .../diffkit-redirect/icons/icon-128.png | Bin 0 -> 5933 bytes extensions/diffkit-redirect/icons/icon-16.png | Bin 0 -> 815 bytes extensions/diffkit-redirect/icons/icon-32.png | Bin 0 -> 1543 bytes extensions/diffkit-redirect/icons/icon-48.png | Bin 0 -> 2124 bytes extensions/diffkit-redirect/icons/logo.svg | 12 + extensions/diffkit-redirect/manifest.json | 30 ++ extensions/diffkit-redirect/options.html | 74 +++ extensions/diffkit-redirect/options.js | 58 +++ extensions/diffkit-redirect/popup.html | 41 ++ extensions/diffkit-redirect/popup.js | 137 ++++++ extensions/diffkit-redirect/shared.js | 400 +++++++++++++++++ extensions/diffkit-redirect/styles.css | 425 ++++++++++++++++++ 14 files changed, 1280 insertions(+) create mode 100644 extensions/diffkit-redirect/README.md create mode 100644 extensions/diffkit-redirect/content.js create mode 100644 extensions/diffkit-redirect/icons/icon-128.png create mode 100644 extensions/diffkit-redirect/icons/icon-16.png create mode 100644 extensions/diffkit-redirect/icons/icon-32.png create mode 100644 extensions/diffkit-redirect/icons/icon-48.png create mode 100644 extensions/diffkit-redirect/icons/logo.svg create mode 100644 extensions/diffkit-redirect/manifest.json create mode 100644 extensions/diffkit-redirect/options.html create mode 100644 extensions/diffkit-redirect/options.js create mode 100644 extensions/diffkit-redirect/popup.html create mode 100644 extensions/diffkit-redirect/popup.js create mode 100644 extensions/diffkit-redirect/shared.js create mode 100644 extensions/diffkit-redirect/styles.css diff --git a/extensions/diffkit-redirect/README.md b/extensions/diffkit-redirect/README.md new file mode 100644 index 0000000..1e40264 --- /dev/null +++ b/extensions/diffkit-redirect/README.md @@ -0,0 +1,81 @@ +# DiffKit Extension + +Standalone browser extension for redirecting only selected GitHub URLs to DiffKit. + +## What it does + +- Redirects matching GitHub URLs at page start +- Lets you keep the extension globally enabled or disabled +- Shows one toggle per redirect rule in the popup +- Uses a configurable list of rules instead of redirecting every GitHub page +- Supports both exact URL redirects and regex-based route schemas +- Supports custom route remaps like GitHub PR changes pages to DiffKit review pages + +## Default rule + +The extension ships with these enabled rules: + +- `https://github.com/` +- `https://diff-kit.com/` +- `https://github.com/pulls/*` +- `https://diff-kit.com/pulls` +- `https://github.com/issues/*` +- `https://diff-kit.com/issues` +- `https://github.com/:owner/:repo/pull/:number` +- `https://diff-kit.com/:owner/:repo/pull/:number` +- `https://github.com/:owner/:repo/pull/:number/changes` +- `https://diff-kit.com/:owner/:repo/review/:number` +- `https://github.com/:owner/:repo/issues/:number` +- `https://diff-kit.com/:owner/:repo/issues/:number` + +## Rule format + +```json +[ + { + "id": "github-pull-details", + "label": "Pull request details", + "description": "Redirect GitHub pull request detail pages to DiffKit.", + "enabled": true, + "match": { + "urlRegex": "^https://github\\.com/([^/?#]+)/([^/?#]+)/pull/(\\d+)/?$", + "excludeUrlRegexes": [] + }, + "redirect": { + "replacement": "https://diff-kit.com/$1/$2/pull/$3" + } + } +] +``` + +For an exact URL redirect, use: + +```json +{ + "id": "one-off", + "label": "Specific PR page", + "description": "Single URL redirect", + "enabled": true, + "match": { + "exactUrl": "https://github.com/org/repo/pull/123" + }, + "redirect": { + "url": "https://diff-kit.com/org/repo/pull/123" + } +} +``` + +## Install locally + +1. Open `chrome://extensions` +2. Enable Developer mode +3. Click Load unpacked +4. Select `extensions/diffkit-redirect` + +## Scope + +This version is intentionally limited to `github.com` in `manifest.json`. If you later want redirects from other source hosts, add those hosts to the extension matches and permissions. + +## Popup behavior + +The popup keeps all default rules enabled unless you turn specific ones off. Use the master switch to pause all redirects without losing your per-rule selections. diff --git a/extensions/diffkit-redirect/content.js b/extensions/diffkit-redirect/content.js new file mode 100644 index 0000000..3e37113 --- /dev/null +++ b/extensions/diffkit-redirect/content.js @@ -0,0 +1,22 @@ +(async function runRedirect() { + const shared = globalThis.DiffKitRedirect; + if (!shared) { + return; + } + + try { + const config = await shared.getConfig(); + if (!config.enabled) { + return; + } + + const redirect = shared.findRedirect(window.location.href, config.rules); + if (!redirect) { + return; + } + + window.location.replace(redirect.targetUrl); + } catch (error) { + console.error("DiffKit: failed to evaluate redirect.", error); + } +})(); diff --git a/extensions/diffkit-redirect/icons/icon-128.png b/extensions/diffkit-redirect/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..6d072c19e5bd4cd23bceb1939430b698e8a99f34 GIT binary patch literal 5933 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 MC}_%8%ff>H2Lv}7hyVZp literal 0 HcmV?d00001 diff --git a/extensions/diffkit-redirect/icons/icon-16.png b/extensions/diffkit-redirect/icons/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..ee77780f8838fab463b2e24965b185ceebe990dc GIT binary patch literal 815 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|6p}rHd>I(3 z)EF2VS{N990fib~Fff!FFfhDIU|_JC!N4G1FlSew4N$@*HNrE^*Ox&H$mU>RY|mt1 z0kRl@SPF<47?>9@GB5*aMv&M7Cb+D?0%imoq|hX^@Dl?AQ?93rV~B-dZm@N}u%p1g zwEXvbw)uN@@Q5*Xc`Ohy@zBgDa8#MlAz-q^Vd9DjKSWFZh&co(bhNYxi1PCC27JhH zYV3G(r1SBO@_onkjH@emB)lujFH3(uJ3s9khtBl)HAf%r+5P&#y2FWcxAV*qzT7FW z@tvB>mmXdDpvn9H6}~+?L2qB|{LEKRLND-y$0R0h{&eIe$Kl^$x3#z&q89pe^)ESX zy-Tnsyg6;lq`Y^^?|Usd7Ch(uG*{&2wG-8@=~4#Wn>CJGvUr<1yS#Nf-gn3L?36sO zCwrzZkV(7O=+v5I?Rd<&MMN>G!;(?^hfPtU!ZLL!m+x;~GUPwa{pa(#=#NLreJjSi z>@t%>+A~)FytAZiQG3{;k4?D`Us{DU_sem)p33fX{1TWT>%JlJ0dI=Px|mou?Kz)c z*BKs|{{DWzJ6{`x>pWFzpSC66Fw(RwQJ`+naDq(GQVnPR&2lYwj;WD z!cXoMX--EHnb$^5J#-{3V%L_Ivb9IsW!Zi**72F|T`UqfOJ4Nv7YT&|vF@|QI|AZA z@J-}eDBQbw8IwWd!SxUGmmSi#w3+_;|8|6=DCz^%zvsho8`(@VLxm$0~np$4- zS?&1J35#D`Klw? z66s;1ni%mz{Byi2+rDcnGE6pUXO@geCwkJW&q- literal 0 HcmV?d00001 diff --git a/extensions/diffkit-redirect/icons/icon-32.png b/extensions/diffkit-redirect/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..9cd8e62fb8468f46dda6ab064b520409290390a3 GIT binary patch literal 1543 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 + + + + + + + + + + + diff --git a/extensions/diffkit-redirect/manifest.json b/extensions/diffkit-redirect/manifest.json new file mode 100644 index 0000000..feec289 --- /dev/null +++ b/extensions/diffkit-redirect/manifest.json @@ -0,0 +1,30 @@ +{ + "manifest_version": 3, + "name": "DiffKit", + "version": "0.1.0", + "description": "Redirect selected GitHub URLs to matching DiffKit routes.", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "permissions": ["storage"], + "host_permissions": ["https://github.com/*"], + "action": { + "default_title": "DiffKit", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png" + } + }, + "options_page": "options.html", + "content_scripts": [ + { + "matches": ["https://github.com/*"], + "js": ["shared.js", "content.js"], + "run_at": "document_start" + } + ] +} diff --git a/extensions/diffkit-redirect/options.html b/extensions/diffkit-redirect/options.html new file mode 100644 index 0000000..6fc8528 --- /dev/null +++ b/extensions/diffkit-redirect/options.html @@ -0,0 +1,74 @@ + + + + + + DiffKit Options + + + +
+
+
+
+

DiffKit

+

Redirect rules

+
+ + +
+ +

+ Add only the GitHub routes DiffKit currently supports. A rule can + match a single exact URL or a regex pattern over the full URL. +

+ + + +
+ + +
+ +

+
+ + +
+ + + + + diff --git a/extensions/diffkit-redirect/options.js b/extensions/diffkit-redirect/options.js new file mode 100644 index 0000000..f310f5b --- /dev/null +++ b/extensions/diffkit-redirect/options.js @@ -0,0 +1,58 @@ +(async function initOptions() { + const shared = globalThis.DiffKitRedirect; + const enabledToggle = document.getElementById("enabled-toggle"); + const rulesEditor = document.getElementById("rules-editor"); + const saveButton = document.getElementById("save-button"); + const resetButton = document.getElementById("reset-button"); + const saveStatus = document.getElementById("save-status"); + + function setStatus(message, tone) { + saveStatus.textContent = message; + saveStatus.dataset.tone = tone || ""; + } + + async function loadConfig() { + const config = await shared.getConfig(); + enabledToggle.checked = config.enabled; + rulesEditor.value = JSON.stringify(config.rules, null, 2); + + if (config.validationErrors.length > 0) { + setStatus(config.validationErrors.join(" "), "error"); + return; + } + + setStatus("", ""); + } + + async function saveConfig() { + let parsedRules; + try { + parsedRules = JSON.parse(rulesEditor.value); + } catch { + setStatus("Rules must be valid JSON.", "error"); + return; + } + + const result = await shared.saveConfig({ + enabled: enabledToggle.checked, + rules: parsedRules, + }); + + if (!result.ok) { + setStatus(result.errors.join(" "), "error"); + return; + } + + setStatus("Rules saved.", "success"); + await loadConfig(); + } + + saveButton.addEventListener("click", saveConfig); + + resetButton.addEventListener("click", () => { + rulesEditor.value = JSON.stringify(shared.getDefaultRules(), null, 2); + setStatus("Defaults restored in the editor. Save to apply them.", ""); + }); + + await loadConfig(); +})(); diff --git a/extensions/diffkit-redirect/popup.html b/extensions/diffkit-redirect/popup.html new file mode 100644 index 0000000..60dbd33 --- /dev/null +++ b/extensions/diffkit-redirect/popup.html @@ -0,0 +1,41 @@ + + + + + + DiffKit + + + +
+ +
+ + + + + diff --git a/extensions/diffkit-redirect/popup.js b/extensions/diffkit-redirect/popup.js new file mode 100644 index 0000000..160ff82 --- /dev/null +++ b/extensions/diffkit-redirect/popup.js @@ -0,0 +1,137 @@ +(async function initPopup() { + const shared = globalThis.DiffKitRedirect; + const toggle = document.getElementById("enabled-toggle"); + const ruleList = document.getElementById("rule-list"); + const statusCopy = document.getElementById("status-copy"); + + function getRuleLabel(rule) { + if (rule.label) { + return rule.label; + } + + if (rule.description) { + return rule.description; + } + + return rule.id + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); + } + + function getRuleDescription(rule) { + if (rule.enabled) { + return "Enabled"; + } + + return "Disabled"; + } + + function renderRuleList(config) { + ruleList.textContent = ""; + + if (config.rules.length === 0) { + const emptyState = document.createElement("p"); + emptyState.className = "empty-state"; + emptyState.textContent = "No redirect rules available yet."; + ruleList.append(emptyState); + return; + } + + for (const rule of config.rules) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "rule-toggle"; + button.dataset.ruleId = rule.id; + button.setAttribute("aria-pressed", String(rule.enabled)); + + const label = document.createElement("span"); + label.className = "rule-copy"; + + const title = document.createElement("span"); + title.className = "rule-title"; + title.textContent = getRuleLabel(rule); + + const description = document.createElement("span"); + description.className = "rule-description"; + description.textContent = getRuleDescription(rule); + + label.append(title, description); + button.append(label); + ruleList.append(button); + } + } + + function renderStatus(config) { + const activeRules = config.rules.filter((rule) => rule.enabled).length; + const totalRules = config.rules.length; + + if (!config.enabled) { + statusCopy.textContent = `Master off. ${activeRules} of ${totalRules} rules selected.`; + return; + } + + statusCopy.textContent = `${activeRules} of ${totalRules} redirects active.`; + } + + async function render() { + const config = await shared.getConfig(); + toggle.checked = config.enabled; + renderRuleList(config); + renderStatus(config); + } + + try { + await render(); + } catch { + statusCopy.textContent = "Failed to load extension state"; + } + + toggle.addEventListener("change", async () => { + const config = await shared.getConfig(); + const nextConfig = { + enabled: toggle.checked, + rules: config.rules, + }; + + const result = await shared.saveConfig(nextConfig); + if (!result.ok) { + statusCopy.textContent = result.errors.join(" "); + return; + } + + await render(); + }); + + ruleList.addEventListener("click", async (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const button = event.target.closest(".rule-toggle"); + if (!button) { + return; + } + + const config = await shared.getConfig(); + const nextRules = config.rules.map((rule) => + rule.id === button.dataset.ruleId + ? { + ...rule, + enabled: !rule.enabled, + } + : rule + ); + + const result = await shared.saveConfig({ + enabled: config.enabled, + rules: nextRules, + }); + + if (!result.ok) { + statusCopy.textContent = result.errors.join(" "); + return; + } + + await render(); + }); +})(); diff --git a/extensions/diffkit-redirect/shared.js b/extensions/diffkit-redirect/shared.js new file mode 100644 index 0000000..f3f4352 --- /dev/null +++ b/extensions/diffkit-redirect/shared.js @@ -0,0 +1,400 @@ +(function initDiffKitRedirectShared(global) { + const extensionApi = global.chrome ?? global.browser; + + const STORAGE_KEYS = { + enabled: "enabled", + rules: "rules", + }; + + const DEFAULT_RULES = [ + { + id: "github-overview", + label: "Overview page", + description: "Redirect the GitHub overview page to DiffKit.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/?(?:[?#].*)?$", + }, + redirect: { + url: "https://diff-kit.com/", + }, + }, + { + id: "github-pulls", + label: "Pulls list", + description: "Redirect the GitHub pulls page to DiffKit pulls.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/pulls(?:/.*)?(?:[?#].*)?$", + }, + redirect: { + url: "https://diff-kit.com/pulls", + }, + }, + { + id: "github-global-issues", + label: "Issues list", + description: "Redirect GitHub global issues pages to DiffKit issues.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/issues(?:/.*)?(?:[?#].*)?$", + }, + redirect: { + url: "https://diff-kit.com/issues", + }, + }, + { + id: "github-pull-details", + label: "Pull request details", + description: "Redirect GitHub pull request detail pages to DiffKit.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/([^/?#]+)/([^/?#]+)/pull/(\\d+)/?$", + }, + redirect: { + replacement: "https://diff-kit.com/$1/$2/pull/$3", + }, + }, + { + id: "github-review-details", + label: "Code review page", + description: "Redirect GitHub PR changes pages to DiffKit review pages.", + enabled: true, + match: { + urlRegex: + "^https://github\\.com/([^/?#]+)/([^/?#]+)/pull/(\\d+)/changes/?$", + }, + redirect: { + replacement: "https://diff-kit.com/$1/$2/review/$3", + }, + }, + { + id: "github-issue-details", + label: "Issue details", + description: "Redirect GitHub issue detail pages to DiffKit.", + enabled: true, + match: { + urlRegex: "^https://github\\.com/([^/?#]+)/([^/?#]+)/issues/(\\d+)/?$", + }, + redirect: { + replacement: "https://diff-kit.com/$1/$2/issues/$3", + }, + }, + ]; + + function deepClone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function callApi(method, ...args) { + return new Promise((resolve, reject) => { + let settled = false; + const callback = (result) => { + if (settled) { + return; + } + + settled = true; + const runtimeError = extensionApi.runtime?.lastError; + if (runtimeError) { + reject(new Error(runtimeError.message)); + return; + } + resolve(result); + }; + + try { + const maybePromise = method(...args, callback); + if (maybePromise && typeof maybePromise.then === "function") { + maybePromise.then( + (result) => { + if (settled) { + return; + } + settled = true; + resolve(result); + }, + (error) => { + if (settled) { + return; + } + settled = true; + reject(error); + } + ); + } + } catch (error) { + reject(error); + } + }); + } + + function storageGet(keys) { + return callApi( + extensionApi.storage.sync.get.bind(extensionApi.storage.sync), + keys + ); + } + + function storageSet(values) { + return callApi( + extensionApi.storage.sync.set.bind(extensionApi.storage.sync), + values + ); + } + + function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; + } + + function normalizeStringArray(value) { + if (!Array.isArray(value)) { + return []; + } + + return value.filter(isNonEmptyString); + } + + function normalizeRule(rawRule, index) { + const fallbackId = `rule-${index + 1}`; + const rule = { + id: isNonEmptyString(rawRule?.id) ? rawRule.id.trim() : fallbackId, + label: isNonEmptyString(rawRule?.label) ? rawRule.label.trim() : "", + description: isNonEmptyString(rawRule?.description) + ? rawRule.description.trim() + : "", + enabled: rawRule?.enabled !== false, + match: { + exactUrl: isNonEmptyString(rawRule?.match?.exactUrl) + ? rawRule.match.exactUrl.trim() + : "", + urlRegex: isNonEmptyString(rawRule?.match?.urlRegex) + ? rawRule.match.urlRegex.trim() + : "", + excludeUrlRegexes: normalizeStringArray( + rawRule?.match?.excludeUrlRegexes + ).map((entry) => entry.trim()), + }, + redirect: { + url: isNonEmptyString(rawRule?.redirect?.url) + ? rawRule.redirect.url.trim() + : "", + replacement: isNonEmptyString(rawRule?.redirect?.replacement) + ? rawRule.redirect.replacement.trim() + : "", + }, + }; + + return rule; + } + + function isValidUrl(value) { + try { + new URL(value); + return true; + } catch { + return false; + } + } + + function isValidRegex(value) { + try { + new RegExp(value); + return true; + } catch { + return false; + } + } + + function validateMatch(rule) { + const errors = []; + + if (!(rule.match.exactUrl || rule.match.urlRegex)) { + errors.push( + `Rule "${rule.id}" needs either match.exactUrl or match.urlRegex.` + ); + } + + if (rule.match.exactUrl && !isValidUrl(rule.match.exactUrl)) { + errors.push(`Rule "${rule.id}" has an invalid match.exactUrl.`); + } + + if (rule.match.urlRegex && !isValidRegex(rule.match.urlRegex)) { + errors.push(`Rule "${rule.id}" has an invalid match.urlRegex.`); + } + + for (const excludePattern of rule.match.excludeUrlRegexes) { + if (!isValidRegex(excludePattern)) { + errors.push( + `Rule "${rule.id}" has an invalid exclude regex "${excludePattern}".` + ); + } + } + + return errors; + } + + function validateRedirect(rule) { + const errors = []; + + if (!(rule.redirect.url || rule.redirect.replacement)) { + errors.push( + `Rule "${rule.id}" needs either redirect.url or redirect.replacement.` + ); + } + + if (rule.redirect.url && !isValidUrl(rule.redirect.url)) { + errors.push(`Rule "${rule.id}" has an invalid redirect.url.`); + } + + return errors; + } + + function validateRule(rule, seenIds) { + const errors = []; + + if (seenIds.has(rule.id)) { + errors.push(`Duplicate rule id "${rule.id}".`); + } + seenIds.add(rule.id); + + return errors.concat(validateMatch(rule), validateRedirect(rule)); + } + + function validateRules(candidateRules) { + if (!Array.isArray(candidateRules)) { + return { + ok: false, + errors: ["Rules must be a JSON array."], + rules: [], + }; + } + + const rules = candidateRules.map(normalizeRule); + const seenIds = new Set(); + const errors = rules.flatMap((rule) => validateRule(rule, seenIds)); + + return { + ok: errors.length === 0, + errors, + rules, + }; + } + + function getDefaultRules() { + return deepClone(DEFAULT_RULES); + } + + async function getConfig() { + const stored = await storageGet({ + [STORAGE_KEYS.enabled]: true, + [STORAGE_KEYS.rules]: getDefaultRules(), + }); + + const validation = validateRules(stored[STORAGE_KEYS.rules]); + return { + enabled: stored[STORAGE_KEYS.enabled] !== false, + rules: validation.ok ? validation.rules : getDefaultRules(), + validationErrors: validation.ok ? [] : validation.errors, + }; + } + + async function saveConfig(config) { + const validation = validateRules(config.rules); + if (!validation.ok) { + return { + ok: false, + errors: validation.errors, + }; + } + + await storageSet({ + [STORAGE_KEYS.enabled]: config.enabled !== false, + [STORAGE_KEYS.rules]: validation.rules, + }); + + return { + ok: true, + errors: [], + }; + } + + function expandReplacement(template, match) { + return template.replace( + /\$(\d+)/g, + (_, index) => match[Number(index)] ?? "" + ); + } + + function isRuleExcluded(urlString, rule) { + return rule.match.excludeUrlRegexes.some((excludePattern) => + new RegExp(excludePattern).test(urlString) + ); + } + + function buildRedirectResult(rule, targetUrl, urlString) { + if (!targetUrl || targetUrl === urlString) { + return null; + } + + return { + rule, + targetUrl, + }; + } + + function matchExactRedirect(urlString, rule) { + if (!(rule.match.exactUrl && urlString === rule.match.exactUrl)) { + return null; + } + + return buildRedirectResult(rule, rule.redirect.url, urlString); + } + + function matchRegexRedirect(urlString, rule) { + if (!rule.match.urlRegex) { + return null; + } + + const regex = new RegExp(rule.match.urlRegex); + const match = urlString.match(regex); + if (!match) { + return null; + } + + const targetUrl = + rule.redirect.url || expandReplacement(rule.redirect.replacement, match); + return buildRedirectResult(rule, targetUrl, urlString); + } + + function findRedirectForRule(urlString, rawRule) { + const rule = normalizeRule(rawRule, 0); + if (!rule.enabled || isRuleExcluded(urlString, rule)) { + return null; + } + + return ( + matchExactRedirect(urlString, rule) || matchRegexRedirect(urlString, rule) + ); + } + + function findRedirect(urlString, rules) { + for (const rawRule of rules) { + const redirect = findRedirectForRule(urlString, rawRule); + if (redirect) { + return redirect; + } + } + + return null; + } + + global.DiffKitRedirect = { + STORAGE_KEYS, + getConfig, + getDefaultRules, + saveConfig, + validateRules, + findRedirect, + }; +})(globalThis); diff --git a/extensions/diffkit-redirect/styles.css b/extensions/diffkit-redirect/styles.css new file mode 100644 index 0000000..8d93010 --- /dev/null +++ b/extensions/diffkit-redirect/styles.css @@ -0,0 +1,425 @@ +:root { + --background: #ffffff; + --foreground: oklch(0.141 0.005 285.823); + --card: #ffffff; + --card-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.871 0.006 286.286); + --brand: oklch(0.68 0.2 150); + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06); + --shadow-md: 0 12px 32px rgba(16, 24, 40, 0.08); + font-family: + "Inter Variable", "Inter", "Avenir Next", ui-sans-serif, system-ui, + sans-serif; + color: var(--foreground); + color-scheme: light; + background: var(--background); +} + +* { + box-sizing: border-box; +} + +body { + min-width: 360px; + margin: 0; + font-size: 13px; + font-weight: 450; + color: var(--foreground); + background: + radial-gradient( + circle at top right, + color-mix(in srgb, var(--brand) 12%, transparent), + transparent 30% + ), + linear-gradient(180deg, #ffffff 0%, #fafafa 100%); +} + +button, +textarea, +input { + font: inherit; +} + +button { + padding: 0.625rem 0.875rem; + font-size: 13px; + font-weight: 500; + color: var(--primary-foreground); + letter-spacing: -0.01em; + cursor: pointer; + background: var(--primary); + border: 1px solid transparent; + border-radius: 10px; + box-shadow: var(--shadow-sm); + transition: + background-color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease; +} + +button.secondary { + color: var(--secondary-foreground); + background: var(--secondary); + border-color: var(--border); +} + +button:hover { + background: color-mix(in srgb, var(--primary) 92%, white); +} + +button.secondary:hover { + background: color-mix(in srgb, var(--secondary) 88%, white); +} + +button:focus-visible, +textarea:focus-visible, +input:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 55%, transparent); +} + +code, +pre { + font-family: + "SFMono-Regular", ui-monospace, "Cascadia Code", "Source Code Pro", + monospace; +} + +.popup-body { + --background: #141518; + --foreground: oklch(0.965 0.003 286); + --card: #16171a; + --card-foreground: oklch(0.965 0.003 286); + --primary: oklch(0.965 0.003 286); + --primary-foreground: #121316; + --secondary: #202227; + --secondary-foreground: oklch(0.86 0.01 286); + --muted: #1a1c20; + --muted-foreground: oklch(0.7 0.012 286); + --border: rgba(255, 255, 255, 0.08); + --input: #2b2e34; + --ring: rgba(255, 255, 255, 0.22); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 16px 40px rgba(0, 0, 0, 0.42); + min-width: 400px; + color-scheme: dark; + background: #141518; +} + +.popup-shell { + padding: 0; +} + +.popup-card, +.options-panel, +.help-panel { + background: color-mix(in srgb, var(--card) 96%, transparent); + border: 1px solid var(--border); + border-radius: 18px; + box-shadow: var(--shadow-md); +} + +.popup-card { + padding: 12px; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.brand-row, +.options-header, +.toggle-row, +.panel-actions, +.status-row { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +.panel-actions { + margin-top: 16px; +} + +.eyebrow { + margin: 0 0 4px; + font-family: + "Geist Mono Variable", "SF Mono", ui-monospace, "Cascadia Code", monospace; + font-size: 11px; + font-weight: 500; + color: var(--muted-foreground); + letter-spacing: 0.06em; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.02em; +} + +h2 { + margin-bottom: 10px; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.muted, +.lede { + margin-top: 12px; + line-height: 1.55; + color: var(--muted-foreground); +} + +.lede { + max-width: 34ch; + text-wrap: pretty; +} + +.rule-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + margin-top: 12px; +} + +.empty-state { + grid-column: 1 / -1; + padding: 12px; + margin: 0; + line-height: 1.5; + color: var(--muted-foreground); + border: 1px dashed var(--border); + border-radius: 14px; +} + +.rule-toggle { + display: flex; + gap: 6px; + align-items: flex-start; + width: 100%; + min-height: 44px; + padding: 7px 8px; + color: var(--muted-foreground); + text-align: left; + background: var(--secondary); + border: 1px solid transparent; + border-radius: 12px; + box-shadow: none; + transition: + background-color 120ms ease, + color 120ms ease, + border-color 120ms ease, + transform 120ms ease; +} + +.rule-toggle:hover { + color: var(--foreground); + background: color-mix(in srgb, var(--secondary) 92%, white 4%); +} + +.rule-toggle[aria-pressed="true"] { + color: #ffffff; + background: color-mix(in srgb, var(--foreground) 14%, #0f1012); +} + +.rule-copy { + position: relative; + display: grid; + gap: 1px; + width: 100%; + min-width: 0; + padding-left: 28px; +} + +.rule-copy::before { + position: absolute; + top: 0.0625rem; + left: 0; + width: 20px; + height: 20px; + content: ""; + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); +} + +.rule-copy::after { + position: absolute; + top: 0.4375rem; + left: 7px; + width: 6px; + height: 6px; + content: ""; + background: currentColor; + border-radius: 999px; + opacity: 0.8; +} + +.rule-toggle[aria-pressed="true"] .rule-copy::before { + background: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} + +.rule-title { + font-size: 12px; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.01em; +} + +.rule-description { + font-family: + "Geist Mono Variable", "SF Mono", ui-monospace, "Cascadia Code", monospace; + font-size: 10px; + letter-spacing: 0.04em; + opacity: 0.72; +} + +.rule-toggle[aria-pressed="true"] .rule-description { + color: rgba(255, 255, 255, 0.72); +} + +.brand-lockup { + display: flex; + gap: 12px; + align-items: center; +} + +.brand-logo { + flex: none; + width: 42px; + height: 42px; + border-radius: 12px; + box-shadow: var(--shadow-sm); +} + +.popup-body button.secondary { + color: var(--foreground); + background: #1c1e23; + border-color: rgba(255, 255, 255, 0.08); +} + +.popup-body button.secondary:hover { + background: #24272d; +} + +.switch { + position: relative; + display: inline-flex; +} + +.switch input { + position: absolute; + inset: 0; + opacity: 0; +} + +.switch-track { + position: relative; + width: 36px; + height: 20px; + background: var(--input); + border-radius: 999px; + box-shadow: inset 0 0 0 1px var(--border); + transition: background 120ms ease; +} + +.switch-track::after { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + content: ""; + background: var(--background); + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + transition: transform 120ms ease; +} + +.switch input:checked + .switch-track { + background: var(--primary); + box-shadow: none; +} + +.switch input:checked + .switch-track::after { + transform: translateX(16px); +} + +.status-row { + padding-top: 14px; + margin-top: 16px; + border-top: 1px solid color-mix(in srgb, var(--border) 85%, transparent); +} + +.options-layout { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); + gap: 18px; + align-items: start; + padding: 24px; +} + +.options-panel, +.help-panel { + padding: 20px; +} + +.rules-editor { + width: 100%; + min-height: 420px; + padding: 16px; + margin-top: 18px; + line-height: 1.5; + color: #f5f5f5; + resize: vertical; + background: #101011; + border: 1px solid var(--border); + border-radius: 16px; +} + +.code-block { + padding: 16px; + margin: 0; + overflow: auto; + line-height: 1.5; + color: #f5f5f5; + background: #101011; + border-radius: 16px; +} + +.status-copy { + min-height: 20px; + line-height: 1.45; + color: var(--muted-foreground); +} + +.status-copy[data-tone="error"] { + color: oklch(0.577 0.245 27.325); +} + +.status-copy[data-tone="success"] { + color: color-mix(in srgb, var(--brand) 60%, black); +} + +@media (max-width: 900px) { + .options-layout { + grid-template-columns: 1fr; + } +}