diff --git a/.changeset/concise-backtick-text.md b/.changeset/concise-backtick-text.md
new file mode 100644
index 00000000..ec1e2164
--- /dev/null
+++ b/.changeset/concise-backtick-text.md
@@ -0,0 +1,9 @@
+---
+"htmljs-parser": minor
+---
+
+Add backtick text to concise mode. A run of backticks after a tag's attributes (or at the start of a line) begins text just like `--`, either on one line or as an indented block closed by a line of the same backticks. The text is exactly what follows the opening backticks, so leading and trailing whitespace is kept, and a closing run of the same length is optional on a single line and may be followed by a comment. A backtick after whitespace where an attribute could start was previously read as an attribute named by the template literal; it is now text.
+
+`onText` and `onPlaceholder` ranges now have a `delimiter`: the range of the `--` or backtick run that began the concise text block, or `undefined` outside of one.
+
+A concise line that ends its open tag before any tag name, such as a line that starts with a semicolon, is now reported as a parse error instead of emitting an open tag end with no tag name.
diff --git a/README.md b/README.md
index b31bdec9..b8bd0e59 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,14 @@ const parser = createParser({
* @example
* 1╭─
Hi
* ╰─ ╰─ text "Hi"
+ *
+ * Text within a concise mode `--` or backtick block also has the
+ * `range.delimiter` that began the block.
+ *
+ * @example
+ * 1╭─ div `Hi`
+ * │ │╰─ text "Hi"
+ * ╰─ ╰─ text.delimiter "`"
*/
onText(range) {},
diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts
index 8744bb29..2a1a1c06 100644
--- a/src/__tests__/api.test.ts
+++ b/src/__tests__/api.test.ts
@@ -41,6 +41,48 @@ describe("api", () => {
});
});
+describe("text delimiters", () => {
+ function delimiters(src: string) {
+ const out: (string | undefined)[] = [];
+ const read = (range: { start: number; end: number } | undefined) =>
+ range && src.slice(range.start, range.end);
+ const parser = createParser({
+ onText: (range) => out.push(read(range.delimiter)),
+ onPlaceholder: (range) => out.push(read(range.delimiter)),
+ });
+ parser.parse(src);
+ return out;
+ }
+
+ it("is the hyphens or backticks that begin the text", () => {
+ assert.deepEqual(delimiters("a -- x"), ["--"]);
+ assert.deepEqual(delimiters("a ---- x"), ["----"]);
+ assert.deepEqual(delimiters("a `x`"), ["`"]);
+ assert.deepEqual(delimiters("a ``x``"), ["``"]);
+ assert.deepEqual(delimiters("-- x\n`y`"), ["--", "`"]);
+ });
+
+ it("applies to every line of a block and to placeholders", () => {
+ assert.deepEqual(delimiters("a `\n x ${y}\n z\n `"), [
+ "`",
+ "`",
+ "`",
+ "`",
+ ]);
+ assert.deepEqual(delimiters("a --\n x\n z\n --"), ["--", "--"]);
+ });
+
+ it("is absent outside of a block", () => {
+ assert.deepEqual(delimiters("x ${y}"), [undefined, undefined]);
+ assert.deepEqual(delimiters("a `x`\ny"), ["`", undefined]);
+ assert.deepEqual(delimiters("a -- x\nb\n y"), ["--", undefined]);
+ });
+
+ it("covers text until the end of the input", () => {
+ assert.deepEqual(delimiters("a `\n x"), ["`"]);
+ });
+});
+
describe("validation internals", () => {
it("rejects doubled operators followed by a terminator", () => {
assert.equal(isValidAttrValue(" ++ ,", true), 0);
diff --git a/src/__tests__/fixtures/concise-backtick-attr-name-in-group/__snapshots__/concise-backtick-attr-name-in-group.expected.txt b/src/__tests__/fixtures/concise-backtick-attr-name-in-group/__snapshots__/concise-backtick-attr-name-in-group.expected.txt
new file mode 100644
index 00000000..45eafcd6
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-attr-name-in-group/__snapshots__/concise-backtick-attr-name-in-group.expected.txt
@@ -0,0 +1,14 @@
+1╭─ div [a `t`]
+ │ │ │ ╰─ attrName "`t`"
+ │ │ ╰─ attrName
+ ╰─ ╰─ tagName "div"
+2╭─ div [a, `t`] `text`
+ │ │ │ │ │╰─ text "text"
+ │ │ │ │ ╰─ openTagEnd
+ │ │ │ ╰─ attrName "`t`"
+ │ │ ╰─ attrName
+ │ ├─ closeTagEnd(div)
+ │ ├─ openTagEnd
+ ╰─ ╰─ tagName "div"
+3╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-attr-name-in-group/input.marko b/src/__tests__/fixtures/concise-backtick-attr-name-in-group/input.marko
new file mode 100644
index 00000000..bd7e26d6
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-attr-name-in-group/input.marko
@@ -0,0 +1,2 @@
+div [a `t`]
+div [a, `t`] `text`
diff --git a/src/__tests__/fixtures/concise-backtick-attr-value-tagged-template/__snapshots__/concise-backtick-attr-value-tagged-template.expected.txt b/src/__tests__/fixtures/concise-backtick-attr-value-tagged-template/__snapshots__/concise-backtick-attr-value-tagged-template.expected.txt
new file mode 100644
index 00000000..f04ee8f5
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-attr-value-tagged-template/__snapshots__/concise-backtick-attr-value-tagged-template.expected.txt
@@ -0,0 +1,16 @@
+1╭─ div a=b` hi`
+ │ │ ││╰─ attrValue.value "b` hi`"
+ │ │ │╰─ attrValue "=b` hi`"
+ │ │ ╰─ attrName
+ ╰─ ╰─ tagName "div"
+2╭─ div a=b`hi` `text`
+ │ │ │││ │╰─ text "text"
+ │ │ │││ ╰─ openTagEnd
+ │ │ ││╰─ attrValue.value "b`hi`"
+ │ │ │╰─ attrValue "=b`hi`"
+ │ │ ╰─ attrName
+ │ ├─ closeTagEnd(div)
+ │ ├─ openTagEnd
+ ╰─ ╰─ tagName "div"
+3╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-attr-value-tagged-template/input.marko b/src/__tests__/fixtures/concise-backtick-attr-value-tagged-template/input.marko
new file mode 100644
index 00000000..72ec268a
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-attr-value-tagged-template/input.marko
@@ -0,0 +1,2 @@
+div a=b` hi`
+div a=b`hi` `text`
diff --git a/src/__tests__/fixtures/concise-backtick-attr-value-template/__snapshots__/concise-backtick-attr-value-template.expected.txt b/src/__tests__/fixtures/concise-backtick-attr-value-template/__snapshots__/concise-backtick-attr-value-template.expected.txt
new file mode 100644
index 00000000..9371f0ef
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-attr-value-template/__snapshots__/concise-backtick-attr-value-template.expected.txt
@@ -0,0 +1,19 @@
+1╭─ div a=`x` b=`y` `text`
+ │ │ │││ │││ │╰─ text "text"
+ │ │ │││ │││ ╰─ openTagEnd
+ │ │ │││ ││╰─ attrValue.value "`y`"
+ │ │ │││ │╰─ attrValue "=`y`"
+ │ │ │││ ╰─ attrName
+ │ │ ││╰─ attrValue.value "`x`"
+ │ │ │╰─ attrValue "=`x`"
+ │ │ ╰─ attrName
+ ╰─ ╰─ tagName "div"
+2╭─ div a=`x ${y} z`
+ │ │ ││╰─ attrValue.value "`x ${y} z`"
+ │ │ │╰─ attrValue "=`x ${y} z`"
+ │ │ ╰─ attrName
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─
+ │ ├─ openTagEnd
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-attr-value-template/input.marko b/src/__tests__/fixtures/concise-backtick-attr-value-template/input.marko
new file mode 100644
index 00000000..352c32e4
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-attr-value-template/input.marko
@@ -0,0 +1,2 @@
+div a=`x` b=`y` `text`
+div a=`x ${y} z`
diff --git a/src/__tests__/fixtures/concise-backtick-attr-value-ternary/__snapshots__/concise-backtick-attr-value-ternary.expected.txt b/src/__tests__/fixtures/concise-backtick-attr-value-ternary/__snapshots__/concise-backtick-attr-value-ternary.expected.txt
new file mode 100644
index 00000000..7a80a7c9
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-attr-value-ternary/__snapshots__/concise-backtick-attr-value-ternary.expected.txt
@@ -0,0 +1,16 @@
+1╭─ div class=a ? `x` : `y`
+ │ │ │ │╰─ attrValue.value "a ? `x` : `y`"
+ │ │ │ ╰─ attrValue "=a ? `x` : `y`"
+ │ │ ╰─ attrName "class"
+ ╰─ ╰─ tagName "div"
+2╭─ div class=a ?
+ │ │ │ │╰─ attrValue.value "a ?\n `x` : `y`"
+ │ │ │ ╰─ attrValue "=a ?\n `x` : `y`"
+ │ │ ╰─ attrName "class"
+ │ ├─ closeTagEnd(div)
+ │ ├─ openTagEnd
+ ╰─ ╰─ tagName "div"
+3├─ `x` : `y`
+4╭─
+ │ ├─ openTagEnd
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-attr-value-ternary/input.marko b/src/__tests__/fixtures/concise-backtick-attr-value-ternary/input.marko
new file mode 100644
index 00000000..7a689b4e
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-attr-value-ternary/input.marko
@@ -0,0 +1,3 @@
+div class=a ? `x` : `y`
+div class=a ?
+ `x` : `y`
diff --git a/src/__tests__/fixtures/concise-backtick-tag-args-template/__snapshots__/concise-backtick-tag-args-template.expected.txt b/src/__tests__/fixtures/concise-backtick-tag-args-template/__snapshots__/concise-backtick-tag-args-template.expected.txt
new file mode 100644
index 00000000..88563377
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-tag-args-template/__snapshots__/concise-backtick-tag-args-template.expected.txt
@@ -0,0 +1,14 @@
+1╭─ div(`x`) `text`
+ │ │ ││ │╰─ text "text"
+ │ │ ││ ╰─ openTagEnd
+ │ │ │╰─ tagArgs.value "`x`"
+ │ │ ╰─ tagArgs "(`x`)"
+ ╰─ ╰─ tagName "div"
+2╭─ div(`x`)
+ │ │ │╰─ tagArgs.value "`x`"
+ │ │ ╰─ tagArgs "(`x`)"
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─
+ │ ├─ openTagEnd
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-tag-args-template/input.marko b/src/__tests__/fixtures/concise-backtick-tag-args-template/input.marko
new file mode 100644
index 00000000..ea316253
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-tag-args-template/input.marko
@@ -0,0 +1,2 @@
+div(`x`) `text`
+div(`x`)
diff --git a/src/__tests__/fixtures/concise-backtick-text-after-comma/__snapshots__/concise-backtick-text-after-comma.expected.txt b/src/__tests__/fixtures/concise-backtick-text-after-comma/__snapshots__/concise-backtick-text-after-comma.expected.txt
new file mode 100644
index 00000000..69e21572
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-after-comma/__snapshots__/concise-backtick-text-after-comma.expected.txt
@@ -0,0 +1,18 @@
+1╭─ div a=1, `t`
+ │ │ │││ │╰─ text
+ │ │ │││ ╰─ openTagEnd
+ │ │ ││╰─ attrValue.value
+ │ │ │╰─ attrValue "=1"
+ │ │ ╰─ attrName
+ ╰─ ╰─ tagName "div"
+2╭─ div a=1,
+ │ │ ││╰─ attrValue.value
+ │ │ │╰─ attrValue "=1"
+ │ │ ╰─ attrName
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─ `t`
+ │ │╰─ text
+ ╰─ ╰─ openTagEnd
+4╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-after-comma/input.marko b/src/__tests__/fixtures/concise-backtick-text-after-comma/input.marko
new file mode 100644
index 00000000..9d53e916
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-after-comma/input.marko
@@ -0,0 +1,3 @@
+div a=1, `t`
+div a=1,
+ `t`
diff --git a/src/__tests__/fixtures/concise-backtick-text-after-semicolon/__snapshots__/concise-backtick-text-after-semicolon.expected.txt b/src/__tests__/fixtures/concise-backtick-text-after-semicolon/__snapshots__/concise-backtick-text-after-semicolon.expected.txt
new file mode 100644
index 00000000..6b110317
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-after-semicolon/__snapshots__/concise-backtick-text-after-semicolon.expected.txt
@@ -0,0 +1,5 @@
+1╭─ div; `x`
+ │ │ │╰─ error(INVALID_CODE_AFTER_SEMICOLON:A semicolon indicates the end of a line. Only comments may follow it.)
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╰─
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-after-semicolon/input.marko b/src/__tests__/fixtures/concise-backtick-text-after-semicolon/input.marko
new file mode 100644
index 00000000..28429570
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-after-semicolon/input.marko
@@ -0,0 +1 @@
+div; `x`
diff --git a/src/__tests__/fixtures/concise-backtick-text-bad-indentation/__snapshots__/concise-backtick-text-bad-indentation.expected.txt b/src/__tests__/fixtures/concise-backtick-text-bad-indentation/__snapshots__/concise-backtick-text-bad-indentation.expected.txt
new file mode 100644
index 00000000..ffb51912
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-bad-indentation/__snapshots__/concise-backtick-text-bad-indentation.expected.txt
@@ -0,0 +1,8 @@
+1╭─ div
+ ╰─ ╰─ tagName "div"
+2╭─ `a`
+ │ │ ╰─ text
+ ╰─ ╰─ openTagEnd
+3╭─ `b`
+ ╰─ ╰─ error(INVALID_INDENTATION:Line indentation does match indentation of previous line)
+4╰─
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-bad-indentation/input.marko b/src/__tests__/fixtures/concise-backtick-text-bad-indentation/input.marko
new file mode 100644
index 00000000..8bd6a80a
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-bad-indentation/input.marko
@@ -0,0 +1,3 @@
+div
+ `a`
+ `b`
diff --git a/src/__tests__/fixtures/concise-backtick-text-block-comment-then-text/__snapshots__/concise-backtick-text-block-comment-then-text.expected.txt b/src/__tests__/fixtures/concise-backtick-text-block-comment-then-text/__snapshots__/concise-backtick-text-block-comment-then-text.expected.txt
new file mode 100644
index 00000000..64847504
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-block-comment-then-text/__snapshots__/concise-backtick-text-block-comment-then-text.expected.txt
@@ -0,0 +1,8 @@
+1╭─ div `hello` /* note */ more
+ │ │ ││ │ │ ╰─ error(INVALID_CHARACTER:In concise mode a javascript comment block can only be followed by whitespace characters and a newline.)
+ │ │ ││ │ ╰─ comment.value " note "
+ │ │ ││ ╰─ comment "/* note */"
+ │ │ │╰─ text "hello"
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╰─
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-block-comment-then-text/input.marko b/src/__tests__/fixtures/concise-backtick-text-block-comment-then-text/input.marko
new file mode 100644
index 00000000..5f10d6e3
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-block-comment-then-text/input.marko
@@ -0,0 +1 @@
+div `hello` /* note */ more
diff --git a/src/__tests__/fixtures/concise-backtick-text-crlf/__snapshots__/concise-backtick-text-crlf.expected.txt b/src/__tests__/fixtures/concise-backtick-text-crlf/__snapshots__/concise-backtick-text-crlf.expected.txt
new file mode 100644
index 00000000..70ebe059
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-crlf/__snapshots__/concise-backtick-text-crlf.expected.txt
@@ -0,0 +1,23 @@
+1╭─ div `a`
+ │ │ │╰─ text
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ span `
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "span"
+3╭─ b
+ ╰─ ╰─ text "b\n"
+4╭─ c
+ ╰─ ╰─ text
+5├─ `
+6╭─ div
+ │ ├─ closeTagEnd(span)
+ ╰─ ╰─ tagName "div"
+7╭─ ``
+ ╰─ ╰─ openTagEnd
+8╭─ d
+ ╰─ ╰─ text " d"
+9├─ ``
+10╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-crlf/input.marko b/src/__tests__/fixtures/concise-backtick-text-crlf/input.marko
new file mode 100644
index 00000000..6dd5cb8c
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-crlf/input.marko
@@ -0,0 +1,9 @@
+div `a`
+span `
+ b
+ c
+ `
+div
+ ``
+ d
+ ``
diff --git a/src/__tests__/fixtures/concise-backtick-text-empty/__snapshots__/concise-backtick-text-empty.expected.txt b/src/__tests__/fixtures/concise-backtick-text-empty/__snapshots__/concise-backtick-text-empty.expected.txt
new file mode 100644
index 00000000..c88ea847
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-empty/__snapshots__/concise-backtick-text-empty.expected.txt
@@ -0,0 +1,22 @@
+1╭─ div ``
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ a
+ ╰─ ╰─ text
+3├─ ``
+4╭─ div ` `
+ │ │ │╰─ text " "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─ div `` x ``
+ │ │ │ ╰─ text " x "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+6╭─ span
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "span"
+7╭─
+ │ ├─ openTagEnd
+ ╰─ ╰─ closeTagEnd(span)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-empty/input.marko b/src/__tests__/fixtures/concise-backtick-text-empty/input.marko
new file mode 100644
index 00000000..b0a358a1
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-empty/input.marko
@@ -0,0 +1,6 @@
+div ``
+ a
+ ``
+div ` `
+div `` x ``
+span
diff --git a/src/__tests__/fixtures/concise-backtick-text-eof-fence-only/__snapshots__/concise-backtick-text-eof-fence-only.expected.txt b/src/__tests__/fixtures/concise-backtick-text-eof-fence-only/__snapshots__/concise-backtick-text-eof-fence-only.expected.txt
new file mode 100644
index 00000000..714e504b
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-eof-fence-only/__snapshots__/concise-backtick-text-eof-fence-only.expected.txt
@@ -0,0 +1,4 @@
+1╭─ div `
+ │ │ │╰─ closeTagEnd(div)
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-eof-fence-only/input.marko b/src/__tests__/fixtures/concise-backtick-text-eof-fence-only/input.marko
new file mode 100644
index 00000000..41ddd7aa
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-eof-fence-only/input.marko
@@ -0,0 +1 @@
+div `
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-eof-multiline/__snapshots__/concise-backtick-text-eof-multiline.expected.txt b/src/__tests__/fixtures/concise-backtick-text-eof-multiline/__snapshots__/concise-backtick-text-eof-multiline.expected.txt
new file mode 100644
index 00000000..326f76b3
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-eof-multiline/__snapshots__/concise-backtick-text-eof-multiline.expected.txt
@@ -0,0 +1,6 @@
+1╭─ div `
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ hello
+ │ │ ╰─ closeTagEnd(div)
+ ╰─ ╰─ text "hello"
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-eof-multiline/input.marko b/src/__tests__/fixtures/concise-backtick-text-eof-multiline/input.marko
new file mode 100644
index 00000000..d698480f
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-eof-multiline/input.marko
@@ -0,0 +1,2 @@
+div `
+ hello
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-eof-placeholder/__snapshots__/concise-backtick-text-eof-placeholder.expected.txt b/src/__tests__/fixtures/concise-backtick-text-eof-placeholder/__snapshots__/concise-backtick-text-eof-placeholder.expected.txt
new file mode 100644
index 00000000..f19f38cb
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-eof-placeholder/__snapshots__/concise-backtick-text-eof-placeholder.expected.txt
@@ -0,0 +1,5 @@
+1╭─ div `a ${b
+ │ │ ││ ╰─ error(MALFORMED_PLACEHOLDER:EOF reached while parsing placeholder)
+ │ │ │╰─ text "a "
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-eof-placeholder/input.marko b/src/__tests__/fixtures/concise-backtick-text-eof-placeholder/input.marko
new file mode 100644
index 00000000..a1212ea4
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-eof-placeholder/input.marko
@@ -0,0 +1 @@
+div `a ${b
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-fence-length/__snapshots__/concise-backtick-text-fence-length.expected.txt b/src/__tests__/fixtures/concise-backtick-text-fence-length/__snapshots__/concise-backtick-text-fence-length.expected.txt
new file mode 100644
index 00000000..727d5e6c
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-fence-length/__snapshots__/concise-backtick-text-fence-length.expected.txt
@@ -0,0 +1,26 @@
+1╭─ div ``a`b``
+ │ │ │ ╰─ text "a`b"
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ div ``a ` b``
+ │ │ │ ╰─ text "a ` b"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─ div ```a``b```
+ │ │ │ ╰─ text "a``b"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+4╭─ div `a``b`
+ │ │ │╰─ text "a``b"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─ div ``a`
+ │ │ │ ╰─ text "a`"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+6╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-fence-length/input.marko b/src/__tests__/fixtures/concise-backtick-text-fence-length/input.marko
new file mode 100644
index 00000000..fe0f0185
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-fence-length/input.marko
@@ -0,0 +1,5 @@
+div ``a`b``
+div ``a ` b``
+div ```a``b```
+div `a``b`
+div ``a`
diff --git a/src/__tests__/fixtures/concise-backtick-text-matches-hyphen-text/__snapshots__/concise-backtick-text-matches-hyphen-text.expected.txt b/src/__tests__/fixtures/concise-backtick-text-matches-hyphen-text/__snapshots__/concise-backtick-text-matches-hyphen-text.expected.txt
new file mode 100644
index 00000000..2d3690e9
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-matches-hyphen-text/__snapshots__/concise-backtick-text-matches-hyphen-text.expected.txt
@@ -0,0 +1,46 @@
+1╭─ div -- hello
+ │ │ │ ╰─ text "hello"
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ div
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─ -- hello
+ │ │ ╰─ text "hello"
+ ╰─ ╰─ openTagEnd
+4╭─ div --
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─ hello
+ ╰─ ╰─ text "hello"
+6╭─ div `hello
+ │ │ │╰─ text "hello"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+7╭─ div `hello`
+ │ │ │╰─ text "hello"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+8╭─ div `
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+9╭─ hello
+ ╰─ ╰─ text "hello"
+10╭─ div
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+11╭─ `hello
+ │ │ ╰─ text "hello"
+ ╰─ ╰─ openTagEnd
+12╭─ div
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+13╭─ `hello`
+ │ │ ╰─ text "hello"
+ ╰─ ╰─ openTagEnd
+14╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-matches-hyphen-text/input.marko b/src/__tests__/fixtures/concise-backtick-text-matches-hyphen-text/input.marko
new file mode 100644
index 00000000..62423035
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-matches-hyphen-text/input.marko
@@ -0,0 +1,13 @@
+div -- hello
+div
+ -- hello
+div --
+ hello
+div `hello
+div `hello`
+div `
+ hello
+div
+ `hello
+div
+ `hello`
diff --git a/src/__tests__/fixtures/concise-backtick-text-mid-line-backtick/__snapshots__/concise-backtick-text-mid-line-backtick.expected.txt b/src/__tests__/fixtures/concise-backtick-text-mid-line-backtick/__snapshots__/concise-backtick-text-mid-line-backtick.expected.txt
new file mode 100644
index 00000000..bd74f1ea
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-mid-line-backtick/__snapshots__/concise-backtick-text-mid-line-backtick.expected.txt
@@ -0,0 +1,26 @@
+1╭─ div `say `hi`
+ │ │ │╰─ text "say `hi"
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ div `a` b
+ │ │ │╰─ text "a` b"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─ div `a` `b`
+ │ │ │╰─ text "a` `b"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+4╭─ div `a`, b=1
+ │ │ │╰─ text "a`, b=1"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─ div `a`b`
+ │ │ │╰─ text "a`b"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+6╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-mid-line-backtick/input.marko b/src/__tests__/fixtures/concise-backtick-text-mid-line-backtick/input.marko
new file mode 100644
index 00000000..7ded1fef
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-mid-line-backtick/input.marko
@@ -0,0 +1,5 @@
+div `say `hi`
+div `a` b
+div `a` `b`
+div `a`, b=1
+div `a`b`
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-blank-lines/__snapshots__/concise-backtick-text-multiline-blank-lines.expected.txt b/src/__tests__/fixtures/concise-backtick-text-multiline-blank-lines/__snapshots__/concise-backtick-text-multiline-blank-lines.expected.txt
new file mode 100644
index 00000000..d63e11bc
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-blank-lines/__snapshots__/concise-backtick-text-multiline-blank-lines.expected.txt
@@ -0,0 +1,16 @@
+1╭─ div `
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ a
+ ╰─ ╰─ text "a\n\n"
+3├─
+4╭─ b
+ ╰─ ╰─ text
+5├─
+6├─
+7╭─ span
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "span"
+8╭─
+ │ ├─ openTagEnd
+ ╰─ ╰─ closeTagEnd(span)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-blank-lines/input.marko b/src/__tests__/fixtures/concise-backtick-text-multiline-blank-lines/input.marko
new file mode 100644
index 00000000..bb9e0447
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-blank-lines/input.marko
@@ -0,0 +1,7 @@
+div `
+ a
+
+ b
+
+
+span
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-closing-fence/__snapshots__/concise-backtick-text-multiline-closing-fence.expected.txt b/src/__tests__/fixtures/concise-backtick-text-multiline-closing-fence/__snapshots__/concise-backtick-text-multiline-closing-fence.expected.txt
new file mode 100644
index 00000000..85a4d2ba
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-closing-fence/__snapshots__/concise-backtick-text-multiline-closing-fence.expected.txt
@@ -0,0 +1,23 @@
+1╭─ div `
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ hello
+ ╰─ ╰─ text "hello"
+3├─ `
+4╭─ div ``
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─ a
+ ╰─ ╰─ text "a\n"
+6╭─ `b`
+ ╰─ ╰─ text "`b`\n"
+7╭─ ```
+ ╰─ ╰─ text "```"
+8├─ ``
+9╭─ span
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "span"
+10╭─
+ │ ├─ openTagEnd
+ ╰─ ╰─ closeTagEnd(span)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-closing-fence/input.marko b/src/__tests__/fixtures/concise-backtick-text-multiline-closing-fence/input.marko
new file mode 100644
index 00000000..55ea11f1
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-closing-fence/input.marko
@@ -0,0 +1,9 @@
+div `
+ hello
+ `
+div ``
+ a
+ `b`
+ ```
+ ``
+span
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-dedent/__snapshots__/concise-backtick-text-multiline-dedent.expected.txt b/src/__tests__/fixtures/concise-backtick-text-multiline-dedent/__snapshots__/concise-backtick-text-multiline-dedent.expected.txt
new file mode 100644
index 00000000..5440178e
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-dedent/__snapshots__/concise-backtick-text-multiline-dedent.expected.txt
@@ -0,0 +1,17 @@
+1╭─ div
+ ╰─ ╰─ tagName "div"
+2╭─ p `
+ │ │ │ ╰─ openTagEnd
+ │ │ ╰─ tagName
+ ╰─ ╰─ openTagEnd
+3╭─ a
+ ╰─ ╰─ text "a\n"
+4╭─ b
+ ╰─ ╰─ text
+5╭─ span
+ │ │ ╰─ tagName "span"
+ ╰─ ╰─ closeTagEnd(p)
+6╭─
+ │ ├─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ closeTagEnd(span)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-dedent/input.marko b/src/__tests__/fixtures/concise-backtick-text-multiline-dedent/input.marko
new file mode 100644
index 00000000..dd49de3d
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-dedent/input.marko
@@ -0,0 +1,5 @@
+div
+ p `
+ a
+ b
+ span
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-matches-hyphen-text/__snapshots__/concise-backtick-text-multiline-matches-hyphen-text.expected.txt b/src/__tests__/fixtures/concise-backtick-text-multiline-matches-hyphen-text/__snapshots__/concise-backtick-text-multiline-matches-hyphen-text.expected.txt
new file mode 100644
index 00000000..7ae59877
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-matches-hyphen-text/__snapshots__/concise-backtick-text-multiline-matches-hyphen-text.expected.txt
@@ -0,0 +1,51 @@
+1╭─ div --
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ hello
+ ╰─ ╰─ text "hello\n"
+3╭─ world
+ ╰─ ╰─ text "world"
+4├─ --
+5╭─ span
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "span"
+6╭─ div `
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(span)
+ │ ├─ openTagEnd
+ ╰─ ╰─ tagName "div"
+7╭─ hello
+ ╰─ ╰─ text "hello\n"
+8╭─ world
+ ╰─ ╰─ text "world"
+9├─ `
+10╭─ span
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "span"
+11╭─ div
+ │ ├─ closeTagEnd(span)
+ │ ├─ openTagEnd
+ ╰─ ╰─ tagName "div"
+12╭─ --
+ ╰─ ╰─ openTagEnd
+13╭─ hello
+ ╰─ ╰─ text " hello"
+14├─ --
+15╭─ span
+ ╰─ ╰─ tagName "span"
+16╭─ div
+ │ ├─ closeTagEnd(div)
+ │ ├─ closeTagEnd(span)
+ │ ├─ openTagEnd
+ ╰─ ╰─ tagName "div"
+17╭─ `
+ ╰─ ╰─ openTagEnd
+18╭─ hello
+ ╰─ ╰─ text " hello"
+19├─ `
+20╭─ span
+ ╰─ ╰─ tagName "span"
+21╭─
+ │ ├─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ closeTagEnd(span)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-matches-hyphen-text/input.marko b/src/__tests__/fixtures/concise-backtick-text-multiline-matches-hyphen-text/input.marko
new file mode 100644
index 00000000..faaa882d
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-matches-hyphen-text/input.marko
@@ -0,0 +1,20 @@
+div --
+ hello
+ world
+ --
+span
+div `
+ hello
+ world
+ `
+span
+div
+ --
+ hello
+ --
+ span
+div
+ `
+ hello
+ `
+ span
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-nested-closing-fence/__snapshots__/concise-backtick-text-multiline-nested-closing-fence.expected.txt b/src/__tests__/fixtures/concise-backtick-text-multiline-nested-closing-fence/__snapshots__/concise-backtick-text-multiline-nested-closing-fence.expected.txt
new file mode 100644
index 00000000..d7205521
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-nested-closing-fence/__snapshots__/concise-backtick-text-multiline-nested-closing-fence.expected.txt
@@ -0,0 +1,15 @@
+1╭─ div
+ ╰─ ╰─ tagName "div"
+2╭─ `
+ ╰─ ╰─ openTagEnd
+3╭─ hello
+ ╰─ ╰─ text " hello\n"
+4╭─ `x`
+ ╰─ ╰─ text " `x`"
+5├─ `
+6╭─ span
+ ╰─ ╰─ tagName "span"
+7╭─
+ │ ├─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ closeTagEnd(span)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-nested-closing-fence/input.marko b/src/__tests__/fixtures/concise-backtick-text-multiline-nested-closing-fence/input.marko
new file mode 100644
index 00000000..d3162550
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-nested-closing-fence/input.marko
@@ -0,0 +1,6 @@
+div
+ `
+ hello
+ `x`
+ `
+ span
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-tabs/__snapshots__/concise-backtick-text-multiline-tabs.expected.txt b/src/__tests__/fixtures/concise-backtick-text-multiline-tabs/__snapshots__/concise-backtick-text-multiline-tabs.expected.txt
new file mode 100644
index 00000000..ad785d74
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-tabs/__snapshots__/concise-backtick-text-multiline-tabs.expected.txt
@@ -0,0 +1,15 @@
+1╭─ div
+ ╰─ ╰─ tagName "div"
+2╭─ `
+ ╰─ ╰─ openTagEnd
+3╭─ a
+ ╰─ ╰─ text "\ta\n"
+4╭─ b
+ ╰─ ╰─ text "\tb"
+5├─ `
+6╭─ span
+ ╰─ ╰─ tagName "span"
+7╭─
+ │ ├─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ closeTagEnd(span)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline-tabs/input.marko b/src/__tests__/fixtures/concise-backtick-text-multiline-tabs/input.marko
new file mode 100644
index 00000000..8fbab6c2
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline-tabs/input.marko
@@ -0,0 +1,6 @@
+div
+ `
+ a
+ b
+ `
+ span
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline/__snapshots__/concise-backtick-text-multiline.expected.txt b/src/__tests__/fixtures/concise-backtick-text-multiline/__snapshots__/concise-backtick-text-multiline.expected.txt
new file mode 100644
index 00000000..8173b89c
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline/__snapshots__/concise-backtick-text-multiline.expected.txt
@@ -0,0 +1,13 @@
+1╭─ div `
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ hello
+ ╰─ ╰─ text "hello\n"
+3╭─ world
+ ╰─ ╰─ text "world"
+4╭─ span
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "span"
+5╭─
+ │ ├─ openTagEnd
+ ╰─ ╰─ closeTagEnd(span)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-multiline/input.marko b/src/__tests__/fixtures/concise-backtick-text-multiline/input.marko
new file mode 100644
index 00000000..e964256b
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-multiline/input.marko
@@ -0,0 +1,4 @@
+div `
+ hello
+ world
+span
diff --git a/src/__tests__/fixtures/concise-backtick-text-open-tag-shapes/__snapshots__/concise-backtick-text-open-tag-shapes.expected.txt b/src/__tests__/fixtures/concise-backtick-text-open-tag-shapes/__snapshots__/concise-backtick-text-open-tag-shapes.expected.txt
new file mode 100644
index 00000000..58607d02
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-open-tag-shapes/__snapshots__/concise-backtick-text-open-tag-shapes.expected.txt
@@ -0,0 +1,79 @@
+1╭─ div class=a id=b `hello`
+ │ │ │ ││ │ ││ │╰─ text "hello"
+ │ │ │ ││ │ ││ ╰─ openTagEnd
+ │ │ │ ││ │ │╰─ attrValue.value
+ │ │ │ ││ │ ╰─ attrValue "=b"
+ │ │ │ ││ ╰─ attrName "id"
+ │ │ │ │╰─ attrValue.value
+ │ │ │ ╰─ attrValue "=a"
+ │ │ ╰─ attrName "class"
+ ╰─ ╰─ tagName "div"
+2╭─ div class="a" `hello`
+ │ │ │ ││ │╰─ text "hello"
+ │ │ │ ││ ╰─ openTagEnd
+ │ │ │ │╰─ attrValue.value "\"a\""
+ │ │ │ ╰─ attrValue "=\"a\""
+ │ │ ╰─ attrName "class"
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─ div disabled `hello`
+ │ │ │ │╰─ text "hello"
+ │ │ │ ╰─ openTagEnd
+ │ │ ╰─ attrName "disabled"
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+4╭─ div(x) `hello`
+ │ │ ││ │╰─ text "hello"
+ │ │ ││ ╰─ openTagEnd
+ │ │ │╰─ tagArgs.value
+ │ │ ╰─ tagArgs "(x)"
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─ div/v `hello`
+ │ │ ││ │╰─ text "hello"
+ │ │ ││ ╰─ openTagEnd
+ │ │ │╰─ tagVar.value
+ │ │ ╰─ tagVar "/v"
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+6╭─ div|p| `hello`
+ │ │ ││ │╰─ text "hello"
+ │ │ ││ ╰─ openTagEnd
+ │ │ │╰─ tagParams.value
+ │ │ ╰─ tagParams "|p|"
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+7╭─ div [a, b] `hello`
+ │ │ │ │ │╰─ text "hello"
+ │ │ │ │ ╰─ openTagEnd
+ │ │ │ ╰─ attrName
+ │ │ ╰─ attrName
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+8╭─ div.a#b `hello`
+ │ │ ││││ │╰─ text "hello"
+ │ │ ││││ ╰─ openTagEnd
+ │ │ │││╰─ tagShorthandId.quasis[0]
+ │ │ ││╰─ tagShorthandId "#b"
+ │ │ │╰─ tagShorthandClass.quasis[0]
+ │ │ ╰─ tagShorthandClass ".a"
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+9╭─ ${tag} `hello`
+ │ │ ││╰─ text "hello"
+ │ │ │╰─ openTagEnd
+ │ │ ╰─ tagName.quasis[1]
+ │ ├─ closeTagEnd(div)
+ │ ├─ tagName.expressions[0] "${tag}"
+ │ ├─ tagName.quasis[0]
+ ╰─ ╰─ tagName "${tag}"
+10╭─ div a=1 /* c */ `hello`
+ │ │ │││ │╰─ text "hello"
+ │ │ │││ ╰─ openTagEnd
+ │ │ ││╰─ attrValue.value "1 /* c */"
+ │ │ │╰─ attrValue "=1 /* c */"
+ │ │ ╰─ attrName
+ │ ├─ closeTagEnd(${tag})
+ ╰─ ╰─ tagName "div"
+11╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-open-tag-shapes/input.marko b/src/__tests__/fixtures/concise-backtick-text-open-tag-shapes/input.marko
new file mode 100644
index 00000000..10b6ab8f
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-open-tag-shapes/input.marko
@@ -0,0 +1,10 @@
+div class=a id=b `hello`
+div class="a" `hello`
+div disabled `hello`
+div(x) `hello`
+div/v `hello`
+div|p| `hello`
+div [a, b] `hello`
+div.a#b `hello`
+${tag} `hello`
+div a=1 /* c */ `hello`
diff --git a/src/__tests__/fixtures/concise-backtick-text-root-level/__snapshots__/concise-backtick-text-root-level.expected.txt b/src/__tests__/fixtures/concise-backtick-text-root-level/__snapshots__/concise-backtick-text-root-level.expected.txt
new file mode 100644
index 00000000..44bbc464
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-root-level/__snapshots__/concise-backtick-text-root-level.expected.txt
@@ -0,0 +1,8 @@
+1╭─ `top`
+ ╰─ ╰─ text "top"
+2├─ `
+3╭─ multi
+ ╰─ ╰─ text " multi\n line"
+4├─ line
+5├─ `
+6╰─
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-root-level/input.marko b/src/__tests__/fixtures/concise-backtick-text-root-level/input.marko
new file mode 100644
index 00000000..634b33e6
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-root-level/input.marko
@@ -0,0 +1,5 @@
+`top`
+`
+ multi
+ line
+`
diff --git a/src/__tests__/fixtures/concise-backtick-text-script-line-start/__snapshots__/concise-backtick-text-script-line-start.expected.txt b/src/__tests__/fixtures/concise-backtick-text-script-line-start/__snapshots__/concise-backtick-text-script-line-start.expected.txt
new file mode 100644
index 00000000..32f9087a
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-script-line-start/__snapshots__/concise-backtick-text-script-line-start.expected.txt
@@ -0,0 +1,11 @@
+1╭─ script
+ ╰─ ╰─ tagName "script"
+2╭─ `x`
+ │ │ ╰─ text
+ ╰─ ╰─ openTagEnd
+3╭─ `y`
+ ╰─ ╰─ text
+4╭─ -- z
+ ╰─ ╰─ text
+5╭─
+ ╰─ ╰─ closeTagEnd(script)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-script-line-start/input.marko b/src/__tests__/fixtures/concise-backtick-text-script-line-start/input.marko
new file mode 100644
index 00000000..f9f68c93
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-script-line-start/input.marko
@@ -0,0 +1,4 @@
+script
+ `x`
+ `y`
+ -- z
diff --git a/src/__tests__/fixtures/concise-backtick-text-script-multiline/__snapshots__/concise-backtick-text-script-multiline.expected.txt b/src/__tests__/fixtures/concise-backtick-text-script-multiline/__snapshots__/concise-backtick-text-script-multiline.expected.txt
new file mode 100644
index 00000000..7432e62f
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-script-multiline/__snapshots__/concise-backtick-text-script-multiline.expected.txt
@@ -0,0 +1,18 @@
+1╭─ script `
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "script"
+2╭─ const a = `x`;
+ ╰─ ╰─ text "const a = `x`;\n"
+3╭─ const b = `
+ ╰─ ╰─ text "const b = `\n y`;"
+4├─ y`;
+5├─ `
+6╭─ style `
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(script)
+ ╰─ ╰─ tagName "style"
+7╭─ a { b: c }
+ ╰─ ╰─ text "a { b: c }"
+8├─ `
+9╭─
+ ╰─ ╰─ closeTagEnd(style)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-script-multiline/input.marko b/src/__tests__/fixtures/concise-backtick-text-script-multiline/input.marko
new file mode 100644
index 00000000..7414df36
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-script-multiline/input.marko
@@ -0,0 +1,8 @@
+script `
+ const a = `x`;
+ const b = `
+ y`;
+ `
+style `
+ a { b: c }
+ `
diff --git a/src/__tests__/fixtures/concise-backtick-text-script-style/__snapshots__/concise-backtick-text-script-style.expected.txt b/src/__tests__/fixtures/concise-backtick-text-script-style/__snapshots__/concise-backtick-text-script-style.expected.txt
new file mode 100644
index 00000000..00292885
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-script-style/__snapshots__/concise-backtick-text-script-style.expected.txt
@@ -0,0 +1,30 @@
+1╭─ script `a(`x`)`
+ │ │ │╰─ text "a(`x`)"
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "script"
+2╭─ script `x = `a``
+ │ │ │╰─ text "x = `a`"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(script)
+ ╰─ ╰─ tagName "script"
+3╭─ script ``x = `a` ``
+ │ │ │ ╰─ text "x = `a` "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(script)
+ ╰─ ╰─ tagName "script"
+4╭─ script `x` // c
+ │ │ ││ │ ╰─ comment.value " c"
+ │ │ ││ ╰─ comment "// c"
+ │ │ │╰─ text
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(script)
+ ╰─ ╰─ tagName "script"
+5╭─ style.less `a { b: c }`
+ │ │ ││ │╰─ text "a { b: c }"
+ │ │ ││ ╰─ openTagEnd
+ │ │ │╰─ tagShorthandClass.quasis[0] "less"
+ │ │ ╰─ tagShorthandClass ".less"
+ │ ├─ closeTagEnd(script)
+ ╰─ ╰─ tagName "style"
+6╭─
+ ╰─ ╰─ closeTagEnd(style)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-script-style/input.marko b/src/__tests__/fixtures/concise-backtick-text-script-style/input.marko
new file mode 100644
index 00000000..dfd80efd
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-script-style/input.marko
@@ -0,0 +1,5 @@
+script `a(`x`)`
+script `x = `a``
+script ``x = `a` ``
+script `x` // c
+style.less `a { b: c }`
diff --git a/src/__tests__/fixtures/concise-backtick-text-siblings/__snapshots__/concise-backtick-text-siblings.expected.txt b/src/__tests__/fixtures/concise-backtick-text-siblings/__snapshots__/concise-backtick-text-siblings.expected.txt
new file mode 100644
index 00000000..e0318e61
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-siblings/__snapshots__/concise-backtick-text-siblings.expected.txt
@@ -0,0 +1,26 @@
+1╭─ ul
+ ╰─ ╰─ tagName "ul"
+2╭─ li `a`
+ │ │ │ │╰─ text
+ │ │ │ ╰─ openTagEnd
+ │ │ ╰─ tagName "li"
+ ╰─ ╰─ openTagEnd
+3╭─ li `b`
+ │ │ │ │╰─ text
+ │ │ │ ╰─ openTagEnd
+ │ │ ╰─ tagName "li"
+ ╰─ ╰─ closeTagEnd(li)
+4╭─ span `c`
+ │ │ │╰─ text
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "span"
+5╭─ li
+ │ │ ╰─ tagName "li"
+ │ ├─ closeTagEnd(li)
+ ╰─ ╰─ closeTagEnd(span)
+6╭─ `d`
+ │ │ ╰─ text
+ ╰─ ╰─ openTagEnd
+7╭─
+ │ ├─ closeTagEnd(ul)
+ ╰─ ╰─ closeTagEnd(li)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-siblings/input.marko b/src/__tests__/fixtures/concise-backtick-text-siblings/input.marko
new file mode 100644
index 00000000..b1e4b625
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-siblings/input.marko
@@ -0,0 +1,6 @@
+ul
+ li `a`
+ li `b`
+ span `c`
+ li
+ `d`
diff --git a/src/__tests__/fixtures/concise-backtick-text-single-line/__snapshots__/concise-backtick-text-single-line.expected.txt b/src/__tests__/fixtures/concise-backtick-text-single-line/__snapshots__/concise-backtick-text-single-line.expected.txt
new file mode 100644
index 00000000..c0cb891a
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-single-line/__snapshots__/concise-backtick-text-single-line.expected.txt
@@ -0,0 +1,46 @@
+1╭─ div `hello`
+ │ │ │╰─ text "hello"
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ div `hello
+ │ │ │╰─ text "hello"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─ div ` hello `
+ │ │ │╰─ text " hello "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+4╭─ div `a ${b} c`
+ │ │ ││ │ │ ╰─ text " c"
+ │ │ ││ │ ╰─ placeholder:escape.value
+ │ │ ││ ╰─ placeholder:escape "${b}"
+ │ │ │╰─ text "a "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─ div `${a}`
+ │ │ ││ ╰─ placeholder:escape.value
+ │ │ │╰─ placeholder:escape "${a}"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+6╭─ div `hi`
+ │ │ │││││ │ │╰─ closeTagEnd(b)
+ │ │ │││││ │ ╰─ closeTagName
+ │ │ │││││ ╰─ closeTagStart ""
+ │ │ ││││╰─ text "hi"
+ │ │ │││╰─ openTagEnd
+ │ │ ││╰─ tagName
+ │ │ │╰─ openTagStart
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+7╭─ div `$ not a scriptlet`
+ │ │ │╰─ text "$ not a scriptlet"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+8╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-single-line/input.marko b/src/__tests__/fixtures/concise-backtick-text-single-line/input.marko
new file mode 100644
index 00000000..453d32ff
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-single-line/input.marko
@@ -0,0 +1,7 @@
+div `hello`
+div `hello
+div ` hello `
+div `a ${b} c`
+div `${a}`
+div `hi`
+div `$ not a scriptlet`
diff --git a/src/__tests__/fixtures/concise-backtick-text-trailing-comment/__snapshots__/concise-backtick-text-trailing-comment.expected.txt b/src/__tests__/fixtures/concise-backtick-text-trailing-comment/__snapshots__/concise-backtick-text-trailing-comment.expected.txt
new file mode 100644
index 00000000..9b453728
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-trailing-comment/__snapshots__/concise-backtick-text-trailing-comment.expected.txt
@@ -0,0 +1,29 @@
+1╭─ div `hello` // note
+ │ │ ││ │ ╰─ comment.value " note"
+ │ │ ││ ╰─ comment "// note"
+ │ │ │╰─ text "hello"
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ div `hello`// note
+ │ │ ││ │ ╰─ comment.value " note"
+ │ │ ││ ╰─ comment "// note"
+ │ │ │╰─ text "hello"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─ div `hello` /* note */
+ │ │ ││ │ ╰─ comment.value " note "
+ │ │ ││ ╰─ comment "/* note */"
+ │ │ │╰─ text "hello"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+4╭─ div `hello // note
+ │ │ ││ │ ╰─ comment.value " note"
+ │ │ ││ ╰─ comment "// note"
+ │ │ │╰─ text "hello "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-trailing-comment/input.marko b/src/__tests__/fixtures/concise-backtick-text-trailing-comment/input.marko
new file mode 100644
index 00000000..a1cece6e
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-trailing-comment/input.marko
@@ -0,0 +1,4 @@
+div `hello` // note
+div `hello`// note
+div `hello` /* note */
+div `hello // note
diff --git a/src/__tests__/fixtures/concise-backtick-text-unclosed-mixed-mode-tag/__snapshots__/concise-backtick-text-unclosed-mixed-mode-tag.expected.txt b/src/__tests__/fixtures/concise-backtick-text-unclosed-mixed-mode-tag/__snapshots__/concise-backtick-text-unclosed-mixed-mode-tag.expected.txt
new file mode 100644
index 00000000..512dd5a5
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-unclosed-mixed-mode-tag/__snapshots__/concise-backtick-text-unclosed-mixed-mode-tag.expected.txt
@@ -0,0 +1,9 @@
+1╭─ div `x`
+ │ │ ││││╰─ text
+ │ │ │││╰─ openTagEnd
+ │ │ ││╰─ tagName
+ │ │ │├─ error(MISSING_END_TAG:Missing ending "b" tag) ""
+ │ │ │╰─ openTagStart
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╰─
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-unclosed-mixed-mode-tag/input.marko b/src/__tests__/fixtures/concise-backtick-text-unclosed-mixed-mode-tag/input.marko
new file mode 100644
index 00000000..0a2a3339
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-unclosed-mixed-mode-tag/input.marko
@@ -0,0 +1 @@
+div `x`
diff --git a/src/__tests__/fixtures/concise-backtick-text-whitespace/__snapshots__/concise-backtick-text-whitespace.expected.txt b/src/__tests__/fixtures/concise-backtick-text-whitespace/__snapshots__/concise-backtick-text-whitespace.expected.txt
new file mode 100644
index 00000000..74f85b2c
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-whitespace/__snapshots__/concise-backtick-text-whitespace.expected.txt
@@ -0,0 +1,41 @@
+1╭─ div ` hello`
+ │ │ │╰─ text " hello"
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ div ` hello`
+ │ │ │╰─ text " hello"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─ div ` hello`
+ │ │ │╰─ text "\thello"
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+4╭─ div `hello `
+ │ │ │╰─ text "hello "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─ div ` hello `
+ │ │ │╰─ text " hello "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+6╭─ div ` `
+ │ │ │╰─ text " "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+7╭─ div ` `
+ │ │ │╰─ text " "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+8╭─ div `` a ``
+ │ │ │ ╰─ text " a "
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+9╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-backtick-text-whitespace/input.marko b/src/__tests__/fixtures/concise-backtick-text-whitespace/input.marko
new file mode 100644
index 00000000..94c6794d
--- /dev/null
+++ b/src/__tests__/fixtures/concise-backtick-text-whitespace/input.marko
@@ -0,0 +1,8 @@
+div ` hello`
+div ` hello`
+div ` hello`
+div `hello `
+div ` hello `
+div ` `
+div ` `
+div `` a ``
diff --git a/src/__tests__/fixtures/concise-comma-line-without-tag-name/__snapshots__/concise-comma-line-without-tag-name.expected.txt b/src/__tests__/fixtures/concise-comma-line-without-tag-name/__snapshots__/concise-comma-line-without-tag-name.expected.txt
new file mode 100644
index 00000000..60c55b38
--- /dev/null
+++ b/src/__tests__/fixtures/concise-comma-line-without-tag-name/__snapshots__/concise-comma-line-without-tag-name.expected.txt
@@ -0,0 +1,3 @@
+1╭─ ,-- text
+ ╰─ ╰─ error(MALFORMED_OPEN_TAG:A concise tag must start with a tag name)
+2╰─
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-comma-line-without-tag-name/input.marko b/src/__tests__/fixtures/concise-comma-line-without-tag-name/input.marko
new file mode 100644
index 00000000..a7af584a
--- /dev/null
+++ b/src/__tests__/fixtures/concise-comma-line-without-tag-name/input.marko
@@ -0,0 +1 @@
+,-- text
diff --git a/src/__tests__/fixtures/concise-hyphen-text-with-backticks/__snapshots__/concise-hyphen-text-with-backticks.expected.txt b/src/__tests__/fixtures/concise-hyphen-text-with-backticks/__snapshots__/concise-hyphen-text-with-backticks.expected.txt
new file mode 100644
index 00000000..f8b819fc
--- /dev/null
+++ b/src/__tests__/fixtures/concise-hyphen-text-with-backticks/__snapshots__/concise-hyphen-text-with-backticks.expected.txt
@@ -0,0 +1,18 @@
+1╭─ div -- `t`
+ │ │ │ ╰─ text "`t`"
+ │ │ ╰─ openTagEnd
+ ╰─ ╰─ tagName "div"
+2╭─ div
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+3╭─ -- `t` and `u`
+ │ │ ╰─ text "`t` and `u`"
+ ╰─ ╰─ openTagEnd
+4╭─ div --
+ │ │ ╰─ openTagEnd
+ │ ├─ closeTagEnd(div)
+ ╰─ ╰─ tagName "div"
+5╭─ `t`
+ ╰─ ╰─ text "`t`"
+6╭─
+ ╰─ ╰─ closeTagEnd(div)
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-hyphen-text-with-backticks/input.marko b/src/__tests__/fixtures/concise-hyphen-text-with-backticks/input.marko
new file mode 100644
index 00000000..03e99b6e
--- /dev/null
+++ b/src/__tests__/fixtures/concise-hyphen-text-with-backticks/input.marko
@@ -0,0 +1,5 @@
+div -- `t`
+div
+ -- `t` and `u`
+div --
+ `t`
diff --git a/src/__tests__/fixtures/concise-semicolon-line-without-tag-name/__snapshots__/concise-semicolon-line-without-tag-name.expected.txt b/src/__tests__/fixtures/concise-semicolon-line-without-tag-name/__snapshots__/concise-semicolon-line-without-tag-name.expected.txt
new file mode 100644
index 00000000..7a26b6ff
--- /dev/null
+++ b/src/__tests__/fixtures/concise-semicolon-line-without-tag-name/__snapshots__/concise-semicolon-line-without-tag-name.expected.txt
@@ -0,0 +1,6 @@
+1╭─ div
+ ╰─ ╰─ tagName "div"
+2╭─ ;
+ │ │ ╰─ error(MALFORMED_OPEN_TAG:A concise tag must start with a tag name)
+ ╰─ ╰─ openTagEnd
+3╰─
\ No newline at end of file
diff --git a/src/__tests__/fixtures/concise-semicolon-line-without-tag-name/input.marko b/src/__tests__/fixtures/concise-semicolon-line-without-tag-name/input.marko
new file mode 100644
index 00000000..1e9bf22c
--- /dev/null
+++ b/src/__tests__/fixtures/concise-semicolon-line-without-tag-name/input.marko
@@ -0,0 +1,2 @@
+div
+ ;
diff --git a/src/__tests__/fixtures/html-backtick-literal-text/__snapshots__/html-backtick-literal-text.expected.txt b/src/__tests__/fixtures/html-backtick-literal-text/__snapshots__/html-backtick-literal-text.expected.txt
new file mode 100644
index 00000000..e9935485
--- /dev/null
+++ b/src/__tests__/fixtures/html-backtick-literal-text/__snapshots__/html-backtick-literal-text.expected.txt
@@ -0,0 +1,27 @@
+1╭─ `a`
+ │ ││ ││ │ │ ╰─ closeTagEnd(div)
+ │ ││ ││ │ ╰─ closeTagName "div"
+ │ ││ ││ ╰─ closeTagStart ""
+ │ ││ │╰─ text " `a` "
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "div"
+ ╰─ ╰─ openTagStart
+2╭─
+ │ ││ ││ │ │ ╰─ closeTagEnd(script)
+ │ ││ ││ │ ╰─ closeTagName "script"
+ │ ││ ││ ╰─ closeTagStart ""
+ │ ││ │╰─ text "`a`"
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "script"
+ ╰─ ╰─ openTagStart
+3╭─
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "div"
+ ╰─ ╰─ openTagStart
+4╭─ `a`
+ ╰─ ╰─ text "\n `a`\n"
+5╭─
+ │ │ │ ╰─ closeTagEnd(div)
+ │ │ ╰─ closeTagName "div"
+ ╰─ ╰─ closeTagStart ""
+6╰─
\ No newline at end of file
diff --git a/src/__tests__/fixtures/html-backtick-literal-text/input.marko b/src/__tests__/fixtures/html-backtick-literal-text/input.marko
new file mode 100644
index 00000000..65f758ef
--- /dev/null
+++ b/src/__tests__/fixtures/html-backtick-literal-text/input.marko
@@ -0,0 +1,5 @@
+ `a`
+
+
+ `a`
+
diff --git a/src/__tests__/fixtures/script-concise-invalid-nested-tag/__snapshots__/script-concise-invalid-nested-tag.expected.txt b/src/__tests__/fixtures/script-concise-invalid-nested-tag/__snapshots__/script-concise-invalid-nested-tag.expected.txt
index 30afb7a1..e0483839 100644
--- a/src/__tests__/fixtures/script-concise-invalid-nested-tag/__snapshots__/script-concise-invalid-nested-tag.expected.txt
+++ b/src/__tests__/fixtures/script-concise-invalid-nested-tag/__snapshots__/script-concise-invalid-nested-tag.expected.txt
@@ -1,6 +1,6 @@
1╭─ script
╰─ ╰─ tagName "script"
2╭─ p - This is not allowed
- │ │ ╰─ error(INVALID_LINE_START:A line within a tag that only allows text content must begin with a "-" character)
+ │ │ ╰─ error(INVALID_LINE_START:A line within a tag that only allows text content must begin with "--" or a backtick)
╰─ ╰─ openTagEnd
3╰─ div
\ No newline at end of file
diff --git a/src/core/Parser.ts b/src/core/Parser.ts
index 304f0306..1c19eb2d 100644
--- a/src/core/Parser.ts
+++ b/src/core/Parser.ts
@@ -39,6 +39,7 @@ export class Parser {
declare public isConcise: boolean; // Set to true if parser is currently in concise mode
declare public beginMixedMode?: boolean; // Used as a flag to mark that the next HTML block should enter the parser into HTML mode
declare public endingMixedModeAtEOL?: boolean; // Used as a flag to record that the next EOL to exit HTML mode and go back to concise
+ declare public textDelimiter: undefined | Range; // The delimiter of the concise text block being parsed
declare public textPos: number; // Used to buffer text that is found within the body of a tag
declare public lines: undefined | number[]; // Keeps track of line indexes to provide line/column info.
declare public options: Options;
@@ -144,7 +145,11 @@ export class Parser {
const start = this.textPos;
if (start !== -1) {
if (start !== this.pos) {
- this.options.onText?.({ start, end: this.pos });
+ this.options.onText?.({
+ start,
+ end: this.pos,
+ delimiter: this.textDelimiter,
+ });
}
this.textPos = -1;
}
@@ -156,7 +161,11 @@ export class Parser {
* return back to the previous parsing mode and to ensure that all
* tags within a block are properly closed.
*/
- beginHtmlBlock(delimiter: string | undefined, singleLine: boolean) {
+ beginHtmlBlock(
+ delimiter: string | undefined,
+ singleLine: boolean,
+ closingDelimiter?: string,
+ ) {
const content = this.enterState(
this.activeTag?.type === TagType.text
? STATE.PARSED_TEXT_CONTENT
@@ -165,6 +174,7 @@ export class Parser {
content.singleLine = singleLine;
content.delimiter = delimiter;
+ content.closingDelimiter = closingDelimiter;
content.indent = this.indent;
}
@@ -284,7 +294,11 @@ export class Parser {
this.textPos = -1;
this.isConcise = true;
this.beginMixedMode = this.endingMixedModeAtEOL = false;
- this.lines = this.activeTag = this.activeAttr = undefined;
+ this.lines =
+ this.activeTag =
+ this.activeAttr =
+ this.textDelimiter =
+ undefined;
// Drop any state left over from a previous parse so reusing a parser
// does not chain (and retain) the old state metas via parent references.
this.activeRange = undefined as unknown as Meta;
diff --git a/src/states/BEGIN_DELIMITED_HTML_BLOCK.ts b/src/states/BEGIN_DELIMITED_HTML_BLOCK.ts
index aa483213..20bea4b1 100644
--- a/src/states/BEGIN_DELIMITED_HTML_BLOCK.ts
+++ b/src/states/BEGIN_DELIMITED_HTML_BLOCK.ts
@@ -1,5 +1,6 @@
import {
htmlEOF,
+ isIndentCode,
isLineCode,
type Meta,
Parser,
@@ -12,10 +13,12 @@ import * as ErrorCode from "../util/error-code.ts";
export interface DelimitedHTMLBlockMeta extends Meta {
delimiter: string;
indent: string;
+ delimiterCode: number;
}
-// In STATE.BEGIN_DELIMITED_HTML_BLOCK we have already found two consecutive hyphens. We expect
-// to reach the end of the line with only whitespace characters
+// In STATE.BEGIN_DELIMITED_HTML_BLOCK we have already found the start of a delimiter: either two
+// consecutive hyphens, or one or more backticks. We expect to reach the end of the line with
+// only whitespace characters
export const BEGIN_DELIMITED_HTML_BLOCK: StateDefinition =
{
name: "BEGIN_DELIMITED_HTML_BLOCK",
@@ -28,10 +31,13 @@ export const BEGIN_DELIMITED_HTML_BLOCK: StateDefinition
end: start,
indent: this.indent,
delimiter: "",
+ delimiterCode: this.data.charCodeAt(start),
};
},
- exit() {},
+ exit() {
+ this.textDelimiter = undefined;
+ },
parse(data, maxPos, block) {
if (this.pos === maxPos) {
@@ -50,24 +56,31 @@ export const BEGIN_DELIMITED_HTML_BLOCK: StateDefinition
? 2
: 1;
const prevPos = this.pos;
- this.beginHtmlBlock(block.delimiter, false);
- handleDelimitedBlockEOL(this, true, len, block);
+ beginText(this, block, false);
+ handleDelimitedBlockEOL(
+ this,
+ true,
+ len,
+ block.indent,
+ block.delimiter,
+ );
if (this.pos === prevPos) this.pos += len; // advance past newline if not already advanced
return;
}
- if (code === CODE.HYPHEN) {
- block.delimiter += "-";
+ if (code === block.delimiterCode) {
+ block.delimiter += String.fromCharCode(code);
this.pos++;
continue;
}
- // Non-hyphen, non-newline: check if whitespace-only remains on line
+ // Non-delimiter, non-newline: check if whitespace-only remains on line
const startPos = this.pos;
- if (!this.consumeWhitespaceOnLine()) {
+ const textOffset = block.delimiterCode === CODE.BACKTICK ? 0 : 1;
+ if (!this.consumeWhitespaceOnLine(textOffset)) {
// Non-whitespace content on this line: start single-line HTML block
- this.pos = startPos + 1;
- this.beginHtmlBlock(undefined, true);
+ this.pos = startPos + textOffset;
+ beginText(this, block, true);
return;
}
// Only whitespace to EOL: consumeWhitespaceOnLine set pos to newline
@@ -78,6 +91,24 @@ export const BEGIN_DELIMITED_HTML_BLOCK: StateDefinition
return() {},
};
+function beginText(
+ parser: Parser,
+ block: DelimitedHTMLBlockMeta,
+ singleLine: boolean,
+) {
+ parser.textDelimiter = {
+ start: block.start,
+ end: block.start + block.delimiter.length,
+ };
+ parser.beginHtmlBlock(
+ singleLine ? undefined : block.delimiter,
+ singleLine,
+ singleLine && block.delimiterCode === CODE.BACKTICK
+ ? block.delimiter
+ : undefined,
+ );
+}
+
export function handleDelimitedEOL(
parser: Parser,
newLineLength: number,
@@ -91,7 +122,13 @@ export function handleDelimitedEOL(
}
if (content.delimiter) {
- handleDelimitedBlockEOL(parser, false, newLineLength, content);
+ handleDelimitedBlockEOL(
+ parser,
+ false,
+ newLineLength,
+ content.indent,
+ content.delimiter,
+ );
return true;
}
@@ -102,13 +139,8 @@ function handleDelimitedBlockEOL(
parser: Parser,
first: boolean,
newLineLength: number,
- {
- indent,
- delimiter,
- }:
- | STATE.ParsedTextContentMeta
- | STATE.HTMLContentMeta
- | DelimitedHTMLBlockMeta,
+ indent: string,
+ delimiter: string,
) {
// If we are within a delimited HTML block then we want to check if the next line is the end
// delimiter. Since we are currently positioned at the start of the new line character our lookahead
@@ -116,7 +148,14 @@ function handleDelimitedBlockEOL(
// the delimiter.
const endHtmlBlockLookahead = indent + delimiter;
- if (parser.lookAheadFor(endHtmlBlockLookahead, parser.pos + newLineLength)) {
+ const isClosingLine =
+ parser.lookAheadFor(endHtmlBlockLookahead, parser.pos + newLineLength) &&
+ (delimiter.charCodeAt(0) !== CODE.BACKTICK ||
+ parser.onlyWhitespaceRemainsOnLine(
+ newLineLength + endHtmlBlockLookahead.length,
+ ));
+
+ if (isClosingLine) {
parser.endText();
parser.pos += newLineLength + endHtmlBlockLookahead.length;
@@ -143,7 +182,7 @@ function handleDelimitedBlockEOL(
} else if (indent && !parser.onlyWhitespaceRemainsOnLine(newLineLength)) {
// the next line does not have enough indentation
// so unless it is blank (whitespace only),
- // we will end the block
+ // we will end the block. A delimiter there is not a closer, it starts the next block.
const pos = parser.pos;
let cur = parser.pos;
while (cur && isLineCode(parser.data.charCodeAt(cur - 1))) {
@@ -159,3 +198,37 @@ function handleDelimitedBlockEOL(
parser.startText();
}
}
+
+export function checkForBacktickCloser(parser: Parser, delimiter: string) {
+ const { data } = parser;
+ const start = parser.pos;
+ let end = start;
+ while (data.charCodeAt(end) === CODE.BACKTICK) end++;
+ if (end - start !== delimiter.length) return false;
+
+ let next = end;
+ while (isIndentCode(data.charCodeAt(next))) next++;
+
+ const code = data.charCodeAt(next);
+ const afterSlash =
+ code === CODE.FORWARD_SLASH ? data.charCodeAt(next + 1) : undefined;
+ const startsLineComment = afterSlash === CODE.FORWARD_SLASH;
+ const startsBlockComment = afterSlash === CODE.ASTERISK;
+ const endsLine = next >= parser.maxPos || isLineCode(code);
+ if (!endsLine && !startsLineComment && !startsBlockComment) return false;
+
+ parser.endText();
+ parser.exitState();
+ parser.exitState();
+ parser.pos = next;
+
+ if (startsLineComment) {
+ parser.enterState(STATE.JS_COMMENT_LINE);
+ parser.pos += 2;
+ } else if (startsBlockComment) {
+ parser.enterState(STATE.JS_COMMENT_BLOCK);
+ parser.pos += 2;
+ }
+
+ return true;
+}
diff --git a/src/states/CONCISE_HTML_CONTENT.ts b/src/states/CONCISE_HTML_CONTENT.ts
index bfaf39d1..2ed4449e 100644
--- a/src/states/CONCISE_HTML_CONTENT.ts
+++ b/src/states/CONCISE_HTML_CONTENT.ts
@@ -84,11 +84,15 @@ export const CONCISE_HTML_CONTENT: StateDefinition = {
}
if (parentTag) {
- if (parentTag.type === TagType.text && code !== CODE.HYPHEN) {
+ if (
+ parentTag.type === TagType.text &&
+ code !== CODE.HYPHEN &&
+ code !== CODE.BACKTICK
+ ) {
this.emitError(
this.pos,
ErrorCode.INVALID_LINE_START,
- 'A line within a tag that only allows text content must begin with a "-" character',
+ 'A line within a tag that only allows text content must begin with "--" or a backtick',
);
return;
}
@@ -129,6 +133,9 @@ export const CONCISE_HTML_CONTENT: StateDefinition = {
);
return;
}
+ case CODE.BACKTICK:
+ this.enterState(STATE.BEGIN_DELIMITED_HTML_BLOCK);
+ return; // pos stays at the first `, BEGIN_DELIMITED_HTML_BLOCK parses it
case CODE.FORWARD_SLASH:
switch (data.charCodeAt(this.pos + 1)) {
case CODE.FORWARD_SLASH:
diff --git a/src/states/HTML_CONTENT.ts b/src/states/HTML_CONTENT.ts
index 637d9d60..ffea650f 100644
--- a/src/states/HTML_CONTENT.ts
+++ b/src/states/HTML_CONTENT.ts
@@ -12,6 +12,7 @@ export interface HTMLContentMeta extends Meta {
indent: string;
singleLine: boolean;
delimiter: undefined | string;
+ closingDelimiter: undefined | string;
}
// In STATE.HTML_CONTENT we are looking for tags and placeholders but
@@ -29,6 +30,7 @@ export const HTML_CONTENT: StateDefinition = {
indent: "",
singleLine: false,
delimiter: undefined,
+ closingDelimiter: undefined,
};
},
@@ -79,6 +81,17 @@ export const HTML_CONTENT: StateDefinition = {
continue;
}
+ if (code === CODE.BACKTICK && content.closingDelimiter) {
+ if (STATE.checkForBacktickCloser(this, content.closingDelimiter))
+ return;
+
+ this.startText();
+ do {
+ this.pos++;
+ } while (this.pos < maxPos && data.charCodeAt(this.pos) === code);
+ continue;
+ }
+
if (code === CODE.OPEN_ANGLE_BRACKET) {
if (STATE.checkForCDATA(this)) return;
@@ -165,13 +178,9 @@ export const HTML_CONTENT: StateDefinition = {
}
this.startText();
- // Eagerly consume the run of chars that cannot match a branch above.
- do {
- this.pos++;
- } while (
- this.pos < maxPos &&
- !isSpecialHtmlContentCode(data.charCodeAt(this.pos))
- );
+ this.pos = content.closingDelimiter
+ ? skipTextToBacktick(data, this.pos, maxPos)
+ : skipText(data, this.pos, maxPos);
}
},
@@ -188,6 +197,27 @@ export const HTML_CONTENT: StateDefinition = {
},
};
+// Eagerly consume the run of chars that cannot match a branch above.
+function skipText(data: string, pos: number, maxPos: number) {
+ do {
+ pos++;
+ } while (pos < maxPos && !isSpecialHtmlContentCode(data.charCodeAt(pos)));
+
+ return pos;
+}
+
+function skipTextToBacktick(data: string, pos: number, maxPos: number) {
+ do {
+ pos++;
+ } while (
+ pos < maxPos &&
+ !isSpecialHtmlContentCode(data.charCodeAt(pos)) &&
+ data.charCodeAt(pos) !== CODE.BACKTICK
+ );
+
+ return pos;
+}
+
// Matches every char that can begin one of the parse branches above.
function isSpecialHtmlContentCode(code: number) {
switch (code) {
diff --git a/src/states/OPEN_TAG.ts b/src/states/OPEN_TAG.ts
index 4723966e..8bb19e24 100644
--- a/src/states/OPEN_TAG.ts
+++ b/src/states/OPEN_TAG.ts
@@ -6,6 +6,7 @@ import {
matchesCloseParen,
matchesPipe,
type Meta,
+ type Parser,
type Ranges,
STATE,
type StateDefinition,
@@ -138,7 +139,7 @@ export const OPEN_TAG: StateDefinition = {
continue;
}
- this.exitState();
+ exitConciseOpenTag(this, tag);
return;
}
@@ -149,7 +150,7 @@ export const OPEN_TAG: StateDefinition = {
if (this.isConcise) {
if (code === CODE.SEMICOLON) {
this.pos++; // skip ;
- this.exitState();
+ if (!exitConciseOpenTag(this, tag)) return;
if (!this.consumeWhitespaceOnLine(0)) {
switch (data.charCodeAt(this.pos)) {
case CODE.FORWARD_SLASH:
@@ -201,31 +202,15 @@ export const OPEN_TAG: StateDefinition = {
return;
}
- // The open tag is complete; compute nested indent from next line
- this.exitState();
-
- let curPos = this.pos + 1;
- // Skip until the next newline.
- while (curPos < maxPos && data.charCodeAt(++curPos) !== CODE.NEWLINE);
- // Skip the newline itself.
- const indentStart = ++curPos;
-
- // Count how many spaces/tabs we have after the newline.
- while (curPos < maxPos) {
- if (isIndentCode(data.charCodeAt(curPos))) {
- curPos++;
- } else {
- break;
- }
- }
-
- const indentSize = curPos - indentStart;
- if (indentSize > this.indent.length) {
- this.indent = data.slice(indentStart, curPos);
- }
-
- this.enterState(STATE.BEGIN_DELIMITED_HTML_BLOCK);
- return; // pos still at the first -, BEGIN_DELIMITED_HTML_BLOCK parses it
+ beginTextAfterOpenTag(this, tag);
+ return;
+ } else if (
+ code === CODE.BACKTICK &&
+ tag.stage !== TAG_STAGE.ATTR_GROUP &&
+ isWhitespaceCode(data.charCodeAt(this.pos - 1))
+ ) {
+ beginTextAfterOpenTag(this, tag);
+ return;
} else if (code === CODE.OPEN_SQUARE_BRACKET) {
if (tag.stage === TAG_STAGE.ATTR_GROUP) {
this.emitError(
@@ -390,7 +375,7 @@ export const OPEN_TAG: StateDefinition = {
return;
}
- this.exitState();
+ exitConciseOpenTag(this, tag);
} else {
this.emitError(
tag,
@@ -527,6 +512,41 @@ export const OPEN_TAG: StateDefinition = {
},
};
+function exitConciseOpenTag(parser: Parser, tag: OpenTagMeta) {
+ if (tag.tagName) {
+ parser.exitState();
+ return true;
+ }
+
+ parser.emitError(
+ tag,
+ ErrorCode.MALFORMED_OPEN_TAG,
+ "A concise tag must start with a tag name",
+ );
+ return false;
+}
+
+function beginTextAfterOpenTag(parser: Parser, tag: OpenTagMeta) {
+ const { data, maxPos } = parser;
+ if (!exitConciseOpenTag(parser, tag)) return;
+
+ let curPos = parser.pos;
+ // Skip until the next newline.
+ while (curPos < maxPos && data.charCodeAt(curPos) !== CODE.NEWLINE) curPos++;
+ // Skip the newline itself.
+ const indentStart = ++curPos;
+
+ // Count how many spaces/tabs we have after the newline.
+ while (curPos < maxPos && isIndentCode(data.charCodeAt(curPos))) curPos++;
+
+ const indentSize = curPos - indentStart;
+ if (indentSize > parser.indent.length) {
+ parser.indent = data.slice(indentStart, curPos);
+ }
+
+ parser.enterState(STATE.BEGIN_DELIMITED_HTML_BLOCK);
+}
+
function shouldTerminateConciseTagVar(
code: number,
data: string,
diff --git a/src/states/PARSED_TEXT_CONTENT.ts b/src/states/PARSED_TEXT_CONTENT.ts
index 2e5464c5..7b4b20da 100644
--- a/src/states/PARSED_TEXT_CONTENT.ts
+++ b/src/states/PARSED_TEXT_CONTENT.ts
@@ -10,6 +10,7 @@ export interface ParsedTextContentMeta extends Meta {
indent: string;
singleLine: boolean;
delimiter: undefined | string;
+ closingDelimiter: undefined | string;
}
// We enter STATE.PARSED_TEXT_CONTENT when we are parsing
@@ -27,6 +28,7 @@ export const PARSED_TEXT_CONTENT: StateDefinition = {
indent: "",
singleLine: false,
delimiter: undefined,
+ closingDelimiter: undefined,
};
},
@@ -86,6 +88,13 @@ export const PARSED_TEXT_CONTENT: StateDefinition = {
this.pos++;
continue;
case CODE.BACKTICK:
+ if (
+ content.closingDelimiter &&
+ STATE.checkForBacktickCloser(this, content.closingDelimiter)
+ ) {
+ return;
+ }
+
this.startText();
this.enterState(STATE.TEMPLATE_STRING);
this.pos++; // skip `
diff --git a/src/states/PLACEHOLDER.ts b/src/states/PLACEHOLDER.ts
index 082687d4..c1053ced 100644
--- a/src/states/PLACEHOLDER.ts
+++ b/src/states/PLACEHOLDER.ts
@@ -29,6 +29,7 @@ export const PLACEHOLDER: StateDefinition = {
start: placeholder.start,
end: placeholder.end,
escape: placeholder.escape,
+ delimiter: this.textDelimiter,
value: {
start: placeholder.start + (placeholder.escape ? 2 : 3), // ignore ${ or $!{
end: placeholder.end - 1, // ignore }
diff --git a/src/util/constants.ts b/src/util/constants.ts
index 0ac1ba95..2d6fca94 100644
--- a/src/util/constants.ts
+++ b/src/util/constants.ts
@@ -49,8 +49,18 @@ export namespace Ranges {
block: boolean;
}
+ export interface Text extends Range {
+ /**
+ * The `--` or backtick run that begins the concise text block holding this
+ * text, or undefined outside of one.
+ */
+ delimiter: Range | undefined;
+ }
+
export interface Placeholder extends Value {
escape: boolean;
+ /** See {@link Text.delimiter}. */
+ delimiter: Range | undefined;
}
export interface AttrValue extends Value {
@@ -77,7 +87,7 @@ export type TagType = (typeof _TagType)[keyof typeof _TagType];
export interface ParserOptions {
onError?(data: Ranges.Error): void;
- onText?(data: Range): void;
+ onText?(data: Ranges.Text): void;
onPlaceholder?(data: Ranges.Placeholder): void;
onComment?(data: Ranges.Value): void;
onCDATA?(data: Ranges.Value): void;