diff --git a/documentation/docs/13-configuration.md b/documentation/docs/13-configuration.md
index 76168790d966..2e1e65deb08d 100644
--- a/documentation/docs/13-configuration.md
+++ b/documentation/docs/13-configuration.md
@@ -23,7 +23,8 @@ module.exports = {
lib: 'src/lib',
routes: 'src/routes',
serviceWorker: 'src/service-worker',
- template: 'src/app.html'
+ template: 'src/app.html',
+ translations: 'src/translations'
},
host: null,
hostHeader: null,
diff --git a/examples/svelte-kit-i18n-demo/.gitignore b/examples/svelte-kit-i18n-demo/.gitignore
new file mode 100644
index 000000000000..9a7b33b97ced
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/.gitignore
@@ -0,0 +1,6 @@
+.DS_Store
+/node_modules
+/build
+/.svelte
+/.vercel_build_output
+/workers-site
\ No newline at end of file
diff --git a/examples/svelte-kit-i18n-demo/README.md b/examples/svelte-kit-i18n-demo/README.md
new file mode 100644
index 000000000000..1adcebcf25f0
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/README.md
@@ -0,0 +1,17 @@
+# svelte-kit-demo
+
+This is a simple app to demonstrate a few different features of SvelteKit, and to ensure that the various adapters are working correctly.
+
+## Deployments
+
+### Vercel
+
+- URL: https://kit-zeta.vercel.app/
+- Info: https://vercel.com/sveltejs/kit
+- Build command: `npm run build:vercel`
+
+### Cloudflare Workers
+
+- URL: https://svelte-kit-demo.halfnelson.workers.dev
+- Build command: `npm run build:cloudflare-workers`
+- Deploy Command: `wrangler publish`
diff --git a/examples/svelte-kit-i18n-demo/package.json b/examples/svelte-kit-i18n-demo/package.json
new file mode 100644
index 000000000000..7c4662e029f7
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "svelte-kit-demo",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "svelte-kit dev",
+ "build": "svelte-kit build",
+ "start": "svelte-kit start",
+ "build:vercel": "ADAPTER=@sveltejs/adapter-vercel OPTIONS={} npm run build",
+ "build:cloudflare-workers": "ADAPTER=@sveltejs/adapter-cloudflare-workers OPTIONS={} npm run build"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-node": "workspace:*",
+ "@sveltejs/adapter-static": "workspace:*",
+ "@sveltejs/adapter-vercel": "workspace:*",
+ "@sveltejs/adapter-cloudflare-workers": "workspace:*",
+ "@sveltejs/kit": "workspace:*",
+ "svelte": "^3.35.0"
+ }
+}
diff --git a/examples/svelte-kit-i18n-demo/src/app.html b/examples/svelte-kit-i18n-demo/src/app.html
new file mode 100644
index 000000000000..2019e7de8760
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/app.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ %svelte.head%
+
+
+
+ %svelte.body%
+
+
+
\ No newline at end of file
diff --git a/examples/svelte-kit-i18n-demo/src/hooks/index.js b/examples/svelte-kit-i18n-demo/src/hooks/index.js
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/examples/svelte-kit-i18n-demo/src/lib/Nav.svelte b/examples/svelte-kit-i18n-demo/src/lib/Nav.svelte
new file mode 100644
index 000000000000..ffe262bb6935
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/lib/Nav.svelte
@@ -0,0 +1,67 @@
+
+
+
+
+ {#if $i18n.locales}
+
+ {#each $i18n.locales as { code }}
+
{code}
+ {/each}
+
+ {/if}
+
+
+
diff --git a/examples/svelte-kit-i18n-demo/src/routes/$layout.svelte b/examples/svelte-kit-i18n-demo/src/routes/$layout.svelte
new file mode 100644
index 000000000000..683ef0278e80
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/routes/$layout.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/svelte-kit-i18n-demo/src/routes/about/[id]/customer/[property]/index.svelte b/examples/svelte-kit-i18n-demo/src/routes/about/[id]/customer/[property]/index.svelte
new file mode 100644
index 000000000000..7d8b48b86c54
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/routes/about/[id]/customer/[property]/index.svelte
@@ -0,0 +1,8 @@
+
+
+Test
+ID: {id}
+Property: {property}
diff --git a/examples/svelte-kit-i18n-demo/src/routes/about/index.svelte b/examples/svelte-kit-i18n-demo/src/routes/about/index.svelte
new file mode 100644
index 000000000000..4f5e996dfe32
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/routes/about/index.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
+ {$t.about}
+
+
+{$t.about}
+this is the about page
+prerendering: {prerendering}
diff --git a/examples/svelte-kit-i18n-demo/src/routes/blog/[slug].json.ts b/examples/svelte-kit-i18n-demo/src/routes/blog/[slug].json.ts
new file mode 100644
index 000000000000..96e0bb9ea83c
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/routes/blog/[slug].json.ts
@@ -0,0 +1,13 @@
+import posts from './_posts';
+
+export function get({ params }) {
+ if (params.slug in posts) {
+ return {
+ body: posts[params.slug]
+ };
+ }
+
+ return {
+ status: 404
+ };
+}
diff --git a/examples/svelte-kit-i18n-demo/src/routes/blog/[slug].svelte b/examples/svelte-kit-i18n-demo/src/routes/blog/[slug].svelte
new file mode 100644
index 000000000000..19c8997cfcda
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/routes/blog/[slug].svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
+ {post.title}
+
+
+{post.title}
+
+ {@html post.body}
+
diff --git a/examples/svelte-kit-i18n-demo/src/routes/blog/_posts.ts b/examples/svelte-kit-i18n-demo/src/routes/blog/_posts.ts
new file mode 100644
index 000000000000..f56fa5c92ec7
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/routes/blog/_posts.ts
@@ -0,0 +1,14 @@
+export default {
+ 'first-post': {
+ title: 'First post!',
+ body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin tincidunt arcu elit, sit amet efficitur dolor bibendum quis. Ut et risus urna. Fusce eget luctus tellus. In hac habitasse platea dictumst. Sed vestibulum mauris a turpis lacinia, in elementum lectus cursus. Nulla eleifend ligula eros, id interdum ligula tincidunt ut. Curabitur ac rutrum dolor, vel commodo elit.
'
+ },
+ 'second-post': {
+ title: 'Second post!',
+ body: 'Vestibulum ipsum dolor, dignissim ut sapien a, sollicitudin consequat dui. Pellentesque libero quam, euismod ac nunc nec, commodo facilisis leo. Ut pretium, metus et commodo tincidunt, urna magna scelerisque est, rhoncus dapibus velit sem id quam. Ut sed felis felis. Phasellus justo ipsum, interdum vel erat sed, rhoncus eleifend nunc. Phasellus nulla libero, interdum ac ultricies vitae, maximus nec risus. Cras hendrerit sed lectus id ullamcorper.
'
+ },
+ 'third-post': {
+ title: 'Third post!',
+ body: 'Fusce vitae tincidunt libero. Pellentesque nec efficitur velit. Nunc sit amet efficitur tellus. Etiam velit dui, commodo volutpat condimentum non, venenatis consequat neque. Phasellus lacinia quis felis eu tristique. Sed et tellus mi. Aliquam congue ex vitae elit fermentum, sed elementum lectus euismod. Nam vel rhoncus dui. Nunc vulputate maximus est vitae sodales. Aenean rhoncus at nisl efficitur ultricies. Donec id lectus et leo dignissim mattis. Cras nisi quam, mattis sed felis in, tempor ullamcorper felis.
'
+ }
+}
\ No newline at end of file
diff --git a/examples/svelte-kit-i18n-demo/src/routes/blog/index.json.ts b/examples/svelte-kit-i18n-demo/src/routes/blog/index.json.ts
new file mode 100644
index 000000000000..dd78388b8003
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/routes/blog/index.json.ts
@@ -0,0 +1,10 @@
+import posts from './_posts';
+
+export function get() {
+ return {
+ body: Object.keys(posts).map(slug => ({
+ slug,
+ ...posts[slug]
+ }))
+ };
+}
\ No newline at end of file
diff --git a/examples/svelte-kit-i18n-demo/src/routes/blog/index.svelte b/examples/svelte-kit-i18n-demo/src/routes/blog/index.svelte
new file mode 100644
index 000000000000..c70fb34817c2
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/routes/blog/index.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+ Blog
+
+
+blog
+
+
+
+
diff --git a/examples/svelte-kit-i18n-demo/src/routes/index.svelte b/examples/svelte-kit-i18n-demo/src/routes/index.svelte
new file mode 100644
index 000000000000..b9663788e8eb
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/routes/index.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
+ Home
+
+
+Home
+
+{$t.welcome('User', 1)}
+
+This is an example of using Vite for SSR.
+
+
+
+
diff --git a/examples/svelte-kit-i18n-demo/src/translations/de.json b/examples/svelte-kit-i18n-demo/src/translations/de.json
new file mode 100644
index 000000000000..3f80e6c45510
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/translations/de.json
@@ -0,0 +1,9 @@
+{
+ "_routes": {
+ "about": "ueber"
+ },
+ "home": "start",
+ "about": "über",
+ "blog": "blog",
+ "welcome": "$1, willkommen! Du hast {{PLURAL:$2|eine neue Nachricht|$2 neue Nachrichten|12=ein Dutzend neue Nachrichten}} von $1"
+}
diff --git a/examples/svelte-kit-i18n-demo/src/translations/en.json b/examples/svelte-kit-i18n-demo/src/translations/en.json
new file mode 100644
index 000000000000..eabdb681b9cb
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/src/translations/en.json
@@ -0,0 +1,6 @@
+{
+ "home": "home",
+ "about": "about",
+ "blog": "blog",
+ "welcome": "Welcome $1, you have $2 new messages from $1."
+}
diff --git a/examples/svelte-kit-i18n-demo/static/favicon.ico b/examples/svelte-kit-i18n-demo/static/favicon.ico
new file mode 100644
index 000000000000..d75d248ef0b1
Binary files /dev/null and b/examples/svelte-kit-i18n-demo/static/favicon.ico differ
diff --git a/examples/svelte-kit-i18n-demo/static/logo.svg b/examples/svelte-kit-i18n-demo/static/logo.svg
new file mode 100644
index 000000000000..c051f1a34d4d
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/static/logo.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/examples/svelte-kit-i18n-demo/static/robots.txt b/examples/svelte-kit-i18n-demo/static/robots.txt
new file mode 100644
index 000000000000..e9e57dc4d41b
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/static/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/examples/svelte-kit-i18n-demo/svelte.config.cjs b/examples/svelte-kit-i18n-demo/svelte.config.cjs
new file mode 100644
index 000000000000..4ccde5fda478
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/svelte.config.cjs
@@ -0,0 +1,23 @@
+const adapter = require(process.env.ADAPTER || '@sveltejs/adapter-node');
+const options = JSON.stringify(process.env.OPTIONS || '{}');
+
+module.exports = {
+ kit: {
+ adapter: adapter(options),
+ target: '#svelte',
+ i18n: {
+ defaultLocale: 'de',
+ fallbackLocale: 'en',
+ locales: [
+ {
+ code: 'de',
+ iso: 'de-DE'
+ },
+ {
+ code: 'en',
+ iso: 'en-GB'
+ }
+ ]
+ }
+ }
+};
diff --git a/examples/svelte-kit-i18n-demo/wrangler.toml b/examples/svelte-kit-i18n-demo/wrangler.toml
new file mode 100644
index 000000000000..9d503e52ed07
--- /dev/null
+++ b/examples/svelte-kit-i18n-demo/wrangler.toml
@@ -0,0 +1,10 @@
+name = "svelte-kit-demo"
+type = "webpack"
+account_id = "f60df21486a4f0e5dbd85493882f1d53"
+workers_dev = true
+route = ""
+zone_id = ""
+
+[site]
+bucket = "./build"
+entry-point = "./workers-site"
\ No newline at end of file
diff --git a/packages/kit/rollup.config.js b/packages/kit/rollup.config.js
index 2762194130ad..b6074746bc4f 100644
--- a/packages/kit/rollup.config.js
+++ b/packages/kit/rollup.config.js
@@ -18,6 +18,7 @@ export default [
'app/stores': 'src/runtime/app/stores.js',
'app/paths': 'src/runtime/app/paths.js',
'app/env': 'src/runtime/app/env.js',
+ 'app/translations': 'src/runtime/app/translations.js',
paths: 'src/runtime/paths.js',
env: 'src/runtime/env.js'
},
diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js
index 27e0eca7c389..50eff196c513 100644
--- a/packages/kit/src/core/build/index.js
+++ b/packages/kit/src/core/build/index.js
@@ -277,9 +277,10 @@ async function build_server(
import { set_prerendering } from './runtime/env.js';
import * as user_hooks from ${s(app_relative(hooks_file))};
- const template = ({ head, body }) => ${s(fs.readFileSync(config.kit.files.template, 'utf-8'))
+ const template = ({ head, body, lang }) => ${s(fs.readFileSync(config.kit.files.template, 'utf-8'))
.replace('%svelte.head%', '" + head + "')
- .replace('%svelte.body%', '" + body + "')};
+ .replace('%svelte.body%', '" + body + "')
+ .replace('%svelte.lang%', '" + lang + "')};
let options = null;
@@ -306,6 +307,7 @@ async function build_server(
},
hooks: get_hooks(user_hooks),
hydrate: ${s(config.kit.hydrate)},
+ i18n: ${s(config.kit.i18n)},
initiator: undefined,
load_component,
manifest,
diff --git a/packages/kit/src/core/create_app/index.js b/packages/kit/src/core/create_app/index.js
index dc9dd791f7dc..40ad00623635 100644
--- a/packages/kit/src/core/create_app/index.js
+++ b/packages/kit/src/core/create_app/index.js
@@ -145,6 +145,7 @@ function generate_app(manifest_data, base) {
+
+
+
+
+ `
+ .replace(/^\t\t/gm, '')
+ .trim();
+
+ mkdirp(path.dirname(file));
+ fs.writeFileSync(file, code);
+
+ return path.relative(cwd, file);
+ }
+
+ /**
+ * @param {string} filename
+ * @param {{locale: string, pattern: RegExp}[]} patterns
+ */
+ function create_pattern_component(filename, patterns) {
+ const dir = `${output}/components/locales`;
+ const file = `${dir}/${filename}`;
+
+ const code = `
+
+
+
+
+
+
+
+ {#each $i18n.locales as locale}
+
+ {/each}
+
+
+
+ `
+ .replace(/^\t\t/gm, '')
+ .trim();
+
+ mkdirp(path.dirname(file));
+ fs.writeFileSync(file, code);
+
+ return path.relative(cwd, file);
+ }
+
+ function create_redirect_component() {
+ const dir = `${output}/components`;
+ const file = `${dir}/redirect.svelte`;
+ const { defaultLocale, locales } = config.kit.i18n || {};
+ const code = `
+
+
+ ${
+ locales
+ ? `
+
+ {#if $page.path === "/"}
+
+ ${locales
+ .map((l) => ` `)
+ .join('\n\t')}
+ {/if}
+ `
+ : ''
+ }
+ `
+ .replace(/^\t\t/gm, '')
+ .trim();
+
+ mkdirp(path.dirname(file));
+ fs.writeFileSync(file, code);
+
+ return path.relative(cwd, file);
+ }
+
+ /**
+ * @param {Part[][]} segments
+ * @param {import('types/i18n').I18nLocale} locale
+ * @returns {Part[][]}
+ */
+ function get_prefixed_segments(segments, locale) {
+ if (locale.prefix) {
+ return [[{ content: locale.prefix, dynamic: false, spread: false }], ...segments];
+ }
+ return segments;
+ }
+
+ /**
+ * @param {Part[][]} segments
+ * @param {import('types/i18n').I18nLocale} locale
+ * @param {Translations[]} translations
+ * @returns {Part[][]}
+ */
+ function get_translated_segments(segments, locale, translations) {
+ if (translations.length === 0) return get_prefixed_segments(segments, locale);
+
+ return get_prefixed_segments(
+ segments.map((parts) =>
+ parts.map((part) => {
+ if (part.dynamic) return part;
+
+ for (let i = translations.length - 1; i >= 0; i--) {
+ const localeTranslations = translations[i][locale.code];
+ if (!isTranslations(localeTranslations)) continue;
+
+ const tr = localeTranslations['_routes'];
+ if (tr && typeof tr !== 'string' && tr[part.content]) {
+ const translation = tr[part.content];
+ if (typeof translation === 'string') return { ...part, content: translation };
+ }
+ }
+ return part;
+ })
+ ),
+ locale
+ );
+ }
+
/** @type {string[]} */
const components = [];
+ /** @type {{[locale: string]: string}} */
+ const locale_components = {};
+
/** @type {import('types/internal').RouteData[]} */
const routes = [];
const default_layout = posixify(path.relative(cwd, `${output}/components/layout.svelte`));
const default_error = posixify(path.relative(cwd, `${output}/components/error.svelte`));
+ const locale_redirect = config.kit.i18n && create_redirect_component();
+
+ const translations =
+ (config.kit.i18n &&
+ find_translations(undefined, path.relative(cwd, config.kit.files.translations))) ||
+ {};
+
+ if (config.kit.i18n) {
+ const { locales } = config.kit.i18n;
+
+ if (locales.length > 0 && !locales.find((l) => l.prefix === undefined || l.prefix === null)) {
+ components.push(locale_redirect);
+ }
+
+ locales.forEach((locale) => {
+ const component = create_locale_component(locale, toTranslations(translations[locale.code]));
+ locale_components[locale.code] = component;
+ components.push(component);
+ });
+ }
/**
* @param {string} dir
@@ -53,8 +418,16 @@ export default function create_manifest_data({ config, output, cwd = process.cwd
* @param {string[]} parent_params
* @param {string[]} layout_stack // accumulated $layout.svelte components
* @param {string[]} error_stack // accumulated $error.svelte components
+ * @param {Translations[]} translations_stack
*/
- function walk(dir, parent_segments, parent_params, layout_stack, error_stack) {
+ function walk(
+ dir,
+ parent_segments,
+ parent_params,
+ layout_stack,
+ error_stack,
+ translations_stack
+ ) {
/** @type {Item[]} */
const items = fs
.readdirSync(dir)
@@ -143,6 +516,7 @@ export default function create_manifest_data({ config, output, cwd = process.cwd
const layout_reset = find_layout('$layout.reset', item.file);
const layout = find_layout('$layout', item.file);
const error = find_layout('$error', item.file);
+ const translations = find_translations('$translations', item.file);
if (layout_reset && layout) {
throw new Error(`Cannot have $layout next to $layout.reset: ${layout_reset}`);
@@ -157,42 +531,75 @@ export default function create_manifest_data({ config, output, cwd = process.cwd
segments,
params,
layout_reset ? [layout_reset] : layout_stack.concat(layout),
- layout_reset ? [error] : error_stack.concat(error)
+ layout_reset ? [error] : error_stack.concat(error),
+ translations ? translations_stack.concat(translations) : translations_stack
);
} else if (item.is_page) {
components.push(item.file);
- const a = layout_stack.concat(item.file);
- const b = error_stack;
+ const pages = [];
+
+ if (config.kit.i18n?.locales.length > 0) {
+ const patterns = config.kit.i18n.locales.map((locale) => ({
+ locale: locale.code,
+ pattern: get_pattern(
+ get_translated_segments(segments, locale, translations_stack),
+ true
+ )
+ }));
+
+ /** @type {string | undefined} */
+ const patternComponent = create_pattern_component(item.file, patterns);
+ components.push(patternComponent);
+
+ patterns.forEach(({ locale, pattern }) => {
+ pages.push({
+ pattern,
+ layout: [locale_components[locale], ...layout_stack, patternComponent, item.file]
+ });
+ });
+
+ if (!config.kit.i18n.locales.find((l) => !l.prefix)) {
+ pages.push({ pattern: get_pattern(segments, true), layout: [locale_redirect] });
+ }
+ } else {
+ pages.push({
+ pattern: get_pattern(segments, true),
+ layout: [...layout_stack, item.file]
+ });
+ }
- const pattern = get_pattern(segments, true);
+ pages.forEach(({ pattern, layout }) => {
+ const a = layout;
+ const b = error_stack;
- let i = a.length;
- while (i--) {
- if (!b[i] && !a[i]) {
- b.splice(i, 1);
- a.splice(i, 1);
+ let i = a.length;
+ while (i--) {
+ if (!b[i] && !a[i]) {
+ b.splice(i, 1);
+ a.splice(i, 1);
+ }
}
- }
- i = b.length;
- while (i--) {
- if (b[i]) break;
- }
+ i = b.length;
+ while (i--) {
+ if (b[i]) break;
+ }
- b.splice(i + 1);
+ b.splice(i + 1);
- const path = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
- ? `/${segments.map((segment) => segment[0].content).join('/')}`
- : null;
+ const path = segments.every((segment) => segment.length === 1 && !segment[0].dynamic)
+ ? `/${segments.map((segment) => segment[0].content).join('/')}`
+ : null;
- routes.push({
- type: 'page',
- pattern,
- params,
- path,
- a,
- b
+ routes.push({
+ type: 'page',
+ pattern,
+ params,
+ path,
+ a,
+ b
+ });
});
} else {
const pattern = get_pattern(segments, !item.route_suffix);
@@ -214,7 +621,7 @@ export default function create_manifest_data({ config, output, cwd = process.cwd
components.push(layout, error);
- walk(config.kit.files.routes, [], [], [layout], [error]);
+ walk(config.kit.files.routes, [], [], [layout], [error], [translations]);
const assets_dir = config.kit.files.assets;
@@ -223,7 +630,8 @@ export default function create_manifest_data({ config, output, cwd = process.cwd
layout,
error,
components,
- routes
+ routes,
+ i18n: config.kit.i18n
};
}
diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js
index 4bec1f1fa9bd..58ae8c353e5e 100644
--- a/packages/kit/src/core/dev/index.js
+++ b/packages/kit/src/core/dev/index.js
@@ -183,6 +183,7 @@ class Watcher extends EventEmitter {
handle: hooks.handle || (({ request, render }) => render(request))
},
hydrate: this.config.kit.hydrate,
+ i18n: this.config.kit.i18n,
paths: this.config.kit.paths,
load_component: async (id) => {
const url = path.resolve(this.cwd, id);
@@ -229,11 +230,12 @@ class Watcher extends EventEmitter {
router: this.config.kit.router,
ssr: this.config.kit.ssr,
target: this.config.kit.target,
- template: ({ head, body }) => {
+ template: ({ head, body, lang }) => {
let rendered = fs
.readFileSync(this.config.kit.files.template, 'utf8')
.replace('%svelte.head%', () => head)
- .replace('%svelte.body%', () => body);
+ .replace('%svelte.body%', () => body)
+ .replace('%svelte.lang%', () => lang);
if (this.config.kit.amp) {
const result = validator.validateString(rendered);
diff --git a/packages/kit/src/core/load_config/index.js b/packages/kit/src/core/load_config/index.js
index 5cce4d283a55..6f9523d425e2 100644
--- a/packages/kit/src/core/load_config/index.js
+++ b/packages/kit/src/core/load_config/index.js
@@ -92,6 +92,7 @@ export async function load_config({ cwd = process.cwd() } = {}) {
validated.kit.files.serviceWorker = path.resolve(cwd, validated.kit.files.serviceWorker);
validated.kit.files.setup = path.resolve(cwd, validated.kit.files.setup);
validated.kit.files.template = path.resolve(cwd, validated.kit.files.template);
+ validated.kit.files.translations = path.resolve(cwd, validated.kit.files.translations);
// TODO remove this, eventually
if (resolve_entry(validated.kit.files.setup)) {
diff --git a/packages/kit/src/core/load_config/index.spec.js b/packages/kit/src/core/load_config/index.spec.js
index 7c43921bc2ca..88fee0a36f66 100644
--- a/packages/kit/src/core/load_config/index.spec.js
+++ b/packages/kit/src/core/load_config/index.spec.js
@@ -21,11 +21,13 @@ test('fills in defaults', () => {
routes: 'src/routes',
serviceWorker: 'src/service-worker',
setup: 'src/setup',
- template: 'src/app.html'
+ template: 'src/app.html',
+ translations: 'src/translations'
},
host: null,
hostHeader: null,
hydrate: true,
+ i18n: null,
paths: {
base: '',
assets: '/.'
@@ -103,11 +105,13 @@ test('fills in partial blanks', () => {
routes: 'src/routes',
serviceWorker: 'src/service-worker',
setup: 'src/setup',
- template: 'src/app.html'
+ template: 'src/app.html',
+ translations: 'src/translations'
},
host: null,
hostHeader: null,
hydrate: true,
+ i18n: null,
paths: {
base: '',
assets: '/.'
diff --git a/packages/kit/src/core/load_config/options.js b/packages/kit/src/core/load_config/options.js
index e1486ade51a6..5b7bce47561a 100644
--- a/packages/kit/src/core/load_config/options.js
+++ b/packages/kit/src/core/load_config/options.js
@@ -68,7 +68,8 @@ const options = {
serviceWorker: expect_string('src/service-worker'),
// TODO remove this, eventually
setup: expect_string('src/setup'),
- template: expect_string('src/app.html')
+ template: expect_string('src/app.html'),
+ translations: expect_string('src/translations')
}
},
@@ -78,6 +79,49 @@ const options = {
hydrate: expect_boolean(true),
+ i18n: {
+ type: 'leaf',
+ default: null,
+ validate: (option, keypath) => {
+ if (typeof option !== 'object' || !option.defaultLocale || !option.locales) {
+ throw new Error(
+ `${keypath} should be and object with "defaultLocale" and "locales" properties`
+ );
+ }
+
+ if (
+ !Array.isArray(option.locales) ||
+ !option['locales'].every((locale) => typeof locale === 'object')
+ ) {
+ throw new Error(`${keypath} must be an array of locale objects`);
+ }
+
+ const locales = option['locales'].map((locale) => {
+ if (!locale.code || !locale.iso) {
+ throw new Error(`Each member of ${keypath} must be have a "code" and "iso" property`);
+ }
+
+ if (typeof locale.code !== 'string') {
+ throw new Error(`Property "code" in member of ${keypath} must be a string`);
+ }
+
+ if (typeof locale.iso !== 'string') {
+ throw new Error(`Property "iso" in member of ${keypath} must be a string`);
+ }
+
+ return {
+ ...locale,
+ prefix: locale.prefix === undefined ? locale.code : locale.prefix
+ };
+ });
+
+ return {
+ ...option,
+ locales
+ };
+ }
+ },
+
paths: {
type: 'branch',
children: {
diff --git a/packages/kit/src/core/load_config/test/index.js b/packages/kit/src/core/load_config/test/index.js
index 9a1437c1d095..b5d04dc56bb9 100644
--- a/packages/kit/src/core/load_config/test/index.js
+++ b/packages/kit/src/core/load_config/test/index.js
@@ -28,11 +28,13 @@ test('load default config', async () => {
routes: join(cwd, 'src/routes'),
serviceWorker: join(cwd, 'src/service-worker'),
setup: join(cwd, 'src/setup'),
- template: join(cwd, 'src/app.html')
+ template: join(cwd, 'src/app.html'),
+ translations: join(cwd, 'src/translations')
},
host: null,
hostHeader: null,
hydrate: true,
+ i18n: null,
paths: { base: '', assets: '/.' },
prerender: { crawl: true, enabled: true, force: false, pages: ['*'] },
router: true,
diff --git a/packages/kit/src/runtime/app/stores.js b/packages/kit/src/runtime/app/stores.js
index e33ebb3f4c91..e462a2162ee6 100644
--- a/packages/kit/src/runtime/app/stores.js
+++ b/packages/kit/src/runtime/app/stores.js
@@ -23,6 +23,9 @@ export const getStores = () => {
page: {
subscribe: stores.page.subscribe
},
+ i18n: {
+ subscribe: stores.i18n.subscribe
+ },
navigating: {
subscribe: stores.navigating.subscribe
},
@@ -46,7 +49,16 @@ export const page = {
}
};
-/** @type {typeof import('$app/stores').navigating} */
+/** @type {typeof import('$app/stores').i18n} */
+export const i18n = {
+ /** @param {(value: any) => void} fn */
+ subscribe(fn) {
+ const store = getStores().i18n;
+ return store.subscribe(fn);
+ }
+};
+
+/** @type {import('svelte/store').Readable} */
export const navigating = {
/** @param {(value: any) => void} fn */
subscribe(fn) {
diff --git a/packages/kit/src/runtime/app/translations.js b/packages/kit/src/runtime/app/translations.js
new file mode 100644
index 000000000000..911ca3d3d89d
--- /dev/null
+++ b/packages/kit/src/runtime/app/translations.js
@@ -0,0 +1,24 @@
+import { derived } from 'svelte/store';
+import { i18n } from './stores';
+
+/** @type {typeof import('$app/translations').t} */
+export const t = derived(i18n, (i18n) => i18n.translations);
+
+/** @type {typeof import('$app/translations').l} */
+export const l = derived([t, i18n], ([t, i18n]) => /** @param {string} path */ (path) => {
+ if (path[0] !== '/') return path;
+ if (path === '/') return i18n?.locale?.prefix ? `/${i18n.locale.prefix}` : path;
+ return (
+ '/' +
+ [
+ ...(i18n.locale?.prefix ? [i18n.locale.prefix] : []),
+ ...path
+ .slice(1)
+ .split('/')
+ .map((segment) => {
+ if (segment.match(/^\[.*\]$/)) return segment.slice(1, -1);
+ return (t?._routes && typeof t._routes === 'object' && t._routes?.[segment]) || segment;
+ })
+ ].join('/')
+ );
+});
diff --git a/packages/kit/src/runtime/client/renderer.js b/packages/kit/src/runtime/client/renderer.js
index 55945edaabdf..9390aadfb57e 100644
--- a/packages/kit/src/runtime/client/renderer.js
+++ b/packages/kit/src/runtime/client/renderer.js
@@ -302,6 +302,8 @@ export class Renderer {
return this.loading.promise;
}
+ console.log(info.routes);
+
for (let i = 0; i < info.routes.length; i += 1) {
const route = info.routes[i];
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index df575ddaf0e9..03f186e179a0 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -167,10 +167,16 @@ export async function render_response({
headers['cache-control'] = `${is_private ? 'private' : 'public'}, max-age=${maxage}`;
}
+ const firstSegment = page.path.split('/')[1];
+ const locale =
+ options.i18n?.locales.find(({ code }) => code === firstSegment) ||
+ options.i18n?.locales.find(({ code }) => code === options.i18n.defaultLocale);
+ const lang = locale?.iso || 'en';
+
return {
status,
headers,
- body: options.template({ head, body })
+ body: options.template({ head, body, lang })
};
}
diff --git a/packages/kit/types/ambient-modules.d.ts b/packages/kit/types/ambient-modules.d.ts
index f37e29981d45..e61b93813497 100644
--- a/packages/kit/types/ambient-modules.d.ts
+++ b/packages/kit/types/ambient-modules.d.ts
@@ -56,17 +56,22 @@ declare module '$app/paths' {
declare module '$app/stores' {
import { Readable, Writable } from 'svelte/store';
- import { Page } from '@sveltejs/kit';
+ import { Page, I18n } from '@sveltejs/kit';
/**
* A convenience function around `getContext` that returns `{ navigating, page, session }`.
* Most of the time, you won't need to use it.
*/
export function getStores(): {
+ i18n: Readable;
navigating: Readable<{ from: string; to: string } | null>;
page: Readable;
session: Writable;
};
+ /**
+ * A readable store with the current locale and a list of available locales.
+ */
+ export const i18n: Readable;
/**
* A readable store whose value reflects the object passed to load functions.
*/
@@ -104,3 +109,18 @@ declare module '$service-worker' {
*/
export const timestamp: number;
}
+
+declare module '$app/translations' {
+ import { Readable } from 'svelte/store';
+ import { Translations } from '@sveltejs/kit';
+
+ /**
+ * A readable store with the current locale's translations.
+ */
+ export const t: Readable;
+
+ /**
+ * A readable store with a function to localize paths
+ */
+ export const l: Readable<(path: string) => string>;
+}
diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts
index 8b23b04ba6e5..0beed8e2f9eb 100644
--- a/packages/kit/types/config.d.ts
+++ b/packages/kit/types/config.d.ts
@@ -1,5 +1,6 @@
import { Logger } from './internal';
import { UserConfig as ViteConfig } from 'vite';
+import { I18nConfig } from './i18n';
export type AdapterUtils = {
log: Logger;
@@ -31,10 +32,12 @@ export type Config = {
routes?: string;
serviceWorker?: string;
template?: string;
+ translations?: string;
};
host?: string;
hostHeader?: string;
hydrate?: boolean;
+ i18n?: I18nConfig;
paths?: {
base?: string;
assets?: string;
@@ -68,10 +71,12 @@ export type ValidatedConfig = {
serviceWorker: string;
setup: string;
template: string;
+ translations: string;
};
host: string;
hostHeader: string;
hydrate: boolean;
+ i18n?: I18nConfig;
paths: {
base: string;
assets: string;
diff --git a/packages/kit/types/i18n.d.ts b/packages/kit/types/i18n.d.ts
new file mode 100644
index 000000000000..4bb4a00c2825
--- /dev/null
+++ b/packages/kit/types/i18n.d.ts
@@ -0,0 +1,25 @@
+export type I18nLocale = {
+ code: string;
+ iso: string;
+ prefix?: string;
+};
+
+export type I18nConfig = {
+ defaultLocale: string;
+ fallbackLocale: string;
+ locales: I18nLocale[];
+ prefixDefault: boolean;
+ prefixRoutes: boolean;
+};
+
+export type Translations = {
+ [key: string]: string | Translations;
+};
+
+export type I18n = {
+ defaultLocale?: string;
+ locale?: I18nLocale;
+ locales: I18nLocale[];
+ localizedPaths: { [locale: string]: string };
+ translations: Translations;
+};
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index b33d0c79eea8..32805277f36f 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -1,6 +1,7 @@
import { Load } from './page';
import { Incoming, GetContext, GetSession, Handle } from './hooks';
import { RequestHandler, ServerResponse } from './endpoint';
+import { I18nConfig } from './i18n';
declare global {
interface ImportMeta {
@@ -137,6 +138,7 @@ export type SSRRenderOptions = {
handle_error: (error: Error) => void;
hooks: Hooks;
hydrate: boolean;
+ i18n?: I18nConfig;
load_component: (id: PageId) => Promise;
manifest: SSRManifest;
paths: {
@@ -148,7 +150,7 @@ export type SSRRenderOptions = {
router: boolean;
ssr: boolean;
target: string;
- template: ({ head, body }: { head: string; body: string }) => string;
+ template: ({ head, body, lang }: { head: string; body: string; lang: string }) => string;
};
export type SSRRenderState = {
@@ -191,6 +193,7 @@ export type ManifestData = {
error: string;
components: string[];
routes: RouteData[];
+ i18n?: I18nConfig;
};
export type BuildData = {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0c97bef98338..3c7283fd4b54 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -66,6 +66,22 @@ importers:
'@sveltejs/kit': link:../../packages/kit
svelte: 3.35.0
+ examples/svelte-kit-i18n-demo:
+ specifiers:
+ '@sveltejs/adapter-cloudflare-workers': workspace:*
+ '@sveltejs/adapter-node': workspace:*
+ '@sveltejs/adapter-static': workspace:*
+ '@sveltejs/adapter-vercel': workspace:*
+ '@sveltejs/kit': workspace:*
+ svelte: ^3.35.0
+ devDependencies:
+ '@sveltejs/adapter-cloudflare-workers': link:../../packages/adapter-cloudflare-workers
+ '@sveltejs/adapter-node': link:../../packages/adapter-node
+ '@sveltejs/adapter-static': link:../../packages/adapter-static
+ '@sveltejs/adapter-vercel': link:../../packages/adapter-vercel
+ '@sveltejs/kit': link:../../packages/kit
+ svelte: 3.35.0
+
packages/adapter-begin:
specifiers:
'@architect/parser': ^3.0.1