diff --git a/README.md b/README.md
index 102b153..a1b5666 100644
--- a/README.md
+++ b/README.md
@@ -306,10 +306,10 @@ npm run serve
```
## What's new
-> Released v0.2.0
+> Released v0.3.0
+- Advanced inlining: [Qwik Speak Inline Vite plugin](./tools/inline.md)
- Extract translations: [Qwik Speak Extract](./tools/extract.md)
-- Inline translation data at compile time: [Qwik Speak Inline Vite plugin](./tools/inline.md)
## License
MIT
diff --git a/package.json b/package.json
index c96f326..d3f1976 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"serve": "node server/entry.express",
"start": "vite --open --mode ssr",
"test": "jest ./src/tests ./tools/tests",
- "test.e2e": "playwright test",
+ "test.e2e": "npm run build.app && playwright test",
"test.watch": "jest ./src/tests ./tools/tests --watch",
"qwik": "qwik",
"qwik-speak-extract": "qwik-speak-extract --supportedLangs=en-US,it-IT --sourceFilesPath=src/app"
diff --git a/playwright.config.ts b/playwright.config.ts
index 15e173d..1091741 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -64,34 +64,6 @@ const config: PlaywrightTestConfig = {
...devices['Desktop Safari'],
},
},
-
- /* Test against mobile viewports. */
- // {
- // name: 'Mobile Chrome',
- // use: {
- // ...devices['Pixel 5'],
- // },
- // },
- // {
- // name: 'Mobile Safari',
- // use: {
- // ...devices['iPhone 12'],
- // },
- // },
-
- /* Test against branded browsers. */
- // {
- // name: 'Microsoft Edge',
- // use: {
- // channel: 'msedge',
- // },
- // },
- // {
- // name: 'Google Chrome',
- // use: {
- // channel: 'chrome',
- // },
- // },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
@@ -99,8 +71,8 @@ const config: PlaywrightTestConfig = {
/* Run your local dev server before starting the tests */
webServer: {
- command: 'npm run dev',
- port: 5173,
+ command: 'npm run serve',
+ port: 8080,
},
};
diff --git a/tools/extract.md b/tools/extract.md
index 343e424..9fbbd66 100644
--- a/tools/extract.md
+++ b/tools/extract.md
@@ -13,7 +13,7 @@ Optionally, you can use a default value for the keys. The syntax is `key@@[defau
```
When you use a default value, it will be used as initial value for the key in every translation.
-> Note. A key will not be extracted when a function argument is a variable (dynamic).
+> Note. A key will not be extracted when a function argument is a variable (dynamic)
#### Naming conventions
If you use scoped translations, the first property will be used as filename:
@@ -26,6 +26,9 @@ will generate two files for each language:
public/i18n
│
└───en-US
+│ app.json
+│ home.json
+└───it-IT
app.json
home.json
```
diff --git a/tools/inline.md b/tools/inline.md
index e753178..5424cbf 100644
--- a/tools/inline.md
+++ b/tools/inline.md
@@ -8,7 +8,7 @@
Make sure that the translation files are only loaded in dev mode, for example:
```typescript
export const loadTranslation$: LoadTranslationFn = $(async (lang: string, asset: string, url?: URL) => {
- if (import.meta.env.DEV ) {
+ if (import.meta.env.DEV) {
// Load translations
}
});
@@ -63,7 +63,7 @@ When there are translations with dynamic keys or params, you can manage them at
}
});
```
-Likewise, you can also create scoped runtime files for the different pages.
+Likewise, you can also create scoped runtime files for different pages.
> Note. The `plural` function must be handled as a dynamic translation
@@ -73,19 +73,67 @@ During the transformation of the modules, and before tree shaking and bundling,
/*#__PURE__*/ _jsx("h2", {
children: t('app.subtitle')
}),
-/*#__PURE__*/ _jsx("p", {
- children: t('home.greeting', {
- name: 'Qwik Speak'
- })
-}),
```
to:
```javascript
/*#__PURE__*/ _jsx("h2", {
children: $lang() === `it-IT` && `Traduci le tue app Qwik in qualsiasi lingua` || `Translate your Qwik apps into any language`
}),
-/*#__PURE__*/ _jsx("p", {
- children: $lang() === `it-IT` && `Ciao! Sono ${'Qwik Speak'}` || `Hi! I am ${'Qwik Speak'}`
-}),
```
`$lang` is imported and added during compilation, and you can still change locales at runtime without redirecting or reloading the page.
+
+## Advanced inlining
+If you have many languages, or long texts, you can further optimize the chunks sent to the browser by enabling the `splitChunks` option :
+```typescript
+qwikSpeakInline({
+ basePath: './',
+ assetsPath: 'public/i18n',
+ supportedLangs: ['en-US', 'it-IT'],
+ defaultLang: 'en-US',
+ splitChunks: true
+})
+```
+In this way the browser chunks are generated one for each language:
+```
+dist/build
+│
+└───en-US
+│ q-*.js
+└───it-IT
+ q-*.js
+```
+Each contains only its own translation:
+```javascript
+/* @__PURE__ */ Ut("h2", {
+ children: `Translate your Qwik apps into any language`
+}),
+```
+```javascript
+/* @__PURE__ */ Ut("h2", {
+ children: `Traduci le tue app Qwik in qualsiasi lingua`
+}),
+```
+
+Qwik uses the `q:base` attribute to determine the base URL for loading the chunks in the browser, so you have to set it in `entry.ssr.tsx` file. For example, if you have a localized router:
+```typescript
+export function extractBase({ envData }: RenderOptions): string {
+ const url = new URL(envData!.url);
+ const lang = config.supportedLocales.find(x => url.pathname.startsWith(`/${x.lang}`))?.lang;
+
+ if (!import.meta.env.DEV && lang) {
+ return '/build/' + lang;
+ } else {
+ return '/build';
+ }
+}
+
+export default function (opts: RenderToStreamOptions) {
+ return renderToStream(, {
+ manifest,
+ ...opts,
+ base: extractBase,
+ });
+}
+```
+
+> Note. To update the `q:base` when language changes, you need to navigate to the new localized URL or reload the page. Therefore, it is not possible to use the `changeLocale` function
diff --git a/tools/inline/plugin.ts b/tools/inline/plugin.ts
index edb3bcc..f7292d8 100644
--- a/tools/inline/plugin.ts
+++ b/tools/inline/plugin.ts
@@ -1,6 +1,7 @@
import type { Plugin } from 'vite';
-import { readFile, readdir } from 'fs/promises';
-import { createWriteStream } from 'fs';
+import type { NormalizedOutputOptions, OutputBundle, OutputAsset, OutputChunk } from 'rollup';
+import { readFile, readdir, writeFile } from 'fs/promises';
+import { createWriteStream, existsSync, mkdirSync } from 'fs';
import { extname, normalize } from 'path';
import type { QwikSpeakInlineOptions, Translation } from './types';
@@ -15,7 +16,7 @@ const dynamicParams: string[] = [];
/**
* Qwik Speak Inline Vite plugin
*
- * Inline $translate values: $lang() === 'lang' && 'value' || 'value'
+ * Inline $translate values
*/
export function qwikSpeakInline(options: QwikSpeakInlineOptions): Plugin {
// Resolve options
@@ -25,6 +26,7 @@ export function qwikSpeakInline(options: QwikSpeakInlineOptions): Plugin {
assetsPath: options.assetsPath ?? 'public/i18n',
keySeparator: options.keySeparator ?? '.',
keyValueSeparator: options.keyValueSeparator ?? '@@',
+ splitChunks: options.splitChunks ?? false
}
// Translation data
@@ -95,11 +97,31 @@ export function qwikSpeakInline(options: QwikSpeakInlineOptions): Plugin {
if (/\/src\//.test(id) && /\.(js|cjs|mjs|jsx|ts|tsx)$/.test(id)) {
// Filter code
if (/\$translate/.test(code)) {
- return inline(code, translation, resolvedOptions);
+ if (target === 'client' && resolvedOptions.splitChunks) {
+ return inlinePlaceholder(code);
+ }
+ else {
+ const alias = getTranslateAlias(code);
+ return inline(code, translation, alias, resolvedOptions);
+ }
}
}
},
+ /**
+ * Split chunks by lang
+ */
+ async writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
+ if (target === 'client' && resolvedOptions.splitChunks) {
+ const dir = options.dir ? options.dir : normalize(`${resolvedOptions.basePath}/dist`);
+ const bundles = Object.values(bundle);
+
+ const tasks = resolvedOptions.supportedLangs
+ .map(x => writeChunks(x, bundles, dir, translation, resolvedOptions));
+ await Promise.all(tasks);
+ }
+ },
+
async closeBundle() {
// Logs
const log = createWriteStream('./qwik-speak-inline.log', { flags: 'a' });
@@ -120,10 +142,9 @@ export function qwikSpeakInline(options: QwikSpeakInlineOptions): Plugin {
export function inline(
code: string,
translation: Translation,
+ alias: string,
opts: Required
): string | null {
- const alias = getTranslateAlias(code);
-
// Parse sequence
const sequence = parseSequenceExpressions(code, alias);
@@ -137,25 +158,7 @@ export function inline(
const args = expr.arguments;
if (args?.[0]?.value) {
- // Dynamic key
- if (args[0].type === 'Identifier') {
- if (args[0].value !== 'key') dynamicKeys.push(`dynamic key: ${originalFn.replace(/\s+/g, ' ')} - skip`)
- continue;
- }
- if (args[0].type === 'Literal') {
- if (args[0].value !== 'key' && /\${.*}/.test(args[0].value)) {
- dynamicKeys.push(`dynamic key: ${originalFn.replace(/\s+/g, ' ')} - skip`)
- continue;
- }
- }
-
- // Dynamic argument
- if (args[1]?.type === 'Identifier' || args[1]?.type === 'CallExpression' ||
- args[2]?.type === 'Identifier' || args[2]?.type === 'CallExpression' ||
- args[3]?.type === 'Identifier' || args[3]?.type === 'CallExpression') {
- dynamicParams.push(`dynamic params: ${originalFn.replace(/\s+/g, ' ')} - skip`);
- continue;
- }
+ if (checkDynamic(args, originalFn)) continue;
let supportedLangs: string[];
let defaultLang: string;
@@ -214,6 +217,89 @@ export function inline(
return code;
}
+export function inlinePlaceholder(code: string): string | null {
+ const alias = getTranslateAlias(code);
+
+ // Parse sequence
+ const sequence = parseSequenceExpressions(code, alias);
+
+ if (sequence.length === 0) return null;
+
+ for (const expr of sequence) {
+ // Original function
+ const originalFn = expr.value;
+ // Arguments
+ const args = expr.arguments;
+
+ if (args?.[0]?.value) {
+ if (checkDynamic(args, originalFn)) continue;
+
+ // Transpile with $inline placeholder
+ const transpiled = originalFn.replace(new RegExp(`${alias}\\(`, 's'), '$inline(');
+ // Replace
+ code = code.replace(originalFn, transpiled);
+ }
+ }
+
+ return code;
+}
+
+export async function writeChunks(
+ lang: string,
+ bundles: (OutputAsset | OutputChunk)[],
+ dir: string,
+ translation: Translation,
+ opts: Required
+) {
+ const targetDir = normalize(`${dir}/build/${lang}`);
+ if (!existsSync(targetDir)) {
+ mkdirSync(targetDir, { recursive: true });
+ }
+
+ const tasks: Promise[] = [];
+ for (const chunk of bundles) {
+ if (chunk.type === 'chunk' && 'code' in chunk && /build\//.test(chunk.fileName)) {
+ const filename = normalize(`${targetDir}/${chunk.fileName.split('/')[1]}`);
+ const alias = '\\$inline';
+ const code = inline(chunk.code, translation, alias, { ...opts, supportedLangs: [lang], defaultLang: lang });
+ tasks.push(writeFile(filename, code || chunk.code));
+
+ // Original chunks to default lang
+ if (lang === opts.defaultLang) {
+ const defaultTargetDir = normalize(`${dir}/build`);
+ const defaultFilename = normalize(`${defaultTargetDir}/${chunk.fileName.split('/')[1]}`);
+ tasks.push(writeFile(defaultFilename, code || chunk.code));
+ }
+ }
+ }
+ await Promise.all(tasks);
+}
+
+export function checkDynamic(args: Argument[], originalFn: string): boolean {
+ // Dynamic key
+ if (args?.[0]?.value) {
+ if (args[0].type === 'Identifier') {
+ if (args[0].value !== 'key') dynamicKeys.push(`dynamic key: ${originalFn.replace(/\s+/g, ' ')} - skip`)
+ return true;
+ }
+ if (args[0].type === 'Literal') {
+ if (args[0].value !== 'key' && /\${.*}/.test(args[0].value)) {
+ dynamicKeys.push(`dynamic key: ${originalFn.replace(/\s+/g, ' ')} - skip`)
+ return true;
+ }
+ }
+
+ // Dynamic argument
+ if (args[1]?.type === 'Identifier' || args[1]?.type === 'CallExpression' ||
+ args[2]?.type === 'Identifier' || args[2]?.type === 'CallExpression' ||
+ args[3]?.type === 'Identifier' || args[3]?.type === 'CallExpression') {
+ dynamicParams.push(`dynamic params: ${originalFn.replace(/\s+/g, ' ')} - skip`);
+ return true;
+ }
+ }
+ return false;
+}
+
export function multilingual(lang: string | undefined, supportedLangs: string[]): string | undefined {
if (!lang) return undefined;
return supportedLangs.find(x => x === lang);
@@ -273,5 +359,8 @@ export function transpileFn(values: Map, supportedLangs: string[
* Add $lang to component
*/
export function addLang(code: string): string {
- return code.replace(/^/, 'import { $lang } from "qwik-speak";\n');
+ if (!/^import\s*\{.*\$lang.*}\s*from\s*/s.test(code)) {
+ code = code.replace(/^/, 'import { $lang } from "qwik-speak";\n');
+ }
+ return code;
}
diff --git a/tools/inline/types.ts b/tools/inline/types.ts
index 7776122..7936f0c 100644
--- a/tools/inline/types.ts
+++ b/tools/inline/types.ts
@@ -26,6 +26,10 @@ export interface QwikSpeakInlineOptions {
* Key-value separator. Default is '@@'
*/
keyValueSeparator?: string;
+ /**
+ * If true, split chunks by lang
+ */
+ splitChunks?: boolean;
}
/**
diff --git a/tools/tests/mock.ts b/tools/tests/mock.ts
index 94bbe27..c04309b 100644
--- a/tools/tests/mock.ts
+++ b/tools/tests/mock.ts
@@ -1,12 +1,12 @@
/* eslint-disable */
-export const mockCode = `import { RelativeTime } from "./app/routes/[...lang]/index";
-import { Fragment as _Fragment } from "@builder.io/qwik/jsx-runtime";
+export const mockCode = `import { Fragment as _Fragment } from "@builder.io/qwik/jsx-runtime";
import { jsx as _jsx } from "@builder.io/qwik/jsx-runtime";
import { jsxs as _jsxs } from "@builder.io/qwik/jsx-runtime";
import { formatDate as fd } from "qwik-speak";
import { formatNumber as fn } from "qwik-speak";
import { plural as p } from "qwik-speak";
import { qrl } from "@builder.io/qwik";
+import { relativeTime as rt } from "qwik-speak";
import { $translate as t } from "qwik-speak";
import { useSpeakLocale } from "qwik-speak";
import { useStore } from "@builder.io/qwik";
@@ -47,7 +47,7 @@ export const s_xJBzwgVGKaQ = ()=>{
children: t('home.increment')
}),
/*#__PURE__*/ _jsx("p", {
- children: p(state.count, 'home.devs')
+ children: p(state.count, 'runtime.devs')
}),
/*#__PURE__*/ _jsx("h3", {
children: t('home.dates')
@@ -58,7 +58,9 @@ export const s_xJBzwgVGKaQ = ()=>{
timeStyle: 'short'
})
}),
- /*#__PURE__*/ _jsx(RelativeTime, {}),
+ /*#__PURE__*/ _jsx("p", {
+ children: rt(-1, 'second')
+ }),
/*#__PURE__*/ _jsx("h3", {
children: t('home.numbers')
}),
@@ -81,7 +83,6 @@ export const s_xJBzwgVGKaQ = ()=>{
};`;
export const inlinedCode = `import { $lang } from "qwik-speak";
-import { RelativeTime } from "./app/routes/[...lang]/index";
import { Fragment as _Fragment } from "@builder.io/qwik/jsx-runtime";
import { jsx as _jsx } from "@builder.io/qwik/jsx-runtime";
import { jsxs as _jsxs } from "@builder.io/qwik/jsx-runtime";
@@ -89,6 +90,7 @@ import { formatDate as fd } from "qwik-speak";
import { formatNumber as fn } from "qwik-speak";
import { plural as p } from "qwik-speak";
import { qrl } from "@builder.io/qwik";
+import { relativeTime as rt } from "qwik-speak";
import { $translate as t } from "qwik-speak";
import { useSpeakLocale } from "qwik-speak";
import { useStore } from "@builder.io/qwik";
@@ -127,7 +129,7 @@ export const s_xJBzwgVGKaQ = ()=>{
children: $lang() === \`it-IT\` && \`Incrementa\` || \`Increment\`
}),
/*#__PURE__*/ _jsx("p", {
- children: p(state.count, 'home.devs')
+ children: p(state.count, 'runtime.devs')
}),
/*#__PURE__*/ _jsx("h3", {
children: $lang() === \`it-IT\` && \`Date e tempo relativo\` || \`Dates & relative time\`
@@ -138,7 +140,9 @@ export const s_xJBzwgVGKaQ = ()=>{
timeStyle: 'short'
})
}),
- /*#__PURE__*/ _jsx(RelativeTime, {}),
+ /*#__PURE__*/ _jsx("p", {
+ children: rt(-1, 'second')
+ }),
/*#__PURE__*/ _jsx("h3", {
children: $lang() === \`it-IT\` && \`Numeri e valute\` || \`Numbers & currencies\`
}),
diff --git a/vite.config.ts b/vite.config.ts
index a50bc7a..21b8474 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -7,6 +7,9 @@ import { qwikSpeakInline } from './tools/inline';
export default defineConfig(() => {
return {
+ build: {
+ minify: false
+ },
plugins: [
qwikCity({
routesDir: './src/app/routes',
@@ -14,7 +17,8 @@ export default defineConfig(() => {
qwikVite(),
qwikSpeakInline({
supportedLangs: ['en-US', 'it-IT'],
- defaultLang: 'en-US'
+ defaultLang: 'en-US',
+ //splitChunks: true
}),
tsconfigPaths(),
],