8000 feat: `paraglide` adder (#67) · sveltejs/cli@b705592 · GitHub
[go: up one dir, main page]

Skip to content

Commit b705592

Browse files
authored
feat: paraglide adder (#67)
1 parent ccebf39 commit b705592

File tree

18 files changed

+455
-28
lines changed

18 files changed

+455
-28
lines changed

.changeset/four-grapes-cheat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'sv': patch
3+
---
4+
5+
feat: paraglide adder

packages/adders/_config/official.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import drizzle from '../drizzle/index.ts';
66
import eslint from '../eslint/index.ts';
77
import lucia from '../lucia/index.ts';
88
import mdsvex from '../mdsvex/index.ts';
9+
import paraglide from '../paraglide/index.ts';
910
import playwright from '../playwright/index.ts';
1011
import prettier from '../prettier/index.ts';
1112
import routify from '../routify/index.ts';
@@ -19,7 +20,7 @@ const categories: Record<Category, Array<Adder<any>>> = {
1920
CSS: [tailwindcss],
2021
Database: [drizzle],
2122
Auth: [lucia< F438 span class=pl-kos>],
22-
'Additional Functionality': [storybook, mdsvex, routify]
23+
'Additional Functionality': [storybook, paraglide, mdsvex, routify]
2324
};
2425

2526
export const adderCategories: AdderCategories = getCategoriesById();

packages/adders/paraglide/index.ts

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { defineAdder, defineAdderOptions, log, utils } from '@sveltejs/cli-core';
4+
import {
5+
array,
6+
common,
7+
functions,
8+
imports,
9+
object,
10+
variables,
11+
exports,
12+
kit
13+
} from '@sveltejs/cli-core/js';
14+
import * as html from '@sveltejs/cli-core/html';
15+
import { parseHtml, parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers';
16+
17+
const DEFAULT_INLANG_PROJECT = {
18+
$schema: 'https://inlang.com/schema/project-settings',
19+
modules: [
20+
'https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@1/dist/index.js',
21+
'https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@1/dist/index.js',
22+
'https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@1/dist/index.js',
23+
'https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@1/dist/index.js',
24+
'https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-valid-js-identifier@1/dist/index.js',
25+
'https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@2/dist/index.js',
26+
'https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@0/dist/index.js'
27+
],
28+
'plugin.inlang.messageFormat': {
29+
pathPattern: './messages/{languageTag}.json'
30+
}
31+
};
32+
33+
export const options = defineAdderOptions({
34+
availableLanguageTags: {
35+
question: 'Which language tags would you like to support?',
36+
type: 'string',
37+
default: '',
38+
placeholder: 'en,de-ch',
39+
validate(input: any) {
40+
const { invalidLanguageTags, validLanguageTags } = parseLanguageTagInput(input);
41+
42+
if (invalidLanguageTags.length > 0) {
43+
if (invalidLanguageTags.length === 1) {
44+
return `The input "${invalidLanguageTags[0]}" is not a valid BCP47 language tag`;
45+
} else {
46+
const listFormat = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
47+
return `The inputs ${listFormat.format(invalidLanguageTags.map((x) => `"${x}"`))} are not valid BCP47 language tags`;
48+
}
49+
}
50+
if (validLanguageTags.length === 0)
51+
return 'Please enter at least one valid BCP47 language tag. Eg: en';
52+
53+
return undefined;
54+
}
55+
},
56+
demo: {
57+
type: 'boolean',
58+
default: false,
59+
question: 'Do you want to include a demo?'
60+
}
61+
});
62+
63+
export default defineAdder({
64+
id: 'paraglide',
65+
name: 'Paraglide',
66+
description: 'Typesafe i18n with localised routing',
67+
environments: { svelte: false, kit: true },
68+
documentation: 'https://inlang.com/m/dxnzrydw/paraglide-sveltekit-i18n',
69+
options,
70+
packages: [
71+
{
72+
name: '@inlang/paraglide-sveltekit',
73+
version: '^0.11.1',
74+
dev: false
75+
}
76+
],
77+
files: [
78+
{
79+
// create an inlang project if it doesn't exist yet
80+
name: () => 'project.inlang/settings.json',
81+
condition: ({ cwd }) => !fs.existsSync(path.join(cwd, 'project.inlang/settings.json')),
82+
content: ({ options, content }) => {
83+
const { data, generateCode } = parseJson(content);
84+
85+
for (const key in DEFAULT_INLANG_PROJECT) {
86+
data[key] = DEFAULT_INLANG_PROJECT[key as keyof typeof DEFAULT_INLANG_PROJECT];
87+
}
88+
const { validLanguageTags } = parseLanguageTagInput(options.availableLanguageTags);
89+
const sourceLanguageTag = validLanguageTags[0];
90+
91+
data.sourceLanguageTag = sourceLanguageTag;
92+
data.languageTags = validLanguageTags;
93+
94+
return generateCode();
95+
}
96+
},
97+
{
98+
// add the vite plugin
99+
name: ({ typescript }) => `vite.config.${typescript ? 'ts' : 'js'}`,
100+
content: ({ content }) => {
101+
const { ast, generateCode } = parseScript(content);
102+
103+
const vitePluginName = 'paraglide';
104+
imports.addNamed(ast, '@inlang/paraglide-sveltekit/vite', {
105+
paraglide: vitePluginName
106+
});
107+
108+
const { value: rootObject } = exports.defaultExport(
109+
ast,
110+
functions.call('defineConfig', [])
111+
);
112+
const param1 = functions.argumentByIndex(rootObject, 0, object.createEmpty());
113+
114+
const pluginsArray = object.property(param1, 'plugins', array.createEmpty());
115+
const pluginFunctionCall = functions.call(vitePluginName, []);
116+
const pluginConfig = object.create({
117+
project: common.createLiteral('./project.inlang'),
118+
outdir: common.createLiteral('./src/lib/paraglide')
119+
});
120+
functions.argumentByIndex(pluginFunctionCall, 0, pluginConfig);
121+
array.push(pluginsArray, pluginFunctionCall);
122+
123+
return generateCode();
124+
}
125+
},
126+
{
127+
// src/lib/i18n file
128+
name: ({ typescript }) => `src/lib/i18n.${typescript ? 'ts' : 'js'}`,
129+
content({ content }) {
130+
const { ast, generateCode } = parseScript(content);
131+
132+
imports.addNamed(ast, '@inlang/paraglide-sveltekit', { createI18n: 'createI18n' });
133+
imports.addDefault(ast, '$lib/paraglide/runtime', '* as runtime');
134+
135+
const createI18nExpression = common.expressionFromString('createI18n(runtime)');
136+
const i18n = variables.declaration(ast, 'const', 'i18n', createI18nExpression);
137+
138+
const existingExport = exports.namedExport(ast, 'i18n', i18n);
139+
if (existingExport.declaration != i18n) {
140+
log.warn('Setting up $lib/i18n failed because it already exports an i18n function');
141+
}
142+
143+
return generateCode();
144+
}
145+
},
146+
{
147+
// reroute hook
148+
name: ({ typescript }) => `src/hooks.${typescript ? 'ts' : 'js'}`,
149+
content({ content }) {
150+
const { ast, generateCode } = parseScript(content);
151+
152+
imports.addNamed(ast, '$lib/i18n', {
153+
i18n: 'i18n'
154+
});
155+
156+
const expression = common.expressionFromString('i18n.reroute()');
157+
const rerouteIdentifier = variables.declaration(ast, 'const', 'reroute', expression);
158+
159+
const existingExport = exports.namedExport(ast, 'reroute', rerouteIdentifier);
160+
if (existingExport.declaration != rerouteIdentifier) {
161+
log.warn('Adding the reroute hook automatically failed. Add it manually');
162+
}
163+
164+
return generateCode();
165+
}
166+
},
167+
{
168+
// handle hook
169+
name: ({ typescript }) => `src/hooks.server.${typescript ? 'ts' : 'js'}`,
170+
content({ content, typescript }) {
171+
const { ast, generateCode } = parseScript(content);
172+
173+
imports.addNamed(ast, '$lib/i18n', {
174+
i18n: 'i18n'
175+
});
176+
177+
const hookHandleContent = 'i18n.handle()';
178+
kit.addHooksHandle(ast, typescript, 'paraglide', hookHandleContent);
179+
180+
return generateCode();
181+
}
182+
},
183+
{
184+
// add the <ParaglideJS> component to the layout
185+
name: ({ kit }) => `${kit?.routesDirectory}/+layout.svelte`,
186+
content: ({ content, dependencyVersion }) => {
187+
const { script, template, generateCode } = parseSvelte(content);
188+
189+
const paraglideComponentName = 'ParaglideJS';
190+
imports.addNamed(script.ast, '@inlang/paraglide-sveltekit', {
191+
[paraglideComponentName]: paraglideComponentName
192+
});
193+
imports.addNamed(script.ast, '$lib/i18n', {
194+
i18n: 'i18n'
195+
});
196+
197+
// wrap the HTML in a ParaglideJS instance
198+
const rootChildren = template.ast.children;
199+
if (rootChildren.length === 0) {
200+
const svelteVersion = dependencyVersion('svelte');
201+
if (!svelteVersion) throw new Error('Failed to determine svelte version');
202+
203+
html.addSlot(script.ast, template.ast, svelteVersion);
204+
}
205+
206+
const hasParaglideJsNode = rootChildren.find(
207+
(x) => x.type == 'tag' && x.name == paraglideComponentName
208+
);
209+
if (!hasParaglideJsNode) {
210+
const root = html.element(paraglideComponentName, {});
211+
root.attribs = {
212+
'{i18n}': ''
213+
};
214+
root.children = rootChildren;
215+
template.ast.children = [root];
216+
}
217+
218+
return generateCode({ script: script.generateCode(), template: template.generateCode() });
219+
}
220+
},
221+
{
222+
// add the text-direction and lang attribute placeholders to app.html
223+
name: () => 'src/app.html',
224+
content: ({ content }) => {
225+
const { ast, generateCode } = parseHtml(content);
226+
227+
const htmlNode = ast.children.find(
228+
(child): child is html.HtmlElement =>
229+
child.type === html.HtmlElementType.Tag && child.name === 'html'
230+
);
231+
if (!htmlNode) {
232+
log.warn(
233+
"Could not find <html> node in app.html. You'll need to add the language placeholder manually"
234+
);
235+
return generate 341A Code();
236+
}
237+
htmlNode.attribs = {
238+
...htmlNode.attribs,
239+
lang: '%paraglide.lang%',
240+
dir: '%paraglide.textDirection%'
241+
};
242+
243+
return generateCode();
244+
}
245+
},
246+
{
247+
// add usage example
248+
name: ({ kit }) => `${kit?.routesDirectory}/+page.svelte`,
249+
condition: ({ options }) => options.demo,
250+
content({ content, options, typescript }) {
251+
const { script, template, generateCode } = parseSvelte(content);
252+
253+
imports.addDefault(script.ast, '$lib/paraglide/messages.js', '* as m');
254+
imports.addNamed(script.ast, '$app/navigation', { goto: 'goto' });
255+
imports.addNamed(script.ast, '$app/stores', { page: 'page' });
256+
imports.addNamed(script.ast, '$lib/i18n', { i18n: 'i18n' });
257+
if (typescript) {
258+
imports.addNamed(
259+
script.ast,
260+
'$lib/paraglide/runtime',
261+
{ AvailableLanguageTag: 'AvailableLanguageTag' },
262+
true
263+
);
264+
}
265+
266+
const { ts } = utils.createPrinter({ ts: typescript });
267+
268+
const methodStatement = common.statementFromString(`
269+
function switchToLanguage(newLanguage${ts(': AvailableLanguageTag')}) {
270+
const canonicalPath = i18n.route($page.url.pathname);
271+
const localisedPath = i18n.resolveRoute(canonicalPath, newLanguage);
272+
goto(localisedPath);
273+
}
274+
`);
275+
if (!typescript) {
276+
common.addJsDocComment(methodStatement, {
277+
'import("$lib/paraglide/runtime").AvailableLanguageTag': 'newLanguage'
278+
});
279+
}
280+
281+
script.ast.body.push(methodStatement);
282+
283+
// add localized message
284+
html.addFromRawHtml(
285+
template.ast.childNodes,
286+
`\n\n<h1>{m.hello_world({ name: 'SvelteKit User' })}</h1>\n`
287+
);
288+
289+
// add links to other localized pages, the first one is the default
290+
// language, thus it does not require any localized route
291+
const { validLanguageTags } = parseLanguageTagInput(options.availableLanguageTags);
292+
const links = validLanguageTags
293+
.map((x) => `\n\t<button onclick="{() => switchToLanguage('${x}')}">${x}</button>`)
294+
.join('');
295+
const div = html.element('div');
296+
html.addFromRawHtml(div.childNodes, `${links}\n`);
297+
html.appendElement(template.ast.childNodes, div);
298+
299+
return generateCode({ script: script.generateCode(), template: template.generateCode() });
300+
}
301+
}
302+
],
303+
postInstall: ({ cwd, options }) => {
304+
const jsonData: Record<string, string> = {};
305+
jsonData['$schema'] = 'https://inlang.com/schema/inlang-message-format';
306+
307+
const { validLanguageTags } = parseLanguageTagInput(options.availableLanguageTags);
308+
for (const languageTag of validLanguageTags) {
309+
jsonData.hello_world = `Hello, {name} from ${languageTag}!`;
310+
311+
const filePath = `messages/${languageTag}.json`;
312+
const directoryPath = path.dirname(filePath);
313+
const fullDirectoryPath = path.join(cwd, directoryPath);
314+
const fullFilePath = path.join(cwd, filePath);
315+
316+
fs.mkdirSync(fullDirectoryPath, { recursive: true });
317+
fs.writeFileSync(fullFilePath, JSON.stringify(jsonData, null, 2) + '\n');
318+
}
319+
},
320+
nextSteps: ({ highlighter }) => [
321+
`Edit your messages in ${highlighter.path('messages/en.json')}`,
322+
'Consider installing the Sherlock IDE Extension'
323+
]
324+
});
325 10000 +
326+
const isValidLanguageTag = (languageTag: string): boolean =>
327+
// Regex vendored in from https://github.com/opral/monorepo/blob/94c2298cc1da5378b908e4c160b0fa71a45caadb/inlang/source-code/versioned-interfaces/language-tag/src/interface.ts#L16
328+
RegExp(
329+
'^((?<grandfathered>(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?<language>([A-Za-z]{2,3}(-(?<extlang>[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?))(-(?<script>[A-Za-z]{4}))?(-(?<region>[A-Za-z]{2}|[0-9]{3}))?(-(?<variant>[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*))$'
330+
).test(languageTag);
331+
332+
function parseLanguageTagInput(input: string): {
333+
validLanguageTags: string[];
334+
invalidLanguageTags: string[];
335+
} {
336+
const probablyLanguageTags = input
337+
.replace(/[,:\s]/g, ' ') // replace common separators with spaces
338+
.split(' ')
339+
.filter(Boolean) // remove empty segments
340+
.map((tag) => tag.toLowerCase());
341+
342+
const validLanguageTags: string[] = [];
343+
const invalidLanguageTags: string[] = [];
344+
345+
for (const tag of probablyLanguageTags) {
346+
if (isValidLanguageTag(tag)) validLanguageTags.push(tag);
347+
else invalidLanguageTags.push(tag);
348+
}
349+
350+
return {
351+
validLanguageTags,
352+
invalidLanguageTags
353+
};
354+
}

packages/adders/paraglide/logo.png

26.1 KB
Loading

packages/adders/paraglide/tests.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineAdderTests } from '@sveltejs/cli-core';
2+
import { options } from './index.ts';
3+
// e2e tests make no sense in this context
4+
5+
export const tests = defineAdderTests({
6+
files: [],
7+
options,
8+
optionValues: [],
9+
tests: []
10+
});

0 commit comments

Comments
 (0)
0