"Extended" Camel-case linking?

Automatic CamelCase linking is one of my favorite feature in Tiddlywiki, it greatly reduces friction of adding relevant links by simply training yourself to refer to topics via CamelCase.

I would like to extend the auto-link parser a bit though just not sure the best way to go about that.

I found Special_Links — creates an automatic wikilink which allows for underscores in Camel_Case which is likely a good reference, but the other patterns I would like to match would be:

Some kind of allowance for “Title-case Acronyms”?
I have a class electromagnetics which colloquially shortens to EMAG but I don’t have an elegant way to camelcase this term with the out of the box parsing.

E-Mag, E_Mag and EMag are not counted camel case as there are no sandwiched lowercase characters, so working as expected.

EmaG and ElMag are, but these look kind of clunky to me.

The other text pattern I would like to see auto-Linked is Titlecase mixed with numerals,

Like Homework2, or a date shorthand like 28Jan26.

I guess you could lump these both into one category of “Mixed Titlecase?”

With Mixed here meaning Titlecase strings that also contain at least one numeral or symbol.

So I could get links for E-Mag or 28Jan26 or
28-Jan-26

But not t-bone or T-bone, or hw3 or 28-1-26

Anyways let me know your thoughts.

-Xyvir

You might benefit from using the “Freelinks” plugin, available from the TWCore Official Plugin Library.

What it does is automatically link any text that matches an existing tiddler title.

For example, you would first create your EMAG tiddler in the usual manner. Then, whenever EMAG occurs in tiddler content, it would be automatically linked to your existing EMAG tiddler.

-e

In TW Titlecase is this: [[a link]]. IMO that’s not really what you describe.

Your first example is EMAG, which is “all uppercase”. So IMO it should be straight forward to create a WikiLink for “all uppercase”.

But it would not link to ALL UPPERCASE. WikiLiks usually are not separated by spaces. If they are they need braces eg: [[ALL UPPERCASE]], which is a standard wiki link.

I would call E-Mag an “hyphen link”. The code would be very similar to “Special_Link”. Only the separator would be different.

I would call 28Jan26 an “ugly link”. I personally would not really want to use / see it.

I would write 28-1-26 as 2026-01-28 or 2026-Jan-28, which I would name “date link” or an extended form of the hypen-link (maybe). The advantage of my preferred format is automatically sorted, without the need for a custom sort operator.

So what you described are basically 4 different “new” link patterns, not one.

For consistency reasons, I would ask. What would be the one pattern, that you really, desperatly need?

Just a question?
-Mario

Thanks for the detailed clarifications, I’m sure I butchered my ‘case’ terms a bit which is why I tried to give examples too. I probably shouldn’t have said TItle Case.

Also I think there is a bit of semantic shift or something going on because some people use the convention:

TitleCase
camelCase (like classes in Python)

but Tiddlywiki doesn’t create hyperlinks out of ‘camelCase,’ but I’m sure I’ve seen CamelCase used the way TW does so who knows.

Anyways, I think I could create a ‘catch-all’ pattern that would match all the additional example strings I would like to see treated as links, namely:

  1. String contains no spaces or whitespace. (ofc)
  2. String contains any number of alpha character groups, as long as each ‘delimited’ group starts with a capital.
  3. At least one delimiter (being a number or hyphen or underscore).
  4. Please also note that delimiters don’t necessarily have to have alpha on both sides, or either side, delimters can be adjacent, etc.

Google Gemini gave me this Regex for that description, which seems to work but I am no Regex wizard, nor am I a TiddlyWiki wizard either lol.

pattern:
^(?=\S*[\d_-])(?![^A-Z]*[a-z])[a-zA-Z\d_-]+$

Test Cases:

These_Pass-
Alpha-Beta
Alpha123
A_B-C
26Jan28
E-Mag
_Hello_World
These fail-
123_abc	Fail
Alpha Beta
Alpha
t-bone

regex101: build, test, and debug regex

So yeah something like that, I’d have to use it more though to see if I thought it was ‘too aggressive’ and made too many strings into links.

Oh this might be the ticket, I had ran across the term freelink but didn’t quite know what it meant in regards to TiddlyWiki.

I will definitely check this out; though this is a bit ‘more’ friction as it needs to have the tiddler existing for it to trigger. But maybe this is for the better.

I’ll check it out.

The Freelinks plugin is pretty good! The only drawback is it (understandably so) doesn’t match on a tiddlers’ aliases field like used in $:/plugins/mklauber/aliases/

I think if I manually adjusted or added to the existing the CamelCase links pattern matching it /would/ work with ‘rule-matching defined aliases’ out-of-the-box at least if I understand the interplay of components correctly.

-Xyvir

You should ask Gemini, if it can create a regexp, that also allows other languages than English. Eg: Öas-Df would fail, even if the “pattern” should detect it.

That’s the reason, why I did use the $tw.config.textPrimitives.??? from core/modules/config.js to create the regexp. They are a little bit better, but still have room for improvements.

-m

Thanks for the tip on the primatives, inside TW I should probably invoke those directly but just cramming them in the existing regex seemed to work well enough for regex101

