urls, mentions, and hashtags are extracted and formatted correctly
This commit is contained in:
@@ -10,22 +10,77 @@
|
|||||||
//
|
//
|
||||||
// URLs, @mentions, and #hashtags are extracted.
|
// URLs, @mentions, and #hashtags are extracted.
|
||||||
//
|
//
|
||||||
// JSON data representation is similar to Draft.js.
|
// JSON data representation is similar to Draft.js raw formatting.
|
||||||
|
|
||||||
(function(environment) { // closure for web browsers
|
(function(environment) { // closure for web browsers
|
||||||
'use strict';
|
'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: ['<b>', '</b>'],
|
||||||
|
IT: ['<i>', '</i>'],
|
||||||
|
ST: ['<del>', '</del>'],
|
||||||
|
CO: ['<tt>', '</tt>']
|
||||||
|
};
|
||||||
|
|
||||||
|
var ENTITY_DECOR = {
|
||||||
|
LN: {
|
||||||
|
open: function(data) { return '<a href="' + data.url + '">'; },
|
||||||
|
close: function(data) { return '</a>'; }
|
||||||
|
},
|
||||||
|
MN: {
|
||||||
|
open: function(data) { return '<a href="#' + data.val + '">'; },
|
||||||
|
close: function(data) { return '</a>'; }
|
||||||
|
},
|
||||||
|
HT: {
|
||||||
|
open: function(data) { return '<a href="#' + data.val + '">'; },
|
||||||
|
close: function(data) { return '</a>'; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var Drafty = (function() {
|
var Drafty = (function() {
|
||||||
|
|
||||||
// Take a string and defined earlier style spans, re-compose them into a tree where each leaf is
|
// Take a string and defined earlier style spans, re-compose them into a tree where each leaf is
|
||||||
// a same-style (or no style) string. I.e. 'hello *bold _italic_* and ~more~ world' ->
|
// a same-style (including unstyled) string. I.e. 'hello *bold _italic_* and ~more~ world' ->
|
||||||
// ('hello ', (b: 'bold ', (i: 'italic')), ' and ', (s: '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
|
// 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;
|
// ranges from markup-ed offsets to plain text offsets.
|
||||||
//
|
|
||||||
// Take string and chunks. Pick first chunk: 1. if it starts at the offset 0, clip the substring
|
|
||||||
// equal in length to the chunk.
|
|
||||||
function chunkify(line, start, end, spans) {
|
function chunkify(line, start, end, spans) {
|
||||||
var chunks = [];
|
var chunks = [];
|
||||||
|
|
||||||
@@ -62,7 +117,8 @@
|
|||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect starts and ends of formatting spans
|
// Detect starts and ends of formatting spans. Unformatted spans are
|
||||||
|
// ignored at this time.
|
||||||
function spannify(original, re_start, re_end, type) {
|
function spannify(original, re_start, re_end, type) {
|
||||||
var result = [];
|
var result = [];
|
||||||
var index = 0;
|
var index = 0;
|
||||||
@@ -78,7 +134,8 @@
|
|||||||
if (start == null) {
|
if (start == null) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// The actual offset may not be at the beginning of the match. Find it in the matched string.
|
// 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]);
|
var start_offset = start['index'] + start[0].lastIndexOf(start[1]);
|
||||||
// Clip the processed part of the string.
|
// Clip the processed part of the string.
|
||||||
line = line.slice(start_offset + 1);
|
line = line.slice(start_offset + 1);
|
||||||
@@ -112,13 +169,14 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert linear array or spans into a tree representation.
|
||||||
|
// Keep standalone and nested spans, throw away partially overlapping spans.
|
||||||
function toTree(spans) {
|
function toTree(spans) {
|
||||||
if (spans.length == 0) {
|
if (spans.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throw away overlapping spans.
|
var tree = [spans[0]];
|
||||||
var cleaned = [spans[0]];
|
|
||||||
var last = spans[0];
|
var last = spans[0];
|
||||||
for (var i = 1; i < spans.length; i++) {
|
for (var i = 1; i < spans.length; i++) {
|
||||||
// Keep spans which start after the end of the previous span or those which
|
// Keep spans which start after the end of the previous span or those which
|
||||||
@@ -126,7 +184,7 @@
|
|||||||
|
|
||||||
if (spans[i].start > last.end) {
|
if (spans[i].start > last.end) {
|
||||||
// Span is completely outside of the previous span.
|
// Span is completely outside of the previous span.
|
||||||
cleaned.push(spans[i]);
|
tree.push(spans[i]);
|
||||||
last = spans[i];
|
last = spans[i];
|
||||||
} else if (spans[i].end < last.end) {
|
} else if (spans[i].end < last.end) {
|
||||||
// Span is fully inside of the previous span. Push to subnode.
|
// Span is fully inside of the previous span. Push to subnode.
|
||||||
@@ -136,14 +194,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recursively rearrange the subnodes.
|
// Recursively rearrange the subnodes.
|
||||||
for (var i in cleaned) {
|
for (var i in tree) {
|
||||||
cleaned[i].children = toTree(cleaned[i].children);
|
tree[i].children = toTree(tree[i].children);
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleaned;
|
return tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
// finally convert the chunks to format suitable for serialization.
|
// 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) {
|
function toDrafty(chunks, startAt) {
|
||||||
var plain = "";
|
var plain = "";
|
||||||
var ranges = [];
|
var ranges = [];
|
||||||
@@ -171,46 +263,28 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
parse: function(content) {
|
parse: function(content) {
|
||||||
// Regular expressions for parsing inline formats
|
|
||||||
var inlineStyles = [
|
|
||||||
// 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)/}
|
|
||||||
];
|
|
||||||
|
|
||||||
var entities = [
|
|
||||||
// URLs
|
|
||||||
{name: "LN", re: /(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/},
|
|
||||||
// Mentions @user
|
|
||||||
{name: "MN", re: /@[\w]+/g},
|
|
||||||
// Hashtags #hashtag
|
|
||||||
{name: "HT", re: /#[\w]+/g}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Split text into lines. It makes formatting easier
|
// Split text into lines. It makes formatting easier
|
||||||
var lines = content.split('\n');
|
var lines = content.split('\n');
|
||||||
// Holds formatted lines
|
// Holds formatted lines
|
||||||
var blocks = [];
|
var blocks = [];
|
||||||
// Holds entities referenced from text
|
// Holds entities referenced from text
|
||||||
var entityMap = {};
|
var entityMap = [];
|
||||||
|
var entityIndex = {};
|
||||||
//
|
//
|
||||||
lines.map(function(line) {
|
lines.map(function(line) {
|
||||||
var spans = [];
|
var spans = [];
|
||||||
|
var entities = [];
|
||||||
|
|
||||||
// Find formatted spans in the string.
|
// Find formatted spans in the string.
|
||||||
// Try to match each style.
|
// Try to match each style.
|
||||||
inlineStyles.map(function(style) {
|
INLINE_STYLES.map(function(style) {
|
||||||
// Each style could be matched multiple times.
|
// Each style could be matched multiple times.
|
||||||
spans = spans.concat(spannify(line, style.start, style.end, style.name));
|
spans = spans.concat(spannify(line, style.start, style.end, style.name));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var block;
|
||||||
if (spans.length == 0) {
|
if (spans.length == 0) {
|
||||||
blocks.push({text: line});
|
block = {text: line};
|
||||||
} else {
|
} else {
|
||||||
// Sort spans by style occurence early -> late
|
// Sort spans by style occurence early -> late
|
||||||
spans.sort(function(a,b) {
|
spans.sort(function(a,b) {
|
||||||
@@ -225,21 +299,42 @@
|
|||||||
|
|
||||||
var drafty = toDrafty(chunks, 0);
|
var drafty = toDrafty(chunks, 0);
|
||||||
|
|
||||||
blocks.push({text: drafty.text, styles: drafty.ranges});
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {blocks: blocks, entityMap: entityMap};
|
var result = {blocks: blocks};
|
||||||
|
if (entityMap.length > 0) {
|
||||||
|
result.refs = entityMap;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
format: function(json) {
|
format: function(content) {
|
||||||
var TAGS = {
|
var {blocks} = content;
|
||||||
BO: ['<b>', '</b>', 7],
|
var {refs} = content;
|
||||||
IT: ['<i>', '</i>', 7],
|
|
||||||
ST: ['<del>', '</del>', 11],
|
|
||||||
CO: ['<tt>', '</tt>', 9]
|
|
||||||
};
|
|
||||||
var {blocks} = json;
|
|
||||||
var text = "";
|
var text = "";
|
||||||
for (var i in blocks) {
|
for (var i in blocks) {
|
||||||
var line = blocks[i].text;
|
var line = blocks[i].text;
|
||||||
@@ -247,10 +342,21 @@
|
|||||||
if (blocks[i].styles) {
|
if (blocks[i].styles) {
|
||||||
for (var j in blocks[i].styles) {
|
for (var j in blocks[i].styles) {
|
||||||
var range = blocks[i].styles[j];
|
var range = blocks[i].styles[j];
|
||||||
markup.push({idx: range.offset, what: TAGS[range.style][0]});
|
markup.push({idx: range.offset, what: STYLE_DECOR[range.style][0]});
|
||||||
markup.push({idx: range.offset + range.len, what: TAGS[range.style][1]});
|
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) {
|
markup.sort(function(a, b) {
|
||||||
return b.idx - a.idx; // in descending order
|
return b.idx - a.idx; // in descending order
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user