change how routes are created

This commit is contained in:
Rich Harris
2018-07-15 22:13:30 -04:00
parent 5b024f2c8d
commit bf8e56748a
29 changed files with 345 additions and 151 deletions

View File

@@ -1,186 +1,263 @@
import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import glob from 'glob';
import { locations } from '../config'; import { locations } from '../config';
import { Route } from '../interfaces'; import { Route } from '../interfaces';
export default function create_routes({ type Component = {
files name: string;
} = { file: string;
files: glob.sync('**/*.*', { };
cwd: locations.routes(),
dot: true,
nodir: true
})
}) {
const all_routes = files
.filter((file: string) => !/(^|\/|\\)(_(?!error\.html)|\.(?!well-known))/.test(file))
.map((file: string) => {
if (/]\[/.test(file)) {
throw new Error(`Invalid route ${file} — parameters must be separated`);
}
if (file === '4xx.html' || file === '5xx.html') { type Page = {
throw new Error('As of Sapper 0.14, 4xx.html and 5xx.html should be replaced with _error.html'); pattern: RegExp;
} parts: Array<{
component: Component;
params: string[];
}>
};
const base = file.replace(/\.[^/.]+$/, ''); type ServerRoute = {
const parts = base.split('/'); // glob output is always posix-style name: string;
if (/^index(\..+)?/.test(parts[parts.length - 1])) { pattern: RegExp;
const part = parts.pop(); file: string;
if (parts.length > 0) parts[parts.length - 1] += part.slice(5); params: string[];
} };
const id = ( export default function create_routes(cwd = locations.routes()) {
parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_') const components: Component[] = [];
) || '_'; const pages: Page[] = [];
const server_routes: ServerRoute[] = [];
const type = file.endsWith('.html') ? 'page' : 'route'; function walk(
dir: string,
parent_segments: Part[][],
parent_params: string[],
stack: Array<{
component: Component,
params: string[]
}>
) {
const items = fs.readdirSync(dir)
.map(basename => {
const resolved = path.join(dir, basename);
const file = path.relative(cwd, resolved);
const is_dir = fs.statSync(resolved).isDirectory();
const params: string[] = []; const segment = is_dir
const match_patterns: Record<string, string> = {}; ? basename
const param_pattern = /\[([^\(\]]+)(?:\((.+?)\))?\]/g; : basename.slice(0, -path.extname(basename).length);
let match; const parts = get_parts(segment);
while (match = param_pattern.exec(base)) { const is_index = is_dir ? false : basename.startsWith('index.');
params.push(match[1]); const is_page = path.extname(basename) === '.html';
if (typeof match[2] !== 'undefined') {
if (/[\(\)\?\:]/.exec(match[2])) { return {
throw new Error('Sapper does not allow (, ), ? or : in RegExp routes yet'); basename,
parts,
file,
is_dir,
is_index,
is_page
};
})
.sort(comparator);
items.forEach(item => {
const segments = parent_segments.slice();
if (item.is_index && segments.length > 0) {
const last_segment = segments[segments.length - 1].slice();
const suffix = item.basename
.slice(0, -path.extname(item.basename).length).
replace('index', '');
if (suffix) {
const last_part = last_segment[last_segment.length - 1];
if (last_part.dynamic) {
last_segment.push({ dynamic: false, content: suffix });
} else {
last_segment[last_segment.length - 1] = {
dynamic: false,
content: `${last_part.content}${suffix}`
};
} }
// Make a map of the regexp patterns
match_patterns[match[1]] = `(${match[2]}?)`; segments[segments.length - 1] = last_segment;
} }
} else {
segments.push(item.parts);
} }
// TODO can we do all this with sub-parts? or does const params = parent_params.slice();
// nesting make that impossible? params.push(...item.parts.filter(p => p.dynamic).map(p => p.content));
let pattern_string = '';
let i = parts.length;
let nested = true;
while (i--) {
const part = encodeURI(parts[i].normalize()).replace(/\?/g, '%3F').replace(/#/g, '%23').replace(/%5B/g, '[').replace(/%5D/g, ']');
const dynamic = ~part.indexOf('[');
if (dynamic) { if (item.is_dir) {
// Get keys from part and replace with stored match patterns const index = path.join(dir, 'index.html');
const keys = part.replace(/\(.*?\)/, '').split(/[\[\]]/).filter((x, i) => { if (i % 2) return x }); const component = fs.existsSync(index)
let matcher = part; ? {
keys.forEach(k => { name: `page_${get_slug(item.file)}`,
const key_pattern = new RegExp('\\[' + k + '(?:\\((.+?)\\))?\\]'); file: path.join(item.file, 'index.html')
matcher = matcher.replace(key_pattern, match_patterns[k] || `([^/]+?)`); }
: null;
if (component) components.push(component);
walk(
path.join(dir, item.basename),
segments,
params,
stack.concat({
component,
params
}) })
pattern_string = (nested && type === 'page') ? `(?:\\/${matcher}${pattern_string})?` : `\\/${matcher}${pattern_string}`;
} else {
nested = false;
pattern_string = `\\/${part}${pattern_string}`;
}
}
const pattern = new RegExp(`^${pattern_string}\\/?$`);
const test = (url: string) => pattern.test(url);
const exec = (url: string) => {
const match = pattern.exec(url);
if (!match) return;
const result: Record<string, string> = {};
params.forEach((param, i) => {
result[param] = match[i + 1];
});
return result;
};
return {
id,
base,
type,
file,
pattern,
test,
exec,
parts,
params
};
});
const pages = all_routes
.filter(r => r.type === 'page')
.sort(comparator);
const server_routes = all_routes
.filter(r => r.type === 'route')
.sort(comparator);
return { pages, server_routes };
}
function comparator(a, b) {
if (a.parts[0] === '_error') return -1;
if (b.parts[0] === '_error') return 1;
const max = Math.max(a.parts.length, b.parts.length);
for (let i = 0; i < max; i += 1) {
const a_part = a.parts[i];
const b_part = b.parts[i];
if (!a_part) return -1;
if (!b_part) return 1;
const a_sub_parts = get_sub_parts(a_part);
const b_sub_parts = get_sub_parts(b_part);
const max = Math.max(a_sub_parts.length, b_sub_parts.length);
for (let i = 0; i < max; i += 1) {
const a_sub_part = a_sub_parts[i];
const b_sub_part = b_sub_parts[i];
if (!a_sub_part) return 1; // b is more specific, so goes first
if (!b_sub_part) return -1;
if (a_sub_part.dynamic !== b_sub_part.dynamic) {
return a_sub_part.dynamic ? 1 : -1;
}
if (!a_sub_part.dynamic && a_sub_part.content !== b_sub_part.content) {
return (
(b_sub_part.content.length - a_sub_part.content.length) ||
(a_sub_part.content < b_sub_part.content ? -1 : 1)
); );
} }
// If both parts dynamic, check for regexp patterns else if (item.basename === 'index.html') {
if (a_sub_part.dynamic && b_sub_part.dynamic) { // TODO if this is a leaf, create a route for it
const regexp_pattern = /\((.*?)\)/; }
const a_match = regexp_pattern.exec(a_sub_part.content);
const b_match = regexp_pattern.exec(b_sub_part.content);
if (!a_match && b_match) { else if (item.is_page) {
return 1; // No regexp, so less specific than b const component = {
} name: `page_${get_slug(item.file)}`,
if (!b_match && a_match) { file: item.file
return -1; };
}
if (a_match && b_match && a_match[1] !== b_match[1]) { const parts = stack.concat({
return b_match[1].length - a_match[1].length; component,
params
});
components.push(component);
if (item.basename === '_default.html') {
pages.push({
pattern: get_pattern(parent_segments),
parts
});
} else {
pages.push({
pattern: get_pattern(segments),
parts
});
} }
} }
else {
server_routes.push({
name: `route_${get_slug(item.file)}`,
pattern: get_pattern(segments),
file: item.file,
params: params
});
}
});
}
walk(cwd, [], [], []);
return {
components,
pages,
server_routes
};
}
type Part = {
content: string;
dynamic: boolean;
qualifier?: string;
};
function comparator(
a: { basename: string, parts: Part[], file: string, is_dir: boolean },
b: { basename: string, parts: Part[], file: string, is_dir: boolean }
) {
const max = Math.max(a.parts.length, b.parts.length);
for (let i = 0; i < max; i += 1) {
const a_sub_part = a.parts[i];
const b_sub_part = b.parts[i];
if (!a_sub_part) return 1; // b is more specific, so goes first
if (!b_sub_part) return -1;
if (a_sub_part.dynamic !== b_sub_part.dynamic) {
return a_sub_part.dynamic ? 1 : -1;
}
if (!a_sub_part.dynamic && a_sub_part.content !== b_sub_part.content) {
return (
(b_sub_part.content.length - a_sub_part.content.length) ||
(a_sub_part.content < b_sub_part.content ? -1 : 1)
);
}
// If both parts dynamic, check for regexp patterns
if (a_sub_part.dynamic && b_sub_part.dynamic) {
const regexp_pattern = /\((.*?)\)/;
const a_match = regexp_pattern.exec(a_sub_part.content);
const b_match = regexp_pattern.exec(b_sub_part.content);
if (!a_match && b_match) {
return 1; // No regexp, so less specific than b
}
if (!b_match && a_match) {
return -1;
}
if (a_match && b_match && a_match[1] !== b_match[1]) {
return b_match[1].length - a_match[1].length;
}
} }
} }
throw new Error(`The ${a.base} and ${b.base} routes clash`);
} }
function get_sub_parts(part: string) { const qualifier_pattern = /\(.+\)$/;
function get_parts(part: string): Part[] {
return part.split(/\[(.+)\]/) return part.split(/\[(.+)\]/)
.map((content, i) => { .map((content, i) => {
if (!content) return null; if (!content) return null;
const dynamic = i % 2 === 1;
const qualifier = dynamic && qualifier_pattern.test(content)
? qualifier_pattern.exec(content)[0]
: null;
return { return {
content, content,
dynamic: i % 2 === 1 dynamic,
qualifier
}; };
}) })
.filter(Boolean); .filter(Boolean);
} }
function get_slug(file: string) {
return file
.replace(/[\\\/]index/, '')
.replace(/_default([\/\\index])?\.html$/, 'index')
.replace(/[\/\\]/g, '_')
.replace(/\.\w+$/, '')
.replace(/\[([^(]+)(?:\([^(]+\))?\]/, '$$$1')
.replace(/[^a-zA-Z0-9_$]/g, c => {
return c === '.' ? '_' : `$${c.charCodeAt(0)}`
});
}
function get_pattern(segments: Part[][]) {
return new RegExp(
`^` +
segments.map(segment => {
return '\\/' + segment.map(part => {
return part.dynamic
? part.qualifier || '([^\\/]+?)'
: encodeURI(part.content.normalize())
.replace(/\?/g, '%3F')
.replace(/#/g, '%23')
.replace(/%5B/g, '[')
.replace(/%5D/g, ']');
}).join('');
}).join('') +
'\\\/?$'
);
}

117
test/unit/create_routes.js Normal file
View File

@@ -0,0 +1,117 @@
const path = require('path');
const assert = require('assert');
const { create_routes_alt } = require('../../dist/core.ts.js');
describe('create_routes', () => {
it('creates routes', () => {
const { components, pages, server_routes } = create_routes_alt(path.join(__dirname, 'samples/basic'));
const page_index = { name: 'page_index', file: '_default.html' };
const page_about = { name: 'page_about', file: 'about.html' };
const page_blog = { name: 'page_blog', file: 'blog/index.html' };
const page_blog_index = { name: 'page_blog_index', file: 'blog/_default.html' };
const page_blog_$slug = { name: 'page_blog_$slug', file: 'blog/[slug].html' };
assert.deepEqual(components, [
page_index,
page_about,
page_blog,
page_blog_index,
page_blog_$slug
]);
assert.deepEqual(pages, [
{
pattern: /^\/?$/,
parts: [
{ component: page_index, params: [] }
]
},
{
pattern: /^\/about\/?$/,
parts: [
{ component: page_about, params: [] }
]
},
{
pattern: /^\/blog\/?$/,
parts: [
{ component: page_blog, params: [] },
{ component: page_blog_index, params: [] }
]
},
{
pattern: /^\/blog\/([^\/]+?)\/?$/,
parts: [
{ component: page_blog, params: [] },
{ component: page_blog_$slug, params: ['slug'] }
]
}
]);
assert.deepEqual(server_routes, [
{
name: 'route_blog_json',
pattern: /^\/blog.json\/?$/,
file: 'blog/index.json.js',
params: []
},
{
name: 'route_blog_$slug_json',
pattern: /^\/blog\/([^\/]+?).json\/?$/,
file: 'blog/[slug].json.js',
params: ['slug']
}
]);
});
it('encodes invalid characters', () => {
const { components, pages } = create_routes_alt(path.join(__dirname, 'samples/encoding'));
const quote = { name: 'page_$34', file: '".html' };
const hash = { name: 'page_$35', file: '#.html' };
const question_mark = { name: 'page_$63', file: '?.html' };
assert.deepEqual(components, [
quote,
hash,
question_mark
]);
assert.deepEqual(pages.map(p => p.pattern), [
/^\/%22\/?$/,
/^\/%23\/?$/,
/^\/%3F\/?$/
]);
});
it('allows regex qualifiers', () => {
const { pages } = create_routes_alt(path.join(__dirname, 'samples/qualifiers'));
assert.deepEqual(pages.map(p => p.pattern), [
/^\/([0-9-a-z]{3,})\/?$/,
/^\/([a-z]{2})\/?$/,
/^\/([^\/]+?)\/?$/
]);
});
it('sorts routes correctly', () => {
const { pages } = create_routes_alt(path.join(__dirname, 'samples/sorting'));
assert.deepEqual(pages.map(p => p.parts.map(part => part.component.file)), [
['_default.html'],
['about.html'],
['post/index.html', 'post/_default.html'],
['post/index.html', 'post/bar.html'],
['post/index.html', 'post/foo.html'],
['post/index.html', 'post/f[xx].html'],
['post/index.html', 'post/[id([0-9-a-z]{3,})].html'],
['post/index.html', 'post/[id].html'],
['[wildcard].html']
]);
});
});

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File