CamelCaseLinks
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# racket-wiki
|
||||
|
||||
Current development version: **0.2.23**.
|
||||
Current development version: **0.2.29**.
|
||||
|
||||
A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend.
|
||||
|
||||
@@ -380,3 +380,20 @@ CamelCase and normalized unlinked page mentions are rendered as automatic links
|
||||
## 0.2.24
|
||||
|
||||
Attachment references are tracked in database schema 6. Admin contains an Orphaned uploads page showing uploads no current page references, together with their last historical page/version when available.
|
||||
|
||||
## 0.2.26
|
||||
|
||||
CamelCase references to an existing, uniquely matching wiki page are normalized when a page is saved. For example `SmokeTestDiffSysteemspecificatie` is stored as `[Smoke Test Diff Systeemspecificatie](smoke-test-diff-systeemspecificatie)`. The conversion is idempotent: existing Markdown links, URLs, inline code, fenced code blocks, indented code and Todo markers are left unchanged. The link text is taken from the actual page title.
|
||||
|
||||
|
||||
## 0.2.27
|
||||
|
||||
CamelCase wiki references are no longer rewritten when a page is saved. The Markdown source stays compact and unchanged. During rendering, an unlinked CamelCase or normalized page mention that uniquely resolves to an existing page is rendered as a wiki link using the actual page title as its visible text. For example `SmoketestDiffSysteemspecificatie` can render as `Smoke Test Diff Systeemspecificatie` while the stored Markdown remains `SmoketestDiffSysteemspecificatie`. Existing Markdown links, inline code, fenced code and other protected rendered elements are not modified.
|
||||
|
||||
## 0.2.29
|
||||
|
||||
CamelCase is now wiki syntax during rendering. Every CamelCase token becomes an implicit wiki link outside code, existing Markdown links/images, URLs and Todo markers. If a matching page exists, its real title and slug are used. If no page exists, the display text and slug are derived from the CamelCase token; following that link opens the normal missing-page view where an editor can create it. Stored Markdown is not changed.
|
||||
|
||||
## 0.2.28
|
||||
|
||||
Implicit CamelCase/wiki mention linking now runs as a lightweight Markdown pre-render step instead of walking the rendered DOM. The stored Markdown is unchanged. Ordinary text is resolved before Marked renders it, so implicit links work consistently in paragraphs, lists, tables, blockquotes and headings. Existing Markdown links and images, URLs, inline code, indented code, fenced code blocks and Todo markers are excluded. A unique match is rendered with the actual target page title.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#lang info
|
||||
|
||||
(define pkg-authors '(hnmdijkema))
|
||||
(define version "0.2.25")
|
||||
(define version "0.2.30")
|
||||
(define license 'MIT)
|
||||
(define collection "racket-wiki")
|
||||
(define pkg-desc
|
||||
|
||||
+110
-32
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user