diff --git a/package-lock.json b/package-lock.json index 11c8f9f..928096b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@archiewood/markdoc", - "version": "0.0.0-evidence.17", + "version": "0.0.0-evidence.29", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@archiewood/markdoc", - "version": "0.0.0-evidence.17", + "version": "0.0.0-evidence.29", "license": "MIT", "devDependencies": { "@types/jasmine": "^3.10.2", diff --git a/package.json b/package.json index 8cf3c28..d580bd7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@hughess/markdoc", + "name": "@archiewood/markdoc", "author": "Ryan Paul", - "version": "0.0.0-evidence.28", + "version": "0.0.0-evidence.29", "description": "A text markup language for documentation", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/spec/marktest/tests.yaml b/spec/marktest/tests.yaml index 15aa1e8..9ef20ef 100644 --- a/spec/marktest/tests.yaml +++ b/spec/marktest/tests.yaml @@ -2302,3 +2302,141 @@ children: - tag: p children: [This is a test] + +- name: Triple-quoted string attribute + config: + tags: + test: + render: test + attributes: + bar: + type: String + render: true + code: | + {% test bar="""simple string""" /%} + expected: + - tag: test + attributes: + bar: 'simple string' + +- name: Triple-quoted string with quotes inside + config: + tags: + test: + render: test + attributes: + bar: + type: String + render: true + code: | + {% test bar="""He said "hello" and 'goodbye'""" /%} + expected: + - tag: test + attributes: + bar: 'He said "hello" and ''goodbye''' + +- name: Triple-quoted string with newlines + config: + tags: + test: + render: test + attributes: + query: + type: String + render: true + code: | + {% test query=""" + SELECT * + FROM users + WHERE active = true + """ /%} + expected: + - tag: test + attributes: + query: "\nSELECT *\nFROM users\nWHERE active = true\n" + +- name: Triple-quoted string with SQL CASE statement + config: + tags: + table: + render: table + attributes: + series: + type: String + render: true + code: | + {% table series=""" + case + when total_sales > 18000 then 'High' + when total_sales > 9000 then 'Medium' + else 'Low' + end + """ /%} + expected: + - tag: table + attributes: + series: "\n case\n when total_sales > 18000 then 'High'\n when total_sales > 9000 then 'Medium'\n else 'Low'\n end\n" + +- name: Mixed regular and triple-quoted strings + config: + tags: + test: + render: test + attributes: + foo: + type: String + render: true + bar: + type: String + render: true + baz: + type: Boolean + render: true + code: | + {% test foo="regular" bar="""triple + quoted""" baz=true /%} + expected: + - tag: test + attributes: + foo: 'regular' + bar: "triple\nquoted" + baz: true + +- name: Empty triple-quoted string + config: + tags: + test: + render: test + attributes: + bar: + type: String + render: true + code: | + {% test bar="""""" /%} + expected: + - tag: test + attributes: + bar: '' + +- name: Triple-quoted string with interpolation + config: + variables: + condition: "status = 'active'" + tags: + test: + render: test + attributes: + query: + type: String + render: true + code: | + {% test query=""" + SELECT * + FROM users + WHERE {{$condition}} + """ /%} + expected: + - tag: test + attributes: + query: "\nSELECT *\nFROM users\nWHERE status = 'active'\n" + diff --git a/src/grammar/tag.js b/src/grammar/tag.js index 67ab2c3..6e0ca5f 100644 --- a/src/grammar/tag.js +++ b/src/grammar/tag.js @@ -168,11 +168,12 @@ function peg$parse(input, options) { var peg$c12 = '{'; var peg$c13 = '}'; var peg$c14 = '-'; - var peg$c15 = '"'; - var peg$c16 = '\\'; - var peg$c17 = 'n'; - var peg$c18 = 'r'; - var peg$c19 = 't'; + var peg$c15 = '"""'; + var peg$c16 = '"'; + var peg$c17 = '\\'; + var peg$c18 = 'n'; + var peg$c19 = 'r'; + var peg$c20 = 't'; var peg$r0 = /^[$@]/; var peg$r1 = /^[0-9]/; @@ -302,16 +303,19 @@ function peg$parse(input, options) { var peg$f28 = function (value) { return value.join(''); }; - var peg$f29 = function () { - return '\n'; + var peg$f29 = function (char) { + return char; }; var peg$f30 = function () { - return '\r'; + return '\n'; }; var peg$f31 = function () { + return '\r'; + }; + var peg$f32 = function () { return '\t'; }; - var peg$f32 = function (sequence) { + var peg$f33 = function (sequence) { return sequence; }; @@ -1646,7 +1650,7 @@ function peg$parse(input, options) { } function peg$parseValueString() { - var s0, s1, s2, s3; + var s0; var rule$expects = function (expected) { if (peg$silentFails === 0) peg$expect(expected); @@ -1654,9 +1658,112 @@ function peg$parse(input, options) { rule$expects(peg$e16); peg$silentFails++; + s0 = peg$parseValueTripleQuotedString(); + if (s0 === peg$FAILED) { + s0 = peg$parseValueQuotedString(); + } + peg$silentFails--; + + return s0; + } + + function peg$parseValueTripleQuotedString() { + var s0, s1, s2, s3; + + var rule$expects = function (expected) { + if (peg$silentFails === 0) peg$expect(expected); + }; + s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 34) { + if (input.substr(peg$currPos, 3) === peg$c15) { s1 = peg$c15; + peg$currPos += 3; + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$parseValueTripleQuotedChars(); + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$parseValueTripleQuotedChars(); + } + if (input.substr(peg$currPos, 3) === peg$c15) { + s3 = peg$c15; + peg$currPos += 3; + } else { + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f28(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseValueTripleQuotedChars() { + var s0, s1, s2; + + var rule$expects = function (expected) { + if (peg$silentFails === 0) peg$expect(expected); + }; + + s0 = peg$currPos; + s1 = peg$currPos; + peg$begin(); + if (input.substr(peg$currPos, 3) === peg$c15) { + s2 = peg$c15; + peg$currPos += 3; + } else { + s2 = peg$FAILED; + } + peg$end(true); + if (s2 === peg$FAILED) { + s1 = undefined; + } else { + peg$currPos = s1; + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + if (input.length > peg$currPos) { + s2 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f29(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseValueQuotedString() { + var s0, s1, s2, s3; + + var rule$expects = function (expected) { + if (peg$silentFails === 0) peg$expect(expected); + }; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 34) { + s1 = peg$c16; peg$currPos++; } else { s1 = peg$FAILED; @@ -1669,7 +1776,7 @@ function peg$parse(input, options) { s3 = peg$parseValueStringChars(); } if (input.charCodeAt(peg$currPos) === 34) { - s3 = peg$c15; + s3 = peg$c16; peg$currPos++; } else { s3 = peg$FAILED; @@ -1685,7 +1792,6 @@ function peg$parse(input, options) { peg$currPos = s0; s0 = peg$FAILED; } - peg$silentFails--; return s0; } @@ -1719,21 +1825,21 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c16; + s1 = peg$c17; peg$currPos++; } else { s1 = peg$FAILED; } if (s1 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c15; + s2 = peg$c16; peg$currPos++; } else { s2 = peg$FAILED; } if (s2 === peg$FAILED) { if (input.charCodeAt(peg$currPos) === 92) { - s2 = peg$c16; + s2 = peg$c17; peg$currPos++; } else { s2 = peg$FAILED; @@ -1741,40 +1847,40 @@ function peg$parse(input, options) { if (s2 === peg$FAILED) { s2 = peg$currPos; if (input.charCodeAt(peg$currPos) === 110) { - s3 = peg$c17; + s3 = peg$c18; peg$currPos++; } else { s3 = peg$FAILED; } if (s3 !== peg$FAILED) { peg$savedPos = s2; - s3 = peg$f29(); + s3 = peg$f30(); } s2 = s3; if (s2 === peg$FAILED) { s2 = peg$currPos; if (input.charCodeAt(peg$currPos) === 114) { - s3 = peg$c18; + s3 = peg$c19; peg$currPos++; } else { s3 = peg$FAILED; } if (s3 !== peg$FAILED) { peg$savedPos = s2; - s3 = peg$f30(); + s3 = peg$f31(); } s2 = s3; if (s2 === peg$FAILED) { s2 = peg$currPos; if (input.charCodeAt(peg$currPos) === 116) { - s3 = peg$c19; + s3 = peg$c20; peg$currPos++; } else { s3 = peg$FAILED; } if (s3 !== peg$FAILED) { peg$savedPos = s2; - s3 = peg$f31(); + s3 = peg$f32(); } s2 = s3; } @@ -1783,7 +1889,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f32(s2); + s0 = peg$f33(s2); } else { peg$currPos = s0; s0 = peg$FAILED; diff --git a/src/grammar/tag.pegjs b/src/grammar/tag.pegjs index 8db36f5..4432998 100644 --- a/src/grammar/tag.pegjs +++ b/src/grammar/tag.pegjs @@ -159,6 +159,16 @@ ValueNumber 'number' = '-'? [0-9]+ ('.'[0-9]+)? { return parseFloat(text()); } ValueString 'string' = + ValueTripleQuotedString / + ValueQuotedString + +ValueTripleQuotedString = + '"""' value:ValueTripleQuotedChars* '"""' { return value.join(''); } + +ValueTripleQuotedChars = + !'"""' char:. { return char; } + +ValueQuotedString = '"' value:ValueStringChars* '"' { return value.join(''); } ValueStringChars = diff --git a/src/grammar/tag.test.ts b/src/grammar/tag.test.ts index 4b71474..08d4c6b 100644 --- a/src/grammar/tag.test.ts +++ b/src/grammar/tag.test.ts @@ -356,5 +356,67 @@ describe('Markdoc tag parser', function () { for (const example of examples) expect(() => parse(example)).toThrowError(SyntaxError); }); + + describe('with triple-quoted strings', function () { + it('with a simple triple-quoted string', function () { + const example = parse('foo="""bar"""'); + expect(example.meta.attributes).toDeepEqual([ + { type: 'attribute', name: 'foo', value: 'bar' }, + ]); + }); + + it('with multi-line triple-quoted string', function () { + const example = parse('foo="""line1\nline2\nline3"""'); + expect(example.meta.attributes).toDeepEqual([ + { type: 'attribute', name: 'foo', value: 'line1\nline2\nline3' }, + ]); + }); + + it('with quotes inside triple-quoted string', function () { + const example = parse('foo="""He said "hello"!"""'); + expect(example.meta.attributes).toDeepEqual([ + { type: 'attribute', name: 'foo', value: 'He said "hello"!' }, + ]); + }); + + it('with SQL case statement', function () { + const example = parse(`series=""" + case + when total_sales > 18000 then 'High' + when total_sales > 9000 then 'Medium' + else 'Low' + end + """`); + expect(example.meta.attributes).toDeepEqual([ + { + type: 'attribute', + name: 'series', + value: ` + case + when total_sales > 18000 then 'High' + when total_sales > 9000 then 'Medium' + else 'Low' + end + `, + }, + ]); + }); + + it('with multiple attributes including triple-quoted', function () { + const example = parse('foo="bar" baz="""multi\nline""" test=true'); + expect(example.meta.attributes).toDeepEqual([ + { type: 'attribute', name: 'foo', value: 'bar' }, + { type: 'attribute', name: 'baz', value: 'multi\nline' }, + { type: 'attribute', name: 'test', value: true }, + ]); + }); + + it('with empty triple-quoted string', function () { + const example = parse('foo=""""""'); + expect(example.meta.attributes).toDeepEqual([ + { type: 'attribute', name: 'foo', value: '' }, + ]); + }); + }); }); }); diff --git a/src/utils.test.ts b/src/utils.test.ts index b39301e..ecabd20 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -182,6 +182,67 @@ describe('Templating', function () { }, ]); }); + + describe('triple-quoted strings', function () { + it('with simple triple-quoted string', function () { + const example = '{% foo bar="""test""" %}'; + const end = findTagEnd(example, 2); + expect(end).toEqual(22); + expect(example[end]).toEqual('%'); + }); + + it('with multi-line triple-quoted string', function () { + const example = '{% foo bar="""line1\nline2\nline3""" %}'; + const end = findTagEnd(example, 2); + expect(end).toEqual(35); + expect(example[end]).toEqual('%'); + }); + + it('with quotes inside triple-quoted string', function () { + const example = '{% foo bar="""He said "hello"!""" %}'; + const end = findTagEnd(example, 2); + expect(end).toEqual(34); + expect(example[end]).toEqual('%'); + }); + + it('with multi-line SQL case statement', function () { + const example = `{% table + data="demo_daily_orders" + date="date" + series=""" + case + when total_sales > 18000 then 'High' + when total_sales > 9000 then 'Medium' + else 'Low' + end + """ +/%}`; + const end = findTagEnd(example, 2); + expect(end).toEqual(example.length - 2); + expect(example[end]).toEqual('%'); + }); + + it('with multiple attributes including triple-quoted', function () { + const example = '{% foo bar="test" baz="""multi\nline""" test=true %}'; + const end = findTagEnd(example, 2); + expect(end).toEqual(49); + expect(example[end]).toEqual('%'); + }); + + it('with empty triple-quoted string', function () { + const example = '{% foo bar="""""" %}'; + const end = findTagEnd(example, 2); + expect(end).toEqual(18); + expect(example[end]).toEqual('%'); + }); + + it('with triple-quoted and regular quoted strings', function () { + const example = '{% foo a="first" b="""second""" c="third" %}'; + const end = findTagEnd(example, 2); + expect(end).toEqual(42); + expect(example[end]).toEqual('%'); + }); + }); }); describe('interpolateString', function () { diff --git a/src/utils.ts b/src/utils.ts index 5b3c81b..aaebce4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,6 +8,7 @@ enum STATES { normal, string, escape, + triple_string, } export const OPEN = '{%'; @@ -154,9 +155,21 @@ export function findTagEnd(content: string, start = 0) { case STATES.escape: state = STATES.string; break; + case STATES.triple_string: + if (char === '"' && content[pos + 1] === '"' && content[pos + 2] === '"') { + state = STATES.normal; + pos += 2; // Skip the next two quotes + } + break; case STATES.normal: - if (char === '"') state = STATES.string; - else if (content.startsWith(CLOSE, pos)) return pos; + if (char === '"' && content[pos + 1] === '"' && content[pos + 2] === '"') { + state = STATES.triple_string; + pos += 2; // Skip the next two quotes + } else if (char === '"') { + state = STATES.string; + } else if (content.startsWith(CLOSE, pos)) { + return pos; + } } }