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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
429 changes: 429 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# JavaGuide — lệnh cho bản dịch tiếng Việt
#
# Cố ý KHÔNG thêm script vào package.json: file đó bị upstream sửa rất thường xuyên
# (54/200 commit gần nhất), thêm vào là chắc chắn conflict khi pull.
# Makefile là file của riêng fork này nên không bao giờ conflict.

VP := npx vuepress

.PHONY: help install dev cn-dev vi-dev cn-build vi-build build sync sync-mark missing check clean

help:
@echo " make install Cài dependency (pnpm)"
@echo ""
@echo " make dev Chạy CẢ HAI site -> http://localhost:8080/ (nút đổi ngôn ngữ hoạt động)"
@echo " make cn-dev Chỉ site tiếng Trung -> http://localhost:8080/"
@echo " make vi-dev Chỉ site tiếng Việt -> http://localhost:8081/vi/"
@echo ""
@echo " make build Build CẢ HAI site (đúng thứ tự) -> dist/ + dist/vi/"
@echo " make cn-build Chỉ build site tiếng Trung -> dist/"
@echo " make vi-build Chỉ build site tiếng Việt -> dist/vi/"
@echo ""
@echo " make sync Sau khi git pull: xem file nào mới/đã đổi cần dịch"
@echo " make sync-mark Đánh dấu đã dịch xong tới commit hiện tại"
@echo " make missing Chỉ in danh sách file chưa dịch"
@echo " make check Quét chữ Hán còn sót trong vi/"

install:
pnpm install

# Chạy cả hai dev server. Site CN (8080) proxy /vi/* sang site VI (8081),
# nên cả hai nằm trên MỘT origin -> nút đổi ngôn ngữ hoạt động y hệt production.
# Luôn mở http://localhost:8080/ , đừng mở 8081 trực tiếp.
dev:
@for p in 8080 8081; do \
if ss -ltn 2>/dev/null | grep -q ":$$p "; then \
echo "✖ Port $$p đang bị chiếm. VuePress sẽ tự nhảy port khác và proxy /vi/ sẽ sai."; \
echo " Tắt tiến trình cũ rồi chạy lại: kill \$$(ss -ltnp 2>/dev/null | grep ':'$$p' ' | grep -o 'pid=[0-9]*' | cut -d= -f2)"; \
exit 1; \
fi; \
done
@echo "→ http://localhost:8080/ (中文)"
@echo "→ http://localhost:8080/vi/ (Tiếng Việt, qua proxy)"
@$(VP) dev vi -p 8081 & VI_PID=$$!; \
trap "kill $$VI_PID 2>/dev/null; exit 0" INT TERM; \
$(VP) dev docs -c i18n/cn.config.ts; \
kill $$VI_PID 2>/dev/null; true

# Site gốc tiếng Trung, dùng wrapper config để chèn nút đổi ngôn ngữ
# mà không phải sửa file nào trong docs/.
# LƯU Ý: chạy riêng lệnh này thì /vi/ sẽ 502 vì chưa có server 8081 -> dùng `make dev`.
cn-dev:
$(VP) dev docs -c i18n/cn.config.ts

vi-dev:
$(VP) dev vi -p 8081

cn-build:
$(VP) build docs -c i18n/cn.config.ts

# QUAN TRỌNG: build docs TRƯỚC (ghi vào dist/, có thể xoá sạch thư mục),
# rồi mới build vi (ghi vào dist/vi/).
vi-build:
$(VP) build vi

build: cn-build vi-build
@echo "✓ dist/ (中文) + dist/vi/ (Tiếng Việt)"

sync:
@node i18n/sync-check.mjs

sync-mark:
@node i18n/sync-check.mjs --mark

missing:
@node i18n/sync-check.mjs --list

check:
@echo "Chữ Hán còn sót trong vi/ (chỉ được còn ngoại lệ ở CLAUDE.md §3.3.1):"
@grep -rnP '[\x{4e00}-\x{9fff}]' vi/ --include='*.md' --include='*.ts' --include='*.vue' --include='*.scss' || echo " (sạch)"

