stem-core
Version:
Frontend and core-library framework
1,655 lines (1,387 loc) • 54.8 kB
JavaScript
// TODO: this file is in dire need of a rewrite
export class StringStream {
constructor(string, options) {
this.string = string;
this.pointer = 0;
}
done() {
return this.pointer >= this.string.length;
}
advance(steps=1) {
this.pointer += steps;
}
char() {
let ch = this.string.charAt(this.pointer);
this.pointer += 1;
return ch;
}
whitespace(whitespaceChar=/\s/) {
let whitespaceStart = this.pointer;
while (!this.done() && whitespaceChar.test(this.at(0))) {
this.pointer += 1;
}
// Return the actual whitespace in case it is needed
return this.string.substring(whitespaceStart, this.pointer);
}
// Gets first encountered non-whitespace substring
word(validChars=/\S/, skipWhitespace=true) {
if (skipWhitespace) {
this.whitespace();
}
let wordStart = this.pointer;
while (!this.done() && validChars.test(this.at(0))) {
this.pointer += 1;
}
return this.string.substring(wordStart, this.pointer);
}
number(skipWhitespace=true) {
if (skipWhitespace) {
this.whitespace();
}
let nanString = "NaN";
if (this.startsWith(nanString)) {
this.advance(nanString.length);
return NaN;
}
let sign = "+";
if (this.at(0) === "-" || this.at(0) === "+") {
sign = this.char();
}
let infinityString = "Infinity";
if (this.startsWith(infinityString)) {
this.advance(infinityString.length);
return sign === "+" ? Infinity : -Infinity;
}
let isDigit = (char) => {
return (char >= "0" || char <= "9");
};
if (this.at(0) === "0" && (this.at(1) === "X" || this.at(1) === "x")) {
// hexadecimal number
this.advance(2);
let isHexDigit = (char) => {
return isDigit(char) || (char >= "A" && char <= "F") ||(char >= "a" && char <= "f");
};
let numberStart = this.pointer;
while (!this.done() && isHexDigit(this.at(0))) {
this.pointer += 1;
}
return parseInt(sign + this.string.substring(numberStart), 16);
}
let numberStart = this.pointer;
while (!this.done() && isDigit(this.at(1))) {
this.pointer += 1;
if (this.peek === ".") {
this.advance(1);
while (!this.done() && isDigit(this.at(1))) {
this.pointer += 1;
}
break;
}
}
return parseFloat(sign + this.string.substring(numberStart, this.pointer));
}
// Gets everything up to delimiter, usually end of line, limited to maxLength
line(delimiter=/\r*\n/, maxLength=Infinity) {
if (delimiter instanceof RegExp) {
// Treat regex differently. It will probably be slower.
let str = this.string.substring(this.pointer);
let delimiterMatch = str.match(delimiter);
let delimiterIndex, delimiterLength;
if (delimiterMatch === null) {
// End of string encountered
delimiterIndex = str.length;
delimiterLength = 0;
} else {
delimiterIndex = delimiterMatch.index;
delimiterLength = delimiterMatch[0].length;
}
if (delimiterIndex >= maxLength) {
this.pointer += maxLength;
return str.substring(0, maxLength);
}
this.advance(delimiterIndex + delimiterLength);
return str.substring(0, delimiterIndex);
}
let delimiterIndex = this.string.indexOf(delimiter, this.pointer);
if (delimiterIndex === -1) {
delimiterIndex = this.string.length;
}
if (delimiterIndex - this.pointer > maxLength) {
let result = this.string.substring(this.pointer, this.pointer + maxLength);
this.advance(maxLength);
return result;
}
let result = this.string.substring(this.pointer, delimiterIndex);
this.pointer = delimiterIndex + delimiter.length;
return result;
}
// The following methods have no side effects
// Access char at offset position, relative to current pointer
at(index) {
return this.string.charAt(this.pointer + index);
}
peek(length=1) {
return this.string.substring(this.pointer, this.pointer + length);
}
startsWith(prefix) {
if (prefix instanceof RegExp) {
// we modify the regex to only check for the beginning of the string
prefix = new RegExp("^" + prefix.toString().slice(1, -1));
return prefix.test(this.string.substring(this.pointer));
}
return this.peek(prefix.length) === prefix;
}
// Returns first position of match
search(pattern) {
let position;
if (pattern instanceof RegExp) {
position = this.string.substring(this.pointer).search(pattern);
} else {
position = this.string.indexOf(pattern, this.pointer) - this.pointer;
}
return position < 0 ? -1 : position;
}
clone() {
let newStream = new this.constructor(this.string);
newStream.pointer = this.pointer;
return newStream;
}
}
function kmp(input) {
if (input.length === 0) {
return [];
}
let prefix = [0];
let prefixLength = 0;
for (let i = 1; i < input.length; i += 1) {
while (prefixLength > 0 && input[i] !== input[prefixLength]) {
prefixLength = prefix[prefixLength];
}
if (input[i] === input[prefixLength]) {
prefixLength += 1;
}
prefix.push(prefixLength);
}
return prefix;
}
class ModifierAutomation {
// build automaton from string
constructor(options) {
this.options = options;
this.steps = 0;
this.startNode = {
value: null,
startNode: true,
};
this.node = this.startNode;
let lastNode = this.startNode;
let char = options.pattern.charAt(0);
let startPatternNode = {
value: char,
startNode: true,
};
let patternPrefix = kmp(options.pattern);
let patternNode = [startPatternNode];
if (options.leftWhitespace) {
// We don't want to match if the first char is not preceeded by whitespace
let whitespaceNode = {
value: " ",
whitespaceNode: true,
};
whitespaceNode.next = (input) => {
if (input === char) return startPatternNode;
return (/\s/).test(input) ? whitespaceNode : this.startNode;
};
lastNode.next = (input) => {
return (/\s/).test(input) ? whitespaceNode : this.startNode;
};
this.node = whitespaceNode;
} else {
lastNode.next = (input) => {
return input === char ? startPatternNode : this.startNode;
}
}
lastNode = startPatternNode;
for (let i = 1; i < options.pattern.length; i += 1) {
let char = options.pattern[i];
let newNode = {
value: char,
};
patternNode.push(newNode);
let backNode = (patternPrefix[i - 1] === 0) ? this.startNode : patternNode[patternPrefix[i - 1] - 1];
lastNode.next = (input) => {
if (input === char) {
return newNode;
}
return backNode.next(input);
};
lastNode = newNode;
}
lastNode.patternLastNode = true;
if (options.captureContent) {
this.capture = [];
let captureNode = {
value: "",
captureNode: true,
};
// We treat the first character separately in order to support empty capture
let char = options.endPattern.charAt(0);
let endCaptureNode = {
value: char
};
let endPatternPrefix = kmp(options.endPattern);
let endPatternNodes = [endCaptureNode];
lastNode.next = captureNode.next = (input) => {
return input === char ? endCaptureNode : captureNode;
};
lastNode = endCaptureNode;
for (let i = 1; i < options.endPattern.length; i += 1) {
let char = options.endPattern[i];
let newNode = {
value: char,
};
endPatternNodes.push(newNode);
let backNode = (endPatternPrefix[i - 1] === 0) ? captureNode : endPatternNodes[endPatternPrefix[i - 1] - 1];
lastNode.next = (input) => {
if (input === char) {
return newNode;
}
return backNode.next(input);
};
lastNode = newNode;
}
lastNode.endPatternLastNode = true;
}
lastNode.endNode = true;
lastNode.next = (input) => {
return this.startNode.next(input);
};
}
nextState(input) {
this.steps += 1;
this.node = this.node.next(input);
if (this.node.startNode) {
this.steps = 0;
delete this.patternStep;
delete this.endPatternStep;
}
if (this.node.patternLastNode) {
this.patternStep = this.steps - this.options.pattern.length + 1;
}
if (this.node.endPatternLastNode) {
// TODO(@all): Shouldn't it be this.options.endPattern.length instead of this.options.pattern.length?
this.endPatternStep = this.steps - this.options.pattern.length + 1;
}
return this.node;
}
done() {
return this.node.endNode;
}
}
class Modifier {
constructor(options) {
}
modify(currentArray, originalString) {
let matcher = new ModifierAutomation({
pattern: this.pattern,
captureContent: this.captureContent, // TODO: some elements should not wrap
endPattern: this.endPattern,
leftWhitespace: this.leftWhitespace,
});
let arrayLocation = 0;
let currentElement = currentArray[arrayLocation];
let newArray = [];
for (let i = 0; i < originalString.length; i += 1) {
let char = originalString[i];
if (i >= currentElement.end) {
newArray.push(currentElement);
arrayLocation += 1;
currentElement = currentArray[arrayLocation];
}
if (currentElement.isJSX) {
matcher.nextState("\\" + char); // prevent char from advancing automata
continue;
}
matcher.nextState(char);
if (matcher.done()) {
let modifierStart = i - (matcher.steps - matcher.patternStep);
let modifierEnd = i - (matcher.steps - matcher.endPatternStep) + this.endPattern.length;
let modifierCapture = [];
while (newArray.length > 0 && modifierStart <= newArray[newArray.length - 1].start) {
let element = newArray.pop();
modifierCapture.push(element);
}
if (newArray.length > 0 && modifierStart < newArray[newArray.length - 1].end) {
let element = newArray.pop();
newArray.push({
isString: true,
start: element.start,
end: modifierStart,
});
modifierCapture.push({
isString: true,
start: modifierStart,
end: element.end,
});
}
if (currentElement.start < modifierStart) {
newArray.push({
isString: true,
start: currentElement.start,
end: modifierStart,
});
}
modifierCapture.reverse();
// this is the end of the capture
modifierCapture.push({
isString: true,
start: Math.max(currentElement.start, modifierStart),
end: modifierEnd,
});
newArray.push({
content: this.wrap(this.processChildren(modifierCapture, originalString)),
start: modifierStart,
end: modifierEnd,
});
// We split the current element to in two(one will be captured, one replaces the current element
currentElement = {
isString: true,
start: modifierEnd,
end: currentElement.end,
};
}
}
if (currentElement.start < originalString.length) {
newArray.push(currentElement);
}
return newArray;
}
processChildren(capture, originalString) {
return capture.map((element) => {
return this.processChild(element, originalString);
});
}
processChild(element, originalString) {
if (element.isDummy) {
return "";
} if (element.isString) {
return originalString.substring(element.start, element.end);
} else {
return element.content;
}
}
}
function InlineModifierMixin(BaseModifierClass) {
return class InlineModifier extends BaseModifierClass {
constructor(options) {
super(options);
this.captureContent = true;
}
wrap(content) {
if (content.length > 0) {
content[0] = content[0].substring(content[0].indexOf(this.pattern) + this.pattern.length);
let lastElement = content.pop();
lastElement = lastElement.substring(0, lastElement.lastIndexOf(this.endPattern));
content.push(lastElement);
return {
tag: this.tag,
children: content
};
}
}
}
}
function LineStartModifierMixin(BaseModifierClass) {
return class LineStartModifier extends BaseModifierClass {
constructor(options) {
super(options);
this.groupConsecutive = false;
}
isValidElement(element) {
return element.content &&
element.content.tag === "p" &&
element.content.children.length > 0 &&
!element.content.children[0].tag && // child is text string
element.content.children[0].startsWith(this.pattern);
}
modify(currentArray, originalString) {
let newArray = [];
for (let i = 0; i < currentArray.length; i += 1) {
let element = currentArray[i];
if (this.isValidElement(element)) {
if (this.groupConsecutive) {
let elements = [];
let start, end;
start = currentArray[i].start;
while (i < currentArray.length && this.isValidElement(currentArray[i])) {
elements.push(this.wrapItem(currentArray[i].content.children));
i += 1;
}
// we make sure no elements are skipped
i -= 1;
end = currentArray[i].end;
newArray.push({
start: start,
end: end,
content: this.wrap(elements)
});
} else {
// We use object assign here to keep the start and end properties. (Maybe along with others)
let newElement = Object.assign({}, element, {
content: this.wrap(element.content.children)
});
newArray.push(newElement);
}
} else {
newArray.push(element);
}
}
return newArray;
}
wrapItem(content) {
let firstChild = content[0];
let patternIndex = firstChild.indexOf(this.pattern);
let patternEnd = patternIndex + this.pattern.length;
content[0] = firstChild.substring(patternEnd);
return {
tag: this.itemTag,
children: content,
}
}
wrap(content) {
return {
tag: this.tag,
children: content,
}
}
}
}
function RawContentModifierMixin(BaseModifierClass) {
return class RawContentModifier extends BaseModifierClass {
processChildren(children, originalString) {
if (children.length === 0) {
return [];
}
return [originalString.substring(children[0].start, children[children.length - 1].end)];
}
}
}
export class BlockCodeModifier extends Modifier {
constructor(options) {
super(options);
this.pattern = "```";
this.endPattern = "\n```";
this.leftWhitespace = true;
this.captureContent = true;
}
processChildren(capture, originalString) {
this.codeOptions = null;
if (capture.length > 0) {
let codeBlock = originalString.substring(capture[0].start, capture[capture.length - 1].end);
codeBlock = codeBlock.substring(codeBlock.indexOf(this.pattern) + this.pattern.length);
codeBlock = codeBlock.substring(0, codeBlock.lastIndexOf(this.endPattern));
let firstLineEnd = codeBlock.indexOf("\n") + 1;
let firstLine = codeBlock.substring(0, firstLineEnd).trim();
codeBlock = codeBlock.substring(firstLineEnd);
if (firstLine.length > 0) {
this.codeOptions = {};
let lineStream = new StringStream(firstLine);
this.codeOptions.aceMode = lineStream.word();
Object.assign(this.codeOptions, MarkupParser.parseOptions(lineStream));
}
return codeBlock;
}
return "";
}
getElement(content) {
return {
tag: this.constructor.tag || "pre",
children: [content],
}
}
wrap(content, options) {
let codeHighlighter = this.getElement(content);
// TODO: this code should not be here
let codeOptions = {
aceMode: "c_cpp",
maxLines: 32,
};
if (this.codeOptions) {
Object.assign(codeOptions, this.codeOptions);
delete this.codeOptions;
}
Object.assign(codeOptions, codeHighlighter);
return codeOptions;
}
}
class HeaderModifier extends LineStartModifierMixin(Modifier) {
constructor(options) {
super(options);
this.pattern = "#";
}
wrap(content) {
let firstChild = content[0];
let hashtagIndex = firstChild.indexOf("#");
let hashtagEnd = hashtagIndex + 1;
let headerLevel = 1;
let nextChar = firstChild.charAt(hashtagEnd);
if (nextChar >= "1" && nextChar <= "6") {
headerLevel = parseInt(nextChar);
hashtagEnd += 1;
} else if (nextChar === "#") {
while (headerLevel < 6 && firstChild.charAt(hashtagEnd) === "#") {
headerLevel += 1;
hashtagEnd += 1;
}
}
content[0] = firstChild.substring(hashtagEnd);
return {
tag: "h" + headerLevel,
children: content,
};
}
}
class HorizontalRuleModifier extends LineStartModifierMixin(Modifier) {
constructor(options) {
super(options);
this.pattern = "---";
}
wrap(content) {
return {
tag: "hr"
};
}
}
class UnorderedListModifier extends LineStartModifierMixin(Modifier) {
constructor(options) {
super(options);
this.tag = "ul";
this.itemTag = "li";
this.pattern = "- ";
this.groupConsecutive = true;
}
}
class OrderedListModifier extends LineStartModifierMixin(Modifier) {
constructor(options) {
super(options);
this.tag = "ol";
this.itemTag = "li";
this.pattern = "1. ";
this.groupConsecutive = true;
}
}
class ParagraphModifier extends Modifier {
modify(currentArray, originalString) {
let newArray = [];
let capturedContent = [];
let arrayLocation = 0;
let currentElement = currentArray[arrayLocation];
let lineStart = 0;
for (let i = 0; i < originalString.length; i += 1) {
if (i >= currentElement.end) {
capturedContent.push(currentElement);
arrayLocation += 1;
currentElement = currentArray[arrayLocation];
}
if (currentElement.isJSX) {
continue;
}
if (originalString[i] === "\n") {
if (currentElement.start < i) {
capturedContent.push({
isString: true,
start: currentElement.start,
end: i,
});
}
newArray.push({
content: this.wrap(this.processChildren(capturedContent, originalString)),
start: lineStart,
end: i + 1,
});
capturedContent = [];
lineStart = i + 1;
if (originalString[i + 1] === "\n") {
let start, end;
start = i;
while (i + 1 < originalString.length && originalString[i + 1] === "\n") {
i += 1;
}
end = i + 1;
newArray.push({
content: {
tag: "br",
},
start: start,
end: end,
});
lineStart = i + 1;
} else {
// TODO: these dummies break code. Refactor!
// newArray.push({
// isDummy: true,
// start: i,
// end: i + 1,
// });
}
currentElement = {
isString: true,
start: lineStart,
end: currentElement.end,
};
}
}
if (currentElement.start < originalString.length) {
capturedContent.push(currentElement);
}
if (capturedContent.length > 0) {
newArray.push({
content: this.wrap(this.processChildren(capturedContent, originalString)),
start: lineStart,
end: originalString.length,
})
}
return newArray;
}
wrap(capture) {
return {
tag: "p",
children: capture,
}
}
}
class StrongModifier extends InlineModifierMixin(Modifier) {
constructor(options) {
super(options);
this.leftWhitespace = true;
this.pattern = "*";
this.endPattern = "*";
this.tag = "strong";
}
}
class ItalicModifier extends InlineModifierMixin(Modifier) {
constructor(options) {
super(options);
this.leftWhitespace = true;
this.pattern = "/";
this.endPattern = "/";
this.tag = "em";
}
}
class InlineCodeModifier extends RawContentModifierMixin(InlineModifierMixin(Modifier)) {
constructor(options) {
super(options);
this.pattern = "`";
this.endPattern = "`";
this.tag = "code";
}
processChildren(children, originalString) {
if (children.length === 0) {
return [];
}
return [originalString.substring(children[0].start, children[children.length - 1].end)];
}
}
class InlineVarModifier extends RawContentModifierMixin(InlineModifierMixin(Modifier)) {
constructor(options) {
super(options);
this.pattern = "$";
this.endPattern = "$";
this.tag = "var";
}
}
class InlineLatexModifier extends RawContentModifierMixin(InlineModifierMixin(Modifier)) {
constructor(options) {
super(options);
this.pattern = "$$";
this.endPattern = "$$";
this.tag = "Latex";
}
}
class LinkModifier extends Modifier {
static isCorrectUrl(str) {
if (str.startsWith("http://") || str.startsWith("https://")) {
return true;
}
}
static trimProtocol(str) {
if (str[4] === 's') {
return str.substring(8, str.length);
}
return str.substring(7, str.length);
}
modify(currentArray, originalString) {
let newArray = [];
let arrayLocation = 0;
let currentElement = currentArray[arrayLocation];
let lineStart = 0;
let checkAndAddUrl = (start, end) => {
let substr = originalString.substring(start, end);
if (this.constructor.isCorrectUrl(substr)) {
if (currentElement.start < start) {
newArray.push({
isString: true,
start: currentElement.start,
end: start,
});
}
newArray.push({
isJSX: true,
content: {
tag: "a",
href: substr,
children: [this.constructor.trimProtocol(substr)],
target: "_blank"
},
start: start,
end: end,
});
currentElement = {
isString: true,
start: end,
end: currentElement.end,
};
}
};
for (let i = 0; i < originalString.length; i += 1) {
if (i >= currentElement.end) {
newArray.push(currentElement);
arrayLocation += 1;
currentElement = currentArray[arrayLocation];
}
if (currentElement.isJSX) {
continue;
}
if ((/\s/).test(originalString[i])) {
checkAndAddUrl(lineStart, i);
lineStart = i + 1;
}
}
if (lineStart < originalString.length) {
checkAndAddUrl(lineStart, originalString.length);
}
if (currentElement.start < originalString.length) {
newArray.push(currentElement);
}
return newArray;
}
}
let MarkupModifier = Modifier;
export {MarkupModifier, HeaderModifier, ParagraphModifier, InlineCodeModifier, InlineLatexModifier, StrongModifier, LinkModifier, HorizontalRuleModifier, UnorderedListModifier, OrderedListModifier, InlineVarModifier, ItalicModifier};
class MarkupParser {
constructor(options) {
options = options || {};
this.modifiers = options.modifiers || this.constructor.modifiers;
this.uiElements = options.uiElements || new Map();
}
parse(content) {
if (!content) return [];
let result = [];
let arr = this.parseUIElements(content);
for (let i = this.modifiers.length - 1; i >= 0; i -= 1) {
let modifier = this.modifiers[i];
arr = modifier.modify(arr, content);
}
for (let el of arr) {
if (el.isDummy) {
// just skip it
} else if (el.isString) {
result.push(content.substring(el.start, el.end));
} else {
result.push(el.content);
}
}
return result;
}
parseUIElements(content) {
let stream = new StringStream(content);
let result = [];
let textStart = 0;
while (!stream.done()) {
let char = stream.char();
if (char === "<" && (/[a-zA-Z]/).test(stream.at(0))) {
stream.pointer -= 1; //step back to beginning of ui element
let elementStart = stream.pointer;
let uiElement;
try {
uiElement = this.parseUIElement(stream);
} catch (e) {
// failed to parse jsx element
continue;
}
if (this.uiElements.has(uiElement.tag)) {
result.push({
isString: true,
start: textStart,
end: elementStart,
});
result.push({
content: uiElement,
isJSX: true,
start: elementStart,
end: stream.pointer,
});
textStart = stream.pointer;
}
}
}
if (textStart < content.length) {
result.push({
isString: true,
start: textStart,
end: content.length,
});
}
return result;
}
parseUIElement(stream, delimiter=(/\/?>/)) {
// content should be of type <ClassName option1="string" option2={{jsonObject: true}} />
// TODO: support nested elements like <ClassName><NestedClass /></ClassName>
stream.whitespace();
if (stream.done()) {
return null;
}
if (stream.at(0) !== "<") {
throw Error("Invalid UIElement declaration.");
}
let result = {};
stream.char(); // skip the '<'
result.tag = stream.word();
stream.whitespace();
Object.assign(result, this.parseOptions(stream, delimiter));
stream.line(delimiter);
return result;
}
parseOptions(stream, optionsEnd) {
return this.constructor.parseOptions(stream, optionsEnd);
}
// optionsEnd cannot include whitespace or start with '='
static parseOptions(stream, optionsEnd) {
let options = {};
stream.whitespace();
while (!stream.done()) {
// argument name is anything that comes before whitespace or '='
stream.whitespace();
let validOptionName = /[\w$]/;
let optionName;
if (validOptionName.test(stream.at(0))) {
optionName = stream.word(validOptionName);
}
stream.whitespace();
if (optionsEnd && stream.search(optionsEnd) === 0) {
options[optionName] = true;
break;
}
if (!optionName) {
throw Error("Invalid option name");
}
if (stream.peek() === "=") {
stream.char();
stream.whitespace();
if (stream.done()) {
throw Error("No argument given for option: " + optionName);
}
if (stream.peek() === '"') {
// We have a string here
let optionString = "";
let foundStringEnd = false;
stream.char();
while (!stream.done()) {
let char = stream.char();
if (char === '"') {
foundStringEnd = true;
break;
}
optionString += char;
}
if (!foundStringEnd) {
// You did not close that string
throw Error("Argument string not closed: " + optionString);
}
options[optionName] = optionString;
} else if (stream.peek() === '{') {
// Once you pop, the fun don't stop
let bracketCount = 0;
let validJSON = false;
let jsonString = "";
stream.char();
while (!stream.done()) {
let char = stream.char();
if (char === '{') {
bracketCount += 1;
} else if (char === '}') {
if (bracketCount > 0) {
bracketCount -= 1;
} else {
// JSON ends here
options[optionName] = jsonString.length > 0 ? this.parseJSON5(jsonString) : undefined;
validJSON = true;
break;
}
}
jsonString += char;
}
if (!validJSON) {
throw Error("Invalid JSON argument for option: " + optionName + ". Input: " + jsonString);
}
} else {
throw Error("Invalid argument for option: " + optionName + ". Need string or JSON.");
}
} else {
options[optionName] = true;
}
stream.whitespace();
}
return options;
}
parseTextLine(stream) {
let lastModifier = new Map();
let capturedContent = [];
// This will always be set to the last closed modifier
let capturedEnd = -1;
let textStart = stream.pointer;
let contentStart = stream.pointer;
while (!stream.done()) {
if (stream.startsWith(/\s+\r*\n/)) {
// end of line, stop here
break;
}
if (stream.at(0) === "<") {
capturedContent.push({
content: stream.string.substring(contentStart, stream.pointer),
start: contentStart,
end: stream.pointer
});
let uiElementStart = stream.pointer;
let uiElement = this.parseUIElement(stream, (/\/*>/));
capturedContent.push({
content: uiElement,
start: uiElementStart,
end: stream.pointer,
});
contentStart = stream.pointer;
continue;
}
let char = stream.char();
if (char === "\\") {
// escape next character
char += stream.char();
}
}
let remainingContent = stream.string.substring(textStart, stream.pointer);
if (remainingContent.length > 0) {
capturedContent.push(remainingContent);
}
stream.line(); // delete line endings
return capturedContent;
}
}
MarkupParser.modifiers = [
new BlockCodeModifier(),
new HeaderModifier(),
new HorizontalRuleModifier(),
new UnorderedListModifier(),
new OrderedListModifier(),
new ParagraphModifier(),
new InlineCodeModifier(),
new InlineLatexModifier(),
new InlineVarModifier(),
new StrongModifier(),
new ItalicModifier(),
new LinkModifier()
];
// json5.js
// This file is based directly off of Douglas Crockford's json_parse.js:
// https://github.com/douglascrockford/JSON-js/blob/master/json_parse.js
MarkupParser.parseJSON5 = (function() {
// This is a function that can parse a JSON5 text, producing a JavaScript
// data structure. It is a simple, recursive descent parser. It does not use
// eval or regular expressions, so it can be used as a model for implementing
// a JSON5 parser in other languages.
// We are defining the function inside of another function to avoid creating
// global variables.
let at, // The index of the current character
lineNumber, // The current line number
columnNumber, // The current column number
ch; // The current character
let escapee = {
"'": "'",
'"': '"',
'\\': '\\',
'/': '/',
'\n': '', // Replace escaped newlines in strings w/ empty string
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t'
};
let text;
let renderChar = (chr) => {
return chr === '' ? 'EOF' : "'" + chr + "'";
};
let error = (m) => {
// Call error when something is wrong.
let error = new SyntaxError();
// beginning of message suffix to agree with that provided by Gecko - see https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse
error.message = m + " at line " + lineNumber + " column " + columnNumber + " of the JSON5 data. Still to read: " + JSON.stringify(text.substring(at - 1, at + 19));
error.at = at;
// These two property names have been chosen to agree with the ones in Gecko, the only popular
// environment which seems to supply this info on JSON.parse
error.lineNumber = lineNumber;
error.columnNumber = columnNumber;
throw error;
};
let next = (c) => {
// If a c parameter is provided, verify that it matches the current character.
if (c && c !== ch) {
error("Expected " + renderChar(c) + " instead of " + renderChar(ch));
}
// Get the next character. When there are no more characters,
// return the empty string.
ch = text.charAt(at);
at++;
columnNumber++;
if (ch === '\n' || ch === '\r' && peek() !== '\n') {
lineNumber++;
columnNumber = 0;
}
return ch;
};
let peek = () => {
// Get the next character without consuming it or
// assigning it to the ch varaible.
return text.charAt(at);
};
let identifier = () => {
// Parse an identifier. Normally, reserved words are disallowed here, but we
// only use this for unquoted object keys, where reserved words are allowed,
// so we don't check for those here. References:
// - http://es5.github.com/#x7.6
// - https://developer.mozilla.org/en/Core_JavaScript_1.5_Guide/Core_Language_Features#Variables
// - http://docstore.mik.ua/orelly/webprog/jscript/ch02_07.htm
// TODO Identifiers can have Unicode "letters" in them; add support for those.
let key = ch;
// Identifiers must start with a letter, _ or $.
if ((ch !== '_' && ch !== '$') &&
(ch < 'a' || ch > 'z') &&
(ch < 'A' || ch > 'Z')) {
error("Bad identifier as unquoted key");
}
// Subsequent characters can contain digits.
while (next() && (
ch === '_' || ch === '$' ||
(ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9'))) {
key += ch;
}
return key;
};
let number = () => {
// Parse a number value.
var number, sign = '', string = '', base = 10;
if (ch === '-' || ch === '+') {
sign = ch;
next(ch);
}
// support for Infinity (could tweak to allow other words):
if (ch === 'I') {
number = word();
if (typeof number !== 'number' || isNaN(number)) {
error('Unexpected word for number');
}
return (sign === '-') ? -number : number;
}
// support for NaN
if (ch === 'N') {
number = word();
if (!isNaN(number)) {
error('expected word to be NaN');
}
// ignore sign as -NaN also is NaN
return number;
}
if (ch === '0') {
string += ch;
next();
if (ch === 'x' || ch === 'X') {
string += ch;
next();
base = 16;
} else if (ch >= '0' && ch <= '9') {
error('Octal literal');
}
}
switch (base) {
case 10:
while (ch >= '0' && ch <= '9') {
string += ch;
next();
}
if (ch === '.') {
string += '.';
while (next() && ch >= '0' && ch <= '9') {
string += ch;
}
}
if (ch === 'e' || ch === 'E') {
string += ch;
next();
if (ch === '-' || ch === '+') {
string += ch;
next();
}
while (ch >= '0' && ch <= '9') {
string += ch;
next();
}
}
break;
case 16:
while (ch >= '0' && ch <= '9' || ch >= 'A' && ch <= 'F' || ch >= 'a' && ch <= 'f') {
string += ch;
next();
}
break;
}
if (sign === '-') {
number = -string;
} else {
number = +string;
}
if (!isFinite(number)) {
error("Bad number");
} else {
return number;
}
};
let string = () => {
// Parse a string value.
let hex, i, string = '', uffff;
let delim; // double quote or single quote
// When parsing for string values, we must look for ' or " and \ characters.
if (ch === '"' || ch === "'") {
delim = ch;
while (next()) {
if (ch === delim) {
next();
return string;
} else if (ch === '\\') {
next();
if (ch === 'u') {
uffff = 0;
for (i = 0; i < 4; i += 1) {
hex = parseInt(next(), 16);
if (!isFinite(hex)) {
break;
}
uffff = uffff * 16 + hex;
}
string += String.fromCharCode(uffff);
} else if (ch === '\r') {
if (peek() === '\n') {
next();
}
} else if (typeof escapee[ch] === 'string') {
string += escapee[ch];
} else {
break;
}
} else if (ch === '\n') {
// unescaped newlines are invalid; see:
// https://github.com/aseemk/json5/issues/24
// TODO this feels special-cased; are there other
// invalid unescaped chars?
break;
} else {
string += ch;
}
}
}
error("Bad string");
};
let inlineComment = () => {
// Skip an inline comment, assuming this is one. The current character should
// be the second / character in the // pair that begins this inline comment.
// To finish the inline comment, we look for a newline or the end of the text.
if (ch !== '/') {
error("Not an inline comment");
}
do {
next();
if (ch === '\n' || ch === '\r') {
next();
return;
}
} while (ch);
};
let blockComment = () => {
// Skip a block comment, assuming this is one. The current character should be
// the * character in the /* pair that begins this block comment.
// To finish the block comment, we look for an ending */ pair of characters,
// but we also watch for the end of text before the comment is terminated.
if (ch !== '*') {
error("Not a block comment");
}
do {
next();
while (ch === '*') {
next('*');
if (ch === '/') {
next('/');
return;
}
}
} while (ch);
error("Unterminated block comment");
};
let comment = () => {
// Skip a comment, whether inline or block-level, assuming this is one.
// Comments always begin with a / character.
if (ch !== '/') {
error("Not a comment");
}
next('/');
if (ch === '/') {
inlineComment();
} else if (ch === '*') {
blockComment();
} else {
error("Unrecognized comment");
}
};
let white = () => {
// Skip whitespace and comments.
// Note that we're detecting comments by only a single / character.
// This works since regular expressions are not valid JSON(5), but this will
// break if there are other valid values that begin with a / character!
while (ch) {
if (ch === '/') {
comment();
} else if (/\s/.test(ch)) {
next();
} else {
return;
}
}
};
let word = () => {
// true, false, or null.
switch (ch) {
case 't':
next('t');
next('r');
next('u');
next('e');
return true;
case 'f':
next('f');
next('a');
next('l');
next('s');
next('e');
return false;
case 'n':
next('n');
next('u');
next('l');
next('l');
return null;
case 'I':
next('I');
next('n');
next('f');
next('i');
next('n');
next('i');
next('t');
next('y');
return Infinity;
case 'N':
next('N');
next('a');
next('N');
return NaN;
}
error("Unexpected " + renderChar(ch));
};
let value;
let array = () => {
// Parse an array value.
let array = [];
if (ch === '[') {
next('[');
white();
while (ch) {
if (ch === ']') {
next(']');
return array; // Potentially empty array
}
// ES5 allows omitting elements in arrays, e.g. [,] and
// [,null]. We don't allow this in JSON5.
if (ch === ',') {
error("Missing array element");
} else {
array.push(value());
}
white();
// If there's no comma after this value, this needs to
// be the end of the array.
if (ch !== ',') {
next(']');
return array;
}
next(',');
white();
}
}
error("Bad array");
};
let object = () => {
// Parse an object value.
var key,
object = {};
if (ch === '{') {
next('{');
white();
while (ch) {
if (ch === '}') {
next('}');
return object; // Potentially empty object
}
// Keys can be unquoted. If they are, they need to be
// valid JS identifiers.
if (ch === '"' || ch === "'") {
key = string();
} else {
key = identifier();
}
white();
next(':');
object[key] = value();
white();
// If there's no comma after this pair, this needs to be
// the end of the object.
if (ch !== ',') {
next('}');
return object;
}
next(',');
white();
}
}
error("Bad object");
};
value = () => {
// Parse a JSON value. It could be an object, an array, a string, a number,
// or a word.
white();
switch (ch) {
case '{':
return object();
case '[':
return array();
case '"':
case "'":
return string();