diff --git a/rollup.config.js b/rollup.config.js index 0fddbc2ab..efd318700 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -54,7 +54,11 @@ export default [ dir: 'dist', format: 'cjs', sourcemap: true, - chunkFileNames: '[name].js' + chunkFileNames: '[name].js', + interop(id) { + if (id === 'events') {return 'esModule';} + return 'auto'; + } }, external, plugins: [ diff --git a/runtime/src/app/app.ts b/runtime/src/app/app.ts index d39c57f65..c4cddc8df 100644 --- a/runtime/src/app/app.ts +++ b/runtime/src/app/app.ts @@ -18,6 +18,7 @@ import { Page } from './types'; import goto from './goto'; +import { extract_path } from "./utils/route_path"; import { page_store } from './stores'; declare const __SAPPER__; @@ -96,8 +97,11 @@ export function extract_query(search: string) { search.slice(1).split('&').forEach(searchParam => { const [, key, value = ''] = /([^=]*)(?:=(.*))?/.exec(decodeURIComponent(searchParam.replace(/\+/g, ' '))); if (typeof query[key] === 'string') query[key] = [query[key]]; - if (typeof query[key] === 'object') (query[key] as string[]).push(value); - else query[key] = value; + if (typeof query[key] === 'object') { + (query[key] as string[]).push(value); + } else { + query[key] = value; + } }); } return query; @@ -105,9 +109,13 @@ export function extract_query(search: string) { export function select_target(url: URL): Target { if (url.origin !== location.origin) return null; - if (!url.pathname.startsWith(initial_data.baseUrl)) return null; + if (initial_data.hashbang) { + if (url.pathname && url.pathname !== location.pathname) return null; + } else { + if (!url.pathname.startsWith(initial_data.baseUrl)) return null; + } - let path = url.pathname.slice(initial_data.baseUrl.length); + let path = extract_path(url); if (path === '') { path = '/'; @@ -131,6 +139,13 @@ export function select_target(url: URL): Target { return { href: url.href, route, match, page }; } } + + if (initial_data.hashbang) { + const query: Query = extract_query(url.search); + const page = { host: location.host, path, query, params: {} }; + + return { href: url.href, route: null, match: null, page }; + } } export function handle_error(url: URL) { @@ -207,23 +222,40 @@ export async function navigate(dest: Target, id: number, noscroll?: boolean, has if (document.activeElement && (document.activeElement instanceof HTMLElement)) document.activeElement.blur(); if (!noscroll) { - let scroll = scroll_history[id]; + scroll_to_target(hash); + } +} - if (hash) { - // scroll is an element id (from a hash), we need to compute y. - const deep_linked = document.getElementById(hash.slice(1)); +function getOffsetY() { + if (self.pageYOffset) { + return self.pageYOffset; + } // Firefox, Chrome, Opera, Safari. + if (document.documentElement && document.documentElement.scrollTop) { + return document.documentElement.scrollTop; + } // Internet Explorer 6 (standards mode). + if (document.body.scrollTop) { + return document.body.scrollTop; + } // Internet Explorer 6, 7 and 8. + return 0; // None of the above. +} - if (deep_linked) { - scroll = { - x: 0, - y: deep_linked.getBoundingClientRect().top + scrollY - }; - } - } +export function scroll_to_target(hash: string) { + let scroll = scroll_history[cid]; - scroll_history[cid] = scroll; - if (scroll) scrollTo(scroll.x, scroll.y); + if (hash) { + // scroll is an element id (from a hash), we need to compute y. + const deep_linked = document.getElementById(hash.slice(1)); + + if (deep_linked) { + scroll = { + x: 0, + y: deep_linked.getBoundingClientRect().top + getOffsetY() + }; + } } + + scroll_history[cid] = scroll; + if (scroll) scrollTo(scroll.x, scroll.y); } async function render(branch: any[], props: any, page: Page) { @@ -281,6 +313,16 @@ export async function hydrate_target(dest: Target): Promise { const props = { error: null, status: 200, segments: [segments[0]] }; + if (route === null && initial_data.hashbang) { + if (!page.path) { + redirect = { statusCode: 302, location: '#!/' }; + return { redirect, props, branch: [] }; + } + props.error = { message: 'Page not found.' }; + props.status = 404; + return { redirect, props, branch: [] }; + } + const preload_context = { fetch: (url: string, opts?: any) => fetch(url, opts), redirect: (statusCode: number, location: string) => { @@ -297,7 +339,7 @@ export async function hydrate_target(dest: Target): Promise { if (!root_preloaded) { const root_preload = root_comp.preload || (() => {}); - root_preloaded = initial_data.preloaded[0] || root_preload.call(preload_context, { + root_preloaded = initial_data.preloaded[0] || await root_preload.call(preload_context, { host: page.host, path: page.path, query: page.query, @@ -346,7 +388,13 @@ export async function hydrate_target(dest: Target): Promise { preloaded = initial_data.preloaded[i + 1]; } - return (props[`level${j}`] = { component, props: preloaded, segment, match, part: part.i }); + return (props[`level${j}`] = { + component, + props: preloaded, + segment, + match, + part: part.i + }); })); } catch (error) { props.error = error; diff --git a/runtime/src/app/start/index.ts b/runtime/src/app/start/index.ts index b5f408ba6..22e691545 100644 --- a/runtime/src/app/start/index.ts +++ b/runtime/src/app/start/index.ts @@ -5,6 +5,7 @@ import { navigate, scroll_history, scroll_state, + scroll_to_target, select_target, handle_error, set_target, @@ -13,6 +14,9 @@ import { set_cid } from '../app'; import prefetch from '../prefetch/index'; +import { extract_hash, extract_path, location_not_include_hash } from "../utils/route_path"; +import { debug, init } from "svelte/internal"; +import goto from "../goto"; export default function start(opts: { target: Node @@ -38,13 +42,16 @@ export default function start(opts: { addEventListener('click', handle_click); addEventListener('popstate', handle_popstate); + if (initial_data.hashbang) { + addEventListener('hashchange', handle_hashchange); + } // prefetch addEventListener('touchstart', trigger_prefetch); addEventListener('mousemove', handle_mousemove); return Promise.resolve().then(() => { - const { hash, href } = location; + const { href } = location; history.replaceState({ id: uid }, '', href); @@ -53,7 +60,8 @@ export default function start(opts: { if (initial_data.error) return handle_error(url); const target = select_target(url); - if (target) return navigate(target, uid, true, hash); + const hash = extract_hash(url.hash); + if (target) return navigate(target, uid, !initial_data.hashbang, hash); }); } @@ -91,7 +99,7 @@ function handle_click(event: MouseEvent) { const href = String(svg ? (a).href.baseVal : a.href); if (href === location.href) { - if (!location.hash) event.preventDefault(); + if (location_not_include_hash()) event.preventDefault(); return; } @@ -106,17 +114,37 @@ function handle_click(event: MouseEvent) { const url = new URL(href); // Don't handle hash changes - if (url.pathname === location.pathname && url.search === location.search) return; + if (extract_path(url) === extract_path(location) && url.search === location.search) { + return; + } const target = select_target(url); if (target) { const noscroll = a.hasAttribute('sapper:noscroll'); - navigate(target, null, noscroll, url.hash); + navigate(target, null, noscroll, extract_hash(url.hash)); event.preventDefault(); history.pushState({ id: cid }, '', url.href); } } +function handle_hashchange(event: HashChangeEvent) { + const from = new URL(event.oldURL); + const to = new URL(event.newURL); + const hash = extract_hash(to.hash); + + if (extract_path(from) === extract_path(to)) { + // same page + scroll_to_target(hash); + return; + } + + const target = select_target(to); + + if (target) { + navigate(target, null, true, hash); + } +} + function which(event: MouseEvent) { return event.which === null ? event.button : event.which; } diff --git a/runtime/src/app/utils/route_path.ts b/runtime/src/app/utils/route_path.ts new file mode 100644 index 000000000..10823849f --- /dev/null +++ b/runtime/src/app/utils/route_path.ts @@ -0,0 +1,31 @@ +import { initial_data } from "../app"; + +export function hash_is_route(hash: string) { + return hash.startsWith('#!/'); +} + +export function extract_hash(hash: string) { + if (initial_data.hashbang) { + if (hash_is_route(hash) && hash.includes('#', 3)) { + return hash.slice(hash.indexOf('#', 3)); + } else { + return ''; + } + } + + return hash; +} + +export function extract_path(url: URL | Location) { + if (initial_data.hashbang) { + return hash_is_route(url.hash) + ? url.hash.slice(2).replace(/#.*/, '') + : ''; + } + + return url.pathname.slice(initial_data.baseUrl.length); +} + +export function location_not_include_hash() { + return !extract_hash(location.hash); +} diff --git a/runtime/src/internal/manifest-server.d.ts b/runtime/src/internal/manifest-server.d.ts index b2c501251..7b4fd6f89 100644 --- a/runtime/src/internal/manifest-server.d.ts +++ b/runtime/src/internal/manifest-server.d.ts @@ -9,6 +9,7 @@ export const src_dir: string; export const build_dir: string; export const dev: boolean; export const manifest: Manifest; +export const template: string; export interface SSRComponentModule { default: SSRComponent; diff --git a/runtime/src/server/middleware/get_page_handler.ts b/runtime/src/server/middleware/get_page_handler.ts index 73db9307b..3a2ec0ac2 100644 --- a/runtime/src/server/middleware/get_page_handler.ts +++ b/runtime/src/server/middleware/get_page_handler.ts @@ -6,7 +6,7 @@ import devalue from 'devalue'; import fetch from 'node-fetch'; import URL from 'url'; import { sourcemap_stacktrace } from './sourcemap_stacktrace'; -import { Manifest, ManifestPage, Req, Res, build_dir, dev, src_dir } from '@sapper/internal/manifest-server'; +import { Manifest, ManifestPage, Req, Res, build_dir, dev, src_dir, template as template_file } from '@sapper/internal/manifest-server'; import App from '@sapper/internal/App.svelte'; export function get_page_handler( @@ -18,8 +18,8 @@ export function get_page_handler( : (assets => () => assets)(JSON.parse(fs.readFileSync(path.join(build_dir, 'build.json'), 'utf-8'))); const template = dev - ? () => read_template(src_dir) - : (str => () => str)(read_template(build_dir)); + ? () => read_template(src_dir, template_file) + : (str => () => str)(read_template(build_dir, template_file)); const has_service_worker = fs.existsSync(path.join(build_dir, 'service-worker.js')); @@ -34,7 +34,12 @@ export function get_page_handler( res.end(`
${message}
`); } - function handle_error(req: Req, res: Res, statusCode: number, error: Error | string) { + function handle_error( + req: Req, + res: Res, + statusCode: number, + error: Error | string + ) { handle_page({ pattern: null, parts: [ @@ -369,8 +374,8 @@ export function get_page_handler( }; } -function read_template(dir = build_dir) { - return fs.readFileSync(`${dir}/template.html`, 'utf-8'); +function read_template(dir = build_dir, file: string = 'template.html') { + return fs.readFileSync(`${dir}/${file}`, 'utf-8'); } function try_serialize(data: any, fail?: (err) => void) { @@ -398,11 +403,11 @@ function serialize_error(error: Error | { message: string }) { function escape_html(html: string) { const chars: Record = { - '"' : 'quot', + '"': 'quot', "'": '#39', '&': 'amp', - '<' : 'lt', - '>' : 'gt' + '<': 'lt', + '>': 'gt' }; return html.replace(/["'&<>]/g, c => `&${chars[c]};`); diff --git a/src/api/build.ts b/src/api/build.ts index 461aad1ba..557360bd2 100644 --- a/src/api/build.ts +++ b/src/api/build.ts @@ -10,6 +10,7 @@ import { noop } from './utils/noop'; import validate_bundler from './utils/validate_bundler'; import { copy_runtime } from './utils/copy_runtime'; import { rimraf, mkdirp } from './utils/fs_utils'; +import { create_index_html } from "../core/generate_index_html"; type Opts = { cwd?: string; @@ -18,10 +19,14 @@ type Opts = { dest?: string; output?: string; static?: string; + basepath?: string, legacy?: boolean; bundler?: 'rollup' | 'webpack'; ext?: string; oncompile?: ({ type, result }: { type: string; result: CompileResult }) => void; + ssr?: boolean; + hashbang?: boolean, + template_file?: string; }; export async function build({ @@ -31,6 +36,10 @@ export async function build({ output = 'src/node_modules/@sapper', static: static_files = 'static', dest = '__sapper__/build', + ssr = true, + hashbang = false, + template_file = 'template.html', + basepath = '', bundler, legacy = false, @@ -52,7 +61,7 @@ export async function build({ rimraf(output); mkdirp(output); - copy_runtime(output); + copy_runtime(output, ssr); rimraf(dest); mkdirp(`${dest}/client`); @@ -60,9 +69,9 @@ export async function build({ // minify src/template.html // TODO compile this to a function? could be quicker than str.replace(...).replace(...).replace(...) - const template = read_template(src); + const template = read_template(src, template_file); - fs.writeFileSync(`${dest}/template.html`, minify_html(template)); + fs.writeFileSync(`${dest}/${template_file}`, minify_html(template)); const manifest_data = create_manifest_data(routes, ext); @@ -75,6 +84,9 @@ export async function build({ dest, routes, output, + ssr, + hashbang, + template: template_file, dev: false }); @@ -86,7 +98,11 @@ export async function build({ result: client_result }); - const build_info = client_result.to_json(manifest_data, { src, routes, dest }); + const build_info = client_result.to_json(manifest_data, { + src, + routes, + dest + }); if (legacy) { process.env.SAPPER_LEGACY_BUILD = 'true'; @@ -130,7 +146,8 @@ export async function build({ manifest_data, output, client_files, - static_files + static_files, + ssr }); serviceworker_stats = await serviceworker.compile(); @@ -140,4 +157,20 @@ export async function build({ result: serviceworker_stats }); } + + if (!ssr) { + create_index_html({ + basepath, + build_info, + dev: false, + output, + cwd, + src, + dest, + ssr, + hashbang, + template_file, + service_worker: !!serviceworker + }); + } } diff --git a/src/api/dev.ts b/src/api/dev.ts index b55779ae1..7afa119c4 100644 --- a/src/api/dev.ts +++ b/src/api/dev.ts @@ -7,7 +7,7 @@ import { EventEmitter } from 'events'; import { create_manifest_data, create_app, create_compilers, create_serviceworker_manifest } from '../core'; import { Compiler, Compilers } from '../core/create_compilers'; import inject_resources from '../core/create_compilers/inject'; -import { CompileResult } from '../core/create_compilers/interfaces'; +import { BuildInfo, CompileResult } from '../core/create_compilers/interfaces'; import Deferred from './utils/Deferred'; import validate_bundler from './utils/validate_bundler'; import { copy_shimport } from './utils/copy_shimport'; @@ -15,6 +15,7 @@ import { ManifestData, FatalEvent, ErrorEvent, ReadyEvent, InvalidEvent } from ' import { noop } from './utils/noop'; import { copy_runtime } from './utils/copy_runtime'; import { rimraf, mkdirp } from './utils/fs_utils'; +import { create_index_html } from "../core/generate_index_html"; type Opts = { cwd?: string; @@ -24,12 +25,17 @@ type Opts = { output?: string; static?: string; 'dev-port'?: number; + 'dev-host'?: string; live?: boolean; hot?: boolean; 'devtools-port'?: number; bundler?: 'rollup' | 'webpack'; port?: number; ext: string; + 'basepath'?: string; + ssr: boolean; + hashbang: boolean; + template_file: string; }; export function dev(opts: Opts) { @@ -45,11 +51,13 @@ class Watcher extends EventEmitter { routes: string; output: string; static: string; + basepath: string; } port: number; closed: boolean; dev_port: number; + dev_host: string; live: boolean; hot: boolean; @@ -69,6 +77,9 @@ class Watcher extends EventEmitter { unique_errors: Set; } ext: string; + ssr: boolean; + hashbang: boolean; + template_file: string; constructor({ cwd = '.', @@ -78,15 +89,22 @@ class Watcher extends EventEmitter { static: static_files = 'static', dest = '__sapper__/dev', 'dev-port': dev_port, + 'dev-host': dev_host, + basepath: basepath = '', live, hot, 'devtools-port': devtools_port, bundler, port = +process.env.PORT, - ext + ext, + ssr = true, + hashbang = false, + template_file = 'template.html' }: Opts) { super(); + basepath = basepath.replace(/^\//, ''); + cwd = path.resolve(cwd); this.bundler = validate_bundler(bundler); @@ -96,13 +114,18 @@ class Watcher extends EventEmitter { dest: path.resolve(cwd, dest), routes: path.resolve(cwd, routes), output: path.resolve(cwd, output), - static: path.resolve(cwd, static_files) + static: path.resolve(cwd, static_files), + basepath }; this.ext = ext; this.port = port; this.closed = false; + this.ssr = ssr; + this.hashbang = hashbang; + this.template_file = template_file; this.dev_port = dev_port; + this.dev_host = dev_host; this.live = live; this.hot = hot; @@ -138,17 +161,18 @@ class Watcher extends EventEmitter { this.port = await ports.find(3000); } - const { cwd, src, dest, routes, output, static: static_files } = this.dirs; + const { cwd, src, dest, routes, output, static: static_files, basepath } = this.dirs; rimraf(output); mkdirp(output); - copy_runtime(output); + copy_runtime(output, this.ssr); rimraf(dest); mkdirp(`${dest}/client`); if (this.bundler === 'rollup') copy_shimport(dest); if (!this.dev_port) this.dev_port = await ports.find(10000); + if (!this.dev_host) this.dev_host = '0.0.0.0'; // Chrome looks for debugging targets on ports 9222 and 9229 by default if (!this.devtools_port) this.devtools_port = await ports.find(9222); @@ -162,6 +186,9 @@ class Watcher extends EventEmitter { manifest_data, dev: true, dev_port: this.dev_port, + ssr: this.ssr, + hashbang: this.hashbang, + template: this.template_file, cwd, src, dest, routes, output }); } catch (err) { @@ -171,7 +198,7 @@ class Watcher extends EventEmitter { return; } - this.dev_server = new DevServer(this.dev_port); + this.dev_server = new DevServer(this.dev_port, 10000, this.dev_host); this.filewatchers.push( watch_dir( @@ -190,6 +217,9 @@ class Watcher extends EventEmitter { manifest_data, dev: true, dev_port: this.dev_port, + ssr: this.ssr, + hashbang: this.hashbang, + template: this.template_file, cwd, src, dest, routes, output }); } catch (error) { @@ -204,7 +234,7 @@ class Watcher extends EventEmitter { if (this.live) { this.filewatchers.push( - fs.watch(`${src}/template.html`, () => { + fs.watch(`${src}/${this.template_file}`, () => { this.dev_server.send({ action: 'reload' }); @@ -336,9 +366,11 @@ class Watcher extends EventEmitter { }, handle_result: (result: CompileResult) => { + const build_info: BuildInfo = result.to_json(manifest_data, this.dirs); + fs.writeFileSync( path.join(dest, 'build.json'), - JSON.stringify(result.to_json(manifest_data, this.dirs), null, ' ') + JSON.stringify(build_info, null, ' ') ); if (this.bundler === 'rollup') { @@ -351,9 +383,36 @@ class Watcher extends EventEmitter { manifest_data, output, client_files, - static_files + static_files, + ssr: this.ssr }); + if (!this.ssr) { + create_index_html({ + basepath, + build_info, + dev: true, + output, + cwd, + src, + dest, + ssr: this.ssr, + hashbang: this.hashbang, + template_file: this.template_file, + service_worker: !!compilers.serviceworker + }); + + if (this.hot && this.bundler === 'webpack') { + this.dev_server.send({ + status: 'completed' + }); + } else if (this.live) { + this.dev_server.send({ + action: 'reload' + }); + } + } + deferred.fulfil(); // we need to wait a beat before watching the service @@ -449,7 +508,7 @@ class DevServer { interval: NodeJS.Timer; _: http.Server; - constructor(port: number, interval = 10000) { + constructor(port: number, interval = 10000, host = '0.0.0.0') { this.clients = new Set(); this._ = http.createServer((req, res) => { @@ -475,7 +534,7 @@ class DevServer { }); }); - this._.listen(port); + this._.listen(port, host); this.interval = setInterval(() => { this.send(null); diff --git a/src/api/export.ts b/src/api/export.ts index fda9bae04..c0db3d929 100644 --- a/src/api/export.ts +++ b/src/api/export.ts @@ -27,6 +27,8 @@ type Opts = { oninfo?: ({ message }: { message: string }) => void; onfile?: ({ file, size, status }: { file: string, size: number, status: number }) => void; entry?: string; + ssr?: boolean; + hashbang?: boolean, }; type Ref = { @@ -86,6 +88,8 @@ async function _export({ concurrent = 8, oninfo = noop, onfile = noop, + ssr = true, + hashbang = false, entry = '/' }: Opts = {}) { basepath = basepath.replace(/^\//, ''); @@ -124,6 +128,11 @@ async function _export({ return resolved; }); + if (!ssr) { + copy(path.join(build_dir, 'index.html'), path.join(export_dir, 'index.html')); + return; + } + const proc = child_process.fork(path.resolve(`${build_dir}/server/server.js`), [], { cwd, env: Object.assign({ diff --git a/src/api/utils/copy_runtime.ts b/src/api/utils/copy_runtime.ts index 1c6f7f120..5d51a7e7f 100644 --- a/src/api/utils/copy_runtime.ts +++ b/src/api/utils/copy_runtime.ts @@ -14,8 +14,11 @@ const runtime = [ source: fs.readFileSync(path.join(__dirname, `../runtime/${file}`), 'utf-8') })); -export function copy_runtime(output: string) { +export function copy_runtime(output: string, ssr: boolean) { runtime.forEach(({ file, source }) => { + if (!ssr && file === 'server.mjs') { + return; + } mkdirp(path.dirname(`${output}/${file}`)); fs.writeFileSync(`${output}/${file}`, source); }); diff --git a/src/cli.ts b/src/cli.ts index 6baaf1074..7a4e04369 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ prog.command('dev') .option('-p, --port', 'Specify a port') .option('-o, --open', 'Open a browser window') .option('--dev-port', 'Specify a port for development server') + .option('--dev-host', 'Specify a host for development server') .option('--hot', 'Use hot module replacement (requires webpack)', true) .option('--live', 'Reload on changes if not using --hot', true) .option('--bundler', 'Specify a bundler (rollup or webpack)') @@ -32,10 +33,14 @@ prog.command('dev') .option('--output', 'Sapper intermediate file output directory', 'src/node_modules/@sapper') .option('--build-dir', 'Development build directory', '__sapper__/dev') .option('--ext', 'Custom Route Extension', '.svelte .html') + .option('--ssr', 'Server-Side Rendering', true) + .option('--hashbang', 'Use hash-based routing instead of pathname routing', false) + .option('--template', 'Template file', 'template.html') .action(async (opts: { port: number; open: boolean; 'dev-port': number; + 'dev-host': string; live: boolean; hot: boolean; bundler?: 'rollup' | 'webpack'; @@ -46,6 +51,9 @@ prog.command('dev') output: string; 'build-dir': string; ext: string; + ssr: boolean; + hashbang: boolean; + template: string; }) => { const { dev } = await import('./api/dev'); @@ -59,10 +67,14 @@ prog.command('dev') dest: opts['build-dir'], port: opts.port, 'dev-port': opts['dev-port'], + 'dev-host': opts['dev-host'], live: opts.live, hot: opts.hot, bundler: opts.bundler, - ext: opts.ext + ext: opts.ext, + ssr: opts.ssr, + hashbang: opts.hashbang, + template_file: opts.template }); let first = true; @@ -155,6 +167,10 @@ prog.command('build [dest]') .option('--routes', 'Routes directory', 'src/routes') .option('--output', 'Sapper intermediate file output directory', 'src/node_modules/@sapper') .option('--ext', 'Custom page route extensions (space separated)', '.svelte .html') + .option('--basepath', 'Specify a base path') + .option('--ssr', 'Server-Side Rendering', true) + .option('--hashbang', 'Use hash-based routing instead of pathname routing', false) + .option('--template', 'Template file', 'template.html') .example(`build custom-dir -p 4567`) .action(async (dest = '__sapper__/build', opts: { port: string; @@ -165,11 +181,15 @@ prog.command('build [dest]') routes: string; output: string; ext: string; + basepath?: string; + ssr: boolean; + hashbang: boolean; + template: string; }) => { console.log(`> Building...`); try { - await _build(opts.bundler, opts.legacy, opts.cwd, opts.src, opts.routes, opts.output, dest, opts.ext); + await _build(opts.bundler, opts.legacy, opts.cwd, opts.src, opts.routes, opts.output, dest, opts.ext, opts.ssr, opts.hashbang, opts.basepath, opts.template); const launcher = path.resolve(dest, 'index.js'); @@ -207,6 +227,9 @@ prog.command('export [dest]') .option('--build-dir', 'Intermediate build directory', '__sapper__/build') .option('--ext', 'Custom page route extensions (space separated)', '.svelte .html') .option('--entry', 'Custom entry points (space separated)', '/') + .option('--ssr', 'Server-Side Rendering', true) + .option('--hashbang', 'Use hash-based routing instead of pathname routing', false) + .option('--template', 'Template file', 'template.html') .action(async (dest = '__sapper__/export', opts: { build: boolean; legacy: boolean; @@ -223,11 +246,14 @@ prog.command('export [dest]') 'build-dir': string; ext: string; entry: string; + ssr: boolean; + hashbang: boolean; + template: string; }) => { try { if (opts.build) { console.log(`> Building...`); - await _build(opts.bundler, opts.legacy, opts.cwd, opts.src, opts.routes, opts.output, opts['build-dir'], opts.ext); + await _build(opts.bundler, opts.legacy, opts.cwd, opts.src, opts.routes, opts.output, opts['build-dir'], opts.ext, opts.ssr, opts.hashbang, opts.basepath, opts.template); console.error(`\n> Built in ${elapsed(start)}`); } @@ -244,6 +270,7 @@ prog.command('export [dest]') timeout: opts.timeout, concurrent: opts.concurrent, entry: opts.entry, + ssr: opts.ssr, oninfo: event => { console.log(colors.bold().cyan(`> ${event.message}`)); @@ -279,7 +306,11 @@ async function _build( routes: string, output: string, dest: string, - ext: string + ext: string, + ssr: boolean, + hashbang: boolean, + basepath: string, + template_file: string ) { const { build } = await import('./api/build'); @@ -292,6 +323,10 @@ async function _build( dest, ext, output, + ssr, + basepath, + hashbang, + template_file, oncompile: event => { let banner = `built ${event.type}`; let c = (txt: string) => colors.cyan(txt); @@ -304,7 +339,7 @@ async function _build( console.log(); console.log(c(`┌─${repeat('─', banner.length)}─┐`)); - console.log(c(`│ ${colors.bold(banner) } │`)); + console.log(c(`│ ${colors.bold(banner)} │`)); console.log(c(`└─${repeat('─', banner.length)}─┘`)); console.log(event.result.print()); diff --git a/src/core/create_app.ts b/src/core/create_app.ts index 680c6dd98..5c52c6eaf 100644 --- a/src/core/create_app.ts +++ b/src/core/create_app.ts @@ -12,6 +12,9 @@ export function create_app({ src, dest, routes, + ssr, + hashbang, + template, output }: { bundler: string; @@ -22,14 +25,19 @@ export function create_app({ src: string; dest: string; routes: string; + ssr: boolean; + hashbang: boolean; + template: string; output: string; }) { if (!fs.existsSync(output)) fs.mkdirSync(output); const path_to_routes = path.relative(`${output}/internal`, routes); - const client_manifest = generate_client_manifest(manifest_data, path_to_routes, bundler, dev, dev_port); - const server_manifest = generate_server_manifest(manifest_data, path_to_routes, cwd, src, dest, dev); + hashbang = !ssr && hashbang; + + const client_manifest = generate_client_manifest(manifest_data, path_to_routes, bundler, dev, dev_port, ssr, hashbang); + const server_manifest = generate_server_manifest(manifest_data, path_to_routes, cwd, src, dest, dev, ssr, template); const app = generate_app(manifest_data, path_to_routes); @@ -38,13 +46,14 @@ export function create_app({ write_if_changed(`${output}/internal/App.svelte`, app); } -export function create_serviceworker_manifest({ manifest_data, output, client_files, static_files }: { +export function create_serviceworker_manifest({ manifest_data, output, client_files, static_files, ssr }: { manifest_data: ManifestData; output: string; client_files: string[]; static_files: string; + ssr: boolean; }) { - let files: string[] = ['service-worker-index.html']; + let files: string[] = [(ssr ? 'service-worker-index.html' : 'index.html')]; if (fs.existsSync(static_files)) { files = files.concat(walk(static_files)); @@ -81,7 +90,9 @@ function generate_client_manifest( path_to_routes: string, bundler: string, dev: boolean, - dev_port?: number + dev_port?: number, + ssr?: boolean, + hashbang?: boolean ) { const page_ids = new Set(manifest_data.pages.map(page => page.pattern.toString())); @@ -93,21 +104,35 @@ function generate_client_manifest( const components = `[ ${manifest_data.components.map((component, i) => { - const annotation = bundler === 'webpack' - ? `/* webpackChunkName: "${component.name}" */ ` - : ''; + const annotation = bundler === 'webpack' + ? `/* webpackChunkName: "${component.name}" */ ` + : ''; - const source = get_file(path_to_routes, component); + const source = get_file(path_to_routes, component); - component_indexes[component.name] = i; + component_indexes[component.name] = i; - return `{ + return `{ js: () => import(${annotation}${stringify(source)}), css: "__SAPPER_CSS_PLACEHOLDER:${stringify(component.file, false)}__" }`; - }).join(',\n\t\t\t\t')} + }).join(',\n\t\t\t\t')} ]`.replace(/^\t/gm, ''); + const route_list = manifest_data.pages.map(page => { + const part = page.parts[page.parts.length - 1]; + const component = part.component; + + const name = (`route_${component.name}`).toUpperCase(); + + const route = get_route_factory(component.file, part.params.length > 0, hashbang); + + return ` + // ${component.file} + export const ${name} = ${route}; + `; + }).join(''); + let needs_decode = false; let routes = `[ @@ -119,14 +144,14 @@ function generate_client_manifest( const missing_layout = !part; if (missing_layout) return 'null'; - if (part.params.length > 0) { - needs_decode = true; - const props = part.params.map(create_param_match); - return `{ i: ${component_indexes[part.component.name]}, params: match => ({ ${props.join(', ')} }) }`; - } + if (part.params.length > 0) { + needs_decode = true; + const props = part.params.map(create_param_match); + return `{ i: ${component_indexes[part.component.name]}, params: match => ({ ${props.join(', ')} }) }`; + } - return `{ i: ${component_indexes[part.component.name]} }`; - }).join(',\n\t\t\t\t\t\t')} + return `{ i: ${component_indexes[part.component.name]} }`; + }).join(',\n\t\t\t\t\t\t')} ] }`).join(',\n\n\t\t\t\t')} ]`.replace(/^\t/gm, ''); @@ -146,6 +171,8 @@ function generate_client_manifest( export const components = ${components}; + ${route_list} + export const routes = ${routes}; ${dev ? `if (typeof window !== 'undefined') { @@ -162,8 +189,39 @@ function generate_server_manifest( cwd: string, src: string, dest: string, - dev: boolean + dev: boolean, + ssr: boolean, + template: string ) { + const build_dir = posixify(path.normalize(path.relative(cwd, dest))); + const src_dir = posixify(path.normalize(path.relative(cwd, src))); + + if (!ssr) { + return ` + // This file is generated by Sapper — do not edit it! + export const ssr = false; + + export const manifest = { + server_routes: [], + + pages: [], + + root: null, + root_preload: ()=> {}, + error: null + }; + + export const template = ${JSON.stringify(template)}; + + export const build_dir = ${JSON.stringify(build_dir)}; + + export const src_dir = ${JSON.stringify(src_dir)}; + + export const dev = ${dev ? 'true' : 'false'}; + `.replace(/^\t{2}/gm, '').trim(); + } + + const imports = [].concat( manifest_data.server_routes.map((route, i) => `import * as route_${i} from ${stringify(posixify(`${path_to_routes}/${route.file}`))};`), @@ -181,13 +239,14 @@ function generate_server_manifest( const code = ` `.replace(/^\t\t/gm, '').trim(); - const build_dir = posixify(path.normalize(path.relative(cwd, dest))); - const src_dir = posixify(path.normalize(path.relative(cwd, src))); - return ` // This file is generated by Sapper — do not edit it! ${imports.join('\n')} + export const ssr = true; + + export const template = ${JSON.stringify(template)}; + const d = decodeURIComponent; export const manifest = { @@ -197,8 +256,8 @@ function generate_server_manifest( pattern: ${route.pattern}, handlers: route_${i}, params: ${route.params.length > 0 - ? `match => ({ ${route.params.map(create_param_match).join(', ')} })` - : `() => ({})`} + ? `match => ({ ${route.params.map(create_param_match).join(', ')} })` + : `() => ({})`} }`).join(',\n\n\t\t\t\t')} ], @@ -208,21 +267,21 @@ function generate_server_manifest( pattern: ${page.pattern}, parts: [ ${page.parts.map(part => { - if (part === null) return 'null'; + if (part === null) return 'null'; - const props = [ - `name: "${part.component.name}"`, - `file: ${stringify(part.component.file)}`, - `component: component_${component_lookup[part.component.name]}` - ].filter(Boolean); + const props = [ + `name: "${part.component.name}"`, + `file: ${stringify(part.component.file)}`, + `component: component_${component_lookup[part.component.name]}` + ].filter(Boolean); - if (part.params.length > 0) { - const params = part.params.map(create_param_match); - props.push(`params: match => ({ ${params.join(', ')} })`); - } + if (part.params.length > 0) { + const params = part.params.map(create_param_match); + props.push(`params: match => ({ ${params.join(', ')} })`); + } - return `{ ${props.join(', ')} }`; - }).join(',\n\t\t\t\t\t\t')} + return `{ ${props.join(', ')} }`; + }).join(',\n\t\t\t\t\t\t')} ] }`).join(',\n\n\t\t\t\t')} ], @@ -297,3 +356,42 @@ function get_file(path_to_routes: string, component: PageComponent) { if (component.default) return `./${component.type}.svelte`; return posixify(`${path_to_routes}/${component.file}`); } + +function get_route_factory( + file: string, + has_params: boolean, + hashbang: boolean +) { + const prefix = hashbang ? '#!' : ''; + + let template = posixify(file) + .replace(/\.\w+$/, '') + .replace(/index$/, '') + .replace(/\/$/, ''); + + if (!has_params) { + return `() => "${prefix}/${template}"`; + } + + const slot_pattern = /\[(\.{3})?(\w+)(\(.+?\))?\]/g; + const params: string[] = []; + const slot_replace: (substring: string, ...args: any[]) => string + = ( + match, + spread, + slot, + qualifier + ) => { + params.push(slot); + return spread + ? `\${Array.isArray(${slot}) ? ${slot}.join('/') : ''}` + : `\${${slot}||''}`; + }; + + template = posixify(template) + .split(/\//) + .map(part => part.replace(slot_pattern, slot_replace)) + .join('/'); + + return `({${params.join(', ')}}) => \`${prefix}/${template}\``; +} diff --git a/src/core/create_manifest_data.ts b/src/core/create_manifest_data.ts index 41929c75e..1e6f1420c 100644 --- a/src/core/create_manifest_data.ts +++ b/src/core/create_manifest_data.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; +import { createHash } from 'crypto'; import { Page, PageComponent, ServerRoute, ManifestData } from '../interfaces'; import { posixify, reserved_words } from '../utils'; @@ -192,14 +193,14 @@ export default function create_manifest_data(cwd: string, extensions: string = ' 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`); - } + 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); - }); + seen_routes.set(pattern, route); + }); return { root, @@ -304,7 +305,14 @@ function get_slug(file: string) { .replace(/[\\/]index/, '') .replace(/[/\\]/g, '_') .replace(/\.\w+$/, '') - .replace(/\[([^(]+)(?:\([^(]+\))?\]/, '$$$1') + .replace(/\[(\.\.\.)?(?:(.+?)(\(.+?\))?)]/g, ( + match, + spread, + content, + qualifier + ) => { + return `${spread ? '$$' : ''}$${content}${hash(qualifier)}`; + }) .replace(/[^a-zA-Z0-9_$]/g, c => { return c === '.' ? '_' : `$${c.charCodeAt(0)}`; }); @@ -313,6 +321,17 @@ function get_slug(file: string) { return name; } +function hash(data: string) { + if (!data) { + return ''; + } + + return '$_' + createHash('md5') + .update(data) + .digest("hex") + .slice(0, 6); +} + function get_pattern(segments: Part[][], add_trailing_slash: boolean) { const path = segments.map(segment => { return segment.map(part => { diff --git a/src/core/generate_index_html.ts b/src/core/generate_index_html.ts new file mode 100755 index 000000000..ba76f27ac --- /dev/null +++ b/src/core/generate_index_html.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { BuildInfo } from "./create_compilers/interfaces"; +import { posixify, write_if_changed } from "../utils"; + +export function create_index_html({ + basepath, + build_info, + dev, + output, + cwd, + src, + dest, + ssr, + hashbang, + template_file = 'template.html', + service_worker + }: { + basepath: string, + build_info: BuildInfo; + dev: boolean; + output: string; + cwd: string, + src: string, + dest: string, + ssr: boolean, + hashbang: boolean, + template_file?: string, + service_worker?: boolean + } +) { + + const build_dir = posixify(path.relative(cwd, dest)); + const src_dir = posixify(path.relative(cwd, src)); + + const template = dev + ? () => read_template(src_dir, template_file) + : (str => () => str)(read_template(build_dir, template_file)); + + + let script = `__SAPPER__={${[ + 'ssr:false', + `hashbang:${hashbang ? 'true' : 'false'}`, + `baseUrl:'${basepath || ''}'`, + 'preloaded:[]', + 'session:{user:null}' + ].join(',')}};`; + + if (service_worker) { + script += `if('serviceWorker' in navigator)navigator.serviceWorker.register('${basepath}/service-worker.js');`; + } + + const file = [].concat(build_info.assets.main).filter(file => file && /\.js$/.test(file))[0]; + const main = `${basepath}/client/${file}`; + + if (build_info.bundler === 'rollup') { + if (build_info.legacy_assets) { + const legacy_main = `${basepath}/client/legacy/${build_info.legacy_assets.main}`; + script += `(function(){try{eval("async function x(){}");var main="${main}"}catch(e){main="${legacy_main}"};var s=document.createElement("script");try{new Function("if(0)import('')")();s.src=main;s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${basepath}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main",main);}document.head.appendChild(s);}());`; + } else { + script += `var s=document.createElement("script");try{new Function("if(0)import('')")();s.src="${main}";s.type="module";s.crossOrigin="use-credentials";}catch(e){s.src="${basepath}/client/shimport@${build_info.shimport}.js";s.setAttribute("data-main","${main}")}document.head.appendChild(s)`; + } + } else { + script += ``) + .replace('%sapper.TIMESTAMP%', () => process.env.TIMESTAMP || Date.now().toString()) + .replace('%sapper.html%', () => '') + .replace('%sapper.head%', () => head) + .replace('%sapper.styles%', () => ''); + + + write_if_changed(`${build_dir}/index.html`, body); +} + +function read_template(dir: string, file: string) { + return fs.readFileSync(path.resolve(dir, file), 'utf-8'); +} + diff --git a/src/core/read_template.ts b/src/core/read_template.ts index afd0f734b..509d2a93f 100644 --- a/src/core/read_template.ts +++ b/src/core/read_template.ts @@ -1,8 +1,11 @@ import * as fs from 'fs'; -export default function read_template(dir: string) { +export default function read_template( + dir: string, + file: string = 'template.html' +) { try { - return fs.readFileSync(`${dir}/template.html`, 'utf-8'); + return fs.readFileSync(`${dir}/${file}`, 'utf-8'); } catch (err) { if (fs.existsSync(`app/template.html`)) { throw new Error(`As of Sapper 0.21, the default folder structure has been changed: diff --git a/test/unit/create_manifest_data/test.ts b/test/unit/create_manifest_data/test.ts index 2d8be0705..f3db9e6b9 100644 --- a/test/unit/create_manifest_data/test.ts +++ b/test/unit/create_manifest_data/test.ts @@ -148,7 +148,7 @@ describe('manifest_data', () => { assert.deepEqual(server_routes, [ { - name: "route_$file$93_$91ext", + name: "route_$file_$ext", pattern: /^\/([^/]+?)\.([^/]+?)$/, file: "[file].[ext].js", params: ["file", "ext"] @@ -161,7 +161,7 @@ describe('manifest_data', () => { assert.deepEqual(server_routes, [ { - name: "route_$file_$91ext$40$91a$45z$93$43$41$93", + name: "route_$file$_c5f051_$ext$_c5f051", pattern: /^\/([a-z]+)\.([a-z]+)$/, file: "[file([a-z]+)].[ext([a-z]+)].js", params: ["file", "ext"]