@sveltejs/kit 1.0.0-next.430 → 1.0.0-next.433

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,7 @@ import path from 'path';
3
3
  import mime from 'mime';
4
4
  import { runtime_directory } from '../../utils.js';
5
5
  import { posixify } from '../../../utils/filesystem.js';
6
- import { parse_route_id } from '../../../utils/routing.js';
7
-
8
- const DEFAULT = 'default';
6
+ import { parse_route_id, affects_path } from '../../../utils/routing.js';
9
7
 
10
8
  /**
11
9
  * @param {{
@@ -20,261 +18,296 @@ export default function create_manifest_data({
20
18
  fallback = `${runtime_directory}/components`,
21
19
  cwd = process.cwd()
22
20
  }) {
23
- /** @type {Map<string, import('types').RouteData>} */
24
- const route_map = new Map();
21
+ const assets = create_assets(config);
22
+ const matchers = create_matchers(config, cwd);
23
+ const { nodes, routes } = create_routes_and_nodes(cwd, config, fallback);
25
24
 
26
- /** @type {Map<string, import('./types').Part[][]>} */
27
- const segment_map = new Map();
25
+ return {
26
+ assets,
27
+ matchers,
28
+ nodes,
29
+ routes
30
+ };
31
+ }
28
32
 
29
- /** @type {import('./types').RouteTree} */
30
- const tree = new Map();
33
+ /**
34
+ * @param {import('types').ValidatedConfig} config
35
+ */
36
+ function create_assets(config) {
37
+ return list_files(config.kit.files.assets).map((file) => ({
38
+ file,
39
+ size: fs.statSync(path.resolve(config.kit.files.assets, file)).size,
40
+ type: mime.getType(file)
41
+ }));
42
+ }
31
43
 
