Hello and welcome to part 2 of the introduction to Hashnode GitHub Sync.
In the previous article, we discussed the one-way sync from GitHub to Hashnode. Today, we will talk about the reverse i.e. Hashnode to GitHub.
If you missed first part, please read it here.
Concept
We are going to use Hashnode's latest offering: Webhook. Hashnode will emit an event whenever a post is created, updated or deleted and we will receive it with the help of webhooks. Let's see how we will utilize it to create a sync between Hashnode and GitHub.
Creating a Serverless function
To create a two-way sync between your Hashnode blog and your GitHub repo, you need to create and host a serverless function on Vercel, which will act as a middleware.
Why is this necessary? Whenever a webhook event is emitted on Hashnode, it sends some data which we want to send to GitHub.
GitHub expects that data in a different format than what we receive from Hashnode. So a middleware is required to convert the event data from Hashnode into the required format.
Refer to this repo to create your own serverless function.
Updating action to support reverse sync
Modifying existing files
We add another input to grab the webhook event in action.yml
file like this.
# action.yml
name: Hashnode Github Sync
description: Establish a two way sync between your Github repo and Hashnode Blog
author: Ammar Mirza
branding:
icon: 'link'
color: 'blue'
runs:
using: 'node20'
main: 'dist/index.js'
inputs:
# Rest code as it is
hashnode_event:
description: The event responsible for triggering action
We will create another function called hashnodeToGithubSync
which will be called whenever a event is present.
Since the Hashnode to GitHub sync is a separate function, we will modify our index file to call this function whenever an event is present. Otherwise, we will call the GitHub to Hashnode function.
// src/index.ts
import * as core from "@actions/core";
import { getInput } from "./shared/getInput";
import githubToHashnodeSync from "./github-to-hashnode";
import hashnodeToGithubSync from "./hashnode-to-github";
export async function run() {
try {
const { hashnode_event } = getInput();
const parsedEvent = JSON.parse(hashnode_event);
if (parsedEvent) await hashnodeToGithubSync(parsedEvent);
else await githubToHashnodeSync();
} catch (error: any) {
core.setFailed(error.message);
}
}
run();
This is the workflow of hashnodeToGithubSync
function. We are calling a dedicated function for each event based on the event type we are getting as input.
// src/hashnode-to-github/index.ts
import { deleteArticle } from "./deleteArticle"
import { modifyArticle } from "./modifyArticle"
import { publishArticle } from "./publishArticle"
const hashnodeToGithubSync = async (parsedEvent: any) => {
const eventType = parsedEvent.eventType
const postId = parsedEvent.post.id
switch (eventType) {
case 'post_published':
await publishArticle(postId)
break;
case 'post_updated':
await modifyArticle(postId)
break;
case 'post_deleted':
await deleteArticle(postId)
break;
}
}
export default hashnodeToGithubSync
Similar to the part 1, there can be 3 cases of synchronization while blogging from Hashnode. You can either publish, update and delete an article.
Case 1 : Publish Flow
Let's break down the publishArticle
function.
// src/hashnode-to-github/publishArticle.ts
import {
checkIfFileExists,
createFile,
deleteFile,
getPostData,
octokit,
} from "./utils";
import { context } from "@actions/github";
export const publishArticle = async (postId: string): Promise<void> => {
const postData = await getPostData(postId);
const isExistingFile = await checkIfFileExists(postData);
if (isExistingFile) {
const {
data: { sha },
} = await octokit.request(
"GET /repos/{owner}/{repo}/contents/{file_path}",
{
owner: context.repo.owner,
repo: context.repo.repo,
file_path: `${postData.post.slug}.md`,
}
);
await deleteFile({ postData, sha });
}
await createFile({ postData });
};
First of all we get the post data by using post id which we get in Hashnode event.
After that we check that whether if a file with a path name same as our post slug exists in our repo.
// src/hashnode-to-github/utils/checkIfFileExists.ts
import { octokit } from "./octokit";
import { context } from "@actions/github";
import { PostData } from "src/shared/types";
export const checkIfFileExists = async (postData: PostData) : Promise<boolean> => {
try {
await octokit.request("GET /repos/{owner}/{repo}/contents/{file_path}", {
owner: context.repo.owner,
repo: context.repo.repo,
file_path: `${postData.post.slug}.md`,
});
return true
} catch {
return false
}
}
- If it exists, we request the
sha
of that file and then delete it.
// src/hashnode-to-github/utils/deleteFile.ts
import { PostData } from "src/shared/types"
import { octokit } from "./octokit";
import { context } from "@actions/github";
type DeleteFileType = {
postData: PostData,
sha: string
}
export const deleteFile = async ({postData, sha}: DeleteFileType) => {
try {
await octokit.rest.repos.deleteFile({
owner: context.repo.owner,
repo: context.repo.repo,
path: `${postData.post.slug}.md`,
message: `File deleted for recreation.`,
sha
})
} catch(error: any) {
console.log(error.message)
}
}
- After that we create a new file in our repo using
createFile
function.
// src/hashnode-to-github/utils/createFile.ts
import { mapGqlToMarkdownInput } from './mapGqlToMarkdownInput';
import { octokit } from './octokit';
import { default as matter } from 'gray-matter'
import { Base64 } from "js-base64";
import { context } from "@actions/github";
import { PostData } from 'src/shared/types';
import { getCommitterDetails } from './getCommitterDetails';
export const createFile = async ({postData, sha}: {
postData: PostData,
sha?: string
}) => {
try {
const commitType = sha ? "Modified" : "Added"
const userDetails = await getCommitterDetails()
const post = postData.post
const fileName = `${post.id}-${post.slug}.md`
const frontMatter = mapGqlToMarkdownInput(postData)
const fileContent = matter.stringify(post.content.markdown, frontMatter)
const contentEncoded = Base64.encode(fileContent)
const { data } = await octokit.repos.createOrUpdateFileContents({
owner: context.repo.owner,
repo: context.repo.repo,
path: fileName,
branch: "main",
message: `${commitType} Blog ${fileName} programatically`,
content: contentEncoded,
committer: {
name: `${userDetails.name}`,
email: `${userDetails.email}`,
},
sha,
author: {
name: `${userDetails.name}`,
email: `${userDetails.email}`,
},
});
console.log(data);
} catch (err) {
console.error(err);
}
};
Let's break down createFile
function.
- We get committer details using octokit/rest like this.
// src/hashnode-to-github/utils/getCommitterDetails.ts
import { context } from "@actions/github";
import { octokit } from "./octokit";
export const getCommitterDetails = async (): Promise<any> => {
const { data } = await octokit.request("GET /users/{owner}", {
owner: context.repo.owner,
});
return data
};
- Then we use a mapper function to frame the post data into a similar pattern as our Frontmatter.
// src/hashnode-to-github/utils/mapGqlToMarkdownInput.ts
import { FrontMatter, PostData } from "src/shared/types";
export const mapGqlToMarkdownInput = (data: PostData): FrontMatter => {
const frontMatter: FrontMatter = {
title: data.post.title,
subtitle: data.post.subtitle,
publishedAt: data.post.publishedAt,
coverImageUrl: data.post.coverImage?.url,
isCoverAttributionHidden:
data.post.coverImage?.isAttributionHidden,
coverImageAttribution: data.post.coverImage?.attribution,
coverImagePhotographer: data.post.coverImage?.photographer,
stickCoverToBottom: data.post.preferences.stickCoverToBottom,
tags: data.post.tags,
disableComments: data.post.preferences.disableComments,
ogTitle: data.post.seo.title,
ogDescription: data.post.seo.description,
ogImage: data.post.ogMetaData.image,
seriesId: data.post.series?.id,
delisted: data.post.preferences.isDelisted,
enableTableOfContent:
data.post.features.tableOfContents.isEnabled,
coAuthors: data.post.coAuthors,
};
const filteredFrontMatter = Object.fromEntries(
Object.entries(frontMatter).filter(([key, value]) => value)
);
return filteredFrontMatter as FrontMatter;
};
After that we use gray-matter and js-base64 to parse and encode our file.
And then we use
createOrUpdateFileContents
function of octokit/rest to create our file.
Case 2 : Update Flow (Modifying an Article)
Let's break down the modifyArticle
function.
// src/hashnode-to-github/modifyArticle.ts
import { createFile, getPostData, octokit } from "./utils";
import { context } from "@actions/github";
export const modifyArticle = async (postId: string) => {
const postData = await getPostData(postId);
const {
data: { sha },
} = await octokit.request("GET /repos/{owner}/{repo}/contents/{file_path}", {
owner: context.repo.owner,
repo: context.repo.repo,
file_path: `${postData.post.id}-${postData.post.slug}.md`,
});
await createFile({ postData, sha });
};
As you can see, the modifyArticle
function is almost the same as the publishArticle
function. In this case, we do not check for the existence of a file or delete it. Additionally, we pass the sha
to the createFile
function because sha
is required when modifying an existing file. We also adjusted the path name when requesting sha
compared to the publishArticle
function.
Case 3 : Delete Flow (Deleting an Article)
Let's break down the deleteArticle
function.
// src/hashnode-to-github/deleteArticle.ts
import { context } from "@actions/github";
import { octokit } from "./utils";
export const deleteArticle = async (postId: string) => {
try {
const { data } = await octokit.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: "",
});
if(!Array.isArray(data)) return
const fileToDelete = data.find(file => file.name.startsWith(postId))
if(fileToDelete) {
await octokit.repos.deleteFile({
owner: context.repo.owner,
repo: context.repo.repo,
path: fileToDelete.name,
sha: fileToDelete.sha,
message: "Blog deleted from Hashnode side"
})
}
} catch (error: any){
console.log(error.message)
}
};
In this function, we get a list of all files in the root of the user's repo.
Then, we check to make sure the data we receive is an array.
Next, we check if any filename in the repo starts with the post id. If it exists, we return that file from the array; otherwise, we return null.
If a filename starts with the post id, we delete that file.
Wrapping Up
And with that, we've wrapped up our brief discussion.
A few weeks ago, I didn't even know about GitHub Actions, so creating something like Hashnode GitHub Sync is a pretty big deal for me.
Special thanks to Victoria Lo her series on GitHub Actions helped me a lot. Here's a link to her series in case you are interested.
I hope you now understand the whole GitHub Action through both articles. If you still have any questions, feel free to ask in the comments below.
Please like and share the article if you found it helpful. Do also follow me on X.
Here are some repo links to drop a ⭐️:
Hashnode GitHub Sync - source code
GitHub Hashnode Webhook - source code
Thanks for reading. Follow me for more articles!