/**
* Implements the php parser's `renderImageGallery` natively.
*
* Params to support (on the extension tag):
* - showfilename
* - caption
* - mode
* - widths
* - heights
* - perrow
*
* A proposed spec is at: https://phabricator.wikimedia.org/P2506
* @module ext/Gallery
*/
'use strict';
const ParsoidExtApi = module.parent.require('./extapi.js').versionCheck('^0.11.0');
const {
ContentUtils,
DOMDataUtils,
DOMUtils,
parseWikitextToDOM,
Promise,
Sanitizer,
TokenUtils,
Util,
} = ParsoidExtApi;
var modes = require('./modes.js');
/**
* @class
*/
class Opts {
constructor(env, attrs) {
Object.assign(this, env.conf.wiki.siteInfo.general.galleryoptions);
var perrow = parseInt(attrs.perrow, 10);
if (!Number.isNaN(perrow)) { this.imagesPerRow = perrow; }
var maybeDim = Util.parseMediaDimensions(String(attrs.widths), true);
if (maybeDim && Util.validateMediaParam(maybeDim.x)) {
this.imageWidth = maybeDim.x;
}
maybeDim = Util.parseMediaDimensions(String(attrs.heights), true);
if (maybeDim && Util.validateMediaParam(maybeDim.x)) {
this.imageHeight = maybeDim.x;
}
var mode = (attrs.mode || '').toLowerCase();
if (modes.has(mode)) { this.mode = mode; }
this.showfilename = (attrs.showfilename !== undefined);
this.showthumbnails = (attrs.showthumbnails !== undefined);
this.caption = attrs.caption;
// TODO: Good contender for T54941
const validUlAttrs = Sanitizer.attributeWhitelist('ul');
this.attrs = Object.keys(attrs)
.filter(function(k) { return validUlAttrs.has(k); })
.reduce(function(o, k) {
o[k] = (k === 'style') ? Sanitizer.checkCss(attrs[k]) : attrs[k];
return o;
}, {});
}
}
/**
* Native Parsoid implementation of the Gallery extension.
*/
class Gallery {
constructor() {
this.config = {
tags: [
{
name: 'gallery',
toDOM: Gallery.toDOM,
modifyArgDict: Gallery.modifyArgDict,
serialHandler: Gallery.serialHandler(),
},
],
styles: ['mediawiki.page.gallery.styles'],
};
}
static *pCaption(data) {
const { state } = data;
var options = state.extToken.getAttribute('options');
var caption = options.find(function(kv) {
return kv.k === 'caption';
});
if (caption === undefined || !caption.v) { return null; }
// `normalizeExtOptions` messes up src offsets, so we do our own
// normalization to avoid parsing sol blocks
const capV = caption.vsrc.replace(/[\t\r\n ]/g, ' ');
const doc = yield parseWikitextToDOM(
state,
capV,
{
pipelineOpts: {
extTag: 'gallery',
inTemplate: state.parseContext.inTemplate,
// FIXME: This needs more analysis. Maybe it's inPHPBlock
inlineContext: true,
},
srcOffsets: caption.srcOffsets.slice(2),
},
false // Gallery captions are deliberately not parsed in SOL context
);
// Store before `migrateChildrenBetweenDocs` in render
DOMDataUtils.visitAndStoreDataAttribs(doc.body);
return doc.body;
}
static *pLine(data, obj) {
const { state, opts } = data;
const env = state.env;
// Regexp from php's `renderImageGallery`
var matches = obj.line.match(/^([^|]+)(\|(?:.*))?$/);
if (!matches) { return null; }
var text = matches[1];
var caption = matches[2] || '';
// TODO: % indicates rawurldecode.
var title = env.makeTitleFromText(text,
env.conf.wiki.canonicalNamespaces.file, true);
if (title === null || !title.getNamespace().isFile()) {
return null;
}
// FIXME: Try to confirm `file` isn't going to break WikiLink syntax.
// See the check for 'FIGURE' below.
var file = title.getPrefixedDBKey();
var mode = modes.get(opts.mode);
// NOTE: We add "none" here so that this renders in the block form
// (ie. figure) for an easier structure to manipulate.
var start = '[[';
var middle = '|' + mode.dimensions(opts) + '|none';
var end = ']]';
var wt = start + file + middle + caption + end;
// This is all in service of lining up the caption
var shiftOffset = function(offset) {
offset -= start.length;
if (offset <= 0) { return null; }
if (offset <= file.length) {
// Align file part
return obj.offset + offset;
}
offset -= file.length;
offset -= middle.length;
if (offset <= 0) { return null; }
if (offset <= caption.length) {
// Align caption part
return obj.offset + text.length + offset;
}
return null;
};
const doc = yield parseWikitextToDOM(
state,
wt,
{
pipelineOpts: {
extTag: 'gallery',
inTemplate: state.parseContext.inTemplate,
// FIXME: This needs more analysis. Maybe it's inPHPBlock
inlineContext: true,
},
frame: state.frame.newChild(state.frame.title, [], wt),
srcOffsets: [0, wt.length],
},
true // sol
);
var body = doc.body;
// Now shift the DSRs in the DOM by startOffset, and strip DSRs
// for bits which aren't the caption or file, since they
// don't refer to actual source wikitext
ContentUtils.shiftDSR(env, body, (dsr) => {
dsr[0] = shiftOffset(dsr[0]);
dsr[1] = shiftOffset(dsr[1]);
// If either offset is invalid, remove entire DSR
if (dsr[0] === null || dsr[1] === null) { return null; }
return dsr;
});
var thumb = body.firstChild;
if (thumb.nodeName !== 'FIGURE') {
return null;
}
var rdfaType = thumb.getAttribute('typeof');
// Detach from document
thumb.remove();
// Detach figcaption as well
var figcaption = thumb.querySelector('figcaption');
if (!figcaption) {
figcaption = doc.createElement('figcaption');
} else {
figcaption.remove();
}
if (opts.showfilename) {
var galleryfilename = doc.createElement('a');
galleryfilename.setAttribute('href', env.makeLink(title));
galleryfilename.setAttribute('class', 'galleryfilename galleryfilename-truncate');
galleryfilename.setAttribute('title', file);
galleryfilename.appendChild(doc.createTextNode(file));
figcaption.insertBefore(galleryfilename, figcaption.firstChild);
}
var gallerytext = null;
for (
let capChild = figcaption.firstChild;
capChild !== null;
capChild = capChild.nextSibling
) {
if (DOMUtils.isText(capChild) && /^\s*$/.test(capChild.nodeValue)) {
continue; // skip blank text nodes
}
// Found a non-blank node!
gallerytext = figcaption;
break;
}
if (gallerytext) {
// Store before `migrateChildrenBetweenDocs` in render
DOMDataUtils.visitAndStoreDataAttribs(gallerytext);
}
return { thumb: thumb, gallerytext: gallerytext, rdfaType: rdfaType };
}
static toDOM(state, content, args) {
const attrs = TokenUtils.kvToHash(args, true);
const opts = new Opts(state.env, attrs);
// Pass this along the promise chain ...
const data = {
state,
opts,
};
const dataAttribs = state.extToken.dataAttribs;
let offset =
dataAttribs.extTagOffsets[0] + dataAttribs.extTagOffsets[2];
// Prepare the lines for processing
const lines = content.split('\n')
.map(function(line, ind) {
const obj = { line: line, offset: offset };
offset += line.length + 1; // For the nl
return obj;
});
return Promise.join(
(opts.caption === undefined) ? null : Gallery.pCaption(data),
Promise.map(lines, line => Gallery.pLine(data, line))
)
.then(function(ret) {
// Drop invalid lines like "References: 5."
const oLines = ret[1].filter(function(o) {
return o !== null;
});
const mode = modes.get(opts.mode);
const doc = mode.render(state.env, opts, ret[0], oLines);
// Reload now that `migrateChildrenBetweenDocs` is done
DOMDataUtils.visitAndLoadDataAttribs(doc.body);
return doc;
});
}
static *contentHandler(node, state) {
var content = '\n';
for (var child = node.firstChild; child; child = child.nextSibling) {
switch (child.nodeType) {
case child.ELEMENT_NODE:
// Ignore if it isn't a "gallerybox"
if (child.nodeName !== 'LI' ||
child.getAttribute('class') !== 'gallerybox') {
break;
}
var thumb = child.querySelector('.thumb');
if (!thumb) { break; }
// FIXME: The below would benefit from a refactoring that
// assumes the figure structure, as in the link handler.
var elt = DOMUtils.selectMediaElt(thumb);
if (elt) {
// FIXME: Should we preserve the original namespace? See T151367
if (elt.hasAttribute('resource')) {
const resource = elt.getAttribute('resource');
content += resource.replace(/^\.\//, '');
// FIXME: Serializing of these attributes should
// match the link handler so that values stashed in
// data-mw aren't ignored.
if (elt.hasAttribute('alt')) {
const alt = elt.getAttribute('alt');
content += '|alt=' + state.serializer.wteHandlers.escapeLinkContent(state, alt, false, child, true);
}
// The first "a" is for the link, hopefully.
const a = thumb.querySelector('a');
if (a && a.hasAttribute('href')) {
const href = a.getAttribute('href');
if (href !== resource) {
content += '|link=' + state.serializer.wteHandlers.escapeLinkContent(state, href.replace(/^\.\//, ''), false, child, true);
}
}
}
} else {
// TODO: Previously (<=1.5.0), we rendered valid titles
// returning mw:Error (apierror-filedoesnotexist) as
// plaintext. Continue to serialize this content until
// that version is no longer supported.
content += thumb.textContent;
}
var gallerytext = child.querySelector('.gallerytext');
if (gallerytext) {
var showfilename = gallerytext.querySelector('.galleryfilename');
if (showfilename) {
showfilename.remove(); // Destructive to the DOM!
}
state.singleLineContext.enforce();
var caption =
yield state.serializeCaptionChildrenToString(
gallerytext,
state.serializer.wteHandlers.wikilinkHandler
);
state.singleLineContext.pop();
// Drop empty captions
if (!/^\s*$/.test(caption)) {
content += '|' + caption;
}
}
content += '\n';
break;
case child.TEXT_NODE:
case child.COMMENT_NODE:
// Ignore it
break;
default:
console.assert(false, 'Should not be here!');
break;
}
}
return content;
}
static serialHandler() {
return {
handle: Promise.async(function *(node, state, wrapperUnmodified) {
var dataMw = DOMDataUtils.getDataMw(node);
dataMw.attrs = dataMw.attrs || {};
// Handle the "gallerycaption" first
var galcaption = node.querySelector('li.gallerycaption');
if (galcaption &&
// FIXME: VE should signal to use the HTML by removing the
// `caption` from data-mw.
typeof dataMw.attrs.caption !== 'string') {
dataMw.attrs.caption =
yield state.serializeCaptionChildrenToString(
galcaption,
state.serializer.wteHandlers.mediaOptionHandler
);
}
var startTagSrc =
yield state.serializer.serializeExtensionStartTag(node, state);
if (!dataMw.body) {
return startTagSrc; // We self-closed this already.
} else {
var content;
// FIXME: VE should signal to use the HTML by removing the
// `extsrc` from the data-mw.
if (typeof dataMw.body.extsrc === 'string') {
content = dataMw.body.extsrc;
} else {
content = yield Gallery.contentHandler(node, state);
}
return startTagSrc + content + '</' + dataMw.name + '>';
}
}),
};
}
static modifyArgDict(env, argDict) {
// FIXME: Only remove after VE switches to editing HTML.
if (env.conf.parsoid.nativeGallery) {
// Remove extsrc from native extensions
argDict.body.extsrc = undefined;
// Remove the caption since it's redundant with the HTML
// and we prefer editing it there.
argDict.attrs.caption = undefined;
}
}
}
Gallery.pLine = Promise.async(Gallery.pLine);
Gallery.pCaption = Promise.async(Gallery.pCaption);
Gallery.contentHandler = Promise.async(Gallery.contentHandler);
if (typeof module === 'object') {
module.exports = Gallery;
}