22 | copyright: © 2016-2023 Athae Eredh Siniath and Others
23 | category: Text
24 | tested-with: GHC == 9.4.6
25 | github: aesiniath/publish
26 |
27 | dependencies:
28 | - base >= 4.11 && < 5
29 | - bytestring
30 | - deepseq
31 | - directory
32 | - filepath
33 | - megaparsec
34 | - pandoc-types >= 1.22
35 | - pandoc >= 2.11
36 | - template-haskell
37 | - text
38 | - typed-process
39 | - core-text >= 0.3.4
40 | - core-data >= 0.3.3
41 | - core-program >= 0.6.5
42 | - core-telemetry >= 0.2.7
43 | - safe-exceptions
44 | - unix
45 | - unordered-containers
46 |
47 | ghc-options: -threaded -Wall -Wwarn -fwarn-tabs
48 |
49 | executables:
50 | render:
51 | source-dirs: src
52 | main: RenderMain.hs
53 | other-modules:
54 | - Environment
55 | - LatexPreamble
56 | - LatexOutputReader
57 | - PandocToMarkdown
58 | - ParseBookfile
59 | - RenderDocument
60 | - Utilities
61 |
62 | format:
63 | source-dirs: src
64 | main: FormatMain.hs
65 | other-modules:
66 | - FormatDocument
67 | - PandocToMarkdown
68 |
69 | tests:
70 | check:
71 | dependencies:
72 | - hspec
73 | ghc-options: -threaded
74 | source-dirs:
75 | - src
76 | - tests
77 | main: TestSuite.hs
78 | other-modules:
79 | - CheckBookfileParser
80 | - CheckTableProperties
81 | - CompareFragments
82 | - Environment
83 | - FormatDocument
84 | - PandocToMarkdown
85 | - ParseBookfile
86 |
87 |
88 |
--------------------------------------------------------------------------------
/tests/CheckBookfileParser.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE OverloadedStrings #-}
2 | {-# LANGUAGE QuasiQuotes #-}
3 | {-# LANGUAGE ScopedTypeVariables #-}
4 |
5 | module CheckBookfileParser
6 | ( checkBookfileParser,
7 | )
8 | where
9 |
10 | import Core.Text
11 | import Environment (Bookfile (..))
12 | import ParseBookfile
13 | import Test.Hspec
14 | import Text.Megaparsec
15 |
16 | checkBookfileParser :: Spec
17 | checkBookfileParser = do
18 | describe "Parse bookfile format" $ do
19 | it "Correctly parses a complete first line" $ do
20 | parseMaybe parseMagicLine "% publish v2\n" `shouldBe` Just 2
21 | it "Errors if first line has incorrect syntax" $ do
22 | parseMaybe parseMagicLine "%\n" `shouldBe` Nothing
23 | parseMaybe parseMagicLine "%publish\n" `shouldBe` Nothing
24 | parseMaybe parseMagicLine "% publish\n" `shouldBe` Nothing
25 | parseMaybe parseMagicLine "% publish \n" `shouldBe` Nothing
26 | parseMaybe parseMagicLine "% publish v\n" `shouldBe` Nothing
27 | parseMaybe parseMagicLine "% publish v2\n" `shouldBe` Nothing
28 | parseMaybe parseMagicLine "% publish v1\n" `shouldBe` Nothing
29 | parseMaybe parseMagicLine "% publish v2 asdf\n" `shouldBe` Nothing
30 |
31 | it "Correctly parses a preamble line" $ do
32 | parseMaybe parseFileLine "preamble.latex" `shouldBe` Just "preamble.latex"
33 | it "Parses two filenames in a list" $ do
34 | parseMaybe (many (parseFileLine <* parseBlank)) "one.markdown\ntwo.markdown"
35 | `shouldBe` Just (["one.markdown", "two.markdown"] :: [FilePath])
36 |
37 | it "Parses two filenames with a blank line between them" $ do
38 | parseMaybe
39 | (many (parseFileLine <* parseBlank))
40 | [quote|
41 | one.markdown
42 |
43 | two.markdown
44 | |]
45 | `shouldBe` Just (["one.markdown", "two.markdown"] :: [FilePath])
46 |
47 | it "Correctly parses a begin end end pragmas" $ do
48 | parseMaybe parseBeginLine "% begin\n" `shouldBe` Just ()
49 | parseMaybe parseEndLine "% end\n" `shouldBe` Just ()
50 |
51 | it "Correctly parses a complete bookfile" $ do
52 | parseMaybe
53 | parseBookfile
54 | [quote|
55 | % publish v2
56 | preamble.latex
57 | % begin
58 | Introduction.markdown
59 | Conclusion.markdown
60 | % end
61 | |]
62 | `shouldBe` Just (Bookfile 2 ["preamble.latex"] ["Introduction.markdown", "Conclusion.markdown"] [])
63 |
64 | it "Correctly parses a complete bookfile with no preamble" $ do
65 | parseMaybe
66 | parseBookfile
67 | [quote|
68 | % publish v2
69 | % begin
70 | Introduction.markdown
71 | Conclusion.markdown
72 | % end
73 | |]
74 | `shouldBe` Just (Bookfile 2 [] ["Introduction.markdown", "Conclusion.markdown"] [])
75 |
76 | it "Correctly parses a complete bookfile with trailing fragments" $ do
77 | parseMaybe
78 | parseBookfile
79 | [quote|
80 | % publish v2
81 | % begin
82 | Introduction.markdown
83 | % end
84 | Conclusion.markdown
85 | |]
86 | `shouldBe` Just (Bookfile 2 [] ["Introduction.markdown"] ["Conclusion.markdown"])
87 |
--------------------------------------------------------------------------------
/src/FormatDocument.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE LambdaCase #-}
2 | {-# LANGUAGE OverloadedStrings #-}
3 | {-# LANGUAGE ScopedTypeVariables #-}
4 |
5 | module FormatDocument (
6 | program,
7 | loadFragment,
8 | markdownToPandoc,
9 | ) where
10 |
11 | import Core.Program
12 | import Core.System
13 | import Core.Text
14 | import qualified Data.Text as T (Text)
15 | import qualified Data.Text.IO as T
16 | import PandocToMarkdown
17 | import System.Directory (getFileSize, renameFile)
18 | import Text.Pandoc (
19 | Extension (..),
20 | Extensions,
21 | Pandoc,
22 | ReaderOptions (readerExtensions),
23 | def,
24 | disableExtension,
25 | pandocExtensions,
26 | readMarkdown,
27 | runIOorExplode,
28 | )
29 |
30 | program :: Program None ()
31 | program = do
32 | info "Identify document fragment"
33 | file <- getFragmentName
34 |
35 | info "Load to Pandoc internal representation"
36 | parsed <- loadFragment file
37 |
38 | info "Write to Markdown format"
39 | writeResult file parsed
40 |
41 | info "Complete"
42 |
43 | getFragmentName :: Program None FilePath
44 | getFragmentName = do
45 | fragment <- queryArgument "document"
46 | pure (fromRope fragment)
47 |
48 | loadFragment :: FilePath -> Program None Pandoc
49 | loadFragment file =
50 | liftIO $ do
51 | contents <- T.readFile file
52 | markdownToPandoc contents
53 |
54 | --
55 | -- Unlike the render use case, here we suppress certain
56 | -- options which mess up the ASCII form of the source documents
57 | --
58 | markdownToPandoc :: T.Text -> IO Pandoc
59 | markdownToPandoc contents =
60 | let disableFrom :: Extensions -> [Extension] -> Extensions
61 | disableFrom extensions list = foldr disableExtension extensions list
62 | readingOptions =
63 | def
64 | { readerExtensions =
65 | disableFrom
66 | pandocExtensions
67 | [ Ext_implicit_figures
68 | , Ext_shortcut_reference_links
69 | , Ext_smart
70 | ]
71 | }
72 | in do
73 | runIOorExplode $ do
74 | readMarkdown readingOptions contents
75 |
76 | data Inplace = Inplace | Console
77 |
78 | writeResult :: FilePath -> Pandoc -> Program None ()
79 | writeResult file doc =
80 | let contents' = pandocToMarkdown doc
81 | result = file ++ "~tmp"
82 | in do
83 | mode <-
84 | queryOptionFlag "inplace" >>= \case
85 | True -> pure Inplace
86 | False -> pure Console
87 |
88 | case mode of
89 | Inplace -> liftIO $ do
90 | withFile result WriteMode $ \handle ->
91 | hWrite handle contents'
92 |
93 | size <- getFileSize result
94 | if size == 0
95 | then error "Zero content, not overwriting"
96 | else renameFile result file
97 | Console -> liftIO $ do
98 | hWrite stdout contents'
99 |
--------------------------------------------------------------------------------
/src/RenderMain.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE CPP #-}
2 | {-# LANGUAGE OverloadedStrings #-}
3 | {-# LANGUAGE QuasiQuotes #-}
4 | {-# LANGUAGE TemplateHaskell #-}
5 |
6 | module Main where
7 |
8 | import Core.Program
9 | import Core.Telemetry
10 | import Core.Text
11 | import Environment (initial)
12 | import RenderDocument (program)
13 |
14 | #ifdef __GHCIDE__
15 | version :: Version
16 | version = "0"
17 | #else
18 | version :: Version
19 | version = $(fromPackage)
20 | #endif
21 |
22 | main :: IO ()
23 | main = do
24 | env <- initial
25 | context <-
26 | configure
27 | version
28 | env
29 | ( simpleConfig
30 | [ Option
31 | "builtin-preamble"
32 | (Just 'p')
33 | Empty
34 | [quote|
35 | Wrap a built-in LaTeX preamble (and ending) around your
36 | supplied source fragments. Most documents will put their own
37 | custom preamble as the first fragment in the .book file, but
38 | for getting started a suitable default can be employed via this
39 | option.
40 | |]
41 | , Option
42 | "watch"
43 | Nothing
44 | Empty
45 | [quote|
46 | Watch all sources listed in the bookfile and re-run the
47 | rendering engine if changes are detected.
48 | |]
49 | , Option
50 | "no-copy"
51 | Nothing
52 | Empty
53 | [quote|
54 | Should the resultant PDF be copied to this directory? Of course
55 | it should, so the default is true. Select this if you want to
56 | leave the file in /tmp.
57 | |]
58 | , Option
59 | "temp"
60 | Nothing
61 | (Value "TMPDIR")
62 | [quote|
63 | The working location for assembling converted fragments and
64 | caching intermediate results between runs. By default, a
65 | temporary directory will be created in /tmp.
66 | |]
67 | , Option
68 | "docker"
69 | Nothing
70 | (Value "IMAGE")
71 | [quote|
72 | Run the specified Docker image in a container, mount the target
73 | directory into it as a volume, and do the build there. This allows
74 | you to have all of the LaTeX dependencies separate from the machine
75 | you are editing on.
76 | |]
77 | , Argument
78 | "bookfile"
79 | [quote|
80 | The file containing the list of fragments making up this book.
81 | If the argument is specified as "Hobbit.book" then "Hobbit"
82 | will be used as the basename for the final output .pdf file.
83 | |]
84 | ]
85 | )
86 |
87 | context' <- initializeTelemetry [consoleExporter, structuredExporter, honeycombExporter] context
88 |
89 | executeWith context' program
90 |
--------------------------------------------------------------------------------
/doc/Tutorial.md:
--------------------------------------------------------------------------------
1 | Tutorial
2 | ========
3 |
4 | Let's say we want to write a book about trees.
5 |
6 | You start with writing your content in a text file using Markdown syntax to
7 | add semantic markup to the text as you see fit. We'll make the assumption that
8 | you know the basics of Markdown syntax from having used it on GitHub, your
9 | blog, or elsewhere. If you need to learn more about Markdown syntax, see this
10 | [tutorial](https://commonmark.org/help/)).
11 |
12 | For our example,
13 |
14 | ```text
15 | On the subject of trees
16 | =======================
17 |
18 | This may come as a complete surprise
19 | to you, but trees are **green**.
20 |
21 | ```
22 |
23 | Put your text into a file called _Introduction.md_.
24 |
25 | You now need to tell **publish** which files make up the document you want to
26 | render. Create another file which lists the pieces of your manuscript, one per
27 | line. Here we've only got one fragment, so this won't take long:
28 |
29 | ```text
30 | % publish v2
31 | % begin
32 | Introduction.md
33 | % end
34 | ```
35 |
36 | Put the list into a file named _Trees.book_. The filename extension does not
37 | matter, but we've adopted the convention of using _.book_ to identify such
38 | "bookfiles". The basename of the file _does_ matter; it will be used to name
39 | the PDF we're going to generate.
40 |
41 | Now you can render your document. The tool installed by **publish** package is
42 | called _render_. Run that as follows:
43 |
44 | ```shell
45 | $ render -p Trees.book
46 | $
47 | ```
48 |
49 | That's it! If you want a bit more detail about what it's doing, you can use
50 | `--verbose` (or `-v` for short):
51 |
52 | ```shell
53 | $ render -p -v Trees.book
54 | 08:52:24Z (00000.002) Reading bookfile
55 | 08:52:24Z (00000.003) Setup temporary directory
56 | 08:52:24Z (00000.004) Convert document fragments to LaTeX
57 | 08:52:24Z (00000.004) Write intermediate LaTeX file
58 | 08:52:24Z (00000.004) Render document to PDF
59 | 08:52:24Z (00000.085) Copy resultant document here
60 | 08:52:24Z (00000.087) Complete
61 | $
62 | ```
63 |
64 | either way you've now got a file called _Trees.pdf_:
65 |
66 | ```
67 | $ ls
68 | Introduction.md
69 | Trees.book
70 | Trees.pdf
71 | $
72 | ```
73 |
74 | Open that with your favourite PDF viewer and you'll see your fabulous book
75 | about the arboreal arts, ready to be sent to the printer.
76 |
77 | Preamble
78 | --------
79 |
80 | The `-p` in the above example was important. It's short for
81 | `--builtin-preamble`. Using that option tells the _render_ program to wrap a
82 | simple built-in LaTeX preamble around your document.
83 |
84 | More advanced users will happily use their own LaTeX preamble based on years
85 | of experience writing academic papers or typesetting mathematical memoirs.
86 | They should put their preamble as the first item in the bookfile, perhaps:
87 |
88 | ```
89 | % publish v2
90 | preamble.tex
91 | % begin
92 | Introduction.md
93 | % end
94 | ```
95 |
96 | Docker integration
97 | ------------------
98 |
99 | Assuming you _don't_ have years of experience using LaTeX toolchains, you can
100 | use a presupplied built-in preamble. If you want to install the packages
101 | yourself you can freely do so. There is also an option to run the render in a
102 | Docker container.
103 |
104 | ```shell
105 | $ render --builtin-preamble --docker=aesiniath/publish-builtin:latest Trees.book
106 | $
107 | ```
108 |
109 | If you specify the `--docker` option _render_ will spawn a Docker container
110 | from the image you specify. The image shown above has the dependencies You're
111 | welcome to use any container you like. Further details are on the
112 | [Docker](Docker.md) page.
113 |
114 | See also:
115 |
116 | - [Further examples](Examples.md)
117 | - [Background](Background.md)
118 |
--------------------------------------------------------------------------------
/tests/CheckTableProperties.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE OverloadedStrings #-}
2 | {-# LANGUAGE QuasiQuotes #-}
3 | {-# LANGUAGE ScopedTypeVariables #-}
4 |
5 | module CheckTableProperties (
6 | checkTableProperties,
7 | ) where
8 |
9 | import Core.Text
10 | import PandocToMarkdown (
11 | tableToMarkdown,
12 | )
13 | import Test.Hspec
14 | import Text.Pandoc
15 |
16 | checkTableProperties :: Spec
17 | checkTableProperties = do
18 | describe "Table rendering code" $ do
19 | it "Header rows format" $
20 | let result =
21 | tableToMarkdown
22 | ("", [], [])
23 | ( Caption
24 | Nothing
25 | []
26 | )
27 | [ (AlignRight, ColWidthDefault)
28 | , (AlignCenter, ColWidthDefault)
29 | , (AlignDefault, ColWidth 0.5)
30 | ]
31 | ( TableHead
32 | ("", [], [])
33 | [ Row
34 | ("", [], [])
35 | [ Cell
36 | ("", [], [])
37 | AlignDefault
38 | (RowSpan 1)
39 | (ColSpan 1)
40 | [Plain [Str "First"]]
41 | , Cell
42 | ("", [], [])
43 | AlignDefault
44 | (RowSpan 1)
45 | (ColSpan 1)
46 | [Plain [Str "Second"]]
47 | , Cell
48 | ("", [], [])
49 | AlignDefault
50 | (RowSpan 1)
51 | (ColSpan 1)
52 | [Plain [Str "Third"]]
53 | ]
54 | ]
55 | )
56 | [ ( TableBody
57 | ("", [], [])
58 | (RowHeadColumns 0)
59 | []
60 | [ Row
61 | ("", [], [])
62 | [ Cell
63 | ("", [], [])
64 | AlignDefault
65 | (RowSpan 1)
66 | (ColSpan 1)
67 | [Plain [Str "1"]]
68 | , Cell
69 | ("", [], [])
70 | AlignDefault
71 | (RowSpan 1)
72 | (ColSpan 1)
73 | [Plain [Str "2"]]
74 | , Cell
75 | ("", [], [])
76 | AlignDefault
77 | (RowSpan 1)
78 | (ColSpan 1)
79 | [Plain [Str "3"]]
80 | ]
81 | ]
82 | )
83 | ]
84 | ( TableFoot
85 | ("", [], [])
86 | []
87 | )
88 | in do
89 | result
90 | `shouldBe` [quote|
91 | | First | Second | Third |
92 | |-------:|:------:|---------------------------------------|
93 | | 1 | 2 | 3 |
94 | |]
95 |
--------------------------------------------------------------------------------
/doc/Background.md:
--------------------------------------------------------------------------------
1 | Background
2 | ==========
3 |
4 | Web pages are the global standard for displaying and searching information but
5 | authoring content for them in raw HTML is tedious. This led to the advent of
6 | lightweight markup formats like Markdown that could easily be converted to
7 | HTML (it is no co-incidence that these styles represent documents using
8 | formatting conventions that were evolved in the early days of the internet by
9 | users who wanted to convey semantic information in text-based mailing lists
10 | and Usenet newsgroups).
11 |
12 | Somewhat surprisingly, the web continues to struggle with taking content into
13 | print form. Perhaps browser vendors are so overwhelmed by their own success
14 | that they don't feel the need to cater for this use case; certainly many
15 | people are happy to read content on screens surrounded by flashy banner ads.
16 |
17 | For some audiences, however, getting high-quality printed output on **paper**
18 | (or at least into a form that _could_ be printed to paper) is the primary
19 | requirement. These users include
20 |
21 | - researchers needing to document results;
22 | - students submitting essays and other papers;
23 | - engineers writing requirements, design, and system documentation;
24 | - business and organizations wishing to circulate content for review and
25 | approval;
26 | - authors wishing to produce their work in manuscript form suitable for
27 | editing;
28 | - publishers needing to do typesetting and actual pre-press rendering of
29 | manuscripts into "camera ready" form; and
30 | - humans who don't like flashy banner ads.
31 |
32 | So we want to work in Markdown, but render to PDF. The challenges and
33 | complications of this process are considerable. Fortunately there is an
34 | awesome tool that can help: Pandoc.
35 |
36 | Pandoc is a document conversion tool. It has a wide variety of "readers" which
37 | take as input any of number of different document formats and converts them to
38 | an internal representation which is then suitable for any one of various
39 | "writers" to onwards convert them to the desired target format.
40 |
41 | After considerable usage (which is to say, fighting with) the _pandoc_ command
42 | and the "templates" it ships with we had learned enough to realize we didn't
43 | need it to render the PDF but could instead rely on it to get us to LaTeX as
44 | an intermediate format. Our initial solution was to use the _pandoc_ command
45 | to convert _.md_ files to LaTeX _.tex_ and then invoke _pdflatex_ ourselves to
46 | get the desired _.pdf_ output. We later switched to _latexmk_ to handle the
47 | multiple passes necessary to resolve cross-references arising when rendering a
48 | LaTeX document, and _lualatex_ for more modern font handling.
49 |
50 | Pandoc is itself a (very large) Haskell library, so it was not a particularly
51 | earthshattering conceptual leap to consider calling into the library directly
52 | from a wrapper program ourselves, especially as we were no longer relying on
53 | it to build the PDF for us.
54 |
55 | **publish**, then, is a tool suite which allows you to specify the files
56 | comprising a manuscript, converts them from Markdown to LaTeX, then combines
57 | them together as input to the LaTeX processor for conversion to Portable
58 | Document Format ready for previewing or printing.
59 |
60 | Images
61 | ------
62 |
63 | Further complications arise when dealing with graphics. While LaTeX grudgingly
64 | passes through raster images such as PNG and photos in JPEG form (and results
65 | will be acceptable so long as the source image is of sufficiently high
66 | resolution), the LaTeX typesetting toolchains have no native support for SVG
67 | vector images.
68 |
69 | This is a surprise to many users as SVG support has been dominant on the web
70 | for some years and the target format, PDF, is itself a high-quality vector
71 | format.
72 |
73 | The solution, or at least work-around, is render (convert) each of the SVGs to
74 | a PDF first using **inkscape**'s command-line program and then include these
75 | fragments in the typeset document. While we tend to think of PDFs as "pages"
76 | it is at its essence just a way of describing vector graphics, and (again not
77 | something you would have thought of) you can include PDF fragments in \[what
78 | will become\] another PDF document using the `\inclugegraphics` command.
79 |
80 | Extensions
81 | ----------
82 |
83 | Both _.markdown_ and _.md_ are supported for files containing Markdown. Both
84 | _.latex_ and _.tex_ are supported for pure LaTeX files.
85 |
86 | Further reading:
87 |
88 | - [Getting Started](Tutorial.md)
89 | - [Examples](Examples.md)
90 |
--------------------------------------------------------------------------------
/doc/Docker.md:
--------------------------------------------------------------------------------
1 | Docker Support
2 | ==============
3 |
4 | Using Docker for LaTeX dependencies
5 | -----------------------------------
6 |
7 | Anyone who has used of LaTeX will be aware that rendering even a simple document
8 | requires hundreds of packages to be installed. If you want to install the
9 | packages yourself on your computer you can freely do so.
10 |
11 | To help people get started we supply an optional, builtin preamble; it still
12 | depends on some 216 LaTeX packages, though. The process of working through
13 | trying to render a document and one-by-one hunting down the packages you need
14 | to install can be tedious.
15 |
16 | So to compliment the builtin preamble we supply a prebuilt Docker image with
17 | these packages already installed. You can instruct _render_ to run the LaTeX
18 | processor in there, rather than on your own system, by specifying the
19 | `--docker` option:
20 |
21 | ```shell
22 | $ render --builtin-preamble --docker=aesiniath/publish-builtin:latest Trees.book
23 | $
24 | ```
25 |
26 | You are welcome to use any container you like. You need Latexmk installed (the
27 | **latexmk** package) with the LuaLaTeX processor installed (the
28 | **texlive-lualatex** collection should pull it in) as _render_ will invoke
29 | _latexmk_ command to build your resultant PDF. Images require that _inkscape_
30 | is present (supplied by **inkscape** package on Fedora) on your host system.
31 |
32 | If you specify the `--docker` option, _render_ will spawn a Docker container
33 | from the image you specify, mount the temporary directory with the intermediate
34 | fragments _render_ has generated into the container, and then run the necessary
35 | _latexmk_ commands therein.
36 |
37 | If you don't use the `--docker` option, _render_ runs the exact same commands,
38 | but on your machine directly.
39 |
40 | Docker Inception
41 | ----------------
42 |
43 | You can also run the _render_ tool itself in a Docker container. There's an
44 | image available at `docker.io/aesiniath/publish-render`. This means conceptually
45 | you should be able to do:
46 |
47 | ```shell
48 | $ docker run \
49 | --rm=true \
50 | --volume=`pwd`:/mnt \
51 | aesiniath/publish-render:latest \
52 | render \
53 | --builtin-preamble \
54 | --docker=aesiniath/publish-builtin:latest \
55 | Trees.book
56 | $
57 | ```
58 |
59 | Nothing is ever simple in Dockerland, however. The first problem is that the
60 | _docker_ command line program needs to be installed in the container that
61 | _render_ is running in. When you just run these programs ordinarily on a Linux
62 | host then it of course has access to run Docker. But if run inside a container
63 | we need to install the binary and make the host's "docker control socket"
64 | available to it:
65 |
66 | ```shell
67 | $ docker run \
68 | --rm=true \
69 | --volume=/var/run/docker.sock:/var/run/docker.sock \
70 | --volume=`pwd`:/mnt \
71 | mypublish:latest \
72 | render \
73 | --builtin-preamble \
74 | --docker=aesiniath/publish-builtin:latest \
75 | Trees.book
76 | $
77 | ```
78 |
79 | where `mypublish` is a locally created image built from `aesiniath/publish-render`
80 | that adds the **docker-ce-cli** and **librsvg2-bin** packages.
81 |
82 | The second trouble is that there's no way to get the temporary directory
83 | (normally created with a random name by _render_ in _/tmp/publish-XXXXXX_ and
84 | recorded in _.target_) that is in the outer container that _render_ is running
85 | in mounted into the inner container that the _latexmk_ process runs in.
86 |
87 | You could get this to work if you "volume mount" the temporary directory in,
88 | but you have to do it from the **host**, because that's where the docker engine
89 | is; volumes requested from within one container (the outer one) won't be in the
90 | same namespace and thus will appear empty in the second (inner) container.
91 |
92 | We added an option to _render_ allowing you to override the temporary directory
93 | and manually force the directory name to be used. Creating it on the host,
94 | volume mounting it in to the outer container and then using `--temp` to specify
95 | it to the inner one works:
96 |
97 | ```shell
98 | $ mkdir /tmp/publish-local
99 | $ docker run \
100 | --rm=true \
101 | --volume=/var/run/docker.sock:/var/run/docker.sock \
102 | --volume=/tmp/publish-local:/tmp/publish-local \
103 | --volume=`pwd`:/mnt \
104 | mypublish:latest \
105 | render \
106 | --temp=/tmp/publish-local \
107 | --builtin-preamble \
108 | --docker=aesiniath/publish-builtin:latest \
109 | Trees.book
110 | $
111 | ```
112 |
113 | This could probably be easier, but it is at least possible, and how our users
114 | on Mac OS X are able to use the **publish** tools.
115 |
116 | The usual caveats about how evil it is to mount the Docker socket into a
117 | container apply. Don't do this at home. Or in prod at work, come to think of
118 | it.
119 |
120 | Other documentation:
121 |
122 | - [README](../README.md)
123 | - [Background](Background.md)
124 | - [Examples](Examples.md)
125 |
126 |
--------------------------------------------------------------------------------
/src/LatexPreamble.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE CPP #-}
2 | {-# LANGUAGE OverloadedStrings #-}
3 | {-# LANGUAGE QuasiQuotes #-}
4 | {-# LANGUAGE TemplateHaskell #-}
5 |
6 | module LatexPreamble
7 | ( preamble,
8 | beginning,
9 | ending,
10 | )
11 | where
12 |
13 | import Core.Program.Metadata
14 | import Core.Text
15 |
16 | preamble :: Rope
17 | preamble =
18 | [quote|
19 | \documentclass[12pt,a4paper,oneside,openany]{memoir}
20 |
21 | %
22 | % Load the TeX Gyre project's "Heros" font, which is an upgrade of URW's
23 | % lovely "Nimbus Sans L" sans-serif font.
24 | %
25 |
26 | \usepackage{fontspec}
27 | \setmainfont{Linux Libertine O}
28 | \setsansfont{TeX Gyre Heros}[Scale=MatchLowercase]
29 | \setmonofont{Inconsolata}[Scale=MatchLowercase]
30 |
31 | % use upquote for straight quotes in verbatim environments
32 | \usepackage{upquote}
33 |
34 | % use microtype
35 | \usepackage{microtype}
36 | \UseMicrotypeSet[protrusion]{basicmath} % disable protrusion for tt fonts
37 |
38 | %
39 | % Customize paper size. Or not: A4 paper is 597pt x 845pt. 4:3 aka 768x1024
40 | % screen is 597pt x 796pt, but 16:9 aka 2560x1440 screen is 597pt x 1062pt. A4
41 | % in landscape is a fair way narrower.
42 | %
43 |
44 | \setlrmarginsandblock{2cm}{2.5cm}{*}
45 | \setulmarginsandblock{2cm}{2cm}{*}
46 |
47 | %
48 | % Setting the \footskip parameter is how you control the bottom margin width,
49 | % not "setting the bottom margin" since the typeblock will be set to be an
50 | % integer multiple of \baselineskip.
51 | %
52 |
53 | \setheadfoot{0pt}{25pt}
54 | \setheaderspaces{1cm}{*}{*}
55 |
56 | \checkandfixthelayout[classic]
57 |
58 | \usepackage{graphicx,grffile}
59 |
60 | \usepackage{longtable}
61 |
62 | \setlength{\emergencystretch}{3em} % prevent overfull lines
63 |
64 | \usepackage[hidelinks]{hyperref}
65 |
66 | %
67 | % Get rid of default headers and put page number in footer.
68 | %
69 |
70 | \makeoddfoot{plain}{}{}{\tiny\textsf{\thepage/\thelastpage}}
71 | \makeevenfoot{plain}{\tiny\textsf{\thepage/\thelastpage}}{}{}
72 |
73 | \makeoddhead{plain}{}{}{}
74 | \makeevenhead{plain}{}{}{}
75 |
76 | \pagestyle{plain}
77 |
78 | \SingleSpacing
79 | \nonzeroparskip
80 | \setlength{\parindent}{0em}
81 |
82 | %
83 | % Customize the section heading fonts to use this accordingly.
84 | %
85 |
86 | \chapterstyle{article}
87 | \setsecnumdepth{none}
88 |
89 | % FIXME Why isn't the \Huge font size command working?
90 | \renewcommand{\chaptitlefont}{\Large\sffamily\bfseries}
91 |
92 | \setsecheadstyle{\large\sffamily}
93 | \setsubsecheadstyle{\normalsize\sffamily\bfseries}
94 | \setsubsubsecheadstyle{\normalsize\rmfamily\itshape}
95 |
96 | |]
97 |
98 | #ifdef __GHCIDE__
99 | version :: Version
100 | version = "0"
101 | #else
102 | version :: Version
103 | version = $(fromPackage)
104 | #endif
105 |
106 | beginning :: Rope
107 | beginning =
108 | [quote|
109 |
110 | %
111 | % Output from Skylighting.styleToLaTeX
112 | %
113 |
114 | \usepackage{color}
115 | \usepackage{fancyvrb}
116 | \newcommand{\VerbBar}{|}
117 | \newcommand{\VERB}{\Verb[commandchars=\\\{\}]}
118 | \DefineVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\{\}}
119 | % Add ',fontsize=\small' for more characters per line
120 | \usepackage{framed}
121 | \definecolor{shadecolor}{RGB}{248,248,248}
122 | \newenvironment{Shaded}{\begin{snugshade}}{\end{snugshade}}
123 | \newcommand{\AlertTok}[1]{\textcolor[rgb]{0.94,0.16,0.16}{#1}}
124 | \newcommand{\AnnotationTok}[1]{\textcolor[rgb]{0.56,0.35,0.01}{\textbf{\textit{#1}}}}
125 | \newcommand{\AttributeTok}[1]{\textcolor[rgb]{0.77,0.63,0.00}{#1}}
126 | \newcommand{\BaseNTok}[1]{\textcolor[rgb]{0.00,0.00,0.81}{#1}}
127 | \newcommand{\BuiltInTok}[1]{#1}
128 | \newcommand{\CharTok}[1]{\textcolor[rgb]{0.31,0.60,0.02}{#1}}
129 | \newcommand{\CommentTok}[1]{\textcolor[rgb]{0.56,0.35,0.01}{\textit{#1}}}
130 | \newcommand{\CommentVarTok}[1]{\textcolor[rgb]{0.56,0.35,0.01}{\textbf{\textit{#1}}}}
131 | \newcommand{\ConstantTok}[1]{\textcolor[rgb]{0.00,0.00,0.00}{#1}}
132 | \newcommand{\ControlFlowTok}[1]{\textcolor[rgb]{0.13,0.29,0.53}{\textbf{#1}}}
133 | \newcommand{\DataTypeTok}[1]{\textcolor[rgb]{0.13,0.29,0.53}{#1}}
134 | \newcommand{\DecValTok}[1]{\textcolor[rgb]{0.00,0.00,0.81}{#1}}
135 | \newcommand{\DocumentationTok}[1]{\textcolor[rgb]{0.56,0.35,0.01}{\textbf{\textit{#1}}}}
136 | \newcommand{\ErrorTok}[1]{\textcolor[rgb]{0.64,0.00,0.00}{\textbf{#1}}}
137 | \newcommand{\ExtensionTok}[1]{#1}
138 | \newcommand{\FloatTok}[1]{\textcolor[rgb]{0.00,0.00,0.81}{#1}}
139 | \newcommand{\FunctionTok}[1]{\textcolor[rgb]{0.00,0.00,0.00}{#1}}
140 | \newcommand{\ImportTok}[1]{#1}
141 | \newcommand{\InformationTok}[1]{\textcolor[rgb]{0.56,0.35,0.01}{\textbf{\textit{#1}}}}
142 | \newcommand{\KeywordTok}[1]{\textcolor[rgb]{0.13,0.29,0.53}{\textbf{#1}}}
143 | \newcommand{\NormalTok}[1]{#1}
144 | \newcommand{\OperatorTok}[1]{\textcolor[rgb]{0.81,0.36,0.00}{\textbf{#1}}}
145 | \newcommand{\OtherTok}[1]{\textcolor[rgb]{0.56,0.35,0.01}{#1}}
146 | \newcommand{\PreprocessorTok}[1]{\textcolor[rgb]{0.56,0.35,0.01}{\textit{#1}}}
147 | \newcommand{\RegionMarkerTok}[1]{#1}
148 | \newcommand{\SpecialCharTok}[1]{\textcolor[rgb]{0.00,0.00,0.00}{#1}}
149 | \newcommand{\SpecialStringTok}[1]{\textcolor[rgb]{0.31,0.60,0.02}{#1}}
150 | \newcommand{\StringTok}[1]{\textcolor[rgb]{0.31,0.60,0.02}{#1}}
151 | \newcommand{\VariableTok}[1]{\textcolor[rgb]{0.00,0.00,0.00}{#1}}
152 | \newcommand{\VerbatimStringTok}[1]{\textcolor[rgb]{0.31,0.60,0.02}{#1}}
153 | \newcommand{\WarningTok}[1]{\textcolor[rgb]{0.56,0.35,0.01}{\textbf{\textit{#1}}}}
154 |
155 | %
156 | % Enable strikeout (specifically the \st command)
157 | %
158 |
159 | \usepackage{soul}
160 | |]
161 | <> "\\hypersetup{pdfproducer={Markdown and Latex rendered via Publish "
162 | <> intoRope (versionNumberFrom version)
163 | <> "},pdfcreator={lualatex}}\n"
164 | <> "\\begin{document}\n"
165 |
166 | ending :: Rope
167 | ending =
168 | [quote|
169 | \end{document}
170 | |]
171 |
--------------------------------------------------------------------------------
/src/PandocToMarkdown.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE DeriveAnyClass #-}
2 | {-# LANGUAGE DeriveGeneric #-}
3 | {-# LANGUAGE OverloadedStrings #-}
4 | {-# LANGUAGE ScopedTypeVariables #-}
5 |
6 | module PandocToMarkdown (
7 | pandocToMarkdown,
8 | NotSafe (..),
9 | tableToMarkdown,
10 | ) where
11 |
12 | import qualified Control.Exception.Safe as Safe (impureThrow)
13 | import Core.System.Base
14 | import Core.Text
15 | import Data.Foldable (foldl')
16 | import Data.List (intersperse)
17 | import qualified Data.Text as T (Text, null)
18 | import Text.Pandoc (
19 | Alignment (..),
20 | Attr,
21 | Block (..),
22 | Caption (..),
23 | Cell (..),
24 | ColSpan (..),
25 | ColSpec,
26 | ColWidth (..),
27 | Format (..),
28 | Inline (..),
29 | ListAttributes,
30 | MathType (..),
31 | Pandoc (..),
32 | QuoteType (..),
33 | Row (..),
34 | RowSpan (..),
35 | TableBody (..),
36 | TableFoot (..),
37 | TableHead (..),
38 | )
39 | import Text.Pandoc.Shared (orderedListMarkers)
40 |
41 | __WIDTH__ :: Int
42 | __WIDTH__ = 78
43 |
44 | pandocToMarkdown :: Pandoc -> Rope
45 | pandocToMarkdown (Pandoc _ blocks) =
46 | blocksToMarkdown __WIDTH__ blocks
47 |
48 | blocksToMarkdown :: Int -> [Block] -> Rope
49 | blocksToMarkdown _ [] = emptyRope
50 | blocksToMarkdown margin (block1 : blocks) =
51 | convertBlock margin block1
52 | <> foldl'
53 | (\text block -> text <> "\n" <> convertBlock margin block)
54 | emptyRope
55 | blocks
56 |
57 | convertBlock :: Int -> Block -> Rope
58 | convertBlock margin block =
59 | let msg = "Unfinished block: " ++ show block -- FIXME
60 | in case block of
61 | Plain inlines -> plaintextToMarkdown margin inlines
62 | Para inlines -> paragraphToMarkdown margin inlines
63 | Header level _ inlines -> headingToMarkdown level inlines
64 | RawBlock (Format "tex") string -> intoRope string <> "\n"
65 | RawBlock (Format "html") string -> intoRope string <> "\n"
66 | RawBlock _ _ -> error msg
67 | CodeBlock attr string -> codeToMarkdown attr string
68 | LineBlock list -> poemToMarkdown list
69 | BlockQuote blocks -> quoteToMarkdown margin blocks
70 | BulletList blockss -> bulletlistToMarkdown margin blockss
71 | OrderedList attrs blockss -> orderedlistToMarkdown margin attrs blockss
72 | DefinitionList blockss -> definitionlistToMarkdown margin blockss
73 | HorizontalRule -> "---\n"
74 | Table attr caption alignments header rows footer -> tableToMarkdown attr caption alignments header rows footer
75 | Div attr blocks -> divToMarkdown margin attr blocks
76 | Figure _ _ _ -> error msg
77 |
78 | {-
79 | This does **not** emit a newline at the end. The intersperse happening in
80 | `blocksToMarkdown` will terminate the line, but you won't get a blank line
81 | between blocks as is the convention everywhere else (this was critical when
82 | lists were nested in tight lists).
83 | -}
84 | plaintextToMarkdown :: Int -> [Inline] -> Rope
85 | plaintextToMarkdown margin inlines =
86 | wrap' margin (inlinesToMarkdown inlines)
87 |
88 | {-
89 | Everything was great until we had to figure out how to deal with line
90 | breaks aka
, represented in Markdown by [' ',' ']. We do this by
91 | replacing the line break Inline with \x2028. This character, U+2028 LS, is
92 | the Line Separator character. It's one of those symbols up in General
93 | Punctuation that no one ever uses. So we use it as a sentinel internally
94 | here; first we break on those, and then we wrap the results.
95 | -}
96 | paragraphToMarkdown :: Int -> [Inline] -> Rope
97 | paragraphToMarkdown margin inlines =
98 | wrap' margin (inlinesToMarkdown inlines) <> "\n"
99 |
100 | wrap' :: Int -> Rope -> Rope
101 | wrap' margin =
102 | mconcat . intersperse " \n" . fmap (wrap margin) . breakPieces isLineSeparator
103 | where
104 | isLineSeparator = (== '\x2028')
105 |
106 | headingToMarkdown :: Int -> [Inline] -> Rope
107 | headingToMarkdown level inlines =
108 | let text = inlinesToMarkdown inlines
109 | in case level of
110 | 1 -> text <> "\n" <> underline '=' text <> "\n"
111 | 2 -> text <> "\n" <> underline '-' text <> "\n"
112 | n -> intoRope (replicate n '#') <> " " <> text <> "\n"
113 |
114 | codeToMarkdown :: Attr -> T.Text -> Rope
115 | codeToMarkdown attr literal =
116 | let body = intoRope literal
117 | lang = fencedAttributesToMarkdown attr
118 | in "```" <> lang <> "\n"
119 | <> body
120 | <> "\n"
121 | <> "```"
122 | <> "\n"
123 |
124 | poemToMarkdown :: [[Inline]] -> Rope
125 | poemToMarkdown list =
126 | mconcat (intersperse "\n" (fmap prefix list)) <> "\n"
127 | where
128 | prefix inlines = "| " <> inlinesToMarkdown inlines
129 |
130 | quoteToMarkdown :: Int -> [Block] -> Rope
131 | quoteToMarkdown margin blocks =
132 | foldl' (\text block -> text <> prefix block) emptyRope blocks
133 | where
134 | prefix :: Block -> Rope
135 | prefix = foldl' (\text line -> text <> "> " <> line <> "\n") emptyRope . rows
136 | rows :: Block -> [Rope]
137 | rows = breakLines . convertBlock (margin - 2)
138 |
139 | bulletlistToMarkdown :: Int -> [[Block]] -> Rope
140 | bulletlistToMarkdown = listToMarkdown (repeat "- ")
141 |
142 | orderedlistToMarkdown :: Int -> ListAttributes -> [[Block]] -> Rope
143 | orderedlistToMarkdown margin (num, style, delim) blockss =
144 | listToMarkdown (intoMarkers (num, style, delim)) margin blockss
145 | where
146 | intoMarkers = fmap pad . fmap intoRope . orderedListMarkers
147 | pad text = text <> if widthRope text > 2 then " " else " "
148 |
149 | definitionlistToMarkdown :: Int -> [([Inline], [[Block]])] -> Rope
150 | definitionlistToMarkdown margin definitions =
151 | case definitions of
152 | [] -> emptyRope
153 | (definition1 : definitionN) ->
154 | handleDefinition definition1
155 | <> foldl'
156 | (\text definition -> text <> "\n" <> handleDefinition definition)
157 | emptyRope
158 | definitionN
159 | where
160 | handleDefinition :: ([Inline], [[Block]]) -> Rope
161 | handleDefinition (term, blockss) =
162 | inlinesToMarkdown term <> "\n\n" <> listToMarkdown (repeat ": ") margin blockss
163 |
164 | listToMarkdown :: [Rope] -> Int -> [[Block]] -> Rope
165 | listToMarkdown markers margin items =
166 | case pairs of
167 | [] -> emptyRope
168 | ((marker1, blocks1) : pairsN) ->
169 | listitem marker1 blocks1
170 | <> foldl'
171 | (\text (markerN, blocksN) -> text <> spacer blocksN <> listitem markerN blocksN)
172 | emptyRope
173 | pairsN
174 | where
175 | pairs = zip markers items
176 | listitem :: Rope -> [Block] -> Rope
177 | listitem _ [] = emptyRope
178 | listitem marker blocks = indent marker blocks
179 | {-
180 | Tricky. Tight lists are represented by Plain, whereas more widely spaced
181 | lists are represented by Para. A complex block (specifically a nested
182 | list!) will handle its own spacing. This seems fragile.
183 | -}
184 | spacer :: [Block] -> Rope
185 | spacer [] = emptyRope
186 | spacer (block : _) = case block of
187 | Plain _ -> emptyRope
188 | Para _ -> "\n"
189 | _ -> emptyRope -- ie nested list
190 | indent :: Rope -> [Block] -> Rope
191 | indent marker =
192 | snd . foldl' (f marker) (True, emptyRope) . breakLines . blocksToMarkdown (margin - 4)
193 | f :: Rope -> (Bool, Rope) -> Rope -> (Bool, Rope)
194 | f marker (first, text) line
195 | | nullRope line =
196 | (False, text <> "\n") -- don't indent lines that should be blank
197 | | otherwise =
198 | if first
199 | then (False, text <> marker <> line <> "\n")
200 | else (False, text <> " " <> line <> "\n")
201 |
202 | {-
203 | In Pandoc flavoured Markdown, are recognized as valid Markdown via
204 | the `native_divs` extension. We turn that off, in favour of the
205 | `fenced_divs` extension, three (or more) colons
206 |
207 | ::: {#identifier .class key=value}
208 | Content
209 | :::
210 |
211 | -}
212 | divToMarkdown :: Int -> Attr -> [Block] -> Rope
213 | divToMarkdown margin attr blocks =
214 | let first = ":::" <> fencedAttributesToMarkdown attr
215 | trail = ":::"
216 | content = mconcat . intersperse "\n" . fmap (convertBlock margin)
217 | in first <> "\n" <> content blocks <> trail <> "\n"
218 |
219 | -- special case for (notably) code blocks where a single class doesn't need braces.
220 | fencedAttributesToMarkdown :: Attr -> Rope
221 | fencedAttributesToMarkdown ("", [], []) = emptyRope
222 | fencedAttributesToMarkdown ("", [single], []) = intoRope single
223 | fencedAttributesToMarkdown (identifier, [], []) = " " <> attributesToMarkdown (identifier, [], [])
224 | fencedAttributesToMarkdown (identifier, classes, pairs) = " " <> attributesToMarkdown (identifier, classes, pairs)
225 |
226 | -- present attributes, used by both fenced blocks and inline spans
227 | attributesToMarkdown :: Attr -> Rope
228 | attributesToMarkdown ("", [], []) = emptyRope
229 | attributesToMarkdown (identifier, [], []) = "{#" <> intoRope identifier <> "}"
230 | attributesToMarkdown (identifier, classes, pairs) =
231 | let i =
232 | if T.null identifier
233 | then emptyRope
234 | else "#" <> intoRope identifier <> " "
235 | cs = fmap (\c -> "." <> intoRope c) classes
236 | ps = fmap (\(k, v) -> intoRope k <> "=" <> intoRope v) pairs
237 | in "{" <> i <> mconcat (intersperse " " (cs ++ ps)) <> "}"
238 |
239 | tableToMarkdown ::
240 | Attr ->
241 | Caption ->
242 | [ColSpec] ->
243 | TableHead ->
244 | [TableBody] ->
245 | TableFoot ->
246 | Rope
247 | tableToMarkdown _ _ alignments thead tbodys _ =
248 | mconcat
249 | ( intersperse
250 | "\n"
251 | [ headerline
252 | , betweenline
253 | , bodylines
254 | ]
255 | )
256 | <> "\n"
257 | where
258 | colonChar = singletonRope ':'
259 | dashChar = singletonRope '-'
260 | pipeChar = singletonRope '|'
261 | spaceChar = singletonRope ' '
262 | newlineChar = singletonRope '\n'
263 |
264 | surround :: Rope -> Rope -> Rope
265 | surround char text = char <> text <> char
266 |
267 | headerline = headerToMarkdown thead
268 |
269 | betweenline =
270 | surround pipeChar . foldl' (<>) emptyRope
271 | . intersperse pipeChar
272 | . fmap columnToMarkdown
273 | $ alignments
274 |
275 | bodylines = bodiesToMarkdown tbodys
276 |
277 | headerToMarkdown :: TableHead -> Rope
278 | headerToMarkdown (TableHead _ [row]) = rowToMarkdown row
279 | headerToMarkdown _ = Safe.impureThrow (NotSafe "What do we do with this TableHead?")
280 |
281 | columnToMarkdown :: (Alignment, ColWidth) -> Rope
282 | columnToMarkdown (align, col) =
283 | let total = fromIntegral __WIDTH__
284 | begin = case align of
285 | AlignLeft -> colonChar
286 | AlignCenter -> colonChar
287 | _ -> dashChar
288 |
289 | num = case col of
290 | ColWidth x -> floor (total * x) - 2
291 | ColWidthDefault -> 6
292 | middle = mconcat (replicate num dashChar)
293 |
294 | end = case align of
295 | AlignRight -> colonChar
296 | AlignCenter -> colonChar
297 | _ -> dashChar
298 | in begin <> middle <> end
299 |
300 | bodiesToMarkdown :: [TableBody] -> Rope
301 | bodiesToMarkdown = mconcat . intersperse newlineChar . fmap bodyToMarkdown
302 |
303 | bodyToMarkdown :: TableBody -> Rope
304 | bodyToMarkdown (TableBody _ _ _ rows) =
305 | foldl' (<>) emptyRope
306 | . intersperse newlineChar
307 | . fmap rowToMarkdown
308 | $ rows
309 |
310 | rowToMarkdown :: Row -> Rope
311 | rowToMarkdown (Row _ cells) =
312 | surround pipeChar . foldl' (<>) emptyRope
313 | . intersperse pipeChar
314 | . fmap (surround spaceChar . cellToMarkdown)
315 | $ cells
316 |
317 | cellToMarkdown :: Cell -> Rope
318 | cellToMarkdown (Cell _ _ (RowSpan 1) (ColSpan 1) [block]) =
319 | convert block
320 | cellToMarkdown _ =
321 | Safe.impureThrow (NotSafe "Multiple Blocks encountered")
322 |
323 | convert :: Block -> Rope
324 | convert (Plain inlines) =
325 | plaintextToMarkdown 100000 inlines
326 | convert _ =
327 | Safe.impureThrow (NotSafe "Incorrect Block type encountered")
328 |
329 | data NotSafe = NotSafe String
330 | deriving (Show)
331 |
332 | instance Exception NotSafe
333 |
334 | ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ----
335 |
336 | inlinesToMarkdown :: [Inline] -> Rope
337 | inlinesToMarkdown inlines =
338 | foldl' (\text inline -> appendRope (convertInline inline) text) emptyRope inlines
339 |
340 | convertInline :: Inline -> Rope
341 | convertInline inline =
342 | let msg = "Unfinished inline: " ++ show inline
343 | in case inline of
344 | Space -> " "
345 | Str text -> stringToMarkdown text
346 | Emph inlines -> "_" <> inlinesToMarkdown inlines <> "_"
347 | Strong inlines -> "**" <> inlinesToMarkdown inlines <> "**"
348 | SoftBreak -> " "
349 | LineBreak -> "\x2028"
350 | Image attr inlines target -> imageToMarkdown attr inlines target
351 | Code _ string -> "`" <> intoRope string <> "`"
352 | RawInline (Format "tex") string -> intoRope string
353 | RawInline (Format "html") string -> intoRope string
354 | RawInline _ _ -> error msg
355 | Link ("", ["uri"], []) _ (url, _) -> uriToMarkdown url
356 | Link attr inlines target -> linkToMarkdown attr inlines target
357 | Strikeout inlines -> "~~" <> inlinesToMarkdown inlines <> "~~"
358 | Math mode text -> mathToMarkdown mode text
359 | -- then things start getting weird
360 | SmallCaps inlines -> smallcapsToMarkdown inlines
361 | Subscript inlines -> "~" <> inlinesToMarkdown inlines <> "~"
362 | Superscript inlines -> "^" <> inlinesToMarkdown inlines <> "^"
363 | Span attr inlines -> spanToMarkdown attr inlines
364 | -- I don't know what the point of these ones are
365 | Quoted SingleQuote inlines -> "'" <> inlinesToMarkdown inlines <> "'"
366 | Quoted DoubleQuote inlines -> "\"" <> inlinesToMarkdown inlines <> "\""
367 | _ -> error msg
368 |
369 | {-
370 | Pandoc uses U+00A0 aka ASCII 160 aka to mark a non-breaking space, which
371 | seems to be how it describes an escaped space in Markdown. So scan for these
372 | and replace the escaped space on output.
373 | -}
374 | stringToMarkdown :: T.Text -> Rope
375 | stringToMarkdown =
376 | escapeSpecialWith '\x00a0' ' '
377 | . escapeSpecial '['
378 | . escapeSpecial ']'
379 | . escapeSpecial '_'
380 | . intoRope
381 |
382 | escapeSpecial :: Char -> Rope -> Rope
383 | escapeSpecial c = escapeSpecialWith c c
384 |
385 | escapeSpecialWith :: Char -> Char -> Rope -> Rope
386 | escapeSpecialWith needle replacement =
387 | mconcat . intersperse (singletonRope '\\' <> singletonRope replacement) . breakPieces isNeedle . intoRope
388 | where
389 | isNeedle c = c == needle
390 |
391 | imageToMarkdown :: Attr -> [Inline] -> (T.Text, T.Text) -> Rope
392 | imageToMarkdown attr inlines (url, title) =
393 | let alt = inlinesToMarkdown inlines
394 | target =
395 | if T.null title
396 | then intoRope url
397 | else intoRope url <> " \"" <> intoRope title <> "\""
398 | in "" <> attributesToMarkdown attr
399 |
400 | uriToMarkdown :: T.Text -> Rope
401 | uriToMarkdown url =
402 | let target = intoRope url
403 | in "<" <> target <> ">"
404 |
405 | linkToMarkdown :: Attr -> [Inline] -> (T.Text, T.Text) -> Rope
406 | linkToMarkdown attr inlines (url, title) =
407 | let text = inlinesToMarkdown inlines
408 | target =
409 | if T.null title
410 | then intoRope url
411 | else intoRope url <> " \"" <> intoRope title <> "\""
412 | in "[" <> text <> "](" <> target <> ")" <> attributesToMarkdown attr
413 |
414 | -- is there more to this?
415 | mathToMarkdown :: MathType -> T.Text -> Rope
416 | mathToMarkdown (InlineMath) math = "$" <> intoRope math <> "$"
417 | mathToMarkdown (DisplayMath) math = "$$" <> intoRope math <> "$$"
418 |
419 | smallcapsToMarkdown :: [Inline] -> Rope
420 | smallcapsToMarkdown inlines =
421 | let text = inlinesToMarkdown inlines
422 | in "[" <> text <> "]{.smallcaps}"
423 |
424 | spanToMarkdown :: Attr -> [Inline] -> Rope
425 | spanToMarkdown attr inlines =
426 | let text = inlinesToMarkdown inlines
427 | in "[" <> text <> "]" <> attributesToMarkdown attr
428 |
--------------------------------------------------------------------------------
/src/RenderDocument.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE ImportQualifiedPost #-}
2 | {-# LANGUAGE LambdaCase #-}
3 | {-# LANGUAGE OverloadedStrings #-}
4 | {-# LANGUAGE QuasiQuotes #-}
5 | {-# LANGUAGE ScopedTypeVariables #-}
6 |
7 | module RenderDocument (
8 | program,
9 | ) where
10 |
11 | import Control.Exception.Safe qualified as Safe
12 | import Control.Monad (filterM, forM_, forever, void)
13 | import Core.Data
14 | import Core.Program
15 | import Core.System
16 | import Core.Telemetry
17 | import Core.Text
18 | import Data.Char (isSpace)
19 | import Data.List qualified as List (dropWhileEnd, null)
20 | import Data.Text.IO qualified as T
21 | import Environment (Bookfile (..), Env (..))
22 | import LatexOutputReader (parseOutputForError)
23 | import LatexPreamble (beginning, ending, preamble)
24 | import ParseBookfile (parseBookfile)
25 | import System.Directory (
26 | copyFileWithMetadata,
27 | doesDirectoryExist,
28 | doesFileExist,
29 | renameFile,
30 | )
31 | import System.Exit (ExitCode (..))
32 | import System.FilePath.Posix (
33 | dropExtension,
34 | replaceDirectory,
35 | replaceExtension,
36 | splitFileName,
37 | takeBaseName,
38 | takeExtension,
39 | )
40 | import System.IO (hPutStrLn)
41 | import System.Posix.Directory (changeWorkingDirectory)
42 | import System.Posix.Temp (mkdtemp)
43 | import System.Posix.User (getEffectiveGroupID, getEffectiveUserID)
44 | import Text.Megaparsec (errorBundlePretty, runParser)
45 | import Text.Pandoc (
46 | TopLevelDivision (TopLevelSection),
47 | def,
48 | pandocExtensions,
49 | readMarkdown,
50 | readerColumns,
51 | readerExtensions,
52 | runIOorExplode,
53 | writeLaTeX,
54 | writerTopLevelDivision,
55 | )
56 | import Utilities (ensureDirectory, ifNewer, isNewer)
57 |
58 | data Mode = Once | Cycle
59 |
60 | data Copy = InstallPdf | NoCopyPdf
61 |
62 | program :: Program Env ()
63 | program = do
64 | (mode, copy) <- extractMode
65 |
66 | info "Identify .book file"
67 | bookfile <- extractBookFile
68 |
69 | case mode of
70 | Once -> do
71 | -- normal operation, single pass
72 | void (renderDocument (mode, copy) bookfile)
73 | Cycle -> do
74 | -- use inotify to rebuild on changes
75 | forever (renderDocument (mode, copy) bookfile >>= waitForChange >> resetTimer)
76 |
77 | renderDocument :: (Mode, Copy) -> FilePath -> Program Env [FilePath]
78 | renderDocument (mode, copy) file = do
79 | setServiceName "render"
80 | beginTrace $ do
81 | encloseSpan "Render document" $ do
82 | telemetry
83 | [ metric "bookfile" file
84 | ]
85 |
86 | book <- encloseSpan "Setup" $ do
87 | info "Read .book file"
88 | book <- processBookFile file
89 |
90 | info "Setup temporary directory"
91 | setupTargetFile file
92 | setupPreambleFile
93 | validatePreamble book
94 |
95 | pure book
96 |
97 | let preambles = preamblesFrom book
98 | let fragments = fragmentsFrom book
99 | let trailers = trailersFrom book
100 |
101 | encloseSpan "Convert fragments" $ do
102 | info "Convert preamble fragments and begin marker to LaTeX"
103 | mapM_ processFragment preambles
104 | setupBeginningFile
105 |
106 | info "Convert document fragments to LaTeX"
107 | mapM_ processFragment fragments
108 |
109 | info "Convert end marker and trailing fragments to LaTeX"
110 | setupEndingFile
111 | mapM_ processFragment trailers
112 |
113 | info "Write intermediate LaTeX file"
114 | produceResult
115 |
116 | encloseSpan "Render LaTeX to PDF" $ do
117 | info "Render document to PDF"
118 | catch
119 | ( do
120 | renderPDF
121 | case copy of
122 | InstallPdf -> copyHere
123 | NoCopyPdf -> return ()
124 | )
125 | ( \(e :: ExitCode) -> case mode of
126 | Once -> throw e
127 | Cycle -> return ()
128 | )
129 |
130 | pure (uniqueList file preambles fragments trailers)
131 |
132 | --
133 | -- Quickly reduce the fragment names to a unique list so we don't waste
134 | -- inotify watches.
135 | --
136 | uniqueList :: FilePath -> [FilePath] -> [FilePath] -> [FilePath] -> [FilePath]
137 | uniqueList file preambles fragments trailers =
138 | let files = insertElement file (intoSet trailers <> (intoSet preambles <> intoSet fragments))
139 | in fromSet files
140 |
141 | extractMode :: Program Env (Mode, Copy)
142 | extractMode = do
143 | mode <-
144 | queryOptionFlag "watch" >>= \case
145 | True -> pure Cycle
146 | False -> pure Once
147 |
148 | copy <-
149 | queryOptionFlag "no-copy" >>= \case
150 | True -> pure NoCopyPdf
151 | False -> pure InstallPdf
152 |
153 | pure (mode, copy)
154 |
155 | {-
156 | For the situation where the .book file is in a location other than '.'
157 | then chdir there first, so any relative paths within _it_ are handled
158 | properly, as are inotify watches later if they are employed.
159 | -}
160 | extractBookFile :: Program Env FilePath
161 | extractBookFile = do
162 | file <- queryArgument "bookfile"
163 | let (relative, bookfile) = splitFileName (fromRope file)
164 |
165 | debugS "relative" relative
166 | debugS "bookfile" bookfile
167 | probe <- liftIO $ do
168 | changeWorkingDirectory relative
169 | doesFileExist bookfile
170 | case probe of
171 | True -> return bookfile
172 | False -> do
173 | write ("error: specified .book file \"" <> intoRope bookfile <> "\" not found.")
174 | throw (userError "no such file")
175 |
176 | setupTargetFile :: FilePath -> Program Env ()
177 | setupTargetFile file = do
178 | env <- getApplicationState
179 | let start = startingDirectoryFrom env
180 | let dotfile = start ++ "/.target"
181 |
182 | tmpdir <-
183 | queryOptionValue "temp" >>= \case
184 | Just dir -> do
185 | -- Append a slash so that /tmp/booga is taken as a directory.
186 | -- Otherwise, you end up ensuring /tmp exists.
187 | ensureDirectory (fromRope dir ++ "/")
188 | return (fromRope dir)
189 | Nothing ->
190 | liftIO $
191 | Safe.catch
192 | ( do
193 | dir' <- readFile dotfile
194 | let dir = trim dir'
195 | probe <- doesDirectoryExist dir
196 | if probe
197 | then return dir
198 | else Safe.throw boom
199 | )
200 | ( \(_ :: IOError) -> do
201 | dir <- mkdtemp "/tmp/publish-"
202 | writeFile dotfile (dir ++ "\n")
203 | return dir
204 | )
205 | debugS "tmpdir" tmpdir
206 |
207 | let master = tmpdir ++ "/" ++ base ++ ".tex"
208 | result = tmpdir ++ "/" ++ base ++ ".pdf"
209 |
210 | let env' =
211 | env
212 | { intermediateFilenamesFrom = []
213 | , masterFilenameFrom = master
214 | , resultFilenameFrom = result
215 | , tempDirectoryFrom = tmpdir
216 | }
217 | setApplicationState env'
218 | where
219 | base = takeBaseName file -- "/directory/file.ext" -> "file"
220 | boom = userError "Temp dir no longer present"
221 | trim :: String -> String
222 | trim = List.dropWhileEnd isSpace
223 |
224 | setupPreambleFile :: Program Env ()
225 | setupPreambleFile = do
226 | env <- getApplicationState
227 | let tmpdir = tempDirectoryFrom env
228 |
229 | first <-
230 | queryOptionFlag "builtin-preamble" >>= \case
231 | False -> return []
232 | True -> do
233 | let name = "00_Preamble.latex"
234 | let target = tmpdir ++ "/" ++ name
235 | liftIO $
236 | withFile target WriteMode $ \handle -> do
237 | hWrite handle preamble
238 | return [name]
239 |
240 | let env' = env{intermediateFilenamesFrom = first}
241 | setApplicationState env'
242 |
243 | {-
244 | This could do a lot more; checking to see if \documentclass is present, for
245 | example. At present this covers the (likely common) failure mode of
246 | specifying neither -p nor a preamble in the bookfile.
247 | -}
248 | validatePreamble :: Bookfile -> Program Env ()
249 | validatePreamble book = do
250 | let preambles = preamblesFrom book
251 |
252 | builtin <- queryOptionFlag "builtin-preamble"
253 |
254 | if List.null preambles && not builtin
255 | then do
256 | write "error: no preamble\n"
257 | let msg :: Rope =
258 | [quote|
259 | You need to either a) put the name of the file including the LaTeX
260 | preamble for your document in the .book file between the "% publish"
261 | and "% begin" lines, or b) specify the --builtin-preamble option on
262 | the command-line when running this program.
263 | |]
264 | writeR msg
265 | terminate 2
266 | else return ()
267 |
268 | setupBeginningFile :: Program Env ()
269 | setupBeginningFile = do
270 | env <- getApplicationState
271 | let tmpdir = tempDirectoryFrom env
272 | files = intermediateFilenamesFrom env
273 |
274 | file <- do
275 | let name = "99_Beginning.latex"
276 | let target = tmpdir ++ "/" ++ name
277 | liftIO $
278 | withFile target WriteMode $ \handle -> do
279 | hWrite handle beginning
280 | return name
281 |
282 | let env' = env{intermediateFilenamesFrom = file : files}
283 | setApplicationState env'
284 |
285 | setupEndingFile :: Program Env ()
286 | setupEndingFile = do
287 | env <- getApplicationState
288 | let tmpdir = tempDirectoryFrom env
289 | files = intermediateFilenamesFrom env
290 |
291 | file <- do
292 | let name = "ZZ_Ending.latex"
293 | let target = tmpdir ++ "/" ++ name
294 | liftIO $
295 | withFile target WriteMode $ \handle -> do
296 | hWrite handle ending
297 | return name
298 |
299 | let env' = env{intermediateFilenamesFrom = file : files}
300 | setApplicationState env'
301 |
302 | processBookFile :: FilePath -> Program Env Bookfile
303 | processBookFile file = do
304 | contents <- liftIO (readFile file)
305 |
306 | let result = runParser parseBookfile file contents
307 | bookfile <- case result of
308 | Left err -> do
309 | write (intoRope (errorBundlePretty err))
310 | terminate 1
311 | Right value -> return value
312 |
313 | list1 <- filterM skipNotFound (preamblesFrom bookfile)
314 | debugS "preambles" (length list1)
315 |
316 | list2 <- filterM skipNotFound (fragmentsFrom bookfile)
317 | debugS "fragments" (length list2)
318 |
319 | list3 <- filterM skipNotFound (trailersFrom bookfile)
320 | debugS "trailers" (length list3)
321 |
322 | return bookfile{preamblesFrom = list1, fragmentsFrom = list2, trailersFrom = list3}
323 | where
324 | skipNotFound :: FilePath -> Program t Bool
325 | skipNotFound fragment = do
326 | probe <- liftIO (doesFileExist fragment)
327 | case probe of
328 | True -> return True
329 | False -> do
330 | warn "Fragment not found"
331 | write ("warning: Fragment \"" <> intoRope fragment <> "\" not found, skipping")
332 | return False
333 |
334 | {-
335 | Which kind of file is it? Dispatch to the appropriate reader switching on
336 | filename extension.
337 | -}
338 | processFragment :: FilePath -> Program Env ()
339 | processFragment file = do
340 | debugS "source" file
341 |
342 | -- Read the fragment, process it if Markdown then run it out to LaTeX.
343 | case takeExtension file of
344 | ".markdown" -> convertMarkdown file
345 | ".md" -> convertMarkdown file
346 | ".latex" -> passthroughLaTeX file
347 | ".tex" -> passthroughLaTeX file
348 | ".svg" -> convertImage file
349 | _ -> passthroughImage file
350 |
351 | {-
352 | Convert Markdown to LaTeX. This is where we "call" Pandoc.
353 |
354 | Default behaviour from the command line is to activate all (?) of Pandoc's
355 | Markdown extensions, but invoking via the `readMarkdown` function with
356 | default ReaderOptions doesn't turn any on. Using `pandocExtensions` here
357 | appears to represent the whole set.
358 |
359 | When output format is LaTeX, the command-line _pandoc_ tool does some
360 | somewhat convoluted heuristics to decide whether top-level headings (ie
361 |
, ====, #) are to be considered \part, \chapter, or \section. The fact
362 | that is not deterministic is annoying. Force the issue.
363 |
364 | Finally, for some reason, the Markdown -> LaTeX pair strips trailing
365 | whitespace from the block, resulting in a no paragraph boundary between
366 | files. So gratuitously add a break.
367 | -}
368 | convertMarkdown :: FilePath -> Program Env ()
369 | convertMarkdown file =
370 | let readingOptions =
371 | def
372 | { readerExtensions = pandocExtensions
373 | , readerColumns = 75
374 | }
375 | writingOptions =
376 | def
377 | { writerTopLevelDivision = TopLevelSection
378 | }
379 | in do
380 | encloseSpan "convertMarkdown" $ do
381 | env <- getApplicationState
382 | let tmpdir = tempDirectoryFrom env
383 | file' = replaceExtension file ".latex"
384 | target = tmpdir ++ "/" ++ file'
385 | files = intermediateFilenamesFrom env
386 |
387 | ensureDirectory target
388 | ifNewer file target $ do
389 | debugS "target" target
390 | liftIO $ do
391 | contents <- T.readFile file
392 |
393 | latex <- runIOorExplode $ do
394 | parsed <- readMarkdown readingOptions contents
395 | writeLaTeX writingOptions parsed
396 |
397 | withFile target WriteMode $ \handle -> do
398 | T.hPutStrLn handle latex
399 | T.hPutStr handle "\n"
400 |
401 | let env' = env{intermediateFilenamesFrom = file' : files}
402 | setApplicationState env'
403 |
404 | telemetry
405 | [ metric "file" file
406 | ]
407 |
408 | {-
409 | If a source fragment is already LaTeX, simply copy it through to
410 | the target file.
411 | -}
412 | passthroughLaTeX :: FilePath -> Program Env ()
413 | passthroughLaTeX file = do
414 | encloseSpan "passthroughLaTeX" $ do
415 | env <- getApplicationState
416 | let tmpdir = tempDirectoryFrom env
417 | target = tmpdir ++ "/" ++ file
418 | files = intermediateFilenamesFrom env
419 |
420 | ensureDirectory target
421 | ifNewer file target $ do
422 | debugS "target" target
423 | liftIO $ do
424 | copyFileWithMetadata file target
425 |
426 | let env' = env{intermediateFilenamesFrom = file : files}
427 | setApplicationState env'
428 | telemetry
429 | [ metric "file" file
430 | ]
431 |
432 | {-
433 | Images in SVG format need to be converted to PDF to be able to be
434 | included in the output as LaTeX doesn't understand SVG natively, which
435 | is slightly shocking.
436 | -}
437 | convertImage :: FilePath -> Program Env ()
438 | convertImage file = do
439 | encloseSpan "convertImage" $ do
440 | telemetry
441 | [ metric "file" file
442 | ]
443 | env <- getApplicationState
444 | let tmpdir = tempDirectoryFrom env
445 | basepath = dropExtension file
446 | target = tmpdir ++ "/" ++ basepath ++ ".pdf"
447 | buffer = tmpdir ++ "/" ++ basepath ++ "~tmp.pdf"
448 | convert =
449 | [ "rsvg-convert"
450 | , "--format"
451 | , "pdf"
452 | , "--output"
453 | , buffer
454 | , file
455 | ]
456 |
457 | ifNewer file target $ do
458 | debugS "target" target
459 | (exit, out, err) <- do
460 | ensureDirectory target
461 | readProcess (fmap intoRope convert)
462 |
463 | case exit of
464 | ExitFailure _ -> do
465 | info "Image processing failed"
466 | debug "stderr" (intoRope err)
467 | debug "stdout" (intoRope out)
468 | write ("error: Unable to convert " <> intoRope file <> " from SVG to PDF")
469 | throw exit
470 | ExitSuccess -> liftIO $ do
471 | renameFile buffer target
472 |
473 | passthroughImage :: FilePath -> Program Env ()
474 | passthroughImage file = do
475 | encloseSpan "passthroughImage" $ do
476 | telemetry
477 | [ metric "file" file
478 | ]
479 | env <- getApplicationState
480 | let tmpdir = tempDirectoryFrom env
481 | target = tmpdir ++ "/" ++ file
482 |
483 | ensureDirectory target
484 | ifNewer file target $ do
485 | debugS "target" target
486 | liftIO $ do
487 | copyFileWithMetadata file target
488 |
489 | {-
490 | Finish up by writing the intermediate "master" file.
491 | -}
492 | produceResult :: Program Env ()
493 | produceResult = do
494 | env <- getApplicationState
495 | let master = masterFilenameFrom env
496 | files = intermediateFilenamesFrom env
497 |
498 | debugS "master" master
499 | liftIO $
500 | withFile master WriteMode $ \handle -> do
501 | hPutStrLn handle ("\\RequirePackage{import}")
502 | forM_ (reverse files) $ \file -> do
503 | let (path, name) = splitFileName file
504 | hPutStrLn handle ("\\subimport{" ++ path ++ "}{" ++ name ++ "}")
505 |
506 | getUserID :: Program a Rope
507 | getUserID = liftIO $ do
508 | uid <- getEffectiveUserID
509 | gid <- getEffectiveGroupID
510 | return (intoRope (show uid ++ ":" ++ show gid))
511 |
512 | renderPDF :: Program Env ()
513 | renderPDF = do
514 | env <- getApplicationState
515 |
516 | let master = intoRope (masterFilenameFrom env)
517 | tmpdir = intoRope (tempDirectoryFrom env)
518 |
519 | user <- getUserID
520 |
521 | command <-
522 | queryOptionValue "docker" >>= \case
523 | Just image ->
524 | pure
525 | [ "docker"
526 | , "run"
527 | , "--rm=true"
528 | , "--volume=" <> tmpdir <> ":" <> tmpdir
529 | , "--user=" <> user
530 | , intoRope image
531 | , "latexmk"
532 | ]
533 | Nothing ->
534 | pure
535 | [ "latexmk"
536 | ]
537 | let options =
538 | [ "-lualatex"
539 | , "-output-directory=" <> tmpdir
540 | , "-interaction=nonstopmode"
541 | , "-halt-on-error"
542 | , "-file-line-error"
543 | , "-cd"
544 | , master
545 | ]
546 | latexmk = command ++ options
547 |
548 | (exit, out, err) <- readProcess latexmk
549 | case exit of
550 | ExitFailure _ -> do
551 | info "Render failed"
552 | debug "stderr" err
553 | debug "stdout" out
554 | write (parseOutputForError (fromRope tmpdir) out)
555 | throw exit
556 | ExitSuccess -> return ()
557 |
558 | copyHere :: Program Env ()
559 | copyHere = do
560 | env <- getApplicationState
561 | let result = resultFilenameFrom env
562 | start = startingDirectoryFrom env
563 | final = replaceDirectory result start -- ie ./Book.pdf
564 | changed <- isNewer result final
565 | case changed of
566 | True -> do
567 | info "Copy resultant PDF to starting directory"
568 | debugS "result" result
569 | debugS "final" final
570 | liftIO $ do
571 | copyFileWithMetadata result final
572 | info "Complete"
573 | False -> do
574 | info "Result unchanged"
575 |
576 | telemetry
577 | [ metric "changed" changed
578 | ]
579 |
--------------------------------------------------------------------------------