feat: filter serialize CST quote round-trip, formatting options, mvvdisplayinline

- Add titleQuote CST metadata to filter parser for double/single/unquoted
  shorthand title syntax, enabling lossless round-trip serialization
- serializeFilterParseTree() now accepts maxRunsPerLine / wrapAt / indent
  options for formatter/linter support
- Add serializeFilterParseTree() utility (filter.js) to wikitext-serialize plugin
- Add mvvdisplayinline serializer rule to wikitext-serialize plugin
- Add tests for all of the above
- Add 5.5.0 release note folder and change note (PR number TBD)
This commit is contained in:
linonetwo 2026-03-10 20:40:21 +08:00
parent b0d99f3bd3
commit f4799ed6af
7 changed files with 474 additions and 2 deletions

View file

@ -203,9 +203,16 @@ exports.parseFilter = function(filterString) {
p = parseFilterOperation(operation.operators,filterString,p);
}
// Quoted strings and unquoted title
if(match[5] || match[6] || match[7]) { // Double quoted string, single quoted string or unquoted title
// Preserve the original quote style as CST metadata on the operator so
// that a serializer can round-trip the original source without normalizing.
// match[5] = double-quoted, match[6] = single-quoted, match[7] = unquoted
// The original condition (match[5] || match[6] || match[7]) is kept so that
// empty quoted titles "" / '' continue to be ignored, preserving existing behaviour.
if(match[5] || match[6] || match[7]) {
var titleText = match[5] || match[6] || match[7];
var titleQuote = match[5] !== undefined ? "double" : (match[6] !== undefined ? "single" : "none");
operation.operators.push(
{operator: "title", operands: [{text: match[5] || match[6] || match[7]}]}
{operator: "title", operands: [{text: titleText}], titleQuote: titleQuote}
);
}
results.push(operation);

View file

@ -0,0 +1,8 @@
tags: $:/tags/wikitext-serialize-test-spec
title: Serialize/MvvDisplayInline
type: text/vnd.tiddlywiki
((myVar))
((myVar||; ))
((([tag[foo]])))
((([tag[foo]]||; )))

View file

@ -0,0 +1,245 @@
/*\
title: test-filter-serialize.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests the filter expression serialization from filter AST.
\*/
describe("Filter serialization unit tests", function () {
it("should serialize simple operator", function () {
var filter = "[tag[docs]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag[docs]]");
});
it("should serialize negated operator", function () {
var filter = "[!tag[docs]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[!tag[docs]]");
});
it("should serialize chained operators", function () {
var filter = "[tag[docs]sort[title]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag[docs]sort[title]]");
});
it("should serialize multiple runs", function () {
var filter = "[tag[docs]] [tag[other]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag[docs]] [tag[other]]");
});
it("should serialize + prefix", function () {
var filter = "[tag[docs]] +[sort[title]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag[docs]] +[sort[title]]");
});
it("should serialize - prefix", function () {
var filter = "[tag[docs]] -[tag[exclude]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag[docs]] -[tag[exclude]]");
});
it("should serialize ~ prefix", function () {
var filter = "[tag[docs]] ~[tag[fallback]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag[docs]] ~[tag[fallback]]");
});
it("should serialize => prefix", function () {
var filter = "=>[sum[]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("=>[sum[]]");
});
it("should serialize = prefix", function () {
var filter = "=[tag[docs]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("=[tag[docs]]");
});
it("should serialize named prefix :filter", function () {
var filter = "[tag[docs]] :filter[get[text]length[]compare:integer:gteq[100]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag[docs]] :filter[get[text]length[]compare:integer:gteq[100]]");
});
it("should serialize operator suffix with single part", function () {
var filter = "[search:title,text[foo]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[search:title,text[foo]]");
});
it("should serialize operator suffix with multiple parts", function () {
var filter = "[search:title:literal,casesensitive[hello]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[search:title:literal,casesensitive[hello]]");
});
it("should serialize indirect operand {}", function () {
var filter = "[title{CurrentTiddler}]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[title{CurrentTiddler}]");
});
it("should serialize variable operand <>", function () {
var filter = "[tag<myVar>]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag<myVar>]");
});
it("should serialize multi-valued variable operand ()", function () {
var filter = "[tag(myMVV)]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag(myMVV)]");
});
it("should serialize multiple operands", function () {
var filter = "[operator[a],[b]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[operator[a],[b]]");
});
it("should serialize mixed operand types", function () {
var filter = "[operator[a],{b},<c>,(d)]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[operator[a],{b},<c>,(d)]");
});
it("should serialize empty operand", function () {
var filter = "[length[]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[length[]]");
});
it("should serialize empty filter", function () {
var tree = $tw.wiki.parseFilter("");
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("");
});
it("should serialize complex real-world filter", function () {
var filter = "[all[tiddlers]!is[system]sort[title]limit[20]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[all[tiddlers]!is[system]sort[title]limit[20]]");
});
it("should handle null/undefined input", function () {
expect($tw.utils.serializeFilterParseTree(null)).toBe("");
expect($tw.utils.serializeFilterParseTree(undefined)).toBe("");
});
it("should serialize named prefix with suffixes", function () {
var filter = ":reduce:flat[add[]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe(":reduce:flat[add[]]");
});
// --- CST round-trip tests for shorthand title syntax ---
it("should round-trip unquoted title (CST: none)", function () {
var filter = "MyTitle";
var tree = $tw.wiki.parseFilter(filter);
expect(tree[0].operators[0].titleQuote).toBe("none");
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("MyTitle");
});
it("should round-trip double-quoted title (CST: double)", function () {
var filter = "\"My Title\"";
var tree = $tw.wiki.parseFilter(filter);
expect(tree[0].operators[0].titleQuote).toBe("double");
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("\"My Title\"");
});
it("should round-trip single-quoted title (CST: single)", function () {
var filter = "'My Title'";
var tree = $tw.wiki.parseFilter(filter);
expect(tree[0].operators[0].titleQuote).toBe("single");
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("'My Title'");
});
it("should NOT set titleQuote on explicit bracket form [title[...]]", function () {
var filter = "[title[MyTitle]]";
var tree = $tw.wiki.parseFilter(filter);
expect(tree[0].operators[0].titleQuote).toBeUndefined();
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[title[MyTitle]]");
});
it("should round-trip multiple shorthand titles with different quotes", function () {
var filter = "\"Title One\" 'Title Two' TitleThree";
var tree = $tw.wiki.parseFilter(filter);
expect(tree[0].operators[0].titleQuote).toBe("double");
expect(tree[1].operators[0].titleQuote).toBe("single");
expect(tree[2].operators[0].titleQuote).toBe("none");
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("\"Title One\" 'Title Two' TitleThree");
});
it("should serialize shorthand title mixed with a regular run", function () {
var filter = "MyTitle [tag[docs]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("MyTitle [tag[docs]]");
});
// --- Formatting options ---
it("should wrap at maxRunsPerLine", function () {
var filter = "[tag[a]] [tag[b]] [tag[c]] [tag[d]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree, {maxRunsPerLine: 2});
expect(serialized).toBe("[tag[a]] [tag[b]]\n [tag[c]] [tag[d]]");
});
it("should use custom indent string", function () {
var filter = "[tag[a]] [tag[b]] [tag[c]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree, {maxRunsPerLine: 1, indent: "\t"});
expect(serialized).toBe("[tag[a]]\n\t[tag[b]]\n\t[tag[c]]");
});
it("should wrap at wrapAt column width", function () {
var filter = "[tag[alpha]] [tag[beta]] [tag[gamma]]";
var tree = $tw.wiki.parseFilter(filter);
// "[tag[alpha]]" is 12 chars, " [tag[beta]]" would make 24, " [tag[gamma]]" would make 37
// wrapAt:20 → wrap before [tag[beta]]
var serialized = $tw.utils.serializeFilterParseTree(tree, {wrapAt: 20});
expect(serialized).toBe("[tag[alpha]]\n [tag[beta]]\n [tag[gamma]]");
});
it("should not wrap with default options", function () {
var filter = "[tag[a]] [tag[b]] [tag[c]]";
var tree = $tw.wiki.parseFilter(filter);
var serialized = $tw.utils.serializeFilterParseTree(tree);
expect(serialized).toBe("[tag[a]] [tag[b]] [tag[c]]");
});
});

