Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for working with locale #1594

Merged
merged 1 commit into from
Nov 6, 2022
Merged
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
61 changes: 61 additions & 0 deletions packages/docs/src/routes/docs/advanced/i18n/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
title: Internationalization
contributors:
- mhevery
---

# Internationalization

Internationalization is a complex problem. Qwik does not solve the internationalization problem directly instead it only provides low-level APIs to allow other libraries to solve it.

## Runtime vs compile time translation

At a high level there are two ways in which the translation problem can be solved:
- Runtime: load a translation map and look up the translations at runtime.
- Compile time: Have a compile step inline the translations into the output string.

Both of the above approaches have trade-offs that one should take into consideration.

The advantages of runtime approaches are:
- Simplicity. Does not require an additional build step.

Disadvantages of the runtime approach are:
- Each string is present in triplicate:
1. Once as the original string in the code.
2. Once as a key in a translation map.
3. Once as a translated value in the translation map.
- The tools don't know how to break up the translation map, for this reason, the whole translation map must be loaded eagerly on application startup. This is a problem because it undoes the effort Qwik put into breaking up and lazy load your codebase. In addition because translation maps are not broken up, the browser will download unnecessary translations. For example, translations for static components that will never re-render on the client.
- There is a runtime cost to translation lookups.

The advantages of compile-time approaches are:
- Qwik's lazy loading of code now extends to the lazy loading of translation strings. (No unnecessary translation text is loaded)
- No runtime translation map means strings are not in triplicate.

Disadvantages of compile time approaches are:
- Extra build step.
- Changing languages requires a page reload.

## Recommendation

With the above in mind, Qwik recommends that you use a tool that best fits your constraints. To help you make a decision there are three different considerations: Browser, Server, and Development.

### Browser

Qwik's goal is to deliver the best possible user experience. It achieves this by deferring the loading of code to later so that the initial startup performance is not overwhelmed. Because the runtime approach requires eager loading of all translations, we don't recommend this approach. We think that the compile-time approach is best for the browser.

### Server

The server does not have the constraint of lazy loading. For this reason, the server can use either the runtime or compiled approach. The disadvantage of compile time approach on the server is that we need to have a separate deployment for each translation. This complicates the deployment process as well as puts greater demand on number of servers. For this reason, we think the runtime approach is preferable on the server.

### Development

During development, fewer build steps will result in a faster turnaround. For this reason, runtime translation should result in a simpler development workflow.

### Our Recommendation

Our recommendation is to use a tool that would provide a runtime approach on the server, and runtime or compile time on the client depending on whether we are in development or production. This way it is possible to prove the best user experience and development experience, and use the least server resources.

# Internationalization libraries

