packages feed

hakyll-diagrams (empty) → 0.1.0.0

raw patch · 9 files changed

+1176/−0 lines, 9 filesdep +HUnitdep +basedep +base16-bytestringsetup-changed

Dependencies added: HUnit, base, base16-bytestring, containers, cryptohash-sha1, data-default, diagrams, diagrams-core, diagrams-lib, diagrams-svg, hakyll, hakyll-diagrams, hint, hspec, pandoc-types, split, svg-builder, tagsoup, text

Files

+ LICENSE view
@@ -0,0 +1,29 @@+Copyright (c) 2025, Renato Garcia+++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of the copyright holder nor the names of its+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,343 @@+# hakyll-diagrams++**Contents**+* [Overview](#overview)+* [Usage](#usage)+  * [Input format](#input-format)+     * [Code](#code)+     * [Options](#options)+  * [Metadata options](#metadata-options)+  * [Haskell API](#haskell-api)+  * [Package databases](#package-databases)+* [Similar projects](#similar-projects)+++## Overview++Compiles any [diagrams](https://diagrams.github.io/) code found within markdown source files+and inserts the resulting figures into the generated HTML output. The figures can be embedded+as inline SVG code or referenced via external image files using `<img>` tags.++For example, when a block like this is found in a markdown file:++```` Haskell+``` diagram { svg:width=300 }+let+  target = mconcat+    [ circle 1 # lw 0 # fc red+    , circle 2 # lw 0 # fc white+    , circle 3 # lw 0 # fc red+    ]++  background = roundedRect 8 8 0.1+    # lw 0+    # fc (sRGB24read "#808080")+    # opacity 0.15++in target <> background+```+````++That code will be compiled, and replaced by the rendered SVG figure:++![](./docs-data/target.svg)++If we add a few attributes to the rendered SVG figure:++```` Haskell+``` diagram {+  figcaption="An interactive figure. It reacts to mouse hover and each part is a link."+  svg:width=400+}+let+  hydrogen =+    mconcat+      [ proton+      , electron # moveTo p0+      , orbit+      ]+    where+      orbit =+        circle 2+          # svgAttr "pointer-events" "stroke"+          # href "https://en.wikipedia.org/wiki/Atomic_orbital"+          # svgClass "svg_orbit"+      electron =+        circle 0.1+          # fc blue+          # lw 0+          # href "https://en.wikipedia.org/wiki/Electron"+          # svgClass "svg_electron"+      proton =+        circle 0.3+          # fc red+          # lw 0+          # href "https://en.wikipedia.org/wiki/Proton"+          # svgClass "svg_proton"+      p0 = fromJust $ maxTraceP origin (angleV $ 3/8 @@ turn) orbit++  background =+    roundedRect 5 5 0.1+      # lw 0+      # fc (sRGB24read "#808080")+      # opacity 0.15++in hydrogen <> background+```+````++We will generate this figure:++![](./docs-data/hydrogen.svg)++And with the help of some additional CSS or JavaScript code, we can make it an interactive+figure. That will not be possible to be shown here, because GitHub has restrictions on+adding inline SVG and JavaScript code to the Markdown files, however that same figure with+interactive features is available+[here](http://renatogarcia.blog.br/posts/hakyll-diagram-example.html).++## Usage++### Input format++In a source file, any Haskell code within a *code block* having `diagram` as one of its+classes will be rendered to a diagram figure. In a markdown file, it means any code block+like this:++````{.markdown}+``` diagram {+        <options>+    }+<code>+```+````++Where `<options>` is a list of library and HTML tag attributes, fully described in the+[Options](#options) section, and `<code>` must consist of a single Haskell expression+whose resulting value has type [`Diagram SVG`](https://hackage.haskell.org/package/diagrams-core-1.5.1.1/docs/Diagrams-Core.html#t:Diagram).++#### Code++The code block must contain a single Haskell expression of type+[`Diagram SVG`](https://hackage.haskell.org/package/diagrams-core/docs/Diagrams-Core.html#t:Diagram).++The interpreter environment is configurable via the [`Options`] record, which serves as an+argument to the [`drawDiagramsWith`] function. All exports from modules listed in+[`globalModules`] and [`localModules`] will be available in the context.++#### Options++The options configure what the diagram rendering will produce as results, and specify any+additional attributes for the resulting HTML and SVG elements.++A valid options list must be in the format:++```+options    := {option}+option     := id | class | figcaption | attribute+id         := "#" name+class      := "." name+figcaption := "figcaption=" value+attritube  := tag ":" name "=" value+tag        := "figure" | "img" | "svg"+name       := <any valid HTML attribute name>+value      := <any valid HTML attribute value>+```++An input like this:++```` Text+``` diagram { img:src="external_figure.svg" figcaption="foo"+      .my_class_a .my_class_b  #my_id+      img:alt="a circle" figure:myattr=foo img:width=10+      svg:class="my_svg_class" svg:width=50+    }+circle 10+```+````++Will generate an external SVG file named *external_figure.svg*, written under the Hakyll's+configured+[`destinationDirectory`](https://hackage.haskell.org/package/hakyll/docs/Hakyll-Core-Configuration.html#v:destinationDirectory).+The `<svg>` tag will have the following attributes:++``` HTML+<svg width="50.0000" class="my_svg_class" ... >+```++Besides generating an external file, that input will be replaced in the resulting HTML+file by a code like:++``` HTML+<figure id="my_id" class="my_class_a my_class_b" data-myattr="foo">+  <img src="external_figure.svg" alt="a circle" width="10" />+  <figcaption>foo</figcaption>+</figure>+```++Regardless of any options, a `<svg>` element will always be generated. If we have set an+`img:src="path"` attribute, the rendered SVG will be written to an external file at+*path*, and an `<img>` element will link to it. If we don't have an `img:src="path"`+attribute, the rendered SVG will be inlined in the HTML code.++If we have set a `figcaption="caption"` attribute, the `<svg>` or `<img>` element will be+nested in a `<figure>` element. Otherwise they will be inserted without a parent.++The attributes must be in the format: `tag:name=value`, and *tag* must be one of:+`figure`, `img`, and `svg`. This means that the pair `name=value` will be assigned to the+*tag* element. If that element would not be generated, such as when we don't have an+`<img>` tag because of an inlined SVG, or if the tag has any other value than those valid+three, it will be ignored without an error.++The `#id` and `.class` values will be assigned to the outermost element, whether it is+`<figure>`, `<img>`, or `<svg>`.++### Metadata options++We can use the Hakyll's [metadata+block](https://jaspervdj.be/hakyll/tutorials/02-basics.html#pages-and-metadata) to create+an [`Options`] record to be used as argument to the [`drawDiagramsWith`] functions that+will render that page diagrams.++This works starting from a base [`Options`] record, whose fields will be modified by the+values at the metadata block. Each one of the record fields can be assigned a value+through a metadata key named the same as that field prefixed with `dg.`.++- Each field must be formatted as a list where each element is separated by a comma `,`.+- Any occurrence of an ellipsis `...` will be replaced by the current value of that field+  as in the base [`Options`].+- When setting either [`globalModules`] or [`localModules`], a value like `Utils` will+  result in an unqualified import of *Utils* module. A value like `Commons as Cm` will+  result in a qualified import of *Commons* module under *Cm* name.+- Any field not present will retain the same value as in the base [`Options`]. An empty+  string `""` will set the field to an empty list.++When starting with base:++```Haskell+Options+  { globalModules =+      [ ("Prelude", Nothing)+      , ("Diagrams.Prelude", Nothing)+      , ("Diagrams.Backend.SVG", Nothing)+      ]+  , localModules = []+  , searchPaths = ["app", "posts"]+  , languageExtensions = ["NoMonomorphismRestriction"]+  }+```++the metadata block:++```yaml+---+dg.localModules: Utils, Commons as Cm+dg.searchPaths: lib, ..., foo+dg.languageExtensions: ""+---+```++will result in:++```Haskell+Options+  { globalModules =+      [ ("Prelude", Nothing)+      , ("Diagrams.Prelude", Nothing)+      , ("Diagrams.Backend.SVG", Nothing)+      ]+  , localModules = [("Utils", Nothing), ("Commons", Just "Cm")]+  , searchPaths = ["lib", "app", "posts", "foo"]+  , languageExtensions = []+  }+```++### Haskell API++All the API documentation is available in the Haddock generated [project+page](http://renatogarcia.blog.br/hakyll-diagrams).++The main functions are [`drawDiagrams`] and [`drawDiagramsWith`]. Both functions accept a+[Pandoc](https://hackage.haskell.org/package/pandoc-types/docs/Text-Pandoc-Definition.html#t:Pandoc)+data structure and transform it by replacing+[CodeBlock's](https://hackage.haskell.org/package/pandoc-types/docs/Text-Pandoc-Definition.html#v:CodeBlock)+having a `diagram` class with their rendered diagram figures, as explained in the [Input+format](#input-format) section.++The simplest way to integrate these functions into a [Hakyll's+rule](https://jaspervdj.be/hakyll/tutorials/03-rules-routes-compilers.html#basic-rules) is+with code like this:++``` Haskell+match "posts/*.md" $ do+  route $ setExtension "html"+  compile $+    pandocCompilerWithTransformM+      defaultHakyllReaderOptions+      defaultHakyllWriterOptions+      drawDiagrams+```++We can use either [`readOptionsFromMetadata`] or [`readOptionsFromMetadataWith`] function+to control at the input source level what [`Options`] will be used to configure the+interpreter environment when rendering the diagrams on that page. The [Metadata+options](#metadata-options) section explains the metadata block syntax.++One simple way to add this metadata reading functionality to the example code above is to+replace the `drawDiagrams` function by a `drawDiagrams'` defined as:++``` Haskell+drawDiagrams' :: Pandoc -> Compiler Pandoc+drawDiagrams' pandoc = readOptionsFromMetadataWith opts >>= flip drawDiagramsWith pandoc+  where+    opts = defaultOptions { searchPaths  = ["app", "posts"] }+```++### Package databases++The [hint library](https://github.com/haskell-hint/hint?tab=readme-ov-file) is used to+interpret the Haskell code, and therefore all of its+[limitations](https://github.com/haskell-hint/hint?tab=readme-ov-file#limitations) will+apply here as well. In particular, if there isn't a `.ghc.environment.\<something>`+[package environment+file](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/packages.html#package-environments)+(as generated by cabal+[`--write-ghc-environment-files=always`](https://cabal.readthedocs.io/en/3.4/cabal-project.html#cfg-field-write-ghc-environment-files)+option), setting either `HAKYLL_DIAGRAMS_PACKAGE_ENV` (a single path) or+`HAKYLL_DIAGRAMS_PACKAGE_DB` (N comma-separated paths) environment variables may be+required. If these environment variables are defined, their values will be used to set the+GHC+[`-package-env`](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/packages.html#ghc-flag-package-env-file-name)+and+[`-package-db`](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/packages.html#ghc-flag-package-db-file)+options respectively.++## Similar projects++There are two *diagrams* projects,+[diagrams-pandoc](https://github.com/diagrams/diagrams-pandoc) and+[diagrams-builder](https://github.com/diagrams/diagrams-builder), that implement+a portion of the functionality of this *hakyll-diagrams* library. They are not used+as dependencies because they would not allow some useful features. For example, with+*diagrams-builder* it is not possible to import a local module qualified, and it has+some [potential issues](https://hackage.haskell.org/package/diagrams-builder/docs/Diagrams-Builder-Modules.html#v:combineModules)+when combining them. With *diagrams-pandoc* we cannot add arbitrary attributes to a+specific HTML element, or generate an inlined SVG instead of an externally linked file.++There is also a homonymous [hakyll-diagrams](https://github.com/cdupont/hakyll-diagrams)+library that integrates the two diagrams libraries with hakyll, running them inside+the+[`Compiler`](https://hackage.haskell.org/package/hakyll/docs/Hakyll-Core-Compiler.html#t:Compiler)+monad. Since it uses those two diagrams libraries, it has the same limitations+discussed above. Because of this, I have decided to create a new hakyll-diagrams library+with all the desired features.++++[`Options`]: http://renatogarcia.blog.br/hakyll-diagrams/Hakyll-Web-Pandoc-Diagrams.html#t:Options+[`drawDiagrams`]: http://renatogarcia.blog.br/hakyll-diagrams/Hakyll-Web-Pandoc-Diagrams.html#v:drawDiagrams+[`drawDiagramsWith`]: http://renatogarcia.blog.br/hakyll-diagrams/Hakyll-Web-Pandoc-Diagrams.html#v:drawDiagramsWith+[`globalModules`]: http://renatogarcia.blog.br/hakyll-diagrams/Hakyll-Web-Pandoc-Diagrams.html#v:globalModules+[`localModules`]: http://renatogarcia.blog.br/hakyll-diagrams/Hakyll-Web-Pandoc-Diagrams.html#v:localModules+[`readOptionsFromMetadata`]: http://renatogarcia.blog.br/hakyll-diagrams/Hakyll-Web-Pandoc-Diagrams.html#v:readOptionsFromMetadata+[`readOptionsFromMetadataWith`]: http://renatogarcia.blog.br/hakyll-diagrams/Hakyll-Web-Pandoc-Diagrams.html#v:readOptionsFromMetadataWith
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ docs-data/target.svg view
@@ -0,0 +1,3 @@+<?xml version="1.0" encoding="UTF-8"?>+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"+    "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg font-size="1" viewBox="0.0 0.0 300.0 300.0" stroke="rgb(0,0,0)" xmlns="http://www.w3.org/2000/svg" width="300.0000" stroke-opacity="1" height="300.0000" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink"><defs></defs><g stroke-linejoin="miter" fill-opacity="1.0" fill="rgb(128,128,128)" stroke="rgb(0,0,0)" stroke-linecap="butt" opacity="0.15" stroke-miterlimit="10.0" stroke-opacity="1.0" stroke-width="0.0"><path d="M 300.0000,295.7143 v -291.4286 c 0.0000,-2.3669 -1.9188,-4.2857 -4.2857 -4.2857h -291.4286 c -2.3669,-0.0000 -4.2857,1.9188 -4.2857 4.2857v 291.4286 c -0.0000,2.3669 1.9188,4.2857 4.2857 4.2857h 291.4286 c 2.3669,0.0000 4.2857,-1.9188 4.2857 -4.2857Z"/></g><g stroke-linejoin="miter" fill-opacity="1.0" fill="rgb(255,0,0)" stroke="rgb(0,0,0)" stroke-linecap="butt" stroke-miterlimit="10.0" stroke-opacity="1.0" stroke-width="0.0"><path d="M 278.5714,150.0000 c 0.0000,-71.0080 -57.5634,-128.5714 -128.5714 -128.5714c -71.0080,-0.0000 -128.5714,57.5634 -128.5714 128.5714c -0.0000,71.0080 57.5634,128.5714 128.5714 128.5714c 71.0080,0.0000 128.5714,-57.5634 128.5714 -128.5714Z"/></g><g stroke-linejoin="miter" fill-opacity="1.0" fill="rgb(255,255,255)" stroke="rgb(0,0,0)" stroke-linecap="butt" stroke-miterlimit="10.0" stroke-opacity="1.0" stroke-width="0.0"><path d="M 235.7143,150.0000 c 0.0000,-47.3387 -38.3756,-85.7143 -85.7143 -85.7143c -47.3387,-0.0000 -85.7143,38.3756 -85.7143 85.7143c -0.0000,47.3387 38.3756,85.7143 85.7143 85.7143c 47.3387,0.0000 85.7143,-38.3756 85.7143 -85.7143Z"/></g><g stroke-linejoin="miter" fill-opacity="1.0" fill="rgb(255,0,0)" stroke="rgb(0,0,0)" stroke-linecap="butt" stroke-miterlimit="10.0" stroke-opacity="1.0" stroke-width="0.0"><path d="M 192.8571,150.0000 c 0.0000,-23.6693 -19.1878,-42.8571 -42.8571 -42.8571c -23.6693,-0.0000 -42.8571,19.1878 -42.8571 42.8571c -0.0000,23.6693 19.1878,42.8571 42.8571 42.8571c 23.6693,0.0000 42.8571,-19.1878 42.8571 -42.8571Z"/></g></svg>
+ hakyll-diagrams.cabal view
@@ -0,0 +1,102 @@+cabal-version:      3.0+name:               hakyll-diagrams+version:            0.1.0.0++synopsis: A Hakyll plugin for rendering diagrams figures from embedded Haskell code.++description: Compiles any Haskell [diagrams](https://diagrams.github.io/) code embedded in+  input source files (Markdown, reStructuredText, etc.), replacing them with the rendered+  diagrams figures in the generated HTML output. The diagrams figures can be inlined as+  SVG code or referenced as external @.svg@ image files using @\<img>@ tags.++  For example, when processing a Markdown input source file, a code block like this:++  @+  ``` diagram { svg:width=300 }+  let+    target = mconcat+      [ circle 1 # lw 0 # fc red+      , circle 2 # lw 0 # fc white+      , circle 3 # lw 0 # fc red+      ]++    background = roundedRect 8 8 0.1+      # lw 0+      # fc (sRGB24read "#808080")+      # opacity 0.15++  in target <> background+  ```+  @++  will be replaced in the resulting HTML page by this figure:++  <<docs-data/target.svg>>++  A thoughtful documentation with usage examples and results can be found at the [project+  repository](https://github.com/renatoGarcia/hakyll-diagrams).+++homepage:           https://github.com/renatoGarcia/hakyll-diagrams++license:            BSD-3-Clause+license-file:       LICENSE+author:             Renato Garcia+maintainer:         fgarcia.renato@gmail.com++category:           Web+build-type:         Simple++extra-doc-files:    docs-data/target.svg+extra-source-files: README.md++tested-with: GHC == { 9.4.8, 9.12.2 }++source-repository head+  type:     git+  location: https://github.com/renatoGarcia/hakyll-diagrams.git++common warnings+  ghc-options: -Wall++common common-deps+  build-depends:+      base >=4.17.2 && <4.22,+      hakyll ^>=4.16.2,+      text >=2.0.2 && <2.2,+      pandoc-types ^>=1.23++library+  import:           warnings, common-deps+  exposed-modules:  Hakyll.Web.Pandoc.Diagrams+  build-depends:+      base16-bytestring ^>=1.0.2,+      cryptohash-sha1 ^>=0.11.101,+      data-default >=0.7.1 && <0.9,+      diagrams ^>=1.4.1,+      diagrams-core ^>=1.5.1,+      diagrams-lib >=1.4.6 && <1.6,+      diagrams-svg >=1.4.3 && <1.6,+      hint ^>=0.9.0,+      split ^>=0.2.3,+      svg-builder ^>=0.1.1++  hs-source-dirs:   src+  default-language: Haskell2010+++test-suite hakyll-diagrams-test+  import:           warnings, common-deps+  default-language: Haskell2010+  other-modules:+      Util+    , HTMLSVGCompare+  type:             exitcode-stdio-1.0+  hs-source-dirs:   test+  main-is:          Main.hs+  build-depends:+      hspec+    , HUnit+    , tagsoup+    , containers+    , hakyll-diagrams
+ src/Hakyll/Web/Pandoc/Diagrams.hs view
@@ -0,0 +1,352 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+License     : BSD-3-Clause+Maintainer  : Renato Garcia+-}+module Hakyll.Web.Pandoc.Diagrams (+  drawDiagrams,+  drawDiagramsWith,+  Options (..),+  defaultOptions,+  readOptionsFromMetadata,+  readOptionsFromMetadataWith,+) where++import Control.Exception (throw)+import qualified Crypto.Hash.SHA1 as SHA1+import qualified Data.ByteString.Base16 as B16+import Data.Default (Default, def)+import Data.Functor ((<&>))+import Data.List (intercalate)+import Data.List.Split (splitOn)+import Data.Maybe (fromMaybe, maybeToList)+import Data.Text (Text, unpack)+import qualified Data.Text as T (drop, isPrefixOf, length, null, pack, take, unpack, unwords)+import qualified Data.Text.Encoding as TE+import qualified Diagrams.Backend.SVG as SVG+import Diagrams.Core (renderDia)+import Diagrams.Prelude (Any, QDiagram, SizeSpec, V2, mkSizeSpec2D)+import Graphics.Svg.Core (makeAttribute)+import Hakyll (Compiler, destinationDirectory, makeDirectories, splitAll, trim)+import Hakyll.Core.Compiler (getUnderlying, unsafeCompiler)+import Hakyll.Core.Compiler.Internal (CompilerRead (..), compilerAsk)+import Hakyll.Core.Metadata (getMetadataField)+import Language.Haskell.Interpreter (OptionVal ((:=)))+import qualified Language.Haskell.Interpreter as Hint+import qualified Language.Haskell.Interpreter.Unsafe as Hint+import System.Environment (lookupEnv)+import Text.Pandoc.Definition (Block (..), Caption (Caption), Inline (..), Pandoc)+import Text.Pandoc.Walk (walkM)+import Text.Read (readMaybe)+++-- | Configure the interpreter environment used when rendering the diagrams.+data Options = Options+  { globalModules :: [(String, Maybe String)]+  -- ^ Global modules to import ("module name", "namespace qualifier")+  , localModules :: [(String, Maybe String)]+  -- ^ Local modules to import ("module name", "namespace qualifier")+  , searchPaths :: [FilePath]+  -- ^ The paths where to search for local modules+  , languageExtensions :: [String]+  -- ^ Language extensions in use by the interpreter+  }+  deriving (Show, Eq)+++instance Default Options where+  def = defaultOptions+++{- | The default value will import "Prelude", "Diagrams.Prelude", and+"Diagrams.Backend.SVG" global modules. All other fields will be emtpty.+-}+defaultOptions :: Options+defaultOptions =+  Options+    { globalModules =+        [ ("Prelude", Nothing)+        , ("Diagrams.Prelude", Nothing)+        , ("Diagrams.Backend.SVG", Nothing)+        ]+    , localModules = []+    , searchPaths = []+    , languageExtensions = []+    }+++splitAtComma :: String -> [String]+splitAtComma = fmap trim . splitAll ","+++readModule :: String -> (String, Maybe String)+readModule x =+  case (fmap trim . splitAll " as ") x of+    [a] -> (a, Nothing)+    [a, b] -> (a, Just b)+    a -> throw $ userError ("Invalid syntax of module metadata: " ++ intercalate " as " a)+++expandEllipsis+  :: [a]+  -- ^ The original value that will replace any ellipsis occurrence+  -> (String -> a)+  -- ^ The function that will read the element from a String+  -> [String]+  -- ^ The list of comma-separated elements from a metadata field+  -> [a]+  -- ^ The resulting element list with all ellipses replaced+expandEllipsis original transf val = val >>= \x -> if x == "..." then original else [transf x]+++{- | Read the 'Options' values from+[metadata block](https://jaspervdj.be/hakyll/tutorials/02-basics.html#pages-and-metadata).++@+---+dg.localModules: Utils, Commons as Cm+dg.searchPaths: lib, ..., posts+dg.languageExtensions: ""+---+@++Each field must be prefixed with a @dg.@ string and must be formatted as a list where each+element is separated by a comma (@,@).++Any occurrence of an ellipsis (@...@) will be replaced by the current value of that field+as in the 'Options' first argument. So to append one element at the end of a list, we can+do: "@..., element@".++When setting either 'globalModules' or 'localModules', a value like "@Utils@" will+translate to @(\"Utils\", Nothing)@, an unqualified import. A value like "@Commons as Cm@"+will translate to @(\"Commons\", Just \"Cm\")@, a qualified import.++Any field not present will retain the same value as in the base 'Options'. An empty string+@""@ will set the field to an empty list.+-}+readOptionsFromMetadataWith+  :: Options+  -- ^ The base 'Options' which will be modified by the metadata values+  -> Compiler Options+  -- ^ The resulting modified 'Options'+readOptionsFromMetadataWith opts = do+  underlyingId <- getUnderlying+  gm <- getMetadataField underlyingId "dg.globalModules"+  lm <- getMetadataField underlyingId "dg.localModules"+  sp <- getMetadataField underlyingId "dg.searchPaths"+  le <- getMetadataField underlyingId "dg.languageExtensions"+  pure $+    opts+      { globalModules = maybe (globalModules opts) (expandEllipsis (globalModules opts) readModule <$> splitAtComma) gm+      , localModules = maybe (localModules opts) (expandEllipsis (localModules opts) readModule <$> splitAtComma) lm+      , searchPaths = maybe (searchPaths opts) (expandEllipsis (searchPaths opts) id <$> splitAtComma) sp+      , languageExtensions = maybe (languageExtensions opts) (expandEllipsis (languageExtensions opts) id <$> splitAtComma) le+      }+++-- | Call 'readOptionsFromMetadataWith' with 'defaultOptions' as the argument+readOptionsFromMetadata :: Compiler Options+readOptionsFromMetadata = readOptionsFromMetadataWith defaultOptions+++setUpInterpreter :: Options -> Text -> Hint.Interpreter (QDiagram SVG.SVG V2 Double Any)+setUpInterpreter opts code = do+  Hint.set+    [ Hint.searchPath := searchPaths opts+    , Hint.languageExtensions := (parseExtension <$> languageExtensions opts)+    ]+  Hint.loadModules $ fst <$> localModules opts+  Hint.setImportsQ $ globalModules opts ++ localModules opts+  Hint.interpret (T.unpack code) (Hint.as :: QDiagram SVG.SVG V2 Double Any)+  where+    parseExtension :: String -> Hint.Extension+    parseExtension t =+      fromMaybe (throw $ userError ("Invalid language extension: " ++ t)) (readMaybe t)+++{- | Parse the values of the HAKYLL_DIAGRAMS_PACKAGE_DB and HAKYLL_DIAGRAMS_PACKAGE_ENV+environment variables and build a list of interpreter arguments.+HAKYLL_DIAGRAMS_PACKAGE_DB can contain N comma-separated values+HAKYLL_DIAGRAMS_PACKAGE_ENV can contain a single value only+-}+getInterpreterArgs :: IO [String]+getInterpreterArgs = do+  packageEnv <- lookupEnv "HAKYLL_DIAGRAMS_PACKAGE_ENV" <&> maybe [] (\x -> ["-package-env", x])+  packageDbs <- lookupEnv "HAKYLL_DIAGRAMS_PACKAGE_DB" <&> maybe [] (prependArgNames . splitOn ":")+  pure $ packageEnv ++ packageDbs+  where+    prependArgNames :: [String] -> [String]+    prependArgNames = concatMap (\x -> ["-package-db", x])+++runInterpreter+  :: Hint.Interpreter (QDiagram SVG.SVG V2 Double Any)+  -> IO (Either Hint.InterpreterError (QDiagram SVG.SVG V2 Double Any))+runInterpreter i = do+  mSandbox <- getInterpreterArgs+  case mSandbox of+    [] -> Hint.runInterpreter i+    a -> Hint.unsafeRunInterpreterWithArgs a i+++buildDiagram :: Options -> Text -> IO (QDiagram SVG.SVG V2 Double Any)+buildDiagram opts code = do+  result <- runInterpreter $ setUpInterpreter opts code+  case result of+    Right diagram -> pure diagram+    Left (Hint.WontCompile errs) ->+      fail $+        foldl (\str err -> str ++ "\n" ++ Hint.errMsg err) "" errs+    Left err -> fail $ show err+++hashCodePrefix :: Text -> Text+hashCodePrefix code =+  let hash = T.take 8 . TE.decodeUtf8 . B16.encode . SHA1.hash . TE.encodeUtf8 $ code+   in "dia_" <> hash <> "_"+++genInlineSvg :: Options -> Text -> SizeSpec V2 Double -> Text -> [Text] -> [(Text, Text)] -> IO Text+genInlineSvg opts code imageSize elementId classes attributes =+  T.pack . show . renderDia SVG.SVG svgOptions <$> buildDiagram opts code+  where+    attrs =+      attributes+        ++ [("id", elementId) | not . T.null $ elementId]+        ++ [("class", T.unwords classes) | not . null $ classes]++    svgOptions =+      SVG.SVGOptions+        imageSize+        Nothing+        (hashCodePrefix code)+        [makeAttribute k v | (k, v) <- attrs]+        False+++genImageFile+  :: Options+  -- ^ Interpreter configuration+  -> FilePath+  -- ^ Destination directory+  -> FilePath+  -- ^ Output path relative to destination+  -> Text+  -- ^ Haskell code to interpret+  -> SizeSpec V2 Double+  -- ^ Output image size+  -> [(Text, Text)]+  -> IO ()+genImageFile opts destDir relPath code imageSize attributes+  | null relPath = fail "The `relPath` attribute of a diagram can not be an empty string."+  | otherwise = do+      makeDirectories imagePath+      SVG.renderSVG' imagePath svgOptions =<< buildDiagram opts code+  where+    imagePath = destDir ++ "/" ++ relPath+    svgOptions =+      SVG.SVGOptions+        imageSize+        Nothing+        (hashCodePrefix code)+        [makeAttribute k v | (k, v) <- attributes]+        True+++imageBlock :: Text -> [Text] -> [(Text, Text)] -> [Inline] -> Text -> Text -> Inline+imageBlock elementId classes attributes alt path title = Image (elementId, classes, attributes) alt (path, title)+++figureBlock :: Text -> [Text] -> [(Text, Text)] -> Text -> Block -> Block+figureBlock elementId classes attributes caption img =+  -- ShortCaption is ignored by Pandoc when generating HTML+  Figure (elementId, classes, attributes) (Caption Nothing [Plain [Str caption]]) [img]+++-- | Compiles the Diagrams code and transforms a code block with a .diagram class in a figure block+transformBlock :: Options -> FilePath -> Block -> IO Block+transformBlock opts destDir (CodeBlock (elementId, classes, keyVals) code)+  | Just relpath <- lookup "img:src" keyVals+  , Just caption <- lookup "figcaption" keyVals = do+      genImageFile opts destDir (unpack relpath) code (mkSizeSpec2D svgWidth svgHeight) (tagAttributes "svg")+      pure $+        figureBlock elementId classes (tagAttributes "figure") caption $+          Plain [imageBlock "" [] (tagAttributes "img") altText relpath imgTitle]+  | Just relpath <- lookup "img:src" keyVals = do+      genImageFile opts destDir (unpack relpath) code (mkSizeSpec2D svgWidth svgHeight) (tagAttributes "svg")+      pure $ Plain [imageBlock elementId classes (tagAttributes "img") altText relpath imgTitle]+  | Just caption <- lookup "figcaption" keyVals = do+      svgText <- genInlineSvg opts code (mkSizeSpec2D svgWidth svgHeight) "" [] (tagAttributes "svg")+      pure $ figureBlock elementId classes (tagAttributes "figure") caption $ RawBlock "html" svgText+  | otherwise = do+      svgText <- genInlineSvg opts code (mkSizeSpec2D svgWidth svgHeight) elementId classes (tagAttributes "svg")+      pure $ RawBlock "html" svgText+  where+    tagAttributes :: Text -> [(Text, Text)]+    tagAttributes tag =+      [ (T.drop (T.length prefix) k, v)+      | (k, v) <- keyVals+      , T.isPrefixOf prefix k+      , k `notElem` specialAttributes+      ]+      where+        prefix = tag <> ":"+        specialAttributes = ["img:src", "img:alt", "img:title", "svg:width", "svg:height"]++    altText = maybeToList (Str <$> lookup "img:alt" keyVals)++    imgTitle = fromMaybe "" (lookup "img:title" keyVals)++    svgWidth =+      fromMaybe (error "Failed to parse `svg:width` attribute value.")+        . readMaybe+        . unpack+        <$> lookup "svg:width" keyVals+    svgHeight =+      fromMaybe (error "Failed to parse `svg:height` attribute value.")+        . readMaybe+        . unpack+        <$> lookup "svg:height" keyVals+transformBlock _ _ block = pure block+++{- | Render the code inside all 'Text.Pandoc.Definition.CodeBlock' with a @.diagram@ class+to either a 'Text.Pandoc.Definition.RawBlock' or an 'Text.Pandoc.Definition.Image'+(depending on if we have received an attribute with a path to an external file), where+these two can or cannot be within a parent 'Text.Pandoc.Definition.Figure' (depending on+if we have received an attribute with a figure caption).++The [hint library](https://github.com/haskell-hint/hint?tab=readme-ov-file) is used to+interpret the Haskell code, and therefore all of its+[limitations](https://github.com/haskell-hint/hint?tab=readme-ov-file#limitations) will+apply here as well. In particular, if there isn't a @.ghc.environment.\<something>@+[package environment+file](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/packages.html#package-environments)+(as generated by cabal+[@--write-ghc-environment-files=always@](https://cabal.readthedocs.io/en/3.4/cabal-project.html#cfg-field-write-ghc-environment-files)+option), setting either @HAKYLL_DIAGRAMS_PACKAGE_ENV@ (a single path) or+@HAKYLL_DIAGRAMS_PACKAGE_DB@ (N comma-separated paths) environment variables may be+required. If these environment variables are defined, their values will be used to set the+GHC+[@-package-env@](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/packages.html#ghc-flag-package-env-file-name)+and+[@-package-db@](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/packages.html#ghc-flag-package-db-file)+options respectively.+-}+drawDiagramsWith :: Options -> Pandoc -> Compiler Pandoc+drawDiagramsWith opts = walkM visitor+  where+    visitor :: Block -> Compiler Block+    visitor (CodeBlock (ident, classes, attrs) code)+      | "diagram" `elem` classes = do+          destDir <- destinationDirectory . compilerConfig <$> compilerAsk+          unsafeCompiler $+            transformBlock opts destDir $+              CodeBlock (ident, filter (/= "diagram") classes, attrs) code+    visitor b = pure b+++-- | Call 'drawDiagramsWith' with the 'defaultOptions'.+drawDiagrams :: Pandoc -> Compiler Pandoc+drawDiagrams = drawDiagramsWith (def :: Options)
+ test/HTMLSVGCompare.hs view
@@ -0,0 +1,92 @@+{-# LANGUAGE OverloadedStrings #-}++module HTMLSVGCompare (+  shouldBeSimilar,+) where++import Data.List (sortOn)+import Data.Text (Text, strip)+import qualified Data.Text as T+import qualified Data.Text.IO as T+import qualified Data.Text.Read as TR+import Test.Hspec+import Text.HTML.TagSoup+import Text.HTML.TagSoup.Tree+++data Norm = Norm+  { nName :: Text+  , nAttrs :: [(Text, Text)]+  , nChildren :: [Norm]+  , nText :: Text+  }+  deriving (Eq, Show)+++-- | Expect two HTML snippets to be "structurally equal"+shouldBeSimilar :: Text -> FilePath -> Expectation+shouldBeSimilar actual expectedPath = do+  expected <- T.readFile expectedPath+  let normA = normalizeTree <$> parseTree (strip actual)+      normE = normalizeTree <$> parseTree (strip expected)+  normA `shouldBe` normE+++normalizeTree :: TagTree Text -> Norm+normalizeTree node = case node of+  TagBranch name attrs children+    | name == "svg" ->+        Norm+          { nName = name+          , nAttrs = normalizeAttrs attrs+          , -- We don't care about the actual drawing, that+            -- is responsibility of the diagrams library+            nChildren = []+          , nText = ""+          }+    | otherwise ->+        Norm+          { nName = name+          , nAttrs = normalizeAttrs attrs+          , nChildren = normalizeTree <$> children+          , nText = ""+          }+  TagLeaf (TagText txt) ->+    Norm+      { nName = "text"+      , nAttrs = []+      , nChildren = []+      , nText = normalizeText txt+      }+  TagLeaf (TagOpen name _)+    | name `elem` ["?xml", "!DOCTYPE"] ->+        Norm+          { nName = name+          , nAttrs = []+          , nChildren = []+          , nText = ""+          }+  other -> error $ "normalizeTree: expected element but got: " ++ show other+  where+    normalizeAttrs attrs = sortOn fst [(k, normalizeText v) | (k, v) <- attrs]+    normalizeText t = T.unwords $ normalizeNumeric <$> T.words t+++parseNumber :: T.Text -> Maybe Double+parseNumber t =+  case TR.double t of+    Right (n, rest) | T.null rest -> Just n+    _ -> Nothing+++epsilon :: Double+epsilon = 0.001+++normalizeNumeric :: T.Text -> T.Text+normalizeNumeric t =+  case parseNumber t of+    Just n ->+      let bucket = (fromIntegral :: Int -> Double) (round (n / epsilon)) * epsilon+       in T.pack (show bucket)+    Nothing -> T.strip t
+ test/Main.hs view
@@ -0,0 +1,137 @@+{-# LANGUAGE OverloadedStrings #-}++module Main (main) where++import qualified Data.Text as T+import qualified Data.Text.IO as T+import HTMLSVGCompare+import Hakyll (Compiler, Identifier, defaultHakyllReaderOptions, defaultHakyllWriterOptions, itemBody, pandocCompilerWithTransformM)+import Hakyll.Web.Pandoc.Diagrams+import Test.Hspec+import Text.Pandoc.Definition (Pandoc)+import Util+++main :: IO ()+main = hspec $ after_ cleanTestEnv $ do+  describe "The four output variations" $ do+    describe "<img> linking an external SVG file" $ do+      it "evaluates code that has no attributes" $ do+        html <- compileMarkdownWith drawDiagrams "data/external_bare.md"+        svg <- T.readFile "_testsite/external_bare.svg"+        html `shouldBeSimilar` "test/data/external_bare.html"+        svg `shouldBeSimilar` "test/data/no_attributes.svg"+      it "evaluates code with attributes" $ do+        html <- compileMarkdownWith drawDiagrams "data/external.md"+        svg <- T.readFile "_testsite/external.svg"+        html `shouldBeSimilar` "test/data/external.html"+        svg `shouldBeSimilar` "test/data/with_attributes.svg"++    describe "<img> linking an external SVG file within a <figure>" $ do+      it "evaluates code that has no attributes" $ do+        html <- compileMarkdownWith drawDiagrams "data/external_figure_bare.md"+        svg <- T.readFile "_testsite/external_figure_bare.svg"+        html `shouldBeSimilar` "test/data/external_figure_bare.html"+        svg `shouldBeSimilar` "test/data/no_attributes.svg"+      it "evaluates code with attributes" $ do+        html <- compileMarkdownWith drawDiagrams "data/external_figure.md"+        svg <- T.readFile "_testsite/external_figure.svg"+        html `shouldBeSimilar` "test/data/external_figure.html"+        svg `shouldBeSimilar` "test/data/with_attributes.svg"++    describe "<svg> code inline" $ do+      it "evaluates code that has no attributes" $ do+        html <- compileMarkdownWith drawDiagrams "data/inline_bare.md"+        html `shouldBeSimilar` "test/data/inline_bare.html"+      it "evaluates code with attributes" $ do+        html <- compileMarkdownWith drawDiagrams "data/inline.md"+        html `shouldBeSimilar` "test/data/inline.html"++    describe "<svg> code inline within a <figure>" $ do+      it "evaluates code that has no attributes" $ do+        html <- compileMarkdownWith drawDiagrams "data/inline_figure_bare.md"+        html `shouldBeSimilar` "test/data/inline_figure_bare.html"+      it "evaluates code with attributes" $ do+        html <- compileMarkdownWith drawDiagrams "data/inline_figure.md"+        html `shouldBeSimilar` "test/data/inline_figure.html"++  describe "Changing the options" $ do+    it "import Data.Maybe global Module to use fromJust" $ do+      let opts =+            defaultOptions+              { globalModules =+                  [ ("Prelude", Nothing)+                  , ("Diagrams.Prelude", Nothing)+                  , ("Diagrams.Backend.SVG", Nothing)+                  , ("Data.Maybe", Nothing)+                  ]+              }+      html <- compileMarkdownWith (drawDiagramsWith opts) "data/global_import.md"+      html `shouldBeSimilar` "test/data/global_import.html"++    it "import an invalid global Module" $ do+      let opts =+            defaultOptions+              { globalModules =+                  [ ("Prelude", Nothing)+                  , ("Diagrams.Prelude", Nothing)+                  , ("Diagrams.Backend.SVG", Nothing)+                  , ("foo", Nothing)+                  ]+              }+      let html = compileMarkdownWith (drawDiagramsWith opts) "data/global_import.md"+      html `shouldThrow` anyIOException++    it "set a language extension" $ do+      let opts =+            defaultOptions+              { languageExtensions = ["NoMonomorphismRestriction"]+              }+      html <- compileMarkdownWith (drawDiagramsWith opts) "data/inline.md"+      html `shouldBeSimilar` "test/data/inline.html"++    it "set an invalid language extension" $ do+      let opts =+            defaultOptions+              { languageExtensions = ["foo"]+              }+      let html = compileMarkdownWith (drawDiagramsWith opts) "data/inline.md"+      html `shouldThrow` anyIOException++    it "read options from metadata" $ do+      opts <- compileReadOptions optDef "data/metadata.md"+      opts `shouldBe` optRef+  where+    optDef =+      defaultOptions+        { searchPaths = ["app", "posts"]+        , languageExtensions = ["NoMonomorphismRestriction"]+        }+    optRef =+      Options+        { globalModules =+            [ ("Prelude", Nothing)+            , ("Diagrams.Prelude", Nothing)+            , ("Diagrams.Backend.SVG", Nothing)+            ]+        , localModules = [("Utils", Nothing), ("Commons", Just "Cm")]+        , searchPaths = ["um", "app", "posts", "dois"]+        , languageExtensions = []+        }+++compileReadOptions :: Options -> Identifier -> IO Options+compileReadOptions opts path = do+  store <- newTestStore+  provider <- newTestProvider store+  testCompilerDone store provider path (readOptionsFromMetadataWith opts)+++compileMarkdownWith :: (Pandoc -> Compiler Pandoc) -> Identifier -> IO T.Text+compileMarkdownWith compiler path = do+  store <- newTestStore+  provider <- newTestProvider store+  item <- testCompilerDone store provider path $ pandocCompWithTransform' compiler+  pure . T.pack . itemBody $ item+  where+    pandocCompWithTransform' = pandocCompilerWithTransformM defaultHakyllReaderOptions defaultHakyllWriterOptions
+ test/Util.hs view
@@ -0,0 +1,116 @@+-- Adapted from https://github.com/jaspervdj/hakyll/blob/master/tests/TestSuite/Util.hs++module Util (+  newTestStore,+  newTestProvider,+  testCompiler,+  testCompilerDone,+  testCompilerError,+  testConfiguration,+  cleanTestEnv,+) where++--------------------------------------------------------------------------------+import Data.List (intercalate, isInfixOf)+import qualified Data.Set as S+-- --------------------------------------------------------------------------------+import Hakyll.Core.Compiler.Internal+import Hakyll.Core.Configuration+import Hakyll.Core.Identifier+import qualified Hakyll.Core.Logger as Logger+import Hakyll.Core.Provider+import Hakyll.Core.Store (Store)+import qualified Hakyll.Core.Store as Store+import Hakyll.Core.Util.File+import Test.HUnit+++--------------------------------------------------------------------------------+newTestStore :: IO Store+newTestStore = Store.new True $ storeDirectory testConfiguration+++--------------------------------------------------------------------------------+newTestProvider :: Store -> IO Provider+newTestProvider store =+  newProvider store (const $ return False) $+    providerDirectory testConfiguration+++--------------------------------------------------------------------------------+testCompiler ::+  Store ->+  Provider ->+  Identifier ->+  Compiler a ->+  IO (CompilerResult a)+testCompiler store provider underlying compiler = do+  logger <- Logger.new Logger.Error+  let read' =+        CompilerRead+          { compilerConfig = testConfiguration+          , compilerUnderlying = underlying+          , compilerProvider = provider+          , compilerUniverse = S.empty+          , compilerRoutes = mempty+          , compilerStore = store+          , compilerLogger = logger+          }++  result <- runCompiler compiler read'+  Logger.flush logger+  return result+++-- --------------------------------------------------------------------------------+testCompilerDone :: Store -> Provider -> Identifier -> Compiler a -> IO a+testCompilerDone store provider underlying compiler = do+  result <- testCompiler store provider underlying compiler+  case result of+    CompilerDone x _ -> return x+    CompilerError e ->+      fail $+        "TestSuite.Util.testCompilerDone: compiler "+          ++ show underlying+          ++ " threw: "+          ++ intercalate "; " (compilerErrorMessages e)+    CompilerRequire i _ ->+      fail $+        "TestSuite.Util.testCompilerDone: compiler "+          ++ show underlying+          ++ " requires: "+          ++ show i+    CompilerSnapshot _ _ ->+      fail+        "TestSuite.Util.testCompilerDone: unexpected CompilerSnapshot"+++testCompilerError :: Store -> Provider -> Identifier -> Compiler a -> String -> IO ()+testCompilerError store provider underlying compiler expectedMessage = do+  result <- testCompiler store provider underlying compiler+  case result of+    CompilerError e ->+      any (expectedMessage `isInfixOf`) (compilerErrorMessages e)+        @? "Expecting '"+        ++ expectedMessage+        ++ "' error"+    _ -> assertFailure "Expecting CompilerError"+++--------------------------------------------------------------------------------+testConfiguration :: Configuration+testConfiguration =+  defaultConfiguration+    { destinationDirectory = "_testsite"+    , storeDirectory = "_teststore"+    , tmpDirectory = "_testtmp"+    , providerDirectory = "test"+    }+++--------------------------------------------------------------------------------+cleanTestEnv :: IO ()+cleanTestEnv = do+  removeDirectory $ destinationDirectory testConfiguration+  removeDirectory $ storeDirectory testConfiguration+  removeDirectory $ tmpDirectory testConfiguration