From 09a49d98700d0b26e00fb9555d05ff09bba12ecd Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:11:23 -0700 Subject: [PATCH 1/4] add generate localized urls --- .../src/compiler/runtime/create-runtime.ts | 2 + .../runtime/generate-static-localized-urls.js | 110 ++++++++++++ .../generate-static-localized-urls.test.ts | 158 ++++++++++++++++++ .../paraglide-js/src/compiler/runtime/type.ts | 1 + 4 files changed, 271 insertions(+) create mode 100644 inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.js create mode 100644 inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.test.ts diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/create-runtime.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/create-runtime.ts index 395e0f1bca..ef87fb9bc6 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/create-runtime.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/create-runtime.ts @@ -129,6 +129,8 @@ ${injectCode("./localize-href.js")} ${injectCode("./track-message-call.js")} +${injectCode("./generate-static-localized-urls.js")} + // ------ TYPES ------ /** diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.js b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.js new file mode 100644 index 0000000000..573ec7ed3f --- /dev/null +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.js @@ -0,0 +1,110 @@ +import { localizeUrl } from "./localize-url.js"; +import { + locales, + baseLocale, + TREE_SHAKE_DEFAULT_URL_PATTERN_USED, + urlPatterns, +} from "./variables.js"; + +/** + * Generates a list of localized URLs for all provided URLs. + * + * This is useful for SSG (Static Site Generation) and sitemap generation. + * NextJS and other frameworks use this function for SSG. + * + * @example + * ```typescript + * const urls = generateStaticLocalizedUrls([ + * "https://example.com/about", + * "https://example.com/blog", + * ]); + * urls[0].href // => "https://example.com/about" + * urls[1].href // => "https://example.com/blog" + * urls[2].href // => "https://example.com/de/about" + * urls[3].href // => "https://example.com/de/blog" + * ... + * ``` + * + * @param {(string | URL)[]} urls - List of URLs to generate localized versions for. Can be absolute URLs or paths. + * @returns {URL[]} List of localized URLs as URL objects + */ +export function generateStaticLocalizedUrls(urls) { + const localizedUrls = new Set(); + + // For default URL pattern, we can optimize the generation + if (TREE_SHAKE_DEFAULT_URL_PATTERN_USED) { + for (const urlInput of urls) { + const url = + urlInput instanceof URL + ? urlInput + : new URL(urlInput, "http://localhost"); + + // Base locale doesn't get a prefix + localizedUrls.add(url); + + // Other locales get their code as prefix + for (const locale of locales) { + if (locale !== baseLocale) { + const localizedPath = `/${locale}${url.pathname}${url.search}${url.hash}`; + const localizedUrl = new URL(localizedPath, url.origin); + localizedUrls.add(localizedUrl); + } + } + } + return Array.from(localizedUrls); + } + + // For custom URL patterns, we need to use localizeUrl for each URL and locale + for (const urlInput of urls) { + const url = + urlInput instanceof URL + ? urlInput + : new URL(urlInput, "http://localhost"); + + // Try each URL pattern to find one that matches + let patternFound = false; + for (const pattern of urlPatterns) { + try { + // Try to match the unlocalized pattern + const unlocalizedMatch = new URLPattern(pattern.pattern, url.href).exec( + url.href + ); + + if (!unlocalizedMatch) continue; + + patternFound = true; + + // Track unique localized URLs to avoid duplicates when patterns are the same + const seenUrls = new Set(); + + // Generate localized URL for each locale + for (const [locale] of pattern.localized) { + try { + const localizedUrl = localizeUrl(url, { locale }); + const urlString = localizedUrl.href; + + // Only add if we haven't seen this exact URL before + if (!seenUrls.has(urlString)) { + seenUrls.add(urlString); + localizedUrls.add(localizedUrl); + } + } catch { + // Skip if localization fails for this locale + continue; + } + } + break; + } catch { + // Skip if pattern matching fails + continue; + } + } + + // If no pattern matched, use the URL as is + if (!patternFound) { + localizedUrls.add(url); + } + } + + return Array.from(localizedUrls); +} diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.test.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.test.ts new file mode 100644 index 0000000000..f6b4406af2 --- /dev/null +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.test.ts @@ -0,0 +1,158 @@ +import { test, expect } from "vitest"; +import { createRuntimeForTesting } from "./create-runtime.js"; +import "@inlang/paraglide-js/urlpattern-polyfill"; + +test("generates localized URLs using default URL pattern", async () => { + const runtime = await createRuntimeForTesting({ + baseLocale: "en", + locales: ["en", "de", "fr"], + compilerOptions: { + strategy: ["url"], + // undefined creates the default pattern + urlPatterns: undefined, + }, + }); + + const urls = runtime.generateStaticLocalizedUrls([ + "http://example.com/about", + "http://example.com/blog/post-1", + "http://example.com/contact", + ]); + const pathnames = urls.map((url) => url.pathname).sort(); + + expect(pathnames).toEqual([ + "/about", + "/blog/post-1", + "/contact", + "/de/about", + "/de/blog/post-1", + "/de/contact", + "/fr/about", + "/fr/blog/post-1", + "/fr/contact", + ]); + + // All URLs should be URL objects + expect(urls.every((url) => url instanceof URL)).toBe(true); +}); + +test("generates localized URLs using custom URL patterns", async () => { + const runtime = await createRuntimeForTesting({ + baseLocale: "en", + locales: ["en", "de"], + compilerOptions: { + strategy: ["url"], + urlPatterns: [ + { + pattern: "/store/item/:id", + localized: [ + ["en", "/store/item/:id"], + ["de", "/laden/artikel/:id"], + ], + }, + { + pattern: "/blog/:slug", + localized: [ + ["en", "/blog/:slug"], + ["de", "/blog/:slug"], // Same pattern for both locales + ], + }, + ], + }, + }); + + const urls = runtime.generateStaticLocalizedUrls([ + "https://example.com/store/item/123", + "https://example.com/store/item/456", + "https://example.com/blog/my-post", + ]); + + const pathnames = urls.map((url) => url.pathname).sort(); + + expect(pathnames).toEqual([ + "/blog/my-post", + "/laden/artikel/123", + "/laden/artikel/456", + "/store/item/123", + "/store/item/456", + ]); + + // All URLs should be URL objects + expect(urls.every((url) => url instanceof URL)).toBe(true); +}); + +test("handles paths that don't match any pattern by including them", async () => { + const runtime = await createRuntimeForTesting({ + baseLocale: "en", + locales: ["en", "de"], + compilerOptions: { + strategy: ["url"], + urlPatterns: [ + { + pattern: "https://example.com/store/:path*", + localized: [ + ["en", "https://example.com/store/:path*"], + ["de", "https://example.com/laden/:path*"], + ], + }, + ], + }, + }); + + const urls = runtime.generateStaticLocalizedUrls([ + "https://example.com/store/item/123", // Should match pattern + "https://example.com/about", // Should not match pattern + "https://example.com/contact", // Should not match pattern + ]); + + const pathnames = urls.map((url) => url.pathname).sort(); + + expect(pathnames).toEqual([ + "/about", + "/contact", + "/laden/item/123", + "/store/item/123", + ]); + + // All URLs should be URL objects + expect(urls.every((url) => url instanceof URL)).toBe(true); +}); + +test("handles URL objects as input", async () => { + const runtime = await createRuntimeForTesting({ + baseLocale: "en", + locales: ["en", "de"], + compilerOptions: { + strategy: ["url"], + urlPatterns: [ + { + pattern: "/store/:path*", + localized: [ + ["en", "/store/:path*"], + ["de", "/laden/:path*"], + ], + }, + ], + }, + }); + + const urls = runtime.generateStaticLocalizedUrls([ + new URL("https://example.com/store/item/123?color=blue#reviews"), + new URL("https://example.com/store/cart"), + new URL("https://example.com/about"), // Should not match pattern + ]); + + const pathnames = urls.map((url) => url.pathname).sort(); + + expect(pathnames).toEqual([ + "/about", + "/laden/cart", + "/laden/item/123", + "/store/cart", + "/store/item/123", + ]); + + // Verify search params and hash fragments are preserve + // All URLs should be URL objects + expect(urls.every((url) => url instanceof URL)).toBe(true); +}); diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/type.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/type.ts index b7c28312c8..3dcc7255db 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/type.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/type.ts @@ -26,5 +26,6 @@ export type Runtime = { extractLocaleFromUrl: typeof import("./extract-locale-from-url.js").extractLocaleFromUrl; extractLocaleFromRequest: typeof import("./extract-locale-from-request.js").extractLocaleFromRequest; extractLocaleFromCookie: typeof import("./extract-locale-from-cookie.js").extractLocaleFromCookie; + generateStaticLocalizedUrls: typeof import("./generate-static-localized-urls.js").generateStaticLocalizedUrls; trackMessageCall: typeof import("./track-message-call.js").trackMessageCall; }; From 2e958c5ff4ede840d7983e8d1b50759fcce81d93 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:55:51 -0700 Subject: [PATCH 2/4] add another test --- .../docs-api/runtime/-internal-.md | 41 +++++++++++++++++++ .../paraglide-js/docs-api/runtime/type.md | 4 ++ .../generate-static-localized-urls.test.ts | 35 ++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/inlang/packages/paraglide/paraglide-js/docs-api/runtime/-internal-.md b/inlang/packages/paraglide/paraglide-js/docs-api/runtime/-internal-.md index bb5eac9171..b9f8d001fc 100644 --- a/inlang/packages/paraglide/paraglide-js/docs-api/runtime/-internal-.md +++ b/inlang/packages/paraglide/paraglide-js/docs-api/runtime/-internal-.md @@ -371,6 +371,47 @@ The extracted locale, or undefined if no locale is found. *** +## generateStaticLocalizedUrls() + +> **generateStaticLocalizedUrls**(`urls`): `URL`[] + +Defined in: [runtime/generate-static-localized-urls.js:31](https://github.com/opral/monorepo/tree/main/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.js) + +Generates a list of localized URLs for all provided URLs. + +This is useful for SSG (Static Site Generation) and sitemap generation. +NextJS and other frameworks use this function for SSG. + +### Parameters + +#### urls + +(`string` \| `URL`)[] + +List of URLs to generate localized versions for. Can be absolute URLs or paths. + +### Returns + +`URL`[] + +List of localized URLs as URL objects + +### Example + +```typescript +const urls = generateStaticLocalizedUrls([ + "https://example.com/about", + "https://example.com/blog", +]); +urls[0].href // => "https://example.com/about" +urls[1].href // => "https://example.com/blog" +urls[2].href // => "https://example.com/de/about" +urls[3].href // => "https://example.com/de/blog" +... +``` + +*** + ## getLocale() > **getLocale**(): `any` diff --git a/inlang/packages/paraglide/paraglide-js/docs-api/runtime/type.md b/inlang/packages/paraglide/paraglide-js/docs-api/runtime/type.md index 3a5949e539..778a8d506b 100644 --- a/inlang/packages/paraglide/paraglide-js/docs-api/runtime/type.md +++ b/inlang/packages/paraglide/paraglide-js/docs-api/runtime/type.md @@ -48,6 +48,10 @@ The Paraglide runtime API. > **extractLocaleFromUrl**: [`extractLocaleFromUrl`](-internal-.md#extractlocalefromurl) +#### generateStaticLocalizedUrls + +> **generateStaticLocalizedUrls**: [`generateStaticLocalizedUrls`](-internal-.md#generatestaticlocalizedurls) + #### getLocale > **getLocale**: [`getLocale`](-internal-.md#getlocale) diff --git a/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.test.ts b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.test.ts index f6b4406af2..d5d4222da9 100644 --- a/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.test.ts +++ b/inlang/packages/paraglide/paraglide-js/src/compiler/runtime/generate-static-localized-urls.test.ts @@ -156,3 +156,38 @@ test("handles URL objects as input", async () => { // All URLs should be URL objects expect(urls.every((url) => url instanceof URL)).toBe(true); }); + +test("generates localized URLs from paths", async () => { + const runtime = await createRuntimeForTesting({ + baseLocale: "en", + locales: ["en", "de", "fr"], + compilerOptions: { + strategy: ["url"], + // undefined creates the default pattern + urlPatterns: undefined, + }, + }); + + const urls = runtime.generateStaticLocalizedUrls([ + "/about", + "/blog/post-1", + "/contact", + ]); + + const pathnames = urls.map((url) => url.pathname).sort(); + + expect(pathnames).toEqual([ + "/about", + "/blog/post-1", + "/contact", + "/de/about", + "/de/blog/post-1", + "/de/contact", + "/fr/about", + "/fr/blog/post-1", + "/fr/contact", + ]); + + // All URLs should be URL objects + expect(urls.every((url) => url instanceof URL)).toBe(true); +}); From 458c4397e99fee69a844ade9e23e633bb5236c46 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:58:37 -0700 Subject: [PATCH 3/4] add changelog --- .../packages/paraglide/paraglide-js/CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/inlang/packages/paraglide/paraglide-js/CHANGELOG.md b/inlang/packages/paraglide/paraglide-js/CHANGELOG.md index 613fd97d87..c6021babd8 100644 --- a/inlang/packages/paraglide/paraglide-js/CHANGELOG.md +++ b/inlang/packages/paraglide/paraglide-js/CHANGELOG.md @@ -50,6 +50,22 @@ After - make `setLocale()` set all strategies. Setting all strategies aligns with user expectations and ensures that server APIs can receive the cookie of the client, for example. [#439](https://github.com/opral/inlang-paraglide-js/issues/439) +- new `generateStaticLocalizedUrls()` API [#443](https://github.com/opral/inlang-paraglide-js/issues/433) + +```diff +const localizedUrls = generateStaticLocalizedUrls([ + "/example", + "/page/blog", + "/123/hello" +]) + +console.log(localizedUrls.map(url => url.pathnames)) +>> /de/example +>> /fr/example +>> ... +``` + + ## 2.0.0-beta.27 - fix wrong matching in API requests [#427](https://github.com/opral/inlang-paraglide-js/issues/427) From 8b0fbfca6a8787bc4c42a9dbc0094d72c7298fb0 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:59:01 -0700 Subject: [PATCH 4/4] version --- inlang/packages/paraglide/paraglide-js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inlang/packages/paraglide/paraglide-js/package.json b/inlang/packages/paraglide/paraglide-js/package.json index 0a0391dd06..93208d5574 100644 --- a/inlang/packages/paraglide/paraglide-js/package.json +++ b/inlang/packages/paraglide/paraglide-js/package.json @@ -1,7 +1,7 @@ { "name": "@inlang/paraglide-js", "type": "module", - "version": "2.0.0-beta.27", + "version": "2.0.0-beta.28", "license": "MIT", "publishConfig": { "access": "public",