Compare commits

..
Author SHA1 Message Date
Théophile DesmedtandGitHub 6680967875 Improve discoverability of the style attribute feature (#9975)
* Improve discoverability of the style attribute feature

* Add missing tiddler  "Style Attributes"
2026-08-21 09:04:34 +02:00
Mario PietschandGitHub f9d5ee0ad7 Add ButtonWidget examples and execution order (#9945)
Document the button widget with runnable test cases and explain how its
attributes combine.

* Add eleven test case tiddlers, listed by title prefix so a new one needs
  no edit to the widget page
* Describe the order in which a button runs body actions, to, message,
  popup, set and actions, whatever order they are written in
* Prefer class over a hardcoded style
2026-08-16 13:17:33 +02:00
Mario PietschandGitHub 68630c939d Docs only Groupedlists examples (#9954)
* Add GroupedLists examples

Document grouped lists with runnable test cases over a shared
bibliography sample.

* Cover plain grouping, search, tabs by first character, both combined,
  and grouping on one field while searching another
* Name the grouped field in one procedure, so the grouping changes in
  one line
* Import it with import-compound, and list the examples by tag so a new
  one needs no page edit
* Rewrite the opening paragraph, which described only the sidebar tabs

* Replace GroupedLists code dumps with examples

Turn the two sidebar tabs the page describes into runnable test cases,
and tighten how the examples handle an empty result.

* Give each new example its own dummy payload, and point the page at the
  live tabs instead of quoting core source
* Carry the values through the filter from the start, so an All tab with
  no matches yields nothing rather than the literal All
* Use the list-empty widget

* use tc-tiny-gap instead of -left -right
2026-08-16 13:16:04 +02:00
superuser-doesandGitHub 7efbd01801 update 5.4.0 release notes: there was no change to default code wrap (#9962) 2026-08-08 11:46:30 +02:00
Théophile DesmedtandGitHub 5143138c16 [DOCS] Update [[Custom Widgets]] with new example for overriding LinkWidget (#9961)
* [DOCS] Update [[Custom Widgets]] with new example for overriding LinkWidget

* fix: non-svg link icon are now sizing properly

* replace HelloThere by list-link in the example for robustness

* Add class to span for better styling in Custom Widgets

* Fix typo in Custom Widgets.tid documentation

* Improve doc by using the proper macro to link widgets

* Change font size style to important for title icon

Updated the font size style for the title icon to be important.

* Update transclude and slot elements in Custom Widgets

Provide a fallback if no body for the link is given

* Enhance link widget to show icons conditionally

Updated the link widget to conditionally display an icon next to the link based on the target tiddler's icon field. Modified the rendering logic to use a conditional statement for the icon display.

* Change transclude height to size in Custom Widgets
2026-08-07 22:05:06 +02:00
Jeremy Ruston ceb0817176 Update Quine instructions 2026-08-06 10:48:53 +01:00
Jeremy Ruston c0b3403f87 Remove extraneous github ribbon in the dev edition 2026-07-21 12:11:14 +01:00
Jeremy Ruston 2ececf21c7 Cherry pick docs update from master 2026-07-18 11:36:37 +01:00
9a0bbf831b Signing the CLA (#9923)
* Signing the CLA

* Apply suggestion from @saqimtiaz

* Apply suggestion from @saqimtiaz

* Apply suggestion from @saqimtiaz

---------

Co-authored-by: Saq Imtiaz <saq.imtiaz@gmail.com>
2026-07-12 19:37:43 +02:00
superuser-doesandGitHub a30854a4c7 [DOCS] Corrected Description Lists tiddler (#9819)
* [DOCS] Corrected Description Lists tiddler

* Correct modified, remove trailing whitespace
2026-07-12 09:48:53 +02:00
Mario PietschandGitHub 856057be81 Add 8972 field names with parens impact note (#9875) 2026-07-12 09:20:31 +02:00
92 changed files with 1471 additions and 2534 deletions
+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.5.0.
TW5_BUILD_VERSION=v5.4.1.
fi
echo "Using TW5_BUILD_VERSION as [$TW5_BUILD_VERSION]"
+8 -60
View File
@@ -1539,8 +1539,8 @@ Register all the module tiddlers that have a module type
$tw.Wiki.prototype.defineShadowModules = function() {
var self = this;
this.eachShadow(function(tiddler,title) {
// Don't define the module if it is overidden by an ordinary tiddler, or has already been defined
if(!self.tiddlerExists(title) && tiddler.hasField("module-type") && !$tw.utils.hop($tw.modules.titles,title)) {
// Don't define the module if it is overidden by an ordinary tiddler
if(!self.tiddlerExists(title) && tiddler.hasField("module-type")) {
if(tiddler.hasField("draft.of")) {
// Report a fundamental problem
console.warn(`TiddlyWiki: Plugins should not contain tiddlers with a 'draft.of' field: ${tiddler.fields.title}`);
@@ -1971,7 +1971,7 @@ $tw.loadTiddlersFromSpecification = function(filepath,excludeRegExp) {
});
// Helper to process a file
var processFile = function(filename,isTiddlerFile,fields,isEditableFile,rootPath,dynamicStoreId) {
var processFile = function(filename,isTiddlerFile,fields,isEditableFile,rootPath) {
var extInfo = $tw.config.fileExtensionInfo[path.extname(filename)],
type = (extInfo || {}).type || fields.type || "text/plain",
typeInfo = $tw.config.contentTypeInfo[type] || {},
@@ -2046,9 +2046,9 @@ $tw.loadTiddlersFromSpecification = function(filepath,excludeRegExp) {
});
});
if(isEditableFile) {
tiddlers.push({filepath: pathname, hasMetaFile: !!metadata && !isTiddlerFile, isEditableFile: true, dynamicStoreId: dynamicStoreId, tiddlers: fileTiddlers});
tiddlers.push({filepath: pathname, hasMetaFile: !!metadata && !isTiddlerFile, isEditableFile: true, tiddlers: fileTiddlers});
} else {
tiddlers.push({dynamicStoreId: dynamicStoreId, tiddlers: fileTiddlers});
tiddlers.push({tiddlers: fileTiddlers});
}
};
// Helper to recursively search subdirectories
@@ -2089,31 +2089,6 @@ $tw.loadTiddlersFromSpecification = function(filepath,excludeRegExp) {
// Process directory specifier
var dirPath = path.resolve(filepath,dirSpec.path);
if(fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
// Register a dynamic store if requested
var dynamicStoreId = null;
if(dirSpec.dynamicStore && $tw.boot.dynamicStores) {
dynamicStoreId = dirPath;
var existing = null;
for(var ds=0; ds<$tw.boot.dynamicStores.length; ds++) {
if($tw.boot.dynamicStores[ds].id === dynamicStoreId) {
existing = $tw.boot.dynamicStores[ds];
break;
}
}
if(!existing) {
$tw.boot.dynamicStores.push({
id: dynamicStoreId,
directory: dirPath,
saveFilter: dirSpec.dynamicStore.saveFilter || "",
watch: dirSpec.dynamicStore.watch !== false,
debounce: dirSpec.dynamicStore.debounce || 400,
filesRegExp: dirSpec.filesRegExp || "^.*$",
searchSubdirectories: !!dirSpec.searchSubdirectories,
isTiddlerFile: !!dirSpec.isTiddlerFile,
fields: dirSpec.fields || {}
});
}
}
var files = getAllFiles(dirPath, dirSpec.searchSubdirectories),
fileRegExp = new RegExp(dirSpec.filesRegExp || "^.*$"),
metaRegExp = /^.*\.meta$/;
@@ -2122,7 +2097,7 @@ $tw.loadTiddlersFromSpecification = function(filepath,excludeRegExp) {
filename = path.basename(thisPath);
if(filename !== "tiddlywiki.files" && !metaRegExp.test(filename) && fileRegExp.test(filename)) {
dirSpec.fields = dirSpec.fields || {};
processFile(thisPath,dirSpec.isTiddlerFile,dirSpec.fields,dirSpec.isEditableFile || !!dirSpec.dynamicStore,dirSpec.path,dynamicStoreId);
processFile(thisPath,dirSpec.isTiddlerFile,dirSpec.fields,dirSpec.isEditableFile,dirSpec.path);
}
}
} else {
@@ -2309,24 +2284,6 @@ $tw.loadWikiTiddlers = function(wikiPath,options) {
$tw.loadPlugins(wikiInfo.plugins,$tw.config.pluginsPath,$tw.config.pluginsEnvVar);
$tw.loadPlugins(wikiInfo.themes,$tw.config.themesPath,$tw.config.themesEnvVar);
$tw.loadPlugins(wikiInfo.languages,$tw.config.languagesPath,$tw.config.languagesEnvVar);
// Register plugin-provided tiddlerdeserializer and tiddlerserializer modules now,
// so they are available when the wiki tiddler files are read from disk below.
// We also apply the supporting `utils`, `tiddlerfield`, and `tiddlermethod`
// modules so deserializers can call into them (e.g. core's text/html
// deserializer needs $tw.utils.extractEncryptedStoreArea).
// (All of these steps run again later in execStartup; they are idempotent.)
$tw.wiki.readPluginInfo();
$tw.wiki.registerPluginTiddlers("plugin");
$tw.wiki.unpackPluginTiddlers();
$tw.wiki.defineShadowModules();
$tw.modules.applyMethods("utils",$tw.utils);
if($tw.node) {
$tw.modules.applyMethods("utils-node",$tw.utils);
}
$tw.Tiddler.fieldModules = $tw.modules.getModulesByTypeAsHashmap("tiddlerfield");
$tw.modules.applyMethods("tiddlermethod",$tw.Tiddler.prototype);
$tw.modules.applyMethods("tiddlerdeserializer",$tw.Wiki.tiddlerDeserializerModules);
$tw.modules.applyMethods("tiddlerserializer",$tw.Wiki.tiddlerSerializerModules);
// Load the wiki files, registering them as writable
var resolvedWikiPath = path.resolve(wikiPath,$tw.config.wikiTiddlersSubDir);
$tw.utils.each($tw.loadTiddlersFromPath(resolvedWikiPath),function(tiddlerFile) {
@@ -2336,8 +2293,7 @@ $tw.loadWikiTiddlers = function(wikiPath,options) {
filepath: tiddlerFile.filepath,
type: tiddlerFile.type,
hasMetaFile: tiddlerFile.hasMetaFile,
isEditableFile: config["retain-original-tiddler-path"] || tiddlerFile.isEditableFile || tiddlerFile.filepath.indexOf($tw.boot.wikiTiddlersPath) !== 0,
dynamicStoreId: tiddlerFile.dynamicStoreId || null
isEditableFile: config["retain-original-tiddler-path"] || tiddlerFile.isEditableFile || tiddlerFile.filepath.indexOf($tw.boot.wikiTiddlersPath) !== 0
};
});
}
@@ -2349,10 +2305,7 @@ $tw.loadWikiTiddlers = function(wikiPath,options) {
for(var title in $tw.boot.files) {
fileInfo = $tw.boot.files[title];
if(fileInfo.isEditableFile) {
// For tiddlers loaded from a dynamic store, compute originalpath relative to the store's directory
// so that save-time path resolution against that directory yields the correct location.
var basePath = fileInfo.dynamicStoreId || $tw.boot.wikiTiddlersPath;
relativePath = path.relative(basePath,fileInfo.filepath);
relativePath = path.relative($tw.boot.wikiTiddlersPath,fileInfo.filepath);
fileInfo.originalpath = relativePath;
output[title] =
path.sep === "/" ?
@@ -2478,8 +2431,6 @@ $tw.boot.initStartup = function(options) {
if(!$tw.boot.tasks.readBrowserTiddlers) {
// For writable tiddler files, a hashmap of title to {filepath:,type:,hasMetaFile:}
$tw.boot.files = Object.create(null);
// Array of {id, directory, saveFilter, watch, debounce} registered via tiddlywiki.files dynamicStore directives
$tw.boot.dynamicStores = [];
// System paths and filenames
$tw.boot.bootPath = options.bootPath || path.dirname(module.filename);
$tw.boot.corePath = path.resolve($tw.boot.bootPath,"../core");
@@ -2569,9 +2520,6 @@ $tw.boot.initStartup = function(options) {
// Install the tiddler deserializer modules
$tw.Wiki.tiddlerDeserializerModules = Object.create(null);
$tw.modules.applyMethods("tiddlerdeserializer",$tw.Wiki.tiddlerDeserializerModules);
// Install the tiddler serializer modules
$tw.Wiki.tiddlerSerializerModules = Object.create(null);
$tw.modules.applyMethods("tiddlerserializer",$tw.Wiki.tiddlerSerializerModules);
// Call unload handlers in the browser
if($tw.browser) {
window.onbeforeunload = function(event) {
+2 -19
View File
@@ -238,10 +238,6 @@ exports.generateTiddlerFileInfo = function(tiddler,options) {
// Save as a .tid file
fileInfo.type = "application/x-tiddler";
fileInfo.hasMetaFile = false;
} else if($tw.Wiki.tiddlerSerializerModules && $tw.Wiki.tiddlerSerializerModules[tiddlerType]) {
// A serializer is registered for this content type - save as a single self-contained file
fileInfo.type = tiddlerType;
fileInfo.hasMetaFile = false;
} else {
// Save as a text/binary file and a .meta file
fileInfo.type = tiddlerType;
@@ -420,16 +416,7 @@ Save a tiddler to a file described by the fileInfo:
*/
exports.saveTiddlerToFile = function(tiddler,fileInfo,callback) {
$tw.utils.createDirectory(path.dirname(fileInfo.filepath));
var serializer = $tw.Wiki.tiddlerSerializerModules && $tw.Wiki.tiddlerSerializerModules[fileInfo.type];
if(serializer && !fileInfo.hasMetaFile && fileInfo.type !== "application/x-tiddler" && fileInfo.type !== "application/json") {
var typeInfo = $tw.config.contentTypeInfo[fileInfo.type] || {encoding: "utf8"};
fs.writeFile(fileInfo.filepath,serializer(tiddler),typeInfo.encoding,function(err) {
if(err) {
return callback(err);
}
return callback(null,fileInfo);
});
} else if(fileInfo.hasMetaFile) {
if(fileInfo.hasMetaFile) {
// Save the tiddler as a separate body and meta file
var typeInfo = $tw.config.contentTypeInfo[tiddler.fields.type || "text/plain"] || {encoding: "utf8"};
fs.writeFile(fileInfo.filepath,tiddler.fields.text || "",typeInfo.encoding,function(err) {
@@ -471,11 +458,7 @@ Save a tiddler to a file described by the fileInfo:
*/
exports.saveTiddlerToFileSync = function(tiddler,fileInfo) {
$tw.utils.createDirectory(path.dirname(fileInfo.filepath));
var serializer = $tw.Wiki.tiddlerSerializerModules && $tw.Wiki.tiddlerSerializerModules[fileInfo.type];
if(serializer && !fileInfo.hasMetaFile && fileInfo.type !== "application/x-tiddler" && fileInfo.type !== "application/json") {
var typeInfo = $tw.config.contentTypeInfo[fileInfo.type] || {encoding: "utf8"};
fs.writeFileSync(fileInfo.filepath,serializer(tiddler),typeInfo.encoding);
} else if(fileInfo.hasMetaFile) {
if(fileInfo.hasMetaFile) {
// Save the tiddler as a separate body and meta file
var typeInfo = $tw.config.contentTypeInfo[tiddler.fields.type || "text/plain"] || {encoding: "utf8"};
fs.writeFileSync(fileInfo.filepath,tiddler.fields.text || "",typeInfo.encoding);
+5 -4
View File
@@ -9,14 +9,15 @@ Serve tiddlers over http
"use strict";
let fs, path, crypto, zlib, URL;
let fs, url, path, querystring, crypto, zlib;
if($tw.node) {
fs = require("fs"),
url = require("url"),
path = require("path"),
querystring = require("querystring"),
crypto = require("crypto"),
zlib = require("zlib");
URL = require("url").URL;
}
/*
@@ -259,8 +260,8 @@ Server.prototype.requestHandler = function(request,response,options) {
state.wiki = options.wiki || self.wiki;
state.boot = options.boot || self.boot;
state.server = self;
state.urlInfo = new URL(request.url, "http://localhost");
state.queryParameters = Object.fromEntries(state.urlInfo.searchParams);
state.urlInfo = url.parse(request.url);
state.queryParameters = querystring.parse(state.urlInfo.query);
state.pathPrefix = options.pathPrefix || this.get("path-prefix") || "";
// Enable CORS
if(this.corsEnable) {
+1 -2
View File
@@ -255,5 +255,4 @@ ViewTemplateTags/Caption: View Template Tags
ViewTemplateTags/Hint: This rule cascade is used by the default view template to dynamically choose the template for displaying the tags area of a tiddler.
WikiInformation/Caption: Wiki Information
WikiInformation/Hint: This page summarises high level information about the configuration of this ~TiddlyWiki. It is designed to enable users to quickly share relevant aspects of the configuration of their ~TiddlyWiki with others, for example when seeking help in one of the forums. No private or personal information is included, and nothing is shared without being explicitly copied and pasted elsewhere
WikiInformation/Drag/Caption: Drag this link to copy this tool to another wiki
WikiInformation/Generate/Caption: Click to generate wiki information report
WikiInformation/Drag/Caption: Drag this link to copy this tool to another wiki
+5 -4
View File
@@ -22,11 +22,12 @@ exports.tag = function(source,operator,options) {
});
} else {
// Old semantics:
var tiddlers;
if(operator.prefix === "!") {
// Returns a copy of the input if operator.operand is missing
const excludeTagSet = new Set(options.wiki.getTiddlersWithTag(operator.operand));
tiddlers = options.wiki.getTiddlersWithTag(operator.operand);
source(function(tiddler,title) {
if(!excludeTagSet.has(title)) {
if(tiddlers.indexOf(title) === -1) {
results.push(title);
}
});
@@ -38,9 +39,9 @@ exports.tag = function(source,operator,options) {
return indexedResults;
}
} else {
const includeTagSet = new Set(options.wiki.getTiddlersWithTag(operator.operand));
tiddlers = options.wiki.getTiddlersWithTag(operator.operand);
source(function(tiddler,title) {
if(includeTagSet.has(title)) {
if(tiddlers.indexOf(title) !== -1) {
results.push(title);
}
});
+2 -3
View File
@@ -88,11 +88,10 @@ BackSubIndexer.prototype.update = function(updateDescriptor) {
var newTargets = [],
oldTargets = [],
self = this;
// System tiddlers are never indexed as sources, matching the _init() scan
if(updateDescriptor.old.exists && !this.wiki.isSystemTiddler(updateDescriptor.old.tiddler.fields.title)) {
if(updateDescriptor.old.exists) {
oldTargets = this._getTarget(updateDescriptor.old.tiddler);
}
if(updateDescriptor.new.exists && !this.wiki.isSystemTiddler(updateDescriptor.new.tiddler.fields.title)) {
if(updateDescriptor.new.exists) {
newTargets = this._getTarget(updateDescriptor.new.tiddler);
}
+54 -66
View File
@@ -528,79 +528,67 @@ exports.parseAttribute = function(source,pos) {
pos = token.end;
// Skip whitespace
pos = $tw.utils.skipWhiteSpace(source,pos);
do {
// Look for a string literal
var stringLiteral = $tw.utils.parseStringLiteral(source,pos);
if(stringLiteral) {
pos = stringLiteral.end;
node.type = "string";
node.value = stringLiteral.value;
break;
}
// Look for a string literal
var stringLiteral = $tw.utils.parseStringLiteral(source,pos);
if(stringLiteral) {
pos = stringLiteral.end;
node.type = "string";
node.value = stringLiteral.value;
} else {
// Look for a filtered value
var filteredValue = $tw.utils.parseTokenRegExp(source,pos,reFilteredValue);
if(filteredValue) {
pos = filteredValue.end;
node.type = "filtered";
node.filter = filteredValue.match[1];
break;
} else {
// Look for an indirect value
var indirectValue = $tw.utils.parseTokenRegExp(source,pos,reIndirectValue);
if(indirectValue) {
pos = indirectValue.end;
node.type = "indirect";
node.textReference = indirectValue.match[1];
} else {
// Look for a macro invocation value
var macroInvocation = $tw.utils.parseMacroInvocationAsTransclusion(source,pos);
if(macroInvocation) {
pos = macroInvocation.end;
node.type = "macro";
node.value = macroInvocation;
} else {
// Look for an MVV reference value
var mvvReference = $tw.utils.parseMVVReferenceAsTransclusion(source,pos);
if(mvvReference) {
pos = mvvReference.end;
node.type = "macro";
node.value = mvvReference;
node.isMVV = true;
} else {
var substitutedValue = $tw.utils.parseTokenRegExp(source,pos,reSubstitutedValue);
if(substitutedValue) {
pos = substitutedValue.end;
node.type = "substituted";
node.rawValue = substitutedValue.match[1] || substitutedValue.match[2];
} else {
// Look for a unquoted value
var unquotedValue = $tw.utils.parseTokenRegExp(source,pos,reUnquotedAttribute);
if(unquotedValue) {
pos = unquotedValue.end;
node.type = "string";
node.value = unquotedValue.match[1];
} else if(source.charAt(pos) === "<" && source.charAt(pos + 1) === "<" && source.indexOf(">>",pos) !== -1) {
// Value looks like a macro invocation (starts with << with a closing >> ahead) but does not parse as one. Return null so the enclosing tag fails to parse rather than silently binding the attribute to "true" and treating the remainder as further attributes (restores v5.3.8 behaviour)
return null;
} else {
node.type = "string";
node.value = "true";
}
}
}
}
}
}
// Look for an indirect value
var indirectValue = $tw.utils.parseTokenRegExp(source,pos,reIndirectValue);
if(indirectValue) {
pos = indirectValue.end;
node.type = "indirect";
node.textReference = indirectValue.match[1];
break;
}
// Look for a macro invocation value
var macroInvocation = $tw.utils.parseMacroInvocationAsTransclusion(source,pos);
if(macroInvocation) {
pos = macroInvocation.end;
node.type = "macro";
node.value = macroInvocation;
break;
}
// Look for an MVV reference value
var mvvReference = $tw.utils.parseMVVReferenceAsTransclusion(source,pos);
if(mvvReference) {
pos = mvvReference.end;
node.type = "macro";
node.value = mvvReference;
node.isMVV = true;
break;
}
// Look for a substituted value
var substitutedValue = $tw.utils.parseTokenRegExp(source,pos,reSubstitutedValue);
if(substitutedValue) {
pos = substitutedValue.end;
node.type = "substituted";
node.rawValue = substitutedValue.match[1] || substitutedValue.match[2];
break;
}
// Look for a unquoted value
var unquotedValue = $tw.utils.parseTokenRegExp(source,pos,reUnquotedAttribute);
if(unquotedValue) {
pos = unquotedValue.end;
node.type = "string";
node.value = unquotedValue.match[1];
break;
}
if(source.charAt(pos) === "<" && source.charAt(pos + 1) === "<" && source.indexOf(">>",pos) !== -1) {
// Value looks like a macro invocation (starts with << with a closing >> ahead) but does not parse as one. Return null so the enclosing tag fails to parse rather than silently binding the attribute to "true" and treating the remainder as further attributes (restores v5.3.8 behaviour)
return null;
}
node.type = "string";
node.value = "true";
} while(false);
}
} else {
// If there is no equals sign or colon, then this is an attribute with no value, defaulting to "true"
node.type = "string";
-1
View File
@@ -34,7 +34,6 @@ exports.startup = function() {
$tw.modules.applyMethods("wikimethod",$tw.Wiki.prototype);
$tw.wiki.addIndexersToWiki();
$tw.modules.applyMethods("tiddlerdeserializer",$tw.Wiki.tiddlerDeserializerModules);
$tw.modules.applyMethods("tiddlerserializer",$tw.Wiki.tiddlerSerializerModules);
$tw.macros = $tw.modules.getModulesByTypeAsHashmap("macro");
$tw.wiki.initParsers();
// --------------------------
+1 -2
View File
@@ -15,8 +15,7 @@ var getCellInfo = function(text, start, length, SEPARATOR) {
var isCellQuoted = text.charAt(start) === QUOTE;
var cellStart = isCellQuoted ? start + 1 : start;
// A quote licenses a separator inside the cell, so only an unquoted cell reads an immediate separator as empty
if(!isCellQuoted && text.charAt(cellStart) === SEPARATOR) {
if(text.charAt(i) === SEPARATOR) {
return [cellStart, cellStart, false];
}
+2 -13
View File
@@ -115,9 +115,7 @@ var TW_Element = function(tag, namespace) {
this.children = [];
this._style = {}; // Internal style object
this.style = new TW_Style(this); // Proxy for style management
// createElementNS with empty-string or null normalises to null (no namespace) per spec.
// https://dom.spec.whatwg.org/#dom-document-createelementns
this.namespaceURI = namespace !== undefined ? (namespace || null) : "http://www.w3.org/1999/xhtml";
this.namespaceURI = namespace || "http://www.w3.org/1999/xhtml";
};
@@ -208,16 +206,7 @@ TW_Element.prototype.addEventListener = function(type,listener,useCapture) {
Object.defineProperty(TW_Element.prototype, "tagName", {
get: function() {
if(!this.tag) {
return "";
}
// HTML elements report uppercase tagName per DOM spec. Other namespaces
// preserve case. Fakedom only models HTML documents.
// https://dom.spec.whatwg.org/#dom-element-tagname
if(this.namespaceURI === "http://www.w3.org/1999/xhtml") {
return this.tag.toUpperCase();
}
return this.tag;
return this.tag || "";
}
});
+1 -1
View File
@@ -22,7 +22,7 @@ Inherit from the base widget class
DiffTextWidget.prototype = new Widget();
DiffTextWidget.prototype.invisibleCharacters = {
"\n": "\n",
"\n": "↩︎\n",
"\r": "⇠",
"\t": "⇥\t"
};
+3 -5
View File
@@ -4,15 +4,13 @@ description: create a new journal tiddler
\whitespace trim
\function get-tags() [<textFieldTags>] [<tagsFieldTags>] +[join[ ]]
<$let journalTitle=<<now format={{$:/config/NewJournal/Title}}>>
textFieldTags={{$:/config/NewJournal/Tags}}
tagsFieldTags={{$:/config/NewJournal/Tags!!tags}}
journalText={{$:/config/NewJournal/Text}}
>
<$let journalTitleTemplate={{$:/config/NewJournal/Title}} textFieldTags={{$:/config/NewJournal/Tags}} tagsFieldTags={{$:/config/NewJournal/Tags!!tags}} journalText={{$:/config/NewJournal/Text}}>
<$wikify name="journalTitle" text="<$transclude $variable='now' format=<<journalTitleTemplate>>/>">
<$reveal type="nomatch" state=<<journalTitle>> text="">
<$action-sendmessage $message="tm-new-tiddler" title=<<journalTitle>> tags=<<get-tags>> text={{{ [<journalTitle>get[]] }}}/>
</$reveal>
<$reveal type="match" state=<<journalTitle>> text="">
<$action-sendmessage $message="tm-new-tiddler" title=<<journalTitle>> tags=<<get-tags>> text=<<journalText>>/>
</$reveal>
</$wikify>
</$let>
+23 -16
View File
@@ -6,23 +6,30 @@ tags: $:/tags/EditTemplate
\procedure lingo-base() $:/language/EditTemplate/
\procedure tag-body-inner(colour,fallbackTarget,colourA,colourB,icon,tagField:"tags")
<$let foregroundColor=<<contrastcolour target=<<colour>> fallbackTarget=<<fallbackTarget>> colourA=<<colourA>> colourB=<<colourB>> >>
backgroundColor=<<colour>>
>
<span class="tc-tag-label tc-tag-list-item tc-small-gap-right"
data-tag-title=<<currentTiddler>>
style=`color:$(foregroundColor)$; fill:$(foregroundColor)$; background-color:$(backgroundColor)$;`
>
<$transclude tiddler=<<icon>>/>
<$view field="title" format="text"/>
<$button class="tc-btn-invisible tc-remove-tag-button"
style.fill=<<foregroundColor>>
<$wikify name="foregroundColor"
text="""<$macrocall $name="contrastcolour"
target=<<colour>>
fallbackTarget=<<fallbackTarget>>
colourA=<<colourA>>
colourB=<<colourB>>/>
"""
>
<$let backgroundColor=<<colour>> >
<span class="tc-tag-label tc-tag-list-item tc-small-gap-right"
data-tag-title=<<currentTiddler>>
style=`color:$(foregroundColor)$; fill:$(foregroundColor)$; background-color:$(backgroundColor)$;`
>
<$action-listops $tiddler=<<saveTiddler>> $field=<<tagField>> $subfilter="-[{!!title}]"/>
{{$:/core/images/close-button}}
</$button>
</span>
</$let>
<$transclude tiddler=<<icon>>/>
<$view field="title" format="text"/>
<$button class="tc-btn-invisible tc-remove-tag-button"
style.fill=<<foregroundColor>>
>
<$action-listops $tiddler=<<saveTiddler>> $field=<<tagField>> $subfilter="-[{!!title}]"/>
{{$:/core/images/close-button}}
</$button>
</span>
</$let>
</$wikify>
\end
\procedure tag-body(colour,palette,icon,tagField:"tags")
+4 -2
View File
@@ -16,13 +16,15 @@ title: $:/core/ui/TagPickerTagTemplate
<$set name="backgroundColor"
value={{{ [<currentTiddler>] :cascade[all[shadows+tiddlers]tag[$:/tags/TiddlerColourFilter]!is[draft]get[text]] }}}
>
<$let foregroundColor=<<contrastcolour target=<<backgroundColor>> fallbackTarget=<<fallbackTarget>> colourA=<<colourA>> colourB=<<colourB>> >> >
<$wikify name="foregroundColor"
text="""<$macrocall $name="contrastcolour" target=<<backgroundColor>> fallbackTarget=<<fallbackTarget>> colourA=<<colourA>> colourB=<<colourB>>/>"""
>
<span class="tc-tag-label tc-btn-invisible"
style=<<tag-pill-styles>>
data-tag-title=<<currentTiddler>>
>
{{||$:/core/ui/TiddlerIcon}}<$view field="title" format="text"/>
</span>
</$let>
</$wikify>
</$set>
</$button>
+3 -4
View File
@@ -6,6 +6,7 @@ description: {{$:/language/Buttons/NewJournalHere/Hint}}
\whitespace trim
\procedure journalButton()
<$button tooltip={{$:/language/Buttons/NewJournalHere/Hint}} aria-label={{$:/language/Buttons/NewJournalHere/Caption}} class=<<tv-config-toolbar-class>>>
<$wikify name="journalTitle" text="""<$transclude $variable="now" format=<<journalTitleTemplate>>/>""">
<$action-sendmessage $message="tm-new-tiddler" title=<<journalTitle>> tags=`[[$(currentTiddlerTag)$]] $(journalTags)$`/>
<%if [<tv-config-toolbar-icons>match[yes]] %>
{{$:/core/images/new-journal-button}}
@@ -15,11 +16,9 @@ description: {{$:/language/Buttons/NewJournalHere/Hint}}
<$text text={{$:/language/Buttons/NewJournalHere/Caption}}/>
</span>
<%endif%>
</$wikify>
</$button>
\end
<$let journalTitle=<<now format={{$:/config/NewJournal/Title}}>>
journalTags={{$:/config/NewJournal/Tags}}
currentTiddlerTag=<<currentTiddler>>
>
<$let journalTitleTemplate={{$:/config/NewJournal/Title}} journalTags={{$:/config/NewJournal/Tags}} currentTiddlerTag=<<currentTiddler>>>
<<journalButton>>
</$let>
+1 -5
View File
@@ -17,10 +17,6 @@ This page summarises high level information about the configuration of this ~Tid
Drag this link to copy this tool to another wiki
\end intrinsic-lingo-Drag/Caption
\procedure intrinsic-lingo-Generate/Caption()
Click to generate wiki information report
\end intrinsic-lingo-Generate/Caption
\procedure lingo(title,mode:"inline")
<%if [<title>addprefix<lingo-base>is[shadow]] %>
<$transclude $tiddler={{{ [<title>addprefix<lingo-base>] }}} $mode=<<mode>>/>
@@ -109,7 +105,7 @@ Click to generate wiki information report
<$button>
<<display-wiki-info-modal>>
<<lingo title:"Generate/Caption">>
Click to generate wiki information report
</$button>
<$link to="$:/core/ui/ControlPanel/WikiInformation">
+1 -1
View File
@@ -1,6 +1,6 @@
title: $:/config/OfficialPluginLibrary
tags: $:/tags/PluginLibrary
url: https://tiddlywiki.com/library/v5.5.0/index.html
url: https://tiddlywiki.com/library/v5.4.1/index.html
caption: {{$:/language/OfficialPluginLibrary}}
{{$:/language/OfficialPluginLibrary/Hint}}
@@ -1,6 +0,0 @@
title: $:/editions/dev/github-fork-ribbon
tags: $:/tags/PageTemplate
caption: ~GitHub ribbon
description: ~GitHub ribbon for tw5.com/dev
<div class="github-fork-ribbon-wrapper right" style><div class="github-fork-ribbon" style="background-color:#DF4848;"><a href="https://github.com/TiddlyWiki/TiddlyWiki5" target="_blank" rel="noopener noreferrer">Find me on ~GitHub</a></div></div>
@@ -1,6 +1,6 @@
title: $:/config/LocalPluginLibrary
tags: $:/tags/PluginLibrary
url: http://127.0.0.1:8080/prerelease/library/v5.5.0/index.html
url: http://127.0.0.1:8080/prerelease/library/v5.4.1/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.5.0/index.html
url: https://tiddlywiki.com/prerelease/library/v5.4.1/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,94 +0,0 @@
created: 20260714055110474
modified: 20260714055129225
tags:
title: TiddlyWiki Pre-release Size Comparison
\function bytes.to.mib(size) [abs[]divide[1048576]fixed[2]addsuffix[ MiB]]
\function bytes.to.kib(size) [abs[]divide[1024]fixed[2]addsuffix[ KiB]]
\function format.file.size(size) [<size>abs[]compare:number:gteq[1048576]bytes.to.mib<size>] :else[<size>abs[]compare:number:gteq[1024]bytes.to.kib<size>] :else[<size>addsuffix[ bytes]]
\procedure get-wiki-filesize(filepath)
\procedure completion-get-json()
<!-- Success -->
<$list filter="[<status>compare:number:gteq[200]compare:number:lteq[299]]" variable="ignore">
<$action-log msg="completed" size={{{ [<data>jsonget[size]] }}}/>
<$action-setfield $tiddler=`$:/temp/file-size-comparison/$(filepath)$` text={{{ [<data>jsonget[size]] }}} />
</$list>
\end completion-get-json
<$action-sendmessage
$message="tm-http-request"
url=`https://api.github.com/repos/TiddlyWiki/tiddlywiki.com-gh-pages/contents/$(filepath)$?ref=master`
method="GET"
oncompletion=<<completion-get-json>>
var-filepath=<<filepath>>
/>
\end get-wiki-filesize
\procedure get-wiki-filesizes()
<$list filter="empty.html prerelease/empty.html" variable="filepath">
<$action-log />
<<$transclude $variable="get-wiki-filesize" filepath=<<filepath>> >>
</$list>
\end get-wiki-filesizes
<%if [[$:/temp/file-size-comparison/empty.html]is[tiddler]] [[$:/temp/file-size-comparison/prerelease/empty.html]is[tiddler]] :and[count[]match[2]]%>
<$let delta={{{ [{$:/temp/file-size-comparison/empty.html}subtract{$:/temp/file-size-comparison/prerelease/empty.html}] }}}
message={{{ [<delta>sign[]match[-1]then[Size increased]] :else[<delta>sign[]match[1]then[Size decreased]] :else[[Size has not changed]] }}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="158" height="20" role="img" style.zoom="1.5" aria-label="<$text text=<<message>>/>: <$text text={{{ [format.file.size<delta>] }}} /> bytes">
<title><$text text=<<message>>/>: <$text text={{{ [format.file.size<delta>] }}} /></title>
<filter id="blur">
<feGaussianBlur stdDeviation="16"/>
</filter>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="158" height="20" rx="3"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="93" height="20" fill="#555"/>
<rect x="93" width="65" height="20" fill="#67ac09"/>
<rect width="158" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
<g transform="scale(.1)">
<g aria-hidden="true" fill="#010101">
<text x="475" y="150" fill-opacity=".8" filter="url(#blur)" textLength="830"><$text text=<<message>>/></text>
<text x="475" y="150" fill-opacity=".3" textLength="830"><$text text=<<message>>/></text>
</g>
<text x="475" y="140" textLength="830"><$text text=<<message>>/></text>
</g>
<g transform="scale(.1)">
<g aria-hidden="true" fill="#010101">
<text x="1245" y="150" fill-opacity=".8" filter="url(#blur)" textLength="550"><$text text={{{ [format.file.size<delta>] }}} /></text>
<text x="1245" y="150" fill-opacity=".3" textLength="550"><$text text={{{ [format.file.size<delta>] }}} /></text>
</g>
<text x="1245" y="140" textLength="550"><$text text={{{ [format.file.size<delta>] }}} /></text>
</g>
</g>
</svg>
; Pre-release empty.html
: <$text text={{{ [{$:/temp/file-size-comparison/prerelease/empty.html}] }}} /> bytes
; <$text text={{{ [tag[ReleaseNotes]sort[released]last[]] }}} /> empty.html
: <$text text={{{ [{$:/temp/file-size-comparison/empty.html}] }}} /> bytes
---
</$let>
<%else %>
<$button actions=<<get-wiki-filesizes>> class="tc-btn-big-green">
Compare size of empty.html
</$button>
<%endif%>
@@ -13,8 +13,6 @@ The pre-release is also available as an [[empty wiki|https://tiddlywiki.com/prer
</div>
{{ TiddlyWiki Pre-release Size Comparison }}
<$list filter="[tag[ReleaseNotes]!has[released]!sort[created]]">
<div class="tc-titlebar">
<h2 class="tc-title"><$text text=<<currentTiddler>>/></h2>
-4
View File
@@ -1,4 +0,0 @@
modified: 20260413092032887
title: TabFour
Text tab 4
-5
View File
@@ -1,5 +0,0 @@
caption: t 1
modified: 20260413092032887
title: TabOne
Text tab 1
-6
View File
@@ -1,6 +0,0 @@
caption: t 3
description: desc
modified: 20260413092032887
title: TabThree
Text tab 3
-5
View File
@@ -1,5 +0,0 @@
caption: t 2
modified: 20260413092032887
title: TabTwo
Text tab 2
-7
View File
@@ -1,7 +0,0 @@
code-body: yes
modified: 20260413092032887
title: body-template
!! <<currentTab>>
<$transclude tiddler=<<currentTab>> mode="block"/>
@@ -1,5 +0,0 @@
code-body: yes
modified: 20260413092032887
title: button-template
<$transclude tiddler=<<currentTab>> field="description"><$transclude tiddler=<<currentTab>> field="caption"><$macrocall $name="currentTab" $type="text/plain" $output="text/plain"/></$transclude></$transclude>
@@ -1,72 +0,0 @@
code-body: yes
modified: 20260413092032887
title: tabs-macro-definition
\define tabs-button()
\whitespace trim
<$button
set=<<tabsState>>
setTo=<<currentTab>>
default=<<__default__>>
selectedClass="tc-tab-selected"
selectedAria="aria-selected"
tooltip={{!!tooltip}}
role="tab"
data-tab-title=<<currentTab>>
>
<$tiddler tiddler=<<save-currentTiddler>>>
<$set name="tv-wikilinks" value="no">
<$transclude tiddler=<<__buttonTemplate__>> mode="inline">
<$transclude tiddler=<<currentTab>> field="caption">
<$macrocall $name="currentTab" $type="text/plain" $output="text/plain"/>
</$transclude>
</$transclude>
</$set>
</$tiddler>
<<__actions__>>
</$button>
\end
\define tabs-tab()
\whitespace trim
<$set name="save-currentTiddler" value=<<currentTiddler>>>
<$tiddler tiddler=<<currentTab>>>
<<tabs-button>>
</$tiddler>
</$set>
\end
\define tabs-tab-list()
\whitespace trim
<$list filter=<<__tabsList__>> variable="currentTab" storyview="pop">
<<tabs-tab>>
</$list>
\end
\define tabs-tab-body()
\whitespace trim
<$list filter=<<__tabsList__>> variable="currentTab">
<$reveal type="match" state=<<tabsState>> text=<<currentTab>> default=<<__default__>> retain=<<__retain__>> tag="div">
<$transclude tiddler=<<__template__>> mode="block">
<$transclude tiddler=<<currentTab>> mode="block"/>
</$transclude>
</$reveal>
</$list>
\end
\define tabs(tabsList,default,state:"$:/state/tab",class,template,buttonTemplate,retain,actions,explicitState)
\whitespace trim
<$qualify title=<<__state__>> name="qualifiedState">
<$let tabsState={{{ [<__explicitState__>minlength[1]] ~[<qualifiedState>] }}}>
<div class={{{ [[tc-tab-set]addsuffix[ ]addsuffix<__class__>] }}} role="tablist">
<div class={{{ [[tc-tab-buttons]addsuffix[ ]addsuffix<__class__>] }}}>
<<tabs-tab-list>>
</div>
<div class={{{ [[tc-tab-divider]addsuffix[ ]addsuffix<__class__>] }}}/>
<div class={{{ [[tc-tab-content]addsuffix[ ]addsuffix<__class__>] }}} role="tabpanel">
<<tabs-tab-body>>
</div>
</div>
</$let>
</$qualify>
\end
@@ -1,5 +0,0 @@
modified: 20260413092032887
title: test-tabs-horizontal-all
\import [[tabs-macro-definition]]
<<tabs "TabOne TabTwo TabThree TabFour" "TabTwo" "$:/state/test-tab-01" template:"body-template" buttonTemplate:"button-template" explicitState:"$:/state/explicit">>
@@ -1,5 +0,0 @@
modified: 20260413092032887
title: test-tabs-horizontal
\import [[tabs-macro-definition]]
<<tabs "TabOne TabTwo TabThree TabFour" "TabTwo" "$:/state/test-tab-01">>
@@ -1,5 +0,0 @@
modified: 20260413092032887
title: test-tabs-vertical
\import [[tabs-macro-definition]]
<<tabs "TabOne TabTwo TabThree TabFour" "TabTwo" "$:/state/test-tab-02" "tc-vertical">>
@@ -1,162 +0,0 @@
/*\
title: test-back-indexer.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Regression tests for #9916: the back-indexer must never record system
tiddlers as backlink or backtransclude sources, neither when the index is
first built nor when it is incrementally updated.
\*/
"use strict";
describe("Back-indexer system source tests (#9916)", function() {
function setupWiki() {
// Create a wiki with indexers and one primed backlink pair
var wiki = new $tw.Wiki();
wiki.addIndexersToWiki();
wiki.addTiddler({
title: "TestIncoming",
text: ""});
wiki.addTiddler({
title: "TestOutgoing",
text: "A link to [[TestIncoming]]"});
return wiki;
}
it("should never report a system tiddler as a backlink source", function() {
// Browser console: $tw.wiki.filterTiddlers("[[HelloThere]backlinks[]]") to prime the index,
// then $tw.wiki.addTiddler(new $tw.Tiddler({title: "$:/temp/demo", text: "[[HelloThere]]"}))
// and run the filter again; $:/temp/demo must not appear.
var wiki = setupWiki();
// The first lookup builds the lazy index; its initial scan skips system tiddlers
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
// The incremental update() must skip them too
wiki.addTiddler({
title: "$:/temp/system-source",
text: "A link to [[TestIncoming]]"});
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
});
it("should keep backlinks stable while a linking system tiddler is modified and deleted", function() {
// Browser console: $tw.wiki.filterTiddlers("[[HelloThere]backlinks[]]") to prime the index,
// then $tw.wiki.addTiddler(new $tw.Tiddler({title: "$:/temp/demo", text: "[[HelloThere]]"})),
// change its text, $tw.wiki.deleteTiddler("$:/temp/demo"); the filter result never changes.
var wiki = setupWiki();
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
wiki.addTiddler({
title: "$:/temp/system-source",
text: "A link to [[TestIncoming]]"});
// Modify: both the old and the new side of the index update are system tiddlers
wiki.addTiddler({
title: "$:/temp/system-source",
text: "Links to [[TestIncoming]] and [[TestOutgoing]]"});
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
wiki.deleteTiddler("$:/temp/system-source");
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
});
it("should drop the backlink when its source is renamed to a system title", function() {
// Browser console: $tw.wiki.addTiddler(new $tw.Tiddler({title: "Demo", text: "[[HelloThere]]"})),
// confirm Demo is in $tw.wiki.filterTiddlers("[[HelloThere]backlinks[]]"), then
// $tw.wiki.renameTiddler("Demo","$:/Demo"); neither Demo nor $:/Demo remains a source.
var wiki = setupWiki();
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
wiki.renameTiddler("TestOutgoing","$:/TestOutgoing");
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("");
});
it("should gain the backlink when a linking system tiddler is renamed to a normal title", function() {
// Browser console: $tw.wiki.addTiddler(new $tw.Tiddler({title: "$:/temp/demo", text: "[[HelloThere]]"})),
// then $tw.wiki.renameTiddler("$:/temp/demo","DemoVisible");
// DemoVisible now appears in $tw.wiki.filterTiddlers("[[HelloThere]backlinks[]]").
var wiki = setupWiki();
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
wiki.addTiddler({
title: "$:/temp/system-source",
text: "A link to [[TestIncoming]]"});
wiki.renameTiddler("$:/temp/system-source","VisibleSource");
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing,VisibleSource");
});
it("should still report normal sources for a system tiddler target", function() {
// Only sources are filtered, targets are not. Browser console:
// $tw.wiki.addTiddler(new $tw.Tiddler({title: "Demo", text: "[[$:/config/NewJournal/Tags]]"}));
// $tw.wiki.filterTiddlers("[[$:/config/NewJournal/Tags]backlinks[]]") contains Demo.
var wiki = setupWiki();
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
wiki.addTiddler({
title: "TestSystemLinker",
text: "A link to [[$:/config/Target]]"});
expect(wiki.filterTiddlers("[[$:/config/Target]backlinks[]]").join(",")).toBe("TestSystemLinker");
});
describe("Adversarial probes", function() {
it("should never index a shadow tiddler as a source, even when revealed by deleting its override", function() {
// Goes red when a refactor of BackSubIndexer.update() checks the tiddler instead of
// the exists flag:
// if(updateDescriptor["new"].tiddler) { ... } // broken: goes red here
// if(updateDescriptor["new"].exists) { ... } // correct: stays green
// After deleteTiddler() on an override, boot.js fills new.tiddler via getTiddler(),
// which falls back to the revealed shadow, while new.exists stays false because
// only the real store counts. The broken variant indexes the shadow's links and
// the final expect fails with "TestOutgoing,ShadowSource".
// The first expect also goes red if _init() ever starts scanning shadows; the
// middle expect pins that a real override IS indexed, so this probe cannot be
// satisfied by indexing nothing at all.
// Browser console: override a plugin shadow with text [[HelloThere]], check it appears in
// $tw.wiki.filterTiddlers("[[HelloThere]backlinks[]]"), then $tw.wiki.deleteTiddler(title);
// the title disappears from the filter result even though the shadow still renders.
var wiki = setupWiki();
wiki.addTiddler({
title: "$:/plugins/test/shadow-plugin",
type: "application/json",
"plugin-type": "plugin",
text: JSON.stringify({tiddlers: {
"ShadowSource": {title: "ShadowSource", text: "A shadow link to [[TestIncoming]]"}
}})});
wiki.readPluginInfo();
wiki.registerPluginTiddlers("plugin");
wiki.unpackPluginTiddlers();
// The initial scan sees only real tiddlers, not the shadow
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
wiki.addTiddler({
title: "ShadowSource",
text: "An overriding link to [[TestIncoming]]"});
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing,ShadowSource");
// Deleting the override reveals the shadow; it must not enter the index
wiki.deleteTiddler("ShadowSource");
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
});
it("should handle hostile titles like __proto__ as source and target", function() {
// Goes red when any title-keyed hashmap on the backlinks path is created like this:
// this.index = {}; // broken: goes red here
// instead of:
// this.index = Object.create(null); // correct: stays green
// this.index = new Map(); // a Map/Set refactor also stays green
// The same applies to the boot tiddler store and to the per-target source maps
// (self.index[target] = ...). On a plain {} the assignment index["__proto__"] = x
// stores no key; it silently replaces the object's prototype. Depending on which
// map regresses, the __proto__ tiddler never registers as a source (second expect),
// or its target entry lands in the shared prototype, where it pollutes every other
// lookup and lookup("__proto__") returns garbage (third expect).
// Browser console: $tw.wiki.addTiddler(new $tw.Tiddler({title: "__proto__",
// text: "[[HelloThere]]"})); __proto__ appears in
// $tw.wiki.filterTiddlers("[[HelloThere]backlinks[]]") and
// $tw.wiki.filterTiddlers("[[__proto__]backlinks[]]") lists tiddlers linking to it.
var wiki = setupWiki();
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing");
wiki.addTiddler({
title: "__proto__",
text: "A link to [[TestIncoming]]"});
expect(wiki.filterTiddlers("TestIncoming +[backlinks[]]").join(",")).toBe("TestOutgoing,__proto__");
wiki.addTiddler({
title: "ProtoLinker",
text: "A link to [[__proto__]]"});
expect(wiki.filterTiddlers("[[__proto__]backlinks[]]").join(",")).toBe("ProtoLinker");
});
});
});
-41
View File
@@ -1,41 +0,0 @@
/*\
title: test-csv.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests the CSV parser.
\*/
"use strict";
describe("CSV tests", function() {
it("parses a simple table", function() {
expect($tw.utils.parseCsvString("a,b,c\n1,2,3")).toEqual([["a","b","c"],["1","2","3"]]);
});
it("parses a table whose first cell reads empty", function() {
expect($tw.utils.parseCsvString(",b,c\n1,2,3")).toEqual([["","b","c"],["1","2","3"]]);
});
it("parses an empty cell in the middle of a row", function() {
expect($tw.utils.parseCsvString("a,,c")).toEqual([["a","","c"]]);
});
it("parses quoted cells", function() {
expect($tw.utils.parseCsvString('a,"b,still b",c')).toEqual([["a","b,still b","c"]]);
});
it("parses a quoted cell holding an escaped quote", function() {
expect($tw.utils.parseCsvString('a,"b ""quoted""",c')).toEqual([["a",'b "quoted"',"c"]]);
});
it("honours a custom separator", function() {
expect($tw.utils.parseCsvString("a;b;c",{separator: ";"})).toEqual([["a","b","c"]]);
});
it("parses a table with a header row into hashmaps", function() {
expect($tw.utils.parseCsvStringWithHeader("a,b\n1,2")).toEqual([{a: "1", b: "2"}]);
});
});
@@ -19,56 +19,6 @@ describe("fakedom tests", function() {
expect($tw.fakeDocument.createTextNode("text").TEXT_NODE).toBe(3);
});
// Per DOM spec, tagName returns the HTML-uppercased qualified name for HTML
// elements. Other namespaces preserve case.
// https://dom.spec.whatwg.org/#dom-element-tagname
var HTML_NS = "http://www.w3.org/1999/xhtml";
var SVG_NS = "http://www.w3.org/2000/svg";
it("tagName uppercases for HTML namespace", function() {
// Default namespace is HTML
expect($tw.fakeDocument.createElement("div").tagName).toBe("DIV");
// The exact predicate the select widget relies on (#9839)
expect($tw.fakeDocument.createElement("optgroup").tagName === "OPTGROUP").toBe(true);
// Already-uppercase input stays uppercase
expect($tw.fakeDocument.createElement("OPTGROUP").tagName).toBe("OPTGROUP");
// Mixed-case input is normalised
expect($tw.fakeDocument.createElement("Div").tagName).toBe("DIV");
// Hyphenated custom-element names uppercase whole tag, hyphens survive
expect($tw.fakeDocument.createElement("my-button").tagName).toBe("MY-BUTTON");
// Empty tag returns empty string
expect($tw.fakeDocument.createElement("").tagName).toBe("");
// Explicit HTML namespace via createElementNS uppercases the same way
expect($tw.fakeDocument.createElementNS(HTML_NS,"Div").tagName).toBe("DIV");
});
it("tagName preserves case for non-HTML namespaces", function() {
// SVG: lowercase preserved
expect($tw.fakeDocument.createElementNS(SVG_NS,"circle").tagName).toBe("circle");
// SVG: camelCase preserved (linearGradient is the canonical example)
expect($tw.fakeDocument.createElementNS(SVG_NS,"linearGradient").tagName).toBe("linearGradient");
// SVG: already-uppercase input is also preserved (NOT lowercased)
expect($tw.fakeDocument.createElementNS(SVG_NS,"DIV").tagName).toBe("DIV");
// Empty namespace string is "no namespace", not HTML. Case preserved.
expect($tw.fakeDocument.createElementNS("","div").tagName).toBe("div");
});
it("tagName reflects current state without mutating it", function() {
// Reading tagName must not overwrite the internal `tag` field
var el = $tw.fakeDocument.createElement("div");
expect(el.tagName).toBe("DIV");
expect(el.tag).toBe("div");
// Idempotent: two reads return identical values
var first = el.tagName, second = el.tagName;
expect(first).toBe(second);
// Dynamic namespace change is reflected. The getter must read current
// state, not a value cached at construction time.
var dynamic = $tw.fakeDocument.createElement("foo");
expect(dynamic.tagName).toBe("FOO");
dynamic.namespaceURI = SVG_NS;
expect(dynamic.tagName).toBe("foo");
});
// 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.
@@ -1,179 +0,0 @@
/*\
title: test-filesystem-dynamic-store.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests for the filesystem syncadaptor dynamic store feature: save routing
driven by saveFilter, and chokidar-based watching of out-of-band edits.
\*/
"use strict";
if($tw.node) {
var fs = require("fs"),
path = require("path"),
os = require("os");
// Load the filesystem adaptor source as if it were a TW module, so that
// $tw is provided without having to include the plugin in the edition
// (which would pull in the server-side syncer and keep the test runner alive).
var adaptorPath = path.resolve($tw.boot.bootPath,"..","plugins","tiddlywiki","filesystem","filesystemadaptor.js"),
adaptorTitle = "$:/plugins/tiddlywiki/filesystem/filesystemadaptor.js";
if(!$tw.modules.titles[adaptorTitle]) {
$tw.modules.titles[adaptorTitle] = {
moduleType: "syncadaptor",
definition: fs.readFileSync(adaptorPath,"utf8")
};
$tw.wiki.addTiddler({
title: adaptorTitle,
type: "application/javascript",
"module-type": "syncadaptor",
text: ""
});
}
var FileSystemAdaptor = $tw.modules.execute(adaptorTitle).adaptorClass;
function makeTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(),prefix));
}
function removeDirRecursive(dir) {
if(fs.existsSync(dir)) {
fs.rmSync(dir,{recursive: true, force: true});
}
}
describe("filesystem dynamic store", function() {
var tmpRoot, wikiTiddlers, storeDir, origDynamicStores, origFiles, originalBootPath;
var adaptor, wiki;
beforeEach(function() {
tmpRoot = makeTempDir("tw-dyn-");
wikiTiddlers = path.join(tmpRoot,"tiddlers");
storeDir = path.join(tmpRoot,"content");
fs.mkdirSync(wikiTiddlers);
fs.mkdirSync(storeDir);
origDynamicStores = $tw.boot.dynamicStores;
origFiles = $tw.boot.files;
originalBootPath = $tw.boot.wikiTiddlersPath;
$tw.boot.dynamicStores = [{
id: storeDir,
directory: storeDir,
saveFilter: "[type[text/x-markdown]]",
watch: true,
debounce: 40,
filesRegExp: ".*\\.tid$",
searchSubdirectories: false,
isTiddlerFile: true,
fields: {}
}];
$tw.boot.files = Object.create(null);
$tw.boot.wikiTiddlersPath = wikiTiddlers;
wiki = new $tw.Wiki();
adaptor = new FileSystemAdaptor({wiki: wiki, boot: $tw.boot});
});
afterEach(function(done) {
adaptor.close().then(function() {
$tw.boot.dynamicStores = origDynamicStores;
$tw.boot.files = origFiles;
$tw.boot.wikiTiddlersPath = originalBootPath;
removeDirRecursive(tmpRoot);
done();
});
});
it("routes saves for matching tiddlers into the dynamic store directory", function(done) {
wiki.addTiddler(new $tw.Tiddler({title: "note1", type: "text/x-markdown", text: "hello"}));
var tiddler = wiki.getTiddler("note1");
adaptor.getTiddlerFileInfo(tiddler,function(err,fileInfo) {
expect(err).toBeFalsy();
expect(fileInfo.filepath.indexOf(storeDir)).toBe(0);
expect(fileInfo.dynamicStoreId).toBe(storeDir);
done();
});
});
it("routes saves for non-matching tiddlers into the default wiki tiddlers path", function(done) {
wiki.addTiddler(new $tw.Tiddler({title: "note2", type: "text/vnd.tiddlywiki", text: "plain"}));
var tiddler = wiki.getTiddler("note2");
adaptor.getTiddlerFileInfo(tiddler,function(err,fileInfo) {
expect(err).toBeFalsy();
expect(fileInfo.filepath.indexOf(wikiTiddlers)).toBe(0);
expect(fileInfo.dynamicStoreId).toBeFalsy();
done();
});
});
it("keeps saving a tiddler into the store it originally came from", function(done) {
// Simulate a tiddler that was loaded at boot from the dynamic store
$tw.boot.files["frozen"] = {
filepath: path.join(storeDir,"frozen.tid"),
type: "application/x-tiddler",
hasMetaFile: false,
isEditableFile: true,
dynamicStoreId: storeDir
};
// Its current type no longer matches the saveFilter — store id must still win
wiki.addTiddler(new $tw.Tiddler({title: "frozen", type: "text/vnd.tiddlywiki", text: "still here"}));
adaptor.getTiddlerFileInfo(wiki.getTiddler("frozen"),function(err,fileInfo) {
expect(err).toBeFalsy();
expect(fileInfo.filepath.indexOf(storeDir)).toBe(0);
expect(fileInfo.dynamicStoreId).toBe(storeDir);
done();
});
});
// Note: the chokidar watcher's only job is to call processFileEvent in
// response to fs events. We invoke processFileEvent directly here so the
// tests don't depend on real fs notifications being delivered (some CI
// sandboxes do not propagate inotify events to chokidar).
it("processes external additions, changes and deletions", function(done) {
var store = $tw.boot.dynamicStores[0];
var filepath = path.join(storeDir,"external.tid");
fs.writeFileSync(filepath,"title: external\ntype: text/x-markdown\n\nInitial\n");
adaptor.processFileEvent(store,filepath,"change");
adaptor.getUpdatedTiddlers({},function(err,updates) {
expect(err).toBeFalsy();
expect(updates.modifications).toContain("external");
adaptor.loadTiddler("external",function(err,fields) {
expect(err).toBeFalsy();
expect(fields).toBeTruthy();
expect(fields.title).toBe("external");
expect(fields.text).toContain("Initial");
// Edit
fs.writeFileSync(filepath,"title: external\ntype: text/x-markdown\n\nChanged\n");
adaptor.processFileEvent(store,filepath,"change");
adaptor.getUpdatedTiddlers({},function(err,updates) {
expect(updates.modifications).toContain("external");
// Delete
fs.unlinkSync(filepath);
adaptor.processFileEvent(store,filepath,"unlink");
adaptor.getUpdatedTiddlers({},function(err,updates) {
expect(updates.deletions).toContain("external");
done();
});
});
});
});
});
it("suppresses echoes when the file on disk matches the current wiki tiddler", function(done) {
var store = $tw.boot.dynamicStores[0];
wiki.addTiddler(new $tw.Tiddler({title: "echo", type: "text/x-markdown", text: "same\n"}));
var filepath = path.join(storeDir,"echo.tid");
fs.writeFileSync(filepath,"title: echo\ntype: text/x-markdown\n\nsame\n");
adaptor.processFileEvent(store,filepath,"change");
adaptor.getUpdatedTiddlers({},function(err,updates) {
expect(updates.modifications).not.toContain("echo");
done();
});
});
});
}
+1 -1
View File
@@ -1062,7 +1062,7 @@ describe("Filter tests", function() {
});
it("should handle the deserializers operator", function() {
var expectedDeserializers = ["application/javascript","application/json","application/x-tiddler","application/x-tiddler-html-div","application/x-tiddlers","text/css","text/html","text/markdown","text/plain","text/x-markdown"];
var expectedDeserializers = ["application/javascript","application/json","application/x-tiddler","application/x-tiddler-html-div","application/x-tiddlers","text/css","text/html","text/plain"];
if($tw.browser) {
expectedDeserializers.unshift("(DOM)");
}
@@ -1,301 +0,0 @@
/*\
title: test-markdown-frontmatter.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests for the markdown plugin's YAML frontmatter parser, deserializer,
and serializer.
\*/
/* eslint-env node, browser, jasmine */
/* eslint no-mixed-spaces-and-tabs: ["error", "smart-tabs"]*/
"use strict";
describe("markdown YAML frontmatter", function() {
var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
var deserializer = require("$:/plugins/tiddlywiki/markdown/frontmatter-deserializer.js");
var serializer = require("$:/plugins/tiddlywiki/markdown/frontmatter-serializer.js");
// --- YAML parser ---
describe("yaml.load scalars", function() {
it("parses null forms", function() {
expect(yaml.load("null")).toBe(null);
expect(yaml.load("~")).toBe(null);
expect(yaml.load("")).toBe(null);
});
it("parses booleans", function() {
expect(yaml.load("true")).toBe(true);
expect(yaml.load("True")).toBe(true);
expect(yaml.load("false")).toBe(false);
});
it("parses numbers", function() {
expect(yaml.load("42")).toBe(42);
expect(yaml.load("-7")).toBe(-7);
expect(yaml.load("3.14")).toBe(3.14);
expect(yaml.load("1e10")).toBe(1e10);
expect(yaml.load("0xFF")).toBe(255);
expect(yaml.load("0o17")).toBe(15);
});
it("parses special floats", function() {
expect(yaml.load(".inf")).toBe(Infinity);
expect(yaml.load("-.inf")).toBe(-Infinity);
});
it("parses quoted strings", function() {
expect(yaml.load('"hello world"')).toBe("hello world");
expect(yaml.load("'hello world'")).toBe("hello world");
expect(yaml.load('"line1\\nline2"')).toBe("line1\nline2");
});
it("parses plain strings", function() {
expect(yaml.load("hello")).toBe("hello");
});
it("rejects non-strings", function() {
expect(function() { yaml.load(123); }).toThrowError(yaml.YAMLException);
});
});
describe("yaml.load flow collections", function() {
it("parses flow sequences", function() {
expect(yaml.load("[a, b, c]")).toEqual(["a","b","c"]);
expect(yaml.load("[1, 2, 3]")).toEqual([1,2,3]);
expect(yaml.load('[1, "two", true, null]')).toEqual([1,"two",true,null]);
expect(yaml.load("[]")).toEqual([]);
expect(yaml.load('["multi word", simple]')).toEqual(["multi word","simple"]);
});
it("parses flow mappings", function() {
expect(yaml.load("{a: 1, b: 2}")).toEqual({a:1,b:2});
expect(yaml.load("{}")).toEqual({});
});
});
describe("yaml.load block collections", function() {
it("parses simple block mappings", function() {
expect(yaml.load("title: Hello\ntags: foo bar\nrating: 6")).toEqual({
title: "Hello",
tags: "foo bar",
rating: 6
});
});
it("parses block mapping with flow array value", function() {
expect(yaml.load("title: Test\ntags: [concept, synthesis, multi word tag]")).toEqual({
title: "Test",
tags: ["concept","synthesis","multi word tag"]
});
});
it("parses block mapping with quoted value", function() {
expect(yaml.load('title: "A: Subtitle"')).toEqual({title: "A: Subtitle"});
});
it("parses block mapping with null value", function() {
expect(yaml.load("title: Test\ndescription:")).toEqual({
title: "Test",
description: null
});
});
it("parses block sequences", function() {
expect(yaml.load("- alpha\n- beta\n- gamma")).toEqual(["alpha","beta","gamma"]);
expect(yaml.load("- 1\n- two\n- true")).toEqual([1,"two",true]);
});
it("parses nested block mappings", function() {
expect(yaml.load("outer:\n inner: value\n count: 3")).toEqual({
outer: {inner: "value", count: 3}
});
});
it("parses block mapping with block sequence value", function() {
expect(yaml.load("title: Test\ntags:\n - concept\n - synthesis")).toEqual({
title: "Test",
tags: ["concept","synthesis"]
});
});
it("ignores comments and blank lines", function() {
expect(yaml.load("# comment\ntitle: Test\n# more\nrating: 5")).toEqual({
title: "Test",
rating: 5
});
});
});
describe("yaml.dump", function() {
it("dumps simple mappings", function() {
expect(yaml.dump({title: "Hello", rating: 6}).trim()).toBe("title: Hello\nrating: 6");
});
it("dumps arrays", function() {
expect(yaml.dump({tags: ["a","b"]}).trim()).toBe("tags:\n - a\n - b");
});
it("dumps null and booleans", function() {
expect(yaml.dump({x: null}).trim()).toBe("x: null");
expect(yaml.dump({x: true, y: false}).trim()).toBe("x: true\ny: false");
});
it("dumps empty containers", function() {
expect(yaml.dump({}).trim()).toBe("{}");
expect(yaml.dump({x: []}).trim()).toBe("x: []");
});
it("quotes string values that look like numbers", function() {
expect(yaml.dump({rating: "9"}).trim()).toBe('rating: "9"');
});
});
// --- Deserializer ---
describe("frontmatter deserializer", function() {
var ds = deserializer["text/x-markdown"];
it("extracts simple frontmatter into fields", function() {
var result = ds("---\ntitle: Foo\ntags: [a, b]\n---\n\nBody text.",{});
expect(result.length).toBe(1);
expect(result[0].title).toBe("Foo");
expect(result[0].tags).toBe("a b");
expect(result[0].text).toBe("Body text.");
expect(result[0].type).toBe("text/x-markdown");
});
it("converts YAML arrays for list fields to TW bracketed lists", function() {
var result = ds("---\ntags: [concept, multi word tag, simple]\n---\n\nbody",{});
expect(result[0].tags).toBe("concept [[multi word tag]] simple");
});
it("falls back to plain body when no frontmatter present", function() {
var result = ds("Just a body, no frontmatter.",{});
expect(result[0].text).toBe("Just a body, no frontmatter.");
expect(result[0].title).toBeUndefined();
});
it("falls back to plain body when frontmatter is malformed", function() {
var result = ds("---\nnot: [valid yaml: at all\n---\n\nbody",{});
// Malformed YAML still parses something; we just ensure body is set
expect(result[0].text).toBeDefined();
});
it("parses ISO-8601 created and modified into TW native format", function() {
var result = ds("---\ntitle: T\ncreated: 2025-01-02T03:04:05.006Z\nmodified: 2026-02-03T04:05:06Z\n---\n\nb",{});
expect(result[0].created).toBe("20250102030405006");
expect(result[0].modified).toBe("20260203040506000");
});
it("accepts a bare YYYY-MM-DD date for created/modified", function() {
var result = ds("---\ntitle: T\ncreated: 2025-03-15\n---\n\nb",{});
expect(result[0].created).toBe("20250315000000000");
});
it("passes through TW native timestamps for created/modified", function() {
var result = ds("---\ntitle: T\ncreated: \"20250101000000000\"\nmodified: \"20260101000000\"\n---\n\nb",{});
expect(result[0].created).toBe("20250101000000000");
expect(result[0].modified).toBe("20260101000000000");
});
it("drops unparseable created/modified values", function() {
var result = ds("---\ntitle: T\ncreated: not-a-date\n---\n\nb",{});
expect(result[0].created).toBeUndefined();
});
it("merges existing tags with frontmatter tags", function() {
var result = ds("---\ntags: [b, c]\n---\n\nbody",{tags: "a"});
// Order: existing first, then new uniques
expect(result[0].tags).toBe("a b c");
});
it("emits non-string non-array values as JSON", function() {
var result = ds("---\ntitle: T\nmeta: {nested: deep}\n---\n\nb",{});
expect(result[0].meta).toBe('{"nested":"deep"}');
});
it("handles CRLF line endings around frontmatter", function() {
var result = ds("---\r\ntitle: T\r\n---\r\n\r\nbody",{});
expect(result[0].title).toBe("T");
expect(result[0].text).toBe("body");
});
});
// --- Serializer ---
describe("frontmatter serializer", function() {
var ser = serializer["text/x-markdown"];
it("emits frontmatter and body", function() {
var t = new $tw.Tiddler({title: "Foo", text: "body", tags: "a b"});
var out = ser(t);
expect(out).toContain("---\n");
expect(out).toContain("title: Foo");
expect(out).toContain("tags:\n - a\n - b");
expect(out.split("\n---\n\n")[1]).toBe("body");
});
it("emits list fields as YAML arrays preserving multi-word tags", function() {
var t = new $tw.Tiddler({title: "X", tags: "concept [[multi word tag]] simple", text: "b"});
var out = ser(t);
expect(out).toContain("- concept");
expect(out).toContain("- multi word tag");
expect(out).toContain("- simple");
});
it("skips text, bag, revision", function() {
var t = new $tw.Tiddler({
title: "X",
text: "body",
bag: "default",
revision: "1"
});
var out = ser(t);
expect(out).not.toContain("bag:");
expect(out).not.toContain("revision:");
expect(out).not.toContain("text:");
});
it("emits created and modified as ISO-8601 strings", function() {
var t = new $tw.Tiddler({
title: "X",
text: "b",
created: "20250102030405006",
modified: "20260203040506000"
});
var out = ser(t);
expect(out).toContain('created: "2025-01-02T03:04:05.006Z"');
expect(out).toContain('modified: "2026-02-03T04:05:06.000Z"');
});
it("drops unparseable created/modified values", function() {
var t = new $tw.Tiddler({title: "X", text: "b", created: "garbage"});
var out = ser(t);
expect(out).not.toContain("created:");
});
it("skips type when it equals text/x-markdown", function() {
var t = new $tw.Tiddler({title: "X", type: "text/x-markdown", text: "b"});
expect(ser(t)).not.toContain("type:");
});
it("emits type when it differs from text/x-markdown", function() {
var t = new $tw.Tiddler({title: "X", type: "text/html", text: "b"});
expect(ser(t)).toContain("type: text/html");
});
it("emits no frontmatter when only skipped fields are present", function() {
var t = new $tw.Tiddler({text: "body only"});
expect(ser(t)).toBe("body only");
});
it("returns empty string for null tiddler", function() {
expect(ser(null)).toBe("");
});
it("title appears first in output", function() {
var t = new $tw.Tiddler({title: "Z", rating: "9", tags: "a", text: "b"});
var out = ser(t);
var lines = out.split("\n");
// First line is "---", second should be "title: Z"
expect(lines[0]).toBe("---");
expect(lines[1]).toBe("title: Z");
});
});
// --- Round-trip ---
describe("frontmatter round-trip", function() {
var ds = deserializer["text/x-markdown"];
var ser = serializer["text/x-markdown"];
it("preserves title, tags, and body across deserialize → serialize", function() {
var input = "---\ntitle: My Tiddler\ntags: [concept, synthesis]\nrating: \"7\"\n---\n\nThis is the body.";
var fields = ds(input,{})[0];
var t = new $tw.Tiddler(fields);
var out = ser(t);
var reparsed = ds(out,{})[0];
expect(reparsed.title).toBe("My Tiddler");
expect(reparsed.tags).toBe("concept synthesis");
expect(reparsed.rating).toBe("7");
expect(reparsed.text).toBe("This is the body.");
});
it("preserves created and modified across deserialize → serialize", function() {
var input = "---\ntitle: T\ncreated: 2025-01-02T03:04:05.006Z\nmodified: 2026-02-03T04:05:06.007Z\n---\n\nbody";
var fields = ds(input,{})[0];
var t = new $tw.Tiddler(fields);
var out = ser(t);
var reparsed = ds(out,{})[0];
expect(reparsed.created).toBe("20250102030405006");
expect(reparsed.modified).toBe("20260203040506007");
});
});
});
+1 -2
View File
@@ -3,8 +3,7 @@
"plugins": [
"tiddlywiki/jasmine",
"tiddlywiki/wikitext-serialize",
"tiddlywiki/geospatial",
"tiddlywiki/markdown"
"tiddlywiki/geospatial"
],
"themes": [
"tiddlywiki/vanilla",
@@ -1,18 +1,22 @@
created: 20150106180000000
modified: 20241204085601176
list: GroupedLists/Example/TypesTab GroupedLists/Example/ByField GroupedLists/Example/WithSearch GroupedLists/Example/WithTabs GroupedLists/Example/TabsWithSearch GroupedLists/Example/SearchAnotherField GroupedLists/Example/RecentTab
modified: 20260729194941115
tags: ListWidget Lists
title: GroupedLists
type: text/vnd.tiddlywiki
The following sidebar tabs give examples of grouped lists created by nesting.
A grouped list is made by nesting one list inside another. The outer list collects each distinct value of a field, and the inner list finds the tiddlers holding that value.
!! [[Types Tab|$:/core/ui/MoreSideBar/Types]]
TiddlyWiki uses the pattern in two of its sidebar tabs. The [[Types Tab|$:/core/ui/MoreSideBar/Types]] groups tiddlers by their `type` field, and sits under ''More'' in the sidebar. The [[Recent Tab|$:/core/ui/SideBar/Recent]] groups them by day, through the <<.mlink timeline>> macro. Both are reproduced as examples below.
For the "Types Tab", the outer list filter as shown below selects each discrete value found in the `type` field. The inner list filter selects all the (non-system) tiddlers with that type.
!! Interactive Examples
<<tw-code "$:/core/ui/MoreSideBar/Types">>
Each example is a runnable test case carrying its own sample data. Where a field is grouped on, it is named once in <<.def groupField>>, so the grouping can be changed without touching anything else.
!! [[Recent Tab|$:/core/ui/SideBar/Recent]]
<<<
<$list filter="[tag[GroupedLists]]">
The list in the "Recent Tab" is generated using the <<.mlink timeline>> macro. Here, the outer list filter selects each discrete day found in the `modified` field, while the inner list filter selects all the tiddlers dated the same day in the `modified` field.
<<tw-code "$:/core/macros/timeline">>
; <$link/>
: {{!!description}}
</$list>
<<<
@@ -0,0 +1,31 @@
title: ButtonWidget/Example/Accessibility
description: Add accessibility and metadata attributes such as aria-label, role, tabindex and data
modified: 20260724235815000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
A button can carry accessibility and metadata attributes that pass straight through to the HTML element. They have no visible effect, so this example shows the markup rather than a result:
* `aria-label` gives an accessible name when the label is only an icon
* `role` overrides the implied ARIA role
* `tabindex` sets the keyboard focus order
* `data-*` attaches custom data attributes for scripts
* `selectedAria` chooses which ARIA state `selectedClass` toggles
Inspect the button in your browser to see the attributes on the element.
+
title: Output
<$wikify name="button-accessibility" mode="inline" output="html"
text="""<$button aria-label="Close"
role="button"
tabindex="0"
data-action="close"
>
×
</$button>
""">
<$text text=<<button-accessibility>>/>
</$wikify>
@@ -0,0 +1,33 @@
title: ButtonWidget/Example/Actions
description: Run one or more ActionWidgets when the button is clicked, using the preferred actions attribute
modified: 20260725015330000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
The `actions` attribute holds one or more ActionWidgets that run when the button is clicked. This is the preferred way to give a button its behaviour, because the whole behaviour sits in one place instead of being spread over several attributes.
A button runs `actions` last, after `to`, `message`, `popup` and `set`. ButtonWidget lists the full order. A single `<$button to="HelloThere">Click Me</$button>` still needs no action string.
The procedure below runs //two// actions in a single click:
* one sets a message
* the other bumps a counter
Click ''Greet'' a few times to watch both update.
+
title: Output
\procedure greetActions()
<$action-setfield $tiddler="$:/state/greeting" text="Hello!"/>
<$action-setfield $tiddler="$:/state/greeting-count"
text={{{ [[$:/state/greeting-count]get[text]] :else[[0]] +[add[1]] }}}
/>
\end
<$button actions=<<greetActions>>>
Greet
</$button>
Message: <$text text={{$:/state/greeting}}/> (clicked <$text text={{{ [[$:/state/greeting-count]get[text]] :else[[0]] }}}/> times)
@@ -0,0 +1,34 @@
title: ButtonWidget/Example/DragAndDrop
description: Make a button draggable with dragTiddler or dragFilter
modified: 20260724235815000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
`dragTiddler` makes the button draggable and sets the single tiddler it carries. `dragFilter` carries a list of tiddlers produced by a filter instead.
Choose one of the two:
* `dragTiddler` is a single tiddler title to drag
* `dragFilter` is a filter whose results are dragged as a list
Drag the button onto the drop area to see the title it carries. Drag and drop needs a desktop browser.
+
title: Output
\procedure recordDrop()
<$action-setfield $tiddler="$:/state/dropped" text=<<actionTiddler>>/>
\end
<$button dragTiddler="HelloThere" class="tc-btn-invisible tc-tiddlylink">
Drag me
</$button>
<$droppable actions=<<recordDrop>>>
<div style="border: 1px dashed #999; padding: 0.5em; margin-top: 0.5em;">
Drop the button here
</div>
</$droppable>
Dropped title: <$text text={{$:/state/dropped}}/>
@@ -0,0 +1,54 @@
title: ButtonWidget/Example/DragFilter
description: Drag a list of tiddlers with dragFilter and receive it with listActions
modified: 20260725001110000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
`dragFilter` makes the button draggable and carries every tiddler the filter returns, where `dragTiddler` carries a single title.
The drop target chooses how it receives that payload:
* `actions` runs once per dragged tiddler, with the title in `<<actionTiddler>>`
* `listActions` runs once for the whole payload, with all titles in `<<actionTiddlerList>>`
This test case carries four payload tiddlers tagged `Concepts`. The button drags all four, so the drop area shows the whole list at once. Drag and drop needs a desktop browser.
+
title: Cascades
tags: Concepts
A cascade is a filter list that is evaluated in turn until one returns a result.
+
title: ColourPalettes
tags: Concepts
A colour palette is a tiddler holding the named colours used by the user interface.
+
title: Commands
tags: Concepts
Commands are the operations run by the Node.js server from the command line.
+
title: Filters
tags: Concepts
A filter is an expression that selects a list of tiddler titles from the wiki.
+
title: Output
\procedure recordDropList()
<$action-setfield $tiddler="$:/state/dropped-list" text=<<actionTiddlerList>>/>
\end
<$button dragFilter="[tag[Concepts]]" class="tc-btn-invisible tc-tiddlylink">
Drag four tiddlers
</$button>
<$droppable listActions=<<recordDropList>>>
<div style="border: 1px dashed #999; padding: 0.5em; margin-top: 0.5em;">
Drop the button here
</div>
</$droppable>
Dropped titles: <$text text={{$:/state/dropped-list}}/>
@@ -0,0 +1,30 @@
title: ButtonWidget/Example/Message
description: Send a widget message with the message and param attributes
modified: 20260724235815000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
The `message` attribute sends a widget message when the button is clicked. The `param` attribute passes a single value with it.
The two attributes work together:
* `message` is the message type to send
* `param` is the value delivered with the message
A `messagecatcher` receives the message here and shows the parameter. Click the button to send it.
+
title: Output
\procedure receiveMessage()
<$action-setfield $tiddler="$:/state/received" text=<<event-param>>/>
\end
<$messagecatcher $tm-sample-message=<<receiveMessage>>>
<$button message="tm-sample-message" param="Hello from param">
Send message
</$button>
</$messagecatcher>
Received param: <$text text={{$:/state/received}}/>
@@ -0,0 +1,42 @@
title: ButtonWidget/Example/NewTiddler
description: Create a tiddler from a template with the tm-new-tiddler message
modified: 20260725002340000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
`tm-new-tiddler` is a message the core already handles, so the button needs no catcher of its own.
* `message` is set to `tm-new-tiddler`
* `param` names a template tiddler, and the new tiddler starts out with that template's fields
This test case carries a `TaskTemplate` payload tiddler holding `tags`, `status` and `priority`. Open the ''TaskTemplate'' tab above to see it.
The message creates a draft and normally opens it for editing. A test case has no story river, so the table lists the drafts instead. Each click adds one row, and the new tiddler takes the next free title because the previous draft still exists.
+
title: TaskTemplate
tags: Task
status: open
priority: normal
Describe the task here.
+
title: Output
<$button message="tm-new-tiddler" param="TaskTemplate">
New task
</$button>
<table>
<tr><th>Click</th><th>New tiddler</th><th>tags</th><th>status</th><th>priority</th></tr>
<$list filter="[has[draft.title]sort[draft.title]]" counter="clickNumber">
<tr>
<td><<clickNumber>></td>
<td><$text text={{!!draft.title}}/></td>
<td><$text text={{!!tags}}/></td>
<td><$text text={{!!status}}/></td>
<td><$text text={{!!priority}}/></td>
</tr>
</$list>
</table>
@@ -0,0 +1,38 @@
created: 20260724134149482
description: Toggle a popup with the popup attribute and a companion reveal widget
modified: 20260725003054000
tags: $:/tags/wiki-test-spec
title: ButtonWidget/Example/Popup
type: text/vnd.tiddlywiki-multiple
title: Narrative
The `popup` attribute names a state tiddler that stores the popup coordinates. A companion `reveal` widget reads that state to show or hide the content.
The button uses these attributes:
* `popup` is the state tiddler for the popup
* `selectedClass` highlights the button while the popup is open
* `popupAbsCoords` writes absolute coordinates when set to `yes`, rather than relative ones
Use `popupTitle` in place of `popup` when the state title should not be read as a text reference. Click the button to toggle the popup.
The `reveal` widget always creates a DOM element of its own: a `div` in block mode, a `span` in inline mode. The `tag` attribute sets that element explicitly. Wrapping the popup content in a `<div>` would add a second element, so this example sets `tag="div"` and puts the `tc-drop-down` class on the widget itself. The popup is then one element instead of two.
+
title: Output
<$button popup="$:/state/popup/demo" selectedClass="tc-selected">
Toggle popup
</$button>
<$reveal type="popup"
tag="div"
state="$:/state/popup/demo"
position="belowleft"
animate="yes"
class="tc-drop-down"
>
This is the popup content.
</$reveal>
@@ -0,0 +1,41 @@
title: ButtonWidget/Example/Presentation
description: Change the rendered element and appearance with tag, class, style and disabled
modified: 20260725003716000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
Several attributes control how the button looks and which element it renders:
* `tag` renders a different HTML element in place of `button`
* `class` adds one or more CSS classes
* `style` sets a full CSS style string
* `disabled` set to `yes` disables the button
Reach for `class` first and keep the rules in a stylesheet. One class restyles every button that uses it, follows the colour palette and stays under the user's control. A hardcoded `style` overrides the theme and has to be repeated at every button, so use it only when no class can do the job.
`style` sets the whole style attribute and replaces anything a `style.*` attribute wrote before it, so do not mix the two.
+
title: Output
<$button tag="a" class="tc-tiddlylink">
tag="a" renders a link element
</$button>
<$button class="tc-btn-big-green">
class uses a rule from the stylesheet
</$button>
<$button disabled="yes">
disabled="yes"
</$button>
<$button style.color="green">
style is the last resort
</$button>
<$button style="font-weight: bold;">
style is the last resort
</$button>
@@ -0,0 +1,52 @@
title: ButtonWidget/Example/SelectedState
description: Highlight the active button with selectedClass, set, setTo and default used together
modified: 20260724235815000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
This example combines four attributes so a row of buttons can show which colour is active:
* `set` and `setTo` write the chosen colour to a state tiddler
* `selectedClass` marks the button whose `setTo` matches the current value of `set`
* `default` gives the value to compare against while the `set` tiddler does not yet exist
Because `default` is `red`, the ''Red'' button starts out selected. Click another colour to move the selection.
+
title: Output
<!-- The inline <style> below is only for this self-contained test/development example.
For production, define styles in a tiddler tagged $:/tags/Stylesheet instead. -->
<style>
.demo-swatch {
padding: 0.2em 0.7em;
}
.demo-selected {
outline: 2px solid #1a73e8;
}
</style>
<$button set="$:/state/colour" setTo="red"
default="red"
class="demo-swatch tc-small-gap-right"
selectedClass="demo-selected"
>
Red
</$button>
<$button set="$:/state/colour" setTo="green"
default="red"
class="demo-swatch tc-small-gap-right"
selectedClass="demo-selected"
>
Green
</$button>
<$button set="$:/state/colour" setTo="blue"
default="red"
class="demo-swatch tc-small-gap-right"
selectedClass="demo-selected"
>
Blue
</$button>
Selected colour: <$text text={{{ [[$:/state/colour]get[text]] :else[[red]] }}}/>
@@ -0,0 +1,24 @@
title: ButtonWidget/Example/SetAndSetTo
description: Assign a value to a tiddler with the set and setTo attributes used together
modified: 20260724235815000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
`set` and `setTo` work as a pair to assign a value when the button is clicked:
* `set` names the storage location, a TextReference
* `setTo` is the value written to it
`set` points at the location //itself//, so it takes no curly brackets, unlike an ordinary transclusion.
Click a colour to write it to the state tiddler shown below.
+
title: Output
<$button set="$:/state/colour" setTo="red">Red</$button>
<$button set="$:/state/colour" setTo="green">Green</$button>
<$button set="$:/state/colour" setTo="blue">Blue</$button>
Selected colour: <$text text={{$:/state/colour}}/>
@@ -0,0 +1,37 @@
title: ButtonWidget/Example/SetField
description: Assign a field or index directly with setTitle, setField and setIndex
modified: 20260724235815000
tags: [[$:/tags/wiki-test-spec]]
type: text/vnd.tiddlywiki-multiple
title: Narrative
The `set` attribute treats its value as a TextReference, which is ambiguous when a title contains `!!` or `##`. The `setTitle` group names the target directly instead.
The attributes work together:
* `setTitle` is the tiddler to change, with no TextReference parsing
* `setField` is the field to write, defaulting to `text`
* `setIndex` writes a data index instead of a field
* `setTo` is the value to assign
Click a button to set the `caption` field, then watch it update below.
+
title: Output
<$button setTitle="$:/state/demo"
setField="caption"
setTo="First"
class="tc-small-gap-right"
>
Set caption to First
</$button>
<$button setTitle="$:/state/demo"
setField="caption"
setTo="Second"
class="tc-small-gap-right"
>
Set caption to Second
</$button>
Caption is now: <$text text={{$:/state/demo!!caption}}/>
@@ -0,0 +1,173 @@
code-body: yes
created: 20260728220000000
description: Bibliography sample data shared by the GroupedLists examples
modified: 20260729180306417
title: GroupedLists/Bibliography
type: text/vnd.tiddlywiki-multiple
title: 3DWiki2011
bibtex-entry-type: InProceedings
bibtex-author: 3D Wiki Collective
bibtex-title: Spatial Notes in Three Dimensions
bibtex-date: 2011
+
title: 3rdWave2014
bibtex-entry-type: Article
bibtex-author: 3rd Wave Research Group
bibtex-title: Note Taking After the Desktop
bibtex-date: 2014
+
title: Aronsson2002
bibtex-entry-type: InProceedings
bibtex-author: Aronsson, Lars
bibtex-title: Operation of a Large Scale, General Purpose Wiki Website
bibtex-date: 2002
+
title: Atkinson2016
bibtex-entry-type: Article
bibtex-author: Atkinson, Paul
bibtex-title: Digital ethnographies
bibtex-date: 2016
+
title: Bakshi2016
bibtex-entry-type: Article
bibtex-author: Bakshi, Divya
bibtex-title: Hypertext and Feminisms: Voicing the Silence
bibtex-date: 2016
+
title: Barker2008
bibtex-entry-type: InProceedings
bibtex-author: Barker, Philip
bibtex-title: Using wikis and weblogs to enhance human performance
bibtex-date: 2008
+
title: Barker2008a
bibtex-entry-type: InProceedings
bibtex-author: Barker, Philip
bibtex-title: Using Wikis for Knowledge Management
bibtex-date: 2008
+
title: Bernstein2016
bibtex-entry-type: Book
bibtex-author: Bernstein, Mark
bibtex-title: Getting Started With Hypertext Narrative
bibtex-date: 2016
+
title: Dalgaard2001
bibtex-entry-type: InProceedings
bibtex-author: Dalgaard, Rune
bibtex-title: Hypertext and the Scholarly Archive: Intertexts, Paratexts and Metatexts at Work
bibtex-date: 2001
+
title: Dickinson2008
bibtex-entry-type: InProceedings
bibtex-author: Dickinson, Anne
bibtex-title: Is the e-Learning Object Create Interactive Accessible e-Learning Accessible?
bibtex-date: 2008
+
title: Finnemann2016
bibtex-entry-type: Article
bibtex-author: Finnemann, Niels Ole
bibtex-title: Hypertext configurations: Genres in networked digital media
bibtex-date: 2016
+
title: Frumkin2005
bibtex-entry-type: Article
bibtex-author: Frumkin, Jeremy
bibtex-title: The wiki and the digital library
bibtex-date: 2005
+
title: Maier2016
bibtex-entry-type: InCollection
bibtex-author: Maier, Carmen Daniela
bibtex-title: Hypertext and Hypermedia
bibtex-date: 2016
+
title: Morris2007
bibtex-entry-type: InProceedings
bibtex-author: Morris, Joseph C.
bibtex-title: DistriWiki: a distributed peer-to-peer wiki network
bibtex-date: 2007
+
title: Ruston2004
bibtex-entry-type: Book
bibtex-author: Ruston, Jeremy
bibtex-title: TiddlyWiki
bibtex-date: 2004
+
title: Rutherford2009
bibtex-entry-type: Thesis
bibtex-author: Rutherford, Jayne
bibtex-title: Graphical Input for TiddlyWiki
bibtex-date: 2009
+
title: Schaffert2006
bibtex-entry-type: InProceedings
bibtex-author: Schaffert, Sebastian
bibtex-title: IkeWiki: A semantic wiki for collaborative knowledge management
bibtex-date: 2006
+
title: Shang2016
bibtex-entry-type: Article
bibtex-author: Shang, Hui-Fang
bibtex-title: Online metacognitive strategies, hypermedia annotations, and motivation on hypertext comprehension
bibtex-date: 2016
+
title: Shang2016a
bibtex-entry-type: Article
bibtex-author: Shang, Hui-Fang
bibtex-title: Exploring demographic and motivational factors associated with hypertext reading by English as a foreign language students
bibtex-date: 2016
+
title: Skiba2005
bibtex-entry-type: Article
bibtex-author: Skiba, Diane J.
bibtex-title: Do your students wiki?
bibtex-date: 2005
+
title: Trentin2009
bibtex-entry-type: Article
bibtex-author: Trentin, Guglielmo
bibtex-title: Using a wiki to evaluate individual contribution to a collaborative learning project
bibtex-date: 2009
+
title: Truman2016
bibtex-entry-type: Article
bibtex-author: Truman, Gail
bibtex-title: Web Archiving Environmental Scan
bibtex-date: 2016
+
title: Wagner2004
bibtex-entry-type: Article
bibtex-author: Wagner, Christian
bibtex-title: Wiki: A technology for conversational knowledge management and group collaboration
bibtex-date: 2004
+
title: Wilson2007
bibtex-entry-type: Article
bibtex-author: Wilson, Tom D.
bibtex-title: Review of TiddlyWiki 2.1.3
bibtex-date: 2007
@@ -0,0 +1,45 @@
created: 20260728220100000
description: Group tiddlers by the value of a field chosen in one place
import-compound: [[GroupedLists/Bibliography]]
modified: 20260729185240642
tags: $:/tags/wiki-test-spec GroupedLists
title: GroupedLists/Example/ByField
type: text/vnd.tiddlywiki-multiple
title: Narrative
`groupField` names the field to group by. It is the only line to change when you want a different grouping.
Two nested lists do the work:
* `f.valuesAll` collects each distinct value of that field, sorted
* `f.tiddlersFor` returns the tiddlers holding one of those values
This test case imports 24 bibliography tiddlers from the `GroupedLists/Bibliography` payload. They came from a ~BibTeX import, so the field is `bibtex-author` rather than `author`.
Barker and Shang each published twice, so their groups list two titles. Set `groupField` to `bibtex-entry-type` to regroup the same data by publication type.
+
title: Output
\whitespace trim
<!-- bibtex-author<value> would make the field name the operator, which cannot be a variable, so f.tiddlersFor compares via get<groupField> -->
<!-- No trailing whitespace after the field name: it becomes part of the name and the list silently renders nothing -->
\procedure groupField() bibtex-author
\function f.valuesAll() [all[tiddlers]!is[system]has<groupField>each<groupField>get<groupField>sort[]]
\function f.tiddlersFor(value) [!is[system]has<groupField>] :filter[get<groupField>match<value>] +[sort[title]]
<$list filter="[f.valuesAll[]]" variable="_value">
<div class="tc-menu-list-item">
<<_value>>
<$list filter="[f.tiddlersFor<_value>]">
<div class="tc-menu-list-subitem">
<$link><$text text={{{ [<currentTiddler>get[bibtex-title]] :else[<currentTiddler>] }}}/></$link>
</div>
</$list>
</div>
</$list>
@@ -0,0 +1,51 @@
title: GroupedLists/Example/RecentTab
created: 20260729190100000
modified: 20260729190100000
description: Group tiddlers by day with the timeline macro, as the sidebar Recent tab does
tags: [[$:/tags/wiki-test-spec]] GroupedLists
type: text/vnd.tiddlywiki-multiple
title: Narrative
The `timeline` macro groups tiddlers by the day found in a date field. The ''Recent'' tab in the sidebar is a single call to it, which is all the Output below is.
The nesting happens inside the macro:
* the outer filter uses `eachday` to yield one tiddler per distinct day
* the inner filter uses `sameday` to collect every tiddler falling on that day
* `dateField` chooses which field to read, so the same macro groups by `created` just as well as by `modified`
The five dummy payload tiddlers below carry hand set `modified` dates. Nothing else here has a date field, so they are exactly what the timeline finds.
Pass `format` to change how each day is headed, and `limit` to cap how many tiddlers are considered.
+
title: Output
\whitespace trim
<$transclude $variable="timeline" format="DDth MMM YYYY"/>
+
title: Alpha
modified: 20260103120000000
Modified on the third, later in the day.
+
title: Beta
modified: 20260103100000000
Modified on the third, earlier in the day.
+
title: Gamma
modified: 20260102120000000
Modified on the second, later in the day.
+
title: Delta
modified: 20260102100000000
Modified on the second, earlier in the day.
+
title: Epsilon
modified: 20260101120000000
Modified on the first.
@@ -0,0 +1,116 @@
created: 20260728224000000
description: Group by one field while the search box filters on another
import-compound: [[GroupedLists/Bibliography]]
modified: 20260729190424885
tags: $:/tags/wiki-test-spec GroupedLists
title: GroupedLists/Example/SearchAnotherField
type: text/vnd.tiddlywiki-multiple
title: Narrative
`searchField` names the field the search box looks at, which no longer has to be the field the list groups by.
Three definitions carry the split:
* `groupField` is `bibtex-entry-type`, so the groups and the tabs are publication types
* `searchField` is `bibtex-author`, so typing still matches people
* `f.tiddlersMatching` narrows the tiddlers first, and the groups are derived from whatever survives
Because the groups are computed after the search, a whole group disappears once none of its entries match. The tab strip is still built from the unfiltered data, so the tabs themselves stay put.
This test case carries the same `_TabButton` and `_TabContent` payload tiddlers as the previous example.
Type `bar` to leave only ''InProceedings'', holding Barker's two papers. The other tabs drop to zero and show the `emptyMessage` of the list.
+
title: Output
\whitespace trim
<!-- Filter operator suffixes cannot be variables, so search:<searchField> is impossible.
f.tiddlersMatching searches the extracted value as a title, which works whichever field it came from. -->
<!-- No trailing whitespace after the field name: it becomes part of the name and the list silently renders nothing -->
\procedure groupField() bibtex-entry-type
\procedure searchField() bibtex-author
\procedure searchLabel() Author
\function f.valuesAll() [all[tiddlers]!is[system]has<groupField>each<groupField>get<groupField>sort[]]
\function f.tiddlersMatching() [all[tiddlers]!is[system]has<groupField>] :filter[get<searchField>search:title{$:/temp/GroupedLists/search-other}]
\function f.valuesFound() [f.tiddlersMatching[]each<groupField>get<groupField>sort[]]
\function f.letters() [f.valuesAll[]] :map[uppercase[]split[]first[]] +[unique[]sort[]]
\function f.valuesFor(letter) [f.valuesFound[]] :filter[uppercase[]split[]first[]match<letter>]
\function f.valuesShown(letter) [f.valuesFound[]] :filter[<letter>match[All]] :else[f.valuesFor<letter>]
\function f.tiddlersFor(value) [f.tiddlersMatching[]] :filter[get<groupField>match<value>] +[sort[title]]
<!-- The inline style below is only for this self-contained example. For production, define styles in a tiddler tagged $:/tags/Stylesheet instead. -->
<style>.demo-group-filter { margin-bottom: 1em; }</style>
<$macrocall
$name="tabs"
tabsList="[[All]] [f.letters[]]"
default="All"
state="$:/state/GroupedLists/tab-other"
buttonTemplate="_TabButton"
template="_TabContent"
/>
+
title: _TabButton
code-body: yes
\whitespace trim
<$text text=<<currentTab>>/><span class="tc-tiny-gap-left">(<$count filter="[f.valuesShown<currentTab>]"/>)</span>
+
title: _TabContent
code-body: yes
\whitespace trim
<!-- tm-focus-selector takes the first match in the document, so .tc-search input would hit the sidebar search. -->
<div class="tc-search demo-group-filter">
<label>
<<searchLabel>>:<$edit-text
tiddler="$:/temp/GroupedLists/search-other"
tag="input"
type="search"
default=""
placeholder={{{ [[Filter by ]addsuffix<searchLabel>] }}}
class="tc-tiny-gap"
/>
</label>
<%if [[$:/temp/GroupedLists/search-other]get[text]minlength[1]] %>
<$button
class="tc-btn-invisible"
tooltip="Clear the filter"
aria-label="Clear the filter"
>
<$action-deletetiddler $tiddler="$:/temp/GroupedLists/search-other"/>
<$action-sendmessage $message="tm-focus-selector" $param=".demo-group-filter input"/>
{{$:/core/images/close-button}}
</$button>
<%endif%>
</div>
<$list filter="[f.valuesShown<currentTab>]" variable="_value">
<$list-empty>
//No matches//
</$list-empty>
<div class="tc-menu-list-item">
<<_value>>
<$list filter="[f.tiddlersFor<_value>]">
<div class="tc-menu-list-subitem">
<$link><$text text={{{ [<currentTiddler>get[bibtex-title]] :else[<currentTiddler>] }}}/></$link>
</div>
</$list>
</div>
</$list>
@@ -0,0 +1,111 @@
created: 20260728220400000
description: Combine the tab strip with a search box inside the tab panel
import-compound: [[GroupedLists/Bibliography]]
modified: 20260729190424885
tags: $:/tags/wiki-test-spec GroupedLists
title: GroupedLists/Example/TabsWithSearch
type: text/vnd.tiddlywiki-multiple
title: Narrative
`_TabContent` holds the search box as well as the list, so the search sits inside the panel and filters within the selected tab.
Which values each definition reads is the whole design:
* `f.letters` reads the //unfiltered// values, so tabs never disappear while you type and the selected tab cannot be stranded on an empty panel
* `f.valuesShown` reads the //filtered// values, so the counts follow the search and a tab showing zero tells you not to look there
This test case carries the same `_TabButton` and `_TabContent` payload tiddlers as the previous example, with the search box added to the panel.
The input is a sibling of the list rather than a child of anything that recomputes, which is what keeps the cursor in the box while you type.
Type `ba` and watch the counts change. Any tab whose count falls to zero shows the `emptyMessage` of the list instead of nothing at all.
+
title: Output
\whitespace trim
<!-- Filter operator suffixes cannot be variables, so search:<groupField> and bibtex-author<value> are impossible.
f.valuesFound searches the collected values as titles, f.tiddlersFor compares via get<groupField>. -->
<!-- No trailing whitespace after the field name: it becomes part of the name and the list silently renders nothing -->
\procedure groupField() bibtex-author
\procedure groupLabel() Author
\function f.valuesAll() [all[tiddlers]!is[system]has<groupField>each<groupField>get<groupField>sort[]]
\function f.valuesFound() [f.valuesAll[]] :filter[search:title{$:/temp/GroupedLists/search}]
\function f.letters() [f.valuesAll[]] :map[uppercase[]split[]first[]] +[unique[]sort[]]
\function f.valuesFor(letter) [f.valuesFound[]] :filter[uppercase[]split[]first[]match<letter>]
\function f.valuesShown(letter) [f.valuesFound[]] :filter[<letter>match[All]] :else[f.valuesFor<letter>]
\function f.tiddlersFor(value) [!is[system]has<groupField>] :filter[get<groupField>match<value>] +[sort[title]]
<!-- The inline style below is only for this self-contained example. For production, define styles in a tiddler tagged $:/tags/Stylesheet instead. -->
<style>.demo-group-filter { margin-bottom: 1em; }</style>
<$macrocall
$name="tabs"
tabsList="[[All]] [f.letters[]]"
default="All"
state="$:/state/GroupedLists/tab-search"
buttonTemplate="_TabButton"
template="_TabContent"
/>
+
title: _TabButton
code-body: yes
\whitespace trim
<$text text=<<currentTab>>/><span class="tc-tiny-gap-left">(<$count filter="[f.valuesShown<currentTab>]"/>)</span>
+
title: _TabContent
code-body: yes
\whitespace trim
<!-- tm-focus-selector takes the first match in the document, so .tc-search input would hit the sidebar search. -->
<div class="tc-search demo-group-filter">
<label>
<<groupLabel>>:<$edit-text
tiddler="$:/temp/GroupedLists/search"
tag="input"
type="search"
default=""
placeholder={{{ [[Filter by ]addsuffix<groupLabel>] }}}
class="tc-tiny-gap"
/>
</label>
<%if [[$:/temp/GroupedLists/search]get[text]minlength[1]] %>
<$button
class="tc-btn-invisible"
tooltip="Clear the filter"
aria-label="Clear the filter"
>
<$action-deletetiddler $tiddler="$:/temp/GroupedLists/search"/>
<$action-sendmessage $message="tm-focus-selector" $param=".demo-group-filter input"/>
{{$:/core/images/close-button}}
</$button>
<%endif%>
</div>
<$list filter="[f.valuesShown<currentTab>]" variable="_value">
<$list-empty>
//No matches//
</$list-empty>
<div class="tc-menu-list-item">
<<_value>>
<$list filter="[f.tiddlersFor<_value>]">
<div class="tc-menu-list-subitem">
<$link><$text text={{{ [<currentTiddler>get[bibtex-title]] :else[<currentTiddler>] }}}/></$link>
</div>
</$list>
</div>
</$list>
@@ -0,0 +1,60 @@
title: GroupedLists/Example/TypesTab
created: 20260729190000000
modified: 20260729190000000
description: Group tiddlers by their type, as the sidebar Types tab does
tags: [[$:/tags/wiki-test-spec]] GroupedLists
type: text/vnd.tiddlywiki-multiple
title: Narrative
`$:/core/Filters/TypedTiddlers` collects one tiddler per distinct type, and the inner list then finds every tiddler sharing that type.
The two lists do different jobs:
* the outer filter ends in `each[type]`, so it yields one representative tiddler per type rather than every tiddler
* the inner filter reads `{!!type}` from that representative, so `currentTiddler` is what carries the type from the outer list to the inner one
This is the code behind the ''Types'' tab in the sidebar, under ''More''. Open that tab to see it running over a real wiki.
The five dummy payload tiddlers below are the only ones here with a `type` field, so they are exactly what the list finds.
+
title: Output
\whitespace trim
<$list filter={{$:/core/Filters/TypedTiddlers!!filter}}>
<div class="tc-menu-list-item">
<$view field="type"/>
<$list filter="[type{!!type}!is[system]sort[title]]">
<div class="tc-menu-list-subitem">
<$link to={{!!title}}><$view field="title"/></$link>
</div>
</$list>
</div>
</$list>
+
title: Alpha
type: image/svg+xml
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7"/></svg>
+
title: Beta
type: image/svg+xml
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect x="1" y="1" width="14" height="14"/></svg>
+
title: Gamma
type: text/plain
A plain text sample.
+
title: Delta
type: text/plain
Another plain text sample.
+
title: Epsilon
type: application/json
{"sample": true}
@@ -0,0 +1,82 @@
created: 20260728220200000
description: Narrow a grouped list with a search box
import-compound: [[GroupedLists/Bibliography]]
modified: 20260729185240642
tags: $:/tags/wiki-test-spec GroupedLists
title: GroupedLists/Example/WithSearch
type: text/vnd.tiddlywiki-multiple
title: Narrative
`search:title` filters the values collected by `f.valuesAll`, so the grouped list narrows while you type without the tiddlers themselves being searched.
Two definitions are added to the plain grouped list:
* `groupLabel` is the human readable name shown beside the input
* `f.valuesFound` applies the search to the collected values
An empty box is a no operation, so the whole list comes back. The clear button appears only once there is something to clear, and puts the cursor back in the input.
Type `ann` to match Dickinson and Finnemann, `wi` to match several at once, or `zzz` to fall through to the `emptyMessage` of the outer list.
+
title: Output
\whitespace trim
<!-- Filter operator suffixes cannot be variables, so search:<groupField> and bibtex-author<value> are impossible.
f.valuesFound searches the collected values as titles, f.tiddlersFor compares via get<groupField>. -->
<!-- tm-focus-selector takes the first match in the document, so .tc-search input would hit the sidebar search. -->
<!-- No trailing whitespace after the field name: it becomes part of the name and the list silently renders nothing -->
\procedure groupField() bibtex-author
\procedure groupLabel() Author
\function f.valuesAll() [all[tiddlers]!is[system]has<groupField>each<groupField>get<groupField>sort[]]
\function f.valuesFound() [f.valuesAll[]] :filter[search:title{$:/temp/GroupedLists/search}]
\function f.tiddlersFor(value) [!is[system]has<groupField>] :filter[get<groupField>match<value>] +[sort[title]]
<!-- The inline style below is only for this self-contained example. For production, define styles in a tiddler tagged $:/tags/Stylesheet instead. -->
<style>.demo-group-filter { margin-bottom: 1em; }</style>
<div class="tc-search demo-group-filter">
<label>
<<groupLabel>>:<$edit-text
tiddler="$:/temp/GroupedLists/search"
tag="input"
type="search"
default=""
placeholder={{{ [[Filter by ]addsuffix<groupLabel>] }}}
class="tc-tiny-gap"
/>
</label>
<%if [[$:/temp/GroupedLists/search]get[text]minlength[1]] %>
<$button
class="tc-btn-invisible"
tooltip="Clear the filter"
aria-label="Clear the filter"
>
<$action-deletetiddler $tiddler="$:/temp/GroupedLists/search"/>
<$action-sendmessage $message="tm-focus-selector" $param=".demo-group-filter input"/>
{{$:/core/images/close-button}}
</$button>
<%endif%>
</div>
<$list filter="[f.valuesFound[]]" variable="_value">
<$list-empty>
//No matches//
</$list-empty>
<div class="tc-menu-list-item">
<<_value>>
<$list filter="[f.tiddlersFor<_value>]">
<div class="tc-menu-list-subitem">
<$link><$text text={{{ [<currentTiddler>get[bibtex-title]] :else[<currentTiddler>] }}}/></$link>
</div>
</$list>
</div>
</$list>
@@ -0,0 +1,76 @@
created: 20260728220300000
description: Bucket the groups into tabs by first character, with a count per tab
import-compound: [[GroupedLists/Bibliography]]
modified: 20260729190424885
tags: $:/tags/wiki-test-spec GroupedLists
title: GroupedLists/Example/WithTabs
type: text/vnd.tiddlywiki-multiple
title: Narrative
The `tabs` macro takes its tab list from a filter, so here the tabs come out of the data: one per distinct first character, plus an ''All'' tab.
Three definitions feed it:
* `f.letters` collects the distinct first characters
* `f.valuesFor` selects the groups filed under one character
* `f.valuesShown` returns everything for ''All'', one character otherwise
This test case carries two payload tiddlers, `_TabButton` and `_TabContent`, because the `tabs` macro transcludes its caption and its panel by tiddler title and cannot take inline wikitext.
Matching is case insensitive, so `de Vries` and `De Vries` would share a tab. A character that is not a letter gets its own tab, which is why the sample data produces a `3` tab. Each caption carries the number of groups behind it, so no tab is a dead end.
+
title: Output
\whitespace trim
<!-- bibtex-author<value> would make the field name the operator, which cannot be a variable, so f.tiddlersFor compares via get<groupField> -->
<!-- No trailing whitespace after the field name: it becomes part of the name and the list silently renders nothing -->
\procedure groupField() bibtex-author
\function f.valuesAll() [all[tiddlers]!is[system]has<groupField>each<groupField>get<groupField>sort[]]
\function f.letters() [f.valuesAll[]] :map[uppercase[]split[]first[]] +[unique[]sort[]]
\function f.valuesFor(letter) [f.valuesAll[]] :filter[uppercase[]split[]first[]match<letter>]
\function f.valuesShown(letter) [f.valuesAll[]] :filter[<letter>match[All]] :else[f.valuesFor<letter>]
\function f.tiddlersFor(value) [!is[system]has<groupField>] :filter[get<groupField>match<value>] +[sort[title]]
<$macrocall
$name="tabs"
tabsList="[[All]] [f.letters[]]"
default="All"
state="$:/state/GroupedLists/tab"
buttonTemplate="_TabButton"
template="_TabContent"
/>
+
title: _TabButton
code-body: yes
\whitespace trim
<$text text=<<currentTab>>/><span class="tc-tiny-gap-left">(<$count filter="[f.valuesShown<currentTab>]"/>)</span>
+
title: _TabContent
code-body: yes
\whitespace trim
<$list filter="[f.valuesShown<currentTab>]" variable="_value">
<$list-empty>
//No matches//
</$list-empty>
<div class="tc-menu-list-item">
<<_value>>
<$list filter="[f.tiddlersFor<_value>]">
<div class="tc-menu-list-subitem">
<$link><$text text={{{ [<currentTiddler>get[bibtex-title]] :else[<currentTiddler>] }}}/></$link>
</div>
</$list>
</div>
</$list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 47 KiB

@@ -4,7 +4,7 @@ created: 20131219100608529
delivery: DIY
description: Flexible hosting on your own machine or in the cloud
method: sync
modified: 20221115230831173
modified: 20260712162730421
tags: Saving [[TiddlyWiki on Node.js]] Windows Mac Linux
title: Installing TiddlyWiki on Node.js
type: text/vnd.tiddlywiki
@@ -39,4 +39,39 @@ The `-g` flag causes TiddlyWiki to be installed globally. Without it, TiddlyWiki
<<.warning "If you are using Debian or Debian-based Linux and you are receiving a `node: command not found` error though node.js package is installed, you may need to create a symbolic link between `nodejs` and `node`. Consult your distro's manual and `whereis` to correctly create a link. See github [[issue 1434|http://github.com/TiddlyWiki/TiddlyWiki5/issues/1434]]. <br><br>Example Debian v8.0: `sudo ln -s /usr/bin/nodejs /usr/bin/node`">>
<br>
<<.tip "You can also install prior versions like this: <br><code> npm install -g tiddlywiki@5.1.13</code>">>
<<.tip "You can also install prior versions like this: <br><code> npm install -g tiddlywiki@5.1.13</code><br>Note: this will overwrite the installed version rather than installing it alongside.">>
!! Installing multiple Node.js verions alongside
There are multiple options:
!!! Local install
```
mkdir -p /home/user/opt/tiddlywiki-5.1.13 && cd "$_" # works in Bash
npm install tiddlywiki@5.1.13
```
This installs the given version in the current directory, at the expense of taking some extra disk space. The binary is in `node_modules/.bin/tiddlywiki` (relative to the current directory). For convenience, it is possible to create a symlink (in Linux) containing explicit version number in the name, like this:
```
ln -s /home/user/opt/tiddlywiki-5.1.13/node_modules/.bin/tiddlywiki /home/user/bin/tiddlywiki-5.1.13
```
Having `~/bin` in `PATH` environment variable (usually at the beginning of it), makes it possible to start this version of TiddlyWiki as simple as `tiddlywiki-5.1.13`.
!!! Use `npx`
To run another version without overwriting the currently installed version, use npx:
```
npx tiddlywiki@5.3.8 --version
```
<<.warning "This comes with a security risk, since it downloads and executes a package from the internet. See https://talk.tiddlywiki.org/t/tiddlywiki-docs-gettingstarted-node-js/15423/4">>
!!! Run directly from the source code repository
# clone https://github.com/TiddlyWiki/TiddlyWiki5/ locally
# checkout any version (using `git checkout` or `git switch` for modern versions of Git)
# set up a link or a shell script for it
@@ -56,11 +56,6 @@ Directory specifications in the `directories` array may take the following forms
** ''isEditableFile'' - <<.from-version "5.1.23">> (optional) if `true`, changes to the tiddler be saved back to the original file. The tiddler will be saved back to the original filepath as long as it does not generate a result from the $:/config/FileSystemPath filters, which will override the final filepath generated if a result is returned from a filter.
** ''searchSubdirectories'' - <<.from-version "5.1.23">> (optional) if `true`, all subdirectories of the //path// are searched recursively for files that match the (optional) //filesRegExp//. If no //filesRegExp// is provided, all files in all subdirectories of the //path// are loaded. Tiddler titles generated via the //source// attribute //filename// (see above) will only include the filename, not any of the subdirectories of the path. If this results in multiple files with loaded with the same tiddler title, then only the last file loaded under that tiddler title will be in memory. In order to prevent this, you can use the //filepath// attribute instead of //filename//. Alternately, you can include multiple directory objects and customise the title field with a //prefix// or //suffix// alongside the //source// attribute.
** ''fields'' - (required) an object containing values that override or customise the fields provided in the tiddler file (see above)
** ''dynamicStore'' - <<.from-version "5.5.0">> (optional) an object marking the directory as a //dynamic store// that is both loaded at boot and actively watched on disk. The filesystem syncadaptor uses the watcher to pick up out-of-band changes (e.g. edits made by an external editor) and folds them into the running wiki. The object has the following properties:
*** ''saveFilter'' - (optional) a filter evaluated against each tiddler the wiki tries to save. Tiddlers that match are saved back into this directory instead of the default tiddlers folder. The first matching dynamic store wins, so specificity matters when multiple stores are registered.
*** ''watch'' - (optional, defaults to `true`) set to `false` to disable the chokidar watcher for this store (tiddlers are still loaded and save-routed, but external changes are not picked up live)
*** ''debounce'' - (optional, defaults to `400`) the per-file debounce window in milliseconds. File events that occur within this window of a previous event for the same file are coalesced, which avoids duplicated reloads during atomic-rename saves performed by many editors.
*** Changes to a file on disk are diffed against the tiddler currently in the wiki before being reported, so self-writes performed by TiddlyWiki itself do not trigger spurious reload events. JavaScript module tiddlers (tiddlers with `type: application/javascript` and a `module-type` field) are never hot-reloaded, because that would require restarting the TiddlyWiki process.
Fields can also be overridden for particular files by creating a file with the same name plus the suffix `.meta` -- see TiddlerFiles.
@@ -140,29 +135,6 @@ From the examples in [[Customising Tiddler File Naming]] we see that the final `
Then, the `[tag[.txt]then[.txt]]` filter in the $:/config/FileSystemExtensions tiddler forces all these tiddlers to be saved back to disk as *.txt and accompanying *.txt.meta files (overriding the normal tiddler-type to file-type mapping). In this case, allowing the snippets of Tiddlywiki wikitext or markdown-text to be saved back to "text" *.txt files.
!! Dynamic store for markdown files
<<.from-version "5.5.0">> This example treats a sibling `content/` folder as a dynamic store: every `.md` file is loaded at boot, every markdown tiddler saved by the wiki is routed back into `content/`, and external edits to those files are picked up automatically by chokidar.
```
{
"directories": [
{
"path": "../content",
"filesRegExp": "^.*\\.md$",
"isTiddlerFile": true,
"searchSubdirectories": true,
"dynamicStore": {
"saveFilter": "[type[text/x-markdown]]",
"watch": true,
"debounce": 400
},
"fields": {}
}
]
}
```
!! Importing and auto-tagging images
This example imports all the image files in the `files` directory and all its subdirectories as external-image tiddlers, and tags them based on their filepath. Each tiddler is set up with the following fields:
@@ -0,0 +1,20 @@
title: $:/changenotes/5.4.0/#8972/impacts/parenthesised-field-names
changenote: $:/changenotes/5.4.0/#8972
created: 20260609193636000
modified: 20260609193636000
tags: $:/tags/ImpactNote
impact-type: compatibility-break
description: A field name containing round brackets can no longer be used as a filter operator suffix, because `(` now starts a multi value variable operand.
Two filter operators take a field name in the suffix position: `regexp` and `search`. Up to 5.3.8 such a field name could contain round brackets, for example:
```
[regexp:_cd-work(s)[(?i)suite]]
[search:_cd-work(s)[suite]]
```
In 5.4.0 the new `(varname)` operand syntax for [[Multi-Valued Variables]] makes `(` start an operand. The parser now splits such a field name at the `(`, so the filter no longer matches.
This will not be fixed. The two readings of `field(x)`, a field named `field(x)` versus the field `field` with the operand `(x)`, are genuinely ambiguous, so `(` and `)` are reserved in filter operator names and suffixes from 5.4.0 onwards.
Workaround: do not use `(` or `)` in field names that are referenced as a filter operator suffix. Rename the field, for example `_cd-works`.
@@ -1,11 +1,10 @@
title: $:/changenotes/5.4.0/#9107
description: Update configuration defaults
description: Update default sidebar layout
release: 5.4.0
tags: $:/tags/ChangeNote
change-type: enhancement
change-category: usability
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9107
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9107 https://github.com/TiddlyWiki/TiddlyWiki5/issues/9757
github-contributors: Jermolene
* Changed default sidebar layout from ''fixed-fluid'' to ''fluid-fixed''
* Changed ''Wrap long lines in code blocks'' default to ''No''
Changed default sidebar layout from ''fixed-fluid'' to ''fluid-fixed''
@@ -1,8 +0,0 @@
title: $:/changenotes/5.4.0/#9715
change-type: performance
change-category: filters
tags: $:/tags/ChangeNote
github-contributors: linonetwo
release: 5.4.0
description: Optimized tag[] and !tag[] filter operators to use Set for O(1) lookup, matching search:tags[] performance.
github-links: [[https://github.com/Jermolene/TiddlyWiki5/pull/9715]]
@@ -1,12 +0,0 @@
change-category: widget
change-type: enhancement
created: 20260711020921000
description: The diff-text widget shows removed or added newlines with a visible ↲ glyph
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9736
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9736
type: text/vnd.tiddlywiki
* The diff-text widget marks newline characters in a diff with the visible `↲` glyph (U+21B2) instead of an invisible character, so line break changes are recognisable in the rendered diff (fixes [[Issue #9461|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9461]])
@@ -1,12 +0,0 @@
change-category: translation
change-type: enhancement
created: 20260711020729000
description: The "Click to generate wiki info" button in the control panel is now translatable
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9737
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9737
type: text/vnd.tiddlywiki
* The "Click to generate wiki info" button on the control panel's wiki information tab uses a language string instead of hard coded English text, so translators can localise it (fixes [[Issue #9654|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9654]])
@@ -1,12 +0,0 @@
change-category: nodejs
change-type: bugfix
created: 20260711020057000
description: The Node.js server no longer emits the DEP0169 url.parse() deprecation warning
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9742
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9742
type: text/vnd.tiddlywiki
* The server module parses request URLs with the WHATWG `URL` API instead of the deprecated `url.parse()`, so recent Node.js versions no longer print the DEP0169 deprecation warning on startup (fixes [[Issue #9628|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9628]])
@@ -1,36 +0,0 @@
title: $:/changenotes/5.5.0/#9806
description: Filesystem dynamic stores with live reload via chokidar
release: 5.5.0
tags: $:/tags/ChangeNote
change-type: feature
change-category: nodejs
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9806
github-contributors: Jermolene
The filesystem syncadaptor can now be configured to treat a folder as a [[dynamic store|tiddlywiki.files Files]]: tiddlers in the folder are loaded at boot, a configurable filter decides which tiddlers are saved back into that folder instead of the default `tiddlers/` directory, and external edits to files in the folder are picked up live by a [[chokidar|https://github.com/paulmillr/chokidar]] watcher.
Dynamic stores are declared inside a `tiddlywiki.files` specification via the new `dynamicStore` property on a directory entry. For example, the following declaration loads every `.md` file in a sibling `content/` folder, routes any tiddler with `type: text/x-markdown` back to that folder on save, and hot-reloads changes made by an external editor:
```
{
"directories": [{
"path": "../content",
"filesRegExp": "^.*\\.md$",
"isTiddlerFile": true,
"dynamicStore": {
"saveFilter": "[type[text/x-markdown]]",
"watch": true,
"debounce": 400
}
}]
}
```
Notes:
* File events are debounced per file (default 400ms, configurable via `debounce`) to cope with editors that save atomically via rename.
* Each detected change is diffed against the current in-wiki tiddler before being reported, so self-writes performed by TiddlyWiki itself do not cause reload loops.
* Deletions on disk propagate to the wiki via the syncer's standard server-side-deletion path.
* JavaScript module tiddlers (those with `type: application/javascript` and a `module-type` field) are never hot-reloaded; reloading them would require restarting the TiddlyWiki process.
This feature adds [[chokidar|https://github.com/paulmillr/chokidar]] as a new runtime dependency of TiddlyWiki on Node.js.
@@ -1,10 +0,0 @@
title: $:/changenotes/5.5.0/#9816
description: Replaces some wikify widget with call dynamic syntax
release: 5.5.0
tags: $:/tags/ChangeNote
change-type: enhancement
change-category: internal
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9816
github-contributors: Leilei332
Replaces unnecessary wikify widget usage with call dynamic attribute syntax.
@@ -1,13 +0,0 @@
change-category: internal
change-type: bugfix
created: 20260711014241000
description: fakedom tagName is uppercase for HTML elements, matching the DOM spec
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9843
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9843
type: text/vnd.tiddlywiki
* `$tw.fakeDocument` elements now report `tagName` in uppercase for HTML elements, matching the DOM specification and real browsers; code that compares fakedom `tagName` against lowercase strings must be updated
* Creating an element with an empty or null namespace now produces a plain element without a namespace
@@ -1,12 +0,0 @@
change-category: filters
change-type: bugfix
created: 20260711011920000
description: backlinks[] and backtranscludes[] no longer report edited system tiddlers as sources
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/issues/9917
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9917
type: text/vnd.tiddlywiki
* Editing a system tiddler that links or transcludes another tiddler no longer adds it as a [[backlinks|backlinks Operator]] or [[backtranscludes|backtranscludes Operator]] source; the incremental back-index update now skips system tiddlers like the initial scan always did
@@ -1,13 +0,0 @@
title: $:/changenotes/5.5.0/#9919
created: 20260712000000000
modified: 20260712000000000
description: Fix the CSV parser returning every cell empty
release: 5.5.0
tags: $:/tags/ChangeNote
change-type: bugfix
change-category: internal
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9919
github-contributors: joshuafontany
type: text/vnd.tiddlywiki
`getCellInfo` read its loop variable before the loop declared it, so hoisting made it undefined and the parser tested the first character of the whole text rather than the first character of the cell. Any CSV opening with a separator therefore returned every cell empty, and the table rendered blank.
@@ -1,10 +0,0 @@
title: $:/changenotes/5.5.0/#9933
description: Update Chinese translations
release: 5.5.0
tags: $:/tags/ChangeNote
change-type: enhancement
change-category: translation
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9933
github-contributors: BramChen
* Add `WikiInformation/Generate/Caption` in `ControlPanel.multids`
@@ -1,13 +0,0 @@
caption: 5.5.0
created: 20260710103826404
modified: 20260710103826404
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>>
@@ -5,25 +5,27 @@ created: 20131129101027725
delivery: App
description: iPad/iPhone app for working with TiddlyWiki
method: save
modified: 20201007205336209
modified: 20260421210013537
tags: Saving iOS [[Standalone App]]
title: Saving on iPad/iPhone
type: text/vnd.tiddlywiki
The iPad/iPhone app ''Quine 2'' makes it possible to view, edit and then save changes to TiddlyWiki5 on iOS. [[Download it here|https://apps.apple.com/us/app/quine-2/id1450128957]].
The iPad/iPhone app ''Quine'' makes it possible to view, edit and then save changes to TiddlyWiki5 on iOS.
Currently in its third major version, rewritten for iOS and iPadOS 26.0.
[[Download it here|https://apps.apple.com/us/app/TiddlyWiki51664603]].
Instructions for use:
# Open Quine 2
# Tap the + toolbar button to create and open a new TiddlyWiki
# Open Quine
# From the file list tap the + toolbar button, or the "New Wiki" button, to create and open a new TiddlyWiki
# From the file list tap an existing TiddlyWiki file to open it
# Edit the TiddlyWiki as normal, and save as normal using either Autosave or the TiddlyWiki save button <<.icon $:/core/images/save-button-dynamic>>
# Tap the left hand "Documents" toolbar button to close an open TiddlyWiki
# Tap the left hand "<" toolbar button to close an open TiddlyWiki, returning to the file list
*Quine 2 works natively in iOS with the local file system and the iCloud file system
*Quine 2 also allows you to open, edit and save TiddlyWiki files stored with cloud file providers
*Quine 2 allows you to follow embedded WikiText links and canonical links to external files for cloud-like file providers which support "folder level sharing".
**This includes the apps "Secure Shellfish" and "Working Copy". Most providers, though, do not allow apps like Quine 2 to access linked files this way.
** If you wish to enable such links for "well behaved" file providers, toggle "on" the "Enable folder selection for out-of-sandbox links" setting in iOS Settings for Quine 2
*Quine works natively in iOS with the local file system and the iCloud file system
*Quine also allows you to open, edit and save TiddlyWiki files stored with other cloud file providers
*Quine allows you to follow embedded WikiText links and canonical links to external files on cloud-like file providers which support "folder level sharing".
//Note that Quine is published independently of TiddlyWiki//
@@ -1,6 +1,6 @@
caption: button
created: 20131024141900000
modified: 20251101091926820
modified: 20260725015330000
tags: Widgets TriggeringWidgets
title: ButtonWidget
type: text/vnd.tiddlywiki
@@ -66,4 +66,40 @@ Press me!
</$button>
```
''Tip:'' Set ''class'' to `tc-btn-invisible tc-tiddlylink` to have a button look like an internal link.
! Order of execution
The order of the attributes written in wikitext does ''not'' matter.
A button can carry several of these attributes at once. They are not alternatives: every one that is present is used, and ''always in the order shown below''.
# ActionWidgets in the button body
# <<.attr to>>
# <<.attr message>>, with <<.attr param>>
# <<.attr popup>> or <<.attr popupTitle>>
# <<.attr set>> or <<.attr setTitle>>, with <<.attr setTo>>, <<.attr setField>> and <<.attr setIndex>>
# <<.attr actions>>
<<.tip """When a button does more than one thing, put the whole behaviour in the `actions` attribute. It keeps the behaviour in one place and runs it last, after every other attribute above. A single `<$button to="HelloThere">Click Me</$button>` needs no action string.""">>
Action widgets are refreshed before each one runs, so they see the results of the actions before them. Ordinary widgets wrapped around them are not. See [[ActionWidget Execution Modes]] when a value looks out of date.
! Examples
This button uses the <<.attr to>> attribute to navigate to the [[HelloThere]] tiddler, opening it in the story river. The optional <<.attr tooltip>> attribute sets the text shown on hover.
<$macrocall $name='wikitext-example-without-html'
src='<$button to="HelloThere" tooltip="Navigate to the HelloThere tiddler">
Open ~HelloThere
</$button>'/>
<<.tip """Set ''class'' to `tc-btn-invisible tc-tiddlylink` to have a button look like an internal link.""">>
! Interactive Examples
<<<
<$list filter="[prefix[ButtonWidget/Example/]]">
; <$link/>
: {{!!description}}
</$list>
<<<
@@ -1,5 +1,5 @@
created: 20221007144237585
modified: 20240422084734129
modified: 20260807190532419
tags: Concepts
title: Custom Widgets
type: text/vnd.tiddlywiki
@@ -79,3 +79,34 @@ Python
<$let test="Tiger">
<$codeblock code=<<test>>/>
</$let>""">>
In this example, we override the <<.wlink "LinkWidget">> widget to automatically display an icon next to the link if the target tiddler has an `icon` field. We use the <<.wlink "ParametersWidget">> widget to capture all attributes passed to the original link and forward them to the underlying widget via <<.wlink "GenesisWidget">>:
<<wikitext-example-without-html """\widget $link()
\function link.icon()
[<params-var>jsonget[to]else<currentTiddler>get[icon]]
\end link.icon
\function link.text()
[<params-var>jsonget[to]else<currentTiddler>]
\end link.text
\whitespace trim
<$parameters $params="params-var">
<$genesis
$type="$link"
$remappable="no"
$names="[<params-var>jsonindexes[]]"
$values="[<params-var>jsonindexes[]] :map[<params-var>jsonget<currentTiddler>]"
>
<%if [<link.icon>]%>
<span class="tc-tiddler-title-icon tc-titlebar" style="fill:currentColor;font-size:11pt!important">
<$transclude $tiddler=<<link.icon>> size="11pt"/>
</span>
<%endif%>
<$slot $name="ts-raw">
<$text text=<<link.text>> />
</$slot>
</$genesis>
</$parameters>
\end
<<list-links "[has[icon]]">>""">>
@@ -7,7 +7,7 @@ type: text/vnd.tiddlywiki
!! Basic syntax
HTML description lists (<abbr title="also known as">AKA</abbr> definition lists) are created with this syntax:
HTML description lists (<abbr title="also known as">AKA</abbr> definition lists) are created with this syntax:
<<wikitext-example src:"; Term being described
: Description / Definition of that term
@@ -17,7 +17,7 @@ HTML description lists (<abbr title="also known as">AKA</abbr> definition lists)
!! Multiple terms and descriptions
You can create multiple descriptions for a term, or multiple terms for a single description:
You can create multiple descriptions for a term, or multiple terms with a single description:
<<wikitext-example src:"; Mouse
: A rodent with a small body and a long tail
@@ -41,4 +41,4 @@ Description lists may also be nested to create lists within lists:
::: A coffee made with espresso and steamed milk
; Tea
: A beverage typically made from tea leaves
">>
">>
@@ -1,6 +1,6 @@
caption: HTML
created: 20131205160816081
modified: 20241025073517909
modified: 20260821065251883
tags: WikiText
title: HTML in WikiText
type: text/vnd.tiddlywiki
@@ -68,26 +68,4 @@ In an extension of conventional HTML syntax, attributes of elements and widgets
!! Style Attributes
TiddlyWiki supports the usual HTML ''style'' attribute for assigning CSS styles to elements:
```
<div style="color:red;">Hello</div>
```
<<.from-version "5.2.2">> In an extension to HTML, TiddlyWiki also supports accessing individual CSS styles as independent attributes. For example:
```
<div style.color="red">Hello</div>
```
The advantage of this syntax is that it simplifies assigning computed values to CSS styles. For example:
```
<div style.color={{!!color}}>Hello</div>
```
<<.from-version "5.3.6">> TiddlyWiki also supports setting [[CSS custom properties|https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties]] as independent attributes. For example:
```
<div --bg-color={{{ [[red]] }}}>Hello</div>
```
{{Style Attributes}}
@@ -0,0 +1,29 @@
created: 20260821065249926
modified: 20260821065326363
tags: [[HTML in WikiText]] [[How to apply custom styles]]
title: Style Attributes
type: text/vnd.tiddlywiki
TiddlyWiki supports the usual HTML ''style'' attribute for assigning CSS styles to elements:
```
<div style="color:red;">Hello</div>
```
<<.from-version "5.2.2">> In an extension to HTML, TiddlyWiki also supports accessing individual CSS styles as independent attributes. For example:
```
<div style.color="red">Hello</div>
```
The advantage of this syntax is that it simplifies assigning computed values to CSS styles. For example:
```
<div style.color={{!!color}}>Hello</div>
```
<<.from-version "5.3.6">> TiddlyWiki also supports setting [[CSS custom properties|https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties]] as independent attributes. For example:
```
<div --bg-color={{{ [[red]] }}}>Hello</div>
```
+1 -2
View File
@@ -256,5 +256,4 @@ ViewTemplateTags/Caption: 查看模板标签区
ViewTemplateTags/Hint: 默认的查看模板使用此规则级联,动态选择模板以显示条目的标签区域。
WikiInformation/Caption: 维基信息
WikiInformation/Hint: 此页面总结了有关此 ~TiddlyWiki 配置的高阶信息。旨在让用户能够快速与他人共享其 ~TiddlyWiki 配置的相关信息,例如,在其中一个论坛中寻求帮助时。不包含隐私或个人信息,没有明确复制和粘贴在其他地方的情况
WikiInformation/Drag/Caption: 拖动此链接可将此工具复制到另一个维基
WikiInformation/Generate/Caption: 生成维基信息报告
WikiInformation/Drag/Caption: 拖动此链接可将此工具复制到另一个维基
+1 -2
View File
@@ -256,5 +256,4 @@ ViewTemplateTags/Caption: 檢視範本標籤
ViewTemplateTags/Hint: 預設的檢視範本使用此規則級聯,動態選擇範本以顯示條目的標籤。
WikiInformation/Caption: 維基資訊
WikiInformation/Hint: 此頁面總結了有關此 ~TiddlyWiki 配置的高階資訊。旨在讓使用者能夠快速與他人共享其 ~TiddlyWiki 配置的相關資訊,例如,在其中一個論壇中尋求幫助時。不包含隱私或個人資訊,沒有明確複製和粘貼在其他地方的情況
WikiInformation/Drag/Caption: 拖動此連結可將此工具複製到另一個維基
WikiInformation/Generate/Caption: 產生維基資訊報告
WikiInformation/Drag/Caption: 拖動此連結可將此工具複製到另一個維基
+2 -1
View File
@@ -650,4 +650,5 @@ Rishu kumar, @rishu-7549, 2025/10/25
Himmel, @NotHimmel, 2026/03/19
@sean-clayton, 2026/05/16
@vuktw, 2026/07/12
+5 -46
View File
@@ -1,6 +1,6 @@
{
"name": "tiddlywiki",
"version": "5.5.0-prerelease",
"version": "5.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -8,9 +8,6 @@
"name": "tiddlywiki",
"version": "5.4.1",
"license": "BSD",
"dependencies": {
"chokidar": "^4.0.3"
},
"bin": {
"tiddlywiki": "tiddlywiki.js"
},
@@ -22,7 +19,7 @@
"globals": "16.4.0"
},
"engines": {
"node": ">=20.0.0"
"node": ">=0.8.2"
}
},
"node_modules/@eslint-community/eslint-utils": {
@@ -332,21 +329,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"dev": true,
@@ -735,20 +717,10 @@
"license": "ISC"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -934,19 +906,6 @@
"node": ">=6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"dev": true,
+2 -5
View File
@@ -1,7 +1,7 @@
{
"name": "tiddlywiki",
"preferGlobal": true,
"version": "5.5.0-prerelease",
"version": "5.4.1",
"author": "Jeremy Ruston <jeremy@jermolene.com>",
"description": "a non-linear personal web notebook",
"contributors": [
@@ -23,14 +23,11 @@
"tiddlywiki5",
"wiki"
],
"dependencies": {
"chokidar": "^4.0.3"
},
"devDependencies": {
"@eslint/js": "9.36.0",
"@stylistic/eslint-plugin": "5.4.0",
"eslint": "9.36.0",
"eslint-plugin-es-x": "9.1.0",
"@stylistic/eslint-plugin": "5.4.0",
"globals": "16.4.0"
},
"license": "BSD",
@@ -11,7 +11,6 @@ A sync adaptor module for synchronising with the local filesystem via node.js AP
// Get a reference to the file system
var fs = $tw.node ? require("fs") : null;
var path = $tw.node ? require("path") : null;
function FileSystemAdaptor(options) {
this.wiki = options.wiki;
@@ -21,19 +20,6 @@ function FileSystemAdaptor(options) {
if(this.boot.wikiTiddlersPath) {
$tw.utils.createDirectory(this.boot.wikiTiddlersPath);
}
// Buffers for out-of-band file changes, drained by getUpdatedTiddlers
this.modifications = Object.create(null);
this.deletions = Object.create(null);
this.pendingTimers = Object.create(null);
this.watchers = [];
this.setupWatchers();
// Only advertise getUpdatedTiddlers (and so opt into syncer polling) when
// there is actually a dynamic store to report changes from. Otherwise the
// syncer would reschedule its poll forever and keep node alive past the
// natural end of headless commands like --build.
if(!(this.boot.dynamicStores && this.boot.dynamicStores.length > 0)) {
this.getUpdatedTiddlers = undefined;
}
}
FileSystemAdaptor.prototype.name = "filesystem";
@@ -41,56 +27,35 @@ FileSystemAdaptor.prototype.name = "filesystem";
FileSystemAdaptor.prototype.supportsLazyLoading = false;
FileSystemAdaptor.prototype.isReady = function() {
// The file system adaptor is always ready
return true;
};
FileSystemAdaptor.prototype.getTiddlerInfo = function(tiddler) {
//Returns the existing fileInfo for the tiddler. To regenerate, call getTiddlerFileInfo().
var title = tiddler.fields.title;
return this.boot.files[title];
};
/*
Find the dynamic store (if any) that a tiddler should be saved into.
Precedence: existing boot.files entry wins; otherwise first matching saveFilter.
*/
FileSystemAdaptor.prototype.findDynamicStoreForTiddler = function(tiddler) {
var stores = this.boot.dynamicStores || [];
if(stores.length === 0) {
return null;
}
var title = tiddler.fields.title,
existing = this.boot.files[title];
if(existing && existing.dynamicStoreId) {
for(var i=0; i<stores.length; i++) {
if(stores[i].id === existing.dynamicStoreId) {
return stores[i];
}
}
}
for(var j=0; j<stores.length; j++) {
var store = stores[j];
if(store.saveFilter) {
var source = this.wiki.makeTiddlerIterator([title]),
result = this.wiki.filterTiddlers(store.saveFilter,null,source);
if(result.length > 0) {
return store;
}
}
}
return null;
};
Return a fileInfo object for a tiddler, creating it if necessary:
filepath: the absolute path to the file containing the tiddler
type: the type of the tiddler file (NOT the type of the tiddler -- see below)
hasMetaFile: true if the file also has a companion .meta file
/*
Return a fileInfo object for a tiddler, creating it if necessary.
The boot process populates this.boot.files for each of the tiddler files that it loads.
The type is found by looking up the extension in $tw.config.fileExtensionInfo (eg "application/x-tiddler" for ".tid" files).
It is the responsibility of the filesystem adaptor to update this.boot.files for new files that are created.
*/
FileSystemAdaptor.prototype.getTiddlerFileInfo = function(tiddler,callback) {
// Error if we don't have a this.boot.wikiTiddlersPath
if(!this.boot.wikiTiddlersPath) {
return callback("filesystemadaptor requires a valid wiki folder");
}
// Always generate a fileInfo object when this fuction is called
var title = tiddler.fields.title, newInfo, pathFilters, extFilters,
fileInfo = this.boot.files[title],
store = this.findDynamicStoreForTiddler(tiddler),
directory = store ? store.directory : this.boot.wikiTiddlersPath;
fileInfo = this.boot.files[title];
if(this.wiki.tiddlerExists("$:/config/FileSystemPaths")) {
pathFilters = this.wiki.getTiddlerText("$:/config/FileSystemPaths","").split("\n");
}
@@ -98,15 +63,12 @@ FileSystemAdaptor.prototype.getTiddlerFileInfo = function(tiddler,callback) {
extFilters = this.wiki.getTiddlerText("$:/config/FileSystemExtensions","").split("\n");
}
newInfo = $tw.utils.generateTiddlerFileInfo(tiddler,{
directory: directory,
directory: this.boot.wikiTiddlersPath,
pathFilters: pathFilters,
extFilters: extFilters,
wiki: this.wiki,
fileInfo: fileInfo
});
if(store) {
newInfo.dynamicStoreId = store.id;
}
callback(null,newInfo);
};
@@ -121,7 +83,6 @@ FileSystemAdaptor.prototype.saveTiddler = function(tiddler,callback,options) {
if(err) {
return callback(err);
}
var dynamicStoreId = fileInfo && fileInfo.dynamicStoreId || null;
$tw.utils.saveTiddlerToFile(tiddler,fileInfo,function(err,fileInfo) {
if(err) {
if((err.code == "EPERM" || err.code == "EACCES") && err.syscall == "open") {
@@ -134,9 +95,6 @@ FileSystemAdaptor.prototype.saveTiddler = function(tiddler,callback,options) {
return callback(err);
}
}
if(dynamicStoreId && fileInfo) {
fileInfo.dynamicStoreId = dynamicStoreId;
}
// Store new boot info only after successful writes
self.boot.files[tiddler.fields.title] = fileInfo;
// Cleanup duplicates if the file moved or changed extensions
@@ -158,28 +116,9 @@ FileSystemAdaptor.prototype.saveTiddler = function(tiddler,callback,options) {
/*
Load a tiddler and invoke the callback with (err,tiddlerFields)
Most tiddlers are pre-loaded at boot, but the syncer may ask us to load
individual tiddlers in response to watcher-driven out-of-band changes.
We don't need to implement loading for the file system adaptor, because all the tiddler files will have been loaded during the boot process.
*/
FileSystemAdaptor.prototype.loadTiddler = function(title,callback) {
var fileInfo = this.boot.files[title];
if(!fileInfo || !fileInfo.dynamicStoreId || !fs.existsSync(fileInfo.filepath)) {
return callback(null,null);
}
var loaded;
try {
loaded = $tw.loadTiddlersFromFile(fileInfo.filepath,{});
} catch(e) {
return callback(e);
}
if(!loaded || !loaded.tiddlers) {
return callback(null,null);
}
for(var i=0; i<loaded.tiddlers.length; i++) {
if(loaded.tiddlers[i] && loaded.tiddlers[i].title === title) {
return callback(null,loaded.tiddlers[i]);
}
}
callback(null,null);
};
@@ -189,16 +128,19 @@ Delete a tiddler and invoke the callback with (err)
FileSystemAdaptor.prototype.deleteTiddler = function(title,callback,options) {
var self = this,
fileInfo = this.boot.files[title];
// Only delete the tiddler if we have writable information for the file
if(fileInfo) {
$tw.utils.deleteTiddlerFile(fileInfo,function(err,fileInfo) {
if(err) {
if((err.code == "EPERM" || err.code == "EACCES") && err.syscall == "unlink") {
// Error deleting the file on disk, should fail gracefully
$tw.syncer.displayError("Server desynchronized. Error deleting file for deleted tiddler \"" + title + "\"",err);
return callback(null,fileInfo);
} else {
return callback(err);
}
}
// Remove the tiddler from self.boot.files & return null adaptorInfo
self.removeTiddlerFileInfo(title);
return callback(null,null);
});
@@ -211,201 +153,10 @@ FileSystemAdaptor.prototype.deleteTiddler = function(title,callback,options) {
Delete a tiddler in cache, without modifying file system.
*/
FileSystemAdaptor.prototype.removeTiddlerFileInfo = function(title) {
// Only delete the tiddler info if we have writable information for the file
if(this.boot.files[title]) {
delete this.boot.files[title];
}
};
/*
Syncer hook: return modifications/deletions that have occurred on disk
since the last poll.
*/
FileSystemAdaptor.prototype.getUpdatedTiddlers = function(syncer,callback) {
var modifications = Object.keys(this.modifications),
deletions = Object.keys(this.deletions);
this.modifications = Object.create(null);
this.deletions = Object.create(null);
callback(null,{modifications: modifications, deletions: deletions});
};
/*
Set up chokidar watchers for each registered dynamic store.
*/
/*
Close all watchers and clear any pending debounce timers. Returns a promise
that resolves once chokidar has fully shut down, for clean teardown in tests.
*/
FileSystemAdaptor.prototype.close = function() {
$tw.utils.each(this.pendingTimers,function(timer) { clearTimeout(timer); });
this.pendingTimers = Object.create(null);
var closes = (this.watchers || []).map(function(w) {
try { return w.close(); } catch(e) { return null; }
});
this.watchers = [];
return Promise.all(closes.filter(Boolean));
};
FileSystemAdaptor.prototype.setupWatchers = function() {
var self = this,
stores = (this.boot.dynamicStores || []).filter(function(s) { return s.watch; });
if(stores.length === 0) {
return;
}
var chokidar;
try {
chokidar = require("chokidar");
} catch(e) {
this.logger.log("chokidar not available; dynamic store watching disabled",e.message);
return;
}
stores.forEach(function(store) {
self.setupWatcher(chokidar,store);
});
};
FileSystemAdaptor.prototype.setupWatcher = function(chokidar,store) {
var self = this,
fileRegExp = new RegExp(store.filesRegExp || "^.*$");
var watcher = chokidar.watch(store.directory,{
ignoreInitial: true,
persistent: true,
depth: store.searchSubdirectories ? undefined : 0,
awaitWriteFinish: {stabilityThreshold: 100, pollInterval: 50},
ignored: function(p) {
// chokidar invokes `ignored` for the root too — don't ignore the root
if(p === store.directory) return false;
var base = path.basename(p);
if(/\.meta$/.test(base)) return false;
// Allow directories through so recursion works when enabled
try {
if(fs.existsSync(p) && fs.statSync(p).isDirectory()) return false;
} catch(e) {}
return !fileRegExp.test(base);
}
});
watcher.on("add",function(filepath) { self.scheduleFileEvent(store,filepath,"change"); });
watcher.on("change",function(filepath) { self.scheduleFileEvent(store,filepath,"change"); });
watcher.on("unlink",function(filepath) { self.scheduleFileEvent(store,filepath,"unlink"); });
watcher.on("error",function(err) {
self.logger.log("chokidar error for " + store.directory,err && err.message);
});
this.watchers.push(watcher);
};
FileSystemAdaptor.prototype.scheduleFileEvent = function(store,filepath,eventType) {
var self = this,
key = filepath,
delay = store.debounce || 400;
// A .meta change should trigger re-read of its companion file
var targetPath = filepath;
if(/\.meta$/.test(filepath)) {
targetPath = filepath.replace(/\.meta$/,"");
}
if(this.pendingTimers[key]) {
clearTimeout(this.pendingTimers[key]);
}
var timer = setTimeout(function() {
delete self.pendingTimers[key];
try {
self.processFileEvent(store,targetPath,eventType);
} catch(e) {
self.logger.log("Error processing file event for " + targetPath,e.message);
}
},delay);
if(timer && typeof timer.unref === "function") {
timer.unref();
}
this.pendingTimers[key] = timer;
};
FileSystemAdaptor.prototype.processFileEvent = function(store,filepath,eventType) {
var self = this;
// Deletion: look up any titles that mapped to this filepath and queue deletion.
// Do NOT call wiki.deleteTiddler here — the syncer's SyncFromServerTask does that.
if(eventType === "unlink" || !fs.existsSync(filepath)) {
var deletedTitles = [];
$tw.utils.each(this.boot.files,function(info,title) {
if(info && info.filepath === filepath) {
deletedTitles.push(title);
}
});
deletedTitles.forEach(function(title) {
delete self.boot.files[title];
self.deletions[title] = true;
delete self.modifications[title];
});
if(deletedTitles.length > 0) {
this.logger.log("Dynamic store: detected removal of " + deletedTitles.length + " tiddler(s) at " + filepath);
}
return;
}
// Add/change: re-parse the file and queue modifications
var loaded;
try {
loaded = $tw.loadTiddlersFromFile(filepath,{});
} catch(e) {
this.logger.log("Failed to load tiddler file " + filepath,e.message);
return;
}
if(!loaded || !loaded.tiddlers) {
return;
}
var newTitles = {};
loaded.tiddlers.forEach(function(fields) {
if(!fields || !fields.title) {
return;
}
if(fields.type === "application/javascript" && fields["module-type"]) {
self.logger.log("Skipping hot-reload of JS module tiddler " + fields.title + " (requires a restart)");
return;
}
var title = fields.title;
newTitles[title] = true;
// Ensure boot.files tracks the file so loadTiddler can find it on demand
self.boot.files[title] = {
filepath: loaded.filepath,
type: loaded.type,
hasMetaFile: loaded.hasMetaFile,
isEditableFile: true,
dynamicStoreId: store.id
};
// Diff against the current wiki tiddler to suppress self-write echoes
var existing = self.wiki.getTiddler(title);
if(existing && self.tiddlerFieldsEqual(existing.fields,fields)) {
return;
}
self.modifications[title] = true;
delete self.deletions[title];
});
// Handle tiddlers that were previously in this file but have now disappeared
$tw.utils.each(this.boot.files,function(info,title) {
if(info && info.filepath === filepath && !newTitles[title]) {
delete self.boot.files[title];
self.deletions[title] = true;
delete self.modifications[title];
}
});
};
FileSystemAdaptor.prototype.tiddlerFieldsEqual = function(existingFields,newFields) {
// Ignore volatile fields that the syncer / server may add
var ignore = {revision: 1, bag: 1};
var keys = {};
$tw.utils.each(existingFields,function(v,k) { if(!ignore[k]) keys[k] = true; });
$tw.utils.each(newFields,function(v,k) { if(!ignore[k]) keys[k] = true; });
for(var k in keys) {
var a = existingFields[k],
b = newFields[k];
// Normalise arrays to string form
if($tw.utils.isArray(a)) a = $tw.utils.stringifyList(a);
if($tw.utils.isArray(b)) b = $tw.utils.stringifyList(b);
if(a instanceof Date) a = $tw.utils.stringifyDate(a);
if(b instanceof Date) b = $tw.utils.stringifyDate(b);
if((a === undefined ? "" : String(a)) !== (b === undefined ? "" : String(b))) {
return false;
}
}
return true;
};
};
if(fs) {
-48
View File
@@ -1,48 +0,0 @@
/*\
title: $:/plugins/tiddlywiki/filesystem/teardown.js
type: application/javascript
module-type: startup
Shut down dynamic store watching and syncer polling once all CLI commands
have completed, unless a server was started. Otherwise the chokidar watchers
and the syncer poll timer keep the node event loop alive forever after
headless commands like --render and --build.
\*/
"use strict";
exports.name = "filesystem-teardown";
exports.platforms = ["node"];
exports.after = ["commands"];
exports.synchronous = true;
var serverStarted = false;
if($tw.node) {
$tw.hooks.addHook("th-server-command-post-start",function() {
serverStarted = true;
});
}
exports.startup = function() {
var adaptor = $tw.syncadaptor;
if(serverStarted || !adaptor || adaptor.name !== "filesystem") {
return;
}
// Stop advertising poll support so the syncer doesn't reschedule
adaptor.getUpdatedTiddlers = undefined;
if(typeof adaptor.close === "function") {
adaptor.close();
}
// Kick the syncer: a pending poll timer may be up to pollTimerInterval
// away. Clearing it and processing the queue directly drains any pending
// save tasks; with polling disabled, nothing is rescheduled afterwards.
if($tw.syncer) {
if($tw.syncer.taskTimerId) {
clearTimeout($tw.syncer.taskTimerId);
$tw.syncer.taskTimerId = null;
}
$tw.syncer.processTaskQueue();
}
};
@@ -1,178 +0,0 @@
/*\
title: $:/plugins/tiddlywiki/markdown/frontmatter-deserializer.js
type: application/javascript
module-type: tiddlerdeserializer
Markdown deserializer with YAML frontmatter extraction.
Parses YAML frontmatter delimited by `---` markers and maps extracted
values to tiddler fields. Array values on list fields (tags, list, any
field with a registered `stringify` method) are converted to TiddlyWiki
bracketed lists. Non-string, non-array values are stored as their JSON
representation.
`created` and `modified` in the frontmatter are accepted in either
TiddlyWiki's native `YYYYMMDDHHMMSSmmm` UTC format or any ISO-8601
string that `Date()` can parse; both are normalised to TW's native
format. Values that cannot be parsed are dropped.
\*/
"use strict";
var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
function deserialize(text,fields) {
var result = Object.create(null),
body = text,
frontmatter = null;
// Copy incoming fields (e.g. from .meta file or filename)
for(var f in fields) {
result[f] = fields[f];
}
// Extract YAML frontmatter if present
if(text.indexOf("---") === 0) {
var endMarker = text.indexOf("\n---",3);
if(endMarker !== -1) {
var yamlText = text.substring(3,endMarker).trim();
// Body starts after the closing --- and its newline
var afterMarker = endMarker + 4;
if(text[afterMarker] === "\n") {
afterMarker++;
} else if(text[afterMarker] === "\r" && text[afterMarker + 1] === "\n") {
afterMarker += 2;
}
// Skip one blank line if present (conventional separator between frontmatter and body)
if(text[afterMarker] === "\n") {
afterMarker++;
} else if(text[afterMarker] === "\r" && text[afterMarker + 1] === "\n") {
afterMarker += 2;
}
body = text.substring(afterMarker);
try {
frontmatter = yaml.load(yamlText);
} catch(e) {
// If YAML parsing fails, treat the whole text as body
body = text;
frontmatter = null;
}
}
}
// Map frontmatter fields to tiddler fields
if(frontmatter && typeof frontmatter === "object" && !Array.isArray(frontmatter)) {
var keys = Object.keys(frontmatter);
for(var i = 0; i < keys.length; i++) {
var key = keys[i],
value = frontmatter[key];
// Apply field collision policy
if(key === "created" || key === "modified") {
var normalised = normaliseDate(value);
if(normalised !== null) {
result[key] = normalised;
}
continue;
}
if(key === "tags" && result[key]) {
// Merge: parse existing tags, add new ones
result[key] = mergeTagValue(result[key],value);
continue;
}
result[key] = fieldValueToString(key,value);
}
}
result.text = body;
if(!result.type) {
result.type = "text/x-markdown";
}
return [result];
}
// Register under both types — text/x-markdown is the deserializer type
// registered for .md file extensions; text/markdown is the raw content type.
exports["text/x-markdown"] = deserialize;
exports["text/markdown"] = deserialize;
/*
Convert a parsed YAML value to a tiddler field string.
- Arrays on list fields (tags, list, etc.) → TW bracketed list format
- Strings → as-is
- Everything else → JSON
*/
function fieldValueToString(key,value) {
if(value === null || value === undefined) {
return "";
}
if(typeof value === "string") {
return value;
}
if(Array.isArray(value)) {
// Check if this field has a stringify method (i.e. it's a list field)
if($tw.Tiddler.fieldModules[key] && $tw.Tiddler.fieldModules[key].stringify) {
var stringItems = [];
for(var i = 0; i < value.length; i++) {
stringItems.push(value[i] == null ? "" : String(value[i]));
}
return $tw.utils.stringifyList(stringItems);
}
return JSON.stringify(value);
}
if(typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
/*
Normalise a frontmatter date value to TiddlyWiki's YYYYMMDDHHMMSSmmm UTC
format. Accepts TW native strings (14 or 17 digits, optional leading "-"
for negative years) and anything `Date()` can parse (ISO 8601, RFC 2822,
Date objects). Returns null if the value cannot be interpreted as a date.
*/
function normaliseDate(value) {
if(value === null || value === undefined) {
return null;
}
if(typeof value === "string") {
if(/^-?\d{14}$/.test(value)) {
return value + "000";
}
if(/^-?\d{17}$/.test(value)) {
return value;
}
var d = new Date(value);
if(!isNaN(d.getTime())) {
return $tw.utils.stringifyDate(d);
}
return null;
}
if(value instanceof Date && !isNaN(value.getTime())) {
return $tw.utils.stringifyDate(value);
}
return null;
}
/*
Merge incoming tag value with existing tags string.
The incoming value may be a string (TW bracketed list) or an array (from YAML).
*/
function mergeTagValue(existing,incoming) {
var existingTags = $tw.utils.parseStringArray(existing) || [];
var newTags;
if(Array.isArray(incoming)) {
newTags = incoming.map(function(t) { return t == null ? "" : String(t); });
} else if(typeof incoming === "string") {
newTags = $tw.utils.parseStringArray(incoming) || [];
} else {
return existing;
}
var seen = Object.create(null);
for(var i = 0; i < existingTags.length; i++) {
seen[existingTags[i]] = true;
}
for(var j = 0; j < newTags.length; j++) {
if(!seen[newTags[j]]) {
existingTags.push(newTags[j]);
seen[newTags[j]] = true;
}
}
return $tw.utils.stringifyList(existingTags);
}
@@ -1,108 +0,0 @@
/*\
title: $:/plugins/tiddlywiki/markdown/frontmatter-serializer.js
type: application/javascript
module-type: tiddlerserializer
Markdown serializer with YAML frontmatter.
Inverse of `frontmatter-deserializer.js`. Given a tiddler, returns a
Markdown file body whose first lines are a YAML frontmatter block
(`---` … `---`), followed by the tiddler's `text` field.
Field handling:
- `title` is always emitted (frontmatter wins over filename when reloaded).
- `text` is the body; not emitted in the frontmatter.
- `created`, `modified` are emitted as ISO-8601 strings (symmetric with
the deserializer, which accepts either ISO-8601 or TW's native format).
- `type` is skipped when it equals `text/x-markdown` (the default for `.md` files).
- `bag`, `revision` are skipped (sync metadata, not authored content).
- List fields (those with a registered `stringify` method) are emitted as YAML arrays.
- All other fields are emitted as YAML strings (preserving their on-disk type).
\*/
"use strict";
var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
// Field names to skip when emitting frontmatter
var SKIP_FIELDS = {
text: true,
bag: true,
revision: true
};
function serialize(tiddler) {
if(!tiddler) {
return "";
}
var fields = tiddler.fields || {},
frontmatter = Object.create(null);
// Always include title first
if(fields.title) {
frontmatter.title = fields.title;
}
// Add other fields
$tw.utils.each(fields,function(value,name) {
if(SKIP_FIELDS[name] || name === "title") {
return;
}
if(name === "type" && value === "text/x-markdown") {
return;
}
if(name === "created" || name === "modified") {
var iso = toIsoDate(value);
if(iso) {
frontmatter[name] = iso;
}
return;
}
// List fields → YAML arrays
if($tw.Tiddler.fieldModules[name] && $tw.Tiddler.fieldModules[name].stringify) {
var items;
if(Array.isArray(value)) {
items = value.slice();
} else {
items = $tw.utils.parseStringArray(value || "") || [];
}
frontmatter[name] = items;
} else if(typeof value === "string") {
frontmatter[name] = value;
} else {
// Fallback: stringify whatever it is
frontmatter[name] = String(value);
}
});
var body = fields.text || "";
var hasFrontmatter = Object.keys(frontmatter).length > 0;
if(!hasFrontmatter) {
return body;
}
return "---\n" + yaml.dump(frontmatter) + "\n---\n\n" + body;
}
/*
Convert a TiddlyWiki date field value to an ISO-8601 string. Accepts a
native `YYYYMMDDHHMMSSmmm` string or a Date. Returns null if the value
cannot be parsed.
*/
function toIsoDate(value) {
if(!value) {
return null;
}
var d;
if($tw.utils.isDate(value)) {
d = value;
} else {
d = $tw.utils.parseDate(String(value));
}
if(d && !isNaN(d.getTime())) {
return d.toISOString();
}
return null;
}
// Register under both types — text/markdown is what the "New Markdown" button
// sets; text/x-markdown is what the deserializer uses after content-type
// resolution for .md files loaded from disk.
exports["text/x-markdown"] = serialize;
exports["text/markdown"] = serialize;
-473
View File
@@ -1,473 +0,0 @@
/*\
title: $:/plugins/tiddlywiki/markdown/yaml.js
type: application/javascript
module-type: library
Minimal YAML parser for frontmatter extraction.
API-compatible subset of js-yaml: load(string) → object, dump(object) → string.
Handles scalars, flow/block arrays, and simple nested maps.
\*/
"use strict";
function YAMLException(message, mark) {
this.name = "YAMLException";
this.message = message;
this.mark = mark || null;
}
YAMLException.prototype = Object.create(Error.prototype);
YAMLException.prototype.constructor = YAMLException;
// -- Scalar parsing --
function parseScalar(raw) {
if(raw === "" || raw === "null" || raw === "Null" || raw === "NULL" || raw === "~") {
return null;
}
if(raw === "true" || raw === "True" || raw === "TRUE") {
return true;
}
if(raw === "false" || raw === "False" || raw === "FALSE") {
return false;
}
// Quoted strings
if((raw[0] === '"' && raw[raw.length - 1] === '"') ||
(raw[0] === "'" && raw[raw.length - 1] === "'")) {
var inner = raw.slice(1, -1);
if(raw[0] === '"') {
// Handle basic escape sequences in double-quoted strings.
// Use a single pass so each escape consumes its backslash before
// later replacements can re-interpret it (e.g. "\\n" must become
// backslash + n, not backslash + newline).
inner = inner.replace(/\\(.)/g, function(_, c) {
switch(c) {
case "n": return "\n";
case "t": return "\t";
case "r": return "\r";
case "\\": return "\\";
case '"': return '"';
default: return c;
}
});
}
return inner;
}
// Numbers: integers and floats
if(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(raw)) {
var num = Number(raw);
if(!isNaN(num)) {
return num;
}
}
// Hex integers
if(/^0x[0-9a-fA-F]+$/.test(raw)) {
return parseInt(raw, 16);
}
// Octal integers
if(/^0o[0-7]+$/.test(raw)) {
return parseInt(raw.slice(2), 8);
}
// Special floats
if(raw === ".inf" || raw === ".Inf" || raw === ".INF") {
return Infinity;
}
if(raw === "-.inf" || raw === "-.Inf" || raw === "-.INF") {
return -Infinity;
}
if(raw === ".nan" || raw === ".NaN" || raw === ".NAN") {
return NaN;
}
return raw;
}
// -- Flow sequence parser: [item, item, ...] --
function parseFlowSequence(str) {
// Strip outer brackets and split respecting nested brackets and quotes
var inner = str.slice(1, -1).trim();
if(inner === "") {
return [];
}
var items = [],
current = "",
depth = 0,
inSingle = false,
inDouble = false;
for(var i = 0; i < inner.length; i++) {
var ch = inner[i];
if(ch === "\\" && inDouble) {
current += ch + (inner[i + 1] || "");
i++;
continue;
}
if(ch === '"' && !inSingle) {
inDouble = !inDouble;
current += ch;
continue;
}
if(ch === "'" && !inDouble) {
inSingle = !inSingle;
current += ch;
continue;
}
if(!inSingle && !inDouble) {
if(ch === "[" || ch === "{") {
depth++;
} else if(ch === "]" || ch === "}") {
depth--;
} else if(ch === "," && depth === 0) {
items.push(parseScalar(current.trim()));
current = "";
continue;
}
}
current += ch;
}
if(current.trim() !== "") {
items.push(parseScalar(current.trim()));
}
return items;
}
// -- Flow mapping parser: {key: value, ...} --
function parseFlowMapping(str) {
var inner = str.slice(1, -1).trim();
if(inner === "") {
return {};
}
var result = Object.create(null),
pairs = [],
current = "",
depth = 0,
inSingle = false,
inDouble = false;
for(var i = 0; i < inner.length; i++) {
var ch = inner[i];
if(ch === "\\" && inDouble) {
current += ch + (inner[i + 1] || "");
i++;
continue;
}
if(ch === '"' && !inSingle) {
inDouble = !inDouble;
current += ch;
continue;
}
if(ch === "'" && !inDouble) {
inSingle = !inSingle;
current += ch;
continue;
}
if(!inSingle && !inDouble) {
if(ch === "[" || ch === "{") {
depth++;
} else if(ch === "]" || ch === "}") {
depth--;
} else if(ch === "," && depth === 0) {
pairs.push(current.trim());
current = "";
continue;
}
}
current += ch;
}
if(current.trim() !== "") {
pairs.push(current.trim());
}
for(var p = 0; p < pairs.length; p++) {
var colonIdx = pairs[p].indexOf(":");
if(colonIdx !== -1) {
var key = pairs[p].slice(0, colonIdx).trim(),
val = pairs[p].slice(colonIdx + 1).trim();
result[parseScalar(key)] = parseScalar(val);
}
}
return result;
}
// -- Block parser (indentation-based) --
/*
Parse block YAML from an array of {indent, raw} line objects.
Returns the parsed value (object, array, or scalar).
*/
function parseBlock(lines, start, baseIndent) {
if(start >= lines.length) {
return {value: null, nextIndex: start};
}
var firstLine = lines[start];
// Block sequence: lines starting with "- "
if(firstLine.raw.indexOf("- ") === 0 || firstLine.raw === "-") {
return parseBlockSequence(lines, start, firstLine.indent);
}
// Block mapping: lines containing ":"
if(firstLine.raw.indexOf(":") !== -1) {
return parseBlockMapping(lines, start, firstLine.indent);
}
// Bare scalar
return {value: parseScalar(firstLine.raw), nextIndex: start + 1};
}
function parseBlockSequence(lines, start, seqIndent) {
var result = [],
i = start;
while(i < lines.length && lines[i].indent === seqIndent && (lines[i].raw.indexOf("- ") === 0 || lines[i].raw === "-")) {
var itemRaw = lines[i].raw.slice(2); // After "- "
// Check for inline flow value
var trimmed = itemRaw.trim();
if(trimmed[0] === "[") {
result.push(parseFlowSequence(trimmed));
i++;
} else if(trimmed[0] === "{") {
result.push(parseFlowMapping(trimmed));
i++;
} else if(trimmed === "" || trimmed === undefined) {
// Multi-line block item — collect indented children
i++;
var childLines = [];
while(i < lines.length && lines[i].indent > seqIndent) {
childLines.push(lines[i]);
i++;
}
if(childLines.length > 0) {
var parsed = parseBlock(childLines, 0, childLines[0].indent);
result.push(parsed.value);
} else {
result.push(null);
}
} else if(trimmed.indexOf(":") !== -1 && !isQuotedColonValue(trimmed)) {
// Inline mapping start as sequence item
// Collect this line (re-indented) plus any deeper-indented children
var mappingLines = [{indent: seqIndent + 2, raw: trimmed}];
i++;
while(i < lines.length && lines[i].indent > seqIndent) {
mappingLines.push(lines[i]);
i++;
}
var parsedMap = parseBlock(mappingLines, 0, mappingLines[0].indent);
result.push(parsedMap.value);
} else {
result.push(parseScalar(trimmed));
i++;
}
}
return {value: result, nextIndex: i};
}
function isQuotedColonValue(str) {
// Check if the colon is inside quotes (meaning it's a scalar, not a mapping)
var colonIdx = str.indexOf(":");
if(colonIdx === -1) {
return false;
}
// If the value starts with a quote and the colon is inside, it's a quoted scalar
if((str[0] === '"' || str[0] === "'") && colonIdx > 0) {
var quote = str[0];
var closeIdx = str.indexOf(quote, 1);
if(closeIdx > colonIdx) {
return true;
}
}
return false;
}
function parseBlockMapping(lines, start, mapIndent) {
var result = Object.create(null),
i = start;
while(i < lines.length && lines[i].indent === mapIndent) {
var line = lines[i].raw,
colonIdx = line.indexOf(":");
if(colonIdx === -1) {
break;
}
var key = line.slice(0, colonIdx).trim(),
valRaw = line.slice(colonIdx + 1).trim();
if(valRaw !== "") {
// Inline value
if(valRaw[0] === "[") {
result[key] = parseFlowSequence(valRaw);
} else if(valRaw[0] === "{") {
result[key] = parseFlowMapping(valRaw);
} else {
result[key] = parseScalar(valRaw);
}
i++;
} else {
// Block value on subsequent indented lines
i++;
var childLines = [];
while(i < lines.length && lines[i].indent > mapIndent) {
childLines.push(lines[i]);
i++;
}
if(childLines.length > 0) {
var parsed = parseBlock(childLines, 0, childLines[0].indent);
result[key] = parsed.value;
} else {
result[key] = null;
}
}
}
return {value: result, nextIndex: i};
}
// -- Main API --
/*
Parse a YAML string into a JavaScript value.
Compatible with js-yaml's load() function.
Handles the subset of YAML used in frontmatter:
scalars, flow/block arrays, flow/block mappings, nested maps.
*/
function load(text) {
if(typeof text !== "string") {
throw new YAMLException("Input must be a string");
}
text = text.trim();
if(text === "") {
return null;
}
// Tokenise into lines with indent tracking
var rawLines = text.split(/\r?\n/),
lines = [];
for(var i = 0; i < rawLines.length; i++) {
var raw = rawLines[i];
// Skip blank lines and comment-only lines
var trimmed = raw.trim();
if(trimmed === "" || trimmed[0] === "#") {
continue;
}
var indent = 0;
while(indent < raw.length && raw[indent] === " ") {
indent++;
}
lines.push({indent: indent, raw: trimmed});
}
if(lines.length === 0) {
return null;
}
// Single-line flow values
if(lines.length === 1) {
var single = lines[0].raw;
if(single[0] === "[") {
return parseFlowSequence(single);
}
if(single[0] === "{") {
return parseFlowMapping(single);
}
}
var parsed = parseBlock(lines, 0, lines[0].indent);
return parsed.value;
}
/*
Serialise a JavaScript value to a YAML string.
Compatible with js-yaml's dump() function.
Handles the subset of YAML used in frontmatter.
*/
function dump(obj, options) {
options = options || {};
var indent = options.indent || 2;
return dumpValue(obj, 0, indent);
}
function dumpValue(val, level, indentSize) {
if(val === null || val === undefined) {
return "null";
}
if(typeof val === "boolean") {
return val ? "true" : "false";
}
if(typeof val === "number") {
if(val !== val) { return ".nan"; }
if(val === Infinity) { return ".inf"; }
if(val === -Infinity) { return "-.inf"; }
return String(val);
}
if(typeof val === "string") {
return dumpString(val);
}
if(Array.isArray(val)) {
return dumpArray(val, level, indentSize);
}
if(typeof val === "object") {
return dumpObject(val, level, indentSize);
}
return String(val);
}
function dumpString(str) {
// Use plain style if safe, otherwise double-quote
if(str === "") {
return "''";
}
if(/^[\w][\w\s\-\.\/]*$/.test(str) &&
str !== "true" && str !== "false" && str !== "null" &&
str !== "True" && str !== "False" && str !== "Null" &&
str !== "TRUE" && str !== "FALSE" && str !== "NULL" &&
!/^-?\d/.test(str)) {
return str;
}
// Double-quote with escaping
return '"' + str.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t") + '"';
}
function dumpArray(arr, level, indentSize) {
if(arr.length === 0) {
return "[]";
}
var prefix = repeat(" ", level * indentSize);
var lines = [];
for(var i = 0; i < arr.length; i++) {
var val = dumpValue(arr[i], level + 1, indentSize);
if(typeof arr[i] === "object" && arr[i] !== null && !Array.isArray(arr[i])) {
// Object items: first key on same line as dash, rest indented
var objLines = val.split("\n");
lines.push(prefix + "- " + objLines[0]);
for(var j = 1; j < objLines.length; j++) {
lines.push(prefix + " " + objLines[j]);
}
} else {
lines.push(prefix + "- " + val);
}
}
return "\n" + lines.join("\n");
}
function dumpObject(obj, level, indentSize) {
var keys = Object.keys(obj);
if(keys.length === 0) {
return "{}";
}
var prefix = repeat(" ", level * indentSize);
var lines = [];
for(var i = 0; i < keys.length; i++) {
var key = keys[i],
val = obj[key];
var dumpedVal = dumpValue(val, level + 1, indentSize);
if((typeof val === "object" && val !== null) &&
((Array.isArray(val) && val.length > 0) || (!Array.isArray(val) && Object.keys(val).length > 0))) {
lines.push(prefix + dumpString(key) + ":" + dumpedVal);
} else {
lines.push(prefix + dumpString(key) + ": " + dumpedVal);
}
}
return lines.join("\n");
}
function repeat(str, count) {
var result = "";
for(var i = 0; i < count; i++) {
result += str;
}
return result;
}
exports.load = load;
exports.dump = dump;
exports.YAMLException = YAMLException;