Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/excaliburjs/create-excalibur. A repo bootstrapping tool to get you started making your first games!
Something went wrong. Try again.
17 kB · 494 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495import { DOCS_URL, GITHUB_REPO, SITE_URL } from "./constants.ts";
/** API symbol → typedoc page URL, as a plain object or Map. */export type SymbolMap = Record<string, string> | Map<string, string>;
export interface Frontmatter { slug: string | null; title: string | null; section: string | null; meta: Record<string, string>; body: string;}
/** * Parse Docusaurus frontmatter. The body is everything after the closing `---`. */export function parseFrontmatter(src: string): Frontmatter { const text = src.replace(/^/, "").replace(/\r\n/g, "\n"); const meta: Record<string, string> = {}; let body = text; const match = text.match(/^---\n([\s\S]*?)\n---[ \t]*\n?/); if (match) { for (const line of match[1].split("\n")) { const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/); if (!kv) continue; let value = kv[2].trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } meta[kv[1]] = value; } body = text.slice(match[0].length); } const slug = meta.slug ? normalizeSlug(meta.slug) : null; const title = meta.title ?? firstHeading(body); return { slug, title, section: meta.section ?? null, meta, body };}
export function normalizeSlug(slug: string): string { let s = String(slug).trim(); if (!s.startsWith("/")) s = "/" + s; if (s.length > 1) s = s.replace(/\/+$/, ""); return s;}
export function docsUrlForSlug(slug: string | null | undefined): string { if (!slug || slug === "/") return `${DOCS_URL}/`; return `${DOCS_URL}${slug}`;}
/** Derive a slug from a docs file path when frontmatter has none (Docusaurus strips number prefixes). */export function slugFromPath(relPath: string): string { const parts = relPath .replace(/\.mdx?$/, "") .split("/") .map((p) => p.replace(/^\d+(?:\.\d+)?[-.]?[a-z]?-/, "").replace(/^\d+(?:\.\d+)?-/, "")); return "/" + parts.map((p) => p.trim().toLowerCase().replace(/\s+/g, "-")).join("/");}
function firstHeading(body: string): string | null { let inFence = false; for (const line of body.split("\n")) { if (/^\s*(```|~~~)/.test(line)) inFence = !inFence; if (inFence) continue; const m = line.match(/^#\s+(.+?)\s*#*\s*$/); if (m) return stripInline(m[1]); } return null;}
/** GitHub/Docusaurus-style heading anchor. */export function slugifyHeading(text: string, seen?: Set<string>): string { let slug = stripInline(text) .toLowerCase() .trim() .replace(/[^\p{L}\p{N}\s_-]/gu, "") .replace(/\s+/g, "-"); if (seen) { const base = slug; let n = 1; while (seen.has(slug)) slug = `${base}-${n++}`; seen.add(slug); } return slug;}
/** Remove inline markdown/wiki/link syntax, leaving plain text. */export function stripInline(text: string): string { return String(text) .replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, sym: string, alias: string | undefined) => (alias ?? sym).trim()) .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") .replace(/<[^>]+>/g, "") .replace(/[*_`~]/g, "") .trim();}
// `// ---cut---`, `// @include: ex`, `// @noErrors` … twoslash-only lines inside fencesconst TWOSLASH_DIRECTIVE = /^\s*\/\/\s*(---cut(-before|-after)?---|@[a-zA-Z-]+(:.*)?)\s*$/;
const ADMONITION_LABELS: Record<string, string> = { note: "ℹ Note", tip: "💡 Tip", info: "ℹ Info", warning: "⚠ Warning", caution: "⚠ Caution", danger: "🛑 Danger",};
/** * Build the API link for a `[[Symbol]]` / `[[Class.member]]` reference. * `symbols` is an optional map of symbol name → typedoc page URL. */export function apiLinkFor(symbolPath: string, symbols?: SymbolMap | null): string { const clean = symbolPath.trim(); const [head, ...rest] = clean.split(/[.#]/); const member = rest.join("."); const base = symbols instanceof Map ? symbols.get(head) : symbols?.[head]; if (base) { return member ? `${base.replace(/\/?$/, "/")}#${member}` : base; } // Unknown kind (class/interface/function…) — fall back to the site search page. return `${SITE_URL}/search/?q=${encodeURIComponent(clean)}`;}
/** * Convert the MDX body into plain markdown the terminal renderer understands. * */export interface ToMarkdownOptions { /** contents of a raw-loader import target */ resolveImport?: (relPath: string) => string | null; /** API symbol → URL map for [[wiki]] links */ symbols?: SymbolMap | null; /** site/docs-relative path (for GitHub fallbacks) */ docRelPath?: string; /** git ref (for GitHub fallbacks) */ ref?: string;}
export function toMarkdown(body: string, options: ToMarkdownOptions = {}): string { const { resolveImport, symbols, docRelPath = "", ref = "main" } = options; const rawImports = new Map<string, string>(); // identifier -> relative path const out: string[] = []; const lines = body.replace(/\r\n/g, "\n").split("\n");
let inFence = false; let fenceMarker = ""; let skipFence = false; // twoslash include blocks let admonition: { label: string } | null = null; let i = 0;
const githubBase = `https://github.com/${GITHUB_REPO}/blob/${ref}/site/docs/`; const docDir = docRelPath.includes("/") ? docRelPath.slice(0, docRelPath.lastIndexOf("/") + 1) : ""; const githubUrlFor = (rel: string): string => githubBase + encodeURI(joinRel(docDir, rel));
const push = (line: string): void => { if (admonition) { out.push(line === "" ? ">" : `> ${line}`); } else { out.push(line); } };
while (i < lines.length) { let line = lines[i];
// ---- fenced code ------------------------------------------------------- const fence = line.match(/^(\s*)(```+|~~~+)(.*)$/); if (fence) { if (!inFence) { inFence = true; fenceMarker = fence[2]; const info = fence[3].trim(); if (/^twoslash\s+include\b/.test(info)) { skipFence = true; } else { skipFence = false; const lang = normalizeFenceInfo(info); push(`${fence[1]}${fenceMarker}${lang}`); } } else if (fence[2][0] === fenceMarker[0] && fence[2].length >= fenceMarker.length) { inFence = false; if (!skipFence) push(`${fence[1]}${fenceMarker}`); skipFence = false; } else { push(line); } i++; continue; } if (inFence) { if (!skipFence && !TWOSLASH_DIRECTIVE.test(line)) push(line); i++; continue; }
// ---- MDX ESM: imports / exports --------------------------------------- const rawImport = line.match(/^import\s+(\w+)\s+from\s+['"]!!raw-loader!(.+?)['"];?\s*$/); if (rawImport) { rawImports.set(rawImport[1], rawImport[2]); i++; continue; } if (/^import\s.+\sfrom\s+['"].+['"];?\s*$/.test(line) || /^import\s+['"].+['"];?\s*$/.test(line)) { i++; continue; } if (/^export\s/.test(line)) { // drop the export statement through the next blank line while (i < lines.length && lines[i].trim() !== "") i++; continue; }
// ---- admonitions ------------------------------------------------------- const admOpen = line.match(/^:::([a-z]+)\s*(.*)$/); if (admOpen && !admonition) { const label = ADMONITION_LABELS[admOpen[1]] ?? capitalize(admOpen[1]); const title = admOpen[2].trim(); if (out.length && out[out.length - 1] !== "") out.push(""); admonition = { label }; out.push(`> **${label}${title ? `: ${title}` : ""}**`); out.push(">"); i++; continue; } if (/^:::\s*$/.test(line) && admonition) { admonition = null; out.push(""); i++; continue; }
// ---- JSX components (may span multiple lines) -------------------------- if (/^\s*<(PlaygroundEmbed|IFrameEmbed|CodeSandboxEmbed|Example|Player|img|iframe)\b/.test(line)) { let block = line; let j = i; while (!/\/>\s*$|<\/\w+>\s*$/.test(block) && j + 1 < lines.length && j - i < 30) { j++; block += "\n" + lines[j]; } i = j + 1; for (const l of renderComponent(block, { rawImports, resolveImport, githubUrlFor })) push(l); continue; }
// ---- plain line: inline rewrites --------------------------------------- push(rewriteInline(line, { symbols, githubUrlFor })); i++; }
// Collapse 3+ blank lines and repeated empty blockquote lines. return ( out .join("\n") .replace(/\n{3,}/g, "\n\n") .replace(/(\n>\s*){2,}\n/g, "\n>\n") .trim() + "\n" );}
function normalizeFenceInfo(info: string): string { if (!info) return ""; let lang = info.split(/\s+/)[0]; lang = lang.replace(/\{.*$/, ""); if (lang === "mdx") lang = "md"; return lang;}
function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1);}
function joinRel(dir: string, rel: string): string { const parts = (dir + rel).split("/"); const outParts: string[] = []; for (const p of parts) { if (p === "." || p === "") continue; if (p === "..") outParts.pop(); else outParts.push(p); } return outParts.join("/");}
function attr(block: string, name: string): { value: string; expr: boolean } | null { const quoted = block.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`)) || block.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`)); if (quoted) return { value: quoted[1], expr: false }; const expr = block.match(new RegExp(`\\b${name}\\s*=\\s*\\{([^}]*)\\}`)); if (expr) return { value: expr[1].trim(), expr: true }; return null;}
interface RenderComponentContext { rawImports: Map<string, string>; resolveImport?: ((relPath: string) => string | null) | undefined; githubUrlFor: (rel: string) => string;}
function renderComponent(block: string, { rawImports, resolveImport, githubUrlFor }: RenderComponentContext): string[] { const tag = block.match(/^\s*<(\w+)/)![1]; const title = attr(block, "title")?.value; const src = attr(block, "src"); const lines: string[] = [];
switch (tag) { case "PlaygroundEmbed": { const code = attr(block, "code"); const ident = code?.expr ? code.value : null; const rel = ident ? rawImports.get(ident) : null; const contents = rel && resolveImport ? resolveImport(rel) : null; lines.push(""); lines.push(`**▶ ${title ?? "Example"}**`); lines.push(""); if (contents != null) { const lang = rel?.endsWith(".js") ? "js" : "ts"; lines.push("```" + lang); lines.push(...contents.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n")); lines.push("```"); } else if (rel) { lines.push(`> Source: ${githubUrlFor(rel)}`); } else if (code && !code.expr) { lines.push("```ts"); lines.push(...code.value.split("\n")); lines.push("```"); } lines.push(""); return lines; } case "IFrameEmbed": case "iframe": { const url = src && !src.expr ? src.value : null; lines.push("", `> ▶ Live example: ${url ?? "(see online docs)"}`, ""); return lines; } case "CodeSandboxEmbed": { const url = src && !src.expr ? src.value : null; lines.push("", `> ▶ CodeSandbox${title ? ` — ${title}` : ""}: ${url ?? "(see online docs)"}`, ""); return lines; } case "Example": { const story = attr(block, "story")?.value; lines.push("", `> ▶ Storybook example${story ? `: ${story}` : ""} (see online docs)`, ""); return lines; } case "Player": { lines.push("", `> ▶ Video player${src && !src.expr ? `: ${src.value}` : ""} (see online docs)`, ""); return lines; } case "img": { const alt = attr(block, "alt")?.value ?? "image"; const url = src && !src.expr ? src.value : null; lines.push("", `> 🖼 ${alt}${url ? ` — ${url.startsWith("http") ? url : githubUrlFor(url)}` : ""}`, ""); return lines; } default: return [stripTags(block)]; }}
function stripTags(text: string): string { if (!text.includes("<")) return text; return text .replace(/<a\s+[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g, (_, href: string, inner: string) => `[${inner.trim()}](${absoluteUrl(href)})`) .replace(/<br\s*\/?>/g, "\n") .replace(/^\s*<li\b[^>]*>/, "- ") .replace(/<li\b[^>]*>/g, " • ") .replace(/<\/li>/g, " ") .replace(/<figcaption\b[^>]*>([\s\S]*?)<\/figcaption>/g, "_$1_") .replace(/<audio\b[^>]*>/g, "> 🔊 Audio clip (see online docs)") .replace(/<\/?[a-z][\w-]*\b[^>]*\/?>/g, "") .replace(/<\/?[A-Z][\w.]*\b[^>]*\/?>/g, "") .replace(/ +• /g, " • ") .replace(/[ \t]+$/gm, "");}
function absoluteUrl(href: string): string { if (/^https?:\/\//.test(href) || href.startsWith("#") || href.startsWith("mailto:")) return href; if (href.startsWith("/")) return SITE_URL + href; return href;}
export interface RewriteInlineOptions { symbols?: SymbolMap | null; githubUrlFor?: (rel: string) => string;}
/** Inline rewrites for a single non-code line. */export function rewriteInline(line: string, { symbols, githubUrlFor }: RewriteInlineOptions = {}): string { let result = line;
// Protect inline code spans from rewriting. const codeSpans: string[] = []; result = result.replace(/`+[^`]*`+/g, (m) => { codeSpans.push(m); return ` ${codeSpans.length - 1}