⚠️
So I wrote this before I moved my content into Notion from WordPress. Expect an updated version at some point in the future once I sort out how I’ll do the same thing with Notion as the data source. That’s not to say it doesn’t work right now, it will just change a bit going forward.

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 🤷.

👉
Feel free to reach out if you have a suggestion!

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!