🚀. Socket Launch Week Day 2:Introducing Manifest Alerts.Learn more
Sign In

openlink

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

openlink - npm Package Compare versions

Comparing version
0.2.0
to
0.3.0
+54
src/cache.js
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;
};
}
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;
}
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,
};
}
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)}`;
}
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"
}
# 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 @@

@@ -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;

@@ -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>;

@@ -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";

@@ -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 @@ },

@@ -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"),
};
}