Skip to content

Commit

Permalink
Merge pull request #16 from robisim74/inline-advanced
Browse files Browse the repository at this point in the history
Advanced inlining
  • Loading branch information
robisim74 committed Nov 17, 2022
2 parents abfea90 + c6ab427 commit 2653062
Show file tree
Hide file tree
Showing 9 changed files with 202 additions and 78 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 2 additions & 30 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,43 +64,15 @@ 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. */
// outputDir: 'test-results/',

/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
port: 5173,
command: 'npm run serve',
port: 8080,
},
};

Expand Down
5 changes: 4 additions & 1 deletion tools/extract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
```
Expand Down
68 changes: 58 additions & 10 deletions tools/inline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
});
Expand Down Expand Up @@ -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
Expand All @@ -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(<Root />, {
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
141 changes: 115 additions & 26 deletions tools/inline/plugin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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' });
Expand All @@ -120,10 +142,9 @@ export function qwikSpeakInline(options: QwikSpeakInlineOptions): Plugin {
export function inline(
code: string,
translation: Translation,
alias: string,
opts: Required<QwikSpeakInlineOptions>
): string | null {
const alias = getTranslateAlias(code);

// Parse sequence
const sequence = parseSequenceExpressions(code, alias);

Expand All @@ -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;
Expand Down Expand Up @@ -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<QwikSpeakInlineOptions>
) {
const targetDir = normalize(`${dir}/build/${lang}`);
if (!existsSync(targetDir)) {
mkdirSync(targetDir, { recursive: true });
}

const tasks: Promise<void>[] = [];
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);
Expand Down Expand Up @@ -273,5 +359,8 @@ export function transpileFn(values: Map<string, string>, 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;
}
4 changes: 4 additions & 0 deletions tools/inline/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export interface QwikSpeakInlineOptions {
* Key-value separator. Default is '@@'
*/
keyValueSeparator?: string;
/**
* If true, split chunks by lang
*/
splitChunks?: boolean;
}

/**
Expand Down
Loading

0 comments on commit 2653062

Please sign in to comment.