1
0
mirror of https://github.com/Jermolene/TiddlyWiki5 synced 2026-06-22 21:28:52 +00:00

Compare commits

..

9 Commits

Author SHA1 Message Date
Jeremy Ruston 31f242a42b Merge branch 'master' into performance-plugin 2026-04-21 20:25:24 +01:00
Jeremy Ruston 1d8131704c Merge branch 'master' into performance-plugin 2026-04-08 20:00:49 +01:00
Jeremy Ruston a1a191b504 Merge branch 'master' into performance-plugin 2026-03-30 09:29:53 +01:00
Jeremy Ruston 34d013ca3d Merge branch 'master' into performance-plugin 2026-03-26 17:29:05 +00:00
Jeremy Ruston 1e06098d20 Startup performance markers
Allows us to test startup performance in a real browser or Playwright
2026-03-15 14:08:42 +00:00
Jeremy Ruston b378f3f462 More optimisations
These ones stemming from a wiki with 80K tiddlers and 5K tags, and a total wiki size of 150MB.

> The biggest win is the P50 (median) refresh dropping from 124ms to 46ms — a 63% improvement
2026-03-14 09:05:36 +00:00
Jeremy Ruston b20b578183 Fix plugin stability 2026-03-13 17:39:33 +00:00
Jeremy Ruston 4b046884b1 Update tag-indexer.js
For my test app with 100K tiddlers and 20K tags, the tag indexer improvement gives a significant improvement:

| Metric | Before | After Tag Indexer | Improvement |
|--------|--------|-------------------|-------------|
| **Initial render** | 261.95ms | 83.51ms | **3.1x faster** |
| **Total refresh** | 2,496.25ms | 1,338.84ms | **1.9x faster (46%)** |
| **Mean refresh** | 416.04ms | 223.14ms | **1.9x faster** |
| **Max refresh** | 480.76ms | 287.26ms | **1.7x faster** |

