/**
* Create list tag around list items and map wiki bullet levels to html
* @module
*/
'use strict';
const { JSUtils } = require('../../utils/jsutils.js');
const TokenHandler = require('./TokenHandler.js');
const { TokenUtils } = require('../../utils/TokenUtils.js');
const { Util } = require('../../utils/Util.js');
const { TagTk, EndTagTk, NlTk } = require('../../tokens/TokenTypes.js');
const lastItem = JSUtils.lastItem;
/**
* @class
* @extends module:wt2html/tt/TokenHandler
*/
class ListHandler extends TokenHandler {
static BULLET_CHARS_MAP() {
return {
'*': { list: 'ul', item: 'li' },
'#': { list: 'ol', item: 'li' },
';': { list: 'dl', item: 'dt' },
':': { list: 'dl', item: 'dd' },
};
}
constructor(manager, options) {
super(manager, options);
this.listFrames = [];
this.reset();
}
reset() {
this.onAnyEnabled = false;
this.nestedTableCount = 0;
this.resetCurrListFrame();
}
resetCurrListFrame() {
this.currListFrame = null;
}
onTag(token) {
return (token.name === 'listItem') ? this.onListItem(token) : token;
}
onAny(token) {
this.env.log("trace/list", this.manager.pipelineId,
"ANY:", function() { return JSON.stringify(token); });
var tokens;
if (!this.currListFrame) {
// this.currListFrame will be null only when we are in a table
// that in turn was seen in a list context.
//
// Since we are not in a list within the table, nothing to do.
// Just send the token back unchanged.
if (token.constructor === EndTagTk && token.name === 'table') {
if (this.nestedTableCount === 0) {
this.currListFrame = this.listFrames.pop();
} else {
this.nestedTableCount--;
}
} else if (token.constructor === TagTk && token.name === 'table') {
this.nestedTableCount++;
}
return { tokens: [token] };
}
// Keep track of open tags per list frame in order to prevent colons
// starting lists illegally. Php's findColonNoLinks.
if (token.constructor === TagTk &&
// Table tokens will push the frame and remain balanced.
// They're safe to ignore in the bookkeeping.
token.name !== "table"
) {
this.currListFrame.numOpenTags += 1;
} else if (token.constructor === EndTagTk && this.currListFrame.numOpenTags > 0) {
this.currListFrame.numOpenTags -= 1;
}
if (token.constructor === EndTagTk) {
if (token.name === 'table') {
// close all open lists and pop a frame
var ret = this.closeLists(token);
this.currListFrame = this.listFrames.pop();
return { tokens: ret };
} else if (TokenUtils.isBlockTag(token.name)) {
if (this.currListFrame.numOpenBlockTags === 0) {
// Unbalanced closing block tag in a list context ==> close all previous lists
return { tokens: this.closeLists(token) };
} else {
this.currListFrame.numOpenBlockTags--;
return { tokens: [token] };
}
}
/* Non-block tag -- fall-through to other tests below */
}
if (this.currListFrame.atEOL) {
if (token.constructor !== NlTk && TokenUtils.isSolTransparent(this.env, token)) {
// Hold on to see where the token stream goes from here
// - another list item, or
// - end of list
if (this.currListFrame.nlTk) {
this.currListFrame.solTokens.push(this.currListFrame.nlTk);
this.currListFrame.nlTk = null;
}
this.currListFrame.solTokens.push(token);
return { tokens: [] };
} else {
// Non-list item in newline context ==> close all previous lists
tokens = this.closeLists(token);
return { tokens: tokens };
}
}
if (token.constructor === NlTk) {
this.currListFrame.atEOL = true;
this.currListFrame.nlTk = token;
// php's findColonNoLinks is run in doBlockLevels, which examines
// the text line-by-line. At nltk, any open tags will cease having
// an effect.
this.currListFrame.numOpenTags = 0;
return { tokens: [] };
}
if (token.constructor === TagTk) {
if (token.name === 'table') {
this.listFrames.push(this.currListFrame);
this.resetCurrListFrame();
} else if (TokenUtils.isBlockTag(token.name)) {
this.currListFrame.numOpenBlockTags++;
}
return { tokens: [token] };
}
// Nothing else left to do
return { tokens: [token] };
}
newListFrame() {
return {
atEOL: true, // flag indicating a list-less line that terminates a list block
nlTk: null, // NlTk that triggered atEOL
solTokens: [],
bstack: [], // Bullet stack, previous element's listStyle
endtags: [], // Stack of end tags
// Partial DOM building heuristic
// # of open block tags encountered within list context
numOpenBlockTags: 0,
// # of open tags encountered within list context
numOpenTags: 0,
};
}
onEnd(token) {
this.env.log("trace/list", this.manager.pipelineId,
"END:", function() { return JSON.stringify(token); });
this.listFrames = [];
if (!this.currListFrame) {
// init here so we dont have to have a check in closeLists
// That way, if we get a null frame there, we know we have a bug.
this.currListFrame = this.newListFrame();
}
var toks = this.closeLists(token);
this.reset();
return { tokens: toks };
}
closeLists(token) {
// pop all open list item tokens
var tokens = this.popTags(this.currListFrame.bstack.length);
// purge all stashed sol-tokens
tokens = tokens.concat(this.currListFrame.solTokens);
if (this.currListFrame.nlTk) {
tokens.push(this.currListFrame.nlTk);
}
tokens.push(token);
// remove any transform if we dont have any stashed list frames
if (this.listFrames.length === 0) {
this.onAnyEnabled = false;
}
this.resetCurrListFrame();
this.env.log("trace/list", this.manager.pipelineId, "----closing all lists----");
this.env.log("trace/list", this.manager.pipelineId, "RET", tokens);
return tokens;
}
onListItem(token) {
if (token.constructor === TagTk) {
this.onAnyEnabled = true;
if (this.currListFrame) {
// Ignoring colons inside tags to prevent illegal overlapping.
// Attempts to mimic findColonNoLinks in the php parser.
if (lastItem(token.getAttribute('bullets')) === ':' &&
this.currListFrame.numOpenTags > 0
) {
return { tokens: [":"] };
}
} else {
this.currListFrame = this.newListFrame();
}
// convert listItem to list and list item tokens
var res = this.doListItem(this.currListFrame.bstack, token.getAttribute('bullets'), token);
return { tokens: res, skipOnAny: true };
}
return { tokens: [token] };
}
commonPrefixLength(x, y) {
var minLength = Math.min(x.length, y.length);
var i = 0;
for (; i < minLength; i++) {
if (x[i] !== y[i]) {
break;
}
}
return i;
}
pushList(container, liTok, dp1, dp2) {
this.currListFrame.endtags.push(new EndTagTk(container.list));
this.currListFrame.endtags.push(new EndTagTk(container.item));
return [
new TagTk(container.list, [], dp1),
new TagTk(container.item, [], dp2),
];
}
popTags(n) {
var tokens = [];
while (n > 0) {
// push list item..
tokens.push(this.currListFrame.endtags.pop());
// and the list end tag
tokens.push(this.currListFrame.endtags.pop());
n--;
}
return tokens;
}
isDtDd(a, b) {
var ab = [a, b].sort();
return (ab[0] === ':' && ab[1] === ';');
}
doListItem(bs, bn, token) {
this.env.log("trace/list", this.manager.pipelineId,
"BEGIN:", function() { return JSON.stringify(token); });
var prefixLen = this.commonPrefixLength(bs, bn);
var prefix = bn.slice(0, prefixLen);
var dp = token.dataAttribs;
var tsr = dp.tsr;
var makeDP = function(k, j) {
var newTSR;
if (tsr) {
newTSR = [ tsr[0] + k, tsr[0] + j ];
} else {
newTSR = undefined;
}
var newDP = Util.clone(dp);
newDP.tsr = newTSR;
return newDP;
};
this.currListFrame.bstack = bn;
var res, itemToken;
// emit close tag tokens for closed lists
this.env.log("trace/list", this.manager.pipelineId, function() {
return " bs: " + JSON.stringify(bs) + "; bn: " + JSON.stringify(bn);
});
if (prefix.length === bs.length && bn.length === bs.length) {
this.env.log("trace/list", this.manager.pipelineId, " -> no nesting change");
// same list item types and same nesting level
itemToken = this.currListFrame.endtags.pop();
this.currListFrame.endtags.push(new EndTagTk(itemToken.name));
res = [ itemToken ].concat(
this.currListFrame.solTokens,
[
// this list item gets all the bullets since this is
// a list item at the same level
//
// **a
// **b
this.currListFrame.nlTk || '',
new TagTk(itemToken.name, [], makeDP(0, bn.length)),
]
);
} else {
var prefixCorrection = 0;
var tokens = [];
if (bs.length > prefixLen &&
bn.length > prefixLen &&
this.isDtDd(bs[prefixLen], bn[prefixLen])) {
/* ------------------------------------------------
* Handle dd/dt transitions
*
* Example:
*
* **;:: foo
* **::: bar
*
* the 3rd bullet is the dt-dd transition
* ------------------------------------------------ */
tokens = this.popTags(bs.length - prefixLen - 1);
tokens = this.currListFrame.solTokens.concat(tokens);
var newName = ListHandler.BULLET_CHARS_MAP()[bn[prefixLen]].item;
var endTag = this.currListFrame.endtags.pop();
this.currListFrame.endtags.push(new EndTagTk(newName));
var newTag;
if (dp.stx === 'row') {
// stx='row' is only set for single-line dt-dd lists (see tokenizer)
// In this scenario, the dd token we are building a token for has no prefix
// Ex: ;a:b, *;a:b, #**;a:b, etc. Compare with *;a\n*:b, #**;a\n#**:b
this.env.log("trace/list", this.manager.pipelineId, " -> single-line dt->dd transition");
newTag = new TagTk(newName, [], makeDP(0, 1));
} else {
this.env.log("trace/list", this.manager.pipelineId, " -> other dt/dd transition");
newTag = new TagTk(newName, [], makeDP(0, prefixLen + 1));
}
tokens = tokens.concat([ endTag, this.currListFrame.nlTk || '', newTag ]);
prefixCorrection = 1;
} else {
this.env.log("trace/list", this.manager.pipelineId, " -> reduced nesting");
tokens = tokens.concat(this.popTags(bs.length - prefixLen));
tokens = this.currListFrame.solTokens.concat(tokens);
if (this.currListFrame.nlTk) {
tokens.push(this.currListFrame.nlTk);
}
if (prefixLen > 0 && bn.length === prefixLen) {
itemToken = this.currListFrame.endtags.pop();
tokens.push(itemToken);
// this list item gets all bullets upto the shared prefix
tokens.push(new TagTk(itemToken.name, [], makeDP(0, bn.length)));
this.currListFrame.endtags.push(new EndTagTk(itemToken.name));
}
}
for (var i = prefixLen + prefixCorrection; i < bn.length; i++) {
if (!ListHandler.BULLET_CHARS_MAP()[bn[i]]) {
throw "Unknown node prefix " + prefix[i];
}
// Each list item in the chain gets one bullet.
// However, the first item also includes the shared prefix.
//
// Example:
//
// **a
// ****b
//
// Yields:
//
// <ul><li-*>
// <ul><li-*>a
// <ul><li-FIRST-ONE-gets-***>
// <ul><li-*>b</li></ul>
// </li></ul>
// </li></ul>
// </li></ul>
//
// Unless prefixCorrection is > 0, in which case we've
// already accounted for the initial bullets.
//
// prefixCorrection is for handling dl-dts like this
//
// ;a:b
// ;;c:d
//
// ";c:d" is embedded within a dt that is 1 char wide(;)
var listDP, listItemDP;
if (i === prefixLen) {
this.env.log("trace/list", this.manager.pipelineId,
" -> increased nesting: first");
listDP = makeDP(0, 0);
listItemDP = makeDP(0, i + 1);
} else {
this.env.log("trace/list", this.manager.pipelineId,
" -> increased nesting: 2nd and higher");
listDP = makeDP(i, i);
listItemDP = makeDP(i, i + 1);
}
tokens = tokens.concat(this.pushList(
ListHandler.BULLET_CHARS_MAP()[bn[i]], token, listDP, listItemDP
));
}
res = tokens;
}
// clear out sol-tokens
this.currListFrame.solTokens = [];
this.currListFrame.nlTk = null;
this.currListFrame.atEOL = false;
this.env.log("trace/list", this.manager.pipelineId,
"RET:", function() { return JSON.stringify(res); });
return res;
}
}
if (typeof module === "object") {
module.exports.ListHandler = ListHandler;
}