Using Third-Party Libraries in Go
So far we've covered command-line applications that only use modules in the standard library
This post will explore using third-party modules by building a conversion tool from Markdown to HTML
Before We Start: Initializing Go Modules #
go mod init github.com/caraya/markdown-converter
Then, whenever we want to install a third-party package, we run the following command
go get <package URL>
For example:
go get github.com/BurntSushi/toml
or
go get go.abhg.dev/goldmark/frontmatter
We'll point out what packages to get
when it's appropriate.
First Iteration: Building the Conversion Tool #
In the first step, I'm trying to understand how to get the Markdown parser to work.
Before we start we need to get
the Goldmark package.
go get github.com/yuin/goldmark
This will make the package and all its sub-packages available to our code. The import block in the program now looks like this:
import (
"bufio"
"bytes"
"fmt"
"os"
"unicode/utf8"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
Configuring Goldmark is fairly easy.
We initialize the Goldmark package with goldmark.New
and pass as parameters the different configuration blocks we want to use.
WithExtensions
lists the extensions that we want to use in the project- In this example, it includes the GitHub Flavored Markdown extension
WithParserOptions
adds parser options to add functionality- In the example, we add the heading ID generation plugin
WithRendererOptions
adds rendering options to the process- In the example, we tell Goldmark to render the output as XHTML
func main() {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithXHTML(),
),
)
We store the text we want to convert into a buffer of bytes and then use the Convert
method on the Goldmark instance we created to convert the file.
var buf bytes.Buffer
if err := md.Convert(fileContent, &buf); err != nil {
panic(err)
}
}
Second iteration: Scanners Everywhere #
For the second iteration, we bring in the scanner to ask the user for the name of the file to open and the name of the file to close.
We've also incorporated Goldmark extension modules to handle table of contents, front matter and mermaid as Markdown fenced codeblocks.
import (
"bufio"
"bytes"
"fmt"
"os"
"unicode/utf8"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"go.abhg.dev/goldmark/frontmatter"
"go.abhg.dev/goldmark/mermaid"
"go.abhg.dev/goldmark/toc"
)
The Goldmark configuration takes the new extensions under the WithExtensions()
block.
Because these are not built-in extensions we pass a pointer to each extension Extender
Method. Right now we're using their default configurations.
func main() {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.DefinitionList,
extension.Footnote,
&toc.Extender{},
&frontmatter.Extender{},
&mermaid.Extender{},
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithXHTML(),
),
)
The biggest change is the introduction of scanners to request input from the user for the file to open and the file to write to.
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("Please enter the name of the file to open:")
var sourceFileName string
if scanner.Scan() {
sourceFileName = scanner.Text()
} else if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "error reading input: %s", err)
os.Exit(1)
}
// Read the source file specified by the user
fileContent, err := os.ReadFile(sourceFileName)
if err != nil {
fmt.Println("Cannot read the file:", err)
os.Exit(1)
}
fmt.Println("Please enter the location and file name to write to:")
if scanner.Scan() {
targetLocation := scanner.Text()
// Write the read content to the specified file location
err = os.WriteFile(targetLocation, []byte(buf.Bytes()), 0644)
if err != nil {
fmt.Println("Failed to write to the file:", err)
os.Exit(1)
} else {
fmt.Printf("File successfully written to %s\n", targetLocation)
}
} else if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "error reading input: %s", err)
os.Exit(1)
}
fmt.Println(buf.String())
}
Now we have a working converter. But there's a problem.
Iteration Three: Wrap Around The Template #
The conversion process works, but there's a problem: it only converts the content but it doesn't produce a valid HTML document. There are no html, head or body elements.
To work around this problem we'll use a template and insert the content inside the template before saving it.
The template looks like this:
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
{{.Content}}
</body>
</html>
We import the html/template
module to work with templates.
import (
"bufio"
"bytes"
"fmt"
"html/template"
"os"
"unicode/utf8"
// Third-part imports"
)
Inside the main function, we load the template using template.ParseFiles.
We use template.Execute to populate the template with the wrapped content.
The Execute
method takes two parameters: A pointer to the template content and the expression map[string]interface{}{"Content": template.HTML(buf.String())}
.
The expression creates a map with string keys and values of any type (using the empty interface: interface{}
). This map contains a single entry where:
- The key is
"Content"
- The value is
template.HTML(buf.String())
- This part of the expression converts the result of
buf.String()
into template.HTML type buf
is a buffer containing the converted Markdown text andbuf.String()
converts its content to a string- The
template.HTML
type is used in the Go template package to prevent Go from escaping HTML tags in the template
- This part of the expression converts the result of
func main() {
// Load the external template file
tmpl, err := template.ParseFiles("templates/template.html")
if err != nil {
panic(err)
}
var wrappedContent bytes.Buffer
if err := tmpl.Execute(&wrappedContent, map[string]interface{}{"Content": template.HTML(buf.String())}); err != nil {
panic(err)
}
fmt.Println("Please enter the location and file name to write to:")
if scanner.Scan() {
targetLocation := scanner.Text()
err = os.WriteFile(targetLocation, wrappedContent.Bytes(), 0644)
if err != nil {
fmt.Println("Failed to write file:", err)
os.Exit(1)
} else {
fmt.Printf("File successfully written to %s\n", targetLocation)
}
} else if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "error writing output: %s", err)
os.Exit(1)
}
fmt.Println(wrappedContent.String())
}
Each page is now a well-formed HTML document. We can also add scripts and stylesheets
Iteration Four: Naming is hard #
So far we've provided a name for the file to open and one for the file to save. In this iteration, we will save the file with the same name and a different extension.
For this iteration, we add the following code to main()
. The code will do the following:
It will point to the starting point for the file extension using the
LastIndex function of the Strings
package.
If there's no extension (the LastIndex function returns -1
) then we return the file name as the file name + html
.
If there is an extension, we replace it with .html
and output the new file name.
extensionIndex := strings.LastIndex(sourceFileName, ".") // 1
var targetLocation string
if extensionIndex == -1 { // 2
targetLocation = sourceFileName + ".html"
} else {
targetLocation = sourceFileName[:extensionIndex] + ".html" // 3
}
Iteration Five: Modularize The Code #
Before we continue, we need to make the code cleaner. The main
function is getting too large and it's becoming hard to walk through the code.
So we break the functionality into discrete functions that we can call from anywhere in the code.
In this iteration, we only cover the new functions along with a brief description
createMarkdownConverter
initializes and sets up Goldmark.
func createMarkdownConverter() goldmark.Markdown {
return goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.DefinitionList,
extension.Footnote,
&toc.Extender{
TitleDepth: 2,
},
&mermaid.Extender{},
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithXHTML(),
),
)
}
In processFile
we will process the front matter content by calling extractFrontmatterAndContent
func processFile(sourceFileName, destDir string, md goldmark.Markdown) {
// Extract frontmatter and content
frontmatter, content, err := extractFrontmatterAndContent(fileContent)
if err != nil {
fmt.Printf("Failed to extract frontmatter: %v", err)
}
var data FrontMatterData
if err := yaml.Unmarshal([]byte(frontmatter), &data); err != nil {
fmt.Printf("Failed to unmarshal frontmatter: %v", err)
}
var buf bytes.Buffer
if err := md.Convert([]byte(content), &buf); err != nil {
fmt.Printf("Failed to convert Markdown content: %v\n", err)
return
}
writeHTMLToFile(buf, sourceFileName, destDir, data)
}
The extractFrontmatterAndContent
function will extract the YAML front matter from the top of each document and pass the data to the calling function.
func extractFrontmatterAndContent(fileContent []byte) (frontmatter, content string, err error) {
const delimiter = "---"
contentStr := string(fileContent)
if !strings.HasPrefix(contentStr, delimiter) {
return "", contentStr, nil
}
parts := strings.SplitN(contentStr, delimiter, 3)
if len(parts) < 3 {
return "", "", fmt.Errorf("malformed frontmatter: missing end delimiter")
}
return strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), nil
}
writeHTMLToFile
handles opening, parsing and executing the template and populating it with the metadata and converted Markdown content.
func writeHTMLToFile(buf bytes.Buffer, sourceFileName, destDir string, data interface{}) {
if err := os.MkdirAll(destDir, 0755); err != nil {
fmt.Printf("Failed to create the destination directory %s: %v\n", destDir, err)
return
}
filename := filepath.Base(sourceFileName)
targetLocation := filepath.Join(destDir, strings.TrimSuffix(filename, filepath.Ext(filename))+".html")
tmpl, err := template.ParseFiles("templates/template.html")
if err != nil {
fmt.Printf("Failed to parse template: %v\n", err)
return
}
var wrappedContent bytes.Buffer
if err := tmpl.Execute(&wrappedContent, map[string]interface{}{
"Content": template.HTML(buf.String()),
"Metadata": data,
}); err != nil {
fmt.Printf("Failed to execute template: %v\n", err)
return
}
err = os.WriteFile(targetLocation, wrappedContent.Bytes(), 0644)
if err != nil {
fmt.Printf("Failed to write to file %s: %v\n", targetLocation, err)
return
}
fmt.Printf("File successfully written to %s\n", targetLocation)
}
Iteration Six: Give Me More Than One File #
Right now, we can only work with one file at a time which can be tedious and you may forget to convert one or more of the files.
This iteration will enable us to enter more than one file in the command line. Now, you can do something like:
go run main.go demo01.md demo02.md demo03.md
This will process all the demo files at one time and produce individual HTML files.
We first check if we provided one or more Markdown files as arguments to the program. If there aren't then we flag the user and exit.
Then we run a for loop, read the individual file and convert it to Markdown.
if len(os.Args) < 2 {
fmt.Println("Please provide one or more markdown files as arguments.")
os.Exit(1)
}
for _, sourceFileName := range os.Args[1:] {
fileContent, err := os.ReadFile(sourceFileName)
if err != nil {
fmt.Printf("Cannot read file %s: %v\n", sourceFileName, err)
continue
}
// UTF-8 validation removed
var buf bytes.Buffer
if err := md.Convert(fileContent, &buf); err != nil {
fmt.Printf("Failed to convert file %s: %v\n", sourceFileName, err)
continue
}
// Rest of the code remains the same
}
Iteration Seven: Working With Directories or Files #
Rather than opening files individually, this iteration will change the code to give us the option of specifying a directory of files we want to process while keeping the ability to work with individual files.
We check if the third argument (after the program name and the destination directory) is a file or a directory using a for loop and slice manipulation functions.
We run different functions if the parameter is a directory or a file.
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"os"
"path/filepath"
"strings"
"unicode/utf8"
// import third-party packages
)
func main() {
destDir := os.Args[1]
md := createMarkdownConverter()
for _, arg := range os.Args[2:] {
fi, err := os.Stat(arg)
if err != nil {
fmt.Printf("Error accessing %s: %v\n", arg, err)
continue
}
if fi.IsDir() {
processDirectory(arg, destDir, md)
} else {
processFile(arg, destDir, md)
}
}
}
The processDirectory
function will continue to walk down the path until there is a file to process (one that ends with either the .md
or .markdown
extensions) at which point it will call processFile
to convert the file to HTML.
func processDirectory(dirPath, destDir string, md goldmark.Markdown) {
filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
fmt.Printf("Error accessing path %s: %v\n", path, err)
return err
}
if !d.IsDir() && (strings.HasSuffix(d.Name(), ".md") || strings.HasSuffix(d.Name(), ".markdown")) {
processFile(path, destDir, md)
}
return nil
})
}
func processFile(sourceFileName, destDir string, md goldmark.Markdown) {
// Rest of the code omitted for brevity
writeHTMLToFile(buf, sourceFileName, destDir, data)
}
Iteration Eight: Use YAML to populate metadata #
All the files have YAML front matter at the beginning of the file. Right now that data is removed and we don't have a title for the document, and we can't use it.
This iteration will read the YAML data and then use it to populate the template.
We first import the YAML package.
package main
import (
// Built-in package imports removed
// Other package imports removed
"gopkg.in/yaml.v2"
)
In the next step, we create a function to process the YAML front matter. The process will do the following:
- Check if there is a prefix
---
that delimits the front matter block- If there isn't one we return the content without the front matter data
- If there is a prefix then split the front matter block into three sections
- the opening delimiter
- the content of the front matter block
- the closing delimiter
- Throw an error if there is no closing delimiter
- Return the components of the front matter to be used elsewhere
func extractFrontmatterAndContent(fileContent []byte) (frontmatter, content string, err error) {
const delimiter = "---"
contentStr := string(fileContent)
if !strings.HasPrefix(contentStr, delimiter) {
return "", contentStr, nil
} // 1
parts := strings.SplitN(contentStr, delimiter, 3) // 2
if len(parts) < 3 {
return "", "", fmt.Errorf("malformed frontmatter: missing end delimiter")
} // 3
return strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), nil // 4
}
With this code, we now have a way to add the title and other metadata we choose to place in the YAML front matter.
The Final Result #
After all the iterations, this is what the final code looks like.
We've made changes to the template file to add basic layout and typography styles along with Prism.js scripts and styles.
It also adds commas after the value of all tags but the last one.
I've uploaded the code to a
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/prism.css">
<title>{{.Metadata.Title}}</title>
</head>
<body>
<div class="grid-container">
<main>
<header>
<h1>{{.Metadata.Title}}</h1>
<p>Created on: {{.Metadata.Date}}</p>
<p>Filed Under: {{range $index, $tag := .Metadata.Tags}}{{if $index}}, {{end}}<span>{{$tag}}</span>{{end}}</p>
</header>
{{.Content}}
</main>
</div>
<script src="js/prism.js"></script>
</body>
</html>
The generator code consolidates all the prior iterations and will produce well-formed, valid HTML documents.
I've packaged the program and its associated files in a Github repository https://github.com/caraya/markdown-converter so you can see it in action.
package main
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"go.abhg.dev/goldmark/mermaid"
"go.abhg.dev/goldmark/toc"
"gopkg.in/yaml.v2"
)
type FrontMatterData struct {
Title string `yaml:"title"`
Date string `yaml:"date"`
Tags []string `yaml:"tags"`
}
func main() {
if len(os.Args) < 3 {
fmt.Println("Usage: <program> <destination-directory> <markdown-file-or-directory>...")
os.Exit(1)
}
destDir := os.Args[1]
md := createMarkdownConverter()
for _, arg := range os.Args[2:] {
fi, err := os.Stat(arg)
if err != nil {
fmt.Printf("Error accessing %s: %v\n", arg, err)
continue
}
if fi.IsDir() {
processDirectory(arg, destDir, md)
} else {
processFile(arg, destDir, md)
}
}
}
func createMarkdownConverter() goldmark.Markdown {
return goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.DefinitionList,
extension.Footnote,
&toc.Extender{
TitleDepth: 2,
},
&mermaid.Extender{},
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithXHTML(),
html.WithUnsafe(),
),
)
}
func processFile(sourceFileName, destDir string, md goldmark.Markdown) {
fileContent, err := os.ReadFile(sourceFileName)
if err != nil {
fmt.Printf("Cannot read file %s: %v\n", sourceFileName, err)
return
}
if !utf8.Valid(fileContent) {
fmt.Printf("File %s is not a valid UTF-8 text file\n", sourceFileName)
return
}
frontmatter, content, err := extractFrontmatterAndContent(fileContent)
if err != nil {
fmt.Printf("Failed to extract frontmatter: %v", err)
}
var data FrontMatterData
if err := yaml.Unmarshal([]byte(frontmatter), &data); err != nil {
fmt.Printf("Failed to unmarshal frontmatter: %v", err)
}
var buf bytes.Buffer
if err := md.Convert([]byte(content), &buf); err != nil {
fmt.Printf("Failed to convert Markdown content: %v\n", err)
return
}
writeHTMLToFile(buf, sourceFileName, destDir, data)
}
func processDirectory(dirPath, destDir string, md goldmark.Markdown) {
filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
fmt.Printf("Error accessing path %s: %v\n", path, err)
return err
}
if !d.IsDir() && (strings.HasSuffix(d.Name(), ".md") || strings.HasSuffix(d.Name(), ".markdown")) {
processFile(path, destDir, md)
}
return nil
})
}
func extractFrontmatterAndContent(fileContent []byte) (frontmatter, content string, err error) {
const delimiter = "---"
contentStr := string(fileContent)
if !strings.HasPrefix(contentStr, delimiter) {
return "", contentStr, nil
}
parts := strings.SplitN(contentStr, delimiter, 3)
if len(parts) < 3 {
return "", "", fmt.Errorf("malformed frontmatter: missing end delimiter")
}
return strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), nil
}
func writeHTMLToFile(buf bytes.Buffer, sourceFileName, destDir string, data interface{}) {
if err := os.MkdirAll(destDir, 0755); err != nil {
fmt.Printf("Failed to create the destination directory %s: %v\n", destDir, err)
return
}
filename := filepath.Base(sourceFileName)
targetLocation := filepath.Join(destDir, strings.TrimSuffix(filename, filepath.Ext(filename))+".html")
tmpl, err := template.ParseFiles("templates/template.html")
if err != nil {
fmt.Printf("Failed to parse template: %v\n", err)
return
}
var wrappedContent bytes.Buffer
if err := tmpl.Execute(&wrappedContent, map[string]interface{}{
"Content": template.HTML(buf.String()),
"Metadata": data, // Pass the frontmatter data here
}); err != nil {
fmt.Printf("Failed to execute template: %v\n", err)
return
}
err = os.WriteFile(targetLocation, wrappedContent.Bytes(), 0644)
if err != nil {
fmt.Printf("Failed to write to file %s: %v\n", targetLocation, err)
return
}
fmt.Printf("File successfully written to %s\n", targetLocation)
}