The specific filter impact: `[subfilter{$:/core/config/GlobalImportFilter}]` went from **1,172ms** (the #1 bottleneck) to effectively **0ms** — it dropped out of the top 20 entirely. That single filter was consuming 47% of total refresh time.

The root cause was that `TagSubIndexer.prototype.update()` was setting `this.index = null` on every tiddler change, forcing a complete rebuild (iterating all 95k tiddlers) on the next tag lookup. The fix makes it incremental — just removing/adding the changed tiddler's tags — which is O(number of tags on the changed tiddler) instead of O(all tiddlers in the wiki).
2026-03-13 17:15:01 +00:00
Jeremy Ruston f272f718fa Initial Commit 2026-03-13 15:36:43 +00:00
92 changed files with 1192 additions and 1677 deletions
-1
View File
@@ -3,4 +3,3 @@
tmp/
output/
node_modules/
.claude/
+1 -1
View File
@@ -5,7 +5,7 @@
# Default to the current version number for building the plugin library
if [ -z "$TW5_BUILD_VERSION" ]; then
TW5_BUILD_VERSION=v5.4.1.
TW5_BUILD_VERSION=v5.5.0
fi
echo "Using TW5_BUILD_VERSION as [$TW5_BUILD_VERSION]"
+57
View File
@@ -14,6 +14,8 @@ var _boot = (function($tw) {
"use strict";
if(typeof performance !== "undefined") { performance.mark("tw-boot-start"); }
// Include bootprefix if we're not given module data
if(!$tw) {
$tw = require("./bootprefix.js").bootprefix();
@@ -1158,6 +1160,30 @@ $tw.Wiki = function(options) {
pluginTiddlers = [], // Array of tiddlers containing registered plugins, ordered by priority
pluginInfo = Object.create(null), // Hashmap of parsed plugin content
shadowTiddlers = Object.create(null), // Hashmap by title of {source:, tiddler:}
systemTiddlerTitles = null, // Array of system tiddler titles (starting with "$:/")
nonSystemTiddlerTitles = null, // Array of non-system tiddler titles
partitionTiddlerTitles = function() {
if(systemTiddlerTitles === null) {
systemTiddlerTitles = [];
nonSystemTiddlerTitles = [];
var titles = getTiddlerTitles();
for(var i = 0, length = titles.length; i < length; i++) {
if(titles[i].indexOf("$:/") === 0) {
systemTiddlerTitles.push(titles[i]);
} else {
nonSystemTiddlerTitles.push(titles[i]);
}
}
}
},
getSystemTiddlerTitles = function() {
partitionTiddlerTitles();
return systemTiddlerTitles;
},
getNonSystemTiddlerTitles = function() {
partitionTiddlerTitles();
return nonSystemTiddlerTitles;
},
shadowTiddlerTitles = null,
getShadowTiddlerTitles = function() {
if(!shadowTiddlerTitles) {
@@ -1206,6 +1232,14 @@ $tw.Wiki = function(options) {
tiddlers[title] = tiddler;
// Check we've got the title
tiddlerTitles = $tw.utils.insertSortedArray(tiddlerTitles || [],title);
// Maintain system/non-system partitions
if(systemTiddlerTitles !== null) {
if(title.indexOf("$:/") === 0) {
$tw.utils.insertSortedArray(systemTiddlerTitles,title);
} else {
$tw.utils.insertSortedArray(nonSystemTiddlerTitles,title);
}
}
// Record the new tiddler state
updateDescriptor["new"] = {
tiddler: tiddler,
@@ -1246,6 +1280,14 @@ $tw.Wiki = function(options) {
tiddlerTitles.splice(index,1);
}
}
// Delete from system/non-system partitions
if(systemTiddlerTitles !== null) {
var partitionArray = title.indexOf("$:/") === 0 ? systemTiddlerTitles : nonSystemTiddlerTitles;
var partitionIndex = partitionArray.indexOf(title);
if(partitionIndex !== -1) {
partitionArray.splice(partitionIndex,1);
}
}
// Record the new tiddler state
updateDescriptor["new"] = {
tiddler: this.getTiddler(title),
@@ -1284,6 +1326,16 @@ $tw.Wiki = function(options) {
return getTiddlerTitles().slice(0);
};
// Get an array of all system tiddler titles (returns cached array; do not mutate)
this.allSystemTitles = function() {
return getSystemTiddlerTitles();
};
// Get an array of all non-system tiddler titles (returns cached array; do not mutate)
this.allNonSystemTitles = function() {
return getNonSystemTiddlerTitles();
};
// Iterate through all tiddler titles
this.each = function(callback) {
var titles = getTiddlerTitles(),
@@ -2539,7 +2591,9 @@ $tw.boot.loadStartup = function(options){
// Load tiddlers
if($tw.boot.tasks.readBrowserTiddlers) {
if(typeof performance !== "undefined") { performance.mark("tw-boot-store-read-start"); }
$tw.loadTiddlersBrowser();
if(typeof performance !== "undefined") { performance.mark("tw-boot-store-read-end"); }
} else {
$tw.loadTiddlersNode();
}
@@ -2551,6 +2605,7 @@ $tw.boot.loadStartup = function(options){
$tw.hooks.invokeHook("th-boot-tiddlers-loaded");
};
$tw.boot.execStartup = function(options){
if(typeof performance !== "undefined") { performance.mark("tw-boot-exec-start"); }
// Unpack plugin tiddlers
$tw.wiki.readPluginInfo();
$tw.wiki.registerPluginTiddlers("plugin",$tw.safeMode ? ["$:/core"] : undefined);
@@ -2578,6 +2633,7 @@ $tw.boot.execStartup = function(options){
$tw.boot.executedStartupModules = Object.create(null);
$tw.boot.disabledStartupModules = $tw.boot.disabledStartupModules || [];
// Repeatedly execute the next eligible task
if(typeof performance !== "undefined") { performance.mark("tw-boot-startup-modules-start"); }
$tw.boot.executeNextStartupTask(options.callback);
};
/*
@@ -2645,6 +2701,7 @@ $tw.boot.executeNextStartupTask = function(callback) {
}
taskIndex++;
}
if(typeof performance !== "undefined") { performance.mark("tw-boot-complete"); }
if(typeof callback === "function") {
callback();
}
+4 -1
View File
@@ -14,6 +14,8 @@ See Boot.js for further details of the boot process.
/* eslint-disable @stylistic/indent */
if(typeof performance !== "undefined") { performance.mark("tw-bootprefix-start"); }
var _bootprefix = (function($tw) {
"use strict";
@@ -23,7 +25,7 @@ $tw.boot = $tw.boot || Object.create(null);
// Config
$tw.config = $tw.config || Object.create(null);
$tw.config.maxEditFileSize = 100 * 1024 * 1024; // 100MB
$tw.config.maxEditFileSize = 200 * 1024 * 1024; // 200MB
// Detect platforms
if(!("browser" in $tw)) {
@@ -121,6 +123,7 @@ return $tw;
if(typeof(exports) === "undefined") {
// Set up $tw global for the browser
window.$tw = _bootprefix(window.$tw);
if(typeof performance !== "undefined") { performance.mark("tw-bootprefix-end"); }
} else {
// Export functionality as a module
exports.bootprefix = _bootprefix;
+3 -2
View File
@@ -156,13 +156,14 @@ Fix the height of textarea to fit content
FramedEngine.prototype.fixHeight = function() {
// Make sure styles are updated
this.copyStyles();
if(this.widget.editTag === "textarea") {
// If .editRows is initialised, it takes precedence
if(this.widget.editTag === "textarea" && !this.widget.editRows) {
if(this.widget.editAutoHeight) {
if(this.domNode && !this.domNode.isTiddlyWikiFakeDom) {
var newHeight = $tw.utils.resizeTextAreaToFit(this.domNode,this.widget.editMinHeight);
this.iframeNode.style.height = newHeight + "px";
}
} else if(!this.widget.editRows) {
} else {
var fixedHeight = parseInt(this.widget.wiki.getTiddlerText(HEIGHT_VALUE_TITLE,"400px"),10);
fixedHeight = Math.max(fixedHeight,20);
this.domNode.style.height = fixedHeight + "px";
+3 -2
View File
@@ -100,12 +100,13 @@ SimpleEngine.prototype.getText = function() {
Fix the height of textarea to fit content
*/
SimpleEngine.prototype.fixHeight = function() {
if(this.widget.editTag === "textarea") {
// If .editRows is initialised, it takes precedence
if((this.widget.editTag === "textarea") && !this.widget.editRows) {
if(this.widget.editAutoHeight) {
if(this.domNode && !this.domNode.isTiddlyWikiFakeDom) {
$tw.utils.resizeTextAreaToFit(this.domNode,this.widget.editMinHeight);
}
} else if(!this.widget.editRows) {
} else {
var fixedHeight = parseInt(this.widget.wiki.getTiddlerText(HEIGHT_VALUE_TITLE,"400px"),10);
fixedHeight = Math.max(fixedHeight,20);
this.domNode.style.height = fixedHeight + "px";
+34 -17
View File
@@ -261,6 +261,7 @@ exports.compileFilter = function(filterString) {
var filterOperators = this.getFilterOperators();
// Assemble array of functions, one for each operation
var operationFunctions = [];
var operationSubFunctions = []; // Unwrapped sub-functions for fast path
// Step through the operations
var self = this;
$tw.utils.each(filterParseTree,function(operation) {
@@ -289,20 +290,24 @@ exports.compileFilter = function(filterString) {
operand.value = self.getTextReference(operand.text,"",currTiddlerTitle);
operand.multiValue = [operand.value];
} else if(operand.variable) {
var varTree = $tw.utils.parseFilterVariable(operand.text);
operand.value = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source})[0] || "";
if(!operand._varTree) {
operand._varTree = $tw.utils.parseFilterVariable(operand.text);
}
operand.value = widgetClass.evaluateVariable(widget,operand._varTree.name,{params: operand._varTree.params, source: source})[0] || "";
operand.multiValue = [operand.value];
} else if(operand.multiValuedVariable) {
var varTree = $tw.utils.parseFilterVariable(operand.text);
var resultList = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source});
if(!operand._varTree) {
operand._varTree = $tw.utils.parseFilterVariable(operand.text);
}
var resultList = widgetClass.evaluateVariable(widget,operand._varTree.name,{params: operand._varTree.params, source: source});
if((resultList.length > 0 && resultList[0] !== undefined) || resultList.length === 0) {
operand.multiValue = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source}) || [];
operand.multiValue = widgetClass.evaluateVariable(widget,operand._varTree.name,{params: operand._varTree.params, source: source}) || [];
operand.value = operand.multiValue[0] || "";
} else {
operand.value = "";
operand.multiValue = [];
}
operand.isMultiValueOperand = true;
operand.isMultiValueOperand = true;
} else {
operand.value = operand.text;
operand.multiValue = [operand.value];
@@ -343,6 +348,7 @@ exports.compileFilter = function(filterString) {
return resultArray;
}
};
operationSubFunctions.push(operationSubFunction);
var filterRunPrefixes = self.getFilterRunPrefixes();
// Wrap the operator functions in a wrapper function that depends on the prefix
operationFunctions.push((function() {
@@ -372,6 +378,10 @@ exports.compileFilter = function(filterString) {
}
})());
});
// Detect single "or" run for fast path (bypass LinkedList)
var isSingleOrRun = filterParseTree.length === 1 &&
(!filterParseTree[0].prefix || filterParseTree[0].prefix === "");
var singleOrSubFunction = isSingleOrRun ? operationSubFunctions[0] : null;
// Return a function that applies the operations to a source iterator of tiddler titles
var fnMeasured = $tw.perf.measure("filter: " + filterString,function filterFunction(source,widget) {
if(!source) {
@@ -382,23 +392,30 @@ exports.compileFilter = function(filterString) {
if(!widget) {
widget = $tw.rootWidget;
}
var results = new $tw.utils.LinkedList();
self.filterRecursionCount = (self.filterRecursionCount || 0) + 1;
var resultArray;
if(self.filterRecursionCount < MAX_FILTER_DEPTH) {
$tw.utils.each(operationFunctions,function(operationFunction) {
var operationResult = operationFunction(results,source,widget);
if(operationResult) {
if(operationResult.variables) {
// If the filter run prefix has returned variables, create a new fake widget with those variables
widget = widget.makeFakeWidgetWithVariables(operationResult.variables);
if(singleOrSubFunction) {
// Fast path: single "or" run, return array directly without LinkedList
resultArray = singleOrSubFunction(source,widget);
} else {
var results = new $tw.utils.LinkedList();
$tw.utils.each(operationFunctions,function(operationFunction) {
var operationResult = operationFunction(results,source,widget);
if(operationResult) {
if(operationResult.variables) {
// If the filter run prefix has returned variables, create a new fake widget with those variables
widget = widget.makeFakeWidgetWithVariables(operationResult.variables);
}
}
}
});
});
resultArray = results.toArray();
}
} else {
results.push("/**-- Excessive filter recursion --**/");
resultArray = ["/**-- Excessive filter recursion --**/"];
}
self.filterRecursionCount = self.filterRecursionCount - 1;
return results.toArray();
return resultArray;
});
if(this.filterCacheCount >= 2000) {
// To prevent memory leak, we maintain an upper limit for cache size.
+12
View File
@@ -13,6 +13,18 @@ Filter function for [is[shadow]]
Export our filter function
*/
exports.shadow = function(source,prefix,options) {
// Fast path: when source is wiki.each (all real tiddlers), use shadow title list
if(source === options.wiki.each && prefix !== "!") {
// Return real tiddlers that are also shadow tiddlers (overridden shadows)
var results = [],
shadowTitles = options.wiki.allShadowTitles();
for(var i = 0, len = shadowTitles.length; i < len; i++) {
if(options.wiki.tiddlerExists(shadowTitles[i])) {
results.push(shadowTitles[i]);
}
}
return results;
}
var results = [];
if(prefix === "!") {
source(function(tiddler,title) {
+8
View File
@@ -13,6 +13,14 @@ Filter function for [is[system]]
Export our filter function
*/
exports.system = function(source,prefix,options) {
// Fast path: when iterating all tiddlers, use pre-partitioned arrays
if(source === options.wiki.each) {
if(prefix === "!") {
return options.wiki.allNonSystemTitles();
} else {
return options.wiki.allSystemTitles();
}
}
var results = [];
if(prefix === "!") {
source(function(tiddler,title) {
+7
View File
@@ -13,6 +13,13 @@ Filter function for [is[tiddler]]
Export our filter function
*/
exports.tiddler = function(source,prefix,options) {
// Fast path: wiki.each only iterates real tiddlers, all of which exist
if(source === options.wiki.each) {
if(prefix === "!") {
return []; // No real tiddler fails tiddlerExists
}
return source; // Return iterator directly; all real tiddlers pass
}
var results = [];
if(prefix === "!") {
source(function(tiddler,title) {
+15
View File
@@ -13,6 +13,21 @@ Filter operator returning all the tags of the selected tiddlers
Export our filter function
*/
exports.tags = function(source,operator,options) {
// Fast path: cache result when iterating all tiddlers
if(source === options.wiki.each) {
return options.wiki.getGlobalCache("filter-tags-all-tiddlers",function() {
var tags = {};
source(function(tiddler,title) {
var t, length;
if(tiddler && tiddler.fields.tags) {
for(t=0, length=tiddler.fields.tags.length; t<length; t++) {
tags[tiddler.fields.tags[t]] = true;
}
}
});
return Object.keys(tags);
});
}
var tags = {};
source(function(tiddler,title) {
var t, length;
+51 -1
View File
@@ -67,7 +67,57 @@ TagSubIndexer.prototype.rebuild = function() {
};
TagSubIndexer.prototype.update = function(updateDescriptor) {
this.index = null;
// If the index hasn't been built yet, no update needed
if(this.index === null) {
return;
}
// Determine whether the old/new tiddler is visible to this iterator
var oldVisible = this._isVisible(updateDescriptor.old),
newVisible = this._isVisible(updateDescriptor["new"]),
self = this;
// Remove old tags from index
if(oldVisible && updateDescriptor.old.tiddler) {
var oldTitle = updateDescriptor.old.tiddler.fields.title,
oldTags = updateDescriptor.old.tiddler.fields.tags || [];
$tw.utils.each(oldTags,function(tag) {
if(self.index[tag]) {
var idx = self.index[tag].titles.indexOf(oldTitle);
if(idx !== -1) {
self.index[tag].titles.splice(idx,1);
if(self.index[tag].titles.length === 0) {
delete self.index[tag];
}
}
}
});
}
// Add new tags to index
if(newVisible && updateDescriptor["new"].tiddler) {
var newTitle = updateDescriptor["new"].tiddler.fields.title,
newTags = updateDescriptor["new"].tiddler.fields.tags || [];
$tw.utils.each(newTags,function(tag) {
if(!self.index[tag]) {
self.index[tag] = {isSorted: false, titles: [newTitle]};
} else if(self.index[tag].titles.indexOf(newTitle) === -1) {
self.index[tag].titles.push(newTitle);
self.index[tag].isSorted = false;
}
});
}
};
/*
Determine whether a tiddler described by a descriptor is visible to this sub-indexer's iterator
*/
TagSubIndexer.prototype._isVisible = function(descriptor) {
if(this.iteratorMethod === "each") {
return descriptor.exists;
} else if(this.iteratorMethod === "eachShadow") {
return descriptor.shadow;
} else {
// eachTiddlerPlusShadows and eachShadowPlusTiddlers both visit all tiddlers and shadows
return descriptor.exists || descriptor.shadow;
}
};
TagSubIndexer.prototype.lookup = function(tag) {
+2 -2
View File
@@ -40,8 +40,6 @@ exports.startup = function() {
// The rest of the startup process here is not strictly to do with loading modules, but are needed before other startup
// modules are executed. It is easier to put them here than to introduce a new startup module
// --------------------------
// Set up the performance framework
$tw.perf = new $tw.Performance($tw.wiki.getTiddlerText(PERFORMANCE_INSTRUMENTATION_CONFIG_TITLE,"no") === "yes");
// Create a root widget for attaching event handlers. By using it as the parentWidget for another widget tree, one can reuse the event handlers
$tw.rootWidget = new widget.widget({
type: "widget",
@@ -50,6 +48,8 @@ exports.startup = function() {
wiki: $tw.wiki,
document: $tw.browser ? document : $tw.fakeDocument
});
// Set up the performance framework
$tw.perf = new $tw.Performance($tw.wiki.getTiddlerText(PERFORMANCE_INSTRUMENTATION_CONFIG_TITLE,"no") === "yes");
// Kick off the filter tracker
$tw.filterTracker = new $tw.FilterTracker($tw.wiki);
$tw.wiki.addEventListener("change",function(changes) {
+11 -60
View File
@@ -3,43 +3,18 @@ title: $:/core/modules/utils/deprecated.js
type: application/javascript
module-type: utils
Deprecated util functions. These preserve the pre-5.4.0 signatures and
behaviour for backwards compatibility with plugins and external scripts.
Prefer modern alternatives in new code (Array.prototype methods, classList,
Math.sign, String.prototype.repeat, etc.).
Deprecated util functions
\*/
exports.logTable = (data) => console.table(data);
/*
Repeats a string
*/
exports.repeat = function(str,count) {
var result = "";
for(var t=0;t<count;t++) {
result += str;
}
return result;
};
exports.repeat = (str,count) => str.repeat(count);
/*
Check if a string starts with another string
*/
exports.startsWith = function(str,search) {
return str.substring(0, search.length) === search;
};
exports.startsWith = (str,search) => str.startsWith(search);
/*
Check if a string ends with another string
*/
exports.endsWith = function(str,search) {
return str.substring(str.length - search.length) === search;
};
exports.endsWith = (str,search) => str.endsWith(search);
/*
Trim whitespace from the start and end of a string
*/
exports.trim = function(str) {
if(typeof str === "string") {
return str.trim();
@@ -54,54 +29,30 @@ exports.sign = Math.sign;
exports.strEndsWith = (str,ending,position) => str.endsWith(ending,position);
exports.stringifyNumber = function(num) {
return num + "";
};
exports.stringifyNumber = (num) => num.toString();
// Returns the fully escaped CSS selector for a tag, e.g.
// "$:/tags/Stylesheet" -> "tc-tagged-\%24\%3A\%2Ftags\%2FStylesheet"
exports.tagToCssSelector = function(tagName) {
return "tc-tagged-" + encodeURIComponent(tagName).replace(/[!"#$%&'()*+,\-./:;<=>?@[\\\]^`{\|}~,]/mg,function(c) {
return "\\" + c;
});
};
/*
Determines whether element 'a' contains element 'b'.
Returns false when a === b (matches the original John Resig semantics).
*/
exports.domContains = function(a,b) {
return a !== b && a.contains(b);
};
exports.domContains = (a,b) => a.compareDocumentPosition(b) & 16;
exports.domMatchesSelector = (node,selector) => node.matches(selector);
exports.hasClass = function(el,className) {
return !!(el && el.classList && el.classList.contains(className));
};
// addClass/removeClass/toggleClass split on whitespace to preserve the
// original setAttribute("class", ...) acceptance of "foo bar" as two
// classes. Regressed in #9251.
function splitClasses(className) {
return (typeof className === "string" && className.match(/\S+/g)) || [];
}
exports.hasClass = (el,className) => el.classList && el.classList.contains(className);
exports.addClass = function(el,className) {
if(!el.classList) return;
splitClasses(className).forEach(function(c) { el.classList.add(c); });
el.classList && className && el.classList.add(className);
};
exports.removeClass = function(el,className) {
if(!el.classList) return;
splitClasses(className).forEach(function(c) { el.classList.remove(c); });
el.classList && className && el.classList.remove(className);
};
exports.toggleClass = function(el,className,status) {
if(!el.classList) return;
splitClasses(className).forEach(function(c) { el.classList.toggle(c,status); });
el.classList && className && el.classList.toggle(className, status);
};
exports.getLocationPath = function() {
return window.location.toString().split("#")[0];
};
exports.getLocationPath = () => window.location.origin + window.location.pathname;
-13
View File
@@ -77,23 +77,10 @@ exports.resizeTextAreaToFit = function(domNode,minHeight) {
// Measure the specified minimum height
domNode.style.height = minHeight;
var measuredHeight = domNode.offsetHeight || parseInt(minHeight,10);
// Temporarily force rows=1 during auto-measurement so the intrinsic floor
// is one row rather than the HTML default of two; restore afterwards
var hadRowsAttr = domNode.hasAttribute("rows"),
savedRows = hadRowsAttr ? domNode.getAttribute("rows") : null;
if(!hadRowsAttr) {
domNode.setAttribute("rows","1");
}
// Set its height to auto so that it snaps to the correct height
domNode.style.height = "auto";
// Calculate the revised height
var newHeight = Math.max(domNode.scrollHeight + domNode.offsetHeight - domNode.clientHeight,measuredHeight);
// Restore the original rows attribute state
if(!hadRowsAttr) {
domNode.removeAttribute("rows");
} else {
domNode.setAttribute("rows",savedRows);
}
// Only try to change the height if it has changed
if(newHeight !== domNode.offsetHeight) {
domNode.style.height = newHeight + "px";
-9
View File
@@ -82,11 +82,6 @@ var TW_Style = function(el) {
// Return a Proxy to handle direct access to individual style properties
return new Proxy(styleObject, {
get: function(target, property) {
// Real CSSStyleDeclaration returns undefined for non-string keys.
// Guards against crashes when consumers probe Symbol.toPrimitive etc.
if(typeof property !== "string") {
return undefined;
}
// If the property exists on styleObject, return it (get, set, setProperty methods)
if(property in target) {
return target[property];
@@ -95,10 +90,6 @@ var TW_Style = function(el) {
return el._style[$tw.utils.convertStyleNameToPropertyName(property)] || "";
},
set: function(target, property, value) {
// Mirror the get trap: ignore non-string keys instead of crashing.
if(typeof property !== "string") {
return true;
}
// Set the property in _style
el._style[$tw.utils.convertStyleNameToPropertyName(property)] = value;
return true;
+42 -8
View File
@@ -59,14 +59,48 @@ LinkedList.prototype.push = function(/* values */) {
LinkedList.prototype.pushTop = function(value) {
var t;
if($tw.utils.isArray(value)) {
for(t=0; t<value.length; t++) {
_assertString(value[t]);
}
for(t=0; t<value.length; t++) {
_removeOne(this,value[t]);
}
for(t=0; t<value.length; t++) {
_linkToEnd(this,value[t]);
if(this.length === 0) {
// Fast path for empty list: skip removal pass
for(t = 0; t < value.length; t++) {
_assertString(value[t]);
}
var prev = null,
useInline = true;
for(t = 0; t < value.length; t++) {
if(useInline) {
var v = value[t];
var old = this.next.get(v);
if(old !== undefined) {
// Duplicate found: switch to _linkToEnd for this and all remaining elements
useInline = false;
_linkToEnd(this,v);
} else {
// Inline the common case of _linkToEnd for new unique values
this.next.set(v,null);
this.prev.set(v,prev);
if(prev !== null) {
this.next.set(prev,v);
} else {
this.next.set(null,v);
}
this.prev.set(null,v);
this.length++;
prev = v;
}
} else {
_linkToEnd(this,value[t]);
}
}
} else {
for(t=0; t<value.length; t++) {
_assertString(value[t]);
}
for(t=0; t<value.length; t++) {
_removeOne(this,value[t]);
}
for(t=0; t<value.length; t++) {
_linkToEnd(this,value[t]);
}
}
} else {
_assertString(value);
+4 -3
View File
@@ -125,12 +125,13 @@ SelectWidget.prototype.setSelectValue = function() {
values = Array.isArray(value) ? value : $tw.utils.parseStringArray(value);
for(var i=0; i < select.children.length; i++){
child=select.children[i];
if(child.tagName && child.tagName.toUpperCase() === "OPTGROUP"){
if(child.children.length === 0){
child.selected = values.indexOf(child.value) !== -1;
} else {
// grouped options
for(var y=0; y < child.children.length; y++){
child.children[y].selected = values.indexOf(child.children[y].value) !== -1;
}
} else {
child.selected = values.indexOf(child.value) !== -1;
}
}
} else {
+33 -6
View File
@@ -1400,15 +1400,15 @@ exports.search = function(text,options) {
fields.push("text");
}
// Function to check a given tiddler for the search term
var searchTiddler = function(title) {
var searchTiddler = function(tiddler,title) {
if(!searchTermsRegExps) {
return true;
}
var notYetFound = searchTermsRegExps.slice();
var tiddler = self.getTiddler(title);
if(!tiddler) {
tiddler = new $tw.Tiddler({title: title, text: "", type: "text/vnd.tiddlywiki"});
tiddler = self.getTiddler(title);
if(!tiddler) {
tiddler = new $tw.Tiddler({title: title, text: "", type: "text/vnd.tiddlywiki"});
}
}
var contentTypeInfo = $tw.config.contentTypeInfo[tiddler.fields.type] || $tw.config.contentTypeInfo["text/vnd.tiddlywiki"],
searchFields;
@@ -1424,6 +1424,33 @@ exports.search = function(text,options) {
} else {
searchFields = fields;
}
// Fast path for single search term (avoids array slice/splice per tiddler)
if(searchTermsRegExps.length === 1) {
var singleRegExp = searchTermsRegExps[0];
for(var fieldIndex=0; fieldIndex<searchFields.length; fieldIndex++) {
var fieldName = searchFields[fieldIndex];
if(fieldName === "text" && contentTypeInfo.encoding !== "utf8") {
continue;
}
var str = tiddler.fields[fieldName];
if(str) {
if($tw.utils.isArray(str)) {
for(var s=0; s<str.length; s++) {
if(singleRegExp.test(str[s])) {
return true;
}
}
} else {
str = tiddler.getFieldString(fieldName);
if(singleRegExp.test(str)) {
return true;
}
}
}
}
return false;
}
var notYetFound = searchTermsRegExps.slice();
for(var fieldIndex=0; notYetFound.length>0 && fieldIndex<searchFields.length; fieldIndex++) {
// Don't search the text field if the content type is binary
var fieldName = searchFields[fieldIndex];
@@ -1463,7 +1490,7 @@ exports.search = function(text,options) {
var results = [],
source = options.source || this.each;
source(function(tiddler,title) {
if(searchTiddler(title) !== invert) {
if(searchTiddler(tiddler,title) !== invert) {
results.push(title);
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
title: $:/config/OfficialPluginLibrary
tags: $:/tags/PluginLibrary
url: https://tiddlywiki.com/library/v5.4.1/index.html
url: https://tiddlywiki.com/library/v5.5.0/index.html
caption: {{$:/language/OfficialPluginLibrary}}
{{$:/language/OfficialPluginLibrary/Hint}}
@@ -11,7 +11,6 @@ ja-title: TiddlyWikiアーカイブ
5.1.20 5.1.21 5.1.22 5.1.23
5.2.0 5.2.1 5.2.2 5.2.3 5.2.4 5.2.5 5.2.6 5.2.7
5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.3.8
5.4.0
\end
TiddlyWikiの古いバージョンは[[アーカイブ|https://github.com/TiddlyWiki/tiddlywiki.com-gh-pages/tree/master/archive]]で入手できます:
@@ -21,5 +21,3 @@ type: text/vnd.tiddlywiki
<<.tip """上に示したように、図に開始線と終了線が1つある場合は、リンクされた上位レベルにさらに情報があることを意味します。パンくずリストはナビゲーションに使用できます""">>
<<.tip """下位レベルで使用されているように、図に開始点と終了点がない場合は、読みやすさと単純さを高めるために、上位レベルのシンタックス要素が削除されていることを意味します。パンくずリストはナビゲーションに使用できます""">>
<<.note """フィルタ式の再帰深度は最大300です。この制限を超えて自身を再帰的に呼び出すフィルタ(例えば、<<.olink subfilter>>や<<.olink filter>>演算子を使ったもの)は、エラーメッセージ`/**-- Excessive filter recursion --**/`を返します。これは無限ループを防ぐためです。""">>
@@ -14,8 +14,6 @@ type: text/vnd.tiddlywiki
"{" [: <-"間接"-> /"}以外"/] "}"
|
"<" [: <-"変数"-> /">以外"/] ">"
|
"(" [: <-"複数値変数"-> /")以外"/ ] ")" /"v5.4.0"/
)
"""/>
@@ -24,7 +22,6 @@ type: text/vnd.tiddlywiki
;<<.def ハード>>
: `[パラメータ例]`
: パラメータは、角括弧内のテキストそのものです。
;<<.def ソフト>>
: <<.def 間接>>
:: `{パラメータ例}`
@@ -33,9 +30,6 @@ type: text/vnd.tiddlywiki
:: `<パラメータ例>`
:: パラメータは、山括弧内の[[変数|Variables]]の現在値です。マクロパラメータは、v5.2.0まではサポートされて<<.em いません>>。
::<<.from-version "5.2.0">> リテラルマクロパラメータがサポートされています。例: `[<now [UTC]YYYY0MM0DD0hh0mm0ssXXX>]`。
: <<.def "複数値変数">>
:: `(パラメータ例)`
:: <<.from-version "5.4.0">> このパラメータは、丸括弧で囲まれた名前の[[複数値変数|Multi-Valued Variables]]に格納されている値のリスト全体に展開されます。このようにアクセスすると、フィルタ演算子は最初の値だけでなく、すべての値を受け取ります。詳細については、[[複数値変数|Multi-Valued Variables]]を参照してください。
<<.note """すべての[[フィルタオペレータ|filter Operator]]の後にはパラメータ式が続く必要があります。[[パラメータの無いオペレータ|Operators without parameters]]の場合、その式は空になります(`[<currentTiddler>links[]]`のフィルタオペレータ<<.olink links>>と同様)。""">>
@@ -43,8 +37,4 @@ type: text/vnd.tiddlywiki
<<.from-version "5.1.23">> [[フィルタステップ|Filter Step]]では、`,`文字で区切られた複数のパラメータがサポートされます。
例えば: `[param1],[param2]`や`<param1>,{param2}`、 `[param1],(param2)`
---
<<.warning """`/regexp/(flags)`オペランド構文は非推奨であり、ブラウザのコンソールに警告が表示されます。代わりに<<.olink regexp>>演算子を使用してください。""">>
例えば: `[param1],[param2]`や`<param1>,{param2}`
@@ -24,13 +24,13 @@ type: text/vnd.tiddlywiki
多くのステップでは、ステップの実行内容をさらに定義する明示的な<<.def パラメータ>>が必要です。
<<.def サフィックス>>は、特定のオペレータの意味を拡張する追加テキスト(多くの場合、[[フィールド|TiddlerFields]]名)です。サフィックスは`:`文字で区切られ、各サフィックスグループには、カンマで区切られた複数の値を含めることができます。例えば、`compare:number:gteq`は、最初のサフィックスは`number`で、2番目のサフィックスは`gteq`です。`:sort:string:reverse,casesensitive`は、最初のサフィックスは`string`で、2番目のサフィックスグループには`reverse`と`casesensitive`の両方が含まれます。
<<.def サフィックス>>は、特定のオペレータの意味を拡張する追加テキスト(多くの場合、[[フィールド|TiddlerFields]]名)です。
ステップの<<.def オペレータ>>と<<.def サフィックス>>がすべて//省略//されている場合は、デフォルトで[[title|title Operator]]オペレータが使用されます。サフィックスが存在するが、コロンの前のオペレータ名が空の場合(例: `[:fieldname[value]]`)、オペレータはデフォルトで<<.olink field>>になります。
ステップの<<.def オペレータ>>と<<.def サフィックス>>がすべて省略されている場合は、デフォルトで[[title|title Operator]]オペレータが使用されます。
<<.from-version "5.1.23">> いくつかのステップでは、`,`文字で区切られた複数の<<.def パラメータ>>を受け入れます。
認識されないオペレータは、<<.olink field>>オペレータのサフィックスであるかのように扱われます。<<.from-version "5.3.0">> ただし、名前に`.`(ドット)文字が含まれる認識されないオペレータは、ユーザー定義のフィルタオペレータとして扱われ、その名前の変数または関数を検索することで解決されます。.
認識されないオペレータは、<<.olink field>>オペレータのサフィックスであるかのように扱われます。
フィルタオペレータはプラグインによって拡張できます。
@@ -16,7 +16,6 @@ type: text/vnd.tiddlywiki
|`-[run]` |`:except[run]` |差集合 |... AND NOT ラン |
|`~[run]` |`:else[run]` |それ以外 |... ELSE ラン |
|`=[run]` |`:all[run]` |重複を排除しない和集合 |... OR ラン |
|`=>[run]` |`:let[run]` |<<.from-version "5.4.0">> 結果を変数に割りあてる |... LET ラン |
ランのインプットは通常、Wiki内の[[隠し|ShadowTiddlers]]Tiddler以外のすべてのTiddlerタイトルのリストです(順不同)。<br>ただし、`+`プレフィックスによってこれを変更できます:
@@ -46,5 +45,3 @@ type: text/vnd.tiddlywiki
新しいフィルタランプレフィックスを作成するには、`filterrunprefix`の[[モジュールタイプ|ModuleType]]で
[[Javascriptモジュール|Modules]]を作成します。
コンパイルされたフィルタ式はパフォーマンスのためキャッシュされます。キャッシュには最大2000件のエントリが保存され、この制限を超えるとキャッシュ全体がリセットされます。
@@ -17,15 +17,6 @@ type: text/vnd.tiddlywiki
[["ラン"|"Filter Run"]]
"""/>
前のランからのフィルタアウトプットは保留されます。`:intersection`フィルタランは、すべてのTiddlerタイトルをインプットとして開始されます。この最後のフィルタランが完了すると、最後のアウトプットが保留アウトプットと比較されます。保留アウトプットと最新アウトプットの両方に表われるタイトルのみを含む新しいアウトプットが生成されます。アウトプットにおけるタイトルの順序は、蓄積された結果から保持されます。
!! `:and` / `+`との違い
`:intersection`と`:and` (または `+`)の主な違いは、''インプット''として何を受け取るかです:
* `:and` / `+`は、''これまでに蓄積された結果''をフィルタランへのインプットとして渡します。ラン内のオペレータは、結果に既に含まれているタイトルのみを表示・操作できます。
* `:intersection`は、''すべてのTiddlerタイトル''をフィルタランへのインプットとして渡し(プレフィックスなしのランと同様)、その後、蓄積された結果にも含まれるタイトルのみを保持します。
これは、`:intersection`がTiddlerプール全体から開始する必要のあるオペレータを使用してタイトルを照合できることを意味します。詳細については、[[交換可能なフィルタランプレフィックス|Interchangeable Filter Run Prefixes]]を参照してください。
前のランからのフィルタアウトプットは保留されます。`:intersection`フィルタランは、すべてのTiddlerタイトルをインプットとして開始されます。この最後のフィルタランが完了すると、最後のアウトプットが保留アウトプットと比較されます。保留アウトプットと最新アウトプットの両方に表われるタイトルのみを含む新しいアウトプットが生成されます。
[[intersectionフィルタランプレフィックス(例)|Intersection Filter Run Prefix (Examples)]]
@@ -1,15 +0,0 @@
created: 20260222152853141
modified: 20260506104906199
original-modified: 20260222184916224
tags: [[Let Filter Run Prefix]]
title: Let Filter Run Prefix (Examples)
ja-title: letフィルタランプレフィックス(例)
type: text/vnd.tiddlywiki
<<.operator-example 1 "3 2 1 4 :let[[myvar]] 6 7 8 [(myvar)sort[]]" "名前付きプレフィックスを使用">>
<<.operator-example 2 "3 2 1 4 =>myvar 6 7 8 [(myvar)sort[]]" "短縮形のプレフィックスを使用">>
<<.operator-example 3 "3 2 1 4 =>myvar 6 7 8 [<myvar>]" "山括弧は最初の値のみを返却">>
<<.operator-example 4 "3 2 1 4 =>mynumbers [(mynumbers)sum[]] [(mynumbers)average[]]">>
<<.operator-example 5 '"[0-9]" =>digitsRE abc 123 de45 67fg hij :filter[regexp<digitsRE>]' "角括弧で囲まれた正規表現では、変数パラメータを使用">>
<<.operator-example 6 '"[prefix[ca]then[ca]]" "[suffix[at]then[at]]" other =>myfilters cat can bat bug :cascade[(myfilters)]' "[[cascadeフィルタランプレフィックス|Cascade Filter Run Prefix]]で使用するフィルタを定義">>
<<.operator-example 7 "[[ ]] [[⁑ ]] [[⁂ ]] :let[[prefixList]] [tag[Learning]first[3]] :map:flat[(prefixList)addsuffix<currentTiddler>]">>
@@ -1,32 +0,0 @@
created: 20250307212252946
from-version: 5.4.0
modified: 20260502101033176
original-modified: 20250307212252946
rp-input: 前回のフィルタランからのすべてのタイトル
rp-output: "let"フィルタランプレフィックスからはいつも空のタイトルリストが返されます
rp-purpose: 前回のフィルタラン結果のタイトルリストを複数値変数へ代入
tags: [[Named Filter Run Prefix]]
title: Let Filter Run Prefix
ja-title: letフィルタランプレフィックス
type: text/vnd.tiddlywiki
<$railroad text="""
\start none
\end none
( ":let" )
[["ラン"|"Filter Run"]]
"""/>
`:let`フィルタランプレフィックスは、前回のフィルタランの結果として得られたタイトルリストを[[他数値変数|Multi-Valued Variables]]に割り当てます。この変数には、フィルタランによって返された最初の結果が名前として付けられます。
この変数は、[[フィルタ式|Filter Expression]]内の残りの[[フィルタラン|Filter Run]]で使用可能になります。通常の方法で変数にアクセスすると、結果リストの最初の項目のみが返されます(結果リストが空の場合は空の文字列が返されます)。オペランドとして変数名を山括弧ではなく丸括弧で囲むと、結果リスト内のすべての項目が取得できます。
このプレフィックスには、オプションの[[ショートカット構文|Shortcut Filter Run Prefix]]記号`=>run`があります。例:
```
=[<myfun1>] =[<myfun2>] =>myvar
```
`:let`フィルタランプレフィックスを指定すると、常に現在の結果リストがクリアされます。
[[letフィルタランプレフィックス(例)|Let Filter Run Prefix (Examples)]]
@@ -25,7 +25,6 @@ type: text/vnd.tiddlywiki
[[<":or"> |"Or Filter Run Prefix"]] |
[[<":reduce"> |"Reduce Filter Run Prefix"]] |
[[<":sort"> /"v5.2.0"/ |"Sort Filter Run Prefix"]] |
[[<":let"> /"v5.4.0"/ |"Let Filter Run Prefix"]] |
[[<":then"> /"v5.3.0"/ |"Then Filter Run Prefix"]]) [[run|"Filter Run"]]
"""/>
@@ -36,14 +35,4 @@ type: text/vnd.tiddlywiki
<<.tip """フィルタランプレフィックス`:reduce`、`:sort`、`:map`、`:filter`内では、変数<<.var currentTiddler>>は処理中のTiddlerのタイトルに設定されます。<br>サブフィルタ外のcurrentTiddlerの値は、変数<<.var "..currentTiddler">>で使用できます <<.from-version "5.2.0">>""" >>
!! サフィックス
名前付きフィルタランプレフィックスは、`:`文字で区切られたサフィックスを受け入れることができ、各サフィックスにはオプションでカンマ区切りの値を含めることができます。一般的な構文は`:prefixname:suffix1:suffix2,...`です。現在、次のプレフィックスがサフィックスを受け入れます:
|!プレフィックス |!サフィッス |!例 |
|`:map` |`flat` — 項目ごとに最初の結果だけでなく、すべての結果を返す |`:map:flat[...]` |
|`:sort` |型 (''string'', ''alphanumeric'', ''number'', ''integer'', ''version'', ''date'')とフラグ (''reverse'', ''casesensitive'', ''caseinsensitive'') |`:sort:number:reverse[...]` |
その他の名前付きプレフィックス(`:all`, `:and`, `:cascade`, `:else`, `:except`, `:filter`, `:intersection`, `:let`, `:or`, `:reduce`, `:then`)は現在サフィックスを受け付けません。
参照: [[交換可能なフィルターランプレフィックス|Interchangeable Filter Run Prefixes]]
@@ -11,7 +11,7 @@ type: text/vnd.tiddlywiki
<$railroad text="""
\start none
\end none
(-|:"+"|"-"|"~"|"="|"=>")
(-|:"+"|"-"|"~"|"=")
[["ラン"|"Filter Run"]]
"""/>
@@ -23,10 +23,8 @@ type: text/vnd.tiddlywiki
* プレフィックス`-`は、アウトプットタイトルがフィルタのアウトプットから<<.em 取り除か>>れます(そのようなTiddlerが存在する場合)
* プレフィックス`~`<<.from-version "5.1.18">>は、フィルタアウトプットが空リストの場合、ランの結果のタイトルがフィルタアウトプットに[[優先的に追加|Dominant Append]]されます。フィルタアウトプットが空リストでない場合、ランは無視されます。
* プレフィックス`~`は、フィルタアウトプットが空リストの場合、ランの結果のタイトルがフィルタアウトプットに[[優先的に追加|Dominant Append]]されます。フィルタアウトプットが空リストでない場合、ランは無視されます。<<.from-version "5.1.18">>
* プレフィックス`=`<<.from-version "5.1.20">>は、アウトプットタイトルが重複排除されずにフィルタのアウトプットに追加されます。
* プレフィックス`=>` <<.from-version "5.4.0">>は、これまでのすべてのランの累積結果は、このランの最初の結果が名前となる[[複数値変数|Multi-Valued Variables]]として割り当てられます。その後、累積結果はクリアされます。
* プレフィックス`=`は、アウトプットタイトルが重複排除されずにフィルタのアウトプットに追加されます。<<.from-version "5.1.20">>
{{Interchangeable Filter Run Prefixes}}
@@ -2,7 +2,7 @@ created: 20210618133745003
from-version: 5.3.0
modified: 20250423104147235
original-modified: 20230710074225410
rp-input: <<.olink all>>のTiddlerタイトル (前回のランのアウトプットが空でない場合にのみ評価されます)
rp-input: <<.olink すべて>>のTiddlerタイトル
rp-output: フィルタランのアウトプットは、空のリストでない限り、前回までのランのアウトプットを置き換えます(以下を参照)。
rp-purpose: フィルタランへのインプットをそのアウトプットで置き換え、インプットがある場合にのみランを評価
search:
@@ -20,9 +20,8 @@ type: text/vnd.tiddlywiki
関数はいくつかの方法で呼び出すことができます:
* [[Calls]]構文を使用する:
** 構文 `<<myfun param:"value">>` を使用して関数を直接トランスクルードす
** 構文 `<div class=<<myfun param:"value">>>` を使用してウィジェット属性に関数を割り当てる
* 構文 `<<myfun param:"value">>` を使用して関数を直接トランスクルードする
* 構文 `<div class=<<myfun param:"value">>>` を使用してウィジェット属性に関数を割り当て
* 構文 `[function[myfun],[value],...]` を使用して [[関数オペレータ|function Operator]] を介して関数を呼び出す
* 構文 `[my.fun[value]]` または `[.myfun[value]]` で、カスタムフィルター演算子として名前にピリオドが含まれる関数を直接呼び出す
@@ -0,0 +1,13 @@
caption: Internet Explorer
created: 20140811172058274
modified: 20241029105938082
original-modified: 20211114031651879
tags: GettingStarted $:/deprecated
title: GettingStarted - Internet Explorer
ja-title: はじめに - Internet Explorer
type: text/vnd.tiddlywiki
{{Saving with TiddlyIE}}
The [[Windows HTA Hack]] describes an alternative method of using TiddlyWiki with Internet Explorer.
[[Windows HTAのハック|Windows HTA Hack]]では、Internet ExplorerでTiddlyWikiを使用する別の方法について説明します。
@@ -1,8 +1,8 @@
created: 20130822170200000
icon: $:/core/icon
list: [[A Gentle Guide to TiddlyWiki]] [[Discover TiddlyWiki]] [[Some of the things you can do with TiddlyWiki]] [[Ten reasons to switch to TiddlyWiki]] Examples [[What happened to the original TiddlyWiki?]]
modified: 20260430010447598
original-modified: 20260420192600833
modified: 20251213104120189
original-modified: 20250807084952911
tags: Welcome
title: HelloThere
ja-title: こんにちは
@@ -1,5 +1,5 @@
created: 20150414070451144
list: [[HelloThumbnail - Latest Version]] [[HelloThumbnail - Donations]] [[HelloThumbnail - Newsletter]] [[HelloThumbnail - Community Survey 2025]] [[HelloThumbnail - Introduction Video]] [[HelloThumbnail - Grok TiddlyWiki]] [[HelloThumbnail - MultiWikiServer]] [[HelloThumbnail - Twenty Years of TiddlyWiki]] [[HelloThumbnail - TiddlyWiki Privacy]] [[HelloThumbnail - Marketplace]] [[HelloThumbnail - Intertwingled Innovations]] [[HelloThumbnail - TiddlyWikiLinks]]
list: list: [[HelloThumbnail - Donations]] [[HelloThumbnail - Newsletter]] [[HelloThumbnail - Community Survey 2025]] [[HelloThumbnail - Introduction Video]] [[HelloThumbnail - Grok TiddlyWiki]] [[HelloThumbnail - Latest Version]] [[HelloThumbnail - MultiWikiServer]] [[HelloThumbnail - Twenty Years of TiddlyWiki]] [[HelloThumbnail - TiddlyWiki Privacy]] [[HelloThumbnail - Marketplace]] [[HelloThumbnail - Intertwingled Innovations]] [[HelloThumbnail - TiddlyWikiLinks]]
modified: 20260406043045066
original-modified: 20150414070948246
title: HelloThumbnail
@@ -5,7 +5,6 @@ caption: v<<version>>の新着情報
link: Releases
image: New Release Banner
color: #fff
ribbon-text: NEW
\define prerelease-regexp() [0-9]+\.[0-9]+\.[0-9]+\-prerelease
<$list filter="[<version>!regexp<prerelease-regexp>]" variable="ignore">
@@ -6,5 +6,6 @@ link: TiddlyWiki Newsletter
image: TiddlyWiki Newsletter Badge
color: #fff
type: text/vnd.tiddlywiki
ribbon-text: NEW
~TiddlyWikiコミュニティからの最も興味深く関連性のあるニュースをまとめた~TiddlyWikiニュースレターを購読できます
@@ -0,0 +1,19 @@
caption: HTA Hack
color: #F06292
created: 20131212223146250
delivery: DIY
description: Internet Explorerで変更を手動で直接保存する方法
method: save
modified: 20241012122755089
original-modified: 20200507110355115
tags: Saving Windows $:/deprecated
title: Windows HTA Hack
ja-title: Windows HTAのハック
type: text/vnd.tiddlywiki
<<.deprecated-since "5.3.6">>
Windowsでは、HTMLファイルの拡張子を`*.hta`に名前変更することで、TiddlyWikiを真のローカルアプリケーションに変換できます。その後、''fsosaver''モジュールは~ActiveX ~FileSystemObjectを使用して変更を保存できます。
この方法の欠点の1つは、TiddlyWikiファイルがUTF-16フォーマットで保存されるため、通常のUTF-8エンコードの場合と比べて2倍の大きさになることです。ただし、別の保存方法でファイルを開いて保存すると、ファイルはUTF-8に再エンコードされます。
詳細については、Wikipediaを参照してください: https://en.wikipedia.org/wiki/HTML_Application
@@ -1,9 +1,58 @@
caption: プロシージャ呼び出し
created: 20221007130006705
modified: 20260429105903754
original-modified: 20260125212303316
modified: 20260327114525571
original-modified: 20230419103154329
tags: WikiText Procedures
title: Procedure Calls
ja-title: プロシージャ呼び出し
type: text/vnd.tiddlywiki
<<.deprecated-since "5.4.0" "Calls">>
!! 紹介
このTiddlerでは、[[プロシージャ|Procedures]]を呼び出すさまざまな方法について説明します。
!! トランスクルージョンショートカットでのプロシージャ呼び出し
[[プロシージャ|Procedures]]を呼び出すには、プロシージャ名とパラメータ値を`<<`二重の山括弧で囲みます`>>`。
```
<<my-procedure param:"これはパラメータ値です">>
```
デフォルトでは、パラメータはプロシージャの定義と同じ順序でリストされます。パラメータに名前とコロンを付けることで、異なる順序でリストすることができます。
パラメータに値が指定されていない場合は、[[プロシージャ定義|Procedure Definitions]]でそのパラメータに指定されているデフォルト値が使用されます。(デフォルト値が定義されていない場合は、パラメータは空白になります。)
各パラメータ値は、`'`シングルクォーテーション`'`、`"`ダブルクォーテーション`"`、`"""`三重のダブルクォーテーション`"""`、`[[`二重の角括弧`]]`で囲むことができます。三重のダブルクォーテーションを使用すると、値にほぼあらゆる文字列を含めることができます。値にスペース、シングルクォーテーション、ダブルクォーテーションが含まれていない場合は、区切り文字は不要です。
[[パーサーモード|WikiText parser mode: macro examples]]に関する議論を参照してください。
!! <<.wlink TranscludeWidget>>ウィジェットでのプロシージャ呼び出し
ショートカット構文は、トランスクルードするプロシージャの名前を指定する`$variable`属性を持つ<<.wlink TranscludeWidget>>ウィジェットに展開されます。
```
<$transclude $variable="my-procedure" param="これはパラメータ値です"/>
```
ウィジェットは、ショートカット構文よりも柔軟性が高く、動的なパラメータ値を指定することも可能です。
!! 属性値にプロシージャ呼び出しを割り当て
プロシージャテキストは、ウィジェットやHTML要素の属性に直接割り当てることができます。プロシージャの結果はWiki化されないため、[[パラメータ処理|Procedure Parameter Handling]]は行われません。
```
<div class=<<myclasses>>>
...
</div>
```
!! フィルタでのプロシージャ呼び出しの使用
プロシージャ呼び出しはフィルタ内で使用できます。テキストはWiki化されないため、パラメータは無視されます。
```
<$list filter="[<my-procedure>]">
...
</$list>
```
@@ -1,66 +0,0 @@
caption: 呼び出し
created: 20221007130006705
modified: 20260429224543285
original-modified: 20260301030947969
tags: WikiText Procedures Functions Macros
title: Calls
ja-title: 呼び出し
type: text/vnd.tiddlywiki
!! 紹介
このTiddlerでは、[[プロシージャ|Procedures]]、[[関数|Functions]]、[[マクロ|Macros]]を呼び出すさまざまな方法について説明します。構文の正式な説明については、[[呼び出し構文|Call Syntax]]を参照してください。
!! トランスクルージョンショートカットでの呼び出し
呼び出しを実行するには、呼び出し先の名前とパラメータ値を`<<`二重の山括弧で囲みます`>>`。
```
<<my-procedure param:"これはパラメータ値です">>
```
デフォルトでは、パラメータは定義と同じ順序で解釈されます。パラメータ値に名前と等号を付けることで、異なる順序でリストすることができます。
パラメータに値が指定されていない場合は、[[プロシージャ定義|Procedure Definitions]]、[[関数定義|Function Definitions]]、[[マクロ定義|Macro Definitions]]でそのパラメータに指定されているデフォルト値が使用されます。(デフォルト値が定義されていない場合は、パラメータは空白になります。)
各パラメータ値は、`'`シングルクォーテーション`'`、`"`ダブルクォーテーション`"`、`"""`三重のダブルクォーテーション`"""`、`[[`二重の角括弧`]]`で囲むことができます。三重のダブルクォーテーションを使用すると、値にほぼあらゆる文字列を含めることができます。値にスペース、シングルクォーテーション、ダブルクォーテーションが含まれていない場合は、区切り文字は不要です。シングルクォーテーション、または、三重のバッククォーテーションで囲まれた[[置換属性値|Substituted Attribute Values]]もサポートされています。
[[パーサーモード|WikiText parser mode: macro examples]]に関する議論を参照してください。
!!! 例
<<testcase TestCases/Calls/ProcedureStaticAttributes>>
<<testcase TestCases/Calls/ProcedureDynamicAttributes>>
!! <<.wlink TranscludeWidget>>ウィジェットを用いた呼び出し
ショートカット構文は、トランスクルードするプロシージャの名前を指定する`$variable`属性を持つ<<.wlink TranscludeWidget>>ウィジェットに展開されます。
```
<$transclude $variable="my-procedure" param="これはパラメータ値です"/>
```
ウィジェットは、ショートカット構文よりも柔軟性が高く、カスタムウィジェットで上書きすることも可能です。
!! 呼び出し結果を属性値に割り当てる
呼び出しから返されるテキストは、ウィジェットやHTML要素の属性に直接割り当てることができます。呼び出しの結果はWiki化されないため、[[パラメータ処理|Procedure Parameter Handling]]は行われません。
```
<div class=<<myclasses>>>
...
</div>
```
!! フィルタでの呼び出しの使用
呼び出しはフィルタ内で使用できます。テキストはWiki化されないため、パラメータは無視されます。
//現在サポートされているのはリテラル文字列パラメータのみであることに注意してください//
```
<$list filter="[<my-procedure>]">
...
</$list>
```
@@ -1,12 +1,12 @@
created: 20130825160900000
modified: 20260430074718157
original-modified: 20250617140259415
modified: 20251214105948181
original-modified: 20241106165307259
tags: Features [[Working with TiddlyWiki]]
title: Encryption
ja-title: 暗号化
type: text/vnd.tiddlywiki
TiddlyWiki5を単一のHTMLファイルとして使用すると、[[Stanford JavaScript Crypto Library]]を使用してCCMモードのAES 256ビット暗号化でコンテンツを暗号化できます。
TiddlyWiki5を単一のHTMLファイルとして使用すると、[[Stanford JavaScript Crypto Library]]を使用してCCMモードのAES 128ビット暗号化でコンテンツを暗号化できます。
# サイドバーの''ツール''タブに切り替えて、南京錠アイコンのボタンを探します
# ボタンに<<.icon $:/core/images/unlocked-padlock>> ''パスワードの設定''と表示されている場合、現在のウィキは暗号化されていません。ボタンをクリックすると、以降の保存を暗号化するために使用されるパスワードの入力を求められます
@@ -16,5 +16,5 @@ TiddlyWiki5を単一のHTMLファイルとして使用すると、[[Stanford Jav
TiddlyWikiには、パスワード/暗号化に関連する、2つの無関係な機能があることに注意してください:
* [[Tiddlyhost]]に保存するときにパスワードを設定する機能。これは、''コントロールパネル'' <<.icon $:/core/images/options-button>>の"保存"タブで行います。
* [[Node.js|TiddlyWiki on Node.js]]のサーバ構成で標準のHTTP基本認証を使用する機能。これは、[[Listenコマンド|ListenCommand]]を使用してコマンドラインで実行されます。SSLと組み合わせると、GoogleやDropboxなどのオンラインサービスで得られるのと同じレベルの暗号化転送が実現されますが、ディスク上のデータは暗号化されません
* TiddlySpotに保存するときにパスワードを設定する機能。これは、''コントロールパネル'' <<.icon $:/core/images/options-button>>の"保存"タブで行います。
* [[Node.js|TiddlyWiki on Node.js]]のサーバ構成で標準のHTTP基本認証を使用する機能。これは、ServerCommandを使用してコマンドラインで実行されます。SSLと組み合わせると、GoogleやDropboxなどのオンラインサービスで得られるのと同じレベルの暗号化転送が実現されますが、ディスク上のデータは暗号化されません
@@ -0,0 +1,28 @@
caption: ~TiddlyIE
color: #4DB6AC
community-author: David Jade
created: 20131211220000000
delivery: Browser Extension
description: Internet Explorer用のブラウザ拡張
method: save
modified: 20241009114650356
original-modified: 20200507201415232
tags: [[Internet Explorer]] Saving $:/deprecated
title: Saving with TiddlyIE
ja-title: TiddlyIEでの保存
type: text/vnd.tiddlywiki
<<.deprecated-since "5.3.6">>
# TiddlyIEアドオンを次の場所からインストールします:
#* https://github.com/davidjade/TiddlyIE/releases
# Internet Explorerを再起動します。IEはTiddlyIEアドオンを有効にするように要求します。
#> //Microsoft Script Runtime//を有効にするプロンプトが表示される場合もあります。
# 次のリンクを[[ダウンロード|Download]]し、空のTiddlyWikiを保存します:
#> https://tiddlywiki.com/empty.html
# ダウンロードしたファイルを見つけます
#* ファイル名を変更することもできますが、拡張子`.html`か`.htm`はそのままとしてください
# Internet Explorerでファイルを開きます
# サイドバーの''新しいTiddlerを作成します'' <<.icon $:/core/images/new-button>>ボタンを使用して、新しいTiddlerを作成してみてください。Tiddlerのコンテンツを入力し、<<.icon $:/core/images/done-button>> ''編集内容を確定します''ボタンをクリックします
# サイドバーの<<.icon $:/core/images/save-button-dynamic>> ''Wikiを保存します''ボタンをクリックして変更を保存します。Internet Explorerは、''名前を付けて保存''ダイアログを表示して、ファイルをローカルに保存することに同意するかどうかを確認します。
# ブラウザウィンドウを更新して、変更が正しく保存されたことを確認してください
@@ -3,13 +3,11 @@ modified: 20260404110322627
original-modified: 20240619210723396
tags: [[Variable Usage]]
title: Behaviour of variables invoked via widget attributes
ja-title: ウィジェット属性を介して呼び出される変数の動作
ja-title:
type: text/vnd.tiddlywiki
|tc-first-col-min-width|k
|!宣言方法|!動作|
|\define|本文テキストに対してパラメータのテキスト置換が実行されます。それ以上の処理は行われません。テキスト置換後の結果が属性の値として使用されます|
|<<.wlink SetWidget>>, <<.wlink LetWidget>>, <<.wlink VarsWidget>>, \procedure, \widget|本文はそのまま取得され、属性の値として使用されます|
|\function|ファンクション(例: .myfun)が`<div class=<<.myfun>>/>`で呼び出されると、`<div class={{{[function[.myfun]]}}}/>`の同義になります。他のフィルタ付きトランスクルージョン(つまり、三重中括弧)と同様に、最初の結果以外はすべて破棄されます。最初の結果が属性の値として使用されます。この形式で呼び出された場合でも、ファンクションは再帰的に処理されることに注意してください。つまり、ファンクション内のフィルタ式は別のファンクションを呼び出すことができ、処理は継続されます|
<<.from-version "5.4.0">> `<<var>>`の代わりに[[複数値変数の属性|Multi-Valued Variable Attribute Values]]構文`((var))`を使用すると、最初の値だけでなく、値のリスト全体が属性に渡されます。これは主に、TranscludeWidgetを介して[[複数値変数|Multi-Valued Variables]]をプロシージャや関数のパラメーターに渡す場合に便利です。
|\function|ファンクション(例: .myfun)が`<div class=<<.myfun>>/>`で呼び出されると、`<div class={{{[function[.myfun]]}}}/>`の同義になります。他のフィルタ付きトランスクルージョン(つまり、三重中括弧)と同様に、最初の結果以外はすべて破棄されます。最初の結果が属性の値として使用されます。この形式で呼び出された場合でも、ファンクションは再帰的に処理されることに注意してください。つまり、ファンクション内のフィルタ式は別のファンクションを呼び出すことができ、処理は継続されます|
@@ -1,46 +1,16 @@
caption: 説明リスト
created: 20131205160424246
modified: 20260409111112126
original-modified: 20260405082055301
modified: 20260221103246354
original-modified: 20251229110936191
tags: WikiText
title: Description Lists in WikiText
ja-title: Wikiテキストでの説明リスト
type: text/vnd.tiddlywiki
!! 基本的な構文
HTML説明リスト(<abbr title="またの名を">別名</abbr> 定義リスト)は次の構文で作成できます:
<<wikitext-example src:"; 説明する用語
: その用語の説明/定義
; 別の用語
: 別の説明/定義
">>
!! 複数の用語と説明
1つの用語に対して複数の説明を作成することも、1つの説明に対して複数の用語を作成することもできます:
<<wikitext-example src:"; ねずみ
: 体が小さく尾が長い齧歯類
: コンピュータを操作するために使用するポインティングデバイス
; りんご
; なし
: バラ科に属する果物の一種
">>
!! 入れ子になった説明リスト
説明リストはネストして、リストの中にリストを作成することもできます:
<<wikitext-example src:"; コーヒー
: 焙煎したコーヒー豆から作られた飲み物
:; ブラックコーヒー
:: 添加物を一切使用しないコーヒー
:; ミルクコーヒー
:: コーヒーにスチームミルクやフォームミルクを加えたもの
::; ラテ
::: エスプレッソとスチームミルクで作ったコーヒー
; ティー
: 一般的に茶葉から作られる飲み物
">>
">>
@@ -40,7 +40,7 @@ type: text/vnd.tiddlywiki
|`__下線テキスト__`には二重アンダースコアを使用します|`<u>下線テキスト<u>` |
|`^^上付き文字^^`テキストには二重サーカムフレックスアクセントを使用します |`<sup>上付き文字</sup>`テキストには二重サーカムフレックスアクセントを使用します |
|`,,下付き文字,,`テキストには二重カンマを使用します |`<sub>下付き文字</sub>`テキストには二重カンマを使用します |
|`~~取り消し線~~`テキストには二重チルダ記号を使用します |`<s>取り消し線</s>`テキストには二重チルダ記号を使用します |
|`~~取り消し線~~`テキストには二重チルダ記号を使用します |`<strike>取り消し線</strike>`テキストには二重チルダ記号を使用します |
|```等幅文字` ``には単一のバッククォートを使用します |`<code>等幅文字</code>`には単一のバッククォートを使用します |
|`@@ハイライト@@`するには二重@記号を使用します |`<span class="tc-inline-style">ハイライト</span>`するには二重@記号を使用します |
@@ -1,7 +1,7 @@
caption: リスト
created: 20131205160257619
modified: 20260409112505342
original-modified: 20260405094604397
modified: 20260225114001626
original-modified: 20160607093103220
tags: WikiText Lists
title: Lists in WikiText
ja-title: Wikiテキストでのリスト
@@ -45,10 +45,6 @@ type: text/vnd.tiddlywiki
#** そしてもう一つ
">>
! 説明リスト
{{Description Lists in WikiText}}
! CSSクラス
次の表記法を使用して、リストの個々のメンバーにCSSクラスを割り当てることもできます:
@@ -2,8 +2,59 @@ caption: マクロ呼び出し
created: 20150220182252000
modified: 20260321104805874
original-modified: 20230419103154328
tags: WikiText Macros
title: Macro Calls
ja-title: マクロ呼び出し
type: text/vnd.tiddlywiki
<<.deprecated-since "5.4.0" "Calls">>
!! 紹介
このTiddlerでは、[[マクロ|Macros]]を呼び出すさまざまな方法について説明します。
!! マクロ呼び出しトランスクルージョンのショートカット
[[マクロ|Macros]]を呼び出すには、マクロ名とパラメータ値を`<<`二重山括弧`>>`で囲みます。
```
<<mymacro param:"これはパラメータ値です">>
```
デフォルトでは、パラメータはマクロの定義と同じ順序でリストします。パラメータに名前とコロンを付けることで、異なる順序でリストすることも可能です。
パラメータに値が指定されていない場合は、[[マクロ定義|Macro Definitions]]でそのパラメータに指定されているデフォルト値が代わりに使用されます。(デフォルト値が定義されていない場合は、パラメータはブランクになります。)
各パラメータ値は、`'`シングルクォーテーション`'`、`"`ダブルクォーテーション`"`、`"""`三重ダブルクォーテーション`"""`、または`[[`二重角括弧`]]`で囲むことができます。三重ダブルクォーテーションを使用すると、値にほぼあらゆる文字列を含めることができます。値にスペース、シングルクォーテーション、ダブルクォーテーションが含まれていない場合は、区切り文字は必要ありません。
この構文の正式な[[説明|Macro Call Syntax]]も利用可能です。
[[パーサーモード|WikiText parser mode: macro examples]]に関する[[例|Macro Calls in WikiText (Examples)]]と議論を参照してください。
!! <<.wlink TranscludeWidget>>ウィジェットを用いたマクロ呼び出し
ショートカット構文は、トランスクルードするマクロの名前を指定する`$variable`属性を持つ<<.wlink TranscludeWidget>>ウィジェットに展開されます。
```
<$transclude $variable="mymacro" param="これはパラメータ値です"/>
```
ウィジェット自体は、パラメータ値を指定できるなど、ショートカット構文よりも柔軟です。
!! 属性値にマクロ呼び出しを割り当てる
マクロの実行結果は、ウィジェットやHTML要素の属性に割り当てることができます。マクロの実行結果はWiki変換されませんが、[[パラメータの置換|Macro Parameter Handling]]は実行されます。
```
<div class=<<myclasses "Horizontal">>>
...
</div>
```
!! フィルターでマクロ呼び出しを使用する
マクロ呼び出しはフィルタ内で使用できます:
```
<$list filter="[<mymacro param:'value'>]">
...
</$list>
```
@@ -1,39 +0,0 @@
created: 20250208120000000
modified: 20260507110511201
original-modified: 20250208120000000
tags: WikiText [[Widget Attributes]]
title: Multi-Valued Variable Attribute Values
ja-title: 複数値変数の属性値
type: text/vnd.tiddlywiki
<<.from-version "5.4.0">> 複数値変数の属性値は、変数名を二重の丸括弧で囲むことで指定できるようになりました。これにより、[[複数値変数|Multi-Valued Variables]]の最初の値だけでなく、すべての値が属性に渡されます。
```
<$transclude $variable="myproc" items=((myvar))/>
```
これは、下位互換性のために最初の値のみを返す[[変数の属性値|Variable Attribute Values]]構文`<<var>>`の複数値版です。この関係は、`<var>`が単一の値を返し、`(var)`がすべての値を返すフィルタオペランドの既存の慣例を反映しています。
! 複数値変数非対応の属性
`((var))`が複数値変数をサポートしていないウィジェット属性([[textウィジェット|TextWidget]]の`text`属性など)で使用される場合、最初の値のみが使用されます:
```
<$text text=((myvar))/>
```
! プロシージャに複数値変数を渡す
この構文の主なユースケースは、複数の値を持つ変数を`$transclude`パイプラインを通してプロシージャや関数に渡すことです:
```
\procedure showItems(itemList)
<$text text={{{ [(itemList)join[-]] }}}/>
\end
<$let items={{{ [all[tiddlers]sort[]] }}}>
<$transclude $variable="showItems" itemList=((items))/>
</$let>
```
この例では、`items`に格納されているTiddlerタイトルの完全なリストが`showItems`プロシージャの`itemList`パラメータに渡されます。
@@ -1,5 +1,5 @@
created: 20220817153236691
modified: 20260409114243582
modified: 20260404112841923
original-modified: 20221010074314452
tags: [[Tables in WikiText]]
title: Tables in WikiText CSS Utility Classes
@@ -81,14 +81,3 @@ full-tiddler-widthである表に、左右の余白を追加するには、`tc-m
| セル1|<$edit-text tiddler="$:/temp/test-table-input" tag="input" field="test"/> |
|^ [[Tiddlerへのリンク|Link to a tiddler]]<br>さらに詳しい説明|<$edit-text tiddler="$:/temp/test-table-input" field="text"/> |
""">>
!! 行スタイルを交互にした表
独自のスタイルシートを作成する必要があります。[[スタイルシートの使用|Using Stylesheets]]を参照してください。考え方は、`:nth-of-type(even/odd)`を使用してルールを作成することです。例:
```
.myclass tbody tr:nth-of-type(even) { background-color: <<color tiddler-editor-fields-even>>; }
.myclass tbody tr:nth-of-type(odd) { background-color: <<color tiddler-editor-fields-odd>>; }
```
<<.note """~TiddlyWikiは、テーブルの行に`evenRow`クラスと`oddRow`クラスを自動的に適用します。ただし、これらのクラスの使用は推奨されません。行は'even'(想定とは逆)で始まり、ヘッダー、フッター、本文のいずれに表示されるかに関わらず、テーブルのすべての行は単一の結合された行セットとして扱われます。""">>
@@ -66,28 +66,6 @@ or, when used with a template, `{{{ [tag[mechanism]]||TemplateTitle }}}` expands
<<.tip "//Internals//プラグインをインストールすると、生成されたウィジェットツリーをエディターのプレビューペインに表示できます">>
!! 複数値変数の表示
<<.from-version "5.4.0">> `((var))`構文をインラインで使用して、[[複数値変数|Multi-Valued Variables]]の値を、デフォルトではカンマとスペースで連結して表示できるようになりました:
```
((myvar))
((myvar||:))
```
オプションの`||`区切り文字は、カスタムの区切り文字を指定します。
!! インラインフィルター表示
<<.from-version "5.4.0">> `(((filter)))`構文によりフィルタ式の結果をデフォルトではカンマとスペースで連結してインラインで表示します:
```
((( [all[tiddlers]sort[]] )))
((( [all[tiddlers]sort[]] ||: )))
```
オプションの`||`区切り文字は、カスタムの区切り文字を指定します。これは、フィルタリングされたトランスクルージョン構文`{{{ }}}`に対応するインライン表示の表現です。
---
参照:
@@ -13,7 +13,6 @@ type: text/vnd.tiddlywiki
* [[マクロ/変数のトランスクルージョン|Variable Attribute Values]]
* [[フィルター式の結果として|Filtered Attribute Values]]
* <<.from-version "5.3.0">> [[指定された文字列に対してフィルタと変数置換を実行した結果として|Substituted Attribute Values]]
* <<.from-version "5.4.0">> [[複数値変数の参照として|Multi-Valued Variable Attribute Values]]
|属性タイプ|構文|h
|リテラル |スペースを含まない値には、シングルクォーテーション、ダブルクォーテーション、トリプルクォーテーション、またはクォーテーションなし |
@@ -21,9 +20,9 @@ type: text/vnd.tiddlywiki
|変数 |マクロまたは変数呼び出しを囲む二重の山括弧 |
|フィルタリング結果 |フィルター式を囲む三重の中括弧|
|置換結果|置換処理を行うテキストの、単一または三重のバッククォート|
|複数値変数 |変数名を囲む二重の丸括弧 |
<$list filter="[[Literal Attribute Values]] [[Transcluded Attribute Values]] [[Variable Attribute Values]] [[Filtered Attribute Values]] [[Substituted Attribute Values]] [[Multi-Valued Variable Attribute Values]]">
<$list filter="[[Literal Attribute Values]] [[Transcluded Attribute Values]] [[Variable Attribute Values]] [[Filtered Attribute Values]] [[Substituted Attribute Values]]">
<$link><h1><$view tiddler=<<currentTiddler>> field=ja-title/></h1></$link>
<$transclude mode="block"/>
</$list>
+10
View File
@@ -0,0 +1,10 @@
{
"description": "Performance measurement edition",
"plugins": [
"tiddlywiki/performance"
],
"themes": [
"tiddlywiki/vanilla",
"tiddlywiki/snowwhite"
]
}
@@ -1,6 +1,6 @@
title: $:/config/LocalPluginLibrary
tags: $:/tags/PluginLibrary
url: http://127.0.0.1:8080/prerelease/library/v5.4.1/index.html
url: http://127.0.0.1:8080/prerelease/library/v5.5.0/index.html
caption: {{$:/language/OfficialPluginLibrary}} (Prerelease Local)
A locally installed version of the official ~TiddlyWiki plugin library at tiddlywiki.com for testing and debugging. //Requires a local web server to share the library//
@@ -1,6 +1,6 @@
title: $:/config/OfficialPluginLibrary
tags: $:/tags/PluginLibrary
url: https://tiddlywiki.com/prerelease/library/v5.4.1/index.html
url: https://tiddlywiki.com/prerelease/library/v5.5.0/index.html
caption: {{$:/language/OfficialPluginLibrary}} (Prerelease)
The prerelease version of the official ~TiddlyWiki plugin library at tiddlywiki.com. Plugins, themes and language packs are maintained by the core team.
+1
View File
@@ -14,6 +14,7 @@
"tiddlywiki/jszip",
"tiddlywiki/confetti",
"tiddlywiki/tour",
"tiddlywiki/performance",
"tiddlywiki/dom-to-image"
],
"themes": [
+1 -1
View File
@@ -7,7 +7,7 @@ const crossPlatformIndexPath = indexPath.replace(/^\/+/, "");
test("get started link", async ({ page }) => {
// The tests can take a while to run
const timeout = 1000 * 60;
const timeout = 1000 * 30;
test.setTimeout(timeout);
// Load the generated test TW html
@@ -1,252 +0,0 @@
/*\
title: test-deprecated.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Regression-guard tests for $:/core/modules/utils/deprecated.js.
Locks in pre-5.4.0 tolerant behaviour of $tw.utils helpers that regressed
in PR #9251 (one-line modern equivalents diverge on edge-case inputs).
Without the companion restoration patch to core/modules/utils/deprecated.js:
8 specs fail. With the patch: green.
The addClass/removeClass/toggleClass specs at the end were moved from
test-utils.js — RSOD guard for the SampleWizard report (class field
"aaa bbb" crashing classList.add with InvalidCharacterError).
\*/
"use strict";
describe("deprecated.js — backwards-compat",function() {
describe("$tw.utils.repeat",function() {
it("returns '' for zero or negative count (does not throw)",function() {
expect($tw.utils.repeat("x",0)).toBe("");
expect($tw.utils.repeat("x",-1)).toBe("");
expect($tw.utils.repeat("x",-100)).toBe("");
});
it("coerces null/undefined str rather than throwing",function() {
expect($tw.utils.repeat(null,3)).toBe("nullnullnull");
expect($tw.utils.repeat(undefined,2)).toBe("undefinedundefined");
});
it("still works for normal inputs",function() {
expect($tw.utils.repeat("ab",3)).toBe("ababab");
expect($tw.utils.repeat("-",5)).toBe("-----");
});
});
describe("$tw.utils.startsWith / $tw.utils.endsWith",function() {
it("tolerate a RegExp search argument without throwing",function() {
// Old impl uses substring coercion; native String.prototype.startsWith
// throws TypeError when passed a RegExp.
expect(function() { $tw.utils.startsWith("abc",/a/); }).not.toThrow();
expect(function() { $tw.utils.endsWith("abc",/c/); }).not.toThrow();
});
it("still match normal string inputs",function() {
expect($tw.utils.startsWith("abcdef","abc")).toBe(true);
expect($tw.utils.startsWith("abcdef","xyz")).toBe(false);
expect($tw.utils.endsWith("abcdef","def")).toBe(true);
expect($tw.utils.endsWith("abcdef","xyz")).toBe(false);
});
});
describe("$tw.utils.stringifyNumber",function() {
it("coerces null/undefined via string-concat rather than throwing",function() {
expect($tw.utils.stringifyNumber(null)).toBe("null");
expect($tw.utils.stringifyNumber(undefined)).toBe("undefined");
});
it("still returns a number's string form",function() {
expect($tw.utils.stringifyNumber(42)).toBe("42");
expect($tw.utils.stringifyNumber(-3.14)).toBe("-3.14");
expect($tw.utils.stringifyNumber(0)).toBe("0");
});
});
describe("$tw.utils.domContains",function() {
// Stub nodes expose both .contains() and .compareDocumentPosition() so
// both the old (compareDocumentPosition & 16 → number) and new
// (a !== b && a.contains(b) → boolean) forms can be observed.
function makeNode(children) {
children = children || [];
var self;
self = {
contains: function(other) {
if(other === self) { return true; }
return children.some(function(c) { return c === other || c.contains(other); });
},
compareDocumentPosition: function(other) {
if(other === self) { return 0; }
return self.contains(other) ? 16 : 0;
}
};
return self;
}
it("returns strictly boolean true/false, not a bit-mask number",function() {
var child = makeNode();
var parent = makeNode([child]);
var unrelated = makeNode();
expect($tw.utils.domContains(parent,child)).toBe(true);
expect($tw.utils.domContains(parent,unrelated)).toBe(false);
});
it("returns false for domContains(x, x)",function() {
var a = makeNode();
expect($tw.utils.domContains(a,a)).toBe(false);
});
});
describe("$tw.utils.hasClass",function() {
it("returns false for null/undefined element without throwing",function() {
expect(function() { $tw.utils.hasClass(null,"foo"); }).not.toThrow();
expect($tw.utils.hasClass(null,"foo")).toBe(false);
expect(function() { $tw.utils.hasClass(undefined,"foo"); }).not.toThrow();
expect($tw.utils.hasClass(undefined,"foo")).toBe(false);
});
it("returns strictly false (not undefined) for elements without classList",function() {
expect($tw.utils.hasClass({},"foo")).toBe(false);
});
it("delegates to classList.contains for real elements",function() {
var el = { classList: { contains: function(c) { return c === "a" || c === "b"; } } };
expect($tw.utils.hasClass(el,"a")).toBe(true);
expect($tw.utils.hasClass(el,"b")).toBe(true);
expect($tw.utils.hasClass(el,"c")).toBe(false);
});
});
// getLocationPath reads window.location: specs pend in Node (no `window`
// in the TW5 sandbox) and use history.replaceState in the browser —
// assigning to window.location would trigger a navigation and reload.
describe("$tw.utils.getLocationPath",function() {
var originalUrl;
beforeEach(function() {
if(!$tw.browser) { return; }
originalUrl = window.location.href;
});
afterEach(function() {
if(!$tw.browser) { return; }
history.replaceState(null,"",originalUrl);
});
it("preserves the query string in the returned path",function() {
if(!$tw.browser) { pending("browser-only: requires window.location - run in browser"); return; }
history.replaceState(null,"","?lang=de&x=1#Intro");
var path = $tw.utils.getLocationPath();
expect(path).toContain("?lang=de&x=1");
expect(path).not.toContain("#Intro");
});
it("strips the hash fragment",function() {
if(!$tw.browser) { pending("browser-only: requires window.location - run in browser"); return; }
history.replaceState(null,"","#SomeTiddler");
// Sanity check: replaceState actually changed the hash.
expect(window.location.hash).toBe("#SomeTiddler");
var path = $tw.utils.getLocationPath();
expect(path).not.toContain("#");
// Rebuild expected href without the hash — works on http(s):// and file://.
var expected = window.location.href.split("#")[0];
expect(path).toBe(expected);
});
it("includes the query string when no hash is present",function() {
if(!$tw.browser) { pending("browser-only: requires window.location - run in browser"); return; }
history.replaceState(null,"","?x=1");
var path = $tw.utils.getLocationPath();
expect(path).toMatch(/\?x=1$/);
expect(path).not.toContain("#");
});
});
// Regression guard: classList.add/remove/toggle throw InvalidCharacterError on
// whitespace. Manual repro: tw5-com #SampleWizard, class="aaa bbb", Done, popup
// -> OK -> nested popup -> RSOD. Stub classList mirrors real DOM semantics
// (reject whitespace, de-dupe on add, no-op on remove of missing token).
describe("addClass/removeClass/toggleClass",function() {
function makeEl() {
var tokens = [];
function reject(t) { if(/\s/.test(t)) { throw new Error("InvalidCharacterError: '" + t + "'"); } }
return {
classList: {
add: function() {
for(var i = 0; i < arguments.length; i++) {
reject(arguments[i]);
if(tokens.indexOf(arguments[i]) === -1) { tokens.push(arguments[i]); }
}
},
remove: function() {
for(var i = 0; i < arguments.length; i++) {
reject(arguments[i]);
var idx = tokens.indexOf(arguments[i]);
if(idx !== -1) { tokens.splice(idx,1); }
}
},
toggle: function(cls,status) {
reject(cls);
var has = tokens.indexOf(cls) !== -1;
var want = status === undefined ? !has : status;
if(want && !has) { tokens.push(cls); }
if(!want && has) { tokens.splice(tokens.indexOf(cls),1); }
}
},
_tokens: tokens
};
}
it("splits on every ASCII-whitespace flavour (space, tab, newline, CR, mixed runs, leading/trailing)",function() {
var el = makeEl();
$tw.utils.addClass(el," a\tb\nc\r\nd \t e ");
expect(el._tokens).toEqual(["a","b","c","d","e"]);
});
it("splits on Unicode whitespace too (U+00A0 non-breaking space, a common paste-in hazard)",function() {
var el = makeEl();
$tw.utils.addClass(el,"a\u00A0b");
expect(el._tokens).toEqual(["a","b"]);
});
it("de-duplicates tokens within one call and across calls",function() {
var el = makeEl();
$tw.utils.addClass(el,"x x y");
$tw.utils.addClass(el,"y z");
expect(el._tokens).toEqual(["x","y","z"]);
});
it("remove is a no-op for missing tokens and tolerates mixed-presence input",function() {
var el = makeEl();
$tw.utils.addClass(el,"a b");
$tw.utils.removeClass(el,"b c d");
expect(el._tokens).toEqual(["a"]);
});
it("toggle with no status flips each token independently",function() {
var el = makeEl();
$tw.utils.addClass(el,"a");
$tw.utils.toggleClass(el,"a b");
expect(el._tokens).toEqual(["b"]);
});
it("toggle with status=true/false forces state regardless of current",function() {
var el = makeEl();
$tw.utils.addClass(el,"a");
$tw.utils.toggleClass(el,"a b",true);
expect(el._tokens).toEqual(["a","b"]);
$tw.utils.toggleClass(el,"a b",false);
expect(el._tokens).toEqual([]);
});
it("is a silent no-op for whitespace-only / empty / non-string / null / undefined className",function() {
var el = makeEl();
var inputs = ["", " \t\n ", null, undefined, 42, {}, ["a"]];
inputs.forEach(function(v) {
expect(function() { $tw.utils.addClass(el,v); }).not.toThrow();
expect(function() { $tw.utils.removeClass(el,v); }).not.toThrow();
expect(function() { $tw.utils.toggleClass(el,v); }).not.toThrow();
});
expect(el._tokens).toEqual([]);
});
it("is a silent no-op when element has no classList (SVG in old browsers, detached nodes, stubs)",function() {
var el = {};
expect(function() { $tw.utils.addClass(el,"a b"); }).not.toThrow();
expect(function() { $tw.utils.removeClass(el,"a b"); }).not.toThrow();
expect(function() { $tw.utils.toggleClass(el,"a b",true); }).not.toThrow();
});
});
});
@@ -1,138 +0,0 @@
/*\
title: $:/tests/test-edit-widgets/helpers
type: application/javascript
module-type: library
Shared test helpers for the edit-text / edit-widget test suite. Every
helper here is usable in fakedom — nothing reaches for real DOM layout,
iframe documents, or synthetic events.
Import with:
var helpers = require("$:/tests/test-edit-widgets/helpers");
\*/
"use strict";
var widget = require("$:/core/modules/widgets/widget.js");
/*
makeWiki: build a fresh in-memory test wiki. Pass an array of tiddler
specs to seed. Use `seedTestTiddler: true` in the options to also add a
convenience "TestTiddler" (empty text) that many tests default to.
*/
exports.makeWiki = function(tiddlers,options) {
options = options || {};
var wiki = $tw.test.wiki();
if(options.seedTestTiddler) {
wiki.addTiddlers([{title: "TestTiddler", text: ""}]);
}
if(tiddlers) {
wiki.addTiddlers(tiddlers);
}
return wiki;
};
/*
parseAndRender: parse a wikitext snippet, build the widget tree against
the fakedom, and render into a detached wrapper <div>. Returns both the
top-level widgetNode (for refresh/teardown) and the wrapper (for DOM
inspection).
*/
exports.parseAndRender = function(widgetText,wiki) {
var parser = wiki.parseText("text/vnd.tiddlywiki",widgetText);
var widgetNode = new widget.widget(
{type: "widget", children: parser.tree},
{wiki: wiki, document: $tw.fakeDocument}
);
$tw.fakeDocument.setSequenceNumber(0);
var wrapper = $tw.fakeDocument.createElement("div");
widgetNode.render(wrapper,null);
return {widgetNode: widgetNode, wrapper: wrapper};
};
/*
findEditTextWidget: walk a widget tree and return the first
EditTextWidget instance (identified by having both an `engine` and an
`editTag` property). Needed because parseAndRender returns the
enclosing "widget" root, not the <$edit-text> itself.
*/
exports.findEditTextWidget = function findEditTextWidget(node) {
if(node.engine && node.editTag !== undefined) {
return node;
}
if(node.children) {
for(var i = 0; i < node.children.length; i++) {
var found = findEditTextWidget(node.children[i]);
if(found) return found;
}
}
return null;
};
/*
renderFromWikitext: parse+render a wikitext snippet and return a bundle
{ widget, root, wrapper, wiki } exposing every layer. Used by the
attribute-propagation and refresh tests which need access to the wiki
and wrapper after render.
*/
exports.renderFromWikitext = function(widgetText,wiki) {
wiki = wiki || exports.makeWiki([{title: "TestTiddler", text: "hello"}]);
var result = exports.parseAndRender(widgetText,wiki);
return {
widget: exports.findEditTextWidget(result.widgetNode),
root: result.widgetNode,
wrapper: result.wrapper,
wiki: wiki
};
};
/*
renderFromAttrs: build an <$edit-text> tag from an attrs object, render
it, and return just the EditTextWidget instance (not a full bundle).
Used by tests that only need the widget and its DOM node.
*/
exports.renderFromAttrs = function(attrs,wiki,tiddlerTitle) {
wiki = wiki || exports.makeWiki(null,{seedTestTiddler: true});
var attrStr = Object.keys(attrs).map(function(k) {
return k + "=\"" + attrs[k] + "\"";
}).join(" ");
var text = "<$edit-text tiddler=\"" + (tiddlerTitle || "TestTiddler") + "\" " + attrStr + "/>";
var rendered = exports.parseAndRender(text,wiki);
return exports.findEditTextWidget(rendered.widgetNode);
};
/*
refresh: drive a widget-tree refresh cycle for a given list of changed
tiddler titles, mimicking what the core does after wiki.addTiddler.
*/
exports.refresh = function(rootWidget,wrapper,changedTitles) {
var changed = {};
(changedTitles || []).forEach(function(t) { changed[t] = true; });
rootWidget.refresh(changed,wrapper,null);
};
/*
editorValue: read the editor's current text in a way that works with
fakedom. SimpleEngine seeds a textarea by appending a text-node child,
while inputs use the `value` attribute. Later updates go through
updateDomNodeText which sets `.value` on both.
*/
exports.editorValue = function(w) {
var dn = w.engine.domNode;
// If .value has been set explicitly, prefer it (covers refresh updates)
if(dn.attributes && dn.attributes.value !== undefined) {
return dn.value;
}
// Otherwise for textareas read the concatenated child text nodes
if(dn.tag === "textarea" && dn.children && dn.children.length) {
return dn.children.map(function(n) { return n.textContent || ""; }).join("");
}
return dn.value;
};
// Note: jasmine-specific helpers (spies) belong in the test-spec files
// themselves. TW library modules run in a sandbox that does not expose
// `jasmine`, so anything that calls jasmine.createSpy must live in a
// file tagged with $:/tags/test-spec.
@@ -1,353 +0,0 @@
/*\
title: test-edit-text-widget-attributes.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Covers edit-text widget behaviour that is observable in fakedom: value
resolution, tag/type selection, DOM attribute propagation, save-back
and refresh paths.
Each spec has a "manual:" comment with a by-hand recipe so a reviewer
can sanity-check the test against a live wiki.
NOT covered (need a real browser — Playwright territory):
* `focus`, `focusSelectFromStart`/`End`, `focusPopup`, `cancelPopups` — real DOM focus + selection APIs
* `inputActions`, `fileDrop` — synthetic events not dispatched by fakedom
* Pixel measurement / growth of auto-height textareas — `$tw.utils.resizeTextAreaToFit` needs real layout
* FramedEngine — requires a real iframe document
\*/
"use strict";
describe("Edit-text widget (attribute propagation and value handling)", function() {
// Shared helpers live in $:/tests/test-edit-widgets/helpers. See that
// file for what each helper prepares and where it is used across the suite.
var helpers = require("$:/tests/test-edit-widgets/helpers");
// Local aliases — `render`, `editorValue` etc. read better inline
// than `helpers.renderFromWikitext`. Each test supplies its own
// tiddlers, so makeWiki is NOT seeded with TestTiddler here.
var makeWiki = helpers.makeWiki;
var render = helpers.renderFromWikitext;
var refresh = helpers.refresh;
var editorValue = helpers.editorValue;
var findEditTextWidget = helpers.findEditTextWidget;
// spyOnSetText stays file-local because it depends on `jasmine`,
// which is only in scope for $:/tags/test-spec modules — NOT for
// library modules loaded via require().
function spyOnSetText(w) {
var spy = jasmine.createSpy("setText");
w.engine.setText = spy;
return spy;
}
// ---------------------------------------------------------------
// Value and type resolution (getEditInfo)
// ---------------------------------------------------------------
describe("value and type resolution", function() {
// manual: create tiddler "TestTiddler" with text "hello", then in any
// tiddler render <$edit-text tiddler='TestTiddler'/> — the
// textarea should show "hello".
it("reads the text field of an existing tiddler", function() {
var w = render("<$edit-text tiddler=\"TestTiddler\"/>");
expect(editorValue(w.widget)).toBe("hello");
});
// manual: give a tiddler "T" a caption="Hi" field, then render
// <$edit-text tiddler="T" field="caption"/> — input should show "Hi".
it("reads an arbitrary named field", function() {
var wiki = makeWiki([{title: "T", text: "body", caption: "Hi"}]);
var w = render("<$edit-text tiddler=\"T\" field=\"caption\"/>",wiki);
expect(editorValue(w.widget)).toBe("Hi");
});
// manual: create tiddler "T" without a caption field, then render
// <$edit-text tiddler="T" field="caption" default="fallback"/> —
// input should show "fallback".
it("falls back to the default attribute when the field is missing", function() {
var wiki = makeWiki([{title: "T", text: "body"}]);
var w = render("<$edit-text tiddler=\"T\" field=\"caption\" default=\"fallback\"/>",wiki);
expect(editorValue(w.widget)).toBe("fallback");
});
// manual: create tiddler "T" without a caption field, render
// <$edit-text tiddler="T" field="caption"/> — input should be empty.
it("falls back to an empty string when neither field nor default is present", function() {
var wiki = makeWiki([{title: "T", text: "body"}]);
var w = render("<$edit-text tiddler=\"T\" field=\"caption\"/>",wiki);
expect(editorValue(w.widget)).toBe("");
});
// manual: ensure NO tiddler named "Missing" exists, then render
// <$edit-text tiddler="Missing" field="title"/> — input should show
// "Missing" (the title field of a not-yet-existing tiddler defaults
// to the tiddler title).
it("uses the tiddler title as the default for the title field on missing tiddlers", function() {
var wiki = makeWiki();
var w = render("<$edit-text tiddler=\"Missing\" field=\"title\"/>",wiki);
expect(editorValue(w.widget)).toBe("Missing");
});
// manual: ensure NO tiddler named "Missing" exists, then render
// <$edit-text tiddler="Missing" default="seed"/> — textarea should
// show "seed" until you type into it.
it("uses default= on a missing tiddler", function() {
var wiki = makeWiki();
var w = render("<$edit-text tiddler=\"Missing\" default=\"seed\"/>",wiki);
expect(editorValue(w.widget)).toBe("seed");
});
// manual: create a data tiddler "Data" with type
// "application/x-tiddler-dictionary" and text "one: 1\ntwo: 2",
// then render <$edit-text tiddler="Data" index="two"/> — input
// should show "2".
it("reads a value from a data tiddler index", function() {
var wiki = makeWiki([
{title: "Data", type: "application/x-tiddler-dictionary", text: "one: 1\ntwo: 2"}
]);
var w = render("<$edit-text tiddler=\"Data\" index=\"two\"/>",wiki);
expect(editorValue(w.widget)).toBe("2");
});
// manual: with the same "Data" dictionary but no "missing" key,
// render <$edit-text tiddler="Data" index="missing" default="none"/> —
// input should show "none".
it("uses default= for a missing index", function() {
var wiki = makeWiki([
{title: "Data", type: "application/x-tiddler-dictionary", text: "one: 1"}
]);
var w = render("<$edit-text tiddler=\"Data\" index=\"missing\" default=\"none\"/>",wiki);
expect(editorValue(w.widget)).toBe("none");
});
});
// ---------------------------------------------------------------
// Tag and type selection
// ---------------------------------------------------------------
describe("tag and type selection", function() {
// manual: render <$edit-text tiddler="TestTiddler"/> and inspect the
// element — it should be a <textarea>.
it("defaults to a textarea for the text field", function() {
var w = render("<$edit-text tiddler=\"TestTiddler\"/>");
expect(w.widget.engine.domNode.tag).toBe("textarea");
});
// manual: render <$edit-text tiddler="TestTiddler" field="caption"/>
// and inspect the element — it should be a single-line <input>.
it("defaults to an input for non-text fields", function() {
var w = render("<$edit-text tiddler=\"TestTiddler\" field=\"caption\"/>");
expect(w.widget.engine.domNode.tag).toBe("input");
});
// manual: render <$edit-text tiddler="TestTiddler" tag="input"/> —
// even though the default for the text field is textarea, this
// should render as a single-line <input>.
it("tag=input forces an input even for the text field", function() {
var w = render("<$edit-text tiddler=\"TestTiddler\" tag=\"input\"/>");
expect(w.widget.engine.domNode.tag).toBe("input");
});
// manual: render <$edit-text tiddler="T" tag="input" type="password"/>
// — the element should be a password input (characters masked as dots).
it("type= sets the input type attribute", function() {
var w = render("<$edit-text tiddler=\"TestTiddler\" tag=\"input\" type=\"password\"/>");
expect(w.widget.engine.domNode.getAttribute("type")).toBe("password");
});
// manual: render <$edit-text tiddler="T" type="password"/> (no tag
// override) — the element should be a plain textarea; type is
// silently ignored because HTML textareas have no type attribute.
it("type= is ignored for textareas (no type attribute on textarea)", function() {
var w = render("<$edit-text tiddler=\"TestTiddler\" type=\"password\"/>");
expect(w.widget.engine.domNode.getAttribute("type")).toBeUndefined();
});
// manual: render <$edit-text tiddler="T" tag="script"/> — must NOT
// inject a <script> element; tag should fall back to <input>. This
// is a defence-in-depth check against wiki-authored HTML injection.
it("unsafe tag names collapse to input", function() {
var w = render("<$edit-text tiddler=\"TestTiddler\" tag=\"script\"/>");
expect(w.widget.engine.domNode.tag).toBe("input");
});
});
// ---------------------------------------------------------------
// DOM attribute propagation
// ---------------------------------------------------------------
describe("DOM attribute propagation", function() {
// manual: render <$edit-text tiddler="TestTiddler" tag="input"
// class="my-class" placeholder="Type here" size="40" tabindex="3"
// autocomplete="email" disabled="yes"/> — inspect the element in
// devtools; every attribute set on the widget should appear on
// the DOM element with the expected value. disabled="no" would
// omit the attribute entirely.
//
// INFO: View as "raw HTML" in preview panel
//
// SimpleEngine has one independent `if(this.widget.editXxx)
// setAttribute(...)` block per attribute. This combined test
// exercises every such block in one shot; if any copy breaks,
// this spec fails and the jasmine expectation report identifies
// the offending attribute.
it("copies class, placeholder, size, tabindex, autocomplete and disabled onto the DOM element", function() {
var w = render(
"<$edit-text tiddler=\"TestTiddler\" tag=\"input\" " +
"class=\"my-class\" placeholder=\"Type here\" size=\"40\" " +
"tabindex=\"3\" autocomplete=\"email\" disabled=\"yes\"/>"
);
var dn = w.widget.engine.domNode;
expect(dn.className).toBe("my-class");
expect(dn.getAttribute("placeholder")).toBe("Type here");
expect(dn.getAttribute("size")).toBe("40");
expect(dn.getAttribute("tabindex")).toBe("3");
expect(dn.getAttribute("autocomplete")).toBe("email");
expect(dn.getAttribute("disabled")).toBe("true");
});
});
// ---------------------------------------------------------------
// Save-back via saveChanges
// ---------------------------------------------------------------
describe("saving changes back to the store", function() {
// manual: with tiddler "T" text="old", render <$edit-text tiddler="T"/>
// and type "new text"; open T from the sidebar — its text field
// should update live.
it("writes a new value to the configured text field", function() {
var wiki = makeWiki([{title: "T", text: "old"}]);
var w = render("<$edit-text tiddler=\"T\"/>",wiki);
w.widget.saveChanges("new text");
expect(wiki.getTiddler("T").fields.text).toBe("new text");
});
// manual: with tiddler "T" caption="old", render
// <$edit-text tiddler="T" field="caption"/> and type a new caption;
// {{T!!caption}} elsewhere should update live.
it("writes to a non-text field", function() {
var wiki = makeWiki([{title: "T", text: "body", caption: "old"}]);
var w = render("<$edit-text tiddler=\"T\" field=\"caption\"/>",wiki);
w.widget.saveChanges("new caption");
expect(wiki.getTiddler("T").fields.caption).toBe("new caption");
});
// manual: ensure no tiddler "NewOne" exists, render
// <$edit-text tiddler="NewOne" default=""/> and type "created" —
// a new tiddler "NewOne" should appear in the sidebar with that
// text.
it("creates the tiddler if it does not exist", function() {
var wiki = makeWiki();
var w = render("<$edit-text tiddler=\"NewOne\" default=\"\"/>",wiki);
w.widget.saveChanges("created");
expect(wiki.tiddlerExists("NewOne")).toBe(true);
expect(wiki.getTiddler("NewOne").fields.text).toBe("created");
});
// manual: with dictionary tiddler "Data" containing one:1, two:2,
// render <$edit-text tiddler="Data" index="one"/> and change the
// value to "uno"; view Data as text — it should now read
// "one: uno\ntwo: 2".
it("writes to a data tiddler index", function() {
var wiki = makeWiki([
{title: "Data", type: "application/x-tiddler-dictionary", text: "one: 1\ntwo: 2"}
]);
var w = render("<$edit-text tiddler=\"Data\" index=\"one\"/>",wiki);
w.widget.saveChanges("uno");
expect(wiki.extractTiddlerDataItem("Data","one")).toBe("uno");
// Other index entries preserved
expect(wiki.extractTiddlerDataItem("Data","two")).toBe("2");
});
// manual: Select the text. Copy / Paste -> modified does not change
it("skips the store update when the value is unchanged", function() {
var wiki = makeWiki([{title: "T", text: "same"}]);
var before = wiki.getTiddler("T").fields.modified;
var w = render("<$edit-text tiddler=\"T\"/>",wiki);
w.widget.saveChanges("same");
expect(wiki.getTiddler("T").fields.modified).toBe(before);
});
});
// ---------------------------------------------------------------
// Refresh behaviour
// ---------------------------------------------------------------
describe("refresh behaviour", function() {
// manual: render <$edit-text tiddler="T"/>, in New Tiddler and New Tiddler 1
// The textarea you are NOT focused on
// should live-update to reflect the new text field.
it("propagates an external change of the edited tiddler through engine.setText", function() {
var wiki = makeWiki([{title: "T", text: "first"}]);
var w = render("<$edit-text tiddler=\"T\"/>",wiki);
expect(editorValue(w.widget)).toBe("first");
var setTextSpy = spyOnSetText(w.widget);
wiki.addTiddler({title: "T", text: "second"});
refresh(w.root,w.wrapper,["T"]);
expect(setTextSpy).toHaveBeenCalled();
expect(setTextSpy.calls.mostRecent().args[0]).toBe("second");
});
// manual:
// 1. Create tiddlers T (text "initial") and Trigger (text "x").
// 2. Render <$edit-text tiddler="T" refreshTitle="Trigger"/>.
// 3. Click into the textarea (it now has focus) and type " typed".
// 4. Open F12, in console:
// $tw.wiki.addTiddler({title:"T",text:"external"})
// Focus stays on textarea. Store changes, but setText skips
// the DOM write because the node is focused — DOM diverges
// from store (textarea still shows "initial typed").
// 5. In console:
// $tw.wiki.addTiddler({title:"Trigger",text:"bump"})
// Textarea should VISIBLY flip to "external" while still
// focused. updateDomNodeText has no focus check.
// Without refreshTitle, step 5 does nothing — DOM stays diverged.
it("refreshTitle triggers updateDomNodeText even when a different tiddler changes", function() {
var wiki = makeWiki([
{title: "T", text: "first"},
{title: "Trigger", text: "x"}
]);
var w = render("<$edit-text tiddler=\"T\" refreshTitle=\"Trigger\"/>",wiki);
// refreshTitle goes through engine.updateDomNodeText, not setText
var updateSpy = jasmine.createSpy("updateDomNodeText");
w.widget.engine.updateDomNodeText = updateSpy;
wiki.addTiddler({title: "T", text: "updated"});
refresh(w.root,w.wrapper,["Trigger"]);
expect(updateSpy).toHaveBeenCalledWith("updated");
});
// manual: render <$edit-text tiddler="T" class={{ClassSrc}}/> and
// change ClassSrc's text. Inspect the textarea element in devtools
// — the class attribute should flip to the new value. This covers
// a full re-render (refreshSelf), not just an in-place text update.
it("changes to attributes that require a full rerender trigger refreshSelf", function() {
// When an attribute like `class` changes via a transcluded variable,
// the widget should rerender itself. We simulate that by wrapping the
// edit-text inside a transclusion where the class is indirect.
var wiki = makeWiki([
{title: "T", text: "body"},
{title: "ClassSrc", text: "first-class"}
]);
var w = render(
"<$edit-text tiddler=\"T\" class={{ClassSrc}}/>",
wiki
);
expect(w.widget.engine.domNode.className).toBe("first-class");
wiki.addTiddler({title: "ClassSrc", text: "second-class"});
refresh(w.root,w.wrapper,["ClassSrc"]);
// Re-locate the widget because a full refresh creates a new instance
var fresh = findEditTextWidget(w.root);
expect(fresh.engine.domNode.className).toBe("second-class");
});
});
});
@@ -1,198 +0,0 @@
/*\
title: test-edit-text-widget.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Covers the `rows` / `autoHeight` / `minHeight` interaction in the
edit-text widget. Regression guard for issue #9451 / PR #9454 follow-up
fix (rows=1 autoHeight=yes must still grow).
Assertions target the routing inside SimpleEngine.fixHeight (which
branch runs, called with which minHeight) — pixel measurement needs a
real browser.
\*/
"use strict";
describe("Edit-text widget", function() {
// Shared helpers live in $:/tests/test-edit-widgets/helpers. See that
// file for what each helper prepares and where it is used across the suite.
var helpers = require("$:/tests/test-edit-widgets/helpers");
// Local wrapper: every test in THIS file seeds an empty "TestTiddler"
// as its default binding target, so we inject that by default.
function makeWiki(extraTiddlers) {
return helpers.makeWiki(extraTiddlers,{seedTestTiddler: true});
}
// Local alias — `renderEditText` reads more naturally than
// `helpers.renderFromAttrs` for the routing tests.
function renderEditText(attrs,wiki,tiddlerTitle) {
return helpers.renderFromAttrs(attrs,wiki,tiddlerTitle);
}
// ---------------------------------------------------------------
// Attribute parsing
// ---------------------------------------------------------------
describe("attribute parsing", function() {
// manual: on a bare <$edit-text tiddler="T"/>, open devtools and
// inspect the widget instance — the element should be a textarea,
// there should be no rows attribute, and auto-height should be on.
// Manual Preview: Also use the widget-tree
it("defaults: tag=textarea, autoHeight=yes, no rows, minHeight=100px", function() {
var et = renderEditText({});
expect(et.editTag).toBe("textarea");
expect(et.editAutoHeight).toBe(true);
expect(et.editRows).toBeUndefined();
expect(et.editMinHeight).toBe("100px");
});
// manual: render <$edit-text tiddler="T" rows=5 autoHeight="no" minHeight="1em"/>
// — the widget's internal state should reflect all three explicit
// values, overriding the defaults verified in the previous test.
// Manual Preview: Also use the widget-tree
it("explicit attributes override defaults (rows, autoHeight, minHeight)", function() {
var et = renderEditText({rows: "5", autoHeight: "no", minHeight: "1em"});
expect(et.editRows).toBe("5");
expect(et.editAutoHeight).toBe(false);
expect(et.editMinHeight).toBe("1em");
});
// manual: render <$edit-text tiddler="T" field="caption" tag="textarea"/>
// — the element should be a multi-line textarea even though the
// field is not `text`.
it("tag=textarea override applies to non-text fields", function() {
var et = renderEditText({field: "caption", tag: "textarea"});
expect(et.editTag).toBe("textarea");
});
// manual: set $:/config/TextEditor/EditorHeight/Mode = "fixed"
// (via control panel → settings → editor toolbar, or edit the
// tiddler directly). <$edit-text tiddler="T"/> should now behave
// as fixed-height (scrollbar on overflow) by default.
it("$:/config/TextEditor/EditorHeight/Mode=fixed flips default autoHeight to false", function() {
var wiki = makeWiki([
{title: "$:/config/TextEditor/EditorHeight/Mode", text: "fixed"}
]);
var et = renderEditText({},wiki);
expect(et.editAutoHeight).toBe(false);
});
// manual: with the same Mode=fixed config as above, an explicit
// <$edit-text tiddler="T" autoHeight="yes"/> should still auto-grow
// — the per-widget attribute wins over the global config.
it("explicit autoHeight=yes overrides Mode=fixed config", function() {
var wiki = makeWiki([
{title: "$:/config/TextEditor/EditorHeight/Mode", text: "fixed"}
]);
var et = renderEditText({autoHeight: "yes"},wiki);
expect(et.editAutoHeight).toBe(true);
});
});
// ---------------------------------------------------------------
// DOM node construction (SimpleEngine)
// ---------------------------------------------------------------
describe("DOM node construction (non-toolbar SimpleEngine)", function() {
// manual: render <$edit-text tiddler="T" rows=1/> and inspect the
// element — it should carry rows="1". Proves SimpleEngine copies
// widget.editRows onto the DOM element.
it("sets the rows attribute on the DOM node when rows is specified", function() {
var et = renderEditText({rows: "1"});
expect(et.engine.domNode.getAttribute("rows")).toBe("1");
});
});
// ---------------------------------------------------------------
// fixHeight routing — the core of the fix
// ---------------------------------------------------------------
describe("fixHeight routing", function() {
var originalResize, resizeSpy;
beforeEach(function() {
originalResize = $tw.utils.resizeTextAreaToFit;
resizeSpy = jasmine.createSpy("resizeTextAreaToFit").and.returnValue(0);
$tw.utils.resizeTextAreaToFit = resizeSpy;
});
afterEach(function() {
$tw.utils.resizeTextAreaToFit = originalResize;
});
// Make the SimpleEngine.fixHeight think we are in a real DOM so it
// actually reaches the $tw.utils.resizeTextAreaToFit call. Without
// this the engine short-circuits because fakedom nodes carry
// isTiddlyWikiFakeDom === true.
function callFixHeight(et) {
et.engine.domNode.isTiddlyWikiFakeDom = false;
et.engine.fixHeight();
}
// manual: render <$edit-text tiddler="T" rows=1 autoHeight=yes minHeight=1em/>
// (the TiddlyTools idiom). In 5.4.0 pre-fix, the textarea stayed
// a single row forever; after the fix, it starts at 1 row and
// grows as you type newlines. This covers BRANCH A (autoHeight=yes)
// of SimpleEngine.fixHeight: resize is called with minHeight even
// when rows is set. Strict superset of the no-rows baseline.
it("rows=1 autoHeight=yes minHeight=1em still calls resize (regression test for the PR #9454 follow-up fix)", function() {
var et = renderEditText({rows: "1", autoHeight: "yes", minHeight: "1em"});
callFixHeight(et);
expect(resizeSpy).toHaveBeenCalled();
expect(resizeSpy.calls.mostRecent().args[1]).toBe("1em");
});
// manual: render <$edit-text tiddler="T" rows=5 autoHeight=no/> —
// the textarea must stay locked at exactly 5 rows tall, even if
// you paste in 50 lines (vertical scrollbar appears inside).
it("rows=5 autoHeight=no does NOT call resize and does NOT apply the fixed-height fallback", function() {
var et = renderEditText({rows: "5", autoHeight: "no"});
callFixHeight(et);
expect(resizeSpy).not.toHaveBeenCalled();
// rows attribute governs height — CSS height must remain unset
expect(et.engine.domNode.style.height).toBe("");
});
// manual: set $:/config/TextEditor/EditorHeight/Height = "250px",
// then render <$edit-text tiddler="T" autoHeight="no"/> — the
// textarea should be exactly 250px tall regardless of content.
it("autoHeight=no with no rows applies the fixed-height fallback from config", function() {
var wiki = makeWiki([
{title: "$:/config/TextEditor/EditorHeight/Height", text: "250px"}
]);
var et = renderEditText({autoHeight: "no"},wiki);
callFixHeight(et);
expect(resizeSpy).not.toHaveBeenCalled();
expect(et.engine.domNode.style.height).toBe("250px");
});
// manual: set $:/config/TextEditor/EditorHeight/Height = "5px",
// render <$edit-text tiddler="T" autoHeight="no"/> — the textarea
// should be 20px tall (clamped), not a sliver, so it stays usable.
it("autoHeight=no fallback height is clamped to at least 20px", function() {
var wiki = makeWiki([
{title: "$:/config/TextEditor/EditorHeight/Height", text: "5px"}
]);
var et = renderEditText({autoHeight: "no"},wiki);
callFixHeight(et);
expect(et.engine.domNode.style.height).toBe("20px");
});
// manual: render <$edit-text tiddler="T" field="caption" autoHeight="no"/>
// — the element is a single-line <input> and the CSS height should
// remain unset (no height fallback applies to inputs).
it("tag=input: neither resize nor fixed-height fallback runs", function() {
var et = renderEditText({field: "caption", autoHeight: "no"});
callFixHeight(et);
expect(resizeSpy).not.toHaveBeenCalled();
expect(et.engine.domNode.style.height).toBe("");
});
});
});
@@ -18,15 +18,4 @@ describe("fakedom tests", function() {
expect($tw.fakeDocument.createTextNode("text").nodeType).toBe(3);
expect($tw.fakeDocument.createTextNode("text").TEXT_NODE).toBe(3);
});
// Real CSSStyleDeclaration returns undefined for Symbol property keys.
// Without a guard, the TW_Style Proxy throws on Symbol access. This bites
// in practice when Jasmine pretty-prints fakedom elements on failure.
// See related TODO in test-select-widget.js
it("returns undefined for Symbol property access on element.style", function() {
var el = $tw.fakeDocument.createElement("div");
expect(function() { return el.style[Symbol.toPrimitive]; }).not.toThrow();
expect(el.style[Symbol.toPrimitive]).toBeUndefined();
expect(function() { el.style[Symbol.iterator] = "x"; }).not.toThrow();
});
});
@@ -1,144 +0,0 @@
/*\
title: test-select-widget.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests the select widget, focused on multi-select refresh behaviour.
\*/
"use strict";
describe("Select widget", function() {
var widget = require("$:/core/modules/widgets/widget.js");
// Helpers reused from: test-widget.js and test-checkbox-widget.js
function createWidgetNode(parseTreeNode,wiki) {
return new widget.widget(parseTreeNode,{
wiki: wiki,
document: $tw.fakeDocument
});
}
function parseText(text,wiki,options) {
var parser = wiki.parseText("text/vnd.tiddlywiki",text,options);
return parser ? {type: "widget", children: parser.tree} : undefined;
}
function renderWidgetNode(widgetNode) {
$tw.fakeDocument.setSequenceNumber(0);
var wrapper = $tw.fakeDocument.createElement("div");
widgetNode.render(wrapper,null);
return wrapper;
}
function refreshWidgetNode(widgetNode,wrapper,changes) {
var changedTiddlers = {};
if(changes) {
$tw.utils.each(changes,function(title) {
changedTiddlers[title] = true;
});
}
widgetNode.refresh(changedTiddlers,wrapper,null);
}
// Don't pass fakedom elements to Jasmine matchers. The matchers pretty-print
// values on assertion which walks the element.style Proxy with Symbol keys and
// crashes inside convertStyleNameToPropertyName.
// TODO: once issue: "fakedom style Proxy guards against non-string property keys" lands
// revert these workarounds to idiomatic matchers (toBe(null), toBeUndefined()).
function findSelectDom(node) {
if(node.tag === "select") return node;
if(node.children) {
for(var i = 0; i < node.children.length; i++) {
var found = findSelectDom(node.children[i]);
if(found) return found;
}
}
return null;
}
function selectedFlags(parent) {
var result = [];
for(var i = 0; i < parent.children.length; i++) {
result.push(!!parent.children[i].selected);
}
return result;
}
// Regression test for https://github.com/TiddlyWiki/TiddlyWiki5/issues/9839
// PR #8093 added <optgroup> support but used `child.children.length === 0` to
// distinguish a plain <option> from an <optgroup>. That heuristic misfires for
// any <option> whose contents render to inline HTML elements (e.g. tc-tiddlylink
// auto-links generated for "$:/..." titles), so the option's `selected` state
// was never restored on refresh.
it("preserves multi-select state across refresh when options contain inline HTML children",function() {
var wiki = $tw.test.wiki();
wiki.addTiddlers([
{title: "Picks", mylist: "foo $:/mumble"}
]);
// Each option has element children to mimic the auto-link case from #9839.
// Explicit value attribute lets fakedom resolve option.value (real browsers
// fall back to text content).
var widgetText = "<$select tiddler='Picks' field='mylist' multiple>" +
"<option value='foo'><span>foo</span></option>" +
"<option value='bar'><span>bar</span></option>" +
"<option value='$:/mumble'><a class='tc-tiddlylink'>$:/mumble</a></option>" +
"</$select>";
var widgetNode = createWidgetNode(parseText(widgetText,wiki),wiki);
var wrapper = renderWidgetNode(widgetNode);
var select = findSelectDom(wrapper);
expect(select === null).toBe(false);
expect(select.children.length).toBe(3);
// After initial render, options matching the field value should be selected.
// foo (idx 0), bar (idx 1), $:/mumble (idx 2).
expect(selectedFlags(select)).toEqual([true,false,true]);
// Change the stored field value and refresh - this is where the bug surfaced:
// the "$:/mumble" option (with an <a> child) was wrongly skipped.
wiki.addTiddler({title: "Picks", mylist: "bar $:/mumble"});
refreshWidgetNode(widgetNode,wrapper,["Picks"]);
expect(selectedFlags(select)).toEqual([false,true,true]);
// The inner <a> must not be touched - .selected is meaningful only on <option>.
var innerLinkSelected = select.children[2].children[0].selected;
expect(innerLinkSelected === undefined).toBe(true);
});
it("still selects options inside <optgroup> across refresh, including $:/-prefixed entries with inline HTML",function() {
var wiki = $tw.test.wiki();
wiki.addTiddlers([
{title: "Picks", mylist: "1 $:/mumble"}
]);
// The "high" group mixes a plain option with a $:/-prefixed option whose
// content is wrapped in an auto-link <a> - same pattern that broke #9839
// for top-level options, exercised here inside an <optgroup>.
var widgetText = "<$select tiddler='Picks' field='mylist' multiple>" +
"<optgroup label='low'>" +
"<option value='1'>1</option>" +
"<option value='2'>2</option>" +
"</optgroup>" +
"<optgroup label='high'>" +
"<option value='4'>4</option>" +
"<option value='$:/mumble'><a class='tc-tiddlylink'>$:/mumble</a></option>" +
"</optgroup>" +
"</$select>";
var widgetNode = createWidgetNode(parseText(widgetText,wiki),wiki);
var wrapper = renderWidgetNode(widgetNode);
var select = findSelectDom(wrapper);
expect(select === null).toBe(false);
expect(select.children.length).toBe(2);
// Initial state: "1" and "$:/mumble" selected
expect(selectedFlags(select.children[0])).toEqual([true,false]);
expect(selectedFlags(select.children[1])).toEqual([false,true]);
wiki.addTiddler({title: "Picks", mylist: "2 4"});
refreshWidgetNode(widgetNode,wrapper,["Picks"]);
expect(selectedFlags(select.children[0])).toEqual([false,true]);
expect(selectedFlags(select.children[1])).toEqual([true,false]);
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,13 @@
[
{
"created": "20260125202948362",
"text": "",
"title": "$:/changenotes/5.4.0/#9609/impacts/event detail variables in eventcatcher",
"modified": "20260125203132149",
"tags": "$:/tags/ImpactNote",
"type": "text/vnd.tiddlywiki",
"changenote": "$:/changenotes/5.4.0/#9609",
"description": "`event-detail*` variables have been removed in favour of the new `eventJSON` variable",
"impact-type": "compatibility-break "
}
]
@@ -1,8 +0,0 @@
changenote: $:/changenotes/5.4.0/#9609
created: 20260125202948362
description: `event-detail*` variables have been removed in favour of the new `eventJSON` variable
impact-type: compatibility-break
modified: 20260428161250718
tags: $:/tags/ImpactNote
title: $:/changenotes/5.4.0/#9609/impacts/event detail variables in eventcatcher
type: text/vnd.tiddlywiki
@@ -1,29 +0,0 @@
title: $:/changenotes/5.4.1/#9836
description: Fix mid-text link cutoff, fix disabling freelinks toggle blanks view, and add configurable MaxLinks cap
tags: $:/tags/ChangeNote
release: 5.4.1
change-type: bugfix
change-category: plugin
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9836
github-contributors: s793016
Fixes correctness and reliability issues in the freelinks plugin.
Fixes:
* Disabling "Enable freelinking within tiddler view templates" in plugin
settings caused the entire tiddler view (including the Control Panel)
to blank out with no way to recover through the UI.
* Mid-text link cutoff caused by `search()` truncating results before the
render stage; the cap is moved to `$:/config/Freelinks/MaxLinks`
(default 500) so text beyond the limit is preserved as plain text.
* `$:/config/Freelinks/MaxLinks` render cap was applied before
longest-match deduplication, silently dropping valid matches within
the limit; cap now applied after the final non-overlapping set is resolved.
* Incorrect failure links for long patterns caused by a hard depth limit in
`buildFailureLinks()`; removed as chain depth is naturally bounded.
* Unnecessary cache rebuilds triggered by draft edits and system tiddler
changes such as tab clicks.
Improvements:
* `$:/config/Freelinks/MaxLinks` added to control rendered link count, with
a settings panel input field for convenience.
@@ -1,13 +0,0 @@
caption: 5.4.1
created: 20260508181012812
modified: 20260508181012812
tags: ReleaseNotes
title: Release 5.4.1
type: text/vnd.tiddlywiki
description: Under development
\procedure release-introduction()
Release v5.4.1 is under development.
\end release-introduction
<<releasenote 5.4.1>>
@@ -0,0 +1,13 @@
caption: 5.5.0
created: 20260420195917090
modified: 20260420195917090
tags: ReleaseNotes
title: Release 5.5.0
type: text/vnd.tiddlywiki
description: Under development
\procedure release-introduction()
Release v5.5.0 is under development.
\end release-introduction
<<releasenote 5.5.0>>
@@ -35,7 +35,7 @@ The content of the `<$edit-text>` widget is ignored.
|size |The size of the input field (in characters). This exact result depends on browser and font. Use the `class` attribute to style width for precise control |
|autoHeight |Either "yes" or "no" to specify whether to automatically resize `textarea` editors to fit their content (''defaults'' to "yes"). This setting can be changed globally with an editor toolbar button|
|minHeight |Minimum height for automatically resized `textarea` editors, specified in CSS length units such as "px", "em" or "%". Has no effect if the [[CodeMirror Plugin]] is active |
|rows|Sets the `rows` attribute of a generated textarea. When combined with `autoHeight="yes"` (the default), it sets the ''initial'' rendered height; the textarea still grows to fit content. To force a fixed number of rows, pair `rows` with `autoHeight="no"`. |
|rows|Sets the rows attribute of a generated textarea. `rows` ''takes precedence'' over `autoHeight`, so text areas can be defined individually |
|cancelPopups |<<.from-version "5.1.23">> if set to "yes", cancels all popups when the input gets focus |
|inputActions |<<.from-version 5.1.23>> Optional actions that are triggered every time an input event occurs within the input field or textarea.<br><<.from-version "5.2.1">> The variable `actionValue` is available to the `inputActions` and contains the value of the input field. |
|refreshTitle |<<.from-version 5.1.23>> An optional tiddler title that makes the input field update whenever the specified tiddler changes |
+1 -2
View File
@@ -5,8 +5,7 @@ Advanced/Hint: Wewnętrzne informacje na temat TiddlyWiki
Appearance/Caption: Wyświetlanie
Appearance/Hint: Dostosowywanie wyglądu tej TiddlyWiki
Basics/AnimDuration/Prompt: Długość animacji
Basics/AutoFocus/Prompt: Domyślnie zfocusowane pole przy tworzeniu nowego tiddlera
Basics/AutoFocusEdit/Prompt: Domyślnie zfocusowane pole przy edycji istniejącego tiddlera
Basics/AutoFocus/Prompt: Domyślne wybrane pole do edycji przy tworzeniu nowego tiddlera
Basics/Caption: Podstawowe
Basics/DefaultTiddlers/BottomHint: Tiddlery, które mają spację w nazie otocz &#91;&#91;podwójnymi nawiasami kwadratowymi&#93;&#93;. Możesz też ustawić, by zawsze widzieć {{ostatnio otwarte tiddlery||$:/snippets/retain-story-ordering-button}}
Basics/DefaultTiddlers/Prompt: Domyślnie otwarte tiddlery
-4
View File
@@ -1,4 +0,0 @@
title: $:/language/Draft/
Attribution: Szkic '<<draft-title>>' autorstwa {{$:/status/UserName}}
Title: Szkic '<<draft-title>>'
-2
View File
@@ -15,8 +15,6 @@ Listing/Preview/TextRaw: Tekst (oryginalny)
Listing/Preview/Fields: Pola
Listing/Preview/Diff: Różnica
Listing/Preview/DiffFields: Róznica (Pola)
Listing/ImportOptions/Caption: Opcje importu
Listing/ImportOptions/NoMatch: Żadna z opcji importu nie możę być zastosowana do tych plików.
Listing/Rename/Tooltip: Zmień nazwę tiddlera przed importem
Listing/Rename/Prompt: Zmień nazwę na:
Listing/Rename/ConfirmRename: Zmień nazwę tiddlera
-1
View File
@@ -1,6 +1,5 @@
title: $:/language/
Alerts: Powiadomienia
AboveStory/ClassicPlugin/Warning: Wygląda na to, że próbujesz wczytać wtyczkę zaprojektowaną dla ~TiddlyWiki Classic. Zwróc uwagę, że [[te wtyczki nie działają z TiddlyWiki 5|https://tiddlywiki.com/#TiddlyWikiClassic]]. Wykryte wtyczki ~TiddlyWiki Classic:
BinaryWarning/Prompt: Ten tiddler zawiera binarne dane
ClassicWarning/Hint: Ten tiddler został napisany w formacie dla TiddlyWiki Classic, który nie jest w pełni kompatybilny z TiddlyWiki 5. Więcej informacji dostępnych pod adresem https://tiddlywiki.com/static/Upgrading.html.
-6
View File
@@ -9,12 +9,6 @@ Advanced/ShadowInfo/NotShadow/Hint: Tiddler <$link to=<<infoTiddler>>><$text tex
Advanced/ShadowInfo/Shadow/Hint: Tiddler <$link to=<<infoTiddler>>><$text text=<<infoTiddler>>/></$link> jest tiddlerem-cieniem
Advanced/ShadowInfo/Shadow/Source: Zdefiniiowany we wtyczce <$link to=<<pluginTiddler>>><$text text=<<pluginTiddler>>/></$link>
Advanced/ShadowInfo/OverriddenShadow/Hint: Nadpisany przez zwykłego tiddlera
Advanced/CascadeInfo/Heading: Szczegóły Kaskady
Advanced/CascadeInfo/Hint: These are the view template segments that are resolved for each of the system view template cascades
Advanced/CascadeInfo/Hint: Lista elementów widoku, których kaskady określają szablony użyte do wyświetlenia tiddlera
Advanced/CascadeInfo/Detail/View: Widok
Advanced/CascadeInfo/Detail/ActiveCascadeFilter: Aktywny filtr kaskady
Advanced/CascadeInfo/Detail/Template: Szablon
Fields/Caption: Pola
List/Caption: Lista
List/Empty: Ten tiddler nie posiada żadnych list
+1 -1
View File
@@ -1,7 +1,7 @@
TiddlyWiki created by Jeremy Ruston, (jeremy [at] jermolene [dot] com)
Copyright (c) 2004-2007, Jeremy Ruston
Copyright (c) 2007-2026, UnaMesa Association
Copyright (c) 2007-2025, UnaMesa Association
All rights reserved.
Redistribution and use in source and binary forms, with or without
-2
View File
@@ -649,5 +649,3 @@ Rishu kumar, @rishu-7549, 2025/10/25
@kjharcombe, 2026/03/16
Himmel, @NotHimmel, 2026/03/19
@sean-clayton, 2026/05/16
+4 -12
View File
@@ -625,9 +625,7 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"version": "3.3.3",
"dev": true,
"license": "ISC"
},
@@ -717,9 +715,7 @@
"license": "ISC"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -784,9 +780,7 @@
"license": "MIT"
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -878,9 +872,7 @@
}
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tiddlywiki",
"preferGlobal": true,
"version": "5.4.1-prerelease",
"version": "5.5.0-prerelease",
"author": "Jeremy Ruston <jeremy@jermolene.com>",
"description": "a non-linear personal web notebook",
"contributors": [
+3 -11
View File
@@ -6,8 +6,6 @@ const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './editions/test/',
timeout: 60000,
// Allow parallel tests
fullyParallel: true,
@@ -25,17 +23,11 @@ module.exports = defineConfig({
// Settings shared with all the tests
use: {
// Take a screenshot when the test fails
screenshot: {
mode: 'only-on-failure',
fullPage: true
},
// Limit individual actions (like click/type) so they don't hang indefinitely
actionTimeout: 15000,
},
expect: {
// Give expect() assertions more time to find elements like '.jasmine-overall-result'
timeout: 20000,
}
},
/* Configure projects for major browsers */
@@ -47,7 +39,7 @@ module.exports = defineConfig({
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
use: { ...devices['Desktop Firefox'] },
},
{
+4 -3
View File
@@ -256,11 +256,12 @@ CodeMirrorEngine.prototype.getText = function() {
Fix the height of textarea to fit content
*/
CodeMirrorEngine.prototype.fixHeight = function() {
if(this.widget.editAutoHeight) {
// rows takes precedence
if(this.widget.editRows) {
this.cm.setSize(null,this.widget.editRows + "em");
} else if(this.widget.editAutoHeight) {
// Resize to fit
this.cm.setSize(null,null);
} else if(this.widget.editRows) {
this.cm.setSize(null,this.widget.editRows + "em");
} else {
var fixedHeight = parseInt(this.widget.wiki.getTiddlerText(HEIGHT_VALUE_TITLE,"400px"),10);
fixedHeight = Math.max(fixedHeight,20);
+13 -19
View File
@@ -4,21 +4,12 @@ title: $:/core/modules/utils/aho-corasick.js
type: application/javascript
module-type: utils
Optimized Aho-Corasick string matching algorithm implementation with enhanced
performance and error handling for TiddlyWiki freelinking functionality.
Optimized Aho-Corasick string matching algorithm implementation with enhanced performance
and error handling for TiddlyWiki freelinking functionality.
- Uses WeakMap for failure links. WeakMap keys are compared by object identity
(reference equality), which is required here because trie nodes are plain
objects — a regular {} map would not work because JavaScript only supports
string and Symbol keys, forcing object keys to be coerced to strings.
- Outputs are merged at build time (classic AC optimization), eliminating the
need to walk the failure chain during search.
- search() converts case per character to avoid Unicode index desync (e.g.
Turkish İ expands under toLowerCase(), shifting subsequent indices).
- No match count cap in search(); truncation is handled at the render stage
by processTextWithMatches() to avoid silently dropping matches mid-text.
- Optional word boundary filtering: CJK always allowed; Latin requires
non-word characters on both sides.
- Uses WeakMap for failure links (required; plain object keys would collide).
- search() converts case per character to avoid Unicode index desync.
- Optional word boundary filtering: CJK always allowed; Latin requires non-word chars around.
\*/
@@ -27,6 +18,7 @@ performance and error handling for TiddlyWiki freelinking functionality.
function AhoCorasick() {
this.trie = {};
this.failure = new WeakMap();
this.maxFailureDepth = 100;
this.patternCount = 0;
}
@@ -84,18 +76,18 @@ AhoCorasick.prototype.buildFailureLinks = function() {
if(!child || typeof child !== "object") continue;
var fail = self.failure.get(node) || root;
var depth = 0;
while(fail !== root && !fail[edge]) {
while(fail !== root && !fail[edge] && depth < self.maxFailureDepth) {
fail = self.failure.get(fail) || root;
depth++;
}
var nextFail = (fail[edge] && fail[edge] !== child) ? fail[edge] : root;
self.failure.set(child, nextFail);
if(nextFail.$) {
if(!child.$) {
child.$ = [];
}
if(!child.$) child.$ = [];
child.$ = child.$.concat(nextFail.$);
}
@@ -114,6 +106,8 @@ AhoCorasick.prototype.search = function(text, useWordBoundary, ignoreCase) {
var root = this.trie;
var textLength = text.length;
var maxMatches = Math.min(textLength * 2, 10000);
for(var i = 0; i < textLength; i++) {
var ch = ignoreCase ? text[i].toLowerCase() : text[i];
@@ -126,7 +120,7 @@ AhoCorasick.prototype.search = function(text, useWordBoundary, ignoreCase) {
if(node.$) {
var outputs = node.$;
for(var j = 0; j < outputs.length; j++) {
for(var j = 0; j < outputs.length && matches.length < maxMatches; j++) {
var out = outputs[j];
var matchStart = i - out.length + 1;
var matchEnd = i + 1;
@@ -1,2 +0,0 @@
title: $:/config/Freelinks/MaxLinks
text: 500
+3 -2
View File
@@ -2,6 +2,7 @@ title: $:/plugins/tiddlywiki/freelinks/macros/view
tags: $:/tags/Macro/View
<$set name="tv-freelinks" value={{$:/config/Freelinks/Enable}}>
<$set name="tv-freelinks-ignore-case" value={{$:/config/Freelinks/IgnoreCase}}/>
</$set>
</$set>
</$set>
+3 -9
View File
@@ -23,10 +23,6 @@ Use `$:/config/Freelinks/TargetFilter` to define which tiddlers are eligible for
Within view templates, the variable `tv-freelinks` is automatically set to the content of `$:/config/Freelinks/Enable`, which can be set via the settings panel of this plugin.
Use `$:/config/Freelinks/MaxLinks` to set the maximum number of links rendered per text node (default: 500).
Since TiddlyWiki splits tiddler content into multiple text nodes, the effective limit per tiddler is higher than this value. Text beyond the limit remains as plain text rather than being silently dropped. This cap protects against DOM overload when a large number of tiddler titles match the text, for example in dictionary-scale wikis. Raising this value significantly increases render time.
!! Notes
To change within which tiddlers freelinking occurs requires customising the shadow tiddler [[$:/plugins/tiddlywiki/freelinks/macros/view]]. This tiddler is tagged `$:/tags/Macro/View` which means that it will be included as a local macro in each view template. By default, its content is:
@@ -61,9 +57,7 @@ The Aho-Corasick algorithm implementation includes:
* Unicode character support for international text
* Prevention of self-referential links within the current tiddler
* Uses a `WeakMap` for failure links, required for correct object-keyed traversal
* Merges pattern outputs at build time for efficient single-pass search
* Converts case per character during search to avoid Unicode index desync (e.g. Turkish İ, German ß)
* Runs search to completion without truncation; the `MaxLinks` cap is applied at render time so that the longest-match selection always has full information and text beyond the cap is preserved as plain text rather than silently dropped
* Performance safeguards including depth protection and result limiting
* Graceful fallback handling for invalid patterns
* Skips draft tiddlers and system tiddlers (`$:/`) during cache invalidation to avoid unnecessary rebuilds on UI interactions
Longer tiddler titles take precedence over shorter ones when multiple matches are possible.
@@ -9,5 +9,3 @@ Filter defining tiddlers to which freelinks are made: <$edit-text tiddler="$:/co
<$checkbox tiddler="$:/config/Freelinks/WordBoundary" field="text" checked="yes" unchecked="no" default="yes"> <$link to="$:/config/Freelinks/WordBoundary">Word Boundary Check</$link> </$checkbox>
<$checkbox tiddler="$:/config/Freelinks/IgnoreCase" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/IgnoreCase">Ignore case</$link> </$checkbox>
Maximum rendered freelinks (higher values may slow down large tiddlers): <$edit-text tiddler="$:/config/Freelinks/MaxLinks" tag="input" placeholder="500" default="500"/>
+5 -25
View File
@@ -9,11 +9,6 @@ Optimized override of the core text widget that automatically linkifies text.
- Global longest-match priority, then removes overlaps.
- Excludes current tiddler title from linking.
- Uses Aho-Corasick for performance.
- Render output capped by $:/config/Freelinks/MaxLinks (default 500).
The cap applies to the final set of non-overlapping rendered links, not to
the raw search results: search always runs to completion so that the
longest-match selection has full information. Text beyond the cap remains
as plain text rather than being silently omitted.
\*/
@@ -21,8 +16,6 @@ Optimized override of the core text widget that automatically linkifies text.
var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter";
var WORD_BOUNDARY_TIDDLER = "$:/config/Freelinks/WordBoundary";
var MAX_LINKS_TIDDLER = "$:/config/Freelinks/MaxLinks";
var IGNORE_CASE_TIDDLER = "$:/config/Freelinks/IgnoreCase";
var Widget = require("$:/core/modules/widgets/widget.js").widget,
LinkWidget = require("$:/core/modules/widgets/link.js").link,
@@ -70,8 +63,7 @@ TextNodeWidget.prototype.execute = function() {
return computeTiddlerTitleInfo(self,ignoreCase);
});
if(this.tiddlerTitleInfo && this.tiddlerTitleInfo.titles &&
this.tiddlerTitleInfo.titles.length > 0 && this.tiddlerTitleInfo.ac) {
if(this.tiddlerTitleInfo && this.tiddlerTitleInfo.titles && this.tiddlerTitleInfo.titles.length > 0 && this.tiddlerTitleInfo.ac) {
var newParseTree = this.processTextWithMatches(text,currentTiddlerTitle,ignoreCase,useWordBoundary);
if(newParseTree && newParseTree.length > 0 &&
(newParseTree.length > 1 || newParseTree[0].type !== "plain-text")) {
@@ -111,14 +103,7 @@ TextNodeWidget.prototype.processTextWithMatches = function(text,currentTiddlerTi
var occupied = new Uint8Array(text.length);
var validMatches = [];
var maxLinks = parseInt(this.wiki.getTiddlerText(MAX_LINKS_TIDDLER,"500"),10);
if(isNaN(maxLinks) || maxLinks <= 0) {
maxLinks = 500;
}
for(var i = 0; i < matches.length; i++) {
if(validMatches.length >= maxLinks) break;
var m = matches[i];
var start = m.index;
var end = start + m.length;
@@ -247,9 +232,7 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) {
$tw.utils.each(changedTiddlers,function(change,title) {
if(titlesHaveChanged) return;
if(title === WORD_BOUNDARY_TIDDLER ||
title === TITLE_TARGET_FILTER ||
title === IGNORE_CASE_TIDDLER) {
if(title === WORD_BOUNDARY_TIDDLER || title === TITLE_TARGET_FILTER) {
titlesHaveChanged = true;
return;
}
@@ -259,8 +242,7 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) {
}
if(change && change.isDeleted) {
if(self.tiddlerTitleInfo && self.tiddlerTitleInfo.titles &&
self.tiddlerTitleInfo.titles.indexOf(title) !== -1) {
if(self.tiddlerTitleInfo && self.tiddlerTitleInfo.titles && self.tiddlerTitleInfo.titles.indexOf(title) !== -1) {
titlesHaveChanged = true;
}
return;
@@ -271,17 +253,15 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) {
return;
}
if(!self.tiddlerTitleInfo || !self.tiddlerTitleInfo.titles ||
self.tiddlerTitleInfo.titles.indexOf(title) === -1) {
if(!self.tiddlerTitleInfo || !self.tiddlerTitleInfo.titles || self.tiddlerTitleInfo.titles.indexOf(title) === -1) {
titlesHaveChanged = true;
}
});
}
var wordBoundaryChanged = !!(changedTiddlers && changedTiddlers[WORD_BOUNDARY_TIDDLER]);
var maxLinksChanged = !!(changedTiddlers && changedTiddlers[MAX_LINKS_TIDDLER]);
if(changedAttributes.text || titlesHaveChanged || wordBoundaryChanged || maxLinksChanged) {
if(changedAttributes.text || titlesHaveChanged || wordBoundaryChanged) {
if(titlesHaveChanged) {
self.wiki.clearCache("tiddler-title-info-insensitive");
self.wiki.clearCache("tiddler-title-info-sensitive");
@@ -0,0 +1,230 @@
/*\
title: $:/plugins/tiddlywiki/performance/perf-replay-command.js
type: application/javascript
module-type: command
Command to replay a recorded performance timeline against the current wiki
Usage: --perf-replay <timeline.json> [--no-coalesce]
Loads the wiki (use --load before this command), builds a widget tree
using fakeDocument, then replays the recorded store modifications
batch by batch, measuring refresh performance for each batch.
\*/
"use strict";
exports.info = {
name: "perf-replay",
synchronous: false
};
var Command = function(params,commander,callback) {
this.params = params;
this.commander = commander;
this.callback = callback;
};
Command.prototype.execute = function() {
var self = this,
fs = require("fs"),
path = require("path"),
wiki = this.commander.wiki,
widget = require("$:/core/modules/widgets/widget.js");
// Parse parameters
if(this.params.length < 1) {
return "Missing timeline filename. Usage: --perf-replay <timeline.json>";
}
var timelinePath = this.params[0];
// Load timeline
var timelineData;
try {
timelineData = JSON.parse(fs.readFileSync(timelinePath,"utf8"));
} catch(e) {
return "Error reading timeline file: " + e.message;
}
if(!Array.isArray(timelineData) || timelineData.length === 0) {
return "Timeline file is empty or invalid";
}
// Count tiddlers in wiki
var tiddlerCount = 0;
wiki.each(function() { tiddlerCount++; });
// Build a widget tree against fakeDocument (mirroring what render.js does in the browser)
var PAGE_TEMPLATE_TITLE = "$:/core/ui/RootTemplate";
// Create root widget
var rootWidget = new widget.widget({
type: "widget",
children: []
},{
wiki: wiki,
document: $tw.fakeDocument
});
// Enable performance instrumentation
var perf = new $tw.Performance(true);
// Wrap filter execution with perf measurement
var origCompileFilter = wiki.compileFilter;
var filterInvocations = 0;
wiki.compileFilter = function(filterString) {
var compiledFilter = origCompileFilter.call(wiki,filterString);
return perf.measure("filter: " + filterString.substring(0,80),function(source,widget) {
filterInvocations++;
return compiledFilter.call(this,source,widget);
});
};
// Re-initialise parsers so filters get wrapped
wiki.clearCache(null);
// Build and render the page widget tree
var pageWidgetNode = wiki.makeTranscludeWidget(PAGE_TEMPLATE_TITLE,{
document: $tw.fakeDocument,
parentWidget: rootWidget,
recursionMarker: "no"
});
var pageContainer = $tw.fakeDocument.createElement("div");
var renderStart = $tw.utils.timer();
pageWidgetNode.render(pageContainer,null);
var renderTime = $tw.utils.timer(renderStart);
// Link root widget
rootWidget.domNodes = [pageContainer];
rootWidget.children = [pageWidgetNode];
// Group timeline events by batch
var batches = [];
var currentBatch = null;
$tw.utils.each(timelineData,function(event) {
if(!currentBatch || event.batch !== currentBatch.batchId) {
currentBatch = {batchId: event.batch, events: []};
batches.push(currentBatch);
}
currentBatch.events.push(event);
});
// Replay each batch and measure refresh
var results = [],
totalRefreshTime = 0,
totalFilterInvocations = 0;
self.commander.streams.output.write("\nPerformance Timeline Replay\n");
self.commander.streams.output.write("==========================\n");
self.commander.streams.output.write("Wiki: " + tiddlerCount + " tiddlers\n");
self.commander.streams.output.write("Timeline: " + timelineData.length + " operations in " + batches.length + " batches\n");
self.commander.streams.output.write("Initial render: " + renderTime.toFixed(2) + "ms\n\n");
self.commander.streams.output.write(padRight("Batch",8) + padRight("Ops",6) + padRight("Changed",10) +
padRight("Refresh(ms)",14) + padRight("Filters",10) + "Tiddlers Changed\n");
self.commander.streams.output.write(padRight("-----",8) + padRight("---",6) + padRight("-------",10) +
padRight("-----------",14) + padRight("-------",10) + "----------------\n");
$tw.utils.each(batches,function(batch,index) {
// Apply all operations in this batch directly (bypassing the intercepted addTiddler
// to avoid re-recording)
var changedTiddlers = Object.create(null);
$tw.utils.each(batch.events,function(event) {
if(event.op === "add") {
wiki.addTiddler(new $tw.Tiddler(event.fields));
changedTiddlers[event.title] = {modified: true};
} else if(event.op === "delete") {
wiki.deleteTiddler(event.title);
changedTiddlers[event.title] = {deleted: true};
}
});
// Measure refresh
filterInvocations = 0;
var refreshStart = $tw.utils.timer();
pageWidgetNode.refresh(changedTiddlers);
var refreshTime = $tw.utils.timer(refreshStart);
totalRefreshTime += refreshTime;
totalFilterInvocations += filterInvocations;
var changedTitles = Object.keys(changedTiddlers);
var titlesDisplay = changedTitles.slice(0,3).join(", ");
if(changedTitles.length > 3) {
titlesDisplay += " (+" + (changedTitles.length - 3) + " more)";
}
results.push({
batch: index + 1,
ops: batch.events.length,
changed: changedTitles.length,
refreshMs: refreshTime,
filters: filterInvocations,
tiddlers: changedTitles
});
self.commander.streams.output.write(
padRight(String(index + 1),8) +
padRight(String(batch.events.length),6) +
padRight(String(changedTitles.length),10) +
padRight(refreshTime.toFixed(2),14) +
padRight(String(filterInvocations),10) +
titlesDisplay + "\n"
);
});
// Summary statistics
var refreshTimes = results.map(function(r) { return r.refreshMs; }).sort(function(a,b) { return a - b; });
var p50 = percentile(refreshTimes,50);
var p95 = percentile(refreshTimes,95);
var p99 = percentile(refreshTimes,99);
var maxRefresh = refreshTimes[refreshTimes.length - 1] || 0;
var meanRefresh = batches.length > 0 ? totalRefreshTime / batches.length : 0;
self.commander.streams.output.write("\nSummary\n");
self.commander.streams.output.write("-------\n");
self.commander.streams.output.write("Initial render: " + renderTime.toFixed(2) + "ms\n");
self.commander.streams.output.write("Total refresh time: " + totalRefreshTime.toFixed(2) + "ms\n");
self.commander.streams.output.write("Mean refresh: " + meanRefresh.toFixed(2) + "ms\n");
self.commander.streams.output.write("P50 refresh: " + p50.toFixed(2) + "ms\n");
self.commander.streams.output.write("P95 refresh: " + p95.toFixed(2) + "ms\n");
self.commander.streams.output.write("P99 refresh: " + p99.toFixed(2) + "ms\n");
self.commander.streams.output.write("Max refresh: " + maxRefresh.toFixed(2) + "ms\n");
self.commander.streams.output.write("Total filters run: " + totalFilterInvocations + "\n");
// Output filter breakdown
self.commander.streams.output.write("\nTop Filter Execution Times\n");
self.commander.streams.output.write("--------------------------\n");
var measures = perf.measures;
var orderedMeasures = Object.keys(measures).sort(function(a,b) {
return measures[b].time - measures[a].time;
}).slice(0,20);
$tw.utils.each(orderedMeasures,function(name) {
var m = measures[name];
self.commander.streams.output.write(
padRight(m.time.toFixed(2) + "ms",14) +
padRight(String(m.invocations) + "x",10) +
padRight((m.time / m.invocations).toFixed(3) + "ms avg",16) +
name + "\n"
);
});
// Write JSON results
var jsonResultPath = timelinePath.replace(/\.json$/,"") + "-results.json";
try {
fs.writeFileSync(jsonResultPath,JSON.stringify({
wiki: {tiddlerCount: tiddlerCount},
timeline: {operations: timelineData.length, batches: batches.length},
initialRender: renderTime,
summary: {
totalRefreshTime: totalRefreshTime,
meanRefresh: meanRefresh,
p50: p50,
p95: p95,
p99: p99,
maxRefresh: maxRefresh,
totalFilterInvocations: totalFilterInvocations
},
batches: results,
topFilters: orderedMeasures.map(function(name) {
return {name: name, time: measures[name].time, invocations: measures[name].invocations};
})
},null,"\t"),"utf8");
self.commander.streams.output.write("\nDetailed results written to: " + jsonResultPath + "\n");
} catch(e) {
self.commander.streams.output.write("\nWarning: Could not write results file: " + e.message + "\n");
}
self.callback(null);
return null;
};
function percentile(sortedArray,p) {
if(sortedArray.length === 0) return 0;
var index = Math.ceil(sortedArray.length * p / 100) - 1;
return sortedArray[Math.max(0,index)];
}
function padRight(str,width) {
while(str.length < width) {
str += " ";
}
return str;
}
exports.Command = Command;
@@ -0,0 +1,7 @@
{
"title": "$:/plugins/tiddlywiki/performance",
"name": "Performance",
"description": "Record and replay wiki store modifications for performance testing",
"list": "readme ui",
"stability": "STABILITY_1_EXPERIMENTAL"
}
+202
View File
@@ -0,0 +1,202 @@
title: $:/plugins/tiddlywiki/performance/readme
! Performance Testing Plugin
This plugin provides a framework for measuring the performance of TiddlyWiki's refresh cycle — the process that updates the display when tiddlers are modified.
The idea is to capture a realistic workload by recording store modifications while a user interacts with a wiki in the browser, and then replaying those modifications under Node.js where the refresh cycle can be precisely measured in isolation.
!! Motivation
An important motivation for this framework is to enable LLMs to iteratively optimise TiddlyWiki's performance. The workflow is:
# An LLM makes a change to the TiddlyWiki codebase (e.g. optimising a filter operator, caching a computation, or restructuring a widget's refresh logic)
# The LLM runs `--perf-replay` against a recorded timeline to measure the impact
# The LLM reads the JSON results file to determine whether the change improved, regressed, or had no effect on performance
# The LLM iterates: tries another approach, measures again, and converges on the best solution
This tight edit-measure-iterate loop works because `--perf-replay` runs entirely under Node.js with no browser required, produces machine-readable JSON output, and completes in seconds.
!! How It Works
The framework has two parts:
!!! 1. Recording (Browser)
The plugin intercepts `wiki.addTiddler()` and `wiki.deleteTiddler()` to capture every store modification as it happens. Each operation is recorded with:
* A sequence number and high-resolution timestamp
* The full tiddler fields (so the exact state can be recreated)
* A batch identifier that tracks TiddlyWiki's change batching via `$tw.utils.nextTick()`
The batch tracking is important because TiddlyWiki groups multiple store changes that occur in the same tick into a single refresh cycle. The recorder preserves these batch boundaries so that playback triggers the same pattern of refreshes.
!!! 2. Playback (Node.js)
The `--perf-replay` command loads a wiki and builds the full widget tree using TiddlyWiki's `$tw.fakeDocument` — the lightweight DOM implementation used for server-side rendering. It then replays the recorded timeline batch by batch, calling `widgetNode.refresh(changedTiddlers)` after each batch and measuring how long it takes.
This means we are measuring TiddlyWiki's own refresh logic (widget tree traversal, filter evaluation, DOM diffing) in isolation from browser layout and paint. This is intentional — it lets us identify performance bottlenecks within TiddlyWiki itself, independent of which browser is being used.
!! Why Store-Level Recording?
An alternative would be to record DOM events (clicks, keystrokes) and replay them in a headless browser. Store-level recording was chosen instead because:
* The refresh cycle responds to ''store changes'', not DOM events — store modifications are the natural input
* Store changes are fully deterministic and reproducible
* No DOM dependency means playback works in pure Node.js with no headless browser to install
* A headless browser would add its own overhead, making measurements less precise
!! Recording
# Include this plugin in your wiki
# Open the Control Panel and find the "Performance Testing Recorder" tab
# Click "Start Recording"
# Interact with the wiki — open tiddlers, edit, type, navigate, switch tabs
# Click "Stop Recording"
# Download the `timeline.json` file
!!! Draft Coalescing
When editing a tiddler, TiddlyWiki writes to draft tiddlers on every keystroke. By default, the recorder coalesces rapid draft updates within the same batch, keeping only the last update. This produces a more compact timeline that focuses on the refresh-relevant changes.
Uncheck "Coalesce rapid draft updates" to record every individual keystroke. This is useful when you specifically want to measure the performance impact of rapid typing.
!! Playback
```
tiddlywiki editions/performance --load mywiki.html --perf-replay timeline.json
```
Or from any edition that includes this plugin:
```
tiddlywiki myedition --perf-replay timeline.json
```
Playback runs at full speed with no delays between batches. The recorded timestamps are preserved in the timeline for reference but are not used for pacing.
!! What Gets Measured
* ''Initial render time'' — the time to build and render the full widget tree from scratch
* ''Refresh time per batch'' — the time `widgetNode.refresh(changedTiddlers)` takes for each batch of store modifications
* ''Filter execution'' — individual filter timings and invocation counts, showing which filters are the most expensive
* ''Statistical summary'' — mean, P50, P95, P99, and maximum refresh times across all batches
!! Output
The command produces two forms of output:
!!! Text Report (stdout)
A human-readable table printed to the console showing per-batch timings, a summary with percentile statistics, and a breakdown of the most expensive filter executions.
!!! JSON Results File
A `<timeline-name>-results.json` file is written alongside the input timeline. This is the primary output for automated consumption. The file contains:
```json
{
"wiki": {
"tiddlerCount": 2076
},
"timeline": {
"operations": 156,
"batches": 42
},
"initialRender": 55.46,
"summary": {
"totalRefreshTime": 234.5,
"meanRefresh": 5.58,
"p50": 4.12,
"p95": 18.7,
"p99": 31.2,
"maxRefresh": 31.2,
"totalFilterInvocations": 4821
},
"batches": [
{
"batch": 1,
"ops": 1,
"changed": 1,
"refreshMs": 12.3,
"filters": 293,
"tiddlers": ["$:/StoryList"]
}
],
"topFilters": [
{
"name": "filter: [subfilter{$:/core/config/GlobalImportFilter}]",
"time": 5.65,
"invocations": 5
}
]
}
```
All times are in milliseconds. The key fields for automated analysis:
* `summary.totalRefreshTime` — the single most important number: total time spent in refresh across all batches
* `summary.meanRefresh` — average refresh time per batch
* `summary.p95` / `summary.p99` — tail latency indicators
* `initialRender` — time to build the widget tree from scratch (measures startup cost)
* `batches[].refreshMs` — per-batch breakdown, useful for identifying which user actions are expensive
* `topFilters[]` — the most expensive filters by total execution time, useful for identifying optimisation targets
!! Example: LLM Optimisation Workflow
An LLM optimising TiddlyWiki performance would follow this pattern:
!!! Step 1: Establish baseline
```
node ./tiddlywiki.js editions/performance --load mywiki.html --perf-replay timeline.json
```
Read `timeline-results.json` and note the baseline `summary.totalRefreshTime`.
!!! Step 2: Make a change
Edit a source file (e.g. optimise a filter operator in `core/modules/filters/`).
!!! Step 3: Measure impact
Run the same `--perf-replay` command again and read the new `timeline-results.json`.
!!! Step 4: Compare
Compare `summary.totalRefreshTime` and `summary.p95` between baseline and new results. If improved, keep the change. If regressed, revert and try a different approach.
!!! Step 5: Iterate
Repeat steps 2-4 until the target metric is optimised.
The JSON results file makes step 4 straightforward — an LLM can read two JSON files and compare numeric fields directly without parsing tabular text output.
!! Timeline Format
The timeline is a JSON array of operations:
```json
[
{
"seq": 0,
"t": 123.45,
"batch": 0,
"op": "add",
"title": "$:/StoryList",
"isDraft": false,
"fields": {
"title": "$:/StoryList",
"list": "GettingStarted",
"text": ""
}
}
]
```
* `seq` — sequential operation number
* `t` — milliseconds since recording started
* `batch` — batch identifier (operations in the same batch trigger a single refresh)
* `op` — `"add"` or `"delete"`
* `isDraft` — whether this is a draft tiddler (used for coalescing)
* `fields` — complete tiddler fields (null for delete operations)
+143
View File
@@ -0,0 +1,143 @@
/*\
title: $:/plugins/tiddlywiki/performance/recorder.js
type: application/javascript
module-type: startup
Store modification recorder for performance testing.
Intercepts wiki.addTiddler() and wiki.deleteTiddler() to capture
a timeline of all store modifications with batch boundary tracking.
\*/
"use strict";
exports.name = "perf-recorder";
exports.platforms = ["browser"];
exports.after = ["load-modules"];
exports.synchronous = true;
exports.startup = function() {
var STATE_TIDDLER = "$:/state/performance/recording",
TIMELINE_TIDDLER = "$:/temp/performance/timeline",
COALESCE_CONFIG = "$:/config/performance/coalesce-drafts",
timeline = [],
seq = 0,
startTime = null,
recording = false,
batchId = 0,
currentBatch = 0,
origNextTick = $tw.utils.nextTick,
origAddTiddler = $tw.wiki.addTiddler.bind($tw.wiki),
origDeleteTiddler = $tw.wiki.deleteTiddler.bind($tw.wiki);
// Patch nextTick to track batch boundaries
$tw.utils.nextTick = function(fn) {
origNextTick(function() {
if(recording) {
currentBatch = ++batchId;
}
fn();
});
};
// Patch addTiddler
$tw.wiki.addTiddler = function(tiddler) {
if(recording) {
if(!(tiddler instanceof $tw.Tiddler)) {
tiddler = new $tw.Tiddler(tiddler);
}
var title = tiddler.fields.title;
// Skip our own state/timeline tiddlers
if(title !== STATE_TIDDLER && title !== TIMELINE_TIDDLER) {
timeline.push({
seq: seq++,
t: $tw.utils.timer(startTime),
batch: currentBatch,
op: "add",
title: title,
isDraft: tiddler.hasField("draft.of"),
fields: tiddler.getFieldStrings()
});
}
}
return origAddTiddler.apply(null,arguments);
};
// Patch deleteTiddler
$tw.wiki.deleteTiddler = function(title) {
if(recording) {
if(title !== STATE_TIDDLER && title !== TIMELINE_TIDDLER) {
timeline.push({
seq: seq++,
t: $tw.utils.timer(startTime),
batch: currentBatch,
op: "delete",
title: title,
isDraft: false,
fields: null
});
}
}
return origDeleteTiddler.apply(null,arguments);
};
// Listen for recording state changes
$tw.wiki.addEventListener("change",function(changes) {
if(STATE_TIDDLER in changes) {
var state = $tw.wiki.getTiddlerText(STATE_TIDDLER,"").trim();
if(state === "yes" && !recording) {
// Start recording
timeline = [];
seq = 0;
batchId = 0;
currentBatch = 0;
startTime = $tw.utils.timer();
recording = true;
console.log("performance: Recording started");
} else if(state !== "yes" && recording) {
// Stop recording and save timeline
recording = false;
var coalesce = $tw.wiki.getTiddlerText(COALESCE_CONFIG,"yes").trim() === "yes";
var output = coalesce ? coalesceDrafts(timeline) : timeline;
origAddTiddler(new $tw.Tiddler({
title: TIMELINE_TIDDLER,
type: "application/json",
text: JSON.stringify(output,null,"\t")
}));
console.log("performance: Recording stopped. " + timeline.length + " operations captured" +
(coalesce ? " (" + output.length + " after coalescing drafts)" : ""));
}
}
});
/*
Coalesce rapid draft tiddler updates within the same batch.
Keeps only the last update for each draft tiddler per batch.
*/
function coalesceDrafts(events) {
var result = [],
i = 0;
while(i < events.length) {
var event = events[i];
if(event.isDraft && event.op === "add") {
// Look ahead for later updates to this same draft in the same batch
var lastIndex = i;
for(var j = i + 1; j < events.length; j++) {
if(events[j].batch !== event.batch) {
break;
}
if(events[j].title === event.title && events[j].op === "add") {
lastIndex = j;
}
}
// Keep only the last one, but fix its seq to maintain ordering
result.push(events[lastIndex]);
i = lastIndex + 1;
} else {
result.push(event);
i++;
}
}
return result;
}
};
+48
View File
@@ -0,0 +1,48 @@
title: $:/plugins/tiddlywiki/performance/ui
caption: Recorder
! Performance Testing Recorder
<$let
state="$:/state/performance/recording"
timeline="$:/temp/performance/timeline"
coalesceConfig="$:/config/performance/coalesce-drafts"
>
!! Recording
<$reveal state=<<state>> type="nomatch" text="yes">
<$button set=<<state>> setTo="yes" class="tc-btn-big-green">Start Recording</$button>
</$reveal>
<$reveal state=<<state>> type="match" text="yes">
<$button set=<<state>> setTo="no" class="tc-btn-big-green" style="background: #d33;">Stop Recording</$button>
&nbsp; //Recording in progress...//
</$reveal>
!! Options
<$checkbox tiddler=<<coalesceConfig>> field="text" checked="yes" unchecked="no" default="yes"> Coalesce rapid draft updates</$checkbox>
!! Timeline
<$reveal type="nomatch" state=<<timeline>> text="">
<$let timelineText={{$(timeline)$}}>
<$vars count={{{ [<timeline>get[text]jsonextract[]] +[count[]] }}}>
Timeline contains <<count>> operations.
</$vars>
<$button>
<$action-sendmessage $message="tm-download-file" $param=<<timeline>> filename="timeline.json"/>
Download timeline.json
</$button>
</$let>
</$reveal>
<$reveal type="match" state=<<timeline>> text="">
No timeline recorded yet. Click "Start Recording", interact with the wiki, then click "Stop Recording".
</$reveal>
</$let>