Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,7 @@ yarn-error.log
# Yarn Integrity file
.yarn-integrity

# Translation cache
.translation-cache.json

public/
75 changes: 67 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,71 @@
## Tech Blog Repo [🌵@](https://seholee.com)
# Seho Lee's Blog

#### Launch Project Locally
A Gatsby-based blog with automatic translation functionality.

```
$ npm install
$ npm run start # or, npx gatsby develop
```
## Features

#### Stack
- **Automatic Translation**: Blog posts are automatically translated between Korean and English using OpenAI's GPT
- **Language Toggle**: Users can switch between original and translated content with a toggle in the menubar
- **Smart Caching**: Translations are cached to avoid unnecessary API calls and costs
- **Content Detection**: Automatically detects the original language and translates to the opposite language
- **Rate Limiting**: Built-in rate limiting and retry logic to handle API limits gracefully

React + Gatsby + TS
## Setup

1. Install dependencies:

```bash
npm install
```

2. Set up environment variables:

```bash
# Create a .env file with your OpenAI API key
OPENAI_API_KEY=your_openai_api_key_here

# Optional: Enable translation in development mode
ENABLE_TRANSLATION=true
```

3. Development (translations disabled by default):

```bash
npm run dev
```

4. Build with translations:
```bash
npm run build
```

## Environment Variables

- `OPENAI_API_KEY`: Required for translation functionality
- `ENABLE_TRANSLATION`: Set to `true` to enable translations in development mode (default: disabled in dev)

## Translation Behavior

- **Production builds**: Translations are enabled by default if `OPENAI_API_KEY` is set
- **Development mode**: Translations are disabled by default to avoid API costs and rate limits
- **Rate limiting**: 1 second minimum between API requests with exponential backoff for 429 errors
- **Retry logic**: Automatic retries with exponential backoff for network errors and rate limits
- **Graceful failures**: If translation fails, the original content is used

During the build process, the translation service will:

- Detect the language of each markdown file
- Translate Korean content to English and English content to Korean
- Cache translations for future builds
- Create translated versions of all content
- Handle rate limits and network errors automatically

## Usage

- Use the language toggle in the menubar to switch between original and translated content
- The toggle shows "원본" (Original) and "번역" (Translated)
- Language preference is saved in localStorage

## Translation Cache

The translation cache (`.translation-cache.json`) stores translated content to avoid redundant API calls. This file is automatically managed and should not be committed to version control.
3 changes: 3 additions & 0 deletions gatsby-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import dotenv from "dotenv";
import type { GatsbyConfig } from "gatsby";

dotenv.config();

const config: GatsbyConfig = {
pathPrefix: "",
plugins: [
Expand Down
176 changes: 176 additions & 0 deletions gatsby-node.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import dotenv from "dotenv";
import { GitHubCommit } from "./src/types/types";
import { TranslationService } from "./src/utils/translation";

dotenv.config();

// Translation rate limiting configuration
const TRANSLATION_DELAY_SECONDS = 10;

// Store nodes that need translation
let nodesToTranslate: Array<{
node: any;
actions: any;
createNodeId: any;
createContentDigest: any;
}> = [];

exports.sourceNodes = async ({
actions,
createNodeId,
createContentDigest,
}: {
actions: any;
createNodeId: any;
createContentDigest: any;
}) => {
const { createNode } = actions;

Expand All @@ -28,3 +47,160 @@ exports.sourceNodes = async ({
const node = { ...commit, ...nodeMeta };
createNode(node);
};

exports.onCreateNode = async ({
node,
actions,
createNodeId,
createContentDigest,
}: {
node: any;
actions: any;
createNodeId: any;
createContentDigest: any;
}) => {
const { createNode } = actions;

// Only process MarkdownRemark nodes
if (node.internal.type === "MarkdownRemark") {
// Add field to original node to mark it as original
node.fields = {
...node.fields,
isTranslated: false,
};

// Skip translation if API key is not set
if (!process.env.OPENAI_API_KEY) {
console.log(
"⚠️ OPENAI_API_KEY not set, skipping translation for:",
node.frontmatter?.title || "Unknown"
);
return;
}

// Skip translation in development unless explicitly enabled
if (
process.env.NODE_ENV === "development" &&
!process.env.ENABLE_TRANSLATION
) {
console.log(
"🚀 Development mode: skipping translation for:",
node.frontmatter?.title || "Unknown"
);
return;
}

// Add this node to the translation queue instead of processing immediately
nodesToTranslate.push({
node,
actions,
createNodeId,
createContentDigest,
});
}
};

// New hook to process all translations sequentially after all nodes are created
exports.onPostBootstrap = async () => {
if (nodesToTranslate.length === 0) {
console.log("📝 No nodes require translation");
return;
}

console.log(
`🌍 Starting sequential translation of ${nodesToTranslate.length} markdown files...`
);
console.log(`⏱️ Using ${TRANSLATION_DELAY_SECONDS}s delay between requests`);

const translationService = new TranslationService();

for (let i = 0; i < nodesToTranslate.length; i++) {
const { node, actions, createNodeId, createContentDigest } =
nodesToTranslate[i];
const { createNode, createNodeField } = actions;

try {
console.log(
`\n📄 [${i + 1}/${nodesToTranslate.length}] Processing: ${
node.frontmatter?.title || "Unknown"
}`
);

// Get the original content
const originalContent = node.internal.content;

// Generate translation
const translatedContent = await translationService.translateContent(
originalContent
);

// Only create translated node if content actually changed
if (translatedContent !== originalContent) {
// Create a new node for the translated version
const translatedNodeId = createNodeId(`${node.id}-translated`);

// Create clean node without Gatsby-managed fields
const translatedNode = {
// Copy basic node properties
children: [],
parent: node.parent,

// Copy frontmatter and other safe properties
frontmatter: node.frontmatter,
excerpt: node.excerpt,
rawMarkdownBody: node.rawMarkdownBody,
fileAbsolutePath: node.fileAbsolutePath,

// Set new ID
id: translatedNodeId,

// Create clean internal object with different type
internal: {
type: "TranslatedMarkdown",
mediaType: "text/markdown",
content: translatedContent,
contentDigest: createContentDigest(translatedContent),
},
};

await createNode(translatedNode);

// Add the isTranslated field using createNodeField
createNodeField({
node: translatedNode,
name: "isTranslated",
value: true,
});

console.log(`✅ Created translated version`);
} else {
console.log(`📝 No translation needed (content unchanged)`);
}

// Wait before next translation (except for the last one)
if (i < nodesToTranslate.length - 1) {
console.log(
`⏱️ Waiting ${TRANSLATION_DELAY_SECONDS}s before next translation...`
);
await new Promise((resolve) =>
setTimeout(resolve, TRANSLATION_DELAY_SECONDS * 1000)
);
}
} catch (error) {
console.warn(
`❌ Failed to create translation for "${
node.frontmatter?.title || "Unknown"
}":`,
(error as Error).message
);
// Don't throw - just skip this translation and continue
}
}

console.log(
`\n🎉 Translation process completed! Processed ${nodesToTranslate.length} files`
);

// Clear the queue
nodesToTranslate = [];
};
Loading