djot 0.1.4.2 → 0.1.4.3
raw patch · 12 files changed
+328/−99 lines, 12 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
Files
- CHANGELOG.md +62/−0
- djot.cabal +1/−1
- src/Djot/AST.hs +13/−11
- src/Djot/Attributes.hs +8/−5
- src/Djot/Blocks.hs +29/−18
- src/Djot/Djot.hs +44/−28
- src/Djot/Html.hs +17/−19
- src/Djot/Inlines.hs +10/−5
- src/Djot/Parse.hs +18/−12
- test/Main.hs +80/−0
- test/attributes.test +34/−0
- test/links_and_images.test +12/−0
CHANGELOG.md view
@@ -1,5 +1,67 @@ # Revision history for djot +## 0.1.4.3 -- 2026-09-23++ * Fix dropped attribute when attaching to last word of merged Strs.+ E.g. in `x y.z{.c}`, the class was silently discarded.++ * Fix implicit reference labels spanning multiple lines.+ Two bugs conspired to make `[link\ntext][]` resolve to an empty href+ even when a "link text" reference was defined.++ * Fix resumable attribute parser ignoring new input on resume.+ Previously a multiline block attribute followed by an indented+ block never finished parsing and the attributes were silently+ dropped, e.g.:++ {#id .class+ style="color:red"}+ A paragraph++ * Avoid quadratic inline parsing by caching chunk count. Previously+ parsing a paragraph was quadratic in the number of lines.++ * Speed up HTML escaping by copying unescaped runs wholesale.++ * Wrap letter list markers around after 26.++ * Fix off-by-one in tab column computation.++ * Propagate inline parse errors in `parseTextLines` instead of crashing.++ * Make NoPos an identity for `<>`, so Monoid Pos is lawful.+ Previously NoPos was absorbing `(NoPos <> p = NoPos)`,+ which would silently discard positions in any law-relying generic code+ (mconcat, fold). The only internal uses of `<>` on Pos (merging+ adjacent Strs) always combine two Pos or two NoPos values, so this+ does not change any parsing or rendering behavior.++ * Fix UTF-8 truncation in `inlinesToByteString`.++ * Improve classification of autolinks. Previously `<user.name@example.com>`+ was rendered as a plain URL link, not an email link. It's an email+ link if it contains an '@' preceded by a character other than ':'.++ * Preserve soft breaks for CR-only line endings.++ * Add tests for whitespace collapsing in quoted attribute values.++ * Fix off-by-one in `pAtMost`.++ * Skip empty chunks when advancing to the next chunk.++ * Speed up djot rendering of Str inlines:++ + Rewrite `escapeDjot` to work directly on ByteStrings, copying+ runs of unescapable characters wholesale instead of processing+ character by character through String. (Equivalence with the+ old implementation checked with 100,000 QuickCheck cases.)+ + Skip the smart-punctuation replacement passes when the+ ByteString contains no 0xE2 byte (the first byte of all the+ UTF-8 sequences involved), avoiding five `T.replace` traversals+ per Str in the common case.+ + Avoid emitting an empty literal after each single-space chunk.+ ## 0.1.4.2 -- 2026-08-27 * Djot renderer: emit raw blocks/inlines for formats other than djot (#18).
djot.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: djot-version: 0.1.4.2+version: 0.1.4.3 synopsis: Parser and renderer for djot light markup syntax. description: Djot (<https://djot.net>) is a light markup language. This package provides a data structure to represent
src/Djot/AST.hs view
@@ -116,11 +116,13 @@ data Pos = NoPos | Pos Int Int Int Int -- start line, start col, end line, end col deriving (Show, Eq, Ord, Typeable, Data, Generic, Lift) +-- | @p1 <> p2@ spans from the start of @p1@ to the end of @p2@.+-- 'NoPos' is an identity. instance Semigroup Pos where Pos sl1 sc1 _ _ <> Pos _ _ el2 ec2 = Pos sl1 sc1 el2 ec2- NoPos <> _ = NoPos- _ <> NoPos = NoPos+ NoPos <> x = x+ x <> NoPos = x instance Monoid Pos where mappend = (<>)@@ -194,13 +196,13 @@ else let sblen = B8.length (B8.filter (\c -> c < '\128' || c >= '\192') sb) (pos1', pos2') =- case pos1 <> pos2 of- NoPos -> (NoPos, NoPos)- Pos sl sc el ec ->- (Pos sl sc el (ec - sblen),- Pos sl (sc + sblen + 1) el ec)+ case (pos1, pos2) of+ (Pos sl1 sc1 el1 ec1, Pos _ _ el2 ec2) ->+ (Pos sl1 sc1 el1 (ec1 - sblen),+ Pos el1 (ec1 - sblen + 1) el2 ec2)+ _ -> (NoPos, NoPos) in Many ((as' Seq.|> Node pos1' mempty (Str sa)- Seq.|> Node pos2' attr (Str (sb <> t))) <> bs')+ Seq.|> Node pos2' attr' (Str (sb <> t))) <> bs') | attr == attr' -> Many (as' <> (Node (pos1 <> pos2) attr (Str (s <> t)) Seq.<| bs')) (as' Seq.:> Node pos attr (Str s), Node _ _ HardBreak Seq.:< _)@@ -430,9 +432,9 @@ Superscript ils -> inlinesToByteString ils Subscript ils -> inlinesToByteString ils Quoted SingleQuotes ils ->- "\x2018" <> inlinesToByteString ils <> "\x2019"+ "\226\128\152" <> inlinesToByteString ils <> "\226\128\153" Quoted DoubleQuotes ils ->- "\x201C" <> inlinesToByteString ils <> "\x201D"+ "\226\128\156" <> inlinesToByteString ils <> "\226\128\157" Verbatim bs -> bs Math DisplayMath bs -> "$$" <> bs <> "$$" Math InlineMath bs -> "$" <> bs <> "$"@@ -446,4 +448,4 @@ FootnoteReference bs -> "[" <> bs <> "]" SoftBreak -> "\n" HardBreak -> "\n"- NonBreakingSpace -> "\160"+ NonBreakingSpace -> "\194\160"
src/Djot/Attributes.hs view
@@ -15,7 +15,6 @@ import qualified Data.ByteString.Char8 as B8 import Data.ByteString.Char8 ( (!?) ) import Data.Typeable (Typeable)-import Data.Maybe (fromMaybe) -- import Debug.Trace @@ -86,10 +85,12 @@ -- | Resumable parser, returning parts in reverse order. parseAttributes :: Maybe AttrParserState -> ByteString -> AttrParseResult parseAttributes mbState bs =- case go (fromMaybe AttrParserState{ aState = START- , subject = bs- , offset = 0- , parts = [] } mbState) of+ case go (case mbState of+ Nothing -> AttrParserState{ aState = START+ , subject = bs+ , offset = 0+ , parts = [] }+ Just st -> st{ subject = bs, offset = 0 }) of AttrParserState{ aState = DONE, parts = attparts, offset = off } -> Done (attrPartsToAttr attparts, off) AttrParserState{ aState = FAIL, offset = off } -> Failed off@@ -123,6 +124,8 @@ case nextc of '"' -> go st{ aState = SCANNING, offset = off + 1 } '\\' -> go st{ aState = SCANNING_ESCAPE, offset = off + 1 }+ -- runs of whitespace (including newlines) collapse to a+ -- single space, as in djot.js c | isWs c -> let st' = skipWhile isWs st in go st'{ parts = AttrValue " " : parts st' }
src/Djot/Blocks.hs view
@@ -36,23 +36,27 @@ parseDoc :: ParseOptions -> ByteString -> Either String Doc parseDoc opts bs = do- case parse pDoc PState{ psParseOptions = opts- , psContainerStack =- NonEmpty.fromList- [emptyContainer{ containerSpec = docSpec }]- , psReferenceMap = mempty- , psAutoReferenceMap = mempty- , psNoteMap = mempty- , psLastAttributeLine = 0- , psAttributes = mempty- , psAttrParserState = Nothing- , psIds = mempty- , psAutoIds = mempty- , psLastColumnPrevLine = 0- , psLastLine = 1- } [Chunk{ chunkLine = 1, chunkColumn = 1, chunkBytes = bs }] of- Just doc -> Right doc- Nothing -> Left "Parse failure."+ case parse ((,) <$> pDoc <*> (psParseError <$> getState))+ PState{ psParseOptions = opts+ , psContainerStack =+ NonEmpty.fromList+ [emptyContainer{ containerSpec = docSpec }]+ , psReferenceMap = mempty+ , psAutoReferenceMap = mempty+ , psNoteMap = mempty+ , psLastAttributeLine = 0+ , psAttributes = mempty+ , psAttrParserState = Nothing+ , psIds = mempty+ , psAutoIds = mempty+ , psLastColumnPrevLine = 0+ , psLastLine = 1+ , psParseError = Nothing+ } [Chunk{ chunkLine = 1, chunkColumn = 1, chunkBytes = bs }] of+ Just (_, Just err) -> Left err+ Just (doc, Nothing) -> Right doc+ Nothing -> Left "Parse failure. The djot block parser should accept any\+ \ input, so this is a bug: please report it." data BlockType = Normal | ListItem | CaptionBlock | Document@@ -745,6 +749,7 @@ , blockContainsBlock = Nothing , blockContainsLines = True , blockClose = \container -> do+ updateState $ \st -> st{ psAttrParserState = Nothing } let bs = foldMap chunkBytes $ containerText container case parseAttributes Nothing bs of Done (attr, off)@@ -857,7 +862,12 @@ parseTextLines :: Container -> P Inlines parseTextLines cont = do opts <- psParseOptions <$> getState- either error pure . parseInlines opts $ containerText cont+ case parseInlines opts (containerText cont) of+ Right ils -> pure ils+ Left msg -> do -- record error; parseDoc will return Left+ updateState $ \st ->+ st{ psParseError = psParseError st <|> Just msg }+ pure mempty emptyContainer :: Container emptyContainer =@@ -924,6 +934,7 @@ , psAutoIds :: Set ByteString , psLastColumnPrevLine :: Int , psLastLine :: Int+ , psParseError :: Maybe String } type P = Parser PState
src/Djot/Djot.hs view
@@ -12,7 +12,6 @@ import Djot.AST import Djot.Options (RenderOptions(..)) import Data.Char (ord, chr, isSpace)-import Djot.Parse (utf8ToStr) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B8 import qualified Data.Set as Set@@ -100,7 +99,7 @@ {-# INLINE escapeDjot #-} escapeDjot :: EscapeContext -> ByteString -> Text escapeDjot Normal bs- | B8.any escapable bs = T.pack. go . utf8ToStr $ bs+ | B8.any escapable bs = fromUtf8 $ B8.concat $ go bs | otherwise = fromUtf8 bs where escapable c = c == '[' || c == ']' || c == '<' || c == '>' ||@@ -108,28 +107,39 @@ c == '-' || c == '^' || c == '~' || c == '*' || c == '_' || c == '\''|| c == '"' || c == '.' || c == '|' || c == '`' || c == '\\'- go [] = []- go ('$':c:cs)- | c == '`' = '\\' : '$' : c : go cs- | otherwise = '$' : go (c : cs)- go ('-':cs) =- case cs of- '-':_ -> '\\' : '-' : go cs- _ -> '-' : go cs- go ('.':cs) =- case cs of- '.':'.':_ -> '\\' : '.' : go cs- _ -> '.' : go cs- go (c:':':cs)- | c /= ']'- , case cs of- [] -> True- (' ':_) -> True- _ -> False- = (if escapable c then ('\\' :) else id) $ c : ':' : go cs- go (c:cs)- | escapable c = '\\' : c : go cs- | otherwise = c : go cs+ -- Copy runs of unescapable bytes wholesale; handle each escapable+ -- byte with the lookahead (and, for ':', lookbehind) rules below.+ go s =+ case B8.findIndex escapable s of+ Nothing -> [s]+ Just i ->+ let c = B8.index s i+ rest = B8.drop (i + 1) s+ in (if i == 0 then id else (B8.take i s :)) $+ handle c (i > 0) rest+ -- endOrSpace r: the escapable char is at the end or followed by space+ endOrSpace r = B8.null r || B8.head r == ' '+ handle c hasPrev rest =+ case c of+ '$' | B8.take 1 rest == "`" -> "\\$`" : go (B8.drop 1 rest)+ | B8.null rest -> ["\\$"]+ | otherwise -> "$" : go rest+ '-' | B8.take 1 rest == "-" -> "\\-" : go rest+ | otherwise -> "-" : go rest+ '.' | B8.take 2 rest == ".." -> "\\." : go rest+ | otherwise -> "." : go rest+ ':' -- unescaped when preceded by an unescapable byte and+ -- followed by space or end; "::" before space or end gets+ -- only the first colon escaped+ | hasPrev, endOrSpace rest -> ":" : go rest+ | B8.take 1 rest == ":", endOrSpace (B8.drop 1 rest)+ -> "\\::" : go (B8.drop 1 rest)+ | otherwise -> "\\:" : go rest+ _ | c /= ']'+ , B8.take 1 rest == ":"+ , endOrSpace (B8.drop 1 rest)+ -> B8.pack ['\\', c, ':'] : go (B8.drop 1 rest)+ | otherwise -> B8.pack ['\\', c] : go rest newtype BlockAttr = BlockAttr Attr @@ -338,8 +348,8 @@ formatNumber :: OrderedListStyle -> Int -> Layout.Doc Text formatNumber Decimal n = literal (T.pack (show n))-formatNumber LetterUpper n = literal (T.singleton (chr (ord 'A' + n - 1)))-formatNumber LetterLower n = literal (T.singleton (chr (ord 'a' + n - 1)))+formatNumber LetterUpper n = literal (T.singleton (chr (ord 'A' + (n - 1) `mod` 26)))+formatNumber LetterLower n = literal (T.singleton (chr (ord 'a' + (n - 1) `mod` 26))) formatNumber RomanUpper n = literal $ toRomanNumeral n formatNumber RomanLower n = literal $ T.toLower (toRomanNumeral n) @@ -368,11 +378,16 @@ toLayout (Node _pos attr il) = (<>) <$> case il of Str bs -> do- let fixSmart = T.replace "\x2014" "---" .+ let fixSmart+ -- all the smart characters are UTF-8 sequences+ -- starting with 0xE2:+ | B8.elem '\xE2' bs =+ T.replace "\x2014" "---" . T.replace "\x2013" "--" . T.replace "\x2026" "..." . T.replace "\x2019" "'" . T.replace "\x201C" "\""+ | otherwise = id let chunks = T.groupBy (\c d -> (c /= ' ' && d /= ' ') || (c == ' ' && d == ' '))@@ -380,7 +395,8 @@ let toChunk ch = case T.uncons ch of Just (' ', rest)- -> afterBreak "{}" <> space <> literal rest+ | T.null rest -> afterBreak "{}" <> space+ | otherwise -> afterBreak "{}" <> space <> literal rest _ -> literal ch pure $ hcat $ map toChunk chunks SoftBreak -> do
src/Djot/Html.hs view
@@ -68,32 +68,30 @@ (Link (str (strToUtf8 "\8617\65038")) (Direct ("#fnref" <> num))) -{-# INLINE escapeHtml #-} escapeHtml :: ByteString -> Builder escapeHtml bs =- if hasEscapable bs- then B.foldl' go mempty bs- else byteString bs+ case B.uncons rest of+ Nothing -> byteString before+ Just (w, rest') -> byteString before <> escaped w <> escapeHtml rest' where- hasEscapable = B.any (\w -> w == 38 || w == 60 || w == 62)- go b 38 = b <> byteString "&"- go b 60 = b <> byteString "<"- go b 62 = b <> byteString ">"- go b c = b <> word8 c+ (before, rest) = B.break (\w -> w == 38 || w == 60 || w == 62) bs+ escaped 38 = byteString "&"+ escaped 60 = byteString "<"+ escaped 62 = byteString ">"+ escaped w = word8 w -- unreachable -{-# INLINE escapeHtmlAttribute #-} escapeHtmlAttribute :: ByteString -> Builder escapeHtmlAttribute bs =- if hasEscapable bs- then B.foldl' go mempty bs- else byteString bs+ case B.uncons rest of+ Nothing -> byteString before+ Just (w, rest') -> byteString before <> escaped w <> escapeHtmlAttribute rest' where- hasEscapable = B.any (\w -> w == 38 || w == 60 || w == 62 || w == 34)- go b 38 = b <> byteString "&"- go b 60 = b <> byteString "<"- go b 62 = b <> byteString ">"- go b 34 = b <> byteString """- go b c = b <> word8 c+ (before, rest) = B.break (\w -> w == 38 || w == 60 || w == 62 || w == 34) bs+ escaped 38 = byteString "&"+ escaped 60 = byteString "<"+ escaped 62 = byteString ">"+ escaped 34 = byteString """+ escaped w = word8 w -- unreachable data BState = BState { noteMap :: NoteMap
src/Djot/Inlines.hs view
@@ -164,6 +164,7 @@ '-' -> pHyphens '.' -> pEllipses '\n' -> pSoftBreak+ '\r' -> pSoftBreak _ -> mzero) <|> pSpecial ) <|> pWords@@ -372,10 +373,14 @@ res <- byteStringOf $ skipSome $ skipSatisfyByte (\c -> c /= '>' && c /= '<') asciiChar '>' let url = B8.filter (\c -> c /= '\n' && c /= '\r') res- case B8.find (\c -> c == '@' || c == ':' || c == '.') url of- Just '@' -> pure $ emailLink url- Just _ -> pure $ urlLink url- Nothing -> mzero+ -- an email link contains an '@' preceded by a character other than ':'+ -- (cf. djot.js, which tests /[^:]@/)+ let isEmail = or $ B8.zipWith (\a b -> b == '@' && a /= ':') url (B.drop 1 url)+ if isEmail+ then pure $ emailLink url+ else case B8.find (\c -> c == ':' || c == '.') url of+ Just _ -> pure $ urlLink url+ Nothing -> mzero pLinkOrSpan :: P Inlines pLinkOrSpan = do@@ -430,7 +435,7 @@ pure $ Reference label pAtMost :: Int -> P () -> P ()-pAtMost n pa = optional_ (pa *> when (n > 0) (pAtMost ( n - 1 ) pa))+pAtMost n pa = when (n > 0) $ optional_ (pa *> pAtMost (n - 1) pa) pOpenDoubleQuote :: P () pOpenDoubleQuote = do
src/Djot/Parse.hs view
@@ -100,6 +100,7 @@ data ParserState a = ParserState { chunks :: [Chunk]+ , chunkCount :: !Int -- ^ length of chunks (cached to avoid O(n) length) , subject :: !ByteString , offset :: !Int , line :: !Int@@ -114,6 +115,7 @@ parse parser ustate chunks'' = snd <$> runParser parser ParserState { chunks = chunks'+ , chunkCount = length chunks' , subject = bs , offset = 0 , line = startline@@ -122,7 +124,7 @@ where (chunks', bs, startline, startcol) =- case chunks'' of+ case dropWhile (B.null . chunkBytes) chunks'' of [] -> ([], mempty, 1, 0) (c:cs) -> (cs, chunkBytes c, chunkLine c, chunkColumn c) @@ -135,8 +137,9 @@ unsafeAdvanceByte :: ParserState s -> ParserState s unsafeAdvanceByte st | offset st + 1 >= B.length (subject st)- , c:cs <- chunks st+ , (emptyChunks, c:cs) <- span (B.null . chunkBytes) (chunks st) = st{ chunks = cs+ , chunkCount = chunkCount st - (length emptyChunks + 1) , subject = chunkBytes c , offset = 0 , line = chunkLine c@@ -147,7 +150,7 @@ , line = line st + 1 , column = 1 } 9 -> st{ offset = offset st + 1- , column = column st + (4 - (column st `mod` 4)) }+ , column = column st + (4 - ((column st - 1) `mod` 4)) } !w | w < 0x80 -> st{ offset = offset st + 1 , column = column st + 1 } -- utf8 multibyte: only count byte 1:@@ -288,22 +291,25 @@ withByteString :: Parser s a -> Parser s (a, ByteString) withByteString pa = Parser $ \st -> case runParser pa st of- Just (st', x) -> Just (st', (x, B8.take (offset st' - offset st)- (B8.drop (offset st) (subject st))))+ Just (st', x) -> Just (st', (x, consumedByteString st st')) Nothing -> Nothing -- | Returns bytestring consumed by parse. byteStringOf :: Parser s a -> Parser s ByteString byteStringOf pa = Parser $ \st -> case runParser pa st of- Just (st', _) -> Just (st',- case length (chunks st) - length (chunks st') of- 0 -> B8.take (offset st' - offset st) (B8.drop (offset st) (subject st))- n ->- B8.drop (offset st) (subject st) <>- foldMap chunkBytes (take (n - 1) (chunks st)) <>- B8.take (offset st') (subject st'))+ Just (st', _) -> Just (st', consumedByteString st st') Nothing -> Nothing++-- | Bytestring consumed between two parser states.+consumedByteString :: ParserState s -> ParserState s -> ByteString+consumedByteString st st' =+ case chunkCount st - chunkCount st' of+ 0 -> B8.take (offset st' - offset st) (B8.drop (offset st) (subject st))+ n ->+ B8.drop (offset st) (subject st) <>+ foldMap chunkBytes (take (n - 1) (chunks st)) <>+ B8.take (offset st') (subject st') -- | Succeeds if first parser succeeds and second fails, returning -- first parser's value.
test/Main.hs view
@@ -35,6 +35,8 @@ | (fp, ts) <- tests , takeFileName fp /= "raw.test"] , testGroup "Djot.Parse" parserTests+ , testGroup "Djot.AST" astTests+ , testGroup "djot writer" writerTests , testGroup "sourcepos" sourcePosTests , testGroup "Fuzz" [testProperty "parses all inputs"@@ -52,8 +54,80 @@ (toChunks $ strToUtf8 "ǎ老bc") @?= Just '老') , testProperty "UTF8 conversion round-trips" (\s -> utf8ToStr (strToUtf8 s) == s)+ , testCase "empty chunk mid-stream does not cause premature EOF"+ (parse (satisfy (=='a') *> satisfy (=='b')) ()+ [ Chunk{ chunkBytes = "a", chunkLine = 1, chunkColumn = 0 }+ , Chunk{ chunkBytes = "", chunkLine = 2, chunkColumn = 0 }+ , Chunk{ chunkBytes = "b", chunkLine = 3, chunkColumn = 0 }+ ] @?= Just 'b')+ , testCase "leading empty chunk does not cause premature EOF"+ (parse (satisfy (=='a')) ()+ [ Chunk{ chunkBytes = "", chunkLine = 1, chunkColumn = 0 }+ , Chunk{ chunkBytes = "a", chunkLine = 2, chunkColumn = 0 }+ ] @?= Just 'a') ] +astTests :: [TestTree]+astTests =+ [ testCase "NoPos is an identity for <>" $ do+ Pos 1 1 2 5 <> NoPos @?= Pos 1 1 2 5+ NoPos <> Pos 1 1 2 5 @?= Pos 1 1 2 5+ , testCase "<> on Pos spans both arguments" $+ Pos 1 1 1 4 <> Pos 2 1 2 7 @?= Pos 1 1 2 7+ , testCase "inlinesToByteString emits valid UTF-8" $ do+ inlinesToByteString (singleQuoted (str "a")) @?=+ strToUtf8 "\x2018\&a\x2019"+ inlinesToByteString (doubleQuoted (str "a")) @?=+ strToUtf8 "\x201C\&a\x201D"+ inlinesToByteString nonBreakingSpace @?= strToUtf8 "\xA0"+ , testCase "image alt text with smart quotes is valid UTF-8" $+ convertNoPos "\n" @?=+ "<p><img alt=\"a \x201C\&b\x201D c\" src=\"url\"></p>\n"+ , testCase "auto identifier with smart quotes is valid UTF-8" $+ convertNoPos "# Say \"hi\"\n" @?=+ "<section id=\"Say-\x201Chi\x201D\">\n<h1>Say \x201Chi\x201D</h1>\n</section>\n"+ , testCase "autolink with dot before @ is an email link" $+ convertNoPos "<user.name@example.com>\n" @?=+ "<p><a href=\"mailto:user.name@example.com\">user.name@example.com</a></p>\n"+ , testCase "autolink with @ only after : is a url link" $+ convertNoPos "<x:@example.com>\n" @?=+ "<p><a href=\"x:@example.com\">x:@example.com</a></p>\n"+ , testCase "CR-only line ending produces a soft break" $+ convertNoPos "a\rb\n" @?= "<p>a\nb</p>\n"+ , testCase "CRLF line ending produces a soft break" $+ convertNoPos "a\r\nb\n" @?= "<p>a\nb</p>\n"+ , testCase "whitespace runs in quoted attribute values collapse (as in djot.js)" $+ convertNoPos "{k=\"a b\"}\npara\n" @?=+ "<p k=\"a b\">para</p>\n"+ , testCase "newline in quoted attribute value becomes a space" $+ convertNoPos "{k=\"a\n b\"}\npara\n" @?=+ "<p k=\"a b\">para</p>\n"+ , testCase "reference labels of no more than 400 bytes" $ do+ let label400 = BL.pack (replicate 400 'x')+ convertNoPos ("[a][" <> label400 <> "]\n") @?= "<p><a>a</a></p>\n"+ convertNoPos ("[a][" <> label400 <> "y]\n") @?=+ "<p>[a][" <> fromUtf8 (label400 <> "y") <> "]</p>\n"+ ]++convertNoPos :: BL.ByteString -> TL.Text+convertNoPos = either mempty (fromUtf8 . toLazyByteString .+ renderHtml RenderOptions{ preserveSoftBreaks = True })+ . parseDoc ParseOptions{ sourcePositions = NoSourcePos }+ . BL.toStrict++writerTests :: [TestTree]+writerTests =+ [ testCase "letter list style wraps around after 26" $+ render Nothing (renderDjot RenderOptions{ preserveSoftBreaks = True }+ mempty{ docBlocks = Djot.AST.orderedList+ OrderedListAttributes{ orderedListStyle = LetterUpper+ , orderedListDelim = RightPeriod+ , orderedListStart = 27 }+ Tight+ [para (str "one"), para (str "two")] })+ @?= "A. one\nB. two\n"+ ]+ sourcePosTests :: [TestTree] sourcePosTests = let convert = either mempty (fromUtf8 . toLazyByteString .@@ -65,6 +139,12 @@ , testCase "attr after *" $ convert "*{.foo}\n" @?= "<p data-pos=\"1:1-1:7\"><span data-pos=\"1:1-1:1\" class=\"foo\">*</span></p>\n"+ , testCase "attr on last word of merged strs" $+ convert "x y.z{.c}\n" @?=+ "<p data-pos=\"1:1-1:9\"><span data-pos=\"1:1-1:2\">x </span><span data-pos=\"1:3-1:5\" class=\"c\">y.z</span></p>\n"+ , testCase "tab advances to next tab stop (1-based)" $+ convert "a\tb *c*\n" @?=+ "<p data-pos=\"1:1-1:9\"><span data-pos=\"1:1-1:6\">a\tb </span><strong data-pos=\"1:7-1:9\"><span data-pos=\"1:8-1:8\">c</span></strong></p>\n" , testCase "no newline at end" $ convert "foo" @?= "<p data-pos=\"1:1-1:3\"><span data-pos=\"1:1-1:3\">foo</span></p>\n"
test/attributes.test view
@@ -13,7 +13,16 @@ <p>(some <span class="attr">text)</span></p> ``` +The last word may come from several parsed inline elements+(here, "y", ".", and "z"):+ ```+x y.z{.c}+.+<p>x <span class="c">y.z</span></p>+```++``` [some text]{.attr} . <p><span class="attr">some text</span></p>@@ -117,6 +126,31 @@ A paragraph . <p id="id" class="class" style="color:red">A paragraph</p>+```++The block after multiline attributes may itself be indented:++```+{#id .class+ style="color:red"}+ A paragraph+.+<p id="id" class="class" style="color:red">A paragraph</p>+```++A multiline attribute block may be followed by another one:++```+{.a+ .b}+one++{.c+ .d}+two+.+<p class="a b">one</p>+<p class="c d">two</p> ``` If the attribute block can't be parsed as attributes, it will be
test/links_and_images.test view
@@ -145,6 +145,18 @@ <p><a href="url">link <em>and</em> link</a></p> ``` +An implicit reference label may span multiple lines:++```+[link+text][]++[link text]: url+.+<p><a href="url">link+text</a></p>+```+ ```  .