From 59b7538b24d8ab5b1ffbe5b2b250f4d6837f06d9 Mon Sep 17 00:00:00 2001 From: Roberto Simonetti Date: Mon, 4 Dec 2023 10:32:37 +0100 Subject: [PATCH 1/3] Feat: domain-base routing (#99) --- packages/qwik-speak/src/index.ts | 18 +- packages/qwik-speak/src/localize-path.ts | 66 ----- packages/qwik-speak/src/routing.ts | 246 ++++++++++++++++++ packages/qwik-speak/src/translate-path.ts | 124 --------- packages/qwik-speak/src/types.ts | 19 ++ packages/qwik-speak/src/use-qwik-speak.tsx | 6 +- packages/qwik-speak/src/validate-locale.ts | 9 - .../qwik-speak/tests/localize-path.test.tsx | 2 +- .../qwik-speak/tests/translate-path.test.tsx | 2 +- 9 files changed, 282 insertions(+), 210 deletions(-) delete mode 100644 packages/qwik-speak/src/localize-path.ts create mode 100644 packages/qwik-speak/src/routing.ts delete mode 100644 packages/qwik-speak/src/translate-path.ts delete mode 100644 packages/qwik-speak/src/validate-locale.ts diff --git a/packages/qwik-speak/src/index.ts b/packages/qwik-speak/src/index.ts index f38518a..666faa5 100644 --- a/packages/qwik-speak/src/index.ts +++ b/packages/qwik-speak/src/index.ts @@ -6,25 +6,21 @@ export type { SpeakConfig, SpeakState, LoadTranslationFn, - RewriteRouteOption + RewriteRouteOption, + DomainBasedRoutingOption } from './types'; export type { QwikSpeakProps, QwikSpeakMockProps } from './use-qwik-speak'; export type { SpeakProps } from './use-speak'; -export type { LocalizePathFn } from './localize-path'; -export type { TranslatePathFn } from './translate-path'; export type { InlinePluralFn } from './inline-plural'; export type { InlineTranslateFn } from './inline-translate'; export type { FormatDateFn } from './use-format-date'; export type { FormatNumberFn } from './use-format-number'; export type { RelativeTimeFn } from './use-relative-time'; export type { DisplayNameFn } from './use-display-name'; +export type { LocalizePathFn, TranslatePathFn } from './routing'; // Inline functions export { inlineTranslate } from './inline-translate'; export { inlinePlural } from './inline-plural'; -// Functions -export { localizePath } from './localize-path'; -export { translatePath } from './translate-path'; -export { validateLocale } from './validate-locale'; // Use functions export { useQwikSpeak } from './use-qwik-speak'; export { useSpeak } from './use-speak'; @@ -37,5 +33,13 @@ export { useSpeakLocale, useSpeakConfig, } from './use-functions'; +// Routing +export { + localizePath, + translatePath, + validateLocale, + extractFromDomain, + extractFromUrl +} from './routing'; // Testing export { QwikSpeakMockProvider } from './use-qwik-speak'; diff --git a/packages/qwik-speak/src/localize-path.ts b/packages/qwik-speak/src/localize-path.ts deleted file mode 100644 index 6d481c5..0000000 --- a/packages/qwik-speak/src/localize-path.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { getLang, getSpeakContext } from './context'; - -export type LocalizePathFn = { - /** - * Localize a path with the language - * @param pathname The path to localize - * @param lang Optional language if different from the default one - * @returns The localized path - */ - (pathname: string, lang?: string): string; - /** - * Localize an url with the language - * @param url The url to localize - * @param lang Optional language if different from the default one - * @returns The localized url - */ - (url: URL, lang?: string): string; - /** - * Localize an array of paths with the language - * @param pathnames The array of paths to localize - * @param lang Optional language if different from the default one - * @returns The localized paths - */ - (pathnames: string[], lang?: string): string[]; -}; - -export const localizePath = (): LocalizePathFn => { - const currentLang = getLang(); - - const getRegEpx = (lang: string) => new RegExp(`(/${lang}/)|(/${lang}$)|(/(${lang})(?=\\?))`); - - const replace = (pathname: string, lang?: string) => { - const { config } = getSpeakContext(); - - lang ??= currentLang; - - const langParam = config.supportedLocales.find(locale => getRegEpx(locale.lang)?.test(pathname))?.lang; - if (langParam) { - if (lang !== config.defaultLocale.lang) { - pathname = pathname.replace(langParam, lang); - } else { - pathname = pathname.replace(getRegEpx(langParam), '/'); - } - } else if (lang !== config.defaultLocale.lang) { - pathname = `/${lang}${pathname}`; - } - - return pathname; - }; - - const localize = (route: (string | URL) | string[], lang?: string) => { - if (Array.isArray(route)) { - return route.map(path => replace(path, lang)); - } - - if (typeof route === 'string') { - return replace(route, lang); - } - - route.pathname = replace(route.pathname, lang); - - return route.toString().replace(/\/\?/, '?'); - }; - - return localize as LocalizePathFn; -}; diff --git a/packages/qwik-speak/src/routing.ts b/packages/qwik-speak/src/routing.ts new file mode 100644 index 0000000..d10b7f6 --- /dev/null +++ b/packages/qwik-speak/src/routing.ts @@ -0,0 +1,246 @@ +import { isDev } from '@builder.io/qwik/build'; + +import type { SpeakLocale } from './types'; +import { getLang, getSpeakContext } from './context'; +import { logWarn } from './log'; + +export type LocalizePathFn = { + /** + * Localize a path with the language + * @param pathname The path to localize + * @param lang Optional language if different from the default one + * @returns The localized path + */ + (pathname: string, lang?: string): string; + /** + * Localize an url with the language + * @param url The url to localize + * @param lang Optional language if different from the default one + * @returns The localized url + */ + (url: URL, lang?: string): string; + /** + * Localize an array of paths with the language + * @param pathnames The array of paths to localize + * @param lang Optional language if different from the default one + * @returns The localized paths + */ + (pathnames: string[], lang?: string): string[]; +}; + +export type TranslatePathFn = { + /** + * Translate a path + * @param pathname The path to translate + * @param lang Optional language if different from the default one + * @returns The translation or the path if not found + */ + (pathname: string, lang?: string): string; + /** + * Translate an url + * @param url The url to translate + * @param lang Optional language if different from the default one + * @returns The translation or the url if not found + */ + (url: URL, lang?: string): string; + /** + * Translate an array of paths + * @param pathname The array of paths to translate + * @param lang Optional language if different from the default one + * @returns The translations or the paths if not found + */ + (pathnames: string[], lang?: string): string[]; +}; + +export const localizePath = (): LocalizePathFn => { + const { config } = getSpeakContext(); + const currentLang = getLang(); + + const getRegEpx = (lang: string) => new RegExp(`(/${lang}/)|(/${lang}$)|(/(${lang})(?=\\?))`); + + const replace = (pathname: string, lang: string) => { + const langParam = config.supportedLocales.find(locale => getRegEpx(locale.lang)?.test(pathname))?.lang; + + // Handle prefix + if (langParam) { + if (lang !== config.defaultLocale.lang) { + pathname = pathname.replace(langParam, lang); + } else { + pathname = pathname.replace(getRegEpx(langParam), '/'); + } + } else if (lang !== config.defaultLocale.lang) { + pathname = `/${lang}${pathname}`; + } + + // In prod, handle no prefix in domain-based routing + if (!isDev) { + if (config.domainBasedRouting?.prefix === 'as-needed' && isDefaultDomain(lang)) { + pathname = pathname.replace(getRegEpx(lang), '/'); + } + } + + return pathname; + }; + + const localize = (route: (string | URL) | string[], lang?: string) => { + lang ??= currentLang; + + if (Array.isArray(route)) { + return route.map(path => replace(path, lang!)); + } + + if (typeof route === 'string') { + return replace(route, lang); + } + + // Is URL + if (config.domainBasedRouting) { + route = localizeDomain(route, lang); + } + + route.pathname = replace(route.pathname, lang); + + // Return URL + return route.toString().replace(/\/\?/, '?'); + }; + + return localize as LocalizePathFn; +}; + +export const translatePath = (): TranslatePathFn => { + const { config } = getSpeakContext(); + const currentLang = getLang(); + + const normalizePath = (pathname: string) => { + const source = config.rewriteRoutes?.find(rewrite => ( + pathname === `/${rewrite.prefix}` || + pathname.startsWith(`/${rewrite.prefix}/`) || + pathname.startsWith(`${rewrite.prefix}/`) + )); + + if (source) { + pathname = pathname === `/${source.prefix}` + ? '/' + : pathname.startsWith('/') + ? pathname.substring(`/${source.prefix}`.length) + : pathname.substring(`${source.prefix}/`.length); + + const sourceEntries = Object.entries(source.paths).map(([from, to]) => [to, from]); + const revertedPaths = Object.fromEntries(sourceEntries); + + const splitted = pathname.split('/'); + const translated = splitted.map(part => revertedPaths[part] ?? part); + return translated.join('/'); + } + + return pathname; + } + + const rewritePath = (pathname: string, prefix: string) => { + let splitted = pathname.split('/'); + + const destination = config.rewriteRoutes?.find( + rewrite => (rewrite.prefix === prefix) + ); + + if (prefix && destination) { // the input prefix is present and not for the defaultLocale + const keys = Object.keys(destination.paths); + const translating = splitted.some(part => keys.includes(part)); + + if ((pathname === '/') || translating) { + pathname = pathname.startsWith('/') + ? `/${prefix}` + pathname + : `${prefix}/` + pathname; + } + } + + splitted = pathname.split('/'); + const translated = splitted.map(part => destination?.paths[part] ?? part); + return translated.join('/') + } + + const slashPath = (pathname: string, rewrote: string) => { + if (pathname.endsWith('/') && !rewrote.endsWith('/')) { + return rewrote + '/'; + } else if (!pathname.endsWith('/') && rewrote.endsWith('/') && (rewrote !== '/')) { + return rewrote.substring(0, rewrote.length - 1); + } + + return rewrote; + } + + const translateOne = (pathname: string, lang: string) => { + const normalized = normalizePath(pathname); + const rewrote = rewritePath(normalized, lang); + return slashPath(pathname, rewrote); + }; + + const translate = (route: (string | URL) | string[], lang?: string) => { + lang ??= currentLang; + + if (!config.rewriteRoutes) { + if (isDev) logWarn(`translatePath: rewriteRoutes not found`); + return route; + } + + if (Array.isArray(route)) { + return route.map(path => translateOne(path, lang!)); + } + + if (typeof route === 'string') { + return translateOne(route, lang); + } + + route.pathname = translateOne(route.pathname, lang); + + return route.toString(); + }; + + return translate as TranslatePathFn; +}; + +export const localizeDomain = (route: URL, lang: string): URL => { + const { config } = getSpeakContext(); + + let domain = config.supportedLocales.find(value => value.lang === lang)?.domain; + if (!domain) { + domain = config.supportedLocales.find(value => value.lang === lang)?.withDomain; + } + if (!domain) { + if (isDev) logWarn(`localizeDomain: domain not found`); + } else if (!isDev) { + route.hostname = domain; + } + return route; +}; + +export const isDefaultDomain = (lang: string): boolean => { + const { config } = getSpeakContext(); + return config.supportedLocales.find(value => value.lang === lang)?.domain !== undefined; +}; + +/** + * Extract lang from domain + */ +export const extractFromDomain = (route: URL, supportedLocales: SpeakLocale[]): string | undefined => { + const hostname = route.hostname; + return supportedLocales.find(value => value.domain === hostname)?.lang; +}; + +/** + * Extract lang from url + */ +export const extractFromUrl = (route: URL): string => { + const parts = route.pathname.split('/'); + return route.pathname.startsWith('/') ? parts[1] : parts[0]; +}; + +/** + * Validate language[-script][-region] + * - `language` ISO 639 two-letter or three-letter code + * - `script` ISO 15924 four-letter script code + * - `region` ISO 3166 two-letter, uppercase code + */ +export const validateLocale = (lang: string): boolean => { + return /^([a-z]{2,3})(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/.test(lang); +}; \ No newline at end of file diff --git a/packages/qwik-speak/src/translate-path.ts b/packages/qwik-speak/src/translate-path.ts deleted file mode 100644 index 3e85cdc..0000000 --- a/packages/qwik-speak/src/translate-path.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { isDev } from '@builder.io/qwik/build'; - -import { getLang, getSpeakContext } from './context'; -import { logWarn } from './log'; - -export type TranslatePathFn = { - /** - * Translate a path - * @param pathname The path to translate - * @param lang Optional language if different from the default one - * @returns The translation or the path if not found - */ - (pathname: string, lang?: string): string; - /** - * Translate an url - * @param url The url to translate - * @param lang Optional language if different from the default one - * @returns The translation or the url if not found - */ - (url: URL, lang?: string): string; - /** - * Translate an array of paths - * @param pathname The array of paths to translate - * @param lang Optional language if different from the default one - * @returns The translations or the paths if not found - */ - (pathnames: string[], lang?: string): string[]; -}; - -export const translatePath = (): TranslatePathFn => { - const currentLang = getLang(); - - const normalizePath = (pathname: string) => { - const { config } = getSpeakContext(); - - const source = config.rewriteRoutes?.find(rewrite => ( - pathname === `/${rewrite.prefix}` || - pathname.startsWith(`/${rewrite.prefix}/`) || - pathname.startsWith(`${rewrite.prefix}/`) - )); - - if (source) { - pathname = pathname === `/${source.prefix}` - ? '/' - : pathname.startsWith('/') - ? pathname.substring(`/${source.prefix}`.length) - : pathname.substring(`${source.prefix}/`.length); - - const sourceEntries = Object.entries(source.paths).map(([from, to]) => [to, from]); - const revertedPaths = Object.fromEntries(sourceEntries); - - const splitted = pathname.split('/'); - const translated = splitted.map(part => revertedPaths[part] ?? part); - return translated.join('/'); - } - - return pathname; - } - - const rewritePath = (pathname: string, prefix?: string) => { - const { config } = getSpeakContext(); - let splitted = pathname.split('/'); - - const destination = config.rewriteRoutes?.find( - rewrite => (rewrite.prefix === prefix) - ); - - if (prefix && destination) { // the input prefix is present and not for the defaultLocale - const keys = Object.keys(destination.paths); - const translating = splitted.some(part => keys.includes(part)); - - if ((pathname === '/') || translating) { - pathname = pathname.startsWith('/') - ? `/${prefix}` + pathname - : `${prefix}/` + pathname; - } - } - - splitted = pathname.split('/'); - const translated = splitted.map(part => destination?.paths[part] ?? part); - return translated.join('/') - } - - const slashPath = (pathname: string, rewrote: string) => { - if (pathname.endsWith('/') && !rewrote.endsWith('/')) { - return rewrote + '/'; - } else if (!pathname.endsWith('/') && rewrote.endsWith('/') && (rewrote !== '/')) { - return rewrote.substring(0, rewrote.length - 1); - } - - return rewrote; - } - - const translateOne = (pathname: string, lang?: string) => { - lang ??= currentLang; - - const normalized = normalizePath(pathname); - const rewrote = rewritePath(normalized, lang); - return slashPath(pathname, rewrote); - }; - - const translate = (route: (string | URL) | string[], lang?: string) => { - const { config } = getSpeakContext(); - - if (!config.rewriteRoutes) { - if (isDev) logWarn(`translatePath: rewriteRoutes not found`); - return route; - } - - if (Array.isArray(route)) { - return route.map(path => translateOne(path, lang)); - } - - if (typeof route === 'string') { - return translateOne(route, lang); - } - - route.pathname = translateOne(route.pathname, lang); - - return route.toString(); - }; - - return translate as TranslatePathFn; -}; diff --git a/packages/qwik-speak/src/types.ts b/packages/qwik-speak/src/types.ts index 67f8aca..6a372ba 100644 --- a/packages/qwik-speak/src/types.ts +++ b/packages/qwik-speak/src/types.ts @@ -30,6 +30,14 @@ export interface SpeakLocale { * Text direction */ dir?: 'ltr' | 'rtl' | 'auto'; + /** + * In domain-based routing, set the default domain for the locale + */ + domain?: string; + /** + * In domain-based routing, set another domain for the locale + */ + withDomain?: string; } /** @@ -61,6 +69,13 @@ export interface RewriteRouteOption { paths: Record; } +export interface DomainBasedRoutingOption { + /** + * Always use the lang prefix in domain-based routing, or as needed + */ + prefix: 'always' | 'as-needed' +} + export interface SpeakConfig { /** * The default locale to use as fallback @@ -91,6 +106,10 @@ export interface SpeakConfig { * Rewrite routes as specified in Vite config for qwikCity */ rewriteRoutes?: RewriteRouteOption[]; + /** + * Domain-based routing options + */ + domainBasedRouting?: DomainBasedRoutingOption } export interface SpeakState { diff --git a/packages/qwik-speak/src/use-qwik-speak.tsx b/packages/qwik-speak/src/use-qwik-speak.tsx index dc15c37..cb36288 100644 --- a/packages/qwik-speak/src/use-qwik-speak.tsx +++ b/packages/qwik-speak/src/use-qwik-speak.tsx @@ -64,7 +64,8 @@ export const useQwikSpeak = (props: QwikSpeakProps) => { assets: props.config.assets, runtimeAssets: props.config.runtimeAssets, keySeparator: props.config.keySeparator || '.', - keyValueSeparator: props.config.keyValueSeparator || '@@' + keyValueSeparator: props.config.keyValueSeparator || '@@', + domainBasedRouting: props.config.domainBasedRouting }; // Resolve functions @@ -158,7 +159,8 @@ export const QwikSpeakMockProvider = component$(props => { assets: props.config.assets, runtimeAssets: props.config.runtimeAssets, keySeparator: props.config.keySeparator || '.', - keyValueSeparator: props.config.keyValueSeparator || '@@' + keyValueSeparator: props.config.keyValueSeparator || '@@', + domainBasedRouting: props.config.domainBasedRouting }; // Resolve functions diff --git a/packages/qwik-speak/src/validate-locale.ts b/packages/qwik-speak/src/validate-locale.ts deleted file mode 100644 index 90031f5..0000000 --- a/packages/qwik-speak/src/validate-locale.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Validate language[-script][-region] - * - `language` ISO 639 two-letter or three-letter code - * - `script` ISO 15924 four-letter script code - * - `region` ISO 3166 two-letter, uppercase code - */ -export const validateLocale = (lang: string): boolean => { - return /^([a-z]{2,3})(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/.test(lang); -}; diff --git a/packages/qwik-speak/tests/localize-path.test.tsx b/packages/qwik-speak/tests/localize-path.test.tsx index b2e3797..df4ab6e 100644 --- a/packages/qwik-speak/tests/localize-path.test.tsx +++ b/packages/qwik-speak/tests/localize-path.test.tsx @@ -2,7 +2,7 @@ import { createDOM } from '@builder.io/qwik/testing'; import { component$ } from '@builder.io/qwik'; import { test, describe, expect } from 'vitest'; -import { localizePath } from '../src'; +import { localizePath } from '../src/routing'; import { QwikSpeakMockProvider } from '../src/use-qwik-speak'; import { config, translationFnStub } from './config'; diff --git a/packages/qwik-speak/tests/translate-path.test.tsx b/packages/qwik-speak/tests/translate-path.test.tsx index dc25de3..0d4a11c 100644 --- a/packages/qwik-speak/tests/translate-path.test.tsx +++ b/packages/qwik-speak/tests/translate-path.test.tsx @@ -2,7 +2,7 @@ import { createDOM } from '@builder.io/qwik/testing'; import { component$ } from '@builder.io/qwik'; import { test, describe, expect } from 'vitest'; -import { translatePath } from '../src/translate-path'; +import { translatePath } from '../src/routing'; import { QwikSpeakMockProvider } from '../src/use-qwik-speak'; import { config, translationFnStub } from './config'; From 41e255d244c675ae8621993609aace937d7cb597 Mon Sep 17 00:00:00 2001 From: Roberto Simonetti Date: Tue, 5 Dec 2023 16:59:33 +0100 Subject: [PATCH 2/3] Feat: domain-based routing with url rewriting (#99) --- packages/qwik-speak/src/index.ts | 3 +- packages/qwik-speak/src/routing.ts | 149 +++++++++++++++++----- packages/qwik-speak/src/types.ts | 14 +- packages/qwik-speak/tools/core/routing.ts | 16 +++ packages/qwik-speak/tools/core/types.ts | 24 ++++ packages/qwik-speak/tools/inline/index.ts | 3 +- src/routes/plugin.ts | 24 ++-- src/speak-routes.ts | 7 +- 8 files changed, 188 insertions(+), 52 deletions(-) create mode 100644 packages/qwik-speak/tools/core/routing.ts diff --git a/packages/qwik-speak/src/index.ts b/packages/qwik-speak/src/index.ts index 666faa5..7ef3ae9 100644 --- a/packages/qwik-speak/src/index.ts +++ b/packages/qwik-speak/src/index.ts @@ -39,7 +39,8 @@ export { translatePath, validateLocale, extractFromDomain, - extractFromUrl + extractFromUrl, + toPrefixAsNeeded, } from './routing'; // Testing export { QwikSpeakMockProvider } from './use-qwik-speak'; diff --git a/packages/qwik-speak/src/routing.ts b/packages/qwik-speak/src/routing.ts index d10b7f6..b27c0e7 100644 --- a/packages/qwik-speak/src/routing.ts +++ b/packages/qwik-speak/src/routing.ts @@ -1,6 +1,6 @@ import { isDev } from '@builder.io/qwik/build'; -import type { SpeakLocale } from './types'; +import type { RewriteRouteOption, SpeakLocale } from './types'; import { getLang, getSpeakContext } from './context'; import { logWarn } from './log'; @@ -94,14 +94,15 @@ export const localizePath = (): LocalizePathFn => { } // Is URL + let url = new URL(route); if (config.domainBasedRouting) { - route = localizeDomain(route, lang); + url = localizeDomain(url, lang); } - route.pathname = replace(route.pathname, lang); + url.pathname = replace(url.pathname, lang); // Return URL - return route.toString().replace(/\/\?/, '?'); + return url.toString().replace(/\/\?/, '?'); }; return localize as LocalizePathFn; @@ -111,8 +112,12 @@ export const translatePath = (): TranslatePathFn => { const { config } = getSpeakContext(); const currentLang = getLang(); + /** + * To file-based routing + */ const normalizePath = (pathname: string) => { - const source = config.rewriteRoutes?.find(rewrite => ( + // Source by prefix + let source = config.rewriteRoutes?.find(rewrite => ( pathname === `/${rewrite.prefix}` || pathname.startsWith(`/${rewrite.prefix}/`) || pathname.startsWith(`${rewrite.prefix}/`) @@ -124,7 +129,20 @@ export const translatePath = (): TranslatePathFn => { : pathname.startsWith('/') ? pathname.substring(`/${source.prefix}`.length) : pathname.substring(`${source.prefix}/`.length); + } + + // In prod, source by current lang in domain-based routing + if (!isDev) { + if (config.domainBasedRouting?.prefix === 'as-needed') { + if (!source) { + source = config.rewriteRoutes?.find( + rewrite => !isEmpty(rewrite.paths) && rewrite.lang == currentLang + ); + } + } + } + if (source) { const sourceEntries = Object.entries(source.paths).map(([from, to]) => [to, from]); const revertedPaths = Object.fromEntries(sourceEntries); @@ -139,11 +157,12 @@ export const translatePath = (): TranslatePathFn => { const rewritePath = (pathname: string, prefix: string) => { let splitted = pathname.split('/'); - const destination = config.rewriteRoutes?.find( - rewrite => (rewrite.prefix === prefix) + let destination = config.rewriteRoutes?.find( + rewrite => rewrite.prefix === prefix ); - if (prefix && destination) { // the input prefix is present and not for the defaultLocale + // Destination by prefix + if (prefix && destination) { const keys = Object.keys(destination.paths); const translating = splitted.some(part => keys.includes(part)); @@ -154,6 +173,17 @@ export const translatePath = (): TranslatePathFn => { } } + // In prod, destination by lang in domain-based routing + if (!isDev) { + if (config.domainBasedRouting?.prefix === 'as-needed') { + if (prefix && !destination) { + destination = config.rewriteRoutes?.find( + rewrite => !isEmpty(rewrite.paths) && rewrite.lang === prefix + ); + } + } + } + splitted = pathname.split('/'); const translated = splitted.map(part => destination?.paths[part] ?? part); return translated.join('/') @@ -191,40 +221,30 @@ export const translatePath = (): TranslatePathFn => { return translateOne(route, lang); } - route.pathname = translateOne(route.pathname, lang); + // Is URL + let url = new URL(route); + if (config.domainBasedRouting) { + url = translateDomain(url, lang); + } + + url.pathname = translateOne(url.pathname, lang); - return route.toString(); + return url.toString(); }; return translate as TranslatePathFn; }; -export const localizeDomain = (route: URL, lang: string): URL => { - const { config } = getSpeakContext(); - - let domain = config.supportedLocales.find(value => value.lang === lang)?.domain; - if (!domain) { - domain = config.supportedLocales.find(value => value.lang === lang)?.withDomain; - } - if (!domain) { - if (isDev) logWarn(`localizeDomain: domain not found`); - } else if (!isDev) { - route.hostname = domain; - } - return route; -}; - -export const isDefaultDomain = (lang: string): boolean => { - const { config } = getSpeakContext(); - return config.supportedLocales.find(value => value.lang === lang)?.domain !== undefined; -}; - /** * Extract lang from domain */ -export const extractFromDomain = (route: URL, supportedLocales: SpeakLocale[]): string | undefined => { - const hostname = route.hostname; - return supportedLocales.find(value => value.domain === hostname)?.lang; +export const extractFromDomain = (route: URL, domains: SpeakLocale[] | RewriteRouteOption[]): string | undefined => { + const hostname = !route.hostname.startsWith('localhost') ? route.hostname : route.host; // with port + const domain = domains.find(value => value.domain === hostname); + return domain && + (('lang' in domain) ? domain.lang : + ('prefix' in domain) ? domain.prefix : + undefined) }; /** @@ -243,4 +263,65 @@ export const extractFromUrl = (route: URL): string => { */ export const validateLocale = (lang: string): boolean => { return /^([a-z]{2,3})(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/.test(lang); -}; \ No newline at end of file +}; + +/** + * In prod, handle the needed prefixes in domain-based routing + */ +export function toPrefixAsNeeded(rewriteRoutes: RewriteRouteOption[]): RewriteRouteOption[] { + if (isDev) return rewriteRoutes; + + const routes = rewriteRoutes.map(rewrite => + ({ + prefix: rewrite.domain ? undefined : rewrite.prefix, paths: rewrite.paths, + lang: rewrite.prefix ? rewrite.prefix : rewrite.lang, domain: rewrite.domain, withDomain: rewrite.withDomain + })); + + return routes; +} + +const localizeDomain = (url: URL, lang: string): URL => { + const { config } = getSpeakContext(); + + const locale = config.supportedLocales.find(value => value.lang === lang); + const domain = locale?.domain || locale?.withDomain; + + if (!domain) { + if (isDev) logWarn(`localizeDomain: domain not found`); + } else if (!isDev) { + if (!domain.startsWith('localhost')) { + url.hostname = domain; + } else { + url.host = domain; // with port + } + } + return url; +}; + +const translateDomain = (url: URL, lang: string): URL => { + const { config } = getSpeakContext(); + + const rewrite = config.rewriteRoutes?.find(value => + value.lang === lang || + value.prefix === lang); + const domain = rewrite?.domain || rewrite?.withDomain; + + if (!domain) { + if (isDev) logWarn(`translateDomain: domain not found`); + } else if (!isDev) { + if (!domain.startsWith('localhost')) { + url.hostname = domain; + } else { + url.host = domain; // with port + } + } + return url; +}; + +const isDefaultDomain = (lang: string): boolean => { + const { config } = getSpeakContext(); + return config.supportedLocales.find(value => value.lang === lang)?.domain !== undefined; +}; + +const isEmpty = (obj: unknown): obj is Record => + typeof obj === 'object' && obj !== null && Object.keys(obj).length === 0; diff --git a/packages/qwik-speak/src/types.ts b/packages/qwik-speak/src/types.ts index 6a372ba..4a15174 100644 --- a/packages/qwik-speak/src/types.ts +++ b/packages/qwik-speak/src/types.ts @@ -59,7 +59,7 @@ export interface TranslationFn { export interface RewriteRouteOption { /** - * Optional language + * Optional prefix */ prefix?: string; /** @@ -67,6 +67,18 @@ export interface RewriteRouteOption { * Key value pairs: folder name - translated value */ paths: Record; + /** + * In domain-based routing, provides the language when there is no prefix + */ + lang?: string; + /** + * In domain-based routing, set the default domain for the prefix + */ + domain?: string; + /** + * In domain-based routing, set another domain for the prefix + */ + withDomain?: string; } export interface DomainBasedRoutingOption { diff --git a/packages/qwik-speak/tools/core/routing.ts b/packages/qwik-speak/tools/core/routing.ts new file mode 100644 index 0000000..39532e0 --- /dev/null +++ b/packages/qwik-speak/tools/core/routing.ts @@ -0,0 +1,16 @@ +import type { RewriteRouteOption } from './types'; + +/** + * In prod, handle the needed prefixes in domain-based routing + */ +export function toPrefixAsNeeded(rewriteRoutes: RewriteRouteOption[], mode: string): RewriteRouteOption[] { + if (mode !== 'production') return rewriteRoutes; + + const routes = rewriteRoutes.map(rewrite => + ({ + prefix: rewrite.domain ? undefined : rewrite.prefix, paths: rewrite.paths, + lang: rewrite.prefix ? rewrite.prefix : rewrite.lang, domain: rewrite.domain, withDomain: rewrite.withDomain + })); + + return routes; +} diff --git a/packages/qwik-speak/tools/core/types.ts b/packages/qwik-speak/tools/core/types.ts index 55e33be..5b8031e 100644 --- a/packages/qwik-speak/tools/core/types.ts +++ b/packages/qwik-speak/tools/core/types.ts @@ -86,3 +86,27 @@ export interface QwikSpeakInlineOptions { * Translation data */ export type Translation = { [key: string]: any }; + +export interface RewriteRouteOption { + /** + * Optional prefix + */ + prefix?: string; + /** + * Translated segments. + * Key value pairs: folder name - translated value + */ + paths: Record; + /** + * In domain-based routing, provides the language when there is no prefix + */ + lang?: string; + /** + * In domain-based routing, set the default domain for the prefix + */ + domain?: string; + /** + * In domain-based routing, set another domain for the prefix + */ + withDomain?: string; +} diff --git a/packages/qwik-speak/tools/inline/index.ts b/packages/qwik-speak/tools/inline/index.ts index f8b02ba..d5e6ad5 100644 --- a/packages/qwik-speak/tools/inline/index.ts +++ b/packages/qwik-speak/tools/inline/index.ts @@ -1,3 +1,4 @@ -export type { QwikSpeakInlineOptions, Translation } from '../core/types'; +export type { QwikSpeakInlineOptions, Translation, RewriteRouteOption } from '../core/types'; export { qwikSpeakInline } from './plugin'; +export { toPrefixAsNeeded } from '../core/routing'; diff --git a/src/routes/plugin.ts b/src/routes/plugin.ts index 4177ccf..8d6e4be 100644 --- a/src/routes/plugin.ts +++ b/src/routes/plugin.ts @@ -2,7 +2,7 @@ import type { RequestHandler } from "@builder.io/qwik-city"; import { validateLocale } from 'qwik-speak'; import { config } from '../speak-config'; -// import { rewriteRoutes } from '../speak-routes'; +//import { rewriteRoutes } from '../speak-routes'; export const onRequest: RequestHandler = ({ params, locale, error }) => { let lang: string | undefined = undefined; @@ -24,14 +24,20 @@ export const onRequest: RequestHandler = ({ params, locale, error }) => { * Uncomment this lines to use url rewriting to translate paths. * Remove [..lang] from folders structure */ -// export const onRequest: RequestHandler = ({ url, locale }) => { -// const parts = url.pathname.split('/') -// const prefix = url.pathname.startsWith('/') ? parts[1] : parts[0] +// export const onRequest: RequestHandler = ({ locale, error, url }) => { +// let lang: string | undefined = undefined; -// const lang = rewriteRoutes.find( -// rewrite => rewrite.prefix === prefix -// )?.prefix +// const prefix = extractFromUrl(url); + +// if (prefix && validateLocale(prefix)) { +// // Check supported locales +// lang = config.supportedLocales.find(value => value.lang === prefix)?.lang; +// // 404 error page +// if (!lang) throw error(404, 'Page not found'); +// } else { +// lang = config.defaultLocale.lang; +// } // // Set Qwik locale -// locale(lang || config.defaultLocale.lang); -// }; \ No newline at end of file +// locale(lang); +// }; diff --git a/src/speak-routes.ts b/src/speak-routes.ts index ebb91ae..1d35b4c 100644 --- a/src/speak-routes.ts +++ b/src/speak-routes.ts @@ -4,12 +4,7 @@ import type { RewriteRouteOption } from 'qwik-speak'; * Translation paths */ export const rewriteRoutes: RewriteRouteOption[] = [ - // No prefix for default locale - // { - // paths: { - // 'page': 'page' - // } - // }, + // No prefix/paths for default locale { prefix: 'it-IT', paths: { From a26928e41ea94f5276367f7de550dd89830de303 Mon Sep 17 00:00:00 2001 From: Roberto Simonetti Date: Wed, 6 Dec 2023 11:23:41 +0100 Subject: [PATCH 3/3] Domain-based routing: fixes --- packages/qwik-speak/src/routing.ts | 35 ++++++++++++++--------- packages/qwik-speak/src/types.ts | 4 --- packages/qwik-speak/tools/core/routing.ts | 2 +- packages/qwik-speak/tools/core/types.ts | 4 --- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/qwik-speak/src/routing.ts b/packages/qwik-speak/src/routing.ts index b27c0e7..196bc52 100644 --- a/packages/qwik-speak/src/routing.ts +++ b/packages/qwik-speak/src/routing.ts @@ -52,6 +52,13 @@ export type TranslatePathFn = { (pathnames: string[], lang?: string): string[]; }; +interface InternalRewriteRouteOption extends RewriteRouteOption { + /** + * Set the language instead of the prefix + */ + lang?: string; +} + export const localizePath = (): LocalizePathFn => { const { config } = getSpeakContext(); const currentLang = getLang(); @@ -110,6 +117,7 @@ export const localizePath = (): LocalizePathFn => { export const translatePath = (): TranslatePathFn => { const { config } = getSpeakContext(); + const rewriteRoutes = config.rewriteRoutes as InternalRewriteRouteOption[]; const currentLang = getLang(); /** @@ -117,7 +125,7 @@ export const translatePath = (): TranslatePathFn => { */ const normalizePath = (pathname: string) => { // Source by prefix - let source = config.rewriteRoutes?.find(rewrite => ( + let source = rewriteRoutes?.find(rewrite => ( pathname === `/${rewrite.prefix}` || pathname.startsWith(`/${rewrite.prefix}/`) || pathname.startsWith(`${rewrite.prefix}/`) @@ -135,8 +143,8 @@ export const translatePath = (): TranslatePathFn => { if (!isDev) { if (config.domainBasedRouting?.prefix === 'as-needed') { if (!source) { - source = config.rewriteRoutes?.find( - rewrite => !isEmpty(rewrite.paths) && rewrite.lang == currentLang + source = rewriteRoutes?.find( + rewrite => rewrite.lang == currentLang ); } } @@ -157,7 +165,7 @@ export const translatePath = (): TranslatePathFn => { const rewritePath = (pathname: string, prefix: string) => { let splitted = pathname.split('/'); - let destination = config.rewriteRoutes?.find( + let destination = rewriteRoutes?.find( rewrite => rewrite.prefix === prefix ); @@ -177,8 +185,8 @@ export const translatePath = (): TranslatePathFn => { if (!isDev) { if (config.domainBasedRouting?.prefix === 'as-needed') { if (prefix && !destination) { - destination = config.rewriteRoutes?.find( - rewrite => !isEmpty(rewrite.paths) && rewrite.lang === prefix + destination = rewriteRoutes?.find( + rewrite => rewrite.lang === prefix ); } } @@ -208,7 +216,7 @@ export const translatePath = (): TranslatePathFn => { const translate = (route: (string | URL) | string[], lang?: string) => { lang ??= currentLang; - if (!config.rewriteRoutes) { + if (!rewriteRoutes) { if (isDev) logWarn(`translatePath: rewriteRoutes not found`); return route; } @@ -274,7 +282,7 @@ export function toPrefixAsNeeded(rewriteRoutes: RewriteRouteOption[]): RewriteRo const routes = rewriteRoutes.map(rewrite => ({ prefix: rewrite.domain ? undefined : rewrite.prefix, paths: rewrite.paths, - lang: rewrite.prefix ? rewrite.prefix : rewrite.lang, domain: rewrite.domain, withDomain: rewrite.withDomain + lang: rewrite.prefix, domain: rewrite.domain, withDomain: rewrite.withDomain })); return routes; @@ -300,11 +308,15 @@ const localizeDomain = (url: URL, lang: string): URL => { const translateDomain = (url: URL, lang: string): URL => { const { config } = getSpeakContext(); + const rewriteRoutes = config.rewriteRoutes as InternalRewriteRouteOption[]; - const rewrite = config.rewriteRoutes?.find(value => + const rewrite = rewriteRoutes?.find(value => value.lang === lang || value.prefix === lang); - const domain = rewrite?.domain || rewrite?.withDomain; + const domain = rewrite?.domain || + rewrite?.withDomain || + // Default locale + rewriteRoutes.find(rewrite => rewrite.domain && Object.keys(rewrite.paths).length === 0)?.domain; if (!domain) { if (isDev) logWarn(`translateDomain: domain not found`); @@ -322,6 +334,3 @@ const isDefaultDomain = (lang: string): boolean => { const { config } = getSpeakContext(); return config.supportedLocales.find(value => value.lang === lang)?.domain !== undefined; }; - -const isEmpty = (obj: unknown): obj is Record => - typeof obj === 'object' && obj !== null && Object.keys(obj).length === 0; diff --git a/packages/qwik-speak/src/types.ts b/packages/qwik-speak/src/types.ts index 4a15174..eb8894a 100644 --- a/packages/qwik-speak/src/types.ts +++ b/packages/qwik-speak/src/types.ts @@ -67,10 +67,6 @@ export interface RewriteRouteOption { * Key value pairs: folder name - translated value */ paths: Record; - /** - * In domain-based routing, provides the language when there is no prefix - */ - lang?: string; /** * In domain-based routing, set the default domain for the prefix */ diff --git a/packages/qwik-speak/tools/core/routing.ts b/packages/qwik-speak/tools/core/routing.ts index 39532e0..1c4853d 100644 --- a/packages/qwik-speak/tools/core/routing.ts +++ b/packages/qwik-speak/tools/core/routing.ts @@ -9,7 +9,7 @@ export function toPrefixAsNeeded(rewriteRoutes: RewriteRouteOption[], mode: stri const routes = rewriteRoutes.map(rewrite => ({ prefix: rewrite.domain ? undefined : rewrite.prefix, paths: rewrite.paths, - lang: rewrite.prefix ? rewrite.prefix : rewrite.lang, domain: rewrite.domain, withDomain: rewrite.withDomain + lang: rewrite.prefix, domain: rewrite.domain, withDomain: rewrite.withDomain })); return routes; diff --git a/packages/qwik-speak/tools/core/types.ts b/packages/qwik-speak/tools/core/types.ts index 5b8031e..7addb90 100644 --- a/packages/qwik-speak/tools/core/types.ts +++ b/packages/qwik-speak/tools/core/types.ts @@ -97,10 +97,6 @@ export interface RewriteRouteOption { * Key value pairs: folder name - translated value */ paths: Record; - /** - * In domain-based routing, provides the language when there is no prefix - */ - lang?: string; /** * In domain-based routing, set the default domain for the prefix */