diff --git a/appveyor.yml b/appveyor.yml index 966054b..3794b22 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -10,7 +10,7 @@ build: off environment: matrix: # node.js - - nodejs_version: stable + - nodejs_version: 10.5 install: - ps: Install-Product node $env:nodejs_version diff --git a/components/default-layout.html b/components/default-layout.html new file mode 100644 index 0000000..8c0dcbb --- /dev/null +++ b/components/default-layout.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mocha.opts b/mocha.opts index 3b7d05f..907904c 100644 --- a/mocha.opts +++ b/mocha.opts @@ -1,4 +1,4 @@ --require source-map-support/register --recursive -test/unit/**/*.js +test/unit/*/*.js test/common/test.js \ No newline at end of file diff --git a/package.json b/package.json index 8a8b7a6..d8d388f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "runtime", "webpack", "sapper", + "components", "dist" ], "directories": { @@ -67,7 +68,7 @@ "cy:open": "cypress open", "test": "mocha --opts mocha.opts", "pretest": "npm run build", - "build": "rollup -c", + "build": "rm -rf dist && rollup -c", "dev": "rollup -cw", "prepublishOnly": "npm test", "update_mime_types": "curl http://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types | grep -e \"^[^#]\" > src/middleware/mime-types.md" diff --git a/rollup.config.js b/rollup.config.js index 856c34f..9a8ee8d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -19,7 +19,8 @@ export default [ }, plugins: [ typescript({ - typescript: require('typescript') + typescript: require('typescript'), + target: "ES2017" }) ] }, diff --git a/src/api/dev.ts b/src/api/dev.ts index 712b302..eb2a769 100644 --- a/src/api/dev.ts +++ b/src/api/dev.ts @@ -105,8 +105,15 @@ class Watcher extends EventEmitter { const dev_port = await ports.find(10000); - const routes = create_routes(); - create_main_manifests({ routes, dev_port }); + try { + const routes = create_routes(); + create_main_manifests({ routes, dev_port }); + } catch (err) { + this.emit('fatal', { + message: err.message + }); + return; + } this.dev_server = new DevServer(dev_port); @@ -114,6 +121,15 @@ class Watcher extends EventEmitter { watch_files(locations.routes(), ['add', 'unlink'], () => { const routes = create_routes(); create_main_manifests({ routes, dev_port }); + + try { + const routes = create_routes(); + create_main_manifests({ routes, dev_port }); + } catch (err) { + this.emit('error', { + message: err.message + }); + } }), watch_files(`${locations.app()}/template.html`, ['change'], () => { @@ -272,7 +288,7 @@ class Watcher extends EventEmitter { if (this.closed) return; this.closed = true; - this.dev_server.close(); + if (this.dev_server) this.dev_server.close(); if (this.proc) this.proc.kill(); this.filewatchers.forEach(watcher => { diff --git a/src/api/find_page.ts b/src/api/find_page.ts index 2e843bd..8a90c8f 100644 --- a/src/api/find_page.ts +++ b/src/api/find_page.ts @@ -2,15 +2,14 @@ import * as glob from 'glob'; import { locations } from '../config'; import { create_routes } from '../core'; -export function find_page(pathname: string, files: string[] = glob.sync('**/*.*', { cwd: locations.routes(), dot: true, nodir: true })) { - const routes = create_routes({ files }); +export function find_page(pathname: string, cwd = locations.routes()) { + const { pages } = create_routes(cwd); - for (let i = 0; i < routes.length; i += 1) { - const route = routes[i]; + for (let i = 0; i < pages.length; i += 1) { + const page = pages[i]; - if (route.pattern.test(pathname)) { - const page = route.handlers.find(handler => handler.type === 'page'); - if (page) return page.file; + if (page.pattern.test(pathname)) { + return page.parts[page.parts.length - 1].component.file; } } } \ No newline at end of file diff --git a/src/core/create_manifests.ts b/src/core/create_manifests.ts index 471667b..a1dc261 100644 --- a/src/core/create_manifests.ts +++ b/src/core/create_manifests.ts @@ -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`, + `` + ); 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}`); } \ No newline at end of file diff --git a/src/core/create_routes.ts b/src/core/create_routes.ts index 556bc28..6129ddb 100644 --- a/src/core/create_routes.ts +++ b/src/core/create_routes.ts @@ -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 = {}; - 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 = {}; - 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 = 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 = 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('') + + '\\\/?$' + ); } \ No newline at end of file diff --git a/src/interfaces.ts b/src/interfaces.ts index 82f6b3b..e63c086 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -18,4 +18,25 @@ export type Template = { export type Store = { get: () => any; +}; + +export type PageComponent = { + default?: boolean; + name: string; + file: string; +}; + +export type Page = { + pattern: RegExp; + parts: Array<{ + component: PageComponent; + params: string[]; + }> +}; + +export type ServerRoute = { + name: string; + pattern: RegExp; + file: string; + params: string[]; }; \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 79e4950..1d2c1b4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -11,13 +11,26 @@ import sourceMapSupport from 'source-map-support'; sourceMapSupport.install(); -type RouteObject = { - id: string; - type: 'page' | 'route'; +type ServerRoute = { pattern: RegExp; + handlers: Record; params: (match: RegExpMatchArray) => Record; - module: Component; - error?: string; +}; + +type Page = { + pattern: RegExp; + parts: Array<{ + name: string; + component: Component; + params?: (match: RegExpMatchArray) => Record; + }> +}; + +type Manifest = { + server_routes: ServerRoute[]; + pages: Page[]; + root: Component; + error: Component; } type Handler = (req: Req, res: ServerResponse, next: () => void) => void; @@ -26,6 +39,20 @@ type Store = { get: () => any }; +type Props = { + path: string; + query: Record; + params: Record; + error?: { message: string }; + status?: number; + child: { + segment: string; + component: Component; + props: Props; + }; + [key: string]: any; +}; + interface Req extends ClientRequest { url: string; baseUrl: string; @@ -33,6 +60,7 @@ interface Req extends ClientRequest { method: string; path: string; params: Record; + query: Record; headers: Record; } @@ -45,17 +73,19 @@ interface Component { preload: (data: any) => any | Promise } -export default function middleware({ App, routes, store }: { - App: Component, - routes: RouteObject[], - store: (req: Req) => Store +export default function middleware(opts: { + manifest: Manifest, + store: (req: Req) => Store, + routes?: any // legacy }) { - if (!App) { - throw new Error(`As of 0.12, you must supply an App component to Sapper — see https://sapper.svelte.technology/guide#0-11-to-0-12 for more information`); + if (opts.routes) { + throw new Error(`As of Sapper 0.15, opts.routes should be opts.manifest`); } const output = locations.dest(); + const { manifest, store } = opts; + let emitted_basepath = false; const middleware = compose_handlers([ @@ -108,8 +138,8 @@ export default function middleware({ App, routes, store }: { cache_control: 'max-age=31536000' }), - get_server_route_handler(routes.server_routes), - get_page_handler(App, routes, store) + get_server_route_handler(manifest.server_routes), + get_page_handler(manifest, store) ].filter(Boolean)); return middleware; @@ -152,8 +182,8 @@ function serve({ prefix, pathname, cache_control }: { }; } -function get_server_route_handler(routes: RouteObject[]) { - function handle_route(route, req, res, next) { +function get_server_route_handler(routes: ServerRoute[]) { + function handle_route(route: ServerRoute, req: Req, res: ServerResponse, next: () => void) { req.params = route.params(route.pattern.exec(req.path)); const method = req.method.toLowerCase(); @@ -196,7 +226,6 @@ function get_server_route_handler(routes: RouteObject[]) { const handle_next = (err?: Error) => { if (err) { - console.error(err.stack); res.statusCode = 500; res.end(err.message); } else { @@ -215,7 +244,7 @@ function get_server_route_handler(routes: RouteObject[]) { } } - return function find_route(req: Req, res: ServerResponse, next) { + return function find_route(req: Req, res: ServerResponse, next: () => void) { for (const route of routes) { if (route.pattern.test(req.path)) { handle_route(route, req, res, next); @@ -227,7 +256,7 @@ function get_server_route_handler(routes: RouteObject[]) { }; } -function get_page_handler(App: Component, routes: RouteObject[], store_getter: (req: Req) => Store) { +function get_page_handler(manifest: Manifest, store_getter: (req: Req) => Store) { const output = locations.dest(); const get_chunks = dev() @@ -238,22 +267,37 @@ function get_page_handler(App: Component, routes: RouteObject[], store_getter: ( ? () => fs.readFileSync(`${locations.app()}/template.html`, 'utf-8') : (str => () => str)(fs.readFileSync(`${locations.dest()}/template.html`, 'utf-8')); - const { server_routes, pages } = routes; - const error_route = routes.error; + const { server_routes, pages } = manifest; + const error_route = manifest.error; - function handle_route(route: RouteObject, req: Req, res: ServerResponse, status = 200, error: Error | string = null) { - req.params = error - ? {} - : route.params(route.pattern.exec(req.path)); + function handle_error(req: Req, res: ServerResponse, statusCode: number, error: Error | string) { + handle_page({ + pattern: null, + parts: [ + { name: null, component: error_route } + ] + }, req, res, statusCode, error); + } - const chunks: Record = get_chunks(); + function handle_page(page: Page, req: Req, res: ServerResponse, status = 200, error: Error | string = null) { + const get_params = page.parts[page.parts.length - 1].params || (() => ({})); + const match = error ? null : page.pattern.exec(req.path); + + const chunks: Record = get_chunks(); res.setHeader('Content-Type', 'text/html'); // preload main.js and current route // TODO detect other stuff we can preload? images, CSS, fonts? - const link = [] - .concat(chunks.main, chunks[route.id] || chunks._error) // TODO this is gross + let preloaded_chunks = Array.isArray(chunks.main) ? chunks.main : [chunks.main]; + if (!error) { + page.parts.forEach(part => { + // using concat because it could be a string or an array. thanks webpack! + preloaded_chunks = preloaded_chunks.concat(chunks[part.name]); + }); + } + + const link = preloaded_chunks .filter(file => !file.match(/\.map$/)) .map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`) .join(', '); @@ -261,63 +305,77 @@ function get_page_handler(App: Component, routes: RouteObject[], store_getter: ( res.setHeader('Link', link); const store = store_getter ? store_getter(req) : null; - const props = { params: req.params, query: req.query, path: req.path }; - - if (route.error) { - props.error = error instanceof Error ? error : { message: error }; - props.status = status; - } let redirect: { statusCode: number, location: string }; let preload_error: { statusCode: number, message: Error | string }; - Promise.resolve( - route.handler.preload ? route.handler.preload.call({ - redirect: (statusCode: number, location: string) => { - redirect = { statusCode, location }; - }, - error: (statusCode: number, message: Error | string) => { - preload_error = { statusCode, message }; - }, - fetch: (url: string, opts?: any) => { - const parsed = new URL(url, `http://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`); + const preload_context = { + redirect: (statusCode: number, location: string) => { + if (redirect && (redirect.statusCode !== statusCode || redirect.location !== location)) { + throw new Error(`Conflicting redirects`); + } + redirect = { statusCode, location }; + }, + error: (statusCode: number, message: Error | string) => { + preload_error = { statusCode, message }; + }, + fetch: (url: string, opts?: any) => { + const parsed = new URL(url, `http://127.0.0.1:${process.env.PORT}${req.baseUrl ? req.baseUrl + '/' :''}`); - if (opts) { - opts = Object.assign({}, opts); + if (opts) { + opts = Object.assign({}, opts); - const include_cookies = ( - opts.credentials === 'include' || - opts.credentials === 'same-origin' && parsed.origin === `http://127.0.0.1:${process.env.PORT}` - ); + const include_cookies = ( + opts.credentials === 'include' || + opts.credentials === 'same-origin' && parsed.origin === `http://127.0.0.1:${process.env.PORT}` + ); - if (include_cookies) { - const cookies: Record = {}; - if (!opts.headers) opts.headers = {}; + if (include_cookies) { + const cookies: Record = {}; + if (!opts.headers) opts.headers = {}; - const str = [] - .concat( - cookie.parse(req.headers.cookie || ''), - cookie.parse(opts.headers.cookie || ''), - cookie.parse(res.getHeader('Set-Cookie') || '') - ) - .map(cookie => { - return Object.keys(cookie) - .map(name => `${name}=${encodeURIComponent(cookie[name])}`) - .join('; '); - }) - .filter(Boolean) - .join(', '); + const str = [] + .concat( + cookie.parse(req.headers.cookie || ''), + cookie.parse(opts.headers.cookie || ''), + cookie.parse(res.getHeader('Set-Cookie') || '') + ) + .map(cookie => { + return Object.keys(cookie) + .map(name => `${name}=${encodeURIComponent(cookie[name])}`) + .join('; '); + }) + .filter(Boolean) + .join(', '); - opts.headers.cookie = str; - } + opts.headers.cookie = str; } + } - return fetch(parsed.href, opts); - }, + return fetch(parsed.href, opts); + }, store - }, req) : {} - ).catch(err => { + }; + + const root_preloaded = manifest.root.preload + ? manifest.root.preload.call(preload_context, { + path: req.path, + query: req.query, + params: {} + }) + : {}; + + Promise.all([root_preloaded].concat(page.parts.map(part => { + return part.component.preload + ? part.component.preload.call(preload_context, { + path: req.path, + query: req.query, + params: part.params ? part.params(match) : {} + }) + : {}; + }))).catch(err => { preload_error = { statusCode: 500, message: err }; + return []; // appease TypeScript }).then(preloaded => { if (redirect) { res.statusCode = redirect.statusCode; @@ -328,17 +386,52 @@ function get_page_handler(App: Component, routes: RouteObject[], store_getter: ( } if (preload_error) { - handle_route(error_route, req, res, preload_error.statusCode, preload_error.message); + handle_error(req, res, preload_error.statusCode, preload_error.message); return; } const serialized = { - preloaded: route.handler.preload && try_serialize(preloaded), + preloaded: `[${preloaded.map(data => try_serialize(data)).join(',')}]`, store: store && try_serialize(store.get()) }; - Object.assign(props, preloaded); - const { html, head, css } = App.render({ Page: route.handler, props }, { + const segments = req.path.split('/').filter(Boolean); + + const props: Props = { + path: req.path, + query: req.query, + params: {}, + child: null + }; + + if (error) { + props.error = error instanceof Error ? error : { message: error }; + props.status = status; + } + + const data = Object.assign({}, props, preloaded[0], { + params: {}, + child: {} + }); + + let level = data.child; + for (let i = 0; i < page.parts.length; i += 1) { + const part = page.parts[i]; + const get_params = part.params || (() => ({})); + + Object.assign(level, { + segment: segments[i], + component: part.component, + props: Object.assign({}, props, { + params: get_params(match) + }, preloaded[i + 1]) + }); + + level.props.child = {}; + level = level.props.child; + } + + const { html, head, css } = manifest.root.render(data, { store }); @@ -349,6 +442,7 @@ function get_page_handler(App: Component, routes: RouteObject[], store_getter: ( .join(''); let inline_script = `__SAPPER__={${[ + error && `error:1`, `baseUrl: "${req.baseUrl}"`, serialized.preloaded && `preloaded: ${serialized.preloaded}`, serialized.store && `store: ${serialized.store}` @@ -359,7 +453,7 @@ function get_page_handler(App: Component, routes: RouteObject[], store_getter: ( inline_script += `if ('serviceWorker' in navigator) navigator.serviceWorker.register('${req.baseUrl}/service-worker.js');`; } - const page = template() + const body = template() .replace('%sapper.base%', () => ``) .replace('%sapper.scripts%', () => `${scripts}`) .replace('%sapper.html%', () => html) @@ -367,7 +461,7 @@ function get_page_handler(App: Component, routes: RouteObject[], store_getter: ( .replace('%sapper.styles%', () => (css && css.code ? `` : '')); res.statusCode = status; - res.end(page); + res.end(body); if (process.send) { process.send({ @@ -377,9 +471,17 @@ function get_page_handler(App: Component, routes: RouteObject[], store_getter: ( method: req.method, status: 200, type: 'text/html', - body: page + body }); } + }).catch(err => { + if (error) { + // we encountered an error while rendering the error page — oops + res.statusCode = 500; + res.end(`
${escape_html(err.message)}
`); + } else { + handle_error(req, res, 500, err); + } }); } @@ -387,13 +489,13 @@ function get_page_handler(App: Component, routes: RouteObject[], store_getter: ( if (!server_routes.some(route => route.pattern.test(req.path))) { for (const page of pages) { if (page.pattern.test(req.path)) { - handle_route(page, req, res); + handle_page(page, req, res); return; } } } - handle_route(error_route, req, res, 404, 'Not found'); + handle_error(req, res, 404, 'Not found'); }; } @@ -424,3 +526,15 @@ function try_serialize(data: any) { return null; } } + +function escape_html(html: string) { + const chars: Record = { + '"' : 'quot', + "'": '#39', + '&': 'amp', + '<' : 'lt', + '>' : 'gt' + }; + + return html.replace(/["'&<>]/g, c => `&${chars[c]};`); +} \ No newline at end of file diff --git a/src/runtime/index.ts b/src/runtime/index.ts index e149c47..ffc68c5 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,14 +1,39 @@ import { detach, findAnchor, scroll_state, which } from './utils'; -import { Component, ComponentConstructor, Params, Query, Route, RouteData, ScrollPosition, Store, Target } from './interfaces'; +import { Component, ComponentConstructor, Params, Query, Redirect, Manifest, RouteData, ScrollPosition, Store, Target } from './interfaces'; -const manifest = typeof window !== 'undefined' && window.__SAPPER__; +const initial_data = typeof window !== 'undefined' && window.__SAPPER__; -export let App: ComponentConstructor; -export let component: Component; +export let root: Component; let target: Node; let store: Store; -let routes: Route[]; -let error_route: Route; +let manifest: Manifest; +let segments: string[] = []; + +type RootProps = { + path: string; + params: Record; + query: Record; + child: Child; +}; + +type Child = { + segment?: string; + props?: any; + component?: Component; +}; + +const root_props: RootProps = { + path: null, + params: null, + query: null, + child: { + segment: null, + component: null, + props: {} + } +}; + +export { root as component }; // legacy reasons — drop in a future version const history = typeof window !== 'undefined' ? window.history : { pushState: (state: any, title: string, href: string) => {}, @@ -26,45 +51,50 @@ if ('scrollRestoration' in history) { function select_route(url: URL): Target { if (url.origin !== window.location.origin) return null; - if (!url.pathname.startsWith(manifest.baseUrl)) return null; + if (!url.pathname.startsWith(initial_data.baseUrl)) return null; - const path = url.pathname.slice(manifest.baseUrl.length); + const path = url.pathname.slice(initial_data.baseUrl.length); // avoid accidental clashes between server routes and pages - if (routes.ignore.some(pattern => pattern.test(path))) return; + if (manifest.ignore.some(pattern => pattern.test(path))) return; - for (let i = 0; i < routes.pages.length; i += 1) { - const page = routes.pages[i]; + for (let i = 0; i < manifest.pages.length; i += 1) { + const page = manifest.pages[i]; const match = page.pattern.exec(path); if (match) { - const params = page.params(match); - const query: Record = {}; if (url.search.length > 0) { url.search.slice(1).split('&').forEach(searchParam => { const [, key, value] = /([^=]+)=(.*)/.exec(searchParam); query[key] = value || true; - }) + }); } - return { url, route: page, props: { params, query, path } }; + return { url, path, page, match, query }; } } } let current_token: {}; -function render(Page: ComponentConstructor, props: any, scroll: ScrollPosition, token: {}) { +function render(data: any, changed_from: number, scroll: ScrollPosition, token: {}) { if (current_token !== token) return; - const data = { - Page, - props, - preloading: false - }; + if (root) { + // first, clear out highest-level root component + let level = data.child; + for (let i = 0; i < changed_from; i += 1) { + if (i === changed_from) break; + level = level.props.child; + } - if (component) { - component.set(data); + const { component } = level; + level.component = null; + root.set({ child: data.child }); + + // then render new stuff + level.component = component; + root.set(data); } else { // first load — remove SSR'd contents const start = document.querySelector('#sapper-head-start'); @@ -76,7 +106,9 @@ function render(Page: ComponentConstructor, props: any, scroll: ScrollPosition, detach(end); } - component = new App({ + Object.assign(data, root_data); + + root = new manifest.root({ target, data, store, @@ -87,52 +119,149 @@ function render(Page: ComponentConstructor, props: any, scroll: ScrollPosition, if (scroll) { window.scrollTo(scroll.x, scroll.y); } + + Object.assign(root_props, data); + ready = true; } -function prepare_route(Page: ComponentConstructor, props: RouteData) { - let redirect: { statusCode: number, location: string } = null; +function changed(a: Record, b: Record) { + return JSON.stringify(a) !== JSON.stringify(b); +} + +let root_preload: Promise; +let root_data: any; + +function prepare_page(target: Target): Promise<{ + redirect?: Redirect; + data?: any; + changed_from?: number; +}> { + if (root) { + root.set({ preloading: true }); + } + + const { page, path, query } = target; + const new_segments = path.split('/').filter(Boolean); + let changed_from = 0; + + while ( + segments[changed_from] && + new_segments[changed_from] && + segments[changed_from] === new_segments[changed_from] + ) changed_from += 1; + + let redirect: Redirect = null; let error: { statusCode: number, message: Error | string } = null; - if (!Page.preload) { - return { Page, props, redirect, error }; - } - - if (!component && manifest.preloaded) { - return { Page, props: Object.assign(props, manifest.preloaded), redirect, error }; - } - - if (component) { - component.set({ - preloading: true - }); - } - - return Promise.resolve(Page.preload.call({ + const preload_context = { store, fetch: (url: string, opts?: any) => window.fetch(url, opts), redirect: (statusCode: number, location: string) => { + if (redirect && (redirect.statusCode !== statusCode || redirect.location !== location)) { + throw new Error(`Conflicting redirects`); + } redirect = { statusCode, location }; }, error: (statusCode: number, message: Error | string) => { error = { statusCode, message }; } - }, props)).catch(err => { + }; + + if (!root_preload) { + root_preload = manifest.root.preload + ? initial_data.preloaded[0] || manifest.root.preload.call(preload_context, { + path, + query, + params: {} + }) + : {}; + } + + return Promise.all(page.parts.map(async (part, i) => { + if (i < changed_from) return null; + + const { default: Component } = await part.component(); + const req = { + path, + query, + params: part.params ? part.params(target.match) : {} + }; + + const preloaded = ready || !initial_data.preloaded[i + 1] + ? Component.preload ? await Component.preload.call(preload_context, req) : {} + : initial_data.preloaded[i + 1]; + + return { Component, preloaded }; + })).catch(err => { error = { statusCode: 500, message: err }; - }).then(preloaded => { - if (error) { - return error_route().then(({ default: Page }: { default: ComponentConstructor }) => { - const err = error.message instanceof Error ? error.message : new Error(error.message); - Object.assign(props, { status: error.statusCode, error: err }); - return { Page, props, redirect: null }; - }); + return []; + }).then(async results => { + if (!root_data) root_data = await root_preload; + + if (redirect) { + return { redirect }; } - Object.assign(props, preloaded) - return { Page, props, redirect }; + segments = new_segments; + + const get_params = page.parts[page.parts.length - 1].params || (() => ({})); + const params = get_params(target.match); + + if (error) { + const props = { + path, + query, + params, + error: typeof error.message === 'string' ? new Error(error.message) : error.message, + status: error.statusCode + }; + + return { + data: Object.assign({}, props, { + preloading: false, + child: { + component: manifest.error, + props + } + }) + }; + } + + const props = { path, query }; + const data = { + path, + preloading: false, + child: Object.assign({}, root_props.child) + }; + if (changed(query, root_props.query)) data.query = query; + if (changed(params, root_props.params)) data.params = params; + + let level = data.child; + for (let i = 0; i < page.parts.length; i += 1) { + const part = page.parts[i]; + const get_params = part.params || (() => ({})); + + if (i < changed_from) { + level.props.path = path; + level.props.query = query; + level.props.child = Object.assign({}, level.props.child); + } else { + level.segment = new_segments[i]; + level.component = results[i].Component; + level.props = Object.assign({}, level.props, props, { + params: get_params(target.match), + }, results[i].preloaded); + level.props.child = {}; + } + + level = level.props.child; + } + + return { data, changed_from }; }); } -function navigate(target: Target, id: number): Promise { +async function navigate(target: Target, id: number): Promise { if (id) { // popstate or initial navigation cid = id; @@ -148,20 +277,19 @@ function navigate(target: Target, id: number): Promise { const loaded = prefetching && prefetching.href === target.url.href ? prefetching.promise : - target.route.load().then(mod => prepare_route(mod.default, target.props)); + prepare_page(target); prefetching = null; const token = current_token = {}; + const { redirect, data, changed_from } = await loaded; - return loaded.then(({ Page, props, redirect }) => { - if (redirect) { - return goto(redirect.location, { replaceState: true }); - } - - render(Page, props, scroll_history[id], token); + if (redirect) { + await goto(redirect.location, { replaceState: true }); + } else { + render(data, changed_from, scroll_history[id], token); document.activeElement.blur(); - }); + } } function handle_click(event: MouseEvent) { @@ -225,16 +353,16 @@ function handle_popstate(event: PopStateEvent) { let prefetching: { href: string; - promise: Promise<{ Page: ComponentConstructor, props: any }>; + promise: Promise<{ redirect?: Redirect, data?: any, changed_from?: number }>; } = null; export function prefetch(href: string) { - const selected = select_route(new URL(href, document.baseURI)); + const target: Target = select_route(new URL(href, document.baseURI)); - if (selected && (!prefetching || href !== prefetching.href)) { + if (target && (!prefetching || href !== prefetching.href)) { prefetching = { href, - promise: selected.route.load().then(mod => prepare_route(mod.default, selected.props)) + promise: prepare_page(target) }; } } @@ -256,19 +384,28 @@ function trigger_prefetch(event: MouseEvent | TouchEvent) { } let inited: boolean; +let ready = false; -export function init(opts: { App: ComponentConstructor, target: Node, routes: Route[], store?: (data: any) => Store }) { +export function init(opts: { + App: ComponentConstructor, + target: Node, + manifest: Manifest, + store?: (data: any) => Store, + routes?: any // legacy +}) { if (opts instanceof HTMLElement) { throw new Error(`The signature of init(...) has changed — see https://sapper.svelte.technology/guide#0-11-to-0-12 for more information`); } - App = opts.App; + if (opts.routes) { + throw new Error(`As of Sapper 0.15, opts.routes should be opts.manifest`); + } + target = opts.target; - routes = opts.routes; - error_route = opts.routes.error; + manifest = opts.manifest; if (opts && opts.store) { - store = opts.store(manifest.store); + store = opts.store(initial_data.store); } if (!inited) { // this check makes HMR possible @@ -292,8 +429,10 @@ export function init(opts: { App: ComponentConstructor, target: Node, routes: Ro history.replaceState({ id: uid }, '', href); - const target = select_route(new URL(window.location.href)); - if (target) return navigate(target, uid); + if (!initial_data.error) { + const target = select_route(new URL(window.location.href)); + if (target) return navigate(target, uid); + } }); } @@ -313,9 +452,9 @@ export function goto(href: string, opts = { replaceState: false }) { } export function prefetchRoutes(pathnames: string[]) { - if (!routes) throw new Error(`You must call init() first`); + if (!manifest) throw new Error(`You must call init() first`); - return routes.pages + return manifest.pages .filter(route => { if (!pathnames) return true; return pathnames.some(pathname => route.pattern.test(pathname)); diff --git a/src/runtime/interfaces.ts b/src/runtime/interfaces.ts index 00f814c..5fbc381 100644 --- a/src/runtime/interfaces.ts +++ b/src/runtime/interfaces.ts @@ -11,15 +11,23 @@ export interface ComponentConstructor { }; export interface Component { + set: (data: any) => void; destroy: () => void; } -export type Route = { +export type Page = { pattern: RegExp; - load: () => Promise<{ default: ComponentConstructor }>; - error?: boolean; - params?: (match: RegExpExecArray) => Record; - ignore?: boolean; + parts: Array<{ + component: () => Promise<{ default: ComponentConstructor }>; + params?: (match: RegExpExecArray) => Record; + }>; +}; + +export type Manifest = { + ignore: RegExp[]; + root: ComponentConstructor; + error: () => Promise<{ default: ComponentConstructor }>; + pages: Page[] }; export type ScrollPosition = { @@ -29,6 +37,13 @@ export type ScrollPosition = { export type Target = { url: URL; - route: Route; - props: RouteData; + path: string; + page: Page; + match: RegExpExecArray; + query: Record; +}; + +export type Redirect = { + statusCode: number; + location: string; }; \ No newline at end of file diff --git a/test/app/app/App.html b/test/app/app/App.html deleted file mode 100644 index a7bf15f..0000000 --- a/test/app/app/App.html +++ /dev/null @@ -1,6 +0,0 @@ -{#if preloading} - -{/if} - - - diff --git a/test/app/app/client.js b/test/app/app/client.js index da89de9..2ca0ebe 100644 --- a/test/app/app/client.js +++ b/test/app/app/client.js @@ -1,13 +1,11 @@ import { init, prefetchRoutes } from '../../../runtime.js'; import { Store } from 'svelte/store.js'; -import { routes } from './manifest/client.js'; -import App from './App.html'; +import { manifest } from './manifest/client.js'; window.init = () => { return init({ target: document.querySelector('#sapper'), - App, - routes, + manifest, store: data => new Store(data) }); }; diff --git a/test/app/app/server.js b/test/app/app/server.js index 3cef2a2..645699f 100644 --- a/test/app/app/server.js +++ b/test/app/app/server.js @@ -4,8 +4,7 @@ import express from 'express'; import serve from 'serve-static'; import sapper from '../../../dist/middleware.ts.js'; import { Store } from 'svelte/store.js'; -import { routes } from './manifest/server.js'; -import App from './App.html' +import { manifest } from './manifest/server.js'; let pending; let ended; @@ -87,8 +86,7 @@ const middlewares = [ }, sapper({ - App, - routes, + manifest, store: () => { return new Store({ title: 'Stored title' diff --git a/test/app/routes/[x]/[y]/[z].html b/test/app/routes/[x]/[y]/[z].html new file mode 100644 index 0000000..787e413 --- /dev/null +++ b/test/app/routes/[x]/[y]/[z].html @@ -0,0 +1,20 @@ +z: {segment} {count} + + + \ No newline at end of file diff --git a/test/app/routes/[x]/[y]/_layout.html b/test/app/routes/[x]/[y]/_layout.html new file mode 100644 index 0000000..40e92ec --- /dev/null +++ b/test/app/routes/[x]/[y]/_layout.html @@ -0,0 +1,20 @@ +y: {segment} {count} + + + \ No newline at end of file diff --git a/test/app/routes/[x]/_counts.js b/test/app/routes/[x]/_counts.js new file mode 100644 index 0000000..1237d12 --- /dev/null +++ b/test/app/routes/[x]/_counts.js @@ -0,0 +1,5 @@ +export default { + x: process.browser ? 1 : 0, + y: process.browser ? 1 : 0, + z: process.browser ? 1 : 0 +}; \ No newline at end of file diff --git a/test/app/routes/[x]/_layout.html b/test/app/routes/[x]/_layout.html new file mode 100644 index 0000000..0fc0f2d --- /dev/null +++ b/test/app/routes/[x]/_layout.html @@ -0,0 +1,20 @@ +x: {segment} {count} + + + \ No newline at end of file diff --git a/test/app/routes/_layout.html b/test/app/routes/_layout.html new file mode 100644 index 0000000..4e39097 --- /dev/null +++ b/test/app/routes/_layout.html @@ -0,0 +1,15 @@ +{#if preloading} + +{/if} + + + + \ No newline at end of file diff --git a/test/app/routes/missing-index/ok.html b/test/app/routes/missing-index/ok.html new file mode 100644 index 0000000..7cc8f66 --- /dev/null +++ b/test/app/routes/missing-index/ok.html @@ -0,0 +1 @@ +

