diff --git a/src/core/create_app.ts b/src/core/create_app.ts index 87347ff..ca405db 100644 --- a/src/core/create_app.ts +++ b/src/core/create_app.ts @@ -30,11 +30,11 @@ function generate_client(routes: Route[], src: string, dev: boolean, dev_port?: return `{ error: '${route.id.slice(1)}', load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`; } - const params = route.dynamic.length === 0 + const params = route.params.length === 0 ? '{}' - : `{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; + : `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; - return `{ pattern: ${route.pattern}, params: ${route.dynamic.length > 0 ? `match` : `()`} => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`; + return `{ pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${file}') }`; }) .join(',\n\t')} ];`.replace(/^\t\t/gm, '').trim(); @@ -77,11 +77,11 @@ function generate_server(routes: Route[], src: string) { return `{ error: '${route.id.slice(1)}', module: ${route.id} }`; } - const params = route.dynamic.length === 0 + const params = route.params.length === 0 ? '{}' - : `{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; + : `{ ${route.params.map((part, i) => `${part}: match[${i + 1}]`).join(', ')} }`; - return `{ id: '${route.id}', type: '${route.type}', pattern: ${route.pattern}, params: ${route.dynamic.length > 0 ? `match` : `()`} => (${params}), module: ${route.id} }`; + return `{ id: '${route.id}', type: '${route.type}', pattern: ${route.pattern}, params: ${route.params.length > 0 ? `match` : `()`} => (${params}), module: ${route.id} }`; }) .join(',\n\t') } diff --git a/src/core/create_routes.ts b/src/core/create_routes.ts index 79bc5be..aa1a934 100644 --- a/src/core/create_routes.ts +++ b/src/core/create_routes.ts @@ -10,17 +10,27 @@ export default function create_routes({ src, files = glob.sync('**/*.+(html|js|m .map((file: string) => { if (/(^|\/|\\)_/.test(file)) return; - const parts = file.replace(/\.(html|js|mjs)$/, '').split('/'); // glob output is always posix-style + if (/]\[/.test(file)) { + throw new Error(`Invalid route ${file} — parameters must be separated`); + } + + const base = file.replace(/\.[^/.]+$/, ''); + const parts = base.split('/'); // glob output is always posix-style if (parts[parts.length - 1] === 'index') parts.pop(); const id = ( parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_') ) || '_'; - const dynamic = parts - .filter(part => part[0] === '[') - .map(part => part.slice(1, -1)); + const params: string[] = []; + const param_pattern = /\[([^\]]+)\]/g; + let match; + while (match = param_pattern.exec(base)) { + params.push(match[1]); + } + // 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; @@ -29,7 +39,8 @@ export default function create_routes({ src, files = glob.sync('**/*.+(html|js|m const dynamic = part[0] === '['; if (dynamic) { - pattern_string = nested ? `(?:\\/([^/]+)${pattern_string})?` : `\\/([^/]+)${pattern_string}`; + const matcher = part.replace(param_pattern, `([^\/]+?)`); + pattern_string = nested ? `(?:\\/${matcher}${pattern_string})?` : `\\/${matcher}${pattern_string}`; } else { nested = false; pattern_string = `\\/${part}${pattern_string}`; @@ -44,12 +55,12 @@ export default function create_routes({ src, files = glob.sync('**/*.+(html|js|m const match = pattern.exec(url); if (!match) return; - const params: Record = {}; - dynamic.forEach((param, i) => { - params[param] = match[i + 1]; + const result: Record = {}; + params.forEach((param, i) => { + result[param] = match[i + 1]; }); - return params; + return result; }; return { @@ -60,7 +71,7 @@ export default function create_routes({ src, files = glob.sync('**/*.+(html|js|m test, exec, parts, - dynamic + params }; }) .filter(Boolean) @@ -79,17 +90,40 @@ export default function create_routes({ src, files = glob.sync('**/*.+(html|js|m if (!a_part) return -1; if (!b_part) return 1; - const a_is_dynamic = a_part[0] === '['; - const b_is_dynamic = b_part[0] === '['; + const a_sub_parts = get_sub_parts(a_part); + const b_sub_parts = get_sub_parts(b_part); - if (a_is_dynamic === b_is_dynamic) { - if (!a_is_dynamic && a_part !== b_part) same = false; - continue; + for (let i = 0; true; i += 1) { + const a_sub_part = a_sub_parts[i]; + const b_sub_part = b_sub_parts[i]; + + if (!a_sub_part && !b_sub_part) break; + + if (!a_sub_part) return 1; // note this is reversed from above — match [foo].json before [foo] + 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; + } } - - return a_is_dynamic ? 1 : -1; } }); return routes; +} + +function get_sub_parts(part: string) { + return part.split(/[\[\]]/) + .map((content, i) => { + if (!content) return null; + return { + content, + dynamic: i % 2 === 1 + }; + }) + .filter(Boolean); } \ No newline at end of file diff --git a/src/interfaces.ts b/src/interfaces.ts index 3d72869..f2e656a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -6,7 +6,7 @@ export type Route = { test: (url: string) => boolean; exec: (url: string) => Record; parts: string[]; - dynamic: string[]; + params: string[]; }; export type Template = { diff --git a/test/unit/create_routes.test.js b/test/unit/create_routes.test.js index e2828ed..4d56f1f 100644 --- a/test/unit/create_routes.test.js +++ b/test/unit/create_routes.test.js @@ -4,7 +4,7 @@ const { create_routes } = require('../../core.js'); describe('create_routes', () => { it('sorts routes correctly', () => { const routes = create_routes({ - files: ['index.html', 'about.html', '[wildcard].html', 'post/foo.html', 'post/[id].html', 'post/bar.html'] + files: ['index.html', 'about.html', 'post/f[xx].html', '[wildcard].html', 'post/foo.html', 'post/[id].html', 'post/bar.html', 'post/[id].json.js'] }); assert.deepEqual( @@ -14,6 +14,8 @@ describe('create_routes', () => { 'about.html', 'post/foo.html', 'post/bar.html', + 'post/f[xx].html', + 'post/[id].json.js', 'post/[id].html', '[wildcard].html' ] @@ -133,4 +135,33 @@ describe('create_routes', () => { b: null }); }); + + it('matches a dynamic part within a part', () => { + const route = create_routes({ + files: ['things/[slug].json.js'] + })[0]; + + assert.deepEqual(route.exec('/things/foo.json'), { + slug: 'foo' + }); + }); + + it('matches multiple dynamic parts within a part', () => { + const route = create_routes({ + files: ['things/[id]_[slug].json.js'] + })[0]; + + assert.deepEqual(route.exec('/things/someid_someslug.json'), { + id: 'someid', + slug: 'someslug' + }); + }); + + it('fails if dynamic params are not separated', () => { + assert.throws(() => { + create_routes({ + files: ['[foo][bar].js'] + }); + }, /Invalid route \[foo\]\[bar\]\.js — parameters must be separated/); + }); }); \ No newline at end of file