341 lines
12 KiB
JavaScript
341 lines
12 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("<", "<")
|
|
.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 `<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;
|
|
}
|
|
|
|
/** 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 = /(?<![\p{L}\p{N}._-])(!?)(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}_-]|\.[\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;
|
|
if (match[1] === "!") {
|
|
replacements.push({
|
|
start,
|
|
end,
|
|
text: literalWikiWord(match[2] || "", match[3])
|
|
});
|
|
continue;
|
|
}
|
|
const namespace = match[2] || "";
|
|
if (namespace.toLocaleLowerCase() === "cmap") {
|
|
const conceptMap = cmapMentionTarget(match[3], conceptMaps);
|
|
if (conceptMap) replacements.push({ start, end, conceptMap });
|
|
} else {
|
|
const page = wikiWordTarget(match[3], 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 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 = `<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
|
|
};
|