From bf8e56748a8a7e2deded1388b1b88e9ab3fa507e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 15 Jul 2018 22:13:30 -0400 Subject: [PATCH] change how routes are created --- src/core/create_routes.ts | 379 +++++++++++------- test/unit/create_routes.js | 117 ++++++ test/unit/samples/basic/_default.html | 0 test/unit/samples/basic/about.html | 0 test/unit/samples/basic/blog/[slug].html | 0 test/unit/samples/basic/blog/[slug].json.js | 0 test/unit/samples/basic/blog/_default.html | 0 test/unit/samples/basic/blog/index.html | 0 test/unit/samples/basic/blog/index.json.js | 0 test/unit/samples/basic/index.html | 0 "test/unit/samples/encoding/\".html" | 0 test/unit/samples/encoding/#.html | 0 test/unit/samples/encoding/?.html | 0 test/unit/samples/encoding/index.html | 0 .../qualifiers/[slug([0-9-a-z]{3,})].html | 0 .../samples/qualifiers/[slug([a-z]{2})].html | 0 test/unit/samples/qualifiers/[slug].html | 0 test/unit/samples/qualifiers/index.html | 0 test/unit/samples/sorting/[wildcard].html | 0 test/unit/samples/sorting/_default.html | 0 test/unit/samples/sorting/about.html | 0 test/unit/samples/sorting/index.html | 0 .../sorting/post/[id([0-9-a-z]{3,})].html | 0 test/unit/samples/sorting/post/[id].html | 0 test/unit/samples/sorting/post/_default.html | 0 test/unit/samples/sorting/post/bar.html | 0 test/unit/samples/sorting/post/f[xx].html | 0 test/unit/samples/sorting/post/foo.html | 0 test/unit/samples/sorting/post/index.html | 0 29 files changed, 345 insertions(+), 151 deletions(-) create mode 100644 test/unit/create_routes.js create mode 100644 test/unit/samples/basic/_default.html create mode 100644 test/unit/samples/basic/about.html create mode 100644 test/unit/samples/basic/blog/[slug].html create mode 100644 test/unit/samples/basic/blog/[slug].json.js create mode 100644 test/unit/samples/basic/blog/_default.html create mode 100644 test/unit/samples/basic/blog/index.html create mode 100644 test/unit/samples/basic/blog/index.json.js create mode 100644 test/unit/samples/basic/index.html create mode 100644 "test/unit/samples/encoding/\".html" create mode 100644 test/unit/samples/encoding/#.html create mode 100644 test/unit/samples/encoding/?.html create mode 100644 test/unit/samples/encoding/index.html create mode 100644 test/unit/samples/qualifiers/[slug([0-9-a-z]{3,})].html create mode 100644 test/unit/samples/qualifiers/[slug([a-z]{2})].html create mode 100644 test/unit/samples/qualifiers/[slug].html create mode 100644 test/unit/samples/qualifiers/index.html create mode 100644 test/unit/samples/sorting/[wildcard].html create mode 100644 test/unit/samples/sorting/_default.html create mode 100644 test/unit/samples/sorting/about.html create mode 100644 test/unit/samples/sorting/index.html create mode 100644 test/unit/samples/sorting/post/[id([0-9-a-z]{3,})].html create mode 100644 test/unit/samples/sorting/post/[id].html create mode 100644 test/unit/samples/sorting/post/_default.html create mode 100644 test/unit/samples/sorting/post/bar.html create mode 100644 test/unit/samples/sorting/post/f[xx].html create mode 100644 test/unit/samples/sorting/post/foo.html create mode 100644 test/unit/samples/sorting/post/index.html diff --git a/src/core/create_routes.ts b/src/core/create_routes.ts index 556bc28..c7ef142 100644 --- a/src/core/create_routes.ts +++ b/src/core/create_routes.ts @@ -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 = {}; - 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 = {}; - 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('') + + '\\\/?$' + ); } \ No newline at end of file diff --git a/test/unit/create_routes.js b/test/unit/create_routes.js new file mode 100644 index 0000000..37551b5 --- /dev/null +++ b/test/unit/create_routes.js @@ -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'] + ]); + }); +}); \ No newline at end of file diff --git a/test/unit/samples/basic/_default.html b/test/unit/samples/basic/_default.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/basic/about.html b/test/unit/samples/basic/about.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/basic/blog/[slug].html b/test/unit/samples/basic/blog/[slug].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/basic/blog/[slug].json.js b/test/unit/samples/basic/blog/[slug].json.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/basic/blog/_default.html b/test/unit/samples/basic/blog/_default.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/basic/blog/index.html b/test/unit/samples/basic/blog/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/basic/blog/index.json.js b/test/unit/samples/basic/blog/index.json.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/basic/index.html b/test/unit/samples/basic/index.html new file mode 100644 index 0000000..e69de29 diff --git "a/test/unit/samples/encoding/\".html" "b/test/unit/samples/encoding/\".html" new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/encoding/#.html b/test/unit/samples/encoding/#.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/encoding/?.html b/test/unit/samples/encoding/?.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/encoding/index.html b/test/unit/samples/encoding/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/qualifiers/[slug([0-9-a-z]{3,})].html b/test/unit/samples/qualifiers/[slug([0-9-a-z]{3,})].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/qualifiers/[slug([a-z]{2})].html b/test/unit/samples/qualifiers/[slug([a-z]{2})].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/qualifiers/[slug].html b/test/unit/samples/qualifiers/[slug].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/qualifiers/index.html b/test/unit/samples/qualifiers/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/[wildcard].html b/test/unit/samples/sorting/[wildcard].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/_default.html b/test/unit/samples/sorting/_default.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/about.html b/test/unit/samples/sorting/about.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/index.html b/test/unit/samples/sorting/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/post/[id([0-9-a-z]{3,})].html b/test/unit/samples/sorting/post/[id([0-9-a-z]{3,})].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/post/[id].html b/test/unit/samples/sorting/post/[id].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/post/_default.html b/test/unit/samples/sorting/post/_default.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/post/bar.html b/test/unit/samples/sorting/post/bar.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/post/f[xx].html b/test/unit/samples/sorting/post/f[xx].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/post/foo.html b/test/unit/samples/sorting/post/foo.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/samples/sorting/post/index.html b/test/unit/samples/sorting/post/index.html new file mode 100644 index 0000000..e69de29