DEV Community

Cover image for AI Assisted Blog with Nuxt, GitHub Codespaces & Actions
Rajeev R. Sharma
Rajeev R. Sharma

Posted on • Updated on

AI Assisted Blog with Nuxt, GitHub Codespaces & Actions

Photo by Rubaitul Azad on Unsplash

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/

Write Assist AI

Screenshots

The Index Page
The index page

The Blog List Page
The blog list page

The Blog Article Page
The blog article page

The VSCode Extension Screenshots

WriteAssistAI Prompt 1

WriteAssistAI API Calling

WriteAssistAI result

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

My Website

My website made by Content Wind theme.

Setup

npm install
Enter fullscreen mode Exit fullscreen mode

Development

npm run dev
Enter fullscreen mode Exit fullscreen mode

Then open http://localhost:3000 to see your app.

Deployment

Learn more how to deploy on Nuxt docs.





Write Assist AI

WriteAssistAI is a VSCode extension that leverages OpenAI APIs to provide users with AI-assisted writing capabilities for their markdown / plain text files. This extension enables users to rephrase, summarize, or expand existing texts. It can also suggest short headlines for the selected text.

Features

This AI text assistant offers a variety of writing styles to choose from. To access these styles, or other features, select the desired text in your markdown/text files, click on the Code Actions bulb tooltip, and then click on the desired action.

Extension Demo

Below is the available feature list:

  • Rewrite text in various tones. Available tones: professional, casual, formal, friendly, informative, authoritative
  • Rephrase selected text
  • Suggest headlines for selected text
  • Summarize selected text
  • Expand selected text (make it verbose)
  • Shorten selected text (make it concise)

Requirements

To use the extension you need to provide your own OpenAI API Key in the VSCode settings.

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

"activationEvents": [
  "onLanguage:markdown"
],
Enter fullscreen mode Exit fullscreen mode

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

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)
      )
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

"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"
      }
    }
  }
},
Enter fullscreen mode Exit fullscreen mode

For further information and the complete implementation, you can check the code in the attached repository.

Creating the Blog Website

Nuxt provides a great content module for creating markdown based blogs/websites. I started with the 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

npx nuxi init -t themes/content-wind my-website
Enter fullscreen mode Exit fullscreen mode

For more information on configuring the theme to your taste, you can visit this link

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

// 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"
}
Enter fullscreen mode Exit fullscreen mode

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 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) 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

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

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');
    });
  });
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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 .github.io domain then you'll need to configure the app baseURL inside your nuxt.config.ts file.

export default defineNuxtConfig({
  extends: 'content-wind',
  app: {
    baseURL: '/gh-blog/', // baseURL: '/<repository_name>/'
  },
})
Enter fullscreen mode Exit fullscreen mode

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

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 }}'

Enter fullscreen mode Exit fullscreen mode

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.

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();
Enter fullscreen mode Exit fullscreen mode

Additional Resources/Info

Some of the resources which helped me:

  1. Creating your first VSCode Extension
  2. VSCode CodeActions Sample
  3. Content Wind Nuxt theme
  4. Configuring your dev container

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. :-)

Top comments (0)