wuchale 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -0
- package/index.js +3 -0
- package/package.json +31 -0
- package/preprocess/compile.js +52 -0
- package/preprocess/gemini.js +61 -0
- package/preprocess/index.js +270 -0
- package/preprocess/prep.js +565 -0
- package/runtime.svelte +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# wuchale
|
|
2
|
+
|
|
3
|
+
A non-invasive compile-time internationalization (i18n) system for Svelte โ
|
|
4
|
+
inspired by Lingui, but built from scratch with performance, clarity, and
|
|
5
|
+
simplicity in mind.
|
|
6
|
+
|
|
7
|
+
> ๐ Smart translations, tiny runtime, full HMR. `wuchale` extracts your
|
|
8
|
+
> strings at build time, generates optimized translation catalogs, supports
|
|
9
|
+
> live translations (even from Gemini), and ships almost no extra code to
|
|
10
|
+
> production.
|
|
11
|
+
|
|
12
|
+
## โจ Features
|
|
13
|
+
|
|
14
|
+
### ๐ช Invisible Integration
|
|
15
|
+
|
|
16
|
+
*Write your Svelte code naturally.* โ instead of
|
|
17
|
+
`<p>{t('Hello')}</p>`, or `<p><Trans>Hello</Trans></p>`, or
|
|
18
|
+
`<p>{t(page.home.hello)}</p>`, you write just:
|
|
19
|
+
|
|
20
|
+
```svelte
|
|
21
|
+
|
|
22
|
+
<p>Hello</p>
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No extra imports or annotations. `wuchale` extracts and compiles everything
|
|
27
|
+
automatically. In the spirit of Svelte itself.
|
|
28
|
+
|
|
29
|
+
### ๐ง Compiler-Powered
|
|
30
|
+
|
|
31
|
+
Built on the Svelte compiler and powered by AST
|
|
32
|
+
analysis. All transformations happen at build time using Vite. Runtime is
|
|
33
|
+
minimal and constant-time.
|
|
34
|
+
|
|
35
|
+
### ๐งฉ Full Nesting Support
|
|
36
|
+
|
|
37
|
+
Handles deeply nested markup and interpolations โ mixed conditionals, loops,
|
|
38
|
+
and awaits โ by compiling them into nested Svelte snippets.
|
|
39
|
+
|
|
40
|
+
### ๐ฆ No String Parsing at Runtime
|
|
41
|
+
|
|
42
|
+
Messages are compiled into arrays with index-based lookups. Runtime only
|
|
43
|
+
concatenates and renders โ no regex, replace, or complex logic. And the
|
|
44
|
+
compiled bundles are as small as possible, they don't even have keys.
|
|
45
|
+
|
|
46
|
+
### ๐ HMR & Dev Translations Live updates during development.
|
|
47
|
+
|
|
48
|
+
Translation files and source changes trigger updates instantly โ including
|
|
49
|
+
optional Gemini-based auto-translation. This means you can write the code in
|
|
50
|
+
English and have it shown in another language in the browser while in dev mode.
|
|
51
|
+
|
|
52
|
+
### ๐ค Uses .po Files
|
|
53
|
+
|
|
54
|
+
Output is standard gettext .po files with references, status tracking, and
|
|
55
|
+
optional integration with external tools.
|
|
56
|
+
|
|
57
|
+
### ๐ Tiny Footprint
|
|
58
|
+
|
|
59
|
+
Adds just 2 packages (your own) to `node_modules`, and only a few kilobytes to
|
|
60
|
+
the bundle. No 90MB dependency trees like some existing solutions.
|
|
61
|
+
|
|
62
|
+
### โจ Ready for Svelte 5
|
|
63
|
+
|
|
64
|
+
Works with Svelte 5's new architecture and snippets. Future-proof and tightly
|
|
65
|
+
integrated
|
|
66
|
+
|
|
67
|
+
## ๐ Getting Started
|
|
68
|
+
|
|
69
|
+
Install:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install wuchale
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Add to your Vite config:
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
|
|
79
|
+
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
|
80
|
+
import { wuchale } from 'wuchale'
|
|
81
|
+
|
|
82
|
+
export default { plugins: [ wuchale(), svelte(), ] }
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use in your Svelte files:
|
|
87
|
+
|
|
88
|
+
```svelte
|
|
89
|
+
|
|
90
|
+
<!-- you write -->
|
|
91
|
+
<p>Hello</p>
|
|
92
|
+
|
|
93
|
+
<!-- it becomes -->
|
|
94
|
+
|
|
95
|
+
<script>
|
|
96
|
+
import WuchaleTrans, { wuchaleTrans } from 'wuchale/runtime.svelte'
|
|
97
|
+
</script>
|
|
98
|
+
<h1>{wuchaleTrans(0)}</h1> <!-- Extracted "Hello" as index 0 -->
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## ๐ฆ How It Works
|
|
102
|
+
|
|
103
|
+
### Process
|
|
104
|
+
|
|
105
|
+

