初始化项目

This commit is contained in:
2026-01-18 10:35:27 +08:00
parent 85042841ae
commit 00ca4c1b0d
116 changed files with 11569 additions and 2 deletions

1
packages/shared/index.js Normal file
View File

@@ -0,0 +1 @@
export * from "./src/index.js";

View File

@@ -0,0 +1,9 @@
{
"name": "@browser-bookmark/shared",
"private": true,
"type": "module",
"version": "0.1.0",
"exports": {
".": "./index.js"
}
}

View File

@@ -0,0 +1,41 @@
// Netscape Bookmark file format parser (Chrome/Edge export)
// Parses <DL>/<DT><H3> folders and <DT><A> bookmarks.
export function parseNetscapeBookmarkHtml(html) {
// Minimal, dependency-free parser using DOMParser (works in browsers).
// For Node.js, server will use a separate HTML parser.
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const rootDl = doc.querySelector("dl");
if (!rootDl) return { folders: [], bookmarks: [] };
const folders = [];
const bookmarks = [];
let folderIdSeq = 1;
function walkDl(dl, parentFolderId) {
const children = Array.from(dl.children);
for (const node of children) {
if (node.tagName?.toLowerCase() !== "dt") continue;
const h3 = node.querySelector(":scope > h3");
const a = node.querySelector(":scope > a");
const nextDl = node.nextElementSibling?.tagName?.toLowerCase() === "dl" ? node.nextElementSibling : null;
if (h3) {
const id = String(folderIdSeq++);
const name = (h3.textContent || "").trim();
folders.push({ id, parentFolderId: parentFolderId ?? null, name });
if (nextDl) walkDl(nextDl, id);
} else if (a) {
const title = (a.textContent || "").trim();
const url = a.getAttribute("href") || "";
bookmarks.push({ parentFolderId: parentFolderId ?? null, title, url });
}
}
}
walkDl(rootDl, null);
return { folders, bookmarks };
}

View File

@@ -0,0 +1,49 @@
export function normalizeUrl(input) {
try {
const url = new URL(input);
url.hash = "";
url.protocol = url.protocol.toLowerCase();
url.hostname = url.hostname.toLowerCase();
// Remove default ports
if ((url.protocol === "http:" && url.port === "80") || (url.protocol === "https:" && url.port === "443")) {
url.port = "";
}
// Trim trailing slash on pathname (but keep root '/')
if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
url.pathname = url.pathname.slice(0, -1);
}
// Drop common tracking params
const trackingPrefixes = ["utm_", "spm", "gclid", "fbclid"];
for (const key of [...url.searchParams.keys()]) {
const lowerKey = key.toLowerCase();
if (trackingPrefixes.some((p) => lowerKey.startsWith(p))) {
url.searchParams.delete(key);
}
}
// Sort params for stable output
const sorted = [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b));
url.search = "";
for (const [k, v] of sorted) url.searchParams.append(k, v);
return url.toString();
} catch {
return input;
}
}
export function computeUrlHash(normalizedUrl) {
// Lightweight hash (non-crypto) for dedupe key; server may replace with crypto later.
let hash = 2166136261;
for (let i = 0; i < normalizedUrl.length; i++) {
hash ^= normalizedUrl.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16);
}
export { parseNetscapeBookmarkHtml } from "./bookmarkHtml.js";

View File

@@ -0,0 +1,3 @@
{
"private": true
}