diff --git a/AGENTS.md b/AGENTS.md index 910b2cb..a076fea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,9 +133,11 @@ reserved name is a known name, so never that code, and the two the flavour reser a form the grammar does not have is a claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one type. What -the grammar itself refuses stays a claim code, key order among it; a well-formed directive the -node tables refuse — an attribute a node does not hold or spells elsewhere, a value outside its -kind or its canonical spelling, an argument or a body its content model does not take — is +the grammar itself refuses stays a claim code, key order among it, and a leaf given a body is refused +at its opener, as a container missing its closer is (the maintainer, 2026-09-16); a well-formed +directive the node tables refuse — an attribute a node does not hold or spells elsewhere, a value +outside its kind or its canonical spelling, an argument, or a body of a shape its content model does +not take — is `unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code across both directions for good, since the call site knows which direction it called and parting them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one diff --git a/corpus/errors/attribute-nesting-depth.md b/corpus/errors/attribute-nesting-depth.md index 550baf8..0bf8beb 100644 --- a/corpus/errors/attribute-nesting-depth.md +++ b/corpus/errors/attribute-nesting-depth.md @@ -1,2 +1,2 @@ -:::tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"} -::: +!adf:tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"} +!adf:/tableCell diff --git a/corpus/errors/carry-invalid-json.md b/corpus/errors/carry-invalid-json.md index 1d3e3ff..1cd3435 100644 --- a/corpus/errors/carry-invalid-json.md +++ b/corpus/errors/carry-invalid-json.md @@ -1,3 +1,3 @@ -```adf +```carry {"type": ``` diff --git a/corpus/errors/claim-after-blockquote.md b/corpus/errors/claim-after-blockquote.md index 094d5ec..3183f7b 100644 --- a/corpus/errors/claim-after-blockquote.md +++ b/corpus/errors/claim-after-blockquote.md @@ -1,2 +1,2 @@ > Part. -::: +!adf:/panel diff --git a/corpus/errors/code-block-body.md b/corpus/errors/code-block-body.md index 35990e4..0bf49d2 100644 --- a/corpus/errors/code-block-body.md +++ b/corpus/errors/code-block-body.md @@ -1,3 +1,3 @@ -:::codeBlock {wrap=true} +!adf:codeBlock {wrap=true} fn main() {} -::: +!adf:/codeBlock diff --git a/corpus/errors/code-block-language-slot.md b/corpus/errors/code-block-language-slot.md index d08ee3b..50c123d 100644 --- a/corpus/errors/code-block-language-slot.md +++ b/corpus/errors/code-block-language-slot.md @@ -1,5 +1,5 @@ -:::codeBlock {language=rust wrap=true} +!adf:codeBlock {language=rust wrap=true} ``` fn main() {} ``` -::: +!adf:/codeBlock diff --git a/corpus/errors/colon-run-alone.md b/corpus/errors/colon-run-alone.md deleted file mode 100644 index 2d2bfd3..0000000 --- a/corpus/errors/colon-run-alone.md +++ /dev/null @@ -1 +0,0 @@ -::: diff --git a/corpus/errors/directive-attribute-order.md b/corpus/errors/directive-attribute-order.md index 2c7ad5f..e3b8970 100644 --- a/corpus/errors/directive-attribute-order.md +++ b/corpus/errors/directive-attribute-order.md @@ -1 +1 @@ -::media {type=file id=a-1} +!adf:media {type=file id=a-1} diff --git a/corpus/errors/directive-attribute-unknown.md b/corpus/errors/directive-attribute-unknown.md index e812626..87d9b20 100644 --- a/corpus/errors/directive-attribute-unknown.md +++ b/corpus/errors/directive-attribute-unknown.md @@ -1,3 +1,3 @@ -:::panel info {bogus=1} +!adf:panel info {bogus=1} Part. -::: +!adf:/panel diff --git a/corpus/errors/directive-body-on-leaf.error b/corpus/errors/directive-body-on-leaf.error index a811539..4f35411 100644 --- a/corpus/errors/directive-body-on-leaf.error +++ b/corpus/errors/directive-body-on-leaf.error @@ -1 +1 @@ -unsupported-node-shape +malformed-directive diff --git a/corpus/errors/directive-body-on-leaf.md b/corpus/errors/directive-body-on-leaf.md index 7f6b9ed..5eaea16 100644 --- a/corpus/errors/directive-body-on-leaf.md +++ b/corpus/errors/directive-body-on-leaf.md @@ -1,3 +1,3 @@ -:::rule +!adf:media {id=01a0aa6b-4221-7518-a640-2b97d6217ce2 type=file} Part. -::: +!adf:/media diff --git a/corpus/errors/directive-carry-reserved.md b/corpus/errors/directive-carry-reserved.md index 2ab969b..acac62c 100644 --- a/corpus/errors/directive-carry-reserved.md +++ b/corpus/errors/directive-carry-reserved.md @@ -1,3 +1,3 @@ -:::carry +!adf:carry {"type":"rule"} -::: +!adf:/carry diff --git a/corpus/errors/colon-run-alone.error b/corpus/errors/directive-closer-mismatched.error similarity index 100% rename from corpus/errors/colon-run-alone.error rename to corpus/errors/directive-closer-mismatched.error diff --git a/corpus/errors/directive-closer-mismatched.md b/corpus/errors/directive-closer-mismatched.md new file mode 100644 index 0000000..effa67e --- /dev/null +++ b/corpus/errors/directive-closer-mismatched.md @@ -0,0 +1,4 @@ +!adf:panel info +!adf:expand +Part. +!adf:/panel diff --git a/corpus/errors/directive-fence-unclosed.error b/corpus/errors/directive-container-unclosed.error similarity index 100% rename from corpus/errors/directive-fence-unclosed.error rename to corpus/errors/directive-container-unclosed.error diff --git a/corpus/errors/directive-container-unclosed.md b/corpus/errors/directive-container-unclosed.md new file mode 100644 index 0000000..3b7dc2b --- /dev/null +++ b/corpus/errors/directive-container-unclosed.md @@ -0,0 +1,2 @@ +!adf:panel info +Part. diff --git a/corpus/errors/directive-fence-unclosed.md b/corpus/errors/directive-fence-unclosed.md deleted file mode 100644 index 24f4d00..0000000 --- a/corpus/errors/directive-fence-unclosed.md +++ /dev/null @@ -1,2 +0,0 @@ -:::panel info -Part. diff --git a/corpus/errors/directive-form-commonmark.md b/corpus/errors/directive-form-commonmark.md index 98aed2d..9a73b68 100644 --- a/corpus/errors/directive-form-commonmark.md +++ b/corpus/errors/directive-form-commonmark.md @@ -1 +1 @@ -::rule +!adf:rule diff --git a/corpus/errors/directive-name-unknown.md b/corpus/errors/directive-name-unknown.md index 0143c08..a1b14e4 100644 --- a/corpus/errors/directive-name-unknown.md +++ b/corpus/errors/directive-name-unknown.md @@ -1,3 +1,3 @@ -:::widget info +!adf:widget info Part. -::: +!adf:/widget diff --git a/corpus/errors/directive-value-uncanonical.md b/corpus/errors/directive-value-uncanonical.md index de317da..ddc9483 100644 --- a/corpus/errors/directive-value-uncanonical.md +++ b/corpus/errors/directive-value-uncanonical.md @@ -1 +1 @@ -::rule {localId="a-1"} +!adf:rule {localId="a-1"} diff --git a/corpus/errors/list-break-body.error b/corpus/errors/list-break-body.error new file mode 100644 index 0000000..4f35411 --- /dev/null +++ b/corpus/errors/list-break-body.error @@ -0,0 +1 @@ +malformed-directive diff --git a/corpus/errors/list-break-body.md b/corpus/errors/list-break-body.md new file mode 100644 index 0000000..8011a94 --- /dev/null +++ b/corpus/errors/list-break-body.md @@ -0,0 +1,7 @@ +- Bolt M8 + +!adf:listBreak +Packed. +!adf:/listBreak + +- Nut M8 diff --git a/corpus/errors/list-break-misplaced.md b/corpus/errors/list-break-misplaced.md index b739d5b..e938db7 100644 --- a/corpus/errors/list-break-misplaced.md +++ b/corpus/errors/list-break-misplaced.md @@ -1,5 +1,5 @@ - Bolt M8 -::listBreak +!adf:listBreak Packed. diff --git a/corpus/round-trip/block-nodes/adjacent-lists.md b/corpus/round-trip/block-nodes/adjacent-lists.md index 893ca28..1c6b0d1 100644 --- a/corpus/round-trip/block-nodes/adjacent-lists.md +++ b/corpus/round-trip/block-nodes/adjacent-lists.md @@ -1,23 +1,23 @@ - Bolt -::listBreak +!adf:listBreak - Nut 1. Cut -::listBreak +!adf:listBreak 1. Weld -:::panel info +!adf:panel info - Left -::listBreak +!adf:listBreak - Right -::: +!adf:/panel - - Head - ::listBreak + !adf:listBreak - Tail diff --git a/corpus/round-trip/block-nodes/block-local-id.md b/corpus/round-trip/block-nodes/block-local-id.md index 914cf26..cc3a713 100644 --- a/corpus/round-trip/block-nodes/block-local-id.md +++ b/corpus/round-trip/block-nodes/block-local-id.md @@ -1,3 +1,3 @@ -:::paragraph {localId=01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8} +!adf:paragraph {localId=01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8} Every block node in the schema carries one. -::: +!adf:/paragraph diff --git a/corpus/round-trip/block-nodes/block-marks.md b/corpus/round-trip/block-nodes/block-marks.md index 2264652..32b00f7 100644 --- a/corpus/round-trip/block-nodes/block-marks.md +++ b/corpus/round-trip/block-nodes/block-marks.md @@ -1,5 +1,5 @@ -::::layoutSection {localId=01a033d0-1393-790b-9d44-c572033ea4aa marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"} -:::layoutColumn {width=100} +!adf:layoutSection {localId=01a033d0-1393-790b-9d44-c572033ea4aa marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"} +!adf:layoutColumn {width=100} Full width. -::: -:::: +!adf:/layoutColumn +!adf:/layoutSection diff --git a/corpus/round-trip/block-nodes/code-block-adf.md b/corpus/round-trip/block-nodes/code-block-adf.md deleted file mode 100644 index ac62a01..0000000 --- a/corpus/round-trip/block-nodes/code-block-adf.md +++ /dev/null @@ -1,13 +0,0 @@ -:::codeBlock {language=adf} -``` -{ - "type": "blockCard" -} -``` -::: - -:::codeBlock {language=adf} -```` -``` -```` -::: diff --git a/corpus/round-trip/block-nodes/code-block-attributes.json b/corpus/round-trip/block-nodes/code-block-attributes.json index 383c929..db3ccd1 100644 --- a/corpus/round-trip/block-nodes/code-block-attributes.json +++ b/corpus/round-trip/block-nodes/code-block-attributes.json @@ -64,7 +64,7 @@ }, "content": [ { - "text": ":::", + "text": "!adf:/codeBlock", "type": "text" } ], diff --git a/corpus/round-trip/block-nodes/code-block-attributes.md b/corpus/round-trip/block-nodes/code-block-attributes.md index 1684d8d..9634331 100644 --- a/corpus/round-trip/block-nodes/code-block-attributes.md +++ b/corpus/round-trip/block-nodes/code-block-attributes.md @@ -1,31 +1,31 @@ -:::codeBlock {hideLineNumbers=true localId=01a0431d-201b-70a7-81fa-8d0072e09eab uniqueId=4478e39c wrap=true} +!adf:codeBlock {hideLineNumbers=true localId=01a0431d-201b-70a7-81fa-8d0072e09eab uniqueId=4478e39c wrap=true} ```rust fn main() {} ``` -::: +!adf:/codeBlock -:::codeBlock {language="a\u0060b"} +!adf:codeBlock {language="a\u0060b"} ``` x ``` -::: +!adf:/codeBlock -:::codeBlock {language=" sql"} +!adf:codeBlock {language=" sql"} ``` SELECT 1 ``` -::: +!adf:/codeBlock -:::codeBlock {language="\u0026#97;df"} +!adf:codeBlock {language="\u0026#97;df"} ``` y ``` -::: +!adf:/codeBlock -::::panel info -:::codeBlock {wrap=true} +!adf:panel info +!adf:codeBlock {wrap=true} ```text -::: +!adf:/codeBlock ``` -::: -:::: +!adf:/codeBlock +!adf:/panel diff --git a/corpus/round-trip/block-nodes/code-block-adf.json b/corpus/round-trip/block-nodes/code-block-carry.json similarity index 87% rename from corpus/round-trip/block-nodes/code-block-adf.json rename to corpus/round-trip/block-nodes/code-block-carry.json index c54486b..a5bbd7d 100644 --- a/corpus/round-trip/block-nodes/code-block-adf.json +++ b/corpus/round-trip/block-nodes/code-block-carry.json @@ -2,7 +2,7 @@ "content": [ { "attrs": { - "language": "adf" + "language": "carry" }, "content": [ { @@ -14,7 +14,7 @@ }, { "attrs": { - "language": "adf" + "language": "carry" }, "content": [ { diff --git a/corpus/round-trip/block-nodes/code-block-carry.md b/corpus/round-trip/block-nodes/code-block-carry.md new file mode 100644 index 0000000..5a032f7 --- /dev/null +++ b/corpus/round-trip/block-nodes/code-block-carry.md @@ -0,0 +1,13 @@ +!adf:codeBlock {language=carry} +``` +{ + "type": "blockCard" +} +``` +!adf:/codeBlock + +!adf:codeBlock {language=carry} +```` +``` +```` +!adf:/codeBlock diff --git a/corpus/round-trip/block-nodes/code-block-empty-language.md b/corpus/round-trip/block-nodes/code-block-empty-language.md index 7d6da9a..12cf0f3 100644 --- a/corpus/round-trip/block-nodes/code-block-empty-language.md +++ b/corpus/round-trip/block-nodes/code-block-empty-language.md @@ -1,5 +1,5 @@ -:::codeBlock {language=""} +!adf:codeBlock {language=""} ``` cargo build --release ``` -::: +!adf:/codeBlock diff --git a/corpus/round-trip/block-nodes/commonmark-blocks.md b/corpus/round-trip/block-nodes/commonmark-blocks.md index 7fc5c32..53d8fe8 100644 --- a/corpus/round-trip/block-nodes/commonmark-blocks.md +++ b/corpus/round-trip/block-nodes/commonmark-blocks.md @@ -1,34 +1,35 @@ -:::heading {level=2 localId=01a0431d-2014-737c-a1bf-f9daedab6de3} +!adf:heading {level=2 localId=01a0431d-2014-737c-a1bf-f9daedab6de3} Release checklist -::: +!adf:/heading -:::paragraph {marks="[{\"attrs\":{\"align\":\"center\"},\"type\":\"alignment\"}]"} +!adf:paragraph {marks="[{\"attrs\":{\"align\":\"center\"},\"type\":\"alignment\"}]"} Centred. -::: +!adf:/paragraph -::::bulletList {localId=01a0431d-2015-737f-afad-33064295ad4f} -:::listItem {localId=01a0431d-2016-7abb-823f-c0942934c878} +!adf:bulletList {localId=01a0431d-2015-737f-afad-33064295ad4f} +!adf:listItem {localId=01a0431d-2016-7abb-823f-c0942934c878} Tag the commit -::: -:::listItem +!adf:/listItem +!adf:listItem Push the tag -::: -:::: +!adf:/listItem +!adf:/bulletList -::::orderedList -:::listItem +!adf:orderedList +!adf:listItem Loosen the clamp -::: -:::: +!adf:/listItem +!adf:/orderedList -> :::paragraph {localId=01a0431d-2017-72cf-baf3-8934a54e0582} +> !adf:paragraph {localId=01a0431d-2017-72cf-baf3-8934a54e0582} > Hold the line. -> ::: +> !adf:/paragraph -- :::paragraph {localId=01a0431d-2018-7667-a769-ab0037ca80aa} +- !adf:paragraph {localId=01a0431d-2018-7667-a769-ab0037ca80aa} Indented. - ::: + !adf:/paragraph -::rule {localId=01a0431d-2019-76b7-bf6f-13841363b111} +!adf:rule {localId=01a0431d-2019-76b7-bf6f-13841363b111} -::paragraph {localId=01a0431d-201a-7a65-bea7-84b441c85bd6} +!adf:paragraph {localId=01a0431d-201a-7a65-bea7-84b441c85bd6} +!adf:/paragraph diff --git a/corpus/round-trip/block-nodes/commonmark-values.md b/corpus/round-trip/block-nodes/commonmark-values.md index c7745f2..b3706da 100644 --- a/corpus/round-trip/block-nodes/commonmark-values.md +++ b/corpus/round-trip/block-nodes/commonmark-values.md @@ -1,19 +1,19 @@ -:::heading {level=7} +!adf:heading {level=7} Seventh -::: +!adf:/heading -:::heading +!adf:heading Levelless -::: +!adf:/heading -::::orderedList {order=999999998} -:::listItem +!adf:orderedList {order=999999998} +!adf:listItem One -::: -:::listItem +!adf:/listItem +!adf:listItem Two -::: -:::listItem +!adf:/listItem +!adf:listItem Three -::: -:::: +!adf:/listItem +!adf:/orderedList diff --git a/corpus/round-trip/block-nodes/container-block-separation.md b/corpus/round-trip/block-nodes/container-block-separation.md index a4bbc97..d45a454 100644 --- a/corpus/round-trip/block-nodes/container-block-separation.md +++ b/corpus/round-trip/block-nodes/container-block-separation.md @@ -1,6 +1,6 @@ -::::expand {title="Full build log"} +!adf:expand {title="Full build log"} The build ran for 11 minutes. -:::panel warning +!adf:panel warning Three warnings went unread. -::: -:::: +!adf:/panel +!adf:/expand diff --git a/corpus/round-trip/block-nodes/container-crossing.md b/corpus/round-trip/block-nodes/container-crossing.md index c880706..f516f4b 100644 --- a/corpus/round-trip/block-nodes/container-crossing.md +++ b/corpus/round-trip/block-nodes/container-crossing.md @@ -1,16 +1,16 @@ -::::panel info -- :::panel warning +!adf:panel info +- !adf:panel warning Check the torque before signing off. - ::: -- - :::expand {title="Deeper still"} + !adf:/panel +- - !adf:expand {title="Deeper still"} Two list levels down. - ::: + !adf:/expand -> :::expand {title="Full build log"} +> !adf:expand {title="Full build log"} > The build ran for 11 minutes. -> ::: +> !adf:/expand > -> :::panel note +> !adf:panel note > Superseded by the next run. -> ::: -:::: +> !adf:/panel +!adf:/panel diff --git a/corpus/round-trip/block-nodes/decision-list.md b/corpus/round-trip/block-nodes/decision-list.md index 6019439..764e4ef 100644 --- a/corpus/round-trip/block-nodes/decision-list.md +++ b/corpus/round-trip/block-nodes/decision-list.md @@ -1,8 +1,8 @@ -::::decisionList {localId=01a033c4-7aa0-723f-bd4b-018e0aa0103f} -:::decisionItem {localId=01a033c8-0df4-74df-a9f4-c2c4aa3cc2c1 state=DECIDED} +!adf:decisionList {localId=01a033c4-7aa0-723f-bd4b-018e0aa0103f} +!adf:decisionItem {localId=01a033c8-0df4-74df-a9f4-c2c4aa3cc2c1 state=DECIDED} Ship on Friday. -::: -:::decisionItem {localId=01a033cb-e13c-79b2-933f-b3cedd57e33e state=DECIDED} +!adf:/decisionItem +!adf:decisionItem {localId=01a033cb-e13c-79b2-933f-b3cedd57e33e state=DECIDED} Keep the old endpoint for one release. -::: -:::: +!adf:/decisionItem +!adf:/decisionList diff --git a/corpus/round-trip/block-nodes/expand.md b/corpus/round-trip/block-nodes/expand.md index 05a2466..0e1af2b 100644 --- a/corpus/round-trip/block-nodes/expand.md +++ b/corpus/round-trip/block-nodes/expand.md @@ -1,9 +1,9 @@ -:::expand {title="Full build log"} +!adf:expand {title="Full build log"} The build ran for 11 minutes. Warnings: 3. -::: +!adf:/expand -:::nestedExpand {localId=01a03382-2ca7-745c-b85d-49b84d32cf99 title="Torque values"} +!adf:nestedExpand {localId=01a03382-2ca7-745c-b85d-49b84d32cf99 title="Torque values"} Bolt M8: 25 Nm. -::: +!adf:/nestedExpand diff --git a/corpus/round-trip/block-nodes/extension.md b/corpus/round-trip/block-nodes/extension.md index 2df652c..68613b8 100644 --- a/corpus/round-trip/block-nodes/extension.md +++ b/corpus/round-trip/block-nodes/extension.md @@ -1,14 +1,14 @@ -::extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":2,\"minLevel\":1}" text="Table of contents"} +!adf:extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":2,\"minLevel\":1}" text="Table of contents"} -:::bodiedExtension {extensionKey=note extensionType="com.atlassian.confluence.macro.core" layout=default localId=01a033d4-123e-70ec-9a9c-f55f189b3cf7} +!adf:bodiedExtension {extensionKey=note extensionType="com.atlassian.confluence.macro.core" layout=default localId=01a033d4-123e-70ec-9a9c-f55f189b3cf7} Handle with care. -::: +!adf:/bodiedExtension -::::multiBodiedExtension {extensionKey=tabs extensionType="com.atlassian.confluence.macro.core" localId=01a033de-845f-70e2-abcf-cd5d23ba6101} -:::extensionFrame +!adf:multiBodiedExtension {extensionKey=tabs extensionType="com.atlassian.confluence.macro.core" localId=01a033de-845f-70e2-abcf-cd5d23ba6101} +!adf:extensionFrame Tab one. -::: -:::extensionFrame +!adf:/extensionFrame +!adf:extensionFrame Tab two. -::: -:::: +!adf:/extensionFrame +!adf:/multiBodiedExtension diff --git a/corpus/round-trip/block-nodes/image-boundary.md b/corpus/round-trip/block-nodes/image-boundary.md index 5b2963f..24db474 100644 --- a/corpus/round-trip/block-nodes/image-boundary.md +++ b/corpus/round-trip/block-nodes/image-boundary.md @@ -1,26 +1,26 @@ -::::mediaSingle {layout=center} -::media {alt="The moon, at night." type=external url="https://example.com/moon.png"} -:::caption +!adf:mediaSingle {layout=center} +!adf:media {alt="The moon, at night." type=external url="https://example.com/moon.png"} +!adf:caption Taken from Kiruna. -::: -:::: +!adf:/caption +!adf:/mediaSingle -:::mediaSingle {layout=center localId=01a03394-6721-7075-bd6e-fd9a57b64b56} -::media {alt="Torque chart" type=external url="https://example.com/torque.png"} -::: +!adf:mediaSingle {layout=center localId=01a03394-6721-7075-bd6e-fd9a57b64b56} +!adf:media {alt="Torque chart" type=external url="https://example.com/torque.png"} +!adf:/mediaSingle -:::mediaSingle {layout=center} -::media {height=480 type=external url="https://example.com/plan.png" width=640} -::: +!adf:mediaSingle {layout=center} +!adf:media {height=480 type=external url="https://example.com/plan.png" width=640} +!adf:/mediaSingle -:::mediaSingle {layout=center} -::media {alt="The assembly drawing" marks="[{\"attrs\":{\"href\":\"https://example.com/assembly\"},\"type\":\"link\"}]" type=external url="https://example.com/assembly.png"} -::: +!adf:mediaSingle {layout=center} +!adf:media {alt="The assembly drawing" marks="[{\"attrs\":{\"href\":\"https://example.com/assembly\"},\"type\":\"link\"}]" type=external url="https://example.com/assembly.png"} +!adf:/mediaSingle -:::mediaSingle {layout=center} -::media {alt="" type=external url="https://example.com/moon.png"} -::: +!adf:mediaSingle {layout=center} +!adf:media {alt="" type=external url="https://example.com/moon.png"} +!adf:/mediaSingle -:::mediaSingle {layout=center} -::media {type=external url="https://example.com/plan.png?a=1\u0026amp;b=2"} -::: +!adf:mediaSingle {layout=center} +!adf:media {type=external url="https://example.com/plan.png?a=1\u0026amp;b=2"} +!adf:/mediaSingle diff --git a/corpus/round-trip/block-nodes/layout.md b/corpus/round-trip/block-nodes/layout.md index 12d73a5..44ff21f 100644 --- a/corpus/round-trip/block-nodes/layout.md +++ b/corpus/round-trip/block-nodes/layout.md @@ -1,8 +1,8 @@ -::::layoutSection {localId=01a033cc-4b60-7b5e-85f4-34f27f039e9a} -:::layoutColumn {localId=01a03389-a5fb-747d-b418-e91f1b161e7f width=50} +!adf:layoutSection {localId=01a033cc-4b60-7b5e-85f4-34f27f039e9a} +!adf:layoutColumn {localId=01a03389-a5fb-747d-b418-e91f1b161e7f width=50} Left. -::: -:::layoutColumn {valign=top width=50} +!adf:/layoutColumn +!adf:layoutColumn {valign=top width=50} Right. -::: -:::: +!adf:/layoutColumn +!adf:/layoutSection diff --git a/corpus/round-trip/block-nodes/media-group.md b/corpus/round-trip/block-nodes/media-group.md index 6b90feb..30486b2 100644 --- a/corpus/round-trip/block-nodes/media-group.md +++ b/corpus/round-trip/block-nodes/media-group.md @@ -1,4 +1,4 @@ -:::mediaGroup -::media {collection=contentId-98237 id=01a03387-e300-7bcf-9a41-c27760696449 occurrenceKey=01a0338a-2f44-764f-a7b7-1c1a2abbb49b type=file} -::media {collection=contentId-98237 id=01a03393-0b0b-71f6-89cd-dd654f42cf34 type=link} -::: +!adf:mediaGroup +!adf:media {collection=contentId-98237 id=01a03387-e300-7bcf-9a41-c27760696449 occurrenceKey=01a0338a-2f44-764f-a7b7-1c1a2abbb49b type=file} +!adf:media {collection=contentId-98237 id=01a03393-0b0b-71f6-89cd-dd654f42cf34 type=link} +!adf:/mediaGroup diff --git a/corpus/round-trip/block-nodes/media-single.md b/corpus/round-trip/block-nodes/media-single.md index 1a8c575..0d74648 100644 --- a/corpus/round-trip/block-nodes/media-single.md +++ b/corpus/round-trip/block-nodes/media-single.md @@ -1,10 +1,10 @@ -::::mediaSingle {layout=center width=50} -::media {collection=MediaServicesSample id=4478e39c-cf9b-41d1-ba92-68589487cd75 type=file} -:::caption {localId=01a03389-670b-7331-ae60-0446747ace7a} +!adf:mediaSingle {layout=center width=50} +!adf:media {collection=MediaServicesSample id=4478e39c-cf9b-41d1-ba92-68589487cd75 type=file} +!adf:caption {localId=01a03389-670b-7331-ae60-0446747ace7a} The moon, at night. -::: -:::: +!adf:/caption +!adf:/mediaSingle -:::mediaSingle {layout=wrap-right width="33.33" widthType=percentage} -::media {alt="Torque chart" height=480 localId=01a03389-8683-7c05-bfbb-457bcdb0ced3 type=external url="https://example.com/torque.png" width=640} -::: +!adf:mediaSingle {layout=wrap-right width="33.33" widthType=percentage} +!adf:media {alt="Torque chart" height=480 localId=01a03389-8683-7c05-bfbb-457bcdb0ced3 type=external url="https://example.com/torque.png" width=640} +!adf:/mediaSingle diff --git a/corpus/round-trip/block-nodes/nested-fences.json b/corpus/round-trip/block-nodes/nested-containers.json similarity index 94% rename from corpus/round-trip/block-nodes/nested-fences.json rename to corpus/round-trip/block-nodes/nested-containers.json index c4326a9..161213d 100644 --- a/corpus/round-trip/block-nodes/nested-fences.json +++ b/corpus/round-trip/block-nodes/nested-containers.json @@ -34,7 +34,7 @@ }, "content": [ { - "text": ":::::::panel warning\n:::", + "text": "!adf:panel warning\n!adf:/panel", "type": "text" } ], diff --git a/corpus/round-trip/block-nodes/nested-containers.md b/corpus/round-trip/block-nodes/nested-containers.md new file mode 100644 index 0000000..bf8892a --- /dev/null +++ b/corpus/round-trip/block-nodes/nested-containers.md @@ -0,0 +1,15 @@ +!adf:table +!adf:tableRow +!adf:tableCell +!adf:nestedExpand {title="Torque values"} +Bolt M8: 25 Nm. +!adf:/nestedExpand +!adf:/tableCell +!adf:tableCell +```text +!adf:panel warning +!adf:/panel +``` +!adf:/tableCell +!adf:/tableRow +!adf:/table diff --git a/corpus/round-trip/block-nodes/nested-fences.md b/corpus/round-trip/block-nodes/nested-fences.md deleted file mode 100644 index e63e6a2..0000000 --- a/corpus/round-trip/block-nodes/nested-fences.md +++ /dev/null @@ -1,15 +0,0 @@ -::::::table -:::::tableRow -::::tableCell -:::nestedExpand {title="Torque values"} -Bolt M8: 25 Nm. -::: -:::: -:::tableCell -```text -:::::::panel warning -::: -``` -::: -::::: -:::::: diff --git a/corpus/round-trip/block-nodes/panel.md b/corpus/round-trip/block-nodes/panel.md index 35283fa..f849f5a 100644 --- a/corpus/round-trip/block-nodes/panel.md +++ b/corpus/round-trip/block-nodes/panel.md @@ -1,11 +1,11 @@ -:::panel warning +!adf:panel warning Check the collation before importing. -::: +!adf:/panel -:::panel info {localId=01a0337c-561a-75aa-b629-3801f68839ad} +!adf:panel info {localId=01a0337c-561a-75aa-b629-3801f68839ad} Backups run at 02:00. -::: +!adf:/panel -:::panel custom {panelColor="#c9372c" panelIcon=":rainbow:" panelIconId=1f308 panelIconText="🌈"} +!adf:panel custom {panelColor="#c9372c" panelIcon=":rainbow:" panelIconId=1f308 panelIconText="🌈"} Weather permitting. -::: +!adf:/panel diff --git a/corpus/round-trip/block-nodes/sync-block.md b/corpus/round-trip/block-nodes/sync-block.md index 23e04b5..5fb62f0 100644 --- a/corpus/round-trip/block-nodes/sync-block.md +++ b/corpus/round-trip/block-nodes/sync-block.md @@ -1,5 +1,5 @@ -::syncBlock {localId=0198f3a2-af74-7e91-b26c-70b15f4d9ec3 resourceId="ari:cloud:confluence:site/page/123"} +!adf:syncBlock {localId=0198f3a2-af74-7e91-b26c-70b15f4d9ec3 resourceId="ari:cloud:confluence:site/page/123"} -:::bodiedSyncBlock {localId=01a033df-0dee-7251-a710-98e76dc285e4 resourceId="ari:cloud:confluence:site/page/456"} +!adf:bodiedSyncBlock {localId=01a033df-0dee-7251-a710-98e76dc285e4 resourceId="ari:cloud:confluence:site/page/456"} Shared release notes. -::: +!adf:/bodiedSyncBlock diff --git a/corpus/round-trip/block-nodes/table-directive.md b/corpus/round-trip/block-nodes/table-directive.md index 8415f26..4b719af 100644 --- a/corpus/round-trip/block-nodes/table-directive.md +++ b/corpus/round-trip/block-nodes/table-directive.md @@ -1,20 +1,20 @@ -:::::table {displayMode=fixed isNumberColumnEnabled=true layout=default localId=01a0339e-c34f-7658-9d98-eb059401b7ab width=760} -::::tableRow -:::tableHeader {colspan=2 colwidth="[340,420]"} +!adf:table {displayMode=fixed isNumberColumnEnabled=true layout=default localId=01a0339e-c34f-7658-9d98-eb059401b7ab width=760} +!adf:tableRow +!adf:tableHeader {colspan=2 colwidth="[340,420]"} Assembly -::: -:::: -::::tableRow {localId=01a033a9-d034-7466-b916-68643be958ff} -:::tableCell {background="#deebff" localId=01a03389-c573-72a6-a036-6cd9c6865301 rowspan=2} +!adf:/tableHeader +!adf:/tableRow +!adf:tableRow {localId=01a033a9-d034-7466-b916-68643be958ff} +!adf:tableCell {background="#deebff" localId=01a03389-c573-72a6-a036-6cd9c6865301 rowspan=2} Bolt M8 -::: -:::tableCell {valign=top} +!adf:/tableCell +!adf:tableCell {valign=top} 40 -::: -:::: -::::tableRow -:::tableCell +!adf:/tableCell +!adf:/tableRow +!adf:tableRow +!adf:tableCell 60 -::: -:::: -::::: +!adf:/tableCell +!adf:/tableRow +!adf:/table diff --git a/corpus/round-trip/block-nodes/table-pipe-boundary.md b/corpus/round-trip/block-nodes/table-pipe-boundary.md index 1cab3b5..d32e03b 100644 --- a/corpus/round-trip/block-nodes/table-pipe-boundary.md +++ b/corpus/round-trip/block-nodes/table-pipe-boundary.md @@ -1,18 +1,18 @@ -:::::table -::::tableRow -:::tableHeader +!adf:table +!adf:tableRow +!adf:tableHeader Pattern -::: -:::tableHeader +!adf:/tableHeader +!adf:tableHeader Meaning -::: -:::: -::::tableRow -:::tableCell +!adf:/tableHeader +!adf:/tableRow +!adf:tableRow +!adf:tableCell `a|b` -::: -:::tableCell +!adf:/tableCell +!adf:tableCell Either a or b -::: -:::: -::::: +!adf:/tableCell +!adf:/tableRow +!adf:/table diff --git a/corpus/round-trip/block-nodes/task-list.md b/corpus/round-trip/block-nodes/task-list.md index 030cce1..d176f9a 100644 --- a/corpus/round-trip/block-nodes/task-list.md +++ b/corpus/round-trip/block-nodes/task-list.md @@ -1,19 +1,20 @@ -:::::taskList {localId=0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90} -:::taskItem DONE {localId=0198f3a2-8d52-70b1-8c4f-5e9f3d2b7ca1} +!adf:taskList {localId=0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90} +!adf:taskItem DONE {localId=0198f3a2-8d52-70b1-8c4f-5e9f3d2b7ca1} Write the spec -::: -:::taskItem TODO {localId=0198f3a2-9e63-7d80-a15b-6fa04e3c8db2} +!adf:/taskItem +!adf:taskItem TODO {localId=0198f3a2-9e63-7d80-a15b-6fa04e3c8db2} Ship it -::: -:::blockTaskItem TODO {localId=01a033b2-95c8-7d62-9b9c-dee7ad510982} +!adf:/taskItem +!adf:blockTaskItem TODO {localId=01a033b2-95c8-7d62-9b9c-dee7ad510982} Check the torque. Then sign off. -::: -::::taskList {localId=01a033b3-d1fa-7489-8c9f-f5846d2c249d} -:::taskItem TODO {localId=01a033bd-a22d-7fb5-be9d-89d1aa261112} +!adf:/blockTaskItem +!adf:taskList {localId=01a033b3-d1fa-7489-8c9f-f5846d2c249d} +!adf:taskItem TODO {localId=01a033bd-a22d-7fb5-be9d-89d1aa261112} Nested step -::: -::taskItem TODO -:::: -::::: +!adf:/taskItem +!adf:taskItem TODO +!adf:/taskItem +!adf:/taskList +!adf:/taskList diff --git a/corpus/round-trip/combinations/attribute-canonical.md b/corpus/round-trip/combinations/attribute-canonical.md index d49fbbe..f2cf442 100644 --- a/corpus/round-trip/combinations/attribute-canonical.md +++ b/corpus/round-trip/combinations/attribute-canonical.md @@ -1,11 +1,11 @@ -:::::table {isNumberColumnEnabled=true layout=wide width="760.5"} -::::tableRow -:::tableHeader {colspan=2 colwidth="[340,420]"} +!adf:table {isNumberColumnEnabled=true layout=wide width="760.5"} +!adf:tableRow +!adf:tableHeader {colspan=2 colwidth="[340,420]"} Assembly -::: -:::: -::::: +!adf:/tableHeader +!adf:/tableRow +!adf:/table -:::expand {localId=01a0-33_z title="a\nb\tc\"d\\e"} +!adf:expand {localId=01a0-33_z title="a\nb\tc\"d\\e"} Notes. -::: +!adf:/expand diff --git a/corpus/round-trip/combinations/attribute-escapes.md b/corpus/round-trip/combinations/attribute-escapes.md index afc54de..8113ebf 100644 --- a/corpus/round-trip/combinations/attribute-escapes.md +++ b/corpus/round-trip/combinations/attribute-escapes.md @@ -2,6 +2,6 @@ a `b !adf:inlineCard{url="c\u0060d"} !adf:inlineCard{url="https://example.com/s?q=a\u0026amp;b\u003cc"} -::extension {extensionKey=jira parameters="{\"jql\":\"a\u007cb \u0060c\u0060 \u0026 d \u003c e\"}"} +!adf:extension {extensionKey=jira parameters="{\"jql\":\"a\u007cb \u0060c\u0060 \u0026 d \u003c e\"}"} [a!adf:inlineCard{url="b](c)"} diff --git a/corpus/round-trip/combinations/carry-attributes.md b/corpus/round-trip/combinations/carry-attributes.md index 981a7ab..088dbe9 100644 --- a/corpus/round-trip/combinations/carry-attributes.md +++ b/corpus/round-trip/combinations/carry-attributes.md @@ -1,4 +1,4 @@ -```adf +```carry { "attrs": { "rounded": true @@ -18,7 +18,7 @@ } ``` -```adf +```carry { "attrs": { "panelType": "extra info" diff --git a/corpus/round-trip/combinations/carve-out-directive.json b/corpus/round-trip/combinations/carve-out-directive.json index 736b8b9..109c44d 100644 --- a/corpus/round-trip/combinations/carve-out-directive.json +++ b/corpus/round-trip/combinations/carve-out-directive.json @@ -21,7 +21,7 @@ { "content": [ { - "text": "::syncBlock {localId=x}", + "text": "!adf:syncBlock {localId=x}", "type": "text" } ], diff --git a/corpus/round-trip/combinations/carve-out-directive.md b/corpus/round-trip/combinations/carve-out-directive.md index 12a0e69..07ea19f 100644 --- a/corpus/round-trip/combinations/carve-out-directive.md +++ b/corpus/round-trip/combinations/carve-out-directive.md @@ -2,9 +2,9 @@ Write \!adf:mention[@Mikael]{id=5b10a2} to tag someone. Namespaced under \!adf: alone. -\::syncBlock {localId=x} +\!adf:syncBlock {localId=x} -\:::panel warning +:::panel warning Stand-up at 10:30, shipped :tada:, {maxLevel} deep, a ::syncBlock mid-line. diff --git a/corpus/round-trip/combinations/carve-out-pipe-table.md b/corpus/round-trip/combinations/carve-out-pipe-table.md index 1a49b8b..20d7444 100644 --- a/corpus/round-trip/combinations/carve-out-pipe-table.md +++ b/corpus/round-trip/combinations/carve-out-pipe-table.md @@ -4,6 +4,6 @@ Pass a | b to the shell. - \| --- | --- | -:::panel info +!adf:panel info \| a | b | -::: +!adf:/panel diff --git a/corpus/round-trip/combinations/closing-fence-line.json b/corpus/round-trip/combinations/closer-line.json similarity index 85% rename from corpus/round-trip/combinations/closing-fence-line.json rename to corpus/round-trip/combinations/closer-line.json index 46c8a02..2339b04 100644 --- a/corpus/round-trip/combinations/closing-fence-line.json +++ b/corpus/round-trip/combinations/closer-line.json @@ -12,7 +12,7 @@ { "content": [ { - "text": ":::", + "text": "!adf:/panel", "type": "text" } ], @@ -29,7 +29,7 @@ { "content": [ { - "text": ":::", + "text": "!adf:/panel", "type": "text" } ], @@ -41,7 +41,7 @@ { "content": [ { - "text": ":::", + "text": "!adf:/panel", "type": "text" } ], @@ -50,7 +50,7 @@ { "content": [ { - "text": "::: x", + "text": "!adf:/panel x", "type": "text" } ], @@ -59,7 +59,7 @@ { "content": [ { - "text": "::", + "text": "!adf:rule", "type": "text" } ], @@ -71,7 +71,7 @@ { "content": [ { - "text": ":::", + "text": "!adf:/panel", "type": "text" } ], diff --git a/corpus/round-trip/combinations/closer-line.md b/corpus/round-trip/combinations/closer-line.md new file mode 100644 index 0000000..152f844 --- /dev/null +++ b/corpus/round-trip/combinations/closer-line.md @@ -0,0 +1,13 @@ +!adf:panel info +- \!adf:/panel + +> \!adf:/panel + +\!adf:/panel + +\!adf:/panel x + +\!adf:rule +!adf:/panel + +\!adf:/panel diff --git a/corpus/round-trip/combinations/closing-fence-line.md b/corpus/round-trip/combinations/closing-fence-line.md deleted file mode 100644 index 4057703..0000000 --- a/corpus/round-trip/combinations/closing-fence-line.md +++ /dev/null @@ -1,13 +0,0 @@ -:::panel info -- \::: - -> \::: - -\::: - -\::: x - -\:: -::: - -\::: diff --git a/corpus/round-trip/combinations/combined-containers.md b/corpus/round-trip/combinations/combined-containers.md index 890497f..3428899 100644 --- a/corpus/round-trip/combinations/combined-containers.md +++ b/corpus/round-trip/combinations/combined-containers.md @@ -1,39 +1,39 @@ -::::::layoutSection -::::layoutColumn {width=50} -:::panel info +!adf:layoutSection +!adf:layoutColumn {width=50} +!adf:panel info Check the collation before importing. -::: -:::: -:::::layoutColumn {width=50} -::::taskList {localId=0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90} -:::taskItem DONE {localId=0198f3a2-8d52-70b1-8c4f-5e9f3d2b7ca1} +!adf:/panel +!adf:/layoutColumn +!adf:layoutColumn {width=50} +!adf:taskList {localId=0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90} +!adf:taskItem DONE {localId=0198f3a2-8d52-70b1-8c4f-5e9f3d2b7ca1} Write the spec -::: -:::taskItem TODO {localId=0198f3a2-9e63-7d80-a15b-6fa04e3c8db2} +!adf:/taskItem +!adf:taskItem TODO {localId=0198f3a2-9e63-7d80-a15b-6fa04e3c8db2} Ship it -::: -:::: -::::: -:::::: +!adf:/taskItem +!adf:/taskList +!adf:/layoutColumn +!adf:/layoutSection -::::mediaSingle {layout=center width=50} -::media {collection=MediaServicesSample id=4478e39c-cf9b-41d1-ba92-68589487cd75 type=file} -:::caption +!adf:mediaSingle {layout=center width=50} +!adf:media {collection=MediaServicesSample id=4478e39c-cf9b-41d1-ba92-68589487cd75 type=file} +!adf:caption The moon, at night. -::: -:::: +!adf:/caption +!adf:/mediaSingle -::::::expand {title="Full build log"} -:::::table {isNumberColumnEnabled=true} -::::tableRow -:::tableHeader +!adf:expand {title="Full build log"} +!adf:table {isNumberColumnEnabled=true} +!adf:tableRow +!adf:tableHeader Step -::: -:::: -::::tableRow -:::tableCell {background="#deebff"} +!adf:/tableHeader +!adf:/tableRow +!adf:tableRow +!adf:tableCell {background="#deebff"} Compile -::: -:::: -::::: -:::::: +!adf:/tableCell +!adf:/tableRow +!adf:/table +!adf:/expand diff --git a/corpus/round-trip/combinations/combined-page.md b/corpus/round-trip/combinations/combined-page.md index 7a8ddd4..22b7629 100644 --- a/corpus/round-trip/combinations/combined-page.md +++ b/corpus/round-trip/combinations/combined-page.md @@ -15,6 +15,6 @@ Shipped !adf:emoji[🚀]{shortName=":rocket:"} on !adf:date{timestamp=1756080000 --- -:::panel warning +!adf:panel warning Do not skip the pre-flight. -::: +!adf:/panel diff --git a/corpus/round-trip/combinations/list-item-whitespace-line.md b/corpus/round-trip/combinations/list-item-whitespace-line.md index f4b0d02..2c78b03 100644 --- a/corpus/round-trip/combinations/list-item-whitespace-line.md +++ b/corpus/round-trip/combinations/list-item-whitespace-line.md @@ -1,20 +1,20 @@ -::::bulletList -:::listItem +!adf:bulletList +!adf:listItem ``` a ``` -::: -:::: +!adf:/listItem +!adf:/bulletList -::::::orderedList {order=1} -:::::listItem -::::bulletList -:::listItem +!adf:orderedList {order=1} +!adf:listItem +!adf:bulletList +!adf:listItem ``` ``` -::: -:::: -::::: -:::::: +!adf:/listItem +!adf:/bulletList +!adf:/listItem +!adf:/orderedList diff --git a/corpus/round-trip/commonmark-subset/paragraph-empty.md b/corpus/round-trip/commonmark-subset/paragraph-empty.md index 9255212..31a1b9a 100644 --- a/corpus/round-trip/commonmark-subset/paragraph-empty.md +++ b/corpus/round-trip/commonmark-subset/paragraph-empty.md @@ -1,5 +1,6 @@ First. -::paragraph +!adf:paragraph +!adf:/paragraph Second. diff --git a/corpus/round-trip/opaque-carry/carry-in-wrappers.md b/corpus/round-trip/opaque-carry/carry-in-wrappers.md index cf1815e..df32328 100644 --- a/corpus/round-trip/opaque-carry/carry-in-wrappers.md +++ b/corpus/round-trip/opaque-carry/carry-in-wrappers.md @@ -1,4 +1,4 @@ -> ```adf +> ```carry > { > "attrs": { > "url": "https://example.com/quoted" @@ -7,7 +7,7 @@ > } > ``` -- ```adf +- ```carry { "attrs": { "url": "https://example.com/listed" diff --git a/corpus/round-trip/opaque-carry/unknown-block.md b/corpus/round-trip/opaque-carry/unknown-block.md index 13d5ec1..a88fe07 100644 --- a/corpus/round-trip/opaque-carry/unknown-block.md +++ b/corpus/round-trip/opaque-carry/unknown-block.md @@ -1,4 +1,4 @@ -```adf +```carry { "attrs": { "url": "https://example.com/roadmap" @@ -7,10 +7,10 @@ } ``` -:::panel info +!adf:panel info The card below has no spelling yet. -```adf +```carry { "attrs": { "layout": "wide", @@ -20,4 +20,4 @@ The card below has no spelling yet. "type": "embedCard" } ``` -::: +!adf:/panel diff --git a/src/corpus.test.ts b/src/corpus.test.ts index a23c4f7..e7577f2 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -119,55 +119,6 @@ test('no round-trip fixture repeats the document another holds', () => { } }) -// spec/flavour.md, Directives: the container fence rule, checked against the emitted bytes. -function fenceNestingFault(markdown: string): string | undefined { - const open: number[] = [] - let codeFence: string | undefined - for (const line of markdown.split('\n')) { - const content = line.replace(/^[ \t]*(?:(?:> ?|[-*+] |\d{1,9}[.)] )[ \t]*)*/, '') - const backticks = /^(`{3,}|~{3,})/.exec(content)?.[1] - if (codeFence !== undefined) { - if (backticks !== undefined && backticks[0] === codeFence[0] && backticks.length >= codeFence.length) codeFence = undefined - continue - } - if (backticks !== undefined) { - codeFence = backticks - continue - } - const colons = /^(:{2,})(.*)$/.exec(content) - if (colons === null) continue - const run = colons[1]?.length ?? 0 - const enclosing = open[open.length - 1] - if (colons[2] === '') { - if (enclosing === undefined) return `${JSON.stringify(line)} closes no open container` - if (run < enclosing) return `${JSON.stringify(line)} is shorter than the ${enclosing} colons it would close` - open.pop() - continue - } - if (enclosing !== undefined && run >= enclosing) return `${JSON.stringify(line)} sits in a container fenced with ${enclosing} colons` - if (run > 2) open.push(run) - } - return undefined -} - -test('the fence nesting check catches a fence a container cannot hold', () => { - assert.equal(fenceNestingFault(':::panel info\n- :::panel warning\n B\n :::\n:::'), '"- :::panel warning" sits in a container fenced with 3 colons') - assert.equal(fenceNestingFault(':::panel info\n- - :::panel warning\n B\n :::\n:::'), '"- - :::panel warning" sits in a container fenced with 3 colons') - assert.equal(fenceNestingFault(':::panel info\n10. :::panel warning\n B\n :::\n:::'), '"10. :::panel warning" sits in a container fenced with 3 colons') - assert.equal(fenceNestingFault('::::panel info\n:::\n::::'), '":::" is shorter than the 4 colons it would close') - assert.equal(fenceNestingFault('Text\n:::\n'), '":::" closes no open container') - assert.equal(fenceNestingFault('::::panel info\n- - :::panel warning\n B\n :::\n::::'), undefined) - assert.equal(fenceNestingFault(':::tableCell\n```text\n:::::::panel warning\n:::\n```\n:::'), undefined) -}) - -for (const directory of roundTripDirectories) { - for (const name of fixtureNames(directory, '.md')) { - test(`${directory}/${name} fences every container longer than its body`, () => { - assert.equal(fenceNestingFault(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')), undefined) - }) - } -} - for (const name of pairedNames(normalizationRoot, '.md', '.json')) { test(`normalization/${name} parses to the document beside it, which emits and reads back to itself`, () => { const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8')) diff --git a/src/markdown-property.test.ts b/src/markdown-property.test.ts index ee2e885..768ba3a 100644 --- a/src/markdown-property.test.ts +++ b/src/markdown-property.test.ts @@ -11,8 +11,18 @@ import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRu import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { blockArgument } from './markdown/block-directive-arguments.ts' import { blockDirectives } from './adf/block-directives.ts' -import { carryFence, carryName } from './markdown/opaque-carry.ts' -import { directivePrefix, spellAttributes, spellInlineDirectiveOpener, spellJsonAttribute, spellLeafDirective, spellStringAttribute, spellVocabulary } from './markdown/directive-syntax.ts' +import { carryName } from './markdown/opaque-carry.ts' +import { + directivePrefix, + spellAttributes, + spellDirectiveCloser, + spellDirectiveOpener, + spellInlineDirectiveOpener, + spellInlineLeafDirective, + spellJsonAttribute, + spellStringAttribute, + spellVocabulary, +} from './markdown/directive-syntax.ts' import { fencedCodeBlock } from './markdown/backtick-runs.ts' import { inlineDirectives } from './adf/inline-directives.ts' import { listBreakName } from './markdown/list-break.ts' @@ -73,8 +83,8 @@ const syntaxTokens = fc.constantFrom( '![', '](', '{}', - '::', - ':::', + `${directivePrefix}/`, + `${directivePrefix}panel `, '> ', '- ', '* ', @@ -107,8 +117,8 @@ const autolink = fc.oneof( ) const hostileAutolink = fc.tuple(fc.constantFrom('http://', 'ab:', 'a:'), textOf(0)).map(([scheme, rest]) => `<${scheme}${rest}>`) const inlineHtml = fc.constantFrom('', '', '', "", '', '', '', '', '', '
', '') -const hardBreak = fc.constantFrom('\\\n', ' \n', '\n', spellLeafDirective('hardBreak', '')) -const spellTextDirective = (held: string) => spellLeafDirective(textDirectiveName, `{${textDirectiveName}=${spellStringAttribute(held)}}`) +const hardBreak = fc.constantFrom('\\\n', ' \n', '\n', spellInlineLeafDirective('hardBreak', '')) +const spellTextDirective = (held: string) => spellInlineLeafDirective(textDirectiveName, `{${textDirectiveName}=${spellStringAttribute(held)}}`) const textDirective = fc.constantFrom(' ', ' ', '\t', '\n', '\n\n').map(spellTextDirective) const hostileTextDirective = fc.constantFrom(' \n', 'a', '').map(spellTextDirective) @@ -151,24 +161,23 @@ const hostileArgument = fc.oneof( { arbitrary: fc.oneof(bareToken.map((held) => ` ${held}`), fc.constantFrom(' info', ' a b', ' "a"')), weight: 1 }, ) -function directiveHeader(colons: number, name: string, argument: string, attrs: string): string { - return `${':'.repeat(colons)}${name}${argument}${attrs === '' ? '' : ` ${attrs}`}` +function hostileOpener(name: string, argument: string, attrs: string): string { + return `${directivePrefix}${name}${argument}${attrs === '' ? '' : ` ${attrs}`}` } -function container(header: (colons: number) => string, body: string, closer: number | null): string { - const colons = Math.max(2, ...[...body.matchAll(/:{2,}/g)].map(([run]) => run.length)) + 1 - return [header(colons), ...(body === '' ? [] : [body]), ':'.repeat(closer ?? colons)].join('\n') +function container(opener: string, body: string, closer: string): string { + return [opener, body, closer].filter((line) => line !== '').join('\n') } const carriedNode = fc.oneof( fc.tuple(fc.constantFrom('mention', 'paragraph', 'status', 'widget'), fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true })).map(([type, attrs]): JsonValue => ({ attrs, type })), textOf(1).map((held): JsonValue => ({ text: held, type: 'text' })), ) -const spellCarry = (json: string) => spellLeafDirective(carryName, `{json=${spellStringAttribute(json)}}`) +const spellCarry = (json: string) => spellInlineLeafDirective(carryName, `{json=${spellStringAttribute(json)}}`) const inlineCarry = carriedNode.map((node) => spellCarry(serializeCanonicalJson(node, 'compact'))) const hostileInlineCarry = fc.oneof(carriedNode, jsonValue).map((node) => spellCarry(JSON.stringify(node, null, 1))) -const blockCarry = carriedNode.map((node) => fencedCodeBlock(carryFence, serializeCanonicalJson(node, 'two-space'))) -const hostileBlockCarry = fc.oneof(carriedNode, jsonValue).map((node) => fencedCodeBlock(carryFence, JSON.stringify(node))) +const blockCarry = carriedNode.map((node) => fencedCodeBlock(carryName, serializeCanonicalJson(node, 'two-space'))) +const hostileBlockCarry = fc.oneof(carriedNode, jsonValue).map((node) => fencedCodeBlock(carryName, JSON.stringify(node))) function prefixLines(body: string, first: string, rest: (index: number) => string): string { return body @@ -189,7 +198,7 @@ const listMarker = fc.oneof( }, ) const indentDrift = fc.oneof({ arbitrary: fc.constant(0), weight: 6 }, { arbitrary: fc.integer({ max: 2, min: -2 }), weight: 1 }) -const closerDrift = fc.option(fc.integer({ max: 5, min: 2 }), { freq: 6 }) +const closerDrift = fc.option(fc.oneof(directiveName.map(spellDirectiveCloser), fc.constantFrom('', `${spellDirectiveCloser('panel')} x`)), { freq: 6 }) function markdownOf(hostile: boolean): Arbitrary { const inline = inlineMarkdown(hostile) @@ -227,7 +236,7 @@ function inlineMarkdown(hostile: boolean): InlineMarkdown { ...Object.entries(inlineDirectives).map(([name, directive]) => fc .tuple(directive.textAttribute === undefined ? fc.constant(null) : fc.option(hostile ? word : prose), tableAttributes(directive.attributes, directive.textAttribute)) - .map(([slot, attrs]) => (slot === null ? spellLeafDirective(name, attrs) : `${spellInlineDirectiveOpener(name)}${slot}]${attrs}`)), + .map(([slot, attrs]) => (slot === null ? spellInlineLeafDirective(name, attrs) : `${spellInlineDirectiveOpener(name)}${slot}]${attrs}`)), ), ...Object.entries(markAttributes) .filter(([name]) => hostile || markSpelling(name)?.kind === 'directive') @@ -255,7 +264,7 @@ function leafBlocks(hostile: boolean, { destination, inlines, label, oneLine, te .tuple( fc.constantFrom('```', '```', '~~~', '````', '``'), fc.oneof(fc.constant(''), bareToken, text), - fc.array(fc.oneof(prose, text, fc.constantFrom('```', '~~~', ':::', ' x')), { maxLength: 3 }), + fc.array(fc.oneof(prose, text, fc.constantFrom('```', '~~~', spellDirectiveCloser('panel'), ' x')), { maxLength: 3 }), fc.constantFrom('', '', '`', '~', 'none'), ) .map(([fence, info, lines, closer]) => [`${fence}${info}`, ...lines, ...(closer === 'none' ? [] : [`${fence}${closer}`])].join('\n')) @@ -302,11 +311,15 @@ function leafBlocks(hostile: boolean, { destination, inlines, label, oneLine, te weight: 1, }, { - arbitrary: fc.tuple(fc.constantFrom(2, 2, 3, 1, 4), directiveName, hostileArgument, hostileAttributes).map(([colons, name, argument, attrs]) => directiveHeader(colons, name, argument, attrs)), + arbitrary: fc.tuple(directiveName, hostileArgument, hostileAttributes).map(([name, argument, attrs]) => hostileOpener(name, argument, attrs)), + hostile: true, + weight: 1, + }, + { + arbitrary: fc.constantFrom(`${directivePrefix}/`, spellDirectiveCloser('panel'), `${directivePrefix}panel`, `${directivePrefix} panel`, `${directivePrefix}panel info extra`, `${directivePrefix}Panel`), hostile: true, weight: 1, }, - { arbitrary: fc.constantFrom(':::', '::', '::::', ':::panel', '::: panel', ':::panel info extra'), hostile: true, weight: 1 }, ]) return { fencedCode, leafBlock } } @@ -318,13 +331,13 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, { const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => { const argument = blockArgument(name) === undefined - ? fc.constant('') - : fc.oneof({ arbitrary: fc.constantFrom(' DONE', ' TODO', ' custom', ' info', ' warning'), weight: 3 }, { arbitrary: bareToken.map((held) => ` ${held}`), weight: 1 }) + ? fc.constant(undefined) + : fc.oneof({ arbitrary: fc.constantFrom('DONE', 'TODO', 'custom', 'info', 'warning'), weight: 3 }, { arbitrary: bareToken, weight: 1 }) const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(directive.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(directive.attributes) - if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => directiveHeader(2, name, held, spelled)) + if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => spellDirectiveOpener(name, held, spelled)) return fc .tuple(argument, attrs, bodyByModel[directive.contentModel], hostile ? closerDrift : fc.constant(null)) - .map(([held, spelled, body, closer]) => container((colons) => directiveHeader(colons, name, held, spelled), body, closer)) + .map(([held, spelled, body, closer]) => container(spellDirectiveOpener(name, held, spelled), body, closer ?? spellDirectiveCloser(name))) }) return { block: choose( @@ -352,8 +365,8 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, { { arbitrary: fc.oneof(...tableDirectives), weight: 3 }, { arbitrary: fc - .tuple(directiveName, hostileArgument, hostileAttributes, tie('blocks'), fc.option(fc.integer({ max: 5, min: 3 }), { freq: 2 }), closerDrift) - .map(([name, argument, attrs, body, opener, closer]) => container((colons) => directiveHeader(opener ?? colons, name, argument, attrs), body, closer)), + .tuple(directiveName, hostileArgument, hostileAttributes, tie('blocks'), closerDrift) + .map(([name, argument, attrs, body, closer]) => container(hostileOpener(name, argument, attrs), body, closer ?? spellDirectiveCloser(name))), hostile: true, weight: 1, }, diff --git a/src/markdown/block-directive-forms.ts b/src/markdown/block-directive-forms.ts new file mode 100644 index 0000000..5bf827e --- /dev/null +++ b/src/markdown/block-directive-forms.ts @@ -0,0 +1,9 @@ +import { blockDirective } from '../adf/block-directives.ts' +import { listBreakName } from './list-break.ts' + +export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined { + if (name === listBreakName) return 'leaf' + const directive = blockDirective(name) + if (directive === undefined) return undefined + return directive.contentModel === 'none' ? 'leaf' : 'container' +} diff --git a/src/markdown/code-language.ts b/src/markdown/code-language.ts index 6d4ef4e..27f3a07 100644 --- a/src/markdown/code-language.ts +++ b/src/markdown/code-language.ts @@ -1,5 +1,5 @@ import type { JsonValue } from '../json-value.ts' -import { carryFence } from './opaque-carry.ts' +import { carryName } from './opaque-carry.ts' import { holdsControlCharacter } from './commonmark-grammar.ts' import { holdsEntityReference } from './entity-references.ts' @@ -8,7 +8,7 @@ export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' // spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides. export function languageSlot(language: JsonValue | undefined): LanguageSlot { if (language === undefined) return { kind: 'none' } - if (typeof language !== 'string' || language === '' || language === carryFence) return { kind: 'attribute' } + if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' } if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' } return { info: language, kind: 'fence' } } diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index c36fbb0..19b73d4 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -53,11 +53,10 @@ const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/ const blankLine = /^[ \t]*$/ const codeFenceOpener = /^(`{3,}|~{3,})/ -const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/ const pipeClaim = /^\|/ const bulletListOpener = /^[*+-](?:[ \t]|$)/ // A superset of what the parser claims: over-escaping a line is safe, under-escaping one breaks the round-trip. -const firstCharacterOpeners = [atxHeadingOpener, /^>/, bulletListOpener, codeFenceOpener, /^:{2,}/, pipeClaim] +const firstCharacterOpeners = [atxHeadingOpener, /^>/, bulletListOpener, codeFenceOpener, pipeClaim] // CommonMark 0.31.2, Autolinks: the email production, whose label may not open or close with a hyphen. const emailNameSource = "[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+" const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?' @@ -81,10 +80,6 @@ export function backslashEscape(text: string, index: number): string | undefined return isAsciiPunctuation(escaped) ? escaped : undefined } -export function claimsDirectiveLine(line: string): boolean { - return directiveClaim.test(line) -} - export function claimsLine(line: string, position: LinePosition): boolean { return escapesLineClaim(line, 0, position) || orderedListOpener.test(line) } diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts index b005f83..6639093 100644 --- a/src/markdown/directive-syntax.test.ts +++ b/src/markdown/directive-syntax.test.ts @@ -12,8 +12,8 @@ function attributes(...pairs: Pair[]): DirectiveAttributes { return new Map(pairs.map(([key, decoded, spelling]) => [key, { decoded, spelling: spelling ?? decoded }])) } -function header(colons: number, name: string, argument?: string, ...pairs: Pair[]): { value: DirectiveLine } { - return { value: { argument, attributes: attributes(...pairs), colons, kind: 'header', name } } +function opener(name: string, argument?: string, ...pairs: Pair[]): { value: DirectiveLine } { + return { value: { argument, attributes: attributes(...pairs), kind: 'opener', name } } } function fault(line: string): string { @@ -32,80 +32,87 @@ function spans(text: string, name: string, content: string | undefined, ...pairs assert.deepEqual(inline(text), { attributes: attributes(...pairs), content, length: text.length, name }) } -test('claims a colon-run line only where a name or nothing follows the colons', () => { +test('claims a prefixed line only where a closer, or a name a space or the line end follows, opens it', () => { assert.equal(readDirectiveLine('Part.'), undefined) - assert.equal(readDirectiveLine(':: two'), undefined) - assert.equal(readDirectiveLine(':panel'), undefined) - assert.equal(readDirectiveLine(' ::rule'), undefined) + assert.equal(readDirectiveLine(':::panel info'), undefined) + assert.equal(readDirectiveLine(' !adf:rule'), undefined) + assert.equal(readDirectiveLine('!adf:mention[@A]'), undefined) + assert.equal(readDirectiveLine('!adf:hardBreak{}'), undefined) + assert.equal(readDirectiveLine('!adf:panel{}'), undefined) + assert.equal(readDirectiveLine('!adf:'), undefined) + assert.equal(readDirectiveLine('!adf:/'), undefined) + assert.equal(readDirectiveLine('!adf:Panel'), undefined) + assert.equal(readDirectiveLine('!adf:panel\tinfo'), undefined) }) -test('reads a bare colon run as the fence that closes a container', () => { - assert.deepEqual(readDirectiveLine(':::'), { value: { colons: 3, kind: 'closing' } }) - assert.deepEqual(readDirectiveLine('::'), { value: { colons: 2, kind: 'closing' } }) - assert.deepEqual(readDirectiveLine(':::::: \t'), { value: { colons: 6, kind: 'closing' } }) +test('reads a closer as the name it closes, and nothing after the name', () => { + assert.deepEqual(readDirectiveLine('!adf:/panel'), { value: { kind: 'closer', name: 'panel' } }) + assert.deepEqual(readDirectiveLine('!adf:/panel \t'), { value: { kind: 'closer', name: 'panel' } }) + const closer = 'a closer carries nothing after its name: this one does; \\!adf: keeps the prefix literal' + assert.equal(fault('!adf:/panel info'), closer) + assert.equal(fault('!adf:/panel{}'), closer) }) -test('reads the leaf and container forms, their argument and their attributes', () => { - assert.deepEqual(readDirectiveLine('::rule'), header(2, 'rule')) - assert.deepEqual(readDirectiveLine('::rule '), header(2, 'rule')) - assert.deepEqual(readDirectiveLine('::taskItem TODO'), header(2, 'taskItem', 'TODO')) - assert.deepEqual(readDirectiveLine('::media {id=a-1 type=file}'), header(2, 'media', undefined, ['id', 'a-1'], ['type', 'file'])) - assert.deepEqual(readDirectiveLine('::panel info {panelColor="#ff0000"} '), header(2, 'panel', 'info', ['panelColor', '#ff0000', '"#ff0000"'])) - assert.deepEqual(readDirectiveLine(':::panel info'), header(3, 'panel', 'info')) +test('reads the opener, its argument and its attributes', () => { + assert.deepEqual(readDirectiveLine('!adf:rule'), opener('rule')) + assert.deepEqual(readDirectiveLine('!adf:rule \t'), opener('rule')) + assert.deepEqual(readDirectiveLine('!adf:rule\t'), opener('rule')) + assert.deepEqual(readDirectiveLine('!adf:taskItem TODO'), opener('taskItem', 'TODO')) + assert.deepEqual(readDirectiveLine('!adf:media {id=a-1 type=file}'), opener('media', undefined, ['id', 'a-1'], ['type', 'file'])) + assert.deepEqual(readDirectiveLine('!adf:panel info {panelColor="#ff0000"} '), opener('panel', 'info', ['panelColor', '#ff0000', '"#ff0000"'])) }) test('decodes a quoted attribute value, the escapes {attrs} reserves included', () => { - assert.deepEqual(readDirectiveLine('::extension {text="two words"}'), header(2, 'extension', undefined, ['text', 'two words', '"two words"'])) + assert.deepEqual(readDirectiveLine('!adf:extension {text="two words"}'), opener('extension', undefined, ['text', 'two words', '"two words"'])) assert.deepEqual( - readDirectiveLine('::extension {text="a\\u0060b\\u0026c\\u003cd\\u007ce"}'), - header(2, 'extension', undefined, ['text', 'a`b&c { - assert.equal(fault('::media {type=file id=a-1}'), 'the {attrs} keys read in alphabetical order: id before type') - assert.deepEqual(readDirectiveLine('::media {id=a-1 type=file}'), header(2, 'media', undefined, ['id', 'a-1'], ['type', 'file'])) + assert.equal(fault('!adf:media {type=file id=a-1}'), 'the {attrs} keys read in alphabetical order: id before type') + assert.deepEqual(readDirectiveLine('!adf:media {id=a-1 type=file}'), opener('media', undefined, ['id', 'a-1'], ['type', 'file'])) }) test('spells an empty {attrs} only where the brace itself claims the directive', () => { const omitted = 'an empty {attrs} is omitted unless the { itself claims the directive: this one spells {}' - assert.equal(fault('::rule {}'), omitted) - assert.equal(fault(':::panel info {}'), omitted) + assert.equal(fault('!adf:rule {}'), omitted) + assert.equal(fault('!adf:panel info {}'), omitted) assert.equal(inline('!adf:underline[a]{}'), omitted) spans('!adf:hardBreak{}', 'hardBreak', undefined) }) -test('names the directive line no spelling reads', () => { - assert.equal(fault('::Panel'), 'a directive name reads [a-z][A-Za-z0-9]*: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::1panel'), 'a directive name reads [a-z][A-Za-z0-9]*: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel info'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel info extra'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel info{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel {a=1} x'), 'a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; \\::: keeps the line literal text') +test('names the opener no spelling reads', () => { + const shape = 'a directive opener reads a name, one bare argument and {attrs}, one space apart: this one does not; \\!adf: keeps the prefix literal' + assert.equal(fault('!adf:panel info'), shape) + assert.equal(fault('!adf:panel info extra'), shape) + assert.equal(fault('!adf:panel "info"'), shape) + assert.equal(fault('!adf:panel info{}'), shape) + assert.equal(fault('!adf:panel {a=1} x'), shape) }) test('names the attributes no spelling reads', () => { - assert.equal(fault('::panel {a=1'), 'the {attrs} closing brace is missing') - assert.equal(fault('::panel {a="x}'), 'the {attrs} quoted value is unclosed') - assert.equal(fault('::panel {a="\\uzzzz"}'), 'the {attrs} quoted value is not a JSON string') - assert.equal(fault('::panel {a}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel {a=}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel {=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel {a=1 b=2}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel { a=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel {a=1 }'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\::: keeps the line literal text') - assert.equal(fault('::panel {a=1 a=2}'), 'the attribute key a is spelled twice') + assert.equal(fault('!adf:panel {a=1'), 'the {attrs} closing brace is missing') + assert.equal(fault('!adf:panel {a="x}'), 'the {attrs} quoted value is unclosed') + assert.equal(fault('!adf:panel {a="\\uzzzz"}'), 'the {attrs} quoted value is not a JSON string') + assert.equal(fault('!adf:panel {a}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal') + assert.equal(fault('!adf:panel {a=}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal') + assert.equal(fault('!adf:panel {=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal') + assert.equal(fault('!adf:panel {a=1 b=2}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal') + assert.equal(fault('!adf:panel { a=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal') + assert.equal(fault('!adf:panel {a=1 }'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal') + assert.equal(fault('!adf:panel {a=1 a=2}'), 'the attribute key a is spelled twice') assert.equal(inline('!adf:mention[@A]{id}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal') }) test('breaks the directive on the raw characters a quoted value spells as escapes', () => { - assert.equal(fault('::panel {a="x`y"}'), 'a raw ` inside {attrs} breaks the directive: spell it \\u0060') - assert.equal(fault('::panel {a="x&y"}'), 'a raw & inside {attrs} breaks the directive: spell it \\u0026') - assert.equal(fault('::panel {a="x { diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index cc418c6..af3a669 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -1,11 +1,10 @@ import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts' import type { ConvertFault } from '../result.ts' import type { JsonValue } from '../json-value.ts' -import { backslashEscape, claimsDirectiveLine } from './commonmark-grammar.ts' +import { backslashEscape } from './commonmark-grammar.ts' import { backtickRun, closingBacktickRun } from './backtick-runs.ts' import { isJsonValue, overNested } from '../json-value.ts' import { largestNesting } from '../nesting.ts' -import { runLength } from './emphasis-matching.ts' import { serializeCanonicalJson } from '../canonical-json.ts' export type AttributeReading = { refusal: 'kind' | 'nesting'; value?: undefined } | { refusal?: undefined; value: VocabularyValue } @@ -15,17 +14,19 @@ export type DirectiveValue = { decoded: string; spelling: string } export type DirectiveAttributes = ReadonlyMap export type DirectiveLine = - | { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string } - | { colons: number; kind: 'closing' } + | { argument: string | undefined; attributes: DirectiveAttributes; kind: 'opener'; name: string } + | { kind: 'closer'; name: string } export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string } export type Read = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T } -type Attributes = { attributes: DirectiveAttributes; length: number } +type Attributes = { attributes: DirectiveAttributes; end: number } type AttributePair = { end: number; key: string; value: DirectiveValue } +type Content = { content: string | undefined; end: number } + export const directivePrefix = '!adf:' const bareTokenSource = '[A-Za-z0-9_-]+' @@ -40,15 +41,14 @@ const quotedEscapes = new RegExp(reservedSource, 'g') const rawReserved = new RegExp(reservedSource) const noAttributes: DirectiveAttributes = new Map() -export const directiveLineEscape = '\\::: keeps the line literal text' -export const inlineDirectiveEscape = `\\${directivePrefix} keeps the prefix literal` +export const directiveEscape = `\\${directivePrefix} keeps the prefix literal` +const closerFault = `a closer carries nothing after its name: this one does; ${directiveEscape}` const emptyFault = 'an empty {attrs} is omitted unless the { itself claims the directive: this one spells {}' -const nameFault = `a directive name reads [a-z][A-Za-z0-9]*: this one does not; ${directiveLineEscape}` +const openerFault = `a directive opener reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveEscape}` const orderFault = 'the {attrs} keys read in alphabetical order' -const pairFault = 'an attribute reads key=value, the value bare or double-quoted: this one does not' -const prefixFault = `an unescaped ${directivePrefix} completes no directive; ${inlineDirectiveEscape}` -const shapeFault = `a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveLineEscape}` +const pairFault = `an attribute reads key=value, the value bare or double-quoted: this one does not; ${directiveEscape}` +const prefixFault = `an unescaped ${directivePrefix} completes no directive; ${directiveEscape}` export function attributeValue(text: string, kind: AttributeKind): AttributeReading { if (kind === 'string') return { value: { kind, value: text } } @@ -76,13 +76,8 @@ export function malformedDirective(message: string): ConvertFault { } export function readDirectiveLine(line: string): Read | undefined { - if (!claimsDirectiveLine(line)) return undefined - const colons = runLength(line, 0) - const rest = line.slice(colons) - if (lineEnd.test(rest)) return { value: { colons, kind: 'closing' } } - const header = readDirectiveHeader(rest) - if (header.fault !== undefined) return { fault: header.fault } - return { value: { argument: header.value.argument, attributes: header.value.attributes, colons, kind: 'header', name: header.value.name } } + if (!claimsDirectivePrefix(line, 0)) return undefined + return line.charAt(directivePrefix.length) === '/' ? readCloserLine(line) : readOpenerLine(line) } export function readInlineDirective(text: string, index: number): Read | undefined { @@ -110,6 +105,14 @@ export function spellAttributes(pairs: readonly (readonly [string, string])[]): return `{${spelled.join(' ')}}` } +export function spellDirectiveCloser(name: string): string { + return `${directivePrefix}/${name}` +} + +export function spellDirectiveOpener(name: string, argument: string | undefined, attributes: string): string { + return `${directivePrefix}${name}${argument === undefined ? '' : ` ${argument}`}${attributes === '' ? '' : ` ${attributes}`}` +} + export function spellJsonAttribute(value: JsonValue): string { return quote(serializeCanonicalJson(value, 'compact')) } @@ -118,7 +121,7 @@ export function spellInlineDirectiveOpener(name: string): string { return `${directivePrefix}${name}[` } -export function spellLeafDirective(name: string, attributes: string): string { +export function spellInlineLeafDirective(name: string, attributes: string): string { return `${directivePrefix}${name}${attributes === '' ? '{}' : attributes}` } @@ -137,18 +140,14 @@ export function spellVocabulary(pairs: readonly VocabularyPair[]): [string, stri return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)]) } -export function unknownDirectiveFault(name: string, escape: string): ConvertFault { - return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node; ${escape}` } +export function unknownDirectiveFault(name: string): ConvertFault { + return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node; ${directiveEscape}` } } export function unsupportedNodeShape(message: string): ConvertFault { return { code: 'unsupported-node-shape', message } } -function attributePairFault(escape: string): ConvertFault { - return malformedDirective(`${pairFault}; ${escape}`) -} - function keyOrder(left: string, right: string): number { if (left < right) return -1 return left > right ? 1 : 0 @@ -168,28 +167,39 @@ function inlineDirectiveName(text: string, index: number): string | undefined { return opened === undefined ? undefined : opened.slice(directivePrefix.length, -1) } -function readDirectiveHeader(rest: string): Read<{ argument: string | undefined; attributes: DirectiveAttributes; name: string }> { - directiveName.lastIndex = 0 - const name = directiveName.exec(rest)?.[0] - if (name === undefined) return { fault: malformedDirective(nameFault) } - let cursor = name.length - let argument: string | undefined - let attributes = noAttributes - if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) !== '{' && !lineEnd.test(rest.slice(cursor))) { - bareRun.lastIndex = cursor + 1 - argument = bareRun.exec(rest)?.[0] - if (argument === undefined) return { fault: malformedDirective(shapeFault) } - cursor += 1 + argument.length - } - if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') { - const read = readAttributes(rest, cursor + 1, directiveLineEscape) - if (read.fault !== undefined) return { fault: read.fault } - if (read.value.attributes.size === 0) return { fault: malformedDirective(emptyFault) } - attributes = read.value.attributes - cursor += 1 + read.value.length - } - if (!lineEnd.test(rest.slice(cursor))) return { fault: malformedDirective(shapeFault) } - return { value: { argument, attributes, name } } +function readCloserLine(line: string): Read | undefined { + const start = directivePrefix.length + 1 + const name = readDirectiveName(line, start) + if (name === undefined) return undefined + return lineEnd.test(line.slice(start + name.length)) ? { value: { kind: 'closer', name } } : { fault: malformedDirective(closerFault) } +} + +function readOpenerLine(line: string): Read | undefined { + const name = readDirectiveName(line, directivePrefix.length) + if (name === undefined) return undefined + const rest = line.slice(directivePrefix.length + name.length) + if (!rest.startsWith(' ') && !lineEnd.test(rest)) return undefined + const opener = readOpenerRest(rest) + if (opener.fault !== undefined) return { fault: opener.fault } + return { value: { argument: opener.value.argument, attributes: opener.value.attributes, kind: 'opener', name } } +} + +function readDirectiveName(text: string, index: number): string | undefined { + directiveName.lastIndex = index + return directiveName.exec(text)?.[0] +} + +function readOpenerRest(rest: string): Read<{ argument: string | undefined; attributes: DirectiveAttributes }> { + bareRun.lastIndex = 1 + const argument = bareRun.exec(rest)?.[0] + const attributes = readOpenerAttributes(rest, argument === undefined ? 0 : 1 + argument.length) + if (attributes.fault !== undefined) return { fault: attributes.fault } + if (!lineEnd.test(rest.slice(attributes.value.end))) return { fault: malformedDirective(openerFault) } + return { value: { argument, attributes: attributes.value.attributes } } +} + +function readOpenerAttributes(rest: string, index: number): Read { + return rest.charAt(index) === ' ' ? readAttributesAt(rest, index + 1, false) : { value: { attributes: noAttributes, end: index } } } function readNestedDirective(text: string, index: number, depth: number): Read | undefined { @@ -199,23 +209,25 @@ function readNestedDirective(text: string, index: number, depth: number): Read largestNesting) { return { fault: { code: 'unsupported-nesting-depth', message: `the input nests inline directives deeper than the ${largestNesting} levels the parser carries` } } } - let cursor = index + directivePrefix.length + name.length - let content: string | undefined - if (text.charAt(cursor) === '[') { - const end = readDirectiveContent(text, cursor + 1, depth) - if (end.fault !== undefined) return { fault: end.fault } - content = text.slice(cursor + 1, end.value) - cursor = end.value + 1 - } - let attributes = noAttributes - if (text.charAt(cursor) === '{') { - const read = readAttributes(text, cursor, inlineDirectiveEscape) - if (read.fault !== undefined) return { fault: read.fault } - if (read.value.attributes.size === 0 && content !== undefined) return { fault: malformedDirective(emptyFault) } - attributes = read.value.attributes - cursor += read.value.length - } - return { value: { attributes, content, length: cursor - index, name } } + const slot = readContentSlot(text, index + directivePrefix.length + name.length, depth) + if (slot.fault !== undefined) return { fault: slot.fault } + const attributes = readAttributesAt(text, slot.value.end, slot.value.content === undefined) + if (attributes.fault !== undefined) return { fault: attributes.fault } + return { value: { attributes: attributes.value.attributes, content: slot.value.content, length: attributes.value.end - index, name } } +} + +function readContentSlot(text: string, index: number, depth: number): Read { + if (text.charAt(index) !== '[') return { value: { content: undefined, end: index } } + const close = readDirectiveContent(text, index + 1, depth) + if (close.fault !== undefined) return { fault: close.fault } + return { value: { content: text.slice(index + 1, close.value), end: close.value + 1 } } +} + +function readAttributesAt(text: string, index: number, braceClaims: boolean): Read { + if (text.charAt(index) !== '{') return { value: { attributes: noAttributes, end: index } } + const read = readAttributes(text, index) + if (read.fault !== undefined || braceClaims || read.value.attributes.size > 0) return read + return { fault: malformedDirective(emptyFault) } } // A code span, an escape and a nested directive each bind before the content's own closing bracket. @@ -234,7 +246,7 @@ function readDirectiveContent(text: string, start: number, depth: number): Read< cursor = span continue } - const nested = character === '!' ? readNestedDirective(text, cursor, depth + 1) : undefined + const nested = readNestedDirective(text, cursor, depth + 1) if (nested !== undefined) { if (nested.fault !== undefined) return { fault: nested.fault } cursor += nested.value.length @@ -245,7 +257,7 @@ function readDirectiveContent(text: string, start: number, depth: number): Read< if (character === ']') brackets -= 1 cursor += 1 } - return { fault: malformedDirective(`an inline directive [content] is unclosed; ${inlineDirectiveEscape}`) } + return { fault: malformedDirective(`an inline directive [content] is unclosed; ${directiveEscape}`) } } // `undefined` where the span crosses the line ending an inline directive may not cross. @@ -256,16 +268,16 @@ function readCodeSpanEnd(text: string, index: number): number | undefined { return text.slice(index, closer + opener).includes('\n') ? undefined : closer + opener } -function readAttributes(text: string, index: number, escape: string): Read { +function readAttributes(text: string, index: number): Read { const attributes = new Map() let cursor = index + 1 let previous = '' while (cursor < text.length && text.charAt(cursor) !== '}') { if (attributes.size > 0) { - if (text.charAt(cursor) !== ' ') return { fault: attributePairFault(escape) } + if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairFault) } cursor += 1 } - const pair = readAttributePair(text, cursor, escape) + const pair = readAttributePair(text, cursor) if (pair.fault !== undefined) return { fault: pair.fault } const key = pair.value.key if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) } @@ -275,13 +287,13 @@ function readAttributes(text: string, index: number, escape: string): Read { +function readAttributePair(text: string, index: number): Read { bareRun.lastIndex = index const key = bareRun.exec(text)?.[0] - if (key === undefined || text.charAt(index + key.length) !== '=') return { fault: attributePairFault(escape) } + if (key === undefined || text.charAt(index + key.length) !== '=') return { fault: malformedDirective(pairFault) } const start = index + key.length + 1 if (text.charAt(start) === '"') { const quoted = readQuotedValue(text, start) @@ -290,7 +302,7 @@ function readAttributePair(text: string, index: number, escape: string): Read { }) test('spells a CommonMark block as a directive where its own spelling holds neither attribute nor mark', () => { - assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'paragraph' }))), '::paragraph {localId=a}\n') - assert.equal(markdown(adfToMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), ':::codeBlock {wrap=true}\n```\n```\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'paragraph' }))), '!adf:paragraph {localId=a}\n!adf:/paragraph\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), '!adf:codeBlock {wrap=true}\n```\n```\n!adf:/codeBlock\n') assert.equal(markdown(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, type: 'hardBreak' }, { text: 'x', type: 'text' })))), '!adf:hardBreak{localId=a}x\n') - assert.equal(markdown(adfToMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), ':::blockquote {marks="[{\\"type\\":\\"border\\"}]"}\n:::\n') - assert.equal(markdown(adfToMarkdown(document({ type: 'listItem' }))), ':::listItem\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), '!adf:blockquote {marks="[{\\"type\\":\\"border\\"}]"}\n!adf:/blockquote\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'listItem' }))), '!adf:listItem\n!adf:/listItem\n') }) test('spells an ordered list from the order attribute its first marker is', () => { const items: AdfNode[] = [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }] - assert.equal(markdown(adfToMarkdown(document({ content: items, type: 'orderedList' }))), '::::orderedList\n:::listItem\nx\n:::\n::::\n') + assert.equal(markdown(adfToMarkdown(document({ content: items, type: 'orderedList' }))), '!adf:orderedList\n!adf:listItem\nx\n!adf:/listItem\n!adf:/orderedList\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 1 }, content: items, type: 'orderedList' }))), '1. x\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: items, type: 'orderedList' }))), '2. x\n') }) test('spells a code block language no info string holds as an attribute', () => { const language = (value: string): string => markdown(adfToMarkdown(document({ attrs: { language: value }, type: 'codeBlock' }))) - assert.equal(language(''), ':::codeBlock {language=""}\n```\n```\n:::\n') - assert.equal(language('a`b'), ':::codeBlock {language="a\\u0060b"}\n```\n```\n:::\n') - assert.equal(language(' sql'), ':::codeBlock {language=" sql"}\n```\n```\n:::\n') - assert.equal(language('adf'), ':::codeBlock {language="\\u0026#97;df"}\n```\n```\n:::\n') - assert.equal(language('foo\\+bar'), ':::codeBlock {language="foo\\\\+bar"}\n```\n```\n:::\n') - assert.equal(language('a\u0000b'), ':::codeBlock {language="a\\u0000b"}\n```\n```\n:::\n') - assert.equal(language('a\tb'), ':::codeBlock {language="a\\tb"}\n```\n```\n:::\n') + assert.equal(language(''), '!adf:codeBlock {language=""}\n```\n```\n!adf:/codeBlock\n') + assert.equal(language('a`b'), '!adf:codeBlock {language="a\\u0060b"}\n```\n```\n!adf:/codeBlock\n') + assert.equal(language(' sql'), '!adf:codeBlock {language=" sql"}\n```\n```\n!adf:/codeBlock\n') + assert.equal(language('adf'), '!adf:codeBlock {language="\\u0026#97;df"}\n```\n```\n!adf:/codeBlock\n') + assert.equal(language('foo\\+bar'), '!adf:codeBlock {language="foo\\\\+bar"}\n```\n```\n!adf:/codeBlock\n') + assert.equal(language('a\u0000b'), '!adf:codeBlock {language="a\\u0000b"}\n```\n```\n!adf:/codeBlock\n') + assert.equal(language('a\tb'), '!adf:codeBlock {language="a\\tb"}\n```\n```\n!adf:/codeBlock\n') }) test('refuses a link destination CommonMark cannot spell', () => { @@ -150,28 +150,29 @@ test('escapes the delimiter row a hard break leaves opening a pipe table with no test('parts two adjacent lists of the same kind, the marker spelling being what merges', () => { const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }], type: 'bulletList' } - assert.equal(markdown(adfToMarkdown(document(list, list))), '- x\n\n::listBreak\n\n- x\n') + assert.equal(markdown(adfToMarkdown(document(list, list))), '- x\n\n!adf:listBreak\n\n- x\n') const ordered: AdfNode = { attrs: { order: 1 }, content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }], type: 'orderedList' } - assert.equal(markdown(adfToMarkdown(document(ordered, ordered))), '1. x\n\n::listBreak\n\n1. x\n') - assert.equal(markdown(adfToMarkdown(document({ attrs: { panelType: 'info' }, content: [list, list], type: 'panel' }))), ':::panel info\n- x\n::listBreak\n- x\n:::\n') + assert.equal(markdown(adfToMarkdown(document(ordered, ordered))), '1. x\n\n!adf:listBreak\n\n1. x\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { panelType: 'info' }, content: [list, list], type: 'panel' }))), '!adf:panel info\n- x\n!adf:listBreak\n- x\n!adf:/panel\n') const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' } - assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n ::listBreak\n\n - x\n') + assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n') const carried: AdfNode = { ...list, attrs: { unknown: 'x' } } - assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```adf\n')) + assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```carry\n')) assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n')) - assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```adf\n')) + assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```carry\n')) }) test('carries a node type no section spells', () => { - assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf\n{\n "type": "blockCard"\n}\n```\n') - assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf\n{\n "type": "toString"\n}\n```\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```carry\n{\n "type": "blockCard"\n}\n```\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```carry\n{\n "type": "toString"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n') - assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf\n{\n "text": "x",\n "type": "text"\n}\n```\n') - assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf\n{\n "type": "hardBreak"\n}\n```\n') + assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```carry\n{\n "text": "x",\n "type": "text"\n}\n```\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\n}\n```\n') }) test('spells the code block whose language is the reserved info string', () => { - assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), ':::codeBlock {language=adf}\n```\n```\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '!adf:codeBlock {language=carry}\n```\n```\n!adf:/codeBlock\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n') }) test('breaks a mark run at the node it carries', () => { @@ -208,18 +209,18 @@ test('refuses a node whose content model the canonical form cannot emit', () => }) test('spells a list its own content shape cannot hold as a directive', () => { - assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), ':::bulletList\n::paragraph\n:::\n') - assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), ':::bulletList\n:::\n') - assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), ':::orderedList {order=2}\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {order=2}\n!adf:/orderedList\n') }) test('spells an ordered list no marker fits as a directive', () => { const item: AdfNode = { content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' } const list = (order: number, items: number): AdfDocument => document({ attrs: { order }, content: Array.from({ length: items }, () => item), type: 'orderedList' }) - assert.equal(markdown(adfToMarkdown(list(1.5, 1))), '::::orderedList {order="1.5"}\n:::listItem\nx\n:::\n::::\n') + assert.equal(markdown(adfToMarkdown(list(1.5, 1))), '!adf:orderedList {order="1.5"}\n!adf:listItem\nx\n!adf:/listItem\n!adf:/orderedList\n') assert.equal(markdown(adfToMarkdown(list(999999999, 1))), '999999999. x\n') - assert.equal(markdown(adfToMarkdown(list(999999999, 2))), '::::orderedList {order=999999999}\n:::listItem\nx\n:::\n:::listItem\nx\n:::\n::::\n') + assert.equal(markdown(adfToMarkdown(list(999999999, 2))), '!adf:orderedList {order=999999999}\n!adf:listItem\nx\n!adf:/listItem\n!adf:listItem\nx\n!adf:/listItem\n!adf:/orderedList\n') }) test('carries a code mark over anything but text', () => { @@ -230,8 +231,8 @@ test('carries a code mark over anything but text', () => { }) test('spells a heading level no ATX heading fits as a directive', () => { - assert.equal(markdown(adfToMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), ':::heading {level=7}\nx\n:::\n') - assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'heading' }))), ':::heading\nx\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), '!adf:heading {level=7}\nx\n!adf:/heading\n') + assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'heading' }))), '!adf:heading\nx\n!adf:/heading\n') }) test('escapes only text that would otherwise open a construct', () => { @@ -260,7 +261,9 @@ test('escapes only text that would otherwise open a construct', () => { assert.equal(emitted('!adf:mention[@x]{id=1}'), '\\!adf:mention[@x]{id=1}\n') assert.equal(emitted('!adf: completes nothing'), '\\!adf: completes nothing\n') assert.equal(emitted('!adfx and a! alone'), '!adfx and a! alone\n') - assert.equal(emitted(':::panel info'), '\\:::panel info\n') + assert.equal(emitted('!adf:/panel'), '\\!adf:/panel\n') + assert.equal(emitted('!adf:rule'), '\\!adf:rule\n') + assert.equal(emitted(':::panel info'), ':::panel info\n') assert.equal(emitted('10:30 tomorrow'), '10:30 tomorrow\n') assert.equal(emitted('[a](b)'), '\\[a](b)\n') assert.equal(emitted('**bold**'), '\\*\\*bold\\*\\*\n') @@ -330,8 +333,8 @@ test('parts a nested list the tight spelling would swallow from the block above assert.equal(markdown(adfToMarkdown(outer(text('a'), { content: [item(text('b'))], type: 'bulletList' }))), '- a\n - b\n') const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' } const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' } - assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- :::panel info\n p\n :::\n\n - b\n') - assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```adf\n')) + assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n') + assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```carry\n')) }) test('refuses marks and attributes nested deeper than the emitter carries', () => { @@ -422,10 +425,10 @@ test('escapes a hyphen underline a hard break would expose', () => { test('spells a list item whose marker completes a thematic break as a directive', () => { const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) - assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '::::bulletList\n:::listItem\n---\n:::\n::::\n') + assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n') const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' } assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n') - assert.equal(markdown(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), '::::bulletList\n:::listItem\n- -\n:::\n::::\n') + assert.equal(markdown(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n- -\n!adf:/listItem\n!adf:/bulletList\n') }) test('refuses the characters CommonMark rewrites', () => { @@ -494,29 +497,29 @@ test('emits an empty list item without trailing whitespace', () => { test('spells a block directive as its node type, arg and attributes', () => { const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' }) - assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), ':::panel warning\nx\n:::\n') - assert.equal(markdown(adfToMarkdown(panel({}))), ':::panel\nx\n:::\n') - assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), ':::caption\nx\n:::\n') - assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '::caption\n') - assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n') + assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n') + assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel\nx\n!adf:/panel\n') + assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n') }) test('carries a directive attribute no section spells', () => { const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node))) - assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n') - assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n') - assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n') + assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```carry\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n') + assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```carry\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n') + assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```carry\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n') assert.equal( carried({ attrs: { width: '50' }, type: 'layoutColumn' }), - '```adf\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n', + '```carry\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n', ) assert.equal( carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }), - '```adf\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n', + '```carry\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n', ) assert.equal( carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }), - ':::mediaGroup\n```adf\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n:::\n', + '!adf:mediaGroup\n```carry\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n!adf:/mediaGroup\n', ) }) @@ -524,15 +527,15 @@ test('carries an arg slot value no bare token spells', () => { const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node))) assert.equal( carried({ attrs: { panelType: 'extra info' }, type: 'panel' }), - '```adf\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n', + '```carry\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n', ) - assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n') + assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```carry\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n') }) test('carries a block node mark in the reserved attribute', () => { const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' }) - assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), ':::layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n:::\n') - assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), ':::layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n:::\n') + assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') + assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') }) test('refuses the content a directive body has no room for', () => { @@ -540,15 +543,15 @@ test('refuses the content a directive body has no room for', () => { assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape: a panel carries no text: this one holds text') }) -test('separates blocks in a container body by a blank line only where the fence is not separation already', () => { +test('separates blocks in a container body by a blank line only where a directive line is not separation already', () => { const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' }) const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' }) - assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), ':::panel info\na\n\nb\n:::\n') + assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), '!adf:panel info\na\n\nb\n!adf:/panel\n') const caption: AdfNode = { content: [{ text: 'c', type: 'text' }], type: 'caption' } - assert.equal(markdown(adfToMarkdown(panel(caption, caption))), '::::panel info\n:::caption\nc\n:::\n:::caption\nc\n:::\n::::\n') - assert.equal(markdown(adfToMarkdown(panel(text('a'), caption))), '::::panel info\na\n:::caption\nc\n:::\n::::\n') - assert.equal(markdown(adfToMarkdown(panel(caption, text('a')))), '::::panel info\n:::caption\nc\n:::\na\n::::\n') - assert.equal(markdown(adfToMarkdown(panel(paragraph(), text('a')))), ':::panel info\n::paragraph\na\n:::\n') + assert.equal(markdown(adfToMarkdown(panel(caption, caption))), '!adf:panel info\n!adf:caption\nc\n!adf:/caption\n!adf:caption\nc\n!adf:/caption\n!adf:/panel\n') + assert.equal(markdown(adfToMarkdown(panel(text('a'), caption))), '!adf:panel info\na\n!adf:caption\nc\n!adf:/caption\n!adf:/panel\n') + assert.equal(markdown(adfToMarkdown(panel(caption, text('a')))), '!adf:panel info\n!adf:caption\nc\n!adf:/caption\na\n!adf:/panel\n') + assert.equal(markdown(adfToMarkdown(panel(paragraph(), text('a')))), '!adf:panel info\n!adf:paragraph\n!adf:/paragraph\na\n!adf:/panel\n') }) test('spells the image form for exactly the centered external media shape', () => { @@ -558,7 +561,7 @@ test('spells the image form for exactly the centered external media shape', () = assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`) - const fallback = (attrs: AdfAttributes): boolean => markdown(adfToMarkdown(single(attrs))).startsWith(':::mediaSingle {layout=center}') + const fallback = (attrs: AdfAttributes): boolean => markdown(adfToMarkdown(single(attrs))).startsWith('!adf:mediaSingle {layout=center}') assert.ok(fallback({ alt: '', type: 'external', url })) assert.ok(fallback({ alt: 'a\nb', type: 'external', url })) assert.ok(fallback({ alt: 'a\u0000b', type: 'external', url })) @@ -577,7 +580,7 @@ test('spells a mediaSingle the image form does not fit as a directive', () => { const single: AdfNode = { attrs: { layout: 'center' }, content: [media], marks: [{ type: 'border' }], type: 'mediaSingle' } assert.equal( markdown(adfToMarkdown(document(single))), - ':::mediaSingle {layout=center marks="[{\\"type\\":\\"border\\"}]"}\n::media {type=external url="https://example.com/moon.png"}\n:::\n', + '!adf:mediaSingle {layout=center marks="[{\\"type\\":\\"border\\"}]"}\n!adf:media {type=external url="https://example.com/moon.png"}\n!adf:/mediaSingle\n', ) }) @@ -586,7 +589,7 @@ test('spells a table as a pipe table only where every row and cell is plain', () const cell = (type: string, ...content: AdfNode[]): AdfNode => ({ content, type }) const row = (...cells: AdfNode[]): AdfNode => ({ content: cells, type: 'tableRow' }) const table = (...rows: AdfNode[]): AdfDocument => document({ content: rows, type: 'table' }) - const directive = (result: Result): boolean => markdown(result).startsWith(':') + const directive = (result: Result): boolean => markdown(result).startsWith('!adf:table') const header = row(cell('tableHeader', text('Part'))) assert.equal(markdown(adfToMarkdown(table(header, row(cell('tableCell', text('Bolt M8')))))), '| Part |\n| --- |\n| Bolt M8 |\n') assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', text('a|b')))))), '| a\\|b |\n| --- |\n') diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index dfd8827..3fcabfa 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { BlockDirective } from '../../adf/block-directives.ts' import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { blockDirective } from '../../adf/block-directives.ts' +import { blockDirectiveForm } from '../block-directive-forms.ts' import { carriedBlock } from '../opaque-carry.ts' import { emitInlineLine } from './inline-line.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -10,14 +11,14 @@ import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParag import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { listBreakSpelling } from '../list-break.ts' -import { spellDirectiveHeader } from './block-directive-spelling.ts' +import { spellBlockDirectiveOpener } from './block-directive-spelling.ts' +import { spellDirectiveCloser } from '../directive-syntax.ts' import { tryImage } from './image.ts' import { tryPipeTable } from './pipe-table.ts' type BlockContainer = 'directive' | 'document' | 'list-item' type BlockSpelling = 'commonmark' | 'directive' | 'list' -type EmittedBody = { fenceColons: number; text: string } -type EmittedBlock = EmittedBody & { spelling: BlockSpelling } +type EmittedBlock = { spelling: BlockSpelling; text: string } type PlacedBlock = EmittedBlock & { node: AdfNode } const largestListMarker = 999999999 @@ -28,10 +29,10 @@ export function adfToMarkdown(document: AdfDocument): Result { if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) const blocks = emitBlocks(nodeContent(document), 'document', [], 0) if (!blocks.ok) return blocks - return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`) + return success(blocks.value === '' ? '' : `${blocks.value}\n`) } -function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: ConvertErrorPath, depth: number): Result { +function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: ConvertErrorPath, depth: number): Result { if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path) const blocks: PlacedBlock[] = [] for (const [index, node] of nodes.entries()) { @@ -39,15 +40,13 @@ function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: if (!block.ok) return block blocks.push({ ...block.value, node }) } - let fenceColons = 0 let text = '' for (const [index, block] of blocks.entries()) { const previous = blocks[index - 1] if (previous !== undefined) text += separationBetween(previous, block, container) - fenceColons = Math.max(fenceColons, block.fenceColons) text += block.text } - return success({ fenceColons, text }) + return success(text) } function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string { @@ -105,42 +104,39 @@ function commonMarkLine(text: Result): Result { } function commonMarkText(text: string): EmittedBlock { - return { fenceColons: 0, spelling: 'commonmark', text } + return { spelling: 'commonmark', text } +} + +function directivePair(node: AdfNode, opener: string, body: string): EmittedBlock { + return { spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` } } function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path) - const content = nodeContent(node) - if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path) + if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path) if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth) - const header = spellDirectiveHeader(node, directive) - if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) - if (directive.contentModel === 'none' || (directive.contentModel === 'inline' && content.length === 0)) { - return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` }) - } - const body = directive.contentModel === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1) - if (!body.ok) return body - const fenceColons = Math.max(3, body.value.fenceColons + 1) - const fence = ':'.repeat(fenceColons) - const lines = body.value.text === '' ? '' : `${body.value.text}\n` - return success({ fenceColons, spelling: 'directive', text: `${fence}${header}\n${lines}${fence}` }) + const opener = spellBlockDirectiveOpener(node, directive) + if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth)) + return emitDirectiveBody(node, directive, opener, path, depth) } -function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result { - const line = emitInlineLine(content, 'paragraph', path) - if (!line.ok) return line - return success({ fenceColons: 0, text: line.value }) +function emitDirectiveBody(node: AdfNode, directive: BlockDirective, opener: string, path: ConvertErrorPath, depth: number): Result { + if (blockDirectiveForm(node.type) === 'leaf') return success({ spelling: 'directive', text: opener }) + const content = nodeContent(node) + const body = directive.contentModel === 'inline' ? emitInlineLine(content, 'paragraph', path) : emitBlocks(content, 'directive', path, depth + 1) + if (!body.ok) return body + return success(directivePair(node, opener, body.value)) } function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result | undefined { if (!carriesOnly(node, [])) return undefined const inner = emitBlocks(nodeContent(node), 'document', path, depth + 1) if (!inner.ok) return inner - const text = inner.value.text + const text = inner.value .split('\n') .map((line) => (line === '' ? '>' : `> ${line}`)) .join('\n') - return success({ fenceColons: inner.value.fenceColons, spelling: 'commonmark', text }) + return success(commonMarkText(text)) } function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result | undefined { @@ -154,12 +150,11 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result { const slot = languageSlot(nodeAttrs(node)['language']) - const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language']) - if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) + const opener = spellBlockDirectiveOpener(node, directive, slot.kind === 'attribute' ? [] : ['language']) + if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth)) const text = codeBlockText(node, path) if (!text.ok) return text - const info = slot.kind === 'fence' ? slot.info : '' - return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info, text.value)}\n:::` }) + return success(directivePair(node, opener, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value))) } function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result { @@ -203,15 +198,13 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result< if (start === undefined || items.length === 0) return undefined if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined const lines: string[] = [] - let fenceColons = 0 for (const [offset, item] of items.entries()) { const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', [...path, 'content', offset], depth) if (emitted === undefined) return undefined if (!emitted.ok) return emitted - fenceColons = Math.max(fenceColons, emitted.value.fenceColons) - lines.push(emitted.value.text) + lines.push(emitted.value) } - return success({ fenceColons, spelling: 'list', text: lines.join('\n') }) + return success({ spelling: 'list', text: lines.join('\n') }) } function listStart(node: AdfNode, items: number): number | undefined { @@ -221,16 +214,16 @@ function listStart(node: AdfNode, items: number): number | undefined { return start + items - 1 > largestListMarker ? undefined : start } -function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result | undefined { +function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result | undefined { const inner = emitBlocks(nodeContent(item), 'list-item', path, depth + 1) if (!inner.ok) return inner - if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) - const body = inner.value.text.split('\n') + if (inner.value === '') return success(marker.trimEnd()) + const body = inner.value.split('\n') if (body.some((line) => line !== '' && isBlankLine(line))) return undefined const indent = ' '.repeat(marker.length) const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) if (isThematicBreak(lines[0] ?? '')) return undefined - return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') }) + return success(lines.join('\n')) } function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result | undefined { diff --git a/src/markdown/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index 9de3071..dbead55 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -1,28 +1,27 @@ import type { AdfNode } from '../../adf/document.ts' import type { BlockDirective } from '../../adf/block-directives.ts' import { blockArgument } from '../block-directive-arguments.ts' -import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' +import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { markValues, marksAttribute } from '../block-directive-marks.ts' import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' -export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { +export function spellBlockDirectiveOpener(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { const argumentAttribute = blockArgument(node.type) - const argument = spellArgument(node, argumentAttribute) - if (argument === undefined) return undefined + const slot = bareArgument(node, argumentAttribute) + if (slot === undefined) return undefined const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled) if (pairs === undefined) return undefined const spelledPairs = spellVocabulary(pairs) const marks = nodeMarks(node) if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))]) - const attributes = spellAttributes(spelledPairs) - return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` + return spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs)) } -function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined { +// `undefined` where the argument slot holds a value no bare token spells. +function bareArgument(node: AdfNode, argumentAttribute: string | undefined): { argument: string | undefined } | undefined { const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute] - if (value === undefined) return '' - if (typeof value !== 'string' || !isBareToken(value)) return undefined - return ` ${value}` + if (value === undefined) return { argument: undefined } + return typeof value === 'string' && isBareToken(value) ? { argument: value } : undefined } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 1bd0582..6bac40a 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -5,7 +5,7 @@ import { carriedInline } from '../opaque-carry.ts' import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' -import { holdsDirectivePrefix, slotLineEndingFault, spellInlineDirectiveOpener, spellLeafDirective } from '../directive-syntax.ts' +import { holdsDirectivePrefix, slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts' import { holdsEntityReference } from '../entity-references.ts' import { inlineDirective } from '../../adf/inline-directives.ts' import { largestNesting } from '../../nesting.ts' @@ -199,7 +199,7 @@ function emitHardBreak(node: AdfNode, directive: InlineDirective, context: Inlin const attributes = spellInlineNodeAttributes(node, directive) if (attributes === undefined) return success({ carry: { first: index, last: index } }) if (attributes === '' && context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] }) - return success({ segments: [syntax(spellLeafDirective('hardBreak', attributes))] }) + return success({ segments: [syntax(spellInlineLeafDirective('hardBreak', attributes))] }) } function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: number, path: ConvertErrorPath): Result { @@ -208,7 +208,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n const attributes = spellInlineNodeAttributes(node, directive) if (attributes === undefined) return success({ carry: { first: index, last: index } }) const slot = directive.textAttribute === undefined ? undefined : nodeAttrs(node)[directive.textAttribute] - if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] }) + if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] }) if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) const spans = slotLineEndingFault(node.type, slot) if (spans !== undefined) return faulted(spans, path) diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 7e6865e..7066730 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -98,7 +98,7 @@ function escapedIndexes(scan: string, escapings: readonly InlineEscaping[], cont mergesWithSyntax(scan, escapings, index) || opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) || (escaping === 'bracketed-link-target' && - ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || (scan.charAt(index) === '!' && claimsDirectivePrefix(scan, index)))) + ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || claimsDirectivePrefix(scan, index))) ) { escaped.add(index) } diff --git a/src/markdown/list-break.ts b/src/markdown/list-break.ts index 132b818..eba28c8 100644 --- a/src/markdown/list-break.ts +++ b/src/markdown/list-break.ts @@ -1,3 +1,5 @@ +import { spellDirectiveOpener } from './directive-syntax.ts' + export const listBreakName = 'listBreak' -export const listBreakSpelling = `::${listBreakName}` +export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '') diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 9fa2954..f637bd9 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -6,10 +6,9 @@ import { isAdfNode } from '../adf/document.ts' import { isJsonValue, overNested } from '../json-value.ts' import { fencedCodeBlock } from './backtick-runs.ts' import { largestNesting } from '../nesting.ts' -import { malformedDirective, readSoleStringAttribute, spellAttributes, spellLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' +import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { serializeCanonicalJson } from '../canonical-json.ts' -export const carryFence = 'adf' export const carryName = 'carry' const jsonAttribute = 'json' @@ -17,13 +16,13 @@ const jsonAttribute = 'json' export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result { const json = carriedJson(node, 'two-space', path, largestNesting - depth) if (!json.ok) return json - return success(fencedCodeBlock(carryFence, json.value)) + return success(fencedCodeBlock(carryName, json.value)) } export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result { const json = carriedJson(node, 'compact', path, largestNesting) if (!json.ok) return json - return success(spellLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value)]]))) + return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value)]]))) } export function readCarriedBlock(body: string, depth: number): Read { diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts index cd8ada1..f4061be 100644 --- a/src/markdown/parse/blocks.test.ts +++ b/src/markdown/parse/blocks.test.ts @@ -69,44 +69,52 @@ test('leaves the paragraph a line no definition spells', () => { test('swallows an HTML block to the end condition its start sets', () => { assert.deepEqual(kinds('
\nx\n\nPart.\n'), ['html', 'paragraph']) - assert.deepEqual(kinds('\nPart.\n'), ['html', 'paragraph']) + assert.deepEqual(kinds('\nPart.\n'), ['html', 'paragraph']) assert.deepEqual(kinds('
x
\nPart.\n'), ['html', 'paragraph']) assert.deepEqual(kinds('
\nx\n'), ['html']) assert.deepEqual(kinds('Part.\n
\n'), ['paragraph', 'html']) }) test('carries a claimed line as the block it opens, the refusal the node layer builds', () => { - assert.deepEqual(kinds(':::\nPart.\n'), ['fault', 'paragraph']) + assert.deepEqual(kinds('!adf:/panel\nPart.\n'), ['fault', 'paragraph']) assert.deepEqual(kinds('Part.\n| x |\n'), ['paragraph', 'fault']) }) -test('holds a directive container open until the fence that closes it', () => { - assert.deepEqual(kinds(':::panel info\nPart.\n:::\nMore.\n'), ['directive', 'paragraph']) - assert.deepEqual(kinds('::rule\nPart.\n'), ['directive', 'paragraph']) - assert.deepEqual(faults(':::panel info\n\nPart.\n\n:::\n'), []) - assert.deepEqual(faults(':::panel info\n> Part.\n> :::\n'), []) - assert.deepEqual(faults('::::panel info\n- :::expand\n Part.\n :::\n::::\n'), []) - assert.deepEqual(faults(':::panel info\n```\n:::\n```\n:::\n'), []) - assert.deepEqual(parseBlocks(':::panel info {panelColor="#ff0000"}\nPart.\n:::\n').blocks, [ +test('opens a container where the content model takes content, and holds it open until the closer naming it', () => { + assert.deepEqual(kinds('!adf:panel info\nPart.\n!adf:/panel\nMore.\n'), ['directive', 'paragraph']) + assert.deepEqual(kinds('!adf:rule\nPart.\n'), ['directive', 'paragraph']) + assert.deepEqual(kinds('!adf:listBreak\nPart.\n'), ['directive', 'paragraph']) + assert.deepEqual(kinds('!adf:widget\nPart.\n'), ['directive', 'paragraph']) + assert.deepEqual(kinds('!adf:mention\nPart.\n'), ['directive', 'paragraph']) + assert.deepEqual(faults('!adf:panel info\n\nPart.\n\n!adf:/panel\n'), []) + assert.deepEqual(faults('!adf:panel info\n> Part.\n!adf:/panel\n'), []) + assert.deepEqual(faults('!adf:panel info\n- !adf:panel warning\n Part.\n !adf:/panel\n!adf:/panel\n'), []) + assert.deepEqual(faults('!adf:panel info\n```\n!adf:/panel\n```\n!adf:/panel\n'), []) + assert.deepEqual(parseBlocks('!adf:panel info {panelColor="#ff0000"}\nPart.\n!adf:/panel\n').blocks, [ { argument: 'info', attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]), - blocks: [{ kind: 'paragraph', position: { line: 2, offset: 37 }, text: 'Part.' }], + blocks: [{ kind: 'paragraph', position: { line: 2, offset: 39 }, text: 'Part.' }], kind: 'directive', name: 'panel', position: { line: 1, offset: 0 }, }, ]) - assert.deepEqual(parseBlocks('::rule\n').blocks, [ + assert.deepEqual(parseBlocks('!adf:rule\n').blocks, [ { argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule', position: { line: 1, offset: 0 } }, ]) }) -test('names the directive fence a container does not sit longer than', () => { - assert.deepEqual(faults(':::panel info\n:::expand\nPart.\n:::\n'), ["a directive fence line is at least as long as the container's 3 colons"]) - assert.deepEqual(faults('::::panel info\n:::\n::::\n'), ['a closing fence is shorter than the 4 colons it would close']) - assert.deepEqual(faults('::::panel info\n:::expand\nPart.\n:::::\n::::\n'), []) - assert.deepEqual(faults(':::panel info\nPart.\n'), ['a container fenced with 3 colons is unclosed']) - assert.deepEqual(faults('- :::panel info\n\nPart.\n'), ['a container fenced with 3 colons is unclosed']) - assert.deepEqual(faults('Part.\n\n:::\n'), ['a closing fence closes no open container; \\::: keeps the line literal text']) +test('closes the containers a closer names past as unclosed, and crosses no list item or blockquote edge', () => { + const unclosed = (name: string): string => `the ${name} container is unclosed: no !adf:/${name} follows inside the block holding it; \\!adf: keeps the prefix literal` + const unopened = (name: string): string => `the closer !adf:/${name} closes no ${name} container open where it stands; \\!adf: keeps the prefix literal` + assert.deepEqual(faults('!adf:panel info\n!adf:expand\nPart.\n!adf:/expand\n!adf:/panel\n'), []) + assert.deepEqual(faults('!adf:panel info\n!adf:expand\n!adf:layoutSection\nPart.\n!adf:/panel\n'), [unclosed('expand')]) + assert.deepEqual(faults('!adf:panel info\nPart.\n'), [unclosed('panel')]) + assert.deepEqual(faults('- !adf:panel info\n\nPart.\n'), [unclosed('panel')]) + assert.deepEqual(faults('> !adf:panel info\n> Part.\n!adf:/panel\n'), [unclosed('panel'), unopened('panel')]) + assert.deepEqual(faults('!adf:panel info\n> !adf:/panel\n!adf:/panel\n'), [unopened('panel')]) + assert.deepEqual(faults('Part.\n\n!adf:/panel\n'), [unopened('panel')]) + assert.deepEqual(kinds('!adf:rule {localId=a-1}\nPart.\n!adf:/rule\n'), ['fault', 'paragraph']) + assert.deepEqual(kinds('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n'), ['fault', 'fault']) }) diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index db40412..4983ae1 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -3,7 +3,6 @@ import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts' import type { LinkDefinition } from '../link-syntax.ts' import { atxHeading, - claimsDirectiveLine, claimsPipeLine, closingCodeFence, decodeTextEscapes, @@ -16,7 +15,8 @@ import { replaceNullCharacters, setextHeadingLevel, } from '../commonmark-grammar.ts' -import { directiveLineEscape, malformedDirective, readDirectiveLine } from '../directive-syntax.ts' +import { blockDirectiveForm } from '../block-directive-forms.ts' +import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts' import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts' import { readLinkDefinitions } from '../link-reference-definitions.ts' @@ -40,12 +40,11 @@ export type DirectiveBlock = Extract type ListBlock = Extract -type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'directive'; parent: Block[]; position: SourcePosition } +type OpenDirective = { blocks: Block[]; depths: number[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition } -type OpenContainer = - | Extract - | OpenDirective - | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock } +type EdgeContainer = Extract | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock } + +type OpenContainer = EdgeContainer | OpenDirective type OpenLeaf = { position: SourcePosition } & ( | { closer: RegExp | undefined; construct: string; kind: 'html' } @@ -60,15 +59,32 @@ type ContainerStart = { kind: 'blockquote'; rest: Line } | { fresh: boolean; ind // The line from an absolute column on: a tab a cut splits keeps the stop it is measured against. type Line = { column: number; text: string } -type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePosition; stack: OpenContainer[] } +type LeafOpener = { index: number; position: SourcePosition } + +type Walk = ParsedBlocks & { + directiveDepths: Map + edges: { container: EdgeContainer; depth: number }[] + leaf: OpenLeaf | undefined + leafOpeners: Map> + position: SourcePosition + stack: OpenContainer[] +} const indentedCodeColumns = 4 const largestOpenerIndentation = 3 -const leafColons = 2 const tabStop = 4 export function parseBlocks(markdown: string): ParsedBlocks { - const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, position: { line: 1, offset: 0 }, stack: [] } + const walk: Walk = { + blocks: [], + definitions: new Map(), + directiveDepths: new Map(), + edges: [], + leaf: undefined, + leafOpeners: new Map(), + position: { line: 1, offset: 0 }, + stack: [], + } for (const line of sourceLines(markdown)) { walk.position = line.position readLine(walk, { column: 0, text: line.text }) @@ -100,22 +116,19 @@ function swallowsLines(leaf: OpenLeaf | undefined): boolean { return leaf?.kind === 'fenced-code' || leaf?.kind === 'html' } +// A directive container has no continuation marker, so every line continues it. function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } { - let depth = 0 let rest = line - for (const container of walk.stack) { + for (const { container, depth } of walk.edges) { const next = continuesContainer(walk, container, rest) - if (next === undefined) break - depth += 1 + if (next === undefined) return { depth, rest } rest = next } - return { depth, rest } + return { depth: walk.stack.length, rest } } -function continuesContainer(walk: Walk, container: OpenContainer, line: Line): Line | undefined { +function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined { if (container.kind === 'blockquote') return blockquoteRest(removeColumns(line, largestOpenerIndentation)) - // A directive container has no continuation marker: only its own fence closes it. - if (container.kind === 'directive') return line // A list item begins with at most one blank line: an empty one gives the second up. if (isBlankLine(line.text)) { return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' } @@ -177,14 +190,18 @@ function openList(start: number | undefined, position: SourcePosition): ListBloc function openContainer(walk: Walk, start: ContainerStart): void { const blocks: Block[] = [] if (start.kind === 'blockquote') { - const blockquote: OpenContainer = { blocks, kind: 'blockquote', position: walk.position } + const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position } currentBlocks(walk).push(blockquote) - walk.stack.push(blockquote) + pushEdge(walk, blockquote) return } const list = openedList(walk, start) list.items.push(blocks) - walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list }) + pushEdge(walk, { blocks, indentation: start.indentation, kind: 'item', list }) +} + +function pushEdge(walk: Walk, container: EdgeContainer): void { + walk.edges.push({ container, depth: walk.stack.push(container) - 1 }) } // Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md). @@ -199,67 +216,90 @@ function openedList(walk: Walk, start: Extract function closeContainers(walk: Walk, depth: number): void { closeLeaf(walk) - for (const container of walk.stack.slice(depth)) { + for (const container of dropContainers(walk, depth)) { if (container.kind !== 'directive') continue container.parent[container.index] = { - fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`), + fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`), kind: 'fault', position: container.position, } } - dropContainers(walk, depth) } -function dropContainers(walk: Walk, depth: number): void { - walk.stack.length = depth -} - -function openDirective(walk: Walk, directive: Extract): void { - const block: DirectiveBlock = { - argument: directive.argument, - attributes: directive.attributes, - blocks: directive.colons > leafColons ? [] : undefined, - kind: 'directive', - name: directive.name, - position: walk.position, +function dropContainers(walk: Walk, depth: number): OpenContainer[] { + const dropped = walk.stack.splice(depth) + for (const container of dropped) { + if (container.kind === 'directive') container.depths.pop() + else walk.edges.pop() } - const parent = currentBlocks(walk) - parent.push(block) - const { position } = block - if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, colons: directive.colons, index: parent.length - 1, kind: 'directive', parent, position }) + return dropped } function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void { - const enclosing = innermostDirective(walk) - if (directive.kind === 'closing') { - closeDirective(walk, directive.colons, enclosing) - return - } - if (enclosing !== undefined && directive.colons >= enclosing.container.colons) { - pushFault(walk, malformedDirective(`a directive fence line is at least as long as the container's ${enclosing.container.colons} colons`)) - return - } - openDirective(walk, directive) + if (directive.kind === 'closer') closeDirective(walk, directive.name) + else openDirective(walk, directive) } -function closeDirective(walk: Walk, colons: number, enclosing: { container: OpenDirective; depth: number } | undefined): void { - if (enclosing === undefined) { - pushFault(walk, malformedDirective(`a closing fence closes no open container; ${directiveLineEscape}`)) +function openDirective(walk: Walk, directive: Extract): void { + const { name } = directive + const block: DirectiveBlock = { + argument: directive.argument, + attributes: directive.attributes, + blocks: blockDirectiveForm(name) === 'container' ? [] : undefined, + kind: 'directive', + name, + position: walk.position, + } + const parent = currentBlocks(walk) + const index = parent.push(block) - 1 + const { position } = block + if (block.blocks === undefined) { + entryOf(walk.leafOpeners, parent, () => new Map()).set(name, { index, position }) return } - if (colons < enclosing.container.colons) { - pushFault(walk, malformedDirective(`a closing fence is shorter than the ${enclosing.container.colons} colons it would close`)) - return - } - dropContainers(walk, enclosing.depth) + const depths = entryOf(walk.directiveDepths, name, (): number[] => []) + depths.push(walk.stack.push({ blocks: block.blocks, depths, index, kind: 'directive', name, parent, position }) - 1) } -function innermostDirective(walk: Walk): { container: OpenDirective; depth: number } | undefined { - for (let depth = walk.stack.length - 1; depth >= 0; depth -= 1) { - const container = walk.stack[depth] - if (container?.kind === 'directive') return { container, depth } +function closeDirective(walk: Walk, name: string): void { + const closer = spellDirectiveCloser(name) + if (blockDirectiveForm(name) === 'leaf') { + faultLeafOpener(walk, name, malformedDirective(`${name} takes no body, so no ${closer} closes it; ${directiveEscape}`)) + return } - return undefined + const depth = openDirectiveDepth(walk, name) + if (depth === undefined) { + pushFault(walk, malformedDirective(`the closer ${closer} closes no ${name} container open where it stands; ${directiveEscape}`)) + return + } + closeContainers(walk, depth + 1) + dropContainers(walk, depth) +} + +// A closer crosses no list item or blockquote edge. +function openDirectiveDepth(walk: Walk, name: string): number | undefined { + const depth = walk.directiveDepths.get(name)?.at(-1) + return depth === undefined || depth < (walk.edges.at(-1)?.depth ?? -1) ? undefined : depth +} + +function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void { + const blocks = currentBlocks(walk) + const openers = walk.leafOpeners.get(blocks) + const opener = openers?.get(name) + if (openers === undefined || opener === undefined) { + pushFault(walk, fault) + return + } + openers.delete(name) + blocks[opener.index] = { fault, kind: 'fault', position: opener.position } +} + +function entryOf(map: Map, key: K, create: () => V): V { + const known = map.get(key) + if (known !== undefined) return known + const created = create() + map.set(key, created) + return created } function pushFault(walk: Walk, fault: ConvertFault): void { @@ -271,7 +311,7 @@ function continuesLazily(walk: Walk, line: Line): boolean { if (walk.leaf?.kind !== 'paragraph' || isBlankLine(line.text)) return false if (leadingColumns(line) >= indentedCodeColumns) return true const opener = removeColumns(line, largestOpenerIndentation).text - if (claimsDirectiveLine(opener) || claimsPipeLine(opener) || isThematicBreak(opener)) return false + if (readDirectiveLine(opener) !== undefined || claimsPipeLine(opener) || isThematicBreak(opener)) return false return atxHeading(opener) === undefined && openingCodeFence(opener) === undefined && openingHtmlBlock(opener, true) === undefined } diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index eda3b72..8ee1a9a 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -4,14 +4,14 @@ import type { ConvertFault } from '../../result.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { Elsewhere } from './directive-attributes.ts' import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' -import { attributeValue, directiveLineEscape, directivePrefix, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' +import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' import { blockArgument } from '../block-directive-arguments.ts' import { blockDirective } from '../../adf/block-directives.ts' -import { carryFence, carryName } from '../opaque-carry.ts' +import { blockDirectiveForm } from '../block-directive-forms.ts' +import { carryName } from '../opaque-carry.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { inlineDirective } from '../../adf/inline-directives.ts' import { inlineMarkSpellingFault } from './directive-marks.ts' -import { listBreakName, listBreakSpelling } from '../list-break.ts' import { marksAttribute, readMarkValues } from '../block-directive-marks.ts' import { readVocabulary } from './directive-attributes.ts' import { slotLineEndingFault } from '../directive-syntax.ts' @@ -26,10 +26,10 @@ export function readBlockDirectiveNode( path: ConvertErrorPath, ): Result { if (name === carryName) { - return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryFence} fence`, path) + return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path) } const directive = blockDirective(name) - if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name, directiveLineEscape), path) + if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path) const argumentKey = blockArgument(name) const rest = new Map(attributes) rest.delete(marksAttribute) @@ -53,7 +53,7 @@ export function readInlineDirectiveNode( path: ConvertErrorPath, ): Result { const directive = inlineDirective(name) - if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name, inlineDirectiveEscape), path) + if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path) const slot = directive.textAttribute if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path) const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' } @@ -80,11 +80,8 @@ function inlineSpellingFault(name: string): ConvertFault | undefined { } function blockSpellingFault(name: string): ConvertFault | undefined { - if (name === listBreakName) return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${listBreakSpelling}, never the inline form` } - const directive = blockDirective(name) - if (directive === undefined) return undefined - const form = directive.contentModel === 'none' ? `::${name}` : `:::${name}` - return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${form}, never the inline form` } + if (blockDirectiveForm(name) === undefined) return undefined + return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${directivePrefix}${name}, never the inline form` } } // spec/flavour.md, Inline nodes: the slot is plain text, its adjacent nodes already merged. diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 2fe973a..75908e1 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -132,30 +132,29 @@ test('reads a fenced code block, its info string the language', () => { }) test('reads the codeBlock directive body as the node content, the info string its language', () => { - const fenced = ':::codeBlock {wrap=true}\n```rust\nfn main() {}\n```\n:::\n' + const fenced = '!adf:codeBlock {wrap=true}\n```rust\nfn main() {}\n```\n!adf:/codeBlock\n' assert.deepEqual(content(markdownToAdf(fenced)), [ { attrs: { language: 'rust', wrap: true }, content: [text('fn main() {}')], type: 'codeBlock' }, ]) - assert.deepEqual(content(markdownToAdf(':::codeBlock {wrap=true}\n```\n```\n:::\n')), [{ attrs: { wrap: true }, type: 'codeBlock' }]) - assert.deepEqual(content(markdownToAdf(':::codeBlock {language=""}\n```\nx\n```\n:::\n')), [ + assert.deepEqual(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```\n```\n!adf:/codeBlock\n')), [{ attrs: { wrap: true }, type: 'codeBlock' }]) + assert.deepEqual(content(markdownToAdf('!adf:codeBlock {language=""}\n```\nx\n```\n!adf:/codeBlock\n')), [ { attrs: { language: '' }, content: [text('x')], type: 'codeBlock' }, ]) - assert.deepEqual(content(markdownToAdf(':::codeBlock {wrap=true}\n fn()\n:::\n')), [ + assert.deepEqual(content(markdownToAdf('!adf:codeBlock {wrap=true}\n fn()\n!adf:/codeBlock\n')), [ { attrs: { wrap: true }, content: [text('fn()')], type: 'codeBlock' }, ]) // The body is a CommonMark fence, so its info string decodes escapes the way any other fence's does. - assert.deepEqual(content(markdownToAdf(':::codeBlock {wrap=true}\n```\\#c\nx\n```\n:::\n')), [ + assert.deepEqual(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```\\#c\nx\n```\n!adf:/codeBlock\n')), [ { attrs: { language: '#c', wrap: true }, content: [text('x')], type: 'codeBlock' }, ]) }) test('names the slot a codeBlock spells its language outside of', () => { const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back' - assert.equal(content(markdownToAdf(':::codeBlock {language=rust wrap=true}\n```\nx\n```\n:::\n')), slot) - assert.equal(content(markdownToAdf(':::codeBlock {language=rust}\n```sql\nx\n```\n:::\n')), slot) - assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```adf\nx\n```\n:::\n')), slot) - assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```a\\b\nx\n```\n:::\n')), slot) - assert.equal(content(markdownToAdf('::codeBlock {wrap=true}\n')), 'unsupported-node-shape: codeBlock spells its body in the container form, :::, never the leaf form') + assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), slot) + assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot) + assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```carry\nx\n```\n!adf:/codeBlock\n')), slot) + assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot) }) test('reads a pipe table into the header row and the body rows under it', () => { @@ -205,33 +204,33 @@ test('names the pipe table whose rows open with no pipe', () => { test('gives back the refusal an inline body holds, never the shape check above it', () => { const bare = 'malformed-pipe-table: a pipe table opens every row with `|`: this one does not; \\| keeps a pipe literal text' - assert.equal(content(markdownToAdf(':::caption\na | b\n--- | ---\n:::\n')), bare) - assert.deepEqual(position(markdownToAdf(':::caption\na | b\n--- | ---\n:::\n')), { line: 2, offset: 11 }) - assert.equal(code(markdownToAdf(':::caption\n| a |\n:::\n')), 'malformed-pipe-table') - assert.equal(content(markdownToAdf(':::caption\n- a\n:::\n')), 'unsupported-node-shape: caption takes one paragraph as its body: this body is not one') + assert.equal(content(markdownToAdf('!adf:caption\na | b\n--- | ---\n!adf:/caption\n')), bare) + assert.deepEqual(position(markdownToAdf('!adf:caption\na | b\n--- | ---\n!adf:/caption\n')), { line: 2, offset: 13 }) + assert.equal(code(markdownToAdf('!adf:caption\n| a |\n!adf:/caption\n')), 'malformed-pipe-table') + assert.equal(content(markdownToAdf('!adf:caption\n- a\n!adf:/caption\n')), 'unsupported-node-shape: caption takes one paragraph as its body: this body is not one') }) test('reads the separator that parts two adjacent lists of one kind', () => { const parted = [bulletList(item(paragraph('a'))), bulletList(item(paragraph('b')))] - assert.deepEqual(content(markdownToAdf('- a\n\n::listBreak\n\n- b\n')), parted) - assert.deepEqual(content(markdownToAdf('- a\n::listBreak\n- b\n')), parted) - assert.deepEqual(content(markdownToAdf('1. a\n\n::listBreak\n\n1. b\n')), [orderedList(1, item(paragraph('a'))), orderedList(1, item(paragraph('b')))]) - assert.deepEqual(content(markdownToAdf('> - a\n> ::listBreak\n> - b\n')), [quote(...parted)]) - assert.deepEqual(path(markdownToAdf('- a\n\n::listBreak\n\n- b\n\n| x |\n')), ['content', 2]) + assert.deepEqual(content(markdownToAdf('- a\n\n!adf:listBreak\n\n- b\n')), parted) + assert.deepEqual(content(markdownToAdf('- a\n!adf:listBreak\n- b\n')), parted) + assert.deepEqual(content(markdownToAdf('1. a\n\n!adf:listBreak\n\n1. b\n')), [orderedList(1, item(paragraph('a'))), orderedList(1, item(paragraph('b')))]) + assert.deepEqual(content(markdownToAdf('> - a\n> !adf:listBreak\n> - b\n')), [quote(...parted)]) + assert.deepEqual(path(markdownToAdf('- a\n\n!adf:listBreak\n\n- b\n\n| x |\n')), ['content', 2]) }) test('refuses the list separator that parts anything else', () => { const parts = 'unsupported-node-shape: listBreak parts two adjacent lists of one type: this one parts something else' - assert.equal(content(markdownToAdf('::listBreak\n')), parts) - assert.equal(content(markdownToAdf('- a\n\n::listBreak\n')), parts) - assert.equal(content(markdownToAdf('- a\n\n::listBreak\n\n1. b\n')), parts) - assert.equal(content(markdownToAdf('Part.\n\n::listBreak\n\n- b\n')), parts) - const bare = 'unsupported-node-shape: listBreak spells the bare leaf form, ::listBreak: this one spells more' - assert.equal(content(markdownToAdf('- a\n\n::listBreak x\n\n- b\n')), bare) - assert.equal(content(markdownToAdf('- a\n\n::listBreak {id=x}\n\n- b\n')), bare) - assert.equal(content(markdownToAdf(':::listBreak\n- a\n:::\n')), bare) - assert.equal(content(markdownToAdf('!adf:listBreak{}\n')), 'unsupported-node-shape: listBreak takes the block form, ::listBreak, never the inline form') - assert.deepEqual(path(markdownToAdf('Part.\n\n::listBreak\n')), ['content', 1]) + assert.equal(content(markdownToAdf('!adf:listBreak\n')), parts) + assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n')), parts) + assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n\n1. b\n')), parts) + assert.equal(content(markdownToAdf('Part.\n\n!adf:listBreak\n\n- b\n')), parts) + const bare = 'unsupported-node-shape: listBreak spells the bare leaf form, !adf:listBreak: this one spells more' + assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak x\n\n- b\n')), bare) + assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak {id=x}\n\n- b\n')), bare) + assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n- b\n!adf:/listBreak\n')), 'malformed-directive: listBreak takes no body, so no !adf:/listBreak closes it; \\!adf: keeps the prefix literal') + assert.equal(content(markdownToAdf('!adf:listBreak{}\n')), 'unsupported-node-shape: listBreak takes the block form, !adf:listBreak, never the inline form') + assert.deepEqual(path(markdownToAdf('Part.\n\n!adf:listBreak\n')), ['content', 1]) }) test('refuses the image a pipe cell holds no ADF node for', () => { @@ -252,22 +251,24 @@ test('reads an indented code block where no paragraph is open', () => { assert.deepEqual(content(markdownToAdf('Part.\n more\n')), [paragraph('Part. more')]) }) -test('claims a block-level colon run with no directive to parse it', () => { - assert.equal(code(markdownToAdf(':::\n')), 'malformed-directive') - assert.equal(code(markdownToAdf('::Panel\n')), 'malformed-directive') - assert.equal(code(markdownToAdf('::panel {a=1 a=2}\n')), 'malformed-directive') - assert.deepEqual(content(markdownToAdf(':10:30\n')), [paragraph(':10:30')]) - assert.deepEqual(content(markdownToAdf(':: two\n')), [paragraph(':: two')]) +test('claims a block line the prefix opens with no directive to parse it', () => { + assert.equal(code(markdownToAdf('!adf:/panel\n')), 'malformed-directive') + assert.equal(code(markdownToAdf('!adf:panel info\n')), 'malformed-directive') + assert.equal(code(markdownToAdf('!adf:panel {a=1 a=2}\n!adf:/panel\n')), 'malformed-directive') + assert.equal(content(markdownToAdf('!adf:Panel\n')), 'malformed-directive: an unescaped !adf: completes no directive; \\!adf: keeps the prefix literal') + assert.deepEqual(content(markdownToAdf(':::panel info\nPart.\n:::\n')), [paragraph(':::panel info Part. :::')]) + assert.deepEqual(content(markdownToAdf('::rule\n')), [paragraph('::rule')]) }) test('reads the three directive forms into the nodes the tables name', () => { - assert.deepEqual(content(markdownToAdf('::rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }]) - assert.deepEqual(content(markdownToAdf('::paragraph\n')), [{ type: 'paragraph' }]) - assert.deepEqual(content(markdownToAdf(' :::panel info\nPart.\n:::\n')), [ + assert.deepEqual(content(markdownToAdf('!adf:rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }]) + assert.deepEqual(content(markdownToAdf('!adf:paragraph\n!adf:/paragraph\n')), [{ type: 'paragraph' }]) + assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:/bulletList\n')), [{ type: 'bulletList' }]) + assert.deepEqual(content(markdownToAdf(' !adf:panel info\nPart.\n !adf:/panel\n')), [ { attrs: { panelType: 'info' }, content: [paragraph('Part.')], type: 'panel' }, ]) - assert.deepEqual(content(markdownToAdf(':::blockquote {localId=a-1}\n:::\n')), [{ attrs: { localId: 'a-1' }, type: 'blockquote' }]) - assert.deepEqual(content(markdownToAdf(':::heading {level=2 localId=a-1}\nPart.\n:::\n')), [ + assert.deepEqual(content(markdownToAdf('!adf:blockquote {localId=a-1}\n!adf:/blockquote\n')), [{ attrs: { localId: 'a-1' }, type: 'blockquote' }]) + assert.deepEqual(content(markdownToAdf('!adf:heading {level=2 localId=a-1}\nPart.\n!adf:/heading\n')), [ { attrs: { level: 2, localId: 'a-1' }, content: [text('Part.')], type: 'heading' }, ]) assert.deepEqual(content(markdownToAdf('Part!adf:hardBreak{}.\n')), [{ content: [text('Part'), hardBreak(), text('.')], type: 'paragraph' }]) @@ -275,53 +276,82 @@ test('reads the three directive forms into the nodes the tables name', () => { test('names the directive form a node CommonMark spells refuses', () => { const named = (type: string): string => `unsupported-node-shape: ${type} takes the CommonMark spelling, not the directive form` - assert.equal(content(markdownToAdf('::rule\n')), named('rule')) - assert.equal(content(markdownToAdf(':::blockquote\nPart.\n:::\n')), named('blockquote')) - assert.equal(content(markdownToAdf(':::heading {level=2}\nPart.\n:::\n')), named('heading')) - assert.equal(content(markdownToAdf(':::paragraph\nPart.\n:::\n')), named('paragraph')) - assert.equal(content(markdownToAdf('::::bulletList\n:::listItem\nPart.\n:::\n::::\n')), named('bulletList')) + assert.equal(content(markdownToAdf('!adf:rule\n')), named('rule')) + assert.equal(content(markdownToAdf('!adf:blockquote\nPart.\n!adf:/blockquote\n')), named('blockquote')) + assert.equal(content(markdownToAdf('!adf:heading {level=2}\nPart.\n!adf:/heading\n')), named('heading')) + assert.equal(content(markdownToAdf('!adf:paragraph\nPart.\n!adf:/paragraph\n')), named('paragraph')) + assert.equal(content(markdownToAdf('!adf:bulletList\n!adf:listItem\nPart.\n!adf:/listItem\n!adf:/bulletList\n')), named('bulletList')) // The item whose first line reads back as a thematic break keeps the directive form the emitter falls back to. - assert.deepEqual(content(markdownToAdf('::::bulletList\n:::listItem\n---\n:::\n::::\n')), [bulletList(item({ type: 'rule' }))]) + assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))]) }) // The spelling the emitter refuses gives the emitter's own error, never a second name for it. test('gives back the refusal the CommonMark spelling itself raises', () => { - const destination = ':::blockquote\n[t](https://example.com/a\\b)\n:::\n' + const destination = '!adf:blockquote\n[t](https://example.com/a\\b)\n!adf:/blockquote\n' assert.equal(content(markdownToAdf(destination)), 'unspellable-link: no canonical escape spells a backslash in a link destination') }) test('names the directive name no node reads back to', () => { - assert.equal(code(markdownToAdf(':::widget info\nx\n:::\n')), 'unknown-directive-name') - assert.equal(content(markdownToAdf('::widget\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\::: keeps the line literal text') + assert.equal(code(markdownToAdf('!adf:widget info\nx\n!adf:/widget\n')), 'unknown-directive-name') + assert.equal(content(markdownToAdf('!adf:widget\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\!adf: keeps the prefix literal') assert.equal(content(markdownToAdf('!adf:widget[x]\n')), 'unknown-directive-name: the directive name widget reads back to no node; \\!adf: keeps the prefix literal') assert.equal(content(markdownToAdf('ratio a!adf:b[c]{d}\n')), 'malformed-directive: an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal') - assert.deepEqual(path(markdownToAdf('Part.\n\n::widget\n')), ['content', 1]) - assert.equal(content(markdownToAdf('Part.\n:::x\n')), 'malformed-directive: a container fenced with 3 colons is unclosed') - assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1]) + assert.deepEqual(path(markdownToAdf('Part.\n\n!adf:widget\n')), ['content', 1]) +}) + +test('names the container no closer closes inside the block holding it', () => { + const unclosed = (name: string): string => `malformed-directive: the ${name} container is unclosed: no !adf:/${name} follows inside the block holding it; \\!adf: keeps the prefix literal` + assert.equal(content(markdownToAdf('Part.\n!adf:expand\n')), unclosed('expand')) + assert.deepEqual(path(markdownToAdf('Part.\n!adf:expand\n')), ['content', 1]) + assert.equal(content(markdownToAdf('!adf:bulletList\n')), unclosed('bulletList')) + assert.equal(content(markdownToAdf('- !adf:panel info\n Part.\n!adf:/panel\n')), unclosed('panel')) + assert.equal(content(markdownToAdf('> !adf:panel info\n> - a\n> !adf:/panel\n')), unclosed('panel')) + assert.equal(content(markdownToAdf('!adf:panel info\n!adf:expand\nPart.\n!adf:/panel\n')), unclosed('expand')) + assert.deepEqual(path(markdownToAdf('!adf:panel info\n!adf:expand\nPart.\n!adf:/panel\n')), ['content', 0, 'content', 0]) +}) + +test('names the closer that finds no container open where it stands', () => { + const unopened = (name: string): string => `malformed-directive: the closer !adf:/${name} closes no ${name} container open where it stands; \\!adf: keeps the prefix literal` + assert.equal(content(markdownToAdf('Part.\n\n!adf:/panel\n')), unopened('panel')) + assert.equal(content(markdownToAdf('!adf:panel info\n> !adf:/panel\n!adf:/panel\n')), unopened('panel')) + assert.equal(content(markdownToAdf('!adf:panel info\n- !adf:/panel\n!adf:/panel\n')), unopened('panel')) + assert.equal(content(markdownToAdf('!adf:panel info\nPart.\n!adf:/expand\n!adf:/panel\n')), unopened('expand')) +}) + +test('names the leaf given a body at its opener, ahead of any refusal the leaf holds itself', () => { + const body = (name: string): string => `malformed-directive: ${name} takes no body, so no !adf:/${name} closes it; \\!adf: keeps the prefix literal` + assert.equal(content(markdownToAdf('!adf:rule\nPart.\n!adf:/rule\n')), body('rule')) + assert.deepEqual(position(markdownToAdf('Part.\n\n!adf:rule\nPart.\n!adf:/rule\n')), { line: 3, offset: 7 }) + assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n!adf:/listBreak\n\n- b\n')), body('listBreak')) + assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\nPart.\n!adf:/listBreak\n\n- b\n')), body('listBreak')) + assert.deepEqual(path(markdownToAdf('!adf:rule {localId=a-1}\n!adf:rule {localId=a-2}\nPart.\n!adf:/rule\n')), ['content', 1]) + assert.deepEqual(path(markdownToAdf('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n')), ['content', 0]) + assert.deepEqual(path(markdownToAdf('Part.\n\n!adf:/rule\n')), ['content', 1]) + assert.deepEqual(path(markdownToAdf('!adf:panel info\n!adf:rule {localId=a-1}\n!adf:/panel\n!adf:/rule\n')), ['content', 1]) }) test('names the position a directive name the other one spells belongs to', () => { - assert.equal(content(markdownToAdf(':::em\na\n:::\n')), 'unsupported-node-shape: em is spelled _x_, never as a block directive') - assert.equal(content(markdownToAdf('::underline\n')), 'unsupported-node-shape: underline is spelled !adf:underline[…], never as a block directive') - assert.equal(content(markdownToAdf('::text {text=" "}\n')), 'unsupported-node-shape: text takes the inline form, !adf:text{…}, never the block form') - assert.equal(content(markdownToAdf('::date {timestamp=1}\n')), 'unsupported-node-shape: date takes the inline form, !adf:date{…}, never the block form') - assert.equal(content(markdownToAdf('!adf:paragraph[a]\n')), 'unsupported-node-shape: paragraph takes the block form, :::paragraph, never the inline form') - assert.equal(content(markdownToAdf('!adf:rule[a]\n')), 'unsupported-node-shape: rule takes the block form, ::rule, never the inline form') - assert.equal(code(markdownToAdf(':::widget\na\n:::\n')), 'unknown-directive-name') + assert.equal(content(markdownToAdf('!adf:em\na\n!adf:/em\n')), 'unsupported-node-shape: em is spelled _x_, never as a block directive') + assert.equal(content(markdownToAdf('!adf:underline\n')), 'unsupported-node-shape: underline is spelled !adf:underline[…], never as a block directive') + assert.equal(content(markdownToAdf('!adf:text {text=" "}\n')), 'unsupported-node-shape: text takes the inline form, !adf:text{…}, never the block form') + assert.equal(content(markdownToAdf('!adf:date {timestamp=1}\n')), 'unsupported-node-shape: date takes the inline form, !adf:date{…}, never the block form') + assert.equal(content(markdownToAdf('!adf:paragraph[a]\n')), 'unsupported-node-shape: paragraph takes the block form, !adf:paragraph, never the inline form') + assert.equal(content(markdownToAdf('!adf:rule[a]\n')), 'unsupported-node-shape: rule takes the block form, !adf:rule, never the inline form') + assert.equal(code(markdownToAdf('!adf:widget\na\n!adf:/widget\n')), 'unknown-directive-name') assert.equal(code(markdownToAdf('!adf:widget[a]\n')), 'unknown-directive-name') }) test('names the reserved carry name a block directive spells', () => { - const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the adf fence' - assert.equal(content(markdownToAdf('::carry\n')), reserved) - assert.equal(content(markdownToAdf(':::carry\nx\n:::\n')), reserved) - assert.deepEqual(content(markdownToAdf('```adfx\nx\n```\n')), [{ attrs: { language: 'adfx' }, content: [text('x')], type: 'codeBlock' }]) + const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the carry fence' + assert.equal(content(markdownToAdf('!adf:carry\n')), reserved) + assert.equal(content(markdownToAdf('!adf:carry\nx\n!adf:/carry\n')), reserved) + assert.deepEqual(content(markdownToAdf('```adf\nx\n```\n')), [{ attrs: { language: 'adf' }, content: [text('x')], type: 'codeBlock' }]) }) const carried = '!adf:carry{json="{\\"type\\":\\"placeholder\\"}"}' -test('reads the adf fence back to the node its JSON holds', () => { - assert.deepEqual(content(markdownToAdf('```adf\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [ +test('reads the carry fence back to the node its JSON holds', () => { + assert.deepEqual(content(markdownToAdf('```carry\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [ { attrs: { url: 'https://example.com/x' }, type: 'blockCard' }, ]) }) @@ -334,22 +364,22 @@ test('reads the inline carry back to the node its json attribute holds', () => { test('names the invalid JSON no opaque carry holds', () => { const invalid = 'malformed-directive: the opaque carry holds invalid JSON' - assert.equal(content(markdownToAdf('```adf\n{"type":\n```\n')), invalid) - assert.equal(content(markdownToAdf('```adf\n```\n')), invalid) + assert.equal(content(markdownToAdf('```carry\n{"type":\n```\n')), invalid) + assert.equal(content(markdownToAdf('```carry\n```\n')), invalid) assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), invalid) assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid) }) test('names the canonical spelling a carried JSON reads alone', () => { const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: " - assert.equal(content(markdownToAdf('```adf\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`) + assert.equal(content(markdownToAdf('```carry\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`) assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`) assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`) }) test('names the node JSON an opaque carry restores alone', () => { const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node" - assert.equal(content(markdownToAdf('```adf\n[]\n```\n')), node) + assert.equal(content(markdownToAdf('```carry\n[]\n```\n')), node) assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), node) assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node) }) @@ -363,7 +393,7 @@ test('names the shape the inline carry reads alone', () => { test('holds a carried JSON value to the nesting its position leaves', () => { const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}` - const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`adf\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n` + const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`carry\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n` const deeper = (levels: number): string => `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${levels} levels its position leaves` assert.equal(content(markdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting)) assert.equal( @@ -376,7 +406,7 @@ test('holds a carried JSON value to the nesting its position leaves', () => { test('names the number no JSON spelling carries in an opaque carry', () => { const named = 'unsupported-node-shape: the opaque carry holds a number JSON cannot spell' assert.equal(content(markdownToAdf('!adf:carry{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named) - assert.equal(content(markdownToAdf('```adf\n1e999\n```\n')), named) + assert.equal(content(markdownToAdf('```carry\n1e999\n```\n')), named) }) test('names the mark spelling no opaque carry sits inside', () => { @@ -401,37 +431,37 @@ test('keeps the carry a mark spelling does not wrap', () => { }) test('reads each attribute value as the type its section assigns', () => { - assert.deepEqual(content(markdownToAdf('::media {height=10 id=a-1 type=file url="/x y" width="20.5"}\n')), [ + assert.deepEqual(content(markdownToAdf('!adf:media {height=10 id=a-1 type=file url="/x y" width="20.5"}\n')), [ { attrs: { height: 10, id: 'a-1', type: 'file', url: '/x y', width: 20.5 }, type: 'media' }, ]) - assert.deepEqual(content(markdownToAdf(':::table {isNumberColumnEnabled=true}\n:::\n')), [{ attrs: { isNumberColumnEnabled: true }, type: 'table' }]) - assert.deepEqual(content(markdownToAdf(':::tableCell {colwidth="[340,420]"}\n:::\n')), [{ attrs: { colwidth: [340, 420] }, type: 'tableCell' }]) - assert.deepEqual(content(markdownToAdf('::rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }]) + assert.deepEqual(content(markdownToAdf('!adf:table {isNumberColumnEnabled=true}\n!adf:/table\n')), [{ attrs: { isNumberColumnEnabled: true }, type: 'table' }]) + assert.deepEqual(content(markdownToAdf('!adf:tableCell {colwidth="[340,420]"}\n!adf:/tableCell\n')), [{ attrs: { colwidth: [340, 420] }, type: 'tableCell' }]) + assert.deepEqual(content(markdownToAdf('!adf:rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }]) }) test('reads the reserved marks key as the node array it spells', () => { - assert.deepEqual(content(markdownToAdf('::rule {marks="[{\\"type\\":\\"em\\"}]"}\n')), [{ marks: [em], type: 'rule' }]) - assert.deepEqual(content(markdownToAdf('::rule {localId=a-1 marks="[{\\"attrs\\":{\\"mode\\":\\"wide\\"},\\"type\\":\\"breakout\\"}]"}\n')), [ + assert.deepEqual(content(markdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\"}]"}\n')), [{ marks: [em], type: 'rule' }]) + assert.deepEqual(content(markdownToAdf('!adf:rule {localId=a-1 marks="[{\\"attrs\\":{\\"mode\\":\\"wide\\"},\\"type\\":\\"breakout\\"}]"}\n')), [ { attrs: { localId: 'a-1' }, marks: [{ attrs: { mode: 'wide' }, type: 'breakout' }], type: 'rule' }, ]) }) test('names the marks key no marks array reads back from', () => { const named = 'unsupported-node-shape: the marks attribute of rule is its marks array in canonical JSON: this one is not' - assert.equal(content(markdownToAdf('::rule {marks="[]"}\n')), named) - assert.equal(content(markdownToAdf('::rule {marks="[1]"}\n')), named) - assert.equal(content(markdownToAdf('::rule {marks="{}"}\n')), named) - assert.equal(content(markdownToAdf('::rule {marks=x}\n')), named) - assert.equal(content(markdownToAdf('::rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named) + assert.equal(content(markdownToAdf('!adf:rule {marks="[]"}\n')), named) + assert.equal(content(markdownToAdf('!adf:rule {marks="[1]"}\n')), named) + assert.equal(content(markdownToAdf('!adf:rule {marks="{}"}\n')), named) + assert.equal(content(markdownToAdf('!adf:rule {marks=x}\n')), named) + assert.equal(content(markdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named) }) test('names the attribute a node holds no reading for', () => { - assert.equal(content(markdownToAdf('::rule {bogus=1}\n')), 'unsupported-node-shape: rule holds no bogus attribute: this one spells it') - assert.equal(content(markdownToAdf('::media {width=wide}\n')), 'unsupported-node-shape: the width attribute of media is no number') - assert.equal(content(markdownToAdf(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean') - assert.equal(content(markdownToAdf('::media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number') - assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json') - assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}') + assert.equal(content(markdownToAdf('!adf:rule {bogus=1}\n')), 'unsupported-node-shape: rule holds no bogus attribute: this one spells it') + assert.equal(content(markdownToAdf('!adf:media {width=wide}\n')), 'unsupported-node-shape: the width attribute of media is no number') + assert.equal(content(markdownToAdf('!adf:table {isNumberColumnEnabled=yes}\n!adf:/table\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean') + assert.equal(content(markdownToAdf('!adf:media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number') + assert.equal(content(markdownToAdf('!adf:tableCell {colwidth="[340,"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json') + assert.equal(content(markdownToAdf('!adf:panel info {panelType=note}\nx\n!adf:/panel\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}') assert.equal(content(markdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}') }) @@ -439,27 +469,24 @@ test('names the depth an attribute value nests past, never the kind the JSON rea const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}` const deeper = (key: string, type: string): string => `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` - assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${nested(largestNesting + 1)}"}\n:::\n`)), deeper('colwidth', 'tableCell')) - assert.equal(content(markdownToAdf(`::rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule')) - assert.equal(content(markdownToAdf(`::rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`)), deeper('marks', 'rule')) - assert.equal(content(markdownToAdf(`::media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number') + assert.equal(content(markdownToAdf(`!adf:tableCell {colwidth="${nested(largestNesting + 1)}"}\n!adf:/tableCell\n`)), deeper('colwidth', 'tableCell')) + assert.equal(content(markdownToAdf(`!adf:rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule')) + assert.equal(content(markdownToAdf(`!adf:rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`)), deeper('marks', 'rule')) + assert.equal(content(markdownToAdf(`!adf:media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number') }) test('names the attribute value spelled outside the canonical form', () => { - assert.equal(content(markdownToAdf('::rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1') - assert.equal(content(markdownToAdf('::media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20') - assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340, 420]"}\n:::\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth="[340,420]"') + assert.equal(content(markdownToAdf('!adf:rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1') + assert.equal(content(markdownToAdf('!adf:media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20') + assert.equal(content(markdownToAdf('!adf:tableCell {colwidth="[340, 420]"}\n!adf:/tableCell\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth="[340,420]"') }) test('names the argument and the body a node takes no reading for', () => { - assert.equal(content(markdownToAdf('::rule x\n')), 'unsupported-node-shape: rule takes no argument: this one spells one') - assert.equal(content(markdownToAdf(':::rule\nPart.\n:::\n')), 'unsupported-node-shape: rule holds no content: this one holds some') - assert.equal(content(markdownToAdf('::bulletList\n')), 'unsupported-node-shape: bulletList spells its body in the container form, :::, never the leaf form') - assert.equal(content(markdownToAdf(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph takes the leaf form, ::, never an empty container') - assert.equal(content(markdownToAdf(':::paragraph\nOne.\n\nTwo.\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') - assert.equal(content(markdownToAdf(':::paragraph\n---\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') - assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\nx\n:::\n')), 'unsupported-node-shape: codeBlock takes one code block as its body: this body is not one') - assert.equal(content(markdownToAdf(':::paragraph\n![a](/u)\n:::\n')), 'unmappable-image: no ADF node carries an image inside a paragraph') + assert.equal(content(markdownToAdf('!adf:rule x\n')), 'unsupported-node-shape: rule takes no argument: this one spells one') + assert.equal(content(markdownToAdf('!adf:paragraph\nOne.\n\nTwo.\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') + assert.equal(content(markdownToAdf('!adf:paragraph\n---\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') + assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes one code block as its body: this body is not one') + assert.equal(content(markdownToAdf('!adf:paragraph\n![a](/u)\n!adf:/paragraph\n')), 'unmappable-image: no ADF node carries an image inside a paragraph') assert.equal(content(markdownToAdf('Part !adf:date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some') }) @@ -500,12 +527,12 @@ test('refuses the raw HTML no element mapping carries', () => { assert.equal(code(markdownToAdf('\n')), ['content', 1]) - assert.equal(code(markdownToAdf('
\nx\n\n:::\n')), 'unmappable-html') + assert.equal(code(markdownToAdf('
\nx\n\n!adf:/panel\n')), 'unmappable-html') assert.equal(code(markdownToAdf('
\n- x\n
\n')), 'unmappable-html') }) test('swallows an HTML block ahead of the claim a line inside it would make', () => { - assert.equal(code(markdownToAdf('\n')), 'unmappable-html') + assert.equal(code(markdownToAdf('\n')), 'unmappable-html') assert.equal(code(markdownToAdf('
\n| x |\n
\n')), 'unmappable-html') }) @@ -514,15 +541,15 @@ test('names the line and the offset in the input a refusal sits at, the innermos assert.deepEqual(position(markdownToAdf('Part.\n\n
\n')), { line: 3, offset: 7 }) assert.deepEqual(position(markdownToAdf('> Part.\n>\n> a b\n')), { line: 3, offset: 10 }) assert.deepEqual(position(markdownToAdf('- Part.\n- a b\n')), { line: 2, offset: 8 }) - assert.deepEqual(position(markdownToAdf('Part.\n\n:::panel info\nMore.\n')), { line: 3, offset: 7 }) + assert.deepEqual(position(markdownToAdf('Part.\n\n!adf:panel info\nMore.\n')), { line: 3, offset: 7 }) assert.deepEqual(position(markdownToAdf('x\n\na b\n===\n')), { line: 3, offset: 3 }) - assert.deepEqual(position(markdownToAdf('x\n\n```adf\n{\n```\n')), { line: 3, offset: 3 }) + assert.deepEqual(position(markdownToAdf('x\n\n```carry\n{\n```\n')), { line: 3, offset: 3 }) assert.deepEqual(position(markdownToAdf('x\n\n| a |\n')), { line: 3, offset: 3 }) assert.deepEqual(position(markdownToAdf('a\nb c\n')), { line: 1, offset: 0 }) assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n
\r\n')), { line: 3, offset: 9 }) assert.deepEqual(position(markdownToAdf('a\u0000b\n\n
\n')), { line: 3, offset: 5 }) - assert.deepEqual(position(markdownToAdf(':::caption\na b\n:::\n')), { line: 2, offset: 11 }) - assert.deepEqual(position(markdownToAdf('x\n\n:::caption\n- a\n:::\n')), { line: 3, offset: 3 }) + assert.deepEqual(position(markdownToAdf('!adf:caption\na b\n!adf:/caption\n')), { line: 2, offset: 13 }) + assert.deepEqual(position(markdownToAdf('x\n\n!adf:caption\n- a\n!adf:/caption\n')), { line: 3, offset: 3 }) }) test('names the line the text a paragraph keeps starts on, never a definition line it gave up', () => { @@ -530,7 +557,7 @@ test('names the line the text a paragraph keeps starts on, never a definition li assert.deepEqual(position(markdownToAdf('[a]: /a\n[b]: /b\n[c]: /c\n[d]: /d\nx\n')), { line: 5, offset: 32 }) assert.deepEqual(position(markdownToAdf('[a]:\n\n"Title"\nb\n')), { line: 4, offset: 23 }) assert.deepEqual(position(markdownToAdf('> [a]: /url\n> b\n')), { line: 2, offset: 12 }) - assert.deepEqual(position(markdownToAdf(':::caption\n[a]: /url\nb\n:::\n')), { line: 3, offset: 21 }) + assert.deepEqual(position(markdownToAdf('!adf:caption\n[a]: /url\nb\n!adf:/caption\n')), { line: 3, offset: 23 }) }) test('gives up the link reference definitions a paragraph opens with', () => { @@ -626,13 +653,13 @@ test('folds a lazy continuation into the paragraph the container holds', () => { }) test('ends a lazy continuation at a claimed line', () => { - assert.equal(code(markdownToAdf('> Part.\n:::\n')), 'malformed-directive') - assert.deepEqual(path(markdownToAdf('> Part.\n:::\n')), ['content', 1]) + assert.equal(code(markdownToAdf('> Part.\n!adf:/panel\n')), 'malformed-directive') + assert.deepEqual(path(markdownToAdf('> Part.\n!adf:/panel\n')), ['content', 1]) assert.equal(code(markdownToAdf('- Part.\n| x |\n')), 'malformed-pipe-table') }) test('names the block the claim inside a container opens', () => { - assert.deepEqual(path(markdownToAdf('> Part.\n>\n> :::x\n')), ['content', 0, 'content', 1]) + assert.deepEqual(path(markdownToAdf('> Part.\n>\n> !adf:expand\n')), ['content', 0, 'content', 1]) assert.deepEqual(path(markdownToAdf('- Part.\n- | x |\n')), ['content', 0, 'content', 1, 'content', 0]) }) @@ -642,6 +669,11 @@ test('refuses input nested deeper than the parser carries', () => { const marks = (levels: number): string => `${'!adf:underline['.repeat(levels)}a${']'.repeat(levels)}\n` assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth') assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }]) + const nest = (names: readonly string[], body: string): string => [...names.map((name) => `!adf:${name}\n`), body, ...names.map((name) => `!adf:/${name}\n`).reverse()].join('') + const repeated = (name: string): string[] => Array.from({ length: largestNesting }, () => name) + assert.ok(markdownToAdf(nest(repeated('panel'), '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok) + assert.deepEqual(position(markdownToAdf(nest(['expand', ...repeated('panel'), 'expand'], 'Part.\n'))), { line: 501, offset: 5501 }) + assert.equal(code(markdownToAdf(nest(['panel', ...repeated('expand'), 'panel'], 'Part.\n'))), 'unsupported-nesting-depth') }) test('decodes the backslash escapes CommonMark spells, and keeps the rest literal', () => { @@ -670,9 +702,9 @@ test('reads a code span, its content literal', () => { assert.deepEqual(content(markdownToAdf('` `\n')), [{ content: [codeSpan(' ')], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('`a\nb`\n')), [{ content: [codeSpan('a b')], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('`foo``bar`\n')), [{ content: [codeSpan('foo``bar')], type: 'paragraph' }]) - assert.deepEqual(content(markdownToAdf('`:::panel` `~~x~~` `\\*` `&`\n')), [ + assert.deepEqual(content(markdownToAdf('`!adf:/panel` `~~x~~` `\\*` `&`\n')), [ { - content: [codeSpan(':::panel'), text(' '), codeSpan('~~x~~'), text(' '), codeSpan('\\*'), text(' '), codeSpan('&')], + content: [codeSpan('!adf:/panel'), text(' '), codeSpan('~~x~~'), text(' '), codeSpan('\\*'), text(' '), codeSpan('&')], type: 'paragraph', }, ]) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 07aa667..ace6616 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -3,7 +3,7 @@ import type { Block, DirectiveBlock } from './blocks.ts' import type { BlockDirectiveNode } from './directive-nodes.ts' import type { ConvertFault } from '../../result.ts' import type { LinkDefinitions } from './inline-content.ts' -import { carryFence, readCarriedBlock } from '../opaque-carry.ts' +import { carryName, readCarriedBlock } from '../opaque-carry.ts' import { commonMarkSpelling } from '../emit/adf-to-markdown.ts' import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts' import { languageSlot } from '../code-language.ts' @@ -43,7 +43,7 @@ function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path // spec/flavour.md, Directives: the separator builds no node, so only the pair it parts spells it. function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next: Block | undefined): ConvertFault | undefined { - if (block.blocks !== undefined || block.argument !== undefined || block.attributes.size > 0) { + if (block.argument !== undefined || block.attributes.size > 0) { return unsupportedNodeShape(`${listBreakName} spells the bare leaf form, ${listBreakSpelling}: this one spells more`) } if (previous?.kind !== 'bulletList' && previous?.kind !== 'orderedList') return partsFault() @@ -94,14 +94,10 @@ function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result { const { contentModel, node } = read - if (blocks === undefined) { - if (contentModel === 'none' || contentModel === 'inline') return success(node) - return failure('unsupported-node-shape', `${node.type} spells its body in the container form, :::, never the leaf form`, path) - } - if (contentModel === 'none') return failure('unsupported-node-shape', `${node.type} holds no content: this one holds some`, path) + if (blocks === undefined) return success(node) if (contentModel === 'code') return codeDirectiveNode(node, blocks, path) - if (contentModel === 'block') return containerNode(node, blocks, definitions, path, depth) - return inlineBodyNode(node, blocks, definitions, path) + if (contentModel === 'inline') return inlineBodyNode(node, blocks, definitions, path) + return containerNode(node, blocks, definitions, path, depth) } function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result { @@ -133,7 +129,7 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path } function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result { - if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::, never an empty container`, path) + if (blocks.length === 0) return success(node) const only = blocks.length === 1 ? blocks[0] : undefined if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position) if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path) @@ -161,7 +157,7 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef } function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result { - if (language === carryFence) { + if (language === carryName) { const carried = readCarriedBlock(text, depth) if (carried.fault !== undefined) return faulted(carried.fault, path) return success(carried.value) diff --git a/src/markdown/text-directive.ts b/src/markdown/text-directive.ts index 4cdea77..05ce403 100644 --- a/src/markdown/text-directive.ts +++ b/src/markdown/text-directive.ts @@ -1,5 +1,5 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts' -import { readSoleStringAttribute, spellAttributes, spellLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' +import { readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' const name = 'text' const whitespaceRun = /^(?:[ \t]+|\n+)$/ @@ -7,7 +7,7 @@ const whitespaceRun = /^(?:[ \t]+|\n+)$/ export const textDirectiveName = name export function spellTextDirective(text: string): string { - return spellLeafDirective(name, spellAttributes([[name, spellStringAttribute(text)]])) + return spellInlineLeafDirective(name, spellAttributes([[name, spellStringAttribute(text)]])) } export function readTextDirective(span: DirectiveSpan): Read | undefined { diff --git a/todo.md b/todo.md index 5000451..e1db05f 100644 --- a/todo.md +++ b/todo.md @@ -74,8 +74,14 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c them: `readNestedDirective` restarts its depth counter per level, so each parse level re-scans the region below it and nested inline directives cost O(depth × content) — 3f's cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level - guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents - nobody typed. + guard. A fifth predates 12c: the list-item walk re-scans the rest of a line once per item + level — `isThematicBreak` in `containerStart` on an opener line, `isBlankLine` and + `leadingColumns` in `continuesContainer` on a continuation line, and a blank line + continues every open item without consuming input; 30000 nested items take 4.4s at 59 KB + (the stability-reviewer, 2026-09-16). §11's scanning rule is + the whole argument; the pipeline persona feeds documents nobody typed. + `readDirectiveContent`'s scan splits into named steps with that fix rather than keeping its + complexity (the maintainer, 2026-09-16). - [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its