+54
| export function createCache(storage) { | ||
| return { | ||
| async get(url) { | ||
| const key = cacheKey(url); | ||
| const cached = await storage.get(key); | ||
| if (!cached) return null; | ||
| const data = typeof cached === "string" ? JSON.parse(cached) : cached; | ||
| if (data.expires && Date.now() > data.expires) { | ||
| await storage.delete?.(key); | ||
| return null; | ||
| } | ||
| return data.value; | ||
| }, | ||
| async set(url, value, ttl = 3600000) { | ||
| const key = cacheKey(url); | ||
| const data = { | ||
| value, | ||
| expires: ttl > 0 ? Date.now() + ttl : 0, | ||
| }; | ||
| await storage.set(key, JSON.stringify(data)); | ||
| }, | ||
| async delete(url) { | ||
| await storage.delete?.(cacheKey(url)); | ||
| }, | ||
| }; | ||
| } | ||
| export function cacheKey(url) { | ||
| return `openlink:${url}`; | ||
| } | ||
| export function memoryCache() { | ||
| const store = new Map(); | ||
| return { | ||
| get: (key) => store.get(key), | ||
| set: (key, value) => store.set(key, value), | ||
| delete: (key) => store.delete(key), | ||
| clear: () => store.clear(), | ||
| }; | ||
| } | ||
| export function withCache(cache, previewFn) { | ||
| return async (url, options = {}) => { | ||
| const cached = await cache.get(url); | ||
| if (cached) return cached; | ||
| const result = await previewFn(url, options); | ||
| await cache.set(url, result, options.cacheTtl); | ||
| return result; | ||
| }; | ||
| } |
+116
| export async function getImageSize(url, options = {}) { | ||
| const fetchFn = options.fetch || globalThis.fetch; | ||
| const controller = new AbortController(); | ||
| const timeout = options.timeout || 5000; | ||
| const timer = setTimeout(() => controller.abort(), timeout); | ||
| try { | ||
| const response = await fetchFn(url, { | ||
| method: "GET", | ||
| signal: controller.signal, | ||
| headers: { | ||
| Range: "bytes=0-65535", | ||
| }, | ||
| }); | ||
| if (!response.ok) return null; | ||
| const buffer = await response.arrayBuffer(); | ||
| const bytes = new Uint8Array(buffer); | ||
| return detectSize(bytes); | ||
| } catch { | ||
| return null; | ||
| } finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| function detectSize(bytes) { | ||
| if (isPng(bytes)) return parsePng(bytes); | ||
| if (isJpeg(bytes)) return parseJpeg(bytes); | ||
| if (isGif(bytes)) return parseGif(bytes); | ||
| if (isWebp(bytes)) return parseWebp(bytes); | ||
| return null; | ||
| } | ||
| function isPng(bytes) { | ||
| return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47; | ||
| } | ||
| function isJpeg(bytes) { | ||
| return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; | ||
| } | ||
| function isGif(bytes) { | ||
| return bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38; | ||
| } | ||
| function isWebp(bytes) { | ||
| return ( | ||
| bytes[0] === 0x52 && | ||
| bytes[1] === 0x49 && | ||
| bytes[2] === 0x46 && | ||
| bytes[3] === 0x46 && | ||
| bytes[8] === 0x57 && | ||
| bytes[9] === 0x45 && | ||
| bytes[10] === 0x42 && | ||
| bytes[11] === 0x50 | ||
| ); | ||
| } | ||
| function parsePng(bytes) { | ||
| if (bytes.length < 24) return null; | ||
| const width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; | ||
| const height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; | ||
| return { width, height, type: "png" }; | ||
| } | ||
| function parseJpeg(bytes) { | ||
| let offset = 2; | ||
| while (offset < bytes.length - 8) { | ||
| if (bytes[offset] !== 0xff) return null; | ||
| const marker = bytes[offset + 1]; | ||
| if (marker === 0xc0 || marker === 0xc2) { | ||
| const height = (bytes[offset + 5] << 8) | bytes[offset + 6]; | ||
| const width = (bytes[offset + 7] << 8) | bytes[offset + 8]; | ||
| return { width, height, type: "jpeg" }; | ||
| } | ||
| const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; | ||
| offset += 2 + length; | ||
| } | ||
| return null; | ||
| } | ||
| function parseGif(bytes) { | ||
| if (bytes.length < 10) return null; | ||
| const width = bytes[6] | (bytes[7] << 8); | ||
| const height = bytes[8] | (bytes[9] << 8); | ||
| return { width, height, type: "gif" }; | ||
| } | ||
| function parseWebp(bytes) { | ||
| if (bytes.length < 30) return null; | ||
| if (bytes[12] === 0x56 && bytes[13] === 0x50 && bytes[14] === 0x38) { | ||
| if (bytes[15] === 0x20) { | ||
| const width = ((bytes[26] | (bytes[27] << 8)) & 0x3fff) + 1; | ||
| const height = ((bytes[28] | (bytes[29] << 8)) & 0x3fff) + 1; | ||
| return { width, height, type: "webp" }; | ||
| } | ||
| if (bytes[15] === 0x4c) { | ||
| const bits = bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24); | ||
| const width = (bits & 0x3fff) + 1; | ||
| const height = ((bits >> 14) & 0x3fff) + 1; | ||
| return { width, height, type: "webp" }; | ||
| } | ||
| if (bytes[15] === 0x58) { | ||
| const width = (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1; | ||
| const height = (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)) + 1; | ||
| return { width, height, type: "webp" }; | ||
| } | ||
| } | ||
| return null; | ||
| } |
+144
| const pattern = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; | ||
| export function parseJsonLd(html) { | ||
| const results = []; | ||
| let match = pattern.exec(html); | ||
| while (match !== null) { | ||
| try { | ||
| const data = JSON.parse(match[1].trim()); | ||
| if (Array.isArray(data)) { | ||
| results.push(...data); | ||
| } else { | ||
| results.push(data); | ||
| } | ||
| } catch {} | ||
| match = pattern.exec(html); | ||
| } | ||
| pattern.lastIndex = 0; | ||
| return results; | ||
| } | ||
| export function extractJsonLd(items) { | ||
| if (!items.length) return null; | ||
| /** @type {{ types: string[], data: any[], article?: any, product?: any, organization?: any, video?: any, breadcrumbs?: any }} */ | ||
| const result = { | ||
| types: [], | ||
| data: items, | ||
| }; | ||
| for (const item of items) { | ||
| const type = item["@type"]; | ||
| if (type) { | ||
| const types = Array.isArray(type) ? type : [type]; | ||
| for (const t of types) { | ||
| if (!result.types.includes(t)) { | ||
| result.types.push(t); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| const article = items.find( | ||
| (i) => i["@type"] === "Article" || i["@type"] === "NewsArticle" || i["@type"] === "BlogPosting", | ||
| ); | ||
| if (article) { | ||
| result.article = { | ||
| headline: article.headline || null, | ||
| description: article.description || null, | ||
| author: extractAuthor(article.author), | ||
| publisher: extractPublisher(article.publisher), | ||
| datePublished: article.datePublished || null, | ||
| dateModified: article.dateModified || null, | ||
| image: extractImage(article.image), | ||
| }; | ||
| } | ||
| const product = items.find((i) => i["@type"] === "Product"); | ||
| if (product) { | ||
| result.product = { | ||
| name: product.name || null, | ||
| description: product.description || null, | ||
| image: extractImage(product.image), | ||
| brand: product.brand?.name || product.brand || null, | ||
| price: extractPrice(product.offers), | ||
| rating: extractRating(product.aggregateRating), | ||
| }; | ||
| } | ||
| const org = items.find((i) => i["@type"] === "Organization"); | ||
| if (org) { | ||
| result.organization = { | ||
| name: org.name || null, | ||
| url: org.url || null, | ||
| logo: extractImage(org.logo), | ||
| }; | ||
| } | ||
| const video = items.find((i) => i["@type"] === "VideoObject"); | ||
| if (video) { | ||
| result.video = { | ||
| name: video.name || null, | ||
| description: video.description || null, | ||
| thumbnail: extractImage(video.thumbnailUrl), | ||
| duration: video.duration || null, | ||
| uploadDate: video.uploadDate || null, | ||
| }; | ||
| } | ||
| const breadcrumbs = items.find((i) => i["@type"] === "BreadcrumbList"); | ||
| if (breadcrumbs?.itemListElement) { | ||
| result.breadcrumbs = breadcrumbs.itemListElement | ||
| .sort((a, b) => (a.position || 0) - (b.position || 0)) | ||
| .map((item) => ({ | ||
| name: item.name || item.item?.name || null, | ||
| url: item.item?.["@id"] || item.item || null, | ||
| })); | ||
| } | ||
| return result; | ||
| } | ||
| function extractAuthor(author) { | ||
| if (!author) return null; | ||
| if (typeof author === "string") return author; | ||
| if (Array.isArray(author)) return author.map((a) => a.name || a).join(", "); | ||
| return author.name || null; | ||
| } | ||
| function extractPublisher(publisher) { | ||
| if (!publisher) return null; | ||
| return { | ||
| name: publisher.name || null, | ||
| logo: extractImage(publisher.logo), | ||
| }; | ||
| } | ||
| function extractImage(image) { | ||
| if (!image) return null; | ||
| if (typeof image === "string") return image; | ||
| if (Array.isArray(image)) return image[0]?.url || image[0] || null; | ||
| return image.url || image["@id"] || null; | ||
| } | ||
| function extractPrice(offers) { | ||
| if (!offers) return null; | ||
| const offer = Array.isArray(offers) ? offers[0] : offers; | ||
| if (!offer) return null; | ||
| return { | ||
| amount: offer.price || null, | ||
| currency: offer.priceCurrency || null, | ||
| availability: offer.availability?.replace("https://schema.org/", "") || null, | ||
| }; | ||
| } | ||
| function extractRating(rating) { | ||
| if (!rating) return null; | ||
| return { | ||
| value: rating.ratingValue || null, | ||
| count: rating.reviewCount || rating.ratingCount || null, | ||
| }; | ||
| } |
+14
| export function createProxyFetch(proxyUrl, baseFetch = globalThis.fetch) { | ||
| return async (url, options = {}) => { | ||
| const proxied = proxyUrl.replace("{url}", encodeURIComponent(url)); | ||
| return baseFetch(proxied, options); | ||
| }; | ||
| } | ||
| export function corsProxy(url) { | ||
| return `https://corsproxy.io/?${encodeURIComponent(url)}`; | ||
| } | ||
| export function allOriginsProxy(url) { | ||
| return `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`; | ||
| } |
+37
| export async function withRetry(fn, options = {}) { | ||
| const { retries = 3, delay = 1000, backoff = 2, shouldRetry = () => true } = options; | ||
| let lastError; | ||
| let currentDelay = delay; | ||
| for (let attempt = 0; attempt <= retries; attempt++) { | ||
| try { | ||
| return await fn(attempt); | ||
| } catch (error) { | ||
| lastError = error; | ||
| if (attempt === retries || !shouldRetry(error, attempt)) { | ||
| throw error; | ||
| } | ||
| await sleep(currentDelay); | ||
| currentDelay *= backoff; | ||
| } | ||
| } | ||
| throw lastError; | ||
| } | ||
| function sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
| export function isRetryable(error) { | ||
| if (error.code === "TIMEOUT") return true; | ||
| if (error.code === "FETCH_ERROR") return true; | ||
| if (error.code === "HTTP_ERROR") { | ||
| const status = error.status; | ||
| return status === 429 || status >= 500; | ||
| } | ||
| return false; | ||
| } |
+43
-37
| { | ||
| "name": "openlink", | ||
| "version": "0.2.0", | ||
| "description": "Edge-first link preview. Zero dependencies.", | ||
| "type": "module", | ||
| "main": "src/index.js", | ||
| "exports": { | ||
| ".": { | ||
| "import": "./src/index.js", | ||
| "types": "./src/index.d.ts" | ||
| } | ||
| }, | ||
| "files": [ | ||
| "src" | ||
| ], | ||
| "scripts": { | ||
| "test": "node test.js" | ||
| }, | ||
| "keywords": [ | ||
| "link", | ||
| "preview", | ||
| "unfurl", | ||
| "opengraph", | ||
| "twitter-cards", | ||
| "oembed", | ||
| "youtube", | ||
| "meta", | ||
| "edge", | ||
| "cloudflare", | ||
| "workers" | ||
| ], | ||
| "author": "josh", | ||
| "license": "MIT", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/visible/openlink.git" | ||
| }, | ||
| "homepage": "https://openlink.sh" | ||
| "name": "openlink", | ||
| "version": "0.3.0", | ||
| "description": "Edge-first link preview. Zero dependencies.", | ||
| "type": "module", | ||
| "main": "src/index.js", | ||
| "types": "src/index.d.ts", | ||
| "exports": { | ||
| ".": { | ||
| "types": "./src/index.d.ts", | ||
| "import": "./src/index.js", | ||
| "default": "./src/index.js" | ||
| } | ||
| }, | ||
| "sideEffects": false, | ||
| "files": ["src"], | ||
| "engines": { | ||
| "node": ">=18" | ||
| }, | ||
| "scripts": { | ||
| "test": "node --test tests/*.test.js", | ||
| "test:live": "node test.js", | ||
| "prepublishOnly": "npm test" | ||
| }, | ||
| "keywords": [ | ||
| "link", | ||
| "preview", | ||
| "unfurl", | ||
| "opengraph", | ||
| "twitter-cards", | ||
| "oembed", | ||
| "youtube", | ||
| "meta", | ||
| "edge", | ||
| "cloudflare", | ||
| "workers" | ||
| ], | ||
| "author": "josh", | ||
| "license": "MIT", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/visible/openlink.git" | ||
| }, | ||
| "homepage": "https://openlink.sh" | ||
| } |
+41
-2
| # openlink | ||
| Edge-first link preview. Zero dependencies, ~2kb gzipped. | ||
| Edge-first link preview. Zero dependencies, ~6kb gzipped. | ||
@@ -15,3 +15,3 @@ ```bash | ||
| Returns `{ url, title, description, image, favicon, siteName, domain, type }` | ||
| Returns `{ url, title, description, image, favicon, siteName, domain, type, contentType, lang, ... }` | ||
@@ -30,2 +30,41 @@ ## oEmbed | ||
| ## JSON-LD | ||
| ```js | ||
| const data = await preview('https://bbc.com/news', { | ||
| includeJsonLd: true | ||
| }) | ||
| console.log(data.jsonLd) // { types, article, product, organization, ... } | ||
| ``` | ||
| ## Retry | ||
| ```js | ||
| const data = await preview('https://example.com', { | ||
| retry: 3, | ||
| retryDelay: 1000 | ||
| }) | ||
| ``` | ||
| ## Cache | ||
| ```js | ||
| import { createCache, memoryCache, withCache, preview } from 'openlink' | ||
| const cache = createCache(memoryCache()) | ||
| const cachedPreview = withCache(cache, preview) | ||
| const data = await cachedPreview('https://github.com') | ||
| ``` | ||
| ## Image Size | ||
| ```js | ||
| import { getImageSize } from 'openlink' | ||
| const size = await getImageSize('https://example.com/image.png') | ||
| console.log(size) // { width: 1200, height: 630, type: 'png' } | ||
| ``` | ||
| Works on Cloudflare Workers, Vercel Edge, Deno, Bun, and Node 18+. | ||
@@ -32,0 +71,0 @@ |
+15
-0
@@ -23,2 +23,4 @@ export function extract(parsed, url) { | ||
| const keywords = parsed.keywords ? parsed.keywords.split(",").map((k) => k.trim()) : null; | ||
| const lang = parsed.htmlLang || parsed.contentLanguage || (locale ? locale.split("_")[0] : null); | ||
| const contentType = detectContentType(type, video, audio, parsed); | ||
@@ -34,2 +36,3 @@ return { | ||
| type, | ||
| contentType, | ||
| author, | ||
@@ -40,2 +43,3 @@ publishedTime, | ||
| locale, | ||
| lang, | ||
| video, | ||
@@ -47,2 +51,13 @@ audio, | ||
| function detectContentType(ogType, video, audio, parsed) { | ||
| if (video) return "video"; | ||
| if (audio) return "audio"; | ||
| if (ogType === "article" || parsed.articlePublishedTime) return "article"; | ||
| if (ogType === "product") return "product"; | ||
| if (ogType === "profile") return "profile"; | ||
| if (ogType === "music.song" || ogType === "music.album") return "music"; | ||
| if (ogType === "video.movie" || ogType === "video.episode") return "video"; | ||
| return "website"; | ||
| } | ||
| function resolve(path, base) { | ||
@@ -49,0 +64,0 @@ if (!path) return null; |
+161
-6
@@ -39,2 +39,8 @@ export interface PreviewOptions { | ||
| /** | ||
| * Include JSON-LD structured data from the page | ||
| * @default false | ||
| */ | ||
| includeJsonLd?: boolean; | ||
| /** | ||
| * Validate that the URL is reachable before parsing | ||
@@ -44,2 +50,14 @@ * @default true | ||
| validateUrl?: boolean; | ||
| /** | ||
| * Number of retry attempts on failure | ||
| * @default 0 | ||
| */ | ||
| retry?: number; | ||
| /** | ||
| * Initial delay between retries in milliseconds | ||
| * @default 1000 | ||
| */ | ||
| retryDelay?: number; | ||
| } | ||
@@ -107,2 +125,5 @@ | ||
| /** Detected content type (article, video, audio, product, profile, music, website) */ | ||
| contentType: string; | ||
| /** Author name if available */ | ||
@@ -123,2 +144,5 @@ author: string | null; | ||
| /** Language code from html lang attribute or content-language */ | ||
| lang: string | null; | ||
| /** Video URL from og:video */ | ||
@@ -138,4 +162,55 @@ video: string | null; | ||
| oembed?: OembedResult | null; | ||
| /** JSON-LD structured data (only if includeJsonLd: true) */ | ||
| jsonLd?: JsonLdResult | null; | ||
| } | ||
| export interface JsonLdResult { | ||
| /** Schema.org types found in the page */ | ||
| types: string[]; | ||
| /** Raw JSON-LD data */ | ||
| data: Record<string, unknown>[]; | ||
| /** Article data if present */ | ||
| article?: { | ||
| headline: string | null; | ||
| description: string | null; | ||
| author: string | null; | ||
| publisher: { name: string | null; logo: string | null } | null; | ||
| datePublished: string | null; | ||
| dateModified: string | null; | ||
| image: string | null; | ||
| }; | ||
| /** Product data if present */ | ||
| product?: { | ||
| name: string | null; | ||
| description: string | null; | ||
| image: string | null; | ||
| brand: string | null; | ||
| price: { amount: string | null; currency: string | null; availability: string | null } | null; | ||
| rating: { value: number | null; count: number | null } | null; | ||
| }; | ||
| /** Organization data if present */ | ||
| organization?: { | ||
| name: string | null; | ||
| url: string | null; | ||
| logo: string | null; | ||
| }; | ||
| /** Video data if present */ | ||
| video?: { | ||
| name: string | null; | ||
| description: string | null; | ||
| thumbnail: string | null; | ||
| duration: string | null; | ||
| uploadDate: string | null; | ||
| }; | ||
| /** Breadcrumb navigation if present */ | ||
| breadcrumbs?: Array<{ name: string | null; url: string | null }>; | ||
| } | ||
| export interface ParseResult { | ||
@@ -171,2 +246,4 @@ ogTitle: string | null; | ||
| robots: string | null; | ||
| htmlLang: string | null; | ||
| contentLanguage: string | null; | ||
| } | ||
@@ -187,3 +264,3 @@ | ||
| code: PreviewError["code"], | ||
| options?: { status?: number; cause?: Error } | ||
| options?: { status?: number; cause?: Error }, | ||
| ); | ||
@@ -211,6 +288,3 @@ } | ||
| */ | ||
| export function preview( | ||
| url: string, | ||
| options?: PreviewOptions | ||
| ): Promise<PreviewResult>; | ||
| export function preview(url: string, options?: PreviewOptions): Promise<PreviewResult>; | ||
@@ -267,3 +341,3 @@ /** | ||
| url: string, | ||
| options?: { fetch?: typeof fetch; timeout?: number } | ||
| options?: { fetch?: typeof fetch; timeout?: number }, | ||
| ): Promise<OembedResult | null>; | ||
@@ -280,1 +354,82 @@ | ||
| export function detectProvider(url: string): { name: string; pattern: RegExp } | null; | ||
| /** | ||
| * Parse JSON-LD structured data from HTML | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { parseJsonLd } from 'openlink' | ||
| * | ||
| * const items = parseJsonLd(html) | ||
| * // Returns array of JSON-LD objects | ||
| * ``` | ||
| */ | ||
| export function parseJsonLd(html: string): Record<string, unknown>[]; | ||
| /** | ||
| * Extract and normalize JSON-LD data | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { parseJsonLd, extractJsonLd } from 'openlink' | ||
| * | ||
| * const items = parseJsonLd(html) | ||
| * const structured = extractJsonLd(items) | ||
| * console.log(structured.types) // ["Article", "Organization"] | ||
| * ``` | ||
| */ | ||
| export function extractJsonLd(items: Record<string, unknown>[]): JsonLdResult | null; | ||
| export interface RetryOptions { | ||
| retries?: number; | ||
| delay?: number; | ||
| backoff?: number; | ||
| shouldRetry?: (error: Error, attempt: number) => boolean; | ||
| } | ||
| export function withRetry<T>( | ||
| fn: (attempt: number) => Promise<T>, | ||
| options?: RetryOptions, | ||
| ): Promise<T>; | ||
| export function isRetryable(error: PreviewError): boolean; | ||
| export function createProxyFetch(proxyUrl: string, baseFetch?: typeof fetch): typeof fetch; | ||
| export function corsProxy(url: string): string; | ||
| export function allOriginsProxy(url: string): string; | ||
| export interface CacheStorage { | ||
| get(key: string): Promise<string | null> | string | null; | ||
| set(key: string, value: string): Promise<void> | void; | ||
| delete?(key: string): Promise<void> | void; | ||
| } | ||
| export interface Cache { | ||
| get(url: string): Promise<PreviewResult | null>; | ||
| set(url: string, value: PreviewResult, ttl?: number): Promise<void>; | ||
| delete(url: string): Promise<void>; | ||
| } | ||
| export function createCache(storage: CacheStorage): Cache; | ||
| export function cacheKey(url: string): string; | ||
| export function memoryCache(): CacheStorage & { clear(): void }; | ||
| export function withCache( | ||
| cache: Cache, | ||
| previewFn: typeof preview, | ||
| ): (url: string, options?: PreviewOptions & { cacheTtl?: number }) => Promise<PreviewResult>; | ||
| export interface ImageSize { | ||
| width: number; | ||
| height: number; | ||
| type: "png" | "jpeg" | "gif" | "webp"; | ||
| } | ||
| export function getImageSize( | ||
| url: string, | ||
| options?: { fetch?: typeof fetch; timeout?: number }, | ||
| ): Promise<ImageSize | null>; |
+78
-16
@@ -0,4 +1,6 @@ | ||
| import { extract } from "./extract.js"; | ||
| import { extractJsonLd, parseJsonLd } from "./jsonld.js"; | ||
| import { detectProvider, fetchOembed, hasOembedSupport } from "./oembed.js"; | ||
| import { parse } from "./parse.js"; | ||
| import { extract } from "./extract.js"; | ||
| import { fetchOembed, hasOembedSupport, detectProvider } from "./oembed.js"; | ||
| import { isRetryable, withRetry } from "./retry.js"; | ||
@@ -24,3 +26,6 @@ export class PreviewError extends Error { | ||
| includeOembed: false, | ||
| includeJsonLd: false, | ||
| validateUrl: true, | ||
| retry: 0, | ||
| retryDelay: 1000, | ||
| }; | ||
@@ -30,3 +35,2 @@ | ||
| const opts = { ...defaults, ...options }; | ||
| const fetchFn = opts.fetch || globalThis.fetch; | ||
@@ -37,8 +41,23 @@ if (!url || typeof url !== "string") { | ||
| url = normalizeUrl(url); | ||
| const normalized = normalizeUrl(url); | ||
| if (opts.validateUrl && !isValidUrl(url)) { | ||
| if (opts.validateUrl && !isValidUrl(normalized)) { | ||
| throw new PreviewError("Invalid URL format", "INVALID_URL"); | ||
| } | ||
| const doFetch = () => fetchPreview(normalized, opts); | ||
| if (opts.retry > 0) { | ||
| return withRetry(doFetch, { | ||
| retries: opts.retry, | ||
| delay: opts.retryDelay, | ||
| shouldRetry: isRetryable, | ||
| }); | ||
| } | ||
| return doFetch(); | ||
| } | ||
| async function fetchPreview(url, opts) { | ||
| const fetchFn = opts.fetch || globalThis.fetch; | ||
| const controller = new AbortController(); | ||
@@ -56,3 +75,4 @@ const timer = setTimeout(() => controller.abort(), opts.timeout); | ||
| if (!response.ok) { | ||
| throw new PreviewError(`HTTP ${response.status}`, "HTTP_ERROR", { | ||
| const statusText = getStatusText(response.status); | ||
| throw new PreviewError(`${statusText} (${response.status})`, "HTTP_ERROR", { | ||
| status: response.status, | ||
@@ -74,2 +94,7 @@ }); | ||
| if (opts.includeJsonLd) { | ||
| const items = parseJsonLd(html); | ||
| data.jsonLd = extractJsonLd(items); | ||
| } | ||
| return data; | ||
@@ -80,6 +105,20 @@ } catch (err) { | ||
| if (err.name === "AbortError") { | ||
| throw new PreviewError("Request timed out", "TIMEOUT", { cause: err }); | ||
| throw new PreviewError(`Request timed out after ${opts.timeout}ms`, "TIMEOUT", { | ||
| cause: err, | ||
| }); | ||
| } | ||
| throw new PreviewError(err.message || "Failed to fetch", "FETCH_ERROR", { | ||
| if (err.code === "ENOTFOUND" || err.code === "ECONNREFUSED") { | ||
| throw new PreviewError(`Cannot connect to ${new URL(url).hostname}`, "FETCH_ERROR", { | ||
| cause: err, | ||
| }); | ||
| } | ||
| if (err.code === "CERT_HAS_EXPIRED" || err.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE") { | ||
| throw new PreviewError(`SSL certificate error for ${new URL(url).hostname}`, "FETCH_ERROR", { | ||
| cause: err, | ||
| }); | ||
| } | ||
| throw new PreviewError(err.message || `Failed to fetch ${url}`, "FETCH_ERROR", { | ||
| cause: err, | ||
@@ -104,21 +143,44 @@ }); | ||
| url = url.trim(); | ||
| let result = url.trim(); | ||
| if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("//")) { | ||
| url = "https://" + url; | ||
| } | ||
| if (base) { | ||
| try { | ||
| return new URL(url, base).href; | ||
| return new URL(result, base).href; | ||
| } catch { | ||
| return url; | ||
| return result; | ||
| } | ||
| } | ||
| return url; | ||
| if (!result.startsWith("http://") && !result.startsWith("https://") && !result.startsWith("//")) { | ||
| result = `https://${result}`; | ||
| } | ||
| return result; | ||
| } | ||
| function getStatusText(status) { | ||
| const texts = { | ||
| 400: "Bad Request", | ||
| 401: "Unauthorized", | ||
| 403: "Forbidden", | ||
| 404: "Not Found", | ||
| 405: "Method Not Allowed", | ||
| 408: "Request Timeout", | ||
| 410: "Gone", | ||
| 429: "Too Many Requests", | ||
| 500: "Internal Server Error", | ||
| 502: "Bad Gateway", | ||
| 503: "Service Unavailable", | ||
| 504: "Gateway Timeout", | ||
| }; | ||
| return texts[status] || "HTTP Error"; | ||
| } | ||
| export { parse } from "./parse.js"; | ||
| export { extract } from "./extract.js"; | ||
| export { fetchOembed, hasOembedSupport, detectProvider } from "./oembed.js"; | ||
| export { parseJsonLd, extractJsonLd } from "./jsonld.js"; | ||
| export { withRetry, isRetryable } from "./retry.js"; | ||
| export { createProxyFetch, corsProxy, allOriginsProxy } from "./proxy.js"; | ||
| export { createCache, cacheKey, memoryCache, withCache } from "./cache.js"; | ||
| export { getImageSize } from "./image.js"; |
+1
-1
@@ -49,3 +49,3 @@ const providers = [ | ||
| name: "figma", | ||
| pattern: /figma\.com\/(file|proto)\/([a-zA-Z0-9]+)/, | ||
| pattern: /figma\.com\/(file|design|proto)\/([a-zA-Z0-9]+)/, | ||
| endpoint: (url) => `https://www.figma.com/api/oembed?url=${encodeURIComponent(url)}`, | ||
@@ -52,0 +52,0 @@ }, |
+20
-3
@@ -1,4 +0,16 @@ | ||
| const og = (p) => new RegExp(`<meta[^>]*(?:property=["']${p}["'][^>]*content=["']([^"']*)["']|content=["']([^"']*)["'][^>]*property=["']${p}["'])[^>]*>`, "i"); | ||
| const meta = (n) => new RegExp(`<meta[^>]*(?:name=["']${n}["'][^>]*content=["']([^"']*)["']|content=["']([^"']*)["'][^>]*name=["']${n}["'])[^>]*>`, "i"); | ||
| const link = (r) => new RegExp(`<link[^>]*(?:rel=["']${r}["'][^>]*href=["']([^"']*)["']|href=["']([^"']*)["'][^>]*rel=["']${r}["'])[^>]*>`, "i"); | ||
| const og = (p) => | ||
| new RegExp( | ||
| `<meta[^>]*(?:property=["']${p}["'][^>]*content=["']([^"']*)["']|content=["']([^"']*)["'][^>]*property=["']${p}["'])[^>]*>`, | ||
| "i", | ||
| ); | ||
| const meta = (n) => | ||
| new RegExp( | ||
| `<meta[^>]*(?:name=["']${n}["'][^>]*content=["']([^"']*)["']|content=["']([^"']*)["'][^>]*name=["']${n}["'])[^>]*>`, | ||
| "i", | ||
| ); | ||
| const link = (r) => | ||
| new RegExp( | ||
| `<link[^>]*(?:rel=["']${r}["'][^>]*href=["']([^"']*)["']|href=["']([^"']*)["'][^>]*rel=["']${r}["'])[^>]*>`, | ||
| "i", | ||
| ); | ||
@@ -35,2 +47,5 @@ const patterns = { | ||
| title: /<title[^>]*>([^<]*)<\/title>/i, | ||
| htmlLang: /<html[^>]*\slang=["']([^"']*)["'][^>]*>/i, | ||
| contentLanguage: | ||
| /<meta[^>]*http-equiv=["']content-language["'][^>]*content=["']([^"']*)["'][^>]*>/i, | ||
| }; | ||
@@ -95,3 +110,5 @@ | ||
| robots: get("robots"), | ||
| htmlLang: get("htmlLang"), | ||
| contentLanguage: get("contentLanguage"), | ||
| }; | ||
| } |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
35755
89.86%13
62.5%1077
95.82%73
114.71%16
77.78%