// Basic parser and formatter for very simple rich text. Mostly targeted at
// mobile use cases similar to Telegram and WhatsApp.
//
// Supports:
// *abc* -> abc
// _abc_ -> abc
// ~abc~ -> abc
// `abc` -> abc
// Nested frmatting is supported, e.g. *abc _def_* -> abc def
//
// URLs, @mentions, and #hashtags are extracted.
//
// JSON data representation is similar to Draft.js raw formatting.
(function(environment) { // closure for web browsers
'use strict';
// Regular expressions for parsing inline formats. Javascript does not support lookbehind,
// so it's a bit messy.
var INLINE_STYLES = [
// Bold, *bold text*
{name: "BO", start: /(?:^|\W)(\*)[^\s*]/, end: /[^\s*](\*)(?=$|\W)/},
// Italic, _italic text_
{name: "IT", start: /(?:^|[\W_])(_)[^\s_]/, end: /[^\s_](_)(?=$|[\W_])/},
// Strikethough, ~strike this though~
{name: "ST", start: /(?:^|\W)(~)[^\s~]/g, end: /[^\s~](~)(?=$|\W)/},
// Code block `this is monospace`
{name: "CO", start: /(?:^|\W)(`)[^`]/, end: /[^`](`)(?=$|\W)/}
];
// RegExps for entity extraction
var ENTITY_TYPES = [
// URLs
{name: "LN", dataName: "url",
pack: function(val) {
// Check if the protocol is pecified, if not use http
if (!/^[a-z]+:\/\//i.test(val)) {
val = 'http://' + val;
}
return {url: val};
},
re: /(https?:\/\/)?(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b(?:[-a-zA-Z0-9@:%_\+.~#?&//=]*)/g},
// Mentions @user (must be 2 or more characters)
{name: "MN", dataName: "val",
pack: function(val) { return {val: val.slice(1)}; },
re: /\B@(\w\w+)/g},
// Hashtags #hashtag
{name: "HT", dataName: "val",
pack: function(val) { return {val: val.slice(1)}; },
re: /\B#(\w\w+)/g}
];
// Formatting HTML tags
var STYLE_DECOR = {
BO: ['', ''],
IT: ['', ''],
ST: ['', ''],
CO: ['', '']
};
var ENTITY_DECOR = {
LN: {
open: function(data) { return ''; },
close: function(data) { return ''; }
},
MN: {
open: function(data) { return ''; },
close: function(data) { return ''; }
},
HT: {
open: function(data) { return ''; },
close: function(data) { return ''; }
}
};
var Drafty = (function() {
// Take a string and defined earlier style spans, re-compose them into a tree where each leaf is
// a same-style (including unstyled) string. I.e. 'hello *bold _italic_* and ~more~ world' ->
// ('hello ', (b: 'bold ', (i: 'italic')), ' and ', (s: 'more'), ' world');
//
// This is needed in order to clear markup, i.e. 'hello *world*' -> 'hello world' and convert
// ranges from markup-ed offsets to plain text offsets.
function chunkify(line, start, end, spans) {
var chunks = [];
if (spans.length == 0) {
return [];
}
for (var i in spans) {
// Get the next chunk from the queue
var span = spans[i];
// Grab the initial unstyled chunk
if (span.start > start) {
chunks.push({text: line.slice(start, span.start)});
}
// Grab the styled chunk. It may include subchunks.
var chunk = {type: span.type};
var chld = chunkify(line, span.start + 1, span.end - 1, span.children);
if (chld.length > 0) {
chunk.children = chld;
} else {
chunk.text = span.text;
}
chunks.push(chunk);
start = span.end + 1; // '+1' is to skip the formatting character
}
// Grab the remaining unstyled chunk, after the last span
if (start < end) {
chunks.push({text: line.slice(start, end)});
}
return chunks;
}
// Detect starts and ends of formatting spans. Unformatted spans are
// ignored at this time.
function spannify(original, re_start, re_end, type) {
var result = [];
var index = 0;
var line = original.slice(0); // make a copy;
while (line.length > 0) {
// match[0]; // match, like '*abc*'
// match[1]; // match captured in parenthesis, like 'abc'
// match['index']; // offset where the match started.
// Find the opening token.
var start = re_start.exec(line);
if (start == null) {
break;
}
// Because javascript RegExp does not support lookbehind, the actual offset may not be at the beginning of
// the match. Find it in the matched string.
var start_offset = start['index'] + start[0].lastIndexOf(start[1]);
// Clip the processed part of the string.
line = line.slice(start_offset + 1);
// start_offset is an offset within the clipped string. Convert to original index.
start_offset += index;
// Index now point to the beginning of 'line' within the 'original' string.
index = start_offset + 1;
// Find the matching closing token.
var end = re_end.exec(line);
if (end == null) {
break;
}
var end_offset = end['index'] + end[0].indexOf(end[1]);
// Clip the processed part of the string.
line = line.slice(end_offset + 1);
// Update offsets
end_offset += index;
// Index now point to the beginning of 'line' within the 'original' string.
index = end_offset + 1;
result.push({
text: original.slice(start_offset+1, end_offset),
children: [],
start: start_offset,
end: end_offset,
type: type
});
}
return result;
}
// Convert linear array or spans into a tree representation.
// Keep standalone and nested spans, throw away partially overlapping spans.
function toTree(spans) {
if (spans.length == 0) {
return [];
}
var tree = [spans[0]];
var last = spans[0];
for (var i = 1; i < spans.length; i++) {
// Keep spans which start after the end of the previous span or those which
// are complete within the previous span.
if (spans[i].start > last.end) {
// Span is completely outside of the previous span.
tree.push(spans[i]);
last = spans[i];
} else if (spans[i].end < last.end) {
// Span is fully inside of the previous span. Push to subnode.
last.children.push(spans[i]);
}
// Span could partially overlap, ignoring it as invalid.
}
// Recursively rearrange the subnodes.
for (var i in tree) {
tree[i].children = toTree(tree[i].children);
}
return tree;
}
// Get a list of entities from a text.
function extractEntities(line, re, name) {
var match;
var extracted = [];
ENTITY_TYPES.map(function(ent) {
while((match = ent.re.exec(line)) !== null) {
extracted.push({
offset: match['index'],
len: match[0].length,
unique: match[0],
data: ent.pack(match[0]),
type: ent.name});
}
});
if (extracted.length == 0) {
return extracted;
}
// Remove entities detected inside other entities, like #hashtag in a URL.
extracted.sort(function(a,b) {
return a.offset - b.offset;
});
var idx = -1;
extracted = extracted.filter(function(el) {
var result = (el.offset > idx);
idx = el.offset + el.len;
return result;
});
return extracted;
}
// Convert the chunks into format suitable for serialization.
function toDrafty(chunks, startAt) {
var plain = "";
var ranges = [];
for (var i in chunks) {
var chunk = chunks[i];
if (!chunk.text) {
var drafty = toDrafty(chunk.children, plain.length + startAt);
chunk.text = drafty.text;
ranges = ranges.concat(drafty.ranges);
}
if (chunk.type) {
ranges.push({offset: plain.length + startAt, len: chunk.text.length, style: chunk.type});
}
plain += chunk.text;
}
return {text: plain, ranges: ranges};
}
// Splice two strings: insert second string into the first one at the given index
function splice(src, at, insert) {
return src.slice(0, at) + insert + src.slice(at);
}
return {
parse: function(content) {
// Split text into lines. It makes formatting easier
var lines = content.split('\n');
// Holds formatted lines
var blocks = [];
// Holds entities referenced from text
var entityMap = [];
var entityIndex = {};
//
lines.map(function(line) {
var spans = [];
var entities = [];
// Find formatted spans in the string.
// Try to match each style.
INLINE_STYLES.map(function(style) {
// Each style could be matched multiple times.
spans = spans.concat(spannify(line, style.start, style.end, style.name));
});
var block;
if (spans.length == 0) {
block = {text: line};
} else {
// Sort spans by style occurence early -> late
spans.sort(function(a,b) {
return a.start - b.start;
});
// Convert an array of possibly overlapping spans into a tree
spans = toTree(spans);
// Build tree representation of the format.
var chunks = chunkify(line, 0, line.length, spans);
var drafty = toDrafty(chunks, 0);
block = {text: drafty.text, styles: drafty.ranges};
}
// Extract entities from the cleaned up string.
entities = extractEntities(block.text);
var ranges = [];
if (entities.length > 0) {
console.log(JSON.stringify(entities));
for (var i in entities) {
// {offset: match['index'], unique: match[0], len: match[0].length, data: ent.packer(), type: ent.name}
var ent = entities[i];
var index = entityIndex[ent.unique];
if (!index) {
index = entityMap.length;
entityIndex[ent.unique] = index;
entityMap.push({type: ent.type, data: ent.data});
}
ranges.push({offset: ent.offset, len: ent.len, key: index});
}
block.ent = ranges;
}
blocks.push(block);
});
var result = {blocks: blocks};
if (entityMap.length > 0) {
result.refs = entityMap;
}
return result;
},
format: function(content) {
var {blocks} = content;
var {refs} = content;
var text = "";
for (var i in blocks) {
var line = blocks[i].text;
var markup = [];
if (blocks[i].styles) {
for (var j in blocks[i].styles) {
var range = blocks[i].styles[j];
markup.push({idx: range.offset, what: STYLE_DECOR[range.style][0]});
markup.push({idx: range.offset + range.len, what: STYLE_DECOR[range.style][1]});
}
}
if (blocks[i].ent) {
for (var j in blocks[i].ent) {
var range = blocks[i].ent[j];
var entity = refs[range.key];
markup.push({idx: range.offset, what: ENTITY_DECOR[entity.type].open(entity.data)});
markup.push({idx: range.offset + range.len, what: ENTITY_DECOR[entity.type].close(entity.data)});
}
}
if (markup.length > 0) {
markup.sort(function(a, b) {
return b.idx - a.idx; // in descending order
});
for (var j in markup) {
line = splice(line, markup[j].idx, markup[j].what);
}
}
text += line + "
";
}
return text;
}
};
});
// Export for the window object or node; Check that is not already defined.
if (typeof(environment.Drafty) === 'undefined') {
environment.Drafty = Drafty();
}
})(this); // 'this' will be window object for the browsers.