Files
racket-wiki/static/js/wiki/markdown.js
T
2026-09-02 08:38:03 +02:00

322 lines
11 KiB
JavaScript

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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/** 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 `<a class="wiki-todo" href="${href}"><strong>${label}:</strong> ${todoText}</a>`;
}
return `<span class="wiki-todo"><strong>${label}:</strong> ${todoText}</span>`;
});
}).join("\n");
}
/** Apply racket-wiki image width and alignment suffixes to rendered image tags. */
function applyImageWidthMarkup(html) {
const imageOptionsSuffix = /(<img\b[^>]*>)\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,
/<https?:\/\/[^>\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;
}
/**
* 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.
* result : Render-only Markdown with WikiWord links.
*/
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 = /(?<![\p{L}\p{N}._-])(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}._-])/gu;
for (const line of lines) {
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
if (fenceMatch) {
const marker = fenceMatch[1].charAt(0);
if (fence === null) fence = marker;
else if (fence === marker) fence = null;
result.push(line);
continue;
}
if (fence !== null || /^\s{4}/.test(line)) {
result.push(line);
continue;
}
const protectedRanges = markdownProtectedRanges(line);
const replacements = [];
for (const match of line.matchAll(wikiWordPattern)) {
const start = match.index;
const end = start + match[0].length;
if (positionIsProtected(start, end, protectedRanges)) continue;
const namespace = match[1] || "";
if (namespace.toLocaleLowerCase() === "cmap") {
const conceptMap = cmapMentionTarget(match[2], conceptMaps);
if (conceptMap) replacements.push({ start, end, conceptMap });
} else {
const page = wikiWordTarget(match[2], aliases, namespace);
if (page) replacements.push({ start, end, page });
}
}
let expanded = line;
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index];
const link = replacement.conceptMap ?
`[${replacement.conceptMap.title}](${cmapRoute(replacement.conceptMap.slug)})` :
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`;
expanded = expanded.slice(0, replacement.start) + link + 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 = `<section class="rw-cmap-embed" data-cmap-reference="${reference}"><div class="rw-cmap-embed-loading">${loading}</div></section>`;
result = result.replace(`<p>${embed.token}</p>`, placeholder).replace(embed.token, placeholder);
}
return result;
}
export {
applyImageWidthMarkup,
cmapMentionTarget,
escapeHtml,
expandNamespacedMarkdownLinks,
expandTodoMarkup,
expandWikiMentions,
extractCmapEmbeds,
restoreCmapEmbeds
};