Compare commits

..
7 Commits
Author SHA1 Message Date
mostalive 54c7502f0f fix test warnings: redundant Data.Text import, explicit Int types in Map ops 2026-04-30 21:45:42 +01:00
mostalive eb374d20f5 update synopsis and module docs to mention DOCX output 2026-04-30 21:42:39 +01:00
mostalive 57f4f9f165 add -docx command example to README 2026-04-30 21:42:11 +01:00
mostalive f016950ac7 add DOCX support to Mermaid diagram rendering
- Transform.hs: DOCX case returns blocks unchanged (no unicode substitution needed)
- Mermaid.hs: DOCX uses PNG images at normal scale (like HTML, not 3x like PDF)
2026-04-30 18:25:27 +01:00
mostalive f4dab3e354 update pandoc bounds to <3.8 (matches 3.7.0.2 used by lts-24.34) 2026-04-30 18:24:07 +01:00
mostalive 6b49db5801 refactor(Compiler): eliminate Maybe Text indirection in CompilationStrategy
- csWriter now writes files directly (WriterOptions -> Pandoc -> FilePath -> IO (Either DocsterError ()))
- csPostProcess no longer takes text content (String -> IO (Either DocsterError ()))
- Each strategy owns its complete output logic (PDF/HTML/DOCX)
- Remove generateOutputFile (eliminated unused CompilationStrategy parameter)
- Pipeline: generateOutputM >>= processOutput => writeAndProcessOutput (1 step)
- Pandoc 3.7 compatibility: writeDocx returns ByteString instead of ()

