Skip to content
Open
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
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,62 @@ To add a new locale manually (e.g. Spanish `es`):
5. **`src/config/routeTranslations.ts`** — add `es` entries for each translated route segment
6. **`src/content/blog/es/`** — add translated blog posts with matching `mappingKey` values

### Using a single language

This kit supports single-language projects where a language switcher is not needed. When configured for a single language:

- **No language prefix** — all pages are served at the root (e.g., `/about/`, not `/en/about/`)
- **No language switcher** — the language switcher components automatically hide
- **All i18n utilities still work** — translation functions and route helpers work unchanged
- **Easy upgrade path** — convert to multi-language later without rewriting code

#### Setting up a single-language project

Run the interactive configuration:

```bash
npm run config-i18n
```

When prompted, select "single-language project" and provide your language code (e.g., `en`, `nl`, `de`). The script will:

1. Update `astro.config.mjs` with your locale and set `prefixDefaultLocale: false`
2. Create `.i18n-single-language` marker file to track single-language mode
3. Provide next steps for removing demo content

#### Single-language utility functions

For single-language projects, `translationUtils.ts` provides simplified helpers:

- **`getSingleLocale()`** — returns the single configured locale
- **`getLocalizedRouteForCurrentLang(path)`** — a simplified version of `getLocalizedRoute()` for single-language use

Example:

```astro
---
import { getSingleLocale, getLocalizedRouteForCurrentLang } from "@js/translationUtils";

const locale = getSingleLocale();
const aboutUrl = getLocalizedRouteForCurrentLang("/about");
---

<a href={aboutUrl}>About</a>
```

All existing i18n functions (`getLocalizedRoute`, `getLocalizedPathname`, `useTranslations`) continue to work as-is.

#### Upgrading from single to multi-language

To convert a single-language project to multi-language:

1. Run `npm run config-i18n` and select "multi-language project"
2. Follow the prompts to specify additional locales
3. The `.i18n-single-language` marker file will be removed
4. Add page files and translations for the new locales following the [multi-language structure](#page-structure)

For more details, see `SINGLE_LANGUAGE.md` in the project root.

### Page Structure

Unlike a plugin-based approach, this kit uses **full page duplication**: English pages live at the root of `src/pages/`, and French pages are duplicated under `src/pages/fr/`. A localization system has been implemented to localize the slugs with translated filenames:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"remove-demo": "node scripts/remove-demo.js",
"remove-dark-mode": "node scripts/remove-dark-mode.js",
"remove-decap": "node scripts/remove-decap.js",
"remove-i18n": "node scripts/remove-i18n.js",
"config-i18n": "node scripts/config-i18n.js",
"create-page": "node scripts/create-page.js"
},
Expand Down
225 changes: 190 additions & 35 deletions scripts/config-i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ try {
process.exit(0);
} catch { /* proceed */ }

try {
await fs.access(join(root, ".i18n-configured"));
console.log("i18n configuration has already been applied (.i18n-configured marker exists).");
console.log("To reconfigure, remove the .i18n-configured marker file and run again.");
process.exit(0);
} catch { /* proceed */ }

// ─── Helpers ──────────────────────────────────────────────────────────────────

const LOCALE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
Expand Down Expand Up @@ -559,6 +566,100 @@ async function patchContentFolders({ defaultLocale, newDefaultLocale, localesToA
}
}

// ─── Phase H: src/components/ ────────────────────────────────────────────────

async function patchComponents({ localesToRemove, editOldDefaultToNewDefault, defaultLocale, newDefaultLocale }) {
const componentsDir = join(root, "src", "components");
if (!existsSync(componentsDir)) {
console.log(" Skipped src/components/ — directory not found");
return;
}

// Recursively find component files that may contain locale-specific imports
async function findComponentFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) files.push(...await findComponentFiles(full));
else if (/\.(astro|ts|tsx|js|jsx)$/.test(e.name)) files.push(full);
}
return files;
}

const files = await findComponentFiles(componentsDir);
let patchedCount = 0;

