"title" or 'title' or (title)
+ const linkRegex = /^([^<\s][^\s]*|<.*?>)(?: ("(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\((?:\\\(|\\\)|[^()])*\)))?$/m;
+ const linkMatch = linkRegex.exec(foundVar.content);
+
+ const href = linkMatch ? linkMatch[1] : null; //TODO: TRIM OFF < > IF PRESENT
+ const title = linkMatch ? linkMatch[2]?.slice(1, -1) : null;
+
+ if(!prefix[0] && href) // Link
+ return `[${label}](${href}${title ? ` "${title}"` : ''})`;
+
+ if(prefix[0] == '!' && href) // Image
+ return ``;
+
+ if(prefix[0] == '$') // Variable
+ return foundVar.content;
+};
+
+const lookupVar = function(label, index, hoist=false) {
+ while (index >= 0) {
+ if(globalVarsList[index]?.[label] !== undefined)
+ return globalVarsList[index][label];
+ index--;
+ }
+
+ if(hoist) { //If normal lookup failed, attempt hoisting
+ index = Object.keys(globalVarsList).length; // Move index to start from last page
+ while (index >= 0) {
+ if(globalVarsList[index]?.[label] !== undefined)
+ return globalVarsList[index][label];
+ index--;
+ }
+ }
+
+ return undefined;
+};
+
+const processVariableQueue = function() {
+ let resolvedOne = true;
+ let finalLoop = false;
+ while (resolvedOne || finalLoop) { // Loop through queue until no more variable calls can be resolved
+ resolvedOne = false;
+ for (const item of varsQueue) {
+ if(item.type == 'text')
+ continue;
+
+ if(item.type == 'varDefBlock') {
+ const regex = /[!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\]/g;
+ let match;
+ let resolved = true;
+ let tempContent = item.content;
+ while (match = regex.exec(item.content)) { // regex to find variable calls
+ const value = replaceVar(match[0], true);
+
+ if(value == undefined)
+ resolved = false;
+ else
+ tempContent = tempContent.replaceAll(match[0], value);
+ }
+
+ if(resolved == true || item.content != tempContent) {
+ resolvedOne = true;
+ item.content = tempContent;
+ }
+
+ globalVarsList[globalPageNumber][item.varName] = {
+ content : item.content,
+ resolved : resolved
+ };
+
+ if(resolved)
+ item.type = 'resolved';
+ }
+
+ if(item.type == 'varCallBlock' || item.type == 'varCallInline') {
+ const value = replaceVar(item.content, true, finalLoop); // final loop will just use the best value so far
+
+ if(value == undefined)
+ continue;
+
+ resolvedOne = true;
+ item.content = value;
+ item.type = 'text';
+ }
+ }
+ varsQueue = varsQueue.filter((item)=>item.type !== 'resolved'); // Remove any fully-resolved variable definitions
+
+ if(finalLoop)
+ break;
+ if(!resolvedOne)
+ finalLoop = true;
+ }
+ varsQueue = varsQueue.filter((item)=>item.type !== 'varDefBlock');
+};
+
+function MarkedVariables() {
+ return {
+ hooks : {
+ preprocess(src) {
+ const codeBlockSkip = /^(?: {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+|^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})(?:[^\n]*)(?:\n|$)(?:|(?:[\s\S]*?)(?:\n|$))(?: {0,3}\2[~`]* *(?=\n|$))|`[^`]*?`/;
+ const blockDefRegex = /^[!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\]:(?!\() *((?:\n? *[^\s].*)+)(?=\n+|$)/; //Matches 3, [4]:5
+ const blockCallRegex = /^[!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\](?=\n|$)/; //Matches 6, [7]
+ const inlineDefRegex = /([!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\])\(([^\n]+)\)/; //Matches 8, 9[10](11)
+ const inlineCallRegex = /[!$]?\[((?!\s*\])(?:\\.|[^\[\]\\])+)\](?!\()/; //Matches 12, [13]
+
+ // Combine regexes and wrap in parens like so: (regex1)|(regex2)|(regex3)|(regex4)
+ const combinedRegex = new RegExp([codeBlockSkip, blockDefRegex, blockCallRegex, inlineDefRegex, inlineCallRegex].map((s)=>`(${s.source})`).join('|'), 'gm');
+
+ let lastIndex = 0;
+ let match;
+ while ((match = combinedRegex.exec(src)) !== null) {
+ // Format any matches into tokens and store
+ if(match.index > lastIndex) { // Any non-variable stuff
+ varsQueue.push(
+ { type : 'text',
+ varName : null,
+ content : src.slice(lastIndex, match.index)
+ });
+ }
+ if(match[1]) {
+ varsQueue.push(
+ { type : 'text',
+ varName : null,
+ content : match[0]
+ });
+ }
+ if(match[3]) { // Block Definition
+ const label = match[4] ? match[4].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
+ const content = match[5] ? match[5].trim().replace(/[ \t]+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
+
+ varsQueue.push(
+ { type : 'varDefBlock',
+ varName : label,
+ content : content
+ });
+ }
+ if(match[6]) { // Block Call
+ const label = match[7] ? match[7].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
+
+ varsQueue.push(
+ { type : 'varCallBlock',
+ varName : label,
+ content : match[0]
+ });
+ }
+ if(match[8]) { // Inline Definition
+ const label = match[10] ? match[10].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
+ let content = match[11] ? match[11].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
+
+ // In case of nested (), find the correct matching end )
+ let level = 0;
+ let i;
+ for (i = 0; i < content.length; i++) {
+ if(content[i] === '\\') {
+ i++;
+ } else if(content[i] === '(') {
+ level++;
+ } else if(content[i] === ')') {
+ level--;
+ if(level < 0)
+ break;
+ }
+ }
+ if(i > -1) {
+ combinedRegex.lastIndex = combinedRegex.lastIndex - (content.length - i);
+ content = content.slice(0, i).trim().replace(/\s+/g, ' ');
+ }
+
+ varsQueue.push(
+ { type : 'varDefBlock',
+ varName : label,
+ content : content
+ });
+ varsQueue.push(
+ { type : 'varCallInline',
+ varName : label,
+ content : match[9]
+ });
+ }
+ if(match[12]) { // Inline Call
+ const label = match[13] ? match[13].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
+
+ varsQueue.push(
+ { type : 'varCallInline',
+ varName : label,
+ content : match[0]
+ });
+ }
+ lastIndex = combinedRegex.lastIndex;
+ }
+
+ if(lastIndex < src.length) {
+ varsQueue.push(
+ { type : 'text',
+ varName : null,
+ content : src.slice(lastIndex)
+ });
+ }
+
+ processVariableQueue();
+
+ const output = varsQueue.map((item)=>item.content).join('');
+ varsQueue = []; // Must clear varsQueue because custom HTML renderer uses Marked.parse which will preprocess again without clearing the array
+ return output;
+ }
+ }
+ };
+};
+//^=====--------------------< Variable Handling >-------------------=====^//
+
+Marked.use(MarkedVariables());
Marked.use({ extensions: [mustacheSpans, mustacheDivs, mustacheInjectInline, definitionLists, superSubScripts] });
Marked.use(mustacheInjectBlock);
-Marked.use({ renderer: renderer, mangle: false });
+Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
Marked.use(MarkedExtendedTables(), MarkedGFMHeadingId(), MarkedSmartypantsLite());
const nonWordAndColonTest = /[^\w:]/g;
@@ -369,12 +653,28 @@ const processStyleTags = (string)=>{
`${attributes?.length ? ` ${attributes.join(' ')}` : ''}`;
};
+const globalVarsList = {};
+let varsQueue = [];
+let globalPageNumber = 0;
+
module.exports = {
marked : Marked,
- render : (rawBrewText)=>{
+ render : (rawBrewText, pageNumber=1)=>{
+ globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order
+ varsQueue = []; //Could move into MarkedVariables()
+ globalPageNumber = pageNumber;
+
rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n\n`)
.replace(/^(:+)$/gm, (match)=>`${``.repeat(match.length)}\n`);
- return Marked.parse(rawBrewText);
+ const opts = Marked.defaults;
+
+ rawBrewText = opts.hooks.preprocess(rawBrewText);
+ const tokens = Marked.lexer(rawBrewText, opts);
+
+ Marked.walkTokens(tokens, opts.walkTokens);
+
+ const html = Marked.parser(tokens, opts);
+ return opts.hooks.postprocess(html);
},
validate : (rawBrewText)=>{
diff --git a/tests/markdown/variables.test.js b/tests/markdown/variables.test.js
new file mode 100644
index 000000000..c909dafec
--- /dev/null
+++ b/tests/markdown/variables.test.js
@@ -0,0 +1,373 @@
+/* eslint-disable max-lines */
+
+const dedent = require('dedent-tabs').default;
+const Markdown = require('naturalcrit/markdown.js');
+
+// Marked.js adds line returns after closing tags on some default tokens.
+// This removes those line returns for comparison sake.
+String.prototype.trimReturns = function(){
+ return this.replace(/\r?\n|\r/g, '').trim();
+};
+
+renderAllPages = function(pages){
+ const outputs = [];
+ pages.forEach((page, index)=>{
+ const output = Markdown.render(page, index);
+ outputs.push(output);
+ });
+
+ return outputs;
+};
+
+// Adding `.failing()` method to `describe` or `it` will make failing tests "pass" as long as they continue to fail.
+// Remove the `.failing()` method once you have fixed the issue.
+
+describe('Block-level variables', ()=>{
+ it('Handles variable assignment and recall with simple text', function() {
+ const source = dedent`
+ [var]: string
+
+ $[var]
+ `;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('string
');
+ });
+
+ it('Handles variable assignment and recall with multiline string', function() {
+ const source = dedent`
+ [var]: string
+ across multiple
+ lines
+
+ $[var]`;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('string across multiple lines
');
+ });
+
+ it('Handles variable assignment and recall with tables', function() {
+ const source = dedent`
+ [var]:
+ ##### Title
+ | H1 | H2 |
+ |:---|:--:|
+ | A | B |
+ | C | D |
+
+ $[var]`;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+ Title
+ | H1 |
+ H2 |
+
|---|
| A |
+ B |
+
| C |
+ D |
+
`.trimReturns());
+ });
+
+ it('Hoists undefined variables', function() {
+ const source = dedent`
+ $[var]
+
+ [var]: string`;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('string
');
+ });
+
+ it('Hoists last instance of variable', function() {
+ const source = dedent`
+ $[var]
+
+ [var]: string
+
+ [var]: new string`;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('new string
');
+ });
+
+ it('Handles complex hoisting', function() {
+ const source = dedent`
+ $[titleAndName]: $[title] $[fullName]
+
+ $[title]: Mr.
+
+ $[fullName]: $[firstName] $[lastName]
+
+ [firstName]: Bob
+
+ Welcome, $[titleAndName]!
+
+ [lastName]: Jacob
+
+ [lastName]: $[lastName]son
+ `;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('Welcome, Mr. Bob Jacobson!
');
+ });
+
+ it('Handles variable reassignment', function() {
+ const source = dedent`
+ [var]: one
+
+ $[var]
+
+ [var]: two
+
+ $[var]
+ `;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('one
two
'.trimReturns());
+ });
+
+ it('Handles variable reassignment with hoisting', function() {
+ const source = dedent`
+ $[var]
+
+ [var]: one
+
+ $[var]
+
+ [var]: two
+
+ $[var]
+ `;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('two
one
two
'.trimReturns());
+ });
+
+ it('Ignores undefined variables that can\'t be hoisted', function() {
+ const source = dedent`
+ $[var](My name is $[first] $[last])
+
+ $[last]: Jones
+ `;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`My name is $[first] Jones
`.trimReturns());
+ });
+});
+
+describe('Inline-level variables', ()=>{
+ it('Handles variable assignment and recall with simple text', function() {
+ const source = dedent`
+ $[var](string)
+
+ $[var]
+ `;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('string
string
');
+ });
+
+ it('Hoists undefined variables when possible', function() {
+ const source = dedent`
+ $[var](My name is $[name] Jones)
+
+ [name]: Bob`;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('My name is Bob Jones
');
+ });
+
+ it('Hoists last instance of variable', function() {
+ const source = dedent`
+ $[var](My name is $[name] Jones)
+
+ $[name](Bob)
+
+ [name]: Bill`;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`My name is Bill Jones
Bob
`.trimReturns());
+ });
+
+ it('Only captures nested parens if balanced', function() {
+ const source = dedent`
+ $[var1](A variable (with nested parens) inside)
+
+ $[var1]
+
+ $[var2](A variable ) with unbalanced parens)
+
+ $[var2]`;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+ A variable (with nested parens) inside
+ A variable (with nested parens) inside
+ A variable with unbalanced parens)
+ A variable
+ `.trimReturns());
+ });
+});
+
+describe('Math', ()=>{
+ it('Handles simple math using numbers only', function() {
+ const source = dedent`
+ $[1 + 3 * 5 - (1 / 4)]
+ `;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('15.75
');
+ });
+
+ it('Handles round function', function() {
+ const source = dedent`
+ $[round(1/4)]`;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('0
');
+ });
+
+ it('Handles floor function', function() {
+ const source = dedent`
+ $[floor(0.6)]`;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('0
');
+ });
+
+ it('Handles ceil function', function() {
+ const source = dedent`
+ $[ceil(0.2)]`;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('1
');
+ });
+
+ it('Handles nested functions', function() {
+ const source = dedent`
+ $[ceil(floor(round(0.6)))]`;
+ const rendered = Markdown.render(source).replace(/\s/g, ' ').trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('1
');
+ });
+
+ it('Handles simple math with variables', function() {
+ const source = dedent`
+ $[num1]: 5
+
+ $[num2]: 4
+
+ Answer is $[answer]($[1 + 3 * num1 - (1 / num2)]).
+ `;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('Answer is 15.75.
');
+ });
+
+ it('Handles variable incrementing', function() {
+ const source = dedent`
+ $[num1]: 5
+
+ Increment num1 to get $[num1]($[num1 + 1]) and again to $[num1]($[num1 + 1]).
+ `;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('Increment num1 to get 6 and again to 7.
');
+ });
+});
+
+describe('Code blocks', ()=>{
+ it('Ignores all variables in fenced code blocks', function() {
+ const source = dedent`
+ \`\`\`
+ [var]: string
+
+ $[var]
+
+ $[var](new string)
+ \`\`\`
+ `;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+
+ [var]: string
+
+ $[var]
+
+ $[var](new string)
+
`.trimReturns());
+ });
+
+ it('Ignores all variables in indented code blocks', function() {
+ const source = dedent`
+ test
+
+ [var]: string
+
+ $[var]
+
+ $[var](new string)
+ `;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+ test
+
+
+ [var]: string
+
+ $[var]
+
+ $[var](new string)
+
`.trimReturns());
+ });
+
+ it('Ignores all variables in inline code blocks', function() {
+ const source = '[var](Hello) `[link](url)`. This `[var] does not work`';
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+ var [link](url). This [var] does not work
`.trimReturns());
+ });
+});
+
+describe('Normal Links and Images', ()=>{
+ it('Renders normal images', function() {
+ const source = ``;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+ 
`.trimReturns());
+ });
+
+ it('Renders normal images with a title', function() {
+ const source = 'An image !';
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+ An image
!
`.trimReturns());
+ });
+
+ it('Applies curly injectors to images', function() {
+ const source = `{width:100px}`;
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+ 
`.trimReturns());
+ });
+
+ it('Renders normal links', function() {
+ const source = 'A Link to my [website](url)!';
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+ A Link to my website!
`.trimReturns());
+ });
+
+ it('Renders normal links with a title', function() {
+ const source = 'A Link to my [website](url "and title")!';
+ const rendered = Markdown.render(source).trimReturns();
+ expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
+ A Link to my website!
`.trimReturns());
+ });
+});
+
+describe('Cross-page variables', ()=>{
+ it('Handles variable assignment and recall across pages', function() {
+ const source0 = `[var]: string`;
+ const source1 = `$[var]`;
+ const rendered = renderAllPages([source0, source1]).join('\n\\page\n').trimReturns();
+ expect(rendered, `Input:\n${[source0, source1].join('\n\\page\n')}`, { showPrefix: false }).toBe('\\pagestring
');
+ });
+
+ it('Handles hoisting across pages', function() {
+ const source0 = `$[var]`;
+ const source1 = `[var]: string`;
+ renderAllPages([source0, source1]).join('\n\\page\n').trimReturns(); //Requires one full render of document before hoisting is picked up
+ const rendered = renderAllPages([source0, source1]).join('\n\\page\n').trimReturns();
+ expect(rendered, `Input:\n${[source0, source1].join('\n\\page\n')}`, { showPrefix: false }).toBe('string
\\page');
+ });
+
+ it('Handles reassignment and hoisting across pages', function() {
+ const source0 = `$[var]\n\n[var]: one\n\n$[var]`;
+ const source1 = `[var]: two\n\n$[var]`;
+ renderAllPages([source0, source1]).join('\n\\page\n').trimReturns(); //Requires one full render of document before hoisting is picked up
+ const rendered = renderAllPages([source0, source1]).join('\n\\page\n').trimReturns();
+ expect(rendered, `Input:\n${[source0, source1].join('\n\\page\n')}`, { showPrefix: false }).toBe('two
one
\\pagetwo
');
+ });
+});
\ No newline at end of file