clean:
rm -rf dist docs/.vuepress/.temp docs/.vuepress/.cache vi/.vuepress/.temp vi/.vuepress/.cache
4 changes: 4 additions & 0 deletions i18n/.sync-state.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"lastSyncedCommit": "d76264cb4e000416c4adca06770ce014bd309150",
"markedAt": "2026-09-18T04:20:30.377Z"
}
21 changes: 21 additions & 0 deletions i18n/cn.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Cấu hình site GỐC tiếng Trung, bọc ngoài docs/.vuepress/config.ts.
//
// Mục đích: thêm nút chuyển ngôn ngữ vào site tiếng Trung mà KHÔNG sửa bất kỳ
// file nào trong docs/ → `git pull` từ repo gốc không bao giờ conflict.
//
// Chạy: npx vuepress dev docs -c i18n/cn.config.ts (xem Makefile)
import { getDirname, path } from "vuepress/utils";
import baseConfig from "../docs/.vuepress/config.js";
import { langSwitchPlugin } from "./lang-switch/plugin.js";
import { scanViRoutes } from "./lang-switch/vi-routes.mjs";

const __dirname = getDirname(import.meta.url);
const viRoutes = scanViRoutes(path.resolve(__dirname, "../vi"));

export default {
...baseConfig,
plugins: [
...(baseConfig.plugins ?? []),
langSwitchPlugin({ site: "cn", viRoutes, devViPort: 8081 }),
],
};
140 changes: 140 additions & 0 deletions i18n/lang-switch/LangSwitch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<template>
<!-- Chỉ render sau khi mount: tránh hydration mismatch vì đích teleport
(.vp-navbar-end của theme) chưa tồn tại lúc SSR. -->
<Teleport v-if="mounted" to=".vp-navbar-end" :disabled="!inNavbar">
<div
class="lang-switch"
:class="{ 'is-floating': !inNavbar }"
role="group"
aria-label="Chuyển ngôn ngữ / 切换语言"
>
<a
class="lang-seg"
:class="{ 'is-active': site === 'cn' }"
:href="cnHref"
:aria-current="site === 'cn' ? 'true' : undefined"
title="中文(原版)"
>中文</a
>
<a
class="lang-seg"
:class="{ 'is-active': site === 'vi', 'is-missing': !viAvailable }"
:href="viHref"
:aria-current="site === 'vi' ? 'true' : undefined"
:title="
viAvailable
? 'Tiếng Việt'
: 'Trang này chưa có bản dịch — mở trang chủ tiếng Việt'
"
>VI</a
>
</div>
</Teleport>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { usePageData } from "vuepress/client";

// Inject lúc build bởi i18n/lang-switch/plugin.ts
declare const __LANG_SITE__: "cn" | "vi";
declare const __VI_ROUTES__: string[];

const site = __LANG_SITE__;
const viRoutes = new Set(__VI_ROUTES__);
const pageData = usePageData();

const mounted = ref(false);
const inNavbar = ref(false);

onMounted(() => {
inNavbar.value = Boolean(document.querySelector(".vp-navbar-end"));
mounted.value = true;
});

// page.path không chứa base -> giống nhau ở cả hai site.
const path = computed(() => pageData.value.path || "/");
const viAvailable = computed(() =>
site === "vi" ? true : viRoutes.has(path.value),
);

// Hai site là hai VuePress app riêng -> phải dùng <a> (full reload).
const cnHref = computed(() => path.value);
const viHref = computed(() =>
viAvailable.value ? `/vi${path.value}` : "/vi/",
);
</script>