^(?=[^a-zA-Z\u00c0-\u00d6\u00d8-\u00de\u00df-\u00f6\u00f8-\u00ff\u0150\u0170\u0151\u0171]*[A-Z\u00c0-\u00d6\u00d8-\u00de\u0150\u0170])(?=\S*[\d_-])(?![a-z\u00df-\u00f6\u00f8-\u00ff\u0151\u0171]|.*[^a-zA-Z\u00c0-\u00d6\u00d8-\u00de\u00df-\u00f6\u00f8-\u00ff\u0150\u0170\u0151\u0171][a-z\u00df-\u00f6\u00f8-\u00ff\u0151\u0171])[a-zA-Z0-9_\-\u00c0-\u00d6\u00d8-\u00de\u00df-\u00f6\u00f8-\u00ff\u0150\u0170\u0151\u0171]+$

Matches
Öas-Df

Could you explain how that matches?

TT

I can’t but Gemini can:

Here is a pseudocode version and an explanation:
^(?=[^<<alpha>>]*[<<upper>>])(?=\S*[\d_-])(?![<<lower>>]|.*[^<<alpha>>][<<lower>>])[<<alpha>>\d_-]+$

(?=[^<<alpha>>]*[<<upper>>]) Scans forward past any non-letters to ensure the very first letter found is a Capital.

(?=\S*[\d_-]) Scans forward to ensure there are absolutely no spaces and at least one delimiter (number, hyphen, or underscore).

(?![<<lower>>]|.*[^<<alpha>>][<<lower>>]) Prevents a lowercase letter from appearing at the very start or immediately after a non-letter.

[<<alpha>>\d_-]+$ Matches the entire string, allowing only letters, digits, underscores, and hyphens.

1 Like

Thanks for the tip, this is a better workflow than trying to change the auto-linking. I had to make a small tweak so that it also triggers on aliases as defined by aliases: field, but it does what I was looking for. Tweak below if anyone is interested:

$:/core/modules/widgets/text.js

/*\
title: $:/core/modules/widgets/text.js
type: application/javascript
module-type: widget

An override of the core text widget that automatically linkifies the text, including Aliases.

\*/

"use strict";

var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter";

var Widget = require("$:/core/modules/widgets/widget.js").widget,
	LinkWidget = require("$:/core/modules/widgets/link.js").link,
	ButtonWidget = require("$:/core/modules/widgets/button.js").button,
	ElementWidget = require("$:/core/modules/widgets/element.js").element;

var TextNodeWidget = function(parseTreeNode,options) {
	this.initialise(parseTreeNode,options);
};

/*
Inherit from the base widget class
*/
TextNodeWidget.prototype = new Widget();

/*
Render this widget into the DOM
*/
TextNodeWidget.prototype.render = function(parent,nextSibling) {
	this.parentDomNode = parent;
	this.computeAttributes();
	this.execute();
	this.renderChildren(parent,nextSibling);
};

