CamelCaseLinks

This commit is contained in:
2026-08-15 02:45:00 +02:00
parent 6eef2d681c
commit c7cc374cc1
3 changed files with 129 additions and 34 deletions
+110 -32
View File
@@ -162,6 +162,33 @@
return String(text || "").match(/[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]*)+/g) || [];
}
function splitCamelCase(text) {
const value = String(text || "");
if (!/^[\p{L}\p{N}]+$/u.test(value)) return [];
const separated = value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
const parts = separated.split(/\s+/).filter(Boolean);
return parts.length >= 2 ? parts : [];
}
function camelCaseTarget(text, aliases) {
const existing = aliases.get(normalizeMentionText(text));
if (existing) {
return { slug: existing.slug, title: existing.title };
}
const parts = splitCamelCase(text);
if (parts.length === 0) return null;
return {
slug: parts.map((part) => part.toLocaleLowerCase()).join("-"),
title: parts.join(" ")
};
}
function mentionAliases(page) {
const aliases = new Set();
const values = [page.title, slugTitle(page.slug), page.slug.replaceAll("-", " ")];
@@ -190,72 +217,122 @@
return map;
}
function linkUnlinkedMentions(html, currentSlug = null) {
const host = document.createElement("div");
host.innerHTML = html || "";
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;
}
function positionIsProtected(start, end, ranges) {
for (const range of ranges) {
if (start < range.end && end > range.start) return true;
}
return false;
}
function expandWikiMentions(markdown, currentSlug = null) {
const aliases = pageMentionMap(currentSlug);
if (aliases.size === 0) return host.innerHTML;
const lines = String(markdown || "").split("\n");
const result = [];
let fence = null;
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
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;
}
for (const textNode of textNodes) {
const parent = textNode.parentElement;
if (!parent || parent.closest("a, code, pre, script, style, textarea")) continue;
const text = textNode.nodeValue || "";
const words = Array.from(text.matchAll(/[\p{L}\p{N}]+/gu));
if (words.length === 0) continue;
if (fence !== null || /^\s{4}/.test(line)) {
result.push(line);
continue;
}
const protectedRanges = markdownProtectedRanges(line);
const words = Array.from(line.matchAll(/[\p{L}\p{N}]+/gu));
const replacements = [];
let wordIndex = 0;
while (wordIndex < words.length) {
let match = null;
const maxWords = Math.min(5, words.length - wordIndex);
for (let count = maxWords; count >= 1; count -= 1) {
const first = words[wordIndex];
const last = words[wordIndex + count - 1];
const start = first.index;
const end = last.index + last[0].length;
const candidate = text.slice(start, end);
if (positionIsProtected(start, end, protectedRanges)) continue;
const candidate = line.slice(start, end);
const page = aliases.get(normalizeMentionText(candidate));
if (page) {
match = { start, end, page };
break;
}
if (count === 1) {
const target = camelCaseTarget(candidate, aliases);
if (target) {
match = { start, end, page: target };
break;
}
}
}
if (match) {
replacements.push(match);
while (wordIndex < words.length && words[wordIndex].index < match.end) wordIndex += 1;
while (wordIndex < words.length && words[wordIndex].index < match.end) {
wordIndex += 1;
}
} else {
wordIndex += 1;
}
}
if (replacements.length === 0) continue;
const fragment = document.createDocumentFragment();
let position = 0;
for (const replacement of replacements) {
if (replacement.start > position) fragment.append(document.createTextNode(text.slice(position, replacement.start)));
const link = document.createElement("a");
link.href = `#/${encodeURIComponent(replacement.page.slug)}`;
link.className = "wiki-auto-link";
link.title = tr("automatic-wiki-link", "Automatic wiki link");
link.textContent = text.slice(replacement.start, replacement.end);
fragment.append(link);
position = replacement.end;
if (replacements.length === 0) {
result.push(line);
continue;
}
if (position < text.length) fragment.append(document.createTextNode(text.slice(position)));
textNode.replaceWith(fragment);
let expanded = line;
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index];
const link = `[${replacement.page.title}](#/${encodeURIComponent(replacement.page.slug)})`;
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
}
result.push(expanded);
}
return host.innerHTML;
return result.join("\n");
}
function renderMarkdown(markdown, pageSlug = null) {
const html = easyMDE.markdown(expandTodoMarkup(markdown || "", pageSlug));
const withWikiLinks = expandWikiMentions(markdown || "", pageSlug);
const withTodos = expandTodoMarkup(withWikiLinks, pageSlug);
const html = easyMDE.markdown(withTodos);
const withImages = applyImageWidthMarkup(html);
return DOMPurify.sanitize(linkUnlinkedMentions(withImages, pageSlug));
return DOMPurify.sanitize(withImages);
}
function slugTitle(slug) {
@@ -999,6 +1076,7 @@
async function savePage() {
const title = $("editor-title").value.trim();
const currentSlug = state.editingNew ? state.newPageSlug : state.currentPage?.slug;
const markdown = easyMDE.value();
const tags = parseTags($("editor-tags").value);
const summary = $("edit-summary").value.trim();