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 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
+ ![Diagram](https://raw.githubusercontent.com/K1DV5/wuchale/main/images/diagram.svg)
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
@@ -0,0 +1,3 @@
1
+ import wuchale from "./preprocess/index.js"
2
+
3
+ export default wuchale
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}