32
- const default_layout = {
33
- component: posixify(path.relative(cwd, `${fallback}/layout.svelte`))
34
- };
44
+ /**
45
+ * @param {import('types').ValidatedConfig} config
46
+ * @param {string} cwd
47
+ */
48
+ function create_matchers(config, cwd) {
49
+ const params_base = path.relative(cwd, config.kit.files.params);
35
50
 
36
- // set default root layout/error
37
- tree.set('', {
38
- error: {
39
- component: posixify(path.relative(cwd, `${fallback}/error.svelte`))
40
- },
41
- layouts: { [DEFAULT]: default_layout }
42
- });
43
-
44
- /** @param {string} id */
45
- function tree_node(id) {
46
- if (!tree.has(id)) {
47
- tree.set(id, {
48
- error: undefined,
49
- layouts: {}
50
- });
51
- }
51
+ /** @type {Record<string, string>} */
52
+ const matchers = {};
53
+ if (fs.existsSync(config.kit.files.params)) {
54
+ for (const file of fs.readdirSync(config.kit.files.params)) {
55
+ const ext = path.extname(file);
56
+ if (!config.kit.moduleExtensions.includes(ext)) continue;
57
+ const type = file.slice(0, -ext.length);
52
58
 
53
- return /** @type {import('./types').RouteTreeNode} */ (tree.get(id));
54
- }
59
+ if (/^\w+$/.test(type)) {
60
+ const matcher_file = path.join(params_base, file);
55
61
 
56
- const routes_base = posixify(path.relative(cwd, config.kit.files.routes));
57
- const valid_extensions = [...config.extensions, ...config.kit.moduleExtensions];
62
+ // Disallow same matcher with different extensions
63
+ if (matchers[type]) {
64
+ throw new Error(`Duplicate matchers: ${matcher_file} and ${matchers[type]}`);
65
+ } else {
66
+ matchers[type] = matcher_file;
67
+ }
68
+ } else {
69
+ throw new Error(
70
+ `Matcher names can only have underscores and alphanumeric characters — "${file}" is invalid`
71
+ );
72
+ }
73
+ }
74
+ }
58
75
 
59
- if (fs.existsSync(config.kit.files.routes)) {
60
- list_files(config.kit.files.routes).forEach((filepath) => {
61
- const extension = valid_extensions.find((ext) => filepath.endsWith(ext));
62
- if (!extension) return;
76
+ return matchers;
77
+ }
63
78
 
64
- const project_relative = `${routes_base}/${filepath}`;
65
- const segments = filepath.split('/');
66
- const file = /** @type {string} */ (segments.pop());
79
+ /**
80
+ * @param {import('types').ValidatedConfig} config
81
+ * @param {string} cwd
82
+ * @param {string} fallback
83
+ */
84
+ function create_routes_and_nodes(cwd, config, fallback) {
85
+ const route_map = new Map();
67
86
 
68
- if (file[0] !== '+') return; // not a route file
87
+ /** @type {Map<string, import('./types').Part[][]>} */
88
+ const segment_map = new Map();
69
89
 
70
- const item = analyze(project_relative, file, config.extensions, config.kit.moduleExtensions);
71
- const id = segments.join('/');
90
+ const routes_base = posixify(path.relative(cwd, config.kit.files.routes));
72
91
 
73
- if (/\]\[/.test(id)) {
74
- throw new Error(`Invalid route ${project_relative} — parameters must be separated`);
75
- }
92
+ const valid_extensions = [...config.extensions, ...config.kit.moduleExtensions];
76
93
 
77
- if (count_occurrences('[', id) !== count_occurrences(']', id)) {
78
- throw new Error(`Invalid route ${project_relative} — brackets are unbalanced`);
79
- }
94
+ /** @type {import('types').PageNode[]} */
95
+ const nodes = [];
80
96
 
81
- // error/layout files should be added to the tree, but don't result
82
- // in a route being created, so deal with them first. note: we are
83
- // relying on the fact that the +error and +layout files precede
84
- // +page files alphabetically, and will therefore be processes
85
- // before we reach the page
86
- if (item.kind === 'component' && item.is_error) {
87
- tree_node(id).error = {
88
- component: project_relative
89
- };
90
-
91
- return;
92
- }
97
+ if (fs.existsSync(config.kit.files.routes)) {
98
+ /**
99
+ * @param {number} depth
100
+ * @param {string} id
101
+ * @param {string} segment
102
+ * @param {import('types').RouteData | null} parent
103
+ */
104
+ const walk = (depth, id, segment, parent) => {
105
+ const { pattern, names, types } = parse_route_id(id);
106
+
107
+ const segments = id.split('/');
108
+
109
+ segment_map.set(
110
+ id,
111
+ segments.filter(Boolean).map((segment) => {
112
+ /** @type {import('./types').Part[]} */
113
+ const parts = [];
114
+ segment.split(/\[(.+?)\]/).map((content, i) => {
115
+ const dynamic = !!(i % 2);
116
+
117
+ if (!content) return;
118
+
119
+ parts.push({
120
+ content,
121
+ dynamic,
122
+ rest: dynamic && content.startsWith('...'),
123
+ type: (dynamic && content.split('=')[1]) || null
124
+ });
125
+ });
126
+ return parts;
127
+ })
128
+ );
93
129
 
94
- if (item.is_layout) {
95
- if (item.declares_layout === DEFAULT) {
96
- throw new Error(`${project_relative} cannot use reserved "${DEFAULT}" name`);
97
- }
130
+ /** @type {import('types').RouteData} */
131
+ const route = {
132
+ id,
133
+ parent,
98
134
 
99
- const layout_id = item.declares_layout || DEFAULT;
135
+ segment,
136
+ pattern,
137
+ names,
138
+ types,
100
139
 
101
- const group = tree_node(id);
140
+ layout: null,
141
+ error: null,
142
+ leaf: null,
143
+ page: null,
144
+ endpoint: null
145
+ };
102
146
 
103
- const defined = group.layouts[layout_id] || (group.layouts[layout_id] = {});
147
+ // important to do this before walking children, so that child
148
+ // routes appear later
149
+ route_map.set(id, route);
104
150
 
105
- if (defined[item.kind] && layout_id !== DEFAULT) {
106
- // edge case
107
- throw new Error(
108
- `Duplicate layout ${project_relative} already defined at ${defined[item.kind]}`
109
- );
110
- }
151
+ // if we don't do this, the route map becomes unwieldy to console.log
152
+ Object.defineProperty(route, 'parent', { enumerable: false });
111
153
 
112
- defined[item.kind] = project_relative;
154
+ const dir = path.join(cwd, routes_base, id);
113
155
 
114
- return;
115
- }
156
+ const files = fs.readdirSync(dir, {
157
+ withFileTypes: true
158
+ });
116
159
 
117
- const type = item.kind === 'server' && !item.is_layout && !item.is_page ? 'endpoint' : 'page';
160
+ // process files first
161
+ for (const file of files) {
162
+ if (file.isDirectory()) continue;
163
+ if (!file.name.startsWith('+')) continue;
164
+ if (!valid_extensions.find((ext) => file.name.endsWith(ext))) continue;
118
165
 
119
- if (type === 'endpoint' && route_map.has(id)) {
120
- // note that we are relying on +server being lexically ordered after
121
- // all other route files — if we added +view or something this is
122
- // potentially brittle, since the server might be added before
123
- // another route file. a problem for another day
124
- throw new Error(
125
- `${file} cannot share a directory with other route files (${project_relative})`
126
- );
127
- }
166
+ const project_relative = posixify(path.relative(cwd, path.join(dir, file.name)));
128
167
 
129
- if (!route_map.has(id)) {
130
- const pattern = parse_route_id(id).pattern;
131
-
132
- segment_map.set(
133
- id,
134
- segments.filter(Boolean).map((segment) => {
135
- /** @type {import('./types').Part[]} */
136
- const parts = [];
137
- segment.split(/\[(.+?)\]/).map((content, i) => {
138
- const dynamic = !!(i % 2);
139
-
140
- if (!content) return;
141
-
142
- parts.push({
143
- content,
144
- dynamic,
145
- rest: dynamic && content.startsWith('...'),
146
- type: (dynamic && content.split('=')[1]) || null
147
- });
148
- });
149
- return parts;
150
- })
168
+ const item = analyze(
169
+ project_relative,
170
+ file.name,
171
+ config.extensions,
172
+ config.kit.moduleExtensions
151
173
  );
152
174
 
153
- if (type === 'endpoint') {
154
- route_map.set(id, {
155
- type,
156
- id,
157
- pattern,
158
- file: project_relative
159
- });
175
+ if (item.kind === 'component') {
176
+ if (item.is_error) {
177
+ route.error = {
178
+ depth,
179
+ component: project_relative
180
+ };
181
+ } else if (item.is_layout) {
182
+ if (!route.layout) route.layout = { depth };
183
+ route.layout.component = project_relative;
184
+ if (item.uses_layout !== undefined) route.layout.parent_id = item.uses_layout;
185
+ } else {
186
+ if (!route.leaf) route.leaf = { depth };
187
+ route.leaf.component = project_relative;
188
+ if (item.uses_layout !== undefined) route.leaf.parent_id = item.uses_layout;
189
+ }
190
+ } else if (item.is_layout) {
191
+ if (!route.layout) route.layout = { depth };
192
+ route.layout[item.kind] = project_relative;
193
+ } else if (item.is_page) {
194
+ if (!route.leaf) route.leaf = { depth };
195
+ route.leaf[item.kind] = project_relative;
160
196
  } else {
161
- route_map.set(id, {
162
- type,
163
- id,
164
- pattern,
165
- errors: [],
166
- layouts: [],
167
- leaf: {}
168
- });
197
+ route.endpoint = {
198
+ file: project_relative
199
+ };
169
200
  }
170
201
  }
171
202
 
172
- if (item.is_page) {
173
- const route = /** @type {import('types').PageData} */ (route_map.get(id));
174
-
175
- // This ensures that layouts and errors are set for pages that have no Svelte file
176
- // and only redirect or throw an error, but are set to the Svelte file definition if it exists.
177
- // This ensures the proper error page is used and rendered in the proper layout.
178
- if (item.kind === 'component' || route.layouts.length === 0) {
179
- const { layouts, errors } = trace(
180
- tree,
181
- id,
182
- item.kind === 'component' ? item.uses_layout : undefined,
183
- project_relative
184
- );
185
- route.layouts = layouts;
186
- route.errors = errors;
187
- }
188
-
189
- if (item.kind === 'component') {
190
- route.leaf.component = project_relative;
191
- } else if (item.kind === 'server') {
192
- route.leaf.server = project_relative;
193
- } else {
194
- route.leaf.shared = project_relative;
203
+ // then handle children
204
+ for (const file of files) {
205
+ if (file.isDirectory()) {
206
+ walk(depth + 1, path.posix.join(id, file.name), file.name, route);
195
207
  }
196
208
  }
197
- });
209
+ };
198
210
 
199
- // TODO remove for 1.0
200
- if (route_map.size === 0) {
201
- throw new Error(
202
- 'The filesystem router API has changed, see https://github.com/sveltejs/kit/discussions/5774 for details'
203
- );
204
- }
205
- }
211
+ walk(0, '', '', null);
206
212
 
207
- /** @type {import('types').PageNode[]} */
208
- const nodes = [];
213
+ const root = /** @type {import('types').RouteData} */ (route_map.get(''));
209
214
 
210
- tree.forEach(({ layouts, error }) => {
211
- // we do [default, error, ...other_layouts] so that components[0] and [1]
212
- // are the root layout/error. kinda janky, there's probably a nicer way
213
- if (layouts[DEFAULT]) {
214
- nodes.push(layouts[DEFAULT]);
215
+ // TODO remove for 1.0
216
+ if (route_map.size === 1) {
217
+ if (!root.leaf && !root.error && !root.layout && !root.endpoint) {
218
+ throw new Error(
219
+ 'The filesystem router API has changed, see https://github.com/sveltejs/kit/discussions/5774 for details'
220
+ );
221
+ }
215
222
  }
216
223
 
217
- if (error) {
218
- nodes.push(error);
224
+ if (!root.layout?.component) {
225
+ if (!root.layout) root.layout = { depth: 0 };
226
+ root.layout.component = posixify(path.relative(cwd, `${fallback}/layout.svelte`));
219
227
  }
220
228
 
221
- for (const id in layouts) {
222
- if (id !== DEFAULT) {
223
- nodes.push(layouts[id]);
224
- }
229
+ if (!root.error?.component) {
230
+ if (!root.error) root.error = { depth: 0 };
231
+ root.error.component = posixify(path.relative(cwd, `${fallback}/error.svelte`));
225
232
  }
226
- });
227
233
 
228
- route_map.forEach((route) => {
229
- if (route.type === 'page') {
234
+ // we do layouts/errors first as they are more likely to be reused,
235
+ // and smaller indexes take fewer bytes. also, this guarantees that
236
+ // the default error/layout are 0/1
237
+ route_map.forEach((route) => {
238
+ if (route.layout) nodes.push(route.layout);
239
+ if (route.error) nodes.push(route.error);
240
+ });
241
+
242
+ /** @type {Map<string, string>} */
243
+ const conflicts = new Map();
244
+
245
+ route_map.forEach((route) => {
246
+ if (!route.leaf) return;
247
+
230
248
  nodes.push(route.leaf);
231
- }
232
- });
233
249
 
234
- const routes = Array.from(route_map.values()).sort((a, b) => compare(a, b, segment_map));
250
+ const normalized = route.id.split('/').filter(affects_path).join('/');
235
251
 
236
- /** @type {import('types').Asset[]} */
237
- const assets = fs.existsSync(config.kit.files.assets)
238
- ? list_files(config.kit.files.assets).map((file) => ({
239
- file,
240
- size: fs.statSync(`${config.kit.files.assets}/${file}`).size,
241
- type: mime.getType(file)
242
- }))
243
- : [];
252
+ if (conflicts.has(normalized)) {
253
+ throw new Error(`${conflicts.get(normalized)} and ${route.id} occupy the same route`);
254
+ }
244
255
 
245
- const params_base = path.relative(cwd, config.kit.files.params);
256
+ conflicts.set(normalized, route.id);
257
+ });
246
258
 
247
- /** @type {Record<string, string>} */
248
- const matchers = {};
249
- if (fs.existsSync(config.kit.files.params)) {
250
- for (const file of fs.readdirSync(config.kit.files.params)) {
251
- const ext = path.extname(file);
252
- if (!config.kit.moduleExtensions.includes(ext)) continue;
253
- const type = file.slice(0, -ext.length);
259
+ const indexes = new Map(nodes.map((node, i) => [node, i]));
254
260
 
255
- if (/^\w+$/.test(type)) {
256
- const matcher_file = path.join(params_base, file);
261
+ route_map.forEach((route) => {
262
+ if (!route.leaf) return;
257
263
 
258
- // Disallow same matcher with different extensions
259
- if (matchers[type]) {
260
- throw new Error(`Duplicate matchers: ${matcher_file} and ${matchers[type]}`);
261
- } else {
262
- matchers[type] = matcher_file;
264
+ if (route.leaf && route.endpoint) {
265
+ // TODO possibly relax this https://github.com/sveltejs/kit/issues/5896
266
+ throw new Error(`${route.endpoint.file} cannot share a directory with other route files`);
267
+ }
268
+
269
+ route.page = {
270
+ layouts: [],
271
+ errors: [],
272
+ leaf: /** @type {number} */ (indexes.get(route.leaf))
273
+ };
274
+
275
+ /** @type {import('types').RouteData | null} */
276
+ let current_route = route;
277
+ let current_node = route.leaf;
278
+ let parent_id = route.leaf.parent_id;
279
+
280
+ while (current_route) {
281
+ if (parent_id === undefined || current_route.segment === parent_id) {
282
+ if (current_route.layout || current_route.error) {
283
+ route.page.layouts.unshift(
284
+ current_route.layout ? indexes.get(current_route.layout) : undefined
285
+ );
286
+ route.page.errors.unshift(
287
+ current_route.error ? indexes.get(current_route.error) : undefined
288
+ );
289
+ }
290
+
291
+ if (current_route.layout) {
292
+ current_node.parent = current_node = current_route.layout;
293
+ parent_id = current_node.parent_id;
294
+ } else {
295
+ parent_id = undefined;
296
+ }
263
297
  }
264
- } else {
265
- throw new Error(
266
- `Matcher names can only have underscores and alphanumeric characters — "${file}" is invalid`
267
- );
298
+
299
+ current_route = current_route.parent;
268
300
  }
269
- }
301
+
302
+ if (parent_id !== undefined) {
303
+ throw new Error(`${current_node.component} references missing segment "${parent_id}"`);
304
+ }
305
+ });
270
306
  }
271
307
 
272
- return {
273
- assets,
274
- nodes,
275
- routes,
276
- matchers
277
- };
308
+ const routes = Array.from(route_map.values()).sort((a, b) => compare(a, b, segment_map));
309
+
310
+ return { nodes, routes };
278
311
  }
279
312
 
280
313
  /**
@@ -288,10 +321,16 @@ function analyze(project_relative, file, component_extensions, module_extensions
288
321
  const component_extension = component_extensions.find((ext) => file.endsWith(ext));
289
322
  if (component_extension) {
290
323
  const name = file.slice(0, -component_extension.length);
291
- const pattern =
292
- /^\+(?:(page(?:@([a-zA-Z0-9_-]+))?)|(layout(?:-([a-zA-Z0-9_-]+))?(?:@([a-zA-Z0-9_-]+))?)|(error))$/;
324
+ const pattern = /^\+(?:(page(?:@([a-zA-Z0-9_-]*))?)|(layout(?:@([a-zA-Z0-9_-]*))?)|(error))$/;
293
325
  const match = pattern.exec(name);
294
326
  if (!match) {
327
+ // TODO remove for 1.0
328
+ if (/^\+layout-/.test(name)) {
329
+ throw new Error(
330
+ `${project_relative} should be reimplemented with layout groups: https://kit.svelte.dev/docs/advanced-routing#advanced-layouts`
331
+ );
332
+ }
333
+
295
334
  throw new Error(`Files prefixed with + are reserved (saw ${project_relative})`);
296
335
  }
297
336
 
@@ -299,9 +338,8 @@ function analyze(project_relative, file, component_extensions, module_extensions
299
338
  kind: 'component',
300
339
  is_page: !!match[1],
301
340
  is_layout: !!match[3],
302
- is_error: !!match[6],
303
- uses_layout: match[2] || match[5],
304
- declares_layout: match[4]
341
+ is_error: !!match[5],
342
+ uses_layout: match[2] ?? match[4]
305
343
  };
306
344
  }
307
345
 
@@ -309,96 +347,29 @@ function analyze(project_relative, file, component_extensions, module_extensions
309
347
  if (module_extension) {
310
348
  const name = file.slice(0, -module_extension.length);
311
349
  const pattern =
312
- /^\+(?:(server)|(page(?:@([a-zA-Z0-9_-]+))?(\.server)?)|(layout(?:-([a-zA-Z0-9_-]+))?(?:@([a-zA-Z0-9_-]+))?(\.server)?))$/;
350
+ /^\+(?:(server)|(page(?:(@[a-zA-Z0-9_-]*))?(\.server)?)|(layout(?:(@[a-zA-Z0-9_-]*))?(\.server)?))$/;
313
351
  const match = pattern.exec(name);
314
352
  if (!match) {
315
353
  throw new Error(`Files prefixed with + are reserved (saw ${project_relative})`);
316
- } else if (match[3] || match[7]) {
354
+ } else if (match[3] || match[6]) {
317
355
  throw new Error(
318
356
  // prettier-ignore
319
- `Only Svelte files can reference named layouts. Remove '@${match[3] || match[7]}' from ${file} (at ${project_relative})`
357
+ `Only Svelte files can reference named layouts. Remove '${match[3] || match[6]}' from ${file} (at ${project_relative})`
320
358
  );
321
359
  }
322
360
 
323
- const kind = !!(match[1] || match[4] || match[8]) ? 'server' : 'shared';
361
+ const kind = !!(match[1] || match[4] || match[7]) ? 'server' : 'shared';
324
362
 
325
363
  return {
326
364
  kind,
327
365
  is_page: !!match[2],
328
- is_layout: !!match[5],
329
- declares_layout: match[6]
366
+ is_layout: !!match[5]
330
367
  };
331
368
  }
332
369
 
333
370
  throw new Error(`Files and directories prefixed with + are reserved (saw ${project_relative})`);
334
371
  }
335
372
 
336
- /**
337
- * @param {import('./types').RouteTree} tree
338
- * @param {string} id
339
- * @param {string} layout_id
340
- * @param {string} project_relative
341
- */
342
- function trace(tree, id, layout_id = DEFAULT, project_relative) {
343
- /** @type {Array<import('types').PageNode | undefined>} */
344
- const layouts = [];
345
-
346
- /** @type {Array<import('types').PageNode | undefined>} */
347
- const errors = [];
348
-
349
- const parts = id.split('/').filter(Boolean);
350
-
351
- // walk up the tree, find which +layout and +error components
352
- // apply to this page
353
- while (true) {
354
- const node = tree.get(parts.join('/'));
355
- const layout = node?.layouts[layout_id];
356
-
357
- if (layout && layouts.indexOf(layout) > -1) {
358
- // TODO this needs to be fixed for #5748
359
- throw new Error(
360
- `Recursive layout detected: ${layout.component} -> ${layouts
361
- .map((l) => l?.component)
362
- .join(' -> ')}`
363
- );
364
- }
365
-
366
- // any segment that has neither a +layout nor an +error can be discarded.
367
- // in other words these...
368
- // layouts: [a, , b, c]
369
- // errors: [d, , e, ]
370
- //
371
- // ...can be compacted to these:
372
- // layouts: [a, b, c]
373
- // errors: [d, e, ]
374
- if (node?.error || layout) {
375
- errors.unshift(node?.error);
376
- layouts.unshift(layout);
377
- }
378
-
379
- const parent_layout_id = layout?.component?.split('/').at(-1)?.split('@')[1]?.split('.')[0];
380
-
381
- if (parent_layout_id) {
382
- layout_id = parent_layout_id;
383
- } else {
384
- if (layout) layout_id = DEFAULT;
385
- if (parts.length === 0) break;
386
- parts.pop();
387
- }
388
- }
389
-
390
- if (layout_id !== DEFAULT) {
391
- throw new Error(`${project_relative} references missing layout "${layout_id}"`);
392
- }
393
-
394
- // trim empty space off the end of the errors array
395
- let i = errors.length;
396
- while (i--) if (errors[i]) break;
397
- errors.length = i + 1;
398
-
399
- return { layouts, errors };
400
- }
401
-
402
373
  /**
403
374
  * @param {import('types').RouteData} a
404
375
  * @param {import('types').RouteData} b
@@ -445,62 +416,31 @@ function compare(a, b, segment_map) {
445
416
  }
446
417
  }
447
418
 
448
- const a_is_endpoint = a.type === 'endpoint';
449
- const b_is_endpoint = b.type === 'endpoint';
450
-
451
- if (a_is_endpoint !== b_is_endpoint) {
452
- return a_is_endpoint ? -1 : +1;
419
+ if (!!a.endpoint !== !!b.endpoint) {
420
+ return a.endpoint ? -1 : +1;
453
421
  }
454
422
 
455
423
  return a < b ? -1 : 1;
456
424
  }
457
425
 
458
- /**
459
- * @param {string} needle
460
- * @param {string} haystack
461
- */
462
- function count_occurrences(needle, haystack) {
463
- let count = 0;
464
- for (let i = 0; i < haystack.length; i += 1) {
465
- if (haystack[i] === needle) count += 1;
466
- }
467
- return count;
468
- }
469
-
470
- /**
471
- * @param {string} dir
472
- * @param {string} [path]
473
- * @param {string[]} [files]
474
- */
475
- function list_files(dir, path = '', files = []) {
476
- fs.readdirSync(dir)
477
- .sort((a, b) => {
478
- // sort each directory in (+layout, +error, everything else) order
479
- // so that we can trace layouts/errors immediately
480
-
481
- if (a.startsWith('+layout') || a.startsWith('+error')) {
482
- if (!b.startsWith('+layout') && !b.startsWith('+error')) return -1;
483
- } else if (b.startsWith('+layout') || b.startsWith('+error')) {
484
- return 1;
485
- } else if (a.startsWith('__')) {
486
- if (!b.startsWith('__')) return -1;
487
- } else if (b.startsWith('__')) {
488
- return 1;
489
- }
490
-
491
- return a < b ? -1 : 1;
492
- })
493
- .forEach((file) => {
494
- const full = `${dir}/${file}`;
495
- const stats = fs.statSync(full);
496
- const joined = path ? `${path}/${file}` : file;
497
-
498
- if (stats.isDirectory()) {
499
- list_files(full, joined, files);
426
+ /** @param {string} dir */
427
+ function list_files(dir) {
428
+ /** @type {string[]} */
429
+ const files = [];
430
+
431
+ /** @param {string} current */
432
+ function walk(current) {
433
+ for (const file of fs.readdirSync(path.resolve(dir, current))) {
434
+ const child = path.posix.join(current, file);
435
+ if (fs.statSync(path.resolve(dir, child)).isDirectory()) {
436
+ walk(child);
500
437
  } else {
501
- files.push(joined);
438
+ files.push(child);
502
439
  }
503
- });
440
+ }
441
+ }
442
+
443
+ if (fs.existsSync(dir)) walk('');
504
444
 
505
445
  return files;
506
446
  }