From 99a1b5cca7ad0dc67b5b2331e9f648f79b6b4703 Mon Sep 17 00:00:00 2001 From: Roberto Simonetti Date: Wed, 16 Nov 2022 16:37:08 +0100 Subject: [PATCH 1/2] Feat: split chunks --- tools/inline/plugin.ts | 139 +++++++++++++++++++++++++++++++++-------- tools/inline/types.ts | 4 ++ 2 files changed, 118 insertions(+), 25 deletions(-) diff --git a/tools/inline/plugin.ts b/tools/inline/plugin.ts index edb3bcc..e1bbbb5 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,34 @@ 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 === 'ssr' || !resolvedOptions.splitChunks) { + const alias = getTranslateAlias(code); + return inline(code, translation, alias, resolvedOptions); + } + else { + /* console.log(''); + console.log(''); + console.log(code); */ + return inlinePlaceholder(code); + } } } }, + /** + * 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 +145,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 +161,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 +220,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); 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; } /** From c6ab427111bd29cbd8b139bfabc17320af2cac07 Mon Sep 17 00:00:00 2001 From: Roberto Simonetti Date: Thu, 17 Nov 2022 16:33:28 +0100 Subject: [PATCH 2/2] Tools(inline): update docs & fixes --- README.md | 4 +-- package.json | 2 +- playwright.config.ts | 32 ++------------------ tools/extract.md | 5 +++- tools/inline.md | 68 +++++++++++++++++++++++++++++++++++------- tools/inline/plugin.ts | 16 +++++----- tools/tests/mock.ts | 18 ++++++----- vite.config.ts | 6 +++- 8 files changed, 91 insertions(+), 60 deletions(-) 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 e1bbbb5..f7292d8 100644 --- a/tools/inline/plugin.ts +++ b/tools/inline/plugin.ts @@ -97,15 +97,12 @@ export function qwikSpeakInline(options: QwikSpeakInlineOptions): Plugin { if (/\/src\//.test(id) && /\.(js|cjs|mjs|jsx|ts|tsx)$/.test(id)) { // Filter code if (/\$translate/.test(code)) { - if (target === 'ssr' || !resolvedOptions.splitChunks) { - const alias = getTranslateAlias(code); - return inline(code, translation, alias, resolvedOptions); + if (target === 'client' && resolvedOptions.splitChunks) { + return inlinePlaceholder(code); } else { - /* console.log(''); - console.log(''); - console.log(code); */ - return inlinePlaceholder(code); + const alias = getTranslateAlias(code); + return inline(code, translation, alias, resolvedOptions); } } } @@ -362,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/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(), ],