diff --git a/.changeset/many-forks-sniff.md b/.changeset/many-forks-sniff.md new file mode 100644 index 000000000000..190723e5f584 --- /dev/null +++ b/.changeset/many-forks-sniff.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +[breaking] implement new layout system (see the PR for migration instructions) diff --git a/documentation/docs/04-advanced-routing.md b/documentation/docs/04-advanced-routing.md index 8c82a8be0ec2..11eca78bac83 100644 --- a/documentation/docs/04-advanced-routing.md +++ b/documentation/docs/04-advanced-routing.md @@ -126,75 +126,95 @@ assert.equal( To express a `%` character, use `%25`, otherwise the result will be malformed. -### Named layouts +### Advanced layouts -Some parts of your app might need something other than the default layout. For these cases you can create _named layouts_... +By default, the _layout hierarchy_ mirrors the _route hierarchy_. In some cases, that might not be what you want. -```svelte -/// file: src/routes/+layout-foo.svelte -
- -
-``` +#### (group) -...and then use them by referencing the layout name (`foo`, in the example above) in the filename: +Perhaps you have some routes that are 'app' routes that should have one layout (e.g. `/dashboard` or `/item`), and others that are 'marketing' routes that should have a different layout (`/blog` or `/testimonials`). We can group these routes with a directory whose name is wrapped in parentheses — unlike normal directories, `(app)` and `(marketing)` do not affect the URL pathname of the routes inside them: -```svelte -/// file: src/routes/my-special-page/+page@foo.svelte -

I am inside +layout-foo

+```diff +src/routes/ ++│ (app)/ +│ ├ dashboard/ +│ ├ item/ +│ └ +layout.svelte ++│ (marketing)/ +│ ├ about/ +│ ├ testimonials/ +│ └ +layout.svelte +├ admin/ +└ +layout.svelte ``` -> Named layout should only be referenced from Svelte files - -Named layouts are very powerful, but it can take a minute to get your head round them. Don't worry if this doesn't make sense all at once. +You can also put a `+page` directly inside a `(group)`, for example if `/` should be an `(app)` or a `(marketing)` page. -#### Scoping +#### +page@ -Named layouts can be created at any depth, and will apply to any components in the same subtree. For example, `+layout-foo` will apply to `/x/one` and `/x/two`, but not `/x/three` or `/four`: +Conversely, some routes of your app might need to break out of the layout hierarchy. Let's add an `/item/[id]/embed` route inside the `(app)` group from the previous example: -```bash +```diff src/routes/ -├ x/ -│ ├ +layout-foo.svelte -│ ├ one/+page@foo.svelte # ✅ page has `@foo` -│ ├ two/+page@foo.svelte # ✅ page has `@foo` -│ └ three/+page.svelte # ❌ page does not have `@foo` -└ four/+page@foo.svelte # ❌ page has `@foo`, but +layout-foo is not 'in scope' +├ (app)/ +│ ├ item/ +│ │ ├ [id]/ +│ │ │ ├ embed/ ++│ │ │ │ └ +page.svelte +│ │ │ └ +layout.svelte +│ │ └ +layout.svelte +│ └ +layout.svelte +└ +layout.svelte ``` -#### Inheritance chains - -Layouts can themselves choose to inherit from named layouts, from the same directory or a parent directory. For example, `x/y/+layout@root.svelte` is the default layout for `/x/y` (meaning `/x/y/one`, `/x/y/two` and `/x/y/three` all inherit from it) because it has no name. Because it specifies `@root`, it will inherit directly from the nearest `+layout-root.svelte`, skipping `+layout.svelte` and `x/+layout.svelte`. +Ordinarily, this would inherit the root layout, the `(app)` layout, the `item` layout and the `[id]` layout. We can reset to one of those layouts by appending `@` followed by the segment name — or, for the root layout, the empty string. In this example, we can choose from `+page@.svelte`, `+page@(app).svelte`, `+page@item.svelte` or `+page@[id].svelte`: -``` +```diff src/routes/ -├ x/ -│ ├ y/ -│ │ ├ +layout@root.svelte -│ │ ├ one/+page.svelte -│ │ ├ two/+page.svelte -│ │ └ three/+page.svelte +├ (app)/ +│ ├ item/ +│ │ ├ [id]/ +│ │ │ ├ embed/ ++│ │ │ │ └ +page@(app).svelte +│ │ │ └ +layout.svelte +│ │ └ +layout.svelte │ └ +layout.svelte -├ +layout.svelte -└ +layout-root.svelte +└ +layout.svelte ``` -> In the case where `+layout-root.svelte` contains a lone ``, this effectively means we're able to 'reset' to a blank layout for any page or nested layout in the app by adding `@root`. +#### +layout@ -If no parent is specified, a layout will inherit from the nearest default (i.e. unnamed) layout _above_ it in the tree. In some cases, it's helpful for a named layout to inherit from a default layout _alongside_ it in the tree, such as `+layout-root.svelte` inheriting from `+layout.svelte`. We can do this by explicitly specifying `@default`, allowing `/x/y/one` and siblings to use the app's default layout without using `x/+layout.svelte`: +Like pages, layouts can _themselves_ break out of their parent layout hierarchy, using the same technique. For example, a `+layout@.svelte` component would reset the hierarchy for all its child routes. -```diff -src/routes/ -├ x/ -│ ├ y/ -│ │ ├ +layout@root.svelte -│ │ ├ one/+page.svelte -│ │ ├ two/+page.svelte -│ │ └ three/+page.svelte -│ └ +layout.svelte -├ +layout.svelte --└ +layout-root.svelte -+└ +layout-root@default.svelte +#### When to use layout groups + +Not all use cases are suited for layout grouping, nor should you feel compelled to use them. It might be that your use case would result in complex `(group)` nesting, or that you don't want to introduce a `(group)` for a single outlier. It's perfectly fine to use other means such as composition (reusable `load` functions or Svelte components) or if-statements to achieve what you want. The following example shows a layout that rewinds to the root layout and reuses components and functions that other layouts can also use: + +```svelte +/// file: src/routes/nested/route/+layout@.svelte + + + + + ``` -> `default` is a reserved name — in other words, you can't have a `+layout-default.svelte` file. +```js +/// file: src/routes/nested/route/+layout.js +// @filename: ambient.d.ts +declare module "$lib/reusable-load-function" { + export function reusableLoad(event: import('@sveltejs/kit').LoadEvent): Promise>; +} +// @filename: index.js +// ---cut--- +import { reusableLoad } from '$lib/reusable-load-function'; + +/** @type {import('./$types').PageLoad} */ +export function load(event) { + // Add additional logic here, if needed + return reusableLoad(event); +} +``` diff --git a/packages/kit/package.json b/packages/kit/package.json index e67591d42e13..c641a32b4aef 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -59,7 +59,6 @@ ], "scripts": { "build": "npm run types", - "dev": "rollup -cw", "lint": "prettier --check . --config ../../.prettierrc --ignore-path .gitignore", "check": "tsc", "check:all": "tsc && pnpm -r --filter=\"./**\" check", diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index f5d3a6451020..03b7bb68a3c3 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -23,7 +23,7 @@ export function create_builder({ config, build_data, prerendered, log }) { /** @param {import('types').RouteData} route */ // TODO routes should come pre-filtered function not_prerendered(route) { - const path = route.type === 'page' && !route.id.includes('[') && `/${route.id}`; + const path = route.page && !route.id.includes('[') && `/${route.id}`; if (path) { return !prerendered_paths.has(path) && !prerendered_paths.has(path + '/'); } @@ -68,17 +68,31 @@ export function create_builder({ config, build_data, prerendered, log }) { const { routes } = build_data.manifest_data; /** @type {import('types').RouteDefinition[]} */ - const facades = routes.map((route) => ({ - id: route.id, - type: route.type, - 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] - })); + const facades = routes.map((route) => { + const methods = new Set(); + + if (route.page) { + methods.add('SET'); + } + + if (route.endpoint) { + for (const method of build_data.server.methods[route.endpoint.file]) { + methods.add(method); + } + } + + return { + id: route.id, + type: route.page ? 'page' : 'endpoint', // TODO change this if support pages+endpoints + segments: route.id.split('/').map((segment) => ({ + dynamic: segment.includes('['), + rest: segment.includes('[...'), + content: segment + })), + pattern: route.pattern, + methods: Array.from(methods) + }; + }); const seen = new Set(); @@ -102,8 +116,9 @@ export function create_builder({ config, build_data, prerendered, log }) { // heuristic: if /foo/[bar] is included, /foo/[bar].json should // also be included, since the page likely needs the endpoint + // TODO is this still necessary, given the new way of doing things? filtered.forEach((route) => { - if (route.type === 'page') { + if (route.page) { const endpoint = routes.find((candidate) => candidate.id === route.id + '.json'); if (endpoint) { diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index 946043484bb9..8f4529cb93aa 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -1,5 +1,4 @@ import { s } from '../../utils/misc.js'; -import { parse_route_id } from '../../utils/routing.js'; import { get_mime_lookup } from '../utils.js'; /** @@ -38,12 +37,11 @@ export function generate_manifest({ build_data, relative_path, routes, format = assets.push(build_data.service_worker); } - /** @param {import('types').PageNode | undefined} id */ - const get_index = (id) => id && /** @type {LookupEntry} */ (bundled_nodes.get(id)).index; - const matchers = new Set(); // prettier-ignore + // String representation of + /** @type {import('types').SSRManifest} */ return `{ appDir: ${s(build_data.app_dir)}, assets: new Set(${s(assets)}), @@ -55,39 +53,20 @@ export function generate_manifest({ build_data, relative_path, routes, format = ], routes: [ ${routes.map(route => { - const { pattern, names, types } = parse_route_id(route.id); - - types.forEach(type => { + route.types.forEach(type => { if (type) matchers.add(type); }); - if (route.type === 'page') { - return `{ - type: 'page', - id: ${s(route.id)}, - pattern: ${pattern}, - names: ${s(names)}, - types: ${s(types)}, - errors: ${s(route.errors.map(get_index))}, - layouts: ${s(route.layouts.map(get_index))}, - leaf: ${s(get_index(route.leaf))} - }`.replace(/^\t\t/gm, ''); - } else { - if (!build_data.server.vite_manifest[route.file]) { - // this is necessary in cases where a .css file snuck in — - // perhaps it would be better to disallow these (and others?) - return null; - } + if (!route.page && !route.endpoint) return; - return `{ - type: 'endpoint', - id: ${s(route.id)}, - pattern: ${pattern}, - names: ${s(names)}, - types: ${s(types)}, - load: ${loader(`${relative_path}/${build_data.server.vite_manifest[route.file].file}`)} - }`.replace(/^\t\t/gm, ''); - } + return `{ + id: ${s(route.id)}, + pattern: ${route.pattern}, + names: ${s(route.names)}, + types: ${s(route.types)}, + page: ${s(route.page)}, + endpoint: ${route.endpoint ? loader(`${relative_path}/${build_data.server.vite_manifest[route.endpoint.file].file}`) : 'null'} + }`; }).filter(Boolean).join(',\n\t\t\t\t')} ], matchers: async () => { diff --git a/packages/kit/src/core/prerender/prerender.js b/packages/kit/src/core/prerender/prerender.js index 93d535efdd6c..a2cfbd79c118 100644 --- a/packages/kit/src/core/prerender/prerender.js +++ b/packages/kit/src/core/prerender/prerender.js @@ -343,7 +343,7 @@ export async function prerender() { /** @type {import('types').ManifestData} */ const { routes } = (await import(pathToFileURL(manifest_path).href)).manifest._; const entries = routes - .map((route) => (route.type === 'page' && !route.id.includes('[') ? `/${route.id}` : '')) + .map((route) => (route.page && !route.id.includes('[') ? `/${route.id}` : '')) .filter(Boolean); for (const entry of entries) { 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 ce8944511ce1..57e4b6ec630d 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -3,9 +3,7 @@ import path from 'path'; import mime from 'mime'; import { runtime_directory } from '../../utils.js'; import { posixify } from '../../../utils/filesystem.js'; -import { parse_route_id } from '../../../utils/routing.js'; - -const DEFAULT = 'default'; +import { parse_route_id, is_no_group } from '../../../utils/routing.js'; /** * @param {{ @@ -20,261 +18,296 @@ export default function create_manifest_data({ fallback = `${runtime_directory}/components`, cwd = process.cwd() }) { - /** @type {Map} */ - const route_map = new Map(); + const assets = create_assets(config); + const matchers = create_matchers(config, cwd); + const { nodes, routes } = create_routes_and_nodes(cwd, config, fallback); - /** @type {Map} */ - const segment_map = new Map(); + return { + assets, + matchers, + nodes, + routes + }; +} - /** @type {import('./types').RouteTree} */ - const tree = new Map(); +/** + * @param {import('types').ValidatedConfig} config + */ +function create_assets(config) { + return list_files(config.kit.files.assets).map((file) => ({ + file, + size: fs.statSync(path.resolve(config.kit.files.assets, file)).size, + type: mime.getType(file) + })); +} - const default_layout = { - component: posixify(path.relative(cwd, `${fallback}/layout.svelte`)) - }; +/** + * @param {import('types').ValidatedConfig} config + * @param {string} cwd + */ +function create_matchers(config, cwd) { + const params_base = path.relative(cwd, config.kit.files.params); - // set default root layout/error - tree.set('', { - error: { - component: posixify(path.relative(cwd, `${fallback}/error.svelte`)) - }, - layouts: { [DEFAULT]: default_layout } - }); - - /** @param {string} id */ - function tree_node(id) { - if (!tree.has(id)) { - tree.set(id, { - error: undefined, - layouts: {} - }); - } + /** @type {Record} */ + const matchers = {}; + if (fs.existsSync(config.kit.files.params)) { + for (const file of fs.readdirSync(config.kit.files.params)) { + const ext = path.extname(file); + if (!config.kit.moduleExtensions.includes(ext)) continue; + const type = file.slice(0, -ext.length); - return /** @type {import('./types').RouteTreeNode} */ (tree.get(id)); - } + if (/^\w+$/.test(type)) { + const matcher_file = path.join(params_base, file); - const routes_base = posixify(path.relative(cwd, config.kit.files.routes)); - const valid_extensions = [...config.extensions, ...config.kit.moduleExtensions]; + // Disallow same matcher with different extensions + if (matchers[type]) { + throw new Error(`Duplicate matchers: ${matcher_file} and ${matchers[type]}`); + } else { + matchers[type] = matcher_file; + } + } else { + throw new Error( + `Matcher names can only have underscores and alphanumeric characters — "${file}" is invalid` + ); + } + } + } - if (fs.existsSync(config.kit.files.routes)) { - list_files(config.kit.files.routes).forEach((filepath) => { - const extension = valid_extensions.find((ext) => filepath.endsWith(ext)); - if (!extension) return; + return matchers; +} - const project_relative = `${routes_base}/${filepath}`; - const segments = filepath.split('/'); - const file = /** @type {string} */ (segments.pop()); +/** + * @param {import('types').ValidatedConfig} config + * @param {string} cwd + * @param {string} fallback + */ +function create_routes_and_nodes(cwd, config, fallback) { + const route_map = new Map(); - if (file[0] !== '+') return; // not a route file + /** @type {Map} */ + const segment_map = new Map(); - const item = analyze(project_relative, file, config.extensions, config.kit.moduleExtensions); - const id = segments.join('/'); + const routes_base = posixify(path.relative(cwd, config.kit.files.routes)); - if (/\]\[/.test(id)) { - throw new Error(`Invalid route ${project_relative} — parameters must be separated`); - } + const valid_extensions = [...config.extensions, ...config.kit.moduleExtensions]; - if (count_occurrences('[', id) !== count_occurrences(']', id)) { - throw new Error(`Invalid route ${project_relative} — brackets are unbalanced`); - } + /** @type {import('types').PageNode[]} */ + const nodes = []; - // error/layout files should be added to the tree, but don't result - // in a route being created, so deal with them first. note: we are - // relying on the fact that the +error and +layout files precede - // +page files alphabetically, and will therefore be processes - // before we reach the page - if (item.kind === 'component' && item.is_error) { - tree_node(id).error = { - component: project_relative - }; - - return; - } + if (fs.existsSync(config.kit.files.routes)) { + /** + * @param {number} depth + * @param {string} id + * @param {string} segment + * @param {import('types').RouteData | null} parent + */ + const walk = (depth, id, segment, parent) => { + const { pattern, names, types } = parse_route_id(id); + + const segments = id.split('/'); + + segment_map.set( + id, + segments.filter(Boolean).map((segment) => { + /** @type {import('./types').Part[]} */ + const parts = []; + segment.split(/\[(.+?)\]/).map((content, i) => { + const dynamic = !!(i % 2); + + if (!content) return; + + parts.push({ + content, + dynamic, + rest: dynamic && content.startsWith('...'), + type: (dynamic && content.split('=')[1]) || null + }); + }); + return parts; + }) + ); - if (item.is_layout) { - if (item.declares_layout === DEFAULT) { - throw new Error(`${project_relative} cannot use reserved "${DEFAULT}" name`); - } + /** @type {import('types').RouteData} */ + const route = { + id, + parent, - const layout_id = item.declares_layout || DEFAULT; + segment, + pattern, + names, + types, - const group = tree_node(id); + layout: null, + error: null, + leaf: null, + page: null, + endpoint: null + }; - const defined = group.layouts[layout_id] || (group.layouts[layout_id] = {}); + // important to do this before walking children, so that child + // routes appear later + route_map.set(id, route); - if (defined[item.kind] && layout_id !== DEFAULT) { - // edge case - throw new Error( - `Duplicate layout ${project_relative} already defined at ${defined[item.kind]}` - ); - } + // if we don't do this, the route map becomes unwieldy to console.log + Object.defineProperty(route, 'parent', { enumerable: false }); - defined[item.kind] = project_relative; + const dir = path.join(cwd, routes_base, id); - return; - } + const files = fs.readdirSync(dir, { + withFileTypes: true + }); - const type = item.kind === 'server' && !item.is_layout && !item.is_page ? 'endpoint' : 'page'; + // process files first + for (const file of files) { + if (file.isDirectory()) continue; + if (!file.name.startsWith('+')) continue; + if (!valid_extensions.find((ext) => file.name.endsWith(ext))) continue; - if (type === 'endpoint' && route_map.has(id)) { - // note that we are relying on +server being lexically ordered after - // all other route files — if we added +view or something this is - // potentially brittle, since the server might be added before - // another route file. a problem for another day - throw new Error( - `${file} cannot share a directory with other route files (${project_relative})` - ); - } + const project_relative = posixify(path.relative(cwd, path.join(dir, file.name))); - if (!route_map.has(id)) { - const pattern = parse_route_id(id).pattern; - - segment_map.set( - id, - segments.filter(Boolean).map((segment) => { - /** @type {import('./types').Part[]} */ - const parts = []; - segment.split(/\[(.+?)\]/).map((content, i) => { - const dynamic = !!(i % 2); - - if (!content) return; - - parts.push({ - content, - dynamic, - rest: dynamic && content.startsWith('...'), - type: (dynamic && content.split('=')[1]) || null - }); - }); - return parts; - }) + const item = analyze( + project_relative, + file.name, + config.extensions, + config.kit.moduleExtensions ); - if (type === 'endpoint') { - route_map.set(id, { - type, - id, - pattern, - file: project_relative - }); + if (item.kind === 'component') { + if (item.is_error) { + route.error = { + depth, + component: project_relative + }; + } else if (item.is_layout) { + if (!route.layout) route.layout = { depth }; + route.layout.component = project_relative; + if (item.uses_layout !== undefined) route.layout.parent_id = item.uses_layout; + } else { + if (!route.leaf) route.leaf = { depth }; + route.leaf.component = project_relative; + if (item.uses_layout !== undefined) route.leaf.parent_id = item.uses_layout; + } + } else if (item.is_layout) { + if (!route.layout) route.layout = { depth }; + route.layout[item.kind] = project_relative; + } else if (item.is_page) { + if (!route.leaf) route.leaf = { depth }; + route.leaf[item.kind] = project_relative; } else { - route_map.set(id, { - type, - id, - pattern, - errors: [], - layouts: [], - leaf: {} - }); + route.endpoint = { + file: project_relative + }; } } - if (item.is_page) { - const route = /** @type {import('types').PageData} */ (route_map.get(id)); - - // This ensures that layouts and errors are set for pages that have no Svelte file - // and only redirect or throw an error, but are set to the Svelte file definition if it exists. - // This ensures the proper error page is used and rendered in the proper layout. - if (item.kind === 'component' || route.layouts.length === 0) { - const { layouts, errors } = trace( - tree, - id, - item.kind === 'component' ? item.uses_layout : undefined, - project_relative - ); - route.layouts = layouts; - route.errors = errors; - } - - if (item.kind === 'component') { - route.leaf.component = project_relative; - } else if (item.kind === 'server') { - route.leaf.server = project_relative; - } else { - route.leaf.shared = project_relative; + // then handle children + for (const file of files) { + if (file.isDirectory()) { + walk(depth + 1, path.posix.join(id, file.name), file.name, route); } } - }); + }; - // TODO remove for 1.0 - if (route_map.size === 0) { - throw new Error( - 'The filesystem router API has changed, see https://github.com/sveltejs/kit/discussions/5774 for details' - ); - } - } + walk(0, '', '', null); - /** @type {import('types').PageNode[]} */ - const nodes = []; + const root = /** @type {import('types').RouteData} */ (route_map.get('')); - tree.forEach(({ layouts, error }) => { - // we do [default, error, ...other_layouts] so that components[0] and [1] - // are the root layout/error. kinda janky, there's probably a nicer way - if (layouts[DEFAULT]) { - nodes.push(layouts[DEFAULT]); + // TODO remove for 1.0 + if (route_map.size === 1) { + if (!root.leaf && !root.error && !root.layout && !root.endpoint) { + throw new Error( + 'The filesystem router API has changed, see https://github.com/sveltejs/kit/discussions/5774 for details' + ); + } } - if (error) { - nodes.push(error); + if (!root.layout?.component) { + if (!root.layout) root.layout = { depth: 0 }; + root.layout.component = posixify(path.relative(cwd, `${fallback}/layout.svelte`)); } - for (const id in layouts) { - if (id !== DEFAULT) { - nodes.push(layouts[id]); - } + if (!root.error?.component) { + if (!root.error) root.error = { depth: 0 }; + root.error.component = posixify(path.relative(cwd, `${fallback}/error.svelte`)); } - }); - route_map.forEach((route) => { - if (route.type === 'page') { + // we do layouts/errors first as they are more likely to be reused, + // and smaller indexes take fewer bytes. also, this guarantees that + // the default error/layout are 0/1 + route_map.forEach((route) => { + if (route.layout) nodes.push(route.layout); + if (route.error) nodes.push(route.error); + }); + + /** @type {Map} */ + const conflicts = new Map(); + + route_map.forEach((route) => { + if (!route.leaf) return; + nodes.push(route.leaf); - } - }); - const routes = Array.from(route_map.values()).sort((a, b) => compare(a, b, segment_map)); + const normalized = route.id.split('/').filter(is_no_group).join('/'); - /** @type {import('types').Asset[]} */ - const assets = fs.existsSync(config.kit.files.assets) - ? list_files(config.kit.files.assets).map((file) => ({ - file, - size: fs.statSync(`${config.kit.files.assets}/${file}`).size, - type: mime.getType(file) - })) - : []; + if (conflicts.has(normalized)) { + throw new Error(`${conflicts.get(normalized)} and ${route.id} occupy the same route`); + } - const params_base = path.relative(cwd, config.kit.files.params); + conflicts.set(normalized, route.id); + }); - /** @type {Record} */ - const matchers = {}; - if (fs.existsSync(config.kit.files.params)) { - for (const file of fs.readdirSync(config.kit.files.params)) { - const ext = path.extname(file); - if (!config.kit.moduleExtensions.includes(ext)) continue; - const type = file.slice(0, -ext.length); + const indexes = new Map(nodes.map((node, i) => [node, i])); - if (/^\w+$/.test(type)) { - const matcher_file = path.join(params_base, file); + route_map.forEach((route) => { + if (!route.leaf) return; - // Disallow same matcher with different extensions - if (matchers[type]) { - throw new Error(`Duplicate matchers: ${matcher_file} and ${matchers[type]}`); - } else { - matchers[type] = matcher_file; + if (route.leaf && route.endpoint) { + // TODO possibly relax this https://github.com/sveltejs/kit/issues/5896 + throw new Error(`${route.endpoint.file} cannot share a directory with other route files`); + } + + route.page = { + layouts: [], + errors: [], + leaf: /** @type {number} */ (indexes.get(route.leaf)) + }; + + /** @type {import('types').RouteData | null} */ + let current_route = route; + let current_node = route.leaf; + let parent_id = route.leaf.parent_id; + + while (current_route) { + if (parent_id === undefined || current_route.segment === parent_id) { + if (current_route.layout || current_route.error) { + route.page.layouts.unshift( + current_route.layout ? indexes.get(current_route.layout) : undefined + ); + route.page.errors.unshift( + current_route.error ? indexes.get(current_route.error) : undefined + ); + } + + if (current_route.layout) { + current_node.parent = current_node = current_route.layout; + parent_id = current_node.parent_id; + } else { + parent_id = undefined; + } } - } else { - throw new Error( - `Matcher names can only have underscores and alphanumeric characters — "${file}" is invalid` - ); + + current_route = current_route.parent; } - } + + if (parent_id !== undefined) { + throw new Error(`${current_node.component} references missing segment "${parent_id}"`); + } + }); } - return { - assets, - nodes, - routes, - matchers - }; + const routes = Array.from(route_map.values()).sort((a, b) => compare(a, b, segment_map)); + + return { nodes, routes }; } /** @@ -288,10 +321,16 @@ function analyze(project_relative, file, component_extensions, module_extensions const component_extension = component_extensions.find((ext) => file.endsWith(ext)); if (component_extension) { const name = file.slice(0, -component_extension.length); - const pattern = - /^\+(?:(page(?:@([a-zA-Z0-9_-]+))?)|(layout(?:-([a-zA-Z0-9_-]+))?(?:@([a-zA-Z0-9_-]+))?)|(error))$/; + const pattern = /^\+(?:(page(?:@([a-zA-Z0-9_-]*))?)|(layout(?:@([a-zA-Z0-9_-]*))?)|(error))$/; const match = pattern.exec(name); if (!match) { + // TODO remove for 1.0 + if (/^\+layout-/.test(name)) { + throw new Error( + `${project_relative} should be reimplemented with layout groups: https://kit.svelte.dev/docs/advanced-routing#advanced-layouts` + ); + } + throw new Error(`Files prefixed with + are reserved (saw ${project_relative})`); } @@ -299,9 +338,8 @@ function analyze(project_relative, file, component_extensions, module_extensions kind: 'component', is_page: !!match[1], is_layout: !!match[3], - is_error: !!match[6], - uses_layout: match[2] || match[5], - declares_layout: match[4] + is_error: !!match[5], + uses_layout: match[2] ?? match[4] }; } @@ -309,96 +347,29 @@ function analyze(project_relative, file, component_extensions, module_extensions if (module_extension) { const name = file.slice(0, -module_extension.length); const pattern = - /^\+(?:(server)|(page(?:@([a-zA-Z0-9_-]+))?(\.server)?)|(layout(?:-([a-zA-Z0-9_-]+))?(?:@([a-zA-Z0-9_-]+))?(\.server)?))$/; + /^\+(?:(server)|(page(?:(@[a-zA-Z0-9_-]*))?(\.server)?)|(layout(?:(@[a-zA-Z0-9_-]*))?(\.server)?))$/; const match = pattern.exec(name); if (!match) { throw new Error(`Files prefixed with + are reserved (saw ${project_relative})`); - } else if (match[3] || match[7]) { + } else if (match[3] || match[6]) { throw new Error( // prettier-ignore - `Only Svelte files can reference named layouts. Remove '@${match[3] || match[7]}' from ${file} (at ${project_relative})` + `Only Svelte files can reference named layouts. Remove '${match[3] || match[6]}' from ${file} (at ${project_relative})` ); } - const kind = !!(match[1] || match[4] || match[8]) ? 'server' : 'shared'; + const kind = !!(match[1] || match[4] || match[7]) ? 'server' : 'shared'; return { kind, is_page: !!match[2], - is_layout: !!match[5], - declares_layout: match[6] + is_layout: !!match[5] }; } throw new Error(`Files and directories prefixed with + are reserved (saw ${project_relative})`); } -/** - * @param {import('./types').RouteTree} tree - * @param {string} id - * @param {string} layout_id - * @param {string} project_relative - */ -function trace(tree, id, layout_id = DEFAULT, project_relative) { - /** @type {Array} */ - const layouts = []; - - /** @type {Array} */ - const errors = []; - - const parts = id.split('/').filter(Boolean); - - // walk up the tree, find which +layout and +error components - // apply to this page - while (true) { - const node = tree.get(parts.join('/')); - const layout = node?.layouts[layout_id]; - - if (layout && layouts.indexOf(layout) > -1) { - // TODO this needs to be fixed for #5748 - throw new Error( - `Recursive layout detected: ${layout.component} -> ${layouts - .map((l) => l?.component) - .join(' -> ')}` - ); - } - - // any segment that has neither a +layout nor an +error can be discarded. - // in other words these... - // layouts: [a, , b, c] - // errors: [d, , e, ] - // - // ...can be compacted to these: - // layouts: [a, b, c] - // errors: [d, e, ] - if (node?.error || layout) { - errors.unshift(node?.error); - layouts.unshift(layout); - } - - const parent_layout_id = layout?.component?.split('/').at(-1)?.split('@')[1]?.split('.')[0]; - - if (parent_layout_id) { - layout_id = parent_layout_id; - } else { - if (layout) layout_id = DEFAULT; - if (parts.length === 0) break; - parts.pop(); - } - } - - if (layout_id !== DEFAULT) { - throw new Error(`${project_relative} references missing layout "${layout_id}"`); - } - - // trim empty space off the end of the errors array - let i = errors.length; - while (i--) if (errors[i]) break; - errors.length = i + 1; - - return { layouts, errors }; -} - /** * @param {import('types').RouteData} a * @param {import('types').RouteData} b @@ -445,62 +416,31 @@ function compare(a, b, segment_map) { } } - const a_is_endpoint = a.type === 'endpoint'; - const b_is_endpoint = b.type === 'endpoint'; - - if (a_is_endpoint !== b_is_endpoint) { - return a_is_endpoint ? -1 : +1; + if (!!a.endpoint !== !!b.endpoint) { + return a.endpoint ? -1 : +1; } return a < b ? -1 : 1; } -/** - * @param {string} needle - * @param {string} haystack - */ -function count_occurrences(needle, haystack) { - let count = 0; - for (let i = 0; i < haystack.length; i += 1) { - if (haystack[i] === needle) count += 1; - } - return count; -} - -/** - * @param {string} dir - * @param {string} [path] - * @param {string[]} [files] - */ -function list_files(dir, path = '', files = []) { - fs.readdirSync(dir) - .sort((a, b) => { - // sort each directory in (+layout, +error, everything else) order - // so that we can trace layouts/errors immediately - - if (a.startsWith('+layout') || a.startsWith('+error')) { - if (!b.startsWith('+layout') && !b.startsWith('+error')) return -1; - } else if (b.startsWith('+layout') || b.startsWith('+error')) { - return 1; - } else if (a.startsWith('__')) { - if (!b.startsWith('__')) return -1; - } else if (b.startsWith('__')) { - return 1; - } - - return a < b ? -1 : 1; - }) - .forEach((file) => { - const full = `${dir}/${file}`; - const stats = fs.statSync(full); - const joined = path ? `${path}/${file}` : file; - - if (stats.isDirectory()) { - list_files(full, joined, files); +/** @param {string} dir */ +function list_files(dir) { + /** @type {string[]} */ + const files = []; + + /** @param {string} current */ + function walk(current) { + for (const file of fs.readdirSync(path.resolve(dir, current))) { + const child = path.posix.join(current, file); + if (fs.statSync(path.resolve(dir, child)).isDirectory()) { + walk(child); } else { - files.push(joined); + files.push(child); } - }); + } + } + + if (fs.existsSync(dir)) walk(''); return files; } 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 5a5bc953ed16..f22ab207c262 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 @@ -34,65 +34,82 @@ const default_error = { component: 'error.svelte' }; +/** @param {import('types').PageNode} node */ +function simplify_node(node) { + /** @type {import('types').PageNode} */ + const simplified = {}; + + if (node.component) simplified.component = node.component; + if (node.shared) simplified.shared = node.shared; + if (node.server) simplified.server = node.server; + if (node.parent_id !== undefined) simplified.parent_id = node.parent_id; + + return simplified; +} + +/** @param {import('types').RouteData} route */ +function simplify_route(route) { + /** @type {{ id: string, pattern: string, page?: import('types').PageNodeIndexes, endpoint?: { file: string } }} */ + const simplified = { + id: route.id, + pattern: route.pattern.toString().replace(/\\\//g, '/').replace(/\\\./g, '.') + }; + + if (route.page) simplified.page = route.page; + if (route.endpoint) simplified.endpoint = route.endpoint; + + return simplified; +} + test('creates routes', () => { const { nodes, routes } = create('samples/basic'); - const index = { component: 'samples/basic/+page.svelte' }; - const about = { component: 'samples/basic/about/+page.svelte' }; - const blog = { component: 'samples/basic/blog/+page.svelte' }; - const blog_$slug = { component: 'samples/basic/blog/[slug]/+page.svelte' }; - - assert.equal(nodes, [default_layout, default_error, index, about, blog, blog_$slug]); + assert.equal(nodes.map(simplify_node), [ + default_layout, + default_error, + { component: 'samples/basic/+page.svelte' }, + { component: 'samples/basic/about/+page.svelte' }, + { component: 'samples/basic/blog/+page.svelte' }, + { component: 'samples/basic/blog/[slug]/+page.svelte' } + ]); - assert.equal(routes, [ + assert.equal(routes.map(simplify_route), [ { - type: 'page', id: '', - pattern: /^\/$/, - errors: [default_error], - layouts: [default_layout], - leaf: index + pattern: '/^/$/', + page: { layouts: [0], errors: [1], leaf: 2 } }, { - type: 'endpoint', id: 'blog.json', - pattern: /^\/blog\.json$/, - file: 'samples/basic/blog.json/+server.js' + pattern: '/^/blog.json$/', + endpoint: { file: 'samples/basic/blog.json/+server.js' } }, { - type: 'page', id: 'about', - pattern: /^\/about\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: about + pattern: '/^/about/?$/', + page: { layouts: [0], errors: [1], leaf: 3 } }, { - type: 'page', id: 'blog', - pattern: /^\/blog\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: blog + pattern: '/^/blog/?$/', + page: { layouts: [0], errors: [1], leaf: 4 } }, { - type: 'endpoint', id: 'blog/[slug].json', - pattern: /^\/blog\/([^/]+?)\.json$/, - file: 'samples/basic/blog/[slug].json/+server.ts' + pattern: '/^/blog/([^/]+?).json$/', + endpoint: { + file: 'samples/basic/blog/[slug].json/+server.ts' + } }, { - type: 'page', id: 'blog/[slug]', - pattern: /^\/blog\/([^/]+?)\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: blog_$slug + pattern: '/^/blog/([^/]+?)/?$/', + page: { layouts: [0], errors: [1], leaf: 5 } } ]); }); @@ -106,28 +123,24 @@ const test_symlinks = symlink_survived_git ? test : test.skip; test_symlinks('creates symlinked routes', () => { const { nodes, routes } = create('samples/symlinks/routes'); - const index = { component: 'samples/symlinks/routes/index.svelte' }; - const symlinked_index = { component: 'samples/symlinks/routes/foo/index.svelte' }; - - assert.equal(nodes, [default_layout, default_error, symlinked_index, index]); + assert.equal(nodes.map(simplify_node), [ + default_layout, + default_error, + { component: 'samples/symlinks/routes/foo/index.svelte' }, + { component: 'samples/symlinks/routes/index.svelte' } + ]); assert.equal(routes, [ { - type: 'page', id: '', - pattern: /^\/$/, - errors: [default_error], - layouts: [default_layout], - leaf: index + pattern: '/^/$/', + page: { layouts: [0], errors: [1], leaf: 1 } }, { - type: 'page', id: 'foo', - pattern: /^\/foo\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: symlinked_index + pattern: '/^/foo/?$/', + page: { layouts: [0], errors: [1], leaf: 2 } } ]); }); @@ -135,40 +148,32 @@ test_symlinks('creates symlinked routes', () => { test('creates routes with layout', () => { const { nodes, routes } = create('samples/basic-layout'); - const layout = { component: 'samples/basic-layout/+layout.svelte' }; - const index = { component: 'samples/basic-layout/+page.svelte' }; - const foo___layout = { component: 'samples/basic-layout/foo/+layout.svelte' }; - const foo = { component: 'samples/basic-layout/foo/+page.svelte' }; + assert.equal(nodes.map(simplify_node), [ + { component: 'samples/basic-layout/+layout.svelte' }, + default_error, + { component: 'samples/basic-layout/foo/+layout.svelte' }, + { component: 'samples/basic-layout/+page.svelte' }, + { component: 'samples/basic-layout/foo/+page.svelte' } + ]); - assert.equal(nodes, [layout, default_error, foo___layout, index, foo]); + assert.equal(routes.map(simplify_route), [ + { + id: '', + pattern: '/^/$/', + page: { layouts: [0], errors: [1], leaf: 3 } + }, - assert.equal( - routes.slice(1, 2), - [ - { - type: 'page', - id: '', - pattern: /^\/$/, - errors: [default_error], - layouts: [layout], - leaf: index - }, - - { - type: 'page', - id: 'foo', - pattern: /^\/foo\/?$/, - errors: [default_error], - layouts: [layout, foo___layout], - leaf: foo - } - ].slice(1, 2) - ); + { + id: 'foo', + pattern: '/^/foo/?$/', + page: { layouts: [0, 2], errors: [1, undefined], leaf: 4 } + } + ]); }); test('succeeds when routes does not exist', () => { const { nodes, routes } = create('samples/basic/routes'); - assert.equal(nodes, [default_layout, default_error]); + assert.equal(nodes, []); assert.equal(routes, []); }); @@ -182,7 +187,7 @@ test('encodes invalid characters', () => { const hash = { component: 'samples/encoding/%23/+page.svelte' }; // const question_mark = 'samples/encoding/?.svelte'; - assert.equal(nodes, [ + assert.equal(nodes.map(simplify_node), [ default_layout, default_error, // quote, @@ -193,6 +198,7 @@ test('encodes invalid characters', () => { assert.equal( routes.map((p) => p.pattern), [ + /^\/$/, // /^\/%22\/?$/, /^\/%23\/?$/ // /^\/%3F\/?$/ @@ -229,53 +235,74 @@ test('sorts routes correctly', () => { }); test('sorts routes with rest correctly', () => { - const { routes } = create('samples/rest'); + const { nodes, routes } = create('samples/rest'); - assert.equal(routes, [ + assert.equal(nodes.map(simplify_node), [ + default_layout, + default_error, + { + component: 'samples/rest/a/[...rest]/+page.svelte', + server: 'samples/rest/a/[...rest]/+page.server.js' + }, + { + component: 'samples/rest/b/[...rest]/+page.svelte', + server: 'samples/rest/b/[...rest]/+page.server.ts' + } + ]); + + assert.equal(routes.map(simplify_route), [ + { + id: '', + pattern: '/^/$/' + }, + { + id: 'a', + pattern: '/^/a/?$/' + }, + { + id: 'b', + pattern: '/^/b/?$/' + }, { - type: 'page', id: 'a/[...rest]', - pattern: /^\/a(?:\/(.*))?\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: { - component: 'samples/rest/a/[...rest]/+page.svelte', - server: 'samples/rest/a/[...rest]/+page.server.js' - } + pattern: '/^/a(?:/(.*))?/?$/', + page: { layouts: [0], errors: [1], leaf: 2 } }, { - type: 'page', id: 'b/[...rest]', - pattern: /^\/b(?:\/(.*))?\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: { - component: 'samples/rest/b/[...rest]/+page.svelte', - server: 'samples/rest/b/[...rest]/+page.server.ts' - } + pattern: '/^/b(?:/(.*))?/?$/', + page: { layouts: [0], errors: [1], leaf: 3 } } ]); }); test('allows rest parameters inside segments', () => { - const { routes } = create('samples/rest-prefix-suffix'); + const { nodes, routes } = create('samples/rest-prefix-suffix'); - assert.equal(routes, [ + assert.equal(nodes.map(simplify_node), [ + default_layout, + default_error, + { + component: 'samples/rest-prefix-suffix/prefix-[...rest]/+page.svelte' + } + ]); + + assert.equal(routes.map(simplify_route), [ + { + id: '', + pattern: '/^/$/' + }, { - type: 'page', id: 'prefix-[...rest]', - pattern: /^\/prefix-(.*?)\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: { - component: 'samples/rest-prefix-suffix/prefix-[...rest]/+page.svelte' - } + pattern: '/^/prefix-(.*?)/?$/', + page: { layouts: [0], errors: [1], leaf: 2 } }, { - type: 'endpoint', id: '[...rest].json', - pattern: /^\/(.*?)\.json$/, - file: 'samples/rest-prefix-suffix/[...rest].json/+server.js' + pattern: '/^/(.*?).json$/', + endpoint: { + file: 'samples/rest-prefix-suffix/[...rest].json/+server.js' + } } ]); }); @@ -283,7 +310,7 @@ test('allows rest parameters inside segments', () => { test('ignores files and directories with leading underscores', () => { const { routes } = create('samples/hidden-underscore'); - assert.equal(routes.map((r) => r.type === 'endpoint' && r.file).filter(Boolean), [ + assert.equal(routes.map((r) => r.endpoint?.file).filter(Boolean), [ 'samples/hidden-underscore/e/f/g/h/+server.js' ]); }); @@ -291,7 +318,7 @@ test('ignores files and directories with leading underscores', () => { test('ignores files and directories with leading dots except .well-known', () => { const { routes } = create('samples/hidden-dot'); - assert.equal(routes.map((r) => r.type === 'endpoint' && r.file).filter(Boolean), [ + assert.equal(routes.map((r) => r.endpoint?.file).filter(Boolean), [ 'samples/hidden-dot/.well-known/dnt-policy.txt/+server.js' ]); }); @@ -299,34 +326,37 @@ test('ignores files and directories with leading dots except .well-known', () => test('allows multiple slugs', () => { const { routes } = create('samples/multiple-slugs'); - assert.equal( - routes.filter((route) => route.type === 'endpoint'), - [ - { - type: 'endpoint', - id: '[file].[ext]', - pattern: /^\/([^/]+?)\.([^/]+?)$/, + assert.equal(routes.filter((route) => route.endpoint).map(simplify_route), [ + { + id: '[file].[ext]', + pattern: '/^/([^/]+?).([^/]+?)$/', + endpoint: { file: 'samples/multiple-slugs/[file].[ext]/+server.js' } - ] - ); + } + ]); }); test('fails if dynamic params are not separated', () => { assert.throws(() => { create('samples/invalid-params'); - }, /Invalid route samples\/invalid-params\/\[foo\]\[bar\]\/\+server\.js — parameters must be separated/); + }, /Invalid route \[foo\]\[bar\] — parameters must be separated/); }); test('ignores things that look like lockfiles', () => { const { routes } = create('samples/lockfiles'); - assert.equal(routes, [ + assert.equal(routes.map(simplify_route), [ + { + id: '', + pattern: '/^/$/' + }, { - type: 'endpoint', id: 'foo', - file: 'samples/lockfiles/foo/+server.js', - pattern: /^\/foo\/?$/ + pattern: '/^/foo/?$/', + endpoint: { + file: 'samples/lockfiles/foo/+server.js' + } } ]); }); @@ -336,62 +366,54 @@ test('works with custom extensions', () => { extensions: ['.jazz', '.beebop', '.funk', '.svelte'] }); - const index = { component: 'samples/custom-extension/+page.funk' }; - const about = { component: 'samples/custom-extension/about/+page.jazz' }; - const blog = { component: 'samples/custom-extension/blog/+page.svelte' }; - const blog_$slug = { component: 'samples/custom-extension/blog/[slug]/+page.beebop' }; - - assert.equal(nodes, [default_layout, default_error, index, about, blog, blog_$slug]); + assert.equal(nodes.map(simplify_node), [ + default_layout, + default_error, + { component: 'samples/custom-extension/+page.funk' }, + { component: 'samples/custom-extension/about/+page.jazz' }, + { component: 'samples/custom-extension/blog/+page.svelte' }, + { component: 'samples/custom-extension/blog/[slug]/+page.beebop' } + ]); - assert.equal(routes, [ + assert.equal(routes.map(simplify_route), [ { - type: 'page', id: '', - pattern: /^\/$/, - errors: [default_error], - layouts: [default_layout], - leaf: index + pattern: '/^/$/', + page: { layouts: [0], errors: [1], leaf: 2 } }, { - type: 'endpoint', id: 'blog.json', - pattern: /^\/blog\.json$/, - file: 'samples/custom-extension/blog.json/+server.js' + pattern: '/^/blog.json$/', + endpoint: { + file: 'samples/custom-extension/blog.json/+server.js' + } }, { - type: 'page', id: 'about', - pattern: /^\/about\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: about + pattern: '/^/about/?$/', + page: { layouts: [0], errors: [1], leaf: 3 } }, { - type: 'page', id: 'blog', - pattern: /^\/blog\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: blog + pattern: '/^/blog/?$/', + page: { layouts: [0], errors: [1], leaf: 4 } }, { - type: 'endpoint', id: 'blog/[slug].json', - pattern: /^\/blog\/([^/]+?)\.json$/, - file: 'samples/custom-extension/blog/[slug].json/+server.js' + pattern: '/^/blog/([^/]+?).json$/', + endpoint: { + file: 'samples/custom-extension/blog/[slug].json/+server.js' + } }, { - type: 'page', id: 'blog/[slug]', - pattern: /^\/blog\/([^/]+?)\/?$/, - errors: [default_error], - layouts: [default_layout], - leaf: blog_$slug + pattern: '/^/blog/([^/]+?)/?$/', + page: { layouts: [0], errors: [1], leaf: 5 } } ]); }); @@ -414,28 +436,35 @@ test('lists static assets', () => { }); test('includes nested error components', () => { - const { routes } = create('samples/nested-errors'); + const { nodes, routes } = create('samples/nested-errors'); - assert.equal(routes, [ + assert.equal(nodes.map(simplify_node), [ + default_layout, + default_error, + { component: 'samples/nested-errors/foo/+layout.svelte' }, + { component: 'samples/nested-errors/foo/bar/+error.svelte' }, + { component: 'samples/nested-errors/foo/bar/baz/+layout.svelte' }, + { component: 'samples/nested-errors/foo/bar/baz/+error.svelte' }, + { component: 'samples/nested-errors/foo/bar/baz/+page.svelte' } + ]); + + assert.equal(routes.map(simplify_route), [ + { + id: '', + pattern: '/^/$/' + }, + { + id: 'foo', + pattern: '/^/foo/?$/' + }, + { + id: 'foo/bar', + pattern: '/^/foo/bar/?$/' + }, { - type: 'page', id: 'foo/bar/baz', - pattern: /^\/foo\/bar\/baz\/?$/, - errors: [ - default_error, - undefined, - { component: 'samples/nested-errors/foo/bar/+error.svelte' }, - { component: 'samples/nested-errors/foo/bar/baz/+error.svelte' } - ], - layouts: [ - default_layout, - { component: 'samples/nested-errors/foo/+layout.svelte' }, - undefined, - { component: 'samples/nested-errors/foo/bar/baz/+layout.svelte' } - ], - leaf: { - component: 'samples/nested-errors/foo/bar/baz/+page.svelte' - } + pattern: '/^/foo/bar/baz/?$/', + page: { layouts: [0, 2, undefined, 4], errors: [1, undefined, 3, 5], leaf: 6 } } ]); }); @@ -443,200 +472,112 @@ test('includes nested error components', () => { test('creates routes with named layouts', () => { const { nodes, routes } = create('samples/named-layouts'); - assert.equal(nodes, [ - { component: 'samples/named-layouts/+layout.svelte' }, - default_error, - { component: 'samples/named-layouts/+layout-home@default.svelte' }, - { - component: 'samples/named-layouts/+layout-special.svelte', - shared: 'samples/named-layouts/+layout-special.js', - server: 'samples/named-layouts/+layout-special.server.js' - }, - { component: 'samples/named-layouts/a/+layout.svelte' }, - { component: 'samples/named-layouts/b/+layout-alsospecial@special.svelte' }, - { component: 'samples/named-layouts/b/c/+layout.svelte' }, - { component: 'samples/named-layouts/b/d/+layout-extraspecial@special.svelte' }, - { component: 'samples/named-layouts/b/d/+layout-special.svelte' }, - { component: 'samples/named-layouts/a/a1/+page.svelte' }, - { component: 'samples/named-layouts/a/a2/+page@special.svelte' }, - { component: 'samples/named-layouts/b/c/c1/+page@alsospecial.svelte' }, - { component: 'samples/named-layouts/b/c/c2/+page@home.svelte' }, - { component: 'samples/named-layouts/b/d/+page@special.svelte' }, - { component: 'samples/named-layouts/b/d/d1/+page.svelte' }, - { component: 'samples/named-layouts/b/d/d2/+page@extraspecial.svelte' } + assert.equal(nodes.map(simplify_node), [ + // layouts + { component: 'samples/named-layouts/+layout.svelte' }, // 0 + default_error, // 1 + { + component: 'samples/named-layouts/(special)/+layout.svelte', + shared: 'samples/named-layouts/(special)/+layout.js', + server: 'samples/named-layouts/(special)/+layout.server.js' + }, // 2 + { component: 'samples/named-layouts/(special)/(alsospecial)/+layout.svelte' }, // 3 + { component: 'samples/named-layouts/a/+layout.svelte' }, // 4 + { component: 'samples/named-layouts/b/c/+layout.svelte' }, // 5 + { component: 'samples/named-layouts/b/d/(special)/+layout.svelte' }, // 6 + { component: 'samples/named-layouts/b/d/(special)/(extraspecial)/+layout.svelte' }, // 7 + + // pages + { component: 'samples/named-layouts/(special)/(alsospecial)/b/c/c1/+page.svelte' }, // 8 + { component: 'samples/named-layouts/(special)/a/a2/+page.svelte' }, // 9 + { component: 'samples/named-layouts/a/a1/+page.svelte' }, // 10 + { component: 'samples/named-layouts/b/c/c2/+page@.svelte', parent_id: '' }, // 11 + { component: 'samples/named-layouts/b/d/(special)/+page.svelte' }, // 12 + { component: 'samples/named-layouts/b/d/(special)/(extraspecial)/d2/+page.svelte' }, // 13 + { component: 'samples/named-layouts/b/d/d1/+page.svelte' } // 14 ]); - assert.equal(routes, [ + assert.equal(routes.filter((route) => route.page).map(simplify_route), [ { - type: 'page', id: 'a/a1', - pattern: /^\/a\/a1\/?$/, - errors: [default_error], - layouts: [ - { component: 'samples/named-layouts/+layout.svelte' }, - { component: 'samples/named-layouts/a/+layout.svelte' } - ], - leaf: { component: 'samples/named-layouts/a/a1/+page.svelte' } - }, - { - type: 'page', - id: 'a/a2', - pattern: /^\/a\/a2\/?$/, - errors: [default_error], - layouts: [ - { - component: 'samples/named-layouts/+layout-special.svelte', - shared: 'samples/named-layouts/+layout-special.js', - server: 'samples/named-layouts/+layout-special.server.js' - } - ], - leaf: { component: 'samples/named-layouts/a/a2/+page@special.svelte' } - }, - { - type: 'page', - id: 'b/d', - pattern: /^\/b\/d\/?$/, - errors: [default_error], - layouts: [ - { component: 'samples/named-layouts/+layout.svelte' }, - { component: 'samples/named-layouts/b/d/+layout-special.svelte' } - ], - leaf: { component: 'samples/named-layouts/b/d/+page@special.svelte' } - }, - { - type: 'page', - id: 'b/c/c1', - pattern: /^\/b\/c\/c1\/?$/, - errors: [default_error], - layouts: [ - { - component: 'samples/named-layouts/+layout-special.svelte', - shared: 'samples/named-layouts/+layout-special.js', - server: 'samples/named-layouts/+layout-special.server.js' - }, - { component: 'samples/named-layouts/b/+layout-alsospecial@special.svelte' } - ], - leaf: { component: 'samples/named-layouts/b/c/c1/+page@alsospecial.svelte' } - }, - { - type: 'page', + pattern: '/^/a/a1/?$/', + page: { layouts: [0, 4], errors: [1, undefined], leaf: 10 } + }, + { + id: '(special)/a/a2', + pattern: '/^/a/a2/?$/', + page: { layouts: [0, 2], errors: [1, undefined], leaf: 9 } + }, + { id: 'b/c/c2', - pattern: /^\/b\/c\/c2\/?$/, - errors: [default_error, default_error], - layouts: [ - { component: 'samples/named-layouts/+layout.svelte' }, - { component: 'samples/named-layouts/+layout-home@default.svelte' } - ], - leaf: { component: 'samples/named-layouts/b/c/c2/+page@home.svelte' } + pattern: '/^/b/c/c2/?$/', + page: { layouts: [0], errors: [1], leaf: 11 } + }, + { + id: 'b/d/(special)', + pattern: '/^/b/d/?$/', + page: { layouts: [0, 6], errors: [1, undefined], leaf: 12 } }, { - type: 'page', id: 'b/d/d1', - pattern: /^\/b\/d\/d1\/?$/, - errors: [default_error], - layouts: [{ component: 'samples/named-layouts/+layout.svelte' }], - leaf: { component: 'samples/named-layouts/b/d/d1/+page.svelte' } - }, - { - type: 'page', - id: 'b/d/d2', - pattern: /^\/b\/d\/d2\/?$/, - errors: [default_error], - layouts: [ - { component: 'samples/named-layouts/+layout.svelte' }, - { component: 'samples/named-layouts/b/d/+layout-special.svelte' }, - { component: 'samples/named-layouts/b/d/+layout-extraspecial@special.svelte' } - ], - leaf: { component: 'samples/named-layouts/b/d/d2/+page@extraspecial.svelte' } + pattern: '/^/b/d/d1/?$/', + page: { layouts: [0], errors: [1], leaf: 14 } + }, + { + id: '(special)/(alsospecial)/b/c/c1', + pattern: '/^/b/c/c1/?$/', + page: { layouts: [0, 2, 3], errors: [1, undefined, undefined], leaf: 8 } + }, + { + id: 'b/d/(special)/(extraspecial)/d2', + pattern: '/^/b/d/d2/?$/', + page: { layouts: [0, 6, 7], errors: [1, undefined, undefined], leaf: 13 } } ]); }); test('handles pages without .svelte file', () => { - const { routes } = create('samples/page-without-svelte-file'); + const { nodes, routes } = create('samples/page-without-svelte-file'); - assert.equal(routes, [ + assert.equal(nodes.map(simplify_node), [ + default_layout, + default_error, + { component: 'samples/page-without-svelte-file/error/+error.svelte' }, + { component: 'samples/page-without-svelte-file/layout/+layout.svelte' }, + { component: 'samples/page-without-svelte-file/+page.svelte' }, + { shared: 'samples/page-without-svelte-file/error/[...path]/+page.js' }, + { component: 'samples/page-without-svelte-file/layout/exists/+page.svelte' }, + { server: 'samples/page-without-svelte-file/layout/redirect/+page.server.js' } + ]); + + assert.equal(routes.map(simplify_route), [ { - type: 'page', id: '', - pattern: /^\/$/, - errors: [ - { - component: 'error.svelte' - } - ], - layouts: [ - { - component: 'layout.svelte' - } - ], - leaf: { - component: 'samples/page-without-svelte-file/+page.svelte' - } + pattern: '/^/$/', + page: { layouts: [0], errors: [1], leaf: 4 } + }, + { + id: 'error', + pattern: '/^/error/?$/' + }, + { + id: 'layout', + pattern: '/^/layout/?$/' }, { - type: 'page', id: 'layout/exists', - pattern: /^\/layout\/exists\/?$/, - errors: [ - { - component: 'error.svelte' - } - ], - layouts: [ - { - component: 'layout.svelte' - }, - { - component: 'samples/page-without-svelte-file/layout/+layout.svelte' - } - ], - leaf: { - component: 'samples/page-without-svelte-file/layout/exists/+page.svelte' - } + pattern: '/^/layout/exists/?$/', + page: { layouts: [0, 3], errors: [1, undefined], leaf: 6 } }, { - type: 'page', id: 'layout/redirect', - pattern: /^\/layout\/redirect\/?$/, - errors: [ - { - component: 'error.svelte' - } - ], - layouts: [ - { - component: 'layout.svelte' - }, - { - component: 'samples/page-without-svelte-file/layout/+layout.svelte' - } - ], - leaf: { - server: 'samples/page-without-svelte-file/layout/redirect/+page.server.js' - } + pattern: '/^/layout/redirect/?$/', + page: { layouts: [0, 3], errors: [1, undefined], leaf: 7 } }, { - type: 'page', id: 'error/[...path]', - pattern: /^\/error(?:\/(.*))?\/?$/, - errors: [ - { - component: 'error.svelte' - }, - { - component: 'samples/page-without-svelte-file/error/+error.svelte' - } - ], - layouts: [ - { - component: 'layout.svelte' - }, - undefined - ], - leaf: { - shared: 'samples/page-without-svelte-file/error/[...path]/+page.js' - } + pattern: '/^/error(?:/(.*))?/?$/', + page: { layouts: [0, undefined], errors: [1, 2], leaf: 5 } } ]); }); @@ -644,44 +585,14 @@ test('handles pages without .svelte file', () => { test('errors on missing layout', () => { assert.throws( () => create('samples/named-layout-missing'), - /samples\/named-layout-missing\/\+page@missing.svelte references missing layout "missing"/ - ); -}); - -test('errors on layout named default', () => { - assert.throws( - () => create('samples/named-layout-default'), - /samples\/named-layout-default\/\+layout-default.svelte cannot use reserved "default" name/ + /samples\/named-layout-missing\/\+page@missing.svelte references missing segment "missing"/ ); }); test('errors on invalid named layout reference', () => { assert.throws( () => create('samples/invalid-named-layout-reference'), - /Only Svelte files can reference named layouts. Remove '@named' from \+page@named.js \(at samples\/invalid-named-layout-reference\/\+page@named.js\)/ - ); -}); - -test('errors on duplicate layout definition', () => { - assert.throws( - () => create('samples/duplicate-layout'), - /Duplicate layout samples\/duplicate-layout\/\+layout-a@x.svelte already defined at samples\/duplicate-layout\/\+layout-a.svelte/ - ); -}); - -test('errors on recursive name layout', () => { - assert.throws( - () => create('samples/named-layout-recursive-1'), - /Recursive layout detected: samples\/named-layout-recursive-1\/\+layout-a@b\.svelte -> samples\/named-layout-recursive-1\/\+layout-b@a\.svelte -> samples\/named-layout-recursive-1\/\+layout-a@b\.svelte/ - ); - assert.throws( - () => create('samples/named-layout-recursive-2'), - /Recursive layout detected: samples\/named-layout-recursive-2\/\+layout-a@a\.svelte -> samples\/named-layout-recursive-2\/\+layout-a@a\.svelte/ - ); - - assert.throws( - () => create('samples/named-layout-recursive-3'), - /Recursive layout detected: samples\/named-layout-recursive-3\/\+layout@a\.svelte -> samples\/named-layout-recursive-3\/\+layout-a@default\.svelte -> samples\/named-layout-recursive-3\/\+layout@a\.svelte/ + /Only Svelte files can reference named layouts. Remove '@' from \+page@.js \(at samples\/invalid-named-layout-reference\/x\/\+page@.js\)/ ); }); @@ -720,4 +631,19 @@ test('errors on duplicate matchers', () => { } }); +test('prevents route conflicts between groups', () => { + assert.throws( + () => create('samples/conflicting-groups'), + /\(x\)\/a and \(y\)\/a occupy the same route/ + ); +}); + +// TODO remove for 1.0 +test('errors on encountering a declared layout', () => { + assert.throws( + () => create('samples/declared-layout'), + /samples\/declared-layout\/\+layout-foo.svelte should be reimplemented with layout groups: https:\/\/kit\.svelte\.dev\/docs\/advanced-routing#advanced-layouts/ + ); +}); + test.run(); diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-3/+page.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/conflicting-groups/(x)/a/+page.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-3/+page.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/conflicting-groups/(x)/a/+page.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/duplicate-layout/+layout-a.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/conflicting-groups/(y)/a/+page.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/duplicate-layout/+layout-a.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/conflicting-groups/(y)/a/+page.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/duplicate-layout/+layout-a@x.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/declared-layout/+layout-foo.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/duplicate-layout/+layout-a@x.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/declared-layout/+layout-foo.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/+layout-named.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/x/+page@.js similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/+layout-named.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/x/+page@.js diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/+page@named.js b/packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/x/+page@.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/+page@named.js rename to packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/x/+page@.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/+page@named.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/(alsospecial)/+layout.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/invalid-named-layout-reference/+page@named.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/(alsospecial)/+layout.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-default/+layout-default.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/(alsospecial)/b/c/c1/+page.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-default/+layout-default.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/(alsospecial)/b/c/c1/+page.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-1/+layout-a@b.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/+layout.js similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-1/+layout-a@b.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/+layout.js diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-1/+layout-b@a.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/+layout.server.js similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-1/+layout-b@a.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/+layout.server.js diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-1/+page@a.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/+layout.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-1/+page@a.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/+layout.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-2/+layout-a@a.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/a/a2/+page.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-2/+layout-a@a.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/(special)/a/a2/+page.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/+layout-special.server.js b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/+layout-special.server.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/+layout-special.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/+layout-special.svelte deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/a/a2/+page@special.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/a/a2/+page@special.svelte deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/+layout-alsospecial@special.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/+layout-alsospecial@special.svelte deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/c/c1/+page@alsospecial.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/c/c1/+page@alsospecial.svelte deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-2/+page@a.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/c/c2/+page@.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-2/+page@a.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/c/c2/+page@.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/c/c2/+page@home.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/c/c2/+page@home.svelte deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-3/+layout-a@default.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/(special)/(extraspecial)/+layout.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-3/+layout-a@default.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/(special)/(extraspecial)/+layout.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-3/+layout@a.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/(special)/(extraspecial)/d2/+page.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layout-recursive-3/+layout@a.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/(special)/(extraspecial)/d2/+page.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/+layout-home@default.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/(special)/+layout.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/+layout-home@default.svelte rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/(special)/+layout.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/+layout-special.js b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/(special)/+page.svelte similarity index 100% rename from packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/+layout-special.js rename to packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/(special)/+page.svelte diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/+layout-extraspecial@special.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/+layout-extraspecial@special.svelte deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/+layout-special.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/+layout-special.svelte deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/+page@special.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/+page@special.svelte deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/d2/+page@extraspecial.svelte b/packages/kit/src/core/sync/create_manifest_data/test/samples/named-layouts/b/d/d2/+page@extraspecial.svelte deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/kit/src/core/sync/create_manifest_data/types.d.ts b/packages/kit/src/core/sync/create_manifest_data/types.d.ts index ac63955a9001..e897cc9e6458 100644 --- a/packages/kit/src/core/sync/create_manifest_data/types.d.ts +++ b/packages/kit/src/core/sync/create_manifest_data/types.d.ts @@ -9,7 +9,7 @@ interface Part { interface RouteTreeNode { error: PageNode | undefined; - layouts: Record; + layout: PageNode | undefined; } export type RouteTree = Map; @@ -20,21 +20,18 @@ interface RouteComponent { is_layout: boolean; is_error: boolean; uses_layout: string | undefined; - declares_layout: string | undefined; } interface RouteSharedModule { kind: 'shared'; is_page: boolean; is_layout: boolean; - declares_layout: string | undefined; } interface RouteServerModule { kind: 'server'; is_page: boolean; is_layout: boolean; - declares_layout: string | undefined; } export type RouteFile = RouteComponent | RouteSharedModule | RouteServerModule; diff --git a/packages/kit/src/core/sync/sync.js b/packages/kit/src/core/sync/sync.js index 60e71ba0f767..4d451066770b 100644 --- a/packages/kit/src/core/sync/sync.js +++ b/packages/kit/src/core/sync/sync.js @@ -4,7 +4,7 @@ import { write_client_manifest } from './write_client_manifest.js'; import { write_matchers } from './write_matchers.js'; import { write_root } from './write_root.js'; import { write_tsconfig } from './write_tsconfig.js'; -import { write_type, write_types } from './write_types.js'; +import { write_types, write_all_types } from './write_types.js'; import { write_ambient } from './write_ambient.js'; /** @@ -29,7 +29,7 @@ export async function create(config) { write_client_manifest(manifest_data, output); write_root(manifest_data, output); write_matchers(manifest_data, output); - await write_types(config, manifest_data); + await write_all_types(config, manifest_data); return { manifest_data }; } @@ -43,7 +43,7 @@ export async function create(config) { * @param {string} file */ export async function update(config, manifest_data, file) { - await write_type(config, manifest_data, file); + await write_types(config, manifest_data, file); return { manifest_data }; } diff --git a/packages/kit/src/core/sync/utils.js b/packages/kit/src/core/sync/utils.js index c0ee2f2cd278..be1862cd1927 100644 --- a/packages/kit/src/core/sync/utils.js +++ b/packages/kit/src/core/sync/utils.js @@ -25,17 +25,6 @@ export function write(file, code) { fs.writeFileSync(file, code); } -/** - * @param {(file: string) => boolean} should_remove - */ -export function remove_from_previous(should_remove) { - for (const key of previous_contents.keys()) { - if (should_remove(key)) { - previous_contents.delete(key); - } - } -} - /** @param {string} str */ export function trim(str) { const indentation = /** @type {RegExpExecArray} */ (/\n?(\s*)/.exec(str))[1]; diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js index 9178c9d95bda..99bc0eccd24b 100644 --- a/packages/kit/src/core/sync/write_client_manifest.js +++ b/packages/kit/src/core/sync/write_client_manifest.js @@ -9,9 +9,6 @@ import { trim, write_if_changed } from './utils.js'; * @param {string} output */ export function write_client_manifest(manifest_data, output) { - /** @type {Map} */ - const node_indexes = new Map(); - /** * Creates a module that exports a `CSRPageNode` * @param {import('types').PageNode} node @@ -41,7 +38,6 @@ export function write_client_manifest(manifest_data, output) { const nodes = manifest_data.nodes .map((node, i) => { - node_indexes.set(node, i); write_if_changed(`${output}/nodes/${i}.js`, generate_node(node)); return `() => import('./nodes/${i}')`; }) @@ -50,17 +46,35 @@ export function write_client_manifest(manifest_data, output) { const dictionary = `{ ${manifest_data.routes .map((route) => { - if (route.type === 'page') { - const errors = route.errors.map((node) => (node ? node_indexes.get(node) : '')).join(','); - const layouts = route.layouts - .map((node) => (node ? node_indexes.get(node) : '')) - .join(','); - const leaf = route.leaf ? node_indexes.get(route.leaf) : ''; + if (route.page) { + const errors = route.page.errors.slice(1).map((n) => n ?? ''); + const layouts = route.page.layouts.slice(1).map((n) => n ?? ''); + + while (layouts.at(-1) === '') layouts.pop(); + while (errors.at(-1) === '') errors.pop(); + + /** @type {import('types').RouteData | null} */ + let current_route = route; + + /** @type {import('types').PageNode | null} */ + let current_node = route.leaf; + + let uses_server_data = false; + while (current_route && !uses_server_data) { + uses_server_data = !!current_node?.server; + current_route = current_route.parent; + current_node = current_route?.layout ?? null; + } + + // encode whether or not the route uses the server data + // using the ones' complement, to save space + const array = [`${uses_server_data ? '~' : ''}${route.page.leaf}`]; - const uses_server_data = [...route.layouts, route.leaf].some((node) => node?.server); - const suffix = uses_server_data ? ', 1' : ''; + // only include non-root layout/error nodes if they exist + if (layouts.length > 0 || errors.length > 0) array.push(`[${layouts.join(',')}]`); + if (errors.length > 0) array.push(`[${errors.join(',')}]`); - return `${s(route.id)}: [[${errors}], [${layouts}], ${leaf}${suffix}]`; + return `${s(route.id)}: [${array.join(',')}]`; } }) .filter(Boolean) diff --git a/packages/kit/src/core/sync/write_root.js b/packages/kit/src/core/sync/write_root.js index 0dd8536bdd37..f954e3fcba48 100644 --- a/packages/kit/src/core/sync/write_root.js +++ b/packages/kit/src/core/sync/write_root.js @@ -9,7 +9,7 @@ export function write_root(manifest_data, output) { const max_depth = Math.max( ...manifest_data.routes.map((route) => - route.type === 'page' ? route.layouts.filter(Boolean).length + 1 : 0 + route.page ? route.page.layouts.filter(Boolean).length + 1 : 0 ), 1 ); diff --git a/packages/kit/src/core/sync/write_types.js b/packages/kit/src/core/sync/write_types.js index 624223027bbe..470bd418e16f 100644 --- a/packages/kit/src/core/sync/write_types.js +++ b/packages/kit/src/core/sync/write_types.js @@ -1,28 +1,8 @@ import fs from 'fs'; import path from 'path'; import MagicString from 'magic-string'; -import { posixify, rimraf } from '../../utils/filesystem.js'; -import { parse_route_id } from '../../utils/routing.js'; -import { remove_from_previous, write_if_changed } from './utils.js'; - -/** - * @typedef { import('types').PageNode & { - * parent?: { - * key: string; - * name: string; - * folder_depth_diff: number; - * } - * } } Node - */ - -/** - * @typedef {{ - * leaf?: Node; - * default_layout?: Node; - * named_layouts: Map; - * endpoint?: string; - * }} NodeGroup - */ +import { posixify, rimraf, walk } from '../../utils/filesystem.js'; +import { compact } from '../../utils/array.js'; /** * @typedef {{ @@ -32,200 +12,133 @@ import { remove_from_previous, write_if_changed } from './utils.js'; * } | null} Proxy */ +/** @type {import('typescript')} */ +// @ts-ignore +let ts = undefined; +try { + ts = (await import('typescript')).default; +} catch {} + const cwd = process.cwd(); const shared_names = new Set(['load']); const server_names = new Set(['load', 'POST', 'PUT', 'PATCH', 'DELETE']); // TODO replace with a single `action` -let first_run = true; - /** * Creates types for the whole manifest - * * @param {import('types').ValidatedConfig} config * @param {import('types').ManifestData} manifest_data */ -export async function write_types(config, manifest_data) { - /** @type {import('typescript') | undefined} */ - let ts = undefined; - try { - ts = (await import('typescript')).default; - } catch (e) { - // No TypeScript installed - skip type generation - return; - } +export async function write_all_types(config, manifest_data) { + if (!ts) return; const types_dir = `${config.kit.outDir}/types`; - if (first_run) { - rimraf(types_dir); - first_run = false; - } - - const routes_dir = posixify(path.relative('.', config.kit.files.routes)); - const groups = get_groups(manifest_data, routes_dir); + // empty out files that no longer need to exist + const routes_dir = path.relative('.', config.kit.files.routes); + const expected_directories = new Set( + manifest_data.routes.map((route) => path.join(routes_dir, route.id)) + ); - let written_files = new Set(); - // ...then, for each directory, write $types.d.ts - for (const [dir] of groups) { - const written = write_types_for_dir(config, manifest_data, routes_dir, dir, groups, ts); - written.forEach((w) => written_files.add(w)); + if (fs.existsSync(types_dir)) { + for (const file of walk(types_dir)) { + const dir = path.dirname(file); + if (!expected_directories.has(dir)) { + rimraf(file); + } + } } - // Remove all files that were not updated, which means their original was removed - remove_from_previous((file) => { - const was_removed = file.startsWith(types_dir) && !written_files.has(file); - if (was_removed) { - rimraf(file); - } - return was_removed; - }); + // For each directory, write $types.d.ts + for (const route of manifest_data.routes) { + update_types(config, manifest_data, route); + } } /** * Creates types related to the given file. This should only be called * if the file in question was edited, not if it was created/deleted/moved. - * * @param {import('types').ValidatedConfig} config * @param {import('types').ManifestData} manifest_data * @param {string} file */ -export async function write_type(config, manifest_data, file) { +export async function write_types(config, manifest_data, file) { + if (!ts) return; + if (!path.basename(file).startsWith('+')) { // Not a route file return; } - /** @type {import('typescript') | undefined} */ - let ts = undefined; - try { - ts = (await import('typescript')).default; - } catch (e) { - // No TypeScript installed - skip type generation - return; - } + const filepath = path.relative(config.kit.files.routes, file); + const id = path.dirname(filepath); - const routes_dir = posixify(path.relative('.', config.kit.files.routes)); - const file_dir = posixify(path.dirname(file).slice(config.kit.files.routes.length + 1)); - const groups = get_groups(manifest_data, routes_dir); + const route = manifest_data.routes.find((route) => route.id === id); + if (!route) return; // this shouldn't ever happen - // We are only interested in the directory that contains the file - write_types_for_dir(config, manifest_data, routes_dir, file_dir, groups, ts); + update_types(config, manifest_data, route); } /** + * + * @param {import('types').ValidatedConfig} config * @param {import('types').ManifestData} manifest_data - * @param {string} routes_dir + * @param {import('types').RouteData} route */ -function get_groups(manifest_data, routes_dir) { - /** - * A map of all directories : route files. We don't just use - * manifest_data.routes, because that will exclude +layout - * files that aren't accompanied by a +page - * @type {Map} - */ - const groups = new Map(); - - /** @param {string} dir */ - function get_group(dir) { - let group = groups.get(dir); - if (!group) { - group = { - named_layouts: new Map() - }; - groups.set(dir, group); - } - - return group; - } - - // first, sort nodes (necessary for finding the nearest layout more efficiently)... - const nodes = [...manifest_data.nodes].sort((n1, n2) => { - // Sort by path length first... - const path_length_diff = - /** @type {string} */ (n1.component ?? n1.shared ?? n1.server).split('/').length - - /** @type {string} */ (n2.component ?? n2.shared ?? n2.server).split('/').length; - - return ( - path_length_diff || - // ...on ties, sort named layouts first - (path.basename(n1.component || '').includes('-') - ? -1 - : path.basename(n2.component || '').includes('-') - ? 1 - : 0) - ); - }); - - // ...then, populate `directories` with +page/+layout files... - for (let i = 0; i < nodes.length; i += 1) { - /** @type {Node} */ - const node = { ...nodes[i] }; // shallow copy so we don't mutate the original when setting parent - - const file_path = /** @type {string} */ (node.component ?? node.shared ?? node.server); - // skip default layout/error - if (!file_path.startsWith(routes_dir)) continue; - - const parts = file_path.split('/'); - - const file = /** @type {string} */ (parts.pop()); - const dir = parts.join('/').slice(routes_dir.length + 1); - - // error pages don't need types - if (!file || file.startsWith('+error')) continue; - - const group = get_group(dir); - - if (file.startsWith('+page')) { - group.leaf = node; - } else { - const match = /^\+layout(?:-([^@.]+))?/.exec(file); +function update_types(config, manifest_data, route) { + const routes_dir = posixify(path.relative('.', config.kit.files.routes)); + const outdir = path.join(config.kit.outDir, 'types', routes_dir, route.id); - // this shouldn't happen, but belt and braces. also keeps TS happy, - // and we live to keep TS happy - if (!match) throw new Error(`Unexpected route file: ${file}`); + // first, check if the types are out of date + const input_files = []; - if (match[1]) { - group.named_layouts.set(match[1], node); - } else { - group.default_layout = node; - } - } + /** @type {import('types').PageNode | null} */ + let node = route.leaf; + while (node) { + if (node.shared) input_files.push(node.shared); + if (node.server) input_files.push(node.server); + node = node.parent ?? null; + } - node.parent = find_nearest_layout(routes_dir, nodes, i); + /** @type {import('types').PageNode | null} */ + node = route.layout; + while (node) { + if (node.shared) input_files.push(node.shared); + if (node.server) input_files.push(node.server); + node = node.parent ?? null; } - // ...then add +server.js files... - for (const route of manifest_data.routes) { - if (route.type === 'endpoint') { - get_group(route.id).endpoint = route.file; - } + if (route.endpoint) { + input_files.push(route.endpoint.file); } - return groups; -} + if (input_files.length === 0) return; // nothing to do -/** - * - * @param {import('types').ValidatedConfig} config - * @param {import('types').ManifestData} manifest_data - * @param {string} routes_dir - * @param {string} dir - * @param {Map} groups - * @param {import('typescript')} ts - */ -function write_types_for_dir(config, manifest_data, routes_dir, dir, groups, ts) { - const group = groups.get(dir); - if (!group) { - return []; - } + try { + fs.mkdirSync(outdir, { recursive: true }); + } catch {} + + const output_files = compact( + fs.readdirSync(outdir).map((name) => { + const stats = fs.statSync(path.join(outdir, name)); + if (stats.isDirectory()) return; + return { + name, + updated: stats.mtimeMs + }; + }) + ); - const outdir = `${config.kit.outDir}/types/${routes_dir}/${dir}`; + const source_last_updated = Math.max(...input_files.map((file) => fs.statSync(file).mtimeMs)); + const types_last_updated = Math.max(...output_files.map((file) => file?.updated)); - const imports = [`import type * as Kit from '@sveltejs/kit';`]; + // types were generated more recently than the source files, so don't regenerate + if (types_last_updated > source_last_updated) return; - /** @type {string[]} */ - const written_files = []; + // track which old files end up being surplus to requirements + const to_delete = new Set(output_files.map((file) => file.name)); + + const imports = [`import type * as Kit from '@sveltejs/kit';`]; /** @type {string[]} */ const declarations = []; @@ -233,10 +146,8 @@ function write_types_for_dir(config, manifest_data, routes_dir, dir, groups, ts) /** @type {string[]} */ const exports = []; - const route_params = parse_route_id(dir).names; - - if (route_params.length > 0) { - const params = route_params.map((param) => `${param}: string`).join('; '); + if (route.names.length > 0) { + const params = route.names.map((param) => `${param}: string`).join('; '); declarations.push( `interface RouteParams extends Partial> { ${params} }` ); @@ -244,15 +155,14 @@ function write_types_for_dir(config, manifest_data, routes_dir, dir, groups, ts) declarations.push(`interface RouteParams extends Partial> {}`); } - if (group.leaf) { + if (route.leaf) { const { data, server_data, load, server_load, errors, written_proxies } = process_node( - ts, - group.leaf, + route.leaf, outdir, - 'RouteParams', - groups + 'RouteParams' ); - written_files.push(...written_proxies); + + for (const file of written_proxies) to_delete.delete(file); exports.push(`export type Errors = ${errors};`); @@ -272,20 +182,18 @@ function write_types_for_dir(config, manifest_data, routes_dir, dir, groups, ts) exports.push('export type PageServerLoadEvent = Parameters[0];'); } - if (group.leaf.server) { + if (route.leaf.server) { exports.push(`export type Action = Kit.Action`); } } - if (group.default_layout || group.named_layouts.size > 0) { - // TODO to be completely rigorous, we should have a LayoutParams per - // layout, and only include params for child pages that use each layout. - // but that's more work than i care to do right now + if (route.layout) { + // TODO collect children in create_manifest_data, instead of this inefficient O(n^2) algorithm const layout_params = new Set(); - manifest_data.routes.forEach((route) => { - if (route.type === 'page' && route.id.startsWith(dir + '/')) { + manifest_data.routes.forEach((other) => { + if (other.page && other.id.startsWith(route.id + '/')) { // TODO this is O(n^2), see if we need to speed it up - for (const name of parse_route_id(route.id.slice(dir.length + 1)).names) { + for (const name of other.names) { layout_params.add(name); } } @@ -298,95 +206,32 @@ function write_types_for_dir(config, manifest_data, routes_dir, dir, groups, ts) declarations.push(`interface LayoutParams extends RouteParams {}`); } - if (group.default_layout) { - const { data, server_data, load, server_load, written_proxies } = process_node( - ts, - group.default_layout, - outdir, - 'LayoutParams', - groups - ); - written_files.push(...written_proxies); - - exports.push(`export type LayoutData = ${data};`); - if (load) { - exports.push( - `export type LayoutLoad | void = Record | void> = ${load};` - ); - exports.push('export type LayoutLoadEvent = Parameters[0];'); - } - - exports.push(`export type LayoutServerData = ${server_data};`); - if (server_load) { - exports.push( - `export type LayoutServerLoad | void = Record | void> = ${server_load};` - ); - exports.push('export type LayoutServerLoadEvent = Parameters[0];'); - } - } + const { data, server_data, load, server_load, written_proxies } = process_node( + route.layout, + outdir, + 'LayoutParams' + ); - if (group.named_layouts.size > 0) { - /** @type {string[]} */ - const data_exports = []; - - /** @type {string[]} */ - const server_data_exports = []; - - /** @type {string[]} */ - const load_exports = []; - - /** @type {string[]} */ - const load_event_exports = []; - - /** @type {string[]} */ - const server_load_exports = []; - - /** @type {string[]} */ - const server_load_event_exports = []; - - for (const [name, node] of group.named_layouts) { - const { data, server_data, load, server_load, written_proxies } = process_node( - ts, - node, - outdir, - 'LayoutParams', - groups - ); - written_files.push(...written_proxies); - data_exports.push(`export type ${name} = ${data};`); - server_data_exports.push(`export type ${name} = ${server_data};`); - if (load) { - load_exports.push( - `export type ${name} | void = Record | void> = ${load};` - ); - load_event_exports.push(`export type ${name} = Parameters[0];`); - } - if (server_load) { - server_load_exports.push( - `export type ${name} | void = Record | void> = ${server_load};` - ); - server_load_event_exports.push( - `export type ${name} = Parameters[0];` - ); - } - } + for (const file of written_proxies) to_delete.delete(file); - exports.push(`\nexport namespace LayoutData {\n\t${data_exports.join('\n\t')}\n}`); - exports.push(`\nexport namespace LayoutLoad {\n\t${load_exports.join('\n\t')}\n}`); - exports.push(`\nexport namespace LayoutLoadEvent {\n\t${load_event_exports.join('\n\t')}\n}`); - exports.push( - `\nexport namespace LayoutServerData {\n\t${server_data_exports.join('\n\t')}\n}` - ); + exports.push(`export type LayoutData = ${data};`); + if (load) { exports.push( - `\nexport namespace LayoutServerLoad {\n\t${server_load_exports.join('\n\t')}\n}` + `export type LayoutLoad | void = Record | void> = ${load};` ); + exports.push('export type LayoutLoadEvent = Parameters[0];'); + } + + exports.push(`export type LayoutServerData = ${server_data};`); + if (server_load) { exports.push( - `\nexport namespace LayoutServerLoadEvent {\n\t${server_load_event_exports.join('\n\t')}\n}` + `export type LayoutServerLoad | void = Record | void> = ${server_load};` ); + exports.push('export type LayoutServerLoadEvent = Parameters[0];'); } } - if (group.endpoint) { + if (route.endpoint) { exports.push(`export type RequestHandler = Kit.RequestHandler;`); exports.push(`export type RequestEvent = Kit.RequestEvent;`); } @@ -395,19 +240,20 @@ function write_types_for_dir(config, manifest_data, routes_dir, dir, groups, ts) .filter(Boolean) .join('\n\n'); - written_files.push(write(`${outdir}/$types.d.ts`, output)); + fs.writeFileSync(`${outdir}/$types.d.ts`, output); + to_delete.delete('$types.d.ts'); - return written_files; + for (const file of to_delete) { + fs.unlinkSync(path.join(outdir, file)); + } } /** - * @param {import('typescript')} ts - * @param {Node} node + * @param {import('types').PageNode} node * @param {string} outdir * @param {string} params - * @param {Map} groups */ -function process_node(ts, node, outdir, params, groups) { +function process_node(node, outdir, params) { let data; let load; let server_load; @@ -420,14 +266,18 @@ function process_node(ts, node, outdir, params, groups) { if (node.server) { const content = fs.readFileSync(node.server, 'utf8'); - const proxy = tweak_types(ts, content, server_names); + const proxy = tweak_types(content, server_names); const basename = path.basename(node.server); if (proxy?.modified) { - written_proxies.push(write(`${outdir}/proxy${basename}`, proxy.code)); + fs.writeFileSync(`${outdir}/proxy${basename}`, proxy.code); + written_proxies.push(`proxy${basename}`); } server_data = get_data_type(node.server, 'null', proxy); - server_load = `Kit.ServerLoad<${params}, ${get_parent_type('LayoutServerData')}, OutputData>`; + server_load = `Kit.ServerLoad<${params}, ${get_parent_type( + node, + 'LayoutServerData' + )}, OutputData>`; if (proxy) { const types = []; @@ -450,13 +300,14 @@ function process_node(ts, node, outdir, params, groups) { server_data = 'null'; } - const parent_type = get_parent_type('LayoutData'); + const parent_type = get_parent_type(node, 'LayoutData'); if (node.shared) { const content = fs.readFileSync(node.shared, 'utf8'); - const proxy = tweak_types(ts, content, shared_names); + const proxy = tweak_types(content, shared_names); if (proxy?.modified) { - written_proxies.push(write(`${outdir}/proxy${path.basename(node.shared)}`, proxy.code)); + fs.writeFileSync(`${outdir}/proxy${path.basename(node.shared)}`, proxy.code); + written_proxies.push(`proxy${path.basename(node.shared)}`); } const type = get_data_type(node.shared, `${parent_type} & ${server_data}`, proxy); @@ -492,39 +343,35 @@ function process_node(ts, node, outdir, params, groups) { return 'unknown'; } } +} - /** - * Get the parent type string by recursively looking up the parent layout and accumulate them to one type. - * @param {string} type - */ - function get_parent_type(type) { - const parent_imports = []; - let parent = node.parent; - let acc_diff = 0; - - while (parent) { - acc_diff += parent.folder_depth_diff; - let parent_group = /** @type {NodeGroup} */ (groups.get(parent.key)); - // unshift because we need it the other way round for the import string - parent_imports.unshift( - (acc_diff === 0 ? '' : `import('` + '../'.repeat(acc_diff) + '$types.js' + `').`) + - `${type}${parent.name ? `.${parent.name}` : ''}` - ); - let parent_layout = /** @type {Node} */ ( - parent.name ? parent_group.named_layouts.get(parent.name) : parent_group.default_layout - ); - parent = parent_layout.parent; - } +/** + * Get the parent type string by recursively looking up the parent layout and accumulate them to one type. + * @param {import('types').PageNode} node + * @param {string} type + */ +function get_parent_type(node, type) { + const parent_imports = []; - let parent_str = parent_imports[0] || 'Record'; - for (let i = 1; i < parent_imports.length; i++) { - // Omit is necessary because a parent could have a property with the same key which would - // cause a type conflict. At runtime the child overwrites the parent property in this case, - // so reflect that in the type definition. - parent_str = `Omit<${parent_str}, keyof ${parent_imports[i]}> & ${parent_imports[i]}`; - } - return parent_str; + let parent = node.parent; + + while (parent) { + const d = node.depth - parent.depth; + // unshift because we need it the other way round for the import string + parent_imports.unshift( + `${d === 0 ? '' : `import('${'../'.repeat(d)}${'$types.js'}').`}${type}` + ); + parent = parent.parent; + } + + let parent_str = parent_imports[0] || 'Record'; + for (let i = 1; i < parent_imports.length; i++) { + // Omit is necessary because a parent could have a property with the same key which would + // cause a type conflict. At runtime the child overwrites the parent property in this case, + // so reflect that in the type definition. + parent_str = `Omit<${parent_str}, keyof ${parent_imports[i]}> & ${parent_imports[i]}`; } + return parent_str; } /** @@ -546,12 +393,11 @@ function replace_ext_with_js(file_path) { } /** - * @param {import('typescript')} ts * @param {string} content * @param {Set} names * @returns {Proxy} */ -export function tweak_types(ts, content, names) { +export function tweak_types(content, names) { try { let modified = false; @@ -699,77 +545,3 @@ export function tweak_types(ts, content, names) { return null; } } - -/** - * @param {string} file - * @param {string} content - */ -function write(file, content) { - write_if_changed(file, content); - return file; -} - -/** - * Finds the nearest layout for given node. - * Assumes that nodes is sorted by path length (lowest first). - * - * @param {string} routes_dir - * @param {import('types').PageNode[]} nodes - * @param {number} start_idx - */ -export function find_nearest_layout(routes_dir, nodes, start_idx) { - const start_file = /** @type {string} */ ( - nodes[start_idx].component || nodes[start_idx].shared || nodes[start_idx].server - ); - - let name = ''; - const match = /^\+(layout|page)(?:-([^@.]+))?(?:@([^@.]+))?/.exec(path.basename(start_file)); - if (!match) throw new Error(`Unexpected route file: ${start_file}`); - if (match[3] && match[3] !== 'default') { - name = match[3]; // a named layout is referenced - } - - let common_path = path.dirname(start_file); - if (match[1] === 'layout' && !match[2] && !name) { - // We are a default layout, so we skip the current level - common_path = path.dirname(common_path); - } - - for (let i = start_idx - 1; i >= 0; i -= 1) { - const node = nodes[i]; - const file = /** @type {string} */ (node.component || node.shared || node.server); - - const current_path = path.dirname(file); - const common_path_length = common_path.split('/').length; - const current_path_length = current_path.split('/').length; - - if (common_path_length < current_path_length) { - // this is a layout in a different tree - continue; - } else if (common_path_length > current_path_length) { - // we've gone back up a folder level - common_path = path.dirname(common_path); - } - if (common_path !== current_path) { - // this is a layout in a different tree - continue; - } - if ( - path.basename(file, path.extname(file)).split('@')[0] !== - '+layout' + (name ? `-${name}` : '') - ) { - // this is not the layout we are searching for - continue; - } - - // matching parent layout found - let folder_depth_diff = - posixify(path.relative(path.dirname(start_file), common_path + '/$types.js')).split('/') - .length - 1; - return { - key: path.dirname(file).slice(routes_dir.length + 1), - name, - folder_depth_diff - }; - } -} diff --git a/packages/kit/src/core/sync/write_types.spec.js b/packages/kit/src/core/sync/write_types.spec.js index 4f0a85d140b7..bb56c892cb8c 100644 --- a/packages/kit/src/core/sync/write_types.spec.js +++ b/packages/kit/src/core/sync/write_types.spec.js @@ -1,7 +1,6 @@ import { test } from 'uvu'; import * as assert from 'uvu/assert'; -import ts from 'typescript'; -import { find_nearest_layout, tweak_types } from './write_types.js'; +import { tweak_types } from './write_types.js'; test('Rewrites types for a TypeScript module', () => { const source = ` @@ -12,7 +11,7 @@ test('Rewrites types for a TypeScript module', () => { }; `; - const rewritten = tweak_types(ts, source, new Set(['GET'])); + const rewritten = tweak_types(source, new Set(['GET'])); assert.equal(rewritten?.exports, ['GET']); assert.equal( @@ -36,7 +35,7 @@ test('Rewrites types for a TypeScript module without param', () => { }; `; - const rewritten = tweak_types(ts, source, new Set(['GET'])); + const rewritten = tweak_types(source, new Set(['GET'])); assert.equal(rewritten?.exports, ['GET']); assert.equal( @@ -61,7 +60,7 @@ test('Rewrites types for a JavaScript module with `function`', () => { }; `; - const rewritten = tweak_types(ts, source, new Set(['GET'])); + const rewritten = tweak_types(source, new Set(['GET'])); assert.equal(rewritten?.exports, ['GET']); assert.equal( @@ -87,7 +86,7 @@ test('Rewrites types for a JavaScript module with `const`', () => { }; `; - const rewritten = tweak_types(ts, source, new Set(['GET'])); + const rewritten = tweak_types(source, new Set(['GET'])); assert.equal(rewritten?.exports, ['GET']); assert.equal( @@ -103,77 +102,4 @@ test('Rewrites types for a JavaScript module with `const`', () => { ); }); -/** @type {import('types').PageNode[]} */ -const nodes = [ - { component: 'src/routes/+layout.svelte' }, // 0 - { component: 'src/routes/+layout-named2@default.svelte' }, // 1 - { component: 'src/routes/+layout-named.svelte' }, // 2 - { - shared: 'src/routes/+page.js', - component: 'src/routes/+page@named2.svelte' - }, // 3 - { - server: 'src/routes/docs/+layout.server.js', - component: 'src/routes/docs/+layout.svelte' - }, // 4 - { shared: 'src/routes/docs/+page.js' }, // 5 - { - server: 'src/routes/faq/+page.server.js', - component: 'src/routes/faq/+page.svelte' - }, // 6 - { - shared: 'src/routes/search/+page.js', - server: 'src/routes/search/+page.server.js', - component: 'src/routes/search/+page.svelte' - }, // 7 - { - server: 'src/routes/docs/[slug]/+page.server.js', - component: 'src/routes/docs/[slug]/+page.svelte' - }, // 8 - { - server: 'src/routes/docs/[slug]/hi/+page.server.js', - component: 'src/routes/docs/[slug]/hi/+page@named.svelte' - } // 9 -]; - -test('Finds nearest layout (nested)', () => { - assert.equal(find_nearest_layout('src/routes', nodes, 8), { - key: 'docs', - folder_depth_diff: 1, - name: '' - }); -}); - -test('Finds nearest layout (root)', () => { - assert.equal(find_nearest_layout('src/routes', nodes, 6), { - key: '', - folder_depth_diff: 1, - name: '' - }); -}); - -test('Finds nearest layout (named)', () => { - assert.equal(find_nearest_layout('src/routes', nodes, 9), { - key: '', - folder_depth_diff: 3, - name: 'named' - }); -}); - -test('Finds nearest named layout from layout', () => { - assert.equal(find_nearest_layout('src/routes', nodes, 1), { - key: '', - folder_depth_diff: 0, - name: '' - }); -}); - -test('Finds nearest layout (recursively named)', () => { - assert.equal(find_nearest_layout('src/routes', nodes, 3), { - key: '', - folder_depth_diff: 0, - name: 'named2' - }); -}); - test.run(); diff --git a/packages/kit/src/runtime/client/ambient.d.ts b/packages/kit/src/runtime/client/ambient.d.ts index 7560947d5fb1..74164d55c347 100644 --- a/packages/kit/src/runtime/client/ambient.d.ts +++ b/packages/kit/src/runtime/client/ambient.d.ts @@ -7,11 +7,12 @@ declare module '__GENERATED__/client-manifest.js' { export const nodes: CSRPageNodeLoader[]; /** - * A map of `[routeId: string]: [errors, layouts, page]` tuples, which + * A map of `[routeId: string]: [leaf, layouts, errors]` tuples, which * is parsed into an array of routes on startup. The numbers refer to the - * indices in `nodes`. + * indices in `nodes`. The route layout and error nodes are not referenced, + * they are always number 0 and 1 and always apply. */ - export const dictionary: Record; + export const dictionary: Record; export const matchers: Record; } diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 5a86ff58d570..d74fe9daba56 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -825,15 +825,10 @@ export function create_client({ target, base, trailing_slash }) { } } - // TODO post-https://github.com/sveltejs/kit/discussions/6124, this will - // no longer be necessary — if we get here, it's because the root layout - // load function failed, which means we have to fall back to the server - return await load_root_error_page({ - status, - error, - url, - routeId: route.id - }); + // if we get here, it's because the root `load` function failed, + // and we need to fall back to the server + native_navigation(url); + return; } } else { // push an empty slot so we can rewind past gaps to the diff --git a/packages/kit/src/runtime/client/parse.js b/packages/kit/src/runtime/client/parse.js index 6acf39b64d10..ec22a49b852e 100644 --- a/packages/kit/src/runtime/client/parse.js +++ b/packages/kit/src/runtime/client/parse.js @@ -2,14 +2,19 @@ import { exec, parse_route_id } from '../../utils/routing.js'; /** * @param {import('types').CSRPageNodeLoader[]} nodes - * @param {Record} dictionary + * @param {typeof import('__GENERATED__/client-manifest.js').dictionary} dictionary * @param {Record boolean>} matchers * @returns {import('types').CSRRoute[]} */ export function parse(nodes, dictionary, matchers) { - return Object.entries(dictionary).map(([id, [errors, layouts, leaf, uses_server_data]]) => { + return Object.entries(dictionary).map(([id, [leaf, layouts, errors]]) => { const { pattern, names, types } = parse_route_id(id); + // whether or not the route uses the server data is + // encoded using the ones' complement, to save space + const uses_server_data = leaf < 0; + if (uses_server_data) leaf = ~leaf; + const route = { id, /** @param {string} path */ @@ -17,10 +22,10 @@ export function parse(nodes, dictionary, matchers) { const match = pattern.exec(path); if (match) return exec(match, names, types, matchers); }, - errors: errors.map((n) => nodes[n]), - layouts: layouts.map((n) => nodes[n]), + errors: [1, ...(errors || [])].map((n) => nodes[n]), + layouts: [0, ...(layouts || [])].map((n) => nodes[n]), leaf: nodes[leaf], - uses_server_data: !!uses_server_data + uses_server_data }; // bit of a hack, but ensures that layout/error node lists are the same diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 9d112214a478..135e22ff8072 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -3,14 +3,12 @@ import { check_method_names, method_not_allowed } from './utils.js'; /** * @param {import('types').RequestEvent} event - * @param {import('types').SSREndpoint} route + * @param {import('types').SSREndpoint} mod * @returns {Promise} */ -export async function render_endpoint(event, route) { +export async function render_endpoint(event, mod) { const method = /** @type {import('types').HttpMethod} */ (event.request.method); - const mod = await route.load(); - // TODO: Remove for 1.0 check_method_names(mod); @@ -21,12 +19,6 @@ export async function render_endpoint(event, route) { } if (!handler) { - if (event.request.headers.get('x-sveltekit-load')) { - // TODO would be nice to avoid these requests altogether, - // by noting whether or not page endpoints export `get` - return new Response(undefined, { status: 204 }); - } - return method_not_allowed(mod, method); } diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index af7e56a84f72..42d990ba2b52 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -93,7 +93,7 @@ export async function respond(request, options, state) { } if (route) { - if (route.type === 'page') { + if (route.page) { const normalized = normalize_path(url.pathname, options.trailing_slash); if (normalized !== url.pathname && !state.prerendering?.fallback) { @@ -253,9 +253,9 @@ export async function respond(request, options, state) { if (route) { /** @type {Response} */ let response; - if (is_data_request && route.type === 'page') { + if (is_data_request && route.page) { try { - const node_ids = [...route.layouts, route.leaf]; + const node_ids = [...route.page.layouts, route.page.leaf]; const invalidated = request.headers.get('x-sveltekit-invalidated')?.split(',').map(Boolean) ?? @@ -317,7 +317,8 @@ export async function respond(request, options, state) { throw error; } - length = i + 1; // don't include nodes after first error + // Math.min because array isn't guaranteed to resolve in order + length = Math.min(length, i + 1); if (error instanceof HttpError) { return /** @type {import('types').ServerErrorNode} */ ({ @@ -358,11 +359,14 @@ export async function respond(request, options, state) { response = json(error_to_pojo(error, options.get_stack), { status: 500 }); } } + } else if (route.page) { + response = await render_page(event, route, route.page, options, state, resolve_opts); + } else if (route.endpoint) { + response = await render_endpoint(event, await route.endpoint()); } else { - response = - route.type === 'endpoint' - ? await render_endpoint(event, route) - : await render_page(event, route, options, state, resolve_opts); + // a route will always have a page or an endpoint, but TypeScript + // doesn't know that + throw new Error('This should never happen'); } if (!is_data_request) { diff --git a/packages/kit/src/runtime/server/page/fetch.js b/packages/kit/src/runtime/server/page/fetch.js index 5ae3b3206f62..e6ef5aae7c89 100644 --- a/packages/kit/src/runtime/server/page/fetch.js +++ b/packages/kit/src/runtime/server/page/fetch.js @@ -9,7 +9,7 @@ import { domain_matches, path_matches } from './cookie.js'; * event: import('types').RequestEvent; * options: import('types').SSROptions; * state: import('types').SSRState; - * route: import('types').SSRPage | import('types').SSRErrorPage; + * route: import('types').SSRRoute | import('types').SSRErrorPage; * }} opts */ export function create_fetch({ event, options, state, route }) { diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 744af2f2d15c..d0df231da30b 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -17,13 +17,14 @@ import { load_data, load_server_data } from './load_data.js'; /** * @param {import('types').RequestEvent} event - * @param {import('types').SSRPage} route + * @param {import('types').SSRRoute} route + * @param {import('types').PageNodeIndexes} page * @param {import('types').SSROptions} options * @param {import('types').SSRState} state * @param {import('types').RequiredResolveOptions} resolve_opts * @returns {Promise} */ -export async function render_page(event, route, options, state, resolve_opts) { +export async function render_page(event, route, page, options, state, resolve_opts) { if (state.initiator === route) { // infinite request cycle detected return new Response(`Not found: ${event.url.pathname}`, { @@ -41,7 +42,7 @@ export async function render_page(event, route, options, state, resolve_opts) { event.request.method !== 'GET' && event.request.method !== 'HEAD' ) { - const node = await options.manifest._.nodes[route.leaf](); + const node = await options.manifest._.nodes[page.leaf](); if (node.server) { return handle_json_request(event, options, node.server); } @@ -52,8 +53,8 @@ export async function render_page(event, route, options, state, resolve_opts) { try { const nodes = await Promise.all([ // we use == here rather than === because [undefined] serializes as "[null]" - ...route.layouts.map((n) => (n == undefined ? n : options.manifest._.nodes[n]())), - options.manifest._.nodes[route.leaf]() + ...page.layouts.map((n) => (n == undefined ? n : options.manifest._.nodes[n]())), + options.manifest._.nodes[page.leaf]() ]); const leaf_node = /** @type {import('types').SSRNode} */ (nodes.at(-1)); @@ -244,8 +245,8 @@ export async function render_page(event, route, options, state, resolve_opts) { const status = error instanceof HttpError ? error.status : 500; while (i--) { - if (route.errors[i]) { - const index = /** @type {number} */ (route.errors[i]); + if (page.errors[i]) { + const index = /** @type {number} */ (page.errors[i]); const node = await options.manifest._.nodes[index](); let j = i; diff --git a/packages/kit/src/utils/array.js b/packages/kit/src/utils/array.js new file mode 100644 index 000000000000..08f93845149b --- /dev/null +++ b/packages/kit/src/utils/array.js @@ -0,0 +1,9 @@ +/** + * Removes nullish values from an array. + * + * @template T + * @param {Array} arr + */ +export function compact(arr) { + return arr.filter(/** @returns {val is NonNullable} */ (val) => val != null); +} diff --git a/packages/kit/src/utils/routing.js b/packages/kit/src/utils/routing.js index 826c75a4d602..5f6dbd4fbc4f 100644 --- a/packages/kit/src/utils/routing.js +++ b/packages/kit/src/utils/routing.js @@ -12,12 +12,21 @@ export function parse_route_id(id) { // const add_trailing_slash = !/\.[a-z]+$/.test(key); let add_trailing_slash = true; + if (/\]\[/.test(id)) { + throw new Error(`Invalid route ${id} — parameters must be separated`); + } + + if (count_occurrences('[', id) !== count_occurrences(']', id)) { + throw new Error(`Invalid route ${id} — brackets are unbalanced`); + } + const pattern = id === '' ? /^\/$/ : new RegExp( `^${id - .split(/(?:@[a-zA-Z0-9_-]+)?(?:\/|$)/) + .split(/(?:\/|$)/) + .filter(is_no_group) .map((segment, i, segments) => { const decoded_segment = decodeURIComponent(segment); // special case — /[...rest]/ could contain zero segments @@ -79,6 +88,13 @@ export function parse_route_id(id) { return { pattern, names, types }; } +/** + * @param {string} segment + */ +export function is_no_group(segment) { + return !/^\([^)]+\)$/.test(segment); +} + /** * @param {RegExpMatchArray} match * @param {string[]} names @@ -106,3 +122,15 @@ export function exec(match, names, types, matchers) { return params; } + +/** + * @param {string} needle + * @param {string} haystack + */ +function count_occurrences(needle, haystack) { + let count = 0; + for (let i = 0; i < haystack.length; i += 1) { + if (haystack[i] === needle) count += 1; + } + return count; +} diff --git a/packages/kit/src/vite/build/build_server.js b/packages/kit/src/vite/build/build_server.js index cb126b83c40d..793179b1c9f2 100644 --- a/packages/kit/src/vite/build/build_server.js +++ b/packages/kit/src/vite/build/build_server.js @@ -158,8 +158,8 @@ export async function build_server(options, client) { // add entry points for every endpoint... manifest_data.routes.forEach((route) => { - if (route.type === 'endpoint') { - const resolved = path.resolve(cwd, route.file); + if (route.endpoint) { + const resolved = path.resolve(cwd, route.endpoint.file); const relative = decodeURIComponent(path.relative(config.kit.files.routes, resolved)); const name = posixify(path.join('entries/endpoints', relative.replace(/\.js$/, ''))); input[name] = resolved; @@ -326,10 +326,16 @@ function get_methods(cwd, output, manifest_data) { /** @type {Record} */ const methods = {}; manifest_data.routes.forEach((route) => { - const file = route.type === 'endpoint' ? route.file : route.leaf.server; + if (route.endpoint) { + if (lookup[route.endpoint.file]) { + methods[route.endpoint.file] = lookup[route.endpoint.file].filter(is_http_method); + } + } - if (file && lookup[file]) { - methods[file] = lookup[file].filter(is_http_method); + if (route.leaf?.server) { + if (lookup[route.leaf.server]) { + methods[route.leaf.server] = lookup[route.leaf.server].filter(is_http_method); + } } }); diff --git a/packages/kit/src/vite/dev/index.js b/packages/kit/src/vite/dev/index.js index 9cbfb128e65d..c0155862e0d6 100644 --- a/packages/kit/src/vite/dev/index.js +++ b/packages/kit/src/vite/dev/index.js @@ -7,12 +7,12 @@ import { getRequest, setResponse } from '../../node/index.js'; import { installPolyfills } from '../../node/polyfills.js'; import { coalesce_to_error } from '../../utils/error.js'; import { posixify } from '../../utils/filesystem.js'; -import { parse_route_id } from '../../utils/routing.js'; import { load_template } from '../../core/config/index.js'; import { SVELTE_KIT_ASSETS } from '../../core/constants.js'; import * as sync from '../../core/sync/sync.js'; import { get_mime_lookup, runtime_base, runtime_prefix } from '../../core/utils.js'; import { get_env, prevent_illegal_vite_imports, resolve_entry } from '../utils.js'; +import { compact } from '../../utils/array.js'; // Vite doesn't expose this so we just copy the list for now // https://github.com/vitejs/vite/blob/3edd1af56e980aef56641a5a51cf2932bb580d41/packages/vite/src/node/plugins/css.ts#L96 @@ -149,36 +149,27 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) { return result; }; }), - routes: manifest_data.routes.map((route) => { - const { pattern, names, types } = parse_route_id(route.id); + routes: compact( + manifest_data.routes.map((route) => { + if (!route.page && !route.endpoint) return null; + + const endpoint = route.endpoint; - if (route.type === 'page') { return { - type: 'page', id: route.id, - pattern, - names, - types, - errors: route.errors.map((id) => (id ? manifest_data.nodes.indexOf(id) : undefined)), - layouts: route.layouts.map((id) => - id ? manifest_data.nodes.indexOf(id) : undefined - ), - leaf: manifest_data.nodes.indexOf(route.leaf) + pattern: route.pattern, + names: route.names, + types: route.types, + page: route.page, + endpoint: endpoint + ? async () => { + const url = path.resolve(cwd, endpoint.file); + return await vite.ssrLoadModule(url); + } + : null }; - } - - return { - type: 'endpoint', - id: route.id, - pattern, - names, - types, - load: async () => { - const url = path.resolve(cwd, route.file); - return await vite.ssrLoadModule(url); - } - }; - }), + }) + ), matchers: async () => { /** @type {Record} */ const matchers = {}; @@ -229,15 +220,16 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) { to_run(); }, 100); }; + // Debounce add/unlink events because in case of folder deletion or moves // they fire in rapid succession, causing needless invocations. watch('add', () => debounce(update_manifest)); watch('unlink', () => debounce(update_manifest)); watch('change', (file) => { // Don't run for a single file if the whole manifest is about to get updated - if (!timeout) { - sync.update(svelte_config, manifest_data, file); - } + if (timeout) return; + + sync.update(svelte_config, manifest_data, file); }); const assets = svelte_config.kit.paths.assets ? SVELTE_KIT_ASSETS : svelte_config.kit.paths.base; diff --git a/packages/kit/test/apps/basics/src/routes/+layout-blank.svelte b/packages/kit/test/apps/basics/src/routes/+layout-blank.svelte deleted file mode 100644 index 4fa864ce7aa9..000000000000 --- a/packages/kit/test/apps/basics/src/routes/+layout-blank.svelte +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/kit/test/apps/basics/src/routes/nested-layout/reset/+layout@blank.svelte b/packages/kit/test/apps/basics/src/routes/nested-layout/reset/+layout@.svelte similarity index 100% rename from packages/kit/test/apps/basics/src/routes/nested-layout/reset/+layout@blank.svelte rename to packages/kit/test/apps/basics/src/routes/nested-layout/reset/+layout@.svelte diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index ea04845a3982..90f8f66760f4 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -981,10 +981,9 @@ test.describe('Nested layouts', () => { test('resets layout', async ({ page }) => { await page.goto('/nested-layout/reset'); - expect(await page.evaluate(() => document.querySelector('footer'))).toBe(null); - expect(await page.evaluate(() => document.querySelector('p'))).toBe(null); expect(await page.textContent('h1')).toBe('Layout reset'); expect(await page.textContent('h2')).toBe('Hello'); + expect(await page.$('#nested')).toBeNull(); }); test('renders the closest error page', async ({ page, clicknav }) => { diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 2e43e299baff..7786b946d2bb 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -80,13 +80,6 @@ export type CSRRoute = { uses_server_data: boolean; }; -export interface EndpointData { - type: 'endpoint'; - id: string; - pattern: RegExp; - file: string; -} - export type GetParams = (match: RegExpExecArray) => Record; export interface Hooks { @@ -123,18 +116,12 @@ export interface MethodOverride { } export interface PageNode { + depth: number; component?: string; // TODO supply default component if it's missing (bit of an edge case) shared?: string; server?: string; -} - -export interface PageData { - type: 'page'; - id: string; - pattern: RegExp; - errors: Array; - layouts: Array; - leaf: PageNode; + parent_id?: string; + parent?: PageNode; } export type PayloadScriptAttributes = @@ -167,7 +154,34 @@ export interface Respond { (request: Request, options: SSROptions, state: SSRState): Promise; } -export type RouteData = PageData | EndpointData; +/** + * Represents a route segement in the app. It can either be an intermediate node + * with only layout/error pages, or a leaf, at which point either `page` and `leaf` + * or `endpoint` is set. + */ +export interface RouteData { + id: string; + parent: RouteData | null; + + segment: string; + pattern: RegExp; + names: string[]; + types: string[]; + + layout: PageNode | null; + error: PageNode | null; + leaf: PageNode | null; + + page: { + layouts: Array; + errors: Array; + leaf: number; + } | null; + + endpoint: { + file: string; + } | null; +} export type ServerData = | { @@ -228,15 +242,6 @@ export interface SSRComponent { export type SSRComponentLoader = () => Promise; -export interface SSREndpoint { - type: 'endpoint'; - id: string; - pattern: RegExp; - names: string[]; - types: string[]; - load(): Promise>>; -} - export interface SSRNode { component: SSRComponentLoader; /** index into the `components` array in client-manifest.js */ @@ -309,27 +314,33 @@ export interface SSROptions { trailing_slash: TrailingSlash; } -export interface SSRPage { - type: 'page'; - id: string; - pattern: RegExp; - names: string[]; - types: string[]; +export interface SSRErrorPage { + id: '__error'; +} + +export interface PageNodeIndexes { errors: Array; layouts: Array; leaf: number; } -export interface SSRErrorPage { - id: '__error'; -} +export type SSREndpoint = Partial>; -export type SSRRoute = SSREndpoint | SSRPage; +export interface SSRRoute { + id: string; + pattern: RegExp; + names: string[]; + types: string[]; + + page: PageNodeIndexes | null; + + endpoint: (() => Promise) | null; +} export interface SSRState { fallback?: string; getClientAddress: () => string; - initiator?: SSRPage | SSRErrorPage; + initiator?: SSRRoute | SSRErrorPage; platform?: any; prerendering?: PrerenderOptions; }