diff --git a/.gitignore b/.gitignore
index db1aa13e..2983b40b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,5 +67,7 @@ yarn-error.log
# Yarn Integrity file
.yarn-integrity
+# Translation cache
+.translation-cache.json
public/
\ No newline at end of file
diff --git a/README.md b/README.md
index ea117224..01e511f8 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/gatsby-config.ts b/gatsby-config.ts
index ba3a688e..73f11dc7 100644
--- a/gatsby-config.ts
+++ b/gatsby-config.ts
@@ -1,5 +1,8 @@
+import dotenv from "dotenv";
import type { GatsbyConfig } from "gatsby";
+dotenv.config();
+
const config: GatsbyConfig = {
pathPrefix: "",
plugins: [
diff --git a/gatsby-node.ts b/gatsby-node.ts
index c8adc4ba..eed4ab22 100644
--- a/gatsby-node.ts
+++ b/gatsby-node.ts
@@ -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;
@@ -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 = [];
+};
diff --git a/package-lock.json b/package-lock.json
index ea6d27cf..10d333da 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@deckdeckgo/highlight-code": "^3.6.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+ "dotenv": "^16.0.0",
"gatsby": "^5.12.4",
"gatsby-plugin-react-helmet": "^6.12.0",
"gatsby-plugin-sharp": "^5.12.3",
@@ -22,6 +23,7 @@
"gatsby-source-filesystem": "^5.12.1",
"gatsby-transformer-remark": "^6.13.1",
"katex": "^0.13.24",
+ "openai": "^4.0.0",
"prismjs": "^1.29.0",
"re-resizable": "^6.9.11",
"react": "^18.2.0",
@@ -4933,6 +4935,17 @@
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
},
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
"node_modules/abortcontroller-polyfill": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz",
@@ -5004,6 +5017,17 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/agentkeepalive": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -7577,11 +7601,14 @@
}
},
"node_modules/dotenv": {
- "version": "8.6.0",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz",
- "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==",
+ "version": "16.5.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
+ "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"engines": {
- "node": ">=10"
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
@@ -7692,6 +7719,26 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
+ "node_modules/engine.io-client/node_modules/ws": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
+ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/engine.io-parser": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz",
@@ -7721,6 +7768,26 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
+ "node_modules/engine.io/node_modules/ws": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
+ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz",
@@ -8530,6 +8597,14 @@
"resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz",
"integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA=="
},
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -9130,6 +9205,18 @@
"node": ">= 14.17"
}
},
+ "node_modules/formdata-node": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+ "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+ "dependencies": {
+ "node-domexception": "1.0.0",
+ "web-streams-polyfill": "4.0.0-beta.3"
+ },
+ "engines": {
+ "node": ">= 12.20"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -10179,6 +10266,14 @@
}
}
},
+ "node_modules/gatsby/node_modules/dotenv": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz",
+ "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/gatsby/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
@@ -10946,6 +11041,14 @@
"node": ">=10.17.0"
}
},
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -12996,6 +13099,25 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz",
"integrity": "sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA=="
},
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
"node_modules/node-fetch": {
"version": "2.6.11",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz",
@@ -13299,6 +13421,48 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/openai": {
+ "version": "4.104.0",
+ "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
+ "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
+ "dependencies": {
+ "@types/node": "^18.11.18",
+ "@types/node-fetch": "^2.6.4",
+ "abort-controller": "^3.0.0",
+ "agentkeepalive": "^4.2.1",
+ "form-data-encoder": "1.7.2",
+ "formdata-node": "^4.3.2",
+ "node-fetch": "^2.6.7"
+ },
+ "bin": {
+ "openai": "bin/cli"
+ },
+ "peerDependencies": {
+ "ws": "^8.18.0",
+ "zod": "^3.23.8"
+ },
+ "peerDependenciesMeta": {
+ "ws": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openai/node_modules/@types/node": {
+ "version": "18.19.110",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.110.tgz",
+ "integrity": "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q==",
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/openai/node_modules/form-data-encoder": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+ "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
+ },
"node_modules/opentracing": {
"version": "0.14.7",
"resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz",
@@ -16211,6 +16375,26 @@
"ws": "~8.11.0"
}
},
+ "node_modules/socket.io-adapter/node_modules/ws": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
+ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/socket.io-client": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.1.tgz",
@@ -17231,6 +17415,11 @@
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
},
+ "node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
+ },
"node_modules/unherit": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz",
@@ -17672,6 +17861,14 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/web-streams-polyfill": {
+ "version": "4.0.0-beta.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+ "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -17926,15 +18123,17 @@
}
},
"node_modules/ws": {
- "version": "8.11.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
- "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
- "utf-8-validate": "^5.0.2"
+ "utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
@@ -21651,6 +21850,14 @@
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
},
+ "abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "requires": {
+ "event-target-shim": "^5.0.0"
+ }
+ },
"abortcontroller-polyfill": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz",
@@ -21700,6 +21907,14 @@
"resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz",
"integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA=="
},
+ "agentkeepalive": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "requires": {
+ "humanize-ms": "^1.2.1"
+ }
+ },
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -23590,9 +23805,9 @@
}
},
"dotenv": {
- "version": "8.6.0",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz",
- "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="
+ "version": "16.5.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
+ "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="
},
"dotenv-expand": {
"version": "5.1.0",
@@ -23672,6 +23887,12 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ },
+ "ws": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
+ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
+ "requires": {}
}
}
},
@@ -23699,6 +23920,12 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ },
+ "ws": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
+ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
+ "requires": {}
}
}
},
@@ -24299,6 +24526,11 @@
"resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz",
"integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA=="
},
+ "event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
+ },
"events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -24753,6 +24985,15 @@
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.3.tgz",
"integrity": "sha512-KqU0nnPMgIJcCOFTNJFEA8epcseEaoox4XZffTgy8jlI6pL/5EFyR54NRG7CnCJN0biY7q52DO3MH6/sJ/TKlQ=="
},
+ "formdata-node": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+ "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+ "requires": {
+ "node-domexception": "1.0.0",
+ "web-streams-polyfill": "4.0.0-beta.3"
+ }
+ },
"forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -25133,6 +25374,11 @@
"ms": "2.1.2"
}
},
+ "dotenv": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz",
+ "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="
+ },
"eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
@@ -26078,6 +26324,14 @@
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="
},
+ "humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "requires": {
+ "ms": "^2.0.0"
+ }
+ },
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -27560,6 +27814,11 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz",
"integrity": "sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA=="
},
+ "node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="
+ },
"node-fetch": {
"version": "2.6.11",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz",
@@ -27759,6 +28018,35 @@
"is-wsl": "^2.1.1"
}
},
+ "openai": {
+ "version": "4.104.0",
+ "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
+ "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
+ "requires": {
+ "@types/node": "^18.11.18",
+ "@types/node-fetch": "^2.6.4",
+ "abort-controller": "^3.0.0",
+ "agentkeepalive": "^4.2.1",
+ "form-data-encoder": "1.7.2",
+ "formdata-node": "^4.3.2",
+ "node-fetch": "^2.6.7"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "18.19.110",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.110.tgz",
+ "integrity": "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q==",
+ "requires": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "form-data-encoder": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+ "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
+ }
+ }
+ },
"opentracing": {
"version": "0.14.7",
"resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz",
@@ -29840,6 +30128,14 @@
"integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==",
"requires": {
"ws": "~8.11.0"
+ },
+ "dependencies": {
+ "ws": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
+ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
+ "requires": {}
+ }
}
},
"socket.io-client": {
@@ -30599,6 +30895,11 @@
}
}
},
+ "undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
+ },
"unherit": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz",
@@ -30893,6 +31194,11 @@
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz",
"integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw=="
},
+ "web-streams-polyfill": {
+ "version": "4.0.0-beta.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+ "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="
+ },
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -31087,9 +31393,11 @@
}
},
"ws": {
- "version": "8.11.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
- "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
+ "optional": true,
+ "peer": true,
"requires": {}
},
"xdg-basedir": {
diff --git a/package.json b/package.json
index 9793b77d..882a14d3 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@deckdeckgo/highlight-code": "^3.6.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+ "dotenv": "^16.0.0",
"gatsby": "^5.12.4",
"gatsby-plugin-react-helmet": "^6.12.0",
"gatsby-plugin-sharp": "^5.12.3",
@@ -31,6 +32,7 @@
"gatsby-source-filesystem": "^5.12.1",
"gatsby-transformer-remark": "^6.13.1",
"katex": "^0.13.24",
+ "openai": "^4.0.0",
"prismjs": "^1.29.0",
"re-resizable": "^6.9.11",
"react": "^18.2.0",
diff --git a/src/components/_layouts/Menubar.tsx b/src/components/_layouts/Menubar.tsx
index f3f4d044..1d75ea5d 100644
--- a/src/components/_layouts/Menubar.tsx
+++ b/src/components/_layouts/Menubar.tsx
@@ -1,5 +1,6 @@
import styled from "@emotion/styled";
import * as React from "react";
+import { useLanguage } from "../../contexts/LanguageContext";
export default function Menubar({
setShowExplorer,
@@ -10,6 +11,8 @@ export default function Menubar({
setExplorerType: Function;
explorerType: "file" | "search";
}) {
+ const { currentLanguage, toggleLanguage } = useLanguage();
+
const folderClickHandler = () => {
if (explorerType === "file") {
setShowExplorer((v: boolean) => !v);
@@ -41,9 +44,28 @@ export default function Menubar({
+
+ {/* Language Toggle */}
+
+
+
+
+
+ μλ³Έ
+
+
+ λ²μ
+
+
+
+
+
@@ -131,3 +153,82 @@ const LinkedIn = styled.a`
text-decoration: none;
font-weight: 600;
`;
+
+const LanguageToggleWrapper = styled.div`
+ margin-top: 16px;
+ padding: 8px 0;
+
+ @media (max-width: 1050px) {
+ margin: 0;
+ margin-right: 16px;
+ }
+`;
+
+const LanguageToggle = styled.div<{ isTranslated: boolean }>`
+ position: relative;
+ width: 40px;
+ height: 60px;
+ background-color: #3a3a3a;
+ border-radius: 20px;
+ cursor: pointer;
+ transition: background-color 0.3s ease;
+ border: 1px solid #555;
+
+ &:hover {
+ background-color: #444;
+ }
+
+ @media (max-width: 1050px) {
+ width: 50px;
+ height: 24px;
+ border-radius: 12px;
+ }
+`;
+
+const ToggleSlider = styled.div<{ isTranslated: boolean }>`
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ background-color: #61dafb;
+ border-radius: 50%;
+ transition: transform 0.3s ease;
+ transform: translateY(${(props) => (props.isTranslated ? "40px" : "6px")});
+ left: 50%;
+ margin-left: -8px;
+
+ @media (max-width: 1050px) {
+ width: 20px;
+ height: 20px;
+ margin-left: -10px;
+ transform: translateX(${(props) => (props.isTranslated ? "22px" : "2px")})
+ translateY(2px);
+ }
+`;
+
+const ToggleLabels = styled.div`
+ position: absolute;
+ left: -20px;
+ right: -20px;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-around;
+ align-items: center;
+ font-size: 8px;
+ pointer-events: none;
+
+ @media (max-width: 1050px) {
+ flex-direction: row;
+ left: -25px;
+ right: -25px;
+ top: -20px;
+ height: auto;
+ }
+`;
+
+const ToggleLabel = styled.span<{ active: boolean }>`
+ color: ${(props) => (props.active ? "#61dafb" : "#888")};
+ font-weight: ${(props) => (props.active ? "600" : "400")};
+ transition: color 0.3s ease;
+ user-select: none;
+`;
diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx
new file mode 100644
index 00000000..04204b74
--- /dev/null
+++ b/src/contexts/LanguageContext.tsx
@@ -0,0 +1,57 @@
+import React, { createContext, useContext, useState, useEffect } from "react";
+
+export type Language = "original" | "translated";
+
+interface LanguageContextType {
+ currentLanguage: Language;
+ setLanguage: (language: Language) => void;
+ toggleLanguage: () => void;
+}
+
+const LanguageContext = createContext(
+ undefined
+);
+
+export function LanguageProvider({ children }: { children: React.ReactNode }) {
+ const [currentLanguage, setCurrentLanguage] = useState("original");
+
+ // Load language preference from localStorage on mount
+ useEffect(() => {
+ const savedLanguage = localStorage.getItem("blog-language") as Language;
+ if (savedLanguage === "original" || savedLanguage === "translated") {
+ setCurrentLanguage(savedLanguage);
+ }
+ }, []);
+
+ // Save language preference to localStorage when it changes
+ const setLanguage = (language: Language) => {
+ setCurrentLanguage(language);
+ localStorage.setItem("blog-language", language);
+ };
+
+ const toggleLanguage = () => {
+ const newLanguage =
+ currentLanguage === "original" ? "translated" : "original";
+ setLanguage(newLanguage);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useLanguage() {
+ const context = useContext(LanguageContext);
+ if (context === undefined) {
+ throw new Error("useLanguage must be used within a LanguageProvider");
+ }
+ return context;
+}
diff --git a/src/pages/{markdownRemark.frontmatter__slug}.tsx b/src/pages/{markdownRemark.frontmatter__slug}.tsx
index a9b602cd..842634ab 100644
--- a/src/pages/{markdownRemark.frontmatter__slug}.tsx
+++ b/src/pages/{markdownRemark.frontmatter__slug}.tsx
@@ -14,6 +14,7 @@ import Footer from "../components/_layouts/Footer";
import Minimap from "../components/Minimap/Minimap";
import StatusBar from "../components/_layouts/StatusBar";
import { SEO } from "../components/SEO/SEO";
+import { LanguageProvider, useLanguage } from "../contexts/LanguageContext";
// markdown custom css
import "../markdownStyle.css";
@@ -25,17 +26,21 @@ import "katex/dist/katex.min.css";
import { defineCustomElements as deckDeckGoHighlightElement } from "@deckdeckgo/highlight-code/dist/loader";
deckDeckGoHighlightElement();
-export default function BlogPostTemplate({
- data, // this prop will be injected by the GraphQL query below.
-}: {
- data: BlogMarkdownRemark;
-}) {
+function BlogPostContent({ data }: { data: BlogMarkdownRemark }) {
+ const { currentLanguage } = useLanguage();
+
// this is for minimap scroll tracking
const contentRef = useRef(null);
- // markdown data
- const { markdownRemark } = data; // data.markdownRemark holds your post data
- const { frontmatter, html } = markdownRemark;
+ // markdown data - select based on language
+ const { markdownRemark, translatedMarkdown } = data;
+ const translatedNode = translatedMarkdown?.nodes?.[0];
+ const selectedMarkdown =
+ currentLanguage === "translated" && translatedNode
+ ? translatedNode
+ : markdownRemark;
+
+ const { frontmatter, html } = selectedMarkdown;
// explorer toggle logic
const [showExplorer, setShowExplorer] = useState(true);
@@ -86,8 +91,20 @@ export default function BlogPostTemplate({
);
}
+export default function BlogPostTemplate({
+ data,
+}: {
+ data: BlogMarkdownRemark;
+}) {
+ return (
+
+
+
+ );
+}
+
export const pageQuery = graphql`
- query ($id: String!) {
+ query ($id: String!, $slug: String!) {
markdownRemark(id: { eq: $id }) {
html
frontmatter {
@@ -96,6 +113,29 @@ export const pageQuery = graphql`
title
subtitle
}
+ fields {
+ isTranslated
+ }
+ }
+ translatedMarkdown: allMarkdownRemark(
+ filter: {
+ fields: { isTranslated: { eq: true } }
+ frontmatter: { slug: { eq: $slug } }
+ }
+ limit: 1
+ ) {
+ nodes {
+ html
+ frontmatter {
+ date(formatString: "MMMM DD, YYYY")
+ slug
+ title
+ subtitle
+ }
+ fields {
+ isTranslated
+ }
+ }
}
}
`;
diff --git a/src/types/types.d.ts b/src/types/types.d.ts
index 50b60ed3..18911a60 100644
--- a/src/types/types.d.ts
+++ b/src/types/types.d.ts
@@ -4,10 +4,19 @@ export type TabsInfo = {
date: string;
subtitle: string;
};
+
+export type MarkdownNode = {
+ html: string;
+ frontmatter: TabsInfo;
+ fields?: {
+ isTranslated: boolean;
+ };
+};
+
export type BlogMarkdownRemark = {
- markdownRemark: {
- html: string;
- frontmatter: TabsInfo;
+ markdownRemark: MarkdownNode;
+ translatedMarkdown?: {
+ nodes: MarkdownNode[];
};
};
diff --git a/src/utils/translation.ts b/src/utils/translation.ts
new file mode 100644
index 00000000..c9b93400
--- /dev/null
+++ b/src/utils/translation.ts
@@ -0,0 +1,155 @@
+import crypto from "crypto";
+import fs from "fs";
+import path from "path";
+
+interface TranslationCache {
+ [contentHash: string]: {
+ translatedContent: string;
+ timestamp: number;
+ };
+}
+
+const CACHE_FILE = path.join(process.cwd(), ".translation-cache.json");
+const CACHE_EXPIRY = 30 * 24 * 60 * 60 * 1000; // 30 days
+
+export class TranslationService {
+ private cache: TranslationCache = {};
+
+ constructor() {
+ this.loadCache();
+ }
+
+ private loadCache() {
+ if (fs.existsSync(CACHE_FILE)) {
+ try {
+ this.cache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
+ } catch (error) {
+ console.warn("Failed to load translation cache:", error);
+ this.cache = {};
+ }
+ }
+ }
+
+ private saveCache() {
+ try {
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(this.cache, null, 2));
+ } catch (error) {
+ console.error("Failed to save translation cache:", error);
+ }
+ }
+
+ private generateContentHash(content: string): string {
+ return crypto.createHash("md5").update(content).digest("hex");
+ }
+
+ private isKorean(text: string): boolean {
+ // 200μ μ΄μ νκΈμ΄ μμΌλ©΄ νκΈλ‘ νλ¨
+ const koreanRegex = /[γ±-γ
|γ
-γ
£|κ°-ν£]/g;
+ const koreanMatches = text.match(koreanRegex)?.length || 0;
+ return koreanMatches > 200;
+ }
+
+ private async callOpenAI(
+ content: string,
+ targetLanguage: "ko" | "en"
+ ): Promise {
+ const apiKey = process.env.OPENAI_API_KEY;
+ if (!apiKey) {
+ throw new Error("OPENAI_API_KEY environment variable is not set");
+ }
+
+ const prompt =
+ targetLanguage === "ko"
+ ? `Translate the following markdown content from English to Korean. Preserve all markdown formatting, HTML tags, and frontmatter exactly as they are. Only translate the text content: \n\n${content}`
+ : `Translate the following markdown content from Korean to English. Preserve all markdown formatting, HTML tags, and frontmatter exactly as they are. Only translate the text content: \n\n${content}`;
+
+ try {
+ console.log(`π Making translation API call (${targetLanguage})...`);
+
+ const response = await fetch(
+ "https://api.openai.com/v1/chat/completions",
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model: "gpt-4o-mini",
+ messages: [
+ {
+ role: "system",
+ content:
+ "You are a professional translator. Translate the content while preserving all markdown formatting, HTML tags, and code blocks exactly as they are. Only translate the actual text content.",
+ },
+ {
+ role: "user",
+ content: prompt,
+ },
+ ],
+ temperature: 0.3,
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(
+ `OpenAI API error: ${response.status} ${response.statusText}`
+ );
+ }
+
+ const data = await response.json();
+ console.log(`β
Translation API call completed successfully`);
+
+ return data.choices[0].message.content;
+ } catch (error) {
+ console.error("Translation failed:", error);
+ throw error;
+ }
+ }
+
+ async translateContent(content: string): Promise {
+ const contentHash = this.generateContentHash(content);
+
+ // Check cache first
+ const cached = this.cache[contentHash];
+ if (cached && Date.now() - cached.timestamp < CACHE_EXPIRY) {
+ console.log("Using cached translation");
+ return cached.translatedContent;
+ }
+
+ // Determine target language based on content
+ const isContentKorean = this.isKorean(content);
+ const targetLanguage: "ko" | "en" = isContentKorean ? "en" : "ko";
+
+ console.log(`Translating content to ${targetLanguage}...`);
+
+ try {
+ const translatedContent = await this.callOpenAI(content, targetLanguage);
+
+ // Cache the result
+ this.cache[contentHash] = {
+ translatedContent,
+ timestamp: Date.now(),
+ };
+ this.saveCache();
+
+ return translatedContent;
+ } catch (error) {
+ console.error("Translation failed, returning original content:", error);
+ return content; // Return original content if translation fails
+ }
+ }
+
+ async translateMarkdownFile(
+ filePath: string
+ ): Promise<{ original: string; translated: string }> {
+ const content = fs.readFileSync(filePath, "utf-8");
+ const translatedContent = await this.translateContent(content);
+
+ return {
+ original: content,
+ translated: translatedContent,
+ };
+ }
+}
diff --git a/src/utils/translationHelpers.ts b/src/utils/translationHelpers.ts
new file mode 100644
index 00000000..3f4bd9ad
--- /dev/null
+++ b/src/utils/translationHelpers.ts
@@ -0,0 +1,31 @@
+export const SUPPORTED_LANGUAGES = {
+ KOREAN: "ko",
+ ENGLISH: "en",
+} as const;
+
+export const LANGUAGE_NAMES = {
+ ko: "νκ΅μ΄",
+ en: "English",
+} as const;
+
+export const detectLanguage = (content: string): "ko" | "en" => {
+ // Check if text contains Korean characters
+ const koreanRegex = /[γ±-γ
|γ
-γ
£|κ°-ν£]/;
+ return koreanRegex.test(content) ? "ko" : "en";
+};
+
+export const getOppositeLanguage = (language: "ko" | "en"): "ko" | "en" => {
+ return language === "ko" ? "en" : "ko";
+};
+
+export const formatTranslationStatus = (
+ isTranslated: boolean,
+ originalLanguage: "ko" | "en"
+) => {
+ const targetLanguage = getOppositeLanguage(originalLanguage);
+ return {
+ status: isTranslated ? "translated" : "original",
+ from: LANGUAGE_NAMES[originalLanguage],
+ to: LANGUAGE_NAMES[targetLanguage],
+ };
+};