diff --git a/packages/create-svelte/templates/default/i18n.config.js b/packages/create-svelte/templates/default/i18n.config.js new file mode 100644 index 000000000000..54fd159985b3 --- /dev/null +++ b/packages/create-svelte/templates/default/i18n.config.js @@ -0,0 +1,37 @@ +import locales from './src/locales.js'; + +export const defaultLocale = locales[0]; + +/** @typedef {{ + * content: string; + * dynamic: boolean; + * spread: boolean; + * }} Part */ + +/** + * Create localized routes prefixed with locale + * @param {Part[][]} segments + * @param {'page' | 'endpoint'} type + * @returns {Part[][][]} + */ +export function localizeRoutes(segments, type) { + if (type === 'endpoint') return [segments]; + return locales.map((locale) => + locale === defaultLocale + ? segments + : [ + [{ content: locale, dynamic: false, spread: false }], + ...segments.map((segment) => segment.map((part) => translate(part))) + ] + ); +} + +/** + * Translate part of a route segment + * @param {Part} part + * @returns {Part} + */ +function translate(part) { + if (part.content === 'about') return { ...part, content: 'ueber' }; + return part; +} diff --git a/packages/create-svelte/templates/default/src/lib/header/Header.svelte b/packages/create-svelte/templates/default/src/lib/header/Header.svelte index 9d3120f3e6fd..e33f2cca172b 100644 --- a/packages/create-svelte/templates/default/src/lib/header/Header.svelte +++ b/packages/create-svelte/templates/default/src/lib/header/Header.svelte @@ -1,8 +1,21 @@ + + {#if defaultPath} + + {/if} + {#each Object.entries(alternatePaths) as [locale, path]} + + {/each} + +
@@ -15,12 +28,14 @@
+
@@ -40,7 +60,7 @@ } .corner { - width: 3em; + display: flex; height: 3em; } @@ -50,6 +70,23 @@ justify-content: center; width: 100%; height: 100%; + text-transform: uppercase; + } + + .corner nav a { + position: relative; + } + + .corner a.active::before { + --size: 6px; + content: ''; + width: 0; + height: 0; + position: absolute; + top: 0; + left: calc(50% - var(--size)); + border: var(--size) solid transparent; + border-top: var(--size) solid var(--accent-color); } .corner img { diff --git a/packages/create-svelte/templates/default/src/lib/i18n.ts b/packages/create-svelte/templates/default/src/lib/i18n.ts new file mode 100644 index 000000000000..e2500d7ca85e --- /dev/null +++ b/packages/create-svelte/templates/default/src/lib/i18n.ts @@ -0,0 +1,29 @@ +import { derived, readable } from 'svelte/store'; +import { page } from '$app/stores'; +import { alternates } from '$app/navigation'; + +import locales from '../locales'; + +export const defaultLocale = locales[0]; + +export const locale = derived( + page, + (page) => page.url.pathname.match(/^\/([a-z]{2})(\/|$)/)?.[1] || defaultLocale +); + +export const localizedPaths = readable( + (path: string): Record => + alternates(path)?.reduce((result, alt) => { + result[alt.match(/^\/([a-z]{2})(\/|$)/)?.[1] || defaultLocale] = alt; + return result; + }, {}) +); + +export const l = derived( + [localizedPaths, locale], + ([localizedPaths, locale]) => + (path: string): string => + localizedPaths(path)?.[locale] || path +); + +export { l as localize }; diff --git a/packages/create-svelte/templates/default/src/locales.js b/packages/create-svelte/templates/default/src/locales.js new file mode 100644 index 000000000000..0c7c8f3ed840 --- /dev/null +++ b/packages/create-svelte/templates/default/src/locales.js @@ -0,0 +1 @@ +export default ['en', 'de']; diff --git a/packages/create-svelte/templates/default/svelte.config.js b/packages/create-svelte/templates/default/svelte.config.js index 51028cd36a87..ea32e4ecbfbe 100644 --- a/packages/create-svelte/templates/default/svelte.config.js +++ b/packages/create-svelte/templates/default/svelte.config.js @@ -1,5 +1,6 @@ import adapter from '@sveltejs/adapter-auto'; import preprocess from 'svelte-preprocess'; +import { localizeRoutes } from './i18n.config.js'; // This config is ignored and replaced with one of the configs in the shared folder when a project is created. @@ -18,7 +19,9 @@ const config = { // Override http methods in the Todo forms methodOverride: { allowed: ['PATCH', 'DELETE'] - } + }, + + alternateRoutes: localizeRoutes } }; diff --git a/packages/kit/.gitignore b/packages/kit/.gitignore index 4d29456c81bc..43e7722cbbb5 100644 --- a/packages/kit/.gitignore +++ b/packages/kit/.gitignore @@ -5,5 +5,6 @@ /client/**/*.d.ts /test/**/.svelte-kit /test/**/build +/test/**/errors.json !/src/core/adapt/test/fixtures/*/.svelte-kit !/test/node_modules \ No newline at end of file diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md index f0db7b4a16b3..5f691726dd98 100644 --- a/packages/kit/CHANGELOG.md +++ b/packages/kit/CHANGELOG.md @@ -1,5 +1,11 @@ # @sveltejs/kit +## 1.0.0-next.252 + +### Patch Changes + +- remove nonexistent `url` store from `$app/stores` ambient types ([#3640](https://github.com/sveltejs/kit/pull/3640)) + ## 1.0.0-next.251 ### Patch Changes diff --git a/packages/kit/package.json b/packages/kit/package.json index c3ae0a3f52b6..5128606513a7 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -1,6 +1,6 @@ { "name": "@sveltejs/kit", - "version": "1.0.0-next.251", + "version": "1.0.0-next.252", "repository": { "type": "git", "url": "https://github.com/sveltejs/kit", diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 7dced035f013..ff8a44f6d247 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -13,6 +13,7 @@ const get_defaults = (prefix = '') => ({ kit: { adapter: null, amp: false, + alternateRoutes: null, appDir: '_app', browser: { hydrate: true, diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 29df696d0395..f65246c43268 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -41,6 +41,14 @@ const options = object( amp: boolean(false), + alternateRoutes: validate(null, (option, keypath) => { + if (typeof option !== 'function') { + throw new Error(`${keypath} must be a function that processes route segments`); + } + + return option; + }), + appDir: validate('_app', (input, keypath) => { assert_string(input, keypath); diff --git a/packages/kit/src/core/create_app/index.js b/packages/kit/src/core/create_app/index.js index 6dd144706183..6bdc4c3ed2d2 100644 --- a/packages/kit/src/core/create_app/index.js +++ b/packages/kit/src/core/create_app/index.js @@ -83,7 +83,8 @@ function generate_client_manifest(manifest_data, base) { '})'; const tuple = [route.pattern, get_indices(route.a), get_indices(route.b)]; - if (params) tuple.push(params); + tuple.push(params || ''); + tuple.push(route.id); return `// ${route.a[route.a.length - 1]}\n\t\t[${tuple.join(', ')}]`; } @@ -149,6 +150,7 @@ function generate_app(manifest_data) { // stores export let stores; export let page; + export let routes; export let components; ${levels.map((l) => `export let props_${l} = null;`).join('\n\t\t\t')} @@ -158,6 +160,8 @@ function generate_app(manifest_data) { $: stores.page.set(page); afterUpdate(stores.page.notify); + if (routes) setContext('__svelte_routes__', routes); + let mounted = false; let navigated = false; let title = null; diff --git a/packages/kit/src/core/create_manifest_data/index.js b/packages/kit/src/core/create_manifest_data/index.js index 273617842dd3..222621585a92 100644 --- a/packages/kit/src/core/create_manifest_data/index.js +++ b/packages/kit/src/core/create_manifest_data/index.js @@ -193,50 +193,70 @@ export default function create_manifest_data({ layout_reset ? [error] : error_stack.concat(error) ); } else if (item.is_page) { + const id = components.length.toString(); + const alternates = config.kit.alternateRoutes + ? config.kit.alternateRoutes(segments, 'page') + : [segments]; + components.push(item.file); const concatenated = layout_stack.concat(item.file); const errors = error_stack.slice(); - const pattern = get_pattern(segments, true); - - let i = concatenated.length; - while (i--) { - if (!errors[i] && !concatenated[i]) { - errors.splice(i, 1); - concatenated.splice(i, 1); + alternates.forEach((segments) => { + const pattern = get_pattern(segments, true); + const params = segments.flatMap((parts) => + parts.filter((p) => p.dynamic).map((p) => p.content) + ); + + let i = concatenated.length; + while (i--) { + if (!errors[i] && !concatenated[i]) { + errors.splice(i, 1); + concatenated.splice(i, 1); + } } - } - i = errors.length; - while (i--) { - if (errors[i]) break; - } - - errors.splice(i + 1); - - const path = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) - ? `/${segments.map((segment) => segment[0].content).join('/')}` - : ''; + i = errors.length; + while (i--) { + if (errors[i]) break; + } - routes.push({ - type: 'page', - segments: simple_segments, - pattern, - params, - path, - a: /** @type {string[]} */ (concatenated), - b: /** @type {string[]} */ (errors) + errors.splice(i + 1); + + const path = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) + ? `/${segments.map((segment) => segment[0].content).join('/')}` + : ''; + + routes.push({ + id, + type: 'page', + segments: simple_segments, + pattern, + params, + path, + a: /** @type {string[]} */ (concatenated), + b: /** @type {string[]} */ (errors) + }); }); } else { - const pattern = get_pattern(segments, !item.route_suffix); - - routes.push({ - type: 'endpoint', - segments: simple_segments, - pattern, - file: item.file, - params + const alternates = config.kit.alternateRoutes + ? config.kit.alternateRoutes(segments, 'endpoint') + : [segments]; + + alternates.forEach((segments) => { + const pattern = get_pattern(segments, !item.route_suffix); + const params = segments.flatMap((parts) => + parts.filter((p) => p.dynamic).map((p) => p.content) + ); + + routes.push({ + type: 'endpoint', + segments: simple_segments, + pattern, + file: item.file, + params + }); }); } }); diff --git a/packages/kit/src/core/create_manifest_data/index.spec.js b/packages/kit/src/core/create_manifest_data/index.spec.js index dc1a81cc399a..971b7ff27db4 100644 --- a/packages/kit/src/core/create_manifest_data/index.spec.js +++ b/packages/kit/src/core/create_manifest_data/index.spec.js @@ -40,6 +40,7 @@ test('creates routes', () => { assert.equal(routes, [ { + id: '2', type: 'page', segments: [], pattern: /^\/$/, @@ -50,6 +51,7 @@ test('creates routes', () => { }, { + id: '3', type: 'page', segments: [{ rest: false, dynamic: false, content: 'about' }], pattern: /^\/about\/?$/, @@ -68,6 +70,7 @@ test('creates routes', () => { }, { + id: '4', type: 'page', segments: [{ rest: false, dynamic: false, content: 'blog' }], pattern: /^\/blog\/?$/, @@ -89,6 +92,7 @@ test('creates routes', () => { }, { + id: '5', type: 'page', segments: [ { rest: false, dynamic: false, content: 'blog' }, @@ -116,6 +120,7 @@ test('creates routes with layout', () => { assert.equal(routes, [ { + id: '2', type: 'page', segments: [], pattern: /^\/$/, @@ -126,6 +131,7 @@ test('creates routes with layout', () => { }, { + id: '4', type: 'page', segments: [{ rest: false, dynamic: false, content: 'foo' }], pattern: /^\/foo\/?$/, @@ -210,6 +216,7 @@ test('disallows rest parameters inside segments', () => { assert.equal(routes, [ { + id: '2', type: 'page', segments: [ { @@ -384,6 +391,7 @@ test('works with custom extensions', () => { assert.equal(routes, [ { + id: '2', type: 'page', segments: [], pattern: /^\/$/, @@ -394,6 +402,7 @@ test('works with custom extensions', () => { }, { + id: '3', type: 'page', segments: [{ rest: false, dynamic: false, content: 'about' }], pattern: /^\/about\/?$/, @@ -412,6 +421,7 @@ test('works with custom extensions', () => { }, { + id: '4', type: 'page', segments: [{ rest: false, dynamic: false, content: 'blog' }], pattern: /^\/blog\/?$/, @@ -433,6 +443,7 @@ test('works with custom extensions', () => { }, { + id: '5', type: 'page', segments: [ { rest: false, dynamic: false, content: 'blog' }, @@ -469,6 +480,7 @@ test('includes nested error components', () => { assert.equal(routes, [ { + id: '6', type: 'page', segments: [ { rest: false, dynamic: false, content: 'foo' }, @@ -500,6 +512,7 @@ test('resets layout', () => { assert.equal(routes, [ { + id: '2', type: 'page', segments: [], pattern: /^\/$/, @@ -509,6 +522,7 @@ test('resets layout', () => { b: [error] }, { + id: '4', type: 'page', segments: [{ rest: false, dynamic: false, content: 'foo' }], pattern: /^\/foo\/?$/, @@ -522,6 +536,7 @@ test('resets layout', () => { b: [error] }, { + id: '7', type: 'page', segments: [ { rest: false, dynamic: false, content: 'foo' }, diff --git a/packages/kit/src/core/dev/plugin.js b/packages/kit/src/core/dev/plugin.js index b05b052e619a..59cc8347b1a5 100644 --- a/packages/kit/src/core/dev/plugin.js +++ b/packages/kit/src/core/dev/plugin.js @@ -101,6 +101,7 @@ export async function create_plugin(config, cwd) { routes: manifest_data.routes.map((route) => { if (route.type === 'page') { return { + id: route.id, type: 'page', pattern: route.pattern, params: get_params(route.params), diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index c98b8f8065a3..884a23d0c268 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -66,6 +66,7 @@ export function generate_manifest( ${routes.map(route => { if (route.type === 'page') { return `{ + id: ${s(route.id)}, type: 'page', pattern: ${route.pattern}, params: ${get_params(route.params)}, diff --git a/packages/kit/src/runtime/app/navigation.js b/packages/kit/src/runtime/app/navigation.js index 52ee7d4397b3..25d4efa57cd6 100644 --- a/packages/kit/src/runtime/app/navigation.js +++ b/packages/kit/src/runtime/app/navigation.js @@ -1,5 +1,6 @@ import { router, renderer } from '../client/singletons.js'; import { get_base_uri } from '../client/utils.js'; +import { getContext } from 'svelte'; /** * @param {string} name @@ -75,3 +76,48 @@ function beforeNavigate_(fn) { function afterNavigate_(fn) { if (router) router.after_navigate(fn); } + +/** + * @param {RegExp} pattern + * @param {string[]} params + * @returns {string} + */ +function pathFromPattern(pattern, params) { + let index = 0; + return pattern.source + .slice(1, -1) + .replace(/\\\//g, '/') + .replace(/\(\[\^\/\]\+\?\)/g, () => params[index++]) + .replace(/\/\?$/, ''); +} + +/** + * @param {any} value + * @return {value is import('types/internal').SSRPage} + */ +function isSSRPage(value) { + return typeof value === 'object' && value.type === 'page'; +} + +/** + * @type {import('$app/navigation').alternates} + */ +export function alternates(href) { + if (!import.meta.env.SSR && router) { + const hrefRoute = router.routes?.find((route) => route && route[0].test(href)); + if (!hrefRoute) return []; + const match = href.match(hrefRoute[0]); + const params = match ? match.slice(1) : []; + const alternates = router.routes.filter((route) => route && route[4] === hrefRoute[4]); + return alternates.map((route) => pathFromPattern(route[0], params)); + } else { + /** @type {import('types/internal').SSRRoute[]} */ + const routes = getContext('__svelte_routes__'); + const hrefRoute = routes.find((route) => route.pattern.test(href)); + if (!hrefRoute || !isSSRPage(hrefRoute)) return []; + const match = href.match(hrefRoute.pattern); + const params = match ? match.slice(1) : []; + const alternates = routes.filter((route) => isSSRPage(route) && route.id === hrefRoute.id); + return alternates.map((route) => pathFromPattern(route.pattern, params)); + } +} diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index bacafb1a0713..aa0b9bcfce86 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -94,7 +94,8 @@ export async function render_response({ error, stuff }, - components: branch.map(({ node }) => node.module.default) + components: branch.map(({ node }) => node.module.default), + routes: options.manifest._.routes }; // TODO remove this for 1.0 diff --git a/packages/kit/types/ambient-modules.d.ts b/packages/kit/types/ambient-modules.d.ts index 083519b0bd24..f4efe776fa3f 100644 --- a/packages/kit/types/ambient-modules.d.ts +++ b/packages/kit/types/ambient-modules.d.ts @@ -85,6 +85,11 @@ declare module '$app/navigation' { * A lifecycle function that runs when the page mounts, and also whenever SvelteKit navigates to a new URL but stays on this component. */ export function afterNavigate(fn: ({ from, to }: { from: URL | null; to: URL }) => void): any; + + /** + * Returns alternate routes for the given page + */ + export function alternates(href: string): string[]; } declare module '$app/paths' { diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts index ecba3176e8ac..c5223d0345d9 100644 --- a/packages/kit/types/config.d.ts +++ b/packages/kit/types/config.d.ts @@ -3,6 +3,7 @@ import { UserConfig as ViteConfig } from 'vite'; import { CspDirectives } from './csp'; import { RecursiveRequired } from './helper'; import { HttpMethod, Logger, RouteSegment, TrailingSlash } from './internal'; +import { Part } from '../src/core/create_manifest_data'; export interface RouteDefinition { type: 'page' | 'endpoint'; @@ -117,6 +118,7 @@ export interface Config { kit?: { adapter?: Adapter; amp?: boolean; + alternateRoutes?: (segments: Part[][], type: 'page' | 'endpoint') => Part[][][]; appDir?: string; browser?: { hydrate?: boolean; diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 294ed943db1d..5852e1ab3c1a 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -71,6 +71,7 @@ export interface SSRPagePart { export type GetParams = (match: RegExpExecArray) => Record; export interface SSRPage { + id: string; type: 'page'; pattern: RegExp; params: GetParams; @@ -96,7 +97,7 @@ export interface SSREndpoint { export type SSRRoute = SSREndpoint | SSRPage; -export type CSRRoute = [RegExp, CSRComponentLoader[], CSRComponentLoader[], GetParams?]; +export type CSRRoute = [RegExp, CSRComponentLoader[], CSRComponentLoader[], GetParams?, string?]; export type SSRNodeLoader = () => Promise; @@ -179,6 +180,7 @@ export interface RouteSegment { export type HttpMethod = 'get' | 'head' | 'post' | 'put' | 'delete' | 'patch'; export interface PageData { + id: string; type: 'page'; segments: RouteSegment[]; pattern: RegExp;