12c - The block directive forms read and spell !adf: openers and closers #86

Merged
lilleman merged 6 commits from 12c into main 2026-09-16 21:48:08 +02:00
83 changed files with 808 additions and 819 deletions
Showing only changes of commit 5bec3fbbb6 - Show all commits
+2 -2
View File
@@ -1,2 +1,2 @@
:::tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"}
:::
!adf:tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"}
!adf:/tableCell
+1 -1
View File
@@ -1,3 +1,3 @@
```adf
```carry
{"type":
```
+1 -1
View File
@@ -1,2 +1,2 @@
> Part.
:::
!adf:/panel
+2 -2
View File
@@ -1,3 +1,3 @@
:::codeBlock {wrap=true}
!adf:codeBlock {wrap=true}
fn main() {}
:::
!adf:/codeBlock
+2 -2
View File
@@ -1,5 +1,5 @@
:::codeBlock {language=rust wrap=true}
!adf:codeBlock {language=rust wrap=true}
```
fn main() {}
```
:::
!adf:/codeBlock
-1
View File
@@ -1 +0,0 @@
:::
+1 -1
View File
@@ -1 +1 @@
::media {type=file id=a-1}
!adf:media {type=file id=a-1}
+2 -2
View File
@@ -1,3 +1,3 @@
:::panel info {bogus=1}
!adf:panel info {bogus=1}
Part.
:::
!adf:/panel
+1 -1
View File
@@ -1 +1 @@
unsupported-node-shape
malformed-directive
+2 -2
View File
@@ -1,3 +1,3 @@
:::rule
!adf:media {id=01a0aa6b-4221-7518-a640-2b97d6217ce2 type=file}
Part.
:::
!adf:/media
+2 -2
View File
@@ -1,3 +1,3 @@
:::carry
!adf:carry
{"type":"rule"}
:::
!adf:/carry
@@ -0,0 +1,4 @@
!adf:panel info
!adf:expand
Part.
!adf:/panel
@@ -0,0 +1,2 @@
!adf:panel info
Part.
@@ -1,2 +0,0 @@
:::panel info
Part.
+1 -1
View File
@@ -1 +1 @@
::rule
!adf:rule
+2 -2
View File
@@ -1,3 +1,3 @@
:::widget info
!adf:widget info
Part.
:::
!adf:/widget
+1 -1
View File
@@ -1 +1 @@
::rule {localId="a-1"}
!adf:rule {localId="a-1"}
+1 -1
View File
@@ -1,5 +1,5 @@
- Bolt M8
::listBreak
!adf:listBreak
Packed.
@@ -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
@@ -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
+4 -4
View File
@@ -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
@@ -1,13 +0,0 @@
:::codeBlock {language=adf}
```
{
"type": "blockCard"
}
```
:::
:::codeBlock {language=adf}
````
```
````
:::
@@ -64,7 +64,7 @@
},
"content": [
{
"text": ":::",
"text": "!adf:/codeBlock",
"type": "text"
}
],
@@ -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
@@ -2,7 +2,7 @@
"content": [
{
"attrs": {
"language": "adf"
"language": "carry"
},
"content": [
{
@@ -14,7 +14,7 @@
},
{
"attrs": {
"language": "adf"
"language": "carry"
},
"content": [
{
@@ -0,0 +1,13 @@
!adf:codeBlock {language=carry}
```
{
"type": "blockCard"
}
```
!adf:/codeBlock
!adf:codeBlock {language=carry}
````
```
````
!adf:/codeBlock
@@ -1,5 +1,5 @@
:::codeBlock {language=""}
!adf:codeBlock {language=""}
```
cargo build --release
```
:::
!adf:/codeBlock
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+4 -4
View File
@@ -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
+9 -9
View File
@@ -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
+20 -20
View File
@@ -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
+6 -6
View File
@@ -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
+4 -4
View File
@@ -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
@@ -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
@@ -34,7 +34,7 @@
},
"content": [
{
"text": ":::::::panel warning\n:::",
"text": "!adf:panel warning\n!adf:/panel",
"type": "text"
}
],
@@ -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
@@ -1,15 +0,0 @@
::::::table
:::::tableRow
::::tableCell
:::nestedExpand {title="Torque values"}
Bolt M8: 25 Nm.
:::
::::
:::tableCell
```text
:::::::panel warning
:::
```
:::
:::::
::::::
+6 -6
View File
@@ -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
+3 -3
View File
@@ -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
@@ -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
@@ -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
+14 -13
View File
@@ -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
@@ -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
@@ -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)"}
@@ -1,4 +1,4 @@
```adf
```carry
{
"attrs": {
"rounded": true
@@ -18,7 +18,7 @@
}
```
```adf
```carry
{
"attrs": {
"panelType": "extra info"
@@ -21,7 +21,7 @@
{
"content": [
{
"text": "::syncBlock {localId=x}",
"text": "!adf:syncBlock {localId=x}",
"type": "text"
}
],
@@ -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.
@@ -4,6 +4,6 @@ Pass a | b to the shell.
- \| --- | --- |
:::panel info
!adf:panel info
\| a | b |
:::
!adf:/panel
@@ -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"
}
],
@@ -0,0 +1,13 @@
!adf:panel info
- \!adf:/panel
> \!adf:/panel
\!adf:/panel
\!adf:/panel x
\!adf:rule
!adf:/panel
\!adf:/panel
@@ -1,13 +0,0 @@
:::panel info
- \:::
> \:::
\:::
\::: x
\::
:::
\:::
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,6 @@
First.
::paragraph
!adf:paragraph
!adf:/paragraph
Second.
@@ -1,4 +1,4 @@
> ```adf
> ```carry
> {
> "attrs": {
> "url": "https://example.com/quoted"
@@ -7,7 +7,7 @@
> }
> ```
- ```adf
- ```carry
{
"attrs": {
"url": "https://example.com/listed"
@@ -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
-49
View File
@@ -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'))
+35 -23
View File
@@ -11,8 +11,17 @@ 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,
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 +82,8 @@ const syntaxTokens = fc.constantFrom(
'![',
'](',
'{}',
'::',
':::',
`${directivePrefix}/`,
`${directivePrefix}panel `,
'> ',
'- ',
'* ',
@@ -107,8 +116,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('<span>', '</span>', '<a href="x">', "<b class='y'/>", '<!-- c -->', '<!---->', '<?x?>', '<![CDATA[x]]>', '<!X y>', '<br/>', '<b', '<3', '< a>')
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 +160,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 directiveOpener(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 +197,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<string> {
const inline = inlineMarkdown(hostile)
@@ -227,7 +235,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 +263,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 +310,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]) => directiveOpener(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 }
}
@@ -321,10 +333,10 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, {
? fc.constant('')
: fc.oneof({ arbitrary: fc.constantFrom(' DONE', ' TODO', ' custom', ' info', ' warning'), weight: 3 }, { arbitrary: bareToken.map((held) => ` ${held}`), 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]) => directiveOpener(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(directiveOpener(name, held, spelled), body, closer ?? spellDirectiveCloser(name)))
})
return {
block: choose(
@@ -352,8 +364,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(directiveOpener(name, argument, attrs), body, closer ?? spellDirectiveCloser(name))),
hostile: true,
weight: 1,
},
+9
View File
@@ -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'
}
+2 -2
View File
@@ -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' }
}
+1 -6
View File
@@ -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)
}
+55 -48
View File
@@ -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<d|e', '"a\\u0060b\\u0026c\\u003cd\\u007ce"']),
readDirectiveLine('!adf:extension {text="a\\u0060b\\u0026c\\u003cd\\u007ce"}'),
opener('extension', undefined, ['text', 'a`b&c<d|e', '"a\\u0060b\\u0026c\\u003cd\\u007ce"']),
)
assert.deepEqual(readDirectiveLine('::extension {text="a\\"b\\\\c\\nd"}'), header(2, 'extension', undefined, ['text', 'a"b\\c\nd', '"a\\"b\\\\c\\nd"']))
assert.deepEqual(readDirectiveLine('::extension {text="}{"}'), header(2, 'extension', undefined, ['text', '}{', '"}{"']))
assert.deepEqual(readDirectiveLine('!adf:extension {text="a\\"b\\\\c\\nd"}'), opener('extension', undefined, ['text', 'a"b\\c\nd', '"a\\"b\\\\c\\nd"']))
assert.deepEqual(readDirectiveLine('!adf:extension {text="}{"}'), opener('extension', undefined, ['text', '}{', '"}{"']))
})
test('names the {attrs} keys read out of the alphabetical order canonical form spells', () => {
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<y"}'), 'a raw < inside {attrs} breaks the directive: spell it \\u003c')
assert.equal(fault('::panel {a="x|y"}'), 'a raw | inside {attrs} breaks the directive: spell it \\u007c')
assert.equal(fault('!adf:panel {a="x`y"}'), 'a raw ` inside {attrs} breaks the directive: spell it \\u0060')
assert.equal(fault('!adf:panel {a="x&y"}'), 'a raw & inside {attrs} breaks the directive: spell it \\u0026')
assert.equal(fault('!adf:panel {a="x<y"}'), 'a raw < inside {attrs} breaks the directive: spell it \\u003c')
assert.equal(fault('!adf:panel {a="x|y"}'), 'a raw | inside {attrs} breaks the directive: spell it \\u007c')
})
test('claims at the prefix, and reads a directive only where a bracket or a brace follows the name', () => {
+68 -65
View File
@@ -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<string, DirectiveValue>
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<T> = { 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,18 @@ export function malformedDirective(message: string): ConvertFault {
}
export function readDirectiveLine(line: string): Read<DirectiveLine> | 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
const closer = line.charAt(directivePrefix.length) === '/'
const start = directivePrefix.length + (closer ? 1 : 0)
directiveName.lastIndex = start
const name = directiveName.exec(line)?.[0]
if (name === undefined) return undefined
const rest = line.slice(start + name.length)
if (closer) return lineEnd.test(rest) ? { value: { kind: 'closer', name } } : { fault: malformedDirective(closerFault) }
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 } }
}
export function readInlineDirective(text: string, index: number): Read<DirectiveSpan> | undefined {
@@ -110,6 +115,10 @@ export function spellAttributes(pairs: readonly (readonly [string, string])[]):
return `{${spelled.join(' ')}}`
}
export function spellDirectiveCloser(name: string): string {
return `${directivePrefix}/${name}`
}
export function spellJsonAttribute(value: JsonValue): string {
return quote(serializeCanonicalJson(value, 'compact'))
}
@@ -118,7 +127,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 +146,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 +173,24 @@ 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
function readOpenerRest(rest: string): Read<{ argument: string | undefined; attributes: DirectiveAttributes }> {
let cursor = 0
let argument: string | undefined
let attributes = noAttributes
if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) !== '{' && !lineEnd.test(rest.slice(cursor))) {
bareRun.lastIndex = cursor + 1
if (rest.charAt(0) === ' ' && rest.charAt(1) !== '{' && !lineEnd.test(rest)) {
bareRun.lastIndex = 1
argument = bareRun.exec(rest)?.[0]
if (argument === undefined) return { fault: malformedDirective(shapeFault) }
cursor += 1 + argument.length
if (argument === undefined) return { fault: malformedDirective(openerFault) }
cursor = 1 + argument.length
}
if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') {
const read = readAttributes(rest, cursor + 1, directiveLineEscape)
const read = readAttributesAt(rest, cursor + 1, false)
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
cursor = read.value.end
}
if (!lineEnd.test(rest.slice(cursor))) return { fault: malformedDirective(shapeFault) }
return { value: { argument, attributes, name } }
if (!lineEnd.test(rest.slice(cursor))) return { fault: malformedDirective(openerFault) }
return { value: { argument, attributes } }
}
function readNestedDirective(text: string, index: number, depth: number): Read<DirectiveSpan> | undefined {
@@ -199,23 +200,25 @@ function readNestedDirective(text: string, index: number, depth: number): Read<D
if (depth > 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<Content> {
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<Attributes> {
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 +237,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 +248,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 +259,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<Attributes> {
function readAttributes(text: string, index: number): Read<Attributes> {
const attributes = new Map<string, DirectiveValue>()
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 +278,13 @@ function readAttributes(text: string, index: number, escape: string): Read<Attri
cursor = pair.value.end
}
if (text.charAt(cursor) !== '}') return { fault: malformedDirective('the {attrs} closing brace is missing') }
return { value: { attributes, length: cursor + 1 - index } }
return { value: { attributes, end: cursor + 1 } }
}
function readAttributePair(text: string, index: number, escape: string): Read<AttributePair> {
function readAttributePair(text: string, index: number): Read<AttributePair> {
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 +293,7 @@ function readAttributePair(text: string, index: number, escape: string): Read<At
}
bareRun.lastIndex = start
const bare = bareRun.exec(text)?.[0]
if (bare === undefined) return { fault: attributePairFault(escape) }
if (bare === undefined) return { fault: malformedDirective(pairFault) }
return { value: { end: start + bare.length, key, value: { decoded: bare, spelling: bare } } }
}
+62 -59
View File
@@ -57,29 +57,29 @@ test('carries a text node attribute no spelling holds', () => {
})
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('&#97;df'), ':::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('&#97;df'), '!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<string>): boolean => markdown(result).startsWith(':')
const directive = (result: Result<string>): 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')
+30 -40
View File
@@ -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<string> {
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<EmittedBody> {
function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: ConvertErrorPath, depth: number): Result<string> {
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,36 @@ function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
}
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<EmittedBlock> {
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)
const leaf = blockDirectiveForm(node.type) === 'leaf'
if (leaf && content.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)
const opener = spellBlockDirectiveOpener(node, directive)
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (leaf) return success({ spelling: 'directive', text: opener })
const body = directive.contentModel === 'inline' ? emitInlineLine(content, 'paragraph', 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}` })
}
function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result<EmittedBody> {
const line = emitInlineLine(content, 'paragraph', path)
if (!line.ok) return line
return success({ fenceColons: 0, text: line.value })
return success(directivePair(node, opener, body.value))
}
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | 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<EmittedBlock> | undefined {
@@ -154,12 +147,11 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
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<string> {
@@ -203,15 +195,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 +211,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<EmittedBody> | undefined {
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<string> | 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<EmittedBlock> | undefined {
@@ -1,12 +1,12 @@
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 { directivePrefix, isBareToken, spellAttributes, 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
@@ -17,7 +17,7 @@ export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, s
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 `${directivePrefix}${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
}
function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined {
+3 -3
View File
@@ -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<Emission> {
@@ -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)
+1 -1
View File
@@ -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)
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { directivePrefix } from './directive-syntax.ts'
export const listBreakName = 'listBreak'
export const listBreakSpelling = `::${listBreakName}`
export const listBreakSpelling = `${directivePrefix}${listBreakName}`
+3 -4
View File
@@ -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<string> {
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<string> {
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<AdfNode> {
+26 -19
View File
@@ -69,44 +69,51 @@ test('leaves the paragraph a line no definition spells', () => {
test('swallows an HTML block to the end condition its start sets', () => {
assert.deepEqual(kinds('<div>\nx\n\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<!--\n:::\n-->\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<!--\n!adf:/panel\n-->\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<pre>x</pre>\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<div>\nx\n'), ['html'])
assert.deepEqual(kinds('Part.\n<div>\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`
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(faults('!adf:rule {localId=a-1}\nPart.\n!adf:/rule\n'), ['rule takes no body, so no !adf:/rule closes it'])
})
+29 -32
View File
@@ -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,7 +40,7 @@ export type DirectiveBlock = Extract<Block, { kind: 'directive' }>
type ListBlock = Extract<Block, { items: Block[][] }>
type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'directive'; parent: Block[]; position: SourcePosition }
type OpenDirective = { blocks: Block[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition }
type OpenContainer =
| Extract<Block, { kind: 'blockquote' }>
@@ -64,7 +64,6 @@ type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePositio
const indentedCodeColumns = 4
const largestOpenerIndentation = 3
const leafColons = 2
const tabStop = 4
export function parseBlocks(markdown: string): ParsedBlocks {
@@ -114,7 +113,7 @@ function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line }
function continuesContainer(walk: Walk, container: OpenContainer, 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.
// A directive container has no continuation marker: only its own closer 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)) {
@@ -202,7 +201,7 @@ function closeContainers(walk: Walk, depth: number): void {
for (const container of walk.stack.slice(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`),
kind: 'fault',
position: container.position,
}
@@ -214,50 +213,48 @@ function dropContainers(walk: Walk, depth: number): void {
walk.stack.length = depth
}
function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'header' }>): void {
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
if (directive.kind === 'closer') closeDirective(walk, directive.name)
else openDirective(walk, directive)
}
function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'opener' }>): void {
const { name } = directive
const block: DirectiveBlock = {
argument: directive.argument,
attributes: directive.attributes,
blocks: directive.colons > leafColons ? [] : undefined,
blocks: blockDirectiveForm(name) === 'container' ? [] : undefined,
kind: 'directive',
name: directive.name,
name,
position: walk.position,
}
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 })
if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, index: parent.length - 1, kind: 'directive', name, parent, position })
}
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
const enclosing = innermostDirective(walk)
if (directive.kind === 'closing') {
closeDirective(walk, directive.colons, enclosing)
function closeDirective(walk: Walk, name: string): void {
const closer = spellDirectiveCloser(name)
if (blockDirectiveForm(name) === 'leaf') {
pushFault(walk, malformedDirective(`${name} takes no body, so no ${closer} closes it`))
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`))
const depth = openDirectiveDepth(walk, name)
if (depth === undefined) {
pushFault(walk, malformedDirective(`the closer ${closer} closes no ${name} container open where it stands; ${directiveEscape}`))
return
}
openDirective(walk, directive)
closeContainers(walk, depth + 1)
dropContainers(walk, depth)
}
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}`))
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)
}
function innermostDirective(walk: Walk): { container: OpenDirective; depth: number } | undefined {
// A closer crosses no list item or blockquote edge.
function openDirectiveDepth(walk: Walk, name: string): 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 }
if (container?.kind !== 'directive') return undefined
if (container.name === name) return depth
}
return undefined
}
@@ -271,7 +268,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
}
+8 -11
View File
@@ -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<BlockDirectiveNode> {
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<AdfNode> {
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.
+130 -115
View File
@@ -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')
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,70 @@ 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`
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!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'))
assert.equal(content(markdownToAdf('!adf:rule {localId=a-1}\nPart.\n!adf:/rule\n')), 'malformed-directive: rule takes no body, so no !adf:/rule closes it')
})
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 +352,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 +381,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 +394,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 +419,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 +457,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 +515,12 @@ test('refuses the raw HTML no element mapping carries', () => {
assert.equal(code(markdownToAdf('<div\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<?php\n')), 'unmappable-html')
assert.deepEqual(path(markdownToAdf('Part.\n\n<div>\n')), ['content', 1])
assert.equal(code(markdownToAdf('<div>\nx\n\n:::\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<div>\nx\n\n!adf:/panel\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<div>\n- x\n</div>\n')), 'unmappable-html')
})
test('swallows an HTML block ahead of the claim a line inside it would make', () => {
assert.equal(code(markdownToAdf('<!--\n:::\n-->\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<!--\n!adf:/panel\n-->\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<div>\n| x |\n</div>\n')), 'unmappable-html')
})
@@ -514,15 +529,15 @@ test('names the line and the offset in the input a refusal sits at, the innermos
assert.deepEqual(position(markdownToAdf('Part.\n\n<div>\n')), { line: 3, offset: 7 })
assert.deepEqual(position(markdownToAdf('> Part.\n>\n> a <span>b</span>\n')), { line: 3, offset: 10 })
assert.deepEqual(position(markdownToAdf('- Part.\n- a <span>b</span>\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 <span>b</span>\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 <span>c</span>\n')), { line: 1, offset: 0 })
assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n<div>\r\n')), { line: 3, offset: 9 })
assert.deepEqual(position(markdownToAdf('a\u0000b\n\n<div>\n')), { line: 3, offset: 5 })
assert.deepEqual(position(markdownToAdf(':::caption\na <span>b</span>\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 <span>b</span>\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 +545,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\n<span>x</span>\n')), { line: 5, offset: 32 })
assert.deepEqual(position(markdownToAdf('[a]:\n<the url>\n"Title"\n<span>b</span>\n')), { line: 4, offset: 23 })
assert.deepEqual(position(markdownToAdf('> [a]: /url\n> <span>b</span>\n')), { line: 2, offset: 12 })
assert.deepEqual(position(markdownToAdf(':::caption\n[a]: /url\n<span>b</span>\n:::\n')), { line: 3, offset: 21 })
assert.deepEqual(position(markdownToAdf('!adf:caption\n[a]: /url\n<span>b</span>\n!adf:/caption\n')), { line: 3, offset: 23 })
})
test('gives up the link reference definitions a paragraph opens with', () => {
@@ -626,13 +641,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])
})
@@ -670,9 +685,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~~` `\\*` `&amp;`\n')), [
assert.deepEqual(content(markdownToAdf('`!adf:/panel` `~~x~~` `\\*` `&amp;`\n')), [
{
content: [codeSpan(':::panel'), text(' '), codeSpan('~~x~~'), text(' '), codeSpan('\\*'), text(' '), codeSpan('&amp;')],
content: [codeSpan('!adf:/panel'), text(' '), codeSpan('~~x~~'), text(' '), codeSpan('\\*'), text(' '), codeSpan('&amp;')],
type: 'paragraph',
},
])
+7 -11
View File
@@ -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<AdfNode> {
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<AdfNode> {
@@ -133,7 +129,7 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path
}
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
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<AdfNode> {
if (language === carryFence) {
if (language === carryName) {
const carried = readCarriedBlock(text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path)
return success(carried.value)
+2 -2
View File
@@ -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<string> | undefined {