diff --git a/.changeset/pre.json b/.changeset/pre.json index 2e7e2c10cbcc..9d42036cc02a 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -27,6 +27,7 @@ "beige-bananas-press", "beige-grapes-love", "beige-teachers-end", + "big-garlics-complain", "blue-apes-type", "blue-cats-mate", "blue-poets-jam", @@ -48,6 +49,7 @@ "chatty-steaks-yell", "chilled-books-dress", "chilled-starfishes-jog", + "chilled-zebras-check", "chilly-files-greet", "chilly-flowers-design", "clever-dolls-poke", @@ -58,6 +60,7 @@ "cold-llamas-brake", "cold-panthers-learn", "cold-pants-unite", + "cool-hounds-divide", "cool-spoons-dress", "curvy-sloths-brake", "cyan-roses-act", @@ -76,6 +79,7 @@ "few-rockets-think", "few-singers-pump", "fifty-turtles-joke", + "five-shirts-allow", "flat-cameras-build", "flat-ducks-impress", "flat-parrots-juggle", @@ -131,6 +135,7 @@ "light-kangaroos-tie", "light-keys-mix", "light-roses-teach", + "little-shirts-happen", "long-bulldogs-invent", "long-moles-fold", "loud-seals-remember", @@ -139,6 +144,7 @@ "mean-bananas-live", "metal-nails-divide", "metal-tigers-kiss", + "mighty-carrots-switch", "mighty-garlics-pretend", "mighty-pumpkins-stare", "modern-boats-lie", diff --git a/packages/adapter-begin/CHANGELOG.md b/packages/adapter-begin/CHANGELOG.md index 1ebeefe18d35..3ffa9db95a48 100644 --- a/packages/adapter-begin/CHANGELOG.md +++ b/packages/adapter-begin/CHANGELOG.md @@ -1,5 +1,11 @@ # @sveltejs/adapter-begin +## 1.0.0-next.9 + +### Patch Changes + +- 1ba1784: Prevent adapter from splitting query params if they contain commas + ## 1.0.0-next.8 ### Patch Changes diff --git a/packages/adapter-begin/package.json b/packages/adapter-begin/package.json index 60796ae027f9..4069d5b6c6da 100644 --- a/packages/adapter-begin/package.json +++ b/packages/adapter-begin/package.json @@ -1,6 +1,6 @@ { "name": "@sveltejs/adapter-begin", - "version": "1.0.0-next.8", + "version": "1.0.0-next.9", "main": "index.cjs", "types": "index.d.ts", "scripts": { diff --git a/packages/adapter-netlify/CHANGELOG.md b/packages/adapter-netlify/CHANGELOG.md index cbca26484df6..dc595cc10f96 100644 --- a/packages/adapter-netlify/CHANGELOG.md +++ b/packages/adapter-netlify/CHANGELOG.md @@ -1,5 +1,16 @@ # @sveltejs/adapter-netlify +## 1.0.0-next.14 + +### Patch Changes + +- f59530f: Allow custom redirects for Netlify Adapter +- 1ba1784: Prevent adapter from splitting query params if they contain commas +- Updated dependencies [261ee1c] +- Updated dependencies [ec156c6] +- Updated dependencies [586785d] + - @sveltejs/kit@1.0.0-next.109 + ## 1.0.0-next.13 ### Patch Changes diff --git a/packages/adapter-netlify/package.json b/packages/adapter-netlify/package.json index 7d2a90c4480a..198ef8b53f43 100644 --- a/packages/adapter-netlify/package.json +++ b/packages/adapter-netlify/package.json @@ -1,6 +1,6 @@ { "name": "@sveltejs/adapter-netlify", - "version": "1.0.0-next.13", + "version": "1.0.0-next.14", "type": "module", "exports": { "import": "./index.js" 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..177f39f81c72 --- /dev/null +++ b/packages/create-svelte/templates/default/i18n.config.js @@ -0,0 +1,37 @@ +import locales from './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/locales.js b/packages/create-svelte/templates/default/locales.js new file mode 100644 index 000000000000..0c7c8f3ed840 --- /dev/null +++ b/packages/create-svelte/templates/default/locales.js @@ -0,0 +1 @@ +export default ['en', 'de']; diff --git a/packages/create-svelte/templates/default/src/lib/Header/index.svelte b/packages/create-svelte/templates/default/src/lib/Header/index.svelte index bab8050a0cb1..a8060c379fad 100644 --- a/packages/create-svelte/templates/default/src/lib/Header/index.svelte +++ b/packages/create-svelte/templates/default/src/lib/Header/index.svelte @@ -1,8 +1,21 @@ + + {#if defaultPath} + + {/if} + {#each Object.entries(alternatePaths) as [locale, path]} + + {/each} + +
@@ -15,9 +28,13 @@
+
@@ -36,7 +58,7 @@ } .corner { - width: 3em; + display: flex; height: 3em; } @@ -46,6 +68,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..37ce3b88bb62 --- /dev/null +++ b/packages/create-svelte/templates/default/src/lib/i18n.ts @@ -0,0 +1,26 @@ +import { derived } 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.path.match(/^\/([a-z]{2})(\/|$)/)?.[1] || defaultLocale +); + +export const localizedPaths = derived(page, (page) => (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/svelte.config.js b/packages/create-svelte/templates/default/svelte.config.js index 3315cb538d64..a84d610428d0 100644 --- a/packages/create-svelte/templates/default/svelte.config.js +++ b/packages/create-svelte/templates/default/svelte.config.js @@ -1,4 +1,5 @@ import preprocess from 'svelte-preprocess'; +import { localizeRoutes } from './i18n.config.js'; const adapter = process.env.ADAPTER; const options = JSON.parse(process.env.OPTIONS || '{}'); @@ -11,7 +12,9 @@ const config = { kit: { // hydrate the
element in src/app.html - target: '#svelte' + target: '#svelte', + + alternateRoutes: localizeRoutes } }; diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md index 80a38033597b..6b788bbd91f8 100644 --- a/packages/kit/CHANGELOG.md +++ b/packages/kit/CHANGELOG.md @@ -1,5 +1,13 @@ # @sveltejs/kit +## 1.0.0-next.109 + +### Patch Changes + +- 261ee1c: Update compatible Node versions +- ec156c6: let hash only changes be handled by router +- 586785d: Allow passing HTTPS key pair in Vite section of config + ## 1.0.0-next.108 ### Patch Changes diff --git a/packages/kit/package.json b/packages/kit/package.json index 063492884754..a2d132977f90 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -1,6 +1,6 @@ { "name": "@sveltejs/kit", - "version": "1.0.0-next.108", + "version": "1.0.0-next.109", "type": "module", "dependencies": { "@sveltejs/vite-plugin-svelte": "^1.0.0-next.10", diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index 3137728009e7..3963776f08ef 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -337,6 +337,7 @@ async function build_server( const params = get_params(route.params); return `{ + id: ${s(route.id)}, type: 'page', pattern: ${route.pattern}, params: ${params}, diff --git a/packages/kit/src/core/create_app/index.js b/packages/kit/src/core/create_app/index.js index 299ea247d056..ccb57510d720 100644 --- a/packages/kit/src/core/create_app/index.js +++ b/packages/kit/src/core/create_app/index.js @@ -86,7 +86,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(', ')}]`; } else { @@ -149,6 +150,7 @@ function generate_app(manifest_data, base) { // 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, base) { $: 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 e03d2223e58a..357295798bb0 100644 --- a/packages/kit/src/core/create_manifest_data/index.js +++ b/packages/kit/src/core/create_manifest_data/index.js @@ -52,11 +52,10 @@ export default function create_manifest_data({ config, output, cwd = process.cwd /** * @param {string} dir * @param {Part[][]} parent_segments - * @param {string[]} parent_params * @param {string[]} layout_stack // accumulated __layout.svelte components * @param {string[]} error_stack // accumulated __error.svelte components */ - function walk(dir, parent_segments, parent_params, layout_stack, error_stack) { + function walk(dir, parent_segments, layout_stack, error_stack) { /** @type {Item[]} */ const items = fs .readdirSync(dir) @@ -157,9 +156,6 @@ export default function create_manifest_data({ config, output, cwd = process.cwd segments.push(item.parts); } - const params = parent_params.slice(); - params.push(...item.parts.filter((p) => p.dynamic).map((p) => p.content)); - if (item.is_dir) { const layout_reset = find_layout('__layout.reset', item.file); const layout = find_layout('__layout', item.file); @@ -176,54 +172,73 @@ export default function create_manifest_data({ config, output, cwd = process.cwd walk( path.join(dir, item.basename), segments, - params, layout_reset ? [layout_reset] : layout_stack.concat(layout), layout_reset ? [error] : error_stack.concat(error) ); - } else if (item.is_page) { - components.push(item.file); - - const a = layout_stack.concat(item.file); - const b = error_stack; - - const pattern = get_pattern(segments, true); + } else { + const alternates = config.kit.alternateRoutes + ? config.kit.alternateRoutes(segments, item.is_page ? 'page' : 'endpoint') + : [segments]; + + if (item.is_page) { + const id = components.length.toString(); + components.push(item.file); + + const a = layout_stack.concat(item.file); + const b = error_stack; + + 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 = 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); + + const path = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) + ? `/${segments.map((segment) => segment[0].content).join('/')}${ + config.kit.trailingSlash === 'always' && segments.length > 0 ? '/' : '' + }` + : null; + + routes.push({ + id, + type: 'page', + pattern, + params, + path, + a, + b + }); + }); + } else { + 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', + pattern, + file: item.file, + params + }); + }); } - - b.splice(i + 1); - - 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 - }); - } else { - const pattern = get_pattern(segments, !item.route_suffix); - - routes.push({ - type: 'endpoint', - pattern, - file: item.file, - params - }); } }); } @@ -235,7 +250,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]); const assets_dir = config.kit.files.assets; 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 2b543a4613e8..9843529cd7ef 100644 --- a/packages/kit/src/core/create_manifest_data/index.spec.js +++ b/packages/kit/src/core/create_manifest_data/index.spec.js @@ -44,6 +44,7 @@ test('creates routes', () => { assert.equal(routes, [ { + id: '2', type: 'page', pattern: /^\/$/, params: [], @@ -53,6 +54,7 @@ test('creates routes', () => { }, { + id: '3', type: 'page', pattern: /^\/about\/?$/, params: [], @@ -69,6 +71,7 @@ test('creates routes', () => { }, { + id: '4', type: 'page', pattern: /^\/blog\/?$/, params: [], @@ -85,6 +88,7 @@ test('creates routes', () => { }, { + id: '5', type: 'page', pattern: /^\/blog\/([^/]+?)\/?$/, params: ['slug'], @@ -108,6 +112,7 @@ test('creates routes with layout', () => { assert.equal(routes, [ { + id: '2', type: 'page', pattern: /^\/$/, params: [], @@ -117,6 +122,7 @@ test('creates routes with layout', () => { }, { + id: '4', type: 'page', pattern: /^\/foo\/?$/, params: [], @@ -263,6 +269,7 @@ test('works with custom extensions', () => { assert.equal(routes, [ { + id: '2', type: 'page', pattern: /^\/$/, params: [], @@ -272,6 +279,7 @@ test('works with custom extensions', () => { }, { + id: '3', type: 'page', pattern: /^\/about\/?$/, params: [], @@ -288,6 +296,7 @@ test('works with custom extensions', () => { }, { + id: '4', type: 'page', pattern: /^\/blog\/?$/, params: [], @@ -304,6 +313,7 @@ test('works with custom extensions', () => { }, { + id: '5', type: 'page', pattern: /^\/blog\/([^/]+?)\/?$/, params: ['slug'], @@ -336,6 +346,7 @@ test('includes nested error components', () => { assert.equal(routes, [ { + id: '6', type: 'page', pattern: /^\/foo\/bar\/baz\/?$/, params: [], @@ -362,6 +373,7 @@ test('resets layout', () => { assert.equal(routes, [ { + id: '2', type: 'page', pattern: /^\/$/, params: [], @@ -370,6 +382,7 @@ test('resets layout', () => { b: [error] }, { + id: '4', type: 'page', pattern: /^\/foo\/?$/, params: [], @@ -382,6 +395,7 @@ test('resets layout', () => { b: [error] }, { + id: '7', type: 'page', pattern: /^\/foo\/bar\/?$/, params: [], diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index 349673bfe358..85b3987e803b 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -334,6 +334,7 @@ class Watcher extends EventEmitter { 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/load_config/index.spec.js b/packages/kit/src/core/load_config/index.spec.js index 4678a7300126..285373d3141d 100644 --- a/packages/kit/src/core/load_config/index.spec.js +++ b/packages/kit/src/core/load_config/index.spec.js @@ -12,6 +12,7 @@ test('fills in defaults', () => { extensions: ['.svelte'], kit: { adapter: null, + alternateRoutes: null, amp: false, appDir: '_app', files: { @@ -96,6 +97,7 @@ test('fills in partial blanks', () => { extensions: ['.svelte'], kit: { adapter: null, + alternateRoutes: null, amp: false, appDir: '_app', files: { diff --git a/packages/kit/src/core/load_config/options.js b/packages/kit/src/core/load_config/options.js index 8c63e9748885..18af95f36710 100644 --- a/packages/kit/src/core/load_config/options.js +++ b/packages/kit/src/core/load_config/options.js @@ -54,6 +54,18 @@ const options = { } }, + alternateRoutes: { + type: 'leaf', + default: null, + validate: (option, keypath) => { + if (typeof option !== 'function') { + throw new Error(`${keypath} must be a function that processes route segments`); + } + + return option; + } + }, + amp: expect_boolean(false), appDir: expect_string('_app', false), diff --git a/packages/kit/src/core/load_config/test/index.js b/packages/kit/src/core/load_config/test/index.js index c86f90e55307..1a9b00e249e8 100644 --- a/packages/kit/src/core/load_config/test/index.js +++ b/packages/kit/src/core/load_config/test/index.js @@ -22,6 +22,7 @@ async function testLoadDefaultConfig(path) { extensions: ['.svelte'], kit: { adapter: null, + alternateRoutes: null, amp: false, appDir: '_app', files: { diff --git a/packages/kit/src/runtime/app/navigation.js b/packages/kit/src/runtime/app/navigation.js index b8f2266bdac5..a8ae40686363 100644 --- a/packages/kit/src/runtime/app/navigation.js +++ b/packages/kit/src/runtime/app/navigation.js @@ -1,5 +1,6 @@ import { router } from '../client/singletons.js'; import { get_base_uri } from '../client/utils.js'; +import { getContext } from 'svelte'; /** * @param {string} name @@ -49,3 +50,46 @@ async function prefetchRoutes_(pathnames) { await Promise.all(promises); } + +/** + * @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) { + const hrefRoute = router?.routes?.find((route) => route[0].test(href)); + if (!hrefRoute) return null; + const [, ...params] = href.match(hrefRoute[0]); + const alternates = router.routes.filter((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 null; + const [, ...params] = href.match(hrefRoute.pattern); + 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 e6538758ba6c..85218a4ffc50 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -66,7 +66,8 @@ export async function render_response({ session }, page, - components: branch.map(({ node }) => node.module.default) + components: branch.map(({ node }) => node.module.default), + routes: options.manifest.routes }; // props_n (instead of props[n]) makes it easy to avoid diff --git a/packages/kit/types/ambient-modules.d.ts b/packages/kit/types/ambient-modules.d.ts index 9e52c1f250bf..a9883aed8232 100644 --- a/packages/kit/types/ambient-modules.d.ts +++ b/packages/kit/types/ambient-modules.d.ts @@ -50,6 +50,10 @@ declare module '$app/navigation' { * Returns a Promise that resolves when the routes have been prefetched. */ export function prefetchRoutes(routes?: string[]): Promise; + /** + * 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 cd3d7e54ade4..a2ecafff8898 100644 --- a/packages/kit/types/config.d.ts +++ b/packages/kit/types/config.d.ts @@ -1,5 +1,6 @@ import { Logger, TrailingSlash } from './internal'; import { UserConfig as ViteConfig } from 'vite'; +import { Part } from '../src/core/create_manifest_data'; export type AdapterUtils = { log: Logger; @@ -30,6 +31,7 @@ export type Config = { extensions?: string[]; kit?: { adapter?: Adapter; + alternateRoutes?: (segments: Part[][], type: 'page' | 'endpoint') => Part[][][]; amp?: boolean; appDir?: string; files?: { @@ -68,6 +70,7 @@ export type ValidatedConfig = { extensions: string[]; kit: { adapter: Adapter; + alternateRoutes: (segments: Part[][], type: 'page' | 'endpoint') => Part[][][]; amp: boolean; appDir: string; files: { diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 6e8189f1c9b7..f7f5f42bc9fb 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -73,6 +73,7 @@ export type SSRPagePart = { export type GetParams = (match: RegExpExecArray) => Record; export type SSRPage = { + id: string; type: 'page'; pattern: RegExp; params: GetParams; @@ -95,7 +96,7 @@ export type SSREndpoint = { export type SSRRoute = SSREndpoint | SSRPage; -export type CSRPage = [RegExp, CSRComponentLoader[], CSRComponentLoader[], GetParams?]; +export type CSRPage = [RegExp, CSRComponentLoader[], CSRComponentLoader[], GetParams?, string?]; export type CSREndpoint = [RegExp]; @@ -168,6 +169,7 @@ export type Asset = { }; export type PageData = { + id: string; type: 'page'; pattern: RegExp; params: string[];