diff --git a/.gitignore b/.gitignore index 0658df9..6f52137 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ site/data/snippets.json # Generated index page (built from templates/index.html by html-generators/) site/index.html +# Generated locale directories (built by html-generators/ for non-English locales) +site/pt-BR/ + # Platform-specific CDS/AOT intermediate files (generated by build-cds.sh) html-generators/generate.aot html-generators/generate.jar diff --git a/html-generators/generate.java b/html-generators/generate.java index f927f1a..62e3972 100644 --- a/html-generators/generate.java +++ b/html-generators/generate.java @@ -13,11 +13,14 @@ static final String BASE_URL = "https://javaevolved.github.io"; static final String CONTENT_DIR = "content"; static final String SITE_DIR = "site"; -static final Pattern TOKEN = Pattern.compile("\\{\\{(\\w+)}}"); +static final String TRANSLATIONS_DIR = "translations"; +static final Pattern TOKEN = Pattern.compile("\\{\\{([\\w.]+)}}"); static final ObjectMapper MAPPER = new ObjectMapper(); static final String CATEGORIES_FILE = "html-generators/categories.properties"; +static final String LOCALES_FILE = "html-generators/locales.properties"; static final SequencedMap CATEGORY_DISPLAY = loadCategoryDisplay(); +static final SequencedMap LOCALES = loadLocales(); static SequencedMap loadCategoryDisplay() { try { @@ -34,6 +37,66 @@ static SequencedMap loadCategoryDisplay() { } } +static SequencedMap loadLocales() { + try { + var map = new LinkedHashMap(); + for (var line : Files.readAllLines(Path.of(LOCALES_FILE))) { + line = line.strip(); + if (line.isEmpty() || line.startsWith("#")) continue; + var idx = line.indexOf('='); + if (idx > 0) map.put(line.substring(0, idx).strip(), line.substring(idx + 1).strip()); + } + return map; + } catch (IOException e) { + throw new UncheckedIOException(e); + } +} + +/** Flatten nested JSON into dot-separated keys: {"a":{"b":"c"}} → {"a.b":"c"} */ +static Map flattenJson(JsonNode node, String prefix) { + var map = new LinkedHashMap(); + var it = node.fields(); + while (it.hasNext()) { + var entry = it.next(); + var key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey(); + if (entry.getValue().isObject()) { + map.putAll(flattenJson(entry.getValue(), key)); + } else { + map.put(key, entry.getValue().asText()); + } + } + return map; +} + +/** Load UI strings for a locale, falling back to en.json for missing keys */ +static Map loadStrings(String locale) throws IOException { + var enPath = Path.of(TRANSLATIONS_DIR, "strings", "en.json"); + var enStrings = flattenJson(MAPPER.readTree(enPath.toFile()), ""); + + if (locale.equals("en")) return enStrings; + + var localePath = Path.of(TRANSLATIONS_DIR, "strings", locale + ".json"); + if (!Files.exists(localePath)) { + IO.println("[WARN] strings/%s.json not found — using all English strings".formatted(locale)); + return enStrings; + } + + var localeStrings = flattenJson(MAPPER.readTree(localePath.toFile()), ""); + var merged = new LinkedHashMap<>(enStrings); + for (var entry : localeStrings.entrySet()) { + if (enStrings.containsKey(entry.getKey())) { + merged.put(entry.getKey(), entry.getValue()); + } + } + // Warn about missing keys + for (var key : enStrings.keySet()) { + if (!localeStrings.containsKey(key)) { + IO.println("[WARN] strings/%s.json: missing key \"%s\" — using English fallback".formatted(locale, key)); + } + } + return merged; +} + static final Set EXCLUDED_KEYS = Set.of("_path", "prev", "next", "related"); record Snippet(JsonNode node) { @@ -85,41 +148,110 @@ static Templates load() throws IOException { } } -void main() throws IOException { +void main(String... args) throws IOException { var templates = Templates.load(); var allSnippets = loadAllSnippets(); IO.println("Loaded %d snippets".formatted(allSnippets.size())); + // Determine which locales to build + List localesToBuild; + if (args.length > 0 && args[0].equals("--all-locales")) { + localesToBuild = new ArrayList<>(LOCALES.sequencedKeySet()); + } else if (args.length > 1 && args[0].equals("--locale")) { + localesToBuild = List.of(args[1]); + } else { + localesToBuild = new ArrayList<>(LOCALES.sequencedKeySet()); + } + + for (var locale : localesToBuild) { + buildLocale(locale, templates, allSnippets); + } +} + +void buildLocale(String locale, Templates templates, SequencedMap allSnippets) throws IOException { + var isEnglish = locale.equals("en"); + var strings = loadStrings(locale); + var localeName = LOCALES.getOrDefault(locale, locale); + var sitePrefix = isEnglish ? "" : locale + "/"; + // basePrefix is the relative path from a detail page back to site root + var basePrefix = isEnglish ? "../" : "../../"; + var homeUrl = isEnglish ? "/" : "/%s/".formatted(locale); + + IO.println("Building locale: %s (%s)".formatted(locale, localeName)); + + // Build locale picker HTML + var localePickerHtml = renderLocalePicker(locale); + // Build hreflang links for index + var indexHreflang = renderHreflangLinks("", "index"); + // Build i18n script block + var i18nScript = renderI18nScript(strings, locale); + + // Load translated content if available for (var snippet : allSnippets.values()) { - var html = generateHtml(templates, snippet, allSnippets).strip(); - Files.createDirectories(Path.of(SITE_DIR, snippet.category())); - Files.writeString(Path.of(SITE_DIR, snippet.category(), snippet.slug() + ".html"), html); + var resolved = resolveSnippet(snippet, locale); + var detailHreflang = renderHreflangLinks(snippet.category() + "/", snippet.slug()); + + var extraTokens = new LinkedHashMap(); + extraTokens.putAll(strings); + extraTokens.put("locale", locale); + extraTokens.put("ogLocale", locale.replace("-", "_")); + extraTokens.put("basePrefix", basePrefix); + extraTokens.put("homeUrl", homeUrl); + extraTokens.put("localePicker", localePickerHtml); + extraTokens.put("hreflangLinks", detailHreflang); + extraTokens.put("i18nScript", i18nScript); + + var html = generateHtml(templates, resolved, allSnippets, extraTokens, locale).strip(); + + if (isEnglish) { + Files.createDirectories(Path.of(SITE_DIR, snippet.category())); + Files.writeString(Path.of(SITE_DIR, snippet.category(), snippet.slug() + ".html"), html); + } else { + Files.createDirectories(Path.of(SITE_DIR, locale, snippet.category())); + Files.writeString(Path.of(SITE_DIR, locale, snippet.category(), snippet.slug() + ".html"), html); + } } - IO.println("Generated %d HTML files".formatted(allSnippets.size())); + IO.println("Generated %d HTML files for %s".formatted(allSnippets.size(), locale)); // Rebuild data/snippets.json var snippetsList = allSnippets.values().stream() .map(s -> { - Map map = MAPPER.convertValue(s.node(), new TypeReference>() {}); + var resolved = resolveSnippet(s, locale); + Map map = MAPPER.convertValue(resolved.node(), new TypeReference>() {}); EXCLUDED_KEYS.forEach(map::remove); return map; }) .toList(); - Files.createDirectories(Path.of(SITE_DIR, "data")); + var dataDir = isEnglish ? Path.of(SITE_DIR, "data") : Path.of(SITE_DIR, locale, "data"); + Files.createDirectories(dataDir); var prettyMapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); - Files.writeString(Path.of(SITE_DIR, "data", "snippets.json"), prettyMapper.writeValueAsString(snippetsList) + "\n"); - IO.println("Rebuilt data/snippets.json with %d entries".formatted(snippetsList.size())); + Files.writeString(dataDir.resolve("snippets.json"), prettyMapper.writeValueAsString(snippetsList) + "\n"); + IO.println("Rebuilt data/snippets.json for %s with %d entries".formatted(locale, snippetsList.size())); // Generate index.html from template var tipCards = allSnippets.values().stream() - .map(s -> renderIndexCard(templates.indexCard(), s)) + .map(s -> renderIndexCard(templates.indexCard(), s, locale, strings)) .collect(Collectors.joining("\n")); - var indexHtml = replaceTokens(templates.index(), Map.of( - "tipCards", tipCards, - "snippetCount", String.valueOf(allSnippets.size()))); - Files.writeString(Path.of(SITE_DIR, "index.html"), indexHtml); - IO.println("Generated index.html with %d cards".formatted(allSnippets.size())); + + var indexTokens = new LinkedHashMap(); + indexTokens.putAll(strings); + indexTokens.put("tipCards", tipCards); + indexTokens.put("snippetCount", String.valueOf(allSnippets.size())); + indexTokens.put("locale", locale); + indexTokens.put("ogLocale", locale.replace("-", "_")); + indexTokens.put("canonicalUrl", isEnglish ? BASE_URL : BASE_URL + "/" + locale); + indexTokens.put("homeUrl", homeUrl); + indexTokens.put("indexBasePrefix", isEnglish ? "" : "../"); + indexTokens.put("localePicker", localePickerHtml); + indexTokens.put("hreflangLinks", indexHreflang); + indexTokens.put("i18nScript", i18nScript); + + var indexHtml = replaceTokens(templates.index(), indexTokens); + var indexPath = isEnglish ? Path.of(SITE_DIR, "index.html") : Path.of(SITE_DIR, locale, "index.html"); + if (!isEnglish) Files.createDirectories(indexPath.getParent()); + Files.writeString(indexPath, indexHtml); + IO.println("Generated index.html for %s with %d cards".formatted(locale, allSnippets.size())); } SequencedMap loadAllSnippets() throws IOException { @@ -159,11 +291,11 @@ String urlEncode(String s) { return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20"); } -String supportBadge(String state) { +String supportBadge(String state, Map strings) { return switch (state) { - case "preview" -> "Preview"; - case "experimental" -> "Experimental"; - default -> "Available"; + case "preview" -> strings.getOrDefault("support.preview", "Preview"); + case "experimental" -> strings.getOrDefault("support.experimental", "Experimental"); + default -> strings.getOrDefault("support.available", "Available"); }; } @@ -175,22 +307,29 @@ String supportBadgeClass(String state) { }; } -String renderNavArrows(Snippet snippet) { +String renderNavArrows(Snippet snippet, String locale) { + var prefix = locale.equals("en") ? "" : "/" + locale; var prev = snippet.optText("prev") - .map(p -> "".formatted(p)) + .map(p -> "".formatted(prefix, p)) .orElse(""); var next = snippet.optText("next") - .map(n -> "".formatted(n)) + .map(n -> "".formatted(prefix, n)) .orElse(""); return prev + "\n " + next; } -String renderIndexCard(String tpl, Snippet s) { +String renderIndexCard(String tpl, Snippet s, String locale, Map strings) { + var cardHref = locale.equals("en") + ? "/%s/%s.html".formatted(s.category(), s.slug()) + : "/%s/%s/%s.html".formatted(locale, s.category(), s.slug()); return replaceTokens(tpl, Map.ofEntries( Map.entry("category", s.category()), Map.entry("slug", s.slug()), Map.entry("catDisplay", s.catDisplay()), Map.entry("title", escape(s.title())), Map.entry("oldCode", escape(s.oldCode())), Map.entry("modernCode", escape(s.modernCode())), - Map.entry("jdkVersion", s.jdkVersion()))); + Map.entry("jdkVersion", s.jdkVersion()), Map.entry("cardHref", cardHref), + Map.entry("cards.old", strings.getOrDefault("cards.old", "Old")), + Map.entry("cards.modern", strings.getOrDefault("cards.modern", "Modern")), + Map.entry("cards.hoverHint", strings.getOrDefault("cards.hoverHint", "hover to see modern →")))); } String renderWhyCards(String tpl, JsonNode whyList) { @@ -203,14 +342,18 @@ String renderWhyCards(String tpl, JsonNode whyList) { return String.join("\n", cards); } -String renderRelatedCard(String tpl, Snippet rel) { +String renderRelatedCard(String tpl, Snippet rel, String locale, Map strings) { + var relatedHref = locale.equals("en") + ? "/%s/%s.html".formatted(rel.category(), rel.slug()) + : "/%s/%s/%s.html".formatted(locale, rel.category(), rel.slug()); return replaceTokens(tpl, Map.ofEntries( Map.entry("category", rel.category()), Map.entry("slug", rel.slug()), Map.entry("catDisplay", rel.catDisplay()), Map.entry("difficulty", rel.difficulty()), Map.entry("title", escape(rel.title())), Map.entry("oldLabel", escape(rel.oldLabel())), Map.entry("oldCode", escape(rel.oldCode())), Map.entry("modernLabel", escape(rel.modernLabel())), Map.entry("modernCode", escape(rel.modernCode())), - Map.entry("jdkVersion", rel.jdkVersion()))); + Map.entry("jdkVersion", rel.jdkVersion()), Map.entry("relatedHref", relatedHref), + Map.entry("cards.hoverHintRelated", strings.getOrDefault("cards.hoverHintRelated", "Hover to see modern ➜")))); } String renderDocLinks(String tpl, JsonNode docs) { @@ -222,20 +365,27 @@ String renderDocLinks(String tpl, JsonNode docs) { return String.join("\n", links); } -String renderRelatedSection(String tpl, Snippet snippet, Map all) { +String renderRelatedSection(String tpl, Snippet snippet, Map all, String locale, Map strings) { return snippet.related().stream().filter(all::containsKey) - .map(p -> renderRelatedCard(tpl, all.get(p))) + .map(p -> renderRelatedCard(tpl, all.get(p), locale, strings)) .collect(Collectors.joining("\n")); } -String renderSocialShare(String tpl, String slug, String title) { +String renderSocialShare(String tpl, String slug, String title, Map strings) { var encodedUrl = urlEncode("%s/%s.html".formatted(BASE_URL, slug)); var encodedText = urlEncode("%s \u2013 java.evolved".formatted(title)); - return replaceTokens(tpl, Map.of("encodedUrl", encodedUrl, "encodedText", encodedText)); + return replaceTokens(tpl, Map.of("encodedUrl", encodedUrl, "encodedText", encodedText, + "share.label", strings.getOrDefault("share.label", "Share"))); } -String generateHtml(Templates tpl, Snippet s, Map all) throws IOException { - return replaceTokens(tpl.page(), Map.ofEntries( +String generateHtml(Templates tpl, Snippet s, Map all, Map extraTokens, String locale) throws IOException { + var isEnglish = locale.equals("en"); + var canonicalUrl = isEnglish + ? "%s/%s/%s.html".formatted(BASE_URL, s.category(), s.slug()) + : "%s/%s/%s/%s.html".formatted(BASE_URL, locale, s.category(), s.slug()); + + var tokens = new LinkedHashMap<>(extraTokens); + tokens.putAll(Map.ofEntries( Map.entry("title", escape(s.title())), Map.entry("summary", escape(s.summary())), Map.entry("slug", s.slug()), Map.entry("category", s.category()), Map.entry("categoryDisplay", s.catDisplay()), Map.entry("difficulty", s.difficulty()), @@ -245,23 +395,133 @@ String generateHtml(Templates tpl, Snippet s, Map all) throws I Map.entry("oldApproach", escape(s.oldApproach())), Map.entry("modernApproach", escape(s.modernApproach())), Map.entry("explanation", escape(s.explanation())), Map.entry("supportDescription", escape(s.supportDesc())), - Map.entry("supportBadge", supportBadge(s.supportState())), + Map.entry("supportBadge", supportBadge(s.supportState(), extraTokens)), Map.entry("supportBadgeClass", supportBadgeClass(s.supportState())), - Map.entry("canonicalUrl", "%s/%s/%s.html".formatted(BASE_URL, s.category(), s.slug())), + Map.entry("canonicalUrl", canonicalUrl), Map.entry("flatUrl", "%s/%s.html".formatted(BASE_URL, s.slug())), Map.entry("titleJson", jsonEscape(s.title())), Map.entry("summaryJson", jsonEscape(s.summary())), Map.entry("categoryDisplayJson", jsonEscape(s.catDisplay())), - Map.entry("navArrows", renderNavArrows(s)), + Map.entry("navArrows", renderNavArrows(s, locale)), Map.entry("whyCards", renderWhyCards(tpl.whyCard(), s.whyModernWins())), Map.entry("docLinks", renderDocLinks(tpl.docLink(), s.node().withArray("docs"))), - Map.entry("relatedCards", renderRelatedSection(tpl.relatedCard(), s, all)), - Map.entry("socialShare", renderSocialShare(tpl.socialShare(), s.slug(), s.title())))); + Map.entry("relatedCards", renderRelatedSection(tpl.relatedCard(), s, all, locale, extraTokens)), + Map.entry("socialShare", renderSocialShare(tpl.socialShare(), s.slug(), s.title(), extraTokens)))); + return replaceTokens(tpl.page(), tokens); } -String replaceTokens(String template, Map replacements) { - var m = TOKEN.matcher(template); +/** Load translated content or fall back to English; overwrite code fields from English */ +Snippet resolveSnippet(Snippet englishSnippet, String locale) { + if (locale.equals("en")) return englishSnippet; + + var translatedPath = Path.of(TRANSLATIONS_DIR, "content", locale, + englishSnippet.category(), englishSnippet.slug() + ".json"); + if (!Files.exists(translatedPath)) return englishSnippet; + + try { + var translatedNode = (com.fasterxml.jackson.databind.node.ObjectNode) MAPPER.readTree(translatedPath.toFile()); + // Overwrite code fields with English values + translatedNode.put("oldCode", englishSnippet.oldCode()); + translatedNode.put("modernCode", englishSnippet.modernCode()); + return new Snippet(translatedNode); + } catch (IOException e) { + IO.println("[WARN] Failed to load %s — using English".formatted(translatedPath)); + return englishSnippet; + } +} + +/** Render hreflang tags for all locales */ +String renderHreflangLinks(String pathPart, String slug) { var sb = new StringBuilder(); - while (m.find()) m.appendReplacement(sb, Matcher.quoteReplacement(replacements.getOrDefault(m.group(1), m.group(0)))); - m.appendTail(sb); + for (var entry : LOCALES.entrySet()) { + var loc = entry.getKey(); + String href; + if (slug.equals("index")) { + href = loc.equals("en") ? BASE_URL + "/" : BASE_URL + "/" + loc + "/"; + } else { + href = loc.equals("en") + ? "%s/%s%s.html".formatted(BASE_URL, pathPart, slug) + : "%s/%s/%s%s.html".formatted(BASE_URL, loc, pathPart, slug); + } + sb.append(" \n".formatted(loc, href)); + } + // x-default points to English + var defaultHref = slug.equals("index") + ? BASE_URL + "/" + : "%s/%s%s.html".formatted(BASE_URL, pathPart, slug); + sb.append(" ".formatted(defaultHref)); + return sb.toString(); +} + +/** Render the locale picker dropdown HTML */ +String renderLocalePicker(String currentLocale) { + var sb = new StringBuilder(); + sb.append("
\n"); + sb.append(" \n"); + sb.append("
    \n"); + for (var entry : LOCALES.entrySet()) { + var selected = entry.getKey().equals(currentLocale); + sb.append("
  • %s
  • \n" + .formatted(entry.getKey(), selected, selected ? " class=\"active\"" : "", entry.getValue())); + } + sb.append("
\n"); + sb.append("
"); return sb.toString(); } + +/** Render the i18n script block for client-side JS */ +String renderI18nScript(Map strings, String locale) { + var localeArray = LOCALES.keySet().stream() + .map(l -> "\"" + l + "\"") + .collect(java.util.stream.Collectors.joining(", ")); + return """ + """.formatted( + locale, + localeArray, + jsEscape(strings.getOrDefault("search.placeholder", "Search snippets…")), + jsEscape(strings.getOrDefault("search.noResults", "No results found.")), + jsEscape(strings.getOrDefault("copy.copied", "Copied!")), + jsEscape(strings.getOrDefault("view.expandAll", "Expand All")), + jsEscape(strings.getOrDefault("view.collapseAll", "Collapse All")), + jsEscape(strings.getOrDefault("cards.hoverHint", "hover to see modern →")), + jsEscape(strings.getOrDefault("cards.touchHint", "👆 tap or swipe →"))); +} + +String jsEscape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"); +} + +String replaceTokens(String template, Map replacements) { + // Loop to handle tokens within replacement values (e.g., {{snippetCount}} inside i18n strings) + var result = template; + for (int pass = 0; pass < 3; pass++) { + var m = TOKEN.matcher(result); + var sb = new StringBuilder(); + boolean found = false; + while (m.find()) { + var replacement = replacements.get(m.group(1)); + if (replacement != null) { + m.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + found = true; + } else { + m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0))); + } + } + m.appendTail(sb); + result = sb.toString(); + if (!found) break; + } + return result; +} diff --git a/html-generators/locales.properties b/html-generators/locales.properties new file mode 100644 index 0000000..e02c2f8 --- /dev/null +++ b/html-generators/locales.properties @@ -0,0 +1,3 @@ +# format: locale=Display name (first entry is the default/primary locale) +en=English +pt-BR=Português (Brasil) diff --git a/site/app.js b/site/app.js index 7ce11e7..169358e 100644 --- a/site/app.js +++ b/site/app.js @@ -6,12 +6,59 @@ (() => { 'use strict'; + /* ---------- Locale Detection ---------- */ + const detectLocale = () => { + const path = location.pathname; + const match = path.match(/^\/([a-z]{2}(?:-[A-Z]{2})?)\//); + return match ? match[1] : 'en'; + }; + const locale = detectLocale(); + const localePrefix = locale === 'en' ? '' : '/' + locale; + + /* ---------- Browser Locale Auto-Redirect ---------- */ + const autoRedirectLocale = () => { + if (locale !== 'en') return; // already on a non-English locale + const available = (window.i18n && window.i18n.availableLocales) || []; + if (available.length <= 1) return; + + // Respect explicit user choice (set when using locale picker) + const preferred = localStorage.getItem('preferred-locale'); + if (preferred === 'en') return; // user explicitly chose English + if (preferred && available.includes(preferred)) { + window.location.replace('/' + preferred + location.pathname + location.search + location.hash); + return; + } + + // Match browser language to available locales + const langs = navigator.languages || [navigator.language]; + for (const lang of langs) { + // Exact match (e.g. pt-BR) + if (lang !== 'en' && available.includes(lang)) { + window.location.replace('/' + lang + location.pathname + location.search + location.hash); + return; + } + // Prefix match (e.g. pt matches pt-BR) + const prefix = lang.split('-')[0]; + if (prefix !== 'en') { + const match = available.find(l => l.startsWith(prefix + '-') || l === prefix); + if (match) { + window.location.replace('/' + match + location.pathname + location.search + location.hash); + return; + } + } + } + }; + autoRedirectLocale(); + /* ---------- Snippets Data ---------- */ let snippets = []; const loadSnippets = async () => { try { - const res = await fetch('/data/snippets.json'); + const indexPath = locale === 'en' + ? '/data/snippets.json' + : '/' + locale + '/data/snippets.json'; + const res = await fetch(indexPath); snippets = await res.json(); } catch (e) { console.warn('Could not load snippets.json:', e); @@ -85,7 +132,7 @@ // Click handlers on results resultsContainer.querySelectorAll('.search-result').forEach(el => { el.addEventListener('click', () => { - window.location.href = '/' + el.dataset.category + '/' + el.dataset.slug + '.html'; + window.location.href = localePrefix + '/' + el.dataset.category + '/' + el.dataset.slug + '.html'; }); }); }; @@ -144,7 +191,7 @@ } else if (e.key === 'Enter') { e.preventDefault(); if (selectedIndex >= 0 && visibleResults[selectedIndex]) { - window.location.href = '/' + visibleResults[selectedIndex].category + '/' + visibleResults[selectedIndex].slug + '.html'; + window.location.href = localePrefix + '/' + visibleResults[selectedIndex].category + '/' + visibleResults[selectedIndex].slug + '.html'; } } }); @@ -202,6 +249,9 @@ : null; if (target) { target.click(); + // Scroll the filter section into view + const section = document.getElementById('all-comparisons'); + if (section) section.scrollIntoView({ behavior: 'smooth' }); } else { const allButton = document.querySelector('.filter-pill[data-filter="all"]'); if (allButton) allButton.click(); @@ -226,7 +276,7 @@ // Update hover hints for touch devices document.querySelectorAll('.hover-hint').forEach(hint => { - hint.textContent = '👆 tap or swipe →'; + hint.textContent = (window.i18n && window.i18n.touchHint) || '👆 tap or swipe →'; }); document.querySelectorAll('.tip-card').forEach(card => { @@ -318,7 +368,7 @@ navigator.clipboard.writeText(text).then(() => { btn.classList.add('copied'); const original = btn.textContent; - btn.textContent = 'Copied!'; + btn.textContent = (window.i18n && window.i18n.copied) || 'Copied!'; setTimeout(() => { btn.classList.remove('copied'); btn.textContent = original; @@ -335,7 +385,7 @@ document.body.removeChild(textarea); btn.classList.add('copied'); const original = btn.textContent; - btn.textContent = 'Copied!'; + btn.textContent = (window.i18n && window.i18n.copied) || 'Copied!'; setTimeout(() => { btn.classList.remove('copied'); btn.textContent = original; @@ -550,7 +600,7 @@ if (isExpanded) { tipsGrid.classList.add('expanded'); toggleBtn.querySelector('.view-toggle-icon').textContent = '⊟'; - toggleBtn.querySelector('.view-toggle-text').textContent = 'Collapse All'; + toggleBtn.querySelector('.view-toggle-text').textContent = (window.i18n && window.i18n.collapseAll) || 'Collapse All'; // Remove toggled class from all cards when expanding document.querySelectorAll('.tip-card').forEach(card => { @@ -559,7 +609,7 @@ } else { tipsGrid.classList.remove('expanded'); toggleBtn.querySelector('.view-toggle-icon').textContent = '⊞'; - toggleBtn.querySelector('.view-toggle-text').textContent = 'Expand All'; + toggleBtn.querySelector('.view-toggle-text').textContent = (window.i18n && window.i18n.expandAll) || 'Expand All'; } }); @@ -593,6 +643,61 @@ }); }; + /* ========================================================== + 8. Locale Picker + ========================================================== */ + const initLocalePicker = () => { + const picker = document.getElementById('localePicker'); + if (!picker) return; + + const toggleBtn = picker.querySelector('.locale-toggle'); + const list = picker.querySelector('ul'); + + const open = () => { + list.style.display = 'block'; + toggleBtn.setAttribute('aria-expanded', 'true'); + }; + + const close = () => { + list.style.display = 'none'; + toggleBtn.setAttribute('aria-expanded', 'false'); + }; + + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + list.style.display === 'block' ? close() : open(); + }); + + document.addEventListener('click', close); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') close(); + }); + + list.querySelectorAll('li').forEach(li => { + li.addEventListener('click', (e) => { + e.stopPropagation(); + const targetLocale = li.dataset.locale; + if (targetLocale === locale) { close(); return; } + + // Rewrite current path for the target locale + let path = location.pathname; + + // Strip current locale prefix if present + if (locale !== 'en') { + path = path.replace(new RegExp('^/' + locale.replace('-', '\\-')), ''); + } + + // Add target locale prefix + if (targetLocale !== 'en') { + path = '/' + targetLocale + path; + } + + localStorage.setItem('preferred-locale', targetLocale); + window.location.href = path; + }); + }); + }; + /* ========================================================== Utilities ========================================================== */ @@ -616,5 +721,6 @@ initSyntaxHighlighting(); initNewsletter(); initThemeToggle(); + initLocalePicker(); }); })(); diff --git a/site/styles.css b/site/styles.css index 82f19e1..581dc5c 100644 --- a/site/styles.css +++ b/site/styles.css @@ -1572,3 +1572,84 @@ footer a:hover { } .share-li { font-family: Georgia, serif; font-weight: 800; font-size: 0.85rem; } .share-reddit { font-size: 1.1rem; } + +/* ============================ + Locale Picker + ============================ */ +.locale-picker { + position: relative; + display: inline-flex; +} +.locale-toggle { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-muted); + font-size: 1rem; + line-height: 1; + transition: all 0.2s; + cursor: pointer; +} +.locale-toggle:hover { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.locale-picker ul { + display: none; + position: absolute; + top: 100%; + right: 0; + margin: 4px 0 0; + padding: 4px 0; + list-style: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 100; + min-width: 160px; +} +.locale-picker li { + padding: 8px 16px; + cursor: pointer; + font-size: 0.9rem; + color: var(--text); + white-space: nowrap; + transition: background 0.15s; +} +.locale-picker li:hover { + background: var(--accent); + color: #fff; +} +.locale-picker li.active { + font-weight: 600; + color: var(--accent); +} +.locale-picker li.active:hover { + color: #fff; +} + +/* ============================ + Untranslated Notice Banner + ============================ */ +.untranslated-notice { + background: var(--surface); + border: 1px solid var(--border); + border-left: 4px solid var(--accent); + padding: 12px 16px; + margin: 0 auto 24px; + max-width: 800px; + border-radius: var(--radius-sm); + font-size: 0.9rem; + color: var(--text-muted); +} +.untranslated-notice a { + color: var(--accent); + text-decoration: underline; + margin-left: 8px; +} diff --git a/templates/index-card.html b/templates/index-card.html index 89ea235..0117fdf 100644 --- a/templates/index-card.html +++ b/templates/index-card.html @@ -1,4 +1,4 @@ - +
{{catDisplay}}
@@ -7,14 +7,14 @@

{{title}}

- Old + {{cards.old}} {{oldCode}}
- Modern + {{cards.modern}} {{modernCode}}
- hover to see modern → + {{cards.hoverHint}}