import { normalizeMentionText, pageReference, slugTitle, splitPageReference, wikiWordTarget } from "./reference.js"; import { cmapRoute, pageRoute } from "./routes.js"; /* * Render-only transformations for racket-wiki Markdown. * * Stored Markdown remains authoritative. These functions prepare wiki syntax * for Marked; the application remains responsible for DOMPurify sanitizing and * for hydrating the generated CMap placeholders. */ /** Escape plain text before placing it in generated HTML. */ function escapeHtml(text) { return String(text) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } /** Expand Todo markers outside fenced code for rendering. */ function expandTodoMarkup(markdown, pageSlug, todoLabel) { let inFence = false; let todoNumber = 0; const label = escapeHtml(todoLabel); return (markdown || "").split("\n").map((line) => { if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return line; } if (inFence) return line; return line.replace(/todo\(([^()\r\n]+)\)/gi, (_match, text) => { todoNumber += 1; const todoText = escapeHtml(text.trim()); if (pageSlug) { const href = `#todo/${encodeURIComponent(pageSlug)}/${todoNumber}`; return `${label}: ${todoText}`; } return `${label}: ${todoText}`; }); }).join("\n"); } /** Apply racket-wiki image width and alignment suffixes to rendered image tags. */ function applyImageWidthMarkup(html) { const imageOptionsSuffix = /(]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi; return (html || "").replace(imageOptionsSuffix, (_match, imageTag, amount, unit, align, floatFlag, floatSide) => { const width = `${amount}${unit || "px"}`; const closing = imageTag.endsWith("/>") ? "/>" : ">"; let tagBody = imageTag.slice(0, imageTag.length - closing.length); const classes = []; const effectiveFloatSide = floatSide || (floatFlag && (align === "left" || align === "right") ? align : null); if (effectiveFloatSide) { classes.push(`wiki-image-float-${effectiveFloatSide}`); } else if (align) { classes.push(`wiki-image-${align}`); } if (/\sstyle=(['"])(.*?)\1/i.test(tagBody)) { tagBody = tagBody.replace(/\sstyle=(['"])(.*?)\1/i, (_style, quote, value) => ` style=${quote}${value}; width: ${width}${quote}`); } else { tagBody += ` style="width: ${width}"`; } if (classes.length > 0) { if (/\sclass=(['"])(.*?)\1/i.test(tagBody)) { tagBody = tagBody.replace(/\sclass=(['"])(.*?)\1/i, (_class, quote, value) => ` class=${quote}${value} ${classes.join(" ")}${quote}`); } else { tagBody += ` class="${classes.join(" ")}"`; } } return `${tagBody}${closing}`; }); } /** Collect the human-readable aliases through which a page can be mentioned. */ function mentionAliases(page) { const aliases = new Set(); const rawSlug = page.pageSlug || splitPageReference(page.slug).slug; const values = [page.title, slugTitle(rawSlug), rawSlug.replaceAll("-", " ")]; for (const value of values) { if (!value) continue; aliases.add(value); for (const wikiWord of value.match(/(?:\p{Lu}\p{Ll}+){2,}/gu) || []) { aliases.add(wikiWord); } } return Array.from(aliases) .map((value) => ({ text: value, key: normalizeMentionText(value) })) .filter((alias) => alias.key.length >= 5); } /** Build the namespace-aware and ambiguity-aware lookup for page mentions. */ function pageMentionMap(pages, pageAliases, currentSlug = null) { const map = new Map(); for (const page of pages) { if (page.slug === currentSlug) continue; const namespace = String(page.namespace || "").toLocaleLowerCase(); for (const alias of mentionAliases(page)) { const namespacedKey = `${namespace}:${alias.key}`; if (!map.has(namespacedKey)) { map.set(namespacedKey, page); } else if (map.get(namespacedKey)?.slug !== page.slug) { map.set(namespacedKey, null); } const rootKey = `:${alias.key}`; if (!map.has(rootKey)) { map.set(rootKey, page); } else if (map.get(rootKey)?.slug !== page.slug) { map.set(rootKey, null); } } } for (const alias of pageAliases) { const targetPage = pages.find((page) => page.slug === alias.targetSlug); if (!targetPage || targetPage.slug === currentSlug) continue; const namespace = String(alias.namespace || "").toLocaleLowerCase(); const aliasPage = { title: alias.title, pageSlug: alias.pageSlug, slug: alias.slug }; for (const candidate of mentionAliases(aliasPage)) { const namespacedKey = `${namespace}:${candidate.key}`; if (!map.has(namespacedKey)) map.set(namespacedKey, targetPage); const rootKey = `:${candidate.key}`; if (!map.has(rootKey)) map.set(rootKey, targetPage); } } return map; } /** Resolve a CMap WikiWord only when it identifies exactly one concept map. */ function cmapMentionTarget(text, conceptMaps) { const key = normalizeMentionText(text); const matches = conceptMaps.filter((conceptMap) => { const aliases = [conceptMap.title, conceptMap.slug, slugTitle(conceptMap.slug)]; return aliases.some((alias) => normalizeMentionText(alias) === key); }); return matches.length === 1 ? matches[0] : null; } /** Locate ranges in one Markdown line in which WikiWords must not be expanded. */ function markdownProtectedRanges(line) { const ranges = []; const patterns = [ /todo\([^()\n]*\)/gi, /!?\[[^\]\n]*\]\([^\)\n]*\)/g, /`+[^`\n]*`+/g, /\n]+>/gi, /https?:\/\/[^\s<>()]+/gi ]; for (const pattern of patterns) { for (const match of line.matchAll(pattern)) { ranges.push({ start: match.index, end: match.index + match[0].length }); } } return ranges; } /** Test whether a candidate WikiWord overlaps protected Markdown syntax. */ function positionIsProtected(start, end, ranges) { for (const range of ranges) { if (start < range.end && end > range.start) return true; } return false; } /** Encode the initial WikiWord letter so a later render pass cannot link it. */ function literalWikiWord(namespace, wikiWord) { const characters = Array.from(wikiWord); const initial = `&#${characters[0].codePointAt(0)};`; const prefix = namespace ? `${namespace}:` : ""; return `${prefix}${initial}${characters.slice(1).join("")}`; } /** * goal : Rewrite namespaced Markdown targets to internal hash routes. * pre : markdown is source text; fenced and indented code must remain untouched. * post : Stored Markdown is not changed. * result : Render-only Markdown with explicit wiki routes. */ function expandNamespacedMarkdownLinks(markdown) { const lines = String(markdown || "").split("\n"); const result = []; let fence = null; for (const line of lines) { const fenceMatch = line.match(/^\s*(```+|~~~+)/); if (fenceMatch) { const marker = fenceMatch[1].charAt(0); fence = fence === null ? marker : (fence === marker ? null : fence); result.push(line); continue; } if (fence !== null || /^\s{4}/.test(line)) { result.push(line); continue; } result.push(line.replace(/(!?\[[^\]\n]*\]\()([\p{L}\p{N}._-]+):([\p{L}\p{N}._-]+)(\))/gu, (_match, before, namespace, slug, after) => { const target = namespace.toLocaleLowerCase() === "cmap" ? cmapRoute(slug) : pageRoute(pageReference(namespace, slug)); return `${before}${target}${after}`; })); } return result.join("\n"); } /** * goal : Expand classic WikiWords to temporary Markdown links. * pre : pages, pageAliases and conceptMaps are current catalogues. * post : Code, Todo markers, URLs and existing Markdown links remain unchanged; * an exclamation mark directly before a WikiWord suppresses its link. * result : Render-only Markdown with WikiWord links. Literal WikiWords contain * an equivalent HTML character reference and remain literal when the * transformation is applied again. */ function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSlug = null) { const aliases = pageMentionMap(pages, pageAliases, currentSlug); const lines = String(markdown || "").split("\n"); const result = []; let fence = null; const wikiWordPattern = /(?= 0; index -= 1) { const replacement = replacements[index]; const rendered = replacement.text || (replacement.conceptMap ? `[${replacement.conceptMap.title}](${cmapRoute(replacement.conceptMap.slug)})` : `[${replacement.page.title}](${pageRoute(replacement.page.slug)})`); expanded = expanded.slice(0, replacement.start) + rendered + expanded.slice(replacement.end); } result.push(expanded); } return result.join("\n"); } /** Replace standalone CMap embeds outside fenced code with stable tokens. */ function extractCmapEmbeds(markdown) { const embeds = []; let fence = null; const lines = String(markdown || "").split("\n").map((line) => { const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); if (fenceMatch) { const marker = fenceMatch[1].charAt(0); if (fence === null) fence = marker; else if (fence === marker) fence = null; return line; } if (fence !== null) return line; const match = line.match(/^\s*\{\{cmap:([^{}\n]+)\}\}\s*$/i); if (!match) return line; const token = `RACKETWIKICMAPEMBED${embeds.length}TOKEN`; embeds.push({ token, reference: match[1].trim() }); return token; }); return { markdown: lines.join("\n"), embeds }; } /** Restore extracted CMap tokens as inert placeholders for later hydration. */ function restoreCmapEmbeds(html, embeds, loadingLabel) { let result = html; const loading = escapeHtml(loadingLabel); for (const embed of embeds) { const reference = escapeHtml(embed.reference); const placeholder = `
${loading}
`; result = result.replace(`

${embed.token}

`, placeholder).replace(embed.token, placeholder); } return result; } export { applyImageWidthMarkup, cmapMentionTarget, escapeHtml, expandNamespacedMarkdownLinks, expandTodoMarkup, expandWikiMentions, extractCmapEmbeds, restoreCmapEmbeds };