for (const filePath of files) {
let content = await fs.readFile(filePath, "utf-8");
if (!content.includes("@locales/")) continue;

let changed = false;

// Handle removed locales
for (const locale of localesToRemove) {
if (!content.includes(`@locales/${locale}/`)) continue;

// Capture variable names used in this locale's imports
const captureRe = new RegExp(`import (\\w+) from ["']@locales\\/${locale}\\/[^"']+["']`, "g");
const removedVarNames = [];
let m;
while ((m = captureRe.exec(content)) !== null) {
removedVarNames.push(m[1]);
}
if (removedVarNames.length === 0) continue;

// Remove entire import lines for this locale
content = content.replace(
new RegExp(`[ \\t]*import \\w+ from ["']@locales\\/${locale}\\/[^"']+["'];?[ \\t]*(?:\\r?\\n)?`, "g"),
"",
);
changed = true;

// Simplify ternary assignments that reference the removed variable
for (const removedVar of removedVarNames) {
// `= locale === "fr" ? frVar : enVar` → `= enVar`
content = content.replace(
new RegExp(`= locale === ["']${locale}["'] \\? ${removedVar} : (\\w+)`, "g"),
"= $1",
);
// `= locale === "fr" ? enVar : frVar` → `= enVar`
content = content.replace(
new RegExp(`= locale === ["']${locale}["'] \\? (\\w+) : ${removedVar}`, "g"),
"= $1",
);
// `= locale !== "fr" ? enVar : frVar` → `= enVar`
content = content.replace(
new RegExp(`= locale !== ["']${locale}["'] \\? (\\w+) : ${removedVar}`, "g"),
"= $1",
);
}
}

// Handle locale rename: update @locales/{old}/ → @locales/{new}/ in import paths
if (editOldDefaultToNewDefault && content.includes(`@locales/${defaultLocale}/`)) {
content = content.replace(
new RegExp(`@locales\\/${defaultLocale}\\/`, "g"),
`@locales/${newDefaultLocale}/`,
);
changed = true;
}

if (changed) {
// Clean up any double-blank lines left behind after import removal
content = content.replace(/\n{3,}/g, "\n\n");
await fs.writeFile(filePath, content, "utf-8");
const rel = filePath.replace(root + "/", "");
console.log(` Patched ${rel}`);
patchedCount++;
}
}

if (patchedCount === 0) {
console.log(" No component files needed patching");
}
}

// ─── Main ─────────────────────────────────────────────────────────────────────