View file

@ -0,0 +1,19 @@
title: $:/changenotes/5.5.0/#PRNUM
description: Filter serialization: CST quote-style preservation and formatting options
tags: $:/tags/ChangeNote
release: 5.5.0
change-type: enhancement
change-category: developer
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/#PRNUM
github-contributors: linonetwo
Extends the filter serialization work introduced in v5.4.0 with two improvements:
* ''CST round-trip for shorthand title syntax'': The filter parser now preserves a `titleQuote` property (`"double"`, `"single"`, or `"none"`) on operators produced by shorthand title syntax (`"My Title"`, `'My Title'`, `MyTitle`). The serializer uses this metadata so that `$tw.utils.serializeFilterParseTree()` faithfully reproduces the original quoting style instead of normalising everything to `[title[...]]`.
* ''Formatting options'': `serializeFilterParseTree(tree, options)` now accepts:
** `maxRunsPerLine` — insert a newline + indent after every N filter runs
** `wrapAt` — wrap at approximately a given column width
** `indent` — the indentation string used when wrapping (default `" "`)
These options lay the groundwork for a future WikiText linter and formatter.

View file

@ -0,0 +1,11 @@
caption: 5.5.0
created: 20260310000000000
modified: 20260310000000000
tags: ReleaseNotes
title: Release 5.5.0
type: text/vnd.tiddlywiki
description: Under development
\procedure release-introduction()
Release v5.5.0.
\end release-introduction

