blogception
There's several ways of creating a blog nowadays, whether it is with a legacy CMS such as Wordpress whether it is by taking a more modern approach using a headless CMS solution like Strapi or even building it with Astro through their brand new feature Content Collections.
Bref, there're plenty of solutions.
Why not build it ourselves ?
I know right, I had the same idea :nerd_face:
I wanted a solution that would be CMS-like, with automatic time tracking of blog post creation/edition possibility of several authors and easy content management.
When describing like this, it does sound familliar right ?
Excatly, I'm basically describing Git and Github.
So, now that's it's clearer in our head let's summarize what we are going to do :
- Use the Github API
- Use a dedicated repo to store our blog posts
- Use Deno + Fresh
- Implements type safety
The Github part
First thing first when it comes to external API, let's grab ourselves a key to authenticate our requests, for github you'll be able to generate one here. You can refine the token accesses or just checking every permissions it's up to you.
Then let's create a new repo, I simply named it blog. Create a basic .md file and we are good for now.
The code part
I'll let you refer to the official Fresh doc to create a new project and grabbing the basics understanding of this framework as it's beyond the scope of this article.
After, creating a new project with this command
1$ deno run -A -r https://fresh.deno.dev github-blog
We're left with this folder structure
1.
2├── README.md
3├── components/
4├── deno.json
5├── deno.lock
6├── dev.ts
7├── fresh.gen.ts
8├── import_map.json
9├── islands/
10├── main.ts
11├── routes/
12│ └── index.tsx
13├── static
14│ └── favicon.ico
15└── twind.config.ts
To start off, let's create a new folder utils at the root directory of our project and create a new file that will hold the logics of fetching our blogposts, I came with the funny name of octoblog.ts.
First thing we are gonna do is to create the Headers object that we'll reuse for all our requests. We need it to of course authenticate our requests.
1const headers = new Headers();
2headers.append('Authorization', `token <TOKEN>`);
3headers.append('Accept','application/vnd.github+json');
That probably won't be a good idea to inline your private access token right here in the code, rather add it to you env variable and access it through Deno.env.get(key: string).
Then let's create a fetch wrapper so that it's all safely typed.
Remember, all of this is executed on server side in fresh
1export class Octoblog {
2 private static get<T extends Endpoint>(endpoint: T): Promise<ResponseType<T>> {
3 const data = await fetch(endpoint, {
4 headers, method: 'GET'
5 });
6 return await data.json();
7 }
8}
Ok, the idea here is simple : we will define the Endpoint type and based on which endpoints we queried, we will receive a proper ResponseType.
We will need to hit 3 different endpoints for this :
/contents used to get base64 string of a file
/commits usedd to get metadata like commit date and author
/git/trees used to list all files in a repo
1type GITBRANCH = "master" | "dev";
2
3const CONTENT =
4 ".../contents/" as const;
5
6const COMMIT =
7 `.../commits?sha=${GITBRANCH}&path=` as
8 `.../commits?sha=${GITBRANCH}&path=`;
9
10const TREE =
11 `.../git/trees/${GITBRANCH}` as
12 `.../git/trees/${GITBRANCH}`;
13
14const ENDPOINTS = {CONTENT, COMMIT, TREE} as const;
15type Endpoint = typeof ENDPOINTS[keyof typeof ENDPOINTS];
16
17type EndpointResponseMap = {
18 "../contents/": IContent;
19 "../commits?sha=dev&path=": ICommit | ICommit[];
20 "../commits?sha=master&path=": ICommit | ICommit[];
21 "../git/trees/master": ITree;
22 "../git/trees/dev": ITree;
23};
24
25type ResponseType<T extends Endpoint> = T extends keyof EndpointResponseMap
26 ? EndpointResponseMap[T]
27 : unknown;
That was a lot of boilerplate but necessary ... hold on we're not done yet. We need to define each response types IContent, ICommit and ITree. But luckily for us there's a handy tool to generate a typescript interface based on a json that exists : quicktype.
And since we're at it, let's define our IPost interface, the actual representation of a blog post in the context of our app.
1export interface IPost {
2 slug: string;
3 title: string;
4 publishedAt: number;
5 content: string;
6 author: string;
7}
Okay, really we're done with types now.
Let's create implement a new method in our Octoblog class to get one post in a repo by its slug.
1public static async getAllPosts(): Promise<IPost[]> {
2 const treeElements: TreeElement[] = (await Octoblog.get(ENDPOINTS.TREE)).tree;
3 return (await Promise.all(
4 treeElements.map( treeElement => Octoblog.getPost(treeElement.path))
5 )).sort((postA, postB) => postB.publishedAt - postA.publishedAt);
6 }
Here we use our fetch wrapper we created earlier get() to make a request to the TREE endpoint, resolving all the promises returned by getPost() (soon) and returning this awaited array of IPost ordered by publishing date.
To get a post we need to merge datas we get from two different responses, ICommit and IContent. To execute them at the same time let's create a helper function around our get() in order to do that.
1//yes I lied, another type..
2type Param<T> = {
3 endpoint: T;
4 params?: {
5 slug?:string
6 }
7}
8//helper method to do more than one request at a time
9private static async getSeveral<T extends Endpoint>(param: Array<Param<T>>): Promise<Array<ResponseType<T>>>{
10 return await Promise.all(param.map(req => Octoblog.get(req.endpoint, req.params)));
11 }
12
13public static async getPost(slug: string): Promise<IPost> {
14 const [commitForPost, contentForPost] = await Octoblog.getSeveral([
15 {
16 endpoint: ENDPOINTS.COMMIT,
17 params: {slug}
18 },
19 {
20 endpoint: ENDPOINTS.CONTENTS,
21 params: {slug}
22 },
23 ]);
24 return Octoblog.makePost((contentForPost as IContent), (commitForPost as Array<ICommit>), slug);
25 }
Finally we need this other helper function makePost() to wrap up the IPost object creation.
1 private static makePost(contentObject: IContent, commits: Array<ICommit>, slug: string): IPost{
2 slug = slug.replace('.md','');
3 const title = slug.replaceAll('-',' ');
4 const makeDate = (index = -1) => new Date((commits.at(index)?.commit.author.date as Date)).getTime();
5 const content = Base64.fromBase64String(contentObject.content).toString();
6 const author = commits.at(-1)?.committer.login;
7 return {
8 slug,
9 title,
10 publishedAt: makeDate(),
11 content,
12 author // possibly here we can add more fields to IPost like authorPicture or lastUpdatedAt contained in ICommit
13 } as IPost
14 }
Results !
And that's it ... Almost
At least on the github api operations side. We still need to display these beautiful posts to our UI.
So, in Fresh the implementation would look like that, but you can easily figure out how to integrate it with your framework of choice.
1// routes/blog/index.tsx
2export const handler: Handlers<IPost[]> = {
3 async GET(_req, ctx) {
4 const posts = await Octoblog.getAllPosts()
5 return ctx.render(posts as IPost[])
6 }
7}
8
9// And simply define our page component in TSX as Fresh uses Preact to render UI
10export default function Blog(props: PageProps<IPost[]>) {
11 const posts = props.data;
12 return (
13 <>
14 <Head>
15 <title>My octoblog</title>
16 </Head>
17 <main>
18 {posts.map(post => <Post post={post}/>)}
19 </main>
20 </>
21 );
22}
And voilà, really DONE.
Conclusion
Well, was it worth it ?
Why not.
Is it optimized ?
I probably don't think so. Having a limited amount of files in a repo is fine but since we are doing 2 fetch requests for each files, it can be limiting for sure.
You can see the source of this very blog implementing this logic on this repo : coding-room