diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..b22cc5a
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,44 @@
+#!/bin/zsh
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
+DIST_DIR="$ROOT_DIR/dist"
+
+mkdir -p "$DIST_DIR"
+
+# Clean old artifacts
+rm -f "$DIST_DIR"/*.zip 2>/dev/null || true
+
+# Copy source to temp dirs for packaging
+WORK_CHROME=$(mktemp -d)
+WORK_FIREFOX=$(mktemp -d)
+
+cleanup() {
+ rm -rf "$WORK_CHROME" "$WORK_FIREFOX" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+# Common files
+for f in manifest.json popup.html popup.js; do
+ cp "$ROOT_DIR/$f" "$WORK_CHROME/"
+ cp "$ROOT_DIR/$f" "$WORK_FIREFOX/"
+done
+
+# Ensure Firefox manifest has browser_specific_settings with a stable id
+# If already present, keep as-is.
+
+# Nothing to change for Chrome; MV3 is fine.
+
+pushd "$WORK_CHROME" >/dev/null
+zip -r "$DIST_DIR/ali-sharelink-chrome.zip" . >/dev/null
+popd >/dev/null
+
+pushd "$WORK_FIREFOX" >/dev/null
+zip -r "$DIST_DIR/ali-sharelink-firefox.zip" . >/dev/null
+popd >/dev/null
+
+echo "Build complete:"
+echo " Chrome: $DIST_DIR/ali-sharelink-chrome.zip"
+echo " Firefox: $DIST_DIR/ali-sharelink-firefox.zip"
+
+
diff --git a/dist/ali-sharelink-chrome.zip b/dist/ali-sharelink-chrome.zip
new file mode 100644
index 0000000..07c1803
Binary files /dev/null and b/dist/ali-sharelink-chrome.zip differ
diff --git a/dist/ali-sharelink-firefox.zip b/dist/ali-sharelink-firefox.zip
new file mode 100644
index 0000000..07c1803
Binary files /dev/null and b/dist/ali-sharelink-firefox.zip differ
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..488c0ed
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,26 @@
+{
+ "manifest_version": 3,
+ "name": "Ali Sharelink",
+ "version": "0.1.0",
+ "description": "Get a clean, canonical share link for AliExpress/Alibaba products, with optional shortlink.",
+ "action": {
+ "default_title": "Ali Sharelink",
+ "default_popup": "popup.html"
+ },
+ "permissions": [
+ "activeTab",
+ "tabs",
+ "clipboardWrite"
+ ],
+ "host_permissions": [
+ "https://is.gd/*"
+ ],
+ "icons": {},
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "alisharelink@local",
+ "strict_min_version": "109.0"
+ }
+ }
+}
+
diff --git a/popup.html b/popup.html
new file mode 100644
index 0000000..8a239d8
--- /dev/null
+++ b/popup.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ Ali Sharelink
+
+
+
+ Ali Sharelink
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/popup.js b/popup.js
new file mode 100644
index 0000000..ea8be71
--- /dev/null
+++ b/popup.js
@@ -0,0 +1,141 @@
+/* global chrome, browser */
+(function () {
+ const $ = (id) => document.getElementById(id);
+ const statusEl = $("status");
+
+ function setStatus(message, kind) {
+ statusEl.textContent = message || "";
+ statusEl.className = `row small ${kind === "error" ? "error" : kind === "ok" ? "ok" : "muted"}`;
+ }
+
+ function getApi() {
+ if (typeof chrome !== "undefined" && chrome.tabs) return chrome;
+ if (typeof browser !== "undefined" && browser.tabs) return browser;
+ throw new Error("Unsupported browser API");
+ }
+
+ function tabsQuery(api, queryInfo) {
+ // chrome: callback-based, firefox: promise-based
+ return new Promise((resolve, reject) => {
+ try {
+ const maybePromise = api.tabs.query(queryInfo, (tabs) => {
+ if (chrome && chrome.runtime && chrome.runtime.lastError) {
+ reject(chrome.runtime.lastError);
+ } else if (tabs) {
+ resolve(tabs);
+ }
+ });
+ if (maybePromise && typeof maybePromise.then === "function") {
+ maybePromise.then(resolve, reject);
+ }
+ } catch (e) {
+ reject(e);
+ }
+ });
+ }
+
+ function copyToClipboard(text) {
+ return navigator.clipboard.writeText(text);
+ }
+
+ function canonicalizeAliExpress(urlString) {
+ const url = new URL(urlString);
+ const host = url.hostname;
+ const path = url.pathname;
+
+ // Prefer global .com canonical
+ const canonicalHost = "www.aliexpress.com";
+ let productId = null;
+
+ const itemMatch = path.match(/\/(item|i)\/(\d+)\.html/i);
+ if (itemMatch) {
+ productId = itemMatch[2];
+ }
+ if (!productId) {
+ const xObjectId = url.searchParams.get("x_object_id") || url.searchParams.get("item_id");
+ if (xObjectId && /^\d+$/.test(xObjectId)) {
+ productId = xObjectId;
+ }
+ }
+
+ if (productId) {
+ return `https://${canonicalHost}/item/${productId}.html`;
+ }
+
+ // Fallback: strip params and hash
+ return `https://${canonicalHost}${path}`;
+ }
+
+ function canonicalizeAlibaba(urlString) {
+ const url = new URL(urlString);
+ // Keep origin + pathname only
+ return `${url.origin}${url.pathname}`;
+ }
+
+ function canonicalize(urlString) {
+ const url = new URL(urlString);
+ const hostname = url.hostname.toLowerCase();
+
+ if (hostname.includes("aliexpress.")) {
+ return { vendor: "AliExpress", url: canonicalizeAliExpress(urlString) };
+ }
+ if (hostname.includes("alibaba.")) {
+ return { vendor: "Alibaba", url: canonicalizeAlibaba(urlString) };
+ }
+ // Unknown: just strip params/hash
+ return { vendor: "Unknown", url: `${url.origin}${url.pathname}` };
+ }
+
+ async function shortenWithIsGd(longUrl) {
+ const endpoint = `https://is.gd/create.php?format=simple&url=${encodeURIComponent(longUrl)}`;
+ const res = await fetch(endpoint, { method: "GET" });
+ const text = await res.text();
+ if (!res.ok) throw new Error(text || `Shorten failed (${res.status})`);
+ if (!/^https?:\/\//i.test(text)) throw new Error(text || "Shorten failed");
+ return text.trim();
+ }
+
+ async function init() {
+ setStatus("Reading current tab…");
+ const api = getApi();
+ try {
+ const tabs = await tabsQuery(api, { active: true, currentWindow: true });
+ const active = tabs && tabs[0];
+ if (!active || !active.url) {
+ setStatus("No active tab URL found", "error");
+ return;
+ }
+ $("currentUrl").value = active.url;
+
+ const { vendor, url } = canonicalize(active.url);
+ $("cleanUrl").value = url;
+ setStatus(`Detected ${vendor}. Clean link ready.`, "ok");
+
+ $("copyClean").addEventListener("click", async () => {
+ try {
+ await copyToClipboard($("cleanUrl").value);
+ setStatus("Clean link copied.", "ok");
+ } catch (e) {
+ setStatus(`Copy failed: ${e.message}`, "error");
+ }
+ });
+
+ $("shortenCopy").addEventListener("click", async () => {
+ setStatus("Shortening with is.gd…");
+ try {
+ const shortUrl = await shortenWithIsGd($("cleanUrl").value);
+ await copyToClipboard(shortUrl);
+ setStatus("Short link copied.", "ok");
+ } catch (e) {
+ setStatus(`Shorten failed: ${e.message}`, "error");
+ }
+ });
+ } catch (e) {
+ setStatus(`Error: ${e.message}`, "error");
+ }
+ }
+
+ document.addEventListener("DOMContentLoaded", init);
+})();
+
+