View file

@ -0,0 +1,45 @@
/*\
title: $:/plugins/tiddlywiki/wikitext-serialize/rules/mvvdisplayinline.js
type: application/javascript
module-type: wikiruleserializer
Serializer for the mvvdisplayinline rule.
Variable display: ((varname)) or ((varname||separator))
Filter display: (((filter))) or (((filter||separator)))
The default separator is ", " (comma space).
\*/
"use strict";
exports.name = "mvvdisplayinline";
exports.serialize = function(tree, serialize) {
var filter = tree.attributes.text.filter;
// Variable mode produces: [(varname)join[sep]]
var varMatch = /^\[\(([^()]+)\)join\[([^\]]*)\]\]$/.exec(filter);
if(varMatch) {
var varName = varMatch[1];
var sep = varMatch[2];
if(sep === ", ") {
return "((" + varName + "))";
} else {
return "((" + varName + "||" + sep + "))";
}
}
// Filter mode produces: originalFilter +[join[sep]]
var filterMatch = /^([\s\S]*) \+\[join\[([^\]]*)\]\]$/.exec(filter);
if(filterMatch) {
var innerFilter = filterMatch[1];
var filterSep = filterMatch[2];
if(filterSep === ", ") {
return "(((" + innerFilter + ")))";
} else {
return "(((" + innerFilter + "||" + filterSep + ")))";
}
}
// Fallback: should not occur in normal usage
return "(((" + filter + ")))";
};

View file

