@inlang/paraglide-js 1.5.0 → 1.6.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 +86 -34
- package/dist/adapter-utils/index.d.ts +4 -0
- package/dist/adapter-utils/index.js +375 -0
- package/dist/adapter-utils/negotiation/language.d.ts +9 -0
- package/dist/adapter-utils/negotiation/language.test.d.ts +1 -0
- package/dist/adapter-utils/routing/detectLanguage.d.ts +20 -0
- package/dist/adapter-utils/routing/detectLanguage.test.d.ts +1 -0
- package/dist/adapter-utils/routing/routeDefinitions.d.ts +45 -0
- package/dist/adapter-utils/routing/routeDefinitions.test.d.ts +1 -0
- package/dist/adapter-utils/routing/validatePathTranslations.d.ts +15 -0
- package/dist/adapter-utils/routing/validatePathTranslations.test.d.ts +1 -0
- package/dist/compiler/compilePattern.d.ts +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -5
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -85,7 +85,33 @@ m.hello() // Hello world!
|
|
|
85
85
|
m.loginHeader({ name: "Samuel" }) // Hello Samuel, please login to continue.
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
## Working with the Inlang Message Format
|
|
89
|
+
|
|
90
|
+
Paraglide is part of the highly modular Inlang Ecosystem which supports many different Message Formats. By default, the [Inlang Message Format](https://inlang.com/m/reootnfj/plugin-inlang-messageFormat) is used.
|
|
91
|
+
|
|
92
|
+
It expects messages to be in `messages/{lang}.json` relative to your repo root.
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
//messages/en.json
|
|
96
|
+
{
|
|
97
|
+
//the $schema key is automatically ignored
|
|
98
|
+
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
99
|
+
"hello_world: "Hello World!",
|
|
100
|
+
"greeting": "Hello {name}!"
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The `messages/{lang}.json` file contains a flat map of message IDs and their translations. You can use curly braces to insert `{parameters}` into translations
|
|
105
|
+
|
|
106
|
+
**Nesting purposely isn't supported and likely won't be**. Nested messages are way harder to interact with from complementary tools like the [Sherlock IDE Extension](https://inlang.com/m/r7kp499g/app-inlang-ideExtension), the [Parrot Figma Plugin](https://inlang.com/m/gkrpgoir/app-parrot-figmaPlugin), or the [Fink Localization editor](https://inlang.com/m/tdozzpar/app-inlang-finkLocalizationEditor). Intellisense also becomes less helpful since it only shows the messages at the current level, not all messages. Additionally enforcing an organization-style side-steps organization discussions with other contributors.
|
|
107
|
+
|
|
108
|
+
### Complex Formatting
|
|
109
|
+
|
|
110
|
+
The Message Format is still quite young, so advanced formats like plurals, param-formatting, and markup interpolation are currently not supported but are all on our roadmap.
|
|
111
|
+
|
|
112
|
+
If you need complex formatting, like plurals, dates, currency, or markup interpolation you can achieve them like so:
|
|
113
|
+
|
|
114
|
+
For a message with multiple cases, aka a _select message_, you can define a message for each case & then use a Map in JS to index into it.
|
|
89
115
|
|
|
90
116
|
```ts
|
|
91
117
|
import * as m from "./paraglide/messages.js"
|
|
@@ -100,9 +126,46 @@ const season = {
|
|
|
100
126
|
const msg = season["spring"]() // Hello spring!
|
|
101
127
|
```
|
|
102
128
|
|
|
129
|
+
For date & currency formatting use the `.toLocaleString` method on the `Date` or `Number`.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import * as m from "./paraglide/messages.js"
|
|
133
|
+
import { languageTag } from "./paraglide/runtime.js"
|
|
134
|
+
|
|
135
|
+
const todaysDate = new Date();
|
|
136
|
+
m.today_is_the({
|
|
137
|
+
date: todaysDate.toLocaleString(languageTag())
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const price = 100;
|
|
141
|
+
m.the_price_is({
|
|
142
|
+
price: price.toLocaleString(languageTag(), {
|
|
143
|
+
style: "currency",
|
|
144
|
+
currency: "EUR",
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
You can put HTML into the messages. This is useful for links and images.
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
// messages/en.json
|
|
153
|
+
{
|
|
154
|
+
"you_must_agree_to_the_tos": "You must agree to the <a href='/en/tos'>Terms of Service</a>."
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
```json
|
|
158
|
+
// messages/de.json
|
|
159
|
+
{
|
|
160
|
+
you_must_agree_to_the_tos": "Sie müssen den <a href='/de/agb'>Nutzungsbedingungen</a> zustimmen."
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
There is currently no way to interpolate full-blown components into the messages. If you require components mid-message you will need to create a one-off component for that bit of text.
|
|
165
|
+
|
|
103
166
|
## Setting the language
|
|
104
167
|
|
|
105
|
-
You can set the [language tag](https://www.inlang.com/m/8y8sxj09/library-inlang-languageTag) by calling `setLanguageTag()
|
|
168
|
+
You can set the [language tag](https://www.inlang.com/m/8y8sxj09/library-inlang-languageTag) by calling `setLanguageTag()` with the desired language, or a getter function. Any subsequent calls to either `languageTag()` or a message function will use the new language tag.
|
|
106
169
|
|
|
107
170
|
```js
|
|
108
171
|
import { setLanguageTag } from "./paraglide/runtime.js"
|
|
@@ -111,11 +174,11 @@ import * as m from "./paraglide/messages.js"
|
|
|
111
174
|
setLanguageTag("de")
|
|
112
175
|
m.hello() // Hallo Welt!
|
|
113
176
|
|
|
114
|
-
setLanguageTag(
|
|
177
|
+
setLanguageTag(()=>document.documentElement.lang /* en */ )
|
|
115
178
|
m.hello() // Hello world!
|
|
116
179
|
```
|
|
117
180
|
|
|
118
|
-
The [language tag](https://www.inlang.com/m/8y8sxj09/library-inlang-languageTag) is global, so you need to be careful with it on the server to make sure multiple requests don't interfere with each other.
|
|
181
|
+
The [language tag](https://www.inlang.com/m/8y8sxj09/library-inlang-languageTag) is global, so you need to be careful with it on the server to make sure multiple requests don't interfere with each other. Always use a getter-function that returns the current language tag _for the current request_.
|
|
119
182
|
|
|
120
183
|
You will need to call `setLanguageTag` on both the server and the client since they run in separate processes.
|
|
121
184
|
|
|
@@ -137,7 +200,7 @@ setLanguageTag("de") // The language changed to de
|
|
|
137
200
|
setLanguageTag("en") // The language changed to en
|
|
138
201
|
```
|
|
139
202
|
|
|
140
|
-
|
|
203
|
+
Things to know about `onSetLanguageTag()`:
|
|
141
204
|
|
|
142
205
|
- You can only register one listener. If you register a second listener it will throw an error.
|
|
143
206
|
- `onSetLanguageTag` shouldn't be used on the server.
|
|
@@ -160,10 +223,9 @@ const msg = m.hello({ name: "Samuel" }, { languageTag: "de" }) // Hallo Samuel!
|
|
|
160
223
|
|
|
161
224
|
## Lazy-Loading
|
|
162
225
|
|
|
163
|
-
Paraglide consciously discourages lazy-loading translations since it seriously hurts
|
|
164
|
-
your Web Vitals. Learn more about why lazy-loading is bad & what to do instead in [our blog post on lazy-loading](https://inlang.com/g/mqlyfa7l/guide-lorissigrist-dontlazyload).
|
|
226
|
+
Paraglide consciously discourages lazy-loading translations since it causes a render-fetch waterfall which seriously hurts your Web Vitals. Learn more about why lazy-loading is bad & what to do instead in [our blog post on lazy-loading](https://inlang.com/g/mqlyfa7l/guide-lorissigrist-dontlazyload).
|
|
165
227
|
|
|
166
|
-
If you want to do it anyway, lazily import the language-specific message files.
|
|
228
|
+
If you want to do it anyway, lazily import the language-specific message files.
|
|
167
229
|
|
|
168
230
|
```ts
|
|
169
231
|
const lazyGerman = await import("./paraglide/messages/de.js")
|
|
@@ -203,18 +265,14 @@ If you want your language files to be in a different location you can change the
|
|
|
203
265
|
|
|
204
266
|
```diff
|
|
205
267
|
// project.inlang/settings.json
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
268
|
+
{
|
|
269
|
+
"plugin.inlang.messageFormat": {
|
|
270
|
+
- "pathPattern": "./messages/{languageTag}.json"
|
|
271
|
+
+ "pathPattern": "./i18n/{languageTag}.json"
|
|
272
|
+
}
|
|
273
|
+
}
|
|
210
274
|
```
|
|
211
275
|
|
|
212
|
-
### Lint Rules
|
|
213
|
-
|
|
214
|
-
If you're using the [Sherlock VS Code extension](https://inlang.com/m/r7kp499g/app-inlang-ideExtension) you might see warnings about certain messages. Perhaps they're duplicates, perhaps they're missing in one language.
|
|
215
|
-
|
|
216
|
-
You can configure which lint-rules are active in `./project.inlang/settings.json`. Simply add or remove them from the `modules` array.
|
|
217
|
-
|
|
218
276
|
# Playground
|
|
219
277
|
|
|
220
278
|
Find examples of how to use Paraglide on CodeSandbox or in [our GitHub repository](https://github.com/opral/monorepo/tree/main/inlang/source-code/paraglide).
|
|
@@ -227,11 +285,11 @@ Find examples of how to use Paraglide on CodeSandbox or in [our GitHub repositor
|
|
|
227
285
|
|
|
228
286
|
# Architecture
|
|
229
287
|
|
|
230
|
-
|
|
288
|
+
Paraglide uses a compiler to generate JS functions from your messages. We call these "message functions".
|
|
231
289
|
|
|
232
|
-
Message Functions are fully typed using JSDoc. They are exported individually from the `messages.js` file making them tree-shakable.
|
|
290
|
+
Message Functions are fully typed using JSDoc. They are exported individually from the `messages.js` file making them tree-shakable. When called, they return a translated string. Message functions aren't reactive in any way, if you want a translation in another language you will need to re-call them.
|
|
233
291
|
|
|
234
|
-
This avoids many edge cases
|
|
292
|
+
This design avoids many edge cases with reactivity, lazy-loading, and namespacing that other i18n libraries have to work around.
|
|
235
293
|
|
|
236
294
|
In addition to the message functions, ParaglideJS also emits a runtime. The runtime is used to set the language tag. It contains less than 50 LOC (lines of code) and is less than 300 bytes minified & gzipped.
|
|
237
295
|
|
|
@@ -271,16 +329,6 @@ The compiler loads an Inlang project and compiles the messages into tree-shakabl
|
|
|
271
329
|
export const hello = (params) => `Hello ${params.name}!`
|
|
272
330
|
```
|
|
273
331
|
|
|
274
|
-
## Messages
|
|
275
|
-
|
|
276
|
-
By convention, we import the compiled functions with a wildcard import.
|
|
277
|
-
|
|
278
|
-
```js
|
|
279
|
-
import * as m from "../paraglide/messages.js"
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
Bundlers like Rollup, Webpack, or Turbopack tree-shake the messages that are not used, so using a wildcard import is perfectly fine.
|
|
283
|
-
|
|
284
332
|
# Writing an Adapter
|
|
285
333
|
|
|
286
334
|
An "Adapter" is a library that integrates with a framework's lifecycle and does two things:
|
|
@@ -288,7 +336,9 @@ An "Adapter" is a library that integrates with a framework's lifecycle and does
|
|
|
288
336
|
1. Calls `setLanguageTag()` at appropriate times to set the language
|
|
289
337
|
2. Reacts to `onSetLanguageTag()`, usually by navigating or relading the page.
|
|
290
338
|
|
|
291
|
-
|
|
339
|
+
Many popular frameworks already have adapters available, check out the [list of available adapters](#use-it-with-your-favorite-framework).
|
|
340
|
+
|
|
341
|
+
If there isn't one for your framework, you can write your own. This example adapts Paraglide to a fictitious full-stack framework.
|
|
292
342
|
|
|
293
343
|
```tsx
|
|
294
344
|
import {
|
|
@@ -316,7 +366,7 @@ if (isClient) {
|
|
|
316
366
|
|
|
317
367
|
// When the language changes we want to re-render the page in the new language
|
|
318
368
|
// Here we just navigate to the new route
|
|
319
|
-
|
|
369
|
+
|
|
320
370
|
// Make sure to call `onSetLanguageTag` after `setLanguageTag` to avoid an infinite loop.
|
|
321
371
|
onSetLanguageTag((newLanguageTag) => {
|
|
322
372
|
window.location.pathname = `/${newLanguageTag}${window.location.pathname}`
|
|
@@ -338,6 +388,8 @@ Of course, we're not done yet! We plan on adding the following features to Parag
|
|
|
338
388
|
- [ ] Pluralization ([Join the Discussion](https://github.com/opral/monorepo/discussions/2025))
|
|
339
389
|
- [ ] Formatting of numbers and dates ([Join the Discussion](https://github.com/opral/monorepo/discussions/992))
|
|
340
390
|
- [ ] Markup Placeholders ([Join the Discussion](https://github.com/opral/monorepo/discussions/913))
|
|
391
|
+
- [ ] Component Interpolation
|
|
392
|
+
- [ ] Per-Language Splitting without Lazy-Loading
|
|
341
393
|
- [ ] Even Smaller Output
|
|
342
394
|
|
|
343
395
|
# Talks
|
|
@@ -347,7 +399,7 @@ Of course, we're not done yet! We plan on adding the following features to Parag
|
|
|
347
399
|
- Web Zurich December 2023
|
|
348
400
|
- [Svelte London January 2024](https://www.youtube.com/watch?v=eswNQiq4T2w&t=646s)
|
|
349
401
|
|
|
350
|
-
# Tooling
|
|
402
|
+
# Complementary Tooling
|
|
351
403
|
|
|
352
404
|
Paraglide JS is part of the Inlang ecosystem and integrates nicely with all the other Inlang-compatible tools.
|
|
353
405
|
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { negotiateLanguagePreferences } from './negotiation/language.js';
|
|
2
|
+
export { detectLanguageFromPath } from './routing/detectLanguage.js';
|
|
3
|
+
export { bestMatch, resolveRoute, parseRouteDefinition, exec, type PathDefinitionTranslations, type ParamMatcher, type RouteParam, } from './routing/routeDefinitions.js';
|
|
4
|
+
export { validatePathTranslations, prettyPrintIssues } from './routing/validatePathTranslations.js';
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
function negotiateLanguagePreferences(accept, availableLanguageTags) {
|
|
2
|
+
if (availableLanguageTags.length === 0)
|
|
3
|
+
return [];
|
|
4
|
+
accept ||= "*";
|
|
5
|
+
const acceptLanguageSpecs = parseAcceptLanguageHeader(accept);
|
|
6
|
+
const priorities = availableLanguageTags.map(
|
|
7
|
+
(languageTag, index) => getHighestLanguagePriority(languageTag, acceptLanguageSpecs, index)
|
|
8
|
+
);
|
|
9
|
+
return priorities.filter((prio) => prio.quality > 0).sort(comparePriorities).map((priority) => priority.languageTag);
|
|
10
|
+
}
|
|
11
|
+
function parseAcceptLanguageHeader(acceptLanguage) {
|
|
12
|
+
const acceptableLanguageDefinitions = acceptLanguage.split(",");
|
|
13
|
+
const specs = acceptableLanguageDefinitions.map((dfn) => dfn.trim()).map((dfn, index) => parseLanguage(dfn, index)).filter((maybeSpec) => Boolean(maybeSpec));
|
|
14
|
+
return specs;
|
|
15
|
+
}
|
|
16
|
+
function parseLanguage(str, index) {
|
|
17
|
+
const LANGUAGE_REGEXP = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/;
|
|
18
|
+
const match = LANGUAGE_REGEXP.exec(str);
|
|
19
|
+
if (!match)
|
|
20
|
+
return void 0;
|
|
21
|
+
const [, prefix, suffix, qualityMatch] = match;
|
|
22
|
+
if (!prefix)
|
|
23
|
+
throw new Error(`Invalid language tag: ${str}`);
|
|
24
|
+
const full = suffix ? `${prefix}-${suffix}` : prefix;
|
|
25
|
+
const quality = qualityMatch ? parseQuality(qualityMatch) ?? 1 : 1;
|
|
26
|
+
return {
|
|
27
|
+
prefix,
|
|
28
|
+
suffix,
|
|
29
|
+
quality,
|
|
30
|
+
index,
|
|
31
|
+
full
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function parseQuality(qualityMatch) {
|
|
35
|
+
const params = qualityMatch.split(";");
|
|
36
|
+
for (const param of params) {
|
|
37
|
+
const [key, value] = param.split("=");
|
|
38
|
+
if (key === "q" && value)
|
|
39
|
+
return parseFloat(value);
|
|
40
|
+
}
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
43
|
+
function getHighestLanguagePriority(availableLanguageTag, acceptableLanguages, index) {
|
|
44
|
+
let highestPriority = {
|
|
45
|
+
languageTag: availableLanguageTag,
|
|
46
|
+
index: 0,
|
|
47
|
+
order: -1,
|
|
48
|
+
quality: 0,
|
|
49
|
+
specificity: 0
|
|
50
|
+
};
|
|
51
|
+
for (const acceptableLanguage of acceptableLanguages) {
|
|
52
|
+
const priority = calculatePriority(availableLanguageTag, acceptableLanguage, index);
|
|
53
|
+
if (!priority)
|
|
54
|
+
continue;
|
|
55
|
+
if (
|
|
56
|
+
//compare the calculated priority to the highest priority ignoring quality.
|
|
57
|
+
(highestPriority.specificity - priority.specificity || highestPriority.quality - priority.quality || highestPriority.order - priority.order) < 0
|
|
58
|
+
) {
|
|
59
|
+
highestPriority = priority;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return highestPriority;
|
|
63
|
+
}
|
|
64
|
+
function calculatePriority(language, spec, index) {
|
|
65
|
+
const parsed = parseLanguage(language, 0);
|
|
66
|
+
if (!parsed)
|
|
67
|
+
return void 0;
|
|
68
|
+
let specificity = 0;
|
|
69
|
+
if (spec.full.toLowerCase() === parsed.full.toLowerCase()) {
|
|
70
|
+
specificity |= 4;
|
|
71
|
+
} else if (spec.prefix.toLowerCase() === parsed.full.toLowerCase()) {
|
|
72
|
+
specificity |= 2;
|
|
73
|
+
} else if (spec.full.toLowerCase() === parsed.prefix.toLowerCase()) {
|
|
74
|
+
specificity |= 1;
|
|
75
|
+
}
|
|
76
|
+
if (specificity === 0 && spec.full !== "*") {
|
|
77
|
+
return void 0;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
languageTag: language,
|
|
81
|
+
index,
|
|
82
|
+
order: spec.index,
|
|
83
|
+
quality: spec.quality,
|
|
84
|
+
specificity
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function comparePriorities(a, b) {
|
|
88
|
+
return b.quality - a.quality || b.specificity - a.specificity || a.order - b.order || a.index - b.index || 0;
|
|
89
|
+
}
|
|
90
|
+
function detectLanguageFromPath({
|
|
91
|
+
path,
|
|
92
|
+
availableLanguageTags,
|
|
93
|
+
base
|
|
94
|
+
}) {
|
|
95
|
+
base ??= "";
|
|
96
|
+
if (base === "/")
|
|
97
|
+
base = "";
|
|
98
|
+
if (!path.startsWith(base)) {
|
|
99
|
+
return void 0;
|
|
100
|
+
}
|
|
101
|
+
const pathWithoutBase = path.replace(base, "");
|
|
102
|
+
const maybeLang = pathWithoutBase.split("/").at(1);
|
|
103
|
+
if (!maybeLang)
|
|
104
|
+
return void 0;
|
|
105
|
+
for (const lang of availableLanguageTags) {
|
|
106
|
+
if (lang.toLowerCase() === maybeLang.toLowerCase()) {
|
|
107
|
+
return lang;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return void 0;
|
|
111
|
+
}
|
|
112
|
+
const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/;
|
|
113
|
+
function parseRouteDefinition(id) {
|
|
114
|
+
const params = [];
|
|
115
|
+
const pattern = id === "/" ? /^\/$/ : new RegExp(
|
|
116
|
+
`^${get_route_segments(id).map((segment) => {
|
|
117
|
+
const rest_match = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment);
|
|
118
|
+
if (rest_match) {
|
|
119
|
+
params.push({
|
|
120
|
+
name: rest_match[1],
|
|
121
|
+
matcher: rest_match[2],
|
|
122
|
+
optional: false,
|
|
123
|
+
rest: true,
|
|
124
|
+
chained: true
|
|
125
|
+
});
|
|
126
|
+
return "(?:/(.*))?";
|
|
127
|
+
}
|
|
128
|
+
const optional_match = /^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(segment);
|
|
129
|
+
if (optional_match) {
|
|
130
|
+
params.push({
|
|
131
|
+
name: optional_match[1],
|
|
132
|
+
matcher: optional_match[2],
|
|
133
|
+
optional: true,
|
|
134
|
+
rest: false,
|
|
135
|
+
chained: true
|
|
136
|
+
});
|
|
137
|
+
return "(?:/([^/]+))?";
|
|
138
|
+
}
|
|
139
|
+
if (!segment) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const parts = segment.split(/\[(.+?)\](?!\])/);
|
|
143
|
+
const result = parts.map((content, i) => {
|
|
144
|
+
if (i % 2) {
|
|
145
|
+
if (content.startsWith("x+")) {
|
|
146
|
+
return escape(String.fromCharCode(parseInt(content.slice(2), 16)));
|
|
147
|
+
}
|
|
148
|
+
if (content.startsWith("u+")) {
|
|
149
|
+
return escape(
|
|
150
|
+
String.fromCharCode(
|
|
151
|
+
...content.slice(2).split("-").map((code) => parseInt(code, 16))
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const match = (
|
|
156
|
+
/** @type {RegExpExecArray} */
|
|
157
|
+
param_pattern.exec(content)
|
|
158
|
+
);
|
|
159
|
+
if (!match) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Invalid param: ${content}. Params and matcher names can only have underscores and alphanumeric characters.`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const [, is_optional, is_rest, name, matcher] = match;
|
|
165
|
+
params.push({
|
|
166
|
+
name,
|
|
167
|
+
matcher,
|
|
168
|
+
optional: !!is_optional,
|
|
169
|
+
rest: !!is_rest,
|
|
170
|
+
chained: is_rest ? i === 1 && parts[0] === "" : false
|
|
171
|
+
});
|
|
172
|
+
return is_rest ? "(.*?)" : is_optional ? "([^/]*)?" : "([^/]+?)";
|
|
173
|
+
}
|
|
174
|
+
return escape(content);
|
|
175
|
+
}).join("");
|
|
176
|
+
return "/" + result;
|
|
177
|
+
}).join("")}/?$`
|
|
178
|
+
);
|
|
179
|
+
return { pattern, params };
|
|
180
|
+
}
|
|
181
|
+
function exec(match, params, matchers) {
|
|
182
|
+
const result = {};
|
|
183
|
+
const values = match.slice(1);
|
|
184
|
+
const values_needing_match = values.filter((value) => value !== void 0);
|
|
185
|
+
let buffered = 0;
|
|
186
|
+
for (const [i, param] of params.entries()) {
|
|
187
|
+
let value = values[i - buffered];
|
|
188
|
+
if (param.chained && param.rest && buffered) {
|
|
189
|
+
value = values.slice(i - buffered, i + 1).filter((s) => s).join("/");
|
|
190
|
+
buffered = 0;
|
|
191
|
+
}
|
|
192
|
+
if (value === void 0) {
|
|
193
|
+
if (param.rest)
|
|
194
|
+
result[param.name] = "";
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (param.matcher && !matchers[param.matcher]) {
|
|
198
|
+
return void 0;
|
|
199
|
+
}
|
|
200
|
+
const matcher = matchers[param.matcher] ?? (() => true);
|
|
201
|
+
if (matcher(value)) {
|
|
202
|
+
result[param.name] = value;
|
|
203
|
+
const next_param = params[i + 1];
|
|
204
|
+
const next_value = values[i + 1];
|
|
205
|
+
if (next_param && !next_param.rest && next_param.optional && next_value && param.chained) {
|
|
206
|
+
buffered = 0;
|
|
207
|
+
}
|
|
208
|
+
if (!next_param && !next_value && Object.keys(result).length === values_needing_match.length) {
|
|
209
|
+
buffered = 0;
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (param.optional && param.chained) {
|
|
214
|
+
buffered++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (buffered)
|
|
220
|
+
return;
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
function escape(str) {
|
|
224
|
+
return str.normalize().replace(/[[\]]/g, "\\$&").replace(/%/g, "%25").replace(/\//g, "%2[Ff]").replace(/\?/g, "%3[Ff]").replace(/#/g, "%23").replace(/[.*+?^${}()|\\]/g, "\\$&");
|
|
225
|
+
}
|
|
226
|
+
const basic_param_pattern = /\[(\[)?(\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/g;
|
|
227
|
+
function resolveRoute(id, params) {
|
|
228
|
+
const segments = get_route_segments(id);
|
|
229
|
+
return "/" + segments.map(
|
|
230
|
+
(segment) => segment.replace(basic_param_pattern, (_, optional, rest, name) => {
|
|
231
|
+
const param_value = params[name];
|
|
232
|
+
if (!param_value) {
|
|
233
|
+
if (optional)
|
|
234
|
+
return "";
|
|
235
|
+
if (rest && param_value !== void 0)
|
|
236
|
+
return "";
|
|
237
|
+
throw new Error(`Missing parameter '${name}' in route ${id}`);
|
|
238
|
+
}
|
|
239
|
+
if (param_value.startsWith("/") || param_value.endsWith("/"))
|
|
240
|
+
throw new Error(
|
|
241
|
+
`Parameter '${name}' in route ${id} cannot start or end with a slash -- this would cause an invalid route like foo//bar`
|
|
242
|
+
);
|
|
243
|
+
return param_value;
|
|
244
|
+
})
|
|
245
|
+
).filter(Boolean).join("/");
|
|
246
|
+
}
|
|
247
|
+
function bestMatch(canonicalPath, pathDefinitions, matchers) {
|
|
248
|
+
let bestMatch2 = void 0;
|
|
249
|
+
for (const pathDefinition of pathDefinitions) {
|
|
250
|
+
const route = parseRouteDefinition(pathDefinition);
|
|
251
|
+
const match = route.pattern.exec(removeTrailingSlash(canonicalPath));
|
|
252
|
+
if (!match)
|
|
253
|
+
continue;
|
|
254
|
+
const params = exec(match, route.params, matchers);
|
|
255
|
+
if (!params)
|
|
256
|
+
continue;
|
|
257
|
+
if (!bestMatch2) {
|
|
258
|
+
bestMatch2 = { params, route, id: pathDefinition };
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const bestMatchNumParams = Object.keys(bestMatch2.route.params).length;
|
|
262
|
+
const currentMatchNumParams = Object.keys(route.params).length;
|
|
263
|
+
if (bestMatchNumParams < currentMatchNumParams) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (bestMatchNumParams > currentMatchNumParams) {
|
|
267
|
+
bestMatch2 = { params, route, id: pathDefinition };
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (bestMatchNumParams === currentMatchNumParams && route.pattern.source.length < bestMatch2.route.pattern.source.length) {
|
|
271
|
+
bestMatch2 = { params, route, id: pathDefinition };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return bestMatch2 ? {
|
|
275
|
+
id: bestMatch2.id,
|
|
276
|
+
params: bestMatch2.params
|
|
277
|
+
} : void 0;
|
|
278
|
+
}
|
|
279
|
+
function removeTrailingSlash(path) {
|
|
280
|
+
return path.endsWith("/") ? path.slice(0, -1) : path;
|
|
281
|
+
}
|
|
282
|
+
function get_route_segments(route) {
|
|
283
|
+
return route.slice(1).split("/");
|
|
284
|
+
}
|
|
285
|
+
function validatePathTranslations(pathTranslations, availableLanguageTags, matchers) {
|
|
286
|
+
const issues = [];
|
|
287
|
+
const expectedLanguages = new Set(availableLanguageTags);
|
|
288
|
+
const availableMatchers = new Set(Object.keys(matchers));
|
|
289
|
+
for (const path in pathTranslations) {
|
|
290
|
+
if (!isValidPath(path)) {
|
|
291
|
+
issues.push({
|
|
292
|
+
path,
|
|
293
|
+
message: "Path must start with a slash."
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const { params: expectedParams } = parseRouteDefinition(path);
|
|
298
|
+
const expectedMatchers = expectedParams.map((param) => param.matcher).filter(Boolean);
|
|
299
|
+
for (const matcher of expectedMatchers) {
|
|
300
|
+
if (!availableMatchers.has(matcher)) {
|
|
301
|
+
issues.push({
|
|
302
|
+
path,
|
|
303
|
+
message: `Matcher ${matcher} is used but not available. Did you forget to pass it to createI18n?`
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const translations = pathTranslations[path];
|
|
308
|
+
if (!translations)
|
|
309
|
+
continue;
|
|
310
|
+
for (const [lang, translatedPath] of Object.entries(translations)) {
|
|
311
|
+
if (!isValidPath(translatedPath)) {
|
|
312
|
+
issues.push({
|
|
313
|
+
path,
|
|
314
|
+
message: `The translation for language ${lang} must start with a slash.`
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
const { params: actualParams } = parseRouteDefinition(translatedPath);
|
|
318
|
+
let paramsDontMatch = false;
|
|
319
|
+
for (const param of expectedParams) {
|
|
320
|
+
if (!actualParams.some((actualParam) => paramsAreEqual(param, actualParam))) {
|
|
321
|
+
paramsDontMatch = true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (expectedParams.length !== actualParams.length) {
|
|
325
|
+
paramsDontMatch = true;
|
|
326
|
+
}
|
|
327
|
+
if (paramsDontMatch) {
|
|
328
|
+
issues.push({
|
|
329
|
+
path,
|
|
330
|
+
message: `The translation for language ${lang} must have the same parameters as the canonical path.`
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const translatedLanguages = new Set(Object.keys(translations));
|
|
335
|
+
if (!isSubset(expectedLanguages, translatedLanguages)) {
|
|
336
|
+
const missingLanguages = new Set(expectedLanguages);
|
|
337
|
+
for (const lang of translatedLanguages) {
|
|
338
|
+
missingLanguages.delete(lang);
|
|
339
|
+
}
|
|
340
|
+
issues.push({
|
|
341
|
+
path,
|
|
342
|
+
message: `The following languages are missing translations: ${[...missingLanguages].join(
|
|
343
|
+
", "
|
|
344
|
+
)}`
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return issues;
|
|
349
|
+
}
|
|
350
|
+
function paramsAreEqual(param1, param2) {
|
|
351
|
+
return param1.chained == param2.chained && param1.matcher == param2.matcher && param1.name == param2.name && param1.optional == param2.optional && param1.rest == param2.rest;
|
|
352
|
+
}
|
|
353
|
+
function isValidPath(maybePath) {
|
|
354
|
+
return maybePath.startsWith("/");
|
|
355
|
+
}
|
|
356
|
+
function isSubset(a, b) {
|
|
357
|
+
for (const value of a) {
|
|
358
|
+
if (!b.has(value))
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
function prettyPrintIssues(issues) {
|
|
364
|
+
return issues.map((issue) => `${issue.path}: ${issue.message}`).join("\n");
|
|
365
|
+
}
|
|
366
|
+
export {
|
|
367
|
+
bestMatch,
|
|
368
|
+
detectLanguageFromPath,
|
|
369
|
+
exec,
|
|
370
|
+
negotiateLanguagePreferences,
|
|
371
|
+
parseRouteDefinition,
|
|
372
|
+
prettyPrintIssues,
|
|
373
|
+
resolveRoute,
|
|
374
|
+
validatePathTranslations
|
|
375
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negotiates which of the provided language tags is preferred from an Accept-Language header
|
|
3
|
+
*
|
|
4
|
+
* @param accept The value of the Accept-Language header. If it's missing, it defaults to "*" as per RFC 2616 sec 14.4
|
|
5
|
+
* @param availableLanguageTags The BCP 47 language tags that are available
|
|
6
|
+
*
|
|
7
|
+
* @returns The acceptable available language tags in descending order of preference
|
|
8
|
+
*/
|
|
9
|
+
export declare function negotiateLanguagePreferences<T extends string = string>(accept: string | undefined | null, availableLanguageTags: readonly T[]): T[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects the language tag from the path if present
|
|
3
|
+
*
|
|
4
|
+
* If the path is not in the base path, no language will be detected
|
|
5
|
+
* If the language is not available, no language will be detected
|
|
6
|
+
*
|
|
7
|
+
* The language is not case sensitive, eg /de-ch/ and /DE_CH/ will both return "de-CH" if present
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* /base/de/ueber-uns -> de
|
|
11
|
+
* @returns the language tag if present
|
|
12
|
+
*
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectLanguageFromPath<T extends string>({ path, availableLanguageTags, base, }: {
|
|
15
|
+
/** The absolute path including the base */
|
|
16
|
+
path: string;
|
|
17
|
+
availableLanguageTags: readonly T[];
|
|
18
|
+
/** The base path */
|
|
19
|
+
base?: string;
|
|
20
|
+
}): T | undefined;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type PathDefinitionTranslations<T extends string = string> = {
|
|
2
|
+
[canonicalPath: `/${string}`]: Record<T, `/${string}`>;
|
|
3
|
+
};
|
|
4
|
+
export type RouteParam = {
|
|
5
|
+
name: string;
|
|
6
|
+
matcher: string;
|
|
7
|
+
optional: boolean;
|
|
8
|
+
rest: boolean;
|
|
9
|
+
chained: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type ParamMatcher = (segment: string) => boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Creates the regex pattern, extracts parameter names, and generates types for a route
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseRouteDefinition(id: string): {
|
|
16
|
+
params: RouteParam[];
|
|
17
|
+
pattern: RegExp;
|
|
18
|
+
};
|
|
19
|
+
export declare function exec(match: RegExpMatchArray, params: RouteParam[], matchers: Record<string, ParamMatcher>): Record<string, string> | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Populate a route ID with params to resolve a pathname.
|
|
22
|
+
* @example
|
|
23
|
+
* ```js
|
|
24
|
+
* resolveRoute(
|
|
25
|
+
* `/blog/[slug]/[...somethingElse]`,
|
|
26
|
+
* {
|
|
27
|
+
* slug: 'hello-world',
|
|
28
|
+
* somethingElse: 'something/else'
|
|
29
|
+
* }
|
|
30
|
+
* ); // `/blog/hello-world/something/else`
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveRoute(id: string, params: Record<string, string | undefined>): string;
|
|
34
|
+
/**
|
|
35
|
+
* Returns the id and params for the route that best matches the given path.
|
|
36
|
+
* @param canonicalPath Canonical pathname excluding the base and language e.g. /foo/bar
|
|
37
|
+
* @param pathDefinitions An array of pathDefinitions
|
|
38
|
+
* @param matchers A map of param matcher functions
|
|
39
|
+
*
|
|
40
|
+
* @returns undefined if no route matches.
|
|
41
|
+
*/
|
|
42
|
+
export declare function bestMatch(canonicalPath: string, pathDefinitions: string[], matchers: Record<string, ParamMatcher>): {
|
|
43
|
+
params: Record<string, string>;
|
|
44
|
+
id: string;
|
|
45
|
+
} | undefined;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ParamMatcher, PathDefinitionTranslations } from './routeDefinitions.js';
|
|
2
|
+
|
|
3
|
+
export type PathTranslationIssue = {
|
|
4
|
+
path: string;
|
|
5
|
+
message: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Check that the path translations are valid.
|
|
9
|
+
* Should only be called in development, this is a waste of time in production.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validatePathTranslations<T extends string>(pathTranslations: PathDefinitionTranslations<T>, availableLanguageTags: readonly T[], matchers: Record<string, ParamMatcher>): PathTranslationIssue[];
|
|
12
|
+
/**
|
|
13
|
+
* Formats the issues into a nice-looking string that can be logged
|
|
14
|
+
*/
|
|
15
|
+
export declare function prettyPrintIssues(issues: PathTranslationIssue[]): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* >> compiled === "`Hello ${params.name}`"
|
|
7
7
|
*/
|
|
8
8
|
export declare const compilePattern: (pattern: ({
|
|
9
|
-
type: "Text";
|
|
10
9
|
value: string;
|
|
10
|
+
type: "Text";
|
|
11
11
|
} | {
|
|
12
|
-
type: "VariableReference";
|
|
13
12
|
name: string;
|
|
13
|
+
type: "VariableReference";
|
|
14
14
|
})[]) => {
|
|
15
15
|
params: Record<string, "NonNullable<unknown>">;
|
|
16
16
|
compiled: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,7 @@ export { cli } from './cli/main.js';
|
|
|
2
2
|
export { compile } from './compiler/compile.js';
|
|
3
3
|
export { writeOutput } from './services/file-handling/write-output.js';
|
|
4
4
|
export { Logger, type LoggerOptions } from './services/logger/index.js';
|
|
5
|
+
export type MessageIndexFunction<T extends string> = (params?: Record<string, never>, options?: {
|
|
6
|
+
languageTag: T;
|
|
7
|
+
}) => string;
|
|
8
|
+
export type MessageFunction = (params?: Record<string, never>) => string;
|
package/dist/index.js
CHANGED
|
@@ -10051,7 +10051,7 @@ function withDefaults$2(oldDefaults, newDefaults) {
|
|
|
10051
10051
|
});
|
|
10052
10052
|
}
|
|
10053
10053
|
const endpoint = withDefaults$2(null, DEFAULTS);
|
|
10054
|
-
const VERSION$c = "8.
|
|
10054
|
+
const VERSION$c = "8.4.0";
|
|
10055
10055
|
function isPlainObject(value2) {
|
|
10056
10056
|
if (typeof value2 !== "object" || value2 === null)
|
|
10057
10057
|
return false;
|
|
@@ -10194,7 +10194,7 @@ function getBufferResponse(response) {
|
|
|
10194
10194
|
return response.arrayBuffer();
|
|
10195
10195
|
}
|
|
10196
10196
|
function fetchWrapper(requestOptions) {
|
|
10197
|
-
var _a2, _b2, _c;
|
|
10197
|
+
var _a2, _b2, _c, _d;
|
|
10198
10198
|
const log2 = requestOptions.request && requestOptions.request.log ? requestOptions.request.log : console;
|
|
10199
10199
|
const parseSuccessResponseBody = ((_a2 = requestOptions.request) == null ? void 0 : _a2.parseSuccessResponseBody) !== false;
|
|
10200
10200
|
if (isPlainObject(requestOptions.body) || Array.isArray(requestOptions.body)) {
|
|
@@ -10215,8 +10215,9 @@ function fetchWrapper(requestOptions) {
|
|
|
10215
10215
|
return fetch2(requestOptions.url, {
|
|
10216
10216
|
method: requestOptions.method,
|
|
10217
10217
|
body: requestOptions.body,
|
|
10218
|
+
redirect: (_c = requestOptions.request) == null ? void 0 : _c.redirect,
|
|
10218
10219
|
headers: requestOptions.headers,
|
|
10219
|
-
signal: (
|
|
10220
|
+
signal: (_d = requestOptions.request) == null ? void 0 : _d.signal,
|
|
10220
10221
|
// duplex must be set if request.body is ReadableStream or Async Iterables.
|
|
10221
10222
|
// See https://fetch.spec.whatwg.org/#dom-requestinit-duplex.
|
|
10222
10223
|
...requestOptions.body && { duplex: "half" }
|
|
@@ -47083,7 +47084,7 @@ const addParaglideJsToDevDependencies = async (ctx) => {
|
|
|
47083
47084
|
if (pkg2.devDependencies === void 0) {
|
|
47084
47085
|
pkg2.devDependencies = {};
|
|
47085
47086
|
}
|
|
47086
|
-
pkg2.devDependencies["@inlang/paraglide-js"] = "1.
|
|
47087
|
+
pkg2.devDependencies["@inlang/paraglide-js"] = "1.6.0";
|
|
47087
47088
|
await ctx.repo.nodeishFs.writeFile("./package.json", stringify2(pkg2));
|
|
47088
47089
|
ctx.logger.success("Added @inlang/paraglide-js to the devDependencies in package.json.");
|
|
47089
47090
|
return ctx;
|
|
@@ -47437,7 +47438,7 @@ const prompt = async (message, options) => {
|
|
|
47437
47438
|
}
|
|
47438
47439
|
return response;
|
|
47439
47440
|
};
|
|
47440
|
-
const cli = new Command().name("paraglide-js").addCommand(compileCommand).addCommand(initCommand).showHelpAfterError().version("1.
|
|
47441
|
+
const cli = new Command().name("paraglide-js").addCommand(compileCommand).addCommand(initCommand).showHelpAfterError().version("1.6.0");
|
|
47441
47442
|
export {
|
|
47442
47443
|
Logger,
|
|
47443
47444
|
getBasename as a,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inlang/paraglide-js",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
"vite-plugin-dts": "^3.8.1",
|
|
63
63
|
"vite-tsconfig-paths": "^4.3.2",
|
|
64
64
|
"vitest": "0.34.3",
|
|
65
|
-
"@inlang/env-variables": "0.2.0",
|
|
66
65
|
"@inlang/cross-sell-sherlock": "0.0.4",
|
|
66
|
+
"@inlang/env-variables": "0.2.0",
|
|
67
67
|
"@inlang/language-tag": "1.5.1",
|
|
68
68
|
"@inlang/plugin-message-format": "2.1.1",
|
|
69
|
-
"@inlang/
|
|
70
|
-
"@inlang/telemetry": "0.3.21",
|
|
69
|
+
"@inlang/telemetry": "0.3.22",
|
|
71
70
|
"@lix-js/client": "1.2.0",
|
|
72
|
-
"@lix-js/fs": "1.0.0"
|
|
71
|
+
"@lix-js/fs": "1.0.0",
|
|
72
|
+
"@inlang/sdk": "0.32.0"
|
|
73
73
|
},
|
|
74
74
|
"exports": {
|
|
75
75
|
".": {
|
|
@@ -79,6 +79,10 @@
|
|
|
79
79
|
"./internal": {
|
|
80
80
|
"import": "./dist/index.js",
|
|
81
81
|
"types": "./dist/index.d.ts"
|
|
82
|
+
},
|
|
83
|
+
"./internal/adapter-utils": {
|
|
84
|
+
"import": "./dist/adapter-utils/index.js",
|
|
85
|
+
"types": "./dist/adapter-utils/index.d.ts"
|
|
82
86
|
}
|
|
83
87
|
},
|
|
84
88
|
"scripts": {
|