+21
| MIT License | ||
| Copyright (c) 2026 josh | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
+21
| # openlink | ||
| Edge-first link preview. Zero dependencies, ~2kb gzipped. | ||
| ```bash | ||
| npm install openlink | ||
| ``` | ||
| ```js | ||
| import { preview } from 'openlink' | ||
| const data = await preview('https://github.com') | ||
| ``` | ||
| Returns `{ url, title, description, image, favicon, siteName, domain, type }` | ||
| Works on Cloudflare Workers, Vercel Edge, Deno, Bun, and Node 18+. | ||
| [Docs](https://openlink.sh/docs) · [API](https://openlink.sh/docs/api) · [TypeScript](https://openlink.sh/docs/typescript) | ||
| MIT |
| export function extract(parsed, url) { | ||
| const base = new URL(url); | ||
| const title = parsed.ogTitle || parsed.twitterTitle || parsed.title || null; | ||
| const description = | ||
| parsed.ogDescription || parsed.twitterDescription || parsed.description || null; | ||
| const image = resolve(parsed.ogImage || parsed.twitterImage, base); | ||
| const favicon = | ||
| resolve(parsed.appleTouchIcon, base) || | ||
| resolve(parsed.favicon, base) || | ||
| `${base.origin}/favicon.ico`; | ||
| const canonical = parsed.canonical || parsed.ogUrl || url; | ||
| const type = parsed.ogType || "website"; | ||
| const siteName = parsed.ogSiteName || base.hostname; | ||
| const author = parsed.articleAuthor || parsed.author || null; | ||
| const publishedTime = parsed.articlePublishedTime || null; | ||
| const themeColor = parsed.themeColor || null; | ||
| const twitterCard = parsed.twitterCard || null; | ||
| const locale = parsed.ogLocale || null; | ||
| const video = resolve(parsed.ogVideo, base); | ||
| const audio = resolve(parsed.ogAudio, base); | ||
| const keywords = parsed.keywords ? parsed.keywords.split(",").map((k) => k.trim()) : null; | ||
| return { | ||
| url: canonical, | ||
| title, | ||
| description, | ||
| image, | ||
| favicon, | ||
| siteName, | ||
| domain: base.hostname, | ||
| type, | ||
| author, | ||
| publishedTime, | ||
| themeColor, | ||
| twitterCard, | ||
| locale, | ||
| video, | ||
| audio, | ||
| keywords, | ||
| }; | ||
| } | ||
| function resolve(path, base) { | ||
| if (!path) return null; | ||
| if (path.startsWith("http://") || path.startsWith("https://")) return path; | ||
| if (path.startsWith("//")) return base.protocol + path; | ||
| if (path.startsWith("/")) return base.origin + path; | ||
| return new URL(path, base.origin).href; | ||
| } |
+199
| export interface PreviewOptions { | ||
| /** | ||
| * Request timeout in milliseconds | ||
| * @default 10000 | ||
| */ | ||
| timeout?: number; | ||
| /** | ||
| * Custom headers to send with the request | ||
| * @default { 'User-Agent': 'OpenLinkBot/1.0 (+https://openlink.sh)', 'Accept': 'text/html,application/xhtml+xml' } | ||
| */ | ||
| headers?: Record<string, string>; | ||
| /** | ||
| * Whether to follow redirects | ||
| * @default true | ||
| */ | ||
| followRedirects?: boolean; | ||
| /** | ||
| * Custom fetch function for testing or custom runtimes | ||
| * @default globalThis.fetch | ||
| */ | ||
| fetch?: typeof fetch; | ||
| /** | ||
| * Include raw parsed metadata in the result | ||
| * @default false | ||
| */ | ||
| includeRaw?: boolean; | ||
| /** | ||
| * Validate that the URL is reachable before parsing | ||
| * @default true | ||
| */ | ||
| validateUrl?: boolean; | ||
| } | ||
| export interface PreviewResult { | ||
| /** Canonical URL of the page */ | ||
| url: string; | ||
| /** Page title from og:title, twitter:title, or <title> */ | ||
| title: string | null; | ||
| /** Page description from og:description, twitter:description, or meta description */ | ||
| description: string | null; | ||
| /** Primary image URL from og:image or twitter:image */ | ||
| image: string | null; | ||
| /** Favicon URL, defaults to /favicon.ico if not found */ | ||
| favicon: string; | ||
| /** Site name from og:site_name or domain */ | ||
| siteName: string; | ||
| /** Hostname extracted from URL */ | ||
| domain: string; | ||
| /** Content type from og:type */ | ||
| type: string; | ||
| /** Author name if available */ | ||
| author: string | null; | ||
| /** Published date if available */ | ||
| publishedTime: string | null; | ||
| /** Theme color from meta theme-color */ | ||
| themeColor: string | null; | ||
| /** Twitter card type */ | ||
| twitterCard: string | null; | ||
| /** Locale from og:locale */ | ||
| locale: string | null; | ||
| /** Video URL from og:video */ | ||
| video: string | null; | ||
| /** Audio URL from og:audio */ | ||
| audio: string | null; | ||
| /** Keywords from meta keywords */ | ||
| keywords: string[] | null; | ||
| /** Raw parsed metadata (only if includeRaw: true) */ | ||
| raw?: ParseResult; | ||
| } | ||
| export interface ParseResult { | ||
| ogTitle: string | null; | ||
| ogDescription: string | null; | ||
| ogImage: string | null; | ||
| ogImageWidth: string | null; | ||
| ogImageHeight: string | null; | ||
| ogImageAlt: string | null; | ||
| ogType: string | null; | ||
| ogSiteName: string | null; | ||
| ogUrl: string | null; | ||
| ogLocale: string | null; | ||
| ogVideo: string | null; | ||
| ogAudio: string | null; | ||
| articleAuthor: string | null; | ||
| articlePublishedTime: string | null; | ||
| twitterTitle: string | null; | ||
| twitterDescription: string | null; | ||
| twitterImage: string | null; | ||
| twitterCard: string | null; | ||
| twitterSite: string | null; | ||
| twitterCreator: string | null; | ||
| title: string | null; | ||
| description: string | null; | ||
| favicon: string | null; | ||
| appleTouchIcon: string | null; | ||
| canonical: string | null; | ||
| themeColor: string | null; | ||
| keywords: string | null; | ||
| author: string | null; | ||
| robots: string | null; | ||
| } | ||
| export class PreviewError extends Error { | ||
| /** Error code for programmatic handling */ | ||
| code: "INVALID_URL" | "TIMEOUT" | "FETCH_ERROR" | "HTTP_ERROR"; | ||
| /** HTTP status code if applicable */ | ||
| status?: number; | ||
| /** Original error if wrapped */ | ||
| cause?: Error; | ||
| constructor( | ||
| message: string, | ||
| code: PreviewError["code"], | ||
| options?: { status?: number; cause?: Error } | ||
| ); | ||
| } | ||
| /** | ||
| * Fetch and parse link preview metadata from a URL | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { preview } from 'openlink' | ||
| * | ||
| * const data = await preview('https://github.com') | ||
| * console.log(data.title) // "GitHub" | ||
| * ``` | ||
| * | ||
| * @example With options | ||
| * ```ts | ||
| * const data = await preview('https://github.com', { | ||
| * timeout: 5000, | ||
| * includeRaw: true | ||
| * }) | ||
| * ``` | ||
| */ | ||
| export function preview( | ||
| url: string, | ||
| options?: PreviewOptions | ||
| ): Promise<PreviewResult>; | ||
| /** | ||
| * Parse HTML string for Open Graph, Twitter Card, and standard meta tags | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { parse } from 'openlink' | ||
| * | ||
| * const html = await fetch('https://example.com').then(r => r.text()) | ||
| * const metadata = parse(html) | ||
| * ``` | ||
| */ | ||
| export function parse(html: string): ParseResult; | ||
| /** | ||
| * Extract and normalize metadata from parsed result | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { parse, extract } from 'openlink' | ||
| * | ||
| * const parsed = parse(html) | ||
| * const result = extract(parsed, 'https://example.com') | ||
| * ``` | ||
| */ | ||
| export function extract(parsed: ParseResult, url: string): PreviewResult; | ||
| /** | ||
| * Check if a string is a valid URL | ||
| */ | ||
| export function isValidUrl(url: string): boolean; | ||
| /** | ||
| * Normalize a URL (add https:// if missing, resolve relative paths) | ||
| */ | ||
| export function normalizeUrl(url: string, base?: string): string; |
+110
| import { parse } from "./parse.js"; | ||
| import { extract } from "./extract.js"; | ||
| export class PreviewError extends Error { | ||
| constructor(message, code, options = {}) { | ||
| super(message); | ||
| this.name = "PreviewError"; | ||
| this.code = code; | ||
| this.status = options.status; | ||
| this.cause = options.cause; | ||
| } | ||
| } | ||
| const defaults = { | ||
| timeout: 10000, | ||
| headers: { | ||
| "User-Agent": "OpenLinkBot/1.0 (+https://openlink.sh)", | ||
| Accept: "text/html,application/xhtml+xml", | ||
| }, | ||
| followRedirects: true, | ||
| includeRaw: false, | ||
| validateUrl: true, | ||
| }; | ||
| export async function preview(url, options = {}) { | ||
| const opts = { ...defaults, ...options }; | ||
| const fetchFn = opts.fetch || globalThis.fetch; | ||
| if (!url || typeof url !== "string") { | ||
| throw new PreviewError("URL is required", "INVALID_URL"); | ||
| } | ||
| url = normalizeUrl(url); | ||
| if (opts.validateUrl && !isValidUrl(url)) { | ||
| throw new PreviewError("Invalid URL format", "INVALID_URL"); | ||
| } | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(), opts.timeout); | ||
| try { | ||
| const response = await fetchFn(url, { | ||
| method: "GET", | ||
| headers: opts.headers, | ||
| signal: controller.signal, | ||
| redirect: opts.followRedirects ? "follow" : "manual", | ||
| }); | ||
| if (!response.ok) { | ||
| throw new PreviewError(`HTTP ${response.status}`, "HTTP_ERROR", { | ||
| status: response.status, | ||
| }); | ||
| } | ||
| const html = await response.text(); | ||
| const parsed = parse(html); | ||
| const data = extract(parsed, response.url || url); | ||
| if (opts.includeRaw) { | ||
| data.raw = parsed; | ||
| } | ||
| return data; | ||
| } catch (err) { | ||
| if (err instanceof PreviewError) throw err; | ||
| if (err.name === "AbortError") { | ||
| throw new PreviewError("Request timed out", "TIMEOUT", { cause: err }); | ||
| } | ||
| throw new PreviewError(err.message || "Failed to fetch", "FETCH_ERROR", { | ||
| cause: err, | ||
| }); | ||
| } finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| export function isValidUrl(url) { | ||
| try { | ||
| const parsed = new URL(url); | ||
| return parsed.protocol === "http:" || parsed.protocol === "https:"; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| export function normalizeUrl(url, base) { | ||
| if (!url) return url; | ||
| url = url.trim(); | ||
| if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("//")) { | ||
| url = "https://" + url; | ||
| } | ||
| if (base) { | ||
| try { | ||
| return new URL(url, base).href; | ||
| } catch { | ||
| return url; | ||
| } | ||
| } | ||
| return url; | ||
| } | ||
| export { parse } from "./parse.js"; | ||
| export { extract } from "./extract.js"; |
+95
| 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 patterns = { | ||
| ogTitle: og("og:title"), | ||
| ogDescription: og("og:description"), | ||
| ogImage: og("og:image"), | ||
| ogImageWidth: og("og:image:width"), | ||
| ogImageHeight: og("og:image:height"), | ||
| ogImageAlt: og("og:image:alt"), | ||
| ogType: og("og:type"), | ||
| ogSiteName: og("og:site_name"), | ||
| ogUrl: og("og:url"), | ||
| ogLocale: og("og:locale"), | ||
| ogVideo: og("og:video"), | ||
| ogAudio: og("og:audio"), | ||
| articleAuthor: og("article:author"), | ||
| articlePublishedTime: og("article:published_time"), | ||
| twitterTitle: meta("twitter:title"), | ||
| twitterDescription: meta("twitter:description"), | ||
| twitterImage: meta("twitter:image"), | ||
| twitterCard: meta("twitter:card"), | ||
| twitterSite: meta("twitter:site"), | ||
| twitterCreator: meta("twitter:creator"), | ||
| description: meta("description"), | ||
| themeColor: meta("theme-color"), | ||
| keywords: meta("keywords"), | ||
| author: meta("author"), | ||
| robots: meta("robots"), | ||
| favicon: link("(?:shortcut )?icon"), | ||
| appleTouchIcon: link("apple-touch-icon"), | ||
| canonical: link("canonical"), | ||
| title: /<title[^>]*>([^<]*)<\/title>/i, | ||
| }; | ||
| const entities = { | ||
| "&": "&", | ||
| "<": "<", | ||
| ">": ">", | ||
| """: '"', | ||
| "'": "'", | ||
| "'": "'", | ||
| "/": "/", | ||
| "'": "'", | ||
| "=": "=", | ||
| " ": " ", | ||
| }; | ||
| const entityPattern = /&(?:amp|lt|gt|quot|apos|nbsp|#39|#x27|#x2F|#x3D);/g; | ||
| function decode(str) { | ||
| if (!str) return null; | ||
| return str.replace(entityPattern, (match) => entities[match] || match); | ||
| } | ||
| export function parse(html) { | ||
| const get = (key) => { | ||
| const match = html.match(patterns[key]); | ||
| if (!match) return null; | ||
| return decode((match[1] || match[2] || "").trim()) || null; | ||
| }; | ||
| return { | ||
| ogTitle: get("ogTitle"), | ||
| ogDescription: get("ogDescription"), | ||
| ogImage: get("ogImage"), | ||
| ogImageWidth: get("ogImageWidth"), | ||
| ogImageHeight: get("ogImageHeight"), | ||
| ogImageAlt: get("ogImageAlt"), | ||
| ogType: get("ogType"), | ||
| ogSiteName: get("ogSiteName"), | ||
| ogUrl: get("ogUrl"), | ||
| ogLocale: get("ogLocale"), | ||
| ogVideo: get("ogVideo"), | ||
| ogAudio: get("ogAudio"), | ||
| articleAuthor: get("articleAuthor"), | ||
| articlePublishedTime: get("articlePublishedTime"), | ||
| twitterTitle: get("twitterTitle"), | ||
| twitterDescription: get("twitterDescription"), | ||
| twitterImage: get("twitterImage"), | ||
| twitterCard: get("twitterCard"), | ||
| twitterSite: get("twitterSite"), | ||
| twitterCreator: get("twitterCreator"), | ||
| title: get("title"), | ||
| description: get("description"), | ||
| favicon: get("favicon"), | ||
| appleTouchIcon: get("appleTouchIcon"), | ||
| canonical: get("canonical"), | ||
| themeColor: get("themeColor"), | ||
| keywords: get("keywords"), | ||
| author: get("author"), | ||
| robots: get("robots"), | ||
| }; | ||
| } |
+32
-7
| { | ||
| "name": "openlink", | ||
| "version": "0.0.0", | ||
| "description": "Reserved package name", | ||
| "main": "index.js", | ||
| "version": "0.1.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": [ | ||
| "placeholder", | ||
| "reserved" | ||
| "link", | ||
| "preview", | ||
| "unfurl", | ||
| "opengraph", | ||
| "twitter-cards", | ||
| "meta", | ||
| "edge", | ||
| "cloudflare", | ||
| "workers" | ||
| ], | ||
| "author": "josh", | ||
| "license": "MIT" | ||
| } | ||
| "license": "MIT", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/visible/openlink.git" | ||
| }, | ||
| "homepage": "https://openlink.sh" | ||
| } |
-3
| export default function() { | ||
| console.log('This package is reserved'); | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
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
No README
QualityPackage does not have a README. This may indicate a failed publish or a low quality package.
Found 1 instance in 1 package
Trivial Package
Supply chain riskPackages less than 10 lines of code are easily copied into your own project and may not warrant the additional supply chain risk of an external dependency.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
13639
4823.83%7
250%392
19500%1
-50%0
-100%22
Infinity%Yes
NaN5
400%