提交0.1.0版本

- 完成了书签的基本功能和插件
This commit is contained in:
2026-01-21 23:09:33 +08:00
parent 3e2d1456eb
commit 1a3bbac9ff
95 changed files with 12431 additions and 12445 deletions

View File

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

View File

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

View File

@@ -1,77 +1,77 @@
// 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 normText(s) {
return String(s || "").replace(/\s+/g, " ").trim();
}
// Collect <DT> nodes that belong to the current <DL> level.
// Chrome/Edge exported HTML often uses `<DL><p>` and browsers may wrap
// subsequent nodes under <p> or other wrapper elements.
function collectLevelDt(container) {
const out = [];
const els = Array.from(container.children || []);
for (const el of els) {
const tag = el.tagName?.toLowerCase();
if (!tag) continue;
if (tag === "dt") {
out.push(el);
continue;
}
if (tag === "dl") {
// nested list belongs to the previous <DT>
continue;
}
out.push(...collectLevelDt(el));
}
return out;
}
// Find the nested <DL> that belongs to a <DT>, even if <DT> is wrapped (e.g. inside <p>).
function findNextDlForDt(dt, stopDl) {
let cur = dt;
while (cur && cur !== stopDl) {
const next = cur.nextElementSibling;
if (next && next.tagName?.toLowerCase() === "dl") return next;
cur = cur.parentElement;
}
return null;
}
function walkDl(dl, parentFolderId) {
const dts = collectLevelDt(dl);
for (const node of dts) {
const h3 = node.querySelector("h3");
const a = node.querySelector("a");
const nestedDl = node.querySelector("dl");
const nextDl = nestedDl || findNextDlForDt(node, dl);
if (h3) {
const id = String(folderIdSeq++);
const name = normText(h3.textContent || "");
folders.push({ id, parentFolderId: parentFolderId ?? null, name });
if (nextDl) walkDl(nextDl, id);
} else if (a) {
const title = normText(a.textContent || "");
const url = a.getAttribute("href") || "";
bookmarks.push({ parentFolderId: parentFolderId ?? null, title, url });
}
}
}
walkDl(rootDl, null);
return { folders, bookmarks };
}
// 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 normText(s) {
return String(s || "").replace(/\s+/g, " ").trim();
}
// Collect <DT> nodes that belong to the current <DL> level.
// Chrome/Edge exported HTML often uses `<DL><p>` and browsers may wrap
// subsequent nodes under <p> or other wrapper elements.
function collectLevelDt(container) {
const out = [];
const els = Array.from(container.children || []);
for (const el of els) {
const tag = el.tagName?.toLowerCase();
if (!tag) continue;
if (tag === "dt") {
out.push(el);
continue;
}
if (tag === "dl") {
// nested list belongs to the previous <DT>
continue;
}
out.push(...collectLevelDt(el));
}
return out;
}
// Find the nested <DL> that belongs to a <DT>, even if <DT> is wrapped (e.g. inside <p>).
function findNextDlForDt(dt, stopDl) {
let cur = dt;
while (cur && cur !== stopDl) {
const next = cur.nextElementSibling;
if (next && next.tagName?.toLowerCase() === "dl") return next;
cur = cur.parentElement;
}
return null;
}
function walkDl(dl, parentFolderId) {
const dts = collectLevelDt(dl);
for (const node of dts) {
const h3 = node.querySelector("h3");
const a = node.querySelector("a");
const nestedDl = node.querySelector("dl");
const nextDl = nestedDl || findNextDlForDt(node, dl);
if (h3) {
const id = String(folderIdSeq++);
const name = normText(h3.textContent || "");
folders.push({ id, parentFolderId: parentFolderId ?? null, name });
if (nextDl) walkDl(nextDl, id);
} else if (a) {
const title = normText(a.textContent || "");
const url = a.getAttribute("href") || "";
bookmarks.push({ parentFolderId: parentFolderId ?? null, title, url });
}
}
}
walkDl(rootDl, null);
return { folders, bookmarks };
}

View File

@@ -1,49 +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";
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

@@ -1,4 +1,4 @@
{
"private": true,
"type": "module"
}
{
"private": true,
"type": "module"
}