/** @module */
'use strict';
const TokenHandler = require('./TokenHandler.js');
const { DOMDataUtils } = require('../../utils/DOMDataUtils.js');
const { TokenUtils } = require('../../utils/TokenUtils.js');
const { Util } = require('../../utils/Util.js');
const { PipelineUtils } = require('../../utils/PipelineUtils.js');
/**
* @class
* @extends module:wt2html/tt/TokenHandler
*/
class ExtensionHandler extends TokenHandler {
constructor(manager, options) {
super(manager, options);
// Extension content expansion
this.manager.addTransform(
(token, cb) => this.onExtension(token, cb),
"ExtensionHandler:onExtension", ExtensionHandler.rank(),
'tag', 'extension'
);
}
static rank() { return 1.11; }
/**
* Parse the extension HTML content and wrap it in a DOMFragment
* to be expanded back into the top-level DOM later.
*/
parseExtensionHTML(extToken, cb, err, doc) {
let errType = '';
let errObj = {};
if (err) {
doc = this.env.createDocument('<span></span>');
doc.body.firstChild.appendChild(doc.createTextNode(extToken.getAttribute('source')));
errType = 'mw:Error ';
// Provide some info in data-mw in case some client can do something with it.
errObj = {
errors: [
{
key: 'mw-api-extparse-error',
message: 'Could not parse extension source.',
},
],
};
this.env.log(
'error/extension', 'Error', err, ' parsing extension token: ',
JSON.stringify(extToken)
);
}
const psd = this.manager.env.conf.parsoid;
if (psd.dumpFlags && psd.dumpFlags.has("extoutput")) {
console.warn("=".repeat(80));
console.warn("EXTENSION INPUT: " + extToken.getAttribute('source'));
console.warn("=".repeat(80));
console.warn("EXTENSION OUTPUT:\n");
console.warn(doc.body.outerHTML);
console.warn("-".repeat(80));
}
// document -> html -> body -> children
const state = {
token: extToken,
wrapperName: extToken.getAttribute('name'),
// We are always wrapping extensions with the DOMFragment mechanism.
wrappedObjectId: this.env.newObjectId(),
wrapperType: errType + 'mw:Extension/' + extToken.getAttribute('name'),
wrapperDataMw: errObj,
isHtmlExt: (extToken.getAttribute('name') === 'html'),
};
// DOMFragment-based encapsulation.
this._onDocument(state, cb, doc);
}
/**
* Fetch the preprocessed wikitext for an extension.
*/
fetchExpandedExtension(text, parentCB, cb) {
const env = this.env;
// We are about to start an async request for an extension
env.log('debug', 'Note: trying to expand ', text);
parentCB({ async: true });
// Pass the page title to the API.
const title = env.page.name || '';
env.batcher.parse(title, text).nodify(cb);
}
static normalizeExtOptions(options) {
// Mimics Sanitizer::decodeTagAttributes from the PHP parser
//
// Extension options should always be interpreted as plain text. The
// tokenizer parses them to tokens in case they are for an HTML tag,
// but here we use the text source instead.
const n = options.length;
for (let i = 0; i < n; i++) {
const o = options[i];
if (!o.v && !o.vsrc) {
continue;
}
// Use the source if present. If not use the value, but ensure it's a
// string, as it can be a token stream if the parser has recognized it
// as a directive.
const v = o.vsrc || TokenUtils.tokensToString(o.v, false, { includeEntities: true });
// Normalize whitespace in extension attribute values
// FIXME: If the option is parsed as wikitext, this normalization
// can mess with src offsets.
o.v = v.replace(/[\t\r\n ]+/g, ' ').trim();
// Decode character references
o.v = Util.decodeWtEntities(o.v);
}
return options;
}
onExtension(token, cb) {
const env = this.env;
const extensionName = token.getAttribute('name');
const nativeExt = env.conf.wiki.extConfig.tags.get(extensionName);
// TODO: use something order/quoting etc independent instead of src
const cachedExpansion = env.extensionCache[token.dataAttribs.src];
const options = token.getAttribute('options');
token.setAttribute('options', ExtensionHandler.normalizeExtOptions(options));
if (nativeExt && nativeExt.toDOM) {
const extContent = Util.extractExtBody(token);
const extArgs = token.getAttribute('options');
const state = {
extToken: token,
// FIXME: This is only used by extapi.js
// but leaks to extensions right now
frame: this.manager.frame,
env: this.manager.env,
// FIXME: extTag, extTagOpts, inTemplate are used
// by extensions. Should we directly export those
// instead?
parseContext: this.options,
};
const p = nativeExt.toDOM(state, extContent, extArgs);
if (p) {
// Pass an async signal since the ext-content won't be processed synchronously
cb({ async: true });
p.nodify((err, doc) => this.parseExtensionHTML(token, cb, err, doc));
} else {
// The extension dropped this instance completely (!!)
// Should be a rarity and presumably the extension
// knows what it is doing. Ex: nested refs are dropped
// in some scenarios.
cb({ tokens: [], async: false });
}
} else if (cachedExpansion) {
// cache hit. Reuse extension expansion.
const toks = PipelineUtils.encapsulateExpansionHTML(env, token, cachedExpansion, {
fromCache: true,
});
cb({ tokens: toks });
} else if (env.conf.parsoid.expandExtensions) {
// Use MediaWiki's action=parse
this.fetchExpandedExtension(
token.getAttribute('source'),
cb,
(err, html) => {
const doc = err ? null : env.createDocument(html);
this.parseExtensionHTML(token, cb, err, doc);
}
);
} else {
const err = new Error("`expandExtensions` is disabled.");
this.parseExtensionHTML(token, cb, err, null);
}
}
_onDocument(state, cb, doc) {
const env = this.manager.env;
const argDict = Util.getExtArgInfo(state.token).dict;
var extTagOffsets = state.token.dataAttribs.extTagOffsets;
if (extTagOffsets[3] === 0) {
argDict.body = undefined; // Serialize to self-closing.
}
// Give native extensions a chance to manipulate the argDict
const nativeExt = env.conf.wiki.extConfig.tags.get(state.wrapperName);
if (nativeExt && nativeExt.modifyArgDict) {
nativeExt.modifyArgDict(env, argDict);
}
const opts = Object.assign({
setDSR: true, // FIXME: This is the only place that sets this ...
wrapperName: state.wrapperName,
// Check if the tag wants its DOM fragment not to be unwrapped.
// The default setting is to unwrap the content DOM fragment automatically.
}, nativeExt && nativeExt.fragmentOptions);
const body = doc.body;
// This special case is only because, from the beginning, Parsoid has
// treated <nowiki>s as core functionality with lean markup (no about,
// no data-mw, custom typeof).
//
// We'll keep this hardcoded to avoid exposing the functionality to
// other native extensions until it's needed.
if (state.wrapperName !== 'nowiki') {
if (!body.hasChildNodes()) {
// RT extensions expanding to nothing.
body.appendChild(body.ownerDocument.createElement('link'));
}
// Wrap the top-level nodes so that we have a firstNode element
// to annotate with the typeof and to apply about ids.
PipelineUtils.addSpanWrappers(body.childNodes);
// Now get the firstNode
const firstNode = body.firstChild;
// Adds the wrapper attributes to the first element
firstNode.setAttribute('typeof', state.wrapperType);
// Add about to all wrapper tokens.
const about = env.newAboutId();
let n = firstNode;
while (n) {
n.setAttribute('about', about);
n = n.nextSibling;
}
// Set data-mw
DOMDataUtils.setDataMw(
firstNode,
Object.assign(state.wrapperDataMw || {}, argDict)
);
// Update data-parsoid
const dp = DOMDataUtils.getDataParsoid(firstNode);
dp.tsr = Util.clone(state.token.dataAttribs.tsr);
dp.src = state.token.dataAttribs.src;
DOMDataUtils.setDataParsoid(firstNode, dp);
}
const toks = PipelineUtils.tunnelDOMThroughTokens(env, state.token, body, opts);
if (state.isHtmlExt) {
toks[0].dataAttribs.tmp = toks[0].dataAttribs.tmp || {};
toks[0].dataAttribs.tmp.isHtmlExt = true;
}
cb({ tokens: toks });
}
}
if (typeof module === "object") {
module.exports.ExtensionHandler = ExtensionHandler;
}