packages feed

commonmark-wikilink (empty) → 0.1.0.0

raw patch · 4 files changed

+398/−0 lines, 4 filesdep +aesondep +basedep +commonmark

Dependencies added: aeson, base, commonmark, commonmark-pandoc, megaparsec, network-uri, pandoc-types, parsec, relude, uri-encode, url-slug

Files

+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2022 Sridhar Ratnakumar++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,5 @@+# commonmark-wikilink++An improved version of [the official wikilink extension](https://hackage.haskell.org/package/commonmark-extensions-0.2.3.3/docs/Commonmark-Extensions-Wikilinks.html) that works with Markdown wikilinks in the wild (such as Obsidian).++This library is used in [Emanote](https://emanote.srid.ca/).
+ commonmark-wikilink.cabal view
@@ -0,0 +1,86 @@+cabal-version:      2.4+name:               commonmark-wikilink+version:            0.1.0.0+license:            MIT+copyright:          2022 Sridhar Ratnakumar+maintainer:         srid@srid.ca+author:             Sridhar Ratnakumar+category:           Web+synopsis:           Obsidian-friendly commonmark wikilink parser+description:        Obsidian-friendly commonmark wikilink parser+bug-reports:        https://github.com/srid/commonmark-wikilink+extra-source-files:+  LICENSE+  README.md++common shared+  ghc-options:+    -Wall -Wincomplete-record-updates -Wincomplete-uni-patterns+    -Wmissing-deriving-strategies -Wunused-foralls -Wunused-foralls+    -fprint-explicit-foralls -fprint-explicit-kinds++  mixins:+    base hiding (Prelude),+    relude (Relude as Prelude, Relude.Container.One),+    relude++  default-extensions:+    NoStarIsType+    BangPatterns+    ConstraintKinds+    DataKinds+    DeriveDataTypeable+    DeriveFoldable+    DeriveFunctor+    DeriveGeneric+    DeriveLift+    DeriveTraversable+    DerivingStrategies+    DerivingVia+    EmptyCase+    EmptyDataDecls+    EmptyDataDeriving+    ExistentialQuantification+    ExplicitForAll+    FlexibleContexts+    FlexibleInstances+    GADTSyntax+    GeneralisedNewtypeDeriving+    ImportQualifiedPost+    KindSignatures+    LambdaCase+    MultiParamTypeClasses+    MultiWayIf+    NumericUnderscores+    OverloadedStrings+    PolyKinds+    PostfixOperators+    RankNTypes+    ScopedTypeVariables+    StandaloneDeriving+    StandaloneKindSignatures+    TupleSections+    TypeApplications+    TypeFamilies+    TypeOperators+    ViewPatterns++  build-depends:+    , aeson+    , base               >=4.13.0.0 && <4.18.0.0.0+    , commonmark+    , commonmark-pandoc+    , megaparsec+    , network-uri+    , pandoc-types+    , parsec+    , relude             >=1.0+    , uri-encode+    , url-slug++  hs-source-dirs:     src+  default-language:   Haskell2010++library+  import:          shared+  exposed-modules: Commonmark.Extensions.WikiLink
+ src/Commonmark/Extensions/WikiLink.hs view
@@ -0,0 +1,286 @@+{-# LANGUAGE DeriveAnyClass #-}++module Commonmark.Extensions.WikiLink (+  -- * Types+  WikiLink,+  WikiLinkType (..),++  -- * Parsing wikilinks+  mkWikiLinkFromSlugs,+  mkWikiLinkFromInline,+  delineateLink,++  -- * Wikilink candidates+  allowedWikiLinks,++  -- * Converting wikilinks+  wikilinkInline,+  wikiLinkInlineRendered,++  -- * Commonmark parser spec+  wikilinkSpec,++  -- * Anchors in URLs+  Anchor,+  anchorSuffix,++  -- * Pandoc helper+  plainify,+) where++import Commonmark qualified as CM+import Commonmark.Pandoc qualified as CP+import Commonmark.TokParsers qualified as CT+import Control.Monad (liftM2)+import Data.Aeson (ToJSON (toJSON))+import Data.Data (Data)+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Network.URI.Encode qualified as UE+import Network.URI.Slug (Slug)+import Network.URI.Slug qualified as Slug+import Text.Megaparsec qualified as M+import Text.Pandoc.Builder qualified as B+import Text.Pandoc.Walk qualified as W+import Text.Parsec qualified as P+import Text.Read (Read (readsPrec))+import Text.Show qualified (Show (show))++{- | Represents the "Foo" in [[Foo]]++ As wiki links may contain multiple path components, it can represent+ [[Foo/Bar]], hence we use nonempty slug list.+-}+newtype WikiLink = WikiLink {unWikiLink :: NonEmpty Slug}+  deriving stock (Eq, Ord, Typeable, Data)++instance ToJSON WikiLink where+  toJSON = toJSON . wikilinkUrl++instance Show WikiLink where+  show wl =+    toString $ "[[" <> wikilinkUrl wl <> "]]"++-- -----------------+-- Making wiki links+-- -----------------++mkWikiLinkFromSlugs :: NonEmpty Slug -> WikiLink+mkWikiLinkFromSlugs = WikiLink++mkWikiLinkFromUrl :: (Monad m, Alternative m) => Text -> m WikiLink+mkWikiLinkFromUrl s = do+  slugs <- maybe empty pure $ nonEmpty $ Slug.decodeSlug <$> T.splitOn "/" s+  pure $ WikiLink slugs++mkWikiLinkFromInline :: B.Inline -> Maybe (WikiLink, [B.Inline])+mkWikiLinkFromInline inl = do+  B.Link (_id, _class, otherAttrs) is (url, tit) <- pure inl+  (Left (_, wl), _manchor) <- delineateLink (otherAttrs <> one ("title", tit)) url+  pure (wl, is)++-- | An URL anchor without the '#'+newtype Anchor = Anchor Text+  deriving newtype (Eq, Show, Ord)++mkAnchor :: String -> Maybe Anchor+mkAnchor ('#' : name) = pure $ Anchor $ toText name+mkAnchor _ = Nothing++anchorSuffix :: Maybe Anchor -> Text+anchorSuffix = maybe "" (\(Anchor a) -> "#" <> a)++dropUrlAnchor :: Text -> (Text, Maybe Anchor)+dropUrlAnchor = second (mkAnchor . toString) . T.breakOn "#"++{- | Given a Pandoc Link node, apparaise what kind of link it is.++ * Nothing, if the link is an absolute URL+ * Just (Left wl), if a wiki-link+ * Just (Right fp), if a relative path (not a wiki-link)+-}+delineateLink :: [(Text, Text)] -> Text -> Maybe (Either (WikiLinkType, WikiLink) FilePath, Maybe Anchor)+delineateLink (Map.fromList -> attrs) url = do+  -- Must be relative+  guard $ not $ "://" `T.isInfixOf` url+  wikiLink <|> internalLink+  where+    wikiLink = do+      wlType :: WikiLinkType <- readMaybe . toString <=< Map.lookup htmlAttr $ attrs+      -- Ignore anchors until https://github.com/srid/emanote/discussions/105+      let (s, manc) = dropUrlAnchor url+      wl <- mkWikiLinkFromUrl s+      pure (Left (wlType, wl), manc)+    internalLink = do+      -- Avoid links like "mailto:", "magnet:", etc.+      -- An easy way to parse them is to look for colon character.+      --+      -- This does mean that "Foo: Bar.md" cannot be linked to this way, however+      -- the user can do it using wiki-links.+      guard $ not $ ":" `T.isInfixOf` url+      let (s, manc) = dropUrlAnchor url+      guard $ not $ T.null s -- Same page anchors+      pure (Right $ UE.decode (toString s), manc)++-- ---------------------+-- Converting wiki links+-- ---------------------++-- | [[Foo/Bar]] -> "Foo/Bar"+wikilinkUrl :: WikiLink -> Text+wikilinkUrl =+  T.intercalate "/" . fmap Slug.unSlug . toList . unWikiLink++wikilinkInline :: WikiLinkType -> WikiLink -> B.Inlines -> B.Inlines+wikilinkInline typ wl = B.linkWith attrs (wikilinkUrl wl) ""+  where+    attrs = ("", [], [(htmlAttr, show typ)])++wikiLinkInlineRendered :: B.Inline -> Maybe Text+wikiLinkInlineRendered x = do+  (wl, inl) <- mkWikiLinkFromInline x+  pure $ case nonEmpty inl of+    Nothing -> show wl+    Just _ ->+      let inlStr = plainify inl+       in if inlStr == wikilinkUrl wl+            then show wl+            else "[[" <> wikilinkUrl wl <> "|" <> plainify inl <> "]]"++{- | Return the various ways to link to a route (ignoring ext)++ Foo/Bar/Qux.md -> [[Qux]], [[Bar/Qux]], [[Foo/Bar/Qux]]++ All possible combinations of Wikilink type use is automatically included.+-}+allowedWikiLinks :: HasCallStack => NonEmpty Slug -> NonEmpty (WikiLinkType, WikiLink)+allowedWikiLinks slugs =+  let wls = WikiLink <$> tailsNE slugs+      typs :: NonEmpty WikiLinkType = NE.fromList universe+   in liftM2 (,) typs wls+  where+    tailsNE =+      NE.fromList . mapMaybe nonEmpty . tails . toList++-------------------------+-- Parser+--------------------------++{- | A # prefix or suffix allows semantically distinct wikilinks++ Typically called branching link or a tag link, when used with #.+-}+data WikiLinkType+  = -- | [[Foo]]+    WikiLinkNormal+  | -- | [[Foo]]#+    WikiLinkBranch+  | -- | #[[Foo]]+    WikiLinkTag+  | -- | ![[Foo]]+    WikiLinkEmbed+  deriving stock (Eq, Show, Ord, Typeable, Data, Enum, Bounded, Generic)+  deriving anyclass (ToJSON)++instance Read WikiLinkType where+  readsPrec _ s+    | s == show WikiLinkNormal = [(WikiLinkNormal, "")]+    | s == show WikiLinkBranch = [(WikiLinkBranch, "")]+    | s == show WikiLinkTag = [(WikiLinkTag, "")]+    | s == show WikiLinkEmbed = [(WikiLinkEmbed, "")]+    | otherwise = []++-- | The HTML 'data attribute' storing the wiki-link type.+htmlAttr :: Text+htmlAttr = "data-wikilink-type"++class HasWikiLink il where+  wikilink :: WikiLinkType -> WikiLink -> Maybe il -> il++instance HasWikiLink (CP.Cm b B.Inlines) where+  wikilink typ wl il =+    CP.Cm $ wikilinkInline typ wl $ maybe mempty CP.unCm il++{- | Like `Commonmark.Extensions.Wikilinks.wikilinkSpec` but Zettelkasten-friendly.++ Compared with the official extension, this has two differences:++ - Supports flipped inner text, eg: `[[Foo | some inner text]]`+ - Supports neuron folgezettel, i.e.: #[[Foo]] or [[Foo]]#+-}+wikilinkSpec ::+  (Monad m, CM.IsInline il, HasWikiLink il) =>+  CM.SyntaxSpec m il bl+wikilinkSpec =+  mempty+    { CM.syntaxInlineParsers =+        [ P.try $+            P.choice+              [ P.try (CT.symbol '#' *> pWikilink WikiLinkTag)+              , P.try (CT.symbol '!' *> pWikilink WikiLinkEmbed)+              , P.try (pWikilink WikiLinkBranch <* CT.symbol '#')+              , P.try (pWikilink WikiLinkNormal)+              ]+        ]+    }+  where+    pWikilink typ = do+      replicateM_ 2 $ CT.symbol '['+      P.notFollowedBy (CT.symbol '[')+      url <-+        CM.untokenize <$> many (satisfyNoneOf [isPipe, isAnchor, isClose])+      wl <- mkWikiLinkFromUrl url+      -- We ignore the anchor until https://github.com/srid/emanote/discussions/105+      _anchor <-+        M.optional $+          CM.untokenize+            <$> ( CT.symbol '#'+                    *> many (satisfyNoneOf [isPipe, isClose])+                )+      title <-+        M.optional $+          -- TODO: Should parse as inline so link text can be formatted?+          CM.untokenize+            <$> ( CT.symbol '|'+                    *> many (satisfyNoneOf [isClose])+                )+      replicateM_ 2 $ CT.symbol ']'+      return $ wikilink typ wl (fmap CM.str title)+    satisfyNoneOf toks =+      CT.satisfyTok $ \t -> not $ or $ toks <&> \tok -> tok t+    isAnchor =+      isSymbol '#'+    isPipe =+      isSymbol '|'+    isClose =+      isSymbol ']'+    isSymbol c =+      CT.hasType (CM.Symbol c)++{- | Convert Pandoc AST inlines to raw text.++ TODO: extend on top of plainify from heist-extra+-}+plainify :: [B.Inline] -> Text+plainify = W.query $ \case+  B.Str x -> x+  B.Code _attr x -> x+  B.Space -> " "+  B.SoftBreak -> " "+  B.LineBreak -> " "+  -- TODO: if fmt is html, we should strip the html tags+  B.RawInline _fmt s -> s+  -- Ignore "wrapper" inlines like span.+  B.Span _ _ -> ""+  -- TODO: How to wrap math stuff here?+  B.Math _mathTyp s -> s+  -- Wiki-links must be displayed using its show instance (which returns its+  -- human-readable representation)+  (mkWikiLinkFromInline -> Just (wl, customText)) ->+    if null customText+      then show wl -- We will display raw wikilink; ideally, though, we want to display the title.+      else plainify customText+  -- Ignore the rest of AST nodes, as they are recursively defined in terms of+  -- `Inline` which `W.query` will traverse again.+  _ -> ""