<style scoped lang="scss">
.lang-switch {
display: flex;
overflow: hidden;
align-items: stretch;
height: 28px;
margin-left: 0.5rem;
border: 1px solid var(--vp-c-border, #e5e7eb);
border-radius: 14px;
font-size: 12px;
line-height: 26px;
white-space: nowrap;
}

.lang-seg {
padding: 0 10px;
color: var(--vp-c-text, #374151);
font-weight: 500;
text-decoration: none;
transition: all 0.2s ease;

&:hover {
color: var(--vp-c-accent, #3eaf7c);
background: var(--vp-c-bg-alt, #f3f4f6);
}

&.is-active {
color: #fff;
background: var(--vp-c-accent, #3eaf7c);
cursor: default;
}

&.is-missing:not(.is-active) {
opacity: 0.55;
}
}

// Fallback khi không tìm thấy .vp-navbar-end (theme đổi cấu trúc)
.lang-switch.is-floating {
position: fixed;
right: 20px;
bottom: 196px;
z-index: 999;
height: 36px;
margin-left: 0;
border-radius: 18px;
background: var(--vp-c-bg, #fff);
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
font-size: 13px;
line-height: 34px;

.lang-seg {
padding: 0 13px;
}
}

:global(.layout-hidden) .lang-switch.is-floating {
display: none;
}

@media (max-width: 719px) {
.lang-switch {
height: 26px;
margin-left: 0.35rem;
font-size: 11px;
line-height: 24px;
}

.lang-seg {
padding: 0 7px;
}
}
</style>
7 changes: 7 additions & 0 deletions i18n/lang-switch/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineClientConfig } from "vuepress/client";
import { h } from "vue";
import LangSwitch from "./LangSwitch.vue";

export default defineClientConfig({
rootComponents: [() => h(LangSwitch)],
});
49 changes: 49 additions & 0 deletions i18n/lang-switch/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getDirname, path } from "vuepress/utils";

const __dirname = getDirname(import.meta.url);

export interface LangSwitchOptions {
/** Site hiện tại: "cn" = bản gốc tiếng Trung, "vi" = bản dịch tiếng Việt */
site: "cn" | "vi";
/** Các route đã có bản dịch VI (chỉ cần cho site "cn" để biết trang nào bật được) */
viRoutes: string[];
/**
* Chỉ cho site "cn" ở chế độ dev: proxy /vi/* sang dev server của site VI.
* Nhờ đó dev chạy trên MỘT origin duy nhất, nút đổi ngôn ngữ hoạt động y hệt production.
* Không đặt = không proxy.
*/
devViPort?: number;
}

/**
* Chèn nút chuyển ngôn ngữ 中文 | VI vào cả hai site mà KHÔNG phải sửa file nào
* trong docs/ — nhờ đó pull code mới từ repo gốc không bao giờ conflict.
*/
export const langSwitchPlugin = ({
site,
viRoutes,
devViPort,
}: LangSwitchOptions) => ({
name: "javaguide-lang-switch",
clientConfigFile: path.resolve(__dirname, "./client.ts"),
define: {
__LANG_SITE__: site,
__VI_ROUTES__: viRoutes,
},
// Dev server của site CN không biết gì về /vi/ (đó là app VuePress riêng).
// Không proxy thì VuePress router sẽ nuốt /vi/ rồi rewrite thành /vi.html -> 404.
extendsBundlerOptions: (bundlerOptions: any, app: any) => {
if (!devViPort || !app.env.isDev) return;
const vite = (bundlerOptions.viteOptions ??= {});
const server = (vite.server ??= {});
const proxy = (server.proxy ??= {});
proxy["/vi"] = {
target: `http://localhost:${devViPort}`,
changeOrigin: true,
ws: true,
// Site VI có base "/vi/" nên nó trả 404 cho "/vi" (thiếu dấu / cuối).
// Thêm dấu / để gõ tay localhost:8080/vi cũng vào được.
rewrite: (p: string) => (p === "/vi" ? "/vi/" : p),
};
},
});
40 changes: 40 additions & 0 deletions i18n/lang-switch/vi-routes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readdirSync, existsSync } from "node:fs";
import { join, relative, sep } from "node:path";

// File .md không được VuePress build thành trang (khớp pagePatterns của cả 2 site)
const EXCLUDE_FILES = new Set(["PROGRESS.md", "GLOSSARY.md", "TODO.md"]);
const EXCLUDE_DIRS = new Set([".vuepress", "node_modules", ".git"]);

/** Liệt kê mọi file .md (đường dẫn tương đối, dùng "/") trong 1 thư mục nguồn. */
export function listMarkdown(srcDir) {
if (!existsSync(srcDir)) return [];
const out = [];
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) {
if (!EXCLUDE_DIRS.has(e.name)) walk(join(dir, e.name));
} else if (
e.name.endsWith(".md") &&
!e.name.endsWith(".snippet.md") &&
!EXCLUDE_FILES.has(e.name)
) {
out.push(relative(srcDir, join(dir, e.name)).split(sep).join("/"));
}
}
};
walk(srcDir);
return out.sort();
}

/** "java/basis/x.md" -> "/java/basis/x.html" · "roadmap/README.md" -> "/roadmap/" · "README.md" -> "/" */
export function toRoute(relPath) {
if (relPath === "README.md") return "/";
if (relPath.endsWith("/README.md"))
return `/${relPath.slice(0, -"README.md".length)}`;
return `/${relPath.slice(0, -".md".length)}.html`;
}

/** Danh sách route đã có bản dịch tiếng Việt. */
export function scanViRoutes(viDir) {
return listMarkdown(viDir).map(toRoute);
}
Loading