- [$localize](https://github.com/mhevery/qwik-i18n)
- [qwik-speak](https://github.com/robisim74/qwik-speak)
1 change: 1 addition & 0 deletions packages/docs/src/routes/docs/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
- [QRL](advanced/qrl/index.mdx)
- [Qwikloader](advanced/qwikloader/index.mdx)
- [Custom Build Directory](advanced/custom-build-dir/index.mdx)
- [Internationalization](advanced/i18n/index.mdx)

## Community

Expand Down
10 changes: 8 additions & 2 deletions packages/qwik-city/buildtime/vite/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function ssrDevMiddleware(ctx: BuildContext, server: ViteDevServer) {
return async (req: Connect.IncomingMessage, res: ServerResponse, next: Connect.NextFunction) => {
try {
const url = new URL(req.originalUrl!, `http://${req.headers.host}`);
const requestHeaders: Record<string, string> = req.headers as any;

if (skipRequest(url.pathname) || isVitePing(url.pathname, req.headers)) {
next();
Expand Down Expand Up @@ -102,7 +103,7 @@ export function ssrDevMiddleware(ctx: BuildContext, server: ViteDevServer) {

if (userResponse.type === 'pagedata') {
// dev server endpoint handler
await pageHandler(requestCtx, userResponse, noopDevRender);
await pageHandler('dev', requestCtx, userResponse, noopDevRender);
return;
}

Expand All @@ -114,7 +115,12 @@ export function ssrDevMiddleware(ctx: BuildContext, server: ViteDevServer) {

// qwik city vite plugin should handle dev ssr rendering
// but add the qwik city user context to the response object
const envData = getQwikCityEnvData(userResponse);
const envData = getQwikCityEnvData(
requestHeaders,
userResponse,
requestCtx.locale,
'dev'
);
if (ctx.isDevServerClientOnly) {
// because we stringify this content for the client only
// dev server, there's some potential stringify issues
Expand Down
3 changes: 2 additions & 1 deletion packages/qwik-city/middleware/cloudflare-pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function createQwikCity(opts: QwikCityCloudflarePagesOptions) {
}

const requestCtx: QwikCityRequestContext<Response> = {
locale: undefined,
url,
request,
response: (status, headers, body) => {
Expand Down Expand Up @@ -74,7 +75,7 @@ export function createQwikCity(opts: QwikCityCloudflarePagesOptions) {
};

// send request to qwik city request handler
const handledResponse = await requestHandler<Response>(requestCtx, opts);
const handledResponse = await requestHandler<Response>('server', requestCtx, opts);
if (handledResponse) {
return handledResponse;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/qwik-city/middleware/netlify-edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function createQwikCity(opts: QwikCityNetlifyOptions) {
}
try {
const requestCtx: QwikCityRequestContext<Response> = {
locale: undefined,
url: new URL(request.url),
request,
response: (status, headers, body) => {
Expand Down Expand Up @@ -58,7 +59,7 @@ export function createQwikCity(opts: QwikCityNetlifyOptions) {
};

// send request to qwik city request handler
const handledResponse = await requestHandler<Response>(requestCtx, opts);
const handledResponse = await requestHandler<Response>('server', requestCtx, opts);
if (handledResponse) {
return handledResponse;
}
Expand Down
1 change: 1 addition & 0 deletions packages/qwik-city/middleware/node/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function fromNodeHttp(url: URL, req: IncomingMessage, res: ServerResponse
ssr: true,
node: process.versions.node,
},
locale: undefined,
};

return requestCtx;
Expand Down
2 changes: 1 addition & 1 deletion packages/qwik-city/middleware/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function createQwikCity(opts: QwikCityNodeRequestOptions) {
try {
const requestCtx = fromNodeHttp(getUrl(req), req, res);
try {
const rsp = await requestHandler(requestCtx, opts);
const rsp = await requestHandler('server', requestCtx, opts);
if (!rsp) {
next();
}
Expand Down
23 changes: 20 additions & 3 deletions packages/qwik-city/middleware/request-handler/page-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ import type {
RenderResult,
RenderToStringResult,
} from '@builder.io/qwik/server';
import type { ClientPageData, QwikCityEnvData } from '../../runtime/src/library/types';
import type {
ClientPageData,
QwikCityEnvData,
QwikCityMode,
} from '../../runtime/src/library/types';
import { getErrorHtml } from './error-handler';
import { HttpStatus } from './http-status-codes';
import type { QwikCityRequestContext, UserResponseContext } from './types';

export function pageHandler<T = any>(
mode: QwikCityMode,
requestCtx: QwikCityRequestContext,
userResponse: UserResponseContext,
render: Render,
Expand All @@ -21,6 +26,8 @@ export function pageHandler<T = any>(
const { status, headers } = userResponse;
const { response } = requestCtx;
const isPageData = userResponse.type === 'pagedata';
const requestHeaders: Record<string, string> = {};
requestCtx.request.headers.forEach((value, key) => (requestHeaders[key] = value));
Copy link
Member

@wmertens wmertens Oct 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have something similar in our React codebase, but we allow registering functions that extract environment data from a request object.

This way, more than headers can be used to set the environment data, it avoids having to copy all headers, and it allows running the render without a request object, like when you're rendering a PDF or in tests.


if (isPageData) {
// page data should always be json
Expand All @@ -35,7 +42,7 @@ export function pageHandler<T = any>(
try {
const result = await render({
stream: isPageData ? noopStream : stream,
envData: getQwikCityEnvData(userResponse),
envData: getQwikCityEnvData(requestHeaders, userResponse, requestCtx.locale, mode),
...opts,
});

Expand Down Expand Up @@ -132,14 +139,24 @@ function getPrefetchBundleNames(result: RenderResult, routeBundleNames: string[]
return bundleNames;
}

export function getQwikCityEnvData(userResponse: UserResponseContext): {
export function getQwikCityEnvData(
requestHeaders: Record<string, string>,
userResponse: UserResponseContext,
locale: string | undefined,
mode: QwikCityMode
): {
url: string;
requestHeaders: Record<string, string>;
locale: string | undefined;
qwikcity: QwikCityEnvData;
} {
const { url, params, pendingBody, resolvedBody, status } = userResponse;
return {
url: url.href,
requestHeaders: requestHeaders,
locale: locale,
qwikcity: {
mode: mode,
params: { ...params },
response: {
body: pendingBody || resolvedBody,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { loadRoute } from '../../runtime/src/library/routing';
import { loadUserResponse, updateRequestCtx } from './user-response';
import type { QwikCityRequestContext, QwikCityHandlerOptions } from './types';
import { errorHandler, ErrorResponse, errorResponse } from './error-handler';
import type { QwikCityMode } from '../../runtime/src/library/types';
import { endpointHandler } from './endpoint-handler';
import { errorHandler, ErrorResponse, errorResponse } from './error-handler';
import { pageHandler } from './page-handler';
import { RedirectResponse, redirectResponse } from './redirect-handler';
import type { QwikCityHandlerOptions, QwikCityRequestContext } from './types';
import { loadUserResponse, updateRequestCtx } from './user-response';

/**
* @alpha
*/
export async function requestHandler<T = any>(
mode: QwikCityMode,
requestCtx: QwikCityRequestContext,
opts: QwikCityHandlerOptions
): Promise<T | null> {
Expand Down Expand Up @@ -43,6 +45,7 @@ export async function requestHandler<T = any>(
}

const pageResult = await pageHandler(
mode,
requestCtx,
userResponse,
render,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function mockRequestContext(opts?: {
});
};

return { url, request, response, responseData, platform: { testing: true } };
return { url, request, response, responseData, platform: { testing: true }, locale: undefined };
}

export interface TestQwikCityRequestContext extends QwikCityRequestContext {
Expand All @@ -47,6 +47,7 @@ export interface TestQwikCityRequestContext extends QwikCityRequestContext {
headers: Headers;
body: any;
};
locale: string | undefined;
}

export async function wait() {
Expand Down
1 change: 1 addition & 0 deletions packages/qwik-city/middleware/request-handler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface QwikCityRequestContext<T = any> {
response: ResponseHandler<T>;
url: URL;
platform: Record<string, any>;
locale: string | undefined;
}

export interface QwikCityDevRequestContext extends QwikCityRequestContext {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export async function loadUserResponse(
get headers() {
return userResponse.headers;
},
get locale() {
return requestCtx.locale;
},
set locale(locale) {
requestCtx.locale = locale;
},
redirect,
error,
};
Expand Down
3 changes: 3 additions & 0 deletions packages/qwik-city/runtime/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export interface DocumentHeadProps<T = unknown> extends RouteLocation {
data: T;
// (undocumented)
head: ResolvedDocumentHead;
// (undocumented)
withLocale: <T>(fn: () => T) => T;
}

// @alpha (undocumented)
Expand Down Expand Up @@ -233,6 +235,7 @@ export interface ResponseContext {
// Warning: (ae-forgotten-export) The symbol "ErrorResponse" needs to be exported by the entry point index.d.ts
readonly error: (status: number) => ErrorResponse;
readonly headers: Headers;
locale: string | undefined;
// Warning: (ae-forgotten-export) The symbol "RedirectResponse" needs to be exported by the entry point index.d.ts
readonly redirect: (url: string, status?: number) => RedirectResponse;
status: number;
Expand Down
10 changes: 8 additions & 2 deletions packages/qwik-city/runtime/src/library/head.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withLocale } from '@builder.io/qwik';
import type {
ContentModule,
RouteLocation,
Expand All @@ -11,20 +12,25 @@ import type {
export const resolveHead = (
endpoint: EndpointResponse | ClientPageData | undefined | null,
routeLocation: RouteLocation,
contentModules: ContentModule[]
contentModules: ContentModule[],
locale: string
) => {
const head = createDocumentHead();
const headProps: DocumentHeadProps = {
data: endpoint ? endpoint.body : null,
head,
withLocale: (fn) => withLocale(locale, fn),
...routeLocation,
};

for (let i = contentModules.length - 1; i >= 0; i--) {
const contentModuleHead = contentModules[i] && contentModules[i].head;
if (contentModuleHead) {
if (typeof contentModuleHead === 'function') {
resolveDocumentHead(head, contentModuleHead(headProps));
resolveDocumentHead(
head,
withLocale(locale, () => contentModuleHead(headProps))
);
} else if (typeof contentModuleHead === 'object') {
resolveDocumentHead(head, contentModuleHead);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Slot,
useContextProvider,
useEnvData,
getLocale,
useStore,
useWatch$,
} from '@builder.io/qwik';
Expand Down Expand Up @@ -93,8 +94,9 @@ export const QwikCity = component$<QwikCityProps>(() => {
useContextProvider(RouteNavigateContext, routeNavigate);

useWatch$(async ({ track }) => {
const locale = getLocale('');
const { routes, menus, cacheModules } = await import('@qwik-city-plan');
const path = track(routeNavigate, 'path');
const path = track(() => routeNavigate.path);
const url = new URL(path, routeLocation.href);
const pathname = url.pathname;

Expand All @@ -121,7 +123,7 @@ export const QwikCity = component$<QwikCityProps>(() => {
contentInternal.contents = noSerialize(contentModules);

const clientPageData = await endpointResponse;
const resolvedHead = resolveHead(clientPageData, routeLocation, contentModules);
const resolvedHead = resolveHead(clientPageData, routeLocation, contentModules, locale);

// Update document head
documentHead.links = resolvedHead.links;
Expand Down
11 changes: 11 additions & 0 deletions packages/qwik-city/runtime/src/library/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export interface DocumentStyle {
export interface DocumentHeadProps<T = unknown> extends RouteLocation {
data: T;
head: ResolvedDocumentHead;
withLocale: <T>(fn: () => T) => T;
}

/**
Expand Down Expand Up @@ -247,6 +248,13 @@ export interface ResponseContext {
*/
status: number;

/**
* Which locale the content is in.
*
* The locale value can be retrieved from selected methods using `getLocale()`:
*/
locale: string | undefined;

/**
* HTTP response headers.
*
Expand Down Expand Up @@ -346,10 +354,13 @@ export interface StaticGenerate {
export interface QwikCityRenderDocument extends Document {}

export interface QwikCityEnvData {
mode: QwikCityMode;
params: RouteParams;
response: EndpointResponse;
}

export type QwikCityMode = 'dev' | 'static' | 'server';

export type GetEndpointData<T> = T extends RequestHandler<infer U> ? U : T;

export interface SimpleURL {
Expand Down
Loading