153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
export type MdxComponent = (props: Record<string, string>, children: string) => string;
|
|
|
|
const escapeHtml = (value: string) =>
|
|
value.replace(
|
|
/[&<>"']/g,
|
|
(character) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!,
|
|
);
|
|
|
|
/** Execute explicitly registered MDX components without evaluating arbitrary JavaScript. */
|
|
export function renderMdxComponents(
|
|
source: string,
|
|
components: Record<string, MdxComponent>,
|
|
): string {
|
|
let output = source;
|
|
const parseProps = (raw: string) =>
|
|
Object.fromEntries(
|
|
[...raw.matchAll(/([A-Za-z_$][\w$-]*)\s*=\s*["']([^"']*)["']/g)].map((match) => [
|
|
match[1]!,
|
|
match[2]!,
|
|
]),
|
|
);
|
|
for (let pass = 0; pass < 20; pass++) {
|
|
let changed = false;
|
|
output = output.replace(
|
|
/<([A-Z][A-Za-z0-9_$]*)\b([^>]*)>([\s\S]*?)<\/\1>|<([A-Z][A-Za-z0-9_$]*)\b([^>]*)\/>/g,
|
|
(whole, pairedName, pairedProps, children, singleName, singleProps) => {
|
|
const name = pairedName ?? singleName;
|
|
const component = components[name];
|
|
if (!component) throw new Error(`WRN-CONTENT-MDX-COMPONENT: '${name}' is not registered.`);
|
|
changed = true;
|
|
return component(parseProps(pairedProps ?? singleProps ?? ""), children ?? "");
|
|
},
|
|
);
|
|
if (!changed) break;
|
|
}
|
|
if (/<[A-Z][A-Za-z0-9_$]*\b/.test(output))
|
|
throw new Error("WRN-CONTENT-MDX-DEPTH: component expansion exceeded its bound.");
|
|
return output;
|
|
}
|
|
|
|
export interface SyntaxLanguageBundle {
|
|
highlight(source: string): string;
|
|
}
|
|
export function createIncrementalHighlighter(
|
|
loaders: Record<string, () => SyntaxLanguageBundle | Promise<SyntaxLanguageBundle>>,
|
|
) {
|
|
const loaded = new Map<string, Promise<SyntaxLanguageBundle>>();
|
|
return {
|
|
languages: () => [...loaded.keys()],
|
|
async highlight(language: string, source: string) {
|
|
const load = loaders[language];
|
|
if (!load) return escapeHtml(source);
|
|
let bundle = loaded.get(language);
|
|
if (!bundle) {
|
|
bundle = Promise.resolve(load());
|
|
loaded.set(language, bundle);
|
|
}
|
|
return (await bundle).highlight(source);
|
|
},
|
|
async render(html: string) {
|
|
const matches = [
|
|
...html.matchAll(/<pre><code class="language-([\w-]+)">([\s\S]*?)<\/code><\/pre>/g),
|
|
];
|
|
let output = html;
|
|
for (const match of matches)
|
|
output = output.replace(
|
|
match[0],
|
|
`<pre><code class="language-${match[1]}">${await this.highlight(match[1]!, match[2]!)}</code></pre>`,
|
|
);
|
|
return output;
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface VendorAdapterOptions {
|
|
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
token?: string;
|
|
map?: (entry: any) => { id: string; content: string; source?: string };
|
|
}
|
|
const requestJson = async (
|
|
url: string,
|
|
options: VendorAdapterOptions,
|
|
headers: HeadersInit = {},
|
|
) => {
|
|
const response = await (options.fetch ?? globalThis.fetch)(url, { headers });
|
|
if (!response.ok) throw new Error(`WRN-CONTENT-CMS: ${response.status}`);
|
|
return response.json();
|
|
};
|
|
const normalize = (entry: any, source: string, map?: VendorAdapterOptions["map"]) =>
|
|
map?.(entry) ?? {
|
|
id: String(entry.id ?? entry.sys?.id ?? entry._id),
|
|
content: String(entry.content ?? entry.body ?? entry.fields?.body ?? ""),
|
|
source,
|
|
};
|
|
|
|
export function contentfulAdapter(
|
|
space: string,
|
|
environment = "master",
|
|
options: VendorAdapterOptions = {},
|
|
) {
|
|
return {
|
|
async list(collection: string) {
|
|
const url = `https://cdn.contentful.com/spaces/${encodeURIComponent(space)}/environments/${encodeURIComponent(environment)}/entries?content_type=${encodeURIComponent(collection)}`;
|
|
const value = await requestJson(
|
|
url,
|
|
options,
|
|
options.token ? { authorization: `Bearer ${options.token}` } : {},
|
|
);
|
|
return (value.items ?? []).map((entry: any) =>
|
|
normalize(entry, `contentful:${space}:${entry.sys?.id}`, options.map),
|
|
);
|
|
},
|
|
};
|
|
}
|
|
export function sanityAdapter(
|
|
project: string,
|
|
dataset: string,
|
|
options: VendorAdapterOptions & { apiVersion?: string } = {},
|
|
) {
|
|
return {
|
|
async list(collection: string) {
|
|
const query = encodeURIComponent(`*[_type == $type]`);
|
|
const url = `https://${encodeURIComponent(project)}.api.sanity.io/v${options.apiVersion ?? "2024-01-01"}/data/query/${encodeURIComponent(dataset)}?query=${query}&$type=${encodeURIComponent(JSON.stringify(collection))}`;
|
|
const value = await requestJson(
|
|
url,
|
|
options,
|
|
options.token ? { authorization: `Bearer ${options.token}` } : {},
|
|
);
|
|
return (value.result ?? []).map((entry: any) =>
|
|
normalize(entry, `sanity:${project}:${entry._id}`, options.map),
|
|
);
|
|
},
|
|
};
|
|
}
|
|
export function strapiAdapter(baseUrl: string, options: VendorAdapterOptions = {}) {
|
|
const base = new URL(baseUrl);
|
|
if (!/^https?:$/.test(base.protocol)) throw new Error("Strapi URL must use HTTP(S)");
|
|
return {
|
|
async list(collection: string) {
|
|
if (!/^[A-Za-z0-9_-]+$/.test(collection)) throw new Error("Invalid Strapi collection");
|
|
const value = await requestJson(
|
|
new URL(`/api/${collection}`, base).href,
|
|
options,
|
|
options.token ? { authorization: `Bearer ${options.token}` } : {},
|
|
);
|
|
return (value.data ?? []).map((entry: any) =>
|
|
normalize(entry, `strapi:${base.host}:${entry.id}`, options.map),
|
|
);
|
|
},
|
|
};
|
|
}
|