authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-06-07 16:45:45-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:44-07:00
log8f9a9d4e99fa9e1b759ae0f7a63151deae720585
tree4772dbbe9517bb7df9c41e1ead831366ff2a4b9c
parent7c84a70b79546fa0cfc34ca1e765413d288e55d8
signature Commit is signed but in an unrecognized format.

fix all type errors


50 files changed, 1329 insertions(+), 5763 deletions(-)

framework/css.ts+4-1
...@@ -26,7 +26,10 @@ export function preprocess(css: string, theme: Theme): string {...@@ -26,7 +26,10 @@ export function preprocess(css: string, theme: Theme): string {
26 return css.replace(26 return css.replace(
27 regex,27 regex,
28 (_, line) =>28 (_, line) =>
29 line.replace(regex2, (_: string, varName: string) => theme[varName]) +29 line.replace(
30 regex2,
31 (_: string, varName: string) => theme[varName as keyof Theme],
32 ) +
30 ";" + line.slice(1),33 ";" + line.slice(1),
31 );34 );
32}35}
framework/definitions.d.ts+2
...@@ -1,2 +1,4 @@...@@ -1,2 +1,4 @@
1declare function UNWRAP<T>(value: T | null | undefined): T; 1declare function UNWRAP<T>(value: T | null | undefined): T;
2declare function ASSERT(value: unknown, ...log: unknown[]): asserts value; 2declare function ASSERT(value: unknown, ...log: unknown[]): asserts value;
3
4type Timer = ReturnType<typeof setTimeout>;
framework/engine/jsx-runtime.ts+14
...@@ -29,4 +29,18 @@ export function jsxDEV(...@@ -29,4 +29,18 @@ export function jsxDEV(
29// jsxs 29// jsxs
30export { jsx as jsxs }; 30export { jsx as jsxs };
31 31
32declare global {
33 namespace JSX {
34 interface IntrinsicElements {
35 [name: string]: Record<string, unknown>;
36 }
37 interface ElementChildrenAttribute {
38 children: {};
39 }
40 type Element = engine.Node;
41 type ElementType = keyof IntrinsicElements | engine.Component;
42 type ElementClass = ReturnType<engine.Component>;
43 }
44}
45
32import * as engine from "./ssr.ts"; 46import * as engine from "./ssr.ts";
framework/engine/marko-runtime.ts+7-3
...@@ -72,7 +72,7 @@ export const dynamicTag = (...@@ -72,7 +72,7 @@ export const dynamicTag = (
72 ]); 72 ]);
73 73
74 if (subRender.async > 0) { 74 if (subRender.async > 0) {
75 const marker = marko.$global().cloverAsyncMarker; 75 const marker = marko.$global().cloverAsyncMarker as Async;
76 marker.isAsync = true; 76 marker.isAsync = true;
77 77
78 // Wait for async work to finish 78 // Wait for async work to finish
...@@ -108,17 +108,21 @@ export const dynamicTag = (...@@ -108,17 +108,21 @@ export const dynamicTag = (
108}; 108};
109 109
110export function fork( 110export function fork(
111 scopeId: string, 111 scopeId: number,
112 accessor: Accessor, 112 accessor: Accessor,
113 promise: Promise<unknown>, 113 promise: Promise<unknown>,
114 callback: (data: unknown) => void, 114 callback: (data: unknown) => void,
115 serializeMarker?: 0 | 1, 115 serializeMarker?: 0 | 1,
116) { 116) {
117 const marker = marko.$global().cloverAsyncMarker; 117 const marker = marko.$global().cloverAsyncMarker as Async;
118 marker.isAsync = true; 118 marker.isAsync = true;
119 marko.fork(scopeId, accessor, promise, callback, serializeMarker); 119 marko.fork(scopeId, accessor, promise, callback, serializeMarker);
120} 120}
121 121
122interface Async {
123 isAsync: boolean;
124}
125
122import * as engine from "./ssr.ts"; 126import * as engine from "./ssr.ts";
123import type { ServerRenderer } from "marko/html/template"; 127import type { ServerRenderer } from "marko/html/template";
124import { type Accessor } from "marko/common/types"; 128import { type Accessor } from "marko/common/types";
framework/engine/ssr.ts+17-20
...@@ -6,24 +6,22 @@...@@ -6,24 +6,22 @@
6// Add-ons to the rendering engine can provide opaque data, And retrieve it 6// Add-ons to the rendering engine can provide opaque data, And retrieve it
7// within component calls with 'getAddonData'. For example, 'sitegen' uses this 7// within component calls with 'getAddonData'. For example, 'sitegen' uses this
8// to track needed client scripts without introducing patches to the engine. 8// to track needed client scripts without introducing patches to the engine.
9type Addons = Record<string | symbol, unknown>;
9 10
10type AddonData = Record<string | symbol, unknown>;
11export function ssrSync(node: Node): Result; 11export function ssrSync(node: Node): Result;
12export function ssrSync<A extends AddonData>( 12export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
13 node: Node, 13export function ssrSync(node: Node, addon: Addons = {}) {
14 addon: AddonData,
15): Result<A>;
16export function ssrSync(node: Node, addon: AddonData = {}) {
17 const r = initRender(false, addon); 14 const r = initRender(false, addon);
18 const resolved = resolveNode(r, node); 15 const resolved = resolveNode(r, node);
19 return { text: renderNode(resolved), addon }; 16 return { text: renderNode(resolved), addon };
20} 17}
18
21export function ssrAsync(node: Node): Promise<Result>; 19export function ssrAsync(node: Node): Promise<Result>;
22export function ssrAsync<A extends AddonData>( 20export function ssrAsync<A extends Addons>(
23 node: Node, 21 node: Node,
24 addon: AddonData, 22 addon: A,
25): Promise<Result<A>>; 23): Promise<Result<A>>;
26export function ssrAsync(node: Node, addon: AddonData = {}) { 24export function ssrAsync(node: Node, addon: Addons = {}) {
27 const r = initRender(true, addon); 25 const r = initRender(true, addon);
28 const resolved = resolveNode(r, node); 26 const resolved = resolveNode(r, node);
29 if (r.async === 0) { 27 if (r.async === 0) {
...@@ -44,7 +42,7 @@ export function html(rawText: string) {...@@ -44,7 +42,7 @@ export function html(rawText: string) {
44 return [kDirectHtml, rawText]; 42 return [kDirectHtml, rawText];
45} 43}
46 44
47interface Result<A extends AddonData = AddonData> { 45interface Result<A extends Addons = Addons> {
48 text: string; 46 text: string;
49 addon: A; 47 addon: A;
50} 48}
...@@ -59,7 +57,7 @@ export interface Render {...@@ -59,7 +57,7 @@ export interface Render {
59 /** When components reject, those are logged here */ 57 /** When components reject, those are logged here */
60 rejections: unknown[] | null; 58 rejections: unknown[] | null;
61 /** Add-ons to the rendering engine store state here */ 59 /** Add-ons to the rendering engine store state here */
62 addon: AddonData; 60 addon: Addons;
63} 61}
64 62
65export const kElement = Symbol("Element"); 63export const kElement = Symbol("Element");
...@@ -100,7 +98,7 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {...@@ -100,7 +98,7 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {
100 if (!node && node !== 0) return ""; // falsy, non numeric 98 if (!node && node !== 0) return ""; // falsy, non numeric
101 if (typeof node !== "object") { 99 if (typeof node !== "object") {
102 if (node === true) return ""; // booleans are ignored 100 if (node === true) return ""; // booleans are ignored
103 if (typeof node === "string") return escapeHTML(node); 101 if (typeof node === "string") return escapeHtml(node);
104 if (typeof node === "number") return String(node); // no escaping ever 102 if (typeof node === "number") return String(node); // no escaping ever
105 throw new Error(`Cannot render ${inspect(node)} to HTML`); 103 throw new Error(`Cannot render ${inspect(node)} to HTML`);
106 } 104 }
...@@ -193,12 +191,12 @@ function renderElement(element: ResolvedElement) {...@@ -193,12 +191,12 @@ function renderElement(element: ResolvedElement) {
193 let attr; 191 let attr;
194 switch (prop) { 192 switch (prop) {
195 default: 193 default:
196 attr = `${prop}=${quoteIfNeeded(escapeHTML(String(value)))}`; 194 attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
197 break; 195 break;
198 case "className": 196 case "className":
199 // Legacy React Compat 197 // Legacy React Compat
200 case "class": 198 case "class":
201 attr = `class=${quoteIfNeeded(escapeHTML(clsx(value)))}`; 199 attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
202 break; 200 break;
203 case "htmlFor": 201 case "htmlFor":
204 throw new Error("Do not use the `htmlFor` attribute. Use `for`"); 202 throw new Error("Do not use the `htmlFor` attribute. Use `for`");
...@@ -227,19 +225,19 @@ export function renderStyleAttribute(style: Record<string, string>) {...@@ -227,19 +225,19 @@ export function renderStyleAttribute(style: Record<string, string>) {
227 for (const styleName in style) { 225 for (const styleName in style) {
228 if (out) out += ";"; 226 if (out) out += ";";
229 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ 227 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
230 escapeHTML(String(style[styleName])) 228 escapeHtml(String(style[styleName]))
231 }`; 229 }`;
232 } 230 }
233 return "style=" + quoteIfNeeded(out); 231 return "style=" + quoteIfNeeded(out);
234} 232}
235export function quoteIfNeeded(text) { 233export function quoteIfNeeded(text: string) {
236 if (text.includes(" ")) return '"' + text + '"'; 234 if (text.includes(" ")) return '"' + text + '"';
237 return text; 235 return text;
238} 236}
239 237
240// -- utility functions -- 238// -- utility functions --
241 239
242export function initRender(allowAsync: boolean, addon: AddonData): Render { 240export function initRender(allowAsync: boolean, addon: Addons): Render {
243 return { 241 return {
244 async: allowAsync ? 0 : -1, 242 async: allowAsync ? 0 : -1,
245 rejections: null, 243 rejections: null,
...@@ -270,11 +268,10 @@ export function inspect(object: unknown) {...@@ -270,11 +268,10 @@ export function inspect(object: unknown) {
270 268
271export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[]; 269export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
272export function clsx(mix: ClsxInput) { 270export function clsx(mix: ClsxInput) {
273 var k, y, str; 271 var k, y, str = "";
274 if (typeof mix === "string") { 272 if (typeof mix === "string") {
275 return mix; 273 return mix;
276 } else if (typeof mix === "object") { 274 } else if (typeof mix === "object") {
277 str = "";
278 if (Array.isArray(mix)) { 275 if (Array.isArray(mix)) {
279 for (k = 0; k < mix.length; k++) { 276 for (k = 0; k < mix.length; k++) {
280 if (mix[k] && (y = clsx(mix[k]))) { 277 if (mix[k] && (y = clsx(mix[k]))) {
...@@ -294,7 +291,7 @@ export function clsx(mix: ClsxInput) {...@@ -294,7 +291,7 @@ export function clsx(mix: ClsxInput) {
294 return str; 291 return str;
295} 292}
296 293
297export const escapeHTML = (unsafeText: string) => 294export const escapeHtml = (unsafeText: string) =>
298 String(unsafeText) 295 String(unsafeText)
299 .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") 296 .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
300 .replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;"); 297 .replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;");
framework/hot.ts+8-2
...@@ -162,7 +162,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) {...@@ -162,7 +162,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) {
162 ) + '\nimport { Script as CloverScriptInclude } from "#sitegen";';162 ) + '\nimport { Script as CloverScriptInclude } from "#sitegen";';
163 }163 }
164164
165 src = marko.compileSync(filepath, {}).code;165 src = marko.compileSync(src, filepath).code;
166 src = src.replace("marko/debug/html", "#ssr/marko");166 src = src.replace("marko/debug/html", "#ssr/marko");
167 return loadEsbuildCode(module, filepath, src);167 return loadEsbuildCode(module, filepath, src);
168}168}
...@@ -174,7 +174,7 @@ function loadMdx(module: NodeJS.Module, filepath: string) {...@@ -174,7 +174,7 @@ function loadMdx(module: NodeJS.Module, filepath: string) {
174 return loadEsbuildCode(module, filepath, src);174 return loadEsbuildCode(module, filepath, src);
175}175}
176176
177function loadCss(module: NodeJS.Module, filepath: string) {177function loadCss(module: NodeJS.Module, _filepath: string) {
178 module.exports = {};178 module.exports = {};
179}179}
180180
...@@ -228,6 +228,12 @@ declare global {...@@ -228,6 +228,12 @@ declare global {
228 }228 }
229 }229 }
230}230}
231declare module "node:module" {
232 export function _resolveFilename(
233 id: string,
234 parent: NodeJS.Module,
235 ): unknown;
236}
231237
232import * as fs from "./fs.ts";238import * as fs from "./fs.ts";
233import * as path from "node:path";239import * as path from "node:path";
framework/meta.ts created+24
...@@ -0,0 +1,24 @@
1export interface Meta {
2 title: string;
3 description?: string | undefined;
4 openGraph?: OpenGraph;
5 alternates?: Alternates;
6}
7export interface OpenGraph {
8 title?: string;
9 description?: string | undefined;
10 type: string;
11 url: string;
12}
13export interface Alternates {
14 canonical: string;
15 types: { [mime: string]: AlternateType };
16}
17export interface AlternateType {
18 url: string;
19 title: string;
20}
21export function renderMeta({ title }: Meta): string {
22 return `<title>${esc(title)}</title>`;
23}
24import { escapeHtml as esc } from "./engine/ssr.ts";
framework/meta/index.ts deleted-13
...@@ -1,13 +0,0 @@
1import { resolveMetadata } from "./merge";
2import { renderMetadata } from "./render";
3import { Metadata } from "./types";
4
5export * from "./types";
6export * from "./merge";
7export * from "./render";
8
9export function resolveAndRenderMetadata(
10 ...metadata: [Metadata, ...Metadata[]]
11) {
12 return renderMetadata(resolveMetadata(...metadata));
13}
framework/meta/merge.ts deleted-154
...@@ -1,154 +0,0 @@
1import { createDefaultMetadata } from "./nextjs/default-metadata";
2import { resolveAsArrayOrUndefined } from "./nextjs/generate/utils";
3import {
4 resolveAlternates,
5 resolveAppleWebApp,
6 resolveAppLinks,
7 resolveRobots,
8 resolveThemeColor,
9 resolveVerification,
10 resolveViewport,
11} from "./nextjs/resolvers/resolve-basics";
12import { resolveIcons } from "./nextjs/resolvers/resolve-icons";
13import {
14 resolveOpenGraph,
15 resolveTwitter,
16} from "./nextjs/resolvers/resolve-opengraph";
17import { resolveTitle } from "./nextjs/resolvers/resolve-title";
18import type {
19 Metadata,
20 ResolvedMetadata,
21} from "./nextjs/types/metadata-interface";
22
23type MetadataAccumulationOptions = {
24 pathname: string;
25};
26
27// Merge the source metadata into the resolved target metadata.
28function merge(
29 target: ResolvedMetadata,
30 source: Metadata | null,
31 titleTemplates: {
32 title?: string | null;
33 twitter?: string | null;
34 openGraph?: string | null;
35 } = {},
36) {
37 const metadataBase = source?.metadataBase || target.metadataBase;
38 for (const key_ in source) {
39 const key = key_ as keyof Metadata;
40
41 switch (key) {
42 case "title": {
43 target.title = resolveTitle(source.title, titleTemplates.title);
44 break;
45 }
46 case "alternates": {
47 target.alternates = resolveAlternates(source.alternates, metadataBase, {
48 pathname: (source as any)._pathname ?? "/",
49 });
50 break;
51 }
52 case "openGraph": {
53 target.openGraph = resolveOpenGraph(source.openGraph, metadataBase);
54 if (target.openGraph) {
55 target.openGraph.title = resolveTitle(
56 target.openGraph.title,
57 titleTemplates.openGraph,
58 );
59 }
60 break;
61 }
62 case "twitter": {
63 target.twitter = resolveTwitter(source.twitter, metadataBase);
64 if (target.twitter) {
65 target.twitter.title = resolveTitle(
66 target.twitter.title,
67 titleTemplates.twitter,
68 );
69 }
70 break;
71 }
72 case "verification":
73 target.verification = resolveVerification(source.verification);
74 break;
75 case "viewport": {
76 target.viewport = resolveViewport(source.viewport);
77 break;
78 }
79 case "icons": {
80 target.icons = resolveIcons(source.icons);
81 break;
82 }
83 case "appleWebApp":
84 target.appleWebApp = resolveAppleWebApp(source.appleWebApp);
85 break;
86 case "appLinks":
87 target.appLinks = resolveAppLinks(source.appLinks);
88 break;
89 case "robots": {
90 target.robots = resolveRobots(source.robots);
91 break;
92 }
93 case "themeColor": {
94 target.themeColor = resolveThemeColor(source.themeColor);
95 break;
96 }
97 case "archives":
98 case "assets":
99 case "bookmarks":
100 case "keywords":
101 case "authors": {
102 // FIXME: type inferring
103 // @ts-ignore
104 target[key] = resolveAsArrayOrUndefined(source[key]) || null;
105 break;
106 }
107 // directly assign fields that fallback to null
108 case "applicationName":
109 case "description":
110 case "generator":
111 case "creator":
112 case "publisher":
113 case "category":
114 case "classification":
115 case "referrer":
116 case "colorScheme":
117 case "itunes":
118 case "formatDetection":
119 case "manifest":
120 // @ts-ignore TODO: support inferring
121 target[key] = source[key] || null;
122 break;
123 case "other":
124 target.other = Object.assign({}, target.other, source.other);
125 break;
126 case "metadataBase":
127 target.metadataBase = metadataBase;
128 break;
129 default:
130 break;
131 }
132 }
133
134 return target;
135}
136
137export interface MetadataWithPathname extends Metadata {
138 /** Set by framework author to the pathname of the page defining this metadata. */
139 _pathname?: string;
140}
141
142export function resolveMetadata(
143 ...metadata: [MetadataWithPathname, ...MetadataWithPathname[]]
144) {
145 const base = createDefaultMetadata();
146 for (const item of metadata) {
147 merge(base, item, {
148 title: base.title?.template,
149 twitter: base.twitter?.title?.template,
150 openGraph: base.openGraph?.title?.template,
151 });
152 }
153 return base;
154}
framework/meta/nextjs/constants.ts deleted-15
...@@ -1,15 +0,0 @@
1import type { Viewport } from "./types/extra-types";
2import type { Icons } from "./types/metadata-types";
3
4export const ViewPortKeys: { [k in keyof Viewport]: string } = {
5 width: "width",
6 height: "height",
7 initialScale: "initial-scale",
8 minimumScale: "minimum-scale",
9 maximumScale: "maximum-scale",
10 viewportFit: "viewport-fit",
11 userScalable: "user-scalable",
12 interactiveWidget: "interactive-widget",
13} as const;
14
15export const IconKeys: (keyof Icons)[] = ["icon", "shortcut", "apple", "other"];
framework/meta/nextjs/default-metadata.ts deleted-50
...@@ -1,50 +0,0 @@
1import type { ResolvedMetadata } from "./types/metadata-interface";
2import process from "node:process";
3
4export function createDefaultMetadata(): ResolvedMetadata {
5 const defaultMetadataBase =
6 process.env.NODE_ENV === "production" && process.env.VERCEL_URL
7 ? new URL(`https://${process.env.VERCEL_URL}`)
8 : null;
9
10 return {
11 viewport: "width=device-width, initial-scale=1",
12 metadataBase: defaultMetadataBase,
13
14 // Other values are all null
15 title: null,
16 description: null,
17 applicationName: null,
18 authors: null,
19 generator: null,
20 keywords: null,
21 referrer: null,
22 themeColor: null,
23 colorScheme: null,
24 creator: null,
25 publisher: null,
26 robots: null,
27 manifest: null,
28 alternates: {
29 canonical: null,
30 languages: null,
31 media: null,
32 types: null,
33 },
34 icons: null,
35 openGraph: null,
36 twitter: null,
37 verification: {},
38 appleWebApp: null,
39 formatDetection: null,
40 itunes: null,
41 abstract: null,
42 appLinks: null,
43 archives: null,
44 assets: null,
45 bookmarks: null,
46 category: null,
47 classification: null,
48 other: {},
49 };
50}
framework/meta/nextjs/generate/alternate.tsx deleted-72
...@@ -1,72 +0,0 @@
1import type { ResolvedMetadata } from "../types/metadata-interface";
2
3import React from "react";
4import { AlternateLinkDescriptor } from "../types/alternative-urls-types";
5
6function AlternateLink({
7 descriptor,
8 ...props
9}: {
10 descriptor: AlternateLinkDescriptor;
11} & React.LinkHTMLAttributes<HTMLLinkElement>) {
12 if (!descriptor.url) return null;
13 return (
14 <link
15 {...props}
16 {...(descriptor.title && { title: descriptor.title })}
17 href={descriptor.url.toString()}
18 />
19 );
20}
21
22export function AlternatesMetadata({
23 alternates,
24}: {
25 alternates: ResolvedMetadata["alternates"];
26}) {
27 if (!alternates) return null;
28 const { canonical, languages, media, types } = alternates;
29 return (
30 <>
31 {canonical
32 ? <AlternateLink rel="canonical" descriptor={canonical} />
33 : null}
34 {languages
35 ? Object.entries(languages).map(([locale, descriptors]) => {
36 return descriptors?.map((descriptor, index) => (
37 <AlternateLink
38 rel="alternate"
39 key={index}
40 hrefLang={locale}
41 descriptor={descriptor}
42 />
43 ));
44 })
45 : null}
46 {media
47 ? Object.entries(media).map(([mediaName, descriptors]) =>
48 descriptors?.map((descriptor, index) => (
49 <AlternateLink
50 rel="alternate"
51 key={index}
52 media={mediaName}
53 descriptor={descriptor}
54 />
55 ))
56 )
57 : null}
58 {types
59 ? Object.entries(types).map(([type, descriptors]) =>
60 descriptors?.map((descriptor, index) => (
61 <AlternateLink
62 rel="alternate"
63 key={index}
64 type={type}
65 descriptor={descriptor}
66 />
67 ))
68 )
69 : null}
70 </>
71 );
72}
framework/meta/nextjs/generate/basic.tsx deleted-171
...@@ -1,171 +0,0 @@
1import type { ResolvedMetadata } from "../types/metadata-interface";
2
3import React from "react";
4import { Meta, MultiMeta } from "./meta";
5
6export function BasicMetadata({ metadata }: { metadata: ResolvedMetadata }) {
7 return (
8 <>
9 <meta charSet="utf-8" />
10 {metadata.title !== null && metadata.title.absolute
11 ? <title>{metadata.title.absolute}</title>
12 : null}
13 <Meta name="description" content={metadata.description} />
14 <Meta name="application-name" content={metadata.applicationName} />
15 {metadata.authors
16 ? metadata.authors.map((author, index) => (
17 <React.Fragment key={index}>
18 {author.url && <link rel="author" href={author.url.toString()} />}
19 <Meta name="author" content={author.name} />
20 </React.Fragment>
21 ))
22 : null}
23 {metadata.manifest
24 ? <link rel="manifest" href={metadata.manifest.toString()} />
25 : null}
26 <Meta name="generator" content={metadata.generator} />
27 <Meta name="keywords" content={metadata.keywords?.join(",")} />
28 <Meta name="referrer" content={metadata.referrer} />
29 {metadata.themeColor
30 ? metadata.themeColor.map((themeColor, index) => (
31 <Meta
32 key={index}
33 name="theme-color"
34 content={themeColor.color}
35 media={themeColor.media}
36 />
37 ))
38 : null}
39 <Meta name="color-scheme" content={metadata.colorScheme} />
40 <Meta name="viewport" content={metadata.viewport} />
41 <Meta name="creator" content={metadata.creator} />
42 <Meta name="publisher" content={metadata.publisher} />
43 <Meta name="robots" content={metadata.robots?.basic} />
44 <Meta name="googlebot" content={metadata.robots?.googleBot} />
45 <Meta name="abstract" content={metadata.abstract} />
46 {metadata.archives
47 ? metadata.archives.map((archive) => (
48 <link rel="archives" href={archive} key={archive} />
49 ))
50 : null}
51 {metadata.assets
52 ? metadata.assets.map((asset) => (
53 <link rel="assets" href={asset} key={asset} />
54 ))
55 : null}
56 {metadata.bookmarks
57 ? metadata.bookmarks.map((bookmark) => (
58 <link rel="bookmarks" href={bookmark} key={bookmark} />
59 ))
60 : null}
61 <Meta name="category" content={metadata.category} />
62 <Meta name="classification" content={metadata.classification} />
63 {metadata.other
64 ? Object.entries(metadata.other).map(([name, content]) => (
65 <Meta
66 key={name}
67 name={name}
68 content={Array.isArray(content) ? content.join(",") : content}
69 />
70 ))
71 : null}
72 </>
73 );
74}
75
76export function ItunesMeta({ itunes }: { itunes: ResolvedMetadata["itunes"] }) {
77 if (!itunes) return null;
78 const { appId, appArgument } = itunes;
79 let content = `app-id=${appId}`;
80 if (appArgument) {
81 content += `, app-argument=${appArgument}`;
82 }
83 return <meta name="apple-itunes-app" content={content} />;
84}
85
86const formatDetectionKeys = [
87 "telephone",
88 "date",
89 "address",
90 "email",
91 "url",
92] as const;
93export function FormatDetectionMeta({
94 formatDetection,
95}: {
96 formatDetection: ResolvedMetadata["formatDetection"];
97}) {
98 if (!formatDetection) return null;
99 let content = "";
100 for (const key of formatDetectionKeys) {
101 if (key in formatDetection) {
102 if (content) content += ", ";
103 content += `${key}=no`;
104 }
105 }
106 return <meta name="format-detection" content={content} />;
107}
108
109export function AppleWebAppMeta({
110 appleWebApp,
111}: {
112 appleWebApp: ResolvedMetadata["appleWebApp"];
113}) {
114 if (!appleWebApp) return null;
115 const { capable, title, startupImage, statusBarStyle } = appleWebApp;
116
117 return (
118 <>
119 {capable
120 ? <meta name="apple-mobile-web-app-capable" content="yes" />
121 : null}
122 <Meta name="apple-mobile-web-app-title" content={title} />
123 {startupImage
124 ? startupImage.map((image, index) => (
125 <link
126 key={index}
127 href={image.url}
128 media={image.media}
129 rel="apple-touch-startup-image"
130 />
131 ))
132 : null}
133 {statusBarStyle
134 ? (
135 <meta
136 name="apple-mobile-web-app-status-bar-style"
137 content={statusBarStyle}
138 />
139 )
140 : null}
141 </>
142 );
143}
144
145export function VerificationMeta({
146 verification,
147}: {
148 verification: ResolvedMetadata["verification"];
149}) {
150 if (!verification) return null;
151
152 return (
153 <>
154 <MultiMeta
155 namePrefix="google-site-verification"
156 contents={verification.google}
157 />
158 <MultiMeta namePrefix="y_key" contents={verification.yahoo} />
159 <MultiMeta
160 namePrefix="yandex-verification"
161 contents={verification.yandex}
162 />
163 <MultiMeta namePrefix="me" contents={verification.me} />
164 {verification.other
165 ? Object.entries(verification.other).map(([key, value], index) => (
166 <MultiMeta key={key + index} namePrefix={key} contents={value} />
167 ))
168 : null}
169 </>
170 );
171}
framework/meta/nextjs/generate/icons.tsx deleted-62
...@@ -1,62 +0,0 @@
1import type { ResolvedMetadata } from "../types/metadata-interface";
2import type { Icon, IconDescriptor } from "../types/metadata-types";
3
4import React from "react";
5
6function IconDescriptorLink({ icon }: { icon: IconDescriptor }) {
7 const { url, rel = "icon", ...props } = icon;
8
9 return <link rel={rel} href={url.toString()} {...props} />;
10}
11
12function IconLink({ rel, icon }: { rel?: string; icon: Icon }) {
13 if (typeof icon === "object" && !(icon instanceof URL)) {
14 if (rel) icon.rel = rel;
15 return <IconDescriptorLink icon={icon} />;
16 } else {
17 const href = icon.toString();
18 return <link rel={rel} href={href} />;
19 }
20}
21
22export function IconsMetadata({ icons }: { icons: ResolvedMetadata["icons"] }) {
23 if (!icons) return null;
24
25 const shortcutList = icons.shortcut;
26 const iconList = icons.icon;
27 const appleList = icons.apple;
28 const otherList = icons.other;
29
30 return (
31 <>
32 {shortcutList
33 ? shortcutList.map((icon, index) => (
34 <IconLink
35 key={`shortcut-${index}`}
36 rel="shortcut icon"
37 icon={icon}
38 />
39 ))
40 : null}
41 {iconList
42 ? iconList.map((icon, index) => (
43 <IconLink key={`shortcut-${index}`} rel="icon" icon={icon} />
44 ))
45 : null}
46 {appleList
47 ? appleList.map((icon, index) => (
48 <IconLink
49 key={`apple-${index}`}
50 rel="apple-touch-icon"
51 icon={icon}
52 />
53 ))
54 : null}
55 {otherList
56 ? otherList.map((icon, index) => (
57 <IconDescriptorLink key={`other-${index}`} icon={icon} />
58 ))
59 : null}
60 </>
61 );
62}
framework/meta/nextjs/generate/meta.tsx deleted-124
...@@ -1,124 +0,0 @@
1import React from "react";
2
3export function Meta({
4 name,
5 property,
6 content,
7 media,
8}: {
9 name?: string;
10 property?: string;
11 media?: string;
12 content: string | number | URL | null | undefined;
13}): React.ReactElement | null {
14 if (typeof content !== "undefined" && content !== null && content !== "") {
15 return (
16 <meta
17 {...(name ? { name } : { property })}
18 {...(media ? { media } : undefined)}
19 content={typeof content === "string" ? content : content.toString()}
20 />
21 );
22 }
23 return null;
24}
25
26type ExtendMetaContent = Record<
27 string,
28 undefined | string | URL | number | boolean | null | undefined
29>;
30type MultiMetaContent =
31 | (ExtendMetaContent | string | URL | number)[]
32 | null
33 | undefined;
34
35function camelToSnake(camelCaseStr: string) {
36 return camelCaseStr.replace(/([A-Z])/g, function (match) {
37 return "_" + match.toLowerCase();
38 });
39}
40
41function getMetaKey(prefix: string, key: string) {
42 // Use `twitter:image` and `og:image` instead of `twitter:image:url` and `og:image:url`
43 // to be more compatible as it's a more common format
44 if ((prefix === "og:image" || prefix === "twitter:image") && key === "url") {
45 return prefix;
46 }
47 if (prefix.startsWith("og:") || prefix.startsWith("twitter:")) {
48 key = camelToSnake(key);
49 }
50 return prefix + ":" + key;
51}
52
53function ExtendMeta({
54 content,
55 namePrefix,
56 propertyPrefix,
57}: {
58 content?: ExtendMetaContent;
59 namePrefix?: string;
60 propertyPrefix?: string;
61}) {
62 const keyPrefix = namePrefix || propertyPrefix;
63 if (!content) return null;
64 return (
65 <React.Fragment>
66 {Object.entries(content).map(([k, v], index) => {
67 return typeof v === "undefined" ? null : (
68 <Meta
69 key={keyPrefix + ":" + k + "_" + index}
70 {...(propertyPrefix && { property: getMetaKey(propertyPrefix, k) })}
71 {...(namePrefix && { name: getMetaKey(namePrefix, k) })}
72 content={typeof v === "string" ? v : v?.toString()}
73 />
74 );
75 })}
76 </React.Fragment>
77 );
78}
79
80export function MultiMeta({
81 propertyPrefix,
82 namePrefix,
83 contents,
84}: {
85 propertyPrefix?: string;
86 namePrefix?: string;
87 contents?: MultiMetaContent | null;
88}) {
89 if (typeof contents === "undefined" || contents === null) {
90 return null;
91 }
92
93 const keyPrefix = propertyPrefix || namePrefix;
94 return (
95 <>
96 {contents.map((content, index) => {
97 if (
98 typeof content === "string" ||
99 typeof content === "number" ||
100 content instanceof URL
101 ) {
102 return (
103 <Meta
104 key={keyPrefix + "_" + index}
105 {...(propertyPrefix
106 ? { property: propertyPrefix }
107 : { name: namePrefix })}
108 content={content}
109 />
110 );
111 } else {
112 return (
113 <ExtendMeta
114 key={keyPrefix + "_" + index}
115 namePrefix={namePrefix}
116 propertyPrefix={propertyPrefix}
117 content={content}
118 />
119 );
120 }
121 })}
122 </>
123 );
124}
framework/meta/nextjs/generate/opengraph.tsx deleted-316
...@@ -1,316 +0,0 @@
1import type { ResolvedMetadata } from "../types/metadata-interface";
2import type { TwitterAppDescriptor } from "../types/twitter-types";
3
4import React from "react";
5import { Meta, MultiMeta } from "./meta";
6
7export function OpenGraphMetadata({
8 openGraph,
9}: {
10 openGraph: ResolvedMetadata["openGraph"];
11}) {
12 if (!openGraph) {
13 return null;
14 }
15
16 let typedOpenGraph;
17 if ("type" in openGraph) {
18 switch (openGraph.type) {
19 case "website":
20 typedOpenGraph = <Meta property="og:type" content="website" />;
21 break;
22 case "article":
23 typedOpenGraph = (
24 <>
25 <Meta property="og:type" content="article" />
26 <Meta
27 property="article:published_time"
28 content={openGraph.publishedTime?.toString()}
29 />
30 <Meta
31 property="article:modified_time"
32 content={openGraph.modifiedTime?.toString()}
33 />
34 <Meta
35 property="article:expiration_time"
36 content={openGraph.expirationTime?.toString()}
37 />
38 <MultiMeta
39 propertyPrefix="article:author"
40 contents={openGraph.authors}
41 />
42 <Meta property="article:section" content={openGraph.section} />
43 <MultiMeta propertyPrefix="article:tag" contents={openGraph.tags} />
44 </>
45 );
46 break;
47 case "book":
48 typedOpenGraph = (
49 <>
50 <Meta property="og:type" content="book" />
51 <Meta property="book:isbn" content={openGraph.isbn} />
52 <Meta
53 property="book:release_date"
54 content={openGraph.releaseDate}
55 />
56 <MultiMeta
57 propertyPrefix="book:author"
58 contents={openGraph.authors}
59 />
60 <MultiMeta propertyPrefix="book:tag" contents={openGraph.tags} />
61 </>
62 );
63 break;
64 case "profile":
65 typedOpenGraph = (
66 <>
67 <Meta property="og:type" content="profile" />
68 <Meta property="profile:first_name" content={openGraph.firstName} />
69 <Meta property="profile:last_name" content={openGraph.lastName} />
70 <Meta property="profile:username" content={openGraph.username} />
71 <Meta property="profile:gender" content={openGraph.gender} />
72 </>
73 );
74 break;
75 case "music.song":
76 typedOpenGraph = (
77 <>
78 <Meta property="og:type" content="music.song" />
79 <Meta
80 property="music:duration"
81 content={openGraph.duration?.toString()}
82 />
83 <MultiMeta
84 propertyPrefix="music:album"
85 contents={openGraph.albums}
86 />
87 <MultiMeta
88 propertyPrefix="music:musician"
89 contents={openGraph.musicians}
90 />
91 </>
92 );
93 break;
94 case "music.album":
95 typedOpenGraph = (
96 <>
97 <Meta property="og:type" content="music.album" />
98 <MultiMeta propertyPrefix="music:song" contents={openGraph.songs} />
99 <MultiMeta
100 propertyPrefix="music:musician"
101 contents={openGraph.musicians}
102 />
103 <Meta
104 property="music:release_date"
105 content={openGraph.releaseDate}
106 />
107 </>
108 );
109 break;
110 case "music.playlist":
111 typedOpenGraph = (
112 <>
113 <Meta property="og:type" content="music.playlist" />
114 <MultiMeta propertyPrefix="music:song" contents={openGraph.songs} />
115 <MultiMeta
116 propertyPrefix="music:creator"
117 contents={openGraph.creators}
118 />
119 </>
120 );
121 break;
122 case "music.radio_station":
123 typedOpenGraph = (
124 <>
125 <Meta property="og:type" content="music.radio_station" />
126 <MultiMeta
127 propertyPrefix="music:creator"
128 contents={openGraph.creators}
129 />
130 </>
131 );
132 break;
133 case "video.movie":
134 typedOpenGraph = (
135 <>
136 <Meta property="og:type" content="video.movie" />
137 <MultiMeta
138 propertyPrefix="video:actor"
139 contents={openGraph.actors}
140 />
141 <MultiMeta
142 propertyPrefix="video:director"
143 contents={openGraph.directors}
144 />
145 <MultiMeta
146 propertyPrefix="video:writer"
147 contents={openGraph.writers}
148 />
149 <Meta property="video:duration" content={openGraph.duration} />
150 <Meta
151 property="video:release_date"
152 content={openGraph.releaseDate}
153 />
154 <MultiMeta propertyPrefix="video:tag" contents={openGraph.tags} />
155 </>
156 );
157 break;
158 case "video.episode":
159 typedOpenGraph = (
160 <>
161 <Meta property="og:type" content="video.episode" />
162 <MultiMeta
163 propertyPrefix="video:actor"
164 contents={openGraph.actors}
165 />
166 <MultiMeta
167 propertyPrefix="video:director"
168 contents={openGraph.directors}
169 />
170 <MultiMeta
171 propertyPrefix="video:writer"
172 contents={openGraph.writers}
173 />
174 <Meta property="video:duration" content={openGraph.duration} />
175 <Meta
176 property="video:release_date"
177 content={openGraph.releaseDate}
178 />
179 <MultiMeta propertyPrefix="video:tag" contents={openGraph.tags} />
180 <Meta property="video:series" content={openGraph.series} />
181 </>
182 );
183 break;
184 case "video.tv_show":
185 typedOpenGraph = <Meta property="og:type" content="video.tv_show" />;
186 break;
187 case "video.other":
188 typedOpenGraph = <Meta property="og:type" content="video.other" />;
189 break;
190 default:
191 throw new Error("Invalid OpenGraph type: " + (openGraph as any).type);
192 }
193 }
194
195 return (
196 <>
197 <Meta property="og:determiner" content={openGraph.determiner} />
198 <Meta property="og:title" content={openGraph.title?.absolute} />
199 <Meta property="og:description" content={openGraph.description} />
200 <Meta property="og:url" content={openGraph.url?.toString()} />
201 <Meta property="og:site_name" content={openGraph.siteName} />
202 <Meta property="og:locale" content={openGraph.locale} />
203 <Meta property="og:country_name" content={openGraph.countryName} />
204 <Meta property="og:ttl" content={openGraph.ttl?.toString()} />
205 <MultiMeta propertyPrefix="og:image" contents={openGraph.images} />
206 <MultiMeta propertyPrefix="og:video" contents={openGraph.videos} />
207 <MultiMeta propertyPrefix="og:audio" contents={openGraph.audio} />
208 <MultiMeta propertyPrefix="og:email" contents={openGraph.emails} />
209 <MultiMeta
210 propertyPrefix="og:phone_number"
211 contents={openGraph.phoneNumbers}
212 />
213 <MultiMeta
214 propertyPrefix="og:fax_number"
215 contents={openGraph.faxNumbers}
216 />
217 <MultiMeta
218 propertyPrefix="og:locale:alternate"
219 contents={openGraph.alternateLocale}
220 />
221 {typedOpenGraph}
222 </>
223 );
224}
225
226function TwitterAppItem({
227 app,
228 type,
229}: {
230 app: TwitterAppDescriptor;
231 type: "iphone" | "ipad" | "googleplay";
232}) {
233 return (
234 <>
235 <Meta name={`twitter:app:name:${type}`} content={app.name} />
236 <Meta name={`twitter:app:id:${type}`} content={app.id[type]} />
237 <Meta
238 name={`twitter:app:url:${type}`}
239 content={app.url?.[type]?.toString()}
240 />
241 </>
242 );
243}
244
245export function TwitterMetadata({
246 twitter,
247}: {
248 twitter: ResolvedMetadata["twitter"];
249}) {
250 if (!twitter) return null;
251 const { card } = twitter;
252
253 return (
254 <>
255 <Meta name="twitter:card" content={card} />
256 <Meta name="twitter:site" content={twitter.site} />
257 <Meta name="twitter:site:id" content={twitter.siteId} />
258 <Meta name="twitter:creator" content={twitter.creator} />
259 <Meta name="twitter:creator:id" content={twitter.creatorId} />
260 <Meta name="twitter:title" content={twitter.title?.absolute} />
261 <Meta name="twitter:description" content={twitter.description} />
262 <MultiMeta namePrefix="twitter:image" contents={twitter.images} />
263 {card === "player"
264 ? twitter.players.map((player, index) => (
265 <React.Fragment key={index}>
266 <Meta
267 name="twitter:player"
268 content={player.playerUrl.toString()}
269 />
270 <Meta
271 name="twitter:player:stream"
272 content={player.streamUrl.toString()}
273 />
274 <Meta name="twitter:player:width" content={player.width} />
275 <Meta name="twitter:player:height" content={player.height} />
276 </React.Fragment>
277 ))
278 : null}
279 {card === "app"
280 ? (
281 <>
282 <TwitterAppItem app={twitter.app} type="iphone" />
283 <TwitterAppItem app={twitter.app} type="ipad" />
284 <TwitterAppItem app={twitter.app} type="googleplay" />
285 </>
286 )
287 : null}
288 </>
289 );
290}
291
292export function AppLinksMeta({
293 appLinks,
294}: {
295 appLinks: ResolvedMetadata["appLinks"];
296}) {
297 if (!appLinks) return null;
298 return (
299 <>
300 <MultiMeta propertyPrefix="al:ios" contents={appLinks.ios} />
301 <MultiMeta propertyPrefix="al:iphone" contents={appLinks.iphone} />
302 <MultiMeta propertyPrefix="al:ipad" contents={appLinks.ipad} />
303 <MultiMeta propertyPrefix="al:android" contents={appLinks.android} />
304 <MultiMeta
305 propertyPrefix="al:windows_phone"
306 contents={appLinks.windows_phone}
307 />
308 <MultiMeta propertyPrefix="al:windows" contents={appLinks.windows} />
309 <MultiMeta
310 propertyPrefix="al:windows_universal"
311 contents={appLinks.windows_universal}
312 />
313 <MultiMeta propertyPrefix="al:web" contents={appLinks.web} />
314 </>
315 );
316}
framework/meta/nextjs/generate/utils.ts deleted-20
...@@ -1,20 +0,0 @@
1function resolveArray<T>(value: T): T[] {
2 if (Array.isArray(value)) {
3 return value;
4 }
5 return [value];
6}
7
8function resolveAsArrayOrUndefined<T extends unknown | readonly unknown[]>(
9 value: T | T[] | undefined | null,
10): undefined | T[] {
11 if (typeof value === "undefined" || value === null) {
12 return undefined;
13 }
14 if (Array.isArray(value)) {
15 return value;
16 }
17 return [value];
18}
19
20export { resolveArray, resolveAsArrayOrUndefined };
framework/meta/nextjs/get-metadata-route.ts deleted-67
...@@ -1,67 +0,0 @@
1import { isMetadataRoute, isMetadataRouteFile } from "./is-metadata-route";
2import path from "../../shared/lib/isomorphic/path";
3import { djb2Hash } from "../../shared/lib/hash";
4
5/*
6 * If there's special convention like (...) or @ in the page path,
7 * Give it a unique hash suffix to avoid conflicts
8 *
9 * e.g.
10 * /app/open-graph.tsx -> /open-graph/route
11 * /app/(post)/open-graph.tsx -> /open-graph/route-[0-9a-z]{6}
12 */
13export function getMetadataRouteSuffix(page: string) {
14 let suffix = "";
15
16 if ((page.includes("(") && page.includes(")")) || page.includes("@")) {
17 suffix = djb2Hash(page).toString(36).slice(0, 6);
18 }
19 return suffix;
20}
21
22/**
23 * Map metadata page key to the corresponding route
24 *
25 * static file page key: /app/robots.txt -> /robots.xml -> /robots.txt/route
26 * dynamic route page key: /app/robots.tsx -> /robots -> /robots.txt/route
27 *
28 * @param page
29 * @returns
30 */
31export function normalizeMetadataRoute(page: string) {
32 let route = page;
33 if (isMetadataRoute(page)) {
34 // Remove the file extension, e.g. /route-path/robots.txt -> /route-path
35 const pathnamePrefix = page.slice(0, -(path.basename(page).length + 1));
36 const suffix = getMetadataRouteSuffix(pathnamePrefix);
37
38 if (route === "/sitemap") {
39 route += ".xml";
40 }
41 if (route === "/robots") {
42 route += ".txt";
43 }
44 if (route === "/manifest") {
45 route += ".webmanifest";
46 }
47 // Support both /<metadata-route.ext> and custom routes /<metadata-route>/route.ts.
48 // If it's a metadata file route, we need to append /[id]/route to the page.
49 if (!route.endsWith("/route")) {
50 const isStaticMetadataFile = isMetadataRouteFile(route, [], true);
51 const { dir, name: baseName, ext } = path.parse(route);
52
53 const isSingleRoute = page.startsWith("/sitemap") ||
54 page.startsWith("/robots") ||
55 page.startsWith("/manifest") ||
56 isStaticMetadataFile;
57
58 route = path.join(
59 dir,
60 `${baseName}${suffix ? `-${suffix}` : ""}${ext}`,
61 isSingleRoute ? "" : "[[...__metadata_id__]]",
62 "route",
63 );
64 }
65 }
66 return route;
67}
framework/meta/nextjs/is-metadata-route.ts deleted-136
...@@ -1,136 +0,0 @@
1export const STATIC_METADATA_IMAGES = {
2 icon: {
3 filename: "icon",
4 extensions: ["ico", "jpg", "jpeg", "png", "svg"],
5 },
6 apple: {
7 filename: "apple-icon",
8 extensions: ["jpg", "jpeg", "png"],
9 },
10 favicon: {
11 filename: "favicon",
12 extensions: ["ico"],
13 },
14 openGraph: {
15 filename: "opengraph-image",
16 extensions: ["jpg", "jpeg", "png", "gif"],
17 },
18 twitter: {
19 filename: "twitter-image",
20 extensions: ["jpg", "jpeg", "png", "gif"],
21 },
22} as const;
23
24// Match routes that are metadata routes, e.g. /sitemap.xml, /favicon.<ext>, /<icon>.<ext>, etc.
25// TODO-METADATA: support more metadata routes with more extensions
26const defaultExtensions = ["js", "jsx", "ts", "tsx"];
27
28const getExtensionRegexString = (extensions: readonly string[]) =>
29 `(?:${extensions.join("|")})`;
30
31// When you only pass the file extension as `[]`, it will only match the static convention files
32// e.g. /robots.txt, /sitemap.xml, /favicon.ico, /manifest.json
33// When you pass the file extension as `['js', 'jsx', 'ts', 'tsx']`, it will also match the dynamic convention files
34// e.g. /robots.js, /sitemap.tsx, /favicon.jsx, /manifest.ts
35// When `withExtension` is false, it will match the static convention files without the extension, by default it's true
36// e.g. /robots, /sitemap, /favicon, /manifest, use to match dynamic API routes like app/robots.ts
37export function isMetadataRouteFile(
38 appDirRelativePath: string,
39 pageExtensions: string[],
40 withExtension: boolean,
41) {
42 const metadataRouteFilesRegex = [
43 new RegExp(
44 `^[\\\\/]robots${
45 withExtension
46 ? `\\.${getExtensionRegexString(pageExtensions.concat("txt"))}`
47 : ""
48 }`,
49 ),
50 new RegExp(
51 `^[\\\\/]sitemap${
52 withExtension
53 ? `\\.${getExtensionRegexString(pageExtensions.concat("xml"))}`
54 : ""
55 }`,
56 ),
57 new RegExp(
58 `^[\\\\/]manifest${
59 withExtension
60 ? `\\.${
61 getExtensionRegexString(
62 pageExtensions.concat("webmanifest", "json"),
63 )
64 }`
65 : ""
66 }`,
67 ),
68 new RegExp(`^[\\\\/]favicon\\.ico$`),
69 // TODO-METADATA: add dynamic routes for metadata images
70 new RegExp(
71 `[\\\\/]${STATIC_METADATA_IMAGES.icon.filename}${
72 withExtension
73 ? `\\.${
74 getExtensionRegexString(
75 pageExtensions.concat(STATIC_METADATA_IMAGES.icon.extensions),
76 )
77 }`
78 : ""
79 }`,
80 ),
81 new RegExp(
82 `[\\\\/]${STATIC_METADATA_IMAGES.apple.filename}${
83 withExtension
84 ? `\\.${
85 getExtensionRegexString(
86 pageExtensions.concat(STATIC_METADATA_IMAGES.apple.extensions),
87 )
88 }`
89 : ""
90 }`,
91 ),
92 new RegExp(
93 `[\\\\/]${STATIC_METADATA_IMAGES.openGraph.filename}${
94 withExtension
95 ? `\\.${
96 getExtensionRegexString(
97 pageExtensions.concat(
98 STATIC_METADATA_IMAGES.openGraph.extensions,
99 ),
100 )
101 }`
102 : ""
103 }`,
104 ),
105 new RegExp(
106 `[\\\\/]${STATIC_METADATA_IMAGES.twitter.filename}${
107 withExtension
108 ? `\\.${
109 getExtensionRegexString(
110 pageExtensions.concat(STATIC_METADATA_IMAGES.twitter.extensions),
111 )
112 }`
113 : ""
114 }`,
115 ),
116 ];
117
118 return metadataRouteFilesRegex.some((r) => r.test(appDirRelativePath));
119}
120
121/*
122 * Remove the 'app' prefix or '/route' suffix, only check the route name since they're only allowed in root app directory
123 * e.g.
124 * /app/robots -> /robots
125 * app/robots -> /robots
126 * /robots -> /robots
127 */
128export function isMetadataRoute(route: string): boolean {
129 let page = route.replace(/^\/?app\//, "").replace(/\/route$/, "");
130 if (page[0] !== "/") page = "/" + page;
131
132 return (
133 !page.endsWith("/page") &&
134 isMetadataRouteFile(page, defaultExtensions, false)
135 );
136}
framework/meta/nextjs/metadata.tsx deleted-58
...@@ -1,58 +0,0 @@
1import React from "react";
2import {
3 AppleWebAppMeta,
4 BasicMetadata,
5 FormatDetectionMeta,
6 ItunesMeta,
7 VerificationMeta,
8} from "./generate/basic";
9import { AlternatesMetadata } from "./generate/alternate";
10import {
11 AppLinksMeta,
12 OpenGraphMetadata,
13 TwitterMetadata,
14} from "./generate/opengraph";
15import { IconsMetadata } from "./generate/icons";
16import { accumulateMetadata, resolveMetadata } from "./resolve-metadata";
17import { LoaderTree } from "../../server/lib/app-dir-module";
18import { GetDynamicParamFromSegment } from "../../server/app-render/app-render";
19
20// Generate the actual React elements from the resolved metadata.
21export async function MetadataTree({
22 tree,
23 pathname,
24 searchParams,
25 getDynamicParamFromSegment,
26}: {
27 tree: LoaderTree;
28 pathname: string;
29 searchParams: { [key: string]: any };
30 getDynamicParamFromSegment: GetDynamicParamFromSegment;
31}) {
32 const options = {
33 pathname,
34 };
35 const resolvedMetadata = await resolveMetadata({
36 tree,
37 parentParams: {},
38 metadataItems: [],
39 searchParams,
40 getDynamicParamFromSegment,
41 });
42 const metadata = await accumulateMetadata(resolvedMetadata, options);
43
44 return (
45 <>
46 <BasicMetadata metadata={metadata} />
47 <AlternatesMetadata alternates={metadata.alternates} />
48 <ItunesMeta itunes={metadata.itunes} />
49 <FormatDetectionMeta formatDetection={metadata.formatDetection} />
50 <VerificationMeta verification={metadata.verification} />
51 <AppleWebAppMeta appleWebApp={metadata.appleWebApp} />
52 <OpenGraphMetadata openGraph={metadata.openGraph} />
53 <TwitterMetadata twitter={metadata.twitter} />
54 <AppLinksMeta appLinks={metadata.appLinks} />
55 <IconsMetadata icons={metadata.icons} />
56 </>
57 );
58}
framework/meta/nextjs/resolve-metadata.ts deleted-453
...@@ -1,453 +0,0 @@
1import type {
2 Metadata,
3 ResolvedMetadata,
4 ResolvingMetadata,
5} from "./types/metadata-interface";
6import type { MetadataImageModule } from "../../build/webpack/loaders/metadata/types";
7import type { GetDynamicParamFromSegment } from "../../server/app-render/app-render";
8import { createDefaultMetadata } from "./default-metadata";
9import {
10 resolveOpenGraph,
11 resolveTwitter,
12} from "./resolvers/resolve-opengraph";
13import { resolveTitle } from "./resolvers/resolve-title";
14import { resolveAsArrayOrUndefined } from "./generate/utils";
15import { isClientReference } from "../client-reference";
16import {
17 getLayoutOrPageModule,
18 LoaderTree,
19} from "../../server/lib/app-dir-module";
20import { ComponentsType } from "../../build/webpack/loaders/next-app-loader";
21import { interopDefault } from "../interop-default";
22import {
23 resolveAlternates,
24 resolveAppleWebApp,
25 resolveAppLinks,
26 resolveRobots,
27 resolveThemeColor,
28 resolveVerification,
29 resolveViewport,
30} from "./resolvers/resolve-basics";
31import { resolveIcons } from "./resolvers/resolve-icons";
32import { getTracer } from "../../server/lib/trace/tracer";
33import { ResolveMetadataSpan } from "../../server/lib/trace/constants";
34import { Twitter } from "./types/twitter-types";
35import { OpenGraph } from "./types/opengraph-types";
36import { PAGE_SEGMENT_KEY } from "../../shared/lib/constants";
37import process from "node:process";
38
39type StaticMetadata = Awaited<ReturnType<typeof resolveStaticMetadata>>;
40
41type MetadataResolver = (
42 _parent: ResolvingMetadata,
43) => Metadata | Promise<Metadata>;
44export type MetadataItems = [
45 Metadata | MetadataResolver | null,
46 StaticMetadata,
47][];
48
49function mergeStaticMetadata(
50 metadata: ResolvedMetadata,
51 staticFilesMetadata: StaticMetadata,
52) {
53 if (!staticFilesMetadata) return;
54 const { icon, apple, openGraph, twitter, manifest } = staticFilesMetadata;
55 if (icon || apple) {
56 metadata.icons = {
57 icon: icon || [],
58 apple: apple || [],
59 };
60 }
61 if (twitter) {
62 const resolvedTwitter = resolveTwitter(
63 { ...metadata.twitter, images: twitter } as Twitter,
64 metadata.metadataBase,
65 );
66 metadata.twitter = resolvedTwitter;
67 }
68
69 if (openGraph) {
70 const resolvedOpenGraph = resolveOpenGraph(
71 { ...metadata.openGraph, images: openGraph } as OpenGraph,
72 metadata.metadataBase,
73 );
74 metadata.openGraph = resolvedOpenGraph;
75 }
76 if (manifest) {
77 metadata.manifest = manifest;
78 }
79
80 return metadata;
81}
82
83// Merge the source metadata into the resolved target metadata.
84function merge({
85 target,
86 source,
87 staticFilesMetadata,
88 titleTemplates,
89 options,
90}: {
91 target: ResolvedMetadata;
92 source: Metadata | null;
93 staticFilesMetadata: StaticMetadata;
94 titleTemplates: {
95 title: string | null;
96 twitter: string | null;
97 openGraph: string | null;
98 };
99 options: MetadataAccumulationOptions;
100}) {
101 // If there's override metadata, prefer it otherwise fallback to the default metadata.
102 const metadataBase = typeof source?.metadataBase !== "undefined"
103 ? source.metadataBase
104 : target.metadataBase;
105 for (const key_ in source) {
106 const key = key_ as keyof Metadata;
107
108 switch (key) {
109 case "title": {
110 target.title = resolveTitle(source.title, titleTemplates.title);
111 break;
112 }
113 case "alternates": {
114 target.alternates = resolveAlternates(source.alternates, metadataBase, {
115 pathname: options.pathname,
116 });
117 break;
118 }
119 case "openGraph": {
120 target.openGraph = resolveOpenGraph(source.openGraph, metadataBase);
121 if (target.openGraph) {
122 target.openGraph.title = resolveTitle(
123 target.openGraph.title,
124 titleTemplates.openGraph,
125 );
126 }
127 break;
128 }
129 case "twitter": {
130 target.twitter = resolveTwitter(source.twitter, metadataBase);
131 if (target.twitter) {
132 target.twitter.title = resolveTitle(
133 target.twitter.title,
134 titleTemplates.twitter,
135 );
136 }
137 break;
138 }
139 case "verification":
140 target.verification = resolveVerification(source.verification);
141 break;
142 case "viewport": {
143 target.viewport = resolveViewport(source.viewport);
144 break;
145 }
146 case "icons": {
147 target.icons = resolveIcons(source.icons);
148 break;
149 }
150 case "appleWebApp":
151 target.appleWebApp = resolveAppleWebApp(source.appleWebApp);
152 break;
153 case "appLinks":
154 target.appLinks = resolveAppLinks(source.appLinks);
155 break;
156 case "robots": {
157 target.robots = resolveRobots(source.robots);
158 break;
159 }
160 case "themeColor": {
161 target.themeColor = resolveThemeColor(source.themeColor);
162 break;
163 }
164 case "archives":
165 case "assets":
166 case "bookmarks":
167 case "keywords":
168 case "authors": {
169 // FIXME: type inferring
170 // @ts-ignore
171 target[key] = resolveAsArrayOrUndefined(source[key]) || null;
172 break;
173 }
174 // directly assign fields that fallback to null
175 case "applicationName":
176 case "description":
177 case "generator":
178 case "creator":
179 case "publisher":
180 case "category":
181 case "classification":
182 case "referrer":
183 case "colorScheme":
184 case "itunes":
185 case "formatDetection":
186 case "manifest":
187 // @ts-ignore TODO: support inferring
188 target[key] = source[key] || null;
189 break;
190 case "other":
191 target.other = Object.assign({}, target.other, source.other);
192 break;
193 case "metadataBase":
194 target.metadataBase = metadataBase;
195 break;
196 default:
197 break;
198 }
199 }
200 mergeStaticMetadata(target, staticFilesMetadata);
201}
202
203async function getDefinedMetadata(
204 mod: any,
205 props: any,
206 route: string,
207): Promise<Metadata | MetadataResolver | null> {
208 // Layer is a client component, we just skip it. It can't have metadata exported.
209 // Return early to avoid accessing properties error for client references.
210 if (isClientReference(mod)) {
211 return null;
212 }
213 return (
214 (mod.generateMetadata
215 ? (parent: ResolvingMetadata) =>
216 getTracer().trace(
217 ResolveMetadataSpan.generateMetadata,
218 {
219 spanName: `generateMetadata ${route}`,
220 attributes: {
221 "next.page": route,
222 },
223 },
224 () => mod.generateMetadata(props, parent),
225 )
226 : mod.metadata) || null
227 );
228}
229
230async function collectStaticImagesFiles(
231 metadata: ComponentsType["metadata"],
232 props: any,
233 type: keyof NonNullable<ComponentsType["metadata"]>,
234) {
235 if (!metadata?.[type]) return undefined;
236
237 const iconPromises = metadata[type as "icon" | "apple"].map(
238 async (imageModule: (p: any) => Promise<MetadataImageModule[]>) =>
239 interopDefault(await imageModule(props)),
240 );
241
242 return iconPromises?.length > 0
243 ? (await Promise.all(iconPromises))?.flat()
244 : undefined;
245}
246
247async function resolveStaticMetadata(components: ComponentsType, props: any) {
248 const { metadata } = components;
249 if (!metadata) return null;
250
251 const [icon, apple, openGraph, twitter] = await Promise.all([
252 collectStaticImagesFiles(metadata, props, "icon"),
253 collectStaticImagesFiles(metadata, props, "apple"),
254 collectStaticImagesFiles(metadata, props, "openGraph"),
255 collectStaticImagesFiles(metadata, props, "twitter"),
256 ]);
257
258 const staticMetadata = {
259 icon,
260 apple,
261 openGraph,
262 twitter,
263 manifest: metadata.manifest,
264 };
265
266 return staticMetadata;
267}
268
269// [layout.metadata, static files metadata] -> ... -> [page.metadata, static files metadata]
270export async function collectMetadata({
271 tree,
272 metadataItems: array,
273 props,
274 route,
275}: {
276 tree: LoaderTree;
277 metadataItems: MetadataItems;
278 props: any;
279 route: string;
280}) {
281 const [mod, modType] = await getLayoutOrPageModule(tree);
282
283 if (modType) {
284 route += `/${modType}`;
285 }
286
287 const staticFilesMetadata = await resolveStaticMetadata(tree[2], props);
288 const metadataExport = mod
289 ? await getDefinedMetadata(mod, props, route)
290 : null;
291
292 array.push([metadataExport, staticFilesMetadata]);
293}
294
295export async function resolveMetadata({
296 tree,
297 parentParams,
298 metadataItems,
299 treePrefix = [],
300 getDynamicParamFromSegment,
301 searchParams,
302}: {
303 tree: LoaderTree;
304 parentParams: { [key: string]: any };
305 metadataItems: MetadataItems;
306 /** Provided tree can be nested subtree, this argument says what is the path of such subtree */
307 treePrefix?: string[];
308 getDynamicParamFromSegment: GetDynamicParamFromSegment;
309 searchParams: { [key: string]: any };
310}): Promise<MetadataItems> {
311 const [segment, parallelRoutes, { page }] = tree;
312 const currentTreePrefix = [...treePrefix, segment];
313 const isPage = typeof page !== "undefined";
314 // Handle dynamic segment params.
315 const segmentParam = getDynamicParamFromSegment(segment);
316 /**
317 * Create object holding the parent params and current params
318 */
319 const currentParams =
320 // Handle null case where dynamic param is optional
321 segmentParam && segmentParam.value !== null
322 ? {
323 ...parentParams,
324 [segmentParam.param]: segmentParam.value,
325 }
326 // Pass through parent params to children
327 : parentParams;
328
329 const layerProps = {
330 params: currentParams,
331 ...(isPage && { searchParams }),
332 };
333
334 await collectMetadata({
335 tree,
336 metadataItems,
337 props: layerProps,
338 route: currentTreePrefix
339 // __PAGE__ shouldn't be shown in a route
340 .filter((s) => s !== PAGE_SEGMENT_KEY)
341 .join("/"),
342 });
343
344 for (const key in parallelRoutes) {
345 const childTree = parallelRoutes[key];
346 await resolveMetadata({
347 tree: childTree,
348 metadataItems,
349 parentParams: currentParams,
350 treePrefix: currentTreePrefix,
351 searchParams,
352 getDynamicParamFromSegment,
353 });
354 }
355
356 return metadataItems;
357}
358
359type MetadataAccumulationOptions = {
360 pathname: string;
361};
362
363export async function accumulateMetadata(
364 metadataItems: MetadataItems,
365 options: MetadataAccumulationOptions,
366): Promise<ResolvedMetadata> {
367 const resolvedMetadata = createDefaultMetadata();
368 const resolvers: ((value: ResolvedMetadata) => void)[] = [];
369 const generateMetadataResults: (Metadata | Promise<Metadata>)[] = [];
370
371 let titleTemplates: {
372 title: string | null;
373 twitter: string | null;
374 openGraph: string | null;
375 } = {
376 title: null,
377 twitter: null,
378 openGraph: null,
379 };
380
381 // Loop over all metadata items again, merging synchronously any static object exports,
382 // awaiting any static promise exports, and resolving parent metadata and awaiting any generated metadata
383
384 let resolvingIndex = 0;
385 for (let i = 0; i < metadataItems.length; i++) {
386 const [metadataExport, staticFilesMetadata] = metadataItems[i];
387 let metadata: Metadata | null = null;
388 if (typeof metadataExport === "function") {
389 if (!resolvers.length) {
390 for (let j = i; j < metadataItems.length; j++) {
391 const [preloadMetadataExport] = metadataItems[j];
392 // call each `generateMetadata function concurrently and stash their resolver
393 if (typeof preloadMetadataExport === "function") {
394 generateMetadataResults.push(
395 preloadMetadataExport(
396 new Promise((resolve) => {
397 resolvers.push(resolve);
398 }),
399 ),
400 );
401 }
402 }
403 }
404
405 const resolveParent = resolvers[resolvingIndex];
406 const generatedMetadata = generateMetadataResults[resolvingIndex++];
407
408 // In dev we clone and freeze to prevent relying on mutating resolvedMetadata directly.
409 // In prod we just pass resolvedMetadata through without any copying.
410 const currentResolvedMetadata: ResolvedMetadata =
411 process.env.NODE_ENV === "development"
412 ? Object.freeze(
413 require(
414 "next/dist/compiled/@edge-runtime/primitives/structured-clone",
415 ).structuredClone(
416 resolvedMetadata,
417 ),
418 )
419 : resolvedMetadata;
420
421 // This resolve should unblock the generateMetadata function if it awaited the parent
422 // argument. If it didn't await the parent argument it might already have a value since it was
423 // called concurrently. Regardless we await the return value before continuing on to the next layer
424 resolveParent(currentResolvedMetadata);
425 metadata = generatedMetadata instanceof Promise
426 ? await generatedMetadata
427 : generatedMetadata;
428 } else if (metadataExport !== null && typeof metadataExport === "object") {
429 // This metadataExport is the object form
430 metadata = metadataExport;
431 }
432
433 merge({
434 options,
435 target: resolvedMetadata,
436 source: metadata,
437 staticFilesMetadata,
438 titleTemplates,
439 });
440
441 // If the layout is the same layer with page, skip the leaf layout and leaf page
442 // The leaf layout and page are the last two items
443 if (i < metadataItems.length - 2) {
444 titleTemplates = {
445 title: resolvedMetadata.title?.template || null,
446 openGraph: resolvedMetadata.openGraph?.title?.template || null,
447 twitter: resolvedMetadata.twitter?.title?.template || null,
448 };
449 }
450 }
451
452 return resolvedMetadata;
453}
framework/meta/nextjs/resolvers/resolve-basics.ts deleted-259
...@@ -1,259 +0,0 @@
1import type {
2 AlternateLinkDescriptor,
3 ResolvedAlternateURLs,
4} from "../types/alternative-urls-types";
5import type { Metadata, ResolvedMetadata } from "../types/metadata-interface";
6import type { ResolvedVerification } from "../types/metadata-types";
7import type {
8 FieldResolver,
9 FieldResolverWithMetadataBase,
10} from "../types/resolvers";
11import type { Viewport } from "../types/extra-types";
12import path from "path";
13import { resolveAsArrayOrUndefined } from "../generate/utils";
14import { resolveUrl } from "./resolve-url";
15import { ViewPortKeys } from "../constants";
16
17// Resolve with `metadataBase` if it's present, otherwise resolve with `pathname`.
18// Resolve with `pathname` if `url` is a relative path.
19function resolveAlternateUrl(
20 url: string | URL,
21 metadataBase: URL | null,
22 pathname: string,
23) {
24 if (typeof url === "string" && url.startsWith("./")) {
25 url = path.resolve(pathname, url);
26 } else if (url instanceof URL) {
27 url = new URL(pathname, url);
28 }
29
30 const result = metadataBase ? resolveUrl(url, metadataBase) : url;
31 return result.toString();
32}
33
34export const resolveThemeColor: FieldResolver<"themeColor"> = (themeColor) => {
35 if (!themeColor) return null;
36 const themeColorDescriptors: ResolvedMetadata["themeColor"] = [];
37
38 resolveAsArrayOrUndefined(themeColor)?.forEach((descriptor) => {
39 if (typeof descriptor === "string") {
40 themeColorDescriptors.push({ color: descriptor });
41 } else if (typeof descriptor === "object") {
42 themeColorDescriptors.push({
43 color: descriptor.color,
44 media: descriptor.media,
45 });
46 }
47 });
48
49 return themeColorDescriptors;
50};
51
52export const resolveViewport: FieldResolver<"viewport"> = (viewport) => {
53 let resolved: ResolvedMetadata["viewport"] = null;
54
55 if (typeof viewport === "string") {
56 resolved = viewport;
57 } else if (viewport) {
58 resolved = "";
59 for (const viewportKey_ in ViewPortKeys) {
60 const viewportKey = viewportKey_ as keyof Viewport;
61 if (viewportKey in viewport) {
62 let value = viewport[viewportKey];
63 if (typeof value === "boolean") value = value ? "yes" : "no";
64 if (resolved) resolved += ", ";
65 resolved += `${ViewPortKeys[viewportKey]}=${value}`;
66 }
67 }
68 }
69 return resolved;
70};
71
72function resolveUrlValuesOfObject(
73 obj:
74 | Record<string, string | URL | AlternateLinkDescriptor[] | null>
75 | null
76 | undefined,
77 metadataBase: ResolvedMetadata["metadataBase"],
78 pathname: string,
79): null | Record<string, AlternateLinkDescriptor[]> {
80 if (!obj) return null;
81
82 const result: Record<string, AlternateLinkDescriptor[]> = {};
83 for (const [key, value] of Object.entries(obj)) {
84 if (typeof value === "string" || value instanceof URL) {
85 result[key] = [
86 {
87 url: resolveAlternateUrl(value, metadataBase, pathname), // metadataBase ? resolveUrl(value, metadataBase)! : value,
88 },
89 ];
90 } else {
91 result[key] = [];
92 value?.forEach((item, index) => {
93 const url = resolveAlternateUrl(item.url, metadataBase, pathname);
94 result[key][index] = {
95 url,
96 title: item.title,
97 };
98 });
99 }
100 }
101 return result;
102}
103
104function resolveCanonicalUrl(
105 urlOrDescriptor: string | URL | null | AlternateLinkDescriptor | undefined,
106 metadataBase: URL | null,
107 pathname: string,
108): null | AlternateLinkDescriptor {
109 if (!urlOrDescriptor) return null;
110
111 const url =
112 typeof urlOrDescriptor === "string" || urlOrDescriptor instanceof URL
113 ? urlOrDescriptor
114 : urlOrDescriptor.url;
115
116 // Return string url because structureClone can't handle URL instance
117 return {
118 url: resolveAlternateUrl(url, metadataBase, pathname),
119 };
120}
121
122export const resolveAlternates: FieldResolverWithMetadataBase<
123 "alternates",
124 { pathname: string }
125> = (alternates, metadataBase, { pathname }) => {
126 if (!alternates) return null;
127
128 const canonical = resolveCanonicalUrl(
129 alternates.canonical,
130 metadataBase,
131 pathname,
132 );
133 const languages = resolveUrlValuesOfObject(
134 alternates.languages,
135 metadataBase,
136 pathname,
137 );
138 const media = resolveUrlValuesOfObject(
139 alternates.media,
140 metadataBase,
141 pathname,
142 );
143 const types = resolveUrlValuesOfObject(
144 alternates.types,
145 metadataBase,
146 pathname,
147 );
148
149 const result: ResolvedAlternateURLs = {
150 canonical,
151 languages,
152 media,
153 types,
154 };
155
156 return result;
157};
158
159const robotsKeys = [
160 "noarchive",
161 "nosnippet",
162 "noimageindex",
163 "nocache",
164 "notranslate",
165 "indexifembedded",
166 "nositelinkssearchbox",
167 "unavailable_after",
168 "max-video-preview",
169 "max-image-preview",
170 "max-snippet",
171] as const;
172const resolveRobotsValue: (robots: Metadata["robots"]) => string | null = (
173 robots,
174) => {
175 if (!robots) return null;
176 if (typeof robots === "string") return robots;
177
178 const values: string[] = [];
179
180 if (robots.index) values.push("index");
181 else if (typeof robots.index === "boolean") values.push("noindex");
182
183 if (robots.follow) values.push("follow");
184 else if (typeof robots.follow === "boolean") values.push("nofollow");
185
186 for (const key of robotsKeys) {
187 const value = robots[key];
188 if (typeof value !== "undefined" && value !== false) {
189 values.push(typeof value === "boolean" ? key : `${key}:${value}`);
190 }
191 }
192
193 return values.join(", ");
194};
195
196export const resolveRobots: FieldResolver<"robots"> = (robots) => {
197 if (!robots) return null;
198 return {
199 basic: resolveRobotsValue(robots),
200 googleBot: typeof robots !== "string"
201 ? resolveRobotsValue(robots.googleBot)
202 : null,
203 };
204};
205
206const VerificationKeys = ["google", "yahoo", "yandex", "me", "other"] as const;
207export const resolveVerification: FieldResolver<"verification"> = (
208 verification,
209) => {
210 if (!verification) return null;
211 const res: ResolvedVerification = {};
212
213 for (const key of VerificationKeys) {
214 const value = verification[key];
215 if (value) {
216 if (key === "other") {
217 res.other = {};
218 for (const otherKey in verification.other) {
219 const otherValue = resolveAsArrayOrUndefined(
220 verification.other[otherKey],
221 );
222 if (otherValue) res.other[otherKey] = otherValue;
223 }
224 } else res[key] = resolveAsArrayOrUndefined(value) as (string | number)[];
225 }
226 }
227 return res;
228};
229
230export const resolveAppleWebApp: FieldResolver<"appleWebApp"> = (appWebApp) => {
231 if (!appWebApp) return null;
232 if (appWebApp === true) {
233 return {
234 capable: true,
235 };
236 }
237
238 const startupImages = appWebApp.startupImage
239 ? resolveAsArrayOrUndefined(appWebApp.startupImage)?.map((item) =>
240 typeof item === "string" ? { url: item } : item
241 )
242 : null;
243
244 return {
245 capable: "capable" in appWebApp ? !!appWebApp.capable : true,
246 title: appWebApp.title || null,
247 startupImage: startupImages,
248 statusBarStyle: appWebApp.statusBarStyle || "default",
249 };
250};
251
252export const resolveAppLinks: FieldResolver<"appLinks"> = (appLinks) => {
253 if (!appLinks) return null;
254 for (const key in appLinks) {
255 // @ts-ignore // TODO: type infer
256 appLinks[key] = resolveAsArrayOrUndefined(appLinks[key]);
257 }
258 return appLinks as ResolvedMetadata["appLinks"];
259};
framework/meta/nextjs/resolvers/resolve-icons.ts deleted-34
...@@ -1,34 +0,0 @@
1import type { ResolvedMetadata } from "../types/metadata-interface";
2import type { Icon, IconDescriptor } from "../types/metadata-types";
3import type { FieldResolver } from "../types/resolvers";
4import { resolveAsArrayOrUndefined } from "../generate/utils";
5import { isStringOrURL } from "./resolve-url";
6import { IconKeys } from "../constants";
7
8export function resolveIcon(icon: Icon): IconDescriptor {
9 if (isStringOrURL(icon)) return { url: icon };
10 else if (Array.isArray(icon)) return icon;
11 return icon;
12}
13
14export const resolveIcons: FieldResolver<"icons"> = (icons) => {
15 if (!icons) {
16 return null;
17 }
18
19 const resolved: ResolvedMetadata["icons"] = {
20 icon: [],
21 apple: [],
22 };
23 if (Array.isArray(icons)) {
24 resolved.icon = icons.map(resolveIcon).filter(Boolean);
25 } else if (isStringOrURL(icons)) {
26 resolved.icon = [resolveIcon(icons)];
27 } else {
28 for (const key of IconKeys) {
29 const values = resolveAsArrayOrUndefined(icons[key]);
30 if (values) resolved[key] = values.map(resolveIcon);
31 }
32 }
33 return resolved;
34};
framework/meta/nextjs/resolvers/resolve-opengraph.ts deleted-147
...@@ -1,147 +0,0 @@
1import type { Metadata, ResolvedMetadata } from "../types/metadata-interface";
2import type {
3 OpenGraph,
4 OpenGraphType,
5 ResolvedOpenGraph,
6} from "../types/opengraph-types";
7import type { FieldResolverWithMetadataBase } from "../types/resolvers";
8import type { ResolvedTwitterMetadata, Twitter } from "../types/twitter-types";
9import { resolveAsArrayOrUndefined } from "../generate/utils";
10import { isStringOrURL, resolveUrl } from "./resolve-url";
11
12const OgTypeFields = {
13 article: ["authors", "tags"],
14 song: ["albums", "musicians"],
15 playlist: ["albums", "musicians"],
16 radio: ["creators"],
17 video: ["actors", "directors", "writers", "tags"],
18 basic: [
19 "emails",
20 "phoneNumbers",
21 "faxNumbers",
22 "alternateLocale",
23 "audio",
24 "videos",
25 ],
26} as const;
27
28function resolveImages(
29 images: Twitter["images"],
30 metadataBase: ResolvedMetadata["metadataBase"],
31): NonNullable<ResolvedMetadata["twitter"]>["images"];
32function resolveImages(
33 images: OpenGraph["images"],
34 metadataBase: ResolvedMetadata["metadataBase"],
35): NonNullable<ResolvedMetadata["openGraph"]>["images"];
36function resolveImages(
37 images: OpenGraph["images"] | Twitter["images"],
38 metadataBase: ResolvedMetadata["metadataBase"],
39):
40 | NonNullable<ResolvedMetadata["twitter"]>["images"]
41 | NonNullable<ResolvedMetadata["openGraph"]>["images"] {
42 const resolvedImages = resolveAsArrayOrUndefined(images);
43 resolvedImages?.forEach((item, index, array) => {
44 if (isStringOrURL(item)) {
45 array[index] = {
46 url: resolveUrl(item, metadataBase)!,
47 };
48 } else {
49 // Update image descriptor url
50 item.url = resolveUrl(item.url, metadataBase)!;
51 }
52 });
53 return resolvedImages;
54}
55
56function getFieldsByOgType(ogType: OpenGraphType | undefined) {
57 switch (ogType) {
58 case "article":
59 case "book":
60 return OgTypeFields.article;
61 case "music.song":
62 case "music.album":
63 return OgTypeFields.song;
64 case "music.playlist":
65 return OgTypeFields.playlist;
66 case "music.radio_station":
67 return OgTypeFields.radio;
68 case "video.movie":
69 case "video.episode":
70 return OgTypeFields.video;
71 default:
72 return OgTypeFields.basic;
73 }
74}
75
76export const resolveOpenGraph: FieldResolverWithMetadataBase<"openGraph"> = (
77 openGraph: Metadata["openGraph"],
78 metadataBase: ResolvedMetadata["metadataBase"],
79) => {
80 if (!openGraph) return null;
81
82 const url = resolveUrl(openGraph.url, metadataBase);
83 const resolved = { ...openGraph } as ResolvedOpenGraph;
84
85 function assignProps(og: OpenGraph) {
86 const ogType = og && "type" in og ? og.type : undefined;
87 const keys = getFieldsByOgType(ogType);
88 for (const k of keys) {
89 const key = k as keyof ResolvedOpenGraph;
90 if (key in og && key !== "url") {
91 const value = og[key];
92 if (value) {
93 const arrayValue = resolveAsArrayOrUndefined(value); /// TODO: improve typing inferring
94 (resolved as any)[key] = arrayValue;
95 }
96 }
97 }
98
99 resolved.images = resolveImages(og.images, metadataBase);
100 }
101
102 assignProps(openGraph);
103
104 resolved.url = url;
105
106 return resolved;
107};
108
109const TwitterBasicInfoKeys = [
110 "site",
111 "siteId",
112 "creator",
113 "creatorId",
114 "description",
115] as const;
116
117export const resolveTwitter: FieldResolverWithMetadataBase<"twitter"> = (
118 twitter,
119 metadataBase,
120) => {
121 if (!twitter) return null;
122 const resolved = {
123 ...twitter,
124 card: "card" in twitter ? twitter.card : "summary",
125 } as ResolvedTwitterMetadata;
126 for (const infoKey of TwitterBasicInfoKeys) {
127 resolved[infoKey] = twitter[infoKey] || null;
128 }
129 resolved.images = resolveImages(twitter.images, metadataBase);
130
131 if ("card" in resolved) {
132 switch (resolved.card) {
133 case "player": {
134 resolved.players = resolveAsArrayOrUndefined(resolved.players) || [];
135 break;
136 }
137 case "app": {
138 resolved.app = resolved.app || {};
139 break;
140 }
141 default:
142 break;
143 }
144 }
145
146 return resolved;
147};
framework/meta/nextjs/resolvers/resolve-title.ts deleted-39
...@@ -1,39 +0,0 @@
1import type { Metadata } from "../types/metadata-interface";
2import type { AbsoluteTemplateString } from "../types/metadata-types";
3
4function resolveTitleTemplate(
5 template: string | null | undefined,
6 title: string,
7) {
8 return template ? template.replace(/%s/g, title) : title;
9}
10
11export function resolveTitle(
12 title: Metadata["title"],
13 stashedTemplate: string | null | undefined,
14): AbsoluteTemplateString {
15 let resolved;
16 const template = typeof title !== "string" && title && "template" in title
17 ? title.template
18 : null;
19
20 if (typeof title === "string") {
21 resolved = resolveTitleTemplate(stashedTemplate, title);
22 } else if (title) {
23 if ("default" in title) {
24 resolved = resolveTitleTemplate(stashedTemplate, title.default);
25 }
26 if ("absolute" in title && title.absolute) {
27 resolved = title.absolute;
28 }
29 }
30
31 if (title && typeof title !== "string") {
32 return {
33 template,
34 absolute: resolved || "",
35 };
36 } else {
37 return { absolute: resolved || title || "", template };
38 }
39}
framework/meta/nextjs/resolvers/resolve-url.ts deleted-38
...@@ -1,38 +0,0 @@
1import path from "path";
2import process from "node:process";
3
4function isStringOrURL(icon: any): icon is string | URL {
5 return typeof icon === "string" || icon instanceof URL;
6}
7
8function resolveUrl(url: null | undefined, metadataBase: URL | null): null;
9function resolveUrl(url: string | URL, metadataBase: URL | null): URL;
10function resolveUrl(
11 url: string | URL | null | undefined,
12 metadataBase: URL | null,
13): URL | null;
14function resolveUrl(
15 url: string | URL | null | undefined,
16 metadataBase: URL | null,
17): URL | null {
18 if (url instanceof URL) return url;
19 if (!url) return null;
20
21 try {
22 // If we can construct a URL instance from url, ignore metadataBase
23 const parsedUrl = new URL(url);
24 return parsedUrl;
25 } catch (_) {}
26
27 if (!metadataBase) {
28 metadataBase = new URL(`http://localhost:${process.env.PORT || 3000}`);
29 }
30
31 // Handle relative or absolute paths
32 const basePath = metadataBase.pathname || "";
33 const joinedPath = path.join(basePath, url);
34
35 return new URL(joinedPath, metadataBase);
36}
37
38export { isStringOrURL, resolveUrl };
framework/meta/nextjs/types/alternative-urls-types.ts deleted-450
...@@ -1,450 +0,0 @@
1// Reference: https://hreflang.org/what-is-a-valid-hreflang
2
3type LangCode =
4 | "aa"
5 | "ab"
6 | "ae"
7 | "af"
8 | "ak"
9 | "am"
10 | "an"
11 | "ar"
12 | "as"
13 | "av"
14 | "ay"
15 | "az"
16 | "ba"
17 | "be"
18 | "bg"
19 | "bh"
20 | "bi"
21 | "bm"
22 | "bn"
23 | "bo"
24 | "br"
25 | "bs"
26 | "ca"
27 | "ce"
28 | "ch"
29 | "co"
30 | "cr"
31 | "cs"
32 | "cu"
33 | "cv"
34 | "cy"
35 | "da"
36 | "de"
37 | "dv"
38 | "dz"
39 | "ee"
40 | "el"
41 | "en"
42 | "eo"
43 | "es"
44 | "et"
45 | "eu"
46 | "fa"
47 | "ff"
48 | "fi"
49 | "fj"
50 | "fo"
51 | "fr"
52 | "fy"
53 | "ga"
54 | "gd"
55 | "gl"
56 | "gn"
57 | "gu"
58 | "gv"
59 | "ha"
60 | "he"
61 | "hi"
62 | "ho"
63 | "hr"
64 | "ht"
65 | "hu"
66 | "hy"
67 | "hz"
68 | "ia"
69 | "id"
70 | "ie"
71 | "ig"
72 | "ii"
73 | "ik"
74 | "io"
75 | "is"
76 | "it"
77 | "iu"
78 | "ja"
79 | "jv"
80 | "ka"
81 | "kg"
82 | "ki"
83 | "kj"
84 | "kk"
85 | "kl"
86 | "km"
87 | "kn"
88 | "ko"
89 | "kr"
90 | "ks"
91 | "ku"
92 | "kv"
93 | "kw"
94 | "ky"
95 | "la"
96 | "lb"
97 | "lg"
98 | "li"
99 | "ln"
100 | "lo"
101 | "lt"
102 | "lu"
103 | "lv"
104 | "mg"
105 | "mh"
106 | "mi"
107 | "mk"
108 | "ml"
109 | "mn"
110 | "mr"
111 | "ms"
112 | "mt"
113 | "my"
114 | "na"
115 | "nb"
116 | "nd"
117 | "ne"
118 | "ng"
119 | "nl"
120 | "nn"
121 | "no"
122 | "nr"
123 | "nv"
124 | "ny"
125 | "oc"
126 | "oj"
127 | "om"
128 | "or"
129 | "os"
130 | "pa"
131 | "pi"
132 | "pl"
133 | "ps"
134 | "pt"
135 | "qu"
136 | "rm"
137 | "rn"
138 | "ro"
139 | "ru"
140 | "rw"
141 | "sa"
142 | "sc"
143 | "sd"
144 | "se"
145 | "sg"
146 | "si"
147 | "sk"
148 | "sl"
149 | "sm"
150 | "sn"
151 | "so"
152 | "sq"
153 | "sr"
154 | "ss"
155 | "st"
156 | "su"
157 | "sv"
158 | "sw"
159 | "ta"
160 | "te"
161 | "tg"
162 | "th"
163 | "ti"
164 | "tk"
165 | "tl"
166 | "tn"
167 | "to"
168 | "tr"
169 | "ts"
170 | "tt"
171 | "tw"
172 | "ty"
173 | "ug"
174 | "uk"
175 | "ur"
176 | "uz"
177 | "ve"
178 | "vi"
179 | "vo"
180 | "wa"
181 | "wo"
182 | "xh"
183 | "yi"
184 | "yo"
185 | "za"
186 | "zh"
187 | "zu"
188 | "af-ZA"
189 | "am-ET"
190 | "ar-AE"
191 | "ar-BH"
192 | "ar-DZ"
193 | "ar-EG"
194 | "ar-IQ"
195 | "ar-JO"
196 | "ar-KW"
197 | "ar-LB"
198 | "ar-LY"
199 | "ar-MA"
200 | "arn-CL"
201 | "ar-OM"
202 | "ar-QA"
203 | "ar-SA"
204 | "ar-SD"
205 | "ar-SY"
206 | "ar-TN"
207 | "ar-YE"
208 | "as-IN"
209 | "az-az"
210 | "az-Cyrl-AZ"
211 | "az-Latn-AZ"
212 | "ba-RU"
213 | "be-BY"
214 | "bg-BG"
215 | "bn-BD"
216 | "bn-IN"
217 | "bo-CN"
218 | "br-FR"
219 | "bs-Cyrl-BA"
220 | "bs-Latn-BA"
221 | "ca-ES"
222 | "co-FR"
223 | "cs-CZ"
224 | "cy-GB"
225 | "da-DK"
226 | "de-AT"
227 | "de-CH"
228 | "de-DE"
229 | "de-LI"
230 | "de-LU"
231 | "dsb-DE"
232 | "dv-MV"
233 | "el-CY"
234 | "el-GR"
235 | "en-029"
236 | "en-AU"
237 | "en-BZ"
238 | "en-CA"
239 | "en-cb"
240 | "en-GB"
241 | "en-IE"
242 | "en-IN"
243 | "en-JM"
244 | "en-MT"
245 | "en-MY"
246 | "en-NZ"
247 | "en-PH"
248 | "en-SG"
249 | "en-TT"
250 | "en-US"
251 | "en-ZA"
252 | "en-ZW"
253 | "es-AR"
254 | "es-BO"
255 | "es-CL"
256 | "es-CO"
257 | "es-CR"
258 | "es-DO"
259 | "es-EC"
260 | "es-ES"
261 | "es-GT"
262 | "es-HN"
263 | "es-MX"
264 | "es-NI"
265 | "es-PA"
266 | "es-PE"
267 | "es-PR"
268 | "es-PY"
269 | "es-SV"
270 | "es-US"
271 | "es-UY"
272 | "es-VE"
273 | "et-EE"
274 | "eu-ES"
275 | "fa-IR"
276 | "fi-FI"
277 | "fil-PH"
278 | "fo-FO"
279 | "fr-BE"
280 | "fr-CA"
281 | "fr-CH"
282 | "fr-FR"
283 | "fr-LU"
284 | "fr-MC"
285 | "fy-NL"
286 | "ga-IE"
287 | "gd-GB"
288 | "gd-ie"
289 | "gl-ES"
290 | "gsw-FR"
291 | "gu-IN"
292 | "ha-Latn-NG"
293 | "he-IL"
294 | "hi-IN"
295 | "hr-BA"
296 | "hr-HR"
297 | "hsb-DE"
298 | "hu-HU"
299 | "hy-AM"
300 | "id-ID"
301 | "ig-NG"
302 | "ii-CN"
303 | "in-ID"
304 | "is-IS"
305 | "it-CH"
306 | "it-IT"
307 | "iu-Cans-CA"
308 | "iu-Latn-CA"
309 | "iw-IL"
310 | "ja-JP"
311 | "ka-GE"
312 | "kk-KZ"
313 | "kl-GL"
314 | "km-KH"
315 | "kn-IN"
316 | "kok-IN"
317 | "ko-KR"
318 | "ky-KG"
319 | "lb-LU"
320 | "lo-LA"
321 | "lt-LT"
322 | "lv-LV"
323 | "mi-NZ"
324 | "mk-MK"
325 | "ml-IN"
326 | "mn-MN"
327 | "mn-Mong-CN"
328 | "moh-CA"
329 | "mr-IN"
330 | "ms-BN"
331 | "ms-MY"
332 | "mt-MT"
333 | "nb-NO"
334 | "ne-NP"
335 | "nl-BE"
336 | "nl-NL"
337 | "nn-NO"
338 | "no-no"
339 | "nso-ZA"
340 | "oc-FR"
341 | "or-IN"
342 | "pa-IN"
343 | "pl-PL"
344 | "prs-AF"
345 | "ps-AF"
346 | "pt-BR"
347 | "pt-PT"
348 | "qut-GT"
349 | "quz-BO"
350 | "quz-EC"
351 | "quz-PE"
352 | "rm-CH"
353 | "ro-mo"
354 | "ro-RO"
355 | "ru-mo"
356 | "ru-RU"
357 | "rw-RW"
358 | "sah-RU"
359 | "sa-IN"
360 | "se-FI"
361 | "se-NO"
362 | "se-SE"
363 | "si-LK"
364 | "sk-SK"
365 | "sl-SI"
366 | "sma-NO"
367 | "sma-SE"
368 | "smj-NO"
369 | "smj-SE"
370 | "smn-FI"
371 | "sms-FI"
372 | "sq-AL"
373 | "sr-BA"
374 | "sr-CS"
375 | "sr-Cyrl-BA"
376 | "sr-Cyrl-CS"
377 | "sr-Cyrl-ME"
378 | "sr-Cyrl-RS"
379 | "sr-Latn-BA"
380 | "sr-Latn-CS"
381 | "sr-Latn-ME"
382 | "sr-Latn-RS"
383 | "sr-ME"
384 | "sr-RS"
385 | "sr-sp"
386 | "sv-FI"
387 | "sv-SE"
388 | "sw-KE"
389 | "syr-SY"
390 | "ta-IN"
391 | "te-IN"
392 | "tg-Cyrl-TJ"
393 | "th-TH"
394 | "tk-TM"
395 | "tlh-QS"
396 | "tn-ZA"
397 | "tr-TR"
398 | "tt-RU"
399 | "tzm-Latn-DZ"
400 | "ug-CN"
401 | "uk-UA"
402 | "ur-PK"
403 | "uz-Cyrl-UZ"
404 | "uz-Latn-UZ"
405 | "uz-uz"
406 | "vi-VN"
407 | "wo-SN"
408 | "xh-ZA"
409 | "yo-NG"
410 | "zh-CN"
411 | "zh-HK"
412 | "zh-MO"
413 | "zh-SG"
414 | "zh-TW"
415 | "zu-ZA";
416
417type UnmatchedLang = "x-default";
418
419type HrefLang = LangCode | UnmatchedLang;
420
421type Languages<T> = {
422 [s in HrefLang]?: T;
423};
424
425export type AlternateLinkDescriptor = {
426 title?: string;
427 url: string | URL;
428};
429
430export type AlternateURLs = {
431 canonical?: null | string | URL | AlternateLinkDescriptor;
432 languages?: Languages<null | string | URL | AlternateLinkDescriptor[]>;
433 media?: {
434 [media: string]: null | string | URL | AlternateLinkDescriptor[];
435 };
436 types?: {
437 [types: string]: null | string | URL | AlternateLinkDescriptor[];
438 };
439};
440
441export type ResolvedAlternateURLs = {
442 canonical: null | AlternateLinkDescriptor;
443 languages: null | Languages<AlternateLinkDescriptor[]>;
444 media: null | {
445 [media: string]: null | AlternateLinkDescriptor[];
446 };
447 types: null | {
448 [types: string]: null | AlternateLinkDescriptor[];
449 };
450};
framework/meta/nextjs/types/extra-types.ts deleted-104
...@@ -1,104 +0,0 @@
1// When rendering applink meta tags add a namespace tag before each array instance
2// if more than one member exists.
3// ref: https://developers.facebook.com/docs/applinks/metadata-reference
4
5export type AppLinks = {
6 ios?: AppLinksApple | Array<AppLinksApple>;
7 iphone?: AppLinksApple | Array<AppLinksApple>;
8 ipad?: AppLinksApple | Array<AppLinksApple>;
9 android?: AppLinksAndroid | Array<AppLinksAndroid>;
10 windows_phone?: AppLinksWindows | Array<AppLinksWindows>;
11 windows?: AppLinksWindows | Array<AppLinksWindows>;
12 windows_universal?: AppLinksWindows | Array<AppLinksWindows>;
13 web?: AppLinksWeb | Array<AppLinksWeb>;
14};
15export type ResolvedAppLinks = {
16 ios?: Array<AppLinksApple>;
17 iphone?: Array<AppLinksApple>;
18 ipad?: Array<AppLinksApple>;
19 android?: Array<AppLinksAndroid>;
20 windows_phone?: Array<AppLinksWindows>;
21 windows?: Array<AppLinksWindows>;
22 windows_universal?: Array<AppLinksWindows>;
23 web?: Array<AppLinksWeb>;
24};
25export type AppLinksApple = {
26 url: string | URL;
27 app_store_id?: string | number;
28 app_name?: string;
29};
30export type AppLinksAndroid = {
31 package: string;
32 url?: string | URL;
33 class?: string;
34 app_name?: string;
35};
36export type AppLinksWindows = {
37 url: string | URL;
38 app_id?: string;
39 app_name?: string;
40};
41export type AppLinksWeb = {
42 url: string | URL;
43 should_fallback?: boolean;
44};
45
46// Apple Itunes APp
47// https://developer.apple.com/documentation/webkit/promoting_apps_with_smart_app_banners
48export type ItunesApp = {
49 appId: string;
50 appArgument?: string;
51};
52
53// Viewport meta structure
54// https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag
55// intentionally leaving out user-scalable, use a string if you want that behavior
56export type Viewport = {
57 width?: string | number;
58 height?: string | number;
59 initialScale?: number;
60 minimumScale?: number;
61 maximumScale?: number;
62 userScalable?: boolean;
63 viewportFit?: "auto" | "cover" | "contain";
64 interactiveWidget?: "resizes-visual" | "resizes-content" | "overlays-content";
65};
66
67// Apple Web App
68// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html
69// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html
70export type AppleWebApp = {
71 // default true
72 capable?: boolean;
73 title?: string;
74 startupImage?: AppleImage | Array<AppleImage>;
75 // default "default"
76 statusBarStyle?: "default" | "black" | "black-translucent";
77};
78export type AppleImage = string | AppleImageDescriptor;
79export type AppleImageDescriptor = {
80 url: string;
81 media?: string;
82};
83
84export type ResolvedAppleWebApp = {
85 capable: boolean;
86 title?: string | null;
87 startupImage?: AppleImageDescriptor[] | null;
88 statusBarStyle?: "default" | "black" | "black-translucent";
89};
90
91// Format Detection
92// This is a poorly specified metadata export type that is supposed to
93// control whether the device attempts to conver text that matches
94// certain formats into links for action. The most supported example
95// is how mobile devices detect phone numbers and make them into links
96// that can initiate a phone call
97// https://www.goodemailcode.com/email-code/template.html
98export type FormatDetection = {
99 telephone?: boolean;
100 date?: boolean;
101 address?: boolean;
102 email?: boolean;
103 url?: boolean;
104};
framework/meta/nextjs/types/manifest-types.ts deleted-86
...@@ -1,86 +0,0 @@
1export type Manifest = {
2 background_color?: string;
3 categories?: string[];
4 description?: string;
5 display?: "fullscreen" | "standalone" | "minimal-ui" | "browser";
6 display_override?: string[];
7 icons?: {
8 src: string;
9 type?: string;
10 sizes?: string;
11 purpose?: "any" | "maskable" | "monochrome" | "badge";
12 }[];
13 id?: string;
14 launch_handler?: {
15 platform?: "windows" | "macos" | "linux";
16 url?: string;
17 };
18 name?: string;
19 orientation?:
20 | "any"
21 | "natural"
22 | "landscape"
23 | "portrait"
24 | "portrait-primary"
25 | "portrait-secondary"
26 | "landscape-primary"
27 | "landscape-secondary";
28 prefer_related_applications?: boolean;
29 protocol_handlers?: {
30 protocol: string;
31 url: string;
32 title?: string;
33 }[];
34 related_applications?: {
35 platform: string;
36 url: string;
37 id?: string;
38 }[];
39 scope?: string;
40 screenshots?: {
41 src: string;
42 type?: string;
43 sizes?: string;
44 }[];
45 serviceworker?: {
46 src?: string;
47 scope?: string;
48 type?: string;
49 update_via_cache?: "import" | "none" | "all";
50 };
51 share_target?: {
52 action?: string;
53 method?: "get" | "post";
54 enctype?:
55 | "application/x-www-form-urlencoded"
56 | "multipart/form-data"
57 | "text/plain";
58 params?: {
59 name: string;
60 value: string;
61 required?: boolean;
62 }[];
63 url?: string;
64 title?: string;
65 text?: string;
66 files?: {
67 accept?: string[];
68 name?: string;
69 }[];
70 };
71 short_name?: string;
72 shortcuts?: {
73 name: string;
74 short_name?: string;
75 description?: string;
76 url: string;
77 icons?: {
78 src: string;
79 type?: string;
80 sizes?: string;
81 purpose?: "any" | "maskable" | "monochrome" | "badge";
82 }[];
83 }[];
84 start_url?: string;
85 theme_color?: string;
86};
framework/meta/nextjs/types/metadata-interface.ts deleted-566
...@@ -1,566 +0,0 @@
1import type {
2 AlternateURLs,
3 ResolvedAlternateURLs,
4} from "./alternative-urls-types";
5import type {
6 AppleWebApp,
7 AppLinks,
8 FormatDetection,
9 ItunesApp,
10 ResolvedAppleWebApp,
11 ResolvedAppLinks,
12 Viewport,
13} from "./extra-types";
14import type {
15 AbsoluteTemplateString,
16 Author,
17 ColorSchemeEnum,
18 DeprecatedMetadataFields,
19 Icon,
20 Icons,
21 IconURL,
22 ReferrerEnum,
23 ResolvedIcons,
24 ResolvedRobots,
25 ResolvedVerification,
26 Robots,
27 TemplateString,
28 ThemeColorDescriptor,
29 Verification,
30} from "./metadata-types";
31import type { Manifest as ManifestFile } from "./manifest-types";
32import type { OpenGraph, ResolvedOpenGraph } from "./opengraph-types";
33import type { ResolvedTwitterMetadata, Twitter } from "./twitter-types";
34
35/**
36 * Metadata interface to describe all the metadata fields that can be set in a document.
37 * @interface
38 */
39interface Metadata extends DeprecatedMetadataFields {
40 /**
41 * The base path and origin for absolute urls for various metadata links such as OpenGraph images.
42 */
43 metadataBase?: null | URL;
44
45 /**
46 * The document title.
47 * @example
48 * ```tsx
49 * "My Blog"
50 * <title>My Blog</title>
51 *
52 * { default: "Dashboard", template: "%s | My Website" }
53 * <title>Dashboard | My Website</title>
54 *
55 * { absolute: "My Blog", template: "%s | My Website" }
56 * <title>My Blog</title>
57 * ```
58 */
59 title?: null | string | TemplateString;
60
61 /**
62 * The document description, and optionally the OpenGraph and twitter descriptions.
63 * @example
64 * ```tsx
65 * "My Blog Description"
66 * <meta name="description" content="My Blog Description" />
67 * ```
68 */
69 description?: null | string;
70
71 // Standard metadata names
72 // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name
73
74 /**
75 * The application name.
76 * @example
77 * ```tsx
78 * "My Blog"
79 * <meta name="application-name" content="My Blog" />
80 * ```
81 */
82 applicationName?: null | string;
83
84 /**
85 * The authors of the document.
86 * @example
87 * ```tsx
88 * [{ name: "Next.js Team", url: "https://nextjs.org" }]
89 *
90 * <meta name="author" content="Next.js Team" />
91 * <link rel="author" href="https://nextjs.org" />
92 * ```
93 */
94 authors?: null | Author | Array<Author>;
95
96 /**
97 * The generator used for the document.
98 * @example
99 * ```tsx
100 * "Next.js"
101 *
102 * <meta name="generator" content="Next.js" />
103 * ```
104 */
105 generator?: null | string;
106
107 /**
108 * The keywords for the document. If an array is provided, it will be flattened into a single tag with comma separation.
109 * @example
110 * ```tsx
111 * "nextjs, react, blog"
112 * <meta name="keywords" content="nextjs, react, blog" />
113 *
114 * ["react", "server components"]
115 * <meta name="keywords" content="react, server components" />
116 * ```
117 */
118 keywords?: null | string | Array<string>;
119
120 /**
121 * The referrer setting for the document.
122 * @example
123 * ```tsx
124 * "origin"
125 * <meta name="referrer" content="origin" />
126 * ```
127 */
128 referrer?: null | ReferrerEnum;
129
130 /**
131 * The theme color for the document.
132 * @example
133 * ```tsx
134 * "#000000"
135 * <meta name="theme-color" content="#000000" />
136 *
137 * { media: "(prefers-color-scheme: dark)", color: "#000000" }
138 * <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
139 *
140 * [
141 * { media: "(prefers-color-scheme: dark)", color: "#000000" },
142 * { media: "(prefers-color-scheme: light)", color: "#ffffff" }
143 * ]
144 * <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
145 * <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
146 * ```
147 */
148 themeColor?: null | string | ThemeColorDescriptor | ThemeColorDescriptor[];
149
150 /**
151 * The color scheme for the document.
152 * @example
153 * ```tsx
154 * "dark"
155 * <meta name="color-scheme" content="dark" />
156 * ```
157 */
158 colorScheme?: null | ColorSchemeEnum;
159
160 /**
161 * The viewport setting for the document.
162 * @example
163 * ```tsx
164 * "width=device-width, initial-scale=1"
165 * <meta name="viewport" content="width=device-width, initial-scale=1" />
166 *
167 * { width: "device-width", initialScale: 1 }
168 * <meta name="viewport" content="width=device-width, initial-scale=1" />
169 * ```
170 */
171 viewport?: null | string | Viewport;
172
173 /**
174 * The creator of the document.
175 * @example
176 * ```tsx
177 * "Next.js Team"
178 * <meta name="creator" content="Next.js Team" />
179 * ```
180 */
181 creator?: null | string;
182
183 /**
184 * The publisher of the document.
185 * @example
186 *
187 * ```tsx
188 * "Vercel"
189 * <meta name="publisher" content="Vercel" />
190 * ```
191 */
192 publisher?: null | string;
193
194 // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#other_metadata_names
195
196 /**
197 * The robots setting for the document.
198 *
199 * @see https://developer.mozilla.org/en-US/docs/Glossary/Robots.txt
200 * @example
201 * ```tsx
202 * "index, follow"
203 * <meta name="robots" content="index, follow" />
204 *
205 * { index: false, follow: false }
206 * <meta name="robots" content="noindex, nofollow" />
207 * ```
208 */
209 robots?: null | string | Robots;
210
211 /**
212 * The canonical and alternate URLs for the document.
213 * @example
214 * ```tsx
215 * { canonical: "https://example.com" }
216 * <link rel="canonical" href="https://example.com" />
217 *
218 * { canonical: "https://example.com", hreflang: { "en-US": "https://example.com/en-US" } }
219 * <link rel="canonical" href="https://example.com" />
220 * <link rel="alternate" href="https://example.com/en-US" hreflang="en-US" />
221 * ```
222 *
223 * Multiple titles example for alternate URLs except `canonical`:
224 * ```tsx
225 * {
226 * canonical: "https://example.com",
227 * types: {
228 * 'application/rss+xml': [
229 * { url: 'blog.rss', title: 'rss' },
230 * { url: 'blog/js.rss', title: 'js title' },
231 * ],
232 * },
233 * }
234 * <link rel="canonical" href="https://example.com" />
235 * <link rel="alternate" href="https://example.com/blog.rss" type="application/rss+xml" title="rss" />
236 * <link rel="alternate" href="https://example.com/blog/js.rss" type="application/rss+xml" title="js title" />
237 * ```
238 */
239 alternates?: null | AlternateURLs;
240
241 /**
242 * The icons for the document. Defaults to rel="icon".
243 *
244 * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel#attr-icon
245 * @example
246 * ```tsx
247 * "https://example.com/icon.png"
248 * <link rel="icon" href="https://example.com/icon.png" />
249 *
250 * { icon: "https://example.com/icon.png", apple: "https://example.com/apple-icon.png" }
251 * <link rel="icon" href="https://example.com/icon.png" />
252 * <link rel="apple-touch-icon" href="https://example.com/apple-icon.png" />
253 *
254 * [{ rel: "icon", url: "https://example.com/icon.png" }, { rel: "apple-touch-icon", url: "https://example.com/apple-icon.png" }]
255 * <link rel="icon" href="https://example.com/icon.png" />
256 * <link rel="apple-touch-icon" href="https://example.com/apple-icon.png" />
257 * ```
258 */
259 icons?: null | IconURL | Array<Icon> | Icons;
260
261 /**
262 * A web application manifest, as defined in the Web Application Manifest specification.
263 *
264 * @see https://developer.mozilla.org/en-US/docs/Web/Manifest
265 * @example
266 * ```tsx
267 * "https://example.com/manifest.json"
268 * <link rel="manifest" href="https://example.com/manifest.json" />
269 * ```
270 */
271 manifest?: null | string | URL;
272
273 /**
274 * The Open Graph metadata for the document.
275 *
276 * @see https://ogp.me
277 * @example
278 * ```tsx
279 * {
280 * type: "website",
281 * url: "https://example.com",
282 * title: "My Website",
283 * description: "My Website Description",
284 * siteName: "My Website",
285 * images: [{
286 * url: "https://example.com/og.png",
287 * }],
288 * }
289 *
290 * <meta property="og:type" content="website" />
291 * <meta property="og:url" content="https://example.com" />
292 * <meta property="og:site_name" content="My Website" />
293 * <meta property="og:title" content="My Website" />
294 * <meta property="og:description" content="My Website Description" />
295 * <meta property="og:image" content="https://example.com/og.png" />
296 * ```
297 */
298 openGraph?: null | OpenGraph;
299
300 /**
301 * The Twitter metadata for the document.
302 * @example
303 * ```tsx
304 * { card: "summary_large_image", site: "@site", creator: "@creator", "images": "https://example.com/og.png" }
305 *
306 * <meta name="twitter:card" content="summary_large_image" />
307 * <meta name="twitter:site" content="@site" />
308 * <meta name="twitter:creator" content="@creator" />
309 * <meta name="twitter:title" content="My Website" />
310 * <meta name="twitter:description" content="My Website Description" />
311 * <meta name="twitter:image" content="https://example.com/og.png" />
312 * ```
313 */
314 twitter?: null | Twitter;
315
316 /**
317 * The common verification tokens for the document.
318 * @example
319 * ```tsx
320 * { verification: { google: "1234567890", yandex: "1234567890", "me": "1234567890" } }
321 * <meta name="google-site-verification" content="1234567890" />
322 * <meta name="yandex-verification" content="1234567890" />
323 * <meta name="me" content="@me" />
324 * ```
325 */
326 verification?: Verification;
327
328 /**
329 * The Apple web app metadata for the document.
330 *
331 * @see https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html
332 * @example
333 * ```tsx
334 * { capable: true, title: "My Website", statusBarStyle: "black-translucent" }
335 * <meta name="apple-mobile-web-app-capable" content="yes" />
336 * <meta name="apple-mobile-web-app-title" content="My Website" />
337 * <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
338 * ```
339 */
340 appleWebApp?: null | boolean | AppleWebApp;
341
342 /**
343 * Indicates if devices should try to interpret various formats and make actionable links out of them. For example it controles
344 * if telephone numbers on mobile that can be clicked to dial or not.
345 * @example
346 * ```tsx
347 * { telephone: false }
348 * <meta name="format-detection" content="telephone=no" />
349 * ```
350 */
351 formatDetection?: null | FormatDetection;
352
353 /**
354 * The metadata for the iTunes App.
355 * It adds the `name="apple-itunes-app"` meta tag.
356 *
357 * @example
358 * ```tsx
359 * { app: { id: "123456789", affiliateData: "123456789", appArguments: "123456789" } }
360 * <meta name="apple-itunes-app" content="app-id=123456789, affiliate-data=123456789, app-arguments=123456789" />
361 * ```
362 */
363 itunes?: null | ItunesApp;
364
365 /**
366 * A brief description of what this web-page is about. Not recommended, superseded by description.
367 * It adds the `name="abstract"` meta tag.
368 *
369 * @see https://www.metatags.org/all-meta-tags-overview/meta-name-abstract/
370 * @example
371 * ```tsx
372 * "My Website Description"
373 * <meta name="abstract" content="My Website Description" />
374 * ```
375 */
376 abstract?: null | string;
377
378 /**
379 * The Facebook AppLinks metadata for the document.
380 * @example
381 * ```tsx
382 * { ios: { appStoreId: "123456789", url: "https://example.com" }, android: { packageName: "com.example", url: "https://example.com" } }
383 *
384 * <meta property="al:ios:app_store_id" content="123456789" />
385 * <meta property="al:ios:url" content="https://example.com" />
386 * <meta property="al:android:package" content="com.example" />
387 * <meta property="al:android:url" content="https://example.com" />
388 * ```
389 */
390 appLinks?: null | AppLinks;
391
392 /**
393 * The archives link rel property.
394 * @example
395 * ```tsx
396 * { archives: "https://example.com/archives" }
397 * <link rel="archives" href="https://example.com/archives" />
398 * ```
399 */
400 archives?: null | string | Array<string>;
401
402 /**
403 * The assets link rel property.
404 * @example
405 * ```tsx
406 * "https://example.com/assets"
407 * <link rel="assets" href="https://example.com/assets" />
408 * ```
409 */
410 assets?: null | string | Array<string>;
411
412 /**
413 * The bookmarks link rel property.
414 * @example
415 * ```tsx
416 * "https://example.com/bookmarks"
417 * <link rel="bookmarks" href="https://example.com/bookmarks" />
418 * ```
419 */
420 bookmarks?: null | string | Array<string>; // This is technically against HTML spec but is used in wild
421
422 // meta name properties
423
424 /**
425 * The category meta name property.
426 * @example
427 * ```tsx
428 * "My Category"
429 * <meta name="category" content="My Category" />
430 * ```
431 */
432 category?: null | string;
433
434 /**
435 * The classification meta name property.
436 * @example
437 * ```tsx
438 * "My Classification"
439 * <meta name="classification" content="My Classification" />
440 * ```
441 */
442 classification?: null | string;
443
444 /**
445 * Arbitrary name/value pairs for the document.
446 */
447 other?: {
448 [name: string]: string | number | Array<string | number>;
449 } & DeprecatedMetadataFields;
450}
451
452interface ResolvedMetadata extends DeprecatedMetadataFields {
453 // origin and base path for absolute urls for various metadata links such as
454 // opengraph-image
455 metadataBase: null | URL;
456
457 // The Document title and template if defined
458 title: null | AbsoluteTemplateString;
459
460 // The Document description, and optionally the opengraph and twitter descriptions
461 description: null | string;
462
463 // Standard metadata names
464 // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name
465 applicationName: null | string;
466 authors: null | Array<Author>;
467 generator: null | string;
468 // if you provide an array it will be flattened into a single tag with comma separation
469 keywords: null | Array<string>;
470 referrer: null | ReferrerEnum;
471 themeColor: null | ThemeColorDescriptor[];
472 colorScheme: null | ColorSchemeEnum;
473 viewport: null | string;
474 creator: null | string;
475 publisher: null | string;
476
477 // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#other_metadata_names
478 robots: null | ResolvedRobots;
479
480 // The canonical and alternate URLs for this location
481 alternates: null | ResolvedAlternateURLs;
482
483 // Defaults to rel="icon" but the Icons type can be used
484 // to get more specific about rel types
485 icons: null | ResolvedIcons;
486
487 openGraph: null | ResolvedOpenGraph;
488
489 manifest: null | string | URL;
490
491 twitter: null | ResolvedTwitterMetadata;
492
493 // common verification tokens
494 verification: null | ResolvedVerification;
495
496 // Apple web app metadata
497 // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html
498 appleWebApp: null | ResolvedAppleWebApp;
499
500 // Should devices try to interpret various formats and make actionable links
501 // out of them? The canonical example is telephone numbers on mobile that can
502 // be clicked to dial
503 formatDetection: null | FormatDetection;
504
505 // meta name="apple-itunes-app"
506 itunes: null | ItunesApp;
507
508 // meta name="abstract"
509 // A brief description of what this web-page is about.
510 // Not recommended, superceded by description.
511 // https://www.metatags.org/all-meta-tags-overview/meta-name-abstract/
512 abstract: null | string;
513
514 // Facebook AppLinks
515 appLinks: null | ResolvedAppLinks;
516
517 // link rel properties
518 archives: null | Array<string>;
519 assets: null | Array<string>;
520 bookmarks: null | Array<string>; // This is technically against HTML spec but is used in wild
521
522 // meta name properties
523 category: null | string;
524 classification: null | string;
525
526 // Arbitrary name/value pairs
527 other:
528 | null
529 | ({
530 [name: string]: string | number | Array<string | number>;
531 } & DeprecatedMetadataFields);
532}
533
534type RobotsFile = {
535 // Apply rules for all
536 rules:
537 | {
538 userAgent?: string | string[];
539 allow?: string | string[];
540 disallow?: string | string[];
541 crawlDelay?: number;
542 }
543 // Apply rules for specific user agents
544 | Array<{
545 userAgent: string | string[];
546 allow?: string | string[];
547 disallow?: string | string[];
548 crawlDelay?: number;
549 }>;
550 sitemap?: string | string[];
551 host?: string;
552};
553
554type SitemapFile = Array<{
555 url: string;
556 lastModified?: string | Date;
557}>;
558
559type ResolvingMetadata = Promise<ResolvedMetadata>;
560declare namespace MetadataRoute {
561 export type Robots = RobotsFile;
562 export type Sitemap = SitemapFile;
563 export type Manifest = ManifestFile;
564}
565
566export { Metadata, MetadataRoute, ResolvedMetadata, ResolvingMetadata };
framework/meta/nextjs/types/metadata-types.ts deleted-155
...@@ -1,155 +0,0 @@
1/**
2 * Metadata types
3 */
4
5export interface DeprecatedMetadataFields {
6 /**
7 * Deprecated options that have a preferred method
8 * @deprecated Use appWebApp to configure apple-mobile-web-app-capable which provides
9 * @see https://www.appsloveworld.com/coding/iphone/11/difference-between-apple-mobile-web-app-capable-and-apple-touch-fullscreen-ipho
10 */
11 "apple-touch-fullscreen"?: never;
12
13 /**
14 * Obsolete since iOS 7.
15 * @see https://web.dev/apple-touch-icon/
16 * @deprecated use icons.apple or instead
17 */
18 "apple-touch-icon-precomposed"?: never;
19}
20
21export type TemplateString =
22 | DefaultTemplateString
23 | AbsoluteTemplateString
24 | AbsoluteString;
25export type DefaultTemplateString = {
26 default: string;
27 template: string;
28};
29export type AbsoluteTemplateString = {
30 absolute: string;
31 template: string | null;
32};
33export type AbsoluteString = {
34 absolute: string;
35};
36
37export type Author = {
38 // renders as <link rel="author"...
39 url?: string | URL;
40 // renders as <meta name="author"...
41 name?: string;
42};
43
44// does not include "unsafe-URL". to use this users should
45// use '"unsafe-URL" as ReferrerEnum'
46export type ReferrerEnum =
47 | "no-referrer"
48 | "origin"
49 | "no-referrer-when-downgrade"
50 | "origin-when-cross-origin"
51 | "same-origin"
52 | "strict-origin"
53 | "strict-origin-when-cross-origin";
54
55export type ColorSchemeEnum =
56 | "normal"
57 | "light"
58 | "dark"
59 | "light dark"
60 | "dark light"
61 | "only light";
62
63type RobotsInfo = {
64 // all and none will be inferred from index/follow boolean options
65 index?: boolean;
66 follow?: boolean;
67
68 /** @deprecated set index to false instead */
69 noindex?: never;
70 /** @deprecated set follow to false instead */
71 nofollow?: never;
72
73 noarchive?: boolean;
74 nosnippet?: boolean;
75 noimageindex?: boolean;
76 nocache?: boolean;
77 notranslate?: boolean;
78 indexifembedded?: boolean;
79 nositelinkssearchbox?: boolean;
80 unavailable_after?: string;
81 "max-video-preview"?: number | string;
82 "max-image-preview"?: "none" | "standard" | "large";
83 "max-snippet"?: number;
84};
85export type Robots = RobotsInfo & {
86 // if you want to specify an alternate robots just for google
87 googleBot?: string | RobotsInfo;
88};
89
90export type ResolvedRobots = {
91 basic: string | null;
92 googleBot: string | null;
93};
94
95export type IconURL = string | URL;
96export type Icon = IconURL | IconDescriptor;
97export type IconDescriptor = {
98 url: string | URL;
99 type?: string;
100 sizes?: string;
101 /** defaults to rel="icon" unless superseded by Icons map */
102 rel?: string;
103 media?: string;
104 /**
105 * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/fetchPriority
106 */
107 fetchPriority?: "high" | "low" | "auto";
108};
109
110export type Icons = {
111 /** rel="icon" */
112 icon?: Icon | Icon[];
113 /** rel="shortcut icon" */
114 shortcut?: Icon | Icon[];
115 /**
116 * @see https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html
117 * rel="apple-touch-icon"
118 */
119 apple?: Icon | Icon[];
120 /** rel inferred from descriptor, defaults to "icon" */
121 other?: IconDescriptor | IconDescriptor[];
122};
123
124export type Verification = {
125 google?: null | string | number | (string | number)[];
126 yahoo?: null | string | number | (string | number)[];
127 yandex?: null | string | number | (string | number)[];
128 me?: null | string | number | (string | number)[];
129 // if you ad-hoc additional verification
130 other?: {
131 [name: string]: string | number | (string | number)[];
132 };
133};
134
135export type ResolvedVerification = {
136 google?: null | (string | number)[];
137 yahoo?: null | (string | number)[];
138 yandex?: null | (string | number)[];
139 me?: null | (string | number)[];
140 other?: {
141 [name: string]: (string | number)[];
142 };
143};
144
145export type ResolvedIcons = {
146 icon: IconDescriptor[];
147 apple: IconDescriptor[];
148 shortcut?: IconDescriptor[];
149 other?: IconDescriptor[];
150};
151
152export type ThemeColorDescriptor = {
153 color: string;
154 media?: string;
155};
framework/meta/nextjs/types/opengraph-types.ts deleted-267
...@@ -1,267 +0,0 @@
1import type { AbsoluteTemplateString, TemplateString } from "./metadata-types";
2
3export type OpenGraphType =
4 | "article"
5 | "book"
6 | "music.song"
7 | "music.album"
8 | "music.playlist"
9 | "music.radio_station"
10 | "profile"
11 | "website"
12 | "video.tv_show"
13 | "video.other"
14 | "video.movie"
15 | "video.episode";
16
17export type OpenGraph =
18 | OpenGraphWebsite
19 | OpenGraphArticle
20 | OpenGraphBook
21 | OpenGraphProfile
22 | OpenGraphMusicSong
23 | OpenGraphMusicAlbum
24 | OpenGraphMusicPlaylist
25 | OpenGraphRadioStation
26 | OpenGraphVideoMovie
27 | OpenGraphVideoEpisode
28 | OpenGraphVideoTVShow
29 | OpenGraphVideoOther
30 | OpenGraphMetadata;
31
32// update this type to reflect actual locales
33type Locale = string;
34
35type OpenGraphMetadata = {
36 determiner?: "a" | "an" | "the" | "auto" | "";
37 title?: string | TemplateString;
38 description?: string;
39 emails?: string | Array<string>;
40 phoneNumbers?: string | Array<string>;
41 faxNumbers?: string | Array<string>;
42 siteName?: string;
43 locale?: Locale;
44 alternateLocale?: Locale | Array<Locale>;
45 images?: OGImage | Array<OGImage>;
46 audio?: OGAudio | Array<OGAudio>;
47 videos?: OGVideo | Array<OGVideo>;
48 url?: string | URL;
49 countryName?: string;
50 ttl?: number;
51};
52type OpenGraphWebsite = OpenGraphMetadata & {
53 type: "website";
54};
55type OpenGraphArticle = OpenGraphMetadata & {
56 type: "article";
57 publishedTime?: string; // datetime
58 modifiedTime?: string; // datetime
59 expirationTime?: string; // datetime
60 authors?: null | string | URL | Array<string | URL>;
61 section?: null | string;
62 tags?: null | string | Array<string>;
63};
64type OpenGraphBook = OpenGraphMetadata & {
65 type: "book";
66 isbn?: null | string;
67 releaseDate?: null | string; // datetime
68 authors?: null | string | URL | Array<string | URL>;
69 tags?: null | string | Array<string>;
70};
71type OpenGraphProfile = OpenGraphMetadata & {
72 type: "profile";
73 firstName?: null | string;
74 lastName?: null | string;
75 username?: null | string;
76 gender?: null | string;
77};
78type OpenGraphMusicSong = OpenGraphMetadata & {
79 type: "music.song";
80 duration?: null | number;
81 albums?: null | string | URL | OGAlbum | Array<string | URL | OGAlbum>;
82 musicians?: null | string | URL | Array<string | URL>;
83};
84type OpenGraphMusicAlbum = OpenGraphMetadata & {
85 type: "music.album";
86 songs?: null | string | URL | OGSong | Array<string | URL | OGSong>;
87 musicians?: null | string | URL | Array<string | URL>;
88 releaseDate?: null | string; // datetime
89};
90type OpenGraphMusicPlaylist = OpenGraphMetadata & {
91 type: "music.playlist";
92 songs?: null | string | URL | OGSong | Array<string | URL | OGSong>;
93 creators?: null | string | URL | Array<string | URL>;
94};
95type OpenGraphRadioStation = OpenGraphMetadata & {
96 type: "music.radio_station";
97 creators?: null | string | URL | Array<string | URL>;
98};
99type OpenGraphVideoMovie = OpenGraphMetadata & {
100 type: "video.movie";
101 actors?: null | string | URL | OGActor | Array<string | URL | OGActor>;
102 directors?: null | string | URL | Array<string | URL>;
103 writers?: null | string | URL | Array<string | URL>;
104 duration?: null | number;
105 releaseDate?: null | string; // datetime
106 tags?: null | string | Array<string>;
107};
108type OpenGraphVideoEpisode = OpenGraphMetadata & {
109 type: "video.episode";
110 actors?: null | string | URL | OGActor | Array<string | URL | OGActor>;
111 directors?: null | string | URL | Array<string | URL>;
112 writers?: null | string | URL | Array<string | URL>;
113 duration?: null | number;
114 releaseDate?: null | string; // datetime
115 tags?: null | string | Array<string>;
116 series?: null | string | URL;
117};
118type OpenGraphVideoTVShow = OpenGraphMetadata & {
119 type: "video.tv_show";
120};
121type OpenGraphVideoOther = OpenGraphMetadata & {
122 type: "video.other";
123};
124
125type OGImage = string | OGImageDescriptor | URL;
126type OGImageDescriptor = {
127 url: string | URL;
128 secureUrl?: string | URL;
129 alt?: string;
130 type?: string;
131 width?: string | number;
132 height?: string | number;
133};
134type OGAudio = string | OGAudioDescriptor | URL;
135type OGAudioDescriptor = {
136 url: string | URL;
137 secureUrl?: string | URL;
138 type?: string;
139};
140type OGVideo = string | OGVideoDescriptor | URL;
141type OGVideoDescriptor = {
142 url: string | URL;
143 secureUrl?: string | URL;
144 type?: string;
145 width?: string | number;
146 height?: string | number;
147};
148
149export type ResolvedOpenGraph =
150 | ResolvedOpenGraphWebsite
151 | ResolvedOpenGraphArticle
152 | ResolvedOpenGraphBook
153 | ResolvedOpenGraphProfile
154 | ResolvedOpenGraphMusicSong
155 | ResolvedOpenGraphMusicAlbum
156 | ResolvedOpenGraphMusicPlaylist
157 | ResolvedOpenGraphRadioStation
158 | ResolvedOpenGraphVideoMovie
159 | ResolvedOpenGraphVideoEpisode
160 | ResolvedOpenGraphVideoTVShow
161 | ResolvedOpenGraphVideoOther
162 | ResolvedOpenGraphMetadata;
163
164type ResolvedOpenGraphMetadata = {
165 determiner?: "a" | "an" | "the" | "auto" | "";
166 title?: AbsoluteTemplateString;
167 description?: string;
168 emails?: Array<string>;
169 phoneNumbers?: Array<string>;
170 faxNumbers?: Array<string>;
171 siteName?: string;
172 locale?: Locale;
173 alternateLocale?: Array<Locale>;
174 images?: Array<OGImage>;
175 audio?: Array<OGAudio>;
176 videos?: Array<OGVideo>;
177 url: null | URL | string;
178 countryName?: string;
179 ttl?: number;
180};
181type ResolvedOpenGraphWebsite = ResolvedOpenGraphMetadata & {
182 type: "website";
183};
184type ResolvedOpenGraphArticle = ResolvedOpenGraphMetadata & {
185 type: "article";
186 publishedTime?: string; // datetime
187 modifiedTime?: string; // datetime
188 expirationTime?: string; // datetime
189 authors?: Array<string>;
190 section?: string;
191 tags?: Array<string>;
192};
193type ResolvedOpenGraphBook = ResolvedOpenGraphMetadata & {
194 type: "book";
195 isbn?: string;
196 releaseDate?: string; // datetime
197 authors?: Array<string>;
198 tags?: Array<string>;
199};
200type ResolvedOpenGraphProfile = ResolvedOpenGraphMetadata & {
201 type: "profile";
202 firstName?: string;
203 lastName?: string;
204 username?: string;
205 gender?: string;
206};
207type ResolvedOpenGraphMusicSong = ResolvedOpenGraphMetadata & {
208 type: "music.song";
209 duration?: number;
210 albums?: Array<OGAlbum>;
211 musicians?: Array<string | URL>;
212};
213type ResolvedOpenGraphMusicAlbum = ResolvedOpenGraphMetadata & {
214 type: "music.album";
215 songs?: Array<string | URL | OGSong>;
216 musicians?: Array<string | URL>;
217 releaseDate?: string; // datetime
218};
219type ResolvedOpenGraphMusicPlaylist = ResolvedOpenGraphMetadata & {
220 type: "music.playlist";
221 songs?: Array<string | URL | OGSong>;
222 creators?: Array<string | URL>;
223};
224type ResolvedOpenGraphRadioStation = ResolvedOpenGraphMetadata & {
225 type: "music.radio_station";
226 creators?: Array<string | URL>;
227};
228type ResolvedOpenGraphVideoMovie = ResolvedOpenGraphMetadata & {
229 type: "video.movie";
230 actors?: Array<string | URL | OGActor>;
231 directors?: Array<string | URL>;
232 writers?: Array<string | URL>;
233 duration?: number;
234 releaseDate?: string; // datetime
235 tags?: Array<string>;
236};
237type ResolvedOpenGraphVideoEpisode = ResolvedOpenGraphMetadata & {
238 type: "video.episode";
239 actors?: Array<string | URL | OGActor>;
240 directors?: Array<string | URL>;
241 writers?: Array<string | URL>;
242 duration?: number;
243 releaseDate?: string; // datetime
244 tags?: Array<string>;
245 series?: string | URL;
246};
247type ResolvedOpenGraphVideoTVShow = ResolvedOpenGraphMetadata & {
248 type: "video.tv_show";
249};
250type ResolvedOpenGraphVideoOther = ResolvedOpenGraphMetadata & {
251 type: "video.other";
252};
253
254type OGSong = {
255 url: string | URL;
256 disc?: number;
257 track?: number;
258};
259type OGAlbum = {
260 url: string | URL;
261 disc?: number;
262 track?: number;
263};
264type OGActor = {
265 url: string | URL;
266 role?: string;
267};
framework/meta/nextjs/types/resolvers.ts deleted-17
...@@ -1,17 +0,0 @@
1import { Metadata, ResolvedMetadata } from "./metadata-interface";
2
3export type FieldResolver<Key extends keyof Metadata> = (
4 T: Metadata[Key],
5) => ResolvedMetadata[Key];
6export type FieldResolverWithMetadataBase<
7 Key extends keyof Metadata,
8 Options = undefined,
9> = Options extends undefined ? (
10 T: Metadata[Key],
11 metadataBase: ResolvedMetadata["metadataBase"],
12 ) => ResolvedMetadata[Key]
13 : (
14 T: Metadata[Key],
15 metadataBase: ResolvedMetadata["metadataBase"],
16 options: Options,
17 ) => ResolvedMetadata[Key];
framework/meta/nextjs/types/twitter-types.ts deleted-94
...@@ -1,94 +0,0 @@
1// Reference: https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup
2
3import type { AbsoluteTemplateString, TemplateString } from "./metadata-types";
4
5export type Twitter =
6 | TwitterSummary
7 | TwitterSummaryLargeImage
8 | TwitterPlayer
9 | TwitterApp
10 | TwitterMetadata;
11
12type TwitterMetadata = {
13 // defaults to card="summary"
14 site?: string; // username for account associated to the site itself
15 siteId?: string; // id for account associated to the site itself
16 creator?: string; // username for the account associated to the creator of the content on the site
17 creatorId?: string; // id for the account associated to the creator of the content on the site
18 description?: string;
19 title?: string | TemplateString;
20 images?: TwitterImage | Array<TwitterImage>;
21};
22type TwitterSummary = TwitterMetadata & {
23 card: "summary";
24};
25type TwitterSummaryLargeImage = TwitterMetadata & {
26 card: "summary_large_image";
27};
28type TwitterPlayer = TwitterMetadata & {
29 card: "player";
30 players: TwitterPlayerDescriptor | Array<TwitterPlayerDescriptor>;
31};
32type TwitterApp = TwitterMetadata & {
33 card: "app";
34 app: TwitterAppDescriptor;
35};
36export type TwitterAppDescriptor = {
37 id: {
38 iphone?: string | number;
39 ipad?: string | number;
40 googleplay?: string;
41 };
42 url?: {
43 iphone?: string | URL;
44 ipad?: string | URL;
45 googleplay?: string | URL;
46 };
47 name?: string;
48};
49
50type TwitterImage = string | TwitterImageDescriptor | URL;
51type TwitterImageDescriptor = {
52 url: string | URL;
53 alt?: string;
54 secureUrl?: string | URL;
55 type?: string;
56 width?: string | number;
57 height?: string | number;
58};
59type TwitterPlayerDescriptor = {
60 playerUrl: string | URL;
61 streamUrl: string | URL;
62 width: number;
63 height: number;
64};
65
66type ResolvedTwitterImage = {
67 url: string | URL;
68 alt?: string;
69 secureUrl?: string | URL;
70 type?: string;
71 width?: string | number;
72 height?: string | number;
73};
74type ResolvedTwitterSummary = {
75 site: string | null;
76 siteId: string | null;
77 creator: string | null;
78 creatorId: string | null;
79 description: string | null;
80 title: AbsoluteTemplateString;
81 images?: Array<ResolvedTwitterImage>;
82};
83type ResolvedTwitterPlayer = ResolvedTwitterSummary & {
84 players: Array<TwitterPlayerDescriptor>;
85};
86type ResolvedTwitterApp = ResolvedTwitterSummary & {
87 app: TwitterAppDescriptor;
88};
89
90export type ResolvedTwitterMetadata =
91 | ({ card: "summary" } & ResolvedTwitterSummary)
92 | ({ card: "summary_large_image" } & ResolvedTwitterSummary)
93 | ({ card: "player" } & ResolvedTwitterPlayer)
94 | ({ card: "app" } & ResolvedTwitterApp);
framework/meta/readme.md deleted-5
...@@ -1,5 +0,0 @@
1the metadata renderer was written in 2022, and is 3700 lines of code. it
2represents the bulk of the code in the framework, which i think is wrong.
3
4a bounty goes to rewriting this codebase into one or two files. merging logic is
5surely not needed, and resolution can happen in the same step as rendering.
framework/meta/render.ts deleted-565
...@@ -1,565 +0,0 @@
1import type { Icon, ResolvedMetadata } from "./types";
2import { escapeHTML as esc } from "./utils";
3
4function Meta(name: string, content: any) {
5 return `<meta name="${esc(name)}" content="${esc(content)}">`;
6}
7
8function MetaProp(name: string, content: any) {
9 return `<meta property="${esc(name)}" content="${esc(content)}">`;
10}
11
12function MetaMedia(name: string, content: any, media: string) {
13 return `<meta name="${esc(name)}" content="${esc(content)}" media="${
14 esc(media)
15 }">`;
16}
17
18function Link(rel: string, href: any) {
19 return `<link rel="${esc(rel)}" href="${esc(href)}" />`;
20}
21
22function LinkMedia(rel: string, href: any, media: string) {
23 return `<link rel="${esc(rel)}" href="${esc(href)}" media="${esc(media)}">`;
24}
25
26const resolveUrl = (
27 url: string | URL,
28) => (typeof url === "string" ? url : url.toString());
29
30function IconLink(rel: string, icon: Icon) {
31 if (typeof icon === "object" && !(icon instanceof URL)) {
32 const { url, rel: _, ...props } = icon;
33 return `<link rel="${esc(rel)}" href="${esc(resolveUrl(url))}"${
34 Object.keys(props)
35 .map((key) => ` ${key}="${esc(props[key])}"`)
36 .join("")
37 }>`;
38 } else {
39 const href = resolveUrl(icon);
40 return Link(rel, href);
41 }
42}
43
44function ExtendMeta(prefix: string, content: any) {
45 if (
46 typeof content === "string" || typeof content === "number" ||
47 content instanceof URL
48 ) {
49 return MetaProp(prefix, content);
50 } else {
51 let str = "";
52 for (const [prop, value] of Object.entries(content)) {
53 if (value) {
54 str += MetaProp(
55 prefix === "og:image" && prop === "url"
56 ? "og:image"
57 : prefix + ":" + prop,
58 value,
59 );
60 }
61 }
62 return str;
63 }
64}
65
66const formatDetectionKeys = [
67 "telephone",
68 "date",
69 "address",
70 "email",
71 "url",
72] as const;
73
74export function renderMetadata(meta: ResolvedMetadata): string {
75 var str = "";
76
77 // <BasicMetadata/>
78 if (meta.title?.absolute) str += `<title>${esc(meta.title.absolute)}</title>`;
79 if (meta.description) str += Meta("description", meta.description);
80 if (meta.applicationName) {
81 str += Meta("application-name", meta.applicationName);
82 }
83 if (meta.authors) {
84 for (var author of meta.authors) {
85 if (author.url) str += Link("author", author.url);
86 if (author.name) str += Meta("author", author.name);
87 }
88 }
89 if (meta.manifest) str += Link("manifest", meta.manifest);
90 if (meta.generator) str += Meta("generator", meta.generator);
91 if (meta.referrer) str += Meta("referrer", meta.referrer);
92 if (meta.themeColor) {
93 for (var themeColor of meta.themeColor) {
94 str += !themeColor.media
95 ? Meta("theme-color", themeColor.color)
96 : MetaMedia("theme-color", themeColor.color, themeColor.media);
97 }
98 }
99 if (meta.colorScheme) str += Meta("color-scheme", meta.colorScheme);
100 if (meta.viewport) str += Meta("viewport", meta.viewport);
101 if (meta.creator) str += Meta("creator", meta.creator);
102 if (meta.publisher) str += Meta("publisher", meta.publisher);
103 if (meta.robots?.basic) str += Meta("robots", meta.robots.basic);
104 if (meta.robots?.googleBot) str += Meta("googlebot", meta.robots.googleBot);
105 if (meta.abstract) str += Meta("abstract", meta.abstract);
106 if (meta.archives) {
107 for (var archive of meta.archives) {
108 str += Link("archives", archive);
109 }
110 }
111 if (meta.assets) {
112 for (var asset of meta.assets) {
113 str += Link("assets", asset);
114 }
115 }
116 if (meta.bookmarks) {
117 for (var bookmark of meta.bookmarks) {
118 str += Link("bookmarks", bookmark);
119 }
120 }
121 if (meta.category) str += Meta("category", meta.category);
122 if (meta.classification) str += Meta("classification", meta.classification);
123 if (meta.other) {
124 for (var [name, content] of Object.entries(meta.other)) {
125 if (content) {
126 str += Meta(name, Array.isArray(content) ? content.join(",") : content);
127 }
128 }
129 }
130
131 // <AlternatesMetadata />
132 var alternates = meta.alternates;
133 if (alternates) {
134 if (alternates.canonical) {
135 str += Link("canonical", alternates.canonical.url);
136 }
137 if (alternates.languages) {
138 for (var [locale, urls] of Object.entries(alternates.languages)) {
139 for (var { url, title } of urls) {
140 str += `<link rel="alternate" hreflang="${esc(locale)}" href="${
141 esc(url.toString())
142 }"${title ? ` title="${esc(title)}"` : ""}>`;
143 }
144 }
145 }
146 if (alternates.media) {
147 for (var [media, urls2] of Object.entries(alternates.media)) {
148 if (urls2) {
149 for (var { url, title } of urls2) {
150 str += `<link rel="alternate" media="${esc(media)}" href="${
151 esc(url.toString())
152 }"${title ? ` title="${esc(title)}"` : ""}>`;
153 }
154 }
155 }
156 }
157 if (alternates.types) {
158 for (var [type, urls2] of Object.entries(alternates.types)) {
159 if (urls2) {
160 for (var { url, title } of urls2) {
161 str += `<link rel="alternate" type="${esc(type)}" href="${
162 esc(url.toString())
163 }"${title ? ` title="${esc(title)}"` : ""}>`;
164 }
165 }
166 }
167 }
168 }
169
170 // <ItunesMeta />
171 if (meta.itunes) {
172 str += Meta(
173 "apple-itunes-app",
174 `app-id=${meta.itunes.appId}${
175 meta.itunes.appArgument
176 ? `, app-argument=${meta.itunes.appArgument}`
177 : ""
178 }`,
179 );
180 }
181
182 // <FormatDetectionMeta />
183 if (meta.formatDetection) {
184 var contentStr = "";
185 for (var key of formatDetectionKeys) {
186 if (key in meta.formatDetection) {
187 if (contentStr) contentStr += ", ";
188 contentStr += `${key}=no`;
189 }
190 }
191 str += Meta("format-detection", contentStr);
192 }
193
194 // <VerificationMeta />
195 if (meta.verification) {
196 if (meta.verification.google) {
197 for (var verificationKey of meta.verification.google) {
198 str += Meta("google-site-verification", verificationKey);
199 }
200 }
201 if (meta.verification.yahoo) {
202 for (var verificationKey of meta.verification.yahoo) {
203 str += Meta("y_key", verificationKey);
204 }
205 }
206 if (meta.verification.yandex) {
207 for (var verificationKey of meta.verification.yandex) {
208 str += Meta("yandex-verification", verificationKey);
209 }
210 }
211 if (meta.verification.me) {
212 for (var verificationKey of meta.verification.me) {
213 str += Meta("me", verificationKey);
214 }
215 }
216 if (meta.verification.other) {
217 for (
218 var [verificationKey2, values] of Object.entries(
219 meta.verification.other,
220 )
221 ) {
222 for (var value of values) {
223 str += Meta(verificationKey2, value);
224 }
225 }
226 }
227 }
228
229 // <AppleWebAppMeta />
230 if (meta.appleWebApp) {
231 const { capable, title, startupImage, statusBarStyle } = meta.appleWebApp;
232 if (capable) {
233 str += '<meta name="apple-mobile-web-app-capable" content="yes" />';
234 }
235 if (title) str += Meta("apple-mobile-web-app-title", title);
236 if (startupImage) {
237 for (const img of startupImage) {
238 str += !img.media
239 ? Link("apple-touch-startup-image", img.url)
240 : LinkMedia("apple-touch-startup-image", img.url, img.media);
241 }
242 }
243 if (statusBarStyle) {
244 str += Meta("apple-mobile-web-app-status-bar-style", statusBarStyle);
245 }
246 }
247
248 // <OpenGraphMetadata />
249 if (meta.openGraph) {
250 const og = meta.openGraph;
251 if (og.determiner) str += MetaProp("og:determiner", og.determiner);
252 if (og.title?.absolute) str += MetaProp("og:title", og.title.absolute);
253 if (og.description) str += MetaProp("og:description", og.description);
254 if (og.url) str += MetaProp("og:url", og.url.toString());
255 if (og.siteName) str += MetaProp("og:site_name", og.siteName);
256 if (og.locale) str += MetaProp("og:locale", og.locale);
257 if (og.countryName) str += MetaProp("og:country_name", og.countryName);
258 if (og.ttl) str += MetaProp("og:ttl", og.ttl);
259 if (og.images) {
260 for (const item of og.images) {
261 str += ExtendMeta("og:image", item);
262 }
263 }
264 if (og.videos) {
265 for (const item of og.videos) {
266 str += ExtendMeta("og:video", item);
267 }
268 }
269 if (og.audio) {
270 for (const item of og.audio) {
271 str += ExtendMeta("og:audio", item);
272 }
273 }
274 if (og.emails) {
275 for (const item of og.emails) {
276 str += ExtendMeta("og:email", item);
277 }
278 }
279 if (og.phoneNumbers) {
280 for (const item of og.phoneNumbers) {
281 str += MetaProp("og:phone_number", item);
282 }
283 }
284 if (og.faxNumbers) {
285 for (const item of og.faxNumbers) {
286 str += MetaProp("og:fax_number", item);
287 }
288 }
289 if (og.alternateLocale) {
290 for (const item of og.alternateLocale) {
291 str += MetaProp("og:locale:alternate", item);
292 }
293 }
294
295 if ("type" in og) {
296 str += MetaProp("og:type", og.type);
297 switch (og.type) {
298 case "website":
299 break;
300 case "article":
301 if (og.publishedTime) {
302 str += MetaProp("article:published_time", og.publishedTime);
303 }
304 if (og.modifiedTime) {
305 str += MetaProp("article:modified_time", og.modifiedTime);
306 }
307 if (og.expirationTime) {
308 str += MetaProp("article:expiration_time", og.expirationTime);
309 }
310 if (og.authors) {
311 for (const item of og.authors) {
312 str += MetaProp("article:author", item);
313 }
314 }
315 if (og.section) str += MetaProp("article:section", og.section);
316 if (og.tags) {
317 for (const item of og.tags) {
318 str += MetaProp("article:tag", item);
319 }
320 }
321 break;
322 case "book":
323 if (og.isbn) str += MetaProp("book:isbn", og.isbn);
324 if (og.releaseDate) {
325 str += MetaProp("book:release_date", og.releaseDate);
326 }
327 if (og.authors) {
328 for (const item of og.authors) {
329 str += MetaProp("article:author", item);
330 }
331 }
332 if (og.tags) {
333 for (const item of og.tags) {
334 str += MetaProp("article:tag", item);
335 }
336 }
337 break;
338 case "profile":
339 if (og.firstName) str += MetaProp("profile:first_name", og.firstName);
340 if (og.lastName) str += MetaProp("profile:last_name", og.lastName);
341 if (og.username) str += MetaProp("profile:first_name", og.username);
342 if (og.gender) str += MetaProp("profile:first_name", og.gender);
343 break;
344 case "music.song":
345 if (og.duration) str += MetaProp("music:duration", og.duration);
346 if (og.albums) {
347 for (const item of og.albums) {
348 str += ExtendMeta("music:albums", item);
349 }
350 }
351 if (og.musicians) {
352 for (const item of og.musicians) {
353 str += MetaProp("music:musician", item);
354 }
355 }
356 break;
357 case "music.album":
358 if (og.songs) {
359 for (const item of og.songs) {
360 str += ExtendMeta("music:song", item);
361 }
362 }
363 if (og.musicians) {
364 for (const item of og.musicians) {
365 str += MetaProp("music:musician", item);
366 }
367 }
368 if (og.releaseDate) {
369 str += MetaProp("music:release_date", og.releaseDate);
370 }
371 break;
372 case "music.playlist":
373 if (og.songs) {
374 for (const item of og.songs) {
375 str += ExtendMeta("music:song", item);
376 }
377 }
378 if (og.creators) {
379 for (const item of og.creators) {
380 str += MetaProp("music:creator", item);
381 }
382 }
383 break;
384 case "music.radio_station":
385 if (og.creators) {
386 for (const item of og.creators) {
387 str += MetaProp("music:creator", item);
388 }
389 }
390 break;
391 case "video.movie":
392 if (og.actors) {
393 for (const item of og.actors) {
394 str += ExtendMeta("video:actor", item);
395 }
396 }
397 if (og.directors) {
398 for (const item of og.directors) {
399 str += MetaProp("video:director", item);
400 }
401 }
402 if (og.writers) {
403 for (const item of og.writers) {
404 str += MetaProp("video:writer", item);
405 }
406 }
407 if (og.duration) str += MetaProp("video:duration", og.duration);
408 if (og.releaseDate) {
409 str += MetaProp("video:release_date", og.releaseDate);
410 }
411 if (og.tags) {
412 for (const item of og.tags) {
413 str += MetaProp("video:tag", item);
414 }
415 }
416 break;
417 case "video.episode":
418 if (og.actors) {
419 for (const item of og.actors) {
420 str += ExtendMeta("video:actor", item);
421 }
422 }
423 if (og.directors) {
424 for (const item of og.directors) {
425 str += MetaProp("video:director", item);
426 }
427 }
428 if (og.writers) {
429 for (const item of og.writers) {
430 str += MetaProp("video:writer", item);
431 }
432 }
433 if (og.duration) str += MetaProp("video:duration", og.duration);
434 if (og.releaseDate) {
435 str += MetaProp("video:release_date", og.releaseDate);
436 }
437 if (og.tags) {
438 for (const item of og.tags) {
439 str += MetaProp("video:tag", item);
440 }
441 }
442 if (og.series) str += MetaProp("video:series", og.series);
443 break;
444 case "video.other":
445 case "video.tv_show":
446 default:
447 throw new Error("Invalid OpenGraph type: " + og.type);
448 }
449 }
450 }
451
452 // <TwitterMetadata />
453 if (meta.twitter) {
454 const twitter = meta.twitter;
455
456 if (twitter.card) str += Meta("twitter:card", twitter.card);
457 if (twitter.site) str += Meta("twitter:site", twitter.site);
458 if (twitter.siteId) str += Meta("twitter:site:id", twitter.siteId);
459 if (twitter.creator) str += Meta("twitter:creator", twitter.creator);
460 if (twitter.creatorId) str += Meta("twitter:creator:id", twitter.creatorId);
461 if (twitter.title?.absolute) {
462 str += Meta("twitter:title", twitter.title.absolute);
463 }
464 if (twitter.description) {
465 str += Meta("twitter:description", twitter.description);
466 }
467 if (twitter.images) {
468 for (const img of twitter.images) {
469 str += Meta("twitter:image", img.url);
470 if (img.alt) str += Meta("twitter:image:alt", img.alt);
471 }
472 }
473 if (twitter.card === "player") {
474 for (const player of twitter.players) {
475 if (player.playerUrl) str += Meta("twitter:player", player.playerUrl);
476 if (player.streamUrl) {
477 str += Meta("twitter:player:stream", player.streamUrl);
478 }
479 if (player.width) str += Meta("twitter:player:width", player.width);
480 if (player.height) str += Meta("twitter:player:height", player.height);
481 }
482 }
483 if (twitter.card === "app") {
484 for (const type of ["iphone", "ipad", "googleplay"]) {
485 if (twitter.app.id[type]) {
486 str += Meta(`twitter:app:name:${type}`, twitter.app.name);
487 str += Meta(`twitter:app:id:${type}`, twitter.app.id[type]);
488 }
489 if (twitter.app.url?.[type]) {
490 str += Meta(`twitter:app:url:${type}`, twitter.app.url[type]);
491 }
492 }
493 }
494 }
495
496 // <AppLinksMeta />
497 if (meta.appLinks) {
498 if (meta.appLinks.ios) {
499 for (var item of meta.appLinks.ios) {
500 str += ExtendMeta("al:ios", item);
501 }
502 }
503 if (meta.appLinks.iphone) {
504 for (var item of meta.appLinks.iphone) {
505 str += ExtendMeta("al:iphone", item);
506 }
507 }
508 if (meta.appLinks.ipad) {
509 for (var item of meta.appLinks.ipad) {
510 str += ExtendMeta("al:ipad", item);
511 }
512 }
513 if (meta.appLinks.android) {
514 for (var item2 of meta.appLinks.android) {
515 str += ExtendMeta("al:android", item2);
516 }
517 }
518 if (meta.appLinks.windows_phone) {
519 for (var item3 of meta.appLinks.windows_phone) {
520 str += ExtendMeta("al:windows_phone", item3);
521 }
522 }
523 if (meta.appLinks.windows) {
524 for (var item3 of meta.appLinks.windows) {
525 str += ExtendMeta("al:windows", item3);
526 }
527 }
528 if (meta.appLinks.windows_universal) {
529 for (var item4 of meta.appLinks.windows_universal) {
530 str += ExtendMeta("al:windows_universal", item4);
531 }
532 }
533 if (meta.appLinks.web) {
534 for (const item of meta.appLinks.web) {
535 str += ExtendMeta("al:web", item);
536 }
537 }
538 }
539
540 // <IconsMetadata />
541 if (meta.icons) {
542 if (meta.icons.shortcut) {
543 for (var icon of meta.icons.shortcut) {
544 str += IconLink("shortcut icon", icon);
545 }
546 }
547 if (meta.icons.icon) {
548 for (var icon of meta.icons.icon) {
549 str += IconLink("icon", icon);
550 }
551 }
552 if (meta.icons.apple) {
553 for (var icon of meta.icons.apple) {
554 str += IconLink("apple-touch-icon", icon);
555 }
556 }
557 if (meta.icons.other) {
558 for (var icon of meta.icons.other) {
559 str += IconLink(icon.rel ?? "icon", icon);
560 }
561 }
562 }
563
564 return str;
565}
framework/meta/types.ts deleted-57
...@@ -1,57 +0,0 @@
1export type {
2 AlternateURLs,
3 ResolvedAlternateURLs,
4} from "./nextjs/types/alternative-urls-types";
5export type {
6 AppleImage,
7 AppleImageDescriptor,
8 AppleWebApp,
9 AppLinks,
10 AppLinksAndroid,
11 AppLinksApple,
12 AppLinksWeb,
13 AppLinksWindows,
14 FormatDetection,
15 ItunesApp,
16 ResolvedAppleWebApp,
17 ResolvedAppLinks,
18 Viewport,
19} from "./nextjs/types/extra-types";
20export type {
21 Metadata,
22 ResolvedMetadata,
23 ResolvingMetadata,
24} from "./nextjs/types/metadata-interface";
25export type {
26 AbsoluteString,
27 AbsoluteTemplateString,
28 Author,
29 ColorSchemeEnum,
30 DefaultTemplateString,
31 Icon,
32 IconDescriptor,
33 Icons,
34 IconURL,
35 ReferrerEnum,
36 ResolvedIcons,
37 ResolvedRobots,
38 ResolvedVerification,
39 Robots,
40 TemplateString,
41 ThemeColorDescriptor,
42 Verification,
43} from "./nextjs/types/metadata-types";
44export type {
45 OpenGraph,
46 OpenGraphType,
47 ResolvedOpenGraph,
48} from "./nextjs/types/opengraph-types";
49export type {
50 FieldResolver,
51 FieldResolverWithMetadataBase,
52} from "./nextjs/types/resolvers";
53export type {
54 ResolvedTwitterMetadata,
55 Twitter,
56 TwitterAppDescriptor,
57} from "./nextjs/types/twitter-types";
framework/meta/utils.ts deleted-13
...@@ -1,13 +0,0 @@
1// Extracted from @paperdave/utils/string
2declare var Bun: any;
3
4export const escapeHTML: (string: string) => string = typeof Bun !== "undefined"
5 ? Bun.escapeHTML
6 : (string: string) => {
7 return string
8 .replaceAll('"', "&quot;")
9 .replaceAll("&", "&amp;")
10 .replaceAll("'", "&#x27;")
11 .replaceAll("<", "&lt;")
12 .replaceAll(">", "&gt;");
13 };
framework/sitegen.tsx+9-10
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1// Sitegen! Clover's static site generator, built with love.1// Sitegen! Clover's static site generator, built with love.
22
3function main() {3export function main() {
4 return withSpinner({4 return withSpinner({
5 text: "Recovering State",5 text: "Recovering State",
6 successText: ({ elapsed }) =>6 successText: ({ elapsed }) =>
...@@ -50,7 +50,7 @@ async function sitegen(status: Spinner) {...@@ -50,7 +50,7 @@ async function sitegen(status: Spinner) {
50 status.text = "Scanning Project";50 status.text = "Scanning Project";
51 for (const section of sections) {51 for (const section of sections) {
52 const { root: sectionRoot } = section;52 const { root: sectionRoot } = section;
53 const sectionPath = (...sub) => path.join(sectionRoot, ...sub);53 const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
54 const rootPrefix = root === sectionRoot54 const rootPrefix = root === sectionRoot
55 ? ""55 ? ""
56 : path.relative(root, sectionRoot) + "/";56 : path.relative(root, sectionRoot) + "/";
...@@ -101,7 +101,7 @@ async function sitegen(status: Spinner) {...@@ -101,7 +101,7 @@ async function sitegen(status: Spinner) {
101101
102 // -- server side render --102 // -- server side render --
103 status.text = "Building";103 status.text = "Building";
104 const cssOnce = new OnceMap();104 const cssOnce = new OnceMap<string>();
105 const cssQueue = new Queue<[string, string[], css.Theme], string>({105 const cssQueue = new Queue<[string, string[], css.Theme], string>({
106 name: "Bundle",106 name: "Bundle",
107 fn: ([, files, theme]) => css.bundleCssFiles(files, theme),107 fn: ([, files, theme]) => css.bundleCssFiles(files, theme),
...@@ -140,7 +140,7 @@ async function sitegen(status: Spinner) {...@@ -140,7 +140,7 @@ async function sitegen(status: Spinner) {
140 // -- metadata --140 // -- metadata --
141 const renderedMetaPromise = Promise.resolve(141 const renderedMetaPromise = Promise.resolve(
142 typeof metadata === "function" ? metadata({ ssr: true }) : metadata,142 typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
143 ).then((m) => meta.resolveAndRenderMetadata(m));143 ).then((m) => meta.renderMeta(m));
144 // -- css --144 // -- css --
145 const cssImports = [globalCssPath, ...hot.getCssImports(item.file)];145 const cssImports = [globalCssPath, ...hot.getCssImports(item.file)];
146 const cssPromise = cssOnce.get(146 const cssPromise = cssOnce.get(
...@@ -148,12 +148,11 @@ async function sitegen(status: Spinner) {...@@ -148,12 +148,11 @@ async function sitegen(status: Spinner) {
148 () => cssQueue.add([item.id, cssImports, theme]),148 () => cssQueue.add([item.id, cssImports, theme]),
149 );149 );
150 // -- html --150 // -- html --
151 const sitegenApi = sg.initRender();
152 const bodyPromise = await ssr.ssrAsync(<Page />, {151 const bodyPromise = await ssr.ssrAsync(<Page />, {
153 sitegen: sitegenApi,152 sitegen: sg.initRender(),
154 });153 });
155154
156 const [body, inlineCss, renderedMeta] = await Promise.all([155 const [{ text, addon }, inlineCss, renderedMeta] = await Promise.all([
157 bodyPromise,156 bodyPromise,
158 cssPromise,157 cssPromise,
159 renderedMetaPromise,158 renderedMetaPromise,
...@@ -168,10 +167,10 @@ async function sitegen(status: Spinner) {...@@ -168,10 +167,10 @@ async function sitegen(status: Spinner) {
168 // contents will be rebuilt at the end. This is more convenient anyways167 // contents will be rebuilt at the end. This is more convenient anyways
169 // because it means client scripts don't re-render the page.168 // because it means client scripts don't re-render the page.
170 renderResults.push({169 renderResults.push({
171 body,170 body: text,
172 head: renderedMeta,171 head: renderedMeta,
173 inlineCss,172 inlineCss,
174 scriptFiles: Array.from(sitegenApi.scripts),173 scriptFiles: Array.from(addon.sitegen.scripts),
175 item: item,174 item: item,
176 });175 });
177 }176 }
...@@ -303,7 +302,7 @@ import * as bundle from "./bundle.ts";...@@ -303,7 +302,7 @@ import * as bundle from "./bundle.ts";
303import * as css from "./css.ts";302import * as css from "./css.ts";
304import * as fs from "./fs.ts";303import * as fs from "./fs.ts";
305import { Spinner, withSpinner } from "@paperclover/console/Spinner";304import { Spinner, withSpinner } from "@paperclover/console/Spinner";
306import * as meta from "./meta";305import * as meta from "./meta.ts";
307import * as ssr from "./engine/ssr.ts";306import * as ssr from "./engine/ssr.ts";
308import * as sg from "./sitegen-lib.ts";307import * as sg from "./sitegen-lib.ts";
309import * as hot from "./hot.ts";308import * as hot from "./hot.ts";
package-lock.json+11-1
...@@ -11,7 +11,8 @@...@@ -11,7 +11,8 @@
11 "esbuild": "^0.25.5",11 "esbuild": "^0.25.5",
12 "hls.js": "^1.6.5",12 "hls.js": "^1.6.5",
13 "hono": "^4.7.11",13 "hono": "^4.7.11",
14 "marko": "^6.0.20"14 "marko": "^6.0.20",
15 "unique-names-generator": "^4.7.1"
15 },16 },
16 "devDependencies": {17 "devDependencies": {
17 "@types/node": "^22.15.29",18 "@types/node": "^22.15.29",
...@@ -2946,6 +2947,15 @@...@@ -2946,6 +2947,15 @@
2946 "url": "https://opencollective.com/unified"2947 "url": "https://opencollective.com/unified"
2947 }2948 }
2948 },2949 },
2950 "node_modules/unique-names-generator": {
2951 "version": "4.7.1",
2952 "resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
2953 "integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==",
2954 "license": "MIT",
2955 "engines": {
2956 "node": ">=8"
2957 }
2958 },
2949 "node_modules/unist-util-is": {2959 "node_modules/unist-util-is": {
2950 "version": "6.0.0",2960 "version": "6.0.0",
2951 "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",2961 "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
package.json+4-2
...@@ -8,7 +8,8 @@...@@ -8,7 +8,8 @@
8 "esbuild": "^0.25.5",8 "esbuild": "^0.25.5",
9 "hls.js": "^1.6.5",9 "hls.js": "^1.6.5",
10 "hono": "^4.7.11",10 "hono": "^4.7.11",
11 "marko": "^6.0.20"11 "marko": "^6.0.20",
12 "unique-names-generator": "^4.7.1"
12 },13 },
13 "devDependencies": {14 "devDependencies": {
14 "@types/node": "^22.15.29",15 "@types/node": "^22.15.29",
...@@ -23,7 +24,8 @@...@@ -23,7 +24,8 @@
23 "#ssr/marko": "./framework/engine/marko-runtime.ts",24 "#ssr/marko": "./framework/engine/marko-runtime.ts",
24 "#marko/html": {25 "#marko/html": {
25 "development": "marko/debug/html",26 "development": "marko/debug/html",
26 "production": "marko/html"27 "production": "marko/html",
28 "types": "marko/html"
27 },29 },
28 "#hono/platform": {30 "#hono/platform": {
29 "bun": "hono/bun",31 "bun": "hono/bun",
src/pages/index.marko+2-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { Metadata } from "next-metadata";1import type { Meta } from "#meta";
2import "./index.css";2import "./index.css";
33
4export const theme = {4export const theme = {
...@@ -7,7 +7,7 @@ export const theme = {...@@ -7,7 +7,7 @@ export const theme = {
7};7};
8static const title = "paper clover";8static const title = "paper clover";
9static const description = "and then we knew, just like paper airplanes: that we could fly...";9static const description = "and then we knew, just like paper airplanes: that we could fly...";
10export const meta: Metadata = {10export const meta: Meta = {
11 title,11 title,
12 description,12 description,
13 openGraph: {13 openGraph: {
src/q+a/artifacts.ts created+89
...@@ -0,0 +1,89 @@
1// Artifacts used to be a central system in the old data-driven website.
2// Now, it simply refers to one of these link presets. Every project has
3// one canonical URL, which the questions page can refer to with `@id`.
4type Artifact = [title: string, url: string, type: ArtifactType];
5type ArtifactType = "music" | "game" | "project" | "video";
6export const artifactMap: Record<string, Artifact> = {
7 // 2025
8 "in-the-summer": ["in the summer", "", "music"],
9 waterfalls: ["waterfalls", "/waterfalls", "music"],
10 lolzip: ["lol.zip", "", "project"],
11 "g-is-missing": ["g is missing", "", "music"],
12 "im-18-now": ["i'm 18 now", "", "music"],
13 "programming-comparison": [
14 "thursday programming language comparison",
15 "",
16 "video",
17 ],
18 aaaaaaaaa: ["aaaaaaaaa", "", "music"],
19 "its-snowing": ["it's snowing", "", "video"],
20 // 2023
21 "iphone-15-review": [
22 "iphone 15 review",
23 "/file/2023/iphone%2015%20review/iphone-15-review.mp4",
24 "video",
25 ],
26 // 2022
27 mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
28 "mystery-of-life": [
29 "mystery of life",
30 "/file/2022/mystery-of-life/mystery-of-life.mp4",
31 "music",
32 ],
33 // 2021
34 "top-10000-bread": [
35 "top 10000 bread",
36 "https://paperclover.net/file/2021/top-10000-bread/output.mp4",
37 "video",
38 ],
39 "phoenix-write-soundtrack": [
40 "Phoenix, WRITE! soundtrack",
41 "/file/2021/phoenix-write/OST",
42 "music",
43 ],
44 "phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
45 "money-visual-cover": [
46 "money visual cover",
47 "/file/2021/money-visual-cover/money-visual-cover.mp4",
48 "video",
49 ],
50 "i-got-this-thing": [
51 "i got this thing",
52 "/file/2021/i-got-this-thing/i-got-this-thing.mp4",
53 "video",
54 ],
55 // 2020
56 elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
57 "elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
58 // 2019
59 "throw-soundtrack": [
60 "throw soundtrack",
61 "/file/2019/throw/soundtrack",
62 "music",
63 ],
64 "elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"],
65 volar: [
66 "volar visual cover",
67 "/file/2019/volar-visual-cover/volar.mp4",
68 "video",
69 ],
70 wpm: [
71 "how to read 500 words per minute",
72 "/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4",
73 "video",
74 ],
75 "dice-roll": [
76 "thursday dice roll",
77 "/file/2019/thursday-dice-roll/thursday-dice-roll.mp4",
78 "video",
79 ],
80 "math-problem": [
81 "thursday math problem",
82 "/file/2019/thursday-math-problem/thursday-math-problem.mp4",
83 "video",
84 ],
85 // 2018
86 // 2017
87 "hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"],
88 "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],
89};
src/q+a/backend.ts deleted-242
...@@ -1,242 +0,0 @@
1import { type Context, Hono } from "hono";
2import type { ContentfulStatusCode } from "hono/utils/http-status";
3import {
4 adjectives,
5 animals,
6 colors,
7 uniqueNamesGenerator,
8} from "unique-names-generator";
9import { hasAdminToken } from "../admin";
10import { regenerateAssets, serveAsset } from "../assets";
11import { PendingQuestion, Question } from "../db";
12import { renderDynamicPage } from "../framework/dynamic-pages";
13import { getQuestionImage } from "./question_image";
14import { formatQuestionId, questionIdToTimestamp } from "./QuestionRender";
15import process from "node:process";
16
17const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
18
19let isStale = false;
20
21const app = new Hono();
22// Main page
23app.get("/q+a", async (c) => {
24 if (isStale) {
25 await regenerateAssets();
26 isStale = false;
27 }
28 if (hasAdminToken(c)) {
29 return serveAsset(c, "/admin/q+a", 200);
30 }
31 return serveAsset(c, "/q+a", 200);
32});
33// Submit form
34app.post("/q+a", async (c) => {
35 const form = await c.req.formData();
36 let text = form.get("text");
37 if (typeof text !== "string") {
38 return questionFailure(c, 400, "Bad Request");
39 }
40 text = text.trim();
41 const input = {
42 date: new Date(),
43 prompt: text,
44 sourceName: "unknown",
45 sourceLocation: "unknown",
46 sourceVPN: null,
47 };
48
49 input.date.setMilliseconds(0);
50
51 if (text.length <= 0) {
52 return questionFailure(c, 400, "Content is too short", text);
53 }
54
55 if (text.length > 16000) {
56 return questionFailure(c, 400, "Content is too long", text);
57 }
58
59 // Ban patterns
60 if (
61 text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN
62 ) {
63 // To prevent known automatic spam-bots from noticing something automatic is
64 // happening, pretend that the question was successfully submitted.
65 return sendSuccess(c, new Date());
66 }
67
68 const ipAddr = c.req.header("cf-connecting-ip");
69 if (ipAddr) {
70 input.sourceName = uniqueNamesGenerator({
71 dictionaries: [adjectives, colors, animals],
72 separator: "-",
73 seed: ipAddr + PROXYCHECK_API_KEY,
74 });
75 }
76
77 const cfIPCountry = c.req.header("cf-ipcountry");
78 if (cfIPCountry) {
79 input.sourceLocation = cfIPCountry;
80 }
81
82 if (ipAddr && PROXYCHECK_API_KEY) {
83 const proxyCheck = await fetch(
84 `https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`,
85 {
86 method: "POST",
87 body: "ips=" + ipAddr,
88 headers: {
89 "Content-Type": "application/x-www-form-urlencoded",
90 },
91 },
92 ).then((res) => res.json());
93
94 if (ipAddr && proxyCheck[ipAddr]) {
95 if (proxyCheck[ipAddr].proxy === "yes") {
96 input.sourceVPN = proxyCheck[ipAddr].operator?.name ??
97 proxyCheck[ipAddr].organisation ??
98 proxyCheck[ipAddr].provider ?? "unknown";
99 }
100 if (Number(proxyCheck[ipAddr].risk) > 72) {
101 return questionFailure(
102 c,
103 403,
104 "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.",
105 text,
106 );
107 }
108 }
109 }
110
111 const date = Question.create(
112 Question.Type.pending,
113 JSON.stringify(input),
114 input.date,
115 );
116 await sendSuccess(c, date);
117});
118async function sendSuccess(c: Context, date: Date) {
119 if (c.req.header("Accept")?.includes("application/json")) {
120 return c.json({
121 success: true,
122 message: "ok",
123 date: date.getTime(),
124 id: formatQuestionId(date),
125 }, { status: 200 });
126 }
127 c.res = await renderDynamicPage(c.req.raw, "qa_success", {
128 permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
129 });
130}
131// Question Permalink
132app.get("/q+a/:id", async (c, next) => {
133 // from deadname era, the seconds used to be in the url.
134 // this was removed so that the url can be crafted by hand.
135 let id = c.req.param("id");
136 if (id.length === 12 && /^\d+$/.test(id)) {
137 return c.redirect(`/q+a/${id.slice(0, 10)}`);
138 }
139 let image = false;
140 if (id.endsWith(".png")) {
141 image = true;
142 id = id.slice(0, -4);
143 }
144
145 const timestamp = questionIdToTimestamp(id);
146 if (!timestamp) return next();
147 const question = await Question.getByDate(timestamp);
148 if (!question) return next();
149
150 if (image) {
151 return getQuestionImage(question, c.req.method === "HEAD");
152 }
153 return renderDynamicPage(c.req.raw, "qa_permalink", { question });
154});
155
156// Admin
157app.get("/admin/q+a", async (c) => {
158 if (isStale) {
159 await regenerateAssets();
160 isStale = false;
161 }
162 return serveAsset(c, "/admin/q+a", 200);
163});
164app.get("/admin/q+a/inbox", async (c) => {
165 return renderDynamicPage(c.req.raw, "qa_backend_inbox", {});
166});
167app.delete("/admin/q+a/:id", async (c, next) => {
168 const id = c.req.param("id");
169 const timestamp = questionIdToTimestamp(id);
170 if (!timestamp) return next();
171 const question = Question.getByDate(timestamp);
172 if (!question) return next();
173 const deleteFull = c.req.header("X-Delete-Full") === "true";
174 if (deleteFull) {
175 Question.deleteByQmid(question.qmid);
176 } else {
177 Question.rejectByQmid(question.qmid);
178 }
179 return c.json({ success: true, message: "ok" });
180});
181app.patch("/admin/q+a/:id", async (c, next) => {
182 const id = c.req.param("id");
183 const timestamp = questionIdToTimestamp(id);
184 if (!timestamp) return next();
185 const question = Question.getByDate(timestamp);
186 if (!question) return next();
187 const form = await c.req.raw.json();
188 if (typeof form.text !== "string" || typeof form.type !== "number") {
189 console.log("bad request");
190 return questionFailure(c, 400, "Bad Request");
191 }
192 Question.updateByQmid(question.qmid, form.text, form.type);
193 isStale = true;
194 return c.json({ success: true, message: "ok" });
195});
196app.get("/admin/q+a/:id", async (c, next) => {
197 const id = c.req.param("id");
198 const timestamp = questionIdToTimestamp(id);
199 if (!timestamp) return next();
200 const question = Question.getByDate(timestamp);
201 if (!question) return next();
202
203 let pendingInfo: null | PendingQuestion.JSON = null;
204 if (question.type === Question.Type.pending) {
205 pendingInfo = JSON.parse(question.text) as PendingQuestion.JSON;
206 question.text = pendingInfo.prompt.trim().split("\n").map((line) =>
207 line.trim().length === 0 ? "" : `q: ${line.trim()}`
208 ).join("\n") + "\n\n";
209 question.type = Question.Type.normal;
210 }
211
212 return renderDynamicPage(c.req.raw, "qa_backend_edit", {
213 pendingInfo,
214 question,
215 });
216});
217
218app.get("/q+a/things/random", async (c) => {
219 c.res = await renderDynamicPage(c.req.raw, "qa_things_random", {});
220});
221
222// 404
223app.get("/q+a/*", async (c) => {
224 return serveAsset(c, "/q+a/404", 404);
225});
226
227async function questionFailure(
228 c: Context,
229 status: ContentfulStatusCode,
230 message: string,
231 content?: string,
232) {
233 if (c.req.header("Accept")?.includes("application/json")) {
234 return c.json({ success: false, message, id: null }, { status });
235 }
236 c.res = await renderDynamicPage(c.req.raw, "qa_fail", {
237 error: message,
238 content,
239 });
240}
241
242export { app as questionAnswerApp };
src/q+a/format.ts created+39
...@@ -0,0 +1,39 @@
1const dateFormat = new Intl.DateTimeFormat("sv", {
2 timeZone: "EST",
3 year: "numeric",
4 month: "2-digit",
5 hour: "2-digit",
6 day: "2-digit",
7 minute: "2-digit",
8});
9
10// YYYY-MM-DD HH:MM
11export function formatQuestionTimestamp(date: Date) {
12 return dateFormat.format(date);
13}
14
15// YYYY-MM-DDTHH:MM:00Z
16export function formatQuestionISOTimestamp(date: Date) {
17 const str = dateFormat.format(date);
18 return `${str.slice(0, 10)}T${str.slice(11)}-05:00`;
19}
20
21// YYMMDDHHMM
22export function formatQuestionId(date: Date) {
23 return formatQuestionTimestamp(date).replace(/[^\d]/g, "").slice(2, 12);
24}
25
26export function questionIdToTimestamp(id: string) {
27 if (id.length !== 10 || !/^\d+$/.test(id)) {
28 return null;
29 }
30 const date = new Date(
31 `20${id.slice(0, 2)}-${id.slice(2, 4)}-${id.slice(4, 6)} ${
32 id.slice(6, 8)
33 }:${id.slice(8, 10)}:00 EST`,
34 );
35 if (isNaN(date.getTime())) {
36 return null;
37 }
38 return date;
39}
src/q+a/simple-markdown.ts+1090-867
...@@ -1,867 +1,1090 @@...@@ -1,867 +1,1090 @@
1// @ts-nocheck
2// # Simple-Markdown Core
3//
4// This is a fork of Khan-academy's Simple-Markdown[1], initially forked in 2022
5// to add Svelte support[2], and used for paper clover's q+a markdown flavor.
6//
7// 1: https://github.com/Khan/perseus/tree/main/packages/simple-markdown/src
8// 2: https://github.com/paperclover/svelte-simple-markdown
9export type Rules = Record<string, ParserRule>;
10export interface ParserRule {
11 name: string;
12 match: MatchFunction;
13 parse: ParseFunction;
14 quality?: QualityFunction;
15}
16
17export class RuleList extends Array<ParserRule> {
18 constructor(input?: ArrayLike<ParserRule>) {
19 super();
20 if (input) {
21 this.push(...Array.from(input));
22 }
23 }
24
25 insertBefore(rule: string, newRule: ParserRule): void {
26 const index = this.findIndex((r) => r.name === rule);
27 if (index === -1) {
28 throw new Error(`Rule ${rule} not found`);
29 }
30 this.splice(index, 0, newRule);
31 }
32
33 insertAfter(rule: string, newRule: ParserRule): void {
34 const index = this.findIndex((r) => r.name === rule);
35 if (index === -1) {
36 throw new Error(`Rule ${rule} not found`);
37 }
38 this.splice(index + 1, 0, newRule);
39 }
40
41 toRuleObject(): Record<string, ParserRule> {
42 const result: Record<string, ParserRule> = {};
43 this.forEach((rule) => {
44 result[rule.name] = rule;
45 });
46 return result;
47 }
48
49 add(rule: ParserRule): void {
50 this.push(rule);
51 }
52
53 get(rule: string): ParserRule | undefined {
54 return this.find((r) => r.name === rule);
55 }
56
57 remove(rule: string): void {
58 const index = this.findIndex((r) => r.name === rule);
59 if (index === -1) {
60 throw new Error(`Rule ${rule} not found`);
61 }
62 this.splice(index, 1);
63 }
64
65 clone() {
66 return new RuleList(this);
67 }
68}
69
70/**
71 * Creates a parser for a given set of rules, with the precedence
72 * specified as a list of rules.
73 *
74 * @param rules
75 * an object containing
76 * rule type -> {match, order, parse} objects
77 * (lower order is higher precedence)
78 * @param [defaultState]
79 *
80 * @returns
81 * The resulting parse function, with the following parameters:
82 * @source: the input source string to be parsed
83 * @state: an optional object to be threaded through parse
84 * calls. Allows clients to add stateful operations to
85 * parsing, such as keeping track of how many levels deep
86 * some nesting is. For an example use-case, see passage-ref
87 * parsing in src/widgets/passage/passage-markdown.jsx
88 */
89export function createParser(
90 ruleListInput: RuleList,
91 defaultState: Partial<ParserState> = {},
92) {
93 let rules = ruleListInput.toRuleObject();
94 let ruleList = Object.keys(rules);
95
96 let latestState: ParserState;
97
98 let nestedParse = function (source: string, state?: ParserState) {
99 let result: ASTNode[] = [];
100 state = state || latestState;
101 latestState = state;
102
103 while (source) {
104 // store the best match, it's rule, and quality:
105 let ruleType = null;
106 let rule = null;
107 let capture = null;
108 let quality = NaN; // loop control variables:
109
110 let i = 0;
111 let currRuleType = ruleList[0];
112
113 let currRule = rules[currRuleType];
114
115 do {
116 let currCapture = currRule.match(source, state);
117
118 if (currCapture) {
119 let currQuality = currRule.quality
120 ? currRule.quality(currCapture, state)
121 : 0;
122
123 // This should always be true the first time because
124 // the initial quality is NaN (that's why there's the
125 // condition negation).
126 if (!(currQuality <= quality)) {
127 ruleType = currRuleType;
128 rule = currRule;
129 capture = currCapture;
130 quality = currQuality;
131 }
132 }
133
134 // Move on to the next item.
135 // Note that this makes `currRule` be the next item
136 i++;
137 currRuleType = ruleList[i];
138 currRule = rules[currRuleType];
139 } while (
140 // keep looping while we're still within the ruleList
141 currRule &&
142 // if we don't have a match yet, continue
143 (!capture ||
144 // or if we have a match, but the next rule is
145 // at the same order, and has a quality measurement
146 // functions, then this rule must have a quality
147 // measurement function (since they are sorted before
148 // those without), and we need to check if there is
149 // a better quality match
150 currRule.quality)
151 );
152
153 if (!rule || !capture || !ruleType) {
154 throw new Error(
155 "Could not find a matching rule for the below " +
156 "content. The rule with highest `order` should " +
157 "always match content provided to it. Check " +
158 "the definition of `match` for '" +
159 ruleList[ruleList.length - 1] +
160 "'. It seems to not match the following source:\n" +
161 source,
162 );
163 }
164
165 if (capture.index) {
166 // If present and non-zero, i.e. a non-^ regexp result:
167 throw new Error(
168 "`match` must return a capture starting at index 0 " +
169 "(the current parse index). Did you forget a ^ at the " +
170 "start of the RegExp?",
171 );
172 }
173
174 let parsed = rule.parse(capture, nestedParse, state);
175
176 // We maintain the same object here so that rules can
177 // store references to the objects they return and
178 // modify them later. (oops sorry! but this adds a lot
179 // of power--see reflinks.)
180
181 // We also let rules override the default type of
182 // their parsed node if they would like to, so that
183 // there can be a single output function for all links,
184 // even if there are several rules to parse them.
185 if (!parsed.type) {
186 parsed.type = ruleType;
187 }
188
189 // Collapse text nodes
190 if (
191 parsed.type === "text" && result[result.length - 1]?.type === "text"
192 ) {
193 result[result.length - 1].content += parsed.content;
194 } else {
195 result.push(parsed as ASTNode);
196 }
197
198 state.prevCapture = capture;
199 source = source.substring(state.prevCapture[0].length);
200 }
201
202 return result;
203 };
204
205 let outerParse = function (
206 source: string,
207 state: ParserState = { inline: false },
208 ) {
209 latestState = populateInitialState(state, defaultState);
210
211 if (!latestState.inline && !latestState.disableAutoBlockNewlines) {
212 source = source + "\n\n";
213 }
214
215 // We store the previous capture so that match functions can
216 // use some limited amount of lookbehind. Lists use this to
217 // ensure they don't match arbitrary '- ' or '* ' in inline
218 // text (see the list rule for more information). This stores
219 // the full regex capture object, if there is one.
220 latestState.prevCapture = undefined;
221 return nestedParse(preprocess(source), latestState);
222 };
223
224 return outerParse;
225}
226
227type Multiple<T> = T | T[];
228type Nullable<T> = T | null | undefined;
229
230export type MatchFunction = (
231 source: string,
232 state: ParserState,
233) => Nullable<RegExpMatchArray>;
234
235export type Parser = (source: string, state?: ParserState) => ASTNode[];
236
237export type ParseFunction = (
238 source: RegExpMatchArray,
239 nestedParse: Parser,
240 state: ParserState,
241) => TypeOptionalASTNode;
242
243export type QualityFunction = (
244 capture: RegExpMatchArray,
245 state: ParserState,
246) => number;
247
248export interface ParserState {
249 inline: boolean;
250 prevCapture?: RegExpMatchArray;
251 [key: string]: any;
252}
253
254export interface ASTNode {
255 type: string;
256 content?: ASTNode[] | string;
257 [key: string]: any;
258}
259
260export type TypeOptionalASTNode = Omit<ASTNode, "type"> & { type?: string };
261
262export interface RefNode {
263 type: string;
264 content?: Multiple<ASTNode>;
265 target?: string;
266 title?: string;
267 alt?: string;
268}
269
270/** Creates a match function for an inline scoped element from a regex */
271export function inlineRegex(regex: RegExp): MatchFunction {
272 return (source, state) => {
273 if (state.inline) {
274 return regex.exec(source);
275 } else {
276 return null;
277 }
278 };
279}
280
281/** Creates a match function for a block scoped element from a regex */
282export function blockRegex(regex: RegExp): MatchFunction {
283 return (source, state) => {
284 if (state.inline) {
285 return null;
286 } else {
287 return regex.exec(source);
288 }
289 };
290}
291
292/** Creates a match function from a regex, ignoring block/inline scope */
293export function anyScopeRegex(regex: RegExp): MatchFunction {
294 return (source) => {
295 return regex.exec(source);
296 };
297}
298
299const UNESCAPE_URL_R = /\\([^0-9A-Za-z\s])/g;
300export function unescapeUrl(rawUrlString: string) {
301 return rawUrlString.replace(UNESCAPE_URL_R, "$1");
302}
303
304/**
305 * Parse some content with the parser `parse`, with state.inline
306 * set to true. Useful for block elements; not generally necessary
307 * to be used by inline elements (where state.inline is already true.
308 */
309export function parseInline(
310 parse: Parser,
311 content: string,
312 state: ParserState,
313) {
314 const isCurrentlyInline = state.inline || false;
315 state.inline = true;
316 const result = parse(content, state);
317 state.inline = isCurrentlyInline;
318 return result;
319}
320
321export function parseBlock(parse: Parser, content: string, state: ParserState) {
322 const isCurrentlyInline = state.inline || false;
323 state.inline = false;
324 const result = parse(content + "\n\n", state);
325 state.inline = isCurrentlyInline;
326 return result;
327}
328
329export function parseCaptureInline(
330 capture: RegExpMatchArray,
331 parse: Parser,
332 state: ParserState,
333) {
334 return {
335 content: parseInline(parse, capture[1], state),
336 };
337}
338
339export function ignoreCapture() {
340 return {};
341}
342
343export function sanitizeUrl(url?: string) {
344 if (url == null) {
345 return null;
346 }
347 try {
348 const prot = new URL(url, "https://localhost").protocol;
349 if (
350 prot.indexOf("javascript:") === 0 ||
351 prot.indexOf("vbscript:") === 0 ||
352 prot.indexOf("data:") === 0
353 ) {
354 return null;
355 }
356 } catch (e) {
357 // invalid URLs should throw a TypeError
358 // see for instance: `new URL("");`
359 return null;
360 }
361 return url;
362}
363
364const CR_NEWLINE_R = /\r\n?/g;
365const TAB_R = /\t/g;
366const FORMFEED_R = /\f/g;
367
368/**
369 * Turn various whitespace into easy-to-process whitespace
370 */
371function preprocess(source: string) {
372 return source.replace(CR_NEWLINE_R, "\n").replace(FORMFEED_R, "").replace(
373 TAB_R,
374 " ",
375 );
376}
377
378function populateInitialState(
379 givenState: Partial<ParserState>,
380 defaultState: Partial<ParserState>,
381) {
382 let state = givenState || {};
383
384 for (let prop in defaultState) {
385 if (Object.prototype.hasOwnProperty.call(defaultState, prop)) {
386 state[prop] = defaultState[prop];
387 }
388 }
389
390 return state as ParserState;
391}
392
393// recognize a `*` `-`, `+`, `1.`, `2.`... list bullet
394const LIST_BULLET = "(?:[*+-]|\\d+\\.)";
395
396// recognize the start of a list item:
397// leading space plus a bullet plus a space (` * `)
398const LIST_ITEM_PREFIX = "( *)(" + LIST_BULLET + ") +";
399const LIST_ITEM_PREFIX_R = new RegExp("^" + LIST_ITEM_PREFIX);
400
401// recognize an individual list item:
402// * hi
403// this is part of the same item
404//
405// as is this, which is a new paragraph in the same item
406//
407// * but this is not part of the same item
408const LIST_ITEM_R = new RegExp(
409 LIST_ITEM_PREFIX + "[^\\n]*(?:\\n" + "(?!\\1" + LIST_BULLET +
410 " )[^\\n]*)*(\n|$)",
411 "gm",
412);
413const BLOCK_END_R = /\n{2,}$/;
414const INLINE_CODE_ESCAPE_BACKTICKS_R = /^ (?= *`)|(` *) $/g;
415
416// recognize the end of a paragraph block inside a list item:
417// two or more newlines at end end of the item
418const LIST_BLOCK_END_R = BLOCK_END_R;
419const LIST_ITEM_END_R = / *\n+$/;
420
421// check whether a list item has paragraphs: if it does,
422// we leave the newlines at the end
423const LIST_R = new RegExp(
424 "^( *)(" +
425 LIST_BULLET +
426 ") " +
427 "[\\s\\S]+?(?:\n{2,}(?! )" +
428 "(?!\\1" +
429 LIST_BULLET +
430 " )\\n*" +
431 // the \\s*$ here is so that we can parse the inside of nested
432 // lists, where our content might end before we receive two `\n`s
433 "|\\s*\n*$)",
434);
435const LIST_LOOKBEHIND_R = /(?:^|\n)( *)$/;
436
437const TABLES = (function () {
438 const TABLE_ROW_SEPARATOR_TRIM = /^ *\| *| *\| *$/g;
439 const TABLE_CELL_END_TRIM = / *$/;
440 const TABLE_RIGHT_ALIGN = /^ *-+: *$/;
441 const TABLE_CENTER_ALIGN = /^ *:-+: *$/;
442 const TABLE_LEFT_ALIGN = /^ *:-+ *$/; // TODO: This needs a real type
443
444 const parseTableAlignCapture = (alignCapture: string) => {
445 if (TABLE_RIGHT_ALIGN.test(alignCapture)) {
446 return "right";
447 } else if (TABLE_CENTER_ALIGN.test(alignCapture)) {
448 return "center";
449 } else if (TABLE_LEFT_ALIGN.test(alignCapture)) {
450 return "left";
451 } else {
452 return null;
453 }
454 };
455
456 const parseTableAlign = (source: string, trimEndSeparators: boolean) => {
457 if (trimEndSeparators) {
458 source = source.replace(TABLE_ROW_SEPARATOR_TRIM, "");
459 }
460
461 const alignText = source.trim().split("|");
462 return alignText.map(parseTableAlignCapture);
463 };
464
465 const parseTableRow = (
466 source: string,
467 parse: Parser,
468 state: ParserState,
469 trimEndSeparators: boolean,
470 ) => {
471 const prevInTable = state.inTable;
472 state.inTable = true;
473 const tableRow = parse(source.trim(), state);
474 state.inTable = prevInTable;
475 const cells: ASTNode[][] = [[]];
476 tableRow.forEach(function (node, i) {
477 if (node.type === "tableSeparator") {
478 // Filter out empty table separators at the start/end:
479 if (!trimEndSeparators || (i !== 0 && i !== tableRow.length - 1)) {
480 // Split the current row:
481 cells.push([]);
482 }
483 } else {
484 if (
485 typeof node.content === "string" &&
486 (tableRow[i + 1] == null || tableRow[i + 1].type === "tableSeparator")
487 ) {
488 node.content = node.content.replace(TABLE_CELL_END_TRIM, "");
489 }
490
491 cells[cells.length - 1].push(node);
492 }
493 });
494 return cells;
495 };
496
497 /**
498 * @param {string} source
499 * @param {SimpleMarkdown.Parser} parse
500 * @param {SimpleMarkdown.State} state
501 * @param {boolean} trimEndSeparators
502 * @returns {SimpleMarkdown.ASTNode[][]}
503 */
504 const parseTableCells = function (
505 source: string,
506 parse: Parser,
507 state: ParserState,
508 trimEndSeparators: boolean,
509 ) {
510 const rowsText = source.trim().split("\n");
511 return rowsText.map(function (rowText) {
512 return parseTableRow(rowText, parse, state, trimEndSeparators);
513 });
514 };
515
516 /**
517 * @param {boolean} trimEndSeparators
518 * @returns {SimpleMarkdown.SingleNodeParseFunction}
519 */
520 const parseTable = function (trimEndSeparators: boolean) {
521 return function (
522 capture: RegExpMatchArray,
523 parse: Parser,
524 state: ParserState,
525 ) {
526 state.inline = true;
527 const header = parseTableRow(capture[1], parse, state, trimEndSeparators);
528 const align = parseTableAlign(capture[2], trimEndSeparators);
529 const cells = parseTableCells(
530 capture[3],
531 parse,
532 state,
533 trimEndSeparators,
534 );
535 state.inline = false;
536 return {
537 type: "table",
538 header: header,
539 align: align,
540 cells: cells,
541 };
542 };
543 };
544
545 return {
546 parseTable: parseTable(true),
547 parseNpTable: parseTable(false),
548 TABLE_REGEX: /^ *(\|.+)\n *\|( *[-:]+[-| :]*)\n((?: *\|.*(?:\n|$))*)\n*/,
549 NPTABLE_REGEX:
550 /^ *(\S.*\|.*)\n *([-:]+ *\|[-| :]*)\n((?:.*\|.*(?:\n|$))*)\n*/,
551 };
552})();
553
554const LINK_INSIDE = "(?:\\[[^\\]]*\\]|[^\\[\\]]|\\](?=[^\\[]*\\]))*";
555const LINK_HREF_AND_TITLE =
556 "\\s*<?((?:\\([^)]*\\)|[^\\s\\\\]|\\\\.)*?)>?(?:\\s+['\"]([\\s\\S]*?)['\"])?\\s*";
557const AUTOLINK_MAILTO_CHECK_R = /mailto:/i;
558
559function parseRef(
560 capture: RegExpMatchArray,
561 state: ParserState,
562 refNode: RefNode,
563) {
564 const ref = (capture[2] || capture[1]).replace(/\s+/g, " ").toLowerCase();
565
566 // We store information about previously seen defs on
567 // state._defs (_ to deconflict with client-defined
568 // state). If the def for this reflink/refimage has
569 // already been seen, we can use its target/source
570 // and title here:
571 if (state._defs && state._defs[ref]) {
572 const def = state._defs[ref];
573
574 // `refNode` can be a link or an image. Both use
575 // target and title properties.
576 refNode.target = def.target;
577 refNode.title = def.title;
578 }
579
580 // In case we haven't seen our def yet (or if someone
581 // overwrites that def later on), we add this node
582 // to the list of ref nodes for that def. Then, when
583 // we find the def, we can modify this link/image AST
584 // node :).
585 // I'm sorry.
586 state._refs = state._refs || {};
587 state._refs[ref] = state._refs[ref] || [];
588
589 state._refs[ref].push(refNode);
590
591 return refNode;
592}
593
594export const defaultRules = new RuleList();
595{
596 defaultRules.add({
597 name: "heading",
598 match: blockRegex(/^ *(#{1,6})([^\n]+?)#* *(?:\n *)+\n/),
599 parse: function (capture, parse, state) {
600 return {
601 level: capture[1].length,
602 content: parseInline(parse, capture[2].trim(), state),
603 };
604 },
605 });
606
607 defaultRules.add({
608 name: "nptable",
609 match: blockRegex(TABLES.NPTABLE_REGEX),
610 parse: TABLES.parseNpTable,
611 });
612
613 defaultRules.add({
614 name: "lheading",
615 match: blockRegex(/^([^\n]+)\n *(=|-){3,} *(?:\n *)+\n/),
616 parse(capture, parse, state) {
617 return {
618 type: "heading",
619 level: capture[2] === "=" ? 1 : 2,
620 content: parseInline(parse, capture[1], state),
621 };
622 },
623 });
624
625 defaultRules.add({
626 name: "hr",
627 match: blockRegex(/^( *[-*_]){3,} *(?:\n *)+\n/),
628 parse: ignoreCapture,
629 });
630
631 defaultRules.add({
632 name: "codeBlock",
633 match: blockRegex(/^(?: {4}[^\n]+\n*)+(?:\n *)+\n/),
634 parse(capture) {
635 const content = capture[0].replace(/^ {4}/gm, "").replace(/\n+$/, "");
636 return {
637 lang: undefined,
638 content: content,
639 };
640 },
641 });
642
643 defaultRules.add({
644 name: "fence",
645 match: blockRegex(
646 /^ *(`{3,}|~{3,}) *(?:(\S+) *)?\n([\s\S]+?)\n?\1 *(?:\n *)+\n/,
647 ),
648 parse(capture) {
649 return {
650 type: "codeBlock",
651 lang: capture[2] || undefined,
652 content: capture[3],
653 };
654 },
655 });
656
657 defaultRules.add({
658 name: "blockQuote",
659 match: blockRegex(/^( *>[^\n]+(\n[^\n]+)*\n*)+\n{2,}/),
660 parse(capture, parse, state) {
661 const content = capture[0].replace(/^ *> ?/gm, "");
662 return {
663 content: parse(content, state),
664 };
665 },
666 });
667
668 defaultRules.add({
669 name: "list",
670
671 match(source, state) {
672 // We only want to break into a list if we are at the start of a
673 // line. This is to avoid parsing "hi * there" with "* there"
674 // becoming a part of a list.
675 // You might wonder, "but that's inline, so of course it wouldn't
676 // start a list?". You would be correct! Except that some of our
677 // lists can be inline, because they might be inside another list,
678 // in which case we can parse with inline scope, but need to allow
679 // nested lists inside this inline scope.
680 const prevCaptureStr = state.prevCapture == null
681 ? ""
682 : state.prevCapture[0];
683 const isStartOfLineCapture = LIST_LOOKBEHIND_R.exec(prevCaptureStr);
684 const isListBlock = state._list || !state.inline;
685
686 if (isStartOfLineCapture && isListBlock) {
687 source = isStartOfLineCapture[1] + source;
688 return LIST_R.exec(source);
689 } else {
690 return null;
691 }
692 },
693 parse(capture, parse, state) {
694 const bullet = capture[2];
695 const ordered = bullet.length > 1;
696 const start = ordered ? +bullet : undefined;
697 // We know this will match here, because of how the regexes are defined
698 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
699 const items = capture[0].replace(LIST_BLOCK_END_R, "\n").match(
700 LIST_ITEM_R,
701 )!;
702
703 let lastItemWasAParagraph = false;
704 const itemContent = items.map(function (item, i) {
705 // We need to see how far indented this item is:
706 const prefixCapture = LIST_ITEM_PREFIX_R.exec(item);
707 const space = prefixCapture ? prefixCapture[0].length : 0; // And then we construct a regex to "unindent" the subsequent
708 // lines of the items by that amount:
709
710 const spaceRegex = new RegExp("^ {1," + space + "}", "gm"); // Before processing the item, we need a couple things
711
712 const content = item // remove indents on trailing lines:
713 .replace(spaceRegex, "") // remove the bullet:
714 .replace(LIST_ITEM_PREFIX_R, ""); // I'm not sur4 why this is necessary again?
715 // Handling "loose" lists, like:
716 //
717 // * this is wrapped in a paragraph
718 //
719 // * as is this
720 //
721 // * as is this
722
723 const isLastItem = i === items.length - 1;
724 const containsBlocks = content.indexOf("\n\n") !== -1; // Any element in a list is a block if it contains multiple
725 // newlines. The last element in the list can also be a block
726 // if the previous item in the list was a block (this is
727 // because non-last items in the list can end with \n\n, but
728 // the last item can't, so we just "inherit" this property
729 // from our previous element).
730
731 const thisItemIsAParagraph = containsBlocks ||
732 (isLastItem && lastItemWasAParagraph);
733 lastItemWasAParagraph = thisItemIsAParagraph; // backup our state for restoration afterwards. We're going to
734 // want to set state._list to true, and state.inline depending
735 // on our list's looseness.
736
737 const oldStateInline = state.inline;
738 const oldStateList = state._list;
739 state._list = true; // Parse inline if we're in a tight list, or block if we're in
740 // a loose list.
741
742 let adjustedContent;
743
744 if (thisItemIsAParagraph) {
745 state.inline = false;
746 adjustedContent = content.replace(LIST_ITEM_END_R, "\n\n");
747 } else {
748 state.inline = true;
749 adjustedContent = content.replace(LIST_ITEM_END_R, "");
750 }
751
752 const result = parse(adjustedContent, state); // Restore our state before returning
753
754 state.inline = oldStateInline;
755 state._list = oldStateList;
756 return result;
757 });
758
759 return {
760 ordered: ordered,
761 start: start,
762 content: itemContent,
763 };
764 },
765 });
766
767 defaultRules.add({
768 name: "def",
769 // TODO: This will match without a blank line before the next
770 // block element, which is inconsistent with most of the rest of
771 // simple-markdown.
772 match: blockRegex(
773 /^ *\[([^\]]+)\]: *<?([^\s>]*)>?(?: +["(]([^\n]+)[")])? *\n(?: *\n)*/,
774 ),
775 parse(capture, parse, state) {
776 const def = capture[1].replace(/\s+/g, " ").toLowerCase();
777 const target = capture[2];
778 const title = capture[3];
779
780 // Look for previous links/images using this def
781 // If any links/images using this def have already been declared,
782 // they will have added themselves to the state._refs[def] list
783 // (_ to deconflict with client-defined state). We look through
784 // that list of reflinks for this def, and modify those AST nodes
785 // with our newly found information now.
786 // Sorry :(.
787 if (state._refs && state._refs[def]) {
788 // `refNode` can be a link or an image
789 state._refs[def].forEach((refNode: RefNode) => {
790 refNode.target = target;
791 refNode.title = title;
792 });
793 }
794
795 // Add this def to our map of defs for any future links/images
796 // In case we haven't found any or all of the refs referring to
797 // this def yet, we add our def to the table of known defs, so
798 // that future reflinks can modify themselves appropriately with
799 // this information.
800 state._defs = state._defs || {};
801 state._defs[def] = {
802 target: target,
803 title: title,
804 };
805
806 // return the relevant parsed information
807 // for debugging only.
808 return {
809 def: def,
810 target: target,
811 title: title,
812 };
813 },
814 });
815
816 defaultRules.add({
817 name: "table",
818 match: blockRegex(TABLES.TABLE_REGEX),
819 parse: TABLES.parseTable,
820 });
821
822 defaultRules.add({
823 name: "newline",
824 match: blockRegex(/^(?:\n *)*\n/),
825 parse: ignoreCapture,
826 });
827
828 defaultRules.add({
829 name: "paragraph",
830 match: blockRegex(/^((?:[^\n]|\n(?! *\n))+)(?:\n *)+\n/),
831 parse: parseCaptureInline,
832 });
833
834 defaultRules.add({
835 name: "escape",
836 // We don't allow escaping numbers, letters, or spaces here so that
837��@VXLL p8�VxK<e@,vD�l3��5�� �???�????????�????????�??????????d�main.c�prop�gt�p���th���r�w�tU�n0tf�c�iwby�R
838 ��������������"\DlM�ain��Ke�xI�it0"a�izPP�rop� g0��o
839�`pAd�3264T���Ph�O�e�R��w��e�'�p{I*m�optPa�bP N�mP 1 �Ppac�O���N�q*fp cinC�Q*bpcko��ch�p�wSuPsys�p)e�VPr�5 �h��o���������������������ų`����j����(0��t� Pro�ces ��r�atPdpTh� VxK�x �vpr�i�n�Bi�1.12Ar428�(TR�l0aPe�)
840U�p�gPam�64-biP an�d�t�op��rptng��sy�1 _Q1�Ful �5� h2oR3E�XE:CPl\�� F�� s\G�tW�P� ow�\0 Zi
841g�1 \2 n��g�.p x0�ZC�m�� l�e]� 9� �-UPoppPgrms�q u�3p�0r�lp� =��1�-�i�n�e�=�t�h�QQ-z�u�r%l�%$
842#�ZS�c�q%f�y��q1i0 Qz� ���  q �%�.���p!%��Ifo�at%�e�e�s�vR�u�ePf2�a�i0��DP�s�bpeF��CP(i�d�0r�A�&p��*c�]Qcv$V�ru�oPf�� S�� 1;�/ss�o0�S��0P;0 �1Ak�"P�S0r0n�kR� d
843f�U14tlp
844ap#e�p DL�5�>o��&Q��� 0s"7�<�77�}7;��%~3pJ�<y?1;�N��Bq"qU�B�m��w�B��Q_} M�?r�F�SnuUw�@QQKc��1/c��rp9s��J��EP=q;pr AP 1Y�Xu0B\jUkP@2�`006�07h�tp�I�t�
845W]d�3[S#�S8a�\�Zb�P,m��q%�NP�2e�d\uU.06\v� � I p:qjl�� WiF0\bPn\;C:8Progam� �lesl�CdmTon 2jI"tJlJ�r�js�ofmv� �%ndw[SyCm3�2G!P)e�Sh]kv1�.0-!�M
846cAoiofFt�b�C P�-rE� o�ma�kc� T�?olk�!t�7PuрTY�U�=�bU��c�dl�+e�Cc�5r�s�\ATp�D� t�\|L�
847��y�Ё#P���t�%��\�`y�l*h�n��8�=S��zi�(t�>���"�"��iG�XKf\�0m�����.s@ o@Bp@A�s@Hi@�s��M�˾i@���o��YK�N�7bd��.j@w�Ga�Cp�Cn� ovU�0m�&c�Rr�aeb|%�"����j�����`"�``r�ZS c 0��f�lU` y�]i` i�TiU�[l� z d�D� ]L���w`a�cZs`b`g�.� T� :��w�� f
848�u� a{� s� a� U�m �a h�iA
849:
850b"�?�VxK� �x�"�64�I��sZm`n��w��e+e8-`sl��mf�k ;���yK�u�&�r`c��?rony^E GaCaR�<A��pU�Fu�x\�]d��- �ţ�.�(.7�-�Bo�s`=��*�k�%m&#��\1��b`Du/3!b��������~�~�~�f�Ke7�S>9��f?\��~� � �~ \�~~n�J�~!�*W]�~\�?1o�! P�Hq4{11�~o�~�~�3��~�~1��������_�?F
851�~r[pGq�h�&\�c��poP?�h�Um0�??�2i�J�����U��2�2���
852�
853�~q0&���~���uېr�wnT�S;�d=�?�py&@�pXR�.�|�qXQ\5y�p�rP�:�xAD�uAЫ�I��.�^т P�>�k�wa0v�}np�߇�+�L�Ѕ R!�KEP
854�NPL��bPQ��_Y(_P _��)�vP>��x���^&����dp3l��s����)����U0q1 �z�x���r�� W2_���t�ԋ��@p��m�ڱ�wk�+r��k;� w! p��4e��)����l�p��1� 
855o���/ ����q]�\0e_xl"r����1 UR зx00:7 ��20�Qi�00 (size:h117
85652<bytls)��j����
857&@�qThV DL kxn~.dl w"a� mp�p-d?Fu�ptE 5o eQ�"C�\ProgaM F�lOs\V5wK x 64 �"
858U�To�:d�d�4au�< ��x����7ŀ.E�6A�m�
859��3��168�#��"����KERN�LB��:S�ѢW@Mn@gVo@fANs�{s@@eq�T32�OW"�OD�@17�U�O43@N&2��c�&���&�R@owB�A��G�i�1p��r�4A��A�âGS->�kņDW�"�UN�l�Pb�c�npv-�M�jk�G��tMsm@n���w����A)n�tY,:;�_�6 �ob0z9 :7`"�b1-�:+{�:�=t���e�Vq;b�`|s�D�;�(���m �vc�1#��I`���.f�?��!�h Yt�P��i�?�~e ;�m?E#C��A-�1`�!E4 � 髧#k7r���2�h?�?�?��'+�'�'B;hm�l7`�sly'�j�L_-l0%�'{{�uX�'gy�'Q[m0 Sgl� �<t�� 0�~�Q�&E��E�&�?�6rz204�&?�`A�!��_0VsUt0��-l�@____ߎ�َ�_�|�_ip�\0
860~i�5��
861??�6]��C���_6�8��<Q�?=X-X6P��|јVБPI�#U_XxP"d0ya��is��0�g� �0mo�_g�ׄ��R_Wg_Ջ��1�9F�y��ag{_g����_S_g��mKP7��e�<\��a�ЩvPp0u �*�*�q)�gB���*3�6���*_��� �4�}�
862s��g�(��s�S�S_S__Y��SF?T4T8r�0pw?TwP�}q�c�P�o�`�S�'�'��_g
863d��w0\S�Y0T��M�C\P�e��h��5??�;�&��1gQ�6g?���&?��P�WR�h4[�o th�epDL:H"C:\Wind ows<s
864yt�m3"2DRPVR T4.Rl�l"
865��oa"e �t�0x0�7FED� (cize �1gk896Cb��s)4���j����P@�$�� � �ibpcr_2-�8�0�] �ta�?h m�Yp��_U�Ru�l�?p�eh-�t����P�1o�g�a�& �!i�!�e�.\G��t���m��g�A6��\�T}��\��Z�����-3G�{��M\61�c3M�4@9�[&\�+�R@Uw�=��h Ô �5m�R�o@t@� K@~hRN@LB�Ŏ U@c>@k@�b@ZsW�SyE(hm�^ve�~tH|-EA�t��Y�}N�zDh^1��DLI���b•\u��l@b�w��%G2� 68 C�-Gau6�t�7?u"u?G?G?G��?G �?Gr��D�����OTj� �\�u�e c.�ew"_����;�yGV`lK�k�x�����q?�#Be�^B @#��D1��7`/�;(knoUS�`RR��?(a��?(�G1�%�#07 1W��e�%0��4�n ���y�!�a�9&�}5mNG�LI�%pPF�_o_oL]?�?�3�������25_H۰,qS4��%8�%���9��f�%L���K_]߁`߁�%����������??;B0��1%5'3��7�U��$ �/�P��X�7d�$�P�7���s�/�����1�$C�$�$8�$q+Iߧ������7KWM9�ߦ��� 0J�����^צ��^�^����CPD�߀��30`80?&Qwj^8V^S�IO0�����r9�_^��?1�EPI1]94���1�M2~�Q͝&RP�W��R�F�LLL�͟^�����9}_dR__�:C��q1]�^�q2�L��^ym�&��T0M�PA�_I�Fu_d��dp"�A �K���� Wr0���9��29216 bytes)U���j����(L@ ��lTh� DL �CFGMR3�.d�l wa�� mppj�d�Fu>pjtM }o[ e+Y: "]:�\WinW�oSss�s�
866ms\�" �
867�okd� u� 0x�0��7�bE�D�4� � (�`iz��G2��184 �N��O�JEA^U����U!�ME@`2u�M8@L0��AV���jΛo@�e�@t�Űk���J7�}E�Ϙ1�Q9@�QKe�V�PKDEV�OBJ��?s/s1?&�#�rE�k5&�%6�%9���%Tq�Kk��n`Ct�%�������PrPog�a�� �`�i�Ya�V K �x���a\/�'U�'C�e7vs5bi4 t-sM+�DN�S [PIM�����Ls!ץ�1�%�%D I!J��s37�`�7`&/��%�%W�%�2_�/s�����%��%l�s��9�E��B�%�,�30�&ц�%�L��f߾9�%�%���tP7Q����dPw�~\0~y0��??;E�P1?%2P%68R%��l�p�b� sЖ-0_%/����?L�C�8\�?L7LG�tޚmPjnPOw�\��ќ\��Oi�s
\ No newline at end of file
1// @ts-nocheck
2// # Simple-Markdown Core
3//
4// This is a fork of Khan-academy's Simple-Markdown[1], initially forked in 2022
5// to add Svelte support[2], and used for paper clover's q+a markdown flavor.
6//
7// 1: https://github.com/Khan/perseus/tree/main/packages/simple-markdown/src
8// 2: https://github.com/paperclover/svelte-simple-markdown
9export type Rules = Record<string, ParserRule>;
10export interface ParserRule {
11 name: string;
12 match: MatchFunction;
13 parse: ParseFunction;
14 quality?: QualityFunction;
15}
16
17export class RuleList extends Array<ParserRule> {
18 constructor(input?: ArrayLike<ParserRule>) {
19 super();
20 if (input) {
21 this.push(...Array.from(input));
22 }
23 }
24
25 insertBefore(rule: string, newRule: ParserRule): void {
26 const index = this.findIndex((r) => r.name === rule);
27 if (index === -1) {
28 throw new Error(`Rule ${rule} not found`);
29 }
30 this.splice(index, 0, newRule);
31 }
32
33 insertAfter(rule: string, newRule: ParserRule): void {
34 const index = this.findIndex((r) => r.name === rule);
35 if (index === -1) {
36 throw new Error(`Rule ${rule} not found`);
37 }
38 this.splice(index + 1, 0, newRule);
39 }
40
41 toRuleObject(): Record<string, ParserRule> {
42 const result: Record<string, ParserRule> = {};
43 this.forEach((rule) => {
44 result[rule.name] = rule;
45 });
46 return result;
47 }
48
49 add(rule: ParserRule): void {
50 this.push(rule);
51 }
52
53 get(rule: string): ParserRule | undefined {
54 return this.find((r) => r.name === rule);
55 }
56
57 remove(rule: string): void {
58 const index = this.findIndex((r) => r.name === rule);
59 if (index === -1) {
60 throw new Error(`Rule ${rule} not found`);
61 }
62 this.splice(index, 1);
63 }
64
65 clone() {
66 return new RuleList(this);
67 }
68}
69
70/**
71 * Creates a parser for a given set of rules, with the precedence
72 * specified as a list of rules.
73 *
74 * @param rules
75 * an object containing
76 * rule type -> {match, order, parse} objects
77 * (lower order is higher precedence)
78 * @param [defaultState]
79 *
80 * @returns
81 * The resulting parse function, with the following parameters:
82 * @source: the input source string to be parsed
83 * @state: an optional object to be threaded through parse
84 * calls. Allows clients to add stateful operations to
85 * parsing, such as keeping track of how many levels deep
86 * some nesting is. For an example use-case, see passage-ref
87 * parsing in src/widgets/passage/passage-markdown.jsx
88 */
89export function createParser(
90 ruleListInput: RuleList,
91 defaultState: Partial<ParserState> = {},
92) {
93 let rules = ruleListInput.toRuleObject();
94 let ruleList = Object.keys(rules);
95
96 let latestState: ParserState;
97
98 let nestedParse = function (source: string, state?: ParserState) {
99 let result: ASTNode[] = [];
100 state = state || latestState;
101 latestState = state;
102
103 while (source) {
104 // store the best match, it's rule, and quality:
105 let ruleType = null;
106 let rule = null;
107 let capture = null;
108 let quality = NaN; // loop control variables:
109
110 let i = 0;
111 let currRuleType = ruleList[0];
112
113 let currRule = rules[currRuleType];
114
115 do {
116 let currCapture = currRule.match(source, state);
117
118 if (currCapture) {
119 let currQuality = currRule.quality
120 ? currRule.quality(currCapture, state)
121 : 0;
122
123 // This should always be true the first time because
124 // the initial quality is NaN (that's why there's the
125 // condition negation).
126 if (!(currQuality <= quality)) {
127 ruleType = currRuleType;
128 rule = currRule;
129 capture = currCapture;
130 quality = currQuality;
131 }
132 }
133
134 // Move on to the next item.
135 // Note that this makes `currRule` be the next item
136 i++;
137 currRuleType = ruleList[i];
138 currRule = rules[currRuleType];
139 } while (
140 // keep looping while we're still within the ruleList
141 currRule &&
142 // if we don't have a match yet, continue
143 (!capture ||
144 // or if we have a match, but the next rule is
145 // at the same order, and has a quality measurement
146 // functions, then this rule must have a quality
147 // measurement function (since they are sorted before
148 // those without), and we need to check if there is
149 // a better quality match
150 currRule.quality)
151 );
152
153 if (!rule || !capture || !ruleType) {
154 throw new Error(
155 "Could not find a matching rule for the below " +
156 "content. The rule with highest `order` should " +
157 "always match content provided to it. Check " +
158 "the definition of `match` for '" +
159 ruleList[ruleList.length - 1] +
160 "'. It seems to not match the following source:\n" +
161 source,
162 );
163 }
164
165 if (capture.index) {
166 // If present and non-zero, i.e. a non-^ regexp result:
167 throw new Error(
168 "`match` must return a capture starting at index 0 " +
169 "(the current parse index). Did you forget a ^ at the " +
170 "start of the RegExp?",
171 );
172 }
173
174 let parsed = rule.parse(capture, nestedParse, state);
175
176 // We maintain the same object here so that rules can
177 // store references to the objects they return and
178 // modify them later. (oops sorry! but this adds a lot
179 // of power--see reflinks.)
180
181 // We also let rules override the default type of
182 // their parsed node if they would like to, so that
183 // there can be a single output function for all links,
184 // even if there are several rules to parse them.
185 if (!parsed.type) {
186 parsed.type = ruleType;
187 }
188
189 // Collapse text nodes
190 if (
191 parsed.type === "text" && result[result.length - 1]?.type === "text"
192 ) {
193 result[result.length - 1].content += parsed.content;
194 } else {
195 result.push(parsed as ASTNode);
196 }
197
198 state.prevCapture = capture;
199 source = source.substring(state.prevCapture[0].length);
200 }
201
202 return result;
203 };
204
205 let outerParse = function (
206 source: string,
207 state: ParserState = { inline: false },
208 ) {
209 latestState = populateInitialState(state, defaultState);
210
211 if (!latestState.inline && !latestState.disableAutoBlockNewlines) {
212 source = source + "\n\n";
213 }
214
215 // We store the previous capture so that match functions can
216 // use some limited amount of lookbehind. Lists use this to
217 // ensure they don't match arbitrary '- ' or '* ' in inline
218 // text (see the list rule for more information). This stores
219 // the full regex capture object, if there is one.
220 latestState.prevCapture = undefined;
221 return nestedParse(preprocess(source), latestState);
222 };
223
224 return outerParse;
225}
226
227type Multiple<T> = T | T[];
228type Nullable<T> = T | null | undefined;
229
230export type MatchFunction = (
231 source: string,
232 state: ParserState,
233) => Nullable<RegExpMatchArray>;
234
235export type Parser = (source: string, state?: ParserState) => ASTNode[];
236
237export type ParseFunction = (
238 source: RegExpMatchArray,
239 nestedParse: Parser,
240 state: ParserState,
241) => TypeOptionalASTNode;
242
243export type QualityFunction = (
244 capture: RegExpMatchArray,
245 state: ParserState,
246) => number;
247
248export interface ParserState {
249 inline: boolean;
250 prevCapture?: RegExpMatchArray;
251 [key: string]: any;
252}
253
254export interface ASTNode {
255 type: string;
256 content?: ASTNode[] | string;
257 [key: string]: any;
258}
259
260export type TypeOptionalASTNode = Omit<ASTNode, "type"> & { type?: string };
261
262export interface RefNode {
263 type: string;
264 content?: Multiple<ASTNode>;
265 target?: string;
266 title?: string;
267 alt?: string;
268}
269
270/** Creates a match function for an inline scoped element from a regex */
271export function inlineRegex(regex: RegExp): MatchFunction {
272 return (source, state) => {
273 if (state.inline) {
274 return regex.exec(source);
275 } else {
276 return null;
277 }
278 };
279}
280
281/** Creates a match function for a block scoped element from a regex */
282export function blockRegex(regex: RegExp): MatchFunction {
283 return (source, state) => {
284 if (state.inline) {
285 return null;
286 } else {
287 return regex.exec(source);
288 }
289 };
290}
291
292/** Creates a match function from a regex, ignoring block/inline scope */
293export function anyScopeRegex(regex: RegExp): MatchFunction {
294 return (source) => {
295 return regex.exec(source);
296 };
297}
298
299const UNESCAPE_URL_R = /\\([^0-9A-Za-z\s])/g;
300export function unescapeUrl(rawUrlString: string) {
301 return rawUrlString.replace(UNESCAPE_URL_R, "$1");
302}
303
304/**
305 * Parse some content with the parser `parse`, with state.inline
306 * set to true. Useful for block elements; not generally necessary
307 * to be used by inline elements (where state.inline is already true.
308 */
309export function parseInline(
310 parse: Parser,
311 content: string,
312 state: ParserState,
313) {
314 const isCurrentlyInline = state.inline || false;
315 state.inline = true;
316 const result = parse(content, state);
317 state.inline = isCurrentlyInline;
318 return result;
319}
320
321export function parseBlock(parse: Parser, content: string, state: ParserState) {
322 const isCurrentlyInline = state.inline || false;
323 state.inline = false;
324 const result = parse(content + "\n\n", state);
325 state.inline = isCurrentlyInline;
326 return result;
327}
328
329export function parseCaptureInline(
330 capture: RegExpMatchArray,
331 parse: Parser,
332 state: ParserState,
333) {
334 return {
335 content: parseInline(parse, capture[1], state),
336 };
337}
338
339export function ignoreCapture() {
340 return {};
341}
342
343export function sanitizeUrl(url?: string) {
344 if (url == null) {
345 return null;
346 }
347 try {
348 const prot = new URL(url, "https://localhost").protocol;
349 if (
350 prot.indexOf("javascript:") === 0 ||
351 prot.indexOf("vbscript:") === 0 ||
352 prot.indexOf("data:") === 0
353 ) {
354 return null;
355 }
356 } catch (e) {
357 // invalid URLs should throw a TypeError
358 // see for instance: `new URL("");`
359 return null;
360 }
361 return url;
362}
363
364const CR_NEWLINE_R = /\r\n?/g;
365const TAB_R = /\t/g;
366const FORMFEED_R = /\f/g;
367
368/**
369 * Turn various whitespace into easy-to-process whitespace
370 */
371function preprocess(source: string) {
372 return source.replace(CR_NEWLINE_R, "\n").replace(FORMFEED_R, "").replace(
373 TAB_R,
374 " ",
375 );
376}
377
378function populateInitialState(
379 givenState: Partial<ParserState>,
380 defaultState: Partial<ParserState>,
381) {
382 let state = givenState || {};
383
384 for (let prop in defaultState) {
385 if (Object.prototype.hasOwnProperty.call(defaultState, prop)) {
386 state[prop] = defaultState[prop];
387 }
388 }
389
390 return state as ParserState;
391}
392
393// recognize a `*` `-`, `+`, `1.`, `2.`... list bullet
394const LIST_BULLET = "(?:[*+-]|\\d+\\.)";
395
396// recognize the start of a list item:
397// leading space plus a bullet plus a space (` * `)
398const LIST_ITEM_PREFIX = "( *)(" + LIST_BULLET + ") +";
399const LIST_ITEM_PREFIX_R = new RegExp("^" + LIST_ITEM_PREFIX);
400
401// recognize an individual list item:
402// * hi
403// this is part of the same item
404//
405// as is this, which is a new paragraph in the same item
406//
407// * but this is not part of the same item
408const LIST_ITEM_R = new RegExp(
409 LIST_ITEM_PREFIX + "[^\\n]*(?:\\n" + "(?!\\1" + LIST_BULLET +
410 " )[^\\n]*)*(\n|$)",
411 "gm",
412);
413const BLOCK_END_R = /\n{2,}$/;
414const INLINE_CODE_ESCAPE_BACKTICKS_R = /^ (?= *`)|(` *) $/g;
415
416// recognize the end of a paragraph block inside a list item:
417// two or more newlines at end end of the item
418const LIST_BLOCK_END_R = BLOCK_END_R;
419const LIST_ITEM_END_R = / *\n+$/;
420
421// check whether a list item has paragraphs: if it does,
422// we leave the newlines at the end
423const LIST_R = new RegExp(
424 "^( *)(" +
425 LIST_BULLET +
426 ") " +
427 "[\\s\\S]+?(?:\n{2,}(?! )" +
428 "(?!\\1" +
429 LIST_BULLET +
430 " )\\n*" +
431 // the \\s*$ here is so that we can parse the inside of nested
432 // lists, where our content might end before we receive two `\n`s
433 "|\\s*\n*$)",
434);
435const LIST_LOOKBEHIND_R = /(?:^|\n)( *)$/;
436
437const TABLES = (function () {
438 const TABLE_ROW_SEPARATOR_TRIM = /^ *\| *| *\| *$/g;
439 const TABLE_CELL_END_TRIM = / *$/;
440 const TABLE_RIGHT_ALIGN = /^ *-+: *$/;
441 const TABLE_CENTER_ALIGN = /^ *:-+: *$/;
442 const TABLE_LEFT_ALIGN = /^ *:-+ *$/; // TODO: This needs a real type
443
444 const parseTableAlignCapture = (alignCapture: string) => {
445 if (TABLE_RIGHT_ALIGN.test(alignCapture)) {
446 return "right";
447 } else if (TABLE_CENTER_ALIGN.test(alignCapture)) {
448 return "center";
449 } else if (TABLE_LEFT_ALIGN.test(alignCapture)) {
450 return "left";
451 } else {
452 return null;
453 }
454 };
455
456 const parseTableAlign = (source: string, trimEndSeparators: boolean) => {
457 if (trimEndSeparators) {
458 source = source.replace(TABLE_ROW_SEPARATOR_TRIM, "");
459 }
460
461 const alignText = source.trim().split("|");
462 return alignText.map(parseTableAlignCapture);
463 };
464
465 const parseTableRow = (
466 source: string,
467 parse: Parser,
468 state: ParserState,
469 trimEndSeparators: boolean,
470 ) => {
471 const prevInTable = state.inTable;
472 state.inTable = true;
473 const tableRow = parse(source.trim(), state);
474 state.inTable = prevInTable;
475 const cells: ASTNode[][] = [[]];
476 tableRow.forEach(function (node, i) {
477 if (node.type === "tableSeparator") {
478 // Filter out empty table separators at the start/end:
479 if (!trimEndSeparators || (i !== 0 && i !== tableRow.length - 1)) {
480 // Split the current row:
481 cells.push([]);
482 }
483 } else {
484 if (
485 typeof node.content === "string" &&
486 (tableRow[i + 1] == null || tableRow[i + 1].type === "tableSeparator")
487 ) {
488 node.content = node.content.replace(TABLE_CELL_END_TRIM, "");
489 }
490
491 cells[cells.length - 1].push(node);
492 }
493 });
494 return cells;
495 };
496
497 /**
498 * @param {string} source
499 * @param {SimpleMarkdown.Parser} parse
500 * @param {SimpleMarkdown.State} state
501 * @param {boolean} trimEndSeparators
502 * @returns {SimpleMarkdown.ASTNode[][]}
503 */
504 const parseTableCells = function (
505 source: string,
506 parse: Parser,
507 state: ParserState,
508 trimEndSeparators: boolean,
509 ) {
510 const rowsText = source.trim().split("\n");
511 return rowsText.map(function (rowText) {
512 return parseTableRow(rowText, parse, state, trimEndSeparators);
513 });
514 };
515
516 /**
517 * @param {boolean} trimEndSeparators
518 * @returns {SimpleMarkdown.SingleNodeParseFunction}
519 */
520 const parseTable = function (trimEndSeparators: boolean) {
521 return function (
522 capture: RegExpMatchArray,
523 parse: Parser,
524 state: ParserState,
525 ) {
526 state.inline = true;
527 const header = parseTableRow(capture[1], parse, state, trimEndSeparators);
528 const align = parseTableAlign(capture[2], trimEndSeparators);
529 const cells = parseTableCells(
530 capture[3],
531 parse,
532 state,
533 trimEndSeparators,
534 );
535 state.inline = false;
536 return {
537 type: "table",
538 header: header,
539 align: align,
540 cells: cells,
541 };
542 };
543 };
544
545 return {
546 parseTable: parseTable(true),
547 parseNpTable: parseTable(false),
548 TABLE_REGEX: /^ *(\|.+)\n *\|( *[-:]+[-| :]*)\n((?: *\|.*(?:\n|$))*)\n*/,
549 NPTABLE_REGEX:
550 /^ *(\S.*\|.*)\n *([-:]+ *\|[-| :]*)\n((?:.*\|.*(?:\n|$))*)\n*/,
551 };
552})();
553
554const LINK_INSIDE = "(?:\\[[^\\]]*\\]|[^\\[\\]]|\\](?=[^\\[]*\\]))*";
555const LINK_HREF_AND_TITLE =
556 "\\s*<?((?:\\([^)]*\\)|[^\\s\\\\]|\\\\.)*?)>?(?:\\s+['\"]([\\s\\S]*?)['\"])?\\s*";
557const AUTOLINK_MAILTO_CHECK_R = /mailto:/i;
558
559function parseRef(
560 capture: RegExpMatchArray,
561 state: ParserState,
562 refNode: RefNode,
563) {
564 const ref = (capture[2] || capture[1]).replace(/\s+/g, " ").toLowerCase();
565
566 // We store information about previously seen defs on
567 // state._defs (_ to deconflict with client-defined
568 // state). If the def for this reflink/refimage has
569 // already been seen, we can use its target/source
570 // and title here:
571 if (state._defs && state._defs[ref]) {
572 const def = state._defs[ref];
573
574 // `refNode` can be a link or an image. Both use
575 // target and title properties.
576 refNode.target = def.target;
577 refNode.title = def.title;
578 }
579
580 // In case we haven't seen our def yet (or if someone
581 // overwrites that def later on), we add this node
582 // to the list of ref nodes for that def. Then, when
583 // we find the def, we can modify this link/image AST
584 // node :).
585 // I'm sorry.
586 state._refs = state._refs || {};
587 state._refs[ref] = state._refs[ref] || [];
588
589 state._refs[ref].push(refNode);
590
591 return refNode;
592}
593
594export const defaultRules = new RuleList();
595{
596 defaultRules.add({
597 name: "heading",
598 match: blockRegex(/^ *(#{1,6})([^\n]+?)#* *(?:\n *)+\n/),
599 parse: function (capture, parse, state) {
600 return {
601 level: capture[1].length,
602 content: parseInline(parse, capture[2].trim(), state),
603 };
604 },
605 });
606
607 defaultRules.add({
608 name: "nptable",
609 match: blockRegex(TABLES.NPTABLE_REGEX),
610 parse: TABLES.parseNpTable,
611 });
612
613 defaultRules.add({
614 name: "lheading",
615 match: blockRegex(/^([^\n]+)\n *(=|-){3,} *(?:\n *)+\n/),
616 parse(capture, parse, state) {
617 return {
618 type: "heading",
619 level: capture[2] === "=" ? 1 : 2,
620 content: parseInline(parse, capture[1], state),
621 };
622 },
623 });
624
625 defaultRules.add({
626 name: "hr",
627 match: blockRegex(/^( *[-*_]){3,} *(?:\n *)+\n/),
628 parse: ignoreCapture,
629 });
630
631 defaultRules.add({
632 name: "codeBlock",
633 match: blockRegex(/^(?: {4}[^\n]+\n*)+(?:\n *)+\n/),
634 parse(capture) {
635 const content = capture[0].replace(/^ {4}/gm, "").replace(/\n+$/, "");
636 return {
637 lang: undefined,
638 content: content,
639 };
640 },
641 });
642
643 defaultRules.add({
644 name: "fence",
645 match: blockRegex(
646 /^ *(`{3,}|~{3,}) *(?:(\S+) *)?\n([\s\S]+?)\n?\1 *(?:\n *)+\n/,
647 ),
648 parse(capture) {
649 return {
650 type: "codeBlock",
651 lang: capture[2] || undefined,
652 content: capture[3],
653 };
654 },
655 });
656
657 defaultRules.add({
658 name: "blockQuote",
659 match: blockRegex(/^( *>[^\n]+(\n[^\n]+)*\n*)+\n{2,}/),
660 parse(capture, parse, state) {
661 const content = capture[0].replace(/^ *> ?/gm, "");
662 return {
663 content: parse(content, state),
664 };
665 },
666 });
667
668 defaultRules.add({
669 name: "list",
670
671 match(source, state) {
672 // We only want to break into a list if we are at the start of a
673 // line. This is to avoid parsing "hi * there" with "* there"
674 // becoming a part of a list.
675 // You might wonder, "but that's inline, so of course it wouldn't
676 // start a list?". You would be correct! Except that some of our
677 // lists can be inline, because they might be inside another list,
678 // in which case we can parse with inline scope, but need to allow
679 // nested lists inside this inline scope.
680 const prevCaptureStr = state.prevCapture == null
681 ? ""
682 : state.prevCapture[0];
683 const isStartOfLineCapture = LIST_LOOKBEHIND_R.exec(prevCaptureStr);
684 const isListBlock = state._list || !state.inline;
685
686 if (isStartOfLineCapture && isListBlock) {
687 source = isStartOfLineCapture[1] + source;
688 return LIST_R.exec(source);
689 } else {
690 return null;
691 }
692 },
693 parse(capture, parse, state) {
694 const bullet = capture[2];
695 const ordered = bullet.length > 1;
696 const start = ordered ? +bullet : undefined;
697 // We know this will match here, because of how the regexes are defined
698 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
699 const items = capture[0].replace(LIST_BLOCK_END_R, "\n").match(
700 LIST_ITEM_R,
701 )!;
702
703 let lastItemWasAParagraph = false;
704 const itemContent = items.map(function (item, i) {
705 // We need to see how far indented this item is:
706 const prefixCapture = LIST_ITEM_PREFIX_R.exec(item);
707 const space = prefixCapture ? prefixCapture[0].length : 0; // And then we construct a regex to "unindent" the subsequent
708 // lines of the items by that amount:
709
710 const spaceRegex = new RegExp("^ {1," + space + "}", "gm"); // Before processing the item, we need a couple things
711
712 const content = item // remove indents on trailing lines:
713 .replace(spaceRegex, "") // remove the bullet:
714 .replace(LIST_ITEM_PREFIX_R, ""); // I'm not sur4 why this is necessary again?
715 // Handling "loose" lists, like:
716 //
717 // * this is wrapped in a paragraph
718 //
719 // * as is this
720 //
721 // * as is this
722
723 const isLastItem = i === items.length - 1;
724 const containsBlocks = content.indexOf("\n\n") !== -1; // Any element in a list is a block if it contains multiple
725 // newlines. The last element in the list can also be a block
726 // if the previous item in the list was a block (this is
727 // because non-last items in the list can end with \n\n, but
728 // the last item can't, so we just "inherit" this property
729 // from our previous element).
730
731 const thisItemIsAParagraph = containsBlocks ||
732 (isLastItem && lastItemWasAParagraph);
733 lastItemWasAParagraph = thisItemIsAParagraph; // backup our state for restoration afterwards. We're going to
734 // want to set state._list to true, and state.inline depending
735 // on our list's looseness.
736
737 const oldStateInline = state.inline;
738 const oldStateList = state._list;
739 state._list = true; // Parse inline if we're in a tight list, or block if we're in
740 // a loose list.
741
742 let adjustedContent;
743
744 if (thisItemIsAParagraph) {
745 state.inline = false;
746 adjustedContent = content.replace(LIST_ITEM_END_R, "\n\n");
747 } else {
748 state.inline = true;
749 adjustedContent = content.replace(LIST_ITEM_END_R, "");
750 }
751
752 const result = parse(adjustedContent, state); // Restore our state before returning
753
754 state.inline = oldStateInline;
755 state._list = oldStateList;
756 return result;
757 });
758
759 return {
760 ordered: ordered,
761 start: start,
762 content: itemContent,
763 };
764 },
765 });
766
767 defaultRules.add({
768 name: "def",
769 // TODO: This will match without a blank line before the next
770 // block element, which is inconsistent with most of the rest of
771 // simple-markdown.
772 match: blockRegex(
773 /^ *\[([^\]]+)\]: *<?([^\s>]*)>?(?: +["(]([^\n]+)[")])? *\n(?: *\n)*/,
774 ),
775 parse(capture, parse, state) {
776 const def = capture[1].replace(/\s+/g, " ").toLowerCase();
777 const target = capture[2];
778 const title = capture[3];
779
780 // Look for previous links/images using this def
781 // If any links/images using this def have already been declared,
782 // they will have added themselves to the state._refs[def] list
783 // (_ to deconflict with client-defined state). We look through
784 // that list of reflinks for this def, and modify those AST nodes
785 // with our newly found information now.
786 // Sorry :(.
787 if (state._refs && state._refs[def]) {
788 // `refNode` can be a link or an image
789 state._refs[def].forEach((refNode: RefNode) => {
790 refNode.target = target;
791 refNode.title = title;
792 });
793 }
794
795 // Add this def to our map of defs for any future links/images
796 // In case we haven't found any or all of the refs referring to
797 // this def yet, we add our def to the table of known defs, so
798 // that future reflinks can modify themselves appropriately with
799 // this information.
800 state._defs = state._defs || {};
801 state._defs[def] = {
802 target: target,
803 title: title,
804 };
805
806 // return the relevant parsed information
807 // for debugging only.
808 return {
809 def: def,
810 target: target,
811 title: title,
812 };
813 },
814 });
815
816 defaultRules.add({
817 name: "table",
818 match: blockRegex(TABLES.TABLE_REGEX),
819 parse: TABLES.parseTable,
820 });
821
822 defaultRules.add({
823 name: "newline",
824 match: blockRegex(/^(?:\n *)*\n/),
825 parse: ignoreCapture,
826 });
827
828 defaultRules.add({
829 name: "paragraph",
830 match: blockRegex(/^((?:[^\n]|\n(?! *\n))+)(?:\n *)+\n/),
831 parse: parseCaptureInline,
832 });
833
834 defaultRules.add({
835 name: "escape",
836 // We don't allow escaping numbers, letters, or spaces here so that
837 // backslashes used in plain text still get rendered. But allowing
838 // escaping anything else provides a very flexible escape mechanism,
839 // regardless of how this grammar is extended.
840 match: inlineRegex(/^\\([^0-9A-Za-z\s])/),
841 parse(capture) {
842 return {
843 type: "text",
844 content: capture[1],
845 };
846 },
847 });
848
849 defaultRules.add({
850 name: "tableSeparator",
851
852 match(source, state) {
853 if (!state.inTable) {
854 return null;
855 }
856
857 return /^ *\| */.exec(source);
858 },
859 parse() {
860 return {
861 type: "tableSeparator",
862 };
863 },
864 });
865
866 defaultRules.add({
867 name: "autolink",
868 match: inlineRegex(/^<([^: >]+:\/[^ >]+)>/),
869 parse(capture) {
870 return {
871 type: "link",
872 content: [
873 {
874 type: "text",
875 content: capture[1],
876 },
877 ],
878 target: capture[1],
879 };
880 },
881 });
882
883 defaultRules.add({
884 name: "mailto",
885 match: inlineRegex(/^<([^ >]+@[^ >]+)>/),
886 parse(capture) {
887 const address = capture[1];
888 let target = capture[1]; // Check for a `mailto:` already existing in the link:
889
890 if (!AUTOLINK_MAILTO_CHECK_R.test(target)) {
891 target = "mailto:" + target;
892 }
893
894 return {
895 type: "link",
896 content: [
897 {
898 type: "text",
899 content: address,
900 },
901 ],
902 target: target,
903 };
904 },
905 });
906
907 defaultRules.add({
908 name: "url",
909 match: inlineRegex(/^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/),
910 parse(capture) {
911 return {
912 type: "link",
913 content: [
914 {
915 type: "text",
916 content: capture[1],
917 },
918 ],
919 target: capture[1],
920 title: undefined,
921 };
922 },
923 });
924
925 defaultRules.add({
926 name: "link",
927 match: inlineRegex(
928 new RegExp(
929 "^\\[(" + LINK_INSIDE + ")\\]\\(" + LINK_HREF_AND_TITLE + "\\)",
930 ),
931 ),
932 parse(capture, parse, state) {
933 const link = {
934 content: parse(capture[1], state),
935 target: unescapeUrl(capture[2]),
936 title: capture[3],
937 };
938 return link;
939 },
940 });
941
942 defaultRules.add({
943 name: "image",
944 match: inlineRegex(
945 new RegExp(
946 "^!\\[(" + LINK_INSIDE + ")\\]\\(" + LINK_HREF_AND_TITLE + "\\)",
947 ),
948 ),
949 parse: function (capture) {
950 const image = {
951 alt: capture[1],
952 target: unescapeUrl(capture[2]),
953 title: capture[3],
954 };
955 return image;
956 },
957 });
958
959 defaultRules.add({
960 name: "reflink",
961 match: inlineRegex(
962 new RegExp(
963 // The first [part] of the link
964 "^\\[(" +
965 LINK_INSIDE +
966 ")\\]" + // The [ref] target of the link
967 "\\s*\\[([^\\]]*)\\]",
968 ),
969 ),
970 parse(capture, parse, state) {
971 return parseRef(capture, state, {
972 type: "link",
973 content: parse(capture[1], state),
974 });
975 },
976 });
977
978 defaultRules.add({
979 name: "refimage",
980 match: inlineRegex(
981 new RegExp(
982 // The first [part] of the link
983 "^!\\[(" +
984 LINK_INSIDE +
985 ")\\]" + // The [ref] target of the link
986 "\\s*\\[([^\\]]*)\\]",
987 ),
988 ),
989 parse(capture, parse, state) {
990 return parseRef(capture, state, {
991 type: "image",
992 alt: capture[1],
993 });
994 },
995 });
996
997 defaultRules.add({
998 name: "em",
999 /* same as strong/u */
1000 match: inlineRegex(
1001 new RegExp(
1002 // only match _s surrounding words.
1003 "^\\b_" +
1004 "((?:__|\\\\[\\s\\S]|[^\\\\_])+?)_" +
1005 "\\b" + // Or match *s:
1006 "|" + // Only match *s that are followed by a non-space:
1007 "^\\*(?=\\S)(" + // Match at least one of:
1008 "(?:" + // - `**`: so that bolds inside italics don't close the
1009 // italics
1010 "\\*\\*|" + // - escape sequence: so escaped *s don't close us
1011 "\\\\[\\s\\S]|" + // - whitespace: followed by a non-* (we don't
1012 // want ' *' to close an italics--it might
1013 // start a list)
1014 "\\s+(?:\\\\[\\s\\S]|[^\\s\\*\\\\]|\\*\\*)|" + // - non-whitespace, non-*, non-backslash characters
1015 "[^\\s\\*\\\\]" +
1016 ")+?" + // followed by a non-space, non-* then *
1017 ")\\*(?!\\*)",
1018 ),
1019 ),
1020 quality(capture) {
1021 // precedence by length, `em` wins ties:
1022 return capture[0].length + 0.2;
1023 },
1024 parse(capture, parse, state) {
1025 return {
1026 content: parse(capture[2] || capture[1], state),
1027 };
1028 },
1029 });
1030
1031 defaultRules.add({
1032 name: "strong",
1033 /* same as em */
1034 match: inlineRegex(/^\*\*((?:\\[\s\S]|[^\\])+?)\*\*(?!\*)/),
1035 quality(capture) {
1036 // precedence by length, wins ties vs `u`:
1037 return capture[0].length + 0.1;
1038 },
1039 parse: parseCaptureInline,
1040 });
1041
1042 defaultRules.add({
1043 name: "u",
1044 /* same as em&strong; increment for next rule */
1045 match: inlineRegex(/^__((?:\\[\s\S]|[^\\])+?)__(?!_)/),
1046 quality(capture) {
1047 // precedence by length, loses all ties
1048 return capture[0].length;
1049 },
1050 parse: parseCaptureInline,
1051 });
1052
1053 defaultRules.add({
1054 name: "del",
1055 match: inlineRegex(/^~~(?=\S)((?:\\[\s\S]|~(?!~)|[^\s~\\]|\s(?!~~))+?)~~/),
1056 parse: parseCaptureInline,
1057 });
1058
1059 defaultRules.add({
1060 name: "inlineCode",
1061 match: inlineRegex(/^(`+)([\s\S]*?[^`])\1(?!`)/),
1062 parse(capture) {
1063 return {
1064 content: capture[2].replace(INLINE_CODE_ESCAPE_BACKTICKS_R, "$1"),
1065 };
1066 },
1067 });
1068
1069 defaultRules.add({
1070 name: "br",
1071 match: anyScopeRegex(/^ {2,}\n/),
1072 parse: ignoreCapture,
1073 });
1074
1075 defaultRules.add({
1076 name: "text",
1077 // Here we look for anything followed by non-symbols,
1078 // double newlines, or double-space-newlines
1079 // We break on any symbol characters so that this grammar
1080 // is easy to extend without needing to modify this regex
1081 match: anyScopeRegex(
1082 /^[\s\S]+?(?=[^0-9A-Za-z\s\u00c0-\uffff]|\n\n| {2,}\n|\w+:\S|$)/,
1083 ),
1084 parse: function (capture) {
1085 return {
1086 content: capture[0],
1087 };
1088 },
1089 });
1090}
src/q+a/views/backend-edit.tsx deleted
Binary files a/src/q+a/views/backend-edit.tsx and /dev/null differ
src/q+a/views/backend-inbox.client.ts deleted
Binary files a/src/q+a/views/backend-inbox.client.ts and /dev/null differ
src/q+a/views/permalink.tsx deleted
Binary files a/src/q+a/views/permalink.tsx and /dev/null differ
tsconfig.json+9-6
...@@ -1,15 +1,18 @@...@@ -1,15 +1,18 @@
1{1{
2 "compilerOptions": {2 "compilerOptions": {
3 "allowImportingTSExtensions": true,3 "allowImportingTsExtensions": true,
4 "baseDir": ".",4 "baseUrl": ".",
5 "incremental": true,5 "incremental": true,
6 "jsx": "react-jsx",
7 "jsxImportSource": "#ssr",
6 "lib": ["dom", "esnext", "esnext.iterator"],8 "lib": ["dom", "esnext", "esnext.iterator"],
7 "module": "nodenext",9 "module": "nodenext",
8 "outdir": ".clover/ts",10 "noEmit": true,
11 "outDir": ".clover/ts",
9 "paths": { "@/*": ["src/*"] },12 "paths": { "@/*": ["src/*"] },
13 "rootDir": ".",
14 "skipLibCheck": true,
10 "strict": true,15 "strict": true,
11 "target": "es2022",16 "target": "es2022"
12 "jsxImportSource": "#ssr",
13 "jsx": "react-jsx"
14 }17 }
15}18}