From b9e081b13d8a5c8e90be875f384a2ead9db3e0b3 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Sat, 15 Aug 2026 03:15:01 +0200 Subject: [PATCH] WikiLinks --- README.md | 2 ++ static/js/wiki.js | 70 ++++++++++++----------------------------------- 2 files changed, 19 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 7bf6537..6f39a8f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # racket-wiki +Version 0.2.30 tightens implicit wiki links to classic WikiWords: only letter-only CamelCase words with at least two capitalized word segments are treated as implicit links. Version-like identifiers and words containing digits or punctuation are not WikiWords. + Current development version: **0.2.29**. A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend. diff --git a/static/js/wiki.js b/static/js/wiki.js index 0a00b44..7c95ad3 100644 --- a/static/js/wiki.js +++ b/static/js/wiki.js @@ -158,31 +158,21 @@ .replace(/[^\p{L}\p{N}]+/gu, ""); } - function camelCaseParts(text) { - return String(text || "").match(/[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]*)+/g) || []; - } - - function splitCamelCase(text) { + function wikiWordParts(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 : []; + if (!/^(?:\p{Lu}\p{Ll}+){2,}$/u.test(value)) return []; + return value.match(/\p{Lu}\p{Ll}+/gu) || []; } function camelCaseTarget(text, aliases) { + const parts = wikiWordParts(text); + if (parts.length === 0) return null; + 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(" ") @@ -195,7 +185,9 @@ for (const value of values) { if (!value) continue; aliases.add(value); - for (const camel of camelCaseParts(value)) aliases.add(camel); + 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) })) @@ -268,45 +260,17 @@ } const protectedRanges = markdownProtectedRanges(line); - const words = Array.from(line.matchAll(/[\p{L}\p{N}]+/gu)); + const words = Array.from(line.matchAll(/\p{L}+/gu)); const replacements = []; - let wordIndex = 0; - while (wordIndex < words.length) { - let match = null; - const maxWords = Math.min(5, words.length - wordIndex); + for (const word of words) { + const start = word.index; + const end = start + word[0].length; + if (positionIsProtected(start, end, protectedRanges)) continue; - 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; - - 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; - } - } else { - wordIndex += 1; + const target = camelCaseTarget(word[0], aliases); + if (target) { + replacements.push({ start, end, page: target }); } }