it works

\ No newline at end of file diff --git a/test/app/routes/preload-root.html b/test/app/routes/preload-root.html new file mode 100644 index 0000000..41f384a --- /dev/null +++ b/test/app/routes/preload-root.html @@ -0,0 +1 @@ +

root preload function ran: {rootPreloadFunctionRan}

\ No newline at end of file diff --git a/test/app/routes/preload-values/index.html b/test/app/routes/preload-values/index.html new file mode 100644 index 0000000..8c0dcbb --- /dev/null +++ b/test/app/routes/preload-values/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/app/routes/show-url.html b/test/app/routes/show-url.html deleted file mode 100644 index bf1e6b9..0000000 --- a/test/app/routes/show-url.html +++ /dev/null @@ -1,9 +0,0 @@ -

URL is {url}

- - diff --git a/test/common/test.js b/test/common/test.js index 84fb55e..936bbd7 100644 --- a/test/common/test.js +++ b/test/common/test.js @@ -32,6 +32,8 @@ Nightmare.action('prefetchRoutes', function(done) { const cli = path.resolve(__dirname, '../../sapper'); +const wait = ms => new Promise(f => setTimeout(f, ms)); + describe('sapper', function() { process.chdir(path.resolve(__dirname, '../app')); @@ -41,7 +43,7 @@ describe('sapper', function() { rimraf.sync('.sapper'); rimraf.sync('start.js'); - this.timeout(process.env.CI ? 30000 : 10000); + this.timeout(process.env.CI ? 30000 : 15000); // TODO reinstate dev tests // run({ @@ -97,13 +99,12 @@ describe('sapper', function() { ]; // Client scripts that should show up in the extraction directory. const expectedClientRegexes = [ - /client\/[^/]+\/_(\.\d+)?\.js/, - /client\/[^/]+\/about(\.\d+)?\.js/, - /client\/[^/]+\/blog_\$slug\$(\.\d+)?\.js/, - /client\/[^/]+\/blog(\.\d+)?\.js/, /client\/[^/]+\/main(\.\d+)?\.js/, - /client\/[^/]+\/show_url(\.\d+)?\.js/, - /client\/[^/]+\/slow_preload(\.\d+)?\.js/, + /client\/[^/]+\/index(\.\d+)?\.js/, + /client\/[^/]+\/about(\.\d+)?\.js/, + /client\/[^/]+\/blog_\$slug(\.\d+)?\.js/, + /client\/[^/]+\/blog(\.\d+)?\.js/, + /client\/[^/]+\/slow\$45preload(\.\d+)?\.js/, ]; const allPages = walkSync(dest); @@ -266,8 +267,9 @@ function run({ mode, basepath = '' }) { }) .then(requests => { assert.deepEqual(requests.map(r => r.url), []); - return nightmare.path(); }) + .then(() => wait(100)) + .then(() => nightmare.path()) .then(path => { assert.equal(path, `${basepath}/about`); return nightmare.title(); @@ -367,16 +369,6 @@ function run({ mode, basepath = '' }) { }); }); - it('passes entire request object to preload', () => { - return nightmare - .goto(`${base}/show-url`) - .init() - .evaluate(() => document.querySelector('p').innerHTML) - .then(html => { - assert.equal(html, `URL is /show-url`); - }); - }); - it('calls a delete handler', () => { return nightmare .goto(`${base}/delete-test`) @@ -629,6 +621,49 @@ function run({ mode, basepath = '' }) { assert.equal(html.indexOf('%sapper'), -1); }); }); + + it('only recreates components when necessary', () => { + return nightmare + .goto(`${base}/foo/bar/baz`) + .init() + .evaluate(() => document.querySelector('#sapper').textContent) + .then(text => { + assert.deepEqual(text.split('\n').filter(Boolean), [ + 'x: foo 1', + 'y: bar 1', + 'z: baz 1' + ]); + + return nightmare.click(`a`) + .then(() => wait(100)) + .then(() => { + return nightmare.evaluate(() => document.querySelector('#sapper').textContent); + }); + }) + .then(text => { + assert.deepEqual(text.split('\n').filter(Boolean), [ + 'x: foo 1', + 'y: bar 1', + 'z: qux 2' + ]); + }); + }); + + it('uses a fallback index component if none is provided', () => { + return nightmare.goto(`${base}/missing-index/ok`) + .page.title() + .then(title => { + assert.equal(title, 'it works'); + }); + }); + + it('runs preload in root component', () => { + return nightmare.goto(`${base}/preload-root`) + .page.title() + .then(title => { + assert.equal(title, 'root preload function ran: true'); + }); + }); }); describe('headers', () => { @@ -641,7 +676,7 @@ function run({ mode, basepath = '' }) { 'text/html' ); - const str = ['main', '_\\.\\d+'] + const str = ['main', '.+?\\.\\d+'] .map(file => { return `<${basepath}/client/[^/]+/${file}\\.js>;rel="preload";as="script"`; }) diff --git a/test/unit/create_routes.test.js b/test/unit/create_routes.test.js deleted file mode 100644 index 535de03..0000000 --- a/test/unit/create_routes.test.js +++ /dev/null @@ -1,366 +0,0 @@ -const assert = require('assert'); -const { create_routes } = require('../../dist/core.ts.js'); - -describe('create_routes', () => { - it('encodes characters not allowed in path', () => { - const { server_routes } = create_routes({ - files: [ - '"', - '#', - '?' - ] - }); - - assert.deepEqual( - server_routes.map(r => r.pattern), - [ - /^\/%22\/?$/, - /^\/%23\/?$/, - /^\/%3F\/?$/ - ] - ); - }); - - it('sorts routes correctly', () => { - const { pages, server_routes } = create_routes({ - files: [ - 'index.html', - 'about.html', - 'post/f[xx].html', - '[wildcard].html', - 'post/foo.html', - 'post/[id].html', - 'post/bar.html', - 'post/[id].json.js', - 'post/[id([0-9-a-z]{3,})].html', - ] - }); - - assert.deepEqual( - pages.map(r => r.file), - [ - 'index.html', - 'about.html', - 'post/bar.html', - 'post/foo.html', - 'post/f[xx].html', - 'post/[id([0-9-a-z]{3,})].html', // RegExp is more specific - 'post/[id].html', - '[wildcard].html' - ] - ); - - assert.deepEqual( - server_routes.map(r => r.file), - [ - 'post/[id].json.js' - ] - ); - }); - - it('distinguishes and sorts regexp routes correctly', () => { - const { pages } = create_routes({ - files: [ - '[slug].html', - '[slug([a-z]{2})].html', - '[slug([0-9-a-z]{3,})].html', - ] - }); - - assert.deepEqual( - pages.map(r => r.file), - [ - '[slug([0-9-a-z]{3,})].html', - '[slug([a-z]{2})].html', - '[slug].html', - ] - ); - }); - - it('prefers index page to nested route', () => { - let { pages, server_routes } = create_routes({ - files: [ - 'api/examples/[slug].js', - 'api/examples/index.js', - 'blog/[slug].html', - 'api/gists/[id].js', - 'api/gists/index.js', - '_error.html', - 'blog/index.html', - 'blog/rss.xml.js', - 'guide/index.html', - 'index.html' - ] - }); - - assert.deepEqual( - pages.map(r => r.file), - [ - '_error.html', - 'index.html', - 'guide/index.html', - 'blog/index.html', - 'blog/[slug].html' - ] - ); - - assert.deepEqual( - server_routes.map(r => r.file), - [ - 'blog/rss.xml.js', - 'api/examples/index.js', - 'api/examples/[slug].js', - 'api/gists/index.js', - 'api/gists/[id].js', - ] - ); - - ({ pages, server_routes } = create_routes({ - files: [ - '_error.html', - 'api/blog/[slug].js', - 'api/blog/index.js', - 'api/guide/contents.js', - 'api/guide/index.js', - 'blog/[slug].html', - 'blog/index.html', - 'blog/rss.xml.js', - 'gist/[id].js', - 'gist/create.js', - 'guide/index.html', - 'index.html', - 'repl/index.html' - ] - })); - - assert.deepEqual( - pages.map(r => r.file), - [ - '_error.html', - 'index.html', - 'guide/index.html', - 'blog/index.html', - 'blog/[slug].html', - 'repl/index.html' - ] - ); - - assert.deepEqual( - server_routes.map(r => r.file), - [ - 'blog/rss.xml.js', - 'gist/create.js', - 'gist/[id].js', - 'api/guide/index.js', - 'api/guide/contents.js', - 'api/blog/index.js', - 'api/blog/[slug].js', - ] - ); - - // RegExp routes - ({ pages } = create_routes({ - files: [ - 'blog/[slug].html', - 'blog/index.html', - 'blog/[slug([^0-9]+)].html', - ] - })); - - assert.deepEqual( - pages.map(r => r.file), - [ - 'blog/index.html', - 'blog/[slug([^0-9]+)].html', - 'blog/[slug].html', - ] - ); - }); - - it('generates params', () => { - const { pages } = create_routes({ - files: ['index.html', 'about.html', '[wildcard].html', 'post/[id].html'] - }); - - let file; - let params; - for (let i = 0; i < pages.length; i += 1) { - const route = pages[i]; - if (params = route.exec('/post/123')) { - file = route.file; - break; - } - } - - assert.equal(file, 'post/[id].html'); - assert.deepEqual(params, { - id: '123' - }); - }); - - it('ignores files and directories with leading underscores', () => { - const { pages } = create_routes({ - files: ['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html'] - }); - - assert.deepEqual( - pages.map(r => r.file), - [ - 'index.html', - 'e/f/g/h.html' - ] - ); - }); - - it('ignores files and directories with leading dots except .well-known', () => { - const { server_routes } = create_routes({ - files: ['.well-known/dnt-policy.txt.js', '.unknown/foo.txt.js'] - }); - - assert.deepEqual( - server_routes.map(r => r.file), - ['.well-known/dnt-policy.txt.js'] - ); - }); - - it('matches /foo/:bar before /:baz/qux', () => { - const a = create_routes({ - files: ['foo/[bar].html', '[baz]/qux.html'] - }); - const b = create_routes({ - files: ['[baz]/qux.html', 'foo/[bar].html'] - }); - - assert.deepEqual( - a.pages.map(r => r.file), - ['foo/[bar].html', '[baz]/qux.html'] - ); - - assert.deepEqual( - b.pages.map(r => r.file), - ['foo/[bar].html', '[baz]/qux.html'] - ); - }); - - it('fails if routes are indistinguishable', () => { - assert.throws(() => { - create_routes({ - files: ['[foo].html', '[bar]/index.html'] - }); - }, /The \[foo\] and \[bar\]\/index routes clash/); - - assert.throws(() => { - create_routes({ - files: ['[foo([0-9-a-z]+)].html', '[bar([0-9-a-z]+)]/index.html'] - }); - }, /The \[foo\(\[0-9-a-z\]\+\)\] and \[bar\(\[0-9-a-z\]\+\)\]\/index routes clash/); - }); - - - it('matches nested routes', () => { - const page = create_routes({ - files: ['settings/[submenu].html'] - }).pages[0]; - - assert.deepEqual(page.exec('/settings/foo'), { - submenu: 'foo' - }); - - assert.deepEqual(page.exec('/settings'), { - submenu: null - }); - }); - - it('prefers index routes to nested routes', () => { - const { pages } = create_routes({ - files: ['settings/[submenu].html', 'settings.html'] - }); - - assert.deepEqual( - pages.map(r => r.file), - ['settings.html', 'settings/[submenu].html'] - ); - }); - - it('matches deeply nested routes', () => { - const page = create_routes({ - files: ['settings/[a]/[b]/index.html'] - }).pages[0]; - - assert.deepEqual(page.exec('/settings/foo/bar'), { - a: 'foo', - b: 'bar' - }); - - assert.deepEqual(page.exec('/settings/foo'), { - a: 'foo', - b: null - }); - - assert.deepEqual(page.exec('/settings'), { - a: null, - b: null - }); - }); - - it('matches a dynamic part within a part', () => { - const route = create_routes({ - files: ['things/[slug].json.js'] - }).server_routes[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'] - }).server_routes[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/); - }); - - it('errors when trying to use reserved characters in route regexp', () => { - assert.throws(() => { - create_routes({ - files: ['[lang([a-z]{2}(?:-[a-z]{2,4})?)]'] - }); - }, /Sapper does not allow \(, \), \? or \: in RegExp routes yet/); - }); - - it('errors on 4xx.html', () => { - assert.throws(() => { - create_routes({ - files: ['4xx.html'] - }); - }, /As of Sapper 0.14, 4xx.html and 5xx.html should be replaced with _error.html/); - }); - - it('errors on 5xx.html', () => { - assert.throws(() => { - create_routes({ - files: ['5xx.html'] - }); - }, /As of Sapper 0.14, 4xx.html and 5xx.html should be replaced with _error.html/); - }); - - it('treats foo/index.json.js the same as foo.json.js', () => { - const route = create_routes({ - files: ['foo/index.json.js'] - }).server_routes[0]; - - assert.ok(route.test('/foo.json')); - }); -}); \ No newline at end of file diff --git a/test/unit/create_routes/index.js b/test/unit/create_routes/index.js new file mode 100644 index 0000000..e8e3586 --- /dev/null +++ b/test/unit/create_routes/index.js @@ -0,0 +1,165 @@ +const path = require('path'); +const assert = require('assert'); +const { create_routes } = require('../../../dist/core.ts.js'); + + +const _default_layout = { + default: true, + name: '_default_layout', + file: null +}; + +describe('create_routes', () => { + it('creates routes', () => { + const { components, pages, server_routes } = create_routes(path.join(__dirname, 'samples/basic')); + + const index = { name: 'index', file: 'index.html' }; + const about = { name: 'about', file: 'about.html' }; + const blog = { name: 'blog', file: 'blog/index.html' }; + const blog_$slug = { name: 'blog_$slug', file: 'blog/[slug].html' }; + + assert.deepEqual(components, [ + index, + about, + _default_layout, + blog, + blog_$slug + ]); + + assert.deepEqual(pages, [ + { + pattern: /^\/?$/, + parts: [ + { component: index, params: [] } + ] + }, + + { + pattern: /^\/about\/?$/, + parts: [ + { component: about, params: [] } + ] + }, + + { + pattern: /^\/blog\/?$/, + parts: [ + { component: _default_layout, params: [] }, + { component: blog, params: [] } + ] + }, + + { + pattern: /^\/blog\/([^\/]+?)\/?$/, + parts: [ + { component: _default_layout, params: [] }, + { component: 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(path.join(__dirname, 'samples/encoding')); + + // had to remove ? and " because windows + + // const quote = { name: '$34', file: '".html' }; + const hash = { name: '$35', file: '#.html' }; + // const question_mark = { name: '$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(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(path.join(__dirname, 'samples/sorting')); + + assert.deepEqual(pages.map(p => p.parts.map(part => part.component.file)), [ + ['index.html'], + ['about.html'], + [_default_layout.file, 'post/index.html'], + [_default_layout.file, 'post/bar.html'], + [_default_layout.file, 'post/foo.html'], + [_default_layout.file, 'post/f[xx].html'], + [_default_layout.file, 'post/[id([0-9-a-z]{3,})].html'], + [_default_layout.file, 'post/[id].html'], + ['[wildcard].html'] + ]); + }); + + it('ignores files and directories with leading underscores', () => { + const { server_routes } = create_routes(path.join(__dirname, 'samples/hidden-underscore')); + + assert.deepEqual(server_routes.map(r => r.file), [ + 'index.js', + 'e/f/g/h.js' + ]); + }); + + it('ignores files and directories with leading dots except .well-known', () => { + const { server_routes } = create_routes(path.join(__dirname, 'samples/hidden-dot')); + + assert.deepEqual(server_routes.map(r => r.file), [ + '.well-known/dnt-policy.txt.js' + ]); + }); + + it('fails on clashes', () => { + assert.throws(() => { + const { pages } = create_routes(path.join(__dirname, 'samples/clash-pages')); + }, /The \[bar\]\/index\.html and \[foo\]\.html pages clash/); + + assert.throws(() => { + const { server_routes } = create_routes(path.join(__dirname, 'samples/clash-routes')); + console.log(server_routes); + }, /The \[bar\]\/index\.js and \[foo\]\.js routes clash/); + }); + + it('fails if dynamic params are not separated', () => { + assert.throws(() => { + create_routes(path.join(__dirname, 'samples/invalid-params')); + }, /Invalid route \[foo\]\[bar\]\.js — parameters must be separated/); + }); + + it('errors when trying to use reserved characters in route regexp', () => { + assert.throws(() => { + create_routes(path.join(__dirname, 'samples/invalid-qualifier')); + }, /Invalid route \[foo\(\[a-z\]\(\[0-9\]\)\)\].js — cannot use \(, \), \? or \: in route qualifiers/); + }); +}); \ No newline at end of file diff --git a/test/unit/create_routes/samples/basic/about.html b/test/unit/create_routes/samples/basic/about.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/basic/blog/[slug].html b/test/unit/create_routes/samples/basic/blog/[slug].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/basic/blog/[slug].json.js b/test/unit/create_routes/samples/basic/blog/[slug].json.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/basic/blog/_default.html b/test/unit/create_routes/samples/basic/blog/_default.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/basic/blog/index.html b/test/unit/create_routes/samples/basic/blog/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/basic/blog/index.json.js b/test/unit/create_routes/samples/basic/blog/index.json.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/basic/index.html b/test/unit/create_routes/samples/basic/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/clash-pages/[bar]/index.html b/test/unit/create_routes/samples/clash-pages/[bar]/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/clash-pages/[foo].html b/test/unit/create_routes/samples/clash-pages/[foo].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/clash-pages/index.html b/test/unit/create_routes/samples/clash-pages/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/clash-routes/[bar]/index.js b/test/unit/create_routes/samples/clash-routes/[bar]/index.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/clash-routes/[foo].js b/test/unit/create_routes/samples/clash-routes/[foo].js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/clash-routes/index.html b/test/unit/create_routes/samples/clash-routes/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/encoding/#.html b/test/unit/create_routes/samples/encoding/#.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/hidden-dot/.unknown/foo.txt.js b/test/unit/create_routes/samples/hidden-dot/.unknown/foo.txt.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/hidden-dot/.well-known/dnt-policy.txt.js b/test/unit/create_routes/samples/hidden-dot/.well-known/dnt-policy.txt.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/hidden-underscore/_foo.js b/test/unit/create_routes/samples/hidden-underscore/_foo.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/hidden-underscore/a/_b/c/d.js b/test/unit/create_routes/samples/hidden-underscore/a/_b/c/d.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/hidden-underscore/e/f/g/h.js b/test/unit/create_routes/samples/hidden-underscore/e/f/g/h.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/hidden-underscore/i/_j.js b/test/unit/create_routes/samples/hidden-underscore/i/_j.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/hidden-underscore/index.html b/test/unit/create_routes/samples/hidden-underscore/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/hidden-underscore/index.js b/test/unit/create_routes/samples/hidden-underscore/index.js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/invalid-params/[foo][bar].js b/test/unit/create_routes/samples/invalid-params/[foo][bar].js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/invalid-qualifier/[foo([a-z]([0-9]))].js b/test/unit/create_routes/samples/invalid-qualifier/[foo([a-z]([0-9]))].js new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/qualifiers/[slug([0-9-a-z]{3,})].html b/test/unit/create_routes/samples/qualifiers/[slug([0-9-a-z]{3,})].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/qualifiers/[slug([a-z]{2})].html b/test/unit/create_routes/samples/qualifiers/[slug([a-z]{2})].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/qualifiers/[slug].html b/test/unit/create_routes/samples/qualifiers/[slug].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/[wildcard].html b/test/unit/create_routes/samples/sorting/[wildcard].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/_layout.html b/test/unit/create_routes/samples/sorting/_layout.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/about.html b/test/unit/create_routes/samples/sorting/about.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/index.html b/test/unit/create_routes/samples/sorting/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/post/[id([0-9-a-z]{3,})].html b/test/unit/create_routes/samples/sorting/post/[id([0-9-a-z]{3,})].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/post/[id].html b/test/unit/create_routes/samples/sorting/post/[id].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/post/_default.html b/test/unit/create_routes/samples/sorting/post/_default.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/post/bar.html b/test/unit/create_routes/samples/sorting/post/bar.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/post/f[xx].html b/test/unit/create_routes/samples/sorting/post/f[xx].html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/post/foo.html b/test/unit/create_routes/samples/sorting/post/foo.html new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/create_routes/samples/sorting/post/index.html b/test/unit/create_routes/samples/sorting/post/index.html new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.json b/tsconfig.json index 73a931e..5ed5373 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,9 +6,9 @@ "noEmitOnError": true, "allowJs": true, "lib": ["es5", "es6", "dom"], - "importHelpers": true + "importHelpers": true, + "target": "ES5" }, - "target": "ES5", "include": [ "src" ],