@ -0,0 +1,137 @@
/*\
title: $:/plugins/tiddlywiki/wikitext-serialize/utils/filter.js
type: application/javascript
module-type: utils
Filter parse tree serialization utility functions.
Serializes the output of $tw.wiki.parseFilter() back into a filter string.
Why this fits in one file: the filter AST has a uniform, flat schema
every node follows the same shape (run operators operands), unlike
the wikitext AST where each rule creates entirely different node structures.
The filter parser preserves CST (Concrete Syntax Tree) information in the
`titleQuote` property on shorthand title operators so that the original
quote style can be round-tripped:
- `titleQuote: "double"` "My Title"
- `titleQuote: "single"` 'My Title'
- `titleQuote: "none"` MyTitle (unquoted)
- absent / undefined [title[My Title]] (explicit bracket form)
\*/
"use strict";
/*
Serialize a single filter operand back to its bracket string.
*/
const serializeOperand = (operand) => {
if(operand.indirect) return `{${operand.text}}`;
if(operand.variable) return `<${operand.text}>`;
if(operand.multiValuedVariable) return `(${operand.text})`;
return `[${operand.text}]`;
};
/*
Serialize a single filter operator (name + optional suffix + operands) to string.
When the operator is a shorthand title (`titleQuote` present), emit the original
quoting style instead of the canonical bracket form.
*/
const serializeOperator = (operator) => {
// Shorthand title syntax: restore original quote style
if(operator.titleQuote !== undefined && operator.operator === "title" && operator.operands.length === 1 && !operator.prefix) {
const text = operator.operands[0].text;
switch(operator.titleQuote) {
case "double": return `"${text}"`;
case "single": return `'${text}'`;
case "none": return text;
}
}
// Operator negation prefix ("!"), name, optional raw suffix
let result = `${operator.prefix || ""}${operator.operator}`;
if(operator.suffix) {
result += `:${operator.suffix}`;
}
// Operands, comma-separated from the second one onwards
operator.operands.forEach((operand, index) => {
if(index > 0) result += ",";
result += serializeOperand(operand);
});
return result;
};
/*
Serialize a filter parse tree (as returned by $tw.wiki.parseFilter()) back to a
filter string.
Options:
maxRunsPerLine {number} - if set, insert a newline + indent after every N runs
(useful for formatting long filters). Default: unlimited.
indent {string} - indentation string used when wrapping. Default: " ".
wrapAt {number} - if set, wrap at approximately this column width by
inserting a newline before the next run that would exceed
it. Default: unlimited.
*/
exports.serializeFilterParseTree = function serializeFilterParseTree(tree, options) {
if(!$tw.utils.isArray(tree)) return "";
options = options || {};
const indent = options.indent !== undefined ? options.indent : " ";
const maxRunsPerLine = options.maxRunsPerLine || 0;
const wrapAt = options.wrapAt || 0;
const runs = tree.map((operation) => {
// Reconstruct the run prefix: named (:filter, :reduce:flat…) or symbolic (+, -, ~, =, =>)
let prefix = "";
if(operation.namedPrefix) {
prefix = `:${operation.namedPrefix}`;
if(operation.suffixes) {
operation.suffixes.forEach((subsuffix) => {
prefix += `:${subsuffix.join(",")}`;
});
}
} else if(operation.prefix) {
prefix = operation.prefix;
}
// Shorthand title operators are serialized without brackets
const isTitleShorthand = operation.operators.length === 1 &&
operation.operators[0].titleQuote !== undefined &&
operation.operators[0].operator === "title" &&
!operation.operators[0].prefix &&
operation.operators[0].operands.length === 1;
if(isTitleShorthand) {
return prefix + serializeOperator(operation.operators[0]);
}
const operatorsStr = operation.operators.map(serializeOperator).join("");
return `${prefix}[${operatorsStr}]`;
});
// Simple join if no wrapping requested
if(!maxRunsPerLine && !wrapAt) {
return runs.join(" ");
}
// Apply wrapping
let output = "";
let lineLen = 0;
let runsOnLine = 0;
runs.forEach((run, index) => {
const sep = index === 0 ? "" : " ";
const candidate = sep + run;
const needsWrap = (maxRunsPerLine && runsOnLine >= maxRunsPerLine) ||
(wrapAt && lineLen + candidate.length > wrapAt && index > 0);
if(needsWrap) {
output += "\n" + indent + run;
lineLen = indent.length + run.length;
runsOnLine = 1;
} else {
output += candidate;
lineLen += candidate.length;
runsOnLine++;
}
});
return output;
};