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
87 changed files with 951 additions and 873 deletions
+5 -3
View File
@@ -133,9 +133,11 @@ reserved name is a known name, so never that code, and the two the flavour reser
a form the grammar does not have is a claim code — `!adf:carry`, whose carry is the fence — and a a form the grammar does not have is a claim code — `!adf:carry`, whose carry is the fence — and a
well-formed form in the wrong place is `unsupported-node-shape`, `!adf:listBreak` parting anything well-formed form in the wrong place is `unsupported-node-shape`, `!adf:listBreak` parting anything
but two adjacent lists of one type. What but two adjacent lists of one type. What
the grammar itself refuses stays a claim code, key order among it; a well-formed directive the the grammar itself refuses stays a claim code, key order among it, and a leaf given a body is refused
node tables refuse — an attribute a node does not hold or spells elsewhere, a value outside its at its opener, as a container missing its closer is (the maintainer, 2026-09-16); a well-formed
kind or its canonical spelling, an argument or a body its content model does not take — is directive the node tables refuse — an attribute a node does not hold or spells elsewhere, a value
outside its kind or its canonical spelling, an argument, or a body of a shape its content model does
not take — is
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code `unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code
across both directions for good, since the call site knows which direction it called and parting across both directions for good, since the call site knows which direction it called and parting
them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one
+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": {"type":
``` ```
+1 -1
View File
@@ -1,2 +1,2 @@
> Part. > Part.
::: !adf:/panel
+2 -2
View File
@@ -1,3 +1,3 @@
:::codeBlock {wrap=true} !adf:codeBlock {wrap=true}
fn main() {} 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() {} 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. 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. Part.
::: !adf:/media
+2 -2
View File
@@ -1,3 +1,3 @@
:::carry !adf:carry
{"type":"rule"} {"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. Part.
::: !adf:/widget
+1 -1
View File
@@ -1 +1 @@
::rule {localId="a-1"} !adf:rule {localId="a-1"}
+1
View File
@@ -0,0 +1 @@
malformed-directive
+7
View File
@@ -0,0 +1,7 @@
- Bolt M8
!adf:listBreak
Packed.
!adf:/listBreak
- Nut M8
+1 -1
View File
@@ -1,5 +1,5 @@
- Bolt M8 - Bolt M8
::listBreak !adf:listBreak
Packed. Packed.
@@ -1,23 +1,23 @@
- Bolt - Bolt
::listBreak !adf:listBreak
- Nut - Nut
1. Cut 1. Cut
::listBreak !adf:listBreak
1. Weld 1. Weld
:::panel info !adf:panel info
- Left - Left
::listBreak !adf:listBreak
- Right - Right
::: !adf:/panel
- - Head - - Head
::listBreak !adf:listBreak
- Tail - 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. 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\"}]"} !adf:layoutSection {localId=01a033d0-1393-790b-9d44-c572033ea4aa marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}
:::layoutColumn {width=100} !adf:layoutColumn {width=100}
Full width. Full width.
::: !adf:/layoutColumn
:::: !adf:/layoutSection
@@ -1,13 +0,0 @@
:::codeBlock {language=adf}
```
{
"type": "blockCard"
}
```
:::
:::codeBlock {language=adf}
````
```
````
:::
@@ -64,7 +64,7 @@
}, },
"content": [ "content": [
{ {
"text": ":::", "text": "!adf:/codeBlock",
"type": "text" "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 ```rust
fn main() {} fn main() {}
``` ```
::: !adf:/codeBlock
:::codeBlock {language="a\u0060b"} !adf:codeBlock {language="a\u0060b"}
``` ```
x x
``` ```
::: !adf:/codeBlock
:::codeBlock {language=" sql"} !adf:codeBlock {language=" sql"}
``` ```
SELECT 1 SELECT 1
``` ```
::: !adf:/codeBlock
:::codeBlock {language="\u0026#97;df"} !adf:codeBlock {language="\u0026#97;df"}
``` ```
y y
``` ```
::: !adf:/codeBlock
::::panel info !adf:panel info
:::codeBlock {wrap=true} !adf:codeBlock {wrap=true}
```text ```text
::: !adf:/codeBlock
``` ```
::: !adf:/codeBlock
:::: !adf:/panel
@@ -2,7 +2,7 @@
"content": [ "content": [
{ {
"attrs": { "attrs": {
"language": "adf" "language": "carry"
}, },
"content": [ "content": [
{ {
@@ -14,7 +14,7 @@
}, },
{ {
"attrs": { "attrs": {
"language": "adf" "language": "carry"
}, },
"content": [ "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 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 Release checklist
::: !adf:/heading
:::paragraph {marks="[{\"attrs\":{\"align\":\"center\"},\"type\":\"alignment\"}]"} !adf:paragraph {marks="[{\"attrs\":{\"align\":\"center\"},\"type\":\"alignment\"}]"}
Centred. Centred.
::: !adf:/paragraph
::::bulletList {localId=01a0431d-2015-737f-afad-33064295ad4f} !adf:bulletList {localId=01a0431d-2015-737f-afad-33064295ad4f}
:::listItem {localId=01a0431d-2016-7abb-823f-c0942934c878} !adf:listItem {localId=01a0431d-2016-7abb-823f-c0942934c878}
Tag the commit Tag the commit
::: !adf:/listItem
:::listItem !adf:listItem
Push the tag Push the tag
::: !adf:/listItem
:::: !adf:/bulletList
::::orderedList !adf:orderedList
:::listItem !adf:listItem
Loosen the clamp 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. > Hold the line.
> ::: > !adf:/paragraph
- :::paragraph {localId=01a0431d-2018-7667-a769-ab0037ca80aa} - !adf:paragraph {localId=01a0431d-2018-7667-a769-ab0037ca80aa}
Indented. 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 Seventh
::: !adf:/heading
:::heading !adf:heading
Levelless Levelless
::: !adf:/heading
::::orderedList {order=999999998} !adf:orderedList {order=999999998}
:::listItem !adf:listItem
One One
::: !adf:/listItem
:::listItem !adf:listItem
Two Two
::: !adf:/listItem
:::listItem !adf:listItem
Three 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. The build ran for 11 minutes.
:::panel warning !adf:panel warning
Three warnings went unread. Three warnings went unread.
::: !adf:/panel
:::: !adf:/expand
@@ -1,16 +1,16 @@
::::panel info !adf:panel info
- :::panel warning - !adf:panel warning
Check the torque before signing off. Check the torque before signing off.
::: !adf:/panel
- - :::expand {title="Deeper still"} - - !adf:expand {title="Deeper still"}
Two list levels down. Two list levels down.
::: !adf:/expand
> :::expand {title="Full build log"} > !adf:expand {title="Full build log"}
> The build ran for 11 minutes. > The build ran for 11 minutes.
> ::: > !adf:/expand
> >
> :::panel note > !adf:panel note
> Superseded by the next run. > Superseded by the next run.
> ::: > !adf:/panel
:::: !adf:/panel
@@ -1,8 +1,8 @@
::::decisionList {localId=01a033c4-7aa0-723f-bd4b-018e0aa0103f} !adf:decisionList {localId=01a033c4-7aa0-723f-bd4b-018e0aa0103f}
:::decisionItem {localId=01a033c8-0df4-74df-a9f4-c2c4aa3cc2c1 state=DECIDED} !adf:decisionItem {localId=01a033c8-0df4-74df-a9f4-c2c4aa3cc2c1 state=DECIDED}
Ship on Friday. Ship on Friday.
::: !adf:/decisionItem
:::decisionItem {localId=01a033cb-e13c-79b2-933f-b3cedd57e33e state=DECIDED} !adf:decisionItem {localId=01a033cb-e13c-79b2-933f-b3cedd57e33e state=DECIDED}
Keep the old endpoint for one release. 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. The build ran for 11 minutes.
Warnings: 3. 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. 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. Handle with care.
::: !adf:/bodiedExtension
::::multiBodiedExtension {extensionKey=tabs extensionType="com.atlassian.confluence.macro.core" localId=01a033de-845f-70e2-abcf-cd5d23ba6101} !adf:multiBodiedExtension {extensionKey=tabs extensionType="com.atlassian.confluence.macro.core" localId=01a033de-845f-70e2-abcf-cd5d23ba6101}
:::extensionFrame !adf:extensionFrame
Tab one. Tab one.
::: !adf:/extensionFrame
:::extensionFrame !adf:extensionFrame
Tab two. Tab two.
::: !adf:/extensionFrame
:::: !adf:/multiBodiedExtension
+20 -20
View File
@@ -1,26 +1,26 @@
::::mediaSingle {layout=center} !adf:mediaSingle {layout=center}
::media {alt="The moon, at night." type=external url="https://example.com/moon.png"} !adf:media {alt="The moon, at night." type=external url="https://example.com/moon.png"}
:::caption !adf:caption
Taken from Kiruna. Taken from Kiruna.
::: !adf:/caption
:::: !adf:/mediaSingle
:::mediaSingle {layout=center localId=01a03394-6721-7075-bd6e-fd9a57b64b56} !adf:mediaSingle {layout=center localId=01a03394-6721-7075-bd6e-fd9a57b64b56}
::media {alt="Torque chart" type=external url="https://example.com/torque.png"} !adf:media {alt="Torque chart" type=external url="https://example.com/torque.png"}
::: !adf:/mediaSingle
:::mediaSingle {layout=center} !adf:mediaSingle {layout=center}
::media {height=480 type=external url="https://example.com/plan.png" width=640} !adf:media {height=480 type=external url="https://example.com/plan.png" width=640}
::: !adf:/mediaSingle
:::mediaSingle {layout=center} !adf: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: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} !adf:mediaSingle {layout=center}
::media {alt="" type=external url="https://example.com/moon.png"} !adf:media {alt="" type=external url="https://example.com/moon.png"}
::: !adf:/mediaSingle
:::mediaSingle {layout=center} !adf:mediaSingle {layout=center}
::media {type=external url="https://example.com/plan.png?a=1\u0026amp;b=2"} !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} !adf:layoutSection {localId=01a033cc-4b60-7b5e-85f4-34f27f039e9a}
:::layoutColumn {localId=01a03389-a5fb-747d-b418-e91f1b161e7f width=50} !adf:layoutColumn {localId=01a03389-a5fb-747d-b418-e91f1b161e7f width=50}
Left. Left.
::: !adf:/layoutColumn
:::layoutColumn {valign=top width=50} !adf:layoutColumn {valign=top width=50}
Right. Right.
::: !adf:/layoutColumn
:::: !adf:/layoutSection
+4 -4
View File
@@ -1,4 +1,4 @@
:::mediaGroup !adf:mediaGroup
::media {collection=contentId-98237 id=01a03387-e300-7bcf-9a41-c27760696449 occurrenceKey=01a0338a-2f44-764f-a7b7-1c1a2abbb49b type=file} !adf: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:media {collection=contentId-98237 id=01a03393-0b0b-71f6-89cd-dd654f42cf34 type=link}
::: !adf:/mediaGroup
@@ -1,10 +1,10 @@
::::mediaSingle {layout=center width=50} !adf:mediaSingle {layout=center width=50}
::media {collection=MediaServicesSample id=4478e39c-cf9b-41d1-ba92-68589487cd75 type=file} !adf:media {collection=MediaServicesSample id=4478e39c-cf9b-41d1-ba92-68589487cd75 type=file}
:::caption {localId=01a03389-670b-7331-ae60-0446747ace7a} !adf:caption {localId=01a03389-670b-7331-ae60-0446747ace7a}
The moon, at night. The moon, at night.
::: !adf:/caption
:::: !adf:/mediaSingle
:::mediaSingle {layout=wrap-right width="33.33" widthType=percentage} !adf: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: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": [ "content": [
{ {
"text": ":::::::panel warning\n:::", "text": "!adf:panel warning\n!adf:/panel",
"type": "text" "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. 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. 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. 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. Shared release notes.
::: !adf:/bodiedSyncBlock
@@ -1,20 +1,20 @@
:::::table {displayMode=fixed isNumberColumnEnabled=true layout=default localId=01a0339e-c34f-7658-9d98-eb059401b7ab width=760} !adf:table {displayMode=fixed isNumberColumnEnabled=true layout=default localId=01a0339e-c34f-7658-9d98-eb059401b7ab width=760}
::::tableRow !adf:tableRow
:::tableHeader {colspan=2 colwidth="[340,420]"} !adf:tableHeader {colspan=2 colwidth="[340,420]"}
Assembly Assembly
::: !adf:/tableHeader
:::: !adf:/tableRow
::::tableRow {localId=01a033a9-d034-7466-b916-68643be958ff} !adf:tableRow {localId=01a033a9-d034-7466-b916-68643be958ff}
:::tableCell {background="#deebff" localId=01a03389-c573-72a6-a036-6cd9c6865301 rowspan=2} !adf:tableCell {background="#deebff" localId=01a03389-c573-72a6-a036-6cd9c6865301 rowspan=2}
Bolt M8 Bolt M8
::: !adf:/tableCell
:::tableCell {valign=top} !adf:tableCell {valign=top}
40 40
::: !adf:/tableCell
:::: !adf:/tableRow
::::tableRow !adf:tableRow
:::tableCell !adf:tableCell
60 60
::: !adf:/tableCell
:::: !adf:/tableRow
::::: !adf:/table
@@ -1,18 +1,18 @@
:::::table !adf:table
::::tableRow !adf:tableRow
:::tableHeader !adf:tableHeader
Pattern Pattern
::: !adf:/tableHeader
:::tableHeader !adf:tableHeader
Meaning Meaning
::: !adf:/tableHeader
:::: !adf:/tableRow
::::tableRow !adf:tableRow
:::tableCell !adf:tableCell
`a|b` `a|b`
::: !adf:/tableCell
:::tableCell !adf:tableCell
Either a or b 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} !adf:taskList {localId=0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90}
:::taskItem DONE {localId=0198f3a2-8d52-70b1-8c4f-5e9f3d2b7ca1} !adf:taskItem DONE {localId=0198f3a2-8d52-70b1-8c4f-5e9f3d2b7ca1}
Write the spec Write the spec
::: !adf:/taskItem
:::taskItem TODO {localId=0198f3a2-9e63-7d80-a15b-6fa04e3c8db2} !adf:taskItem TODO {localId=0198f3a2-9e63-7d80-a15b-6fa04e3c8db2}
Ship it Ship it
::: !adf:/taskItem
:::blockTaskItem TODO {localId=01a033b2-95c8-7d62-9b9c-dee7ad510982} !adf:blockTaskItem TODO {localId=01a033b2-95c8-7d62-9b9c-dee7ad510982}
Check the torque. Check the torque.
Then sign off. Then sign off.
::: !adf:/blockTaskItem
::::taskList {localId=01a033b3-d1fa-7489-8c9f-f5846d2c249d} !adf:taskList {localId=01a033b3-d1fa-7489-8c9f-f5846d2c249d}
:::taskItem TODO {localId=01a033bd-a22d-7fb5-be9d-89d1aa261112} !adf:taskItem TODO {localId=01a033bd-a22d-7fb5-be9d-89d1aa261112}
Nested step Nested step
::: !adf:/taskItem
::taskItem TODO !adf:taskItem TODO
:::: !adf:/taskItem
::::: !adf:/taskList
!adf:/taskList
@@ -1,11 +1,11 @@
:::::table {isNumberColumnEnabled=true layout=wide width="760.5"} !adf:table {isNumberColumnEnabled=true layout=wide width="760.5"}
::::tableRow !adf:tableRow
:::tableHeader {colspan=2 colwidth="[340,420]"} !adf:tableHeader {colspan=2 colwidth="[340,420]"}
Assembly 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. 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"} !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)"} [a!adf:inlineCard{url="b](c)"}
@@ -1,4 +1,4 @@
```adf ```carry
{ {
"attrs": { "attrs": {
"rounded": true "rounded": true
@@ -18,7 +18,7 @@
} }
``` ```
```adf ```carry
{ {
"attrs": { "attrs": {
"panelType": "extra info" "panelType": "extra info"
@@ -21,7 +21,7 @@
{ {
"content": [ "content": [
{ {
"text": "::syncBlock {localId=x}", "text": "!adf:syncBlock {localId=x}",
"type": "text" "type": "text"
} }
], ],
@@ -2,9 +2,9 @@ Write \!adf:mention[@Mikael]{id=5b10a2} to tag someone.
Namespaced under \!adf: alone. 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. 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 | \| a | b |
::: !adf:/panel
@@ -12,7 +12,7 @@
{ {
"content": [ "content": [
{ {
"text": ":::", "text": "!adf:/panel",
"type": "text" "type": "text"
} }
], ],
@@ -29,7 +29,7 @@
{ {
"content": [ "content": [
{ {
"text": ":::", "text": "!adf:/panel",
"type": "text" "type": "text"
} }
], ],
@@ -41,7 +41,7 @@
{ {
"content": [ "content": [
{ {
"text": ":::", "text": "!adf:/panel",
"type": "text" "type": "text"
} }
], ],
@@ -50,7 +50,7 @@
{ {
"content": [ "content": [
{ {
"text": "::: x", "text": "!adf:/panel x",
"type": "text" "type": "text"
} }
], ],
@@ -59,7 +59,7 @@
{ {
"content": [ "content": [
{ {
"text": "::", "text": "!adf:rule",
"type": "text" "type": "text"
} }
], ],
@@ -71,7 +71,7 @@
{ {
"content": [ "content": [
{ {
"text": ":::", "text": "!adf:/panel",
"type": "text" "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 !adf:layoutSection
::::layoutColumn {width=50} !adf:layoutColumn {width=50}
:::panel info !adf:panel info
Check the collation before importing. Check the collation before importing.
::: !adf:/panel
:::: !adf:/layoutColumn
:::::layoutColumn {width=50} !adf:layoutColumn {width=50}
::::taskList {localId=0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90} !adf:taskList {localId=0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90}
:::taskItem DONE {localId=0198f3a2-8d52-70b1-8c4f-5e9f3d2b7ca1} !adf:taskItem DONE {localId=0198f3a2-8d52-70b1-8c4f-5e9f3d2b7ca1}
Write the spec Write the spec
::: !adf:/taskItem
:::taskItem TODO {localId=0198f3a2-9e63-7d80-a15b-6fa04e3c8db2} !adf:taskItem TODO {localId=0198f3a2-9e63-7d80-a15b-6fa04e3c8db2}
Ship it Ship it
::: !adf:/taskItem
:::: !adf:/taskList
::::: !adf:/layoutColumn
:::::: !adf:/layoutSection
::::mediaSingle {layout=center width=50} !adf:mediaSingle {layout=center width=50}
::media {collection=MediaServicesSample id=4478e39c-cf9b-41d1-ba92-68589487cd75 type=file} !adf:media {collection=MediaServicesSample id=4478e39c-cf9b-41d1-ba92-68589487cd75 type=file}
:::caption !adf:caption
The moon, at night. The moon, at night.
::: !adf:/caption
:::: !adf:/mediaSingle
::::::expand {title="Full build log"} !adf:expand {title="Full build log"}
:::::table {isNumberColumnEnabled=true} !adf:table {isNumberColumnEnabled=true}
::::tableRow !adf:tableRow
:::tableHeader !adf:tableHeader
Step Step
::: !adf:/tableHeader
:::: !adf:/tableRow
::::tableRow !adf:tableRow
:::tableCell {background="#deebff"} !adf:tableCell {background="#deebff"}
Compile 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. Do not skip the pre-flight.
::: !adf:/panel
@@ -1,20 +1,20 @@
::::bulletList !adf:bulletList
:::listItem !adf:listItem
``` ```
a a
``` ```
::: !adf:/listItem
:::: !adf:/bulletList
::::::orderedList {order=1} !adf:orderedList {order=1}
:::::listItem !adf:listItem
::::bulletList !adf:bulletList
:::listItem !adf:listItem
``` ```
``` ```
::: !adf:/listItem
:::: !adf:/bulletList
::::: !adf:/listItem
:::::: !adf:/orderedList
@@ -1,5 +1,6 @@
First. First.
::paragraph !adf:paragraph
!adf:/paragraph
Second. Second.
@@ -1,4 +1,4 @@
> ```adf > ```carry
> { > {
> "attrs": { > "attrs": {
> "url": "https://example.com/quoted" > "url": "https://example.com/quoted"
@@ -7,7 +7,7 @@
> } > }
> ``` > ```
- ```adf - ```carry
{ {
"attrs": { "attrs": {
"url": "https://example.com/listed" "url": "https://example.com/listed"
@@ -1,4 +1,4 @@
```adf ```carry
{ {
"attrs": { "attrs": {
"url": "https://example.com/roadmap" "url": "https://example.com/roadmap"
@@ -7,10 +7,10 @@
} }
``` ```
:::panel info !adf:panel info
The card below has no spelling yet. The card below has no spelling yet.
```adf ```carry
{ {
"attrs": { "attrs": {
"layout": "wide", "layout": "wide",
@@ -20,4 +20,4 @@ The card below has no spelling yet.
"type": "embedCard" "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')) { for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
test(`normalization/${name} parses to the document beside it, which emits and reads back to itself`, () => { 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')) const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8'))
+38 -25
View File
@@ -11,8 +11,18 @@ import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRu
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
import { blockArgument } from './markdown/block-directive-arguments.ts' import { blockArgument } from './markdown/block-directive-arguments.ts'
import { blockDirectives } from './adf/block-directives.ts' import { blockDirectives } from './adf/block-directives.ts'
import { carryFence, carryName } from './markdown/opaque-carry.ts' import { carryName } from './markdown/opaque-carry.ts'
import { directivePrefix, spellAttributes, spellInlineDirectiveOpener, spellJsonAttribute, spellLeafDirective, spellStringAttribute, spellVocabulary } from './markdown/directive-syntax.ts' import {
directivePrefix,
spellAttributes,
spellDirectiveCloser,
spellDirectiveOpener,
spellInlineDirectiveOpener,
spellInlineLeafDirective,
spellJsonAttribute,
spellStringAttribute,
spellVocabulary,
} from './markdown/directive-syntax.ts'
import { fencedCodeBlock } from './markdown/backtick-runs.ts' import { fencedCodeBlock } from './markdown/backtick-runs.ts'
import { inlineDirectives } from './adf/inline-directives.ts' import { inlineDirectives } from './adf/inline-directives.ts'
import { listBreakName } from './markdown/list-break.ts' import { listBreakName } from './markdown/list-break.ts'
@@ -73,8 +83,8 @@ const syntaxTokens = fc.constantFrom(
'![', '![',
'](', '](',
'{}', '{}',
'::', `${directivePrefix}/`,
':::', `${directivePrefix}panel `,
'> ', '> ',
'- ', '- ',
'* ', '* ',
@@ -107,8 +117,8 @@ const autolink = fc.oneof(
) )
const hostileAutolink = fc.tuple(fc.constantFrom('http://', 'ab:', 'a:'), textOf(0)).map(([scheme, rest]) => `<${scheme}${rest}>`) const 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 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 hardBreak = fc.constantFrom('\\\n', ' \n', '\n', spellInlineLeafDirective('hardBreak', ''))
const spellTextDirective = (held: string) => spellLeafDirective(textDirectiveName, `{${textDirectiveName}=${spellStringAttribute(held)}}`) const spellTextDirective = (held: string) => spellInlineLeafDirective(textDirectiveName, `{${textDirectiveName}=${spellStringAttribute(held)}}`)
const textDirective = fc.constantFrom(' ', ' ', '\t', '\n', '\n\n').map(spellTextDirective) const textDirective = fc.constantFrom(' ', ' ', '\t', '\n', '\n\n').map(spellTextDirective)
const hostileTextDirective = fc.constantFrom(' \n', 'a', '').map(spellTextDirective) const hostileTextDirective = fc.constantFrom(' \n', 'a', '').map(spellTextDirective)
@@ -151,24 +161,23 @@ const hostileArgument = fc.oneof(
{ arbitrary: fc.oneof(bareToken.map((held) => ` ${held}`), fc.constantFrom(' info', ' a b', ' "a"')), weight: 1 }, { 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 { function hostileOpener(name: string, argument: string, attrs: string): string {
return `${':'.repeat(colons)}${name}${argument}${attrs === '' ? '' : ` ${attrs}`}` return `${directivePrefix}${name}${argument}${attrs === '' ? '' : ` ${attrs}`}`
} }
function container(header: (colons: number) => string, body: string, closer: number | null): string { function container(opener: string, body: string, closer: string): string {
const colons = Math.max(2, ...[...body.matchAll(/:{2,}/g)].map(([run]) => run.length)) + 1 return [opener, body, closer].filter((line) => line !== '').join('\n')
return [header(colons), ...(body === '' ? [] : [body]), ':'.repeat(closer ?? colons)].join('\n')
} }
const carriedNode = fc.oneof( 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 })), 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' })), 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 inlineCarry = carriedNode.map((node) => spellCarry(serializeCanonicalJson(node, 'compact')))
const hostileInlineCarry = fc.oneof(carriedNode, jsonValue).map((node) => spellCarry(JSON.stringify(node, null, 1))) 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 blockCarry = carriedNode.map((node) => fencedCodeBlock(carryName, serializeCanonicalJson(node, 'two-space')))
const hostileBlockCarry = fc.oneof(carriedNode, jsonValue).map((node) => fencedCodeBlock(carryFence, JSON.stringify(node))) const hostileBlockCarry = fc.oneof(carriedNode, jsonValue).map((node) => fencedCodeBlock(carryName, JSON.stringify(node)))
function prefixLines(body: string, first: string, rest: (index: number) => string): string { function prefixLines(body: string, first: string, rest: (index: number) => string): string {
return body return body
@@ -189,7 +198,7 @@ const listMarker = fc.oneof(
}, },
) )
const indentDrift = fc.oneof({ arbitrary: fc.constant(0), weight: 6 }, { arbitrary: fc.integer({ max: 2, min: -2 }), weight: 1 }) const 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> { function markdownOf(hostile: boolean): Arbitrary<string> {
const inline = inlineMarkdown(hostile) const inline = inlineMarkdown(hostile)
@@ -227,7 +236,7 @@ function inlineMarkdown(hostile: boolean): InlineMarkdown {
...Object.entries(inlineDirectives).map(([name, directive]) => ...Object.entries(inlineDirectives).map(([name, directive]) =>
fc fc
.tuple(directive.textAttribute === undefined ? fc.constant(null) : fc.option(hostile ? word : prose), tableAttributes(directive.attributes, directive.textAttribute)) .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) ...Object.entries(markAttributes)
.filter(([name]) => hostile || markSpelling(name)?.kind === 'directive') .filter(([name]) => hostile || markSpelling(name)?.kind === 'directive')
@@ -255,7 +264,7 @@ function leafBlocks(hostile: boolean, { destination, inlines, label, oneLine, te
.tuple( .tuple(
fc.constantFrom('```', '```', '~~~', '````', '``'), fc.constantFrom('```', '```', '~~~', '````', '``'),
fc.oneof(fc.constant(''), bareToken, text), 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'), fc.constantFrom('', '', '`', '~', 'none'),
) )
.map(([fence, info, lines, closer]) => [`${fence}${info}`, ...lines, ...(closer === 'none' ? [] : [`${fence}${closer}`])].join('\n')) .map(([fence, info, lines, closer]) => [`${fence}${info}`, ...lines, ...(closer === 'none' ? [] : [`${fence}${closer}`])].join('\n'))
@@ -302,11 +311,15 @@ function leafBlocks(hostile: boolean, { destination, inlines, label, oneLine, te
weight: 1, weight: 1,
}, },
{ {
arbitrary: fc.tuple(fc.constantFrom(2, 2, 3, 1, 4), directiveName, hostileArgument, hostileAttributes).map(([colons, name, argument, attrs]) => directiveHeader(colons, name, argument, attrs)), arbitrary: fc.tuple(directiveName, hostileArgument, hostileAttributes).map(([name, argument, attrs]) => hostileOpener(name, argument, attrs)),
hostile: true,
weight: 1,
},
{
arbitrary: fc.constantFrom(`${directivePrefix}/`, spellDirectiveCloser('panel'), `${directivePrefix}panel`, `${directivePrefix} panel`, `${directivePrefix}panel info extra`, `${directivePrefix}Panel`),
hostile: true, hostile: true,
weight: 1, weight: 1,
}, },
{ arbitrary: fc.constantFrom(':::', '::', '::::', ':::panel', '::: panel', ':::panel info extra'), hostile: true, weight: 1 },
]) ])
return { fencedCode, leafBlock } return { fencedCode, leafBlock }
} }
@@ -318,13 +331,13 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, {
const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => { const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => {
const argument = const argument =
blockArgument(name) === undefined blockArgument(name) === undefined
? fc.constant('') ? fc.constant(undefined)
: fc.oneof({ arbitrary: fc.constantFrom(' DONE', ' TODO', ' custom', ' info', ' warning'), weight: 3 }, { arbitrary: bareToken.map((held) => ` ${held}`), weight: 1 }) : fc.oneof({ arbitrary: fc.constantFrom('DONE', 'TODO', 'custom', 'info', 'warning'), weight: 3 }, { arbitrary: bareToken, weight: 1 })
const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(directive.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(directive.attributes) const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(directive.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(directive.attributes)
if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => directiveHeader(2, name, held, spelled)) if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => spellDirectiveOpener(name, held, spelled))
return fc return fc
.tuple(argument, attrs, bodyByModel[directive.contentModel], hostile ? closerDrift : fc.constant(null)) .tuple(argument, attrs, bodyByModel[directive.contentModel], hostile ? closerDrift : fc.constant(null))
.map(([held, spelled, body, closer]) => container((colons) => directiveHeader(colons, name, held, spelled), body, closer)) .map(([held, spelled, body, closer]) => container(spellDirectiveOpener(name, held, spelled), body, closer ?? spellDirectiveCloser(name)))
}) })
return { return {
block: choose( block: choose(
@@ -352,8 +365,8 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, {
{ arbitrary: fc.oneof(...tableDirectives), weight: 3 }, { arbitrary: fc.oneof(...tableDirectives), weight: 3 },
{ {
arbitrary: fc arbitrary: fc
.tuple(directiveName, hostileArgument, hostileAttributes, tie('blocks'), fc.option(fc.integer({ max: 5, min: 3 }), { freq: 2 }), closerDrift) .tuple(directiveName, hostileArgument, hostileAttributes, tie('blocks'), closerDrift)
.map(([name, argument, attrs, body, opener, closer]) => container((colons) => directiveHeader(opener ?? colons, name, argument, attrs), body, closer)), .map(([name, argument, attrs, body, closer]) => container(hostileOpener(name, argument, attrs), body, closer ?? spellDirectiveCloser(name))),
hostile: true, hostile: true,
weight: 1, 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 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 { holdsControlCharacter } from './commonmark-grammar.ts'
import { holdsEntityReference } from './entity-references.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. // spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
export function languageSlot(language: JsonValue | undefined): LanguageSlot { export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (language === undefined) return { kind: 'none' } 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' } if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
return { info: language, kind: 'fence' } return { info: language, kind: 'fence' }
} }
+1 -6
View File
@@ -53,11 +53,10 @@ const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/ const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
const blankLine = /^[ \t]*$/ const blankLine = /^[ \t]*$/
const codeFenceOpener = /^(`{3,}|~{3,})/ const codeFenceOpener = /^(`{3,}|~{3,})/
const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/
const pipeClaim = /^\|/ const pipeClaim = /^\|/
const bulletListOpener = /^[*+-](?:[ \t]|$)/ const bulletListOpener = /^[*+-](?:[ \t]|$)/
// A superset of what the parser claims: over-escaping a line is safe, under-escaping one breaks the round-trip. // 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. // 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 emailNameSource = "[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+"
const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[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 return isAsciiPunctuation(escaped) ? escaped : undefined
} }
export function claimsDirectiveLine(line: string): boolean {
return directiveClaim.test(line)
}
export function claimsLine(line: string, position: LinePosition): boolean { export function claimsLine(line: string, position: LinePosition): boolean {
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line) 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 }])) 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 } { function opener(name: string, argument?: string, ...pairs: Pair[]): { value: DirectiveLine } {
return { value: { argument, attributes: attributes(...pairs), colons, kind: 'header', name } } return { value: { argument, attributes: attributes(...pairs), kind: 'opener', name } }
} }
function fault(line: string): string { 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 }) 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('Part.'), undefined)
assert.equal(readDirectiveLine(':: two'), undefined) assert.equal(readDirectiveLine(':::panel info'), undefined)
assert.equal(readDirectiveLine(':panel'), undefined) assert.equal(readDirectiveLine(' !adf:rule'), undefined)
assert.equal(readDirectiveLine(' ::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', () => { test('reads a closer as the name it closes, and nothing after the name', () => {
assert.deepEqual(readDirectiveLine(':::'), { value: { colons: 3, kind: 'closing' } }) assert.deepEqual(readDirectiveLine('!adf:/panel'), { value: { kind: 'closer', name: 'panel' } })
assert.deepEqual(readDirectiveLine('::'), { value: { colons: 2, kind: 'closing' } }) assert.deepEqual(readDirectiveLine('!adf:/panel \t'), { value: { kind: 'closer', name: 'panel' } })
assert.deepEqual(readDirectiveLine(':::::: \t'), { value: { colons: 6, kind: 'closing' } }) 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', () => { test('reads the opener, its argument and its attributes', () => {
assert.deepEqual(readDirectiveLine('::rule'), header(2, 'rule')) assert.deepEqual(readDirectiveLine('!adf:rule'), opener('rule'))
assert.deepEqual(readDirectiveLine('::rule '), header(2, 'rule')) assert.deepEqual(readDirectiveLine('!adf:rule \t'), opener('rule'))
assert.deepEqual(readDirectiveLine('::taskItem TODO'), header(2, 'taskItem', 'TODO')) assert.deepEqual(readDirectiveLine('!adf:rule\t'), opener('rule'))
assert.deepEqual(readDirectiveLine('::media {id=a-1 type=file}'), header(2, 'media', undefined, ['id', 'a-1'], ['type', 'file'])) assert.deepEqual(readDirectiveLine('!adf:taskItem TODO'), opener('taskItem', 'TODO'))
assert.deepEqual(readDirectiveLine('::panel info {panelColor="#ff0000"} '), header(2, 'panel', 'info', ['panelColor', '#ff0000', '"#ff0000"'])) assert.deepEqual(readDirectiveLine('!adf:media {id=a-1 type=file}'), opener('media', undefined, ['id', 'a-1'], ['type', 'file']))
assert.deepEqual(readDirectiveLine(':::panel info'), header(3, 'panel', 'info')) 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', () => { 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( assert.deepEqual(
readDirectiveLine('::extension {text="a\\u0060b\\u0026c\\u003cd\\u007ce"}'), readDirectiveLine('!adf:extension {text="a\\u0060b\\u0026c\\u003cd\\u007ce"}'),
header(2, 'extension', undefined, ['text', 'a`b&c<d|e', '"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('!adf:extension {text="a\\"b\\\\c\\nd"}'), opener('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="}{"}'), opener('extension', undefined, ['text', '}{', '"}{"']))
}) })
test('names the {attrs} keys read out of the alphabetical order canonical form spells', () => { 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.equal(fault('!adf: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.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', () => { 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 {}' const omitted = 'an empty {attrs} is omitted unless the { itself claims the directive: this one spells {}'
assert.equal(fault('::rule {}'), omitted) assert.equal(fault('!adf:rule {}'), omitted)
assert.equal(fault(':::panel info {}'), omitted) assert.equal(fault('!adf:panel info {}'), omitted)
assert.equal(inline('!adf:underline[a]{}'), omitted) assert.equal(inline('!adf:underline[a]{}'), omitted)
spans('!adf:hardBreak{}', 'hardBreak', undefined) spans('!adf:hardBreak{}', 'hardBreak', undefined)
}) })
test('names the directive line no spelling reads', () => { test('names the opener 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') 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('::1panel'), 'a directive name reads [a-z][A-Za-z0-9]*: this one does not; \\::: keeps the line literal text') assert.equal(fault('!adf:panel info'), shape)
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('!adf:panel info extra'), shape)
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('!adf:panel "info"'), shape)
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('!adf:panel info{}'), shape)
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('!adf:panel {a=1} x'), shape)
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 attributes no spelling reads', () => { test('names the attributes no spelling reads', () => {
assert.equal(fault('::panel {a=1'), 'the {attrs} closing brace is missing') assert.equal(fault('!adf:panel {a=1'), 'the {attrs} closing brace is missing')
assert.equal(fault('::panel {a="x}'), 'the {attrs} quoted value is unclosed') assert.equal(fault('!adf: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('!adf: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('!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('::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('!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('::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('!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('::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('!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('::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('!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('::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('!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('::panel {a=1 a=2}'), 'the attribute key a is spelled twice') 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') 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', () => { 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('!adf: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('!adf: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('!adf: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 \\u007c')
}) })
test('claims at the prefix, and reads a directive only where a bracket or a brace follows the name', () => { test('claims at the prefix, and reads a directive only where a bracket or a brace follows the name', () => {
+85 -73
View File
@@ -1,11 +1,10 @@
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts' import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
import type { ConvertFault } from '../result.ts' import type { ConvertFault } from '../result.ts'
import type { JsonValue } from '../json-value.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 { backtickRun, closingBacktickRun } from './backtick-runs.ts'
import { isJsonValue, overNested } from '../json-value.ts' import { isJsonValue, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { runLength } from './emphasis-matching.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
export type AttributeReading = { refusal: 'kind' | 'nesting'; value?: undefined } | { refusal?: undefined; value: VocabularyValue } 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 DirectiveAttributes = ReadonlyMap<string, DirectiveValue>
export type DirectiveLine = export type DirectiveLine =
| { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string } | { argument: string | undefined; attributes: DirectiveAttributes; kind: 'opener'; name: string }
| { colons: number; kind: 'closing' } | { kind: 'closer'; name: string }
export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; 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 } 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 AttributePair = { end: number; key: string; value: DirectiveValue }
type Content = { content: string | undefined; end: number }
export const directivePrefix = '!adf:' export const directivePrefix = '!adf:'
const bareTokenSource = '[A-Za-z0-9_-]+' const bareTokenSource = '[A-Za-z0-9_-]+'
@@ -40,15 +41,14 @@ const quotedEscapes = new RegExp(reservedSource, 'g')
const rawReserved = new RegExp(reservedSource) const rawReserved = new RegExp(reservedSource)
const noAttributes: DirectiveAttributes = new Map() const noAttributes: DirectiveAttributes = new Map()
export const directiveLineEscape = '\\::: keeps the line literal text' export const directiveEscape = `\\${directivePrefix} keeps the prefix literal`
export const inlineDirectiveEscape = `\\${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 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 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 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; ${inlineDirectiveEscape}` const prefixFault = `an unescaped ${directivePrefix} completes no directive; ${directiveEscape}`
const shapeFault = `a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveLineEscape}`
export function attributeValue(text: string, kind: AttributeKind): AttributeReading { export function attributeValue(text: string, kind: AttributeKind): AttributeReading {
if (kind === 'string') return { value: { kind, value: text } } if (kind === 'string') return { value: { kind, value: text } }
@@ -76,13 +76,8 @@ export function malformedDirective(message: string): ConvertFault {
} }
export function readDirectiveLine(line: string): Read<DirectiveLine> | undefined { export function readDirectiveLine(line: string): Read<DirectiveLine> | undefined {
if (!claimsDirectiveLine(line)) return undefined if (!claimsDirectivePrefix(line, 0)) return undefined
const colons = runLength(line, 0) return line.charAt(directivePrefix.length) === '/' ? readCloserLine(line) : readOpenerLine(line)
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 } }
} }
export function readInlineDirective(text: string, index: number): Read<DirectiveSpan> | undefined { export function readInlineDirective(text: string, index: number): Read<DirectiveSpan> | undefined {
@@ -110,6 +105,14 @@ export function spellAttributes(pairs: readonly (readonly [string, string])[]):
return `{${spelled.join(' ')}}` return `{${spelled.join(' ')}}`
} }
export function spellDirectiveCloser(name: string): string {
return `${directivePrefix}/${name}`
}
export function spellDirectiveOpener(name: string, argument: string | undefined, attributes: string): string {
return `${directivePrefix}${name}${argument === undefined ? '' : ` ${argument}`}${attributes === '' ? '' : ` ${attributes}`}`
}
export function spellJsonAttribute(value: JsonValue): string { export function spellJsonAttribute(value: JsonValue): string {
return quote(serializeCanonicalJson(value, 'compact')) return quote(serializeCanonicalJson(value, 'compact'))
} }
@@ -118,7 +121,7 @@ export function spellInlineDirectiveOpener(name: string): string {
return `${directivePrefix}${name}[` return `${directivePrefix}${name}[`
} }
export function spellLeafDirective(name: string, attributes: string): string { export function spellInlineLeafDirective(name: string, attributes: string): string {
return `${directivePrefix}${name}${attributes === '' ? '{}' : attributes}` return `${directivePrefix}${name}${attributes === '' ? '{}' : attributes}`
} }
@@ -137,18 +140,14 @@ export function spellVocabulary(pairs: readonly VocabularyPair[]): [string, stri
return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)]) return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)])
} }
export function unknownDirectiveFault(name: string, escape: string): ConvertFault { export function unknownDirectiveFault(name: string): ConvertFault {
return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node; ${escape}` } return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node; ${directiveEscape}` }
} }
export function unsupportedNodeShape(message: string): ConvertFault { export function unsupportedNodeShape(message: string): ConvertFault {
return { code: 'unsupported-node-shape', message } return { code: 'unsupported-node-shape', message }
} }
function attributePairFault(escape: string): ConvertFault {
return malformedDirective(`${pairFault}; ${escape}`)
}
function keyOrder(left: string, right: string): number { function keyOrder(left: string, right: string): number {
if (left < right) return -1 if (left < right) return -1
return left > right ? 1 : 0 return left > right ? 1 : 0
@@ -168,28 +167,39 @@ function inlineDirectiveName(text: string, index: number): string | undefined {
return opened === undefined ? undefined : opened.slice(directivePrefix.length, -1) return opened === undefined ? undefined : opened.slice(directivePrefix.length, -1)
} }
function readDirectiveHeader(rest: string): Read<{ argument: string | undefined; attributes: DirectiveAttributes; name: string }> { function readCloserLine(line: string): Read<DirectiveLine> | undefined {
directiveName.lastIndex = 0 const start = directivePrefix.length + 1
const name = directiveName.exec(rest)?.[0] const name = readDirectiveName(line, start)
if (name === undefined) return { fault: malformedDirective(nameFault) } if (name === undefined) return undefined
let cursor = name.length return lineEnd.test(line.slice(start + name.length)) ? { value: { kind: 'closer', name } } : { fault: malformedDirective(closerFault) }
let argument: string | undefined }
let attributes = noAttributes
if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) !== '{' && !lineEnd.test(rest.slice(cursor))) { function readOpenerLine(line: string): Read<DirectiveLine> | undefined {
bareRun.lastIndex = cursor + 1 const name = readDirectiveName(line, directivePrefix.length)
argument = bareRun.exec(rest)?.[0] if (name === undefined) return undefined
if (argument === undefined) return { fault: malformedDirective(shapeFault) } const rest = line.slice(directivePrefix.length + name.length)
cursor += 1 + argument.length if (!rest.startsWith(' ') && !lineEnd.test(rest)) return undefined
} const opener = readOpenerRest(rest)
if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') { if (opener.fault !== undefined) return { fault: opener.fault }
const read = readAttributes(rest, cursor + 1, directiveLineEscape) return { value: { argument: opener.value.argument, attributes: opener.value.attributes, kind: 'opener', name } }
if (read.fault !== undefined) return { fault: read.fault } }
if (read.value.attributes.size === 0) return { fault: malformedDirective(emptyFault) }
attributes = read.value.attributes function readDirectiveName(text: string, index: number): string | undefined {
cursor += 1 + read.value.length directiveName.lastIndex = index
} return directiveName.exec(text)?.[0]
if (!lineEnd.test(rest.slice(cursor))) return { fault: malformedDirective(shapeFault) } }
return { value: { argument, attributes, name } }
function readOpenerRest(rest: string): Read<{ argument: string | undefined; attributes: DirectiveAttributes }> {
bareRun.lastIndex = 1
const argument = bareRun.exec(rest)?.[0]
const attributes = readOpenerAttributes(rest, argument === undefined ? 0 : 1 + argument.length)
if (attributes.fault !== undefined) return { fault: attributes.fault }
if (!lineEnd.test(rest.slice(attributes.value.end))) return { fault: malformedDirective(openerFault) }
return { value: { argument, attributes: attributes.value.attributes } }
}
function readOpenerAttributes(rest: string, index: number): Read<Attributes> {
return rest.charAt(index) === ' ' ? readAttributesAt(rest, index + 1, false) : { value: { attributes: noAttributes, end: index } }
} }
function readNestedDirective(text: string, index: number, depth: number): Read<DirectiveSpan> | undefined { function readNestedDirective(text: string, index: number, depth: number): Read<DirectiveSpan> | undefined {
@@ -199,23 +209,25 @@ function readNestedDirective(text: string, index: number, depth: number): Read<D
if (depth > largestNesting) { if (depth > largestNesting) {
return { fault: { code: 'unsupported-nesting-depth', message: `the input nests inline directives deeper than the ${largestNesting} levels the parser carries` } } 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 const slot = readContentSlot(text, index + directivePrefix.length + name.length, depth)
let content: string | undefined if (slot.fault !== undefined) return { fault: slot.fault }
if (text.charAt(cursor) === '[') { const attributes = readAttributesAt(text, slot.value.end, slot.value.content === undefined)
const end = readDirectiveContent(text, cursor + 1, depth) if (attributes.fault !== undefined) return { fault: attributes.fault }
if (end.fault !== undefined) return { fault: end.fault } return { value: { attributes: attributes.value.attributes, content: slot.value.content, length: attributes.value.end - index, name } }
content = text.slice(cursor + 1, end.value) }
cursor = end.value + 1
} function readContentSlot(text: string, index: number, depth: number): Read<Content> {
let attributes = noAttributes if (text.charAt(index) !== '[') return { value: { content: undefined, end: index } }
if (text.charAt(cursor) === '{') { const close = readDirectiveContent(text, index + 1, depth)
const read = readAttributes(text, cursor, inlineDirectiveEscape) if (close.fault !== undefined) return { fault: close.fault }
if (read.fault !== undefined) return { fault: read.fault } return { value: { content: text.slice(index + 1, close.value), end: close.value + 1 } }
if (read.value.attributes.size === 0 && content !== undefined) return { fault: malformedDirective(emptyFault) } }
attributes = read.value.attributes
cursor += read.value.length function readAttributesAt(text: string, index: number, braceClaims: boolean): Read<Attributes> {
} if (text.charAt(index) !== '{') return { value: { attributes: noAttributes, end: index } }
return { value: { attributes, content, length: cursor - index, name } } 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. // A code span, an escape and a nested directive each bind before the content's own closing bracket.
@@ -234,7 +246,7 @@ function readDirectiveContent(text: string, start: number, depth: number): Read<
cursor = span cursor = span
continue continue
} }
const nested = character === '!' ? readNestedDirective(text, cursor, depth + 1) : undefined const nested = readNestedDirective(text, cursor, depth + 1)
if (nested !== undefined) { if (nested !== undefined) {
if (nested.fault !== undefined) return { fault: nested.fault } if (nested.fault !== undefined) return { fault: nested.fault }
cursor += nested.value.length cursor += nested.value.length
@@ -245,7 +257,7 @@ function readDirectiveContent(text: string, start: number, depth: number): Read<
if (character === ']') brackets -= 1 if (character === ']') brackets -= 1
cursor += 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. // `undefined` where the span crosses the line ending an inline directive may not cross.
@@ -256,16 +268,16 @@ function readCodeSpanEnd(text: string, index: number): number | undefined {
return text.slice(index, closer + opener).includes('\n') ? undefined : closer + opener 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>() const attributes = new Map<string, DirectiveValue>()
let cursor = index + 1 let cursor = index + 1
let previous = '' let previous = ''
while (cursor < text.length && text.charAt(cursor) !== '}') { while (cursor < text.length && text.charAt(cursor) !== '}') {
if (attributes.size > 0) { if (attributes.size > 0) {
if (text.charAt(cursor) !== ' ') return { fault: attributePairFault(escape) } if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairFault) }
cursor += 1 cursor += 1
} }
const pair = readAttributePair(text, cursor, escape) const pair = readAttributePair(text, cursor)
if (pair.fault !== undefined) return { fault: pair.fault } if (pair.fault !== undefined) return { fault: pair.fault }
const key = pair.value.key const key = pair.value.key
if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) } if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) }
@@ -275,13 +287,13 @@ function readAttributes(text: string, index: number, escape: string): Read<Attri
cursor = pair.value.end cursor = pair.value.end
} }
if (text.charAt(cursor) !== '}') return { fault: malformedDirective('the {attrs} closing brace is missing') } 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 bareRun.lastIndex = index
const key = bareRun.exec(text)?.[0] 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 const start = index + key.length + 1
if (text.charAt(start) === '"') { if (text.charAt(start) === '"') {
const quoted = readQuotedValue(text, start) const quoted = readQuotedValue(text, start)
@@ -290,7 +302,7 @@ function readAttributePair(text: string, index: number, escape: string): Read<At
} }
bareRun.lastIndex = start bareRun.lastIndex = start
const bare = bareRun.exec(text)?.[0] 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 } } } 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', () => { 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: { localId: 'a' }, type: 'paragraph' }))), '!adf:paragraph {localId=a}\n!adf:/paragraph\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), ':::codeBlock {wrap=true}\n```\n```\n:::\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(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({ marks: [{ type: 'border' }], type: 'blockquote' }))), '!adf:blockquote {marks="[{\\"type\\":\\"border\\"}]"}\n!adf:/blockquote\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'listItem' }))), ':::listItem\n:::\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', () => { test('spells an ordered list from the order attribute its first marker is', () => {
const items: AdfNode[] = [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }] 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: 1 }, content: items, type: 'orderedList' }))), '1. x\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: items, type: 'orderedList' }))), '2. 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', () => { 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' }))) 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(''), '!adf:codeBlock {language=""}\n```\n```\n!adf:/codeBlock\n')
assert.equal(language('a`b'), ':::codeBlock {language="a\\u0060b"}\n```\n```\n:::\n') assert.equal(language('a`b'), '!adf:codeBlock {language="a\\u0060b"}\n```\n```\n!adf:/codeBlock\n')
assert.equal(language(' sql'), ':::codeBlock {language=" sql"}\n```\n```\n:::\n') assert.equal(language(' sql'), '!adf:codeBlock {language=" sql"}\n```\n```\n!adf:/codeBlock\n')
assert.equal(language('&#97;df'), ':::codeBlock {language="\\u0026#97;df"}\n```\n```\n:::\n') assert.equal(language('&#97;df'), '!adf:codeBlock {language="\\u0026#97;df"}\n```\n```\n!adf:/codeBlock\n')
assert.equal(language('foo\\+bar'), ':::codeBlock {language="foo\\\\+bar"}\n```\n```\n:::\n') assert.equal(language('foo\\+bar'), '!adf:codeBlock {language="foo\\\\+bar"}\n```\n```\n!adf:/codeBlock\n')
assert.equal(language('a\u0000b'), ':::codeBlock {language="a\\u0000b"}\n```\n```\n:::\n') assert.equal(language('a\u0000b'), '!adf:codeBlock {language="a\\u0000b"}\n```\n```\n!adf:/codeBlock\n')
assert.equal(language('a\tb'), ':::codeBlock {language="a\\tb"}\n```\n```\n:::\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', () => { 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', () => { 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' } 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' } 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(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' }))), ':::panel info\n- x\n::listBreak\n- x\n:::\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' } 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' } } 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(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', () => { 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: 'blockCard' }))), '```carry\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: '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(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({ text: 'x', type: 'text' }))), '```carry\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({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\n}\n```\n')
}) })
test('spells the code block whose language is the reserved info string', () => { 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', () => { 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', () => { 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({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), ':::bulletList\n:::\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' }))), ':::orderedList {order=2}\n:::\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', () => { test('spells an ordered list no marker fits as a directive', () => {
const item: AdfNode = { content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' } const item: AdfNode = { content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }
const list = (order: number, items: number): AdfDocument => const list = (order: number, items: number): AdfDocument =>
document({ attrs: { order }, content: Array.from({ length: items }, () => item), type: 'orderedList' }) 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, 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', () => { 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', () => { 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({ 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' }))), ':::heading\nx\n:::\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', () => { 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:mention[@x]{id=1}'), '\\!adf:mention[@x]{id=1}\n')
assert.equal(emitted('!adf: completes nothing'), '\\!adf: completes nothing\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('!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('10:30 tomorrow'), '10:30 tomorrow\n')
assert.equal(emitted('[a](b)'), '\\[a](b)\n') assert.equal(emitted('[a](b)'), '\\[a](b)\n')
assert.equal(emitted('**bold**'), '\\*\\*bold\\*\\*\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') 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 list: AdfNode = { content: [item(text('b'))], type: 'bulletList' }
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' } 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.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 ```adf\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', () => { 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', () => { test('spells a list item whose marker completes a thematic break as a directive', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) 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' } const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n') 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', () => { 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', () => { 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' }) 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({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(panel({}))), ':::panel\nx\n:::\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' }))), ':::caption\nx\n:::\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' }))), '::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' }))), '::syncBlock {localId=a}\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n')
}) })
test('carries a directive attribute no section spells', () => { test('carries a directive attribute no section spells', () => {
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node))) 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: { 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' }), '```adf\n{\n "attrs": {\n "toString": "x"\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' }), '```adf\n{\n "attrs": {\n "localId": 4\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( assert.equal(
carried({ attrs: { width: '50' }, type: 'layoutColumn' }), 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( assert.equal(
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }), 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( assert.equal(
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }), 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))) const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
assert.equal( assert.equal(
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }), 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', () => { test('carries a block node mark in the reserved attribute', () => {
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' }) 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({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), ':::layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n:::\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', () => { 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') 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 text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' }) 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' } 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(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))), '::::panel info\na\n:::caption\nc\n:::\n::::\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')))), '::::panel info\n:::caption\nc\n:::\na\n::::\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')))), ':::panel info\n::paragraph\na\n:::\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', () => { 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({ 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({ type: 'external', url }))), `![](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${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: '', type: 'external', url }))
assert.ok(fallback({ alt: 'a\nb', type: 'external', url })) assert.ok(fallback({ alt: 'a\nb', type: 'external', url }))
assert.ok(fallback({ alt: 'a\u0000b', 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' } const single: AdfNode = { attrs: { layout: 'center' }, content: [media], marks: [{ type: 'border' }], type: 'mediaSingle' }
assert.equal( assert.equal(
markdown(adfToMarkdown(document(single))), 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 cell = (type: string, ...content: AdfNode[]): AdfNode => ({ content, type })
const row = (...cells: AdfNode[]): AdfNode => ({ content: cells, type: 'tableRow' }) const row = (...cells: AdfNode[]): AdfNode => ({ content: cells, type: 'tableRow' })
const table = (...rows: AdfNode[]): AdfDocument => document({ content: rows, type: 'table' }) 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'))) 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(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') assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', text('a|b')))))), '| a\\|b |\n| --- |\n')
+33 -40
View File
@@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts' import type { BlockDirective } from '../../adf/block-directives.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockDirective } from '../../adf/block-directives.ts' import { blockDirective } from '../../adf/block-directives.ts'
import { blockDirectiveForm } from '../block-directive-forms.ts'
import { carriedBlock } from '../opaque-carry.ts' import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts' import { emitInlineLine } from './inline-line.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.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 { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { listBreakSpelling } from '../list-break.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 { tryImage } from './image.ts'
import { tryPipeTable } from './pipe-table.ts' import { tryPipeTable } from './pipe-table.ts'
type BlockContainer = 'directive' | 'document' | 'list-item' type BlockContainer = 'directive' | 'document' | 'list-item'
type BlockSpelling = 'commonmark' | 'directive' | 'list' type BlockSpelling = 'commonmark' | 'directive' | 'list'
type EmittedBody = { fenceColons: number; text: string } type EmittedBlock = { spelling: BlockSpelling; text: string }
type EmittedBlock = EmittedBody & { spelling: BlockSpelling }
type PlacedBlock = EmittedBlock & { node: AdfNode } type PlacedBlock = EmittedBlock & { node: AdfNode }
const largestListMarker = 999999999 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}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = emitBlocks(nodeContent(document), 'document', [], 0) const blocks = emitBlocks(nodeContent(document), 'document', [], 0)
if (!blocks.ok) return blocks 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) if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
const blocks: PlacedBlock[] = [] const blocks: PlacedBlock[] = []
for (const [index, node] of nodes.entries()) { for (const [index, node] of nodes.entries()) {
@@ -39,15 +40,13 @@ function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path:
if (!block.ok) return block if (!block.ok) return block
blocks.push({ ...block.value, node }) blocks.push({ ...block.value, node })
} }
let fenceColons = 0
let text = '' let text = ''
for (const [index, block] of blocks.entries()) { for (const [index, block] of blocks.entries()) {
const previous = blocks[index - 1] const previous = blocks[index - 1]
if (previous !== undefined) text += separationBetween(previous, block, container) if (previous !== undefined) text += separationBetween(previous, block, container)
fenceColons = Math.max(fenceColons, block.fenceColons)
text += block.text text += block.text
} }
return success({ fenceColons, text }) return success(text)
} }
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string { function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
@@ -105,42 +104,39 @@ function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
} }
function commonMarkText(text: string): 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> { 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) 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 (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
if (directive.contentModel === 'none' && 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) if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
const header = spellDirectiveHeader(node, directive) const opener = spellBlockDirectiveOpener(node, directive)
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (directive.contentModel === 'none' || (directive.contentModel === 'inline' && content.length === 0)) { return emitDirectiveBody(node, directive, opener, path, depth)
return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
}
const body = directive.contentModel === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1)
if (!body.ok) return body
const fenceColons = Math.max(3, body.value.fenceColons + 1)
const fence = ':'.repeat(fenceColons)
const lines = body.value.text === '' ? '' : `${body.value.text}\n`
return success({ fenceColons, spelling: 'directive', text: `${fence}${header}\n${lines}${fence}` })
} }
function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result<EmittedBody> { function emitDirectiveBody(node: AdfNode, directive: BlockDirective, opener: string, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const line = emitInlineLine(content, 'paragraph', path) if (blockDirectiveForm(node.type) === 'leaf') return success({ spelling: 'directive', text: opener })
if (!line.ok) return line const content = nodeContent(node)
return success({ fenceColons: 0, text: line.value }) const body = directive.contentModel === 'inline' ? emitInlineLine(content, 'paragraph', path) : emitBlocks(content, 'directive', path, depth + 1)
if (!body.ok) return body
return success(directivePair(node, opener, body.value))
} }
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, [])) return undefined if (!carriesOnly(node, [])) return undefined
const inner = emitBlocks(nodeContent(node), 'document', path, depth + 1) const inner = emitBlocks(nodeContent(node), 'document', path, depth + 1)
if (!inner.ok) return inner if (!inner.ok) return inner
const text = inner.value.text const text = inner.value
.split('\n') .split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`)) .map((line) => (line === '' ? '>' : `> ${line}`))
.join('\n') .join('\n')
return success({ fenceColons: inner.value.fenceColons, spelling: 'commonmark', text }) return success(commonMarkText(text))
} }
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
@@ -154,12 +150,11 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(nodeAttrs(node)['language']) const slot = languageSlot(nodeAttrs(node)['language'])
const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language']) const opener = spellBlockDirectiveOpener(node, directive, slot.kind === 'attribute' ? [] : ['language'])
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const text = codeBlockText(node, path) const text = codeBlockText(node, path)
if (!text.ok) return text if (!text.ok) return text
const info = slot.kind === 'fence' ? slot.info : '' return success(directivePair(node, opener, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info, text.value)}\n:::` })
} }
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> { function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
@@ -203,15 +198,13 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<
if (start === undefined || items.length === 0) return undefined if (start === undefined || items.length === 0) return undefined
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
const lines: string[] = [] const lines: string[] = []
let fenceColons = 0
for (const [offset, item] of items.entries()) { for (const [offset, item] of items.entries()) {
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', [...path, 'content', offset], depth) const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', [...path, 'content', offset], depth)
if (emitted === undefined) return undefined if (emitted === undefined) return undefined
if (!emitted.ok) return emitted if (!emitted.ok) return emitted
fenceColons = Math.max(fenceColons, emitted.value.fenceColons) lines.push(emitted.value)
lines.push(emitted.value.text)
} }
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 { function listStart(node: AdfNode, items: number): number | undefined {
@@ -221,16 +214,16 @@ function listStart(node: AdfNode, items: number): number | undefined {
return start + items - 1 > largestListMarker ? undefined : start 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) const inner = emitBlocks(nodeContent(item), 'list-item', path, depth + 1)
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) if (inner.value === '') return success(marker.trimEnd())
const body = inner.value.text.split('\n') const body = inner.value.split('\n')
if (body.some((line) => line !== '' && isBlankLine(line))) return undefined if (body.some((line) => line !== '' && isBlankLine(line))) return undefined
const indent = ' '.repeat(marker.length) const indent = ' '.repeat(marker.length)
const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
if (isThematicBreak(lines[0] ?? '')) return undefined 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 { function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
+9 -10
View File
@@ -1,28 +1,27 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts' import type { BlockDirective } from '../../adf/block-directives.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { blockArgument } from '../block-directive-arguments.ts'
import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { markValues, marksAttribute } from '../block-directive-marks.ts' import { markValues, marksAttribute } from '../block-directive-marks.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.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 argumentAttribute = blockArgument(node.type)
const argument = spellArgument(node, argumentAttribute) const slot = bareArgument(node, argumentAttribute)
if (argument === undefined) return undefined if (slot === undefined) return undefined
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled) const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled)
if (pairs === undefined) return undefined if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs) const spelledPairs = spellVocabulary(pairs)
const marks = nodeMarks(node) const marks = nodeMarks(node)
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))]) if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(spelledPairs) return spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs))
return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
} }
function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined { // `undefined` where the argument slot holds a value no bare token spells.
function bareArgument(node: AdfNode, argumentAttribute: string | undefined): { argument: string | undefined } | undefined {
const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute] const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute]
if (value === undefined) return '' if (value === undefined) return { argument: undefined }
if (typeof value !== 'string' || !isBareToken(value)) return undefined return typeof value === 'string' && isBareToken(value) ? { argument: value } : undefined
return ` ${value}`
} }
+3 -3
View File
@@ -5,7 +5,7 @@ import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts' import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts'
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.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 { holdsEntityReference } from '../entity-references.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
@@ -199,7 +199,7 @@ function emitHardBreak(node: AdfNode, directive: InlineDirective, context: Inlin
const attributes = spellInlineNodeAttributes(node, directive) const attributes = spellInlineNodeAttributes(node, directive)
if (attributes === undefined) return success({ carry: { first: index, last: index } }) if (attributes === undefined) return success({ carry: { first: index, last: index } })
if (attributes === '' && context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] }) 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> { 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) const attributes = spellInlineNodeAttributes(node, directive)
if (attributes === undefined) return success({ carry: { first: index, last: index } }) if (attributes === undefined) return success({ carry: { first: index, last: index } })
const slot = directive.textAttribute === undefined ? undefined : nodeAttrs(node)[directive.textAttribute] 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 } }) if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
const spans = slotLineEndingFault(node.type, slot) const spans = slotLineEndingFault(node.type, slot)
if (spans !== undefined) return faulted(spans, path) 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) || mergesWithSyntax(scan, escapings, index) ||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) || opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) ||
(escaping === 'bracketed-link-target' && (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) escaped.add(index)
} }
+3 -1
View File
@@ -1,3 +1,5 @@
import { spellDirectiveOpener } from './directive-syntax.ts'
export const listBreakName = 'listBreak' export const listBreakName = 'listBreak'
export const listBreakSpelling = `::${listBreakName}` export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
+3 -4
View File
@@ -6,10 +6,9 @@ import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, overNested } from '../json-value.ts' import { isJsonValue, overNested } from '../json-value.ts'
import { fencedCodeBlock } from './backtick-runs.ts' import { fencedCodeBlock } from './backtick-runs.ts'
import { largestNesting } from '../nesting.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' import { serializeCanonicalJson } from '../canonical-json.ts'
export const carryFence = 'adf'
export const carryName = 'carry' export const carryName = 'carry'
const jsonAttribute = 'json' const jsonAttribute = 'json'
@@ -17,13 +16,13 @@ const jsonAttribute = 'json'
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> { export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
const json = carriedJson(node, 'two-space', path, largestNesting - depth) const json = carriedJson(node, 'two-space', path, largestNesting - depth)
if (!json.ok) return json 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> { export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'compact', path, largestNesting) const json = carriedJson(node, 'compact', path, largestNesting)
if (!json.ok) return json 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> { export function readCarriedBlock(body: string, depth: number): Read<AdfNode> {
+27 -19
View File
@@ -69,44 +69,52 @@ test('leaves the paragraph a line no definition spells', () => {
test('swallows an HTML block to the end condition its start sets', () => { 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('<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('<pre>x</pre>\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<div>\nx\n'), ['html']) assert.deepEqual(kinds('<div>\nx\n'), ['html'])
assert.deepEqual(kinds('Part.\n<div>\n'), ['paragraph', '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', () => { 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']) assert.deepEqual(kinds('Part.\n| x |\n'), ['paragraph', 'fault'])
}) })
test('holds a directive container open until the fence that closes it', () => { test('opens a container where the content model takes content, and holds it open until the closer naming it', () => {
assert.deepEqual(kinds(':::panel info\nPart.\n:::\nMore.\n'), ['directive', 'paragraph']) assert.deepEqual(kinds('!adf:panel info\nPart.\n!adf:/panel\nMore.\n'), ['directive', 'paragraph'])
assert.deepEqual(kinds('::rule\nPart.\n'), ['directive', 'paragraph']) assert.deepEqual(kinds('!adf:rule\nPart.\n'), ['directive', 'paragraph'])
assert.deepEqual(faults(':::panel info\n\nPart.\n\n:::\n'), []) assert.deepEqual(kinds('!adf:listBreak\nPart.\n'), ['directive', 'paragraph'])
assert.deepEqual(faults(':::panel info\n> Part.\n> :::\n'), []) assert.deepEqual(kinds('!adf:widget\nPart.\n'), ['directive', 'paragraph'])
assert.deepEqual(faults('::::panel info\n- :::expand\n Part.\n :::\n::::\n'), []) assert.deepEqual(kinds('!adf:mention\nPart.\n'), ['directive', 'paragraph'])
assert.deepEqual(faults(':::panel info\n```\n:::\n```\n:::\n'), []) assert.deepEqual(faults('!adf:panel info\n\nPart.\n\n!adf:/panel\n'), [])
assert.deepEqual(parseBlocks(':::panel info {panelColor="#ff0000"}\nPart.\n:::\n').blocks, [ 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', argument: 'info',
attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]), 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', kind: 'directive',
name: 'panel', name: 'panel',
position: { line: 1, offset: 0 }, 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 } }, { 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', () => { test('closes the containers a closer names past as unclosed, and crosses no list item or blockquote edge', () => {
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"]) const unclosed = (name: string): string => `the ${name} container is unclosed: no !adf:/${name} follows inside the block holding it; \\!adf: keeps the prefix literal`
assert.deepEqual(faults('::::panel info\n:::\n::::\n'), ['a closing fence is shorter than the 4 colons it would close']) 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('::::panel info\n:::expand\nPart.\n:::::\n::::\n'), []) assert.deepEqual(faults('!adf:panel info\n!adf:expand\nPart.\n!adf:/expand\n!adf:/panel\n'), [])
assert.deepEqual(faults(':::panel info\nPart.\n'), ['a container fenced with 3 colons is unclosed']) assert.deepEqual(faults('!adf:panel info\n!adf:expand\n!adf:layoutSection\nPart.\n!adf:/panel\n'), [unclosed('expand')])
assert.deepEqual(faults('- :::panel info\n\nPart.\n'), ['a container fenced with 3 colons is unclosed']) assert.deepEqual(faults('!adf:panel info\nPart.\n'), [unclosed('panel')])
assert.deepEqual(faults('Part.\n\n:::\n'), ['a closing fence closes no open container; \\::: keeps the line literal text']) assert.deepEqual(faults('- !adf:panel info\n\nPart.\n'), [unclosed('panel')])
assert.deepEqual(faults('> !adf:panel info\n> Part.\n!adf:/panel\n'), [unclosed('panel'), unopened('panel')])
assert.deepEqual(faults('!adf:panel info\n> !adf:/panel\n!adf:/panel\n'), [unopened('panel')])
assert.deepEqual(faults('Part.\n\n!adf:/panel\n'), [unopened('panel')])
assert.deepEqual(kinds('!adf:rule {localId=a-1}\nPart.\n!adf:/rule\n'), ['fault', 'paragraph'])
assert.deepEqual(kinds('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n'), ['fault', 'fault'])
}) })
+104 -64
View File
@@ -3,7 +3,6 @@ import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts'
import type { LinkDefinition } from '../link-syntax.ts' import type { LinkDefinition } from '../link-syntax.ts'
import { import {
atxHeading, atxHeading,
claimsDirectiveLine,
claimsPipeLine, claimsPipeLine,
closingCodeFence, closingCodeFence,
decodeTextEscapes, decodeTextEscapes,
@@ -16,7 +15,8 @@ import {
replaceNullCharacters, replaceNullCharacters,
setextHeadingLevel, setextHeadingLevel,
} from '../commonmark-grammar.ts' } 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 { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
import { readLinkDefinitions } from '../link-reference-definitions.ts' import { readLinkDefinitions } from '../link-reference-definitions.ts'
@@ -40,12 +40,11 @@ export type DirectiveBlock = Extract<Block, { kind: 'directive' }>
type ListBlock = Extract<Block, { items: Block[][] }> type ListBlock = Extract<Block, { items: Block[][] }>
type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'directive'; parent: Block[]; position: SourcePosition } type OpenDirective = { blocks: Block[]; depths: number[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition }
type OpenContainer = type EdgeContainer = Extract<Block, { kind: 'blockquote' }> | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
| Extract<Block, { kind: 'blockquote' }>
| OpenDirective type OpenContainer = EdgeContainer | OpenDirective
| { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
type OpenLeaf = { position: SourcePosition } & ( type OpenLeaf = { position: SourcePosition } & (
| { closer: RegExp | undefined; construct: string; kind: 'html' } | { closer: RegExp | undefined; construct: string; kind: 'html' }
@@ -60,15 +59,32 @@ type ContainerStart = { kind: 'blockquote'; rest: Line } | { fresh: boolean; ind
// The line from an absolute column on: a tab a cut splits keeps the stop it is measured against. // The line from an absolute column on: a tab a cut splits keeps the stop it is measured against.
type Line = { column: number; text: string } type Line = { column: number; text: string }
type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePosition; stack: OpenContainer[] } type LeafOpener = { index: number; position: SourcePosition }
type Walk = ParsedBlocks & {
directiveDepths: Map<string, number[]>
edges: { container: EdgeContainer; depth: number }[]
leaf: OpenLeaf | undefined
leafOpeners: Map<Block[], Map<string, LeafOpener>>
position: SourcePosition
stack: OpenContainer[]
}
const indentedCodeColumns = 4 const indentedCodeColumns = 4
const largestOpenerIndentation = 3 const largestOpenerIndentation = 3
const leafColons = 2
const tabStop = 4 const tabStop = 4
export function parseBlocks(markdown: string): ParsedBlocks { export function parseBlocks(markdown: string): ParsedBlocks {
const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, position: { line: 1, offset: 0 }, stack: [] } const walk: Walk = {
blocks: [],
definitions: new Map(),
directiveDepths: new Map(),
edges: [],
leaf: undefined,
leafOpeners: new Map(),
position: { line: 1, offset: 0 },
stack: [],
}
for (const line of sourceLines(markdown)) { for (const line of sourceLines(markdown)) {
walk.position = line.position walk.position = line.position
readLine(walk, { column: 0, text: line.text }) readLine(walk, { column: 0, text: line.text })
@@ -100,22 +116,19 @@ function swallowsLines(leaf: OpenLeaf | undefined): boolean {
return leaf?.kind === 'fenced-code' || leaf?.kind === 'html' return leaf?.kind === 'fenced-code' || leaf?.kind === 'html'
} }
// A directive container has no continuation marker, so every line continues it.
function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } { function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } {
let depth = 0
let rest = line let rest = line
for (const container of walk.stack) { for (const { container, depth } of walk.edges) {
const next = continuesContainer(walk, container, rest) const next = continuesContainer(walk, container, rest)
if (next === undefined) break if (next === undefined) return { depth, rest }
depth += 1
rest = next rest = next
} }
return { depth, rest } return { depth: walk.stack.length, rest }
} }
function continuesContainer(walk: Walk, container: OpenContainer, line: Line): Line | undefined { function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined {
if (container.kind === 'blockquote') return blockquoteRest(removeColumns(line, largestOpenerIndentation)) if (container.kind === 'blockquote') return blockquoteRest(removeColumns(line, largestOpenerIndentation))
// A directive container has no continuation marker: only its own fence closes it.
if (container.kind === 'directive') return line
// A list item begins with at most one blank line: an empty one gives the second up. // A list item begins with at most one blank line: an empty one gives the second up.
if (isBlankLine(line.text)) { if (isBlankLine(line.text)) {
return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' } return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' }
@@ -177,14 +190,18 @@ function openList(start: number | undefined, position: SourcePosition): ListBloc
function openContainer(walk: Walk, start: ContainerStart): void { function openContainer(walk: Walk, start: ContainerStart): void {
const blocks: Block[] = [] const blocks: Block[] = []
if (start.kind === 'blockquote') { if (start.kind === 'blockquote') {
const blockquote: OpenContainer = { blocks, kind: 'blockquote', position: walk.position } const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position }
currentBlocks(walk).push(blockquote) currentBlocks(walk).push(blockquote)
walk.stack.push(blockquote) pushEdge(walk, blockquote)
return return
} }
const list = openedList(walk, start) const list = openedList(walk, start)
list.items.push(blocks) list.items.push(blocks)
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list }) pushEdge(walk, { blocks, indentation: start.indentation, kind: 'item', list })
}
function pushEdge(walk: Walk, container: EdgeContainer): void {
walk.edges.push({ container, depth: walk.stack.push(container) - 1 })
} }
// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md). // Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md).
@@ -199,67 +216,90 @@ function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>
function closeContainers(walk: Walk, depth: number): void { function closeContainers(walk: Walk, depth: number): void {
closeLeaf(walk) closeLeaf(walk)
for (const container of walk.stack.slice(depth)) { for (const container of dropContainers(walk, depth)) {
if (container.kind !== 'directive') continue if (container.kind !== 'directive') continue
container.parent[container.index] = { container.parent[container.index] = {
fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`), fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`),
kind: 'fault', kind: 'fault',
position: container.position, position: container.position,
} }
} }
dropContainers(walk, depth)
} }
function dropContainers(walk: Walk, depth: number): void { function dropContainers(walk: Walk, depth: number): OpenContainer[] {
walk.stack.length = depth const dropped = walk.stack.splice(depth)
} for (const container of dropped) {
if (container.kind === 'directive') container.depths.pop()
function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'header' }>): void { else walk.edges.pop()
const block: DirectiveBlock = {
argument: directive.argument,
attributes: directive.attributes,
blocks: directive.colons > leafColons ? [] : undefined,
kind: 'directive',
name: directive.name,
position: walk.position,
} }
const parent = currentBlocks(walk) return dropped
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 })
} }
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void { function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
const enclosing = innermostDirective(walk) if (directive.kind === 'closer') closeDirective(walk, directive.name)
if (directive.kind === 'closing') { else openDirective(walk, directive)
closeDirective(walk, directive.colons, enclosing)
return
}
if (enclosing !== undefined && directive.colons >= enclosing.container.colons) {
pushFault(walk, malformedDirective(`a directive fence line is at least as long as the container's ${enclosing.container.colons} colons`))
return
}
openDirective(walk, directive)
} }
function closeDirective(walk: Walk, colons: number, enclosing: { container: OpenDirective; depth: number } | undefined): void { function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'opener' }>): void {
if (enclosing === undefined) { const { name } = directive
pushFault(walk, malformedDirective(`a closing fence closes no open container; ${directiveLineEscape}`)) const block: DirectiveBlock = {
argument: directive.argument,
attributes: directive.attributes,
blocks: blockDirectiveForm(name) === 'container' ? [] : undefined,
kind: 'directive',
name,
position: walk.position,
}
const parent = currentBlocks(walk)
const index = parent.push(block) - 1
const { position } = block
if (block.blocks === undefined) {
entryOf(walk.leafOpeners, parent, () => new Map<string, LeafOpener>()).set(name, { index, position })
return return
} }
if (colons < enclosing.container.colons) { const depths = entryOf(walk.directiveDepths, name, (): number[] => [])
pushFault(walk, malformedDirective(`a closing fence is shorter than the ${enclosing.container.colons} colons it would close`)) depths.push(walk.stack.push({ blocks: block.blocks, depths, index, kind: 'directive', name, parent, position }) - 1)
return
}
dropContainers(walk, enclosing.depth)
} }
function innermostDirective(walk: Walk): { container: OpenDirective; depth: number } | undefined { function closeDirective(walk: Walk, name: string): void {
for (let depth = walk.stack.length - 1; depth >= 0; depth -= 1) { const closer = spellDirectiveCloser(name)
const container = walk.stack[depth] if (blockDirectiveForm(name) === 'leaf') {
if (container?.kind === 'directive') return { container, depth } faultLeafOpener(walk, name, malformedDirective(`${name} takes no body, so no ${closer} closes it; ${directiveEscape}`))
return
} }
return undefined const depth = openDirectiveDepth(walk, name)
if (depth === undefined) {
pushFault(walk, malformedDirective(`the closer ${closer} closes no ${name} container open where it stands; ${directiveEscape}`))
return
}
closeContainers(walk, depth + 1)
dropContainers(walk, depth)
}
// A closer crosses no list item or blockquote edge.
function openDirectiveDepth(walk: Walk, name: string): number | undefined {
const depth = walk.directiveDepths.get(name)?.at(-1)
return depth === undefined || depth < (walk.edges.at(-1)?.depth ?? -1) ? undefined : depth
}
function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void {
const blocks = currentBlocks(walk)
const openers = walk.leafOpeners.get(blocks)
const opener = openers?.get(name)
if (openers === undefined || opener === undefined) {
pushFault(walk, fault)
return
}
openers.delete(name)
blocks[opener.index] = { fault, kind: 'fault', position: opener.position }
}
function entryOf<K, V>(map: Map<K, V>, key: K, create: () => V): V {
const known = map.get(key)
if (known !== undefined) return known
const created = create()
map.set(key, created)
return created
} }
function pushFault(walk: Walk, fault: ConvertFault): void { function pushFault(walk: Walk, fault: ConvertFault): void {
@@ -271,7 +311,7 @@ function continuesLazily(walk: Walk, line: Line): boolean {
if (walk.leaf?.kind !== 'paragraph' || isBlankLine(line.text)) return false if (walk.leaf?.kind !== 'paragraph' || isBlankLine(line.text)) return false
if (leadingColumns(line) >= indentedCodeColumns) return true if (leadingColumns(line) >= indentedCodeColumns) return true
const opener = removeColumns(line, largestOpenerIndentation).text 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 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 { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts' import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.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 { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.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 { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
import { inlineMarkSpellingFault } from './directive-marks.ts' import { inlineMarkSpellingFault } from './directive-marks.ts'
import { listBreakName, listBreakSpelling } from '../list-break.ts'
import { marksAttribute, readMarkValues } from '../block-directive-marks.ts' import { marksAttribute, readMarkValues } from '../block-directive-marks.ts'
import { readVocabulary } from './directive-attributes.ts' import { readVocabulary } from './directive-attributes.ts'
import { slotLineEndingFault } from '../directive-syntax.ts' import { slotLineEndingFault } from '../directive-syntax.ts'
@@ -26,10 +26,10 @@ export function readBlockDirectiveNode(
path: ConvertErrorPath, path: ConvertErrorPath,
): Result<BlockDirectiveNode> { ): Result<BlockDirectiveNode> {
if (name === carryName) { 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) 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 argumentKey = blockArgument(name)
const rest = new Map(attributes) const rest = new Map(attributes)
rest.delete(marksAttribute) rest.delete(marksAttribute)
@@ -53,7 +53,7 @@ export function readInlineDirectiveNode(
path: ConvertErrorPath, path: ConvertErrorPath,
): Result<AdfNode> { ): Result<AdfNode> {
const directive = inlineDirective(name) 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 const slot = directive.textAttribute
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path) 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' } 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 { 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` } if (blockDirectiveForm(name) === undefined) return undefined
const directive = blockDirective(name) return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${directivePrefix}${name}, never the inline form` }
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` }
} }
// spec/flavour.md, Inline nodes: the slot is plain text, its adjacent nodes already merged. // spec/flavour.md, Inline nodes: the slot is plain text, its adjacent nodes already merged.
+147 -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', () => { 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)), [ assert.deepEqual(content(markdownToAdf(fenced)), [
{ attrs: { language: 'rust', wrap: true }, content: [text('fn main() {}')], type: 'codeBlock' }, { 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('!adf:codeBlock {wrap=true}\n```\n```\n!adf:/codeBlock\n')), [{ attrs: { wrap: true }, type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf(':::codeBlock {language=""}\n```\nx\n```\n:::\n')), [ assert.deepEqual(content(markdownToAdf('!adf:codeBlock {language=""}\n```\nx\n```\n!adf:/codeBlock\n')), [
{ attrs: { language: '' }, content: [text('x')], type: 'codeBlock' }, { 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' }, { 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. // 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' }, { attrs: { language: '#c', wrap: true }, content: [text('x')], type: 'codeBlock' },
]) ])
}) })
test('names the slot a codeBlock spells its language outside of', () => { 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' 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('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf(':::codeBlock {language=rust}\n```sql\nx\n```\n:::\n')), slot) assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```adf\nx\n```\n:::\n')), slot) assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```carry\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```a\\b\nx\n```\n:::\n')), slot) assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\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')
}) })
test('reads a pipe table into the header row and the body rows under it', () => { 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', () => { 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' 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.equal(content(markdownToAdf('!adf:caption\na | b\n--- | ---\n!adf:/caption\n')), bare)
assert.deepEqual(position(markdownToAdf(':::caption\na | b\n--- | ---\n:::\n')), { line: 2, offset: 11 }) assert.deepEqual(position(markdownToAdf('!adf:caption\na | b\n--- | ---\n!adf:/caption\n')), { line: 2, offset: 13 })
assert.equal(code(markdownToAdf(':::caption\n| a |\n:::\n')), 'malformed-pipe-table') assert.equal(code(markdownToAdf('!adf:caption\n| a |\n!adf:/caption\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\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', () => { test('reads the separator that parts two adjacent lists of one kind', () => {
const parted = [bulletList(item(paragraph('a'))), bulletList(item(paragraph('b')))] 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\n!adf:listBreak\n\n- b\n')), parted)
assert.deepEqual(content(markdownToAdf('- a\n::listBreak\n- b\n')), parted) assert.deepEqual(content(markdownToAdf('- a\n!adf: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('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> ::listBreak\n> - b\n')), [quote(...parted)]) assert.deepEqual(content(markdownToAdf('> - a\n> !adf: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(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', () => { 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' 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('!adf:listBreak\n')), parts)
assert.equal(content(markdownToAdf('- a\n\n::listBreak\n')), parts) assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n')), parts)
assert.equal(content(markdownToAdf('- a\n\n::listBreak\n\n1. b\n')), parts) assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n\n1. b\n')), parts)
assert.equal(content(markdownToAdf('Part.\n\n::listBreak\n\n- 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, ::listBreak: this one spells more' const bare = 'unsupported-node-shape: listBreak spells the bare leaf form, !adf: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!adf: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('- a\n\n!adf:listBreak {id=x}\n\n- b\n')), bare)
assert.equal(content(markdownToAdf(':::listBreak\n- a\n:::\n')), bare) assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n- b\n!adf:/listBreak\n')), 'malformed-directive: listBreak takes no body, so no !adf:/listBreak closes it; \\!adf: keeps the prefix literal')
assert.equal(content(markdownToAdf('!adf:listBreak{}\n')), 'unsupported-node-shape: listBreak takes the block form, ::listBreak, never the inline form') 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::listBreak\n')), ['content', 1]) assert.deepEqual(path(markdownToAdf('Part.\n\n!adf:listBreak\n')), ['content', 1])
}) })
test('refuses the image a pipe cell holds no ADF node for', () => { 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')]) assert.deepEqual(content(markdownToAdf('Part.\n more\n')), [paragraph('Part. more')])
}) })
test('claims a block-level colon run with no directive to parse it', () => { test('claims a block line the prefix opens with no directive to parse it', () => {
assert.equal(code(markdownToAdf(':::\n')), 'malformed-directive') assert.equal(code(markdownToAdf('!adf:/panel\n')), 'malformed-directive')
assert.equal(code(markdownToAdf('::Panel\n')), 'malformed-directive') assert.equal(code(markdownToAdf('!adf:panel info\n')), 'malformed-directive')
assert.equal(code(markdownToAdf('::panel {a=1 a=2}\n')), 'malformed-directive') assert.equal(code(markdownToAdf('!adf:panel {a=1 a=2}\n!adf:/panel\n')), 'malformed-directive')
assert.deepEqual(content(markdownToAdf(':10:30\n')), [paragraph(':10:30')]) assert.equal(content(markdownToAdf('!adf:Panel\n')), 'malformed-directive: an unescaped !adf: completes no directive; \\!adf: keeps the prefix literal')
assert.deepEqual(content(markdownToAdf(':: two\n')), [paragraph(':: two')]) 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', () => { 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('!adf:rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }])
assert.deepEqual(content(markdownToAdf('::paragraph\n')), [{ type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('!adf:paragraph\n!adf:/paragraph\n')), [{ type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf(' :::panel info\nPart.\n:::\n')), [ 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' }, { 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('!adf:blockquote {localId=a-1}\n!adf:/blockquote\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:heading {level=2 localId=a-1}\nPart.\n!adf:/heading\n')), [
{ attrs: { level: 2, localId: 'a-1' }, content: [text('Part.')], type: 'heading' }, { 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' }]) assert.deepEqual(content(markdownToAdf('Part!adf:hardBreak{}.\n')), [{ content: [text('Part'), hardBreak(), text('.')], type: 'paragraph' }])
@@ -275,53 +276,82 @@ test('reads the three directive forms into the nodes the tables name', () => {
test('names the directive form a node CommonMark spells refuses', () => { 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` 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('!adf:rule\n')), named('rule'))
assert.equal(content(markdownToAdf(':::blockquote\nPart.\n:::\n')), named('blockquote')) assert.equal(content(markdownToAdf('!adf:blockquote\nPart.\n!adf:/blockquote\n')), named('blockquote'))
assert.equal(content(markdownToAdf(':::heading {level=2}\nPart.\n:::\n')), named('heading')) assert.equal(content(markdownToAdf('!adf:heading {level=2}\nPart.\n!adf:/heading\n')), named('heading'))
assert.equal(content(markdownToAdf(':::paragraph\nPart.\n:::\n')), named('paragraph')) assert.equal(content(markdownToAdf('!adf:paragraph\nPart.\n!adf:/paragraph\n')), named('paragraph'))
assert.equal(content(markdownToAdf('::::bulletList\n:::listItem\nPart.\n:::\n::::\n')), named('bulletList')) 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. // 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. // 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', () => { 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') 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', () => { test('names the directive name no node reads back to', () => {
assert.equal(code(markdownToAdf(':::widget info\nx\n:::\n')), 'unknown-directive-name') assert.equal(code(markdownToAdf('!adf:widget info\nx\n!adf:/widget\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(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('!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.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.deepEqual(path(markdownToAdf('Part.\n\n!adf: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])
test('names the container no closer closes inside the block holding it', () => {
const unclosed = (name: string): string => `malformed-directive: the ${name} container is unclosed: no !adf:/${name} follows inside the block holding it; \\!adf: keeps the prefix literal`
assert.equal(content(markdownToAdf('Part.\n!adf:expand\n')), unclosed('expand'))
assert.deepEqual(path(markdownToAdf('Part.\n!adf:expand\n')), ['content', 1])
assert.equal(content(markdownToAdf('!adf:bulletList\n')), unclosed('bulletList'))
assert.equal(content(markdownToAdf('- !adf:panel info\n Part.\n!adf:/panel\n')), unclosed('panel'))
assert.equal(content(markdownToAdf('> !adf:panel info\n> - a\n> !adf:/panel\n')), unclosed('panel'))
assert.equal(content(markdownToAdf('!adf:panel info\n!adf:expand\nPart.\n!adf:/panel\n')), unclosed('expand'))
assert.deepEqual(path(markdownToAdf('!adf:panel info\n!adf:expand\nPart.\n!adf:/panel\n')), ['content', 0, 'content', 0])
})
test('names the closer that finds no container open where it stands', () => {
const unopened = (name: string): string => `malformed-directive: the closer !adf:/${name} closes no ${name} container open where it stands; \\!adf: keeps the prefix literal`
assert.equal(content(markdownToAdf('Part.\n\n!adf:/panel\n')), unopened('panel'))
assert.equal(content(markdownToAdf('!adf:panel info\n> !adf:/panel\n!adf:/panel\n')), unopened('panel'))
assert.equal(content(markdownToAdf('!adf:panel info\n- !adf:/panel\n!adf:/panel\n')), unopened('panel'))
assert.equal(content(markdownToAdf('!adf:panel info\nPart.\n!adf:/expand\n!adf:/panel\n')), unopened('expand'))
})
test('names the leaf given a body at its opener, ahead of any refusal the leaf holds itself', () => {
const body = (name: string): string => `malformed-directive: ${name} takes no body, so no !adf:/${name} closes it; \\!adf: keeps the prefix literal`
assert.equal(content(markdownToAdf('!adf:rule\nPart.\n!adf:/rule\n')), body('rule'))
assert.deepEqual(position(markdownToAdf('Part.\n\n!adf:rule\nPart.\n!adf:/rule\n')), { line: 3, offset: 7 })
assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\n!adf:/listBreak\n\n- b\n')), body('listBreak'))
assert.equal(content(markdownToAdf('- a\n\n!adf:listBreak\nPart.\n!adf:/listBreak\n\n- b\n')), body('listBreak'))
assert.deepEqual(path(markdownToAdf('!adf:rule {localId=a-1}\n!adf:rule {localId=a-2}\nPart.\n!adf:/rule\n')), ['content', 1])
assert.deepEqual(path(markdownToAdf('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n')), ['content', 0])
assert.deepEqual(path(markdownToAdf('Part.\n\n!adf:/rule\n')), ['content', 1])
assert.deepEqual(path(markdownToAdf('!adf:panel info\n!adf:rule {localId=a-1}\n!adf:/panel\n!adf:/rule\n')), ['content', 1])
}) })
test('names the position a directive name the other one spells belongs to', () => { 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('!adf:em\na\n!adf:/em\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('!adf: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('!adf: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: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: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, ::rule, 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(':::widget\na\n:::\n')), 'unknown-directive-name') 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') assert.equal(code(markdownToAdf('!adf:widget[a]\n')), 'unknown-directive-name')
}) })
test('names the reserved carry name a block directive spells', () => { 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' const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the carry fence'
assert.equal(content(markdownToAdf('::carry\n')), reserved) assert.equal(content(markdownToAdf('!adf:carry\n')), reserved)
assert.equal(content(markdownToAdf(':::carry\nx\n:::\n')), reserved) assert.equal(content(markdownToAdf('!adf:carry\nx\n!adf:/carry\n')), reserved)
assert.deepEqual(content(markdownToAdf('```adfx\nx\n```\n')), [{ attrs: { language: 'adfx' }, content: [text('x')], type: 'codeBlock' }]) assert.deepEqual(content(markdownToAdf('```adf\nx\n```\n')), [{ attrs: { language: 'adf' }, content: [text('x')], type: 'codeBlock' }])
}) })
const carried = '!adf:carry{json="{\\"type\\":\\"placeholder\\"}"}' const carried = '!adf:carry{json="{\\"type\\":\\"placeholder\\"}"}'
test('reads the adf fence back to the node its JSON holds', () => { test('reads the carry 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')), [ 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' }, { attrs: { url: 'https://example.com/x' }, type: 'blockCard' },
]) ])
}) })
@@ -334,22 +364,22 @@ test('reads the inline carry back to the node its json attribute holds', () => {
test('names the invalid JSON no opaque carry holds', () => { test('names the invalid JSON no opaque carry holds', () => {
const invalid = 'malformed-directive: the opaque carry holds invalid JSON' const invalid = 'malformed-directive: the opaque carry holds invalid JSON'
assert.equal(content(markdownToAdf('```adf\n{"type":\n```\n')), invalid) assert.equal(content(markdownToAdf('```carry\n{"type":\n```\n')), invalid)
assert.equal(content(markdownToAdf('```adf\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="{"}\n')), invalid)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid) assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid)
}) })
test('names the canonical spelling a carried JSON reads alone', () => { test('names the canonical spelling a carried JSON reads alone', () => {
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: " 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\\"}"}\n')), `${canonically}compact, keys sorted`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\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', () => { 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" 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=null}\n')), node)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node) assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node)
}) })
@@ -363,7 +393,7 @@ test('names the shape the inline carry reads alone', () => {
test('holds a carried JSON value to the nesting its position leaves', () => { test('holds a carried JSON value to the nesting its position leaves', () => {
const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}` 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` 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(content(markdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting))
assert.equal( assert.equal(
@@ -376,7 +406,7 @@ test('holds a carried JSON value to the nesting its position leaves', () => {
test('names the number no JSON spelling carries in an opaque carry', () => { 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' 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: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', () => { test('names the mark spelling no opaque carry sits inside', () => {
@@ -401,37 +431,37 @@ test('keeps the carry a mark spelling does not wrap', () => {
}) })
test('reads each attribute value as the type its section assigns', () => { 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' }, { 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('!adf:table {isNumberColumnEnabled=true}\n!adf:/table\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('!adf:tableCell {colwidth="[340,420]"}\n!adf:/tableCell\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:rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }])
}) })
test('reads the reserved marks key as the node array it spells', () => { 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('!adf: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 {localId=a-1 marks="[{\\"attrs\\":{\\"mode\\":\\"wide\\"},\\"type\\":\\"breakout\\"}]"}\n')), [
{ attrs: { localId: 'a-1' }, marks: [{ attrs: { mode: 'wide' }, type: 'breakout' }], type: 'rule' }, { attrs: { localId: 'a-1' }, marks: [{ attrs: { mode: 'wide' }, type: 'breakout' }], type: 'rule' },
]) ])
}) })
test('names the marks key no marks array reads back from', () => { 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' 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('!adf:rule {marks="[]"}\n')), named)
assert.equal(content(markdownToAdf('::rule {marks="[1]"}\n')), named) assert.equal(content(markdownToAdf('!adf:rule {marks="[1]"}\n')), named)
assert.equal(content(markdownToAdf('::rule {marks="{}"}\n')), named) assert.equal(content(markdownToAdf('!adf:rule {marks="{}"}\n')), named)
assert.equal(content(markdownToAdf('::rule {marks=x}\n')), named) assert.equal(content(markdownToAdf('!adf:rule {marks=x}\n')), named)
assert.equal(content(markdownToAdf('::rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named) assert.equal(content(markdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named)
}) })
test('names the attribute a node holds no reading for', () => { 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('!adf: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('!adf: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('!adf:table {isNumberColumnEnabled=yes}\n!adf:/table\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('!adf: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('!adf:tableCell {colwidth="[340,"}\n!adf:/tableCell\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: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}') assert.equal(content(markdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}')
}) })
@@ -439,27 +469,24 @@ test('names the depth an attribute value nests past, never the kind the JSON rea
const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}` const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}`
const deeper = (key: string, type: string): string => const deeper = (key: string, type: string): string =>
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` `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(`!adf:tableCell {colwidth="${nested(largestNesting + 1)}"}\n!adf:/tableCell\n`)), deeper('colwidth', 'tableCell'))
assert.equal(content(markdownToAdf(`::rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule')) assert.equal(content(markdownToAdf(`!adf: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(`!adf: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: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', () => { 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('!adf: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('!adf: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: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', () => { 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('!adf: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('!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('::bulletList\n')), 'unsupported-node-shape: bulletList spells its body in the container form, :::, never the leaf form') 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(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph takes the leaf form, ::, never an empty container') 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(':::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('!adf:paragraph\n![a](/u)\n!adf:/paragraph\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
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('Part !adf:date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some') assert.equal(content(markdownToAdf('Part !adf:date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some')
}) })
@@ -500,12 +527,12 @@ test('refuses the raw HTML no element mapping carries', () => {
assert.equal(code(markdownToAdf('<div\n')), 'unmappable-html') assert.equal(code(markdownToAdf('<div\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<?php\n')), 'unmappable-html') assert.equal(code(markdownToAdf('<?php\n')), 'unmappable-html')
assert.deepEqual(path(markdownToAdf('Part.\n\n<div>\n')), ['content', 1]) 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') 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', () => { 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') assert.equal(code(markdownToAdf('<div>\n| x |\n</div>\n')), 'unmappable-html')
}) })
@@ -514,15 +541,15 @@ test('names the line and the offset in the input a refusal sits at, the innermos
assert.deepEqual(position(markdownToAdf('Part.\n\n<div>\n')), { line: 3, offset: 7 }) 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>\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- 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\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('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('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('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('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('!adf:caption\na <span>b</span>\n!adf:/caption\n')), { line: 2, offset: 13 })
assert.deepEqual(position(markdownToAdf('x\n\n:::caption\n- a\n:::\n')), { line: 3, offset: 3 }) 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', () => { test('names the line the text a paragraph keeps starts on, never a definition line it gave up', () => {
@@ -530,7 +557,7 @@ test('names the line the text a paragraph keeps starts on, never a definition li
assert.deepEqual(position(markdownToAdf('[a]: /a\n[b]: /b\n[c]: /c\n[d]: /d\n<span>x</span>\n')), { line: 5, offset: 32 }) 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]:\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('> [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', () => { test('gives up the link reference definitions a paragraph opens with', () => {
@@ -626,13 +653,13 @@ test('folds a lazy continuation into the paragraph the container holds', () => {
}) })
test('ends a lazy continuation at a claimed line', () => { test('ends a lazy continuation at a claimed line', () => {
assert.equal(code(markdownToAdf('> Part.\n:::\n')), 'malformed-directive') assert.equal(code(markdownToAdf('> Part.\n!adf:/panel\n')), 'malformed-directive')
assert.deepEqual(path(markdownToAdf('> Part.\n:::\n')), ['content', 1]) assert.deepEqual(path(markdownToAdf('> Part.\n!adf:/panel\n')), ['content', 1])
assert.equal(code(markdownToAdf('- Part.\n| x |\n')), 'malformed-pipe-table') assert.equal(code(markdownToAdf('- Part.\n| x |\n')), 'malformed-pipe-table')
}) })
test('names the block the claim inside a container opens', () => { 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]) assert.deepEqual(path(markdownToAdf('- Part.\n- | x |\n')), ['content', 0, 'content', 1, 'content', 0])
}) })
@@ -642,6 +669,11 @@ test('refuses input nested deeper than the parser carries', () => {
const marks = (levels: number): string => `${'!adf:underline['.repeat(levels)}a${']'.repeat(levels)}\n` const marks = (levels: number): string => `${'!adf:underline['.repeat(levels)}a${']'.repeat(levels)}\n`
assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth') assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth')
assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }])
const nest = (names: readonly string[], body: string): string => [...names.map((name) => `!adf:${name}\n`), body, ...names.map((name) => `!adf:/${name}\n`).reverse()].join('')
const repeated = (name: string): string[] => Array.from({ length: largestNesting }, () => name)
assert.ok(markdownToAdf(nest(repeated('panel'), '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok)
assert.deepEqual(position(markdownToAdf(nest(['expand', ...repeated('panel'), 'expand'], 'Part.\n'))), { line: 501, offset: 5501 })
assert.equal(code(markdownToAdf(nest(['panel', ...repeated('expand'), 'panel'], 'Part.\n'))), 'unsupported-nesting-depth')
}) })
test('decodes the backslash escapes CommonMark spells, and keeps the rest literal', () => { test('decodes the backslash escapes CommonMark spells, and keeps the rest literal', () => {
@@ -670,9 +702,9 @@ test('reads a code span, its content literal', () => {
assert.deepEqual(content(markdownToAdf('` `\n')), [{ content: [codeSpan(' ')], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('` `\n')), [{ content: [codeSpan(' ')], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('`a\nb`\n')), [{ content: [codeSpan('a b')], 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('`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', 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 { BlockDirectiveNode } from './directive-nodes.ts'
import type { ConvertFault } from '../../result.ts' import type { ConvertFault } from '../../result.ts'
import type { LinkDefinitions } from './inline-content.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 { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts' import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
import { languageSlot } from '../code-language.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. // 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 { 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`) return unsupportedNodeShape(`${listBreakName} spells the bare leaf form, ${listBreakSpelling}: this one spells more`)
} }
if (previous?.kind !== 'bulletList' && previous?.kind !== 'orderedList') return partsFault() 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> { function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const { contentModel, node } = read const { contentModel, node } = read
if (blocks === undefined) { if (blocks === undefined) return success(node)
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 (contentModel === 'code') return codeDirectiveNode(node, blocks, path) if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'block') return containerNode(node, blocks, definitions, path, depth) if (contentModel === 'inline') return inlineBodyNode(node, blocks, definitions, path)
return inlineBodyNode(node, blocks, definitions, path) return containerNode(node, blocks, definitions, path, depth)
} }
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> { 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> { 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 const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position) 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) 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> { function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result<AdfNode> {
if (language === carryFence) { if (language === carryName) {
const carried = readCarriedBlock(text, depth) const carried = readCarriedBlock(text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path) if (carried.fault !== undefined) return faulted(carried.fault, path)
return success(carried.value) return success(carried.value)
+2 -2
View File
@@ -1,5 +1,5 @@
import type { DirectiveSpan, Read } from './directive-syntax.ts' 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 name = 'text'
const whitespaceRun = /^(?:[ \t]+|\n+)$/ const whitespaceRun = /^(?:[ \t]+|\n+)$/
@@ -7,7 +7,7 @@ const whitespaceRun = /^(?:[ \t]+|\n+)$/
export const textDirectiveName = name export const textDirectiveName = name
export function spellTextDirective(text: string): string { 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 { export function readTextDirective(span: DirectiveSpan): Read<string> | undefined {
+8 -2
View File
@@ -74,8 +74,14 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
them: `readNestedDirective` restarts its depth counter per level, so each parse level them: `readNestedDirective` restarts its depth counter per level, so each parse level
re-scans the region below it and nested inline directives cost O(depth × content) — 3f's re-scans the region below it and nested inline directives cost O(depth × content) — 3f's
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents guard. A fifth predates 12c: the list-item walk re-scans the rest of a line once per item
nobody typed. level — `isThematicBreak` in `containerStart` on an opener line, `isBlankLine` and
`leadingColumns` in `continuesContainer` on a continuation line, and a blank line
continues every open item without consuming input; 30000 nested items take 4.4s at 59 KB
(the stability-reviewer, 2026-09-16). §11's scanning rule is
the whole argument; the pipeline persona feeds documents nobody typed.
`readDirectiveContent`'s scan splits into named steps with that fix rather than keeping its
complexity (the maintainer, 2026-09-16).
- [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces - [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its