Nested routes

Fixes #262
This commit is contained in:
Rich Harris
2018-07-22 21:00:37 -04:00
committed by GitHub
parent b75ae7ba96
commit 58de0f9c99
67 changed files with 1156 additions and 777 deletions

View File

@@ -3,10 +3,10 @@ import * as path from 'path';
import * as glob from 'glob';
import { posixify, write_if_changed } from './utils';
import { dev, locations } from '../config';
import { Route } from '../interfaces';
import { Page, PageComponent, ServerRoute } from '../interfaces';
export function create_main_manifests({ routes, dev_port }: {
routes: Route[];
routes: { components: PageComponent[], pages: Page[], server_routes: ServerRoute[] };
dev_port?: number;
}) {
const path_to_routes = path.relative(`${locations.app()}/manifest`, locations.routes());
@@ -14,12 +14,16 @@ export function create_main_manifests({ routes, dev_port }: {
const client_manifest = generate_client(routes, path_to_routes, dev_port);
const server_manifest = generate_server(routes, path_to_routes);
write_if_changed(
`${locations.app()}/manifest/default-layout.html`,
`<svelte:component this={child.component} {...child.props}/>`
);
write_if_changed(`${locations.app()}/manifest/client.js`, client_manifest);
write_if_changed(`${locations.app()}/manifest/server.js`, server_manifest);
}
export function create_serviceworker_manifest({ routes, client_files }: {
routes: Route[];
routes: { components: PageComponent[], pages: Page[], server_routes: ServerRoute[] };
client_files: string[];
}) {
const assets = glob.sync('**', { cwd: 'assets', nodir: true });
@@ -32,42 +36,67 @@ export function create_serviceworker_manifest({ routes, client_files }: {
export const shell = [\n\t${client_files.map((x: string) => `"${x}"`).join(',\n\t')}\n];
export const routes = [\n\t${routes.pages.filter(r => r.id !== '_error').map((r: Route) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n];
export const routes = [\n\t${routes.pages.map((r: Page) => `{ pattern: ${r.pattern} }`).join(',\n\t')}\n];
`.replace(/^\t\t/gm, '').trim();
write_if_changed(`${locations.app()}/manifest/service-worker.js`, code);
}
function generate_client(routes: Route[], path_to_routes: string, dev_port?: number) {
const page_ids = new Set(routes.pages.map(page => page.id));
const server_routes_to_ignore = routes.server_routes.filter(route => !page_ids.has(route.id));
function right_pad(str: string, len: number) {
while (str.length < len) str += ' ';
return str;
}
const pages = routes.pages.filter(page => page.id !== '_error');
const error_route = routes.pages.find(page => page.id === '_error');
function generate_client(
routes: { root: PageComponent, components: PageComponent[], pages: Page[], server_routes: ServerRoute[] },
path_to_routes: string,
dev_port?: number
) {
const page_ids = new Set(routes.pages.map(page =>
page.pattern.toString()));
const server_routes_to_ignore = routes.server_routes.filter(route =>
!page_ids.has(route.pattern.toString()));
const len = Math.max(...routes.components.map(c => c.name.length));
let code = `
// This file is generated by Sapper — do not edit it!
export const routes = {
import root from '${posixify(`${path_to_routes}/${routes.root.file}`)}';
import error from '${posixify(`${path_to_routes}/_error.html`)}';
${routes.components.map(component =>
`const ${component.name} = () =>
import(/* webpackChunkName: "${component.name}" */ '${get_file(path_to_routes, component)}');`)
.join('\n')}
export const manifest = {
ignore: [${server_routes_to_ignore.map(route => route.pattern).join(', ')}],
pages: [
${pages.map(page => {
const file = posixify(`${path_to_routes}/${page.file}`);
${routes.pages.map(page => `{
// ${page.parts[page.parts.length - 1].component.file}
pattern: ${page.pattern},
parts: [
${page.parts.map(part => {
if (part.params.length > 0) {
const props = part.params.map((param, i) => `${param}: match[${i + 1}]`);
return `{ component: ${part.component.name}, params: match => ({ ${props.join(', ')} }) }`;
}
if (page.id === '_error') {
return `{ error: true, load: () => import(/* webpackChunkName: "${page.id}" */ '${file}') }`;
}
const params = page.params.length === 0
? '{}'
: `{ ${page.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
return `{ pattern: ${page.pattern}, params: ${page.params.length > 0 ? `match` : `()`} => (${params}), load: () => import(/* webpackChunkName: "${page.id}" */ '${file}') }`;
}).join(',\n\t\t\t\t')}
return `{ component: ${part.component.name} }`;
}).join(',\n\t\t\t\t\t\t')}
]
}`).join(',\n\n\t\t\t\t')}
],
error: () => import(/* webpackChunkName: '_error' */ '${posixify(`${path_to_routes}/${error_route.file}`)}')
};`.replace(/^\t\t/gm, '').trim();
root,
error
};
// this is included for legacy reasons
export const routes = {};`.replace(/^\t\t/gm, '').trim();
if (dev()) {
const sapper_dev_client = posixify(
@@ -86,47 +115,72 @@ function generate_client(routes: Route[], path_to_routes: string, dev_port?: num
return code;
}
function generate_server(routes: Route[], path_to_routes: string) {
const error_route = routes.pages.find(page => page.id === '_error');
function generate_server(
routes: { root: PageComponent, components: PageComponent[], pages: Page[], server_routes: ServerRoute[] },
path_to_routes: string
) {
const imports = [].concat(
routes.server_routes.map(route =>
`import * as route_${route.id} from '${posixify(`${path_to_routes}/${route.file}`)}';`),
routes.pages.map(page =>
`import page_${page.id} from '${posixify(`${path_to_routes}/${page.file}`)}';`),
`import error from '${posixify(`${path_to_routes}/${error_route.file}`)}';`
`import * as ${route.name} from '${posixify(`${path_to_routes}/${route.file}`)}';`),
routes.components.map(component =>
`import ${component.name} from '${get_file(path_to_routes, component)}';`),
`import root from '${posixify(`${path_to_routes}/${routes.root.file}`)}';`,
`import error from '${posixify(`${path_to_routes}/_error.html`)}';`
);
let code = `
// This file is generated by Sapper — do not edit it!
${imports.join('\n')}
export const routes = {
export const manifest = {
server_routes: [
${routes.server_routes.map(route => {
const params = route.params.length === 0
? '{}'
: `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
return `{ id: '${route.id}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), handlers: route_${route.id} }`;
}).join(',\n\t\t\t\t')}
${routes.server_routes.map(route => `{
// ${route.file}
pattern: ${route.pattern},
handlers: ${route.name},
params: ${route.params.length > 0
? `match => ({ ${route.params.map((param, i) => `${param}: match[${i + 1}]`).join(', ')} })`
: `() => ({})`}
}`).join(',\n\n\t\t\t\t')}
],
pages: [
${routes.pages.map(page => {
const params = page.params.length === 0
? '{}'
: `{ ${page.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`;
${routes.pages.map(page => `{
// ${page.parts[page.parts.length - 1].component.file}
pattern: ${page.pattern},
parts: [
${page.parts.map(part => {
const props = [
`name: "${part.component.name}"`,
`component: ${part.component.name}`
];
return `{ id: '${page.id}', pattern: ${page.pattern}, params: ${page.params.length > 0 ? `match` : `()`} => (${params}), handler: page_${page.id} }`;
}).join(',\n\t\t\t\t')}
if (part.params.length > 0) {
const params = part.params.map((param, i) => `${param}: match[${i + 1}]`);
props.push(`params: match => ({ ${params.join(', ')} })`);
}
return `{ ${props.join(', ')} }`;
}).join(',\n\t\t\t\t\t\t')}
]
}`).join(',\n\n\t\t\t\t')}
],
error: {
error: true,
handler: error
}
};`.replace(/^\t\t/gm, '').trim();
root,
error
};
// this is included for legacy reasons
export const routes = {};`.replace(/^\t\t/gm, '').trim();
return code;
}
function get_file(path_to_routes: string, component: PageComponent) {
if (component.default) {
return `./default-layout.html`;
}
return posixify(`${path_to_routes}/${component.file}`);
}

View File

@@ -1,186 +1,306 @@
import * as fs from 'fs';
import * as path from 'path';
import glob from 'glob';
import { locations } from '../config';
import { Route } from '../interfaces';
import { Page, PageComponent, ServerRoute } from '../interfaces';
import { posixify } from './utils';
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`);
}
const default_layout_file = posixify(path.resolve(
__dirname,
'../components/default-layout.html'
));
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');
}
export default function create_routes(cwd = locations.routes()) {
const components: PageComponent[] = [];
const pages: Page[] = [];
const server_routes: ServerRoute[] = [];
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);
}
const default_layout: PageComponent = {
default: true,
name: '_default_layout',
file: null
};
const id = (
parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_')
) || '_';
function walk(
dir: string,
parent_segments: Part[][],
parent_params: string[],
stack: Array<{
component: PageComponent,
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 type = file.endsWith('.html') ? 'page' : 'route';
const segment = is_dir
? basename
: basename.slice(0, -path.extname(basename).length);
const params: string[] = [];
const match_patterns: Record<string, string> = {};
const param_pattern = /\[([^\(\]]+)(?:\((.+?)\))?\]/g;
const parts = get_parts(segment);
const is_index = is_dir ? false : basename.startsWith('index.');
const is_page = path.extname(basename) === '.html';
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');
parts.forEach(part => {
if (/\]\[/.test(part.content)) {
throw new Error(`Invalid route ${file} — parameters must be separated`);
}
// Make a map of the regexp patterns
match_patterns[match[1]] = `(${match[2]}?)`;
}
}
// 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('[');
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] || `([^/]+?)`);
})
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];
if (part.qualifier && /[\(\)\?\:]/.test(part.qualifier.slice(1, -1))) {
throw new Error(`Invalid route ${file} — cannot use (, ), ? or : in route qualifiers`);
}
});
return result;
};
return {
basename,
parts,
file: posixify(file),
is_dir,
is_index,
is_page
};
})
.sort(comparator);
return {
id,
base,
type,
file,
pattern,
test,
exec,
parts,
params
};
items.forEach(item => {
if (item.basename[0] === '_') return;
if (item.basename[0] === '.') {
if (item.file !== '.well-known') return;
}
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}`
};
}
segments[segments.length - 1] = last_segment;
}
} else {
segments.push(item.parts);
}
const params = parent_params.slice();
params.push(...item.parts.filter(p => p.dynamic).map(p => p.content));
if (item.is_dir) {
const index = path.join(dir, item.basename, '_layout.html');
const layout = fs.existsSync(index)
? {
name: `${get_slug(item.file)}__layout`,
file: `${item.file}/_layout.html`
}
: null;
if (layout) {
components.push(layout);
} else if (components.indexOf(default_layout) === -1) {
components.push(default_layout);
}
walk(
path.join(dir, item.basename),
segments,
params,
stack.concat({
component: layout || default_layout,
params
})
);
}
else if (item.is_page) {
const component = {
name: get_slug(item.file),
file: item.file
};
const parts = stack.concat({
component,
params
});
components.push(component);
if (item.basename === 'index.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
});
}
});
}
const pages = all_routes
.filter(r => r.type === 'page')
.sort(comparator);
const root_file = path.join(cwd, '_layout.html');
const root = fs.existsSync(root_file)
? {
name: 'main',
file: '_layout.html'
}
: default_layout;
const server_routes = all_routes
.filter(r => r.type === 'route')
.sort(comparator);
walk(cwd, [], [], []);
return { pages, server_routes };
// check for clashes
const seen_pages: Map<string, Page> = new Map();
pages.forEach(page => {
const pattern = page.pattern.toString();
if (seen_pages.has(pattern)) {
const file = page.parts.pop().component.file;
const other_page = seen_pages.get(pattern);
const other_file = other_page.parts.pop().component.file;
throw new Error(`The ${other_file} and ${file} pages clash`);
}
seen_pages.set(pattern, page);
});
const seen_routes: Map<string, ServerRoute> = new Map();
server_routes.forEach(route => {
const pattern = route.pattern.toString();
if (seen_routes.has(pattern)) {
const other_route = seen_routes.get(pattern);
throw new Error(`The ${other_route.file} and ${route.file} routes clash`);
}
seen_routes.set(pattern, route);
});
return {
root,
components,
pages,
server_routes
};
}
function comparator(a, b) {
if (a.parts[0] === '_error') return -1;
if (b.parts[0] === '_error') return 1;
type Part = {
content: string;
dynamic: boolean;
qualifier?: string;
};
function comparator(
a: { basename: string, parts: Part[], file: string, is_index: boolean },
b: { basename: string, parts: Part[], file: string, is_index: boolean }
) {
if (a.is_index !== b.is_index) return a.is_index ? -1 : 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];
const a_sub_part = a.parts[i];
const b_sub_part = b.parts[i];
if (!a_part) return -1;
if (!b_part) return 1;
if (!a_sub_part) return 1; // b is more specific, so goes first
if (!b_sub_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);
if (a_sub_part.dynamic !== b_sub_part.dynamic) {
return a_sub_part.dynamic ? 1 : -1;
}
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.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 (!a_sub_part) return 1; // b is more specific, so goes first
if (!b_sub_part) return -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_sub_part.dynamic !== b_sub_part.dynamic) {
return a_sub_part.dynamic ? 1 : -1;
if (!a_match && b_match) {
return 1; // No regexp, so less specific than b
}
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 (!b_match && a_match) {
return -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;
}
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) {
function get_parts(part: string): Part[] {
return part.split(/\[(.+)\]/)
.map((content, i) => {
if (!content) return null;
.map((str, i) => {
if (!str) return null;
const dynamic = i % 2 === 1;
const [, content, qualifier] = dynamic
? /([^(]+)(\(.+\))?$/.exec(str)
: [, str, 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('') +
'\\\/?$'
);
}