fix all type errors
This commit is contained in:
parent
f841f766d2
commit
7242c6eb89
50 changed files with 1329 additions and 5763 deletions
|
@ -26,7 +26,10 @@ export function preprocess(css: string, theme: Theme): string {
|
|||
return css.replace(
|
||||
regex,
|
||||
(_, line) =>
|
||||
line.replace(regex2, (_: string, varName: string) => theme[varName]) +
|
||||
line.replace(
|
||||
regex2,
|
||||
(_: string, varName: string) => theme[varName as keyof Theme],
|
||||
) +
|
||||
";" + line.slice(1),
|
||||
);
|
||||
}
|
||||
|
|
2
framework/definitions.d.ts
vendored
2
framework/definitions.d.ts
vendored
|
@ -1,2 +1,4 @@
|
|||
declare function UNWRAP<T>(value: T | null | undefined): T;
|
||||
declare function ASSERT(value: unknown, ...log: unknown[]): asserts value;
|
||||
|
||||
type Timer = ReturnType<typeof setTimeout>;
|
||||
|
|
|
@ -29,4 +29,18 @@ export function jsxDEV(
|
|||
// jsxs
|
||||
export { jsx as jsxs };
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
[name: string]: Record<string, unknown>;
|
||||
}
|
||||
interface ElementChildrenAttribute {
|
||||
children: {};
|
||||
}
|
||||
type Element = engine.Node;
|
||||
type ElementType = keyof IntrinsicElements | engine.Component;
|
||||
type ElementClass = ReturnType<engine.Component>;
|
||||
}
|
||||
}
|
||||
|
||||
import * as engine from "./ssr.ts";
|
||||
|
|
|
@ -72,7 +72,7 @@ export const dynamicTag = (
|
|||
]);
|
||||
|
||||
if (subRender.async > 0) {
|
||||
const marker = marko.$global().cloverAsyncMarker;
|
||||
const marker = marko.$global().cloverAsyncMarker as Async;
|
||||
marker.isAsync = true;
|
||||
|
||||
// Wait for async work to finish
|
||||
|
@ -108,17 +108,21 @@ export const dynamicTag = (
|
|||
};
|
||||
|
||||
export function fork(
|
||||
scopeId: string,
|
||||
scopeId: number,
|
||||
accessor: Accessor,
|
||||
promise: Promise<unknown>,
|
||||
callback: (data: unknown) => void,
|
||||
serializeMarker?: 0 | 1,
|
||||
) {
|
||||
const marker = marko.$global().cloverAsyncMarker;
|
||||
const marker = marko.$global().cloverAsyncMarker as Async;
|
||||
marker.isAsync = true;
|
||||
marko.fork(scopeId, accessor, promise, callback, serializeMarker);
|
||||
}
|
||||
|
||||
interface Async {
|
||||
isAsync: boolean;
|
||||
}
|
||||
|
||||
import * as engine from "./ssr.ts";
|
||||
import type { ServerRenderer } from "marko/html/template";
|
||||
import { type Accessor } from "marko/common/types";
|
||||
|
|
|
@ -6,24 +6,22 @@
|
|||
// Add-ons to the rendering engine can provide opaque data, And retrieve it
|
||||
// within component calls with 'getAddonData'. For example, 'sitegen' uses this
|
||||
// to track needed client scripts without introducing patches to the engine.
|
||||
type Addons = Record<string | symbol, unknown>;
|
||||
|
||||
type AddonData = Record<string | symbol, unknown>;
|
||||
export function ssrSync(node: Node): Result;
|
||||
export function ssrSync<A extends AddonData>(
|
||||
node: Node,
|
||||
addon: AddonData,
|
||||
): Result<A>;
|
||||
export function ssrSync(node: Node, addon: AddonData = {}) {
|
||||
export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
|
||||
export function ssrSync(node: Node, addon: Addons = {}) {
|
||||
const r = initRender(false, addon);
|
||||
const resolved = resolveNode(r, node);
|
||||
return { text: renderNode(resolved), addon };
|
||||
}
|
||||
|
||||
export function ssrAsync(node: Node): Promise<Result>;
|
||||
export function ssrAsync<A extends AddonData>(
|
||||
export function ssrAsync<A extends Addons>(
|
||||
node: Node,
|
||||
addon: AddonData,
|
||||
addon: A,
|
||||
): Promise<Result<A>>;
|
||||
export function ssrAsync(node: Node, addon: AddonData = {}) {
|
||||
export function ssrAsync(node: Node, addon: Addons = {}) {
|
||||
const r = initRender(true, addon);
|
||||
const resolved = resolveNode(r, node);
|
||||
if (r.async === 0) {
|
||||
|
@ -44,7 +42,7 @@ export function html(rawText: string) {
|
|||
return [kDirectHtml, rawText];
|
||||
}
|
||||
|
||||
interface Result<A extends AddonData = AddonData> {
|
||||
interface Result<A extends Addons = Addons> {
|
||||
text: string;
|
||||
addon: A;
|
||||
}
|
||||
|
@ -59,7 +57,7 @@ export interface Render {
|
|||
/** When components reject, those are logged here */
|
||||
rejections: unknown[] | null;
|
||||
/** Add-ons to the rendering engine store state here */
|
||||
addon: AddonData;
|
||||
addon: Addons;
|
||||
}
|
||||
|
||||
export const kElement = Symbol("Element");
|
||||
|
@ -100,7 +98,7 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {
|
|||
if (!node && node !== 0) return ""; // falsy, non numeric
|
||||
if (typeof node !== "object") {
|
||||
if (node === true) return ""; // booleans are ignored
|
||||
if (typeof node === "string") return escapeHTML(node);
|
||||
if (typeof node === "string") return escapeHtml(node);
|
||||
if (typeof node === "number") return String(node); // no escaping ever
|
||||
throw new Error(`Cannot render ${inspect(node)} to HTML`);
|
||||
}
|
||||
|
@ -193,12 +191,12 @@ function renderElement(element: ResolvedElement) {
|
|||
let attr;
|
||||
switch (prop) {
|
||||
default:
|
||||
attr = `${prop}=${quoteIfNeeded(escapeHTML(String(value)))}`;
|
||||
attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
|
||||
break;
|
||||
case "className":
|
||||
// Legacy React Compat
|
||||
case "class":
|
||||
attr = `class=${quoteIfNeeded(escapeHTML(clsx(value)))}`;
|
||||
attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
|
||||
break;
|
||||
case "htmlFor":
|
||||
throw new Error("Do not use the `htmlFor` attribute. Use `for`");
|
||||
|
@ -227,19 +225,19 @@ export function renderStyleAttribute(style: Record<string, string>) {
|
|||
for (const styleName in style) {
|
||||
if (out) out += ";";
|
||||
out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
|
||||
escapeHTML(String(style[styleName]))
|
||||
escapeHtml(String(style[styleName]))
|
||||
}`;
|
||||
}
|
||||
return "style=" + quoteIfNeeded(out);
|
||||
}
|
||||
export function quoteIfNeeded(text) {
|
||||
export function quoteIfNeeded(text: string) {
|
||||
if (text.includes(" ")) return '"' + text + '"';
|
||||
return text;
|
||||
}
|
||||
|
||||
// -- utility functions --
|
||||
|
||||
export function initRender(allowAsync: boolean, addon: AddonData): Render {
|
||||
export function initRender(allowAsync: boolean, addon: Addons): Render {
|
||||
return {
|
||||
async: allowAsync ? 0 : -1,
|
||||
rejections: null,
|
||||
|
@ -270,11 +268,10 @@ export function inspect(object: unknown) {
|
|||
|
||||
export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
|
||||
export function clsx(mix: ClsxInput) {
|
||||
var k, y, str;
|
||||
var k, y, str = "";
|
||||
if (typeof mix === "string") {
|
||||
return mix;
|
||||
} else if (typeof mix === "object") {
|
||||
str = "";
|
||||
if (Array.isArray(mix)) {
|
||||
for (k = 0; k < mix.length; k++) {
|
||||
if (mix[k] && (y = clsx(mix[k]))) {
|
||||
|
@ -294,7 +291,7 @@ export function clsx(mix: ClsxInput) {
|
|||
return str;
|
||||
}
|
||||
|
||||
export const escapeHTML = (unsafeText: string) =>
|
||||
export const escapeHtml = (unsafeText: string) =>
|
||||
String(unsafeText)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`");
|
||||
|
|
|
@ -162,7 +162,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) {
|
|||
) + '\nimport { Script as CloverScriptInclude } from "#sitegen";';
|
||||
}
|
||||
|
||||
src = marko.compileSync(filepath, {}).code;
|
||||
src = marko.compileSync(src, filepath).code;
|
||||
src = src.replace("marko/debug/html", "#ssr/marko");
|
||||
return loadEsbuildCode(module, filepath, src);
|
||||
}
|
||||
|
@ -174,7 +174,7 @@ function loadMdx(module: NodeJS.Module, filepath: string) {
|
|||
return loadEsbuildCode(module, filepath, src);
|
||||
}
|
||||
|
||||
function loadCss(module: NodeJS.Module, filepath: string) {
|
||||
function loadCss(module: NodeJS.Module, _filepath: string) {
|
||||
module.exports = {};
|
||||
}
|
||||
|
||||
|
@ -228,6 +228,12 @@ declare global {
|
|||
}
|
||||
}
|
||||
}
|
||||
declare module "node:module" {
|
||||
export function _resolveFilename(
|
||||
id: string,
|
||||
parent: NodeJS.Module,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
import * as fs from "./fs.ts";
|
||||
import * as path from "node:path";
|
||||
|
|
24
framework/meta.ts
Normal file
24
framework/meta.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
export interface Meta {
|
||||
title: string;
|
||||
description?: string | undefined;
|
||||
openGraph?: OpenGraph;
|
||||
alternates?: Alternates;
|
||||
}
|
||||
export interface OpenGraph {
|
||||
title?: string;
|
||||
description?: string | undefined;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
export interface Alternates {
|
||||
canonical: string;
|
||||
types: { [mime: string]: AlternateType };
|
||||
}
|
||||
export interface AlternateType {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
export function renderMeta({ title }: Meta): string {
|
||||
return `<title>${esc(title)}</title>`;
|
||||
}
|
||||
import { escapeHtml as esc } from "./engine/ssr.ts";
|
|
@ -1,13 +0,0 @@
|
|||
import { resolveMetadata } from "./merge";
|
||||
import { renderMetadata } from "./render";
|
||||
import { Metadata } from "./types";
|
||||
|
||||
export * from "./types";
|
||||
export * from "./merge";
|
||||
export * from "./render";
|
||||
|
||||
export function resolveAndRenderMetadata(
|
||||
...metadata: [Metadata, ...Metadata[]]
|
||||
) {
|
||||
return renderMetadata(resolveMetadata(...metadata));
|
||||
}
|
|
@ -1,154 +0,0 @@
|
|||
import { createDefaultMetadata } from "./nextjs/default-metadata";
|
||||
import { resolveAsArrayOrUndefined } from "./nextjs/generate/utils";
|
||||
import {
|
||||
resolveAlternates,
|
||||
resolveAppleWebApp,
|
||||
resolveAppLinks,
|
||||
resolveRobots,
|
||||
resolveThemeColor,
|
||||
resolveVerification,
|
||||
resolveViewport,
|
||||
} from "./nextjs/resolvers/resolve-basics";
|
||||
import { resolveIcons } from "./nextjs/resolvers/resolve-icons";
|
||||
import {
|
||||
resolveOpenGraph,
|
||||
resolveTwitter,
|
||||
} from "./nextjs/resolvers/resolve-opengraph";
|
||||
import { resolveTitle } from "./nextjs/resolvers/resolve-title";
|
||||
import type {
|
||||
Metadata,
|
||||
ResolvedMetadata,
|
||||
} from "./nextjs/types/metadata-interface";
|
||||
|
||||
type MetadataAccumulationOptions = {
|
||||
pathname: string;
|
||||
};
|
||||
|
||||
// Merge the source metadata into the resolved target metadata.
|
||||
function merge(
|
||||
target: ResolvedMetadata,
|
||||
source: Metadata | null,
|
||||
titleTemplates: {
|
||||
title?: string | null;
|
||||
twitter?: string | null;
|
||||
openGraph?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const metadataBase = source?.metadataBase || target.metadataBase;
|
||||
for (const key_ in source) {
|
||||
const key = key_ as keyof Metadata;
|
||||
|
||||
switch (key) {
|
||||
case "title": {
|
||||
target.title = resolveTitle(source.title, titleTemplates.title);
|
||||
break;
|
||||
}
|
||||
case "alternates": {
|
||||
target.alternates = resolveAlternates(source.alternates, metadataBase, {
|
||||
pathname: (source as any)._pathname ?? "/",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "openGraph": {
|
||||
target.openGraph = resolveOpenGraph(source.openGraph, metadataBase);
|
||||
if (target.openGraph) {
|
||||
target.openGraph.title = resolveTitle(
|
||||
target.openGraph.title,
|
||||
titleTemplates.openGraph,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "twitter": {
|
||||
target.twitter = resolveTwitter(source.twitter, metadataBase);
|
||||
if (target.twitter) {
|
||||
target.twitter.title = resolveTitle(
|
||||
target.twitter.title,
|
||||
titleTemplates.twitter,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "verification":
|
||||
target.verification = resolveVerification(source.verification);
|
||||
break;
|
||||
case "viewport": {
|
||||
target.viewport = resolveViewport(source.viewport);
|
||||
break;
|
||||
}
|
||||
case "icons": {
|
||||
target.icons = resolveIcons(source.icons);
|
||||
break;
|
||||
}
|
||||
case "appleWebApp":
|
||||
target.appleWebApp = resolveAppleWebApp(source.appleWebApp);
|
||||
break;
|
||||
case "appLinks":
|
||||
target.appLinks = resolveAppLinks(source.appLinks);
|
||||
break;
|
||||
case "robots": {
|
||||
target.robots = resolveRobots(source.robots);
|
||||
break;
|
||||
}
|
||||
case "themeColor": {
|
||||
target.themeColor = resolveThemeColor(source.themeColor);
|
||||
break;
|
||||
}
|
||||
case "archives":
|
||||
case "assets":
|
||||
case "bookmarks":
|
||||
case "keywords":
|
||||
case "authors": {
|
||||
// FIXME: type inferring
|
||||
// @ts-ignore
|
||||
target[key] = resolveAsArrayOrUndefined(source[key]) || null;
|
||||
break;
|
||||
}
|
||||
// directly assign fields that fallback to null
|
||||
case "applicationName":
|
||||
case "description":
|
||||
case "generator":
|
||||
case "creator":
|
||||
case "publisher":
|
||||
case "category":
|
||||
case "classification":
|
||||
case "referrer":
|
||||
case "colorScheme":
|
||||
case "itunes":
|
||||
case "formatDetection":
|
||||
case "manifest":
|
||||
// @ts-ignore TODO: support inferring
|
||||
target[key] = source[key] || null;
|
||||
break;
|
||||
case "other":
|
||||
target.other = Object.assign({}, target.other, source.other);
|
||||
break;
|
||||
case "metadataBase":
|
||||
target.metadataBase = metadataBase;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
export interface MetadataWithPathname extends Metadata {
|
||||
/** Set by framework author to the pathname of the page defining this metadata. */
|
||||
_pathname?: string;
|
||||
}
|
||||
|
||||
export function resolveMetadata(
|
||||
...metadata: [MetadataWithPathname, ...MetadataWithPathname[]]
|
||||
) {
|
||||
const base = createDefaultMetadata();
|
||||
for (const item of metadata) {
|
||||
merge(base, item, {
|
||||
title: base.title?.template,
|
||||
twitter: base.twitter?.title?.template,
|
||||
openGraph: base.openGraph?.title?.template,
|
||||
});
|
||||
}
|
||||
return base;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import type { Viewport } from "./types/extra-types";
|
||||
import type { Icons } from "./types/metadata-types";
|
||||
|
||||
export const ViewPortKeys: { [k in keyof Viewport]: string } = {
|
||||
width: "width",
|
||||
height: "height",
|
||||
initialScale: "initial-scale",
|
||||
minimumScale: "minimum-scale",
|
||||
maximumScale: "maximum-scale",
|
||||
viewportFit: "viewport-fit",
|
||||
userScalable: "user-scalable",
|
||||
interactiveWidget: "interactive-widget",
|
||||
} as const;
|
||||
|
||||
export const IconKeys: (keyof Icons)[] = ["icon", "shortcut", "apple", "other"];
|
|
@ -1,50 +0,0 @@
|
|||
import type { ResolvedMetadata } from "./types/metadata-interface";
|
||||
import process from "node:process";
|
||||
|
||||
export function createDefaultMetadata(): ResolvedMetadata {
|
||||
const defaultMetadataBase =
|
||||
process.env.NODE_ENV === "production" && process.env.VERCEL_URL
|
||||
? new URL(`https://${process.env.VERCEL_URL}`)
|
||||
: null;
|
||||
|
||||
return {
|
||||
viewport: "width=device-width, initial-scale=1",
|
||||
metadataBase: defaultMetadataBase,
|
||||
|
||||
// Other values are all null
|
||||
title: null,
|
||||
description: null,
|
||||
applicationName: null,
|
||||
authors: null,
|
||||
generator: null,
|
||||
keywords: null,
|
||||
referrer: null,
|
||||
themeColor: null,
|
||||
colorScheme: null,
|
||||
creator: null,
|
||||
publisher: null,
|
||||
robots: null,
|
||||
manifest: null,
|
||||
alternates: {
|
||||
canonical: null,
|
||||
languages: null,
|
||||
media: null,
|
||||
types: null,
|
||||
},
|
||||
icons: null,
|
||||
openGraph: null,
|
||||
twitter: null,
|
||||
verification: {},
|
||||
appleWebApp: null,
|
||||
formatDetection: null,
|
||||
itunes: null,
|
||||
abstract: null,
|
||||
appLinks: null,
|
||||
archives: null,
|
||||
assets: null,
|
||||
bookmarks: null,
|
||||
category: null,
|
||||
classification: null,
|
||||
other: {},
|
||||
};
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
import type { ResolvedMetadata } from "../types/metadata-interface";
|
||||
|
||||
import React from "react";
|
||||
import { AlternateLinkDescriptor } from "../types/alternative-urls-types";
|
||||
|
||||
function AlternateLink({
|
||||
descriptor,
|
||||
...props
|
||||
}: {
|
||||
descriptor: AlternateLinkDescriptor;
|
||||
} & React.LinkHTMLAttributes<HTMLLinkElement>) {
|
||||
if (!descriptor.url) return null;
|
||||
return (
|
||||
<link
|
||||
{...props}
|
||||
{...(descriptor.title && { title: descriptor.title })}
|
||||
href={descriptor.url.toString()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlternatesMetadata({
|
||||
alternates,
|
||||
}: {
|
||||
alternates: ResolvedMetadata["alternates"];
|
||||
}) {
|
||||
if (!alternates) return null;
|
||||
const { canonical, languages, media, types } = alternates;
|
||||
return (
|
||||
<>
|
||||
{canonical
|
||||
? <AlternateLink rel="canonical" descriptor={canonical} />
|
||||
: null}
|
||||
{languages
|
||||
? Object.entries(languages).map(([locale, descriptors]) => {
|
||||
return descriptors?.map((descriptor, index) => (
|
||||
<AlternateLink
|
||||
rel="alternate"
|
||||
key={index}
|
||||
hrefLang={locale}
|
||||
descriptor={descriptor}
|
||||
/>
|
||||
));
|
||||
})
|
||||
: null}
|
||||
{media
|
||||
? Object.entries(media).map(([mediaName, descriptors]) =>
|
||||
descriptors?.map((descriptor, index) => (
|
||||
<AlternateLink
|
||||
rel="alternate"
|
||||
key={index}
|
||||
media={mediaName}
|
||||
descriptor={descriptor}
|
||||
/>
|
||||
))
|
||||
)
|
||||
: null}
|
||||
{types
|
||||
? Object.entries(types).map(([type, descriptors]) =>
|
||||
descriptors?.map((descriptor, index) => (
|
||||
<AlternateLink
|
||||
rel="alternate"
|
||||
key={index}
|
||||
type={type}
|
||||
descriptor={descriptor}
|
||||
/>
|
||||
))
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,171 +0,0 @@
|
|||
import type { ResolvedMetadata } from "../types/metadata-interface";
|
||||
|
||||
import React from "react";
|
||||
import { Meta, MultiMeta } from "./meta";
|
||||
|
||||
export function BasicMetadata({ metadata }: { metadata: ResolvedMetadata }) {
|
||||
return (
|
||||
<>
|
||||
<meta charSet="utf-8" />
|
||||
{metadata.title !== null && metadata.title.absolute
|
||||
? <title>{metadata.title.absolute}</title>
|
||||
: null}
|
||||
<Meta name="description" content={metadata.description} />
|
||||
<Meta name="application-name" content={metadata.applicationName} />
|
||||
{metadata.authors
|
||||
? metadata.authors.map((author, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{author.url && <link rel="author" href={author.url.toString()} />}
|
||||
<Meta name="author" content={author.name} />
|
||||
</React.Fragment>
|
||||
))
|
||||
: null}
|
||||
{metadata.manifest
|
||||
? <link rel="manifest" href={metadata.manifest.toString()} />
|
||||
: null}
|
||||
<Meta name="generator" content={metadata.generator} />
|
||||
<Meta name="keywords" content={metadata.keywords?.join(",")} />
|
||||
<Meta name="referrer" content={metadata.referrer} />
|
||||
{metadata.themeColor
|
||||
? metadata.themeColor.map((themeColor, index) => (
|
||||
<Meta
|
||||
key={index}
|
||||
name="theme-color"
|
||||
content={themeColor.color}
|
||||
media={themeColor.media}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
<Meta name="color-scheme" content={metadata.colorScheme} />
|
||||
<Meta name="viewport" content={metadata.viewport} />
|
||||
<Meta name="creator" content={metadata.creator} />
|
||||
<Meta name="publisher" content={metadata.publisher} />
|
||||
<Meta name="robots" content={metadata.robots?.basic} />
|
||||
<Meta name="googlebot" content={metadata.robots?.googleBot} />
|
||||
<Meta name="abstract" content={metadata.abstract} />
|
||||
{metadata.archives
|
||||
? metadata.archives.map((archive) => (
|
||||
<link rel="archives" href={archive} key={archive} />
|
||||
))
|
||||
: null}
|
||||
{metadata.assets
|
||||
? metadata.assets.map((asset) => (
|
||||
<link rel="assets" href={asset} key={asset} />
|
||||
))
|
||||
: null}
|
||||
{metadata.bookmarks
|
||||
? metadata.bookmarks.map((bookmark) => (
|
||||
<link rel="bookmarks" href={bookmark} key={bookmark} />
|
||||
))
|
||||
: null}
|
||||
<Meta name="category" content={metadata.category} />
|
||||
<Meta name="classification" content={metadata.classification} />
|
||||
{metadata.other
|
||||
? Object.entries(metadata.other).map(([name, content]) => (
|
||||
<Meta
|
||||
key={name}
|
||||
name={name}
|
||||
content={Array.isArray(content) ? content.join(",") : content}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItunesMeta({ itunes }: { itunes: ResolvedMetadata["itunes"] }) {
|
||||
if (!itunes) return null;
|
||||
const { appId, appArgument } = itunes;
|
||||
let content = `app-id=${appId}`;
|
||||
if (appArgument) {
|
||||
content += `, app-argument=${appArgument}`;
|
||||
}
|
||||
return <meta name="apple-itunes-app" content={content} />;
|
||||
}
|
||||
|
||||
const formatDetectionKeys = [
|
||||
"telephone",
|
||||
"date",
|
||||
"address",
|
||||
"email",
|
||||
"url",
|
||||
] as const;
|
||||
export function FormatDetectionMeta({
|
||||
formatDetection,
|
||||
}: {
|
||||
formatDetection: ResolvedMetadata["formatDetection"];
|
||||
}) {
|
||||
if (!formatDetection) return null;
|
||||
let content = "";
|
||||
for (const key of formatDetectionKeys) {
|
||||
if (key in formatDetection) {
|
||||
if (content) content += ", ";
|
||||
content += `${key}=no`;
|
||||
}
|
||||
}
|
||||
return <meta name="format-detection" content={content} />;
|
||||
}
|
||||
|
||||
export function AppleWebAppMeta({
|
||||
appleWebApp,
|
||||
}: {
|
||||
appleWebApp: ResolvedMetadata["appleWebApp"];
|
||||
}) {
|
||||
if (!appleWebApp) return null;
|
||||
const { capable, title, startupImage, statusBarStyle } = appleWebApp;
|
||||
|
||||
return (
|
||||
<>
|
||||
{capable
|
||||
? <meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
: null}
|
||||
<Meta name="apple-mobile-web-app-title" content={title} />
|
||||
{startupImage
|
||||
? startupImage.map((image, index) => (
|
||||
<link
|
||||
key={index}
|
||||
href={image.url}
|
||||
media={image.media}
|
||||
rel="apple-touch-startup-image"
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{statusBarStyle
|
||||
? (
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content={statusBarStyle}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationMeta({
|
||||
verification,
|
||||
}: {
|
||||
verification: ResolvedMetadata["verification"];
|
||||
}) {
|
||||
if (!verification) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MultiMeta
|
||||
namePrefix="google-site-verification"
|
||||
contents={verification.google}
|
||||
/>
|
||||
<MultiMeta namePrefix="y_key" contents={verification.yahoo} />
|
||||
<MultiMeta
|
||||
namePrefix="yandex-verification"
|
||||
contents={verification.yandex}
|
||||
/>
|
||||
<MultiMeta namePrefix="me" contents={verification.me} />
|
||||
{verification.other
|
||||
? Object.entries(verification.other).map(([key, value], index) => (
|
||||
<MultiMeta key={key + index} namePrefix={key} contents={value} />
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
import type { ResolvedMetadata } from "../types/metadata-interface";
|
||||
import type { Icon, IconDescriptor } from "../types/metadata-types";
|
||||
|
||||
import React from "react";
|
||||
|
||||
function IconDescriptorLink({ icon }: { icon: IconDescriptor }) {
|
||||
const { url, rel = "icon", ...props } = icon;
|
||||
|
||||
return <link rel={rel} href={url.toString()} {...props} />;
|
||||
}
|
||||
|
||||
function IconLink({ rel, icon }: { rel?: string; icon: Icon }) {
|
||||
if (typeof icon === "object" && !(icon instanceof URL)) {
|
||||
if (rel) icon.rel = rel;
|
||||
return <IconDescriptorLink icon={icon} />;
|
||||
} else {
|
||||
const href = icon.toString();
|
||||
return <link rel={rel} href={href} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function IconsMetadata({ icons }: { icons: ResolvedMetadata["icons"] }) {
|
||||
if (!icons) return null;
|
||||
|
||||
const shortcutList = icons.shortcut;
|
||||
const iconList = icons.icon;
|
||||
const appleList = icons.apple;
|
||||
const otherList = icons.other;
|
||||
|
||||
return (
|
||||
<>
|
||||
{shortcutList
|
||||
? shortcutList.map((icon, index) => (
|
||||
<IconLink
|
||||
key={`shortcut-${index}`}
|
||||
rel="shortcut icon"
|
||||
icon={icon}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{iconList
|
||||
? iconList.map((icon, index) => (
|
||||
<IconLink key={`shortcut-${index}`} rel="icon" icon={icon} />
|
||||
))
|
||||
: null}
|
||||
{appleList
|
||||
? appleList.map((icon, index) => (
|
||||
<IconLink
|
||||
key={`apple-${index}`}
|
||||
rel="apple-touch-icon"
|
||||
icon={icon}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{otherList
|
||||
? otherList.map((icon, index) => (
|
||||
<IconDescriptorLink key={`other-${index}`} icon={icon} />
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,124 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export function Meta({
|
||||
name,
|
||||
property,
|
||||
content,
|
||||
media,
|
||||
}: {
|
||||
name?: string;
|
||||
property?: string;
|
||||
media?: string;
|
||||
content: string | number | URL | null | undefined;
|
||||
}): React.ReactElement | null {
|
||||
if (typeof content !== "undefined" && content !== null && content !== "") {
|
||||
return (
|
||||
<meta
|
||||
{...(name ? { name } : { property })}
|
||||
{...(media ? { media } : undefined)}
|
||||
content={typeof content === "string" ? content : content.toString()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type ExtendMetaContent = Record<
|
||||
string,
|
||||
undefined | string | URL | number | boolean | null | undefined
|
||||
>;
|
||||
type MultiMetaContent =
|
||||
| (ExtendMetaContent | string | URL | number)[]
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function camelToSnake(camelCaseStr: string) {
|
||||
return camelCaseStr.replace(/([A-Z])/g, function (match) {
|
||||
return "_" + match.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
function getMetaKey(prefix: string, key: string) {
|
||||
// Use `twitter:image` and `og:image` instead of `twitter:image:url` and `og:image:url`
|
||||
// to be more compatible as it's a more common format
|
||||
if ((prefix === "og:image" || prefix === "twitter:image") && key === "url") {
|
||||
return prefix;
|
||||
}
|
||||
if (prefix.startsWith("og:") || prefix.startsWith("twitter:")) {
|
||||
key = camelToSnake(key);
|
||||
}
|
||||
return prefix + ":" + key;
|
||||
}
|
||||
|
||||
function ExtendMeta({
|
||||
content,
|
||||
namePrefix,
|
||||
propertyPrefix,
|
||||
}: {
|
||||
content?: ExtendMetaContent;
|
||||
namePrefix?: string;
|
||||
propertyPrefix?: string;
|
||||
}) {
|
||||
const keyPrefix = namePrefix || propertyPrefix;
|
||||
if (!content) return null;
|
||||
return (
|
||||
<React.Fragment>
|
||||
{Object.entries(content).map(([k, v], index) => {
|
||||
return typeof v === "undefined" ? null : (
|
||||
<Meta
|
||||
key={keyPrefix + ":" + k + "_" + index}
|
||||
{...(propertyPrefix && { property: getMetaKey(propertyPrefix, k) })}
|
||||
{...(namePrefix && { name: getMetaKey(namePrefix, k) })}
|
||||
content={typeof v === "string" ? v : v?.toString()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiMeta({
|
||||
propertyPrefix,
|
||||
namePrefix,
|
||||
contents,
|
||||
}: {
|
||||
propertyPrefix?: string;
|
||||
namePrefix?: string;
|
||||
contents?: MultiMetaContent | null;
|
||||
}) {
|
||||
if (typeof contents === "undefined" || contents === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyPrefix = propertyPrefix || namePrefix;
|
||||
return (
|
||||
<>
|
||||
{contents.map((content, index) => {
|
||||
if (
|
||||
typeof content === "string" ||
|
||||
typeof content === "number" ||
|
||||
content instanceof URL
|
||||
) {
|
||||
return (
|
||||
<Meta
|
||||
key={keyPrefix + "_" + index}
|
||||
{...(propertyPrefix
|
||||
? { property: propertyPrefix }
|
||||
: { name: namePrefix })}
|
||||
content={content}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ExtendMeta
|
||||
key={keyPrefix + "_" + index}
|
||||
namePrefix={namePrefix}
|
||||
propertyPrefix={propertyPrefix}
|
||||
content={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,316 +0,0 @@
|
|||
import type { ResolvedMetadata } from "../types/metadata-interface";
|
||||
import type { TwitterAppDescriptor } from "../types/twitter-types";
|
||||
|
||||
import React from "react";
|
||||
import { Meta, MultiMeta } from "./meta";
|
||||
|
||||
export function OpenGraphMetadata({
|
||||
openGraph,
|
||||
}: {
|
||||
openGraph: ResolvedMetadata["openGraph"];
|
||||
}) {
|
||||
if (!openGraph) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let typedOpenGraph;
|
||||
if ("type" in openGraph) {
|
||||
switch (openGraph.type) {
|
||||
case "website":
|
||||
typedOpenGraph = <Meta property="og:type" content="website" />;
|
||||
break;
|
||||
case "article":
|
||||
typedOpenGraph = (
|
||||
<>
|
||||
<Meta property="og:type" content="article" />
|
||||
<Meta
|
||||
property="article:published_time"
|
||||
content={openGraph.publishedTime?.toString()}
|
||||
/>
|
||||
<Meta
|
||||
property="article:modified_time"
|
||||
content={openGraph.modifiedTime?.toString()}
|
||||
/>
|
||||
<Meta
|
||||
property="article:expiration_time"
|
||||
content={openGraph.expirationTime?.toString()}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="article:author"
|
||||
contents={openGraph.authors}
|
||||
/>
|
||||
<Meta property="article:section" content={openGraph.section} />
|
||||
<MultiMeta propertyPrefix="article:tag" contents={openGraph.tags} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "book":
|
||||
typedOpenGraph = (
|
||||
<>
|
||||
<Meta property="og:type" content="book" />
|
||||
<Meta property="book:isbn" content={openGraph.isbn} />
|
||||
<Meta
|
||||
property="book:release_date"
|
||||
content={openGraph.releaseDate}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="book:author"
|
||||
contents={openGraph.authors}
|
||||
/>
|
||||
<MultiMeta propertyPrefix="book:tag" contents={openGraph.tags} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "profile":
|
||||
typedOpenGraph = (
|
||||
<>
|
||||
<Meta property="og:type" content="profile" />
|
||||
<Meta property="profile:first_name" content={openGraph.firstName} />
|
||||
<Meta property="profile:last_name" content={openGraph.lastName} />
|
||||
<Meta property="profile:username" content={openGraph.username} />
|
||||
<Meta property="profile:gender" content={openGraph.gender} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "music.song":
|
||||
typedOpenGraph = (
|
||||
<>
|
||||
<Meta property="og:type" content="music.song" />
|
||||
<Meta
|
||||
property="music:duration"
|
||||
content={openGraph.duration?.toString()}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="music:album"
|
||||
contents={openGraph.albums}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="music:musician"
|
||||
contents={openGraph.musicians}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "music.album":
|
||||
typedOpenGraph = (
|
||||
<>
|
||||
<Meta property="og:type" content="music.album" />
|
||||
<MultiMeta propertyPrefix="music:song" contents={openGraph.songs} />
|
||||
<MultiMeta
|
||||
propertyPrefix="music:musician"
|
||||
contents={openGraph.musicians}
|
||||
/>
|
||||
<Meta
|
||||
property="music:release_date"
|
||||
content={openGraph.releaseDate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "music.playlist":
|
||||
typedOpenGraph = (
|
||||
<>
|
||||
<Meta property="og:type" content="music.playlist" />
|
||||
<MultiMeta propertyPrefix="music:song" contents={openGraph.songs} />
|
||||
<MultiMeta
|
||||
propertyPrefix="music:creator"
|
||||
contents={openGraph.creators}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "music.radio_station":
|
||||
typedOpenGraph = (
|
||||
<>
|
||||
<Meta property="og:type" content="music.radio_station" />
|
||||
<MultiMeta
|
||||
propertyPrefix="music:creator"
|
||||
contents={openGraph.creators}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "video.movie":
|
||||
typedOpenGraph = (
|
||||
<>
|
||||
<Meta property="og:type" content="video.movie" />
|
||||
<MultiMeta
|
||||
propertyPrefix="video:actor"
|
||||
contents={openGraph.actors}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="video:director"
|
||||
contents={openGraph.directors}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="video:writer"
|
||||
contents={openGraph.writers}
|
||||
/>
|
||||
<Meta property="video:duration" content={openGraph.duration} />
|
||||
<Meta
|
||||
property="video:release_date"
|
||||
content={openGraph.releaseDate}
|
||||
/>
|
||||
<MultiMeta propertyPrefix="video:tag" contents={openGraph.tags} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "video.episode":
|
||||
typedOpenGraph = (
|
||||
<>
|
||||
<Meta property="og:type" content="video.episode" />
|
||||
<MultiMeta
|
||||
propertyPrefix="video:actor"
|
||||
contents={openGraph.actors}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="video:director"
|
||||
contents={openGraph.directors}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="video:writer"
|
||||
contents={openGraph.writers}
|
||||
/>
|
||||
<Meta property="video:duration" content={openGraph.duration} />
|
||||
<Meta
|
||||
property="video:release_date"
|
||||
content={openGraph.releaseDate}
|
||||
/>
|
||||
<MultiMeta propertyPrefix="video:tag" contents={openGraph.tags} />
|
||||
<Meta property="video:series" content={openGraph.series} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "video.tv_show":
|
||||
typedOpenGraph = <Meta property="og:type" content="video.tv_show" />;
|
||||
break;
|
||||
case "video.other":
|
||||
typedOpenGraph = <Meta property="og:type" content="video.other" />;
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid OpenGraph type: " + (openGraph as any).type);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta property="og:determiner" content={openGraph.determiner} />
|
||||
<Meta property="og:title" content={openGraph.title?.absolute} />
|
||||
<Meta property="og:description" content={openGraph.description} />
|
||||
<Meta property="og:url" content={openGraph.url?.toString()} />
|
||||
<Meta property="og:site_name" content={openGraph.siteName} />
|
||||
<Meta property="og:locale" content={openGraph.locale} />
|
||||
<Meta property="og:country_name" content={openGraph.countryName} />
|
||||
<Meta property="og:ttl" content={openGraph.ttl?.toString()} />
|
||||
<MultiMeta propertyPrefix="og:image" contents={openGraph.images} />
|
||||
<MultiMeta propertyPrefix="og:video" contents={openGraph.videos} />
|
||||
<MultiMeta propertyPrefix="og:audio" contents={openGraph.audio} />
|
||||
<MultiMeta propertyPrefix="og:email" contents={openGraph.emails} />
|
||||
<MultiMeta
|
||||
propertyPrefix="og:phone_number"
|
||||
contents={openGraph.phoneNumbers}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="og:fax_number"
|
||||
contents={openGraph.faxNumbers}
|
||||
/>
|
||||
<MultiMeta
|
||||
propertyPrefix="og:locale:alternate"
|
||||
contents={openGraph.alternateLocale}
|
||||
/>
|
||||
{typedOpenGraph}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TwitterAppItem({
|
||||
app,
|
||||
type,
|
||||
}: {
|
||||
app: TwitterAppDescriptor;
|
||||
type: "iphone" | "ipad" | "googleplay";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Meta name={`twitter:app:name:${type}`} content={app.name} />
|
||||
<Meta name={`twitter:app:id:${type}`} content={app.id[type]} />
|
||||
<Meta
|
||||
name={`twitter:app:url:${type}`}
|
||||
content={app.url?.[type]?.toString()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwitterMetadata({
|
||||
twitter,
|
||||
}: {
|
||||
twitter: ResolvedMetadata["twitter"];
|
||||
}) {
|
||||
if (!twitter) return null;
|
||||
const { card } = twitter;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta name="twitter:card" content={card} />
|
||||
<Meta name="twitter:site" content={twitter.site} />
|
||||
<Meta name="twitter:site:id" content={twitter.siteId} />
|
||||
<Meta name="twitter:creator" content={twitter.creator} />
|
||||
<Meta name="twitter:creator:id" content={twitter.creatorId} />
|
||||
<Meta name="twitter:title" content={twitter.title?.absolute} />
|
||||
<Meta name="twitter:description" content={twitter.description} />
|
||||
<MultiMeta namePrefix="twitter:image" contents={twitter.images} />
|
||||
{card === "player"
|
||||
? twitter.players.map((player, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Meta
|
||||
name="twitter:player"
|
||||
content={player.playerUrl.toString()}
|
||||
/>
|
||||
<Meta
|
||||
name="twitter:player:stream"
|
||||
content={player.streamUrl.toString()}
|
||||
/>
|
||||
<Meta name="twitter:player:width" content={player.width} />
|
||||
<Meta name="twitter:player:height" content={player.height} />
|
||||
</React.Fragment>
|
||||
))
|
||||
: null}
|
||||
{card === "app"
|
||||
? (
|
||||
<>
|
||||
<TwitterAppItem app={twitter.app} type="iphone" />
|
||||
<TwitterAppItem app={twitter.app} type="ipad" />
|
||||
<TwitterAppItem app={twitter.app} type="googleplay" />
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppLinksMeta({
|
||||
appLinks,
|
||||
}: {
|
||||
appLinks: ResolvedMetadata["appLinks"];
|
||||
}) {
|
||||
if (!appLinks) return null;
|
||||
return (
|
||||
<>
|
||||
<MultiMeta propertyPrefix="al:ios" contents={appLinks.ios} />
|
||||
<MultiMeta propertyPrefix="al:iphone" contents={appLinks.iphone} />
|
||||
<MultiMeta propertyPrefix="al:ipad" contents={appLinks.ipad} />
|
||||
<MultiMeta propertyPrefix="al:android" contents={appLinks.android} />
|
||||
<MultiMeta
|
||||
propertyPrefix="al:windows_phone"
|
||||
contents={appLinks.windows_phone}
|
||||
/>
|
||||
<MultiMeta propertyPrefix="al:windows" contents={appLinks.windows} />
|
||||
<MultiMeta
|
||||
propertyPrefix="al:windows_universal"
|
||||
contents={appLinks.windows_universal}
|
||||
/>
|
||||
<MultiMeta propertyPrefix="al:web" contents={appLinks.web} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
function resolveArray<T>(value: T): T[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
|
||||
function resolveAsArrayOrUndefined<T extends unknown | readonly unknown[]>(
|
||||
value: T | T[] | undefined | null,
|
||||
): undefined | T[] {
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
|
||||
export { resolveArray, resolveAsArrayOrUndefined };
|
|
@ -1,67 +0,0 @@
|
|||
import { isMetadataRoute, isMetadataRouteFile } from "./is-metadata-route";
|
||||
import path from "../../shared/lib/isomorphic/path";
|
||||
import { djb2Hash } from "../../shared/lib/hash";
|
||||
|
||||
/*
|
||||
* If there's special convention like (...) or @ in the page path,
|
||||
* Give it a unique hash suffix to avoid conflicts
|
||||
*
|
||||
* e.g.
|
||||
* /app/open-graph.tsx -> /open-graph/route
|
||||
* /app/(post)/open-graph.tsx -> /open-graph/route-[0-9a-z]{6}
|
||||
*/
|
||||
export function getMetadataRouteSuffix(page: string) {
|
||||
let suffix = "";
|
||||
|
||||
if ((page.includes("(") && page.includes(")")) || page.includes("@")) {
|
||||
suffix = djb2Hash(page).toString(36).slice(0, 6);
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map metadata page key to the corresponding route
|
||||
*
|
||||
* static file page key: /app/robots.txt -> /robots.xml -> /robots.txt/route
|
||||
* dynamic route page key: /app/robots.tsx -> /robots -> /robots.txt/route
|
||||
*
|
||||
* @param page
|
||||
* @returns
|
||||
*/
|
||||
export function normalizeMetadataRoute(page: string) {
|
||||
let route = page;
|
||||
if (isMetadataRoute(page)) {
|
||||
// Remove the file extension, e.g. /route-path/robots.txt -> /route-path
|
||||
const pathnamePrefix = page.slice(0, -(path.basename(page).length + 1));
|
||||
const suffix = getMetadataRouteSuffix(pathnamePrefix);
|
||||
|
||||
if (route === "/sitemap") {
|
||||
route += ".xml";
|
||||
}
|
||||
if (route === "/robots") {
|
||||
route += ".txt";
|
||||
}
|
||||
if (route === "/manifest") {
|
||||
route += ".webmanifest";
|
||||
}
|
||||
// Support both /<metadata-route.ext> and custom routes /<metadata-route>/route.ts.
|
||||
// If it's a metadata file route, we need to append /[id]/route to the page.
|
||||
if (!route.endsWith("/route")) {
|
||||
const isStaticMetadataFile = isMetadataRouteFile(route, [], true);
|
||||
const { dir, name: baseName, ext } = path.parse(route);
|
||||
|
||||
const isSingleRoute = page.startsWith("/sitemap") ||
|
||||
page.startsWith("/robots") ||
|
||||
page.startsWith("/manifest") ||
|
||||
isStaticMetadataFile;
|
||||
|
||||
route = path.join(
|
||||
dir,
|
||||
`${baseName}${suffix ? `-${suffix}` : ""}${ext}`,
|
||||
isSingleRoute ? "" : "[[...__metadata_id__]]",
|
||||
"route",
|
||||
);
|
||||
}
|
||||
}
|
||||
return route;
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
export const STATIC_METADATA_IMAGES = {
|
||||
icon: {
|
||||
filename: "icon",
|
||||
extensions: ["ico", "jpg", "jpeg", "png", "svg"],
|
||||
},
|
||||
apple: {
|
||||
filename: "apple-icon",
|
||||
extensions: ["jpg", "jpeg", "png"],
|
||||
},
|
||||
favicon: {
|
||||
filename: "favicon",
|
||||
extensions: ["ico"],
|
||||
},
|
||||
openGraph: {
|
||||
filename: "opengraph-image",
|
||||
extensions: ["jpg", "jpeg", "png", "gif"],
|
||||
},
|
||||
twitter: {
|
||||
filename: "twitter-image",
|
||||
extensions: ["jpg", "jpeg", "png", "gif"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Match routes that are metadata routes, e.g. /sitemap.xml, /favicon.<ext>, /<icon>.<ext>, etc.
|
||||
// TODO-METADATA: support more metadata routes with more extensions
|
||||
const defaultExtensions = ["js", "jsx", "ts", "tsx"];
|
||||
|
||||
const getExtensionRegexString = (extensions: readonly string[]) =>
|
||||
`(?:${extensions.join("|")})`;
|
||||
|
||||
// When you only pass the file extension as `[]`, it will only match the static convention files
|
||||
// e.g. /robots.txt, /sitemap.xml, /favicon.ico, /manifest.json
|
||||
// When you pass the file extension as `['js', 'jsx', 'ts', 'tsx']`, it will also match the dynamic convention files
|
||||
// e.g. /robots.js, /sitemap.tsx, /favicon.jsx, /manifest.ts
|
||||
// When `withExtension` is false, it will match the static convention files without the extension, by default it's true
|
||||
// e.g. /robots, /sitemap, /favicon, /manifest, use to match dynamic API routes like app/robots.ts
|
||||
export function isMetadataRouteFile(
|
||||
appDirRelativePath: string,
|
||||
pageExtensions: string[],
|
||||
withExtension: boolean,
|
||||
) {
|
||||
const metadataRouteFilesRegex = [
|
||||
new RegExp(
|
||||
`^[\\\\/]robots${
|
||||
withExtension
|
||||
? `\\.${getExtensionRegexString(pageExtensions.concat("txt"))}`
|
||||
: ""
|
||||
}`,
|
||||
),
|
||||
new RegExp(
|
||||
`^[\\\\/]sitemap${
|
||||
withExtension
|
||||
? `\\.${getExtensionRegexString(pageExtensions.concat("xml"))}`
|
||||
: ""
|
||||
}`,
|
||||
),
|
||||
new RegExp(
|
||||
`^[\\\\/]manifest${
|
||||
withExtension
|
||||
? `\\.${
|
||||
getExtensionRegexString(
|
||||
pageExtensions.concat("webmanifest", "json"),
|
||||
)
|
||||
}`
|
||||
: ""
|
||||
}`,
|
||||
),
|
||||
new RegExp(`^[\\\\/]favicon\\.ico$`),
|
||||
// TODO-METADATA: add dynamic routes for metadata images
|
||||
new RegExp(
|
||||
`[\\\\/]${STATIC_METADATA_IMAGES.icon.filename}${
|
||||
withExtension
|
||||
? `\\.${
|
||||
getExtensionRegexString(
|
||||
pageExtensions.concat(STATIC_METADATA_IMAGES.icon.extensions),
|
||||
)
|
||||
}`
|
||||
: ""
|
||||
}`,
|
||||
),
|
||||
new RegExp(
|
||||
`[\\\\/]${STATIC_METADATA_IMAGES.apple.filename}${
|
||||
withExtension
|
||||
? `\\.${
|
||||
getExtensionRegexString(
|
||||
pageExtensions.concat(STATIC_METADATA_IMAGES.apple.extensions),
|
||||
)
|
||||
}`
|
||||
: ""
|
||||
}`,
|
||||
),
|
||||
new RegExp(
|
||||
`[\\\\/]${STATIC_METADATA_IMAGES.openGraph.filename}${
|
||||
withExtension
|
||||
? `\\.${
|
||||
getExtensionRegexString(
|
||||
pageExtensions.concat(
|
||||
STATIC_METADATA_IMAGES.openGraph.extensions,
|
||||
),
|
||||
)
|
||||
}`
|
||||
: ""
|
||||
}`,
|
||||
),
|
||||
new RegExp(
|
||||
`[\\\\/]${STATIC_METADATA_IMAGES.twitter.filename}${
|
||||
withExtension
|
||||
? `\\.${
|
||||
getExtensionRegexString(
|
||||
pageExtensions.concat(STATIC_METADATA_IMAGES.twitter.extensions),
|
||||
)
|
||||
}`
|
||||
: ""
|
||||
}`,
|
||||
),
|
||||
];
|
||||
|
||||
return metadataRouteFilesRegex.some((r) => r.test(appDirRelativePath));
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove the 'app' prefix or '/route' suffix, only check the route name since they're only allowed in root app directory
|
||||
* e.g.
|
||||
* /app/robots -> /robots
|
||||
* app/robots -> /robots
|
||||
* /robots -> /robots
|
||||
*/
|
||||
export function isMetadataRoute(route: string): boolean {
|
||||
let page = route.replace(/^\/?app\//, "").replace(/\/route$/, "");
|
||||
if (page[0] !== "/") page = "/" + page;
|
||||
|
||||
return (
|
||||
!page.endsWith("/page") &&
|
||||
isMetadataRouteFile(page, defaultExtensions, false)
|
||||
);
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
AppleWebAppMeta,
|
||||
BasicMetadata,
|
||||
FormatDetectionMeta,
|
||||
ItunesMeta,
|
||||
VerificationMeta,
|
||||
} from "./generate/basic";
|
||||
import { AlternatesMetadata } from "./generate/alternate";
|
||||
import {
|
||||
AppLinksMeta,
|
||||
OpenGraphMetadata,
|
||||
TwitterMetadata,
|
||||
} from "./generate/opengraph";
|
||||
import { IconsMetadata } from "./generate/icons";
|
||||
import { accumulateMetadata, resolveMetadata } from "./resolve-metadata";
|
||||
import { LoaderTree } from "../../server/lib/app-dir-module";
|
||||
import { GetDynamicParamFromSegment } from "../../server/app-render/app-render";
|
||||
|
||||
// Generate the actual React elements from the resolved metadata.
|
||||
export async function MetadataTree({
|
||||
tree,
|
||||
pathname,
|
||||
searchParams,
|
||||
getDynamicParamFromSegment,
|
||||
}: {
|
||||
tree: LoaderTree;
|
||||
pathname: string;
|
||||
searchParams: { [key: string]: any };
|
||||
getDynamicParamFromSegment: GetDynamicParamFromSegment;
|
||||
}) {
|
||||
const options = {
|
||||
pathname,
|
||||
};
|
||||
const resolvedMetadata = await resolveMetadata({
|
||||
tree,
|
||||
parentParams: {},
|
||||
metadataItems: [],
|
||||
searchParams,
|
||||
getDynamicParamFromSegment,
|
||||
});
|
||||
const metadata = await accumulateMetadata(resolvedMetadata, options);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BasicMetadata metadata={metadata} />
|
||||
<AlternatesMetadata alternates={metadata.alternates} />
|
||||
<ItunesMeta itunes={metadata.itunes} />
|
||||
<FormatDetectionMeta formatDetection={metadata.formatDetection} />
|
||||
<VerificationMeta verification={metadata.verification} />
|
||||
<AppleWebAppMeta appleWebApp={metadata.appleWebApp} />
|
||||
<OpenGraphMetadata openGraph={metadata.openGraph} />
|
||||
<TwitterMetadata twitter={metadata.twitter} />
|
||||
<AppLinksMeta appLinks={metadata.appLinks} />
|
||||
<IconsMetadata icons={metadata.icons} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,453 +0,0 @@
|
|||
import type {
|
||||
Metadata,
|
||||
ResolvedMetadata,
|
||||
ResolvingMetadata,
|
||||
} from "./types/metadata-interface";
|
||||
import type { MetadataImageModule } from "../../build/webpack/loaders/metadata/types";
|
||||
import type { GetDynamicParamFromSegment } from "../../server/app-render/app-render";
|
||||
import { createDefaultMetadata } from "./default-metadata";
|
||||
import {
|
||||
resolveOpenGraph,
|
||||
resolveTwitter,
|
||||
} from "./resolvers/resolve-opengraph";
|
||||
import { resolveTitle } from "./resolvers/resolve-title";
|
||||
import { resolveAsArrayOrUndefined } from "./generate/utils";
|
||||
import { isClientReference } from "../client-reference";
|
||||
import {
|
||||
getLayoutOrPageModule,
|
||||
LoaderTree,
|
||||
} from "../../server/lib/app-dir-module";
|
||||
import { ComponentsType } from "../../build/webpack/loaders/next-app-loader";
|
||||
import { interopDefault } from "../interop-default";
|
||||
import {
|
||||
resolveAlternates,
|
||||
resolveAppleWebApp,
|
||||
resolveAppLinks,
|
||||
resolveRobots,
|
||||
resolveThemeColor,
|
||||
resolveVerification,
|
||||
resolveViewport,
|
||||
} from "./resolvers/resolve-basics";
|
||||
import { resolveIcons } from "./resolvers/resolve-icons";
|
||||
import { getTracer } from "../../server/lib/trace/tracer";
|
||||
import { ResolveMetadataSpan } from "../../server/lib/trace/constants";
|
||||
import { Twitter } from "./types/twitter-types";
|
||||
import { OpenGraph } from "./types/opengraph-types";
|
||||
import { PAGE_SEGMENT_KEY } from "../../shared/lib/constants";
|
||||
import process from "node:process";
|
||||
|
||||
type StaticMetadata = Awaited<ReturnType<typeof resolveStaticMetadata>>;
|
||||
|
||||
type MetadataResolver = (
|
||||
_parent: ResolvingMetadata,
|
||||
) => Metadata | Promise<Metadata>;
|
||||
export type MetadataItems = [
|
||||
Metadata | MetadataResolver | null,
|
||||
StaticMetadata,
|
||||
][];
|
||||
|
||||
function mergeStaticMetadata(
|
||||
metadata: ResolvedMetadata,
|
||||
staticFilesMetadata: StaticMetadata,
|
||||
) {
|
||||
if (!staticFilesMetadata) return;
|
||||
const { icon, apple, openGraph, twitter, manifest } = staticFilesMetadata;
|
||||
if (icon || apple) {
|
||||
metadata.icons = {
|
||||
icon: icon || [],
|
||||
apple: apple || [],
|
||||
};
|
||||
}
|
||||
if (twitter) {
|
||||
const resolvedTwitter = resolveTwitter(
|
||||
{ ...metadata.twitter, images: twitter } as Twitter,
|
||||
metadata.metadataBase,
|
||||
);
|
||||
metadata.twitter = resolvedTwitter;
|
||||
}
|
||||
|
||||
if (openGraph) {
|
||||
const resolvedOpenGraph = resolveOpenGraph(
|
||||
{ ...metadata.openGraph, images: openGraph } as OpenGraph,
|
||||
metadata.metadataBase,
|
||||
);
|
||||
metadata.openGraph = resolvedOpenGraph;
|
||||
}
|
||||
if (manifest) {
|
||||
metadata.manifest = manifest;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
// Merge the source metadata into the resolved target metadata.
|
||||
function merge({
|
||||
target,
|
||||
source,
|
||||
staticFilesMetadata,
|
||||
titleTemplates,
|
||||
options,
|
||||
}: {
|
||||
target: ResolvedMetadata;
|
||||
source: Metadata | null;
|
||||
staticFilesMetadata: StaticMetadata;
|
||||
titleTemplates: {
|
||||
title: string | null;
|
||||
twitter: string | null;
|
||||
openGraph: string | null;
|
||||
};
|
||||
options: MetadataAccumulationOptions;
|
||||
}) {
|
||||
// If there's override metadata, prefer it otherwise fallback to the default metadata.
|
||||
const metadataBase = typeof source?.metadataBase !== "undefined"
|
||||
? source.metadataBase
|
||||
: target.metadataBase;
|
||||
for (const key_ in source) {
|
||||
const key = key_ as keyof Metadata;
|
||||
|
||||
switch (key) {
|
||||
case "title": {
|
||||
target.title = resolveTitle(source.title, titleTemplates.title);
|
||||
break;
|
||||
}
|
||||
case "alternates": {
|
||||
target.alternates = resolveAlternates(source.alternates, metadataBase, {
|
||||
pathname: options.pathname,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "openGraph": {
|
||||
target.openGraph = resolveOpenGraph(source.openGraph, metadataBase);
|
||||
if (target.openGraph) {
|
||||
target.openGraph.title = resolveTitle(
|
||||
target.openGraph.title,
|
||||
titleTemplates.openGraph,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "twitter": {
|
||||
target.twitter = resolveTwitter(source.twitter, metadataBase);
|
||||
if (target.twitter) {
|
||||
target.twitter.title = resolveTitle(
|
||||
target.twitter.title,
|
||||
titleTemplates.twitter,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "verification":
|
||||
target.verification = resolveVerification(source.verification);
|
||||
break;
|
||||
case "viewport": {
|
||||
target.viewport = resolveViewport(source.viewport);
|
||||
break;
|
||||
}
|
||||
case "icons": {
|
||||
target.icons = resolveIcons(source.icons);
|
||||
break;
|
||||
}
|
||||
case "appleWebApp":
|
||||
target.appleWebApp = resolveAppleWebApp(source.appleWebApp);
|
||||
break;
|
||||
case "appLinks":
|
||||
target.appLinks = resolveAppLinks(source.appLinks);
|
||||
break;
|
||||
case "robots": {
|
||||
target.robots = resolveRobots(source.robots);
|
||||
break;
|
||||
}
|
||||
case "themeColor": {
|
||||
target.themeColor = resolveThemeColor(source.themeColor);
|
||||
break;
|
||||
}
|
||||
case "archives":
|
||||
case "assets":
|
||||
case "bookmarks":
|
||||
case "keywords":
|
||||
case "authors": {
|
||||
// FIXME: type inferring
|
||||
// @ts-ignore
|
||||
target[key] = resolveAsArrayOrUndefined(source[key]) || null;
|
||||
break;
|
||||
}
|
||||
// directly assign fields that fallback to null
|
||||
case "applicationName":
|
||||
case "description":
|
||||
case "generator":
|
||||
case "creator":
|
||||
case "publisher":
|
||||
case "category":
|
||||
case "classification":
|
||||
case "referrer":
|
||||
case "colorScheme":
|
||||
case "itunes":
|
||||
case "formatDetection":
|
||||
case "manifest":
|
||||
// @ts-ignore TODO: support inferring
|
||||
target[key] = source[key] || null;
|
||||
break;
|
||||
case "other":
|
||||
target.other = Object.assign({}, target.other, source.other);
|
||||
break;
|
||||
case "metadataBase":
|
||||
target.metadataBase = metadataBase;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
mergeStaticMetadata(target, staticFilesMetadata);
|
||||
}
|
||||
|
||||
async function getDefinedMetadata(
|
||||
mod: any,
|
||||
props: any,
|
||||
route: string,
|
||||
): Promise<Metadata | MetadataResolver | null> {
|
||||
// Layer is a client component, we just skip it. It can't have metadata exported.
|
||||
// Return early to avoid accessing properties error for client references.
|
||||
if (isClientReference(mod)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
(mod.generateMetadata
|
||||
? (parent: ResolvingMetadata) =>
|
||||
getTracer().trace(
|
||||
ResolveMetadataSpan.generateMetadata,
|
||||
{
|
||||
spanName: `generateMetadata ${route}`,
|
||||
attributes: {
|
||||
"next.page": route,
|
||||
},
|
||||
},
|
||||
() => mod.generateMetadata(props, parent),
|
||||
)
|
||||
: mod.metadata) || null
|
||||
);
|
||||
}
|
||||
|
||||
async function collectStaticImagesFiles(
|
||||
metadata: ComponentsType["metadata"],
|
||||
props: any,
|
||||
type: keyof NonNullable<ComponentsType["metadata"]>,
|
||||
) {
|
||||
if (!metadata?.[type]) return undefined;
|
||||
|
||||
const iconPromises = metadata[type as "icon" | "apple"].map(
|
||||
async (imageModule: (p: any) => Promise<MetadataImageModule[]>) =>
|
||||
interopDefault(await imageModule(props)),
|
||||
);
|
||||
|
||||
return iconPromises?.length > 0
|
||||
? (await Promise.all(iconPromises))?.flat()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function resolveStaticMetadata(components: ComponentsType, props: any) {
|
||||
const { metadata } = components;
|
||||
if (!metadata) return null;
|
||||
|
||||
const [icon, apple, openGraph, twitter] = await Promise.all([
|
||||
collectStaticImagesFiles(metadata, props, "icon"),
|
||||
collectStaticImagesFiles(metadata, props, "apple"),
|
||||
collectStaticImagesFiles(metadata, props, "openGraph"),
|
||||
collectStaticImagesFiles(metadata, props, "twitter"),
|
||||
]);
|
||||
|
||||
const staticMetadata = {
|
||||
icon,
|
||||
apple,
|
||||
openGraph,
|
||||
twitter,
|
||||
manifest: metadata.manifest,
|
||||
};
|
||||
|
||||
return staticMetadata;
|
||||
}
|
||||
|
||||
// [layout.metadata, static files metadata] -> ... -> [page.metadata, static files metadata]
|
||||
export async function collectMetadata({
|
||||
tree,
|
||||
metadataItems: array,
|
||||
props,
|
||||
route,
|
||||
}: {
|
||||
tree: LoaderTree;
|
||||
metadataItems: MetadataItems;
|
||||
props: any;
|
||||
route: string;
|
||||
}) {
|
||||
const [mod, modType] = await getLayoutOrPageModule(tree);
|
||||
|
||||
if (modType) {
|
||||
route += `/${modType}`;
|
||||
}
|
||||
|
||||
const staticFilesMetadata = await resolveStaticMetadata(tree[2], props);
|
||||
const metadataExport = mod
|
||||
? await getDefinedMetadata(mod, props, route)
|
||||
: null;
|
||||
|
||||
array.push([metadataExport, staticFilesMetadata]);
|
||||
}
|
||||
|
||||
export async function resolveMetadata({
|
||||
tree,
|
||||
parentParams,
|
||||
metadataItems,
|
||||
treePrefix = [],
|
||||
getDynamicParamFromSegment,
|
||||
searchParams,
|
||||
}: {
|
||||
tree: LoaderTree;
|
||||
parentParams: { [key: string]: any };
|
||||
metadataItems: MetadataItems;
|
||||
/** Provided tree can be nested subtree, this argument says what is the path of such subtree */
|
||||
treePrefix?: string[];
|
||||
getDynamicParamFromSegment: GetDynamicParamFromSegment;
|
||||
searchParams: { [key: string]: any };
|
||||
}): Promise<MetadataItems> {
|
||||
const [segment, parallelRoutes, { page }] = tree;
|
||||
const currentTreePrefix = [...treePrefix, segment];
|
||||
const isPage = typeof page !== "undefined";
|
||||
// Handle dynamic segment params.
|
||||
const segmentParam = getDynamicParamFromSegment(segment);
|
||||
/**
|
||||
* Create object holding the parent params and current params
|
||||
*/
|
||||
const currentParams =
|
||||
// Handle null case where dynamic param is optional
|
||||
segmentParam && segmentParam.value !== null
|
||||
? {
|
||||
...parentParams,
|
||||
[segmentParam.param]: segmentParam.value,
|
||||
}
|
||||
// Pass through parent params to children
|
||||
: parentParams;
|
||||
|
||||
const layerProps = {
|
||||
params: currentParams,
|
||||
...(isPage && { searchParams }),
|
||||
};
|
||||
|
||||
await collectMetadata({
|
||||
tree,
|
||||
metadataItems,
|
||||
props: layerProps,
|
||||
route: currentTreePrefix
|
||||
// __PAGE__ shouldn't be shown in a route
|
||||
.filter((s) => s !== PAGE_SEGMENT_KEY)
|
||||
.join("/"),
|
||||
});
|
||||
|
||||
for (const key in parallelRoutes) {
|
||||
const childTree = parallelRoutes[key];
|
||||
await resolveMetadata({
|
||||
tree: childTree,
|
||||
metadataItems,
|
||||
parentParams: currentParams,
|
||||
treePrefix: currentTreePrefix,
|
||||
searchParams,
|
||||
getDynamicParamFromSegment,
|
||||
});
|
||||
}
|
||||
|
||||
return metadataItems;
|
||||
}
|
||||
|
||||
type MetadataAccumulationOptions = {
|
||||
pathname: string;
|
||||
};
|
||||
|
||||
export async function accumulateMetadata(
|
||||
metadataItems: MetadataItems,
|
||||
options: MetadataAccumulationOptions,
|
||||
): Promise<ResolvedMetadata> {
|
||||
const resolvedMetadata = createDefaultMetadata();
|
||||
const resolvers: ((value: ResolvedMetadata) => void)[] = [];
|
||||
const generateMetadataResults: (Metadata | Promise<Metadata>)[] = [];
|
||||
|
||||
let titleTemplates: {
|
||||
title: string | null;
|
||||
twitter: string | null;
|
||||
openGraph: string | null;
|
||||
} = {
|
||||
title: null,
|
||||
twitter: null,
|
||||
openGraph: null,
|
||||
};
|
||||
|
||||
// Loop over all metadata items again, merging synchronously any static object exports,
|
||||
// awaiting any static promise exports, and resolving parent metadata and awaiting any generated metadata
|
||||
|
||||
let resolvingIndex = 0;
|
||||
for (let i = 0; i < metadataItems.length; i++) {
|
||||
const [metadataExport, staticFilesMetadata] = metadataItems[i];
|
||||
let metadata: Metadata | null = null;
|
||||
if (typeof metadataExport === "function") {
|
||||
if (!resolvers.length) {
|
||||
for (let j = i; j < metadataItems.length; j++) {
|
||||
const [preloadMetadataExport] = metadataItems[j];
|
||||
// call each `generateMetadata function concurrently and stash their resolver
|
||||
if (typeof preloadMetadataExport === "function") {
|
||||
generateMetadataResults.push(
|
||||
preloadMetadataExport(
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolveParent = resolvers[resolvingIndex];
|
||||
const generatedMetadata = generateMetadataResults[resolvingIndex++];
|
||||
|
||||
// In dev we clone and freeze to prevent relying on mutating resolvedMetadata directly.
|
||||
// In prod we just pass resolvedMetadata through without any copying.
|
||||
const currentResolvedMetadata: ResolvedMetadata =
|
||||
process.env.NODE_ENV === "development"
|
||||
? Object.freeze(
|
||||
require(
|
||||
"next/dist/compiled/@edge-runtime/primitives/structured-clone",
|
||||
).structuredClone(
|
||||
resolvedMetadata,
|
||||
),
|
||||
)
|
||||
: resolvedMetadata;
|
||||
|
||||
// This resolve should unblock the generateMetadata function if it awaited the parent
|
||||
// argument. If it didn't await the parent argument it might already have a value since it was
|
||||
// called concurrently. Regardless we await the return value before continuing on to the next layer
|
||||
resolveParent(currentResolvedMetadata);
|
||||
metadata = generatedMetadata instanceof Promise
|
||||
? await generatedMetadata
|
||||
: generatedMetadata;
|
||||
} else if (metadataExport !== null && typeof metadataExport === "object") {
|
||||
// This metadataExport is the object form
|
||||
metadata = metadataExport;
|
||||
}
|
||||
|
||||
merge({
|
||||
options,
|
||||
target: resolvedMetadata,
|
||||
source: metadata,
|
||||
staticFilesMetadata,
|
||||
titleTemplates,
|
||||
});
|
||||
|
||||
// If the layout is the same layer with page, skip the leaf layout and leaf page
|
||||
// The leaf layout and page are the last two items
|
||||
if (i < metadataItems.length - 2) {
|
||||
titleTemplates = {
|
||||
title: resolvedMetadata.title?.template || null,
|
||||
openGraph: resolvedMetadata.openGraph?.title?.template || null,
|
||||
twitter: resolvedMetadata.twitter?.title?.template || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedMetadata;
|
||||
}
|
|
@ -1,259 +0,0 @@
|
|||
import type {
|
||||
AlternateLinkDescriptor,
|
||||
ResolvedAlternateURLs,
|
||||
} from "../types/alternative-urls-types";
|
||||
import type { Metadata, ResolvedMetadata } from "../types/metadata-interface";
|
||||
import type { ResolvedVerification } from "../types/metadata-types";
|
||||
import type {
|
||||
FieldResolver,
|
||||
FieldResolverWithMetadataBase,
|
||||
} from "../types/resolvers";
|
||||
import type { Viewport } from "../types/extra-types";
|
||||
import path from "path";
|
||||
import { resolveAsArrayOrUndefined } from "../generate/utils";
|
||||
import { resolveUrl } from "./resolve-url";
|
||||
import { ViewPortKeys } from "../constants";
|
||||
|
||||
// Resolve with `metadataBase` if it's present, otherwise resolve with `pathname`.
|
||||
// Resolve with `pathname` if `url` is a relative path.
|
||||
function resolveAlternateUrl(
|
||||
url: string | URL,
|
||||
metadataBase: URL | null,
|
||||
pathname: string,
|
||||
) {
|
||||
if (typeof url === "string" && url.startsWith("./")) {
|
||||
url = path.resolve(pathname, url);
|
||||
} else if (url instanceof URL) {
|
||||
url = new URL(pathname, url);
|
||||
}
|
||||
|
||||
const result = metadataBase ? resolveUrl(url, metadataBase) : url;
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
export const resolveThemeColor: FieldResolver<"themeColor"> = (themeColor) => {
|
||||
if (!themeColor) return null;
|
||||
const themeColorDescriptors: ResolvedMetadata["themeColor"] = [];
|
||||
|
||||
resolveAsArrayOrUndefined(themeColor)?.forEach((descriptor) => {
|
||||
if (typeof descriptor === "string") {
|
||||
themeColorDescriptors.push({ color: descriptor });
|
||||
} else if (typeof descriptor === "object") {
|
||||
themeColorDescriptors.push({
|
||||
color: descriptor.color,
|
||||
media: descriptor.media,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return themeColorDescriptors;
|
||||
};
|
||||
|
||||
export const resolveViewport: FieldResolver<"viewport"> = (viewport) => {
|
||||
let resolved: ResolvedMetadata["viewport"] = null;
|
||||
|
||||
if (typeof viewport === "string") {
|
||||
resolved = viewport;
|
||||
} else if (viewport) {
|
||||
resolved = "";
|
||||
for (const viewportKey_ in ViewPortKeys) {
|
||||
const viewportKey = viewportKey_ as keyof Viewport;
|
||||
if (viewportKey in viewport) {
|
||||
let value = viewport[viewportKey];
|
||||
if (typeof value === "boolean") value = value ? "yes" : "no";
|
||||
if (resolved) resolved += ", ";
|
||||
resolved += `${ViewPortKeys[viewportKey]}=${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
function resolveUrlValuesOfObject(
|
||||
obj:
|
||||
| Record<string, string | URL | AlternateLinkDescriptor[] | null>
|
||||
| null
|
||||
| undefined,
|
||||
metadataBase: ResolvedMetadata["metadataBase"],
|
||||
pathname: string,
|
||||
): null | Record<string, AlternateLinkDescriptor[]> {
|
||||
if (!obj) return null;
|
||||
|
||||
const result: Record<string, AlternateLinkDescriptor[]> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === "string" || value instanceof URL) {
|
||||
result[key] = [
|
||||
{
|
||||
url: resolveAlternateUrl(value, metadataBase, pathname), // metadataBase ? resolveUrl(value, metadataBase)! : value,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
result[key] = [];
|
||||
value?.forEach((item, index) => {
|
||||
const url = resolveAlternateUrl(item.url, metadataBase, pathname);
|
||||
result[key][index] = {
|
||||
url,
|
||||
title: item.title,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveCanonicalUrl(
|
||||
urlOrDescriptor: string | URL | null | AlternateLinkDescriptor | undefined,
|
||||
metadataBase: URL | null,
|
||||
pathname: string,
|
||||
): null | AlternateLinkDescriptor {
|
||||
if (!urlOrDescriptor) return null;
|
||||
|
||||
const url =
|
||||
typeof urlOrDescriptor === "string" || urlOrDescriptor instanceof URL
|
||||
? urlOrDescriptor
|
||||
: urlOrDescriptor.url;
|
||||
|
||||
// Return string url because structureClone can't handle URL instance
|
||||
return {
|
||||
url: resolveAlternateUrl(url, metadataBase, pathname),
|
||||
};
|
||||
}
|
||||
|
||||
export const resolveAlternates: FieldResolverWithMetadataBase<
|
||||
"alternates",
|
||||
{ pathname: string }
|
||||
> = (alternates, metadataBase, { pathname }) => {
|
||||
if (!alternates) return null;
|
||||
|
||||
const canonical = resolveCanonicalUrl(
|
||||
alternates.canonical,
|
||||
metadataBase,
|
||||
pathname,
|
||||
);
|
||||
const languages = resolveUrlValuesOfObject(
|
||||
alternates.languages,
|
||||
metadataBase,
|
||||
pathname,
|
||||
);
|
||||
const media = resolveUrlValuesOfObject(
|
||||
alternates.media,
|
||||
metadataBase,
|
||||
pathname,
|
||||
);
|
||||
const types = resolveUrlValuesOfObject(
|
||||
alternates.types,
|
||||
metadataBase,
|
||||
pathname,
|
||||
);
|
||||
|
||||
const result: ResolvedAlternateURLs = {
|
||||
canonical,
|
||||
languages,
|
||||
media,
|
||||
types,
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const robotsKeys = [
|
||||
"noarchive",
|
||||
"nosnippet",
|
||||
"noimageindex",
|
||||
"nocache",
|
||||
"notranslate",
|
||||
"indexifembedded",
|
||||
"nositelinkssearchbox",
|
||||
"unavailable_after",
|
||||
"max-video-preview",
|
||||
"max-image-preview",
|
||||
"max-snippet",
|
||||
] as const;
|
||||
const resolveRobotsValue: (robots: Metadata["robots"]) => string | null = (
|
||||
robots,
|
||||
) => {
|
||||
if (!robots) return null;
|
||||
if (typeof robots === "string") return robots;
|
||||
|
||||
const values: string[] = [];
|
||||
|
||||
if (robots.index) values.push("index");
|
||||
else if (typeof robots.index === "boolean") values.push("noindex");
|
||||
|
||||
if (robots.follow) values.push("follow");
|
||||
else if (typeof robots.follow === "boolean") values.push("nofollow");
|
||||
|
||||
for (const key of robotsKeys) {
|
||||
const value = robots[key];
|
||||
if (typeof value !== "undefined" && value !== false) {
|
||||
values.push(typeof value === "boolean" ? key : `${key}:${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return values.join(", ");
|
||||
};
|
||||
|
||||
export const resolveRobots: FieldResolver<"robots"> = (robots) => {
|
||||
if (!robots) return null;
|
||||
return {
|
||||
basic: resolveRobotsValue(robots),
|
||||
googleBot: typeof robots !== "string"
|
||||
? resolveRobotsValue(robots.googleBot)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
const VerificationKeys = ["google", "yahoo", "yandex", "me", "other"] as const;
|
||||
export const resolveVerification: FieldResolver<"verification"> = (
|
||||
verification,
|
||||
) => {
|
||||
if (!verification) return null;
|
||||
const res: ResolvedVerification = {};
|
||||
|
||||
for (const key of VerificationKeys) {
|
||||
const value = verification[key];
|
||||
if (value) {
|
||||
if (key === "other") {
|
||||
res.other = {};
|
||||
for (const otherKey in verification.other) {
|
||||
const otherValue = resolveAsArrayOrUndefined(
|
||||
verification.other[otherKey],
|
||||
);
|
||||
if (otherValue) res.other[otherKey] = otherValue;
|
||||
}
|
||||
} else res[key] = resolveAsArrayOrUndefined(value) as (string | number)[];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
export const resolveAppleWebApp: FieldResolver<"appleWebApp"> = (appWebApp) => {
|
||||
if (!appWebApp) return null;
|
||||
if (appWebApp === true) {
|
||||
return {
|
||||
capable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const startupImages = appWebApp.startupImage
|
||||
? resolveAsArrayOrUndefined(appWebApp.startupImage)?.map((item) =>
|
||||
typeof item === "string" ? { url: item } : item
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
capable: "capable" in appWebApp ? !!appWebApp.capable : true,
|
||||
title: appWebApp.title || null,
|
||||
startupImage: startupImages,
|
||||
statusBarStyle: appWebApp.statusBarStyle || "default",
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveAppLinks: FieldResolver<"appLinks"> = (appLinks) => {
|
||||
if (!appLinks) return null;
|
||||
for (const key in appLinks) {
|
||||
// @ts-ignore // TODO: type infer
|
||||
appLinks[key] = resolveAsArrayOrUndefined(appLinks[key]);
|
||||
}
|
||||
return appLinks as ResolvedMetadata["appLinks"];
|
||||
};
|
|
@ -1,34 +0,0 @@
|
|||
import type { ResolvedMetadata } from "../types/metadata-interface";
|
||||
import type { Icon, IconDescriptor } from "../types/metadata-types";
|
||||
import type { FieldResolver } from "../types/resolvers";
|
||||
import { resolveAsArrayOrUndefined } from "../generate/utils";
|
||||
import { isStringOrURL } from "./resolve-url";
|
||||
import { IconKeys } from "../constants";
|
||||
|
||||
export function resolveIcon(icon: Icon): IconDescriptor {
|
||||
if (isStringOrURL(icon)) return { url: icon };
|
||||
else if (Array.isArray(icon)) return icon;
|
||||
return icon;
|
||||
}
|
||||
|
||||
export const resolveIcons: FieldResolver<"icons"> = (icons) => {
|
||||
if (!icons) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved: ResolvedMetadata["icons"] = {
|
||||
icon: [],
|
||||
apple: [],
|
||||
};
|
||||
if (Array.isArray(icons)) {
|
||||
resolved.icon = icons.map(resolveIcon).filter(Boolean);
|
||||
} else if (isStringOrURL(icons)) {
|
||||
resolved.icon = [resolveIcon(icons)];
|
||||
} else {
|
||||
for (const key of IconKeys) {
|
||||
const values = resolveAsArrayOrUndefined(icons[key]);
|
||||
if (values) resolved[key] = values.map(resolveIcon);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
};
|
|
@ -1,147 +0,0 @@
|
|||
import type { Metadata, ResolvedMetadata } from "../types/metadata-interface";
|
||||
import type {
|
||||
OpenGraph,
|
||||
OpenGraphType,
|
||||
ResolvedOpenGraph,
|
||||
} from "../types/opengraph-types";
|
||||
import type { FieldResolverWithMetadataBase } from "../types/resolvers";
|
||||
import type { ResolvedTwitterMetadata, Twitter } from "../types/twitter-types";
|
||||
import { resolveAsArrayOrUndefined } from "../generate/utils";
|
||||
import { isStringOrURL, resolveUrl } from "./resolve-url";
|
||||
|
||||
const OgTypeFields = {
|
||||
article: ["authors", "tags"],
|
||||
song: ["albums", "musicians"],
|
||||
playlist: ["albums", "musicians"],
|
||||
radio: ["creators"],
|
||||
video: ["actors", "directors", "writers", "tags"],
|
||||
basic: [
|
||||
"emails",
|
||||
"phoneNumbers",
|
||||
"faxNumbers",
|
||||
"alternateLocale",
|
||||
"audio",
|
||||
"videos",
|
||||
],
|
||||
} as const;
|
||||
|
||||
function resolveImages(
|
||||
images: Twitter["images"],
|
||||
metadataBase: ResolvedMetadata["metadataBase"],
|
||||
): NonNullable<ResolvedMetadata["twitter"]>["images"];
|
||||
function resolveImages(
|
||||
images: OpenGraph["images"],
|
||||
metadataBase: ResolvedMetadata["metadataBase"],
|
||||
): NonNullable<ResolvedMetadata["openGraph"]>["images"];
|
||||
function resolveImages(
|
||||
images: OpenGraph["images"] | Twitter["images"],
|
||||
metadataBase: ResolvedMetadata["metadataBase"],
|
||||
):
|
||||
| NonNullable<ResolvedMetadata["twitter"]>["images"]
|
||||
| NonNullable<ResolvedMetadata["openGraph"]>["images"] {
|
||||
const resolvedImages = resolveAsArrayOrUndefined(images);
|
||||
resolvedImages?.forEach((item, index, array) => {
|
||||
if (isStringOrURL(item)) {
|
||||
array[index] = {
|
||||
url: resolveUrl(item, metadataBase)!,
|
||||
};
|
||||
} else {
|
||||
// Update image descriptor url
|
||||
item.url = resolveUrl(item.url, metadataBase)!;
|
||||
}
|
||||
});
|
||||
return resolvedImages;
|
||||
}
|
||||
|
||||
function getFieldsByOgType(ogType: OpenGraphType | undefined) {
|
||||
switch (ogType) {
|
||||
case "article":
|
||||
case "book":
|
||||
return OgTypeFields.article;
|
||||
case "music.song":
|
||||
case "music.album":
|
||||
return OgTypeFields.song;
|
||||
case "music.playlist":
|
||||
return OgTypeFields.playlist;
|
||||
case "music.radio_station":
|
||||
return OgTypeFields.radio;
|
||||
case "video.movie":
|
||||
case "video.episode":
|
||||
return OgTypeFields.video;
|
||||
default:
|
||||
return OgTypeFields.basic;
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveOpenGraph: FieldResolverWithMetadataBase<"openGraph"> = (
|
||||
openGraph: Metadata["openGraph"],
|
||||
metadataBase: ResolvedMetadata["metadataBase"],
|
||||
) => {
|
||||
if (!openGraph) return null;
|
||||
|
||||
const url = resolveUrl(openGraph.url, metadataBase);
|
||||
const resolved = { ...openGraph } as ResolvedOpenGraph;
|
||||
|
||||
function assignProps(og: OpenGraph) {
|
||||
const ogType = og && "type" in og ? og.type : undefined;
|
||||
const keys = getFieldsByOgType(ogType);
|
||||
for (const k of keys) {
|
||||
const key = k as keyof ResolvedOpenGraph;
|
||||
if (key in og && key !== "url") {
|
||||
const value = og[key];
|
||||
if (value) {
|
||||
const arrayValue = resolveAsArrayOrUndefined(value); /// TODO: improve typing inferring
|
||||
(resolved as any)[key] = arrayValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolved.images = resolveImages(og.images, metadataBase);
|
||||
}
|
||||
|
||||
assignProps(openGraph);
|
||||
|
||||
resolved.url = url;
|
||||
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const TwitterBasicInfoKeys = [
|
||||
"site",
|
||||
"siteId",
|
||||
"creator",
|
||||
"creatorId",
|
||||
"description",
|
||||
] as const;
|
||||
|
||||
export const resolveTwitter: FieldResolverWithMetadataBase<"twitter"> = (
|
||||
twitter,
|
||||
metadataBase,
|
||||
) => {
|
||||
if (!twitter) return null;
|
||||
const resolved = {
|
||||
...twitter,
|
||||
card: "card" in twitter ? twitter.card : "summary",
|
||||
} as ResolvedTwitterMetadata;
|
||||
for (const infoKey of TwitterBasicInfoKeys) {
|
||||
resolved[infoKey] = twitter[infoKey] || null;
|
||||
}
|
||||
resolved.images = resolveImages(twitter.images, metadataBase);
|
||||
|
||||
if ("card" in resolved) {
|
||||
switch (resolved.card) {
|
||||
case "player": {
|
||||
resolved.players = resolveAsArrayOrUndefined(resolved.players) || [];
|
||||
break;
|
||||
}
|
||||
case "app": {
|
||||
resolved.app = resolved.app || {};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
};
|
|
@ -1,39 +0,0 @@
|
|||
import type { Metadata } from "../types/metadata-interface";
|
||||
import type { AbsoluteTemplateString } from "../types/metadata-types";
|
||||
|
||||
function resolveTitleTemplate(
|
||||
template: string | null | undefined,
|
||||
title: string,
|
||||
) {
|
||||
return template ? template.replace(/%s/g, title) : title;
|
||||
}
|
||||
|
||||
export function resolveTitle(
|
||||
title: Metadata["title"],
|
||||
stashedTemplate: string | null | undefined,
|
||||
): AbsoluteTemplateString {
|
||||
let resolved;
|
||||
const template = typeof title !== "string" && title && "template" in title
|
||||
? title.template
|
||||
: null;
|
||||
|
||||
if (typeof title === "string") {
|
||||
resolved = resolveTitleTemplate(stashedTemplate, title);
|
||||
} else if (title) {
|
||||
if ("default" in title) {
|
||||
resolved = resolveTitleTemplate(stashedTemplate, title.default);
|
||||
}
|
||||
if ("absolute" in title && title.absolute) {
|
||||
resolved = title.absolute;
|
||||
}
|
||||
}
|
||||
|
||||
if (title && typeof title !== "string") {
|
||||
return {
|
||||
template,
|
||||
absolute: resolved || "",
|
||||
};
|
||||
} else {
|
||||
return { absolute: resolved || title || "", template };
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
import path from "path";
|
||||
import process from "node:process";
|
||||
|
||||
function isStringOrURL(icon: any): icon is string | URL {
|
||||
return typeof icon === "string" || icon instanceof URL;
|
||||
}
|
||||
|
||||
function resolveUrl(url: null | undefined, metadataBase: URL | null): null;
|
||||
function resolveUrl(url: string | URL, metadataBase: URL | null): URL;
|
||||
function resolveUrl(
|
||||
url: string | URL | null | undefined,
|
||||
metadataBase: URL | null,
|
||||
): URL | null;
|
||||
function resolveUrl(
|
||||
url: string | URL | null | undefined,
|
||||
metadataBase: URL | null,
|
||||
): URL | null {
|
||||
if (url instanceof URL) return url;
|
||||
if (!url) return null;
|
||||
|
||||
try {
|
||||
// If we can construct a URL instance from url, ignore metadataBase
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl;
|
||||
} catch (_) {}
|
||||
|
||||
if (!metadataBase) {
|
||||
metadataBase = new URL(`http://localhost:${process.env.PORT || 3000}`);
|
||||
}
|
||||
|
||||
// Handle relative or absolute paths
|
||||
const basePath = metadataBase.pathname || "";
|
||||
const joinedPath = path.join(basePath, url);
|
||||
|
||||
return new URL(joinedPath, metadataBase);
|
||||
}
|
||||
|
||||
export { isStringOrURL, resolveUrl };
|
|
@ -1,450 +0,0 @@
|
|||
// Reference: https://hreflang.org/what-is-a-valid-hreflang
|
||||
|
||||
type LangCode =
|
||||
| "aa"
|
||||
| "ab"
|
||||
| "ae"
|
||||
| "af"
|
||||
| "ak"
|
||||
| "am"
|
||||
| "an"
|
||||
| "ar"
|
||||
| "as"
|
||||
| "av"
|
||||
| "ay"
|
||||
| "az"
|
||||
| "ba"
|
||||
| "be"
|
||||
| "bg"
|
||||
| "bh"
|
||||
| "bi"
|
||||
| "bm"
|
||||
| "bn"
|
||||
| "bo"
|
||||
| "br"
|
||||
| "bs"
|
||||
| "ca"
|
||||
| "ce"
|
||||
| "ch"
|
||||
| "co"
|
||||
| "cr"
|
||||
| "cs"
|
||||
| "cu"
|
||||
| "cv"
|
||||
| "cy"
|
||||
| "da"
|
||||
| "de"
|
||||
| "dv"
|
||||
| "dz"
|
||||
| "ee"
|
||||
| "el"
|
||||
| "en"
|
||||
| "eo"
|
||||
| "es"
|
||||
| "et"
|
||||
| "eu"
|
||||
| "fa"
|
||||
| "ff"
|
||||
| "fi"
|
||||
| "fj"
|
||||
| "fo"
|
||||
| "fr"
|
||||
| "fy"
|
||||
| "ga"
|
||||
| "gd"
|
||||
| "gl"
|
||||
| "gn"
|
||||
| "gu"
|
||||
| "gv"
|
||||
| "ha"
|
||||
| "he"
|
||||
| "hi"
|
||||
| "ho"
|
||||
| "hr"
|
||||
| "ht"
|
||||
| "hu"
|
||||
| "hy"
|
||||
| "hz"
|
||||
| "ia"
|
||||
| "id"
|
||||
| "ie"
|
||||
| "ig"
|
||||
| "ii"
|
||||
| "ik"
|
||||
| "io"
|
||||
| "is"
|
||||
| "it"
|
||||
| "iu"
|
||||
| "ja"
|
||||
| "jv"
|
||||
| "ka"
|
||||
| "kg"
|
||||
| "ki"
|
||||
| "kj"
|
||||
| "kk"
|
||||
| "kl"
|
||||
| "km"
|
||||
| "kn"
|
||||
| "ko"
|
||||
| "kr"
|
||||
| "ks"
|
||||
| "ku"
|
||||
| "kv"
|
||||
| "kw"
|
||||
| "ky"
|
||||
| "la"
|
||||
| "lb"
|
||||
| "lg"
|
||||
| "li"
|
||||
| "ln"
|
||||
| "lo"
|
||||
| "lt"
|
||||
| "lu"
|
||||
| "lv"
|
||||
| "mg"
|
||||
| "mh"
|
||||
| "mi"
|
||||
| "mk"
|
||||
| "ml"
|
||||
| "mn"
|
||||
| "mr"
|
||||
| "ms"
|
||||
| "mt"
|
||||
| "my"
|
||||
| "na"
|
||||
| "nb"
|
||||
| "nd"
|
||||
| "ne"
|
||||
| "ng"
|
||||
| "nl"
|
||||
| "nn"
|
||||
| "no"
|
||||
| "nr"
|
||||
| "nv"
|
||||
| "ny"
|
||||
| "oc"
|
||||
| "oj"
|
||||
| "om"
|
||||
| "or"
|
||||
| "os"
|
||||
| "pa"
|
||||
| "pi"
|
||||
| "pl"
|
||||
| "ps"
|
||||
| "pt"
|
||||
| "qu"
|
||||
| "rm"
|
||||
| "rn"
|
||||
| "ro"
|
||||
| "ru"
|
||||
| "rw"
|
||||
| "sa"
|
||||
| "sc"
|
||||
| "sd"
|
||||
| "se"
|
||||
| "sg"
|
||||
| "si"
|
||||
| "sk"
|
||||
| "sl"
|
||||
| "sm"
|
||||
| "sn"
|
||||
| "so"
|
||||
| "sq"
|
||||
| "sr"
|
||||
| "ss"
|
||||
| "st"
|
||||
| "su"
|
||||
| "sv"
|
||||
| "sw"
|
||||
| "ta"
|
||||
| "te"
|
||||
| "tg"
|
||||
| "th"
|
||||
| "ti"
|
||||
| "tk"
|
||||
| "tl"
|
||||
| "tn"
|
||||
| "to"
|
||||
| "tr"
|
||||
| "ts"
|
||||
| "tt"
|
||||
| "tw"
|
||||
| "ty"
|
||||
| "ug"
|
||||
| "uk"
|
||||
| "ur"
|
||||
| "uz"
|
||||
| "ve"
|
||||
| "vi"
|
||||
| "vo"
|
||||
| "wa"
|
||||
| "wo"
|
||||
| "xh"
|
||||
| "yi"
|
||||
| "yo"
|
||||
| "za"
|
||||
| "zh"
|
||||
| "zu"
|
||||
| "af-ZA"
|
||||
| "am-ET"
|
||||
| "ar-AE"
|
||||
| "ar-BH"
|
||||
| "ar-DZ"
|
||||
| "ar-EG"
|
||||
| "ar-IQ"
|
||||
| "ar-JO"
|
||||
| "ar-KW"
|
||||
| "ar-LB"
|
||||
| "ar-LY"
|
||||
| "ar-MA"
|
||||
| "arn-CL"
|
||||
| "ar-OM"
|
||||
| "ar-QA"
|
||||
| "ar-SA"
|
||||
| "ar-SD"
|
||||
| "ar-SY"
|
||||
| "ar-TN"
|
||||
| "ar-YE"
|
||||
| "as-IN"
|
||||
| "az-az"
|
||||
| "az-Cyrl-AZ"
|
||||
| "az-Latn-AZ"
|
||||
| "ba-RU"
|
||||
| "be-BY"
|
||||
| "bg-BG"
|
||||
| "bn-BD"
|
||||
| "bn-IN"
|
||||
| "bo-CN"
|
||||
| "br-FR"
|
||||
| "bs-Cyrl-BA"
|
||||
| "bs-Latn-BA"
|
||||
| "ca-ES"
|
||||
| "co-FR"
|
||||
| "cs-CZ"
|
||||
| "cy-GB"
|
||||
| "da-DK"
|
||||
| "de-AT"
|
||||
| "de-CH"
|
||||
| "de-DE"
|
||||
| "de-LI"
|
||||
| "de-LU"
|
||||
| "dsb-DE"
|
||||
| "dv-MV"
|
||||
| "el-CY"
|
||||
| "el-GR"
|
||||
| "en-029"
|
||||
| "en-AU"
|
||||
| "en-BZ"
|
||||
| "en-CA"
|
||||
| "en-cb"
|
||||
| "en-GB"
|
||||
| "en-IE"
|
||||
| "en-IN"
|
||||
| "en-JM"
|
||||
| "en-MT"
|
||||
| "en-MY"
|
||||
| "en-NZ"
|
||||
| "en-PH"
|
||||
| "en-SG"
|
||||
| "en-TT"
|
||||
| "en-US"
|
||||
| "en-ZA"
|
||||
| "en-ZW"
|
||||
| "es-AR"
|
||||
| "es-BO"
|
||||
| "es-CL"
|
||||
| "es-CO"
|
||||
| "es-CR"
|
||||
| "es-DO"
|
||||
| "es-EC"
|
||||
| "es-ES"
|
||||
| "es-GT"
|
||||
| "es-HN"
|
||||
| "es-MX"
|
||||
| "es-NI"
|
||||
| "es-PA"
|
||||
| "es-PE"
|
||||
| "es-PR"
|
||||
| "es-PY"
|
||||
| "es-SV"
|
||||
| "es-US"
|
||||
| "es-UY"
|
||||
| "es-VE"
|
||||
| "et-EE"
|
||||
| "eu-ES"
|
||||
| "fa-IR"
|
||||
| "fi-FI"
|
||||
| "fil-PH"
|
||||
| "fo-FO"
|
||||
| "fr-BE"
|
||||
| "fr-CA"
|
||||
| "fr-CH"
|
||||
| "fr-FR"
|
||||
| "fr-LU"
|
||||
| "fr-MC"
|
||||
| "fy-NL"
|
||||
| "ga-IE"
|
||||
| "gd-GB"
|
||||
| "gd-ie"
|
||||
| "gl-ES"
|
||||
| "gsw-FR"
|
||||
| "gu-IN"
|
||||
| "ha-Latn-NG"
|
||||
| "he-IL"
|
||||
| "hi-IN"
|
||||
| "hr-BA"
|
||||
| "hr-HR"
|
||||
| "hsb-DE"
|
||||
| "hu-HU"
|
||||
| "hy-AM"
|
||||
| "id-ID"
|
||||
| "ig-NG"
|
||||
| "ii-CN"
|
||||
| "in-ID"
|
||||
| "is-IS"
|
||||
| "it-CH"
|
||||
| "it-IT"
|
||||
| "iu-Cans-CA"
|
||||
| "iu-Latn-CA"
|
||||
| "iw-IL"
|
||||
| "ja-JP"
|
||||
| "ka-GE"
|
||||
| "kk-KZ"
|
||||
| "kl-GL"
|
||||
| "km-KH"
|
||||
| "kn-IN"
|
||||
| "kok-IN"
|
||||
| "ko-KR"
|
||||
| "ky-KG"
|
||||
| "lb-LU"
|
||||
| "lo-LA"
|
||||
| "lt-LT"
|
||||
| "lv-LV"
|
||||
| "mi-NZ"
|
||||
| "mk-MK"
|
||||
| "ml-IN"
|
||||
| "mn-MN"
|
||||
| "mn-Mong-CN"
|
||||
| "moh-CA"
|
||||
| "mr-IN"
|
||||
| "ms-BN"
|
||||
| "ms-MY"
|
||||
| "mt-MT"
|
||||
| "nb-NO"
|
||||
| "ne-NP"
|
||||
| "nl-BE"
|
||||
| "nl-NL"
|
||||
| "nn-NO"
|
||||
| "no-no"
|
||||
| "nso-ZA"
|
||||
| "oc-FR"
|
||||
| "or-IN"
|
||||
| "pa-IN"
|
||||
| "pl-PL"
|
||||
| "prs-AF"
|
||||
| "ps-AF"
|
||||
| "pt-BR"
|
||||
| "pt-PT"
|
||||
| "qut-GT"
|
||||
| "quz-BO"
|
||||
| "quz-EC"
|
||||
| "quz-PE"
|
||||
| "rm-CH"
|
||||
| "ro-mo"
|
||||
| "ro-RO"
|
||||
| "ru-mo"
|
||||
| "ru-RU"
|
||||
| "rw-RW"
|
||||
| "sah-RU"
|
||||
| "sa-IN"
|
||||
| "se-FI"
|
||||
| "se-NO"
|
||||
| "se-SE"
|
||||
| "si-LK"
|
||||
| "sk-SK"
|
||||
| "sl-SI"
|
||||
| "sma-NO"
|
||||
| "sma-SE"
|
||||
| "smj-NO"
|
||||
| "smj-SE"
|
||||
| "smn-FI"
|
||||
| "sms-FI"
|
||||
| "sq-AL"
|
||||
| "sr-BA"
|
||||
| "sr-CS"
|
||||
| "sr-Cyrl-BA"
|
||||
| "sr-Cyrl-CS"
|
||||
| "sr-Cyrl-ME"
|
||||
| "sr-Cyrl-RS"
|
||||
| "sr-Latn-BA"
|
||||
| "sr-Latn-CS"
|
||||
| "sr-Latn-ME"
|
||||
| "sr-Latn-RS"
|
||||
| "sr-ME"
|
||||
| "sr-RS"
|
||||
| "sr-sp"
|
||||
| "sv-FI"
|
||||
| "sv-SE"
|
||||
| "sw-KE"
|
||||
| "syr-SY"
|
||||
| "ta-IN"
|
||||
| "te-IN"
|
||||
| "tg-Cyrl-TJ"
|
||||
| "th-TH"
|
||||
| "tk-TM"
|
||||
| "tlh-QS"
|
||||
| "tn-ZA"
|
||||
| "tr-TR"
|
||||
| "tt-RU"
|
||||
| "tzm-Latn-DZ"
|
||||
| "ug-CN"
|
||||
| "uk-UA"
|
||||
| "ur-PK"
|
||||
| "uz-Cyrl-UZ"
|
||||
| "uz-Latn-UZ"
|
||||
| "uz-uz"
|
||||
| "vi-VN"
|
||||
| "wo-SN"
|
||||
| "xh-ZA"
|
||||
| "yo-NG"
|
||||
| "zh-CN"
|
||||
| "zh-HK"
|
||||
| "zh-MO"
|
||||
| "zh-SG"
|
||||
| "zh-TW"
|
||||
| "zu-ZA";
|
||||
|
||||
type UnmatchedLang = "x-default";
|
||||
|
||||
type HrefLang = LangCode | UnmatchedLang;
|
||||
|
||||
type Languages<T> = {
|
||||
[s in HrefLang]?: T;
|
||||
};
|
||||
|
||||
export type AlternateLinkDescriptor = {
|
||||
title?: string;
|
||||
url: string | URL;
|
||||
};
|
||||
|
||||
export type AlternateURLs = {
|
||||
canonical?: null | string | URL | AlternateLinkDescriptor;
|
||||
languages?: Languages<null | string | URL | AlternateLinkDescriptor[]>;
|
||||
media?: {
|
||||
[media: string]: null | string | URL | AlternateLinkDescriptor[];
|
||||
};
|
||||
types?: {
|
||||
[types: string]: null | string | URL | AlternateLinkDescriptor[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ResolvedAlternateURLs = {
|
||||
canonical: null | AlternateLinkDescriptor;
|
||||
languages: null | Languages<AlternateLinkDescriptor[]>;
|
||||
media: null | {
|
||||
[media: string]: null | AlternateLinkDescriptor[];
|
||||
};
|
||||
types: null | {
|
||||
[types: string]: null | AlternateLinkDescriptor[];
|
||||
};
|
||||
};
|
|
@ -1,104 +0,0 @@
|
|||
// When rendering applink meta tags add a namespace tag before each array instance
|
||||
// if more than one member exists.
|
||||
// ref: https://developers.facebook.com/docs/applinks/metadata-reference
|
||||
|
||||
export type AppLinks = {
|
||||
ios?: AppLinksApple | Array<AppLinksApple>;
|
||||
iphone?: AppLinksApple | Array<AppLinksApple>;
|
||||
ipad?: AppLinksApple | Array<AppLinksApple>;
|
||||
android?: AppLinksAndroid | Array<AppLinksAndroid>;
|
||||
windows_phone?: AppLinksWindows | Array<AppLinksWindows>;
|
||||
windows?: AppLinksWindows | Array<AppLinksWindows>;
|
||||
windows_universal?: AppLinksWindows | Array<AppLinksWindows>;
|
||||
web?: AppLinksWeb | Array<AppLinksWeb>;
|
||||
};
|
||||
export type ResolvedAppLinks = {
|
||||
ios?: Array<AppLinksApple>;
|
||||
iphone?: Array<AppLinksApple>;
|
||||
ipad?: Array<AppLinksApple>;
|
||||
android?: Array<AppLinksAndroid>;
|
||||
windows_phone?: Array<AppLinksWindows>;
|
||||
windows?: Array<AppLinksWindows>;
|
||||
windows_universal?: Array<AppLinksWindows>;
|
||||
web?: Array<AppLinksWeb>;
|
||||
};
|
||||
export type AppLinksApple = {
|
||||
url: string | URL;
|
||||
app_store_id?: string | number;
|
||||
app_name?: string;
|
||||
};
|
||||
export type AppLinksAndroid = {
|
||||
package: string;
|
||||
url?: string | URL;
|
||||
class?: string;
|
||||
app_name?: string;
|
||||
};
|
||||
export type AppLinksWindows = {
|
||||
url: string | URL;
|
||||
app_id?: string;
|
||||
app_name?: string;
|
||||
};
|
||||
export type AppLinksWeb = {
|
||||
url: string | URL;
|
||||
should_fallback?: boolean;
|
||||
};
|
||||
|
||||
// Apple Itunes APp
|
||||
// https://developer.apple.com/documentation/webkit/promoting_apps_with_smart_app_banners
|
||||
export type ItunesApp = {
|
||||
appId: string;
|
||||
appArgument?: string;
|
||||
};
|
||||
|
||||
// Viewport meta structure
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag
|
||||
// intentionally leaving out user-scalable, use a string if you want that behavior
|
||||
export type Viewport = {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
initialScale?: number;
|
||||
minimumScale?: number;
|
||||
maximumScale?: number;
|
||||
userScalable?: boolean;
|
||||
viewportFit?: "auto" | "cover" | "contain";
|
||||
interactiveWidget?: "resizes-visual" | "resizes-content" | "overlays-content";
|
||||
};
|
||||
|
||||
// Apple Web App
|
||||
// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html
|
||||
// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html
|
||||
export type AppleWebApp = {
|
||||
// default true
|
||||
capable?: boolean;
|
||||
title?: string;
|
||||
startupImage?: AppleImage | Array<AppleImage>;
|
||||
// default "default"
|
||||
statusBarStyle?: "default" | "black" | "black-translucent";
|
||||
};
|
||||
export type AppleImage = string | AppleImageDescriptor;
|
||||
export type AppleImageDescriptor = {
|
||||
url: string;
|
||||
media?: string;
|
||||
};
|
||||
|
||||
export type ResolvedAppleWebApp = {
|
||||
capable: boolean;
|
||||
title?: string | null;
|
||||
startupImage?: AppleImageDescriptor[] | null;
|
||||
statusBarStyle?: "default" | "black" | "black-translucent";
|
||||
};
|
||||
|
||||
// Format Detection
|
||||
// This is a poorly specified metadata export type that is supposed to
|
||||
// control whether the device attempts to conver text that matches
|
||||
// certain formats into links for action. The most supported example
|
||||
// is how mobile devices detect phone numbers and make them into links
|
||||
// that can initiate a phone call
|
||||
// https://www.goodemailcode.com/email-code/template.html
|
||||
export type FormatDetection = {
|
||||
telephone?: boolean;
|
||||
date?: boolean;
|
||||
address?: boolean;
|
||||
email?: boolean;
|
||||
url?: boolean;
|
||||
};
|
|
@ -1,86 +0,0 @@
|
|||
export type Manifest = {
|
||||
background_color?: string;
|
||||
categories?: string[];
|
||||
description?: string;
|
||||
display?: "fullscreen" | "standalone" | "minimal-ui" | "browser";
|
||||
display_override?: string[];
|
||||
icons?: {
|
||||
src: string;
|
||||
type?: string;
|
||||
sizes?: string;
|
||||
purpose?: "any" | "maskable" | "monochrome" | "badge";
|
||||
}[];
|
||||
id?: string;
|
||||
launch_handler?: {
|
||||
platform?: "windows" | "macos" | "linux";
|
||||
url?: string;
|
||||
};
|
||||
name?: string;
|
||||
orientation?:
|
||||
| "any"
|
||||
| "natural"
|
||||
| "landscape"
|
||||
| "portrait"
|
||||
| "portrait-primary"
|
||||
| "portrait-secondary"
|
||||
| "landscape-primary"
|
||||
| "landscape-secondary";
|
||||
prefer_related_applications?: boolean;
|
||||
protocol_handlers?: {
|
||||
protocol: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}[];
|
||||
related_applications?: {
|
||||
platform: string;
|
||||
url: string;
|
||||
id?: string;
|
||||
}[];
|
||||
scope?: string;
|
||||
screenshots?: {
|
||||
src: string;
|
||||
type?: string;
|
||||
sizes?: string;
|
||||
}[];
|
||||
serviceworker?: {
|
||||
src?: string;
|
||||
scope?: string;
|
||||
type?: string;
|
||||
update_via_cache?: "import" | "none" | "all";
|
||||
};
|
||||
share_target?: {
|
||||
action?: string;
|
||||
method?: "get" | "post";
|
||||
enctype?:
|
||||
| "application/x-www-form-urlencoded"
|
||||
| "multipart/form-data"
|
||||
| "text/plain";
|
||||
params?: {
|
||||
name: string;
|
||||
value: string;
|
||||
required?: boolean;
|
||||
}[];
|
||||
url?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
files?: {
|
||||
accept?: string[];
|
||||
name?: string;
|
||||
}[];
|
||||
};
|
||||
short_name?: string;
|
||||
shortcuts?: {
|
||||
name: string;
|
||||
short_name?: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
icons?: {
|
||||
src: string;
|
||||
type?: string;
|
||||
sizes?: string;
|
||||
purpose?: "any" | "maskable" | "monochrome" | "badge";
|
||||
}[];
|
||||
}[];
|
||||
start_url?: string;
|
||||
theme_color?: string;
|
||||
};
|
|
@ -1,566 +0,0 @@
|
|||
import type {
|
||||
AlternateURLs,
|
||||
ResolvedAlternateURLs,
|
||||
} from "./alternative-urls-types";
|
||||
import type {
|
||||
AppleWebApp,
|
||||
AppLinks,
|
||||
FormatDetection,
|
||||
ItunesApp,
|
||||
ResolvedAppleWebApp,
|
||||
ResolvedAppLinks,
|
||||
Viewport,
|
||||
} from "./extra-types";
|
||||
import type {
|
||||
AbsoluteTemplateString,
|
||||
Author,
|
||||
ColorSchemeEnum,
|
||||
DeprecatedMetadataFields,
|
||||
Icon,
|
||||
Icons,
|
||||
IconURL,
|
||||
ReferrerEnum,
|
||||
ResolvedIcons,
|
||||
ResolvedRobots,
|
||||
ResolvedVerification,
|
||||
Robots,
|
||||
TemplateString,
|
||||
ThemeColorDescriptor,
|
||||
Verification,
|
||||
} from "./metadata-types";
|
||||
import type { Manifest as ManifestFile } from "./manifest-types";
|
||||
import type { OpenGraph, ResolvedOpenGraph } from "./opengraph-types";
|
||||
import type { ResolvedTwitterMetadata, Twitter } from "./twitter-types";
|
||||
|
||||
/**
|
||||
* Metadata interface to describe all the metadata fields that can be set in a document.
|
||||
* @interface
|
||||
*/
|
||||
interface Metadata extends DeprecatedMetadataFields {
|
||||
/**
|
||||
* The base path and origin for absolute urls for various metadata links such as OpenGraph images.
|
||||
*/
|
||||
metadataBase?: null | URL;
|
||||
|
||||
/**
|
||||
* The document title.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "My Blog"
|
||||
* <title>My Blog</title>
|
||||
*
|
||||
* { default: "Dashboard", template: "%s | My Website" }
|
||||
* <title>Dashboard | My Website</title>
|
||||
*
|
||||
* { absolute: "My Blog", template: "%s | My Website" }
|
||||
* <title>My Blog</title>
|
||||
* ```
|
||||
*/
|
||||
title?: null | string | TemplateString;
|
||||
|
||||
/**
|
||||
* The document description, and optionally the OpenGraph and twitter descriptions.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "My Blog Description"
|
||||
* <meta name="description" content="My Blog Description" />
|
||||
* ```
|
||||
*/
|
||||
description?: null | string;
|
||||
|
||||
// Standard metadata names
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name
|
||||
|
||||
/**
|
||||
* The application name.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "My Blog"
|
||||
* <meta name="application-name" content="My Blog" />
|
||||
* ```
|
||||
*/
|
||||
applicationName?: null | string;
|
||||
|
||||
/**
|
||||
* The authors of the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* [{ name: "Next.js Team", url: "https://nextjs.org" }]
|
||||
*
|
||||
* <meta name="author" content="Next.js Team" />
|
||||
* <link rel="author" href="https://nextjs.org" />
|
||||
* ```
|
||||
*/
|
||||
authors?: null | Author | Array<Author>;
|
||||
|
||||
/**
|
||||
* The generator used for the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "Next.js"
|
||||
*
|
||||
* <meta name="generator" content="Next.js" />
|
||||
* ```
|
||||
*/
|
||||
generator?: null | string;
|
||||
|
||||
/**
|
||||
* The keywords for the document. If an array is provided, it will be flattened into a single tag with comma separation.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "nextjs, react, blog"
|
||||
* <meta name="keywords" content="nextjs, react, blog" />
|
||||
*
|
||||
* ["react", "server components"]
|
||||
* <meta name="keywords" content="react, server components" />
|
||||
* ```
|
||||
*/
|
||||
keywords?: null | string | Array<string>;
|
||||
|
||||
/**
|
||||
* The referrer setting for the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "origin"
|
||||
* <meta name="referrer" content="origin" />
|
||||
* ```
|
||||
*/
|
||||
referrer?: null | ReferrerEnum;
|
||||
|
||||
/**
|
||||
* The theme color for the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "#000000"
|
||||
* <meta name="theme-color" content="#000000" />
|
||||
*
|
||||
* { media: "(prefers-color-scheme: dark)", color: "#000000" }
|
||||
* <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
|
||||
*
|
||||
* [
|
||||
* { media: "(prefers-color-scheme: dark)", color: "#000000" },
|
||||
* { media: "(prefers-color-scheme: light)", color: "#ffffff" }
|
||||
* ]
|
||||
* <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
|
||||
* <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
|
||||
* ```
|
||||
*/
|
||||
themeColor?: null | string | ThemeColorDescriptor | ThemeColorDescriptor[];
|
||||
|
||||
/**
|
||||
* The color scheme for the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "dark"
|
||||
* <meta name="color-scheme" content="dark" />
|
||||
* ```
|
||||
*/
|
||||
colorScheme?: null | ColorSchemeEnum;
|
||||
|
||||
/**
|
||||
* The viewport setting for the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "width=device-width, initial-scale=1"
|
||||
* <meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
*
|
||||
* { width: "device-width", initialScale: 1 }
|
||||
* <meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
* ```
|
||||
*/
|
||||
viewport?: null | string | Viewport;
|
||||
|
||||
/**
|
||||
* The creator of the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "Next.js Team"
|
||||
* <meta name="creator" content="Next.js Team" />
|
||||
* ```
|
||||
*/
|
||||
creator?: null | string;
|
||||
|
||||
/**
|
||||
* The publisher of the document.
|
||||
* @example
|
||||
*
|
||||
* ```tsx
|
||||
* "Vercel"
|
||||
* <meta name="publisher" content="Vercel" />
|
||||
* ```
|
||||
*/
|
||||
publisher?: null | string;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#other_metadata_names
|
||||
|
||||
/**
|
||||
* The robots setting for the document.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Glossary/Robots.txt
|
||||
* @example
|
||||
* ```tsx
|
||||
* "index, follow"
|
||||
* <meta name="robots" content="index, follow" />
|
||||
*
|
||||
* { index: false, follow: false }
|
||||
* <meta name="robots" content="noindex, nofollow" />
|
||||
* ```
|
||||
*/
|
||||
robots?: null | string | Robots;
|
||||
|
||||
/**
|
||||
* The canonical and alternate URLs for the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* { canonical: "https://example.com" }
|
||||
* <link rel="canonical" href="https://example.com" />
|
||||
*
|
||||
* { canonical: "https://example.com", hreflang: { "en-US": "https://example.com/en-US" } }
|
||||
* <link rel="canonical" href="https://example.com" />
|
||||
* <link rel="alternate" href="https://example.com/en-US" hreflang="en-US" />
|
||||
* ```
|
||||
*
|
||||
* Multiple titles example for alternate URLs except `canonical`:
|
||||
* ```tsx
|
||||
* {
|
||||
* canonical: "https://example.com",
|
||||
* types: {
|
||||
* 'application/rss+xml': [
|
||||
* { url: 'blog.rss', title: 'rss' },
|
||||
* { url: 'blog/js.rss', title: 'js title' },
|
||||
* ],
|
||||
* },
|
||||
* }
|
||||
* <link rel="canonical" href="https://example.com" />
|
||||
* <link rel="alternate" href="https://example.com/blog.rss" type="application/rss+xml" title="rss" />
|
||||
* <link rel="alternate" href="https://example.com/blog/js.rss" type="application/rss+xml" title="js title" />
|
||||
* ```
|
||||
*/
|
||||
alternates?: null | AlternateURLs;
|
||||
|
||||
/**
|
||||
* The icons for the document. Defaults to rel="icon".
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel#attr-icon
|
||||
* @example
|
||||
* ```tsx
|
||||
* "https://example.com/icon.png"
|
||||
* <link rel="icon" href="https://example.com/icon.png" />
|
||||
*
|
||||
* { icon: "https://example.com/icon.png", apple: "https://example.com/apple-icon.png" }
|
||||
* <link rel="icon" href="https://example.com/icon.png" />
|
||||
* <link rel="apple-touch-icon" href="https://example.com/apple-icon.png" />
|
||||
*
|
||||
* [{ rel: "icon", url: "https://example.com/icon.png" }, { rel: "apple-touch-icon", url: "https://example.com/apple-icon.png" }]
|
||||
* <link rel="icon" href="https://example.com/icon.png" />
|
||||
* <link rel="apple-touch-icon" href="https://example.com/apple-icon.png" />
|
||||
* ```
|
||||
*/
|
||||
icons?: null | IconURL | Array<Icon> | Icons;
|
||||
|
||||
/**
|
||||
* A web application manifest, as defined in the Web Application Manifest specification.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/Manifest
|
||||
* @example
|
||||
* ```tsx
|
||||
* "https://example.com/manifest.json"
|
||||
* <link rel="manifest" href="https://example.com/manifest.json" />
|
||||
* ```
|
||||
*/
|
||||
manifest?: null | string | URL;
|
||||
|
||||
/**
|
||||
* The Open Graph metadata for the document.
|
||||
*
|
||||
* @see https://ogp.me
|
||||
* @example
|
||||
* ```tsx
|
||||
* {
|
||||
* type: "website",
|
||||
* url: "https://example.com",
|
||||
* title: "My Website",
|
||||
* description: "My Website Description",
|
||||
* siteName: "My Website",
|
||||
* images: [{
|
||||
* url: "https://example.com/og.png",
|
||||
* }],
|
||||
* }
|
||||
*
|
||||
* <meta property="og:type" content="website" />
|
||||
* <meta property="og:url" content="https://example.com" />
|
||||
* <meta property="og:site_name" content="My Website" />
|
||||
* <meta property="og:title" content="My Website" />
|
||||
* <meta property="og:description" content="My Website Description" />
|
||||
* <meta property="og:image" content="https://example.com/og.png" />
|
||||
* ```
|
||||
*/
|
||||
openGraph?: null | OpenGraph;
|
||||
|
||||
/**
|
||||
* The Twitter metadata for the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* { card: "summary_large_image", site: "@site", creator: "@creator", "images": "https://example.com/og.png" }
|
||||
*
|
||||
* <meta name="twitter:card" content="summary_large_image" />
|
||||
* <meta name="twitter:site" content="@site" />
|
||||
* <meta name="twitter:creator" content="@creator" />
|
||||
* <meta name="twitter:title" content="My Website" />
|
||||
* <meta name="twitter:description" content="My Website Description" />
|
||||
* <meta name="twitter:image" content="https://example.com/og.png" />
|
||||
* ```
|
||||
*/
|
||||
twitter?: null | Twitter;
|
||||
|
||||
/**
|
||||
* The common verification tokens for the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* { verification: { google: "1234567890", yandex: "1234567890", "me": "1234567890" } }
|
||||
* <meta name="google-site-verification" content="1234567890" />
|
||||
* <meta name="yandex-verification" content="1234567890" />
|
||||
* <meta name="me" content="@me" />
|
||||
* ```
|
||||
*/
|
||||
verification?: Verification;
|
||||
|
||||
/**
|
||||
* The Apple web app metadata for the document.
|
||||
*
|
||||
* @see https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html
|
||||
* @example
|
||||
* ```tsx
|
||||
* { capable: true, title: "My Website", statusBarStyle: "black-translucent" }
|
||||
* <meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
* <meta name="apple-mobile-web-app-title" content="My Website" />
|
||||
* <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
* ```
|
||||
*/
|
||||
appleWebApp?: null | boolean | AppleWebApp;
|
||||
|
||||
/**
|
||||
* Indicates if devices should try to interpret various formats and make actionable links out of them. For example it controles
|
||||
* if telephone numbers on mobile that can be clicked to dial or not.
|
||||
* @example
|
||||
* ```tsx
|
||||
* { telephone: false }
|
||||
* <meta name="format-detection" content="telephone=no" />
|
||||
* ```
|
||||
*/
|
||||
formatDetection?: null | FormatDetection;
|
||||
|
||||
/**
|
||||
* The metadata for the iTunes App.
|
||||
* It adds the `name="apple-itunes-app"` meta tag.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* { app: { id: "123456789", affiliateData: "123456789", appArguments: "123456789" } }
|
||||
* <meta name="apple-itunes-app" content="app-id=123456789, affiliate-data=123456789, app-arguments=123456789" />
|
||||
* ```
|
||||
*/
|
||||
itunes?: null | ItunesApp;
|
||||
|
||||
/**
|
||||
* A brief description of what this web-page is about. Not recommended, superseded by description.
|
||||
* It adds the `name="abstract"` meta tag.
|
||||
*
|
||||
* @see https://www.metatags.org/all-meta-tags-overview/meta-name-abstract/
|
||||
* @example
|
||||
* ```tsx
|
||||
* "My Website Description"
|
||||
* <meta name="abstract" content="My Website Description" />
|
||||
* ```
|
||||
*/
|
||||
abstract?: null | string;
|
||||
|
||||
/**
|
||||
* The Facebook AppLinks metadata for the document.
|
||||
* @example
|
||||
* ```tsx
|
||||
* { ios: { appStoreId: "123456789", url: "https://example.com" }, android: { packageName: "com.example", url: "https://example.com" } }
|
||||
*
|
||||
* <meta property="al:ios:app_store_id" content="123456789" />
|
||||
* <meta property="al:ios:url" content="https://example.com" />
|
||||
* <meta property="al:android:package" content="com.example" />
|
||||
* <meta property="al:android:url" content="https://example.com" />
|
||||
* ```
|
||||
*/
|
||||
appLinks?: null | AppLinks;
|
||||
|
||||
/**
|
||||
* The archives link rel property.
|
||||
* @example
|
||||
* ```tsx
|
||||
* { archives: "https://example.com/archives" }
|
||||
* <link rel="archives" href="https://example.com/archives" />
|
||||
* ```
|
||||
*/
|
||||
archives?: null | string | Array<string>;
|
||||
|
||||
/**
|
||||
* The assets link rel property.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "https://example.com/assets"
|
||||
* <link rel="assets" href="https://example.com/assets" />
|
||||
* ```
|
||||
*/
|
||||
assets?: null | string | Array<string>;
|
||||
|
||||
/**
|
||||
* The bookmarks link rel property.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "https://example.com/bookmarks"
|
||||
* <link rel="bookmarks" href="https://example.com/bookmarks" />
|
||||
* ```
|
||||
*/
|
||||
bookmarks?: null | string | Array<string>; // This is technically against HTML spec but is used in wild
|
||||
|
||||
// meta name properties
|
||||
|
||||
/**
|
||||
* The category meta name property.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "My Category"
|
||||
* <meta name="category" content="My Category" />
|
||||
* ```
|
||||
*/
|
||||
category?: null | string;
|
||||
|
||||
/**
|
||||
* The classification meta name property.
|
||||
* @example
|
||||
* ```tsx
|
||||
* "My Classification"
|
||||
* <meta name="classification" content="My Classification" />
|
||||
* ```
|
||||
*/
|
||||
classification?: null | string;
|
||||
|
||||
/**
|
||||
* Arbitrary name/value pairs for the document.
|
||||
*/
|
||||
other?: {
|
||||
[name: string]: string | number | Array<string | number>;
|
||||
} & DeprecatedMetadataFields;
|
||||
}
|
||||
|
||||
interface ResolvedMetadata extends DeprecatedMetadataFields {
|
||||
// origin and base path for absolute urls for various metadata links such as
|
||||
// opengraph-image
|
||||
metadataBase: null | URL;
|
||||
|
||||
// The Document title and template if defined
|
||||
title: null | AbsoluteTemplateString;
|
||||
|
||||
// The Document description, and optionally the opengraph and twitter descriptions
|
||||
description: null | string;
|
||||
|
||||
// Standard metadata names
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name
|
||||
applicationName: null | string;
|
||||
authors: null | Array<Author>;
|
||||
generator: null | string;
|
||||
// if you provide an array it will be flattened into a single tag with comma separation
|
||||
keywords: null | Array<string>;
|
||||
referrer: null | ReferrerEnum;
|
||||
themeColor: null | ThemeColorDescriptor[];
|
||||
colorScheme: null | ColorSchemeEnum;
|
||||
viewport: null | string;
|
||||
creator: null | string;
|
||||
publisher: null | string;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#other_metadata_names
|
||||
robots: null | ResolvedRobots;
|
||||
|
||||
// The canonical and alternate URLs for this location
|
||||
alternates: null | ResolvedAlternateURLs;
|
||||
|
||||
// Defaults to rel="icon" but the Icons type can be used
|
||||
// to get more specific about rel types
|
||||
icons: null | ResolvedIcons;
|
||||
|
||||
openGraph: null | ResolvedOpenGraph;
|
||||
|
||||
manifest: null | string | URL;
|
||||
|
||||
twitter: null | ResolvedTwitterMetadata;
|
||||
|
||||
// common verification tokens
|
||||
verification: null | ResolvedVerification;
|
||||
|
||||
// Apple web app metadata
|
||||
// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html
|
||||
appleWebApp: null | ResolvedAppleWebApp;
|
||||
|
||||
// Should devices try to interpret various formats and make actionable links
|
||||
// out of them? The canonical example is telephone numbers on mobile that can
|
||||
// be clicked to dial
|
||||
formatDetection: null | FormatDetection;
|
||||
|
||||
// meta name="apple-itunes-app"
|
||||
itunes: null | ItunesApp;
|
||||
|
||||
// meta name="abstract"
|
||||
// A brief description of what this web-page is about.
|
||||
// Not recommended, superceded by description.
|
||||
// https://www.metatags.org/all-meta-tags-overview/meta-name-abstract/
|
||||
abstract: null | string;
|
||||
|
||||
// Facebook AppLinks
|
||||
appLinks: null | ResolvedAppLinks;
|
||||
|
||||
// link rel properties
|
||||
archives: null | Array<string>;
|
||||
assets: null | Array<string>;
|
||||
bookmarks: null | Array<string>; // This is technically against HTML spec but is used in wild
|
||||
|
||||
// meta name properties
|
||||
category: null | string;
|
||||
classification: null | string;
|
||||
|
||||
// Arbitrary name/value pairs
|
||||
other:
|
||||
| null
|
||||
| ({
|
||||
[name: string]: string | number | Array<string | number>;
|
||||
} & DeprecatedMetadataFields);
|
||||
}
|
||||
|
||||
type RobotsFile = {
|
||||
// Apply rules for all
|
||||
rules:
|
||||
| {
|
||||
userAgent?: string | string[];
|
||||
allow?: string | string[];
|
||||
disallow?: string | string[];
|
||||
crawlDelay?: number;
|
||||
}
|
||||
// Apply rules for specific user agents
|
||||
| Array<{
|
||||
userAgent: string | string[];
|
||||
allow?: string | string[];
|
||||
disallow?: string | string[];
|
||||
crawlDelay?: number;
|
||||
}>;
|
||||
sitemap?: string | string[];
|
||||
host?: string;
|
||||
};
|
||||
|
||||
type SitemapFile = Array<{
|
||||
url: string;
|
||||
lastModified?: string | Date;
|
||||
}>;
|
||||
|
||||
type ResolvingMetadata = Promise<ResolvedMetadata>;
|
||||
declare namespace MetadataRoute {
|
||||
export type Robots = RobotsFile;
|
||||
export type Sitemap = SitemapFile;
|
||||
export type Manifest = ManifestFile;
|
||||
}
|
||||
|
||||
export { Metadata, MetadataRoute, ResolvedMetadata, ResolvingMetadata };
|
|
@ -1,155 +0,0 @@
|
|||
/**
|
||||
* Metadata types
|
||||
*/
|
||||
|
||||
export interface DeprecatedMetadataFields {
|
||||
/**
|
||||
* Deprecated options that have a preferred method
|
||||
* @deprecated Use appWebApp to configure apple-mobile-web-app-capable which provides
|
||||
* @see https://www.appsloveworld.com/coding/iphone/11/difference-between-apple-mobile-web-app-capable-and-apple-touch-fullscreen-ipho
|
||||
*/
|
||||
"apple-touch-fullscreen"?: never;
|
||||
|
||||
/**
|
||||
* Obsolete since iOS 7.
|
||||
* @see https://web.dev/apple-touch-icon/
|
||||
* @deprecated use icons.apple or instead
|
||||
*/
|
||||
"apple-touch-icon-precomposed"?: never;
|
||||
}
|
||||
|
||||
export type TemplateString =
|
||||
| DefaultTemplateString
|
||||
| AbsoluteTemplateString
|
||||
| AbsoluteString;
|
||||
export type DefaultTemplateString = {
|
||||
default: string;
|
||||
template: string;
|
||||
};
|
||||
export type AbsoluteTemplateString = {
|
||||
absolute: string;
|
||||
template: string | null;
|
||||
};
|
||||
export type AbsoluteString = {
|
||||
absolute: string;
|
||||
};
|
||||
|
||||
export type Author = {
|
||||
// renders as <link rel="author"...
|
||||
url?: string | URL;
|
||||
// renders as <meta name="author"...
|
||||
name?: string;
|
||||
};
|
||||
|
||||
// does not include "unsafe-URL". to use this users should
|
||||
// use '"unsafe-URL" as ReferrerEnum'
|
||||
export type ReferrerEnum =
|
||||
| "no-referrer"
|
||||
| "origin"
|
||||
| "no-referrer-when-downgrade"
|
||||
| "origin-when-cross-origin"
|
||||
| "same-origin"
|
||||
| "strict-origin"
|
||||
| "strict-origin-when-cross-origin";
|
||||
|
||||
export type ColorSchemeEnum =
|
||||
| "normal"
|
||||
| "light"
|
||||
| "dark"
|
||||
| "light dark"
|
||||
| "dark light"
|
||||
| "only light";
|
||||
|
||||
type RobotsInfo = {
|
||||
// all and none will be inferred from index/follow boolean options
|
||||
index?: boolean;
|
||||
follow?: boolean;
|
||||
|
||||
/** @deprecated set index to false instead */
|
||||
noindex?: never;
|
||||
/** @deprecated set follow to false instead */
|
||||
nofollow?: never;
|
||||
|
||||
noarchive?: boolean;
|
||||
nosnippet?: boolean;
|
||||
noimageindex?: boolean;
|
||||
nocache?: boolean;
|
||||
notranslate?: boolean;
|
||||
indexifembedded?: boolean;
|
||||
nositelinkssearchbox?: boolean;
|
||||
unavailable_after?: string;
|
||||
"max-video-preview"?: number | string;
|
||||
"max-image-preview"?: "none" | "standard" | "large";
|
||||
"max-snippet"?: number;
|
||||
};
|
||||
export type Robots = RobotsInfo & {
|
||||
// if you want to specify an alternate robots just for google
|
||||
googleBot?: string | RobotsInfo;
|
||||
};
|
||||
|
||||
export type ResolvedRobots = {
|
||||
basic: string | null;
|
||||
googleBot: string | null;
|
||||
};
|
||||
|
||||
export type IconURL = string | URL;
|
||||
export type Icon = IconURL | IconDescriptor;
|
||||
export type IconDescriptor = {
|
||||
url: string | URL;
|
||||
type?: string;
|
||||
sizes?: string;
|
||||
/** defaults to rel="icon" unless superseded by Icons map */
|
||||
rel?: string;
|
||||
media?: string;
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/fetchPriority
|
||||
*/
|
||||
fetchPriority?: "high" | "low" | "auto";
|
||||
};
|
||||
|
||||
export type Icons = {
|
||||
/** rel="icon" */
|
||||
icon?: Icon | Icon[];
|
||||
/** rel="shortcut icon" */
|
||||
shortcut?: Icon | Icon[];
|
||||
/**
|
||||
* @see https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html
|
||||
* rel="apple-touch-icon"
|
||||
*/
|
||||
apple?: Icon | Icon[];
|
||||
/** rel inferred from descriptor, defaults to "icon" */
|
||||
other?: IconDescriptor | IconDescriptor[];
|
||||
};
|
||||
|
||||
export type Verification = {
|
||||
google?: null | string | number | (string | number)[];
|
||||
yahoo?: null | string | number | (string | number)[];
|
||||
yandex?: null | string | number | (string | number)[];
|
||||
me?: null | string | number | (string | number)[];
|
||||
// if you ad-hoc additional verification
|
||||
other?: {
|
||||
[name: string]: string | number | (string | number)[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ResolvedVerification = {
|
||||
google?: null | (string | number)[];
|
||||
yahoo?: null | (string | number)[];
|
||||
yandex?: null | (string | number)[];
|
||||
me?: null | (string | number)[];
|
||||
other?: {
|
||||
[name: string]: (string | number)[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ResolvedIcons = {
|
||||
icon: IconDescriptor[];
|
||||
apple: IconDescriptor[];
|
||||
shortcut?: IconDescriptor[];
|
||||
other?: IconDescriptor[];
|
||||
};
|
||||
|
||||
export type ThemeColorDescriptor = {
|
||||
color: string;
|
||||
media?: string;
|
||||
};
|
|
@ -1,267 +0,0 @@
|
|||
import type { AbsoluteTemplateString, TemplateString } from "./metadata-types";
|
||||
|
||||
export type OpenGraphType =
|
||||
| "article"
|
||||
| "book"
|
||||
| "music.song"
|
||||
| "music.album"
|
||||
| "music.playlist"
|
||||
| "music.radio_station"
|
||||
| "profile"
|
||||
| "website"
|
||||
| "video.tv_show"
|
||||
| "video.other"
|
||||
| "video.movie"
|
||||
| "video.episode";
|
||||
|
||||
export type OpenGraph =
|
||||
| OpenGraphWebsite
|
||||
| OpenGraphArticle
|
||||
| OpenGraphBook
|
||||
| OpenGraphProfile
|
||||
| OpenGraphMusicSong
|
||||
| OpenGraphMusicAlbum
|
||||
| OpenGraphMusicPlaylist
|
||||
| OpenGraphRadioStation
|
||||
| OpenGraphVideoMovie
|
||||
| OpenGraphVideoEpisode
|
||||
| OpenGraphVideoTVShow
|
||||
| OpenGraphVideoOther
|
||||
| OpenGraphMetadata;
|
||||
|
||||
// update this type to reflect actual locales
|
||||
type Locale = string;
|
||||
|
||||
type OpenGraphMetadata = {
|
||||
determiner?: "a" | "an" | "the" | "auto" | "";
|
||||
title?: string | TemplateString;
|
||||
description?: string;
|
||||
emails?: string | Array<string>;
|
||||
phoneNumbers?: string | Array<string>;
|
||||
faxNumbers?: string | Array<string>;
|
||||
siteName?: string;
|
||||
locale?: Locale;
|
||||
alternateLocale?: Locale | Array<Locale>;
|
||||
images?: OGImage | Array<OGImage>;
|
||||
audio?: OGAudio | Array<OGAudio>;
|
||||
videos?: OGVideo | Array<OGVideo>;
|
||||
url?: string | URL;
|
||||
countryName?: string;
|
||||
ttl?: number;
|
||||
};
|
||||
type OpenGraphWebsite = OpenGraphMetadata & {
|
||||
type: "website";
|
||||
};
|
||||
type OpenGraphArticle = OpenGraphMetadata & {
|
||||
type: "article";
|
||||
publishedTime?: string; // datetime
|
||||
modifiedTime?: string; // datetime
|
||||
expirationTime?: string; // datetime
|
||||
authors?: null | string | URL | Array<string | URL>;
|
||||
section?: null | string;
|
||||
tags?: null | string | Array<string>;
|
||||
};
|
||||
type OpenGraphBook = OpenGraphMetadata & {
|
||||
type: "book";
|
||||
isbn?: null | string;
|
||||
releaseDate?: null | string; // datetime
|
||||
authors?: null | string | URL | Array<string | URL>;
|
||||
tags?: null | string | Array<string>;
|
||||
};
|
||||
type OpenGraphProfile = OpenGraphMetadata & {
|
||||
type: "profile";
|
||||
firstName?: null | string;
|
||||
lastName?: null | string;
|
||||
username?: null | string;
|
||||
gender?: null | string;
|
||||
};
|
||||
type OpenGraphMusicSong = OpenGraphMetadata & {
|
||||
type: "music.song";
|
||||
duration?: null | number;
|
||||
albums?: null | string | URL | OGAlbum | Array<string | URL | OGAlbum>;
|
||||
musicians?: null | string | URL | Array<string | URL>;
|
||||
};
|
||||
type OpenGraphMusicAlbum = OpenGraphMetadata & {
|
||||
type: "music.album";
|
||||
songs?: null | string | URL | OGSong | Array<string | URL | OGSong>;
|
||||
musicians?: null | string | URL | Array<string | URL>;
|
||||
releaseDate?: null | string; // datetime
|
||||
};
|
||||
type OpenGraphMusicPlaylist = OpenGraphMetadata & {
|
||||
type: "music.playlist";
|
||||
songs?: null | string | URL | OGSong | Array<string | URL | OGSong>;
|
||||
creators?: null | string | URL | Array<string | URL>;
|
||||
};
|
||||
type OpenGraphRadioStation = OpenGraphMetadata & {
|
||||
type: "music.radio_station";
|
||||
creators?: null | string | URL | Array<string | URL>;
|
||||
};
|
||||
type OpenGraphVideoMovie = OpenGraphMetadata & {
|
||||
type: "video.movie";
|
||||
actors?: null | string | URL | OGActor | Array<string | URL | OGActor>;
|
||||
directors?: null | string | URL | Array<string | URL>;
|
||||
writers?: null | string | URL | Array<string | URL>;
|
||||
duration?: null | number;
|
||||
releaseDate?: null | string; // datetime
|
||||
tags?: null | string | Array<string>;
|
||||
};
|
||||
type OpenGraphVideoEpisode = OpenGraphMetadata & {
|
||||
type: "video.episode";
|
||||
actors?: null | string | URL | OGActor | Array<string | URL | OGActor>;
|
||||
directors?: null | string | URL | Array<string | URL>;
|
||||
writers?: null | string | URL | Array<string | URL>;
|
||||
duration?: null | number;
|
||||
releaseDate?: null | string; // datetime
|
||||
tags?: null | string | Array<string>;
|
||||
series?: null | string | URL;
|
||||
};
|
||||
type OpenGraphVideoTVShow = OpenGraphMetadata & {
|
||||
type: "video.tv_show";
|
||||
};
|
||||
type OpenGraphVideoOther = OpenGraphMetadata & {
|
||||
type: "video.other";
|
||||
};
|
||||
|
||||
type OGImage = string | OGImageDescriptor | URL;
|
||||
type OGImageDescriptor = {
|
||||
url: string | URL;
|
||||
secureUrl?: string | URL;
|
||||
alt?: string;
|
||||
type?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
};
|
||||
type OGAudio = string | OGAudioDescriptor | URL;
|
||||
type OGAudioDescriptor = {
|
||||
url: string | URL;
|
||||
secureUrl?: string | URL;
|
||||
type?: string;
|
||||
};
|
||||
type OGVideo = string | OGVideoDescriptor | URL;
|
||||
type OGVideoDescriptor = {
|
||||
url: string | URL;
|
||||
secureUrl?: string | URL;
|
||||
type?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
};
|
||||
|
||||
export type ResolvedOpenGraph =
|
||||
| ResolvedOpenGraphWebsite
|
||||
| ResolvedOpenGraphArticle
|
||||
| ResolvedOpenGraphBook
|
||||
| ResolvedOpenGraphProfile
|
||||
| ResolvedOpenGraphMusicSong
|
||||
| ResolvedOpenGraphMusicAlbum
|
||||
| ResolvedOpenGraphMusicPlaylist
|
||||
| ResolvedOpenGraphRadioStation
|
||||
| ResolvedOpenGraphVideoMovie
|
||||
| ResolvedOpenGraphVideoEpisode
|
||||
| ResolvedOpenGraphVideoTVShow
|
||||
| ResolvedOpenGraphVideoOther
|
||||
| ResolvedOpenGraphMetadata;
|
||||
|
||||
type ResolvedOpenGraphMetadata = {
|
||||
determiner?: "a" | "an" | "the" | "auto" | "";
|
||||
title?: AbsoluteTemplateString;
|
||||
description?: string;
|
||||
emails?: Array<string>;
|
||||
phoneNumbers?: Array<string>;
|
||||
faxNumbers?: Array<string>;
|
||||
siteName?: string;
|
||||
locale?: Locale;
|
||||
alternateLocale?: Array<Locale>;
|
||||
images?: Array<OGImage>;
|
||||
audio?: Array<OGAudio>;
|
||||
videos?: Array<OGVideo>;
|
||||
url: null | URL | string;
|
||||
countryName?: string;
|
||||
ttl?: number;
|
||||
};
|
||||
type ResolvedOpenGraphWebsite = ResolvedOpenGraphMetadata & {
|
||||
type: "website";
|
||||
};
|
||||
type ResolvedOpenGraphArticle = ResolvedOpenGraphMetadata & {
|
||||
type: "article";
|
||||
publishedTime?: string; // datetime
|
||||
modifiedTime?: string; // datetime
|
||||
expirationTime?: string; // datetime
|
||||
authors?: Array<string>;
|
||||
section?: string;
|
||||
tags?: Array<string>;
|
||||
};
|
||||
type ResolvedOpenGraphBook = ResolvedOpenGraphMetadata & {
|
||||
type: "book";
|
||||
isbn?: string;
|
||||
releaseDate?: string; // datetime
|
||||
authors?: Array<string>;
|
||||
tags?: Array<string>;
|
||||
};
|
||||
type ResolvedOpenGraphProfile = ResolvedOpenGraphMetadata & {
|
||||
type: "profile";
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
gender?: string;
|
||||
};
|
||||
type ResolvedOpenGraphMusicSong = ResolvedOpenGraphMetadata & {
|
||||
type: "music.song";
|
||||
duration?: number;
|
||||
albums?: Array<OGAlbum>;
|
||||
musicians?: Array<string | URL>;
|
||||
};
|
||||
type ResolvedOpenGraphMusicAlbum = ResolvedOpenGraphMetadata & {
|
||||
type: "music.album";
|
||||
songs?: Array<string | URL | OGSong>;
|
||||
musicians?: Array<string | URL>;
|
||||
releaseDate?: string; // datetime
|
||||
};
|
||||
type ResolvedOpenGraphMusicPlaylist = ResolvedOpenGraphMetadata & {
|
||||
type: "music.playlist";
|
||||
songs?: Array<string | URL | OGSong>;
|
||||
creators?: Array<string | URL>;
|
||||
};
|
||||
type ResolvedOpenGraphRadioStation = ResolvedOpenGraphMetadata & {
|
||||
type: "music.radio_station";
|
||||
creators?: Array<string | URL>;
|
||||
};
|
||||
type ResolvedOpenGraphVideoMovie = ResolvedOpenGraphMetadata & {
|
||||
type: "video.movie";
|
||||
actors?: Array<string | URL | OGActor>;
|
||||
directors?: Array<string | URL>;
|
||||
writers?: Array<string | URL>;
|
||||
duration?: number;
|
||||
releaseDate?: string; // datetime
|
||||
tags?: Array<string>;
|
||||
};
|
||||
type ResolvedOpenGraphVideoEpisode = ResolvedOpenGraphMetadata & {
|
||||
type: "video.episode";
|
||||
actors?: Array<string | URL | OGActor>;
|
||||
directors?: Array<string | URL>;
|
||||
writers?: Array<string | URL>;
|
||||
duration?: number;
|
||||
releaseDate?: string; // datetime
|
||||
tags?: Array<string>;
|
||||
series?: string | URL;
|
||||
};
|
||||
type ResolvedOpenGraphVideoTVShow = ResolvedOpenGraphMetadata & {
|
||||
type: "video.tv_show";
|
||||
};
|
||||
type ResolvedOpenGraphVideoOther = ResolvedOpenGraphMetadata & {
|
||||
type: "video.other";
|
||||
};
|
||||
|
||||
type OGSong = {
|
||||
url: string | URL;
|
||||
disc?: number;
|
||||
track?: number;
|
||||
};
|
||||
type OGAlbum = {
|
||||
url: string | URL;
|
||||
disc?: number;
|
||||
track?: number;
|
||||
};
|
||||
type OGActor = {
|
||||
url: string | URL;
|
||||
role?: string;
|
||||
};
|
|
@ -1,17 +0,0 @@
|
|||
import { Metadata, ResolvedMetadata } from "./metadata-interface";
|
||||
|
||||
export type FieldResolver<Key extends keyof Metadata> = (
|
||||
T: Metadata[Key],
|
||||
) => ResolvedMetadata[Key];
|
||||
export type FieldResolverWithMetadataBase<
|
||||
Key extends keyof Metadata,
|
||||
Options = undefined,
|
||||
> = Options extends undefined ? (
|
||||
T: Metadata[Key],
|
||||
metadataBase: ResolvedMetadata["metadataBase"],
|
||||
) => ResolvedMetadata[Key]
|
||||
: (
|
||||
T: Metadata[Key],
|
||||
metadataBase: ResolvedMetadata["metadataBase"],
|
||||
options: Options,
|
||||
) => ResolvedMetadata[Key];
|
|
@ -1,94 +0,0 @@
|
|||
// Reference: https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup
|
||||
|
||||
import type { AbsoluteTemplateString, TemplateString } from "./metadata-types";
|
||||
|
||||
export type Twitter =
|
||||
| TwitterSummary
|
||||
| TwitterSummaryLargeImage
|
||||
| TwitterPlayer
|
||||
| TwitterApp
|
||||
| TwitterMetadata;
|
||||
|
||||
type TwitterMetadata = {
|
||||
// defaults to card="summary"
|
||||
site?: string; // username for account associated to the site itself
|
||||
siteId?: string; // id for account associated to the site itself
|
||||
creator?: string; // username for the account associated to the creator of the content on the site
|
||||
creatorId?: string; // id for the account associated to the creator of the content on the site
|
||||
description?: string;
|
||||
title?: string | TemplateString;
|
||||
images?: TwitterImage | Array<TwitterImage>;
|
||||
};
|
||||
type TwitterSummary = TwitterMetadata & {
|
||||
card: "summary";
|
||||
};
|
||||
type TwitterSummaryLargeImage = TwitterMetadata & {
|
||||
card: "summary_large_image";
|
||||
};
|
||||
type TwitterPlayer = TwitterMetadata & {
|
||||
card: "player";
|
||||
players: TwitterPlayerDescriptor | Array<TwitterPlayerDescriptor>;
|
||||
};
|
||||
type TwitterApp = TwitterMetadata & {
|
||||
card: "app";
|
||||
app: TwitterAppDescriptor;
|
||||
};
|
||||
export type TwitterAppDescriptor = {
|
||||
id: {
|
||||
iphone?: string | number;
|
||||
ipad?: string | number;
|
||||
googleplay?: string;
|
||||
};
|
||||
url?: {
|
||||
iphone?: string | URL;
|
||||
ipad?: string | URL;
|
||||
googleplay?: string | URL;
|
||||
};
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type TwitterImage = string | TwitterImageDescriptor | URL;
|
||||
type TwitterImageDescriptor = {
|
||||
url: string | URL;
|
||||
alt?: string;
|
||||
secureUrl?: string | URL;
|
||||
type?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
};
|
||||
type TwitterPlayerDescriptor = {
|
||||
playerUrl: string | URL;
|
||||
streamUrl: string | URL;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ResolvedTwitterImage = {
|
||||
url: string | URL;
|
||||
alt?: string;
|
||||
secureUrl?: string | URL;
|
||||
type?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
};
|
||||
type ResolvedTwitterSummary = {
|
||||
site: string | null;
|
||||
siteId: string | null;
|
||||
creator: string | null;
|
||||
creatorId: string | null;
|
||||
description: string | null;
|
||||
title: AbsoluteTemplateString;
|
||||
images?: Array<ResolvedTwitterImage>;
|
||||
};
|
||||
type ResolvedTwitterPlayer = ResolvedTwitterSummary & {
|
||||
players: Array<TwitterPlayerDescriptor>;
|
||||
};
|
||||
type ResolvedTwitterApp = ResolvedTwitterSummary & {
|
||||
app: TwitterAppDescriptor;
|
||||
};
|
||||
|
||||
export type ResolvedTwitterMetadata =
|
||||
| ({ card: "summary" } & ResolvedTwitterSummary)
|
||||
| ({ card: "summary_large_image" } & ResolvedTwitterSummary)
|
||||
| ({ card: "player" } & ResolvedTwitterPlayer)
|
||||
| ({ card: "app" } & ResolvedTwitterApp);
|
|
@ -1,5 +0,0 @@
|
|||
the metadata renderer was written in 2022, and is 3700 lines of code. it
|
||||
represents the bulk of the code in the framework, which i think is wrong.
|
||||
|
||||
a bounty goes to rewriting this codebase into one or two files. merging logic is
|
||||
surely not needed, and resolution can happen in the same step as rendering.
|
|
@ -1,565 +0,0 @@
|
|||
import type { Icon, ResolvedMetadata } from "./types";
|
||||
import { escapeHTML as esc } from "./utils";
|
||||
|
||||
function Meta(name: string, content: any) {
|
||||
return `<meta name="${esc(name)}" content="${esc(content)}">`;
|
||||
}
|
||||
|
||||
function MetaProp(name: string, content: any) {
|
||||
return `<meta property="${esc(name)}" content="${esc(content)}">`;
|
||||
}
|
||||
|
||||
function MetaMedia(name: string, content: any, media: string) {
|
||||
return `<meta name="${esc(name)}" content="${esc(content)}" media="${
|
||||
esc(media)
|
||||
}">`;
|
||||
}
|
||||
|
||||
function Link(rel: string, href: any) {
|
||||
return `<link rel="${esc(rel)}" href="${esc(href)}" />`;
|
||||
}
|
||||
|
||||
function LinkMedia(rel: string, href: any, media: string) {
|
||||
return `<link rel="${esc(rel)}" href="${esc(href)}" media="${esc(media)}">`;
|
||||
}
|
||||
|
||||
const resolveUrl = (
|
||||
url: string | URL,
|
||||
) => (typeof url === "string" ? url : url.toString());
|
||||
|
||||
function IconLink(rel: string, icon: Icon) {
|
||||
if (typeof icon === "object" && !(icon instanceof URL)) {
|
||||
const { url, rel: _, ...props } = icon;
|
||||
return `<link rel="${esc(rel)}" href="${esc(resolveUrl(url))}"${
|
||||
Object.keys(props)
|
||||
.map((key) => ` ${key}="${esc(props[key])}"`)
|
||||
.join("")
|
||||
}>`;
|
||||
} else {
|
||||
const href = resolveUrl(icon);
|
||||
return Link(rel, href);
|
||||
}
|
||||
}
|
||||
|
||||
function ExtendMeta(prefix: string, content: any) {
|
||||
if (
|
||||
typeof content === "string" || typeof content === "number" ||
|
||||
content instanceof URL
|
||||
) {
|
||||
return MetaProp(prefix, content);
|
||||
} else {
|
||||
let str = "";
|
||||
for (const [prop, value] of Object.entries(content)) {
|
||||
if (value) {
|
||||
str += MetaProp(
|
||||
prefix === "og:image" && prop === "url"
|
||||
? "og:image"
|
||||
: prefix + ":" + prop,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
const formatDetectionKeys = [
|
||||
"telephone",
|
||||
"date",
|
||||
"address",
|
||||
"email",
|
||||
"url",
|
||||
] as const;
|
||||
|
||||
export function renderMetadata(meta: ResolvedMetadata): string {
|
||||
var str = "";
|
||||
|
||||
// <BasicMetadata/>
|
||||
if (meta.title?.absolute) str += `<title>${esc(meta.title.absolute)}</title>`;
|
||||
if (meta.description) str += Meta("description", meta.description);
|
||||
if (meta.applicationName) {
|
||||
str += Meta("application-name", meta.applicationName);
|
||||
}
|
||||
if (meta.authors) {
|
||||
for (var author of meta.authors) {
|
||||
if (author.url) str += Link("author", author.url);
|
||||
if (author.name) str += Meta("author", author.name);
|
||||
}
|
||||
}
|
||||
if (meta.manifest) str += Link("manifest", meta.manifest);
|
||||
if (meta.generator) str += Meta("generator", meta.generator);
|
||||
if (meta.referrer) str += Meta("referrer", meta.referrer);
|
||||
if (meta.themeColor) {
|
||||
for (var themeColor of meta.themeColor) {
|
||||
str += !themeColor.media
|
||||
? Meta("theme-color", themeColor.color)
|
||||
: MetaMedia("theme-color", themeColor.color, themeColor.media);
|
||||
}
|
||||
}
|
||||
if (meta.colorScheme) str += Meta("color-scheme", meta.colorScheme);
|
||||
if (meta.viewport) str += Meta("viewport", meta.viewport);
|
||||
if (meta.creator) str += Meta("creator", meta.creator);
|
||||
if (meta.publisher) str += Meta("publisher", meta.publisher);
|
||||
if (meta.robots?.basic) str += Meta("robots", meta.robots.basic);
|
||||
if (meta.robots?.googleBot) str += Meta("googlebot", meta.robots.googleBot);
|
||||
if (meta.abstract) str += Meta("abstract", meta.abstract);
|
||||
if (meta.archives) {
|
||||
for (var archive of meta.archives) {
|
||||
str += Link("archives", archive);
|
||||
}
|
||||
}
|
||||
if (meta.assets) {
|
||||
for (var asset of meta.assets) {
|
||||
str += Link("assets", asset);
|
||||
}
|
||||
}
|
||||
if (meta.bookmarks) {
|
||||
for (var bookmark of meta.bookmarks) {
|
||||
str += Link("bookmarks", bookmark);
|
||||
}
|
||||
}
|
||||
if (meta.category) str += Meta("category", meta.category);
|
||||
if (meta.classification) str += Meta("classification", meta.classification);
|
||||
if (meta.other) {
|
||||
for (var [name, content] of Object.entries(meta.other)) {
|
||||
if (content) {
|
||||
str += Meta(name, Array.isArray(content) ? content.join(",") : content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <AlternatesMetadata />
|
||||
var alternates = meta.alternates;
|
||||
if (alternates) {
|
||||
if (alternates.canonical) {
|
||||
str += Link("canonical", alternates.canonical.url);
|
||||
}
|
||||
if (alternates.languages) {
|
||||
for (var [locale, urls] of Object.entries(alternates.languages)) {
|
||||
for (var { url, title } of urls) {
|
||||
str += `<link rel="alternate" hreflang="${esc(locale)}" href="${
|
||||
esc(url.toString())
|
||||
}"${title ? ` title="${esc(title)}"` : ""}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (alternates.media) {
|
||||
for (var [media, urls2] of Object.entries(alternates.media)) {
|
||||
if (urls2) {
|
||||
for (var { url, title } of urls2) {
|
||||
str += `<link rel="alternate" media="${esc(media)}" href="${
|
||||
esc(url.toString())
|
||||
}"${title ? ` title="${esc(title)}"` : ""}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (alternates.types) {
|
||||
for (var [type, urls2] of Object.entries(alternates.types)) {
|
||||
if (urls2) {
|
||||
for (var { url, title } of urls2) {
|
||||
str += `<link rel="alternate" type="${esc(type)}" href="${
|
||||
esc(url.toString())
|
||||
}"${title ? ` title="${esc(title)}"` : ""}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <ItunesMeta />
|
||||
if (meta.itunes) {
|
||||
str += Meta(
|
||||
"apple-itunes-app",
|
||||
`app-id=${meta.itunes.appId}${
|
||||
meta.itunes.appArgument
|
||||
? `, app-argument=${meta.itunes.appArgument}`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// <FormatDetectionMeta />
|
||||
if (meta.formatDetection) {
|
||||
var contentStr = "";
|
||||
for (var key of formatDetectionKeys) {
|
||||
if (key in meta.formatDetection) {
|
||||
if (contentStr) contentStr += ", ";
|
||||
contentStr += `${key}=no`;
|
||||
}
|
||||
}
|
||||
str += Meta("format-detection", contentStr);
|
||||
}
|
||||
|
||||
// <VerificationMeta />
|
||||
if (meta.verification) {
|
||||
if (meta.verification.google) {
|
||||
for (var verificationKey of meta.verification.google) {
|
||||
str += Meta("google-site-verification", verificationKey);
|
||||
}
|
||||
}
|
||||
if (meta.verification.yahoo) {
|
||||
for (var verificationKey of meta.verification.yahoo) {
|
||||
str += Meta("y_key", verificationKey);
|
||||
}
|
||||
}
|
||||
if (meta.verification.yandex) {
|
||||
for (var verificationKey of meta.verification.yandex) {
|
||||
str += Meta("yandex-verification", verificationKey);
|
||||
}
|
||||
}
|
||||
if (meta.verification.me) {
|
||||
for (var verificationKey of meta.verification.me) {
|
||||
str += Meta("me", verificationKey);
|
||||
}
|
||||
}
|
||||
if (meta.verification.other) {
|
||||
for (
|
||||
var [verificationKey2, values] of Object.entries(
|
||||
meta.verification.other,
|
||||
)
|
||||
) {
|
||||
for (var value of values) {
|
||||
str += Meta(verificationKey2, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <AppleWebAppMeta />
|
||||
if (meta.appleWebApp) {
|
||||
const { capable, title, startupImage, statusBarStyle } = meta.appleWebApp;
|
||||
if (capable) {
|
||||
str += '<meta name="apple-mobile-web-app-capable" content="yes" />';
|
||||
}
|
||||
if (title) str += Meta("apple-mobile-web-app-title", title);
|
||||
if (startupImage) {
|
||||
for (const img of startupImage) {
|
||||
str += !img.media
|
||||
? Link("apple-touch-startup-image", img.url)
|
||||
: LinkMedia("apple-touch-startup-image", img.url, img.media);
|
||||
}
|
||||
}
|
||||
if (statusBarStyle) {
|
||||
str += Meta("apple-mobile-web-app-status-bar-style", statusBarStyle);
|
||||
}
|
||||
}
|
||||
|
||||
// <OpenGraphMetadata />
|
||||
if (meta.openGraph) {
|
||||
const og = meta.openGraph;
|
||||
if (og.determiner) str += MetaProp("og:determiner", og.determiner);
|
||||
if (og.title?.absolute) str += MetaProp("og:title", og.title.absolute);
|
||||
if (og.description) str += MetaProp("og:description", og.description);
|
||||
if (og.url) str += MetaProp("og:url", og.url.toString());
|
||||
if (og.siteName) str += MetaProp("og:site_name", og.siteName);
|
||||
if (og.locale) str += MetaProp("og:locale", og.locale);
|
||||
if (og.countryName) str += MetaProp("og:country_name", og.countryName);
|
||||
if (og.ttl) str += MetaProp("og:ttl", og.ttl);
|
||||
if (og.images) {
|
||||
for (const item of og.images) {
|
||||
str += ExtendMeta("og:image", item);
|
||||
}
|
||||
}
|
||||
if (og.videos) {
|
||||
for (const item of og.videos) {
|
||||
str += ExtendMeta("og:video", item);
|
||||
}
|
||||
}
|
||||
if (og.audio) {
|
||||
for (const item of og.audio) {
|
||||
str += ExtendMeta("og:audio", item);
|
||||
}
|
||||
}
|
||||
if (og.emails) {
|
||||
for (const item of og.emails) {
|
||||
str += ExtendMeta("og:email", item);
|
||||
}
|
||||
}
|
||||
if (og.phoneNumbers) {
|
||||
for (const item of og.phoneNumbers) {
|
||||
str += MetaProp("og:phone_number", item);
|
||||
}
|
||||
}
|
||||
if (og.faxNumbers) {
|
||||
for (const item of og.faxNumbers) {
|
||||
str += MetaProp("og:fax_number", item);
|
||||
}
|
||||
}
|
||||
if (og.alternateLocale) {
|
||||
for (const item of og.alternateLocale) {
|
||||
str += MetaProp("og:locale:alternate", item);
|
||||
}
|
||||
}
|
||||
|
||||
if ("type" in og) {
|
||||
str += MetaProp("og:type", og.type);
|
||||
switch (og.type) {
|
||||
case "website":
|
||||
break;
|
||||
case "article":
|
||||
if (og.publishedTime) {
|
||||
str += MetaProp("article:published_time", og.publishedTime);
|
||||
}
|
||||
if (og.modifiedTime) {
|
||||
str += MetaProp("article:modified_time", og.modifiedTime);
|
||||
}
|
||||
if (og.expirationTime) {
|
||||
str += MetaProp("article:expiration_time", og.expirationTime);
|
||||
}
|
||||
if (og.authors) {
|
||||
for (const item of og.authors) {
|
||||
str += MetaProp("article:author", item);
|
||||
}
|
||||
}
|
||||
if (og.section) str += MetaProp("article:section", og.section);
|
||||
if (og.tags) {
|
||||
for (const item of og.tags) {
|
||||
str += MetaProp("article:tag", item);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "book":
|
||||
if (og.isbn) str += MetaProp("book:isbn", og.isbn);
|
||||
if (og.releaseDate) {
|
||||
str += MetaProp("book:release_date", og.releaseDate);
|
||||
}
|
||||
if (og.authors) {
|
||||
for (const item of og.authors) {
|
||||
str += MetaProp("article:author", item);
|
||||
}
|
||||
}
|
||||
if (og.tags) {
|
||||
for (const item of og.tags) {
|
||||
str += MetaProp("article:tag", item);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "profile":
|
||||
if (og.firstName) str += MetaProp("profile:first_name", og.firstName);
|
||||
if (og.lastName) str += MetaProp("profile:last_name", og.lastName);
|
||||
if (og.username) str += MetaProp("profile:first_name", og.username);
|
||||
if (og.gender) str += MetaProp("profile:first_name", og.gender);
|
||||
break;
|
||||
case "music.song":
|
||||
if (og.duration) str += MetaProp("music:duration", og.duration);
|
||||
if (og.albums) {
|
||||
for (const item of og.albums) {
|
||||
str += ExtendMeta("music:albums", item);
|
||||
}
|
||||
}
|
||||
if (og.musicians) {
|
||||
for (const item of og.musicians) {
|
||||
str += MetaProp("music:musician", item);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "music.album":
|
||||
if (og.songs) {
|
||||
for (const item of og.songs) {
|
||||
str += ExtendMeta("music:song", item);
|
||||
}
|
||||
}
|
||||
if (og.musicians) {
|
||||
for (const item of og.musicians) {
|
||||
str += MetaProp("music:musician", item);
|
||||
}
|
||||
}
|
||||
if (og.releaseDate) {
|
||||
str += MetaProp("music:release_date", og.releaseDate);
|
||||
}
|
||||
break;
|
||||
case "music.playlist":
|
||||
if (og.songs) {
|
||||
for (const item of og.songs) {
|
||||
str += ExtendMeta("music:song", item);
|
||||
}
|
||||
}
|
||||
if (og.creators) {
|
||||
for (const item of og.creators) {
|
||||
str += MetaProp("music:creator", item);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "music.radio_station":
|
||||
if (og.creators) {
|
||||
for (const item of og.creators) {
|
||||
str += MetaProp("music:creator", item);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "video.movie":
|
||||
if (og.actors) {
|
||||
for (const item of og.actors) {
|
||||
str += ExtendMeta("video:actor", item);
|
||||
}
|
||||
}
|
||||
if (og.directors) {
|
||||
for (const item of og.directors) {
|
||||
str += MetaProp("video:director", item);
|
||||
}
|
||||
}
|
||||
if (og.writers) {
|
||||
for (const item of og.writers) {
|
||||
str += MetaProp("video:writer", item);
|
||||
}
|
||||
}
|
||||
if (og.duration) str += MetaProp("video:duration", og.duration);
|
||||
if (og.releaseDate) {
|
||||
str += MetaProp("video:release_date", og.releaseDate);
|
||||
}
|
||||
if (og.tags) {
|
||||
for (const item of og.tags) {
|
||||
str += MetaProp("video:tag", item);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "video.episode":
|
||||
if (og.actors) {
|
||||
for (const item of og.actors) {
|
||||
str += ExtendMeta("video:actor", item);
|
||||
}
|
||||
}
|
||||
if (og.directors) {
|
||||
for (const item of og.directors) {
|
||||
str += MetaProp("video:director", item);
|
||||
}
|
||||
}
|
||||
if (og.writers) {
|
||||
for (const item of og.writers) {
|
||||
str += MetaProp("video:writer", item);
|
||||
}
|
||||
}
|
||||
if (og.duration) str += MetaProp("video:duration", og.duration);
|
||||
if (og.releaseDate) {
|
||||
str += MetaProp("video:release_date", og.releaseDate);
|
||||
}
|
||||
if (og.tags) {
|
||||
for (const item of og.tags) {
|
||||
str += MetaProp("video:tag", item);
|
||||
}
|
||||
}
|
||||
if (og.series) str += MetaProp("video:series", og.series);
|
||||
break;
|
||||
case "video.other":
|
||||
case "video.tv_show":
|
||||
default:
|
||||
throw new Error("Invalid OpenGraph type: " + og.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <TwitterMetadata />
|
||||
if (meta.twitter) {
|
||||
const twitter = meta.twitter;
|
||||
|
||||
if (twitter.card) str += Meta("twitter:card", twitter.card);
|
||||
if (twitter.site) str += Meta("twitter:site", twitter.site);
|
||||
if (twitter.siteId) str += Meta("twitter:site:id", twitter.siteId);
|
||||
if (twitter.creator) str += Meta("twitter:creator", twitter.creator);
|
||||
if (twitter.creatorId) str += Meta("twitter:creator:id", twitter.creatorId);
|
||||
if (twitter.title?.absolute) {
|
||||
str += Meta("twitter:title", twitter.title.absolute);
|
||||
}
|
||||
if (twitter.description) {
|
||||
str += Meta("twitter:description", twitter.description);
|
||||
}
|
||||
if (twitter.images) {
|
||||
for (const img of twitter.images) {
|
||||
str += Meta("twitter:image", img.url);
|
||||
if (img.alt) str += Meta("twitter:image:alt", img.alt);
|
||||
}
|
||||
}
|
||||
if (twitter.card === "player") {
|
||||
for (const player of twitter.players) {
|
||||
if (player.playerUrl) str += Meta("twitter:player", player.playerUrl);
|
||||
if (player.streamUrl) {
|
||||
str += Meta("twitter:player:stream", player.streamUrl);
|
||||
}
|
||||
if (player.width) str += Meta("twitter:player:width", player.width);
|
||||
if (player.height) str += Meta("twitter:player:height", player.height);
|
||||
}
|
||||
}
|
||||
if (twitter.card === "app") {
|
||||
for (const type of ["iphone", "ipad", "googleplay"]) {
|
||||
if (twitter.app.id[type]) {
|
||||
str += Meta(`twitter:app:name:${type}`, twitter.app.name);
|
||||
str += Meta(`twitter:app:id:${type}`, twitter.app.id[type]);
|
||||
}
|
||||
if (twitter.app.url?.[type]) {
|
||||
str += Meta(`twitter:app:url:${type}`, twitter.app.url[type]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <AppLinksMeta />
|
||||
if (meta.appLinks) {
|
||||
if (meta.appLinks.ios) {
|
||||
for (var item of meta.appLinks.ios) {
|
||||
str += ExtendMeta("al:ios", item);
|
||||
}
|
||||
}
|
||||
if (meta.appLinks.iphone) {
|
||||
for (var item of meta.appLinks.iphone) {
|
||||
str += ExtendMeta("al:iphone", item);
|
||||
}
|
||||
}
|
||||
if (meta.appLinks.ipad) {
|
||||
for (var item of meta.appLinks.ipad) {
|
||||
str += ExtendMeta("al:ipad", item);
|
||||
}
|
||||
}
|
||||
if (meta.appLinks.android) {
|
||||
for (var item2 of meta.appLinks.android) {
|
||||
str += ExtendMeta("al:android", item2);
|
||||
}
|
||||
}
|
||||
if (meta.appLinks.windows_phone) {
|
||||
for (var item3 of meta.appLinks.windows_phone) {
|
||||
str += ExtendMeta("al:windows_phone", item3);
|
||||
}
|
||||
}
|
||||
if (meta.appLinks.windows) {
|
||||
for (var item3 of meta.appLinks.windows) {
|
||||
str += ExtendMeta("al:windows", item3);
|
||||
}
|
||||
}
|
||||
if (meta.appLinks.windows_universal) {
|
||||
for (var item4 of meta.appLinks.windows_universal) {
|
||||
str += ExtendMeta("al:windows_universal", item4);
|
||||
}
|
||||
}
|
||||
if (meta.appLinks.web) {
|
||||
for (const item of meta.appLinks.web) {
|
||||
str += ExtendMeta("al:web", item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <IconsMetadata />
|
||||
if (meta.icons) {
|
||||
if (meta.icons.shortcut) {
|
||||
for (var icon of meta.icons.shortcut) {
|
||||
str += IconLink("shortcut icon", icon);
|
||||
}
|
||||
}
|
||||
if (meta.icons.icon) {
|
||||
for (var icon of meta.icons.icon) {
|
||||
str += IconLink("icon", icon);
|
||||
}
|
||||
}
|
||||
if (meta.icons.apple) {
|
||||
for (var icon of meta.icons.apple) {
|
||||
str += IconLink("apple-touch-icon", icon);
|
||||
}
|
||||
}
|
||||
if (meta.icons.other) {
|
||||
for (var icon of meta.icons.other) {
|
||||
str += IconLink(icon.rel ?? "icon", icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
export type {
|
||||
AlternateURLs,
|
||||
ResolvedAlternateURLs,
|
||||
} from "./nextjs/types/alternative-urls-types";
|
||||
export type {
|
||||
AppleImage,
|
||||
AppleImageDescriptor,
|
||||
AppleWebApp,
|
||||
AppLinks,
|
||||
AppLinksAndroid,
|
||||
AppLinksApple,
|
||||
AppLinksWeb,
|
||||
AppLinksWindows,
|
||||
FormatDetection,
|
||||
ItunesApp,
|
||||
ResolvedAppleWebApp,
|
||||
ResolvedAppLinks,
|
||||
Viewport,
|
||||
} from "./nextjs/types/extra-types";
|
||||
export type {
|
||||
Metadata,
|
||||
ResolvedMetadata,
|
||||
ResolvingMetadata,
|
||||
} from "./nextjs/types/metadata-interface";
|
||||
export type {
|
||||
AbsoluteString,
|
||||
AbsoluteTemplateString,
|
||||
Author,
|
||||
ColorSchemeEnum,
|
||||
DefaultTemplateString,
|
||||
Icon,
|
||||
IconDescriptor,
|
||||
Icons,
|
||||
IconURL,
|
||||
ReferrerEnum,
|
||||
ResolvedIcons,
|
||||
ResolvedRobots,
|
||||
ResolvedVerification,
|
||||
Robots,
|
||||
TemplateString,
|
||||
ThemeColorDescriptor,
|
||||
Verification,
|
||||
} from "./nextjs/types/metadata-types";
|
||||
export type {
|
||||
OpenGraph,
|
||||
OpenGraphType,
|
||||
ResolvedOpenGraph,
|
||||
} from "./nextjs/types/opengraph-types";
|
||||
export type {
|
||||
FieldResolver,
|
||||
FieldResolverWithMetadataBase,
|
||||
} from "./nextjs/types/resolvers";
|
||||
export type {
|
||||
ResolvedTwitterMetadata,
|
||||
Twitter,
|
||||
TwitterAppDescriptor,
|
||||
} from "./nextjs/types/twitter-types";
|
|
@ -1,13 +0,0 @@
|
|||
// Extracted from @paperdave/utils/string
|
||||
declare var Bun: any;
|
||||
|
||||
export const escapeHTML: (string: string) => string = typeof Bun !== "undefined"
|
||||
? Bun.escapeHTML
|
||||
: (string: string) => {
|
||||
return string
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
// Sitegen! Clover's static site generator, built with love.
|
||||
|
||||
function main() {
|
||||
export function main() {
|
||||
return withSpinner({
|
||||
text: "Recovering State",
|
||||
successText: ({ elapsed }) =>
|
||||
|
@ -50,7 +50,7 @@ async function sitegen(status: Spinner) {
|
|||
status.text = "Scanning Project";
|
||||
for (const section of sections) {
|
||||
const { root: sectionRoot } = section;
|
||||
const sectionPath = (...sub) => path.join(sectionRoot, ...sub);
|
||||
const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
|
||||
const rootPrefix = root === sectionRoot
|
||||
? ""
|
||||
: path.relative(root, sectionRoot) + "/";
|
||||
|
@ -101,7 +101,7 @@ async function sitegen(status: Spinner) {
|
|||
|
||||
// -- server side render --
|
||||
status.text = "Building";
|
||||
const cssOnce = new OnceMap();
|
||||
const cssOnce = new OnceMap<string>();
|
||||
const cssQueue = new Queue<[string, string[], css.Theme], string>({
|
||||
name: "Bundle",
|
||||
fn: ([, files, theme]) => css.bundleCssFiles(files, theme),
|
||||
|
@ -140,7 +140,7 @@ async function sitegen(status: Spinner) {
|
|||
// -- metadata --
|
||||
const renderedMetaPromise = Promise.resolve(
|
||||
typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
|
||||
).then((m) => meta.resolveAndRenderMetadata(m));
|
||||
).then((m) => meta.renderMeta(m));
|
||||
// -- css --
|
||||
const cssImports = [globalCssPath, ...hot.getCssImports(item.file)];
|
||||
const cssPromise = cssOnce.get(
|
||||
|
@ -148,12 +148,11 @@ async function sitegen(status: Spinner) {
|
|||
() => cssQueue.add([item.id, cssImports, theme]),
|
||||
);
|
||||
// -- html --
|
||||
const sitegenApi = sg.initRender();
|
||||
const bodyPromise = await ssr.ssrAsync(<Page />, {
|
||||
sitegen: sitegenApi,
|
||||
sitegen: sg.initRender(),
|
||||
});
|
||||
|
||||
const [body, inlineCss, renderedMeta] = await Promise.all([
|
||||
const [{ text, addon }, inlineCss, renderedMeta] = await Promise.all([
|
||||
bodyPromise,
|
||||
cssPromise,
|
||||
renderedMetaPromise,
|
||||
|
@ -168,10 +167,10 @@ async function sitegen(status: Spinner) {
|
|||
// contents will be rebuilt at the end. This is more convenient anyways
|
||||
// because it means client scripts don't re-render the page.
|
||||
renderResults.push({
|
||||
body,
|
||||
body: text,
|
||||
head: renderedMeta,
|
||||
inlineCss,
|
||||
scriptFiles: Array.from(sitegenApi.scripts),
|
||||
scriptFiles: Array.from(addon.sitegen.scripts),
|
||||
item: item,
|
||||
});
|
||||
}
|
||||
|
@ -303,7 +302,7 @@ import * as bundle from "./bundle.ts";
|
|||
import * as css from "./css.ts";
|
||||
import * as fs from "./fs.ts";
|
||||
import { Spinner, withSpinner } from "@paperclover/console/Spinner";
|
||||
import * as meta from "./meta";
|
||||
import * as meta from "./meta.ts";
|
||||
import * as ssr from "./engine/ssr.ts";
|
||||
import * as sg from "./sitegen-lib.ts";
|
||||
import * as hot from "./hot.ts";
|
||||
|
|
12
package-lock.json
generated
12
package-lock.json
generated
|
@ -11,7 +11,8 @@
|
|||
"esbuild": "^0.25.5",
|
||||
"hls.js": "^1.6.5",
|
||||
"hono": "^4.7.11",
|
||||
"marko": "^6.0.20"
|
||||
"marko": "^6.0.20",
|
||||
"unique-names-generator": "^4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.29",
|
||||
|
@ -2946,6 +2947,15 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unique-names-generator": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
|
||||
"integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-is": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
"esbuild": "^0.25.5",
|
||||
"hls.js": "^1.6.5",
|
||||
"hono": "^4.7.11",
|
||||
"marko": "^6.0.20"
|
||||
"marko": "^6.0.20",
|
||||
"unique-names-generator": "^4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.29",
|
||||
|
@ -23,7 +24,8 @@
|
|||
"#ssr/marko": "./framework/engine/marko-runtime.ts",
|
||||
"#marko/html": {
|
||||
"development": "marko/debug/html",
|
||||
"production": "marko/html"
|
||||
"production": "marko/html",
|
||||
"types": "marko/html"
|
||||
},
|
||||
"#hono/platform": {
|
||||
"bun": "hono/bun",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { Metadata } from "next-metadata";
|
||||
import type { Meta } from "#meta";
|
||||
import "./index.css";
|
||||
|
||||
export const theme = {
|
||||
|
@ -7,7 +7,7 @@ export const theme = {
|
|||
};
|
||||
static const title = "paper clover";
|
||||
static const description = "and then we knew, just like paper airplanes: that we could fly...";
|
||||
export const meta: Metadata = {
|
||||
export const meta: Meta = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
|
|
89
src/q+a/artifacts.ts
Normal file
89
src/q+a/artifacts.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
// Artifacts used to be a central system in the old data-driven website.
|
||||
// Now, it simply refers to one of these link presets. Every project has
|
||||
// one canonical URL, which the questions page can refer to with `@id`.
|
||||
type Artifact = [title: string, url: string, type: ArtifactType];
|
||||
type ArtifactType = "music" | "game" | "project" | "video";
|
||||
export const artifactMap: Record<string, Artifact> = {
|
||||
// 2025
|
||||
"in-the-summer": ["in the summer", "", "music"],
|
||||
waterfalls: ["waterfalls", "/waterfalls", "music"],
|
||||
lolzip: ["lol.zip", "", "project"],
|
||||
"g-is-missing": ["g is missing", "", "music"],
|
||||
"im-18-now": ["i'm 18 now", "", "music"],
|
||||
"programming-comparison": [
|
||||
"thursday programming language comparison",
|
||||
"",
|
||||
"video",
|
||||
],
|
||||
aaaaaaaaa: ["aaaaaaaaa", "", "music"],
|
||||
"its-snowing": ["it's snowing", "", "video"],
|
||||
// 2023
|
||||
"iphone-15-review": [
|
||||
"iphone 15 review",
|
||||
"/file/2023/iphone%2015%20review/iphone-15-review.mp4",
|
||||
"video",
|
||||
],
|
||||
// 2022
|
||||
mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
|
||||
"mystery-of-life": [
|
||||
"mystery of life",
|
||||
"/file/2022/mystery-of-life/mystery-of-life.mp4",
|
||||
"music",
|
||||
],
|
||||
// 2021
|
||||
"top-10000-bread": [
|
||||
"top 10000 bread",
|
||||
"https://paperclover.net/file/2021/top-10000-bread/output.mp4",
|
||||
"video",
|
||||
],
|
||||
"phoenix-write-soundtrack": [
|
||||
"Phoenix, WRITE! soundtrack",
|
||||
"/file/2021/phoenix-write/OST",
|
||||
"music",
|
||||
],
|
||||
"phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
|
||||
"money-visual-cover": [
|
||||
"money visual cover",
|
||||
"/file/2021/money-visual-cover/money-visual-cover.mp4",
|
||||
"video",
|
||||
],
|
||||
"i-got-this-thing": [
|
||||
"i got this thing",
|
||||
"/file/2021/i-got-this-thing/i-got-this-thing.mp4",
|
||||
"video",
|
||||
],
|
||||
// 2020
|
||||
elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
|
||||
"elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
|
||||
// 2019
|
||||
"throw-soundtrack": [
|
||||
"throw soundtrack",
|
||||
"/file/2019/throw/soundtrack",
|
||||
"music",
|
||||
],
|
||||
"elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"],
|
||||
volar: [
|
||||
"volar visual cover",
|
||||
"/file/2019/volar-visual-cover/volar.mp4",
|
||||
"video",
|
||||
],
|
||||
wpm: [
|
||||
"how to read 500 words per minute",
|
||||
"/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4",
|
||||
"video",
|
||||
],
|
||||
"dice-roll": [
|
||||
"thursday dice roll",
|
||||
"/file/2019/thursday-dice-roll/thursday-dice-roll.mp4",
|
||||
"video",
|
||||
],
|
||||
"math-problem": [
|
||||
"thursday math problem",
|
||||
"/file/2019/thursday-math-problem/thursday-math-problem.mp4",
|
||||
"video",
|
||||
],
|
||||
// 2018
|
||||
// 2017
|
||||
"hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"],
|
||||
"test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],
|
||||
};
|
|
@ -1,242 +0,0 @@
|
|||
import { type Context, Hono } from "hono";
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import {
|
||||
adjectives,
|
||||
animals,
|
||||
colors,
|
||||
uniqueNamesGenerator,
|
||||
} from "unique-names-generator";
|
||||
import { hasAdminToken } from "../admin";
|
||||
import { regenerateAssets, serveAsset } from "../assets";
|
||||
import { PendingQuestion, Question } from "../db";
|
||||
import { renderDynamicPage } from "../framework/dynamic-pages";
|
||||
import { getQuestionImage } from "./question_image";
|
||||
import { formatQuestionId, questionIdToTimestamp } from "./QuestionRender";
|
||||
import process from "node:process";
|
||||
|
||||
const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
|
||||
|
||||
let isStale = false;
|
||||
|
||||
const app = new Hono();
|
||||
// Main page
|
||||
app.get("/q+a", async (c) => {
|
||||
if (isStale) {
|
||||
await regenerateAssets();
|
||||
isStale = false;
|
||||
}
|
||||
if (hasAdminToken(c)) {
|
||||
return serveAsset(c, "/admin/q+a", 200);
|
||||
}
|
||||
return serveAsset(c, "/q+a", 200);
|
||||
});
|
||||
// Submit form
|
||||
app.post("/q+a", async (c) => {
|
||||
const form = await c.req.formData();
|
||||
let text = form.get("text");
|
||||
if (typeof text !== "string") {
|
||||
return questionFailure(c, 400, "Bad Request");
|
||||
}
|
||||
text = text.trim();
|
||||
const input = {
|
||||
date: new Date(),
|
||||
prompt: text,
|
||||
sourceName: "unknown",
|
||||
sourceLocation: "unknown",
|
||||
sourceVPN: null,
|
||||
};
|
||||
|
||||
input.date.setMilliseconds(0);
|
||||
|
||||
if (text.length <= 0) {
|
||||
return questionFailure(c, 400, "Content is too short", text);
|
||||
}
|
||||
|
||||
if (text.length > 16000) {
|
||||
return questionFailure(c, 400, "Content is too long", text);
|
||||
}
|
||||
|
||||
// Ban patterns
|
||||
if (
|
||||
text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN
|
||||
) {
|
||||
// To prevent known automatic spam-bots from noticing something automatic is
|
||||
// happening, pretend that the question was successfully submitted.
|
||||
return sendSuccess(c, new Date());
|
||||
}
|
||||
|
||||
const ipAddr = c.req.header("cf-connecting-ip");
|
||||
if (ipAddr) {
|
||||
input.sourceName = uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, colors, animals],
|
||||
separator: "-",
|
||||
seed: ipAddr + PROXYCHECK_API_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
const cfIPCountry = c.req.header("cf-ipcountry");
|
||||
if (cfIPCountry) {
|
||||
input.sourceLocation = cfIPCountry;
|
||||
}
|
||||
|
||||
if (ipAddr && PROXYCHECK_API_KEY) {
|
||||
const proxyCheck = await fetch(
|
||||
`https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`,
|
||||
{
|
||||
method: "POST",
|
||||
body: "ips=" + ipAddr,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
).then((res) => res.json());
|
||||
|
||||
if (ipAddr && proxyCheck[ipAddr]) {
|
||||
if (proxyCheck[ipAddr].proxy === "yes") {
|
||||
input.sourceVPN = proxyCheck[ipAddr].operator?.name ??
|
||||
proxyCheck[ipAddr].organisation ??
|
||||
proxyCheck[ipAddr].provider ?? "unknown";
|
||||
}
|
||||
if (Number(proxyCheck[ipAddr].risk) > 72) {
|
||||
return questionFailure(
|
||||
c,
|
||||
403,
|
||||
"This IP address has been flagged as a high risk IP address. If you are using a VPN/Proxy, please disable it and try again.",
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const date = Question.create(
|
||||
Question.Type.pending,
|
||||
JSON.stringify(input),
|
||||
input.date,
|
||||
);
|
||||
await sendSuccess(c, date);
|
||||
});
|
||||
async function sendSuccess(c: Context, date: Date) {
|
||||
if (c.req.header("Accept")?.includes("application/json")) {
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "ok",
|
||||
date: date.getTime(),
|
||||
id: formatQuestionId(date),
|
||||
}, { status: 200 });
|
||||
}
|
||||
c.res = await renderDynamicPage(c.req.raw, "qa_success", {
|
||||
permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
|
||||
});
|
||||
}
|
||||
// Question Permalink
|
||||
app.get("/q+a/:id", async (c, next) => {
|
||||
// from deadname era, the seconds used to be in the url.
|
||||
// this was removed so that the url can be crafted by hand.
|
||||
let id = c.req.param("id");
|
||||
if (id.length === 12 && /^\d+$/.test(id)) {
|
||||
return c.redirect(`/q+a/${id.slice(0, 10)}`);
|
||||
}
|
||||
let image = false;
|
||||
if (id.endsWith(".png")) {
|
||||
image = true;
|
||||
id = id.slice(0, -4);
|
||||
}
|
||||
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = await Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
|
||||
if (image) {
|
||||
return getQuestionImage(question, c.req.method === "HEAD");
|
||||
}
|
||||
return renderDynamicPage(c.req.raw, "qa_permalink", { question });
|
||||
});
|
||||
|
||||
// Admin
|
||||
app.get("/admin/q+a", async (c) => {
|
||||
if (isStale) {
|
||||
await regenerateAssets();
|
||||
isStale = false;
|
||||
}
|
||||
return serveAsset(c, "/admin/q+a", 200);
|
||||
});
|
||||
app.get("/admin/q+a/inbox", async (c) => {
|
||||
return renderDynamicPage(c.req.raw, "qa_backend_inbox", {});
|
||||
});
|
||||
app.delete("/admin/q+a/:id", async (c, next) => {
|
||||
const id = c.req.param("id");
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
const deleteFull = c.req.header("X-Delete-Full") === "true";
|
||||
if (deleteFull) {
|
||||
Question.deleteByQmid(question.qmid);
|
||||
} else {
|
||||
Question.rejectByQmid(question.qmid);
|
||||
}
|
||||
return c.json({ success: true, message: "ok" });
|
||||
});
|
||||
app.patch("/admin/q+a/:id", async (c, next) => {
|
||||
const id = c.req.param("id");
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
const form = await c.req.raw.json();
|
||||
if (typeof form.text !== "string" || typeof form.type !== "number") {
|
||||
console.log("bad request");
|
||||
return questionFailure(c, 400, "Bad Request");
|
||||
}
|
||||
Question.updateByQmid(question.qmid, form.text, form.type);
|
||||
isStale = true;
|
||||
return c.json({ success: true, message: "ok" });
|
||||
});
|
||||
app.get("/admin/q+a/:id", async (c, next) => {
|
||||
const id = c.req.param("id");
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
|
||||
let pendingInfo: null | PendingQuestion.JSON = null;
|
||||
if (question.type === Question.Type.pending) {
|
||||
pendingInfo = JSON.parse(question.text) as PendingQuestion.JSON;
|
||||
question.text = pendingInfo.prompt.trim().split("\n").map((line) =>
|
||||
line.trim().length === 0 ? "" : `q: ${line.trim()}`
|
||||
).join("\n") + "\n\n";
|
||||
question.type = Question.Type.normal;
|
||||
}
|
||||
|
||||
return renderDynamicPage(c.req.raw, "qa_backend_edit", {
|
||||
pendingInfo,
|
||||
question,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/q+a/things/random", async (c) => {
|
||||
c.res = await renderDynamicPage(c.req.raw, "qa_things_random", {});
|
||||
});
|
||||
|
||||
// 404
|
||||
app.get("/q+a/*", async (c) => {
|
||||
return serveAsset(c, "/q+a/404", 404);
|
||||
});
|
||||
|
||||
async function questionFailure(
|
||||
c: Context,
|
||||
status: ContentfulStatusCode,
|
||||
message: string,
|
||||
content?: string,
|
||||
) {
|
||||
if (c.req.header("Accept")?.includes("application/json")) {
|
||||
return c.json({ success: false, message, id: null }, { status });
|
||||
}
|
||||
c.res = await renderDynamicPage(c.req.raw, "qa_fail", {
|
||||
error: message,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
export { app as questionAnswerApp };
|
39
src/q+a/format.ts
Normal file
39
src/q+a/format.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
const dateFormat = new Intl.DateTimeFormat("sv", {
|
||||
timeZone: "EST",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
day: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
// YYYY-MM-DD HH:MM
|
||||
export function formatQuestionTimestamp(date: Date) {
|
||||
return dateFormat.format(date);
|
||||
}
|
||||
|
||||
// YYYY-MM-DDTHH:MM:00Z
|
||||
export function formatQuestionISOTimestamp(date: Date) {
|
||||
const str = dateFormat.format(date);
|
||||
return `${str.slice(0, 10)}T${str.slice(11)}-05:00`;
|
||||
}
|
||||
|
||||
// YYMMDDHHMM
|
||||
export function formatQuestionId(date: Date) {
|
||||
return formatQuestionTimestamp(date).replace(/[^\d]/g, "").slice(2, 12);
|
||||
}
|
||||
|
||||
export function questionIdToTimestamp(id: string) {
|
||||
if (id.length !== 10 || !/^\d+$/.test(id)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(
|
||||
`20${id.slice(0, 2)}-${id.slice(2, 4)}-${id.slice(4, 6)} ${
|
||||
id.slice(6, 8)
|
||||
}:${id.slice(8, 10)}:00 EST`,
|
||||
);
|
||||
if (isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
|
@ -834,34 +834,257 @@ export const defaultRules = new RuleList();
|
|||
defaultRules.add({
|
||||
name: "escape",
|
||||
// We don't allow escaping numbers, letters, or spaces here so that
|
||||
ª±@VXLL |