ogthing 0.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,365 @@
1
+ import type { MetaData, UaMode } from "./types";
2
+
3
+ export const userAgents: Record<UaMode, string> = {
4
+ browser:
5
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
6
+ facebook: "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
7
+ twitter: "Twitterbot/1.0",
8
+ slack: "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)",
9
+ discord: "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)",
10
+ telegram: "TelegramBot (like TwitterBot)",
11
+ whatsapp: "WhatsApp/2.23.20.0",
12
+ linkedin: "LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +http://www.linkedin.com)",
13
+ };
14
+
15
+ const entities: Record<string, string> = {
16
+ amp: "&",
17
+ lt: "<",
18
+ gt: ">",
19
+ quot: '"',
20
+ apos: "'",
21
+ nbsp: " ",
22
+ copy: "\u00a9",
23
+ reg: "\u00ae",
24
+ trade: "\u2122",
25
+ hellip: "\u2026",
26
+ mdash: "\u2014",
27
+ ndash: "\u2013",
28
+ lsquo: "\u2018",
29
+ rsquo: "\u2019",
30
+ ldquo: "\u201c",
31
+ rdquo: "\u201d",
32
+ deg: "\u00b0",
33
+ plusmn: "\u00b1",
34
+ middot: "\u00b7",
35
+ bull: "\u2022",
36
+ euro: "\u20ac",
37
+ pound: "\u00a3",
38
+ yen: "\u00a5",
39
+ cent: "\u00a2",
40
+ sect: "\u00a7",
41
+ para: "\u00b6",
42
+ laquo: "\u00ab",
43
+ raquo: "\u00bb",
44
+ times: "\u00d7",
45
+ divide: "\u00f7",
46
+ szlig: "\u00df",
47
+ Agrave: "\u00c0",
48
+ Aacute: "\u00c1",
49
+ Acirc: "\u00c2",
50
+ Atilde: "\u00c3",
51
+ Auml: "\u00c4",
52
+ Aring: "\u00c5",
53
+ Ccedil: "\u00c7",
54
+ Egrave: "\u00c8",
55
+ Eacute: "\u00c9",
56
+ Ecirc: "\u00ca",
57
+ Euml: "\u00cb",
58
+ Igrave: "\u00cc",
59
+ Iacute: "\u00cd",
60
+ Icirc: "\u00ce",
61
+ Iuml: "\u00cf",
62
+ Ntilde: "\u00d1",
63
+ Ograve: "\u00d2",
64
+ Oacute: "\u00d3",
65
+ Ocirc: "\u00d4",
66
+ Otilde: "\u00d5",
67
+ Ouml: "\u00d6",
68
+ Oslash: "\u00d8",
69
+ Ugrave: "\u00d9",
70
+ Uacute: "\u00da",
71
+ Ucirc: "\u00db",
72
+ Uuml: "\u00dc",
73
+ agrave: "\u00e0",
74
+ aacute: "\u00e1",
75
+ acirc: "\u00e2",
76
+ atilde: "\u00e3",
77
+ auml: "\u00e4",
78
+ aring: "\u00e5",
79
+ ccedil: "\u00e7",
80
+ egrave: "\u00e8",
81
+ eacute: "\u00e9",
82
+ ecirc: "\u00ea",
83
+ euml: "\u00eb",
84
+ igrave: "\u00ec",
85
+ iacute: "\u00ed",
86
+ icirc: "\u00ee",
87
+ iuml: "\u00ef",
88
+ ntilde: "\u00f1",
89
+ ograve: "\u00f2",
90
+ oacute: "\u00f3",
91
+ ocirc: "\u00f4",
92
+ otilde: "\u00f5",
93
+ ouml: "\u00f6",
94
+ oslash: "\u00f8",
95
+ ugrave: "\u00f9",
96
+ uacute: "\u00fa",
97
+ ucirc: "\u00fb",
98
+ uuml: "\u00fc",
99
+ yacute: "\u00fd",
100
+ };
101
+
102
+ export const decodeEntities = (value: string): string =>
103
+ value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body: string) => {
104
+ if (body.startsWith("#x") || body.startsWith("#X")) {
105
+ const code = Number.parseInt(body.slice(2), 16);
106
+ return Number.isNaN(code) ? match : String.fromCodePoint(code);
107
+ }
108
+ if (body.startsWith("#")) {
109
+ const code = Number.parseInt(body.slice(1), 10);
110
+ return Number.isNaN(code) ? match : String.fromCodePoint(code);
111
+ }
112
+ return entities[body] ?? match;
113
+ });
114
+
115
+ const collapseSpace = (value: string): string => value.replace(/\s+/g, " ").trim();
116
+
117
+ interface TagInfo {
118
+ name: string;
119
+ attrs: Record<string, string>;
120
+ }
121
+
122
+ /** Parse the attributes of a single `<tag ...>` string. */
123
+ export const parseTagAttributes = (raw: string): TagInfo | null => {
124
+ const match = /^<\s*([a-zA-Z][a-zA-Z0-9:-]*)/.exec(raw);
125
+ if (!match) return null;
126
+ const name = match[1].toLowerCase();
127
+ const attrs: Record<string, string> = {};
128
+ const body = raw.slice(match[0].length).replace(/>[^>]*$/, "");
129
+ const attrPattern =
130
+ /([:@a-zA-Z_][-.:a-zA-Z0-9_]*)\s*(?:=\s*("([^"]*)"|'([^']*)'|[^\s"'=<>`]+))?/g;
131
+ for (const attr of body.matchAll(attrPattern)) {
132
+ const key = attr[1].toLowerCase();
133
+ const value = attr[3] ?? attr[4] ?? attr[2] ?? "";
134
+ if (!(key in attrs)) attrs[key] = decodeEntities(value);
135
+ }
136
+ return { name, attrs };
137
+ };
138
+
139
+ /** Extract title/meta/link tags from an HTML document without a DOM dependency. */
140
+ export const extractHeadTags = (html: string) => {
141
+ let head = html;
142
+ const headMatch = /<head[\s>]([\s\S]*?)<\/head\s*>/i.exec(html);
143
+ if (headMatch) {
144
+ head = headMatch[1];
145
+ } else {
146
+ // Some pages ship malformed HTML; fall back to everything before <body>.
147
+ const bodyIndex = html.search(/<body[\s>]/i);
148
+ if (bodyIndex >= 0) head = html.slice(0, bodyIndex);
149
+ }
150
+
151
+ const cleaned = head
152
+ .replace(/<!--[\s\S]*?-->/g, "")
153
+ .replace(/<script[\s>][\s\S]*?<\/script\s*>/gi, "")
154
+ .replace(/<style[\s>][\s\S]*?<\/style\s*>/gi, "")
155
+ .replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
156
+
157
+ const titleMatch = /<title[\s>]*>([\s\S]*?)<\/title\s*>/i.exec(cleaned);
158
+ const title = titleMatch ? collapseSpace(decodeEntities(titleMatch[1])) : undefined;
159
+
160
+ const htmlTag = /<html[^>]*>/i.exec(html);
161
+ const lang = htmlTag ? parseTagAttributes(htmlTag[0])?.attrs.lang : undefined;
162
+
163
+ const metas: TagInfo[] = [];
164
+ const links: TagInfo[] = [];
165
+ for (const tag of cleaned.matchAll(/<[a-zA-Z][^>]*>/g)) {
166
+ const info = parseTagAttributes(tag[0]);
167
+ if (!info) continue;
168
+ if (info.name === "meta") metas.push(info);
169
+ else if (info.name === "link" && info.attrs.rel && info.attrs.href !== undefined)
170
+ links.push(info);
171
+ }
172
+ return { title, lang, metas, links };
173
+ };
174
+
175
+ const firstMeta = (metas: TagInfo[], match: (attrs: Record<string, string>) => boolean) => {
176
+ for (const meta of metas) if (match(meta.attrs) && meta.attrs.content) return meta.attrs.content;
177
+ return undefined;
178
+ };
179
+
180
+ const allMetas = (metas: TagInfo[], match: (attrs: Record<string, string>) => boolean) =>
181
+ metas.filter((meta) => match(meta.attrs) && meta.attrs.content).map((meta) => meta.attrs.content);
182
+
183
+ const firstLink = (links: TagInfo[], rels: string[]) => {
184
+ const wanted = new Set(rels);
185
+ for (const link of links) {
186
+ const rel = link.attrs.rel?.toLowerCase() ?? "";
187
+ if (rel.split(/\s+/).some((part) => wanted.has(part))) return link.attrs.href;
188
+ }
189
+ return undefined;
190
+ };
191
+
192
+ const makeAbsolute = (value: string | undefined, base: string): string | undefined => {
193
+ if (!value) return undefined;
194
+ try {
195
+ return new URL(value, base).toString();
196
+ } catch {
197
+ return value;
198
+ }
199
+ };
200
+
201
+ const linkKeys = ["icon", "href", "image", "url", "canonical"];
202
+
203
+ /**
204
+ * Build the metadata object golbat-style from raw HTML and the final URL
205
+ * (after redirects). `probes` reports whether robots.txt/sitemap.xml exist.
206
+ */
207
+ export const buildMetadata = (
208
+ html: string,
209
+ baseUrl: string,
210
+ probes?: { robotsFile?: boolean; sitemap?: boolean },
211
+ ): MetaData => {
212
+ const { title, lang, metas, links } = extractHeadTags(html);
213
+
214
+ const charset =
215
+ firstMeta(metas, (attrs) => "charset" in attrs) ??
216
+ firstMeta(metas, (attrs) => attrs["http-equiv"]?.toLowerCase() === "content-type");
217
+
218
+ const faviconHref =
219
+ firstLink(links, ["icon"]) ??
220
+ firstLink(links, ["shortcut icon"]) ??
221
+ firstLink(links, ["apple-touch-icon"]) ??
222
+ firstLink(links, ["apple-touch-icon-precomposed"]) ??
223
+ firstLink(links, ["mask-icon", "fluid-icon"]);
224
+
225
+ const metadata: MetaData = {
226
+ url: baseUrl,
227
+ title: title || firstMeta(metas, (attrs) => attrs.property === "og:title"),
228
+ description: firstMeta(metas, (attrs) => attrs.name === "description"),
229
+ ogTitle: firstMeta(metas, (attrs) => attrs.property === "og:title"),
230
+ ogDescription: firstMeta(metas, (attrs) => attrs.property === "og:description"),
231
+ ogImage: firstMeta(
232
+ metas,
233
+ (attrs) => attrs.property === "og:image" || attrs.property === "og:image:url",
234
+ ),
235
+ ogImageAll: allMetas(
236
+ metas,
237
+ (attrs) => attrs.property === "og:image" || attrs.property === "og:image:url",
238
+ ),
239
+ ogType: firstMeta(metas, (attrs) => attrs.property === "og:type"),
240
+ ogUrl: firstMeta(metas, (attrs) => attrs.property === "og:url"),
241
+ ogSiteName: firstMeta(metas, (attrs) => attrs.property === "og:site_name"),
242
+ ogLocale: firstMeta(metas, (attrs) => attrs.property === "og:locale"),
243
+ twitterCard: firstMeta(
244
+ metas,
245
+ (attrs) => attrs.name === "twitter:card" || attrs.property === "twitter:card",
246
+ ),
247
+ twitterTitle: firstMeta(
248
+ metas,
249
+ (attrs) => attrs.name === "twitter:title" || attrs.property === "twitter:title",
250
+ ),
251
+ twitterDescription: firstMeta(
252
+ metas,
253
+ (attrs) => attrs.name === "twitter:description" || attrs.property === "twitter:description",
254
+ ),
255
+ twitterImage: firstMeta(
256
+ metas,
257
+ (attrs) =>
258
+ attrs.name === "twitter:image" ||
259
+ attrs.property === "twitter:image" ||
260
+ attrs.name === "twitter:image:src",
261
+ ),
262
+ twitterImageAll: allMetas(
263
+ metas,
264
+ (attrs) =>
265
+ attrs.name === "twitter:image" ||
266
+ attrs.property === "twitter:image" ||
267
+ attrs.name === "twitter:image:src",
268
+ ),
269
+ twitterSite: firstMeta(
270
+ metas,
271
+ (attrs) => attrs.name === "twitter:site" || attrs.property === "twitter:site",
272
+ ),
273
+ twitterCreator: firstMeta(
274
+ metas,
275
+ (attrs) => attrs.name === "twitter:creator" || attrs.property === "twitter:creator",
276
+ ),
277
+ language: lang,
278
+ charset,
279
+ viewport: firstMeta(metas, (attrs) => attrs.name === "viewport"),
280
+ robots: firstMeta(metas, (attrs) => attrs.name === "robots"),
281
+ generator: firstMeta(metas, (attrs) => attrs.name === "generator"),
282
+ themeColor: firstMeta(metas, (attrs) => attrs.name === "theme-color"),
283
+ canonical: firstLink(links, ["canonical"]),
284
+ alternate: firstLink(links, ["alternate"]),
285
+ author: firstLink(links, ["author"]),
286
+ prev: firstLink(links, ["prev"]),
287
+ next: firstLink(links, ["next"]),
288
+ search: firstLink(links, ["search"]),
289
+ icon: firstLink(links, ["icon"]),
290
+ appleItunesApp: firstMeta(metas, (attrs) => attrs.name === "apple-itunes-app"),
291
+ appleMobileWebAppCapable: firstMeta(
292
+ metas,
293
+ (attrs) => attrs.name === "apple-mobile-web-app-capable",
294
+ ),
295
+ appleMobileWebAppTitle: firstMeta(
296
+ metas,
297
+ (attrs) => attrs.name === "apple-mobile-web-app-title",
298
+ ),
299
+ formatDetection: firstMeta(metas, (attrs) => attrs.name === "format-detection"),
300
+ favicon: faviconHref,
301
+ };
302
+
303
+ // Resolve every URL-shaped field against the final response URL.
304
+ for (const [key, value] of Object.entries(metadata)) {
305
+ const isUrlShaped =
306
+ linkKeys.some((fragment) => key.toLowerCase().includes(fragment)) || key === "favicon";
307
+ if (!isUrlShaped) continue;
308
+ if (typeof value === "string") metadata[key] = makeAbsolute(value, baseUrl);
309
+ else if (Array.isArray(value))
310
+ metadata[key] = value.map((entry) => makeAbsolute(entry, baseUrl) ?? entry);
311
+ }
312
+
313
+ metadata.favicon = metadata.favicon ?? makeAbsolute("/favicon.ico", baseUrl);
314
+
315
+ if (probes?.robotsFile) metadata.robotsFile = makeAbsolute("/robots.txt", baseUrl);
316
+ if (probes?.sitemap) metadata.sitemap = makeAbsolute("/sitemap.xml", baseUrl);
317
+
318
+ return metadata;
319
+ };
320
+
321
+ /** Collect every meta/link on the page as flat `meta_x` / `link_x` keys. */
322
+ export const appendFullTags = (html: string, metadata: MetaData): void => {
323
+ const { metas, links } = extractHeadTags(html);
324
+ const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9]/g, "_");
325
+ for (const meta of metas) {
326
+ const name = meta.attrs.name || meta.attrs.property || meta.attrs["http-equiv"];
327
+ if (name && meta.attrs.content) metadata[`meta_${sanitize(name)}`] = meta.attrs.content;
328
+ }
329
+ for (const link of links) {
330
+ if (link.attrs.href && link.attrs.rel)
331
+ metadata[`link_${sanitize(link.attrs.rel)}`] = link.attrs.href;
332
+ }
333
+ };
334
+
335
+ const escapeHtml = (value: string): string =>
336
+ value
337
+ .replaceAll("&", "&amp;")
338
+ .replaceAll("<", "&lt;")
339
+ .replaceAll(">", "&gt;")
340
+ .replaceAll('"', "&quot;");
341
+
342
+ /** Rebuild the meta tags that would produce this metadata. */
343
+ export const generateHtmlTags = (metadata: MetaData): string => {
344
+ const tags: string[] = [];
345
+ const pushMeta = (kind: "name" | "property", key: string, value?: string) => {
346
+ if (value) tags.push(`<meta ${kind}="${key}" content="${escapeHtml(value)}" />`);
347
+ };
348
+
349
+ if (metadata.title) tags.push(`<title>${escapeHtml(metadata.title)}</title>`);
350
+ pushMeta("name", "description", metadata.description);
351
+ if (metadata.canonical)
352
+ tags.push(`<link rel="canonical" href="${escapeHtml(metadata.canonical)}" />`);
353
+ pushMeta("property", "og:title", metadata.ogTitle);
354
+ pushMeta("property", "og:description", metadata.ogDescription);
355
+ pushMeta("property", "og:image", metadata.ogImage);
356
+ pushMeta("property", "og:type", metadata.ogType);
357
+ pushMeta("property", "og:url", metadata.ogUrl);
358
+ pushMeta("property", "og:site_name", metadata.ogSiteName);
359
+ pushMeta("name", "twitter:card", metadata.twitterCard);
360
+ pushMeta("name", "twitter:title", metadata.twitterTitle);
361
+ pushMeta("name", "twitter:description", metadata.twitterDescription);
362
+ pushMeta("name", "twitter:image", metadata.twitterImage);
363
+ pushMeta("name", "twitter:site", metadata.twitterSite);
364
+ return tags.join("\n");
365
+ };
@@ -0,0 +1,130 @@
1
+ import type { Connect, Plugin } from "vite";
2
+ import { appendFullTags, buildMetadata, generateHtmlTags, userAgents } from "./metadata";
3
+ import type { MetaData, UaMode } from "./types";
4
+
5
+ const jsonHeaders = {
6
+ "content-type": "application/json; charset=utf-8",
7
+ "cache-control": "no-store",
8
+ };
9
+
10
+ const fetchTimeoutMs = Number(process.env.OGTHING_FETCH_TIMEOUT_MS || 15_000);
11
+ const maxBodyBytes = 5_000_000;
12
+
13
+ const sendJson = (response: import("node:http").ServerResponse, status: number, value: unknown) => {
14
+ response.statusCode = status;
15
+ for (const [name, headerValue] of Object.entries(jsonHeaders))
16
+ response.setHeader(name, headerValue);
17
+ response.end(JSON.stringify(value));
18
+ };
19
+
20
+ const headExists = async (url: string): Promise<boolean> => {
21
+ try {
22
+ const response = await fetch(url, {
23
+ method: "HEAD",
24
+ redirect: "follow",
25
+ signal: AbortSignal.timeout(Math.min(fetchTimeoutMs, 5_000)),
26
+ });
27
+ return response.ok;
28
+ } catch {
29
+ return false;
30
+ }
31
+ };
32
+
33
+ export const inspectUrl = async (
34
+ rawUrl: string,
35
+ uaMode: UaMode,
36
+ full: boolean,
37
+ ): Promise<MetaData> => {
38
+ const target = new URL(rawUrl);
39
+ if (!/^https?:$/.test(target.protocol))
40
+ throw Object.assign(new Error("only http and https urls are supported"), { status: 400 });
41
+
42
+ const userAgent = userAgents[uaMode] ?? userAgents.browser;
43
+ const response = await fetch(target, {
44
+ redirect: "follow",
45
+ signal: AbortSignal.timeout(fetchTimeoutMs),
46
+ headers: {
47
+ "user-agent": userAgent,
48
+ accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
49
+ "accept-language": "en-US,en;q=0.9",
50
+ },
51
+ });
52
+
53
+ if (!response.ok) {
54
+ throw Object.assign(
55
+ new Error(`the site responded ${response.status} ${response.statusText}`.trim()),
56
+ { status: response.status },
57
+ );
58
+ }
59
+
60
+ const html = (await response.text()).slice(0, maxBodyBytes);
61
+ const finalUrl = response.url || target.toString();
62
+ const [robotsFile, sitemap] = await Promise.all([
63
+ headExists(new URL("/robots.txt", finalUrl).toString()),
64
+ headExists(new URL("/sitemap.xml", finalUrl).toString()),
65
+ ]);
66
+
67
+ const metadata = buildMetadata(html, finalUrl, { robotsFile, sitemap });
68
+ metadata.statusCode = response.status;
69
+ if (full) appendFullTags(html, metadata);
70
+ return metadata;
71
+ };
72
+
73
+ export function ogthingApi(): Plugin {
74
+ return {
75
+ name: "ogthing-api",
76
+ configureServer(server) {
77
+ server.middlewares.use((request, response, next) => {
78
+ void handleApiRequest(request, response, next);
79
+ });
80
+ },
81
+ };
82
+ }
83
+
84
+ export const handleApiRequest = async (
85
+ request: import("node:http").IncomingMessage,
86
+ response: import("node:http").ServerResponse,
87
+ next: Connect.NextFunction,
88
+ ) => {
89
+ if (!request.url?.startsWith("/api/")) {
90
+ next();
91
+ return;
92
+ }
93
+ const url = new URL(request.url, "http://127.0.0.1");
94
+
95
+ if (url.pathname === "/api/health" && request.method === "GET") {
96
+ sendJson(response, 200, { ok: true, service: "ogthing", platform: process.platform });
97
+ return;
98
+ }
99
+
100
+ if (url.pathname === "/api/metadata" && request.method === "GET") {
101
+ const rawUrl = url.searchParams.get("url");
102
+ if (!rawUrl) {
103
+ sendJson(response, 400, { error: "a url parameter is required" });
104
+ return;
105
+ }
106
+ let formatted = rawUrl.trim();
107
+ if (!/^https?:\/\//i.test(formatted)) formatted = `https://${formatted}`;
108
+ try {
109
+ const uaMode = (url.searchParams.get("ua") as UaMode) || "browser";
110
+ const full = url.searchParams.get("full") !== "false";
111
+ const metadata = await inspectUrl(formatted, uaMode, full);
112
+ metadata.htmlTags = generateHtmlTags(metadata);
113
+ sendJson(response, 200, metadata);
114
+ } catch (error) {
115
+ const err = error as Error & { status?: number; cause?: Error };
116
+ let message = err.message || "could not fetch the url";
117
+ const causeCode = (err.cause as { code?: string } | undefined)?.code;
118
+ const causeMessage = (err.cause as Error | undefined)?.message ?? "";
119
+ if (/refused/i.test(`${causeCode} ${causeMessage}`) || /unable to connect/i.test(message))
120
+ message = "connection refused - is the server running?";
121
+ else if (causeCode === "ENOTFOUND") message = "host not found - check the address";
122
+ else if (err.name === "TimeoutError" || /timeout|timed out/i.test(message))
123
+ message = "the site took too long to respond";
124
+ sendJson(response, err.status && err.status >= 400 ? err.status : 502, { error: message });
125
+ }
126
+ return;
127
+ }
128
+
129
+ sendJson(response, 404, { error: "not found" });
130
+ };
@@ -0,0 +1,53 @@
1
+ export type UaMode =
2
+ | "browser"
3
+ | "facebook"
4
+ | "twitter"
5
+ | "slack"
6
+ | "discord"
7
+ | "telegram"
8
+ | "whatsapp"
9
+ | "linkedin";
10
+
11
+ export interface MetaData {
12
+ url?: string;
13
+ statusCode?: number;
14
+ htmlTags?: string;
15
+ title?: string;
16
+ description?: string;
17
+ ogTitle?: string;
18
+ ogDescription?: string;
19
+ ogImage?: string;
20
+ ogImageAll?: string[];
21
+ ogType?: string;
22
+ ogUrl?: string;
23
+ ogSiteName?: string;
24
+ ogLocale?: string;
25
+ twitterCard?: string;
26
+ twitterTitle?: string;
27
+ twitterDescription?: string;
28
+ twitterImage?: string;
29
+ twitterImageAll?: string[];
30
+ twitterSite?: string;
31
+ twitterCreator?: string;
32
+ canonical?: string;
33
+ favicon?: string;
34
+ language?: string;
35
+ charset?: string;
36
+ viewport?: string;
37
+ robots?: string;
38
+ generator?: string;
39
+ themeColor?: string;
40
+ alternate?: string;
41
+ author?: string;
42
+ prev?: string;
43
+ next?: string;
44
+ search?: string;
45
+ icon?: string;
46
+ appleItunesApp?: string;
47
+ appleMobileWebAppCapable?: string;
48
+ appleMobileWebAppTitle?: string;
49
+ formatDetection?: string;
50
+ robotsFile?: string;
51
+ sitemap?: string;
52
+ [key: string]: string | string[] | number | undefined;
53
+ }