/******************************************************************************/ /* */ /* md2epub.rex - Markdown to EPUB conversion tool */ /* ============================================== */ /* */ /* This program is part of the Rexx Parser package */ /* [See https://rexx.epbcn.com/rexx-parser/] */ /* */ /* Copyright (c) 2024-2026 Josep Maria Blasco */ /* */ /* License: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0) */ /* */ /* Date Version Details */ /* -------- ------- --------------------------------------------------------- */ /* 20260323 0.1 First version */ /* */ /******************************************************************************/ Call Time "R" CLIhelper = InitCLI() myName = CLIhelper~name myHelp = CLIhelper~help args = CLIhelper~args rootDir = .File~new(.context~package~name)~parentFile~parent rootDir = ChangeStr("\",rootDir,"/") check = "✔" fail = "❌" -- Under windows, if we are not using the UTF8 codepage, -- revert to plain ASCII. If .RexxInfo~platform~startsWith("Windows") Then Do Address COMMAND "CHCP" With Output Stem O. Parse Var O.1 ":" codepage . If codepage \== 65001 Then Do check = "[Ok]" fail = "[Fail!]" End End defaultTheme = "dark" cliStyle = 0 -- Track if --style was specified language = "en" defaultOptions = "" csl = "rexxpub" -- Default Citation Style Language style continue = 0 sectionNumbers = -1 -- -1 = use docclass default numberFigures = 1 executor = 0 experimental = 0 unicode = 0 itrace = 0 cssDir = "" highlightStyle = "pygments" -- Default Pandoc highlight style coverImage = "" chapterLevel = 1 ProcessOptions: Loop While args~size > 0, args[1][1] == "-" option = args[1] args~delete(1) Select Case Lower(option) When "-h", "--help" Then Signal Help When "-it", "--itrace" Then itrace = 1 When "--continue" Then continue = 1 When "-xtr", "--executor" Then executor = 1 When "-exp", "--experimental" Then experimental = 1 When "-u", "--tutor", "--unicode" Then unicode = 1 When "--default" Then Do If args~size == 0 Then Call Error "Missing options after '"option"' option." defaultOptions = args[1] args~delete(1) End When "--csl" Then Do If args~size == 0 Then Call Error "Missing CSL style name after '"option"' option." csl = args[1] args~delete(1) End When "-c", "--css" Then Do If args~size == 0 Then Call Error "Missing CSS directory after '"option"' option." cssDir = args[1] args~delete(1) End When "--style" Then Do If args~size == 0 Then Call Error "Missing style name after '"option"' option." defaultTheme = Lower(args[1]) cliStyle = 1 args~delete(1) End When "--pandoc-highlighting-style" Then Do If args~size == 0 Then Call Error "Missing style name after '"option"' option." highlightStyle = Lower(args[1]) args~delete(1) End When "--cover" Then Do If args~size == 0 Then Call Error "Missing image file after '"option"' option." coverImage = args[1] args~delete(1) End When "--chapter-level" Then Do If args~size == 0 Then Call Error "Missing number after '"option"' option." chapterLevel = args[1] args~delete(1) If \chapterLevel~dataType("W") | chapterLevel < 1 | chapterLevel > 6 Then Call Error "Chapter level must be a number between 1 and 6." End Otherwise Call Error "Invalid option '"option"'." End End -- Determine the CSS base directory If cssDir == "" Then cssDir = rootDir"/css" Else Do If \SysFileExists(cssDir) Then Call Error "CSS directory '"cssDir"' not found." If \SysIsFileDirectory(cssDir) Then Call Error "'"cssDir"' is not a directory." cssDir = .File~new(cssDir)~absolutePath End -- Validate the highlighting style (EPUB needs flattened CSS, -- because ebook readers do not support CSS nesting) cssFile = cssDir"/flattened/rexx-"defaultTheme".css" If \.File~new(cssFile)~exists Then Call Error "Style '"defaultTheme"' not found." If .File~new(cssFile)~isDirectory Then Call Error "File '"cssFile"' is a directory." -- Resolve the CSL file path. If csl~contains("/") | csl~contains("\") Then Do cslPath = csl If \.File~new(cslPath)~exists Then Call Error 'CSL file "'cslPath'" not found.' End Else Do cslPath = rootDir"/csl/"Lower(csl)".csl" If \.File~new(cslPath)~exists Then Call Error 'CSL style "'csl'.csl" not found in the "'rootDir'/csl" directory.' End ------------------------------------------------------------------------------ -- Determine mode: single file or batch directory -- ------------------------------------------------------------------------------ mode = "single" Select Case args~items When 0 Then Signal Help When 1 Then Do arg1 = args[1] If SysIsFileDirectory(arg1) Then Signal BatchMode -- Single file mode file = arg1 If \SysFileExists(file) Then Do If SysFileExists(file".md") Then file ||= ".md" Else Call Error "File '"file"' not found." End Call Directory .File~new(file)~parentFile~absolutePath Call ProcessFile file, "" Exit End When 2 Then Do arg1 = args[1] If \SysFileExists(arg1) Then Do If SysFileExists(arg1".md") Then Call Error "Unexpected argument '"args[2]"'." Call Error "'"arg1"' not found." End If SysIsFileDirectory(arg1) Then Signal BatchMode Call Error "Unexpected argument '"args[2]"'." End Otherwise Call Error "Unexpected argument '"args[3]"'." End Exit ------------------------------------------------------------------------------ -- Batch directory mode -- ------------------------------------------------------------------------------ BatchMode: mode = "batch" source = .File~new(arg1)~absolutePath If args~items == 2 Then Do destination = args[2] If \SysFileExists(destination) Then Call Error "Destination directory '"destination"' does not exist." If \SysIsFileDirectory(destination) Then Call Error "'"destination"' is not a directory." destination = .File~new(destination)~absolutePath End Else destination = "" -- EPUBs go alongside their .md files Call SysFileTree source || .File~separator || "*.md", "md.", "FOS" If md.0 == 0 Then Do Say "No .md files found in '"arg1"'. Nothing to do." Exit 0 End prefixLength = Length(source) + 2 processed = 0 failed = 0 sep = .File~separator Loop i = 1 To md.0 file = .File~new(md.i) relPath = SubStr(md.i, prefixLength) If destination \== "" Then Do relDir = FileSpec("Location", relPath) outDir = destination || sep || relDir Call SysMkDir outDir End Else outDir = "" Say Time("Long") "Processing" relPath"..." savedDir = Directory() Call Directory file~parentFile~absolutePath processRC = ProcessFile(md.i, outDir) Call Directory savedDir If processRC == 0 Then processed += 1 Else Do failed += 1 If \continue Then Do Say "Aborting (use --continue to process remaining files)." Exit 1 End End End If failed == 0 Then Say Time("Long") "Processed" processed "files, took" Time("E") "seconds." Else Say Time("Long") "Processed" processed "files (" failed "failed)," - "took" Time("E") "seconds." If failed > 0 Then Exit 1 Exit 0 -------------------------------------------------------------------------------- ProcessFile: Procedure Expose rootDir cssDir cssFile check fail - defaultTheme cliStyle defaultOptions language continue - itrace cslPath sectionNumbers executor experimental unicode mode - numberFigures highlightStyle coverImage chapterLevel Use Strict Arg file, outputDir = "" fileName = FileSpec("Name", file) If fileName~endsWith(".md") Then fileName = Left(fileName,Length(fileName)-3) source = LoadProgramSource(file) ------------------------------------------------------------------------------ -- Parse YAML front matter for RexxPub options -- ------------------------------------------------------------------------------ yaml = YAMLFrontMatter(source) opts = ParseRexxPubYAML(yaml) -- style: CLI > YAML > default thisTheme = defaultTheme If \cliStyle, opts["style"] \== .Nil Then thisTheme = opts["style"] -- Structural options: YAML wins thisLanguage = language thisSectionNumbers = sectionNumbers thisNumberFigures = numberFigures thisChapterLevel = chapterLevel thisCoverImage = coverImage If opts["language"] \== .Nil Then thisLanguage = opts["language"] If opts["section-numbers"] \== .Nil Then thisSectionNumbers = opts["section-numbers"] If opts["number-figures"] \== .Nil Then thisNumberFigures = opts["number-figures"] If opts["highlight-style"] \== .Nil Then highlightStyle = opts["highlight-style"] -- EPUB-specific YAML options If opts["cover"] \== .Nil Then thisCoverImage = opts["cover"] If opts["chapter-level"] \== .Nil Then thisChapterLevel = opts["chapter-level"] -- Resolve sectionNumbers default (use article defaults for EPUB) If thisSectionNumbers == -1 Then thisSectionNumbers = 3 fileObj = .File~new(file) sep = .File~separator absFile = fileObj~absolutePath extension = FileSpec("Extension",absFile) name = FileSpec("Name",absFile) name = Left(name, Length(name) - Length(extension) - 1) fileDir = fileObj~parent Signal On Syntax Name IndividualFileFailed combinedDefaults = defaultOptions If executor Then combinedDefaults = Strip(combinedDefaults "executor") If experimental Then combinedDefaults = Strip(combinedDefaults "experimental") If unicode Then combinedDefaults = Strip(combinedDefaults "unicode") options. = 0 options.default = combinedDefaults If mode == "single" Then options.["CONTINUE"] = 1 Else If continue Then options.["CONTINUE"] = 1 source = FencedCode( file, source, thisTheme, options. ) Signal Off Syntax Signal AllWentWell IndividualFileFailed: If \IsAParseError(Condition("O"), itrace) Then Raise Propagate Return 1 AllWentWell: ------------------------------------------------------------------------------ -- Build the list of CSS files to embed in the EPUB -- ------------------------------------------------------------------------------ cssFiles = .Array~new -- Main highlighting theme cssFiles~append(cssFile) -- Pandoc syntax highlighting CSS pandocCSSFile = cssDir"/pandoc/"highlightStyle".css" If .File~new(pandocCSSFile)~exists Then cssFiles~append(pandocCSSFile) Else Do pandocCSSFile = cssDir"/pandoc/pygments.css" If .File~new(pandocCSSFile)~exists Then cssFiles~append(pandocCSSFile) End -- Scan source for per-block highlighting styles and add them contents = source~makeString allowed = XRange("ALNUM")".-_" loaded = .Set~new rest = contents Loop While rest~pos('class="highlight-rexx-') > 0 Parse Var rest 'class="highlight-rexx-'extraStyle'"'rest If extraStyle == "" Then Iterate If extraStyle~verify(allowed) > 0 Then Iterate If extraStyle == thisTheme Then Iterate If loaded~hasIndex(extraStyle) Then Iterate extraCSSFile = cssDir"/flattened/rexx-"extraStyle".css" If .File~new(extraCSSFile)~exists, \.File~new(extraCSSFile)~isDirectory Then Do cssFiles~append(extraCSSFile) loaded~put(extraStyle) End End ------------------------------------------------------------------------------ -- Invoke Pandoc to generate EPUB -- ------------------------------------------------------------------------------ -- Determine the output directory for the EPUB If outputDir \== "" Then outDir = outputDir Else outDir = fileDir outFile = outDir || sep || name".epub" ------------------------------------------------------------------------------ -- Extract the title from the Markdown source -- -- Look for a Setext h1 (underlined with ===) or ATX h1 (# Title) -- ------------------------------------------------------------------------------ title = "" Loop j = 1 To source~items line = source[j]~strip If line == "" Then Iterate If line~left(2) == "# " Then Do title = line~substr(3)~strip Leave End If j < source~items Then Do nextLine = source[j+1]~strip If nextLine \== "", nextLine~verify("=") == 0 Then Do title = line Leave End End End -- Strip Pandoc attributes like {.class} and subtitles If title~pos("{") > 0 Then Parse Var title title "{" If title~caselessPos("") > 0 Then Parse Caseless Var title title "" title = title~strip If title == "" Then title = fileName -- Build Pandoc command pandocCommand = 'pandoc' - '--to epub' - '--citeproc' - '--csl="'cslPath'"' - '-M link-citations=true' - '-M title="'title'"' - '--epub-chapter-level='thisChapterLevel - '-M lang='thisLanguage - '-o "'outFile'"' -- Add CSS files Loop cssItem Over cssFiles pandocCommand ||= ' --css="'cssItem'"' End -- Add cover image if specified If thisCoverImage \== "" Then Do If .File~new(thisCoverImage)~exists Then pandocCommand ||= ' --epub-cover-image="'thisCoverImage'"' Else .Error~Say( "Warning: cover image '"thisCoverImage"' not found, ignored." ) End .Error~CharOut("Invoking Pandoc (EPUB)... ") Address COMMAND pandocCommand - With Input Using (source) Error Stem Error. If rc \== 0 Then Do .Error~Say(fail "Pandoc failed with return code" rc":") Loop i = 1 To Error.0 .Error~Say(Error.i) End Return rc End .Error~say(check) Return 0 -------------------------------------------------------------------------------- Error: .Error~Say(Arg(1)) Exit 1 -------------------------------------------------------------------------------- Help: Say .Resources[Help]~makeString - ~caselessChangeStr("myName", myName) - ~caselessChangeStr("myHelp", myHelp) Exit 1 ::Resource Help End "::End" myname -- Convert Markdown documents to EPUB using Pandoc Usage: [rexx] myname OPTIONS filename [rexx] myname OPTIONS source-directory [destination-directory] When the argument is a file, convert that single file to EPUB. When the argument is a directory, convert all .md files in it (and its subdirectories) to EPUB. If a destination directory is given, the output EPUB files are placed there, replicating the source directory structure. Otherwise, each EPUB is placed alongside its source .md file. Options: --chapter-level N Set the heading level for EPUB chapter splits (default: 1) --continue Continue when a file fails (batch mode) --cover FILE Set the cover image for the EPUB -c, --css DIR Set the CSS base directory --csl NAME|PATH Sets the Citation Style Language style (name looks in csl/; path is used as-is) --default OPTIONS Set default options for Rexx code blocks -exp, --experimental Enable Experimental features for all code blocks -h, --help Display this help -it, --itrace Print internal traceback on error --style NAME Set the default visual theme for Rexx code blocks --pandoc-highlighting-style NAME Set Pandoc syntax highlighting theme for non-Rexx code blocks (default: pygments) -u, --tutor, --unicode Enable TUTOR-flavoured Unicode for all code blocks -xtr, --executor Enable Executor support for all code blocks The 'myname' program is part of the Rexx Parser package, see https://rexx.epbcn.com/rexx-parser/. It is distributed under the Apache 2.0 License (https://www.apache.org/licenses/LICENSE-2.0). Copyright (c) 2024-2026 Josep Maria Blasco . See myhelp for details. ::End ::Requires "BaseClassesAndRoutines.cls" ::Requires "ErrorHandler.cls" ::Requires "CLISupport.cls" ::Requires "FencedCode.cls" ::Requires "YAMLFrontMatter.cls" ::Requires "RexxPubOptions.cls"