diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 6c1afb0140ef..7c589dff643a 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -39,7 +39,11 @@ export function create_builder({ config, build_data, prerendered, log }) { /** @type {import('types').RouteDefinition[]} */ const facades = routes.map((route) => ({ type: route.type, - segments: route.segments, + segments: route.id.split('/').map((segment) => ({ + dynamic: segment.includes('['), + rest: segment.includes('[...'), + content: segment + })), pattern: route.pattern, methods: route.type === 'page' ? ['get'] : build_data.server.methods[route.file] })); @@ -68,22 +72,7 @@ export function create_builder({ config, build_data, prerendered, log }) { // also be included, since the page likely needs the endpoint filtered.forEach((route) => { if (route.type === 'page') { - const length = route.segments.length; - - const endpoint = routes.find((candidate) => { - if (candidate.segments.length !== length) return false; - - for (let i = 0; i < length; i += 1) { - const a = route.segments[i]; - const b = candidate.segments[i]; - - if (i === length - 1) { - return b.content === `${a.content}.json`; - } - - if (a.content !== b.content) return false; - } - }); + const endpoint = routes.find((candidate) => candidate.id === route.id + '.json'); if (endpoint) { filtered.add(endpoint); diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js index 64a34b6c9067..c7d1d6ffcd64 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -3,6 +3,7 @@ import path from 'path'; import mime from 'mime'; import { get_runtime_path } from '../../utils.js'; import { posixify } from '../../../utils/filesystem.js'; +import { parse_route_id } from '../../../utils/routing.js'; /** * A portion of a file or directory name where the name has been split into @@ -14,9 +15,7 @@ import { posixify } from '../../../utils/filesystem.js'; * type: string | null; * }} Part * @typedef {{ - * basename: string; * name: string; - * ext: string; * parts: Part[], * file: string; * is_dir: boolean; @@ -62,14 +61,13 @@ export default function create_manifest_data({ /** * @param {string} dir * @param {string[]} parent_id - * @param {Part[][]} parent_segments - * @param {string[]} parent_params * @param {Array} layout_stack // accumulated __layout.svelte components * @param {Array} error_stack // accumulated __error.svelte components */ - function walk(dir, parent_id, parent_segments, parent_params, layout_stack, error_stack) { + function walk(dir, parent_id, layout_stack, error_stack) { /** @type {Item[]} */ - let items = []; + const items = []; + fs.readdirSync(dir).forEach((basename) => { const resolved = path.join(dir, basename); const file = posixify(path.relative(cwd, resolved)); @@ -82,110 +80,38 @@ export default function create_manifest_data({ if (ext === undefined) return; - const name = ext ? basename.slice(0, -ext.length) : basename; - - // TODO remove this after a while - ['layout', 'layout.reset', 'error'].forEach((reserved) => { - if (name === `$${reserved}`) { - const prefix = posixify(path.relative(cwd, dir)); - const bad = `${prefix}/$${reserved}${ext}`; - const good = `${prefix}/__${reserved}${ext}`; - - throw new Error(`${bad} should be renamed ${good}`); - } - }); + const name = basename.slice(0, basename.length - ext.length); - if (basename.startsWith('__') && !specials.has(name)) { + if (name.startsWith('__') && !specials.has(name)) { throw new Error(`Files and directories prefixed with __ are reserved (saw ${file})`); } - if (!is_dir && !/^(\.[a-z0-9]+)+$/i.test(ext)) return null; // filter out tmp files etc - - if (!config.kit.routes(file)) { - return; - } - - const segment = is_dir ? basename : name; - - if (/\]\[/.test(segment)) { - throw new Error(`Invalid route ${file} — parameters must be separated`); - } - - if (count_occurrences('[', segment) !== count_occurrences(']', segment)) { - throw new Error(`Invalid route ${file} — brackets are unbalanced`); - } - - const parts = get_parts(segment, file); - const is_index = is_dir ? false : basename.startsWith('index.'); - const is_page = config.extensions.indexOf(ext) !== -1; - const route_suffix = basename.slice(basename.indexOf('.'), -ext.length); + if (!config.kit.routes(file)) return; items.push({ - basename, - name, - ext, - parts, file, + name, + parts: get_parts(name, file), + route_suffix: basename.slice(basename.indexOf('.'), -ext.length), is_dir, - is_index, - is_page, - route_suffix + is_index: !is_dir && basename.startsWith('index.'), + is_page: config.extensions.includes(ext) }); }); - items = items.sort(comparator); + + items.sort(comparator); items.forEach((item) => { - const id = parent_id.slice(); - const segments = parent_segments.slice(); + const id_parts = parent_id.slice(); if (item.is_index) { - if (item.route_suffix) { - if (segments.length > 0) { - const last_segment = segments[segments.length - 1].slice(); - const last_part = last_segment[last_segment.length - 1]; - - if (last_part.dynamic) { - last_segment.push({ - dynamic: false, - rest: false, - content: item.route_suffix, - type: null - }); - } else { - last_segment[last_segment.length - 1] = { - dynamic: false, - rest: false, - content: `${last_part.content}${item.route_suffix}`, - type: null - }; - } - - segments[segments.length - 1] = last_segment; - id[id.length - 1] += item.route_suffix; - } else { - segments.push(item.parts); - } + if (item.route_suffix && id_parts.length > 0) { + id_parts[id_parts.length - 1] += item.route_suffix; } } else { - id.push(item.name); - segments.push(item.parts); + id_parts.push(item.name); } - const params = parent_params.slice(); - params.push(...item.parts.filter((p) => p.dynamic).map((p) => p.content)); - - // TODO seems slightly backwards to derive the simple segment representation - // from the more complex form, rather than vice versa — maybe swap it round - const simple_segments = segments.map((segment) => { - return { - dynamic: segment.some((part) => part.dynamic), - rest: segment.some((part) => part.rest), - content: segment - .map((part) => (part.dynamic ? `[${part.content}]` : part.content)) - .join('') - }; - }); - if (item.is_dir) { const layout_reset = find_layout('__layout.reset', item.file); const layout = find_layout('__layout', item.file); @@ -200,62 +126,55 @@ export default function create_manifest_data({ if (error) components.push(error); walk( - path.join(dir, item.basename), - id, - segments, - params, + path.join(dir, item.name), + id_parts, layout_reset ? [layout_reset] : layout_stack.concat(layout), layout_reset ? [error] : error_stack.concat(error) ); - } else if (item.is_page) { - components.push(item.file); + } else { + const id = id_parts.join('/'); + const { pattern } = parse_route_id(id); - const concatenated = layout_stack.concat(item.file); - const errors = error_stack.slice(); + if (item.is_page) { + components.push(item.file); - const pattern = get_pattern(segments, true); + const concatenated = layout_stack.concat(item.file); + const errors = error_stack.slice(); - let i = concatenated.length; - while (i--) { - if (!errors[i] && !concatenated[i]) { - errors.splice(i, 1); - concatenated.splice(i, 1); + 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; - } + 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('/')}` - : ''; - - routes.push({ - type: 'page', - id: id.join('/'), - segments: simple_segments, - pattern, - params, - path, - shadow: null, - a: /** @type {string[]} */ (concatenated), - b: /** @type {string[]} */ (errors) - }); - } else { - const pattern = get_pattern(segments, !item.route_suffix); - - routes.push({ - type: 'endpoint', - id: id.join('/'), - segments: simple_segments, - pattern, - file: item.file, - params - }); + errors.splice(i + 1); + + const path = id.includes('[') ? '' : `/${id}`; + + routes.push({ + type: 'page', + id, + pattern, + path, + shadow: null, + a: /** @type {string[]} */ (concatenated), + b: /** @type {string[]} */ (errors) + }); + } else { + routes.push({ + type: 'endpoint', + id, + pattern, + file: item.file + }); + } } }); } @@ -267,7 +186,7 @@ export default function create_manifest_data({ components.push(layout, error); - walk(config.kit.files.routes, [], [], [], [layout], [error]); + walk(config.kit.files.routes, [], [layout], [error]); const lookup = new Map(); for (const route of routes) { @@ -330,11 +249,7 @@ function count_occurrences(needle, haystack) { return count; } -/** @param {string} path */ -function is_spread(path) { - const spread_pattern = /\[\.{3}/g; - return spread_pattern.test(path); -} +const spread_pattern = /\[\.{3}/; /** * @param {Item} a @@ -342,9 +257,8 @@ function is_spread(path) { */ function comparator(a, b) { if (a.is_index !== b.is_index) { - if (a.is_index) return is_spread(a.file) ? 1 : -1; - - return is_spread(b.file) ? -1 : 1; + if (a.is_index) return spread_pattern.test(a.file) ? 1 : -1; + return spread_pattern.test(b.file) ? -1 : 1; } const max = Math.max(a.parts.length, b.parts.length); @@ -396,6 +310,14 @@ function comparator(a, b) { * @param {string} file */ function get_parts(part, file) { + if (/\]\[/.test(part)) { + throw new Error(`Invalid route ${file} — parameters must be separated`); + } + + if (count_occurrences('[', part) !== count_occurrences(']', part)) { + throw new Error(`Invalid route ${file} — brackets are unbalanced`); + } + /** @type {Part[]} */ const result = []; part.split(/\[(.+?\(.+?\)|.+?)\]/).map((str, i) => { @@ -427,52 +349,6 @@ function get_parts(part, file) { return result; } -/** - * @param {Part[][]} segments - * @param {boolean} add_trailing_slash - */ -function get_pattern(segments, add_trailing_slash) { - const path = segments - .map((segment) => { - if (segment.length === 1 && segment[0].rest) { - // special case — `src/routes/foo/[...bar]/baz` matches `/foo/baz` - // so we need to make the leading slash optional - return '(?:\\/(.*))?'; - } - - const parts = segment.map((part) => { - if (part.rest) return '(.*?)'; - if (part.dynamic) return '([^/]+?)'; - - return ( - part.content - // allow users to specify characters on the file system in an encoded manner - .normalize() - // We use [ and ] to denote parameters, so users must encode these on the file - // system to match against them. We don't decode all characters since others - // can already be epressed and so that '%' can be easily used directly in filenames - .replace(/%5[Bb]/g, '[') - .replace(/%5[Dd]/g, ']') - // '#', '/', and '?' can only appear in URL path segments in an encoded manner. - // They will not be touched by decodeURI so need to be encoded here, so - // that we can match against them. - // We skip '/' since you can't create a file with it on any OS - .replace(/#/g, '%23') - .replace(/\?/g, '%3F') - // escape characters that have special meaning in regex - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - ); - }); - - return '\\/' + parts.join(''); - }) - .join(''); - - const trailing = add_trailing_slash && segments.length ? '\\/?$' : '$'; - - return new RegExp(`^${path || '\\/'}${trailing}`); -} - /** * @param {{ * config: import('types').ValidatedConfig; diff --git a/packages/kit/src/core/sync/create_manifest_data/index.spec.js b/packages/kit/src/core/sync/create_manifest_data/index.spec.js index a4a014440541..367e4e0691ca 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.spec.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.spec.js @@ -42,9 +42,7 @@ test('creates routes', () => { { type: 'page', id: '', - segments: [], pattern: /^\/$/, - params: [], path: '/', shadow: null, a: [layout, index], @@ -54,9 +52,7 @@ test('creates routes', () => { { type: 'page', id: 'about', - segments: [{ rest: false, dynamic: false, content: 'about' }], pattern: /^\/about\/?$/, - params: [], path: '/about', shadow: null, a: [layout, about], @@ -66,18 +62,14 @@ test('creates routes', () => { { type: 'endpoint', id: 'blog.json', - segments: [{ rest: false, dynamic: false, content: 'blog.json' }], pattern: /^\/blog\.json$/, - file: 'samples/basic/blog/index.json.js', - params: [] + file: 'samples/basic/blog/index.json.js' }, { type: 'page', id: 'blog', - segments: [{ rest: false, dynamic: false, content: 'blog' }], pattern: /^\/blog\/?$/, - params: [], path: '/blog', shadow: null, a: [layout, blog], @@ -87,24 +79,14 @@ test('creates routes', () => { { type: 'endpoint', id: 'blog/[slug].json', - segments: [ - { rest: false, dynamic: false, content: 'blog' }, - { rest: false, dynamic: true, content: '[slug].json' } - ], pattern: /^\/blog\/([^/]+?)\.json$/, - file: 'samples/basic/blog/[slug].json.ts', - params: ['slug'] + file: 'samples/basic/blog/[slug].json.ts' }, { type: 'page', id: 'blog/[slug]', - segments: [ - { rest: false, dynamic: false, content: 'blog' }, - { rest: false, dynamic: true, content: '[slug]' } - ], pattern: /^\/blog\/([^/]+?)\/?$/, - params: ['slug'], path: '', shadow: null, a: [layout, blog_$slug], @@ -128,9 +110,7 @@ test('creates routes with layout', () => { { type: 'page', id: '', - segments: [], pattern: /^\/$/, - params: [], path: '/', shadow: null, a: [layout, index], @@ -140,9 +120,7 @@ test('creates routes with layout', () => { { type: 'page', id: 'foo', - segments: [{ rest: false, dynamic: false, content: 'foo' }], pattern: /^\/foo\/?$/, - params: [], path: '/foo', shadow: null, a: [layout, foo___layout, foo], @@ -217,22 +195,14 @@ test('sorts routes with rest correctly', () => { ); }); -test('disallows rest parameters inside segments', () => { +test('allows rest parameters inside segments', () => { const { routes } = create('samples/rest-prefix-suffix'); assert.equal(routes, [ { type: 'page', id: 'prefix-[...rest]', - segments: [ - { - dynamic: true, - rest: true, - content: 'prefix-[...rest]' - } - ], pattern: /^\/prefix-(.*?)\/?$/, - params: ['...rest'], path: '', shadow: null, a: [layout, 'samples/rest-prefix-suffix/prefix-[...rest].svelte'], @@ -241,16 +211,8 @@ test('disallows rest parameters inside segments', () => { { type: 'endpoint', id: '[...rest].json', - segments: [ - { - dynamic: true, - rest: true, - content: '[...rest].json' - } - ], pattern: /^\/(.*?)\.json$/, - file: 'samples/rest-prefix-suffix/[...rest].json.js', - params: ['...rest'] + file: 'samples/rest-prefix-suffix/[...rest].json.js' } ]); }); @@ -308,10 +270,8 @@ test('allows multiple slugs', () => { { type: 'endpoint', id: '[file].[ext]', - segments: [{ dynamic: true, rest: false, content: '[file].[ext]' }], pattern: /^\/([^/]+?)\.([^/]+?)$/, - file: 'samples/multiple-slugs/[file].[ext].js', - params: ['file', 'ext'] + file: 'samples/multiple-slugs/[file].[ext].js' } ] ); @@ -330,9 +290,7 @@ test('ignores things that look like lockfiles', () => { { type: 'endpoint', id: 'foo', - segments: [{ rest: false, dynamic: false, content: 'foo' }], file: 'samples/lockfiles/foo.js', - params: [], pattern: /^\/foo\/?$/ } ]); @@ -354,9 +312,7 @@ test('works with custom extensions', () => { { type: 'page', id: '', - segments: [], pattern: /^\/$/, - params: [], path: '/', shadow: null, a: [layout, index], @@ -366,9 +322,7 @@ test('works with custom extensions', () => { { type: 'page', id: 'about', - segments: [{ rest: false, dynamic: false, content: 'about' }], pattern: /^\/about\/?$/, - params: [], path: '/about', shadow: null, a: [layout, about], @@ -378,18 +332,14 @@ test('works with custom extensions', () => { { type: 'endpoint', id: 'blog.json', - segments: [{ rest: false, dynamic: false, content: 'blog.json' }], pattern: /^\/blog\.json$/, - file: 'samples/custom-extension/blog/index.json.js', - params: [] + file: 'samples/custom-extension/blog/index.json.js' }, { type: 'page', id: 'blog', - segments: [{ rest: false, dynamic: false, content: 'blog' }], pattern: /^\/blog\/?$/, - params: [], path: '/blog', shadow: null, a: [layout, blog], @@ -399,24 +349,14 @@ test('works with custom extensions', () => { { type: 'endpoint', id: 'blog/[slug].json', - segments: [ - { rest: false, dynamic: false, content: 'blog' }, - { rest: false, dynamic: true, content: '[slug].json' } - ], pattern: /^\/blog\/([^/]+?)\.json$/, - file: 'samples/custom-extension/blog/[slug].json.js', - params: ['slug'] + file: 'samples/custom-extension/blog/[slug].json.js' }, { type: 'page', id: 'blog/[slug]', - segments: [ - { rest: false, dynamic: false, content: 'blog' }, - { rest: false, dynamic: true, content: '[slug]' } - ], pattern: /^\/blog\/([^/]+?)\/?$/, - params: ['slug'], path: '', shadow: null, a: [layout, blog_$slug], @@ -449,13 +389,7 @@ test('includes nested error components', () => { { type: 'page', id: 'foo/bar/baz', - segments: [ - { rest: false, dynamic: false, content: 'foo' }, - { rest: false, dynamic: false, content: 'bar' }, - { rest: false, dynamic: false, content: 'baz' } - ], pattern: /^\/foo\/bar\/baz\/?$/, - params: [], path: '/foo/bar/baz', shadow: null, a: [ @@ -482,9 +416,7 @@ test('resets layout', () => { { type: 'page', id: '', - segments: [], pattern: /^\/$/, - params: [], path: '/', shadow: null, a: [layout, 'samples/layout-reset/index.svelte'], @@ -493,9 +425,7 @@ test('resets layout', () => { { type: 'page', id: 'foo', - segments: [{ rest: false, dynamic: false, content: 'foo' }], pattern: /^\/foo\/?$/, - params: [], path: '/foo', shadow: null, a: [ @@ -508,12 +438,7 @@ test('resets layout', () => { { type: 'page', id: 'foo/bar', - segments: [ - { rest: false, dynamic: false, content: 'foo' }, - { rest: false, dynamic: false, content: 'bar' } - ], pattern: /^\/foo\/bar\/?$/, - params: [], path: '/foo/bar', shadow: null, a: [ diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index 814f95813ca3..cb3c494d8043 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -1,36 +1,72 @@ -/** @param {string} key */ -export function parse_route_id(key) { +const param_pattern = /^(\.\.\.)?(\w+)(?:=(\w+))?$/; + +/** @param {string} id */ +export function parse_route_id(id) { /** @type {string[]} */ const names = []; /** @type {string[]} */ const types = []; + // `/foo` should get an optional trailing slash, `/foo.json` should not + // const add_trailing_slash = !/\.[a-z]+$/.test(key); + let add_trailing_slash = true; + const pattern = - key === '' + id === '' ? /^\/$/ : new RegExp( - `^${decodeURIComponent(key) + `^${decodeURIComponent(id) .split('/') - .map((segment) => { + .map((segment, i, segments) => { // special case — /[...rest]/ could contain zero segments - const match = /^\[\.\.\.(\w+)(?:=\w+)?\]$/.exec(segment); + const match = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment); if (match) { names.push(match[1]); types.push(match[2]); return '(?:/(.*))?'; } + const is_last = i === segments.length - 1; + return ( '/' + - segment.replace(/\[(\.\.\.)?(\w+)(?:=(\w+))?\]/g, (m, rest, name, type) => { - names.push(name); - types.push(type); - return rest ? '(.*?)' : '([^/]+?)'; - }) + segment + .split(/\[(.+?)\]/) + .map((content, i) => { + if (i % 2) { + const [, rest, name, type] = /** @type {RegExpMatchArray} */ ( + param_pattern.exec(content) + ); + names.push(name); + types.push(type); + return rest ? '(.*?)' : '([^/]+?)'; + } + + if (is_last && content.includes('.')) add_trailing_slash = false; + + return ( + content // allow users to specify characters on the file system in an encoded manner + .normalize() + // We use [ and ] to denote parameters, so users must encode these on the file + // system to match against them. We don't decode all characters since others + // can already be epressed and so that '%' can be easily used directly in filenames + .replace(/%5[Bb]/g, '[') + .replace(/%5[Dd]/g, ']') + // '#', '/', and '?' can only appear in URL path segments in an encoded manner. + // They will not be touched by decodeURI so need to be encoded here, so + // that we can match against them. + // We skip '/' since you can't create a file with it on any OS + .replace(/#/g, '%23') + .replace(/\?/g, '%3F') + // escape characters that have special meaning in regex + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ); // TODO handle encoding + }) + .join('') ); }) - .join('')}/?$` + .join('')}${add_trailing_slash ? '/?' : ''}$` ); return { pattern, names, types }; diff --git a/packages/kit/src/utils/routing.spec.js b/packages/kit/src/utils/routing.spec.js index 3ba9f4b907a1..d08bc37dd53f 100644 --- a/packages/kit/src/utils/routing.spec.js +++ b/packages/kit/src/utils/routing.spec.js @@ -7,6 +7,41 @@ const tests = { pattern: /^\/$/, names: [], types: [] + }, + blog: { + pattern: /^\/blog\/?$/, + names: [], + types: [] + }, + 'blog.json': { + pattern: /^\/blog\.json$/, + names: [], + types: [] + }, + 'blog/[slug]': { + pattern: /^\/blog\/([^/]+?)\/?$/, + names: ['slug'], + types: [undefined] + }, + 'blog/[slug].json': { + pattern: /^\/blog\/([^/]+?)\.json$/, + names: ['slug'], + types: [undefined] + }, + '[...catchall]': { + pattern: /^(?:\/(.*))?\/?$/, + names: ['catchall'], + types: [undefined] + }, + 'foo/[...catchall]/bar': { + pattern: /^\/foo(?:\/(.*))?\/bar\/?$/, + names: ['catchall'], + types: [undefined] + }, + 'matched/[id=uuid]': { + pattern: /^\/matched\/([^/]+?)\/?$/, + names: ['id'], + types: ['uuid'] } }; @@ -15,6 +50,8 @@ for (const [key, expected] of Object.entries(tests)) { const actual = parse_route_id(key); assert.equal(actual.pattern.toString(), expected.pattern.toString()); + assert.equal(actual.names, expected.names); + assert.equal(actual.types, expected.types); }); } diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index c7be6de1b855..5e34beea4208 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -78,9 +78,7 @@ export type CSRRoute = { export interface EndpointData { type: 'endpoint'; id: string; - segments: RouteSegment[]; pattern: RegExp; - params: string[]; file: string; } @@ -129,9 +127,7 @@ export interface PageData { type: 'page'; id: string; shadow: string | null; - segments: RouteSegment[]; pattern: RegExp; - params: string[]; path: string; a: string[]; b: string[];