|
|
106
|
+
|
|
107
|
+
1. All text nodes are extracted using AST traversal.
|
|
108
|
+
1. Replaced with index-based lookups `wuchaleTrans(n)`, which is minifiable for production builds.
|
|
109
|
+
1. Catalog is updated
|
|
110
|
+
1. If Gemini integration is enabled, it fetches translations automatically.
|
|
111
|
+
1. Messages are compiled and written
|
|
112
|
+
1. In dev mode: Vite picks the write and does HMR during dev
|
|
113
|
+
1. In production mode: unused messages are marked obsolete
|
|
114
|
+
1. On next run, obsolete ones are purged unless reused.
|
|
115
|
+
1. Final build contains only minified catalogs and the runtime.
|
|
116
|
+
|
|
117
|
+
### Catalogs:
|
|
118
|
+
|
|
119
|
+
- Stored as PO files (.po).
|
|
120
|
+
- Compatible with tools like Poedit or translation.io.
|
|
121
|
+
- Includes source references and obsolete flags for cleaning.
|
|
122
|
+
|
|
123
|
+
## ๐ Gemini Integration (optional)
|
|
124
|
+
|
|
125
|
+
To enable the Gemini live translation, set the environment variable
|
|
126
|
+
`GEMINI_API_KEY` to your API key beforehand. The integration is:
|
|
127
|
+
|
|
128
|
+
- Rate-limit friendly (bundles messages to be translated into one request)
|
|
129
|
+
- Only translates new/changed messages
|
|
130
|
+
- Keeps original source intact
|
|
131
|
+
|
|
132
|
+
## ๐งช Example
|
|
133
|
+
|
|
134
|
+
Input:
|
|
135
|
+
|
|
136
|
+
```svelte
|
|
137
|
+
|
|
138
|
+
<p>Hello <b>{userName}</b></p>
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Output:
|
|
143
|
+
|
|
144
|
+
```svelte
|
|
145
|
+
|
|
146
|
+
<p>{wuchaleTrans(0, <b>{userName}</b>)}</p>
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Catalog (PO):
|
|
151
|
+
|
|
152
|
+
```nginx
|
|
153
|
+
|
|
154
|
+
msgid "Hello {0}" msgstr "Bonjour {0}"
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Supported syntax
|
|
159
|
+
|
|
160
|
+
Text can be in three places: markup, script and attributes. Script means not
|
|
161
|
+
just the part inside the `<script>` tags, but also inside interpolations inside
|
|
162
|
+
the markup, such as `<p>{'This string'}</p>` and also in other places such as
|
|
163
|
+
`{#if 'this string' == ''}`. And each can have their own rules. While these
|
|
164
|
+
rules can be configured, the default is:
|
|
165
|
+
|
|
166
|
+
- Markup:
|
|
167
|
+
- All text should be extracted unless prefixed with `-`. Example: `<p>-
|
|
168
|
+
This will be ignored.</p>`
|
|
169
|
+
- Attributes:
|
|
170
|
+
- All attributes starting with upper case letters are extracted unless
|
|
171
|
+
prefixed with `-` like `label="-Ignore"`.
|
|
172
|
+
- All attributes starting with lower case letters are ignored, unless
|
|
173
|
+
prefixed with `+` like `alt="+extract"`.
|
|
174
|
+
- Script:
|
|
175
|
+
- Strings: same rules as attributes above, but:
|
|
176
|
+
- If they are used inside the `<script>` tags, there is the additional
|
|
177
|
+
restriction that they must be inside a `$derived` variable declaration.
|
|
178
|
+
This is to make the behavior less magic and being more explicit.
|
|
179
|
+
|
|
180
|
+
## Where does it look?
|
|
181
|
+
|
|
182
|
+
All files that can contain reactive logic. This means `*.svelte` and
|
|
183
|
+
`*.svelte.js` files specifically.
|
|
184
|
+
|
|
185
|
+
## Plurals?
|
|
186
|
+
|
|
187
|
+
Since messages can be written anywhere in the reactive places, it was not
|
|
188
|
+
necessary to have a separate plurals support because you can do something like:
|
|
189
|
+
|
|
190
|
+
```svelte
|
|
191
|
+
|
|
192
|
+
<p>{items === 1 ? 'One item listed' : `${items} items listed`}
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
And they will be extracted separately. You can also make a reusable function
|
|
197
|
+
yourself.
|
|
198
|
+
|
|
199
|
+
## ๐งน Cleaning
|
|
200
|
+
|
|
201
|
+
Unused keys are marked as obsolete during a production build. Obsoletes are
|
|
202
|
+
purged on the next run (build or dev). Essentially this means cleaning needs
|
|
203
|
+
two passes. This is because of how vite/rollup works.
|
|
204
|
+
|
|
205
|
+
## ๐งช Tests
|
|
206
|
+
|
|
207
|
+
A wide range of scenarios are tested, including:
|
|
208
|
+
|
|
209
|
+
- Raw strings, HTML markup, nested blocks
|
|
210
|
+
- Loops, awaits, conditionals
|
|
211
|
+
- PO parsing and index consistency
|
|
212
|
+
- Obsolete tracking and cleanup
|
|
213
|
+
|
|
214
|
+
## ๐ License MIT
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wuchale",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "i18n for svelte without turning your codebase upside down",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node tests"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"svelte",
|
|
11
|
+
"js",
|
|
12
|
+
"i18n"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"preprocess/",
|
|
17
|
+
"runtime.svelte"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://github.com/K1DV5/wuchale",
|
|
20
|
+
"bugs": "https://github.com/K1DV5/wuchale/issues",
|
|
21
|
+
"author": "K1DV5",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"magic-string": "^0.30.17",
|
|
25
|
+
"pofile": "^1.1.4"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"svelte": "^5.28.1"
|
|
29
|
+
},
|
|
30
|
+
"type": "module"
|
|
31
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// $$ cd .. && npm run test
|
|
2
|
+
// $$ node %f
|
|
3
|
+
|
|
4
|
+
import { parse } from "svelte/compiler"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {{ type: string; data: any; name: string | any[]; children: any; expression: { value: any; }; }} ast
|
|
8
|
+
*/
|
|
9
|
+
function walkCompileNodes(ast) {
|
|
10
|
+
const parts = []
|
|
11
|
+
if (ast.type === 'Text') {
|
|
12
|
+
parts.push(ast.data)
|
|
13
|
+
} else if (ast.type === 'InlineComponent') {
|
|
14
|
+
const nodeIndex = Number(ast.name.slice(1))
|
|
15
|
+
const subParts = [nodeIndex]
|
|
16
|
+
for (const child of ast.children) {
|
|
17
|
+
subParts.push(...walkCompileNodes(child))
|
|
18
|
+
}
|
|
19
|
+
parts.push(subParts)
|
|
20
|
+
} else if (ast.type === 'MustacheTag') {
|
|
21
|
+
parts.push(ast.expression.value)
|
|
22
|
+
} else if (ast.type === 'Fragment') {
|
|
23
|
+
for (const child of ast.children) {
|
|
24
|
+
parts.push(...walkCompileNodes(child))
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
console.log(ast)
|
|
28
|
+
}
|
|
29
|
+
return parts
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} text
|
|
34
|
+
* @param {any} fallback
|
|
35
|
+
*/
|
|
36
|
+
export default function compileTranslation(text, fallback) {
|
|
37
|
+
if (!text) {
|
|
38
|
+
return fallback
|
|
39
|
+
}
|
|
40
|
+
if (!text.includes('<') && !text.includes('{')) {
|
|
41
|
+
return text
|
|
42
|
+
}
|
|
43
|
+
// <0></0> to <X0></X0> to please svelte parser
|
|
44
|
+
text = text.replace(/(<|(<\/))(\d+)/g, '$1X$3')
|
|
45
|
+
try {
|
|
46
|
+
const ast = parse(text).html
|
|
47
|
+
return walkCompileNodes(ast)
|
|
48
|
+
} catch(err) {
|
|
49
|
+
console.error(err)
|
|
50
|
+
return fallback
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// $$ node %f
|
|
2
|
+
|
|
3
|
+
const baseURL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key='
|
|
4
|
+
const h = {'Content-Type': 'application/json'}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string[]} fragments
|
|
8
|
+
* @param {string} sourceLocale
|
|
9
|
+
* @param {string} targetLocale
|
|
10
|
+
*/
|
|
11
|
+
function prepareData(fragments, sourceLocale, targetLocale) {
|
|
12
|
+
const instruction = `
|
|
13
|
+
You will be given text fragments for a web app separated by new line characters.
|
|
14
|
+
Translate each of the fragments from the source to the target language.
|
|
15
|
+
You have to find out the languages using their ISO 639-1 codes.
|
|
16
|
+
The source language is: ${sourceLocale}.
|
|
17
|
+
The target language is: ${targetLocale}.
|
|
18
|
+
Preserve any placeholders and provide the translations in the target language only,
|
|
19
|
+
separated by new line characters in the same order.
|
|
20
|
+
The placeholder format is like the following examples:
|
|
21
|
+
- {0}: means arbitrary values.
|
|
22
|
+
- <0>something</0>: means something enclosed in some tags, like HTML tags
|
|
23
|
+
- <0/>: means a self closing tag, like in HTML
|
|
24
|
+
In all of the examples, 0 is an example for any integer.
|
|
25
|
+
`
|
|
26
|
+
return {
|
|
27
|
+
system_instruction: {
|
|
28
|
+
parts: [{ text: instruction }]
|
|
29
|
+
},
|
|
30
|
+
contents: [{parts: [{text: fragments.join('\n')}]}]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} targetLocale
|
|
36
|
+
* @param {string | undefined} apiKey
|
|
37
|
+
*/
|
|
38
|
+
function setupGemini(sourceLocale = 'en', targetLocale, apiKey) {
|
|
39
|
+
if (apiKey === 'env') {
|
|
40
|
+
apiKey = process.env.GEMINI_API_KEY
|
|
41
|
+
}
|
|
42
|
+
if (!apiKey) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const url = `${baseURL}${apiKey}`
|
|
46
|
+
return async (/** @type {string[]} */ fragments) => {
|
|
47
|
+
const data = prepareData(fragments, sourceLocale, targetLocale)
|
|
48
|
+
const res = await fetch(url, {method: 'POST', headers: h, body: JSON.stringify(data)})
|
|
49
|
+
const json = await res.json()
|
|
50
|
+
const resText = json.candidates[0].content.parts[0].text
|
|
51
|
+
const trans = {}
|
|
52
|
+
for (const [i, text] of resText.split('\n').entries()) {
|
|
53
|
+
if (text.trim()) {
|
|
54
|
+
trans[fragments[i]] = text
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return trans
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default setupGemini
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import Preprocess, { IndexTracker } from "./prep.js"
|
|
2
|
+
import { parse } from "svelte/compiler"
|
|
3
|
+
import {writeFile} from 'node:fs/promises'
|
|
4
|
+
import compileTranslation from "./compile.js"
|
|
5
|
+
import setupGemini from "./gemini.js"
|
|
6
|
+
import PO from "pofile"
|
|
7
|
+
import { relative } from "node:path"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} text
|
|
11
|
+
* @param {string} scope
|
|
12
|
+
* @returns {{extract: boolean, replace: string}}
|
|
13
|
+
*/
|
|
14
|
+
export function defaultHeuristic(text, scope = 'markup') {
|
|
15
|
+
if (scope === 'markup') {
|
|
16
|
+
if (text.startsWith('-')) {
|
|
17
|
+
return {extract: false, replace: text.slice(1).trim()}
|
|
18
|
+
}
|
|
19
|
+
return {extract: true, replace: text}
|
|
20
|
+
}
|
|
21
|
+
// script and attribute
|
|
22
|
+
if (text.startsWith('+')) {
|
|
23
|
+
return {extract: true, replace: text.slice(1).trim()}
|
|
24
|
+
}
|
|
25
|
+
const range = 'AZ'
|
|
26
|
+
const startCode = text.charCodeAt(0)
|
|
27
|
+
if (startCode >= range.charCodeAt(0) && startCode <= range.charCodeAt(1)) {
|
|
28
|
+
return {extract: true, replace: text}
|
|
29
|
+
}
|
|
30
|
+
return {extract: false, replace: text}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const defaultOptions = {
|
|
34
|
+
sourceLocale: 'en',
|
|
35
|
+
otherLocales: ['am'],
|
|
36
|
+
localesDir: './locales',
|
|
37
|
+
importFrom: 'wuchale/runtime.svelte',
|
|
38
|
+
heuristic: defaultHeuristic,
|
|
39
|
+
geminiAPIKey: 'env',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} filename
|
|
44
|
+
*/
|
|
45
|
+
async function loadPONoFail(filename) {
|
|
46
|
+
return new Promise((res) => {
|
|
47
|
+
PO.load(filename, (err, po) => {
|
|
48
|
+
if (err) {
|
|
49
|
+
res({})
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const translations = {}
|
|
53
|
+
let total = 0
|
|
54
|
+
let obsolete = 0
|
|
55
|
+
let untranslated = 0
|
|
56
|
+
for (const item of po.items) {
|
|
57
|
+
total++
|
|
58
|
+
if (item.obsolete) {
|
|
59
|
+
obsolete++
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (!item.msgstr[0]) {
|
|
63
|
+
untranslated++
|
|
64
|
+
}
|
|
65
|
+
translations[item.msgid] = item
|
|
66
|
+
}
|
|
67
|
+
res({translations, total, obsolete, untranslated})
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {{ [s: string]: any; } | ArrayLike<any>} translations
|
|
74
|
+
* @param {string} filename
|
|
75
|
+
*/
|
|
76
|
+
async function savePO(translations, filename) {
|
|
77
|
+
const po = new PO()
|
|
78
|
+
for (const item of Object.values(translations)) {
|
|
79
|
+
po.items.push(item)
|
|
80
|
+
}
|
|
81
|
+
return new Promise((res, rej) => {
|
|
82
|
+
po.save(filename, err => {
|
|
83
|
+
if (err) {
|
|
84
|
+
rej(err)
|
|
85
|
+
} else {
|
|
86
|
+
res()
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mergeOptionsWithDefault(options = defaultOptions) {
|
|
93
|
+
for (const key of Object.keys(defaultOptions)) {
|
|
94
|
+
if (key in options) {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
options[key] = defaultOptions[key]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default async function wuchale(options = defaultOptions) {
|
|
102
|
+
mergeOptionsWithDefault(options)
|
|
103
|
+
const locales = [options.sourceLocale, ...options.otherLocales]
|
|
104
|
+
const translations = {}
|
|
105
|
+
const compiledFname = {}
|
|
106
|
+
const translationsFname = {}
|
|
107
|
+
for (const loc of locales) {
|
|
108
|
+
compiledFname[loc] = `${options.localesDir}/${loc}.json`
|
|
109
|
+
translationsFname[loc] = `${options.localesDir}/${loc}.po`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let forProduction = false
|
|
113
|
+
let projectRoot = ''
|
|
114
|
+
|
|
115
|
+
let sourceTranslations = {}
|
|
116
|
+
let indexTracker = new IndexTracker({})
|
|
117
|
+
|
|
118
|
+
const compiled = {}
|
|
119
|
+
|
|
120
|
+
async function loadFilesAndSetup() {
|
|
121
|
+
for (const loc of locales) {
|
|
122
|
+
const {translations: trans, total, obsolete, untranslated} = await loadPONoFail(translationsFname[loc])
|
|
123
|
+
translations[loc] = trans
|
|
124
|
+
console.info(`i18n stats (${loc}): total: ${total}, obsolete: ${obsolete}, untranslated: ${untranslated}`)
|
|
125
|
+
}
|
|
126
|
+
sourceTranslations = translations[options.sourceLocale]
|
|
127
|
+
indexTracker = new IndexTracker(sourceTranslations)
|
|
128
|
+
// startup compile
|
|
129
|
+
for (const loc of locales) {
|
|
130
|
+
compiled[loc] = []
|
|
131
|
+
for (const txt in translations[loc]) {
|
|
132
|
+
const poItem = translations[loc][txt]
|
|
133
|
+
if (forProduction) {
|
|
134
|
+
poItem.references = []
|
|
135
|
+
}
|
|
136
|
+
const index = indexTracker.get(txt)
|
|
137
|
+
compiled[loc][index] = compileTranslation(poItem.msgstr[0], compiled[options.sourceLocale][index])
|
|
138
|
+
}
|
|
139
|
+
await writeFile(compiledFname[loc], JSON.stringify(compiled[loc], null, 2))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} content
|
|
145
|
+
* @param {import("./prep.js").Node} ast
|
|
146
|
+
* @param {string} filename
|
|
147
|
+
*/
|
|
148
|
+
async function preprocess(content, ast, filename) {
|
|
149
|
+
const prep = new Preprocess(indexTracker, options.heuristic, options.importFrom)
|
|
150
|
+
const txts = prep.process(content, ast)
|
|
151
|
+
if (!txts.length) {
|
|
152
|
+
return {}
|
|
153
|
+
}
|
|
154
|
+
for (const loc of locales) {
|
|
155
|
+
const newTxts = []
|
|
156
|
+
for (const nTxt of txts) {
|
|
157
|
+
const txt = nTxt.toString()
|
|
158
|
+
let translated = translations[loc][txt]
|
|
159
|
+
if (translated == null) {
|
|
160
|
+
translated = new PO.Item()
|
|
161
|
+
translated.msgid = txt
|
|
162
|
+
translations[loc][txt] = translated
|
|
163
|
+
}
|
|
164
|
+
if (!translated.references.includes(filename)) {
|
|
165
|
+
translated.references.push(filename)
|
|
166
|
+
}
|
|
167
|
+
if (loc === options.sourceLocale) {
|
|
168
|
+
if (translated.msgstr[0] !== txt) {
|
|
169
|
+
translated.msgstr = [txt]
|
|
170
|
+
newTxts.push(txt)
|
|
171
|
+
}
|
|
172
|
+
} else if (!translated.msgstr[0]) {
|
|
173
|
+
newTxts.push(txt)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (loc !== options.sourceLocale && newTxts.length) {
|
|
177
|
+
const geminiT = setupGemini(options.sourceLocale, loc, options.geminiAPIKey)
|
|
178
|
+
if (geminiT) {
|
|
179
|
+
const gTrans = await geminiT(newTxts)
|
|
180
|
+
for (const txt of newTxts) {
|
|
181
|
+
translations[loc][txt].msgstr = [gTrans[txt]]
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const nTxt of txts) {
|
|
186
|
+
const txt = nTxt.toString()
|
|
187
|
+
const index = indexTracker.get(txt)
|
|
188
|
+
compiled[loc][index] = compileTranslation(translations[loc][txt].msgstr[0], compiled[options.sourceLocale][index])
|
|
189
|
+
}
|
|
190
|
+
for (const [i, c] of compiled[loc].entries()) {
|
|
191
|
+
if (c == null) {
|
|
192
|
+
compiled[loc][i] = 0 // reduce json size for jumped indices, instead of null
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!newTxts.length) {
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
code: prep.mstr.toString(),
|
|
201
|
+
map: prep.mstr.generateMap(),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
name: 'wuchale',
|
|
207
|
+
/**
|
|
208
|
+
* @param {{ env: { PROD: boolean; }, root: string; }} config
|
|
209
|
+
*/
|
|
210
|
+
async configResolved(config) {
|
|
211
|
+
forProduction = config.env.PROD
|
|
212
|
+
projectRoot = config.root
|
|
213
|
+
await loadFilesAndSetup()
|
|
214
|
+
},
|
|
215
|
+
transform: {
|
|
216
|
+
order: 'pre',
|
|
217
|
+
/**
|
|
218
|
+
* @param {string} code
|
|
219
|
+
* @param {string} id
|
|
220
|
+
*/
|
|
221
|
+
handler: async function(code, id) {
|
|
222
|
+
const isModule = id.endsWith('.svelte.js')
|
|
223
|
+
if (!id.endsWith('.svelte') && !isModule) {
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
let ast
|
|
227
|
+
if (isModule) {
|
|
228
|
+
ast = this.parse(code)
|
|
229
|
+
} else {
|
|
230
|
+
ast = parse(code)
|
|
231
|
+
ast.type = 'SvelteComponent'
|
|
232
|
+
}
|
|
233
|
+
const filename = relative(projectRoot, id)
|
|
234
|
+
const processed = await preprocess(code, ast, filename)
|
|
235
|
+
if (processed.code) {
|
|
236
|
+
for (const loc of locales) {
|
|
237
|
+
await savePO(translations[loc], translationsFname[loc])
|
|
238
|
+
await writeFile(compiledFname[loc], JSON.stringify(compiled[loc]))
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return processed
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
async buildEnd() {
|
|
245
|
+
if (!forProduction) {
|
|
246
|
+
// just being pragmatic
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
for (const loc of locales) {
|
|
250
|
+
for (const txt in translations[loc]) {
|
|
251
|
+
const poItem = translations[loc][txt]
|
|
252
|
+
poItem.obsolete = poItem.references.length === 0
|
|
253
|
+
}
|
|
254
|
+
await savePO(translations[loc], translationsFname[loc])
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
setupTesting() {
|
|
258
|
+
for (const loc of locales) {
|
|
259
|
+
translations[loc] = {}
|
|
260
|
+
compiled[loc] = []
|
|
261
|
+
}
|
|
262
|
+
sourceTranslations = translations[options.sourceLocale]
|
|
263
|
+
return {
|
|
264
|
+
translations,
|
|
265
|
+
compiled,
|
|
266
|
+
preprocess,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
// $$ cd .. && npm run test
|
|
2
|
+
|
|
3
|
+
import MagicString from "magic-string"
|
|
4
|
+
|
|
5
|
+
const snipPrefix = 'wuchaleSnippet'
|
|
6
|
+
const rtComponent = 'WuchaleTrans'
|
|
7
|
+
const rtFunc = 'wuchaleTrans'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} Node
|
|
11
|
+
* @property {string} type
|
|
12
|
+
* @property {number} start
|
|
13
|
+
* @property {number} end
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Node & {expression: Node, value: boolean | (Node & {data: string})[]}} Attribute
|
|
18
|
+
* @typedef {Node & {value: string}} NodeWithVal
|
|
19
|
+
* @typedef {Node & {data: string}} NodeWithData
|
|
20
|
+
* @typedef {Node & {
|
|
21
|
+
* attributes: Attribute[],
|
|
22
|
+
* children: (Element & NodeWithData & { expression: Node })[]
|
|
23
|
+
* inCompoundText: boolean | null,
|
|
24
|
+
* }} Element
|
|
25
|
+
* @typedef {(text: string, scope?: string) => {extract: boolean; replace: string}} HeuristicFunc
|
|
26
|
+
* @typedef {Node & {
|
|
27
|
+
* properties: Node & { key: Node, value: Node }[]
|
|
28
|
+
* }} ObjExpr
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
class NestText extends String {
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} txt
|
|
34
|
+
* @param {string} scope
|
|
35
|
+
*/
|
|
36
|
+
constructor(txt, scope) {
|
|
37
|
+
super(txt)
|
|
38
|
+
this.scope = scope
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class IndexTracker {
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} sourceTranslations
|
|
45
|
+
*/
|
|
46
|
+
constructor(sourceTranslations) {
|
|
47
|
+
this.indices = {}
|
|
48
|
+
this.nextIndex = 0
|
|
49
|
+
for (const txt of Object.keys(sourceTranslations)) {
|
|
50
|
+
// guaranteed order for strings since ES2015
|
|
51
|
+
this.indices[txt] = this.nextIndex
|
|
52
|
+
this.nextIndex++
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get = (/** @type {string} */ txt) => {
|
|
57
|
+
if (txt in this.indices) {
|
|
58
|
+
return this.indices[txt]
|
|
59
|
+
}
|
|
60
|
+
const index = this.nextIndex
|
|
61
|
+
this.indices[txt] = index
|
|
62
|
+
this.nextIndex++
|
|
63
|
+
return index
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default class Preprocess {
|
|
68
|
+
/**
|
|
69
|
+
* @param {IndexTracker} index
|
|
70
|
+
* @param {HeuristicFunc} heuristic
|
|
71
|
+
* @param {string} importFrom
|
|
72
|
+
*/
|
|
73
|
+
constructor(index, heuristic, importFrom) {
|
|
74
|
+
this.index = index
|
|
75
|
+
this.importFrom = importFrom
|
|
76
|
+
this.heuristic = heuristic
|
|
77
|
+
this.content = ''
|
|
78
|
+
/** @type {MagicString} */
|
|
79
|
+
this.mstr = null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {{ start: number; end: number; }} node
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @param {string} scope
|
|
86
|
+
* @returns {Array<*> & {0: boolean, 1: NestText}}
|
|
87
|
+
*/
|
|
88
|
+
modifyCheck = (node, text, scope) => {
|
|
89
|
+
text = text.replace(/\s+/g, ' ').trim()
|
|
90
|
+
if (text === '') {
|
|
91
|
+
// nothing to ask
|
|
92
|
+
return [false, null]
|
|
93
|
+
}
|
|
94
|
+
let {extract, replace} = this.heuristic(text, scope)
|
|
95
|
+
replace = replace.trim()
|
|
96
|
+
if (!extract && text !== replace) {
|
|
97
|
+
this.mstr.update(node.start, node.end, replace)
|
|
98
|
+
}
|
|
99
|
+
return [extract, new NestText(replace, scope)]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// visitComment = () => []
|
|
103
|
+
// visitIdentifier = () => []
|
|
104
|
+
// visitImportDeclaration = () => []
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {NodeWithVal} node
|
|
108
|
+
* @returns {NestText[]}
|
|
109
|
+
*/
|
|
110
|
+
visitLiteral = node => {
|
|
111
|
+
if (typeof node.value !== 'string') {
|
|
112
|
+
return []
|
|
113
|
+
}
|
|
114
|
+
const [pass, txt] = this.modifyCheck(node, node.value, 'script')
|
|
115
|
+
if (!pass) {
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
this.mstr.update(node.start, node.end, `${rtFunc}(${this.index.get(txt.toString())})`)
|
|
119
|
+
return [txt]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {Node & { elements: Node[] }} node
|
|
124
|
+
* @returns {NestText[]}
|
|
125
|
+
*/
|
|
126
|
+
visitArrayExpression = node => {
|
|
127
|
+
const txts = []
|
|
128
|
+
for (const elm of node.elements) {
|
|
129
|
+
txts.push(...this.visit(elm))
|
|
130
|
+
}
|
|
131
|
+
return txts
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {ObjExpr} node
|
|
136
|
+
* @returns {NestText[]}
|
|
137
|
+
*/
|
|
138
|
+
visitObjectExpression = node => {
|
|
139
|
+
const txts = []
|
|
140
|
+
for (const prop of node.properties) {
|
|
141
|
+
txts.push(...this.visit(prop.key))
|
|
142
|
+
txts.push(...this.visit(prop.value))
|
|
143
|
+
}
|
|
144
|
+
return txts
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {Node & { object: Node, property: Node }} node
|
|
149
|
+
* @returns {NestText[]}
|
|
150
|
+
*/
|
|
151
|
+
visitMemberExpression = node => {
|
|
152
|
+
return [
|
|
153
|
+
...this.visit(node.object),
|
|
154
|
+
...this.visit(node.property),
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {Node & { callee: Node, arguments: Node[] }} node
|
|
160
|
+
* @returns {NestText[]}
|
|
161
|
+
*/
|
|
162
|
+
visitCallExpression = node => {
|
|
163
|
+
const txts = [...this.visit(node.callee)]
|
|
164
|
+
for (const arg of node.arguments) {
|
|
165
|
+
txts.push(...this.visit(arg))
|
|
166
|
+
}
|
|
167
|
+
return txts
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {Node & {
|
|
172
|
+
* declarations: Node & {
|
|
173
|
+
* init: Node & {
|
|
174
|
+
* callee: Node & { name: string },
|
|
175
|
+
* },
|
|
176
|
+
* }[]
|
|
177
|
+
* }} node
|
|
178
|
+
* @returns {NestText[]}
|
|
179
|
+
*/
|
|
180
|
+
visitVariableDeclaration = node => {
|
|
181
|
+
const txts = []
|
|
182
|
+
for (const dec of node.declarations) {
|
|
183
|
+
if (!dec.init) {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
// visit only contents inside $derived
|
|
187
|
+
if (dec.init.type !== 'CallExpression' || dec.init.callee.type !== 'Identifier' || dec.init.callee.name !== '$derived') {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
const decVisit = this.visit(dec.init)
|
|
191
|
+
if (!decVisit.length) {
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
txts.push(...decVisit)
|
|
195
|
+
}
|
|
196
|
+
return txts
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {Node & { declaration: Node }} node
|
|
201
|
+
* @returns {NestText[]}
|
|
202
|
+
*/
|
|
203
|
+
visitExportDefaultDeclaration = node => this.visit(node.declaration)
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param {Node & {
|
|
207
|
+
* quasis: (Node & {
|
|
208
|
+
* value: {cooked: string}
|
|
209
|
+
* })[],
|
|
210
|
+
* expressions: Node[]
|
|
211
|
+
* }} node
|
|
212
|
+
* @returns {NestText[]}
|
|
213
|
+
*/
|
|
214
|
+
visitTemplateLiteral = node => {
|
|
215
|
+
const txts = []
|
|
216
|
+
const quasi0 = node.quasis[0]
|
|
217
|
+
const [pass, txt] = this.modifyCheck(quasi0, quasi0.value.cooked, 'script')
|
|
218
|
+
if (!pass) {
|
|
219
|
+
return txts
|
|
220
|
+
}
|
|
221
|
+
let nTxt = txt.toString()
|
|
222
|
+
for (const [i, expr] of node.expressions.entries()) {
|
|
223
|
+
txts.push(...this.visit(expr))
|
|
224
|
+
const quasi = node.quasis[i + 1]
|
|
225
|
+
nTxt += `{${i}}${quasi.value.cooked}`
|
|
226
|
+
this.mstr.remove(quasi.start - 1, quasi.end)
|
|
227
|
+
if (i + 1 === node.expressions.length) {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
this.mstr.update(quasi.end, quasi.end + 2, ', ')
|
|
231
|
+
}
|
|
232
|
+
let repl = `${rtFunc}(${this.index.get(txt.toString())}`
|
|
233
|
+
if (node.expressions.length) {
|
|
234
|
+
repl += ', '
|
|
235
|
+
}
|
|
236
|
+
this.mstr.update(quasi0.start - 1, quasi0.end + 2, repl)
|
|
237
|
+
this.mstr.update(node.end - 1, node.end, ')')
|
|
238
|
+
txts.push(new NestText(nTxt, 'script'))
|
|
239
|
+
return txts
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {Node & {expression: Node}} node
|
|
245
|
+
* @returns {NestText[]}
|
|
246
|
+
*/
|
|
247
|
+
visitMustacheTag = node => this.visit(node.expression)
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @param {Node & {children: NodeWithData[]}} node
|
|
251
|
+
* @returns {boolean}
|
|
252
|
+
*/
|
|
253
|
+
checkHasCompoundText = node => {
|
|
254
|
+
let text = false
|
|
255
|
+
let nonText = false
|
|
256
|
+
for (const child of node.children ?? []) {
|
|
257
|
+
if (child.type === 'Text') {
|
|
258
|
+
if (child.data.trim()) {
|
|
259
|
+
text = true
|
|
260
|
+
}
|
|
261
|
+
} else if (child.type !== 'Comment') {
|
|
262
|
+
nonText = true
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return text && nonText // mixed content
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {Element} node
|
|
270
|
+
* @returns {NestText[]}
|
|
271
|
+
*/
|
|
272
|
+
visitElement = node => {
|
|
273
|
+
const txts = []
|
|
274
|
+
for (const attrib of node.attributes) {
|
|
275
|
+
txts.push(...this.visitAttribute(attrib))
|
|
276
|
+
}
|
|
277
|
+
if (node.children.length === 0) {
|
|
278
|
+
return txts
|
|
279
|
+
}
|
|
280
|
+
let hasTextChild = false
|
|
281
|
+
let hasNonTextChild = false
|
|
282
|
+
const textNodesToModify = {}
|
|
283
|
+
for (const [i, child] of node.children.entries()) {
|
|
284
|
+
if (child.type === 'Text') {
|
|
285
|
+
const [pass, modify] = this.modifyCheck(child, child.data, 'markup')
|
|
286
|
+
if (pass) {
|
|
287
|
+
hasTextChild = true
|
|
288
|
+
textNodesToModify[i] = modify
|
|
289
|
+
} else if (i === 0 && modify != null) { // non whitespace
|
|
290
|
+
return txts // explicitly to ignore
|
|
291
|
+
}
|
|
292
|
+
} else if (child.type !== 'Comment') {
|
|
293
|
+
hasNonTextChild = true
|
|
294
|
+
}
|
|
295
|
+
// no break because of textNodesToModify, already started, finish it
|
|
296
|
+
}
|
|
297
|
+
let hasCompoundText = hasTextChild && hasNonTextChild
|
|
298
|
+
let txt = ''
|
|
299
|
+
let iArg = 0
|
|
300
|
+
let iTag = 0
|
|
301
|
+
const lastChildEnd = node.children.slice(-1)[0].end
|
|
302
|
+
for (const [i, child] of node.children.entries()) {
|
|
303
|
+
if (child.type === 'Comment') {
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
if (child.type === 'Text') {
|
|
307
|
+
const modify = textNodesToModify[i]
|
|
308
|
+
if (modify == null) { // whitespace
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
txt += ' ' + modify
|
|
312
|
+
if (node.inCompoundText && node.children.length === 1) {
|
|
313
|
+
this.mstr.update(child.start, child.end, `{ctx[1]}`)
|
|
314
|
+
} else {
|
|
315
|
+
this.mstr.remove(child.start, child.end)
|
|
316
|
+
}
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
if (!node.inCompoundText && !hasCompoundText) {
|
|
320
|
+
txts.push(...this.visit(child))
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
if (child.type === 'MustacheTag') {
|
|
324
|
+
txts.push(...this.visitMustacheTag(child))
|
|
325
|
+
txt += ` {${iArg}}`
|
|
326
|
+
this.mstr.move(child.start + 1, child.end - 1, lastChildEnd)
|
|
327
|
+
if (iArg > 0) {
|
|
328
|
+
this.mstr.update(child.start, child.start + 1, ', ')
|
|
329
|
+
} else {
|
|
330
|
+
this.mstr.remove(child.start, child.start + 1)
|
|
331
|
+
}
|
|
332
|
+
this.mstr.remove(child.end - 1, child.end)
|
|
333
|
+
iArg++
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
// elements and components
|
|
337
|
+
child.inCompoundText = true
|
|
338
|
+
let chTxt = ''
|
|
339
|
+
for (const txt of this.visit(child)) {
|
|
340
|
+
if (child.type === 'Element' && txt.scope === 'markup') {
|
|
341
|
+
chTxt += txt.toString()
|
|
342
|
+
} else { // attributes, blocks
|
|
343
|
+
txts.push(txt)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (child.type === 'Element') {
|
|
347
|
+
chTxt = `<${iTag}>${chTxt}</${iTag}>`
|
|
348
|
+
} else {
|
|
349
|
+
// InlineComponent
|
|
350
|
+
chTxt = `<${iTag}/>`
|
|
351
|
+
}
|
|
352
|
+
const snippetName = `${snipPrefix}${iTag}`
|
|
353
|
+
const snippetBegin = `\n{#snippet ${snippetName}(ctx)}\n`
|
|
354
|
+
const snippetEnd = '\n{/snippet}'
|
|
355
|
+
this.mstr.appendRight(child.start, snippetBegin)
|
|
356
|
+
this.mstr.prependLeft(child.end, snippetEnd)
|
|
357
|
+
iTag++
|
|
358
|
+
if (!txt.endsWith(' ')) {
|
|
359
|
+
txt += ' '
|
|
360
|
+
}
|
|
361
|
+
txt += chTxt
|
|
362
|
+
}
|
|
363
|
+
txt = txt.trim()
|
|
364
|
+
if (!txt) {
|
|
365
|
+
return txts
|
|
366
|
+
}
|
|
367
|
+
const nTxt = new NestText(txt, 'markup')
|
|
368
|
+
txts.push(nTxt)
|
|
369
|
+
if (iTag > 0) {
|
|
370
|
+
const snippets = []
|
|
371
|
+
// reference all new snippets added
|
|
372
|
+
for (let i = 0; i < iTag; i++) {
|
|
373
|
+
snippets.push(`${snipPrefix}${i}`)
|
|
374
|
+
}
|
|
375
|
+
let begin = `\n<${rtComponent} tags={[${snippets.join(', ')}]} `
|
|
376
|
+
if (node.inCompoundText) {
|
|
377
|
+
begin += `ctx={ctx}`
|
|
378
|
+
} else {
|
|
379
|
+
begin += `id={${this.index.get(txt)}}`
|
|
380
|
+
}
|
|
381
|
+
let end = ' />\n'
|
|
382
|
+
if (iArg > 0) {
|
|
383
|
+
begin += ' args={['
|
|
384
|
+
end = ']}' + end
|
|
385
|
+
}
|
|
386
|
+
this.mstr.appendLeft(lastChildEnd, begin)
|
|
387
|
+
this.mstr.appendRight(lastChildEnd, end)
|
|
388
|
+
} else if (!node.inCompoundText) {
|
|
389
|
+
this.mstr.appendLeft(lastChildEnd, `{${rtFunc}(${this.index.get(txt)}, `)
|
|
390
|
+
this.mstr.appendRight(lastChildEnd, ')}')
|
|
391
|
+
}
|
|
392
|
+
return txts
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
visitInlineComponent = this.visitElement
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* @param {NodeWithData} node
|
|
399
|
+
* @returns {NestText[]}
|
|
400
|
+
*/
|
|
401
|
+
visitText = node => {
|
|
402
|
+
const [pass, txt] = this.modifyCheck(node, node.data, 'markup')
|
|
403
|
+
if (!pass) {
|
|
404
|
+
return []
|
|
405
|
+
}
|
|
406
|
+
this.mstr.update(node.start, node.end, `{${rtFunc}(${this.index.get(txt.toString())})}`)
|
|
407
|
+
return [txt]
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* @param {Attribute} node
|
|
412
|
+
* @returns {NestText[]}
|
|
413
|
+
*/
|
|
414
|
+
visitAttribute = node => {
|
|
415
|
+
if (node.type === 'Spread') {
|
|
416
|
+
return this.visit(node.expression)
|
|
417
|
+
}
|
|
418
|
+
if (node.type !== 'Attribute' || typeof node.value === 'boolean') {
|
|
419
|
+
return []
|
|
420
|
+
}
|
|
421
|
+
const txts = []
|
|
422
|
+
for (const value of node.value) {
|
|
423
|
+
if (value.type !== 'Text') {
|
|
424
|
+
txts.push(...this.visit(value))
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
const [pass, txt] = this.modifyCheck(value, value.data, 'attribute')
|
|
428
|
+
if (!pass) {
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
txts.push(txt)
|
|
432
|
+
this.mstr.update(value.start, value.end, `{${rtFunc}(${this.index.get(txt.toString())})}`)
|
|
433
|
+
let {start, end} = value
|
|
434
|
+
if (!`'"`.includes(this.content[start - 1])) {
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
this.mstr.remove(start - 1, start)
|
|
438
|
+
this.mstr.remove(end, end + 1)
|
|
439
|
+
}
|
|
440
|
+
return txts
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* @param {Node & {children: Node[]}} node
|
|
445
|
+
* @returns {NestText[]}
|
|
446
|
+
*/
|
|
447
|
+
visitFragment = node => {
|
|
448
|
+
const txts = []
|
|
449
|
+
for (const child of node.children) {
|
|
450
|
+
txts.push(...this.visit(child))
|
|
451
|
+
}
|
|
452
|
+
return txts
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
visitSnippetBlock = this.visitFragment
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* @param {Node & {children: Node[], expression: Node}} node
|
|
459
|
+
* @returns {NestText[]}
|
|
460
|
+
*/
|
|
461
|
+
visitIfBlock = node => {
|
|
462
|
+
const txts = this.visit(node.expression)
|
|
463
|
+
for (const child of node.children) {
|
|
464
|
+
txts.push(...this.visit(child))
|
|
465
|
+
}
|
|
466
|
+
return txts
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
visitEachBlock = this.visitIfBlock
|
|
470
|
+
|
|
471
|
+
visitKeyBlock = this.visitFragment
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @typedef {Node & {children: Node[]}} Block
|
|
475
|
+
* @param {Node & {
|
|
476
|
+
* expression: Node
|
|
477
|
+
* value: ObjExpr
|
|
478
|
+
* pending: Block
|
|
479
|
+
* then: Block
|
|
480
|
+
* catch: Block
|
|
481
|
+
* }} node
|
|
482
|
+
* @returns {NestText[]}
|
|
483
|
+
*/
|
|
484
|
+
visitAwaitBlock = node => {
|
|
485
|
+
const txts = this.visit(node.expression)
|
|
486
|
+
txts.push(
|
|
487
|
+
...this.visit(node.value),
|
|
488
|
+
...this.visitFragment(node.pending),
|
|
489
|
+
...this.visitFragment(node.then),
|
|
490
|
+
...this.visitFragment(node.catch),
|
|
491
|
+
)
|
|
492
|
+
return txts
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* @typedef {Node & {body: Node[]}} Program
|
|
497
|
+
* @param {Program} node
|
|
498
|
+
* @returns {NestText[]}
|
|
499
|
+
*/
|
|
500
|
+
visitProgram = (node, needImport = true) => {
|
|
501
|
+
const txts = []
|
|
502
|
+
for (const child of node.body) {
|
|
503
|
+
txts.push(...this.visit(child))
|
|
504
|
+
}
|
|
505
|
+
if (needImport) {
|
|
506
|
+
const importStmt = `import {${rtFunc}} from "${this.importFrom}"\n`
|
|
507
|
+
this.mstr.appendRight(node.start, importStmt)
|
|
508
|
+
}
|
|
509
|
+
return txts
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* @param {Node & {
|
|
514
|
+
* html: Node & {children: Node[]}
|
|
515
|
+
* instance: Node & {content: Program}}
|
|
516
|
+
* } node
|
|
517
|
+
* @returns {NestText[]}
|
|
518
|
+
*/
|
|
519
|
+
visitSvelteComponent = node => {
|
|
520
|
+
const txts = this.visitFragment(node.html)
|
|
521
|
+
if (node.instance) {
|
|
522
|
+
txts.push(...this.visitProgram(node.instance.content, false))
|
|
523
|
+
}
|
|
524
|
+
// @ts-ignore: module is a reserved keyword, not sure how to specify the type
|
|
525
|
+
if (node.module) {
|
|
526
|
+
// @ts-ignore
|
|
527
|
+
txts.push(...this.visitProgram(node.module.content, false))
|
|
528
|
+
}
|
|
529
|
+
const importStmt = `import ${rtComponent}, {${rtFunc}} from "${this.importFrom}"\n`
|
|
530
|
+
if (node.instance) {
|
|
531
|
+
this.mstr.appendRight(node.instance.content.start, importStmt)
|
|
532
|
+
// @ts-ignore
|
|
533
|
+
} else if (node.module) {
|
|
534
|
+
// @ts-ignore
|
|
535
|
+
this.mstr.appendRight(node.module.content.start, importStmt)
|
|
536
|
+
} else {
|
|
537
|
+
this.mstr.prepend(`<script>${importStmt}</script>\n`)
|
|
538
|
+
}
|
|
539
|
+
return txts
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* @param {Node} node
|
|
544
|
+
* @returns {NestText[]}
|
|
545
|
+
*/
|
|
546
|
+
visit = node => {
|
|
547
|
+
const methodName = `visit${node.type}`
|
|
548
|
+
if (methodName in this) {
|
|
549
|
+
return this[methodName](node)
|
|
550
|
+
}
|
|
551
|
+
// console.log(node)
|
|
552
|
+
return []
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* @param {string} content
|
|
557
|
+
* @param {Node} ast
|
|
558
|
+
* @returns {NestText[]}
|
|
559
|
+
*/
|
|
560
|
+
process = (content, ast) => {
|
|
561
|
+
this.content = content
|
|
562
|
+
this.mstr = new MagicString(content)
|
|
563
|
+
return this.visit(ast)
|
|
564
|
+
}
|
|
565
|
+
}
|
package/runtime.svelte
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script module>
|
|
2
|
+
|
|
3
|
+
let translations = $state({})
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} transArray
|
|
7
|
+
*/
|
|
8
|
+
export function setTranslations(transArray) {
|
|
9
|
+
translations = transArray
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {number} id
|
|
14
|
+
*/
|
|
15
|
+
function getCtx(id) {
|
|
16
|
+
const ctx = translations[id]
|
|
17
|
+
if (typeof ctx === 'string') {
|
|
18
|
+
return [ctx]
|
|
19
|
+
}
|
|
20
|
+
if (ctx == null || typeof ctx === 'number') {
|
|
21
|
+
return [`[i18n-404:${id}(${ctx})]`]
|
|
22
|
+
}
|
|
23
|
+
return ctx
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {number} id
|
|
28
|
+
* @param {string[]} args
|
|
29
|
+
*/
|
|
30
|
+
export function wuchaleTrans(id, ...args) {
|
|
31
|
+
const ctx = getCtx(id)
|
|
32
|
+
let txt = ''
|
|
33
|
+
for (const fragment of ctx) {
|
|
34
|
+
if (typeof fragment === 'string') {
|
|
35
|
+
txt += fragment
|
|
36
|
+
} else if (typeof fragment === 'number') { // index of non-text children
|
|
37
|
+
txt += args[fragment]
|
|
38
|
+
} else {
|
|
39
|
+
// shouldn't happen
|
|
40
|
+
console.error('Unknown item in compiled catalog: ', id, fragment)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return txt
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<script>
|
|
49
|
+
const {id = null, ctx, tags, args} = $props()
|
|
50
|
+
const finalCtx = $derived(id != null ? getCtx(id) : ctx)
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
{#each finalCtx as fragment, i}
|
|
54
|
+
{#if typeof fragment === 'string'}
|
|
55
|
+
{fragment}
|
|
56
|
+
{:else if typeof fragment === 'number'}
|
|
57
|
+
{#if id != null || i > 0}
|
|
58
|
+
{args[fragment]}
|
|
59
|
+
{/if}
|
|
60
|
+
{:else}
|
|
61
|
+
{@const tag = tags[fragment[0]]}
|
|
62
|
+
{#if tag == null}
|
|
63
|
+
[i18n-404:tag]
|
|
64
|
+
{:else}
|
|
65
|
+
{@render tag(fragment)}
|
|
66
|
+
{/if}
|
|
67
|
+
{/if}
|
|
68
|
+
{/each}
|