fix all type errors

This commit is contained in:
chloe caruso 2025-06-07 16:45:45 -07:00
parent f841f766d2
commit 7242c6eb89
50 changed files with 1329 additions and 5763 deletions

View file

@ -26,7 +26,10 @@ export function preprocess(css: string, theme: Theme): string {
return css.replace( return css.replace(
regex, regex,
(_, line) => (_, line) =>
line.replace(regex2, (_: string, varName: string) => theme[varName]) + line.replace(
regex2,
(_: string, varName: string) => theme[varName as keyof Theme],
) +
";" + line.slice(1), ";" + line.slice(1),
); );
} }

View file

@ -1,2 +1,4 @@
declare function UNWRAP<T>(value: T | null | undefined): T; declare function UNWRAP<T>(value: T | null | undefined): T;
declare function ASSERT(value: unknown, ...log: unknown[]): asserts value; declare function ASSERT(value: unknown, ...log: unknown[]): asserts value;
type Timer = ReturnType<typeof setTimeout>;

View file

@ -29,4 +29,18 @@ export function jsxDEV(
// jsxs // jsxs
export { jsx as 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"; import * as engine from "./ssr.ts";

View file

@ -72,7 +72,7 @@ export const dynamicTag = (
]); ]);
if (subRender.async > 0) { if (subRender.async > 0) {
const marker = marko.$global().cloverAsyncMarker; const marker = marko.$global().cloverAsyncMarker as Async;
marker.isAsync = true; marker.isAsync = true;
// Wait for async work to finish // Wait for async work to finish
@ -108,17 +108,21 @@ export const dynamicTag = (
}; };
export function fork( export function fork(
scopeId: string, scopeId: number,
accessor: Accessor, accessor: Accessor,
promise: Promise<unknown>, promise: Promise<unknown>,
callback: (data: unknown) => void, callback: (data: unknown) => void,
serializeMarker?: 0 | 1, serializeMarker?: 0 | 1,
) { ) {
const marker = marko.$global().cloverAsyncMarker; const marker = marko.$global().cloverAsyncMarker as Async;
marker.isAsync = true; marker.isAsync = true;
marko.fork(scopeId, accessor, promise, callback, serializeMarker); marko.fork(scopeId, accessor, promise, callback, serializeMarker);
} }
interface Async {
isAsync: boolean;
}
import * as engine from "./ssr.ts"; import * as engine from "./ssr.ts";
import type { ServerRenderer } from "marko/html/template"; import type { ServerRenderer } from "marko/html/template";
import { type Accessor } from "marko/common/types"; import { type Accessor } from "marko/common/types";

View file

@ -6,24 +6,22 @@
// Add-ons to the rendering engine can provide opaque data, And retrieve it // Add-ons to the rendering engine can provide opaque data, And retrieve it
// within component calls with 'getAddonData'. For example, 'sitegen' uses this // within component calls with 'getAddonData'. For example, 'sitegen' uses this
// to track needed client scripts without introducing patches to the engine. // 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(node: Node): Result;
export function ssrSync<A extends AddonData>( export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
node: Node, export function ssrSync(node: Node, addon: Addons = {}) {
addon: AddonData,
): Result<A>;
export function ssrSync(node: Node, addon: AddonData = {}) {
const r = initRender(false, addon); const r = initRender(false, addon);
const resolved = resolveNode(r, node); const resolved = resolveNode(r, node);
return { text: renderNode(resolved), addon }; return { text: renderNode(resolved), addon };
} }
export function ssrAsync(node: Node): Promise<Result>; export function ssrAsync(node: Node): Promise<Result>;
export function ssrAsync<A extends AddonData>( export function ssrAsync<A extends Addons>(
node: Node, node: Node,
addon: AddonData, addon: A,
): Promise<Result<A>>; ): Promise<Result<A>>;
export function ssrAsync(node: Node, addon: AddonData = {}) { export function ssrAsync(node: Node, addon: Addons = {}) {
const r = initRender(true, addon); const r = initRender(true, addon);
const resolved = resolveNode(r, node); const resolved = resolveNode(r, node);
if (r.async === 0) { if (r.async === 0) {
@ -44,7 +42,7 @@ export function html(rawText: string) {
return [kDirectHtml, rawText]; return [kDirectHtml, rawText];
} }
interface Result<A extends AddonData = AddonData> { interface Result<A extends Addons = Addons> {
text: string; text: string;
addon: A; addon: A;
} }
@ -59,7 +57,7 @@ export interface Render {
/** When components reject, those are logged here */ /** When components reject, those are logged here */
rejections: unknown[] | null; rejections: unknown[] | null;
/** Add-ons to the rendering engine store state here */ /** Add-ons to the rendering engine store state here */
addon: AddonData; addon: Addons;
} }
export const kElement = Symbol("Element"); 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 (!node && node !== 0) return ""; // falsy, non numeric
if (typeof node !== "object") { if (typeof node !== "object") {
if (node === true) return ""; // booleans are ignored 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 if (typeof node === "number") return String(node); // no escaping ever
throw new Error(`Cannot render ${inspect(node)} to HTML`); throw new Error(`Cannot render ${inspect(node)} to HTML`);
} }
@ -193,12 +191,12 @@ function renderElement(element: ResolvedElement) {
let attr; let attr;
switch (prop) { switch (prop) {
default: default:
attr = `${prop}=${quoteIfNeeded(escapeHTML(String(value)))}`; attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
break; break;
case "className": case "className":
// Legacy React Compat // Legacy React Compat
case "class": case "class":
attr = `class=${quoteIfNeeded(escapeHTML(clsx(value)))}`; attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
break; break;
case "htmlFor": case "htmlFor":
throw new Error("Do not use the `htmlFor` attribute. Use `for`"); 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) { for (const styleName in style) {
if (out) out += ";"; if (out) out += ";";
out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
escapeHTML(String(style[styleName])) escapeHtml(String(style[styleName]))
}`; }`;
} }
return "style=" + quoteIfNeeded(out); return "style=" + quoteIfNeeded(out);
} }
export function quoteIfNeeded(text) { export function quoteIfNeeded(text: string) {
if (text.includes(" ")) return '"' + text + '"'; if (text.includes(" ")) return '"' + text + '"';
return text; return text;
} }
// -- utility functions -- // -- utility functions --
export function initRender(allowAsync: boolean, addon: AddonData): Render { export function initRender(allowAsync: boolean, addon: Addons): Render {
return { return {
async: allowAsync ? 0 : -1, async: allowAsync ? 0 : -1,
rejections: null, rejections: null,
@ -270,11 +268,10 @@ export function inspect(object: unknown) {
export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[]; export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
export function clsx(mix: ClsxInput) { export function clsx(mix: ClsxInput) {
var k, y, str; var k, y, str = "";
if (typeof mix === "string") { if (typeof mix === "string") {
return mix; return mix;
} else if (typeof mix === "object") { } else if (typeof mix === "object") {
str = "";
if (Array.isArray(mix)) { if (Array.isArray(mix)) {
for (k = 0; k < mix.length; k++) { for (k = 0; k < mix.length; k++) {
if (mix[k] && (y = clsx(mix[k]))) { if (mix[k] && (y = clsx(mix[k]))) {
@ -294,7 +291,7 @@ export function clsx(mix: ClsxInput) {
return str; return str;
} }
export const escapeHTML = (unsafeText: string) => export const escapeHtml = (unsafeText: string) =>
String(unsafeText) String(unsafeText)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;"); .replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;");

View file

@ -162,7 +162,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) {
) + '\nimport { Script as CloverScriptInclude } from "#sitegen";'; ) + '\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"); src = src.replace("marko/debug/html", "#ssr/marko");
return loadEsbuildCode(module, filepath, src); return loadEsbuildCode(module, filepath, src);
} }
@ -174,7 +174,7 @@ function loadMdx(module: NodeJS.Module, filepath: string) {
return loadEsbuildCode(module, filepath, src); return loadEsbuildCode(module, filepath, src);
} }
function loadCss(module: NodeJS.Module, filepath: string) { function loadCss(module: NodeJS.Module, _filepath: string) {
module.exports = {}; 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 fs from "./fs.ts";
import * as path from "node:path"; import * as path from "node:path";

24
framework/meta.ts Normal file
View 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";

View file

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

View file

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

View file

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

View file

@ -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: {},
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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[];
};
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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('"', "&quot;")
.replaceAll("&", "&amp;")
.replaceAll("'", "&#x27;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
};

View file

@ -1,6 +1,6 @@
// Sitegen! Clover's static site generator, built with love. // Sitegen! Clover's static site generator, built with love.
function main() { export function main() {
return withSpinner({ return withSpinner({
text: "Recovering State", text: "Recovering State",
successText: ({ elapsed }) => successText: ({ elapsed }) =>
@ -50,7 +50,7 @@ async function sitegen(status: Spinner) {
status.text = "Scanning Project"; status.text = "Scanning Project";
for (const section of sections) { for (const section of sections) {
const { root: sectionRoot } = section; const { root: sectionRoot } = section;
const sectionPath = (...sub) => path.join(sectionRoot, ...sub); const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
const rootPrefix = root === sectionRoot const rootPrefix = root === sectionRoot
? "" ? ""
: path.relative(root, sectionRoot) + "/"; : path.relative(root, sectionRoot) + "/";
@ -101,7 +101,7 @@ async function sitegen(status: Spinner) {
// -- server side render -- // -- server side render --
status.text = "Building"; status.text = "Building";
const cssOnce = new OnceMap(); const cssOnce = new OnceMap<string>();
const cssQueue = new Queue<[string, string[], css.Theme], string>({ const cssQueue = new Queue<[string, string[], css.Theme], string>({
name: "Bundle", name: "Bundle",
fn: ([, files, theme]) => css.bundleCssFiles(files, theme), fn: ([, files, theme]) => css.bundleCssFiles(files, theme),
@ -140,7 +140,7 @@ async function sitegen(status: Spinner) {
// -- metadata -- // -- metadata --
const renderedMetaPromise = Promise.resolve( const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata({ ssr: true }) : metadata, typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
).then((m) => meta.resolveAndRenderMetadata(m)); ).then((m) => meta.renderMeta(m));
// -- css -- // -- css --
const cssImports = [globalCssPath, ...hot.getCssImports(item.file)]; const cssImports = [globalCssPath, ...hot.getCssImports(item.file)];
const cssPromise = cssOnce.get( const cssPromise = cssOnce.get(
@ -148,12 +148,11 @@ async function sitegen(status: Spinner) {
() => cssQueue.add([item.id, cssImports, theme]), () => cssQueue.add([item.id, cssImports, theme]),
); );
// -- html -- // -- html --
const sitegenApi = sg.initRender();
const bodyPromise = await ssr.ssrAsync(<Page />, { 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, bodyPromise,
cssPromise, cssPromise,
renderedMetaPromise, renderedMetaPromise,
@ -168,10 +167,10 @@ async function sitegen(status: Spinner) {
// contents will be rebuilt at the end. This is more convenient anyways // contents will be rebuilt at the end. This is more convenient anyways
// because it means client scripts don't re-render the page. // because it means client scripts don't re-render the page.
renderResults.push({ renderResults.push({
body, body: text,
head: renderedMeta, head: renderedMeta,
inlineCss, inlineCss,
scriptFiles: Array.from(sitegenApi.scripts), scriptFiles: Array.from(addon.sitegen.scripts),
item: item, item: item,
}); });
} }
@ -303,7 +302,7 @@ import * as bundle from "./bundle.ts";
import * as css from "./css.ts"; import * as css from "./css.ts";
import * as fs from "./fs.ts"; import * as fs from "./fs.ts";
import { Spinner, withSpinner } from "@paperclover/console/Spinner"; 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 ssr from "./engine/ssr.ts";
import * as sg from "./sitegen-lib.ts"; import * as sg from "./sitegen-lib.ts";
import * as hot from "./hot.ts"; import * as hot from "./hot.ts";

12
package-lock.json generated
View file

@ -11,7 +11,8 @@
"esbuild": "^0.25.5", "esbuild": "^0.25.5",
"hls.js": "^1.6.5", "hls.js": "^1.6.5",
"hono": "^4.7.11", "hono": "^4.7.11",
"marko": "^6.0.20" "marko": "^6.0.20",
"unique-names-generator": "^4.7.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
@ -2946,6 +2947,15 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/unist-util-is": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",

View file

@ -8,7 +8,8 @@
"esbuild": "^0.25.5", "esbuild": "^0.25.5",
"hls.js": "^1.6.5", "hls.js": "^1.6.5",
"hono": "^4.7.11", "hono": "^4.7.11",
"marko": "^6.0.20" "marko": "^6.0.20",
"unique-names-generator": "^4.7.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
@ -23,7 +24,8 @@
"#ssr/marko": "./framework/engine/marko-runtime.ts", "#ssr/marko": "./framework/engine/marko-runtime.ts",
"#marko/html": { "#marko/html": {
"development": "marko/debug/html", "development": "marko/debug/html",
"production": "marko/html" "production": "marko/html",
"types": "marko/html"
}, },
"#hono/platform": { "#hono/platform": {
"bun": "hono/bun", "bun": "hono/bun",

View file

@ -1,4 +1,4 @@
import type { Metadata } from "next-metadata"; import type { Meta } from "#meta";
import "./index.css"; import "./index.css";
export const theme = { export const theme = {
@ -7,7 +7,7 @@ export const theme = {
}; };
static const title = "paper clover"; static const title = "paper clover";
static const description = "and then we knew, just like paper airplanes: that we could fly..."; static const description = "and then we knew, just like paper airplanes: that we could fly...";
export const meta: Metadata = { export const meta: Meta = {
title, title,
description, description,
openGraph: { openGraph: {

89
src/q+a/artifacts.ts Normal file
View 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"],
};

View file

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

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

View file

@ -1,15 +1,18 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowImportingTSExtensions": true, "allowImportingTsExtensions": true,
"baseDir": ".", "baseUrl": ".",
"incremental": true, "incremental": true,
"jsx": "react-jsx",
"jsxImportSource": "#ssr",
"lib": ["dom", "esnext", "esnext.iterator"], "lib": ["dom", "esnext", "esnext.iterator"],
"module": "nodenext", "module": "nodenext",
"outdir": ".clover/ts", "noEmit": true,
"outDir": ".clover/ts",
"paths": { "@/*": ["src/*"] }, "paths": { "@/*": ["src/*"] },
"rootDir": ".",
"skipLibCheck": true,
"strict": true, "strict": true,
"target": "es2022", "target": "es2022"
"jsxImportSource": "#ssr",
"jsx": "react-jsx"
} }
} }