|
| 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 | +} |
0 commit comments