Making a static word of the day site with Google AI Studio and GitHub Actions
This project's aim is to try out Google AI Studio in a quick-and-dirty one-day build. I want to create a site that will teach me a new Japanese word every day. Instead of using a word list and creating a database of definitions, I'm going to have Google Gemini Pro generate a random word for me every day. Then, I'll generate a new static page for each word. I'll use a stored list of words to guard against the AI providing duplicate words.
This project involves using the Google AI Studio API in Node.js, generating basic static HTML pages from templates, and scheduling GitHub Actions.
You can find the GitHub repository here. The finished site is at jwotd.mary.codes.
Table of Contents
- Getting started with Google AI Studio
- Invoking app.js with Node
- Creating an HTML template
- Updating pages
- Updating the AI prompt
- Navigating between pages
- Scheduling with GitHub Actions
- When something goes wrong
- Setting up GitHub pages
Getting started with Google AI Studio
First, I want to get Google AI Studio to provide us with word definitions. Google AI Studio is accessible at https://makersuite.google.com/. Gemini Pro is currently free up to 60 queries per minute. This should work for my use case, because once there's a generated word for the day, I won't need to query Gemini Pro again until the next day.
After tinkering with the inputs for a bit, I've come up with this prompt:
Provide one random elementary-level Japanese word in the following format:
{
"hiragana": word,
"katakana": word,
"kanji": word,
"romaji": romaji,
"pronunciation": pronunciation,
"definition": definition,
"part_of_speech": part of speech of the word,
"sentence": provide an example sentence using the word,
"sentence_romaji" : provide a romaji version of the example sentence,
"sentence_translation": translate the example sentence into English
}
Do not return the following words:
I'll be modifying the JavaScript to list the previous words in the last line of the prompt. This should keep Gemini from providing the same basic words over and over.
Once I've run my prompt a few times and I'm satisfied with its performance, I need to get an API key to integrate it with the app I'm going to build. To get an API key, Google creates a new Google Cloud project. The default name is "Generative Language Client". After navigating away from and back to the API key page (why this didn't just appear after the key was generated, I don't know), there's information on the API key as well as a pre-generated cURL command to test the API.
Now we can click "Get code" in Google AI Studio and get a JavaScript snippet to use in our project.
Now I'm going to get my local environment up and running to start building out the app. I need to install the generative-ai package from npm. I'm also going to install dotenv so I can use an .env file to hide my API key.
mkdir japanese_word_of_the_day
cd japanese_word_of_the_day
npm init
git init
npm install @google/generative-ai dotenv --save
These are all the dependencies I'll need for this project!
Invoking app.js with Node
First, create a .env file and add the API key to it.
Now, I'm going to create a new file called app.js. This file will be run by node and will use the generative-ai package to run the prompt. Eventually, I'll use this file to generate new HTML files from the responses, but for now, we'll set it up to simply run on the command line and return a new word.
Most of this will be copied and pasted from Google AI Studio, but there are a few changes to make at the top of the file before the run()
function. I've added a shebang to the start of the file to tell it to run in node. I've also required dotenv, and used the environment variable to set the API key.
#!/usr/bin/env node
require('dotenv').config({path: '.env'});
const {
GoogleGenerativeAI,
HarmCategory,
HarmBlockThreshold,
} = require("@google/generative-ai");
const MODEL_NAME = "gemini-pro";
const API_KEY = process.env.API_KEY;
...
Now, go back to your terminal window. Make the app.js file executable, and run it.
chmod u+x app.js
./app.js
There will be a brief pause as the API works to return a response to our prompt, but there should be a new word object printed to the terminal shortly.
Creating an HTML template
I'm going to create a file called template.html. I'll use this template to generate new HTML files for each word. I'm adding placeholders where I'll slot in each part of the response from the AI. I'm also adding a {{css}} placeholder where I'll add the CSS for the page. I figure, since I'm already using a template, I might as well just add the CSS to the head of the page with the template instead of linking out to another file.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>Japanese Word of the Day</title>
<style>
{{css}}
</style>
</head>
<body>
<h1>Japanese Word of the Day</h1>
<main>
<p id="kanji">{{kanji}}</p>
<p id="hiragana">{{hiragana}}</p>
<p id="katakana">{{katakana}}</p>
<p id="romaji">{{romaji}}</p>
<p id="pronunciation">{{pronunciation}}</p>
<p id="definition">{{definition}}</p>
<p id="part_of_speech">{{part_of_speech}}</p>
<div id="example">
<p id="sentence">{{sentence}}</p>
<p id="sentence_romaji">{{sentence_romaji}}</p>
<p id="sentence_translation">{{sentence_translation}}</p>
</div>
</main>
</body>
</html>
I want to use template.html to create a new index.html file. The end of the run()
function will need to be modified slightly to return a value instead of logging it out to the console, and it will need to be called from inside our new function. Also, don't forget to require 'fs' to use the Node filesystem.
async function run() {
...
const response = result.response;
return response.text();
}
function createIndexPage() {
let template = fs.readFileSync('./template.html', 'utf8');
let styles = fs.readFileSync(`./style.css`, 'utf8');
run().then((data) => {
const word = JSON.parse(data);
// Replace all the template variables with the word data.
for (let key of Object.keys(word)) {
template = template.replace(`{{${key}}}`, word[key]);
}
// Replace page styles.
template = template.replace('{{css}}', styles);
// Write the index.html file.
fs.writeFileSync(`./index.html`, template);
});
}
createIndexPage();
The function createIndexPage()
will read the template.html file, replace the placeholders with the values from the word object, and write the new index.html file to the root directory. Now, running ./app.js
will create a new index.html file. Refresh index.html in your browser (you can use the drag and drop method from the file explorer at this point) to see the new word. There's no need to run a server, it's all static files!
Updating pages
My ultimate goal will be to run a cron job with Github Actions that will run the app.js file every day and commit the new index.html file to the repository. I want the current index.js file to be copied into the pages directory and renamed with its word. This will allow me to review previous words.
First, we'll write to a file that will hold a list of the previous words. Add this to the end of the createIndexPage()
function.
fs.appendFileSync(`./past_words.txt`, `${word['kanji']}\n`);
This will append the kanji of the new word to the past_words.txt file.
Create a new directory called "pages". This is where the generated HTML files will be stored.
Next, I'm going to add a function that will check the past_words.txt file for any previous words. If there's a previous word, it will call another function that will copy the index.html file to the pages directory and rename it with the previous word. If there isn't a previous word (or the file doesn't exist), only index.html will be created.
// Rename index.html to the last word and move it to the pages folder.
function moveIndexToPages(last) {
fs.copyFile(`./index.html`, `./pages/${last}.html`, (err) => {
if (err) throw err;
});
}
function start() {
fs.readFile(`./past_words.txt`, 'utf8', (err, data) => {
if (err) {
// Create initial index page if the file doesn't exist.
createIndexPage();
}
if (data) {
// Get the last word from past_words.txt.
let past_words = data.trim().split('\n');
let last = past_words.pop();
if (last) {
// Update index.html and rename it to the word.html.
moveIndexToPages(last);
}
// Create new index page.
createIndexPage();
}
});
}
start();
Running app.js should now copy index.html to the pages directory and rename it and create a new index.html file. You can test both of these by dragging and dropping them in the browser.
Updating the AI prompt
Since we now have the past_words.txt file, we can update the prompt to tell it not to use any words that we've previously seen. This is updated in the run()
function provided by Google AI Studio.
const parts = [
{text: "Provide one random elementary-level Japanese word in the following format:\n{\n\"hiragana\": word,\n\"katakana\": word,\n\"kanji\": word,\n\"romaji\": romaji,\n\"pronunciation\": pronunciation,\n\"definition\": definition,\n\"part_of_speech\": part of speech of the word,\n\"sentence\": provide an example sentence using the word,\n\"sentence_romaji\" : provide a romaji version of the example sentence,\n\"sentence_translation\": translate the example sentence into English\n}"},
];
try {
let past_words = fs.readFileSync(`./past_words.txt`, 'utf8');
if (past_words) {
past_words = past_words.trim().replaceAll('\n', ',');
parts.push({text: "Do not return the following words: "+past_words});
}
} catch (err) {}
Navigating between pages
The problem now is navigating between pages. I'm going to add two arrow buttons to the template and style them with CSS so they're to the left and right of the word area.
<a id="back" {{back_href}}><</a>
<main>
<p id="kanji">{{kanji}}</p>
<p id="hiragana">{{hiragana}}</p>
<p id="katakana">{{katakana}}</p>
<p id="romaji">{{romaji}}</p>
<p id="pronunciation">{{pronunciation}}</p>
<p id="definition">{{definition}}</p>
<p id="part_of_speech">{{part_of_speech}}</p>
<div id="example">
<p id="sentence">{{sentence}}</p>
<p id="sentence_romaji">{{sentence_romaji}}</p>
<p id="sentence_translation">{{sentence_translation}}</p>
</div>
</main>
<a id="forward" {{forward_href}}>></a>
You'll notice that there's no href in these links, but there are template placeholders. While the href is missing, those elements will remain hidden. We'll perform a string replacement to replace those empty links with hrefs.
a:not([href]) {
visibility: hidden;
}
First, we'll update the createIndexPage()
function to add the previous word to the back button's href. We'll check to see if a file exists for the previous word with existsSync()
, and if it does, we'll replace the back button's placeholder with the link to the previous page.
// Create index page, add data to template, save index.html.
function createIndexPage(last) {
...
// Replace page styles.
template = template.replace('{{css}}', styles);
// If there is a previous page, link the back button to it.
if (fs.existsSync(`./pages/${last}.html`)) {
template = template.replace('{{back_href}}', `href="/pages/${last}.html"`);
}
...
}
Now, I need to come up with a way to replace the forward button to the page that comes after it chronologically. Index.html doesn't have a page after it, so we'll leave the placeholder in place on index.html. The page formerly known as index.html will have the forward href placeholder replaced with a link to index.html. And the page before that, which formerly pointed to index.html, will now point to the now-renamed prior index.html.
It's quite confusing, and I'm not sure if the code makes it much clearer.
// Update the forward button to point to the next page.
function linkForwardButton(page, nextPage) {
let template = fs.readFileSync(page, 'utf8');
if (nextPage) {
// Point page to the former index page's new name.
template = template.replace(`href="/index.html"`, `href="${nextPage}"`);
} else {
// Point former index page to index.html
template = template.replace('{{forward_href}}', `href="/index.html"`);
}
fs.writeFileSync(page, template);
}
This code reads the file as a template then inserts the new links, either into the placeholder if it's the index page that will be renamed, or into the space previously occupied by a link to index.html.
This function is inserted into our start()
function.
function start() {
fs.readFile(`./past_words.txt`, 'utf8', (err, data) => {
if (err) {
// Create initial index page if the file doesn't exist.
createIndexPage(null);
}
if (data) {
// Get the last two words from past_words.txt.
let past_words = data.trim().split('\n');
let last = past_words.pop();
if (last) {
// Update index.html to point to index.html and rename it to the word.html.
linkForwardButton(`./index.html`);
moveIndexToPages(last);
}
let second = past_words.pop();
if (second) {
// Update the most recent word file to point at the renamed file that was the index.
linkForwardButton(`./pages/${second}.html`, `/pages/${last}.html`);
}
// Create new index page.
createIndexPage(last);
}
});
}
You can see that I added the linkForwardButton()
function before renaming and moving the index page. I also added a check for the second word on the list, which will update that second-most-recent page's previous button's link.
If you run ./app.js
a few times to generate some pages, you might notice that the back and next links don't work on the filesystem. This is because I'm using relative file names. At this point, you'll need to use something like http-server to spin up a little local file server. Now the links should work.
That was a bit tedious, and I'm sure there are static page generators that can do the same thing with less work, but why add another dependency when you can DIY it?
Scheduling with GitHub Actions
Ok, we're getting to the finish line here. I'm pushing all of my code to a GitHub repository, including a "pages" folder with an empty file in it called "empty.txt". This is important, because I'll need that pages folder for subsequent runs of the app.js file, but also, git won't save an empty directory.
My file tree basically looks like this:
japanese_word_of_the_day
├── pages
│ └── empty.txt
├── app.js
├── template.html
├── style.css
├── github-mark.svg
├── package.json
├── package-lock.json
... plus a few README and license files.
Now, I'll set up an action on GitHub. Navigate to the "Actions" tab and click "New workflow". I then clicked "set up a workflow yourself". After a little trial and error, I came up with this workflow that will run app.js and commit the changes back to the repo.
name: "Get new Japanese word definition and generate static page"
on:
schedule:
- cron: 0 1 * * *
workflow_dispatch:
jobs:
run-app:
name: "Run app.js"
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: "20"
cache: "npm"
- run: npm install
- run: ./app.js
env:
API_KEY: ${{ secrets.API_KEY }}
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: Created new static page
You'll notice that I have a cron job set for 1 AM daily, and I also added the workflow_dispatch event that will allow me to run the workflow manually.
I also have to set up my action's secrets in the settings tab of the repository. This part is pretty straightforward.
Now, I can run the workflow manually and make sure that it works. After a handful of misfires (which I fixed in .github/workflows/action.yml), I got it to work. Now all I have to do is see if the workflow runs automatically early tomorrow.
When something goes wrong
While testing I noticed that sometimes the Gemini Pro API would go a little rogue. In some cases, it doesn't format my request quite the way I asked it to. In that case, the GitHub action will error out, and I'll have to run it manually. This actually happened during one of my manual test runs.
It didn't format the pronunciation correctly, so JSON.parse() errored out. I didn't put any error handling in to retry if this happens (this is a one-day build, after all!). If I was trying to make something more robust, I would handle any times that the AI gives me bad data. If this becomes a persistent problem, I'll go back in and add some error handling and retries.
I'm not terribly worried about it. If I notice a bad run of the GitHub action, I'll just click the button to run it again.
Setting up GitHub pages
The very last thing I have to do is set up GitHub Pages to host my static site. This is pretty easy and I've gone over it in the past. On the settings tab, click Pages. I'll leave the source as "Deploy from a branch" and I'm deploying from the main branch. This will set up another GitHub action that will run after the custom app.js action to deploy the updated pages.
The final step is to set up my custom subdomain. I've pointed jwotd.mary.codes to my GitHub pages domain in my domain registrar by adding a CNAME record. It's important that I use this custom domain because of how I have my page routing set up. Because I'm using relative file paths, I need to be able to access the pages directory from the root of the domain, and I can't do that with GitHub pages.
There it is! Not too bad for a handful of files and an AI! I did end up changing the colors for accessibility reasons. As an added bonus, the site uses zero JavaScript in the browser and is incredibly fast. The site gets 100s across the board in Lighthouse. I'm pretty happy with the result.