Eliminating Magic Strings and Streamlining Renaming by Auto Generation
Introduction
Have you ever found yourself in a situation where you needed to rename a route in your SvelteKit application, only to end up searching through multiple files without any guidance or error notifications? If you’ve experienced this frustration, you’re not alone. Fortunately, there’s a solution to this problem that can save you time and make your code more maintainable. In this blog post, we’ll explore how to turn this cumbersome process:
<a href="/blog/blog-id/content">Content</a>
Into this streamlined and error-resistant structure:
<a href={routes.blog(blog.id).content().value}>Content</a>
Understanding Svelte’s File Structure
Before we dive into generating a routes file, let’s take a closer look at SvelteKit’s file structure. SvelteKit follows a unique approach where routes are organized based on folders and rely on the presence of +page.ts
files. These folders represent different parts of your application and play a crucial role in structuring your routes. In addition, SvelteKit utilizes slugs to make dynamic routing possible.
A slug, in the context of SvelteKit, is a placeholder that can represent various values, providing flexibility in routing. Understanding this file structure and the role of slugs is essential to creating a more organized and manageable codebase.
Here is an examine an example file structure:
routes
- api
- login
- +server.ts
- blog
- [id]
- +page.svelte
- +page.ts
- contact
- +page.ts
- users
- [id]
- +page.svelte
- +page.ts
+layout.svelte
+page.svelte
Defining a Routes Format
To streamline the process of generating routes and making them more robust, we need to define a routes format. In this section, we’ll focus on handling routes and slugs without involving URL parameters to keep things simple.
The structure we aim to achieve should resemble the following:
function slugOpt(value?: string) {
return value ? `/${value}` : "";
}
const routes = {
blog: function (blogId: string) {
return {
value: `blog/${slugOpt(blogId)}`,
content: function () {
return {
value: `blog${slugOpt(blogId)}/content`,
};
},
};
},
} as const;
In this format, we opt for using functions for each route part, ensuring consistency whether a route includes a slug or not.
The value
property is used to access the value of a specific route, which can be a slug or a fixed segment.
Optionally, you can specify whether a slug is mandatory or not by including a +page.ts
file above the slug in the folder structure.
When a +page.ts
is present, the slug becomes optional; otherwise, it’s mandatory.
This format simplifies the process of defining and managing routes, making
your codebase more readable and preventing errors when renaming routes.
Finding a Template
With an understanding of the SvelteKit file structure and the desired routes format, it’s time to find a template that will enable us to generate routes in a consistent and maintainable way. Based on the structure we’ve uncovered, we can identify three possible base cases for route generation:
- The route is at the end and contains a
+page.ts
, so it contains only a value. - The route contains no
+page.ts
and can’t be navigated to directly; it only contains subroutes. - The route contains a
+page.ts
and subroutes, including a value and subroute combination.
Armed with this knowledge, we can create a template that will serve as the foundation for our route generation:
{{name}}: function ({{slugName}}) {
return {
{{content}}
};
}
Here name
is equivalent to the folder name at current depth, slugName
is the optional slug name and type and content
is the
template itself again with an optional value for to navigate to the current route, if possible.
Reading the Routes
To illustrate the process, let’s examine the file structure of a sample website:
routes
- api
- contact
- +server.ts
- blog
- [id]
- +page.server.ts
- +page.svelte
- +page.ts
- +page.server.ts
- +page.svelte
- contact
- +page.ts
+layout.svelte
+page.svelte
This hierarchy represents the SvelteKit file structure, where routes are organized within the routes directory. Notably, dynamic routes are denoted by square brackets, such as [id]. To efficiently handle and interpret this structure, a recursive process is implemented to traverse the directories and construct a node tree. This process is exemplified in the following TypeScript code:
function readDirectory(currField: Field, directoryPath: string) {
const items = fs.readdirSync(directoryPath);
for (let item of items) {
let itemPath = path.join(directoryPath, item);
// Normalize paths for cross-platform compatibility
item = item.replace(/\/g, "/");
itemPath = itemPath.replace(/\/g, "/");
if (fs.statSync(itemPath).isDirectory()) {
const newField = new Field(item);
currField.add(newField);
readDirectory(newField, itemPath); // Recursively read the subdirectory
} else {
if (item == "+page.svelte") {
const fieldValue = itemPath.replace("+page.svelte", "")
.replace(basePath, "").slice(0, -1);
currField.valueObject = new ValueObject(fieldValue);
}
}
}
}
The readDirectory
function accepts a current field and a directory path, iterating through the
directory’s items. For directories, it creates a new field and recursively calls itself
to process subdirectories. When encountering a +page.svelte
file, it extracts the corresponding
field value. It’s worth noting the normalization of file paths to ensure consistency across platforms,
simplifying future operations.
Now, let’s delve into the core data structure employed for this process:
export class Field {
//... placeholder string fields
private slugChildField: Field | undefined = undefined;
public children: Field[] = [];
public valueObject?: ValueObject;
public isSlug = false;
private fieldTemplate = `${this.fieldNamePlaceholder}: function (${this.slugNamePlaceholder}) {
return {
${this.fieldContentPlaceholder}
};
},`;
constructor(public name: string) {
if (name.startsWith("[") && endsWith("]")) {
this.isSlug = true;
}
this.name = this.isSlug ? name.slice(1, -1) : name;
}
public add(field: Field) {
this.children.push(field);
if (field.isSlug && this.slugChildField) {
throw new Error("Only one slug per field is allowed");
}
if (field.isSlug) {
this.slugChildField = field;
}
}
private getInitialTemplate() {
let buildTemplate = this.fieldContentPlaceholder;
if (!this.isSlug) {
buildTemplate = this.fieldTemplate.replace(this.fieldNamePlaceholder, this.name);
}
return buildTemplate;
}
private handleBuildSlug(buildTemplate: string, slugs: string[]) {
let slugParam = "";
if (this.slugChildField) {
slugs.push(`${this.slugChildField.name}`);
const paramName = this.slugChildField.name;
slugParam = this.valueObject ? `${paramName}?: string` : `${paramName}: string`;
}
buildTemplate = buildTemplate.replace(this.slugNamePlaceholder, slugParam);
return buildTemplate;
}
public build(depth: number, slugs: string[]) {
//... (to be filled in)
}
}
A Field represents the one-depth structure of our template defined earlier, meaning it represents one route level
of depth like routes.auth()
for the “auth” or routes.auth().login()
for the “login” folder. It is a close to 1:1 mapping of
the file system, with the exception of a slug. A slug folder is pulled as a parameter into the route itself
and decreases the depth on this branch by one.
Now lets take a look at how to build the routes:
public build(depth: number, slugs: string[]) {
if (this.isEmpty) {
return "";
}
let buildTemplate = this.getInitialTemplate();
buildTemplate = this.handleBuildSlug(buildTemplate, slugs);
let content = "";
if (this.valueObject && !this.isSlug) {
let value = this.valueObject.value;
if(this.slugChildField)
value += `/[${this.slugChildField.name}]`;
let valueRoute = `value: `${value}`,`;
for (const slug of slugs) {
valueRoute = valueRoute.replace(`/[${slug}]`, `${slugOpt(${slug})}`);
}
content += valueRoute;
if (this.children.length > 0) {
content += "\n";
}
}
for (let i = 0; i < this.children.length; i++) {
const field = this.children[i];
content += field.build(depth + 1, slugs);
if (i < this.children.length - 1) {
content += "\n";
}
}
buildTemplate = buildTemplate.replace(this.fieldContentPlaceholder, content);
return buildTemplate;
}
The build function plays a pivotal role in constructing the route template for a specific field within the SvelteKit application. It begins by checking if the current field is empty; if so, it returns an empty string. Next, it retrieves the initial template for the field, taking into consideration whether the field is a slug or not. The function then addresses the building of slugs, appending them to the slugs array for the current branch.
Moving on, the function evaluates if the field possesses a valueObject and is not a slug. In such cases, it assembles the value field of the template, accommodating the presence of slugs in the route path. This step ensures the generated template accurately represents the navigation structure.
Subsequently, the function iterates over the field’s children, recursively invoking the build function for each. This recursive approach ensures the comprehensive construction of the route template, incorporating all nested subfields.
In summary, the build function orchestrates the dynamic assembly of a SvelteKit route template, considering the depth of the field, the presence of slugs, and the hierarchical structure of subfields. This systematic process contributes to the generation of organized and predictable route definitions, enhancing code readability and maintainability.
Result
The generated output file serves as the culmination of the route generation process.
function slugOpt(value?: string) {
return value ? `/${value}` : "";
}
export const routes = {
blog: function (id?: string) {
return {
value: `/blog${slugOpt(id)}`,
};
},
contact: function () {
return {
value: `/contact`,
};
},
impressum: function () {
return {
value: `/impressum`,
};
}
} as const
The generated output file defines SvelteKit routes using a utility function slugOpt
for optional slug inclusion and
produces a structured routes object with functions for each route, maintaining flexibility. If the +page.ts
is removed
from the blog route, the output enforces a mandatory slug parameter, altering the typing system to reflect the change.
export const routes = {
blog: function (id: string) {
return {
value: `/blog${slugOpt(id)}`,
};
},
//... (other routes)
}
Note: While slugOpt is still utilized, the removal of +page.ts
enforces the slug’s mandatory nature
through changes in the typing system, making the id parameter non-optional.
Source Code
Once some bugs are fixed and code cleanup is done, the full source code will be linked here.