release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { extname, join, relative, resolve } from "node:path";
|
||||
|
||||
export interface ContentSchema<T> {
|
||||
parse(input: unknown): T;
|
||||
}
|
||||
export interface ContentEntry<T = Record<string, unknown>> {
|
||||
id: string;
|
||||
slug: string;
|
||||
collection: string;
|
||||
data: T;
|
||||
body: string;
|
||||
html: string;
|
||||
excerpt: string;
|
||||
headings: ContentHeading[];
|
||||
draft: boolean;
|
||||
version?: string;
|
||||
source: string;
|
||||
}
|
||||
export interface ContentHeading {
|
||||
depth: number;
|
||||
text: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface ContentLoaderResult {
|
||||
id: string;
|
||||
source: string;
|
||||
content: string;
|
||||
}
|
||||
export interface ContentLoader {
|
||||
load(): ContentLoaderResult[] | Promise<ContentLoaderResult[]>;
|
||||
}
|
||||
export interface ContentCollectionOptions<T> {
|
||||
name: string;
|
||||
schema: ContentSchema<T>;
|
||||
loader: ContentLoader;
|
||||
includeDrafts?: boolean;
|
||||
previewToken?: string;
|
||||
references?: Record<string, ContentCollection<unknown>>;
|
||||
}
|
||||
export interface ContentCollection<T> {
|
||||
name: string;
|
||||
load(options?: {
|
||||
drafts?: boolean;
|
||||
previewToken?: string;
|
||||
version?: string;
|
||||
}): Promise<ContentEntry<T>[]>;
|
||||
get(
|
||||
id: string,
|
||||
options?: { drafts?: boolean; previewToken?: string; version?: string },
|
||||
): Promise<ContentEntry<T> | null>;
|
||||
}
|
||||
|
||||
function scalar(value: string): unknown {
|
||||
const text = value.trim();
|
||||
if (/^(true|false)$/i.test(text)) return text.toLowerCase() === "true";
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text);
|
||||
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))
|
||||
return text.slice(1, -1);
|
||||
if (text.startsWith("[") && text.endsWith("]"))
|
||||
return text
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map((item) => scalar(item));
|
||||
return text;
|
||||
}
|
||||
export function parseFrontmatter(source: string): { data: Record<string, unknown>; body: string } {
|
||||
if (!source.startsWith("---\n") && !source.startsWith("---\r\n"))
|
||||
return { data: {}, body: source };
|
||||
const normalized = source.replace(/\r\n/g, "\n");
|
||||
const end = normalized.indexOf("\n---\n", 4);
|
||||
if (end < 0) throw new Error("WRN-CONTENT-FRONTMATTER: closing delimiter is missing.");
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const line of normalized.slice(4, end).split("\n")) {
|
||||
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
||||
const match = /^([A-Za-z_$][\w$.-]*):\s*(.*)$/.exec(line);
|
||||
if (!match) throw new Error(`WRN-CONTENT-FRONTMATTER: invalid line '${line}'.`);
|
||||
data[match[1]!] = scalar(match[2]!);
|
||||
}
|
||||
return { data, body: normalized.slice(end + 5) };
|
||||
}
|
||||
const escape = (value: string) =>
|
||||
value.replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!,
|
||||
);
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
export function renderMarkdown(source: string): {
|
||||
html: string;
|
||||
headings: ContentHeading[];
|
||||
excerpt: string;
|
||||
} {
|
||||
const headings: ContentHeading[] = [];
|
||||
const lines = source.replace(/\r\n/g, "\n").split("\n");
|
||||
const output: string[] = [];
|
||||
let code: string[] | null = null;
|
||||
let language = "";
|
||||
for (const line of lines) {
|
||||
const fence = /^```([\w-]*)/.exec(line);
|
||||
if (fence) {
|
||||
if (code) {
|
||||
output.push(
|
||||
`<pre><code class="language-${escape(language)}">${escape(code.join("\n"))}</code></pre>`,
|
||||
);
|
||||
code = null;
|
||||
} else {
|
||||
code = [];
|
||||
language = fence[1] ?? "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (code) {
|
||||
code.push(line);
|
||||
continue;
|
||||
}
|
||||
const heading = /^(#{1,6})\s+(.+)$/.exec(line);
|
||||
if (heading) {
|
||||
const text = heading[2]!.trim();
|
||||
const item = { depth: heading[1]!.length, text, slug: slugify(text) };
|
||||
headings.push(item);
|
||||
output.push(`<h${item.depth} id="${escape(item.slug)}">${escape(text)}</h${item.depth}>`);
|
||||
} else if (line.trim())
|
||||
output.push(
|
||||
`<p>${escape(line.trim()).replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, '<a href="$2">$1</a>')}</p>`,
|
||||
);
|
||||
}
|
||||
if (code) throw new Error("WRN-CONTENT-MARKDOWN: code fence is not closed.");
|
||||
const excerpt =
|
||||
lines
|
||||
.find((line) => line.trim() && !line.startsWith("#") && !line.startsWith("---"))
|
||||
?.trim()
|
||||
.slice(0, 240) ?? "";
|
||||
return { html: output.join("\n"), headings, excerpt };
|
||||
}
|
||||
|
||||
export function localContentLoader(directory: string): ContentLoader {
|
||||
const root = resolve(directory);
|
||||
return {
|
||||
load() {
|
||||
if (!existsSync(root)) return [];
|
||||
const values: ContentLoaderResult[] = [];
|
||||
const walk = (current: string) => {
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
const path = join(current, entry.name);
|
||||
if (entry.isDirectory()) walk(path);
|
||||
else if ([".md", ".mdx"].includes(extname(entry.name)))
|
||||
values.push({
|
||||
id: relative(root, path)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.mdx?$/, ""),
|
||||
source: path,
|
||||
content: readFileSync(path, "utf8"),
|
||||
});
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return values.sort((left, right) => left.id.localeCompare(right.id));
|
||||
},
|
||||
};
|
||||
}
|
||||
export function remoteContentLoader(
|
||||
url: string,
|
||||
options: {
|
||||
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
headers?: HeadersInit;
|
||||
} = {},
|
||||
): ContentLoader {
|
||||
return {
|
||||
async load() {
|
||||
const response = await (options.fetch ?? globalThis.fetch)(url, { headers: options.headers });
|
||||
if (!response.ok) throw new Error(`WRN-CONTENT-REMOTE: ${response.status}`);
|
||||
const value = (await response.json()) as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
source?: string;
|
||||
}>;
|
||||
if (!Array.isArray(value)) throw new TypeError("WRN-CONTENT-REMOTE: expected an array.");
|
||||
return value.map((entry) => ({
|
||||
id: entry.id,
|
||||
content: entry.content,
|
||||
source: entry.source ?? url,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
export function defineCollection<T>(options: ContentCollectionOptions<T>): ContentCollection<T> {
|
||||
const parse = (raw: ContentLoaderResult): ContentEntry<T> => {
|
||||
const parsed = parseFrontmatter(raw.content);
|
||||
const rendered = renderMarkdown(parsed.body);
|
||||
const data = options.schema.parse(parsed.data);
|
||||
const record = data as Record<string, unknown>;
|
||||
return {
|
||||
id: raw.id,
|
||||
slug: String(record.slug ?? raw.id),
|
||||
collection: options.name,
|
||||
data,
|
||||
body: parsed.body,
|
||||
...rendered,
|
||||
draft: record.draft === true,
|
||||
version: typeof record.version === "string" ? record.version : undefined,
|
||||
source: raw.source,
|
||||
};
|
||||
};
|
||||
const load = async (
|
||||
filter: { drafts?: boolean; previewToken?: string; version?: string } = {},
|
||||
) => {
|
||||
const preview = Boolean(options.previewToken && filter.previewToken === options.previewToken);
|
||||
const drafts = options.includeDrafts || filter.drafts || preview;
|
||||
return (await options.loader.load())
|
||||
.map(parse)
|
||||
.filter(
|
||||
(entry) =>
|
||||
(drafts || !entry.draft) && (!filter.version || entry.version === filter.version),
|
||||
);
|
||||
};
|
||||
return {
|
||||
name: options.name,
|
||||
load,
|
||||
async get(id, filter) {
|
||||
return (await load(filter)).find((entry) => entry.id === id || entry.slug === id) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
export function resolveContentReference(
|
||||
collections: Record<string, ContentCollection<unknown>>,
|
||||
reference: string,
|
||||
) {
|
||||
const [collection, id] = reference.split(":", 2);
|
||||
if (!collection || !id || !collections[collection])
|
||||
throw new Error(`WRN-CONTENT-REFERENCE: '${reference}' is invalid.`);
|
||||
return collections[collection].get(id);
|
||||
}
|
||||
export function paginateContent<T>(entries: T[], page = 1, pageSize = 10) {
|
||||
if (!Number.isInteger(page) || page < 1 || !Number.isInteger(pageSize) || pageSize < 1)
|
||||
throw new RangeError("content pagination values must be positive integers");
|
||||
const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
|
||||
return {
|
||||
items: entries.slice((page - 1) * pageSize, page * pageSize),
|
||||
page,
|
||||
pageSize,
|
||||
total: entries.length,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrevious: page > 1,
|
||||
};
|
||||
}
|
||||
export function createSearchIndex(entries: ContentEntry[]) {
|
||||
return entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
title: String((entry.data as Record<string, unknown>).title ?? entry.id),
|
||||
text: `${entry.excerpt} ${entry.headings.map((heading) => heading.text).join(" ")}`.toLowerCase(),
|
||||
}));
|
||||
}
|
||||
export function searchContent(index: ReturnType<typeof createSearchIndex>, query: string) {
|
||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return index.filter((entry) =>
|
||||
terms.every((term) => `${entry.title} ${entry.text}`.toLowerCase().includes(term)),
|
||||
);
|
||||
}
|
||||
export function contentSitemap(entries: ContentEntry[], baseUrl: string) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${entries.map((entry) => `<url><loc>${escape(new URL(entry.slug, baseUrl).href)}</loc></url>`).join("")}</urlset>`;
|
||||
}
|
||||
export function contentRss(
|
||||
entries: ContentEntry[],
|
||||
options: { title: string; baseUrl: string; description?: string },
|
||||
) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>${escape(options.title)}</title><link>${escape(options.baseUrl)}</link><description>${escape(options.description ?? options.title)}</description>${entries.map((entry) => `<item><title>${escape(String((entry.data as Record<string, unknown>).title ?? entry.id))}</title><link>${escape(new URL(entry.slug, options.baseUrl).href)}</link><guid>${escape(entry.id)}</guid><description>${escape(entry.excerpt)}</description></item>`).join("")}</channel></rss>`;
|
||||
}
|
||||
export interface CmsAdapter {
|
||||
list(collection: string): Promise<Array<{ id: string; content: string; source?: string }>>;
|
||||
}
|
||||
export function cmsContentLoader(adapter: CmsAdapter, collection: string): ContentLoader {
|
||||
return {
|
||||
async load() {
|
||||
return (await adapter.list(collection)).map((entry) => ({
|
||||
...entry,
|
||||
source: entry.source ?? `cms:${collection}:${entry.id}`,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
export {
|
||||
renderMdxComponents,
|
||||
createIncrementalHighlighter,
|
||||
contentfulAdapter,
|
||||
sanityAdapter,
|
||||
strapiAdapter,
|
||||
} from "./advanced.ts";
|
||||
export type { MdxComponent, SyntaxLanguageBundle, VendorAdapterOptions } from "./advanced.ts";
|
||||
Reference in New Issue
Block a user