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 glob from 'glob';
import { locations } from '../config';
import { Route } from '../interfaces';
export default function create_routes({
files
} = {
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`);
}
type Component = {
name: string;
file: string;
};
if (file === '4xx.html' || file === '5xx.html') {
throw new Error('As of Sapper 0.14, 4xx.html and 5xx.html should be replaced with _error.html');
}
type Page = {
pattern: RegExp;
parts: Array<{
component: Component;
params: string[];
}>
};
const base = file.replace(/\.[^/.]+$/, '');
const parts = base.split('/'); // glob output is always posix-style
if (/^index(\..+)?/.test(parts[parts.length - 1])) {
const part = parts.pop();
if (parts.length > 0) parts[parts.length - 1] += part.slice(5);
}
type ServerRoute = {
name: string;
pattern: RegExp;
file: string;
params: string[];
};
const id = (
parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_')
) || '_';
export default function create_routes(cwd = locations.routes()) {
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 match_patterns: Record<string, string> = {};
const param_pattern = /\[([^\(\]]+)(?:\((.+?)\))?\]/g;
const segment = is_dir
? basename
: basename.slice(0, -path.extname(basename).length);
let match;
while (match = param_pattern.exec(base)) {
params.push(match[1]);
if (typeof match[2] !== 'undefined') {
if (/[\(\)\?\:]/.exec(match[2])) {
throw new Error('Sapper does not allow (, ), ? or : in RegExp routes yet');
const parts = get_parts(segment);
const is_index = is_dir ? false : basename.startsWith('index.');
const is_page = path.extname(basename) === '.html';
return {
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
// nesting make that impossible?
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('[');
const params = parent_params.slice();
params.push(...item.parts.filter(p => p.dynamic).map(p => p.content));
if (dynamic) {
// Get keys from part and replace with stored match patterns
const keys = part.replace(/\(.*?\)/, '').split(/[\[\]]/).filter((x, i) => { if (i % 2) return x });
let matcher = part;
keys.forEach(k => {
const key_pattern = new RegExp('\\[' + k + '(?:\\((.+?)\\))?\\]');
matcher = matcher.replace(key_pattern, match_patterns[k] || `([^/]+?)`);
if (item.is_dir) {
const index = path.join(dir, 'index.html');
const component = fs.existsSync(index)
? {
name: `page_${get_slug(item.file)}`,
file: path.join(item.file, 'index.html')
}
: 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
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);
else if (item.basename === 'index.html') {
// TODO if this is a leaf, create a route for it
}
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;
else if (item.is_page) {
const component = {
name: `page_${get_slug(item.file)}`,
file: item.file
};
const parts = stack.concat({
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(/\[(.+)\]/)
.map((content, i) => {
if (!content) return null;
const dynamic = i % 2 === 1;
const qualifier = dynamic && qualifier_pattern.test(content)
? qualifier_pattern.exec(content)[0]
: null;
return {
content,
dynamic: i % 2 === 1
dynamic,
qualifier
};
})
.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('') +
'\\\/?$'
);
}