/*
Compute the internal state of the widget
*/
TextNodeWidget.prototype.execute = function() {
	var self = this,
		ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes";
	// Get our parameters
	var childParseTree = [{
			type: "plain-text",
			text: this.getAttribute("text",this.parseTreeNode.text || "")
		}];
	// Only process links if not disabled and we're not within a button or link widget
	if(this.getVariable("tv-wikilinks",{defaultValue:"yes"}).trim() !== "no" && this.getVariable("tv-freelinks",{defaultValue:"no"}).trim() === "yes" && !this.isWithinButtonOrLink()) {
		// Get the information about the current tiddler titles, and construct a regexp
		// Modified Cache Key to differentiate from standard Freelinks
		this.tiddlerTitleInfo = this.wiki.getGlobalCache("tiddler-title-info-aliases-" + (ignoreCase ? "insensitive" : "sensitive"),function() {
			var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER),
				// Get valid tiddlers based on filter
				sourceTitles = !!targetFilterText ? self.wiki.filterTiddlers(targetFilterText,$tw.rootWidget) : self.wiki.allTitles();
			
			// 1. Build a list of candidate objects: { text: "match string", target: "Real Title" }
			var candidates = [];
			
			$tw.utils.each(sourceTitles, function(title) {
				// Add the title itself
				candidates.push({text: title, target: title});
				
				// Check for Aliases field
				var tiddler = self.wiki.getTiddler(title);
				if(tiddler && tiddler.fields.aliases) {
					// Parse the aliases list (handles spaces/brackets standard TW list format)
					var aliases = $tw.utils.parseStringArray(tiddler.fields.aliases);
					if(aliases) {
						$tw.utils.each(aliases, function(alias) {
							if(alias) {
								candidates.push({text: alias, target: title});
							}
						});
					}
				}
			});

			// 2. Sort candidates by length of the text (Longest first prevents "link" masking "linkage")
			candidates.sort(function(a,b) {
				var lenA = a.text.length,
					lenB = b.text.length;
				if(lenA !== lenB) {
					return lenA < lenB ? +1 : -1;
				} else {
					// Alphabetical sort for same length
					if(a.text < b.text) return -1;
					if(a.text > b.text) return +1;
					return 0;
				}
			});

			// 3. Separate into parallel arrays for the Regex construction
			var titles = [],
				targets = [],
				reparts = [];
			
			$tw.utils.each(candidates, function(item) {
				if(item.text.substring(0,3) !== "$:/") {
					titles.push(item.text);
					targets.push(item.target);
					// Create capturing group for this specific candidate
					reparts.push("(" + $tw.utils.escapeRegExp(item.text) + ")");
				}
			});

			var regexpStr = "\\b(?:" + reparts.join("|") + ")\\b";
			return {
				titles: titles,
				targets: targets, // We now store targets explicitly
				regexp: new RegExp(regexpStr,ignoreCase ? "i" : "")
			};
		});

		// Repeatedly linkify
		if(this.tiddlerTitleInfo.titles.length > 0) {
			var index,text,match,matchEnd;
			do {
				index = childParseTree.length - 1;
				text = childParseTree[index].text;
				match = this.tiddlerTitleInfo.regexp.exec(text);
				if(match) {
					// Make a text node for any text before the match
					if(match.index > 0) {
						childParseTree[index].text = text.substring(0,match.index);
						index += 1;
					}
					
					// Resolve the target title.
					// We find which capturing group matched (index 1..n)
					// And map it to our 0-indexed targets array.
					var matchIndex = match.indexOf(match[0], 1); 
					var targetTitle = this.tiddlerTitleInfo.targets[matchIndex - 1];

					// Make a link node for the match
					childParseTree[index] = {
						type: "link",
						attributes: {
							// Link to the Target, not the matched text
							to: {type: "string", value: targetTitle},
							"class": {type: "string", value: "tc-freelink"}
						},
						children: [{
							type: "plain-text", text: match[0]
						}]
					};
					index += 1;
					// Make a text node for any text after the match
					matchEnd = match.index + match[0].length;
					if(matchEnd < text.length) {
						childParseTree[index] = {
							type: "plain-text",
							text: text.substring(matchEnd)
						};					
					}
				}
			} while(match && childParseTree[childParseTree.length - 1].type === "plain-text");			
		}
	}
	// Make the child widgets
	this.makeChildWidgets(childParseTree);
};

TextNodeWidget.prototype.isWithinButtonOrLink = function() {
	var withinButtonOrLink = false,
		widget = this.parentWidget;
	while(!withinButtonOrLink && widget) {
		withinButtonOrLink = widget instanceof ButtonWidget || widget instanceof LinkWidget || ((widget instanceof ElementWidget) && widget.parseTreeNode.tag === "a");
		widget = widget.parentWidget;
	}
	return withinButtonOrLink;
};

/*
Selectively refreshes the widget if needed. Returns true if the widget or any of its children needed re-rendering
*/
TextNodeWidget.prototype.refresh = function(changedTiddlers) {
	var self = this,
		changedAttributes = this.computeAttributes(),
		titlesHaveChanged = false;
	$tw.utils.each(changedTiddlers,function(change,title) {
		if(change.isDeleted) {
			titlesHaveChanged = true;
		} else {
			// We must assume any change could include alias changes, checking strictly is expensive
			// simpler to rely on the standard title check or trigger a refresh if the specific tiddler is involved
			titlesHaveChanged = titlesHaveChanged || !self.tiddlerTitleInfo || self.tiddlerTitleInfo.titles.indexOf(title) !== -1 || self.tiddlerTitleInfo.targets.indexOf(title) !== -1;
		}
	});
	if(changedAttributes.text || titlesHaveChanged) {
		this.refreshSelf();
		return true;
	} else {
		return false;	
	}
};

exports.text = TextNodeWidget;

I have seen the possibility demonstrated to select text in view mode (already in editors) to create a link/tiddler from a selection, with Freelinks this then becomes a link. I would like to see the ability to select text in the view mode and do the following;

  • Wrap the selection where found in the text with [[selected text]] and if possible add this also to a list field for possible further use, ie collate the items so linked.
    • Handle the exception where this may have come from tiddlywiki script, eg a $list but still add the selection title in a list field and perhaps append to the text a new title
  • This allows the prolific use of missing tiddlers allowing an indicator that more content (in the tiddler so named) may be needed.
    • A tool that lets us search and insert such titles as a separate group would help authors.
  • Any tiddlers so named could also have an organising tag or flag to indicate its nature eg extended content, glossary entry, footnote etc…

All this can also be applied to the extended camel-case linking.

1 Like

I also know there is a Right-Click context menu for links:

https://saqimtiaz.github.io/tw5-plugins-sandbox/#Links%20Context%20Menu

I bet a similar plugin could be developed for a right-click context menu for selected text on viewmode.

But yes TW_Tones I agree and reiterate that the frictionless CamelCase linking idea from vanilla TW should be a major selling point and can easily be extended to be an extremely unopinionated and agile workflow.

Other PKMS (I know TW is not strictly a PKMS) implement similar ideas to varying degrees of success and friction but none yet have really stuck the landing IMO.