So cross posting my content is something I’ve wanted to do for some time. I know Hashnode and Dev.to are two of the best places for developers to write, but at the same time I like to own my content, which is why I store all of my content in my own WordPress instance.
Well today I took some time to play a bit with the code to post to Hashnode, and next thing I know I’ve got a fully working tool to publish content from my blog to to both of the above platforms.
As a TLDR on the process, here’s how it works:
- Grab the latest posts from WordPress from its API
- Get the posts that published in the previous 24 hours
- Convert the content from HTML to Markdown
- Publish them to Hashnode & Dev.to via their APIs
Fetching from WordPress
The first step is pulling the posts from WordPress. I’m doing this through the REST API available with all WordPress installations. The GetLatestPosts
hits the default /posts
endpoint which returns 10 posts by default.
func GetLatestPosts() []Post {
// Craft the request
url := fmt.Sprintf("%v/wp-json/wp/v2/posts", os.Getenv("WP_URL"))
req, err := http.NewRequest("GET", url, nil)
if err != nil {
log.Fatal(err)
}
// Set the basic auth header
auth := os.Getenv("WP_USERNAME") + ":" + os.Getenv("WP_PASSWORD")
authHeader := b64.StdEncoding.EncodeToString([]byte(auth))
req.Header.Add("Authorization", fmt.Sprintf("Basic %v", authHeader))
// Execute the request
c := http.Client{}
res, err := c.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
// Parse the JSON repsonse into a struct
bbytes, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
var posts []Post
err = json.Unmarshal(bbytes, &posts)
if err != nil {
log.Fatal(err)
}
return posts
}
I also created a custom JSON parser for the date_gmt
field on the Post
type. This parses the response from the WordPress API into time.Time
which I can work with easier in go, which I’ll demo in a sec.
func (t *CustomTime) UnmarshalJSON(b []byte) (err error) {
date, err := time.Parse(`"2006-01-02T15:04:05"`, string(b))
if err != nil {
return err
}
t.Time = date
return
}
type Post struct {
ID int `json:"id"`
DateGmt CustomTime `json:"date_gmt"`
Slug string `json:"slug"`
Status string `json:"status"`
Title Title `json:"title"`
Content Content `json:"content"`
Excerpt Excerpt `json:"excerpt"`
JetpackFeaturedMediaURL string `json:"jetpack_featured_media_url"`
}
This is all handled in the main
function of main.go
.
posts := wordpress.GetLatestPosts()
filtered := []wordpress.Post{}
past24hrs := time.Now().Add(time.Hour * 24 * -1)
for _, el := range posts {
if el.DateGmt.Time.Unix() > past24hrs.Unix() {
filtered = append(filtered, el)
}
}
Converting to markdown
Now the next thing that I needed to do is convert the posts Content.Rendered
field into markdown. WordPress returns HTML in that property and both the Hashnode and dev.to API’s require markdown. I did this with a function on the Post
struct:
func (p *Post) MarkdownBody() string {
converter := md.NewConverter("", true, nil)
converter.Use(plugin.GitHubFlavored())
markdown, err := converter.ConvertString(p.Content.Rendered)
if err != nil {
log.Fatal(err)
}
spl := strings.Split(markdown, "\n\n")
rebuilt := ""
for _, el := range spl {
spl2 := strings.Split(el, "\n")
if len(spl2) > 0 {
for _, el2 := range spl2 {
rebuilt += el2 + "\r"
}
} else {
rebuilt += el
rebuilt += "\r\r"
}
}
return rebuilt
}
Now here’s the thing, I honestly have no idea why a simple strings.Replace
function wouldn’t properly fix the newlines in the returned string from the markdown library I’m using. I had to split the string by newlines and loop over it to properly set it up. I really feel this function can be improved but it works so 🤷.
Publishing to Hashnode & Dev.to
Finally, in the main
function, I can call functions setup for each service to publish my WordPress post to their platforms. Here’s the Hashnode version which uses a GraphQL mutation:
func PublishPost(post wordpress.Post) {
baseQuery := `
mutation {
createStory(input: {
title: "%v"
isPartOfPublication:{
publicationId: "%v"
}
contentMarkdown: "%v"
tags:[]
coverImageURL: "%v"
isRepublished: {
originalArticleURL: "%v"
}
}) {
post {
title
dateAdded
}
}
}
`
originalUrl := fmt.Sprintf("https://brianmorrison.me/blog/%v", post.Slug)
query := fmt.Sprintf(baseQuery,
post.Title.Rendered,
os.Getenv("HASHNODE_PUB_ID"),
post.MarkdownBody(),
post.JetpackFeaturedMediaURL,
originalUrl)
client := graphql.NewClient("https://api.hashnode.com")
request := graphql.NewRequest(query)
request.Header.Add("Authorization", os.Getenv("HASHNODE_KEY"))
var response interface{}
err := client.Run(context.Background(), request, &response)
if err != nil {
log.Fatal(err)
}
}
The HASHNODE_PUB_ID
is the ID of my blog on Hashnode, and the HASHNODE_KEY
was the API key generated in my settings. Here is the same code for Dev.to’s REST API:
func PublishPost(post wordpress.Post) {
body := PublishPostRequestBody{
Article: Article{
Title: post.Title.Rendered,
Published: true,
BodyMarkdown: post.MarkdownBody(),
MainImage: post.JetpackFeaturedMediaURL,
CanonicalUrl: post.CanonicalUrl(),
},
}
jsonData, err := json.Marshal(body)
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest("POST", "https://dev.to/api/articles", bytes.NewBuffer(jsonData))
if err != nil {
log.Fatal(err)
}
req.Header.Add("api-key", os.Getenv("DEV_TO_KEY"))
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
c := http.Client{}
res, err := c.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
bbytes, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Panic(err)
}
log.Println(string(bbytes))
log.Println("done!")
}
What’s next
Next thing I want to do with this is deploy it to a container on my Kubernetes server and configure it to run every hour or so to look for posts published in the previous hour. This should automatically deploy my newest posts without needing me to do it manually. I also might configure it as an endpoint for WordPress webhooks to handle things like updates if they are ever needed!