async function configI18n() {
Expand Down Expand Up @@ -596,45 +697,65 @@ async function configI18n() {
console.log(`\nCurrent config: defaultLocale="${defaultLocale}", locales=[${currentLocales.join(", ")}], prefixDefaultLocale=${currentPrefixDefaultLocale}\n`);
console.log("NOTE: locale examples at https://github.com/cospired/i18n-iso-languages\n");

// ── Prompt 1: multiple languages? ─────────────────────────────────────────
const multiAnswer = (await ask("Do you plan to use multiple languages? (y/n): ")).trim().toLowerCase();
if (multiAnswer !== "y") {
rl.close();
console.log("\nExiting. No changes made.\n");
process.exit(0);
}
// ── Prompt 1: single language or multi? ──────────────────────────────────────
const singleLangAnswer = (await ask("Is this a single-language project? (y/n) [n]: ")).trim().toLowerCase();
const isSingleLanguage = singleLangAnswer === "y";

// ── Prompt 2: new default locale ──────────────────────────────────────────
let newDefaultLocale;
while (true) {
const answer = (await ask(`\nDefault locale? [${defaultLocale}]: `)).trim();
const val = answer === "" ? defaultLocale : answer.toLowerCase();
if (validateLocale(val)) { newDefaultLocale = val; break; }
console.log(' Invalid locale. Use a 2-letter code like "en", "fr", or "de".');
}
let newLocales;
let prefixDefaultLocale;

if (isSingleLanguage) {
// ── Single-language mode ──────────────────────────────────────────────
// Prompt for single language
while (true) {
const answer = (await ask(`\nSingle language code? [${defaultLocale}]: `)).trim();
const val = answer === "" ? defaultLocale : answer.toLowerCase();
if (validateLocale(val)) { newDefaultLocale = val; break; }
console.log(' Invalid locale. Use a 2-letter code like "en", "fr", or "de".');
}
newLocales = [newDefaultLocale];

// ── Prompt 3: additional locales ──────────────────────────────────────────
let additionalLocales;
while (true) {
const answer = (await ask("Additional locales (comma-separated, e.g. fr, de): ")).trim();
if (!answer) { console.log(" Please enter at least one additional locale."); continue; }
const parsed = answer.split(",").map((l) => l.trim().toLowerCase()).filter(Boolean);
const invalid = parsed.filter((l) => !validateLocale(l));
if (invalid.length > 0) { console.log(` Invalid: ${invalid.join(", ")}. Use 2-letter codes.`); continue; }
additionalLocales = parsed;
break;
}
// Single-language projects typically don't prefix the locale in URLs
prefixDefaultLocale = false;
} else {
// ── Multi-language mode ───────────────────────────────────────────────
// Prompt 2: new default locale
while (true) {
const answer = (await ask(`\nDefault locale? [${defaultLocale}]: `)).trim();
const val = answer === "" ? defaultLocale : answer.toLowerCase();
if (validateLocale(val)) { newDefaultLocale = val; break; }
console.log(' Invalid locale. Use a 2-letter code like "en", "fr", or "de".');
}

// Build final locales list (default first, deduplicated)
const newLocales = [...new Set([newDefaultLocale, ...additionalLocales])];
// Prompt 3: additional locales
let additionalLocales;
while (true) {
const answer = (await ask("Additional locales (comma-separated, e.g. fr, de): ")).trim();
if (!answer) { console.log(" Please enter at least one additional locale."); continue; }
const parsed = answer.split(",").map((l) => l.trim().toLowerCase()).filter(Boolean);
const invalid = parsed.filter((l) => !validateLocale(l));
if (invalid.length > 0) { console.log(` Invalid: ${invalid.join(", ")}. Use 2-letter codes.`); continue; }
additionalLocales = parsed;
break;
}

// Build final locales list (default first, deduplicated)
newLocales = [...new Set([newDefaultLocale, ...additionalLocales])];

// ── Prompt 4: prefixDefaultLocale ─────────────────────────────────────────
const prefixPromptDefault = currentPrefixDefaultLocale ? "y" : "n";
const prefixAnswer = (await ask(`\nPrefix default locale in URLs? (/en/about vs /about) (y/n) [${prefixPromptDefault}]: `)).trim().toLowerCase();
const prefixDefaultLocale = prefixAnswer === "" ? currentPrefixDefaultLocale : prefixAnswer === "y";
// Prompt 4: prefixDefaultLocale
const prefixPromptDefault = currentPrefixDefaultLocale ? "y" : "n";
const prefixAnswer = (await ask(`\nPrefix default locale in URLs? (/en/about vs /about) (y/n) [${prefixPromptDefault}]: `)).trim().toLowerCase();
prefixDefaultLocale = prefixAnswer === "" ? currentPrefixDefaultLocale : prefixAnswer === "y";
}

// ── Confirm ───────────────────────────────────────────────────────────────
console.log(`\nNew config:`);
if (isSingleLanguage) {
console.log(` Mode: Single-language`);
} else {
console.log(` Mode: Multi-language`);
}
console.log(` defaultLocale: "${newDefaultLocale}"`);
console.log(` locales: [${newLocales.join(", ")}]`);
console.log(` prefixDefaultLocale: ${prefixDefaultLocale}`);
Expand All @@ -649,6 +770,15 @@ async function configI18n() {

console.log();

// ── Create single-language marker if needed ───────────────────────────────
if (isSingleLanguage) {
try {
await fs.writeFile(join(root, ".i18n-single-language"), JSON.stringify({ locale: newDefaultLocale, version: "1" }, null, 2));
} catch (err) {
console.warn(` Warning: Could not write .i18n-single-language marker: ${err.message}`);
}
}

// ── Determine operations ──────────────────────────────────────────────────
const { localesToAdd, localesToRemove, editOldDefaultToNewDefault } = determineOperations({
defaultLocale,
Expand Down Expand Up @@ -698,6 +828,17 @@ async function configI18n() {
console.log("\nPhase G: src/content/...");
await patchContentFolders(ops);

// ── Phase H ───────────────────────────────────────────────────────────────
console.log("\nPhase H: src/components/...");
await patchComponents(ops);

// ── Create .i18n-configured marker ───────────────────────────────────────
try {
await fs.writeFile(join(root, ".i18n-configured"), JSON.stringify({ timestamp: new Date().toISOString(), version: "1" }, null, 2) + "\n", "utf-8");
} catch (err) {
console.warn(` Warning: Could not write .i18n-configured marker: ${err.message}`);
}

// ── Summary ───────────────────────────────────────────────────────────────
console.log("\n...done!\n");
console.log("=====================================");
Expand All @@ -706,10 +847,24 @@ async function configI18n() {

console.log("Next steps:");
let step = 1;
if (localesToAdd.length > 0) {
console.log(`${step++}. Translate strings in src/locales/${localesToAdd.join("/ and src/locales/")}/`);
console.log(`${step++}. Update route slugs in src/config/routeTranslations.ts`);
console.log(`${step++}. Review auto-generated localeMap values in src/config/siteSettings.ts`);

if (isSingleLanguage) {
console.log(`${step++}. Components with locale-specific imports were auto-patched (Phase H).`);
console.log(` Remaining manual updates:`);
console.log(" - src/components/LanguageSwitch/TwoLocalesSelect.astro");
console.log(" - src/components/LanguageSwitch/MultiLocalesSelect.astro");
console.log(" - src/components/Settings/Settings.astro (remove language switcher)");
console.log(`${step++}. Optional: Use wrapper functions from translationUtils:`);
console.log(" - getLocalizedRoute(locale, path) still works with single locale");
console.log(" - getLocalizedPathname(locale, url) still works with single locale");
console.log(`${step++}. Note: Keep locales array with single entry for internal consistency`);
console.log(`${step++}. Marker file created: .i18n-single-language and .i18n-configured`);
} else {
if (localesToAdd.length > 0) {
console.log(`${step++}. Translate strings in src/locales/${localesToAdd.join("/ and src/locales/")}/`);
console.log(`${step++}. Update route slugs in src/config/routeTranslations.ts`);
console.log(`${step++}. Review auto-generated localeMap values in src/config/siteSettings.ts`);
}
}
console.log(`${step++}. Run \`npm run dev\` to verify the site loads`);
console.log();
Expand Down
Loading