Compare commits

..
Author SHA1 Message Date
saqimtiaz 56e3aee20b refactor: further improvements using new String methods 2026-07-24 20:58:01 +02:00
saqimtiaz f41ab866fb refactor: prefix, suffix and subfilter operators 2026-07-24 20:48:25 +02:00
saqimtiaz 0239971000 fix: added missing file 2026-07-24 20:39:45 +02:00
saqimtiaz cf4f7e0918 chore: eslint 2026-07-24 20:38:20 +02:00
saqimtiaz 297d0d2002 chore: eslint 2026-07-24 20:36:13 +02:00
saqimtiaz ee45768ade refactor: use ES2017 to optimize filter run prefixes 2026-07-24 20:17:56 +02:00
24b433211d feat: add JSON output support to makepatches operator (#9940)
* feat: enhance makepatches function to support multiple suffixes and JSON output format

* feat: add release notes for makepatches JSON output enhancement

* doc: update makepatches operator documentation to document new json output

* doc: add JSON output examples to makepatches and applypatches documentation

* chore: convert space indentations to tabs

* test: add tests for makepatches operator with JSON output support

* chore: change space to tab indentation

* test: add DiffMergePatch4 integration test for testing makepatches json output

* fix: remove superfluous heading character

* refactor the parsing of the makepatches suffixes

Co-authored-by: Saq Imtiaz <saq.imtiaz@gmail.com>

* refactor: improve makepatches function for better readability and performance

* chore: apply style guide by collapsing consecutive variable assignments

* chore: remove extraneous tabs

---------

Co-authored-by: Saq Imtiaz <saq.imtiaz@gmail.com>
2026-07-24 16:17:34 +02:00
abf8ffdbca Fix 9814 file system paths "pragma: \ define" tiddler (#9815)
* New test for all "forbidden" directory characters

* Fix for 9814 Pragma: \define creates dir problem

* improve handling of tiddlers in root/community directory

* Add ChangeNote

* Apply suggestion from @saqimtiaz

* Apply suggestion from @saqimtiaz

---------

Co-authored-by: Saq Imtiaz <saq.imtiaz@gmail.com>
2026-07-22 13:45:25 +02:00
Mario PietschandGitHub 97e27d0dad Fix regex for code block end detection in wikiparser (#9739)
* Fix regex for code block end detection in wikiparser

* Anchor the codeblock end fence to its own line

Close an empty code block without consuming the tiddler, while a fence
only counts when it stands alone on its line.

* Match the end fence with (^|\r?\n) so the empty block case closes at
  the line start and a code line ending in ``` stays inside the block
* Add test-codeblock-parser.js covering empty blocks with language and
  CRLF, delimiter newline handling, mid-line fences and a missing fence
2026-07-22 13:12:55 +02:00
Jeremy Ruston 855dc2fbb9 Merge branch 'master' of https://github.com/TiddlyWiki/TiddlyWiki5 2026-07-21 17:24:39 +01:00
lin onetwoandGitHub e1e34f4907 Move background action console.log inside platforms check (#9891)
* Move background action log inside platforms check

* Add change note for #9891

* Shorten change note

* Release notes: move #9891 from 5.4.1 to 5.5.0

PR #9891 is still open, so the background-action log change is planned for 5.5.0 instead of 5.4.1.

* Fix change-category for #9891 release note

 is not a valid category; use  instead.
2026-07-21 18:16:33 +02:00
Jeremy Ruston 7196b9ee9b Merge branch 'tiddlywiki-com' 2026-07-21 12:11:44 +01:00
Jeremy Ruston c0b3403f87 Remove extraneous github ribbon in the dev edition 2026-07-21 12:11:14 +01:00
Jeremy Ruston ed71c8262a Merge branch 'tiddlywiki-com' 2026-07-18 11:36:50 +01:00
Jeremy Ruston 2ececf21c7 Cherry pick docs update from master 2026-07-18 11:36:37 +01:00
7516d96eed Update docs: running a custom Node.js TiddlyWiki version with npx (#9886)
* Update docs: running a custom Node.js TiddlyWiki version with npx

* Update docs: document different options of running multiple TW versions

---------

Co-authored-by: shadow <shadow@darkstar.localdomain>
Co-authored-by: me <me@localhost>
2026-07-18 10:31:42 +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
50 changed files with 463 additions and 1997 deletions
+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) {
+19 -22
View File
@@ -217,6 +217,11 @@ exports.generateTiddlerFileInfo = function(tiddler,options) {
fileInfo.isEditableFile = true;
fileInfo.originalpath = options.fileInfo.originalpath;
}
// Propagate the pinFilepath flag (so generateTiddlerFilepath can short-circuit
// FileSystemPaths and write back to the original location)
if(options.fileInfo && !!options.fileInfo.pinFilepath) {
fileInfo.pinFilepath = true;
}
// Check if the tiddler has any unsafe fields that can't be expressed in a .tid or .meta file: containing control characters, or leading/trailing whitespace
var hasUnsafeFields = false;
$tw.utils.each(tiddler.getFieldStrings(),function(value,fieldName) {
@@ -238,10 +243,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;
@@ -323,9 +324,16 @@ exports.generateTiddlerFilepath = function(title,options) {
extension = options.extension || "",
originalpath = (options.fileInfo && options.fileInfo.originalpath) ? options.fileInfo.originalpath : "",
overwrite = options.fileInfo && options.fileInfo.overwrite || false,
pinFilepath = !!(options.fileInfo && options.fileInfo.pinFilepath),
filepath;
// If the tiddler's filepath is pinned via tiddlywiki.files, the originalpath
// wins and the path filters are skipped entirely.
if(pinFilepath && originalpath) {
var pinnedExt = path.extname(originalpath);
filepath = originalpath.substring(0,originalpath.length - pinnedExt.length);
}
// Check if any of the pathFilters applies
if(options.pathFilters && options.wiki) {
if(!filepath && options.pathFilters && options.wiki) {
$tw.utils.each(options.pathFilters,function(filter) {
if(!filepath) {
var source = options.wiki.makeTiddlerIterator([title]),
@@ -356,8 +364,10 @@ exports.generateTiddlerFilepath = function(title,options) {
}
// Replace any Unicode control codes
filepath = filepath.replace(/[\x00-\x1f\x80-\x9f]/g,"_");
// Replace any characters that can't be used in cross-platform filenames
filepath = $tw.utils.transliterate(filepath.replace(/<|>|~|\:|\"|\||\?|\*|\^/g,"_"));
// Replace any characters that can't be used in cross-platform filenames.
if(!pinFilepath) {
filepath = $tw.utils.transliterate(filepath.replace(/<|>|~|\:|\"|\||\?|\*|\^|\\/g,"_"));
}
// Replace any dots or spaces at the end of the extension with the same number of underscores
extension = extension.replace(/[\. ]+$/, function (u) { return u.replace(/[\. ]/g, "_");});
// Truncate the extension if it is too long
@@ -420,16 +430,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 +472,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);
+1 -1
View File
@@ -78,7 +78,6 @@ class BackgroundActionTracker {
fnProcess: (changes) => {
if(this.hasChanged) {
this.hasChanged = false;
console.log("Processing background action", this.title);
const tiddler = this.wiki.getTiddler(this.title);
let doActions = true;
if(tiddler && tiddler.fields.platforms) {
@@ -89,6 +88,7 @@ class BackgroundActionTracker {
}
}
if(doActions) {
console.log("Processing background action", this.title);
this.wiki.invokeActionString(
this.actions,
null,
@@ -13,14 +13,15 @@ Export our filter prefix function
exports.intersection = function(operationSubFunction) {
return function(results,source,widget) {
if(results.length !== 0) {
var secondRunResults = operationSubFunction(source,widget);
var firstRunResults = results.toArray();
const secondRunResults = operationSubFunction(source,widget),
secondRunSet = new Set(secondRunResults),
firstRunResults = results.toArray();
results.clear();
$tw.utils.each(firstRunResults,function(title) {
if(secondRunResults.indexOf(title) !== -1) {
firstRunResults.forEach((title) => {
if(secondRunSet.has(title)) {
results.push(title);
}
});
}
};
};
};
+15 -20
View File
@@ -13,33 +13,28 @@ Export our filter prefix function
exports.sort = function(operationSubFunction,options) {
return function(results,source,widget) {
if(results.length > 0) {
var suffixes = options.suffixes,
const suffixes = options.suffixes,
sortType = (suffixes[0] && suffixes[0][0]) ? suffixes[0][0] : "string",
invert = suffixes[1] ? (suffixes[1].indexOf("reverse") !== -1) : false,
isCaseSensitive = suffixes[1] ? (suffixes[1].indexOf("casesensitive") !== -1) : false,
invert = suffixes[1] ? suffixes[1].includes("reverse") : false,
isCaseSensitive = suffixes[1] ? suffixes[1].includes("casesensitive") : false,
inputTitles = results.toArray(),
sortKeys = [],
indexes = new Array(inputTitles.length),
compareFn;
results.each(function(title) {
var key = operationSubFunction(options.wiki.makeTiddlerIterator([title]),widget.makeFakeWidgetWithVariables({
"currentTiddler": "" + title,
"..currentTiddler": widget.getVariable("currentTiddler",{defaultValue:""})
}));
compareFn = $tw.utils.makeCompareFunction(sortType,{defaultType: "string", invert:invert, isCaseSensitive:isCaseSensitive});
results.each((title) => {
const key = operationSubFunction(
options.wiki.makeTiddlerIterator([title]),
widget.makeFakeWidgetWithVariables({
"currentTiddler": "" + title,
"..currentTiddler": widget.getVariable("currentTiddler",{defaultValue:""})
})
);
sortKeys.push(key[0] || "");
});
results.clear();
// Prepare an array of indexes to sort
for(var t=0; t<inputTitles.length; t++) {
indexes[t] = t;
}
// Sort the indexes
compareFn = $tw.utils.makeCompareFunction(sortType,{defaultType: "string", invert:invert, isCaseSensitive:isCaseSensitive});
indexes = indexes.sort(function(a,b) {
return compareFn(sortKeys[a],sortKeys[b]);
});
// Add to results in correct order
$tw.utils.each(indexes,function(index) {
let indexes = Array.from(inputTitles.keys());
indexes.sort((a,b) => compareFn(sortKeys[a],sortKeys[b]));
indexes.forEach((index) => {
results.push(inputTitles[index]);
});
}
+16 -31
View File
@@ -13,37 +13,22 @@ Filter operator for checking if a title starts with a prefix
Export our filter function
*/
exports.prefix = function(source,operator,options) {
var results = [],
suffixes = (operator.suffixes || [])[0] || [];
if(suffixes.indexOf("caseinsensitive") !== -1) {
var operand = operator.operand.toLowerCase();
if(operator.prefix === "!") {
source(function(tiddler,title) {
if(title.toLowerCase().substr(0,operand.length) !== operand) {
results.push(title);
}
});
} else {
source(function(tiddler,title) {
if(title.toLowerCase().substr(0,operand.length) === operand) {
results.push(title);
}
});
const results = [],
suffixes = (operator.suffixes || [])[0] || [],
caseInsensitive = suffixes.includes("caseinsensitive"),
negate = operator.prefix === "!";
const operand = caseInsensitive ?
operator.operand.toLowerCase() :
operator.operand;
source((tiddler,title) => {
const value = caseInsensitive ? title.toLowerCase() : title,
matches = value.startsWith(operand);
if(negate ? !matches : matches) {
results.push(title);
}
} else {
if(operator.prefix === "!") {
source(function(tiddler,title) {
if(title.substr(0,operator.operand.length) !== operator.operand) {
results.push(title);
}
});
} else {
source(function(tiddler,title) {
if(title.substr(0,operator.operand.length) === operator.operand) {
results.push(title);
}
});
}
}
});
return results;
};
+7 -12
View File
@@ -14,13 +14,13 @@ Export our filter function
*/
exports.sortsub = function(source,operator,options) {
// Compile the subfilter
var filterFn = options.wiki.compileFilter(operator.operand);
let filterFn = options.wiki.compileFilter(operator.operand);
// Collect the input titles and the corresponding sort keys
var inputTitles = [],
let inputTitles = [],
sortKeys = [];
source(function(tiddler,title) {
inputTitles.push(title);
var r = filterFn.call(options.wiki,function(iterator) {
let r = filterFn.call(options.wiki,function(iterator) {
iterator(options.wiki.getTiddler(title),title);
},options.widget.makeFakeWidgetWithVariables({
"currentTiddler": "" + title,
@@ -29,17 +29,12 @@ exports.sortsub = function(source,operator,options) {
sortKeys.push(r[0] || "");
});
// Rather than sorting the titles array, we'll sort the indexes so that we can consult both arrays
var indexes = new Array(inputTitles.length);
for(var t=0; t<inputTitles.length; t++) {
indexes[t] = t;
}
let indexes = Array.from(inputTitles.keys());
// Sort the indexes
var compareFn = $tw.utils.makeCompareFunction(operator.suffix,{defaultType: "string",invert: operator.prefix === "!"});
indexes = indexes.sort(function(a,b) {
return compareFn(sortKeys[a],sortKeys[b]);
});
let compareFn = $tw.utils.makeCompareFunction(operator.suffix,{defaultType: "string",invert: operator.prefix === "!"});
indexes = indexes.sort((a,b) => compareFn(sortKeys[a],sortKeys[b]));
// Make the results array in order
var results = [];
let results = [];
$tw.utils.each(indexes,function(index) {
results.push(inputTitles[index]);
});
+26 -13
View File
@@ -91,22 +91,35 @@ function diffLineWordMode(text1,text2,mode) {
return diffs;
}
exports.makepatches = function(source,operator,options) {
var suffix = operator.suffix || "",
result = [];
source(function(tiddler,title) {
let diffs, patches;
if(suffix === "lines" || suffix === "words") {
diffs = diffLineWordMode(title,operator.operand,suffix);
patches = dmp.patchMake(title,diffs);
exports.makepatches = function(source, operator, options) {
const suffixes = operator.suffixes || [],
[modeArg = [], formatArg = []] = suffixes,
modeSuffix = modeArg[0] || operator.suffix || "",
mode = ["lines", "words"].includes(modeSuffix) ? modeSuffix : "",
isJson = formatArg[0] === "json",
results = [];
source((tiddler, title) => {
if (isJson) {
const diffs = (mode === "lines" || mode === "words")
? diffLineWordMode(title, operator.operand, mode)
: dmp.diffMain(title, operator.operand);
const jsonOutput = diffs.map(([typeCode, text]) => ({
type: typeCode === 1 ? "insert" : (typeCode === -1 ? "delete" : "equal"),
text
}));
results.push(JSON.stringify(jsonOutput));
} else {
patches = dmp.patchMake(title,operator.operand);
const patches = (mode === "lines" || mode === "words")
? dmp.patchMake(title, diffLineWordMode(title, operator.operand, mode))
: dmp.patchMake(title, operator.operand);
results.push(dmp.patchToText(patches));
}
Array.prototype.push.apply(result,[dmp.patchToText(patches)]);
});
return result;
return results;
};
exports.applypatches = makeStringBinaryOperator(
@@ -235,4 +248,4 @@ exports.charcode = function(source,operator,options) {
}
});
return [chars.join("")];
};
};
+5 -4
View File
@@ -13,11 +13,12 @@ Filter operator returning its operand evaluated as a filter
Export our filter function
*/
exports.subfilter = function(source,operator,options) {
var list = options.wiki.filterTiddlers(operator.operand,options.widget,source);
const list = options.wiki.filterTiddlers(operator.operand,options.widget,source);
if(operator.prefix === "!") {
var results = [];
source(function(tiddler,title) {
if(list.indexOf(title) === -1) {
const results = [],
listSet = new Set(list);
source((tiddler,title) => {
if(!listSet.has(title)) {
results.push(title);
}
});
+17 -31
View File
@@ -13,41 +13,27 @@ Filter operator for checking if a title ends with a suffix
Export our filter function
*/
exports.suffix = function(source,operator,options) {
var results = [],
const results = [],
suffixes = (operator.suffixes || [])[0] || [];
if(!operator.operand) {
source(function(tiddler,title) {
source((tiddler,title) => {
results.push(title);
});
} else if(suffixes.indexOf("caseinsensitive") !== -1) {
var operand = operator.operand.toLowerCase();
if(operator.prefix === "!") {
source(function(tiddler,title) {
if(title.toLowerCase().substr(-operand.length) !== operand) {
results.push(title);
}
});
} else {
source(function(tiddler,title) {
if(title.toLowerCase().substr(-operand.length) === operand) {
results.push(title);
}
});
}
} else {
if(operator.prefix === "!") {
source(function(tiddler,title) {
if(title.substr(-operator.operand.length) !== operator.operand) {
results.push(title);
}
});
} else {
source(function(tiddler,title) {
if(title.substr(-operator.operand.length) === operator.operand) {
results.push(title);
}
});
}
return results;
}
const caseInsensitive = suffixes.indexOf("caseinsensitive") !== -1,
negate = operator.prefix === "!",
operand = caseInsensitive ? operator.operand.toLowerCase() : operator.operand;
source((tiddler,title) => {
const value = caseInsensitive ? title.toLowerCase() : title,
matches = value.endsWith(operand);
if(negate ? !matches : matches) {
results.push(title);
}
});
return results;
};
@@ -25,7 +25,7 @@ exports.init = function(parser) {
};
exports.parse = function() {
var reEnd = /(\r?\n```$)/mg;
var reEnd = /(^|\r?\n)```$/mg;
var languageStart = this.parser.pos + 3,
languageEnd = languageStart + this.match[1].length;
// Move past the match
-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,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>
-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">>
@@ -0,0 +1,22 @@
title: Filters/DiffMergePatch4
description: Tests for diff-merge-patch derived operators
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: Output
\whitespace trim
\define text1()
The quick brown fox
\end
\define text2()
The fast brown fox
\end
<$text text={{{ [<text1>makepatches::json<text2>] }}}/>
+
title: ExpectedResult
[{"type":"equal","text":"The "},{"type":"delete","text":"quick"},{"type":"insert","text":"fast"},{"type":"equal","text":" brown fox"}]
@@ -0,0 +1,69 @@
/*\
title: test-codeblock-parser.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests the codeblock wikitext rule (#9047): an empty code block must parse
cleanly, and a closing fence only counts when it stands alone on its line.
\*/
"use strict";
describe("codeblock parser tests (#9047)", function() {
var wiki = new $tw.Wiki();
function parse(text) {
return wiki.parseText("text/vnd.tiddlywiki",text).tree;
}
it("parses an empty code block with a language", function() {
// Browser console: $tw.wiki.parseText("text/vnd.tiddlywiki","```bash\n```").tree
// Expected: one codeblock node, code "", language "bash".
var tree = parse("```bash\n```");
expect(tree.length).toBe(1);
expect(tree[0].type).toBe("codeblock");
expect(tree[0].attributes.code.value).toBe("");
expect(tree[0].attributes.language.value).toBe("bash");
});
it("handles CRLF line endings around both fences", function() {
// Browser console: $tw.wiki.parseText("text/vnd.tiddlywiki","```bash\r\nfoo\r\n```").tree
// Expected: one codeblock node with code "foo"; neither the CRLF after the
// opening fence nor the one before the closing fence is part of the code.
var tree = parse("```bash\r\nfoo\r\n```");
expect(tree.length).toBe(1);
expect(tree[0].type).toBe("codeblock");
expect(tree[0].attributes.code.value).toBe("foo");
});
it("keeps the content of a block and drops the delimiting newlines", function() {
// Browser console: $tw.wiki.parseText("text/vnd.tiddlywiki","```\nfoo\n```").tree
// Expected: one codeblock node with code "foo"; the newlines around the
// delimiter lines are not part of the code.
var tree = parse("```\nfoo\n```");
expect(tree.length).toBe(1);
expect(tree[0].type).toBe("codeblock");
expect(tree[0].attributes.code.value).toBe("foo");
});
it("only closes the block when the fence stands alone on its line", function() {
// A content line ending in ``` is code, not a closing fence, e.g. nested
// markdown fences or template literals quoted inside a code block.
// Browser console: $tw.wiki.parseText("text/vnd.tiddlywiki","```\nabc```\ndef\n```").tree
// Expected: one codeblock containing "abc```\ndef"; nothing leaks out as wikitext.
var tree = parse("```\nabc```\ndef\n```");
expect(tree.length).toBe(1);
expect(tree[0].type).toBe("codeblock");
expect(tree[0].attributes.code.value).toBe("abc```\ndef");
});
it("swallows the rest of the tiddler when no closing fence exists", function() {
// Browser console: $tw.wiki.parseText("text/vnd.tiddlywiki","```bash\nfoo").tree
// Expected: one codeblock node with code "foo", extending to the end of the text.
var tree = parse("```bash\nfoo");
expect(tree.length).toBe(1);
expect(tree[0].type).toBe("codeblock");
expect(tree[0].attributes.code.value).toBe("foo");
});
});
@@ -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();
});
});
});
}
@@ -0,0 +1,83 @@
/*\
title: test-filesystem.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests for $:/core-server filesystem utilities.
\*/
"use strict";
if($tw.node) {
var path = require("path");
describe("generateTiddlerFilepath", function() {
var directory = path.resolve("/tmp/tw5-test-filesystem");
// Characters stripped by the cross-platform-filename regex at
// core-server/filesystem.js:356. Each entry is [char, description].
// The forbidden set includes all characters disallowed by Windows
// (< > : " | ? *), backslash (directory separator on Windows),
// tilde (legacy 8.3 short-name marker), and caret (disallowed in
// some shell/FS contexts).
var forbiddenChars = [
["<","less-than"],
[">","greater-than"],
["~","tilde"],
[":","colon"],
["\"","double-quote"],
["|","pipe"],
["?","question-mark"],
["*","asterisk"],
["^","caret"],
["\\","backslash"]
];
// Use originalpath so we exercise the line-356 regex directly.
// The title-branch at line ~342 pre-strips "/" and "\" before the
// main regex runs, which would mask the backslash case.
function filepathFromOriginalpath(title,extension) {
return $tw.utils.generateTiddlerFilepath(title,{
extension: extension,
directory: directory,
fileInfo: {
overwrite: true,
originalpath: title + extension
}
});
}
forbiddenChars.forEach(function(entry) {
var char = entry[0], name = entry[1];
it("should replace " + name + " (" + char + ") with underscore", function() {
var result = filepathFromOriginalpath("a" + char + "b",".tid");
expect(path.dirname(result)).toBe(directory);
expect(path.basename(result)).toBe("a_b.tid");
});
});
it("should replace every forbidden char in a dense string", function() {
// Prefix with "x" so the sanitized result isn't all underscores,
// which would trigger the charcode-fallback branch at line ~371.
var title = "x" + forbiddenChars.map(function(e) { return e[0]; }).join("");
var expected = "x" + forbiddenChars.map(function() { return "_"; }).join("");
var result = filepathFromOriginalpath(title,".tid");
expect(path.dirname(result)).toBe(directory);
expect(path.basename(result)).toBe(expected + ".tid");
});
it("should sanitize real-world 'Pragma: \\define' title without creating a subdirectory", function() {
// Issue for editions/tw5.com titles like "Pragma: \define".
// Before the fix, backslash survived sanitization and path.resolve
// treated it as a separator on Windows, producing a "Pragma_ "
// subdir containing "define.tid".
var result = filepathFromOriginalpath("Pragma: \\define",".tid");
expect(path.dirname(result)).toBe(directory);
expect(path.basename(result)).toBe("Pragma_ _define.tid");
});
});
}
+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)");
}
@@ -173,5 +173,20 @@ describe("json filter tests", function() {
expect(wiki.filterTiddlers("[{First}format:json[ ]]")).toEqual(["{\n \"a\": \"one\",\n \"b\": \"\",\n \"c\": 1.618,\n \"d\": {\n \"e\": \"four\",\n \"f\": [\n \"five\",\n \"six\",\n true,\n false,\n null\n ]\n }\n}"]);
});
it("should support the makepatches operator with json output", function() {
expect(wiki.filterTiddlers("[[The quick brown fox]makepatches::json[The fast brown fox]]")).toEqual([
'[{"type":"equal","text":"The "},{"type":"delete","text":"quick"},{"type":"insert","text":"fast"},{"type":"equal","text":" brown fox"}]'
]);
expect(wiki.filterTiddlers("[[The quick brown fox]makepatches:words:json[The fast brown fox]]")).toEqual([
'[{"type":"equal","text":"The "},{"type":"delete","text":"quick "},{"type":"insert","text":"fast "},{"type":"equal","text":"brown fox"}]'
]);
// Safely ignores missing/invalid second suffix and returns a standard patch string instead of JSON
expect(wiki.filterTiddlers("[[The quick brown fox]makepatches:words[The fast brown fox]]")[0]).not.toContain(
'"type":"equal"'
);
});
});
@@ -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,5 +1,5 @@
created: 20230304160331362
modified: 20230304160332927
modified: 20260724082228670
tags: [[makepatches Operator]] [[applypatches Operator]] [[Operator Examples]]
title: makepatches and applypatches Operator (Examples)
type: text/vnd.tiddlywiki
@@ -40,4 +40,12 @@ The `lines` mode doesn't work as well in this application:
It is better suited as a very fast algorithm to detect line-wise incremental changes to texts and store only the changes instead of multiple versions of the whole texts.
The `json` suffix outputs a structured JSON array representing the differences instead of a patch string. To use the JSON output with the default character mode, skip the first suffix with a double colon (`::`).
<<.operator-example 8 "[{Hamlet##Shakespeare-old}makepatches::json{Hamlet##Shakespeare-new}]">>
The `json` suffix can also be combined with the `words` or `lines` modes:
<<.operator-example 9 "[{Hamlet##Shakespeare-old}makepatches:words:json{Hamlet##Shakespeare-new}]">>
</div>
@@ -1,23 +1,28 @@
caption: makepatches
created: 20230304122354967
modified: 20230304122400128
op-purpose: returns a set of patches that transform the input to a given string
modified: 20260724081502905
op-input: a [[selection of titles|Title Selection]]
op-output: a set of patch instructions per input title to be used by the [[applypatches Operator]] to transform the input title(s) into the string <<.place S>>, or a structured JSON array representing the differences if the `json` suffix is used
op-parameter: a string of characters
op-parameter-name: S
op-output: a set of patch instructions per input title to be used by the [[applypatches Operator]] to transform the input title(s) into the string <<.place S>>
op-suffix: `lines` to operate in line mode, `words` to operate in word mode. If omitted (default), the algorithm operates in character mode. See notes below.
op-suffix-name: T
op-purpose: returns a set of patches that transform the input to a given string
op-suffix: optional suffixes specifying the diff mode and output format
op-suffix-name: T:F
tags: [[Filter Operators]] [[String Operators]]
title: makepatches Operator
type: text/vnd.tiddlywiki
<<.from-version "5.2.6">>
The <<.op makepatches>> operator uses up to two suffixes:
* <<.place T>> (diff mode): `lines` to operate in line mode, `words` to operate in word mode. If omitted (default), the algorithm operates in character mode.
* <<.place F>> (output format): <<.from-version "5.5.0">> `json` to output a structured JSON array of differences instead of a standard patch string. To use the JSON output with the default character mode, skip the first suffix with a double colon (e.g., `makepatches::json`).
The difference algorithm operates in character mode by default. This produces the most detailed diff possible. In `words` mode, each word in the input text is transformed into a meta-character, upon which the algorithm then operates. In the default character mode, the filter would find two patches between "ActionWidget" and "Action-Widgets" (the hyphen and the plural s), while in `words` mode, the whole word is found to be changed. In `lines` mode, the meta-character is formed from the whole line, delimited by newline characters, and is found to be changed independent of the number of changes within the line.
The different modes influence the result when the patches are applied to texts other than the original, as well as the runtime.
<<.tip "The calculation in `words` mode is roughly 10 times faster than the default character mode, while `lines` mode can be more than 100 times faster than the default.">>
<<.operator-examples "makepatches and applypatches">>
<<.operator-examples "makepatches and applypatches">>
@@ -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`.
@@ -0,0 +1,13 @@
change-category: hackability
change-type: bugfix
created: 20260711181412000
description: Empty code blocks parse cleanly; a closing fence only counts alone on its line
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9739
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9739
type: text/vnd.tiddlywiki
* An empty code block such as ```` ```bash ```` directly followed by the closing fence parses as an empty codeblock instead of swallowing the rest of the tiddler (fixes [[Issue #9047|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9047]])
* A closing fence only ends the block when it stands alone on its line, so code lines ending in three backticks stay inside the block
@@ -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.
@@ -0,0 +1,13 @@
change-category: nodejs
change-type: bugfix
created: 20260711175727000
description: Tiddler titles containing a backslash no longer break generated file paths
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9815
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9815
type: text/vnd.tiddlywiki
* Titles containing a backslash, such as `pragma: \define`, get the backslash replaced like other forbidden filename characters instead of it acting as a Windows path separator (fixes [[Issue #9814|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9814]])
* Tiddler files declared in `tiddlywiki.files` are pinned to their original location and skip the `$:/config/FileSystemPaths` filters and filename sanitising
@@ -0,0 +1,10 @@
title: $:/changenotes/5.5.0/#9891
description: Move background action log inside platforms check to avoid spurious server-side logs
release: 5.5.0
tags: $:/tags/ChangeNote
change-type: bugfix
change-category: internal
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9891
github-contributors: linonetwo
Background actions with `platforms: browser` were logging on the server even though execution was correctly skipped. The log now only prints when the action actually runs.
@@ -0,0 +1,12 @@
change-category: filters
change-type: enhancement
created: 20260724080519370
description: The makepatches operator can now output structured JSON
github-contributors: DesignThinkerer
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9940
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9940
type: text/vnd.tiddlywiki
* The [[makepatches|makepatches Operator]] operator now supports a `:json` suffix (e.g., `makepatches::json`) that outputs a structured JSON array of the differences, enabling easier and robust parsing with WikiText
@@ -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
">>
">>
+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
-31
View File
@@ -8,9 +8,6 @@
"name": "tiddlywiki",
"version": "5.4.1",
"license": "BSD",
"dependencies": {
"chokidar": "^4.0.3"
},
"bin": {
"tiddlywiki": "tiddlywiki.js"
},
@@ -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,
@@ -934,19 +916,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,
+1 -4
View File
@@ -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;