From af006d148d239c7cc05105ac06694dc9130124d6 Mon Sep 17 00:00:00 2001 From: abdelrhmanehab10 Date: Wed, 8 Apr 2026 01:02:20 +0200 Subject: [PATCH 1/7] feat: add Arabic-first i18n with locale switcher --- .eleventy.js | 50 ++++-- content/blogs/blogs.11tydata.js | 17 ++ content/blogs/blogs.json | 4 - package-lock.json | 49 +++++- package.json | 9 +- scripts/check-i18n.js | 4 + scripts/verify-localized-build.js | 36 ++++ src/_data/locales.js | 16 ++ src/_data/site.json | 4 +- src/_includes/components/blog-card.njk | 42 ++--- src/_includes/components/discord-widget.njk | 16 +- src/_includes/components/footer.njk | 32 ++-- src/_includes/components/giscus.njk | 9 +- src/_includes/components/header.njk | 46 ++--- src/_includes/layouts/base.njk | 19 ++- src/_includes/layouts/blog.njk | 67 ++++---- src/assets/css/input.css | 53 +++++- src/assets/js/main.js | 65 +++++--- src/blogs.11tydata.js | 15 ++ src/blogs.njk | 31 ++-- src/contributing.11tydata.js | 15 ++ src/contributing.njk | 137 ++++++++------- src/i18n/ar/blog.json | 13 ++ src/i18n/ar/blogs.json | 19 +++ src/i18n/ar/common.json | 29 ++++ src/i18n/ar/contributing.json | 71 ++++++++ src/i18n/ar/home.json | 42 +++++ src/i18n/en/blog.json | 13 ++ src/i18n/en/blogs.json | 19 +++ src/i18n/en/common.json | 29 ++++ src/i18n/en/contributing.json | 71 ++++++++ src/i18n/en/home.json | 42 +++++ src/i18n/index.js | 176 ++++++++++++++++++++ src/index.11tydata.js | 15 ++ src/index.njk | 58 +++---- 35 files changed, 1049 insertions(+), 284 deletions(-) create mode 100644 content/blogs/blogs.11tydata.js delete mode 100644 content/blogs/blogs.json create mode 100644 scripts/check-i18n.js create mode 100644 scripts/verify-localized-build.js create mode 100644 src/_data/locales.js create mode 100644 src/blogs.11tydata.js create mode 100644 src/contributing.11tydata.js create mode 100644 src/i18n/ar/blog.json create mode 100644 src/i18n/ar/blogs.json create mode 100644 src/i18n/ar/common.json create mode 100644 src/i18n/ar/contributing.json create mode 100644 src/i18n/ar/home.json create mode 100644 src/i18n/en/blog.json create mode 100644 src/i18n/en/blogs.json create mode 100644 src/i18n/en/common.json create mode 100644 src/i18n/en/contributing.json create mode 100644 src/i18n/en/home.json create mode 100644 src/i18n/index.js create mode 100644 src/index.11tydata.js diff --git a/.eleventy.js b/.eleventy.js index 4ac2cec..d65537f 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -1,6 +1,14 @@ const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight"); +const { + createI18nInstance, + localizeUrl, + validateResources +} = require("./src/i18n"); module.exports = function (eleventyConfig) { + validateResources(); + const i18n = createI18nInstance(); + // Plugins eleventyConfig.addPlugin(syntaxHighlight); @@ -8,32 +16,34 @@ module.exports = function (eleventyConfig) { eleventyConfig.addPassthroughCopy({ "src/assets/images": "assets/images" }); eleventyConfig.addPassthroughCopy({ "src/assets/js": "assets/js" }); - // Blog collection sorted by date (newest first) - eleventyConfig.addCollection("blogs", function (collectionApi) { + function getBlogPosts(collectionApi) { return collectionApi.getFilteredByGlob("content/blogs/*.md") .filter(post => post.fileSlug !== "0template") .sort((a, b) => b.date - a.date); + } + + // Blog collection sorted by date (newest first) + eleventyConfig.addCollection("blogs", function (collectionApi) { + return getBlogPosts(collectionApi); }); // Get unique tags from all blogs eleventyConfig.addCollection("blogTags", function (collectionApi) { const tags = new Set(); - collectionApi.getFilteredByGlob("content/blogs/*.md") - .filter(post => post.fileSlug !== "0template") - .forEach(post => { + getBlogPosts(collectionApi).forEach(post => { if (post.data.tags) { post.data.tags.forEach(tag => tags.add(tag)); } - }); + }); return [...tags].sort(); }); // Date formatting filter - eleventyConfig.addFilter("dateFormat", function (date) { - return new Date(date).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' + eleventyConfig.addFilter("dateFormat", function (date, locale = "ar") { + return new Date(date).toLocaleDateString(locale === "en" ? "en-US" : "ar-EG", { + year: "numeric", + month: "long", + day: "numeric" }); }); @@ -56,6 +66,22 @@ module.exports = function (eleventyConfig) { return arr.slice(0, limit); }); + eleventyConfig.addNunjucksGlobal("t", function (key, locale = "ar", options = {}) { + if (!i18n.exists(key, { lng: locale })) { + throw new Error(`Missing translation key "${key}" for locale "${locale}".`); + } + + return i18n.t(key, { lng: locale, ...options }); + }); + + eleventyConfig.addNunjucksGlobal("localeUrl", function (url, locale = "ar") { + return localizeUrl(url, locale); + }); + + eleventyConfig.addNunjucksGlobal("alternateLocaleUrl", function (url, locale = "ar") { + return localizeUrl(url, locale === "en" ? "ar" : "en"); + }); + // Ignore node_modules and other non-content directories eleventyConfig.ignores.add("node_modules/**"); eleventyConfig.ignores.add(".git/**"); @@ -65,6 +91,8 @@ module.exports = function (eleventyConfig) { eleventyConfig.ignores.add("src/_data/**"); eleventyConfig.ignores.add("src/_includes/**"); eleventyConfig.ignores.add("src/assets/**"); + eleventyConfig.ignores.add("src/i18n/**"); + eleventyConfig.ignores.add("scripts/**"); return { dir: { diff --git a/content/blogs/blogs.11tydata.js b/content/blogs/blogs.11tydata.js new file mode 100644 index 0000000..3cdc891 --- /dev/null +++ b/content/blogs/blogs.11tydata.js @@ -0,0 +1,17 @@ +module.exports = { + layout: "blog.njk", + pagination: { + data: "locales", + size: 1, + alias: "localeData" + }, + eleventyComputed: { + locale: (data) => data.localeData.code, + lang: (data) => data.localeData.lang, + dir: (data) => data.localeData.dir, + permalink: (data) => { + const slug = data.page.fileSlug; + return data.localeData.code === "en" ? `/en/blogs/${slug}/` : `/blogs/${slug}/`; + } + } +}; diff --git a/content/blogs/blogs.json b/content/blogs/blogs.json deleted file mode 100644 index d5257dd..0000000 --- a/content/blogs/blogs.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "layout": "blog.njk", - "permalink": "/blogs/{{ page.fileSlug }}/" -} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 66e630d..1ee0fc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "BLEU-Website", "version": "1.0.0", "license": "MIT", + "dependencies": { + "i18next": "^25.6.0" + }, "devDependencies": { "@11ty/eleventy": "^3.0.0", "@11ty/eleventy-fetch": "^4.0.0", @@ -251,6 +254,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -733,6 +745,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2088,6 +2101,37 @@ "url": "https://opencollective.com/express" } }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2628,6 +2672,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3502,6 +3547,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3717,6 +3763,7 @@ "integrity": "sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "posthtml-parser": "^0.11.0", "posthtml-render": "^3.0.0" @@ -5163,4 +5210,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index e994d34..7e0b659 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "description": "BLEU Community Blog - Open-source, community-driven Programming, Software Enginnering, and Computer Science", "type": "commonjs", "scripts": { + "check:i18n": "node scripts/check-i18n.js", "build:css": "tailwindcss -i ./src/assets/css/input.css -o ./_site/assets/css/style.css --minify", "watch:css": "tailwindcss -i ./src/assets/css/input.css -o ./_site/assets/css/style.css --watch", "build:11ty": "eleventy", "watch:11ty": "eleventy --serve", - "build": "npm run build:css && npm run build:11ty", + "verify:i18n-build": "node scripts/verify-localized-build.js", + "build": "npm run check:i18n && npm run build:css && npm run build:11ty && npm run verify:i18n-build", "dev": "npm-run-all --parallel watch:*", "clean": "rm -rf _site" }, @@ -29,5 +31,8 @@ "postcss": "^8.4.35", "postcss-cli": "^11.0.0", "tailwindcss": "^3.4.1" + }, + "dependencies": { + "i18next": "^25.6.0" } -} \ No newline at end of file +} diff --git a/scripts/check-i18n.js b/scripts/check-i18n.js new file mode 100644 index 0000000..ca58b96 --- /dev/null +++ b/scripts/check-i18n.js @@ -0,0 +1,4 @@ +const { validateResources } = require("../src/i18n"); + +validateResources(); +console.log("i18n dictionaries are in sync."); diff --git a/scripts/verify-localized-build.js b/scripts/verify-localized-build.js new file mode 100644 index 0000000..6ea1651 --- /dev/null +++ b/scripts/verify-localized-build.js @@ -0,0 +1,36 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.join(__dirname, "..", "_site"); +const blogDir = path.join(__dirname, "..", "content", "blogs"); + +function ensureExists(relativePath) { + const absolutePath = path.join(root, relativePath); + if (!fs.existsSync(absolutePath)) { + throw new Error(`Missing built file: ${relativePath}`); + } +} + +const pages = [ + "index.html", + path.join("en", "index.html"), + path.join("blogs", "index.html"), + path.join("en", "blogs", "index.html"), + path.join("contributing", "index.html"), + path.join("en", "contributing", "index.html") +]; + +for (const page of pages) { + ensureExists(page); +} + +const postFiles = fs.readdirSync(blogDir) + .filter((file) => file.endsWith(".md") && file !== "0template.md") + .map((file) => path.basename(file, ".md")); + +for (const slug of postFiles) { + ensureExists(path.join("blogs", slug, "index.html")); + ensureExists(path.join("en", "blogs", slug, "index.html")); +} + +console.log("Localized build output verified."); diff --git a/src/_data/locales.js b/src/_data/locales.js new file mode 100644 index 0000000..49f6bca --- /dev/null +++ b/src/_data/locales.js @@ -0,0 +1,16 @@ +module.exports = [ + { + code: "ar", + lang: "ar", + dir: "rtl", + label: "العربية", + prefix: "" + }, + { + code: "en", + lang: "en", + dir: "ltr", + label: "English", + prefix: "/en" + } +]; diff --git a/src/_data/site.json b/src/_data/site.json index 160c38d..5a3f46a 100644 --- a/src/_data/site.json +++ b/src/_data/site.json @@ -1,7 +1,5 @@ { "name": "BLEU Community", - "tagline": "Building • Learning • Exploring • Uniting", - "description": "A community-driven blog for developers. Share knowledge, learn together, and build the future of technology.", "url": "https://bleu-io.github.io/BLEU-Website", "repo": "BLEU-IO/BLEU-Website", "discord": { @@ -13,4 +11,4 @@ "linkedin": "https://www.linkedin.com/company/bleu-io", "github": "https://github.com/BLEU-IO" } -} \ No newline at end of file +} diff --git a/src/_includes/components/blog-card.njk b/src/_includes/components/blog-card.njk index fd314cf..52bded8 100644 --- a/src/_includes/components/blog-card.njk +++ b/src/_includes/components/blog-card.njk @@ -1,44 +1,44 @@ - + {% if post.data.thumbnail %}
- {{ post.data.title }}
{% endif %} - + {% if post.data.tags %} -
+ {% endif %} - -

+ +

{{ post.data.title }}

- -

+ +

{{ post.data.excerpt or post.templateContent | excerpt }}

- -
-
- {{ post.data.author }} +
+ {{ post.data.author }} - {{ post.data.author }} + {{ post.data.author }}
-
- - {{ post.templateContent | readingTime }} min +
+ + {{ t("blog:post.minutesRead", locale, { count: post.templateContent | readingTime }) }}
diff --git a/src/_includes/components/discord-widget.njk b/src/_includes/components/discord-widget.njk index 5d12032..9065414 100644 --- a/src/_includes/components/discord-widget.njk +++ b/src/_includes/components/discord-widget.njk @@ -4,21 +4,21 @@ Discord -
-
Join our Discord
+
+
{{ t("home:discord.title", locale) }}
- {{ discord.members }} members • - {{ discord.online }} online + {{ discord.members }} {{ t("home:discord.members", locale) }} • + {{ discord.online }} {{ t("home:discord.online", locale) }}
- - Join Server + {{ t("home:discord.joinServer", locale) }}
diff --git a/src/_includes/components/footer.njk b/src/_includes/components/footer.njk index 12abae0..5fa1856 100644 --- a/src/_includes/components/footer.njk +++ b/src/_includes/components/footer.njk @@ -1,25 +1,23 @@ diff --git a/src/_includes/components/giscus.njk b/src/_includes/components/giscus.njk index b671ec5..3fca0e9 100644 --- a/src/_includes/components/giscus.njk +++ b/src/_includes/components/giscus.njk @@ -1,9 +1,10 @@
-

Comments

+

{{ t("blog:post.comments", locale) }}

- Comments are powered by giscus and stored in GitHub Discussions. + {{ t("blog:post.commentsDescription", locale) }} + giscus.

-