Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions Sources/MarkdownUtilities/FormatConversion/HTML/HTMLOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/// Configuration options for Markdown → HTML conversion.
public struct HTMLOptions: ConversionOptions, Sendable {

// MARK: - ConversionOptions

/// Prepend YAML frontmatter as an HTML comment (`<!-- ... -->`).
public var includeFrontmatter: Bool

// MARK: - Document structure

/// Wrap the rendered body in a minimal HTML document skeleton:
/// `<!DOCTYPE html><html><head></head><body>…</body></html>`.
public var wrapInDocument: Bool

// MARK: - Rendering behaviour

/// Render soft line breaks as `<br>` instead of a space.
public var hardBreaks: Bool

/// Pass raw HTML through unchanged (unsafe). When `false`, raw HTML is
/// replaced with an HTML comment placeholder.
public var allowUnsafeHTML: Bool

/// Convert straight quotes to curly, `---` to em dashes, `--` to en dashes.
public var smartPunctuation: Bool

// MARK: - Feature toggles

/// Which GFM extensions to enable. Defaults to `.all`.
public var extensions: MarkdownExtensionOptions

// MARK: - Default

/// Default options: no frontmatter, no document wrapper, soft breaks,
/// safe HTML, no smart punctuation, all GFM extensions enabled.
public static let `default` = HTMLOptions(
includeFrontmatter: false,
wrapInDocument: false,
hardBreaks: false,
allowUnsafeHTML: false,
smartPunctuation: false,
extensions: .all
)

public init(
includeFrontmatter: Bool = false,
wrapInDocument: Bool = false,
hardBreaks: Bool = false,
allowUnsafeHTML: Bool = false,
smartPunctuation: Bool = false,
extensions: MarkdownExtensionOptions = .all
) {
self.includeFrontmatter = includeFrontmatter
self.wrapInDocument = wrapInDocument
self.hardBreaks = hardBreaks
self.allowUnsafeHTML = allowUnsafeHTML
self.smartPunctuation = smartPunctuation
self.extensions = extensions
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import MarkdownSyntax

/// Controls which GFM extensions are active during Markdown → HTML rendering.
///
/// This is our public API for toggling individual features. Internally it maps to
/// `CMExtensionOption` — users never touch that type directly.
///
/// Use `.all` (the default) for full GFM support or `.none` for CommonMark-only output.
/// Individual options can be combined using set operations:
/// ```swift
/// var options = MarkdownExtensionOptions.all
/// options.remove(.tables) // all GFM except tables
///
/// let tablesOnly: MarkdownExtensionOptions = [.tables]
/// ```
public struct MarkdownExtensionOptions: OptionSet, Sendable {
public let rawValue: Int64
public init(rawValue: Int64) { self.rawValue = rawValue }

/// No GFM extensions — CommonMark only.
public static let none: MarkdownExtensionOptions = []

/// All supported GFM extensions (tables, autolinks, strikethrough, tagfilters, tasklist).
public static let all: MarkdownExtensionOptions = [.tables, .autolinks, .strikethrough, .tagfilters, .tasklist]

// MARK: - GFM Extensions (bits 0–4, matching CMExtensionOption's layout)

/// GFM pipe tables.
public static let tables = MarkdownExtensionOptions(rawValue: 1)

/// URL autolinks.
public static let autolinks = MarkdownExtensionOptions(rawValue: 2)

/// `~~Strikethrough~~` via double tildes.
public static let strikethrough = MarkdownExtensionOptions(rawValue: 4)

/// Filter unsafe HTML tags from output (e.g. `<script>`).
public static let tagfilters = MarkdownExtensionOptions(rawValue: 8)

/// `- [x]` Task list checkboxes.
public static let tasklist = MarkdownExtensionOptions(rawValue: 16)

// Future: bits 32+ for other flavors (MultiMarkdown, Pandoc, etc.)
// Int64 gives 63 usable bits; CMExtensionOption uses Int32 (31 bits).
}

// MARK: - Internal mapping to CMExtensionOption

extension MarkdownExtensionOptions {
/// Maps our public options to the underlying cmark type.
///
/// This is intentionally internal — `CMExtensionOption` is an implementation detail.
var asCMExtensionOption: CMExtensionOption {
var result: CMExtensionOption = []
if contains(.tables) { result.insert(.tables) }
if contains(.autolinks) { result.insert(.autolinks) }
if contains(.strikethrough) { result.insert(.strikethrough) }
if contains(.tagfilters) { result.insert(.tagfilters) }
if contains(.tasklist) { result.insert(.tasklist) }
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,51 @@ extension MarkdownDocument {
try YAMLConversion.serialize(frontMatter)
}

// MARK: - Future Format Conversions (Placeholders)
// MARK: - HTML Conversion

// Uncomment and implement as needed:
/// Converts the Markdown document to HTML.
///
/// Uses the battle-tested cmark-gfm renderer directly, bypassing the typed AST.
/// GFM extensions (tables, autolinks, strikethrough, tagfilters, task lists) are
/// enabled by default; disable them individually via `options.extensions`.
///
/// Example usage:
/// ```swift
/// let doc = try MarkdownDocument(content: markdownText)
/// let html = try await doc.toHTML()
/// ```
///
/// With custom options:
/// ```swift
/// let options = HTMLOptions(wrapInDocument: true, extensions: [.tables, .tasklist])
/// let html = try await doc.toHTML(options: options)
/// ```
///
/// - Parameter options: Configuration options for the conversion (default: .default)
/// - Returns: The HTML representation of the document
/// - Throws: Conversion errors if the cmark renderer fails
public func toHTML(options: HTMLOptions = .default) async throws -> String {
var cmarkOptions: CMDocumentOption = [.strikethroughDoubleTilde, .footnotes]
if options.hardBreaks { cmarkOptions.insert(.hardBreaks) }
if options.allowUnsafeHTML { cmarkOptions.insert(.unsafe) }
if options.smartPunctuation { cmarkOptions.insert(.smart) }

// /// Converts the Markdown document to HTML.
// ///
// /// - Parameter options: Configuration options for HTML conversion
// /// - Returns: The HTML representation of the document
// /// - Throws: Conversion errors if the operation fails
// public func toHTML(options: HTMLOptions = .default) async throws -> String {
// fatalError("HTML conversion not yet implemented")
// }
let document = try CMDocument(
text: body,
options: cmarkOptions,
extensions: options.extensions.asCMExtensionOption
)
var html = try await document.renderHtml()

// /// Converts the Markdown document to RTF.
// ///
// /// - Parameter options: Configuration options for RTF conversion
// /// - Returns: The RTF data representation of the document
// /// - Throws: Conversion errors if the operation fails
// public func toRTF(options: RTFOptions = .default) async throws -> Data {
// fatalError("RTF conversion not yet implemented")
// }
if options.includeFrontmatter && !frontMatter.isEmpty {
let yaml = try serializeFrontmatter()
html = "<!--\n\(yaml)-->\n" + html
}

if options.wrapInDocument {
html = "<!DOCTYPE html>\n<html>\n<head></head>\n<body>\n\(html)</body>\n</html>\n"
}

return html
}
}
4 changes: 3 additions & 1 deletion Sources/md-utils/ConvertCommands/ConvertCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ extension CLIEntry {

Available commands:
- to-text: Convert Markdown to plain text
- to-html: Convert Markdown to HTML
- to-csv: Convert Markdown files with frontmatter to CSV

Future formats (planned):
- to-html: Convert Markdown to HTML
- to-rtf: Convert Markdown to RTF

By default, processes directories recursively and outputs
Expand All @@ -27,6 +28,7 @@ extension CLIEntry {
subcommands: [
ToText.self,
ToCSV.self,
ToHTML.self,
]
)
}
Expand Down
Loading