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], + }; +};