.gitignore: exclude stack/cabal config and generated files
2026-04-30 18:13:00 +01:00
mostalive b0457388dc remove unused parameter 2026-04-30 17:36:24 +01:00
7 changed files with 71 additions and 84 deletions
+10
View File
@@ -1,6 +1,8 @@
dist-newstyle dist-newstyle
dist-newstyle dist-newstyle
/.stack-work/ /.stack-work/
/.stack-root/
/.cabal-config/
*.mmd *.mmd
*.png *.png
*.svg *.svg
@@ -10,3 +12,11 @@ dist-newstyle
dist-newstyle dist-newstyle
output/ output/
*.log *.log
cabal.project
lts-24-34.yaml
stack-setup-2.yaml
analytics-charts.md
architecture-deep-dive.md
devcontainer.org
.devcontainer/
root.json
+2 -1
View File
@@ -6,8 +6,9 @@ A self-contained CLI tool that converts Markdown with Mermaid diagrams into PDF
docster -pdf path/to/file.md docster -pdf path/to/file.md
docster -html path/to/file.md docster -html path/to/file.md
docster -docx path/to/file.md
Mermaid code blocks (```mermaid) will be rendered to SVG and embedded. Mermaid code blocks (```mermaid) will be rendered to SVG (HTML) or PNG (PDF/DOCX) and embedded.
## Installation ## Installation
+4 -4
View File
@@ -1,9 +1,9 @@
cabal-version: 3.0 cabal-version: 3.0
name: docster name: docster
version: 0.1.0.0 version: 0.1.0.0
synopsis: A self-contained CLI tool that converts Markdown with Mermaid diagrams to PDF/HTML synopsis: A self-contained CLI tool that converts Markdown with Mermaid diagrams to PDF, HTML, or DOCX
description: Docster converts Markdown documents containing Mermaid diagrams into PDF or HTML files description: Docster converts Markdown documents containing Mermaid diagrams into PDF, HTML, or DOCX files
using Pandoc and Mermaid CLI. It automatically renders Mermaid code blocks to SVG using Pandoc and Mermaid CLI. It automatically renders Mermaid code blocks to SVG (HTML) or PNG (PDF/DOCX)
and embeds them in the output. and embeds them in the output.
homepage: https://github.com/yourusername/docster homepage: https://github.com/yourusername/docster
license: BSD-3-Clause license: BSD-3-Clause
@@ -41,7 +41,7 @@ library
process >=1.6 && <1.7, process >=1.6 && <1.7,
hashable >=1.4 && <1.6, hashable >=1.4 && <1.6,
containers >=0.6 && <0.8, containers >=0.6 && <0.8,
pandoc >=3.0 && <3.2, pandoc >=3.0 && <3.8,
pandoc-types >=1.23 && <1.25, pandoc-types >=1.23 && <1.25,
bytestring >=0.11 && <0.13, bytestring >=0.11 && <0.13,
temporary >=1.3 && <1.4, temporary >=1.3 && <1.4,
+44 -72
View File
@@ -1,7 +1,7 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
-- | Document compilation functionality for PDF and HTML output -- | Document compilation functionality for PDF, HTML, and DOCX output
module Docster.Compiler module Docster.Compiler
( -- * Compilation Functions ( -- * Compilation Functions
compileToPDF compileToPDF
@@ -47,7 +47,6 @@ data CompilationContext = CompilationContext
, ccDocName :: Text , ccDocName :: Text
, ccReaderOptions :: ReaderOptions , ccReaderOptions :: ReaderOptions
, ccConfig :: DiagramConfig , ccConfig :: DiagramConfig
, ccWritesFile :: Bool
} }
-- | Monad stack for compilation pipeline -- | Monad stack for compilation pipeline
@@ -57,44 +56,57 @@ type CompilationM = ReaderT CompilationContext (ExceptT DocsterError IO)
data CompilationStrategy = CompilationStrategy data CompilationStrategy = CompilationStrategy
{ -- | Format for diagram configuration { -- | Format for diagram configuration
csOutputFormat :: OutputFormat csOutputFormat :: OutputFormat
-- | Pandoc writer function (returns Text for HTML/PDF, unused for DOCX) -- | Pandoc writer: writes output directly to the given file path
, csWriter :: WriterOptions -> Pandoc -> PandocIO Text , csWriter :: WriterOptions -> Pandoc -> FilePath -> IO (Either DocsterError ())
-- | Post-processing function for the generated content -- | Post-processing after write (PDF→xelatex, HTML→open browser, DOCX→noop)
, csProcessOutput :: String -> Text -> IO (Either DocsterError ()) , csPostProcess :: String -> IO (Either DocsterError ())
-- | Success message formatter -- | Success message formatter
, csSuccessMessage :: String -> Text , csSuccessMessage :: String -> Text
-- | True for formats where writer writes a file directly (DOCX)
, csWritesFile :: Bool
} }
-- | PDF compilation strategy -- | PDF compilation strategy
pdfStrategy :: CompilationStrategy pdfStrategy :: CompilationStrategy
pdfStrategy = CompilationStrategy pdfStrategy = CompilationStrategy
{ csOutputFormat = PDF { csOutputFormat = PDF
, csWriter = writeLaTeX , csWriter = \opts doc path -> do
, csProcessOutput = processPDFOutput result <- runIO (writeLaTeX opts doc)
case result of
Left err -> return $ Left $ FileError $ "LaTeX write failed: " <> T.pack (show err)
Right latex -> do
TIO.writeFile path (latexTemplate latex)
return $ Right ()
, csPostProcess = processPDFOutput
, csSuccessMessage = \path -> successEmoji <> " PDF written to " <> T.pack path , csSuccessMessage = \path -> successEmoji <> " PDF written to " <> T.pack path
, csWritesFile = False
} }
-- | HTML compilation strategy -- | HTML compilation strategy
htmlStrategy :: CompilationStrategy htmlStrategy :: CompilationStrategy
htmlStrategy = CompilationStrategy htmlStrategy = CompilationStrategy
{ csOutputFormat = HTML { csOutputFormat = HTML
, csWriter = writeHtml5String , csWriter = \opts doc path -> do
, csProcessOutput = processHTMLOutput result <- runIO (writeHtml5String opts doc)
case result of
Left err -> return $ Left $ FileError $ "HTML write failed: " <> T.pack (show err)
Right html -> do
TIO.writeFile path html
return $ Right ()
, csPostProcess = processHTMLOutput
, csSuccessMessage = \path -> successEmoji <> " HTML written to " <> T.pack path , csSuccessMessage = \path -> successEmoji <> " HTML written to " <> T.pack path
, csWritesFile = False
} }
-- | DOCX compilation strategy (Pandoc writes file directly) -- | DOCX compilation strategy (Pandoc writes file directly)
docxStrategy :: CompilationStrategy docxStrategy :: CompilationStrategy
docxStrategy = CompilationStrategy docxStrategy = CompilationStrategy
{ csOutputFormat = DOCX { csOutputFormat = DOCX
, csWriter = \_ _ -> return "" -- unused: writeDocx writes file directly , csWriter = \opts doc path -> do
, csProcessOutput = \_ _ -> return $ Right () -- no post-processing needed result <- runIO (writeDocx opts doc)
case result of
Left err -> return $ Left $ FileError $ "DOCX generation failed: " <> T.pack (show err)
Right docxBS -> do
BSL.writeFile path docxBS
return $ Right ()
, csPostProcess = \_ -> return $ Right () -- no post-processing needed
, csSuccessMessage = \path -> successEmoji <> " DOCX written to " <> T.pack path , csSuccessMessage = \path -> successEmoji <> " DOCX written to " <> T.pack path
, csWritesFile = True
} }
-- | Parse LaTeX log content to extract meaningful error messages -- | Parse LaTeX log content to extract meaningful error messages
@@ -160,11 +172,10 @@ extractFatalErrors = mapMaybe extractFatal
| "! " `T.isPrefixOf` line && not ("Missing character:" `T.isInfixOf` line) = Just $ T.drop 2 line | "! " `T.isPrefixOf` line && not ("Missing character:" `T.isInfixOf` line) = Just $ T.drop 2 line
| otherwise = Nothing | otherwise = Nothing
-- | Process PDF output: LaTeX template application and direct XeLaTeX compilation -- | Process PDF output: direct XeLaTeX compilation (LaTeX already written by csWriter)
processPDFOutput :: String -> Text -> IO (Either DocsterError ()) processPDFOutput :: String -> IO (Either DocsterError ())
processPDFOutput outputPath latexOutput = do processPDFOutput outputPath = do
let completeLatex = latexTemplate latexOutput let logOutputPath = replaceExtension outputPath "log"
logOutputPath = replaceExtension outputPath "log"
-- Use temporary directory for LaTeX compilation -- Use temporary directory for LaTeX compilation
withSystemTempDirectory "docster-latex" $ \tempDir -> do withSystemTempDirectory "docster-latex" $ \tempDir -> do
@@ -172,9 +183,6 @@ processPDFOutput outputPath latexOutput = do
pdfFile = tempDir </> "document.pdf" pdfFile = tempDir </> "document.pdf"
logFile = tempDir </> "document.log" logFile = tempDir </> "document.log"
-- Write LaTeX content to temporary file
TIO.writeFile texFile completeLatex
-- Run XeLaTeX compilation -- Run XeLaTeX compilation
(exitCode, _stdout, stderr) <- readProcessWithExitCode "xelatex" (exitCode, _stdout, stderr) <- readProcessWithExitCode "xelatex"
[ "-output-directory=" <> tempDir [ "-output-directory=" <> tempDir
@@ -210,11 +218,9 @@ processPDFOutput outputPath latexOutput = do
errorSummary <> "\n\n" <> errorSummary <> "\n\n" <>
"Full LaTeX log written to: " <> T.pack logOutputPath "Full LaTeX log written to: " <> T.pack logOutputPath
-- | Process HTML output: file writing and browser opening -- | Process HTML output: open browser (HTML already written by csWriter)
processHTMLOutput :: String -> Text -> IO (Either DocsterError ()) processHTMLOutput :: String -> IO (Either DocsterError ())
processHTMLOutput outputPath html = do processHTMLOutput outputPath = do
TIO.writeFile outputPath html
-- Open the generated HTML file in browser for verification -- Open the generated HTML file in browser for verification
putStrLn $ "🌐 Opening " <> outputPath <> " in browser for error checking..." putStrLn $ "🌐 Opening " <> outputPath <> " in browser for error checking..."
void $ callProcess "open" [outputPath] void $ callProcess "open" [outputPath]
@@ -267,28 +273,13 @@ transformDocumentM pandoc = do
docName <- asks ccDocName docName <- asks ccDocName
liftEitherM $ transformDocument config docName pandoc liftEitherM $ transformDocument config docName pandoc
-- | Pipeline step: Generate output using format-specific writer -- | Pipeline step: Write output and post-process (format-specific)
generateOutputM :: Pandoc -> CompilationM Text writeAndProcessOutput :: Pandoc -> CompilationM ()
generateOutputM pandoc = do writeAndProcessOutput pandoc = do
strategy <- asks ccStrategy strategy <- asks ccStrategy
writesFile <- asks ccWritesFile outputPath <- asks ccOutputPath
if writesFile liftEitherM $ csWriter strategy def pandoc outputPath
then do liftEitherM $ (csPostProcess strategy) outputPath
outputPath <- asks ccOutputPath
_ <- liftIO $ generateOutputFile strategy outputPath pandoc
return "" -- placeholder, won't be used
else liftEitherM $ generateOutput strategy pandoc
-- | Pipeline step: Process output and write to file
processOutput :: Text -> CompilationM ()
processOutput output = do
strategy <- asks ccStrategy
writesFile <- asks ccWritesFile
if writesFile
then return () -- file already written by writer
else do
outputPath <- asks ccOutputPath
liftEitherM $ csProcessOutput strategy outputPath output
-- | Pipeline step: Print success message -- | Pipeline step: Print success message
printSuccess :: CompilationM () printSuccess :: CompilationM ()
@@ -302,8 +293,8 @@ compileWithStrategy :: CompilationStrategy -> SourceDir -> OutputDir -> Text ->
compileWithStrategy strategy sourceDir outputDir docName (OutputPath inputPath) (OutputPath outputPath) = do compileWithStrategy strategy sourceDir outputDir docName (OutputPath inputPath) (OutputPath outputPath) = do
let readerOptions = def { readerExtensions = getDefaultExtensions "markdown" } let readerOptions = def { readerExtensions = getDefaultExtensions "markdown" }
config = DiagramConfig sourceDir outputDir (csOutputFormat strategy) config = DiagramConfig sourceDir outputDir (csOutputFormat strategy)
context = CompilationContext strategy inputPath outputPath docName readerOptions config (csWritesFile strategy) context = CompilationContext strategy inputPath outputPath docName readerOptions config
pipeline = readContent >>= parseDocument >>= transformDocumentM >>= generateOutputM >>= processOutput >> printSuccess pipeline = readContent >>= parseDocument >>= transformDocumentM >>= writeAndProcessOutput >> printSuccess
runExceptT $ runReaderT pipeline context runExceptT $ runReaderT pipeline context
@@ -315,26 +306,7 @@ parseMarkdown readerOptions content = do
Left err -> Left $ FileError $ "Failed to parse markdown: " <> T.pack (show err) Left err -> Left $ FileError $ "Failed to parse markdown: " <> T.pack (show err)
Right pandoc -> Right pandoc Right pandoc -> Right pandoc
-- | Generate output using the strategy's writer with error handling
generateOutput :: CompilationStrategy -> Pandoc -> IO (Either DocsterError Text)
generateOutput strategy transformed = do
result <- runIO $ csWriter strategy def transformed
return $ case result of
Left err -> Left $ case csOutputFormat strategy of
PDF -> PDFGenerationError $ "LaTeX generation failed: " <> T.pack (show err)
HTML -> FileError $ "HTML generation failed: " <> T.pack (show err)
DOCX -> FileError $ "DOCX generation failed: " <> T.pack (show err)
Right output -> Right output
-- | Generate output file directly (for DOCX which writes to file)
generateOutputFile :: CompilationStrategy -> FilePath -> Pandoc -> IO (Either DocsterError ())
generateOutputFile _ outputPath pandoc = do
result <- runIO $ writeDocx def pandoc
case result of
Left err -> return $ Left $ FileError $ "DOCX generation failed: " <> T.pack (show err)
Right docxBS -> do
BSL.writeFile outputPath docxBS
return $ Right ()
-- | Compile markdown to PDF using XeLaTeX -- | Compile markdown to PDF using XeLaTeX
compileToPDF :: FilePath -> IO () compileToPDF :: FilePath -> IO ()
+3
View File
@@ -79,6 +79,8 @@ generateDiagramPaths (DiagramConfig _ (OutputDir outDir) format) (DiagramId diag
in (svgFile, T.pack $ takeFileName svgFile) in (svgFile, T.pack $ takeFileName svgFile)
PDF -> let pngFile = outDir </> diagIdStr <> ".png" PDF -> let pngFile = outDir </> diagIdStr <> ".png"
in (pngFile, T.pack pngFile) in (pngFile, T.pack pngFile)
DOCX -> let pngFile = outDir </> diagIdStr <> ".png"
in (pngFile, T.pack pngFile)
-- | Puppeteer configuration content for disabling sandbox -- | Puppeteer configuration content for disabling sandbox
puppeteerConfigContent :: Text puppeteerConfigContent :: Text
@@ -90,6 +92,7 @@ callMermaidProcess format mmdFile outputFile = do
let baseArgs = case format of let baseArgs = case format of
HTML -> ["-i", mmdFile, "-o", outputFile] HTML -> ["-i", mmdFile, "-o", outputFile]
PDF -> ["-i", mmdFile, "-o", outputFile, "--scale", "3"] PDF -> ["-i", mmdFile, "-o", outputFile, "--scale", "3"]
DOCX -> ["-i", mmdFile, "-o", outputFile]
-- Create temporary puppeteer config file -- Create temporary puppeteer config file
result <- bracket result <- bracket
+2 -1
View File
@@ -36,8 +36,9 @@ transformDocument config docName (Pandoc meta blocks) = do
Left err -> return $ Left err Left err -> return $ Left err
Right (newBlocks, _finalState) -> Right (newBlocks, _finalState) ->
case dcOutputFormat config of case dcOutputFormat config of
PDF -> return $ Right $ substituteUnicodeSymbols (Pandoc meta newBlocks) PDF -> return $ Right $ substituteUnicodeSymbols (Pandoc meta newBlocks)
HTML -> return $ Right $ Pandoc meta newBlocks HTML -> return $ Right $ Pandoc meta newBlocks
DOCX -> return $ Right $ Pandoc meta newBlocks
-- | Process a single block with heading tracking state -- | Process a single block with heading tracking state
processBlockStateful :: DiagramConfig -> Block -> TransformM Block processBlockStateful :: DiagramConfig -> Block -> TransformM Block
+6 -6
View File
@@ -4,7 +4,7 @@ module Docster.TransformSpec (spec) where
import Test.Hspec import Test.Hspec
import qualified Data.Map.Strict as Map import qualified Data.Map.Strict as Map
import Data.Text (Text) import Data.Text()
import qualified Data.Text as T import qualified Data.Text as T
import Text.Pandoc.Definition (Inline(..)) import Text.Pandoc.Definition (Inline(..))
@@ -64,21 +64,21 @@ spec = do
describe "diagram naming logic" $ do describe "diagram naming logic" $ do
it "first diagram under heading has no suffix" $ it "first diagram under heading has no suffix" $
let baseName = "file_flow" let baseName = "file_flow"
counter = Map.findWithDefault 0 baseName Map.empty counter = Map.findWithDefault (0 :: Int) baseName Map.empty
diagName = if counter == 0 then baseName else baseName <> "_" <> T.pack (show counter) diagName = if counter == 0 then baseName else baseName <> "_" <> T.pack (show counter)
in diagName `shouldBe` "file_flow" in diagName `shouldBe` "file_flow"
it "second diagram gets _1 suffix" $ it "second diagram gets _1 suffix" $
let baseName = "file_flow" let baseName = "file_flow"
counters = Map.singleton "file_flow" 1 counters = Map.singleton "file_flow" (1 :: Int)
counter = Map.findWithDefault 0 baseName counters counter = Map.findWithDefault (0 :: Int) baseName counters
diagName = if counter == 0 then baseName else baseName <> "_" <> T.pack (show counter) diagName = if counter == 0 then baseName else baseName <> "_" <> T.pack (show counter)
in diagName `shouldBe` "file_flow_1" in diagName `shouldBe` "file_flow_1"
it "third diagram gets _2 suffix" $ it "third diagram gets _2 suffix" $
let baseName = "file_flow" let baseName = "file_flow"
counters = Map.singleton "file_flow" 2 counters = Map.singleton "file_flow" (2 :: Int)
counter = Map.findWithDefault 0 baseName counters counter = Map.findWithDefault (0 :: Int) baseName counters
diagName = if counter == 0 then baseName else baseName <> "_" <> T.pack (show counter) diagName = if counter == 0 then baseName else baseName <> "_" <> T.pack (show counter)
in diagName `shouldBe` "file_flow_2" in diagName `shouldBe` "file_flow_2"