-
Notifications
You must be signed in to change notification settings - Fork 2
/
index.js
401 lines (370 loc) · 12.8 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
'use strict';
const Promise = require('bluebird');
const _ = require('lodash');
const defaultOptions = {start: '[[', end: ']]'};
const xGetAttributes = new RegExp('(\\S+)\\s*=\\s*([\\\'\\"])(.*?)\\2|(\\S+)\\s*=\\s*(\\S+)|([^\\\'^\\"^\\s]+)(?:\\s|$)|([\\\'\\"])(.*?)\\7', 'g');
const xGetTagAttributesText = '{start}.*?\\s(.*?){end}';
const xStartTagContents = '{start}(.*){end}';
const xTagMatch = '{start}.*?{end}';
const xIsEndTag = '^{start}\/';
const xGetTagName = '{start}(?:\/|)(.*?)(?:\\s|{end})';
const xStart = /\{start\}/g;
const xEnd = /\{end\}/g;
/**
* @typedef ShortcodeParserFinder
* Regular expressions object to use in extracting tag and tag-attribute data.
*
* @property {RegExp} tagMatch Expression for extracting a tag.
* @property {RegExp} isEndTag Expression to test if a tag is an
* end tag
* @property {RegExp} getTagName Expression to extract the tag name.
* @property {Function} getAttributes Method to extract the attributes in
* a given start tag string.
* @property {RegExp} getStartTagContent Expression for extracting the
* contents of start tag.
*/
/**
* Add slashes to every character in a string. Can be used to ensure all of
* contents is treated as text and not used as regular expression functionality
* when creating a RegExp with the given content.
*
* @private
* @param {string} txt The string to add slashes to.
* @returns {string} New slashed string.
*/
function _addSlashToEachCharacter(txt) {
return txt.split('').map(char=>'\\' + char).join('');
}
/**
* Get the attributes in the given tag text. Will return an object of the tag
* attributes with properties being equal to their names and property values
* equalling their value. Also, assign numbered properties for attribute
* positions.
*
* @private
* @param {RegExp} getAttributes The regular expression to use in getting
* the attributes.
* @param {string} tag The tag text from open tag start
* and close.
* @returns {Object} The attributes object.
*/
function _getAttribute(getAttributes, tag) {
let results = getAttributes.exec(tag);
let attributes = {};
if (results) {
let result;
let count = 1;
while (result = xGetAttributes.exec(results[1])) {
if (!result[6] && (result[1] || result[4])) {
attributes[count] = {};
attributes[result[1] || result[4]] = result[3] || result[5];
attributes[count][result[1] || result[4]] = result[3] || result[5];
} else if (result[6] || result[8]) {
attributes[count] = result[6] || result[8];
}
count++;
}
}
return attributes;
}
/**
* Safely create a regular expression from the given template with the given
* start and end characters replaced in the regular expression.
*
* @private
* @param {string} template The regular expression template. The text
* {start} and {end} will be replaced with
* the given startChars and endChars.
* @param {string} startChars Tag start characters.
* @param {string} endChars Tag end characters.
* @param {string} [options=''] The regular expression options to
* use (eg. 'g' or 'gi').
* @returns {RegExp}
*/
function _createRegExp(template, startChars, endChars, options = '') {
return new RegExp(template
.replace(xStart, _addSlashToEachCharacter(startChars))
.replace(xEnd, _addSlashToEachCharacter(endChars)
), options);
}
/**
* Get an object containing the regular expressions to use in extracting tag
* and tag-attribute data. Construct these expressions to work with the given
* start and end tag characters supplied in the options object.
*
* @private
* @class ShortcodeParserFinder
* @param {object} options The options object.
* @param {string} options.start Start of tag characters.
* @param {string} options.end End of tag characters.
* @returns {ShortcodeParserFinder}
*
*/
function _createRegExpsObj(options) {
return {
tagMatch: _createRegExp(xTagMatch, options.start, options.end, 'g'),
isEndTag: _createRegExp(xIsEndTag, options.start, options.end),
getTagName: _createRegExp(xGetTagName, options.start, options.end),
getAttributes: _getAttribute.bind({}, _createRegExp(xGetTagAttributesText, options.start, options.end)),
getStartTagContent: _createRegExp(xStartTagContents, options.start, options.end)
};
}
/**
* Given an array of tags, remove end tags combining them with their start tag
* and placing tag content in the tag object.
*
* @private
* @param {string} txt The text containing all the given tags.
* @param {Array} tags Array of tag objects.
* @returns {ShortcodeParserTag[]} New array with end tags removed and tag
* data updated with any tag content.
*/
function _fixEndTags(txt, tags) {
return tags.map((result, n)=> {
if (result.endTag) {
for (let nn = n; nn >= 0; nn--) {
if ((tags[nn].tagName === result.tagName) && (!tags[nn].endTag)) {
tags[nn].content = txt.substring(tags[nn].end, result.start);
tags[nn].fullMatch += (tags[nn].content + result.fullMatch);
tags[nn].end = result.end;
tags[nn].selfClosing = false;
}
}
}
return result;
}).filter(result=>!result.endTag);
}
/**
* @private
* Fix overlapping tags, removing tags inside tags
*
* @param {ShortcodeParserTag[]} tags Tags to filter.
* @returns {ShortcodeParserTag[]} Filtered tags.
*/
function _filterOverlappingTags(tags) {
return tags.filter((tag, n)=> {
if (n > 0) {
for (let nn = (n - 1); nn >= 0; nn--) {
if (tags[nn].end > tag.start) return false;
}
}
return true;
});
}
/**
* @typedef ShortcodeParserTag.
* @property {string} tagName The name of tag.
* @property {boolean} endTag Is this an end tag.
* @property {string} fullMatch The full tag text and content.
* @property {integer} start Start character number in
* original text.
* @property {integer} end end character number in
* original text.
* @property {object} attributes The tag attributes as an object.
* @property {string} content The content of tag when their is
* an opening and closing tag.
* @property {boolean} selfClosing Is this a self-closing tag?
* @property {string} tagContents Contents of starting tag.
*/
/**
* Create new tag object, describing extracted tag.
*
* @private
* @class ShortcodeParserTag
* @param {ShortcodeParserFinder} finder Finder object to apply.
* @param {Array} result Results of tag extraction.
* @returns {ShortcodeParserTag} New tag object.
*/
function ShortcodeParserTag(finder, result) {
return {
tagName: finder.getTagName.exec(result[0])[1],
endTag: finder.isEndTag.test(result[0]),
fullMatch: result[0],
end: result.lastIndex,
start: result.lastIndex - result[0].length,
attributes: finder.getAttributes(result[0]),
content: '',
tagContents: finder.getStartTagContent.exec(result[0])[1],
selfClosing: true
};
}
/**
* Extract tag strings from given text, return regular expression matches
* (with some addtional data, such as lastIndex).
*
* @private
* @param {string} txt Text to extract tags from.
* @param {ShortcodeParserFinder} finder Finder object to apply.
* @returns {Array} Results array.
*/
function _extractTagStrings(txt, finder) {
let results = [];
let result;
while (result = finder.tagMatch.exec(txt)) {
result.lastIndex = finder.tagMatch.lastIndex;
results.push(result);
}
return results;
}
/**
* Parse string for tags that handlers have been added for. Return tags that
* can be parsed.
*
* @private
* @param {string} txt Text to parse for tags.
* @param {ShortcodeParserFinder} finder Finder object to apply.
* @param {ShortcodeParser} parserInstance The parser instance.
* @returns {ShortcodeParserTag[]} Tags which can be handled.
*/
function _parse(txt, finder, parserInstance) {
return _extractTagStrings(txt, finder).map(
result=>ShortcodeParserTag(finder, result)
);
}
/**
* @typedef ShortcodeParserReplacer
* @property {string} replacer Text to replace tag with.
* @property {ShortcodeParserTag} tag Tag to do replacement on.
*/
/**
* Apply a handler function to a given tag with supplied parameters.
*
* @private
* @param {Function} handler Handler to apply.
* @param {ShortcodeParserTag} tag Tag to apply handler to.
* @param {Array} params Further parameters to pass
* to the handler.
* @returns {Promise.<ShortcodeParserReplacer>}
*/
function _applyHandler(handler, tag, params) {
return Promise.resolve(handler.apply({}, params) || '').then(replacer=>{
return {replacer, tag};
});
}
/**
* Test if given selector is selector for the given tag.
*
* @private
* @param {RegExp|Function} selector Selector to test.
* @param {ShortcodeParserTag} tag Tag to test against.
* @returns {boolean}
*/
function _isSelectorMatch(selector, tag) {
return ((_.isRegExp(selector) && selector.test(tag.tagContents)) || (_.isFunction(selector) && selector(tag.tagContents)));
}
/**
* Create a new Shortcode parser instance.
*
* @class
* @public
* @param {Object} options Options to ShortcodeParser function.
* @returns {ShortcodeParser} New instance of shortcode parser.
*/
function ShortcodeParser(options = defaultOptions) {
const tags = new Map();
const finder = _createRegExpsObj(Object.assign({}, defaultOptions, options));
/**
* Run set handlers for given tags, replacing text content as the handler
* return content.
*
* @private
* @param {string} txt The full text containing the tags to
* do the replacements on.
* @param {ShortcodeParserTag[]} _tags The tags to run handlers on.
* @param {Array} params The parameters to pass on to the
* tag handlers.
* @returns {Promise.<string>} Promise resolving on completion of
* tag replacements.
*/
function _runHandlers(txt, _tags, params) {
return Promise.all(_tags.map(tag=>{
let promise;
if (exports.has(tag.tagName)) {
let handler = exports.get(tag.tagName).bind({}, tag);
promise = _applyHandler(handler, tag, params);
} else {
tags.forEach((handler, selector)=>{
let _handler = handler.bind({}, tag);
if (!promise && _isSelectorMatch(selector, tag)) promise = _applyHandler(_handler, tag, params);
});
}
return promise;
})).filter(
result=>result
).mapSeries(result=>{
txt = txt.replace(result.tag.fullMatch, result.replacer);
}).then(()=>txt);
}
const exports = {
/**
* Add a new handler to the parser for given tag name.
*
* @public
* @memberof ShortcodeParser
* @param {string|Function|RegExp} name Tag name to set handler for.
* @param {function} handler Handler function to fire on tag.
* @param {boolean} [throwOnAlreadySet=true] Throw error if tage already exists?
* @return {function} The handler function returned.
*/
add: (name, handler, throwOnAlreadySet=true)=> {
if (exports.has(name) && throwOnAlreadySet) throw new Error(`Tag '${name}' already exists`);
if (!_.isFunction(handler)) throw new TypeError(`Cannot assign a non function as handler method for '${name}'`);
if (!_.isString(name) && !_.isRegExp(name) && !_.isFunction(name)) throw new TypeError('Cannot add handler if the reference is not a string, regular expression or function. Reference of type: ' + (typeof name) + ', was given.');
tags.set(name, handler);
return exports.get(name);
},
/**
* Test if a handler for given tag name.
*
* @public
* @memberof ShortcodeParser
* @param {string} name The tag to look for a handler on.
* @returns {boolean} Does it exist?
*/
has: name=>tags.has(name),
/**
* Delete the handler for given tag name.
*
* @public
* @memberof ShortcodeParser
* @param {string} name The tagname to delete the handler for.
* @returns {boolean}
*/
delete: name=> {
if (!exports.has(name)) throw new RangeError(`Tag '${name}' does not exist`);
return tags.delete(name);
},
/**
* Get the handler function for given tag name.
*
* @public
* @memberof ShortcodeParser
* @param {string} name Tag name to get the handler for.
* @returns {function} The handler for the given tag name.
*/
get: name=> {
if (!exports.has(name)) throw new RangeError(`Tag '${name}' does not exist`);
return tags.get(name);
},
/**
* Parse given text for tags, running handlers where handlers are
* defined and returning parsed text.
*
* @public
* @memberof ShortcodeParser
* @param {string} txt Text to parse.
* @param {Array} [params=[]] Parameters to pass to the handlers.
* @returns {Promise.<string>} Promise resolving to new
* parsed text.
*/
parse: (txt, ...params)=> {
let tags = _filterOverlappingTags(_fixEndTags(txt, _parse(txt, finder, exports)));
return _runHandlers(txt, tags, params).then(parsedTxt=> {
if (txt !== parsedTxt) return exports.parse(parsedTxt, finder, exports);
return parsedTxt;
});
}
};
return Object.freeze(exports);
}
module.exports = ShortcodeParser;