@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
597 lines (507 loc) • 16.9 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import colors from 'kleur';
import { lookup } from 'mrmime';
import { list_files, runtime_directory } from '../../utils.js';
import { posixify, resolve_entry } from '../../../utils/filesystem.js';
import { parse_route_id } from '../../../utils/routing.js';
import { sort_routes } from './sort.js';
import { isSvelte5Plus } from '../utils.js';
/**
* Generates the manifest data used for the client-side manifest and types generation.
* @param {{
* config: import('types').ValidatedConfig;
* fallback?: string;
* cwd?: string;
* }} opts
* @returns {import('types').ManifestData}
*/
export default function create_manifest_data({
config,
fallback = `${runtime_directory}/components/${isSvelte5Plus() ? 'svelte-5' : 'svelte-4'}`,
cwd = process.cwd()
}) {
const assets = create_assets(config);
const hooks = create_hooks(config, cwd);
const matchers = create_matchers(config, cwd);
const { nodes, routes } = create_routes_and_nodes(cwd, config, fallback);
for (const route of routes) {
for (const param of route.params) {
if (param.matcher && !matchers[param.matcher]) {
throw new Error(`No matcher found for parameter '${param.matcher}' in route ${route.id}`);
}
}
}
return {
assets,
hooks,
matchers,
nodes,
routes
};
}
/**
* @param {import('types').ValidatedConfig} config
*/
export 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: lookup(file) || null
}));
}
/**
* @param {import('types').ValidatedConfig} config
* @param {string} cwd
*/
function create_hooks(config, cwd) {
const client = resolve_entry(config.kit.files.hooks.client);
const server = resolve_entry(config.kit.files.hooks.server);
const universal = resolve_entry(config.kit.files.hooks.universal);
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 create_matchers(config, cwd) {
const params_base = path.relative(cwd, config.kit.files.params);
/** @type {Record<string, string>} */
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);
if (/^\w+$/.test(type)) {
const matcher_file = path.join(params_base, file);
// 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 {
// Allow for matcher test collocation
if (type.endsWith('.test') || type.endsWith('.spec')) continue;
throw new Error(
`Matcher names can only have underscores and alphanumeric characters — "${file}" is invalid`
);
}
}
}
return matchers;
}
/**
* @param {import('types').ValidatedConfig} config
* @param {string} cwd
* @param {string} fallback
*/
function create_routes_and_nodes(cwd, config, fallback) {
/** @type {import('types').RouteData[]} */
const routes = [];
const routes_base = posixify(path.relative(cwd, config.kit.files.routes));
const valid_extensions = [...config.extensions, ...config.kit.moduleExtensions];
/** @type {import('types').PageNode[]} */
const nodes = [];
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 unescaped = id.replace(/\[([ux])\+([^\]]+)\]/gi, (match, type, code) => {
if (match !== match.toLowerCase()) {
throw new Error(`Character escape sequence in ${id} must be lowercase`);
}
if (!/[0-9a-f]+/.test(code)) {
throw new Error(`Invalid character escape sequence in ${id}`);
}
if (type === 'x') {
if (code.length !== 2) {
throw new Error(`Hexadecimal escape sequence in ${id} must be two characters`);
}
return String.fromCharCode(parseInt(code, 16));
} else {
if (code.length < 4 || code.length > 6) {
throw new Error(
`Unicode escape sequence in ${id} must be between four and six characters`
);
}
return String.fromCharCode(parseInt(code, 16));
}
});
if (/\]\[/.test(unescaped)) {
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`);
}
if (/#/.test(segment)) {
// Vite will barf on files with # in them
throw new Error(`Route ${id} should be renamed to ${id.replace(/#/g, '[x+23]')}`);
}
if (/\[\.\.\.\w+\]\/\[\[/.test(id)) {
throw new Error(
`Invalid route ${id} — an [[optional]] route segment cannot follow a [...rest] route segment`
);
}
if (/\[\[\.\.\./.test(id)) {
throw new Error(
`Invalid route ${id} — a rest route segment is always optional, remove the outer square brackets`
);
}
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 can't use withFileTypes because of a NodeJs bug which returns wrong results
// with isDirectory() in case of symlinks: https://github.com/nodejs/node/issues/30646
const files = fs.readdirSync(dir).map((name) => ({
is_dir: fs.statSync(path.join(dir, name)).isDirectory(),
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);
// check if it is a valid route filename but missing the + prefix
const typo =
/^(?:(page(?:@(.*))?)|(layout(?:@(.*))?)|(error))$/.test(name) ||
/^(?:(server)|(page(?:(@[a-zA-Z0-9_-]*))?(\.server)?)|(layout(?:(@[a-zA-Z0-9_-]*))?(\.server)?))$/.test(
name
);
if (typo) {
console.log(
colors
.bold()
.yellow(
`Missing route file prefix. Did you mean +${file.name}?` +
` at ${path.join(dir, file.name)}`
)
);
}
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);
const valid =
/^\+(?:(page(?:@(.*))?)|(layout(?:@(.*))?)|(error))$/.test(name) ||
/^\+(?:(server)|(page(?:(@[a-zA-Z0-9_-]*))?(\.server)?)|(layout(?:(@[a-zA-Z0-9_-]*))?(\.server)?))$/.test(
name
);
if (valid) continue;
}
const project_relative = posixify(path.relative(cwd, path.join(dir, file.name)));
const item = analyze(
project_relative,
file.name,
config.extensions,
config.kit.moduleExtensions
);
if (config.kit.router.type === 'hash' && item.kind === 'server') {
throw new Error(
`Cannot use server-only files in an app with \`router.type === 'hash': ${project_relative}`
);
}
/**
* @param {string} type
* @param {string} existing_file
*/
function duplicate_files_error(type, existing_file) {
return new Error(
`Multiple ${type} files found in ${routes_base}${route.id} : ${path.basename(
existing_file
)} and ${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) {
throw duplicate_files_error('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) {
throw duplicate_files_error('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]) {
throw duplicate_files_error(
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]) {
throw duplicate_files_error(
item.kind + ' page module',
/** @type {string} */ (route.leaf[item.kind])
);
}
route.leaf[item.kind] = project_relative;
} else {
if (route.endpoint) {
throw duplicate_files_error('endpoint', route.endpoint.file);
}
route.endpoint = {
file: project_relative
};
}
}
// 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) {
throw new Error(
'No routes found. If you are using a custom src/routes directory, make sure it is specified in svelte.config.js'
);
}
}
} 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);
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 };
root.error.component = posixify(path.relative(cwd, `${fallback}/error.svelte`));
}
// 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]));
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) {
throw new Error(`${current_node.component} references missing segment "${parent_id}"`);
}
}
return {
nodes,
routes: sort_routes(routes)
};
}
/**
* @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 pattern = /^\+(?:(page(?:@(.*))?)|(layout(?:@(.*))?)|(error))$/;
const match = pattern.exec(name);
if (!match) {
throw new Error(`Files prefixed with + are reserved (saw ${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 pattern =
/^\+(?:(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[6]) {
throw new Error(
// prettier-ignore
`Only Svelte files can reference named layouts. Remove '${match[3] || match[6]}' from ${file} (at ${project_relative})`
);
}
const kind = match[1] || match[4] || match[7] ? 'server' : 'universal';
return {
kind,
is_page: !!match[2],
is_layout: !!match[5]
};
}
throw new Error(`Files and directories prefixed with + are reserved (saw ${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;
}
/** @param {import('types').RouteData[]} routes */
function prevent_conflicts(routes) {
/** @type {Map<string, string>} */
const lookup = new Map();
for (const route of routes) {
if (!route.leaf && !route.endpoint) continue;
const normalized = normalize_route_id(route.id);
// find all permutations created by optional parameters
const split = normalized.split(/<\?(.+?)>/g);
let permutations = [/** @type {string} */ (split[0])];
// turn `x/[[optional]]/y` into `x/y` and `x/[required]/y`
for (let i = 1; i < split.length; i += 2) {
const matcher = split[i];
const next = split[i + 1];
permutations = permutations.reduce((a, b) => {
a.push(b + next);
if (!(matcher === '*' && b.endsWith('//'))) a.push(b + `<${matcher}>${next}`);
return a;
}, /** @type {string[]} */ ([]));
}
for (const permutation of permutations) {
// remove leading/trailing/duplicated slashes caused by prior
// manipulation of optional parameters and (groups)
const key = permutation
.replace(/\/{2,}/, '/')
.replace(/^\//, '')
.replace(/\/$/, '');
if (lookup.has(key)) {
throw new Error(
`The "${lookup.get(key)}" and "${route.id}" routes conflict with each other`
);
}
lookup.set(key, route.id);
}
}
}
/** @param {string} id */
function normalize_route_id(id) {
return (
id
// remove groups
.replace(/(?<=^|\/)\(.+?\)(?=$|\/)/g, '')
.replace(/\[[ux]\+([0-9a-f]+)\]/g, (_, x) =>
String.fromCharCode(parseInt(x, 16)).replace(/\//g, '%2f')
)
// replace `[param]` with `<*>`, `[param=x]` with `<x>`, and `[[param]]` with `<?*>`
.replace(
/\[(?:(\[)|(\.\.\.))?.+?(=.+?)?\]\]?/g,
(_, optional, rest, matcher) => `<${optional ? '?' : ''}${rest ?? ''}${matcher ?? '*'}>`
)
);
}