AI Assisted Blog with Nuxt, GitHub Codespaces & Actions
Tags: githubhack23, nuxt, githubactions, openai
Published: 2023-05-23 22:55:04

Photo by <a href="https://unsplash.com/es/@rubaitulazad?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Rubaitul Azad</a> on <a href="https://unsplash.com/photos/HLQDfaJUTVI?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>
  
## What I built 
For the Hackathon I created a markdown based blogging website  which triggers a preconfigured GitHub Codespace for writing new blog posts. To assist with writing the posts, I also created a new VSCode extension which uses OpenAI APIs to rewrite your sentences. This extension is also preconfigured in the repo's codespaces. Finally, on new commits a GitHub action kicks in to generate a static version of the website and deploy it to GitHub Pages.

### Category Submission: 
Maintainer Must-Haves
DIY Deployments
Wacky Wildcards

### App Link
[https://ra-jeev.github.io/gh-blog/](https://ra-jeev.github.io/gh-blog/)

[Write Assist AI](https://marketplace.visualstudio.com/items?itemName=ra-jeev.write-assist-ai)

### Screenshots 
**The Index Page**
![The index page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4nr539owernxwv0gftoq.png)

**The Blog List Page**
![The blog list page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/154w21fuhs79gas0kuy9.png)

**The Blog Article Page**
![The blog article page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yiclvs7tk930lhddqeqs.png)

**The VSCode Extension Screenshots**

![WriteAssistAI Prompt 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n8sir4swn63c4h609vor.png)

![WriteAssistAI API Calling](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i2srpm4l11akv2jczgv8.png)

![WriteAssistAI result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mk9vec6yf8ogzfvp7i32.png)

### Description 
gh-blog is a markdown based blog website. It utilizes the GitHub APIs to directly trigger the start of a preconfigured GitHub Codespace, thus allowing us to add new posts or edit the existing ones. To safeguard the trigger it requires you to login to the website as an admin.

The current features set include the following
1. A markdown based blog / content website template
2. A hidden login page for signing in to the website as an admin
3. A button click trigger to securely start a preconfigured codespace from a server environment
4. A brand new OpenAI Writer Assistant preconfigured in the codespace for rewriting your sentences / paragraphs
5. A Github Action to generate the static website on every push commit to the repository and deploy it to GitHub Pages

### Link to Source Code 
{% embed https://github.com/ra-jeev/gh-blog %}
{% embed https://github.com/ra-jeev/write-assist-ai %}

### Permissive License 
Both the repositories have permissive MIT Licenses.

## Background (What made you decide to build this particular app? What inspired you?) 
AI is current hype, and I've been feeling a lot of FOMO about it. So, I wanted to use it for the hackathon. I also wanted to create something tangible which can be used by others, or at the least be a starting point. Both of these desires guided me towards creating an AI Writer Assistant and a blogging website template. I chose to use `Nuxt3 Content` to build the template as it is based on markdown files which can be edited easily from inside a codespace.

### How I built it (How did you utilize GitHub Actions or GitHub Codespaces? Did you learn something new along the way? Pick up a new skill?) 

**Creating the VSCode Extensison**

The first step in the journey was to create the extension as I wanted to use it inside the codespace. But there was a problem, I have never created an extension before. 

After some Googling I found this helpful guide on [how to create your first extension](https://code.visualstudio.com/api/get-started/your-first-extension) from VSCode. I wanted this extension to have the following features
1. Work for markdown files
2. It should create some kind of overlay on text selection.

To achieve #1, I needed to mention the `activationEvents` in the `package.json` of the extension.

```json
"activationEvents": [
  "onLanguage:markdown"
],
```

To achieve #2, I found out that I can use the `CodeActionKind` provider. So my activate function takes the below form

```ts
export function activate(context: vscode.ExtensionContext) {
  const writeAssist: WriteAssistAI = new WriteAssistAI();
  const aiActionProvider = vscode.languages.registerCodeActionsProvider(
    'markdown',
    writeAssist,
    {
      providedCodeActionKinds: WriteAssistAI.providedCodeActionKinds,
    }
  );

  context.subscriptions.push(aiActionProvider);
  for (const command of writeAssist.commands) {
    context.subscriptions.push(
      vscode.commands.registerCommand(command, () =>
        writeAssist.handleAction(command)
      )
    );
  }
}
```

Whenever any text is selected `provideCodeActions` from `WriteAssistAI` class is called and we provide the available actions (the rewrite options we see in the above screenshots). And when the user selects one of the actions, we call OpenAI API to rewrite the selected text in the user selected tone.

For allowing users to use their OpenAI API Key, I needed to create a setting for it in the `package.json` file.

```json
"contributes": {
  "configuration": {
    "title": "Write Assist AI",
    "properties": {
      "writeAssistAi.openAiApiKey": {
        "type": "string",
        "default": "",
        "description": "Enter you OpenAI API Key here"
      },
      "writeAssistAi.maxTokens": {
        "type": "number",
        "default": 1200,
        "description": "Enter the maximum tokens to use for each OpenAI API call"
      }
    }
  }
},
```

For further information and the complete implementation, you can check the code in the [attached repository](https://github.com/ra-jeev/write-assist-ai).

**Creating the Blog Website**

Nuxt provides a great content module for creating markdown based blogs/websites. I started with the [Content Wind](https://nuxt.studio/themes/content-wind) theme created by the Nuxt team, and then used some of the components from the alpine theme to create the final website.

You can get started by using the below command

```bash
npx nuxi init -t themes/content-wind my-website
```

For more information on configuring the theme to your taste, you can visit [this link](https://content-wind.nuxt.space/)

**Configuring GitHub Codespaces**

Since this is a markdown based website, it is quite handy to use GitHub Codespaces for adding / editing markdown files. And to use the VSCode extension I created (and the other helpful extensions), you'll need to configure the codespace manually. But there is a better way, create a `devcontainer.json` file inside the `.devcontainer` folder in the root of the repo.

This is the configuration I used for the container
```json
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
  "name": "gh-blog",
  // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
  "image": "mcr.microsoft.com/devcontainers/typescript-node:0-18-bullseye",
  "hostRequirements": {
    "cpus": 4
  },

  // Features to add to the dev container. More info: https://containers.dev/features.
  // "features": {},

  // Use 'forwardPorts' to make a list of ports inside the container available locally.
  "forwardPorts": [3000],
  "portsAttributes": {
    "3000": {
      "label": "Application",
      "onAutoForward": "openPreview"
    }
  },

  "waitFor": "onCreateCommand",
  // install the dependencies automatically
  "updateContentCommand": "yarn install",
  "postCreateCommand": "",
  // As soon as the container is attached run the nuxt server in dev mode
  "postAttachCommand": "yarn dev",

  // Add the needed customizations & extensions
  "customizations": {
    "vscode": {
      "extensions": [
        "Vue.volar",
        "Vue.vscode-typescript-vue-plugin",
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "ra-jeev.write-assist-ai",
        "DavidAnson.vscode-markdownlint"
      ]
    }
  }

  // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
  // "remoteUser": "root"
}
```

**Trigger Codespace Start from website**

After configuring the container I created a codespace once from the GitHub website. Now whenever I want to create new posts, I wanted to start this codespace programmatically from the website itself. But not everyone should be able to trigger this, so I needed to add authentication to the website, and create an admin user. 

To achieve the above requirement I used Firebase Auth (and [Rowy](https://www.rowy.io/) for adding the ADMIN claims to my account).

_**How Rowy helps here?**_
Without Rowy, to create an Admin user you'll need to spin up a new Firebase Cloud function ([relevant documentation](https://firebase.google.com/docs/auth/admin/custom-claims#:~:text=Admin%20SDK.-,Set%20and%20validate%20custom%20user%20claims%20via%20the%20Admin%20SDK,-Custom%20claims%20can)) which will set the Admin claims in the IdToken. Since my blog has one owner (me), I simply used the same emailId, which I'm going to use for my blog, to create a project on Rowy (it links to your existing Firebase project). Rowy internally uses Firebase Authentication, and sets the needed claims automatically for that email id.

These are the claims returned by firebase auth (calling `user.getIdTokenResult()`) after using Rowy

![IdToken claims and roles](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4i3fsd4yg1nkqjedwff7.png)

As you can see, the IdToken has two roles ["OWNER", "ADMIN"] assigned to it inside the `claims` attribute. Just using Rowy for creating an account and a project in their dashboard was enough to give me this. If we need to add more admins, or create new roles (say Editors), then we can do so easily using the Rowy project's workspace.

For admin users, a `New Post` button gets visible in the App Toolbar. On the button click we call a `Firebase Cloud Function` which has a preconfigured GitHub `Fine Grained Access Token` (with `codespaces_lifecycle_admin` permisssion).

Below is the code for triggering start of an existing codespace (the codespace name is also stored as an environment variable of the function) for a user

```js
const functions = require('firebase-functions');
const { Octokit } = require('@octokit/rest');
const admin = require('firebase-admin');
const cors = require('cors');

const { defineSecret, defineString } = require('firebase-functions/params');
const githubToken = defineSecret('github_token');
const codespaceName = defineString('GITHUB_CODESPACE_NAME');

admin.initializeApp();

exports.startCodespaces = functions
  .region('europe-west1')
  .runWith({ secrets: [githubToken] })
  .https.onRequest(async (req, resp) => {
    functions.logger.info('Incoming startCodespaces req!', {
      structuredData: true,
    });

    cors({ origin: true })(req, resp, async () => {
      // Verify that the calling user is an ADMIN
      if (
        req.headers.authorization &&
        req.headers.authorization.startsWith('Bearer ')
      ) {
        const idToken = req.headers.authorization.split('Bearer ')[1];
        const decodedToken = await admin.auth().verifyIdToken(idToken);

        functions.logger.log('decodedToken', decodedToken);

        if (decodedToken.roles && decodedToken.roles.includes('ADMIN')) {
          const octokit = new Octokit({
            auth: githubToken.value(),
          });

          functions.logger.log('codespace name:', codespaceName.value());
          try {
            const codespace =
              await octokit.codespaces.startForAuthenticatedUser({
                codespace_name: codespaceName.value(),
              });

            functions.logger.log('got some codespace response', codespace);

            return resp.status(200).send(JSON.stringify(codespace.data));
          } catch (error) {
            functions.logger.error(error);
            resp
              .status(500)
              .send(error.message || 'Failed to start the workspace');
          }
        }
      }

      resp.status(401).send('Not authorized');
    });
  });
```
This function returns the WEB_URL of the codespace, which we use to open the codespace in a new window.

Before using the Codespace, do not forget to create the needed environment variables by going to your `repository settings -> Secret and variables -> Codespaces`.

**Adding GitHub Action for Auto Deployments**

Now we just need a way to automatically deploy our changes to GitHub Pages whenever a new commit is made. I used the below action file to do this

```yaml
name: Deploy to GitHub Pages

on:
  push:
    branches:
      - main

  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: 'pages'
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Detect package manager
        id: detect-package-manager
        run: |
          if [ -f "${{ github.workspace }}/yarn.lock" ]; then
            echo "manager=yarn" >> $GITHUB_OUTPUT
            echo "command=install" >> $GITHUB_OUTPUT
            exit 0
          elif [ -f "${{ github.workspace }}/package.json" ]; then
            echo "manager=npm" >> $GITHUB_OUTPUT
            echo "command=ci" >> $GITHUB_OUTPUT
            exit 0
          else
            echo "Unable to determine package manager"
            exit 1
          fi

      - name: Setup node env
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: ${{ steps.detect-package-manager.outputs.manager }}

      - name: Setup Pages
        uses: actions/configure-pages@v3

      - name: Restore cache
        uses: actions/cache@v3
        with:
          path: |
            .output/public
            .nuxt
          key: ${{ runner.os }}-nuxt-build-${{ hashFiles('.output/public') }}
          restore-keys: |
            ${{ runner.os }}-nuxt-build-

      - name: Install dependencies
        run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}

      - name: Generate the static files
        run: ${{ steps.detect-package-manager.outputs.manager }} run generate
        env:
          # Setting an environment variable with the value of a configuration variable
          FIREBASE_API_KEY: ${{ vars.FIREBASE_API_KEY }}
          FIREBASE_AUTH_DOMAIN: ${{ vars.FIREBASE_AUTH_DOMAIN }}
          FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
          FIREBASE_STORAGE_BUCKET: ${{ vars.FIREBASE_STORAGE_BUCKET }}
          FIREBASE_MESSAGING_SENDER_ID: ${{ vars.FIREBASE_MESSAGING_SENDER_ID }}
          FIREBASE_APP_ID: ${{ vars.FIREBASE_APP_ID }}
          START_CODESPACE_FN_URL: ${{ vars.START_CODESPACE_FN_URL }}

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v1
        with:
          path: .output/public

  # Deployment job
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}

    runs-on: ubuntu-latest

    needs: build

    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v2
```

The above action has the following differences from the official `Nuxt Deploy to GitHub Pages Action`
1. The official action uses `static_site_generator: nuxt` setting in the `Setup Pages` step above. This doesn't work for `Nuxt3` so we need to remove that.
2. Any occurrence of "dist" folder needs to be replaced with `.output/public` as this is where the statically generated website files are present now
3. Since we have some environment variables, we need to create them in the repo settings, and use them here while building

Note: If you're not hosting it on the <username>.github.io domain then you'll need to configure the app `baseURL` inside your `nuxt.config.ts` file.

```ts
export default defineNuxtConfig({
  extends: 'content-wind',
  app: {
    baseURL: '/gh-blog/', // baseURL: '/<repository_name>/'
  },
})
```

And done. Now our website will be auto built by the Github Action and deployed to GitHub pages.

**GitHub Action for creating drafts on dev.to**
We can also create a new GitHub Action for automatically creating drafts (or directly publish) on DEV. To do that we need to get an API Key for the Dev Community APIs. You can visit [this link](https://developers.forem.com/api/v1#tag/articles) to know more about how to create an API Key, and also how to use the APIs.

This is the action which gets triggered only if there has been a change in the `content/blog` folder (that is where we keep our blog posts). We also restrict the change to markdown files only.

```yaml
name: Create drafts on dev.to

on:
  push:
    branches:
      - main
    paths:
      - 'content/blog/**.md'

permissions:
  contents: read

jobs:
  new-posts-check:
    runs-on: ubuntu-latest

    outputs:
      num_added_files: ${{ steps.get_new_files.outputs.num_added_files }}
      new_files: ${{ steps.get_new_files.outputs.new_files }}

    steps:
      - name: Get Files Added
        id: get_new_files
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh api \
            -H "Accept: application/vnd.github+json" \
            -H "X-GitHub-Api-Version: 2022-11-28" \
            /repos/${{ github.repository }}/compare/${{ github.event.before }}...${{ github.event.after }} > /tmp/diff_data.json

          new_files=$(jq -c '.files | map(select(.status == "added" and (.filename | startswith("content/blog"))) | {filename})' /tmp/diff_data.json)
          echo "new files = $new_files"

          num_added_files=$(echo "$new_files" | jq length)
          echo "num_added_files = $num_added_files"

          echo "num_added_files=$num_added_files" >> "$GITHUB_OUTPUT"
          echo "new_files=$new_files" >> "$GITHUB_OUTPUT"

  parse-and-create-drafts:
    needs: new-posts-check
    runs-on: ubuntu-latest

    if: needs.new-posts-check.outputs.num_added_files > 0

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup node env
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: npm

      - name: Install dependencies
        run: npm install axios gray-matter

      - name: Parse markdown files and create drafts
        env:
          DEV_API_KEY: ${{ secrets.DEV_API_KEY }}
          GH_PAGES_WEB_URL: 'https://${{github.repository_owner}}.github.io/${{github.event.repository.name}}'
        run: |
          node ./scripts/parseAndCreateDrafts.js '${{ needs.new-posts-check.outputs.new_files }}'

```

If triggered, the action checks if any new files were added to the said folder (we're not handling edits or deletes for now). We do this using the `gh api`. If the answer is in the affirmative, then we parse those new files and create drafts using the `/api/articles` endpoint. Please remember to add your `DEV_API_KEY` secret in the repo action secrets.

And this is the script file which uses `gray-matter` to parse the markdown files, and then posts them to DEV using `axios`.

```js
const matter = require('gray-matter');
const { readFileSync } = require('fs');
const axios = require('axios');

// Function to create a draft on dev.to
async function createDraftPostOnDev(article) {
  try {
    const res = await axios.post(
      'https://dev.to/api/articles',
      { article },
      {
        headers: {
          'api-key': process.env.DEV_API_KEY,
          accept: 'application/vnd.forem.api-v1+json',
        },
      }
    );

    console.log(`Article posted successfully:  ${res.data.url}`);

    return res.data;
  } catch (error) {
    console.error('Failed to post the article:', error);
  }
}

// Function to parse Markdown file using gray-matter
function parseMarkdownFile(filename) {
  try {
    const fileContent = readFileSync(filename, 'utf8');
    const { data, content } = matter(fileContent);

    const post = {
      title: data.title,
      description: data.description,
      body_markdown: content,
      canonical_url:
        process.env.GH_PAGES_WEB_URL +
        filename.split('content')[1].replace('.md', ''),
    };

    if (data.image?.src) {
      post.main_image = process.env.GH_PAGES_WEB_URL + data.image.src;
    }

    console.log('final post', post);
    return post;
  } catch (error) {
    console.error('Error:', error);
  }
}

const main = async () => {
  const args = process.argv.slice(2);

  const filenames = JSON.parse(args[0]);

  const res = [];
  for (const file of filenames) {
    const post = parseMarkdownFile(file.filename);
    if (post) {
      const result = await createDraftPostOnDev(post);
      if (result) {
        res.push(result);
      }

      // Wait for 5 seconds before posting another article
      // This is to avoid getting 429 Too Many Requests error
      // from the dev.to API
      await new Promise((r) => setTimeout(r, 5000));
    }
  }

  console.log('res:', res);
};

main();
```

### Additional Resources/Info
Some of the resources which helped me:
1. [Creating your first VSCode Extension](https://code.visualstudio.com/api/get-started/your-first-extension)
2. [VSCode CodeActions Sample](https://github.com/microsoft/vscode-extension-samples/tree/main/code-actions-sample)
3. [Content Wind Nuxt theme](https://content-wind.nuxt.space/)
4. [Configuring your dev container](https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/setting-up-your-nodejs-project-for-codespaces)

**Further Enhancements**
1. Currently there is no way to subscribe to the blog and get notified on new posts. I'm planning to use a GitHub Action and Rowy (with its SendGrid integration) to add this functionality
2. When a post is edited, automatically update the post on `The Dev Platform`.
3. Implement a way to add comments to articles.
4. Further enhance the WriteAssistAI and add more refactoring options

### Conclusion
GitHub Codespaces provide a very handy and easy way to get stated from any machine without configuring it for development first. Overall it was a great experience to use Codespaces and GitHub Actions to automate a part of the workflow. I thoroughly enjoyed creating the two projects. 

I hope you liked reading the article. Do share your thoughts in the comments section. :-)