@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
586 lines (499 loc) • 16.4 kB
JavaScript
/** @import { ValidatedConfig } from 'types' */
import { lookup } from '../../../utils/mime.js';
import fs from 'node:fs';
import path from 'node:path';
import { resolve_entry, walk } from '../../../utils/filesystem.js';
import { posixify } from '../../../utils/os.js';
import { parse_route_id, validate_route_id_params } from '../../../utils/routing.js';
import { runtime_directory } from '../../utils.js';
import * as e from '../../../messages/build-errors.js';
import * as w from '../../../messages/build-warnings.js';
import { prevent_conflicts } from './conflict.js';
import { sort_routes } from './sort.js';
import {
create_node_analyser,
get_page_options
} from '../../../exports/vite/static_analysis/index.js';
const component_name_pattern = /^\+(?:(page(?:@(.*))?)|(layout(?:@(.*))?)|(error))$/;
const module_name_pattern =
/^\+(?:(server)|(page(?:(@[a-zA-Z0-9_-]*))?(\.server)?)|(layout(?:(@[a-zA-Z0-9_-]*))?(\.server)?))$/;
/**
* Generates the manifest data used for the client-side manifest and types generation.
* @param {ValidatedConfig} config
* @param {string} root
* @param {string} [fallback] Where to look for fallback components
* @returns {import('types').ManifestData}
*/
export default function create_manifest_data(
config,
root,
fallback = `${runtime_directory}/components`
) {
const assets = create_assets(config);
const hooks = create_hooks(config, root);
const params = resolve_params(config, root);
const { nodes, routes } = create_routes_and_nodes(root, config, fallback);
return {
assets,
hooks,
params,
nodes,
routes
};
}
/**
* Whether this route has a `+page`. Independent of `is_endpoint_route` — a route can be both.
* @param {import('types').RouteData} route
* @returns {boolean}
*/
export function is_page_route(route) {
return !!route.page;
}
/**
* Whether this route has a `+server`. Independent of `is_page_route` — a route can be both.
* @param {import('types').RouteData} route
* @returns {boolean}
*/
export function is_endpoint_route(route) {
return !!route.endpoint;
}
/**
* Whether the router can match this route. `manifest_data.routes` also contains entries for
* directories with layouts or errors, which never reach `event.route.id`.
* @param {import('types').RouteData} route
* @returns {boolean}
*/
export function is_app_route(route) {
return is_page_route(route) || is_endpoint_route(route);
}
/**
* Returns a list of files in the `static` directory.
* @param {import('types').ValidatedConfig} config
*/
export function create_assets(config) {
if (!fs.existsSync(config.files.assets)) return [];
return [...walk(config.files.assets)].map((file) => ({
file,
type: lookup(file) || null
}));
}
/**
* @param {import('types').ValidatedConfig} config
* @param {string} cwd
*/
function create_hooks(config, cwd) {
const client = resolve_entry(config.files.hooks.client, config.moduleExtensions);
const server = resolve_entry(config.files.hooks.server, config.moduleExtensions);
const universal = resolve_entry(config.files.hooks.universal, config.moduleExtensions);
return {
client: client && posixify(path.relative(cwd, client)),
server: server && posixify(path.relative(cwd, server)),
universal: universal && posixify(path.relative(cwd, universal))
};
}
/**
* @param {import('types').ValidatedConfig} config
* @param {string} cwd
*/
function resolve_params(config, cwd) {
const params_file = resolve_entry(config.files.params, config.moduleExtensions);
return params_file ? posixify(path.relative(cwd, params_file)) : null;
}
/**
* @param {string} cwd
* @param {import('types').ValidatedConfig} config
* @param {string} fallback
*/
function create_routes_and_nodes(cwd, config, fallback) {
/** @type {import('types').RouteData[]} */
let routes = [];
const routes_base = posixify(path.relative(cwd, config.files.routes));
const valid_extensions = [...config.extensions, ...config.moduleExtensions];
/** @type {import('types').PageNode[]} */
const nodes = [];
// create route data by processing files in `src/routes`
if (fs.existsSync(config.files.routes)) {
/**
* @param {number} depth
* @param {string} id
* @param {string} segment
* @param {import('types').RouteData | null} parent
*/
const walk = (depth, id, segment, parent) => {
const unescaped = id.replace(/\[([ux])\+([^\]]+)\]/gi, (match, type, code) => {
if (match !== match.toLowerCase()) {
e.route_escape_uppercase({ id });
}
if (!/[0-9a-f]+/.test(code)) {
e.route_escape_invalid({ id });
}
if (type === 'x') {
if (code.length !== 2) {
e.route_escape_hex_length({ id });
}
return String.fromCharCode(parseInt(code, 16));
} else {
if (code.length < 4 || code.length > 6) {
e.route_escape_unicode_length({ id });
}
return String.fromCodePoint(parseInt(code, 16));
}
});
if (/\]\[/.test(unescaped)) {
e.route_params_adjacent({ id });
}
if (count_occurrences('[', id) !== count_occurrences(']', id)) {
e.route_unbalanced_brackets({ id });
}
if (/#/.test(segment)) {
// Vite will barf on files with # in them
e.route_hash_character({ id, suggestion: id.replace(/#/g, '[x+23]') });
}
if (/\[\.\.\.[\w-]+\]\/\[\[/.test(id)) {
e.route_optional_after_rest({ id });
}
if (/\[\[\.\.\./.test(id)) {
e.route_optional_rest({ id });
}
const invalid_param = validate_route_id_params(id);
if (invalid_param !== undefined) {
e.route_param_invalid({ param: invalid_param, id });
}
const { pattern, params } = parse_route_id(id);
/** @type {import('types').RouteData} */
const route = {
id,
parent,
segment,
pattern,
params,
layout: null,
error: null,
leaf: null,
page: null,
endpoint: null
};
// important to do this before walking children, so that child
// routes appear later
routes.push(route);
// if we don't do this, the route map becomes unwieldy to console.log
Object.defineProperty(route, 'parent', { enumerable: false });
const dir = path.join(cwd, routes_base, id);
// We sort the entries because `readdirSync` order is not guaranteed and differs
// between runtimes (e.g. Node returns entries alphabetically, Bun in directory
// order). Node indices are assigned from this traversal order, so without sorting
// the SSR and client manifests can disagree, causing hydration mismatches.
const files = fs
.readdirSync(dir, { withFileTypes: true })
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
.map((entry) => ({
is_dir: entry.isSymbolicLink()
? fs.statSync(path.join(dir, entry.name)).isDirectory()
: entry.isDirectory(),
name: entry.name
}));
// process files first
for (const file of files) {
if (file.is_dir) continue;
const ext = valid_extensions.find((ext) => file.name.endsWith(ext));
if (!ext) continue;
if (!file.name.startsWith('+')) {
const name = file.name.slice(0, -ext.length);
const pattern = config.extensions.includes(ext)
? component_name_pattern
: module_name_pattern;
// check if it is a valid route filename but missing the + prefix
if (pattern.test(`+${name}`)) {
w.route_file_prefix_missing({
corrected: `+${file.name}`,
file: path.join(dir, file.name)
});
}
continue;
}
// allow e.g. `+page.stories.svelte` or `+server.test.ts`
if (
file.name.includes('.test.') ||
file.name.includes('.spec.') ||
file.name.includes('.stories.')
) {
continue;
}
if (file.name.endsWith('.d.ts')) {
let name = file.name.slice(0, -5);
const ext = valid_extensions.find((ext) => name.endsWith(ext));
if (ext) name = name.slice(0, -ext.length);
if (component_name_pattern.test(name) || module_name_pattern.test(name)) continue;
}
const project_relative = posixify(path.relative(cwd, path.join(dir, file.name)));
const item = analyze(
project_relative,
file.name,
config.extensions,
config.moduleExtensions
);
if (config.router.type === 'hash' && item.kind === 'server') {
e.route_server_file_hash_router({ file: project_relative });
}
/**
* @param {string} type
* @param {string} existing_file
* @returns {never}
*/
function duplicate_files(type, existing_file) {
e.route_duplicate_files({
type,
directory: `${routes_base}${route.id}`,
existing: path.basename(existing_file),
file: file.name
});
}
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, child_pages: [] };
} else if (route.layout.component) {
duplicate_files('layout component', route.layout.component);
}
route.layout.component = project_relative;
if (item.uses_layout !== undefined) route.layout.parent_id = item.uses_layout;
} else {
if (!route.leaf) {
route.leaf = { depth };
} else if (route.leaf.component) {
duplicate_files('page component', route.leaf.component);
}
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, child_pages: [] };
} else if (route.layout[item.kind]) {
duplicate_files(
item.kind + ' layout module',
/** @type {string} */ (route.layout[item.kind])
);
}
route.layout[item.kind] = project_relative;
} else if (item.is_page) {
if (!route.leaf) {
route.leaf = { depth };
} else if (route.leaf[item.kind]) {
duplicate_files(
item.kind + ' page module',
/** @type {string} */ (route.leaf[item.kind])
);
}
route.leaf[item.kind] = project_relative;
} else {
if (route.endpoint) {
duplicate_files('endpoint', route.endpoint.file);
}
route.endpoint = {
file: project_relative,
page_options: null // will be filled later
};
}
}
// then handle children
for (const file of files) {
if (file.is_dir) {
walk(depth + 1, path.posix.join(id, file.name), file.name, route);
}
}
};
walk(0, '/', '', null);
if (routes.length === 1) {
const root = routes[0];
if (!root.leaf && !root.error && !root.layout && !root.endpoint) {
e.routes_not_found();
}
}
} else {
// If there's no routes directory, we'll just create a single empty route. This ensures the root layout and
// error components are included in the manifest, which is needed for subsequent build/dev commands to work
routes.push({
id: '/',
segment: '',
pattern: /^$/,
params: [],
parent: null,
layout: null,
error: null,
leaf: null,
page: null,
endpoint: null
});
}
prevent_conflicts(routes);
// fallback root layout and root error components
const root = routes[0];
if (!root.layout?.component) {
if (!root.layout) root.layout = { depth: 0, child_pages: [] };
root.layout.component = posixify(path.relative(cwd, `${fallback}/layout.svelte`));
}
if (!root.error?.component) {
if (!root.error) root.error = { depth: 0, parent: root.layout };
root.error.component = posixify(path.relative(cwd, `${fallback}/error.svelte`));
}
// populate the page nodes list
// 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
for (const route of routes) {
if (route.layout) {
if (!route.layout?.component) {
route.layout.component = posixify(path.relative(cwd, `${fallback}/layout.svelte`));
}
nodes.push(route.layout);
}
if (route.error) nodes.push(route.error);
}
for (const route of routes) {
if (route.leaf) nodes.push(route.leaf);
}
const indexes = new Map(nodes.map((node, i) => [node, i]));
const node_analyser = create_node_analyser(cwd);
// add the related layout, page, and error nodes for a route
for (const route of routes) {
if (!route.leaf) continue;
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) {
/** @type {import('types').PageNode[]} */ (current_route.layout.child_pages).push(
route.leaf
);
current_node.parent = current_node = current_route.layout;
parent_id = current_node.parent_id;
} else {
parent_id = undefined;
}
}
current_route = current_route.parent;
}
if (parent_id !== undefined) {
e.route_layout_segment_missing({
file: /** @type {string} */ (current_node.component),
segment: parent_id
});
}
}
// remove route objects with no route file
routes = routes.filter((route) => route.endpoint || route.leaf || route.layout || route.error);
// add parents to error nodes so that we can compute which page options apply to them
for (const route of routes) {
if (!route.error) continue;
/** @type {import('types').RouteData | null} */
let current_route = route;
while (current_route) {
if (current_route.layout) {
route.error.parent = current_route.layout;
break;
}
current_route = current_route.parent;
}
}
// compute the final page options for each page node
for (const node of nodes) {
node.page_options = node_analyser.get_page_options(node);
}
for (const route of routes) {
if (route.endpoint) {
route.endpoint.page_options = get_page_options(route.endpoint.file, cwd);
}
if (route.page && route.endpoint) {
const page = nodes[route.page.leaf];
if (page.page_options?.prerender || route.endpoint.page_options?.prerender) {
const endpoint_file = route.endpoint.file.split('/').pop();
e.route_prerender_page_and_endpoint({
id: route.id,
file: /** @type {string} */ (endpoint_file)
});
}
}
}
return {
nodes,
routes: sort_routes(routes)
};
}
/**
* Determine if and how the file is relevant to the routing system.
* @param {string} project_relative
* @param {string} file
* @param {string[]} component_extensions
* @param {string[]} module_extensions
* @returns {import('./types.js').RouteFile}
*/
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 match = component_name_pattern.exec(name);
if (!match) {
e.route_file_reserved({ file: project_relative });
}
return {
kind: 'component',
is_page: !!match[1],
is_layout: !!match[3],
is_error: !!match[5],
uses_layout: match[2] ?? match[4]
};
}
const module_extension = module_extensions.find((ext) => file.endsWith(ext));
if (module_extension) {
const name = file.slice(0, -module_extension.length);
const match = module_name_pattern.exec(name);
if (!match) {
e.route_file_reserved({ file: project_relative });
} else if (match[3] || match[6]) {
e.route_named_layout_in_module({
layout: match[3] || match[6],
name: file,
file: project_relative
});
}
const kind = match[1] || match[4] || match[7] ? 'server' : 'universal';
return {
kind,
is_page: !!match[2],
is_layout: !!match[5]
};
}
e.route_file_reserved({ file: project_relative });
}
/**
* @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;
}