mirror of
https://github.com/kevin-DL/sapper.git
synced 2026-01-13 19:45:26 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af5503009 | ||
|
|
c1de442dd1 | ||
|
|
0c6b7e3836 | ||
|
|
dc5e2543cb | ||
|
|
ecc7b80d91 | ||
|
|
40024e7d86 | ||
|
|
6f71f7ad4d | ||
|
|
6eb99b195e | ||
|
|
9e08fee9a1 | ||
|
|
442ce366e2 | ||
|
|
dc929fcd83 | ||
|
|
2dc246398b | ||
|
|
b7ac067459 | ||
|
|
8b50ff34b8 | ||
|
|
62abdb2a87 | ||
|
|
34d0bae4a1 | ||
|
|
4f0b336627 | ||
|
|
e71bf298fb | ||
|
|
e4936375db | ||
|
|
08ff7ad234 | ||
|
|
5995b7ae6a | ||
|
|
71ed3864b7 | ||
|
|
bd7f6e2b1a | ||
|
|
dd1f2d79ff | ||
|
|
dccd3cdeb0 | ||
|
|
b3b5d9f352 | ||
|
|
10ddaeb7a3 | ||
|
|
060f9b2f5e | ||
|
|
32dfa94247 | ||
|
|
797cc3cde1 | ||
|
|
9eca90067c | ||
|
|
57f293e872 | ||
|
|
7e65c481d8 | ||
|
|
0fe93cd177 | ||
|
|
67fe570f6d | ||
|
|
a3d44aba31 | ||
|
|
80ae909b73 | ||
|
|
892b18cf80 | ||
|
|
0eb96bf01f | ||
|
|
419f5c5235 | ||
|
|
4c61ed5fdd | ||
|
|
c19447cf05 | ||
|
|
cb2364f476 | ||
|
|
de427d400e | ||
|
|
e810ead93f | ||
|
|
f5a19ef34b | ||
|
|
b8c03d330b | ||
|
|
6e769496ec | ||
|
|
e46aceb2fe | ||
|
|
a87cac2481 | ||
|
|
608fdb7533 | ||
|
|
80166b5a7d | ||
|
|
24b259f80b | ||
|
|
8a9f4bd268 | ||
|
|
d940da1a77 | ||
|
|
91269f5705 | ||
|
|
80a9818e95 | ||
|
|
478ccf53cc | ||
|
|
b32278e88b | ||
|
|
881d411db0 | ||
|
|
2a5786d7d7 | ||
|
|
8b89a5f27e | ||
|
|
d46a270cf0 | ||
|
|
72efde9515 | ||
|
|
7c0789cabf | ||
|
|
bffffe0035 | ||
|
|
22d3cb2b1e | ||
|
|
e1ed1896e6 | ||
|
|
6d4c26d15d | ||
|
|
941867f0a4 | ||
|
|
c7e3fc4493 | ||
|
|
a8373c1568 | ||
|
|
db4223133e | ||
|
|
ef3a3e83e8 | ||
|
|
86292d119b | ||
|
|
2549477e05 | ||
|
|
cf0ab4b9c7 | ||
|
|
58768ae27d | ||
|
|
fa70024a92 | ||
|
|
33fcb865c8 | ||
|
|
c6778c961b | ||
|
|
cea14b4b53 | ||
|
|
12661449c2 | ||
|
|
2f51435d93 | ||
|
|
642c2904df | ||
|
|
727782aca2 | ||
|
|
b6f789e50c | ||
|
|
e723f781a8 | ||
|
|
a22e28a11f | ||
|
|
ad8a410ba4 |
18
CHANGELOG.md
Normal file
18
CHANGELOG.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# sapper changelog
|
||||
|
||||
## 0.1.3-4
|
||||
|
||||
* Fix typo
|
||||
|
||||
## 0.1.2
|
||||
|
||||
* Use `atime.getTime()` and `mtime.getTime()` instead of `atimeMs` and `mtimeMs` ([#11](https://github.com/sveltejs/sapper/issues/11))
|
||||
* Make dest dir before anyone tries to write to it ([#18](https://github.com/sveltejs/sapper/pull/18))
|
||||
|
||||
## 0.1.1
|
||||
|
||||
* Expose resolved pathname to `sapper/runtime/app.js` as `__app__` inside main.js
|
||||
|
||||
## 0.1.0
|
||||
|
||||
* First public preview
|
||||
74
README.md
74
README.md
@@ -16,6 +16,7 @@ High-level goals:
|
||||
* Code-splitting and HMR out of the box (probably via webpack)
|
||||
* Best-in-class performance
|
||||
* As little magic as possible. Anyone should be able to understand how everything fits together, and e.g. make changes to the webpack config
|
||||
* Links are just `<a>` tags, no special `<Link>` components
|
||||
|
||||
|
||||
## Design
|
||||
@@ -26,8 +27,6 @@ A Sapper app is just an Express app (conventionally, `server.js`) that uses the
|
||||
const app = require('express')();
|
||||
const sapper = require('sapper');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(sapper());
|
||||
|
||||
const { PORT = 3000 } = process.env;
|
||||
@@ -45,13 +44,13 @@ Like Next, routes are defined by the project directory structure, but with some
|
||||
|
||||
* Files with an `.html` extension are treated as Svelte components. The `routes/about.html` (or `routes/about/index.html`) would create the `/about` route.
|
||||
* Files with a `.js` or `.mjs` extension are more generic route handlers. These files should export functions corresponding to the HTTP methods they support (example below).
|
||||
* Instead of route masking, we embed parameters in the filename. For example `post/%id%.html` maps to `/post/:id`, and the component will be rendered with the appropriate parameter.
|
||||
* Nested routes (read [this article](https://joshduff.com/2015-06-why-you-need-a-state-router.md)) can be handled by creating a file that matches the subroute — for example, `routes/app/settings/%submenu%.html` would match `/app/settings/profile` *and* `app/settings`, but in the latter case the `submenu` parameter would be `null`.
|
||||
* Instead of route masking, we embed parameters in the filename. For example `post/[id].html` maps to `/post/:id`, and the component will be rendered with the appropriate parameter.
|
||||
* Nested routes (read [this article](https://joshduff.com/2015-06-why-you-need-a-state-router.md)) can be handled by creating a file that matches the subroute — for example, `routes/app/settings/[submenu].html` would match `/app/settings/profile` *and* `app/settings`, but in the latter case the `submenu` parameter would be `null`.
|
||||
|
||||
An example of a generic route:
|
||||
|
||||
```js
|
||||
// routes/api/post/%id%.js
|
||||
// routes/api/post/[id].js
|
||||
export async function get(req, res) {
|
||||
try {
|
||||
const data = await getPostFromDatabase(req.params.id);
|
||||
@@ -72,13 +71,71 @@ export async function get(req, res) {
|
||||
Or, if you omit the `res` argument, it can use the return value:
|
||||
|
||||
```js
|
||||
// routes/api/post/%id%.js
|
||||
export async function get(req, res) {
|
||||
// routes/api/post/[id].js
|
||||
export async function get(req) {
|
||||
return await getPostFromDatabase(req.params.id);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Client-side app
|
||||
|
||||
Sapper will create (and in development mode, update) a barebones `main.js` file that dynamically imports individual routes and renders them — something like this:
|
||||
|
||||
```js
|
||||
window.addEventListener('click', event => {
|
||||
let a = event.target;
|
||||
while (a && a.nodeName !== 'A') a = a.parentNode;
|
||||
if (!a) return;
|
||||
|
||||
if (navigate(new URL(a.href))) event.preventDefault();
|
||||
});
|
||||
|
||||
const target = document.querySelector('#sapper');
|
||||
let component;
|
||||
|
||||
function navigate(url) {
|
||||
if (url.origin !== window.location.origin) return;
|
||||
|
||||
let match;
|
||||
let params = {};
|
||||
const query = {};
|
||||
|
||||
function render(mod) {
|
||||
if (component) {
|
||||
component.destroy();
|
||||
} else {
|
||||
target.innerHTML = '';
|
||||
}
|
||||
|
||||
component = new mod.default({
|
||||
target,
|
||||
data: { query, params },
|
||||
hydrate: !!component
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/about') {
|
||||
import('/about/index.html').then(render);
|
||||
} else if (url.pathname === '/') {
|
||||
import('/index.js').then(render);
|
||||
} else if (match = /^\/post\/([^\/]+)$/.exec(url.pathname)) {
|
||||
params.id = match[1];
|
||||
import('/post/[id].html').then(render);
|
||||
} else if (match = /^\/([^\/]+)$/.exec(url.pathname)) {
|
||||
params.wildcard = match[1];
|
||||
import('/[wildcard].html').then(render);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
navigate(window.location);
|
||||
```
|
||||
|
||||
We're glossing over a lot of important stuff here — e.g. handling `popstate` — but you get the idea. Knowledge of all the possible routes means we can generate optimal code, much in the same way that statically analysing Svelte templates allows the compiler to generate optimal code.
|
||||
|
||||
|
||||
## Things to figure out
|
||||
|
||||
* How to customise the overall page template
|
||||
@@ -87,4 +144,7 @@ export async function get(req, res) {
|
||||
* `store` integration
|
||||
* Route transitions
|
||||
* Equivalent of `next export`
|
||||
* A good story for realtime/GraphQL stuff
|
||||
* Service worker
|
||||
* Using `Link...rel=preload` headers to push main.js/[route].js plus styles
|
||||
* ...and lots of other things that haven't occurred to me yet.
|
||||
67
connect.js
67
connect.js
@@ -1,67 +0,0 @@
|
||||
require('svelte/ssr/register');
|
||||
const esm = require('@std/esm');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
const create_matchers = require('./utils/create_matchers.js');
|
||||
|
||||
require = esm(module, {
|
||||
esm: 'all'
|
||||
});
|
||||
|
||||
module.exports = function connect(opts = {}) {
|
||||
const routes = path.resolve('routes');
|
||||
const out = path.resolve('.sapper');
|
||||
|
||||
let pages = glob.sync('**/*.html', { cwd: routes });
|
||||
let page_matchers = create_matchers(pages);
|
||||
|
||||
let server_routes = glob.sync('**/*.+(js|mjs)', { cwd: routes });
|
||||
let server_route_matchers = create_matchers(server_routes);
|
||||
|
||||
return async function(req, res, next) {
|
||||
const url = req.url.replace(/\?.+/, '');
|
||||
|
||||
for (let i = 0; i < page_matchers.length; i += 1) {
|
||||
const matcher = page_matchers[i];
|
||||
if (matcher.test(url)) {
|
||||
const params = matcher.exec(url);
|
||||
const Component = require(`${routes}/${matcher.file}`);
|
||||
|
||||
res.end(Component.render({
|
||||
params,
|
||||
query: req.query
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < server_route_matchers.length; i += 1) {
|
||||
const matcher = server_route_matchers[i];
|
||||
if (matcher.test(url)) {
|
||||
req.params = matcher.exec(url);
|
||||
const route = require(`${routes}/${matcher.file}`);
|
||||
|
||||
const handler = route[req.method.toLowerCase()];
|
||||
if (handler) {
|
||||
if (handler.length === 2) {
|
||||
handler(req, res);
|
||||
} else {
|
||||
const data = await handler(req);
|
||||
|
||||
// TODO headers, error handling
|
||||
if (typeof data === 'string') {
|
||||
res.end(data);
|
||||
} else {
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
19
lib/config.js
Normal file
19
lib/config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const path = require('path');
|
||||
const mkdirp = require('mkdirp');
|
||||
const rimraf = require('rimraf');
|
||||
|
||||
exports.dev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
exports.templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates');
|
||||
|
||||
exports.src = path.resolve(process.env.SAPPER_ROUTES || 'routes');
|
||||
|
||||
exports.dest = path.resolve(
|
||||
process.env.NOW ? '/tmp' :
|
||||
process.env.SAPPER_DEST || '.sapper'
|
||||
);
|
||||
|
||||
mkdirp(exports.dest);
|
||||
rimraf.sync(path.join(exports.dest, '**/*'));
|
||||
|
||||
exports.server_routes = path.resolve(exports.dest, 'server-routes.js');
|
||||
162
lib/index.js
Normal file
162
lib/index.js
Normal file
@@ -0,0 +1,162 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
const rimraf = require('rimraf');
|
||||
const mkdirp = require('mkdirp');
|
||||
const webpack = require('webpack');
|
||||
const create_routes = require('./utils/create_routes.js');
|
||||
const templates = require('./templates.js');
|
||||
const create_app = require('./utils/create_app.js');
|
||||
const create_compiler = require('./utils/create_compiler.js');
|
||||
const escape_html = require('escape-html');
|
||||
const { src, dest, dev } = require('./config.js');
|
||||
|
||||
module.exports = function connect(opts) {
|
||||
let routes = create_routes(
|
||||
glob.sync('**/*.+(html|js|mjs)', { cwd: src })
|
||||
);
|
||||
|
||||
create_app(src, dest, routes, opts);
|
||||
|
||||
const client = webpack(
|
||||
require(path.resolve('webpack.client.config.js'))
|
||||
);
|
||||
|
||||
const server = webpack(
|
||||
require(path.resolve('webpack.server.config.js'))
|
||||
);
|
||||
|
||||
const compiler = create_compiler(
|
||||
client,
|
||||
server,
|
||||
dest,
|
||||
routes,
|
||||
dev
|
||||
);
|
||||
|
||||
const dev_middleware = dev ? require('webpack-dev-middleware')(client, {
|
||||
noInfo: true,
|
||||
logLevel: 'silent',
|
||||
publicPath: '/client/'
|
||||
}) : null;
|
||||
|
||||
const hot_middleware = dev ? require('webpack-hot-middleware')(client, {
|
||||
reload: true,
|
||||
path: '/__webpack_hmr',
|
||||
heartbeat: 10 * 1000
|
||||
}) : null;
|
||||
|
||||
async function handle_webpack_generated_files(url, req, res, next) {
|
||||
if (dev) {
|
||||
dev_middleware(req, res, () => {
|
||||
hot_middleware(req, res, next);
|
||||
});
|
||||
} else {
|
||||
if (url.startsWith('/client/')) {
|
||||
await compiler.ready;
|
||||
res.set({
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'max-age=31536000'
|
||||
});
|
||||
res.end(compiler.asset_cache[url]);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handle_index(url, req, res, next) {
|
||||
if (url === '/index.html') {
|
||||
await compiler.ready;
|
||||
res.set({
|
||||
'Content-Type': 'text/html',
|
||||
'Cache-Control': dev ? 'no-cache' : 'max-age=600'
|
||||
});
|
||||
res.end(compiler.shell);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
async function handle_service_worker(url, req, res, next) {
|
||||
if (url === '/service-worker.js') {
|
||||
await compiler.ready;
|
||||
res.set({
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': dev ? 'no-cache' : 'max-age=600'
|
||||
});
|
||||
res.end(compiler.service_worker);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
async function handle_route(url, req, res, next) {
|
||||
// whatever happens, we're going to serve some HTML
|
||||
res.set({
|
||||
'Content-Type': 'text/html'
|
||||
});
|
||||
|
||||
try {
|
||||
for (const route of routes) {
|
||||
if (route.test(url)) {
|
||||
await compiler.ready;
|
||||
|
||||
req.params = route.exec(url);
|
||||
|
||||
const mod = require(compiler.server_routes)[route.id];
|
||||
|
||||
if (route.type === 'page') {
|
||||
let data = { params: req.params, query: req.query };
|
||||
if (mod.preload) data = Object.assign(data, await mod.preload(data));
|
||||
|
||||
const { html, head, css } = mod.render(data);
|
||||
|
||||
const page = templates.render(200, {
|
||||
main: compiler.client_main,
|
||||
html,
|
||||
head: `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`,
|
||||
styles: (css && css.code ? `<style>${css.code}</style>` : '')
|
||||
});
|
||||
|
||||
res.status(200);
|
||||
res.end(page);
|
||||
}
|
||||
|
||||
else {
|
||||
const handler = mod[req.method.toLowerCase()];
|
||||
if (handler) handler(req, res, next);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(404).end(templates.render(404, {
|
||||
title: 'Not found',
|
||||
status: 404,
|
||||
method: req.method,
|
||||
url
|
||||
}));
|
||||
} catch(err) {
|
||||
res.status(500).end(templates.render(500, {
|
||||
title: (err && err.name) || 'Internal server error',
|
||||
url,
|
||||
error: escape_html(err && (err.details || err.message || err) || 'Unknown error'),
|
||||
stack: err && err.stack.split('\n').slice(1).join('\n')
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return async function(req, res, next) {
|
||||
const url = req.url.replace(/\?.+/, '');
|
||||
|
||||
handle_index(url, req, res, () => {
|
||||
handle_service_worker(url, req, res, () => {
|
||||
handle_webpack_generated_files(url, req, res, () => {
|
||||
handle_route(url, req, res, next);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
16
lib/route_manager.js
Normal file
16
lib/route_manager.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const glob = require('glob');
|
||||
const create_routes = require('./utils/create_routes.js');
|
||||
const { src } = require('./config.js');
|
||||
|
||||
const route_manager = {
|
||||
routes: create_routes(
|
||||
glob.sync('**/*.+(html|js|mjs)', { cwd: src })
|
||||
),
|
||||
|
||||
onchange(fn) {
|
||||
// TODO in dev mode, keep this updated, and allow
|
||||
// webpack compiler etc to hook into it
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = route_manager;
|
||||
43
lib/templates.js
Normal file
43
lib/templates.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
|
||||
const templates = glob.sync('*.html', { cwd: 'templates' })
|
||||
.map(file => {
|
||||
const template = fs.readFileSync(`templates/${file}`, 'utf-8');
|
||||
const status = file.replace('.html', '').toLowerCase();
|
||||
|
||||
if (!/^[0-9x]{3}$/.test(status)) {
|
||||
throw new Error(`Bad template — should be a valid status code like 404.html, or a wildcard like 2xx.html`);
|
||||
}
|
||||
|
||||
const specificity = (
|
||||
(status[0] === 'x' ? 0 : 4) +
|
||||
(status[1] === 'x' ? 0 : 2) +
|
||||
(status[2] === 'x' ? 0 : 1)
|
||||
);
|
||||
|
||||
const pattern = new RegExp(`^${status.split('').map(d => d === 'x' ? '\\d' : d).join('')}$`);
|
||||
|
||||
return {
|
||||
test: status => pattern.test(status),
|
||||
specificity,
|
||||
render(data) {
|
||||
return template.replace(/%sapper\.(\w+)%/g, (match, key) => {
|
||||
return key in data ? data[key] : '';
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.specificity - a.specificity);
|
||||
|
||||
exports.render = (status, data) => {
|
||||
const template = templates.find(template => template.test(status));
|
||||
if (template) return template.render(data);
|
||||
|
||||
return `Missing template for status code ${status}`;
|
||||
};
|
||||
|
||||
exports.onchange = fn => {
|
||||
// TODO in dev mode, keep this updated, and allow
|
||||
// webpack compiler etc to hook into it
|
||||
};
|
||||
56
lib/utils/create_app.js
Normal file
56
lib/utils/create_app.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { dest, server_routes, dev } = require('../config.js');
|
||||
|
||||
module.exports = function create_app(src, dest, routes, options) {
|
||||
function create_client_main() {
|
||||
const template = fs.readFileSync('templates/main.js', 'utf-8');
|
||||
|
||||
const code = `[${
|
||||
routes
|
||||
.filter(route => route.type === 'page')
|
||||
.map(route => {
|
||||
const params = route.dynamic.length === 0 ?
|
||||
'{}' :
|
||||
`{ ${route.dynamic.map((part, i) => `${part}: match[${i + 1}]`).join(', ') } }`;
|
||||
|
||||
return `{ pattern: ${route.pattern}, params: match => (${params}), load: () => import(/* webpackChunkName: "${route.id}" */ '${src}/${route.file}') }`
|
||||
})
|
||||
.join(', ')
|
||||
}]`;
|
||||
|
||||
const main = template
|
||||
.replace(/__app__/g, path.resolve(__dirname, '../../runtime/app.js'))
|
||||
.replace(/__routes__/g, code)
|
||||
.replace(/__dev__/g, String(dev));
|
||||
|
||||
const file = path.resolve(dest, 'main.js');
|
||||
|
||||
fs.writeFileSync(file, main);
|
||||
|
||||
// need to fudge the mtime, because webpack is soft in the head
|
||||
const { atime, mtime } = fs.statSync(file);
|
||||
fs.utimesSync(file, atime.getTime() - 999999, mtime.getTime() - 999999);
|
||||
}
|
||||
|
||||
function create_server_routes() {
|
||||
const imports = routes
|
||||
.map(route => {
|
||||
return route.type === 'page' ?
|
||||
`import ${route.id} from '${src}/${route.file}';` :
|
||||
`import * as ${route.id} from '${src}/${route.file}';`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const exports = `export { ${routes.map(route => route.id)} };`;
|
||||
|
||||
fs.writeFileSync(server_routes, `${imports}\n\n${exports}`);
|
||||
|
||||
const { atime, mtime } = fs.statSync(server_routes);
|
||||
fs.utimesSync(server_routes, atime.getTime() - 999999, mtime.getTime() - 999999);
|
||||
}
|
||||
|
||||
// TODO in dev mode, watch files
|
||||
create_client_main();
|
||||
create_server_routes();
|
||||
};
|
||||
152
lib/utils/create_compiler.js
Normal file
152
lib/utils/create_compiler.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
const chalk = require('chalk');
|
||||
const { dev } = require('../config.js');
|
||||
const templates = require('../templates.js');
|
||||
|
||||
module.exports = function create_compiler(client, server, dest, routes, dev) {
|
||||
const compiler = {};
|
||||
|
||||
function client_updated(stats) {
|
||||
console.log(stats.toString({ colors: true }));
|
||||
|
||||
const info = stats.toJson();
|
||||
|
||||
compiler.client_main = `/client/${info.assetsByChunkName.main}`;
|
||||
compiler.assets = info.assets.map(asset => `/client/${asset.name}`);
|
||||
|
||||
const _fs = client.outputFileSystem && client.outputFileSystem.readFileSync ? client.outputFileSystem : fs;
|
||||
compiler.asset_cache = {};
|
||||
compiler.assets.forEach(file => {
|
||||
compiler.asset_cache[file] = _fs.readFileSync(path.join(dest, file), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
function server_updated(stats) {
|
||||
console.log(stats.toString({ colors: true }));
|
||||
|
||||
const info = stats.toJson();
|
||||
compiler.server_routes = path.resolve(dest, 'server', info.assetsByChunkName.server_routes);
|
||||
compiler.chunks = info.assetsByChunkName;
|
||||
}
|
||||
|
||||
function both_updated() {
|
||||
const assets = glob.sync('**', { cwd: 'assets', nodir: true });
|
||||
|
||||
const route_code = `[${
|
||||
routes
|
||||
.filter(route => route.type === 'page')
|
||||
.map(route => `{ pattern: ${route.pattern} }`)
|
||||
.join(', ')
|
||||
}]`;
|
||||
|
||||
compiler.service_worker = fs.readFileSync('templates/service-worker.js', 'utf-8')
|
||||
.replace('__timestamp__', Date.now())
|
||||
.replace('__assets__', JSON.stringify(assets))
|
||||
.replace('__shell__', JSON.stringify(compiler.assets.concat('/index.html')))
|
||||
.replace('__routes__', route_code);
|
||||
|
||||
compiler.shell = templates.render(200, {
|
||||
styles: '',
|
||||
head: '',
|
||||
html: '<noscript>Please enable JavaScript!</noscript>',
|
||||
main: compiler.client_main
|
||||
});
|
||||
|
||||
// useful for debugging, but the files are served from memory
|
||||
fs.writeFileSync(path.resolve(dest, 'service-worker.js'), compiler.service_worker);
|
||||
fs.writeFileSync(path.resolve(dest, 'index.html'), compiler.shell);
|
||||
}
|
||||
|
||||
if (dev) {
|
||||
let client_is_ready = false;
|
||||
let server_is_ready = false;
|
||||
|
||||
let fulfil;
|
||||
let reject;
|
||||
|
||||
const invalidate = () => new Promise((f, r) => {
|
||||
fulfil = f;
|
||||
reject = r;
|
||||
});
|
||||
|
||||
compiler.ready = invalidate();
|
||||
|
||||
client.plugin('invalid', filename => {
|
||||
console.log(chalk.red(`client bundle invalidated, file changed: ${chalk.bold(filename)}`));
|
||||
client_is_ready = false;
|
||||
compiler.ready = invalidate();
|
||||
});
|
||||
|
||||
client.plugin('done', stats => {
|
||||
if (stats.hasErrors()) {
|
||||
reject(stats.toJson().errors[0]);
|
||||
} else {
|
||||
client_updated(stats);
|
||||
}
|
||||
|
||||
client_is_ready = true;
|
||||
if (server_is_ready) fulfil();
|
||||
});
|
||||
|
||||
client.plugin('failed', reject);
|
||||
|
||||
server.plugin('invalid', filename => {
|
||||
console.log(chalk.red(`server bundle invalidated, file changed: ${chalk.bold(filename)}`));
|
||||
server_is_ready = false;
|
||||
compiler.ready = invalidate();
|
||||
});
|
||||
|
||||
server.plugin('done', stats => {
|
||||
if (stats.hasErrors()) {
|
||||
reject(stats.toJson().errors[0]);
|
||||
} else {
|
||||
server_updated(stats);
|
||||
}
|
||||
|
||||
server_is_ready = true;
|
||||
if (client_is_ready) fulfil();
|
||||
});
|
||||
|
||||
server.plugin('failed', reject);
|
||||
|
||||
// client is already being watched by the middleware,
|
||||
// so we only need to start the server compiler
|
||||
server.watch({}, (err, stats) => {
|
||||
if (stats.hasErrors()) {
|
||||
reject(stats.toJson().errors[0]);
|
||||
} else {
|
||||
server_updated(stats);
|
||||
server_is_ready = true;
|
||||
if (client_is_ready) fulfil();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
compiler.ready = Promise.all([
|
||||
new Promise((fulfil, reject) => {
|
||||
client.run((err, stats) => {
|
||||
if (stats.hasErrors()) {
|
||||
reject(stats.toJson().errors[0]);
|
||||
} else {
|
||||
client_updated(stats);
|
||||
}
|
||||
fulfil();
|
||||
});
|
||||
}),
|
||||
|
||||
new Promise((fulfil, reject) => {
|
||||
server.run((err, stats) => {
|
||||
if (stats.hasErrors()) {
|
||||
reject(stats.toJson().errors[0]);
|
||||
} else {
|
||||
server_updated(stats);
|
||||
}
|
||||
fulfil();
|
||||
});
|
||||
})
|
||||
]).then(both_updated);
|
||||
}
|
||||
|
||||
return compiler;
|
||||
};
|
||||
@@ -3,15 +3,21 @@ const path = require('path');
|
||||
module.exports = function create_matchers(files) {
|
||||
return files
|
||||
.map(file => {
|
||||
if (/(^|\/|\\)_/.test(file)) return;
|
||||
|
||||
const parts = file.replace(/\.(html|js|mjs)$/, '').split(path.sep);
|
||||
if (parts[parts.length - 1] === 'index') parts.pop();
|
||||
|
||||
const id = (
|
||||
parts.join('_').replace(/[[\]]/g, '$').replace(/^\d/, '_$&').replace(/[^a-zA-Z0-9_$]/g, '_')
|
||||
) || '_';
|
||||
|
||||
const dynamic = parts
|
||||
.filter(part => part[0] === '%')
|
||||
.filter(part => part[0] === '[')
|
||||
.map(part => part.slice(1, -1));
|
||||
|
||||
const pattern = new RegExp(
|
||||
`^\\/${parts.map(p => p[0] === '%' ? '([^/]+)' : p).join('\\/')}$`
|
||||
`^\\/${parts.map(p => p[0] === '[' ? '([^/]+)' : p).join('\\/')}$`
|
||||
);
|
||||
|
||||
const test = url => pattern.test(url);
|
||||
@@ -29,24 +35,21 @@ module.exports = function create_matchers(files) {
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
type: path.extname(file) === '.html' ? 'page' : 'route',
|
||||
file,
|
||||
pattern,
|
||||
test,
|
||||
exec,
|
||||
parts,
|
||||
dynamic
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
(a.dynamic.length - b.dynamic.length) || // match static paths first
|
||||
(b.parts.length - a.parts.length) // match longer paths first
|
||||
);
|
||||
})
|
||||
.map(matcher => {
|
||||
return {
|
||||
file: matcher.file,
|
||||
test: matcher.test,
|
||||
exec: matcher.exec
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,21 +5,21 @@ const create_matchers = require('./create_matchers.js');
|
||||
|
||||
describe('create_matchers', () => {
|
||||
it('sorts routes correctly', () => {
|
||||
const matchers = create_matchers(['index.html', 'about.html', '%wildcard%.html', 'post/%id%.html']);
|
||||
const matchers = create_matchers(['index.html', 'about.html', '[wildcard].html', 'post/[id].html']);
|
||||
|
||||
assert.deepEqual(
|
||||
matchers.map(m => m.file),
|
||||
[
|
||||
'about.html',
|
||||
'index.html',
|
||||
'post/%id%.html',
|
||||
'%wildcard%.html'
|
||||
'post/[id].html',
|
||||
'[wildcard].html'
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('generates params', () => {
|
||||
const matchers = create_matchers(['index.html', 'about.html', '%wildcard%.html', 'post/%id%.html']);
|
||||
const matchers = create_matchers(['index.html', 'about.html', '[wildcard].html', 'post/[id].html']);
|
||||
|
||||
let file;
|
||||
let params;
|
||||
@@ -31,9 +31,21 @@ describe('create_matchers', () => {
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(file, 'post/%id%.html');
|
||||
assert.equal(file, 'post/[id].html');
|
||||
assert.deepEqual(params, {
|
||||
id: '123'
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores files and directories with leading underscores', () => {
|
||||
const matches = create_matchers(['index.html', '_foo.html', 'a/_b/c/d.html', 'e/f/g/h.html', 'i/_j.html']);
|
||||
|
||||
assert.deepEqual(
|
||||
matches.map(m => m.file),
|
||||
[
|
||||
'e/f/g/h.html',
|
||||
'index.html'
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
2860
package-lock.json
generated
2860
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -1,17 +1,22 @@
|
||||
{
|
||||
"name": "sapper",
|
||||
"version": "0.0.1",
|
||||
"description": "Combat-ready apps, engineered by Svelte",
|
||||
"main": "connect.js",
|
||||
"version": "0.1.4",
|
||||
"description": "Military-grade apps, engineered by Svelte",
|
||||
"main": "lib/index.js",
|
||||
"directories": {
|
||||
"test": "test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@std/esm": "^0.18.0"
|
||||
"chalk": "^2.3.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "^2.6.2",
|
||||
"webpack": "^3.10.0",
|
||||
"webpack-dev-middleware": "^2.0.1",
|
||||
"webpack-hot-middleware": "^2.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "^4.0.1",
|
||||
"svelte": "^1.47.0"
|
||||
"mocha": "^4.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "mocha --opts mocha.opts"
|
||||
@@ -29,8 +34,5 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/sveltejs/sapper/issues"
|
||||
},
|
||||
"homepage": "https://github.com/sveltejs/sapper#readme",
|
||||
"@std/esm": {
|
||||
"esm": "js"
|
||||
}
|
||||
"homepage": "https://github.com/sveltejs/sapper#readme"
|
||||
}
|
||||
|
||||
192
runtime/app.js
Normal file
192
runtime/app.js
Normal file
@@ -0,0 +1,192 @@
|
||||
const detach = node => {
|
||||
node.parentNode.removeChild(node);
|
||||
};
|
||||
|
||||
export let component;
|
||||
let target;
|
||||
let routes;
|
||||
|
||||
const scroll_history = {};
|
||||
let uid = 1;
|
||||
let cid;
|
||||
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'manual'
|
||||
}
|
||||
|
||||
function select_route(url) {
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
|
||||
for (const route of routes) {
|
||||
const match = route.pattern.exec(url.pathname);
|
||||
if (match) {
|
||||
const params = route.params(match);
|
||||
|
||||
const query = {};
|
||||
for (const [key, value] of url.searchParams) query[key] = value || true;
|
||||
|
||||
return { route, data: { params, query } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(Component, data, scroll) {
|
||||
Promise.resolve(
|
||||
Component.preload ? Component.preload(data) : {}
|
||||
).then(preloaded => {
|
||||
if (component) {
|
||||
component.destroy();
|
||||
} else {
|
||||
// first load — remove SSR'd <head> contents
|
||||
const start = document.querySelector('#sapper-head-start');
|
||||
let end = document.querySelector('#sapper-head-end');
|
||||
|
||||
if (start && end) {
|
||||
while (start.nextSibling !== end) detach(start.nextSibling);
|
||||
detach(start);
|
||||
detach(end);
|
||||
}
|
||||
|
||||
// preload additional routes
|
||||
routes.reduce((promise, route) => promise.then(route.load), Promise.resolve());
|
||||
}
|
||||
|
||||
component = new Component({
|
||||
target,
|
||||
data: Object.assign(data, preloaded),
|
||||
hydrate: !!component
|
||||
});
|
||||
|
||||
if (scroll) {
|
||||
window.scrollTo(scroll.x, scroll.y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(url, id) {
|
||||
const selected = select_route(url);
|
||||
if (selected) {
|
||||
if (id) {
|
||||
// popstate or initial navigation
|
||||
cid = id;
|
||||
} else {
|
||||
// clicked on a link. preserve scroll state
|
||||
scroll_history[cid] = scroll_state();
|
||||
|
||||
id = cid = ++uid;
|
||||
scroll_history[cid] = { x: 0, y: 0 };
|
||||
|
||||
history.pushState({ id }, '', url.href);
|
||||
}
|
||||
|
||||
selected.route.load().then(mod => {
|
||||
render(mod.default, selected.data, scroll_history[id]);
|
||||
});
|
||||
|
||||
cid = id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function handle_click(event) {
|
||||
// Adapted from https://github.com/visionmedia/page.js
|
||||
// MIT license https://github.com/visionmedia/page.js#license
|
||||
if (which(event) !== 1) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
const a = findAnchor(event.target);
|
||||
if (!a) return;
|
||||
|
||||
// check if link is inside an svg
|
||||
// in this case, both href and target are always inside an object
|
||||
const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString';
|
||||
const href = svg ? a.href.baseVal : a.href;
|
||||
|
||||
if (href === window.location.href) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if tag has
|
||||
// 1. 'download' attribute
|
||||
// 2. rel='external' attribute
|
||||
if (a.hasAttribute('download') || a.getAttribute('rel') === 'external') return;
|
||||
|
||||
// Ignore if <a> has a target
|
||||
if (svg ? a.target.baseVal : a.target) return;
|
||||
|
||||
const url = new URL(href);
|
||||
|
||||
// Don't handle hash changes
|
||||
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
||||
|
||||
if (navigate(url, null)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handle_popstate(event) {
|
||||
scroll_history[cid] = scroll_state();
|
||||
|
||||
if (event.state) {
|
||||
navigate(new URL(window.location), event.state.id);
|
||||
} else {
|
||||
// hashchange
|
||||
cid = ++uid;
|
||||
history.replaceState({ id: cid }, '', window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
function prefetch(event) {
|
||||
const a = findAnchor(event.target);
|
||||
if (!a || a.rel !== 'prefetch') return;
|
||||
|
||||
const selected = select_route(new URL(a.href));
|
||||
|
||||
if (selected) {
|
||||
selected.route.load().then(mod => {
|
||||
if (mod.default.preload) mod.default.preload(selected.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function findAnchor(node) {
|
||||
while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG <a> elements have a lowercase name
|
||||
return node;
|
||||
}
|
||||
|
||||
let inited;
|
||||
|
||||
export function init(_target, _routes) {
|
||||
target = _target;
|
||||
routes = _routes;
|
||||
|
||||
if (!inited) { // this check makes HMR possible
|
||||
window.addEventListener('click', handle_click);
|
||||
window.addEventListener('popstate', handle_popstate);
|
||||
|
||||
// prefetch
|
||||
window.addEventListener('touchstart', prefetch);
|
||||
window.addEventListener('mouseover', prefetch);
|
||||
|
||||
inited = true;
|
||||
}
|
||||
|
||||
const scroll = scroll_history[uid] = scroll_state();
|
||||
|
||||
history.replaceState({ id: uid }, '', window.location.href);
|
||||
navigate(new URL(window.location), uid);
|
||||
}
|
||||
|
||||
function which(event) {
|
||||
event = event || window.event;
|
||||
return event.which === null ? event.button : event.which;
|
||||
}
|
||||
|
||||
function scroll_state() {
|
||||
return {
|
||||
x: window.scrollX,
|
||||
y: window.scrollY
|
||||
};
|
||||
}
|
||||
41
webpack/config.js
Normal file
41
webpack/config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const path = require('path');
|
||||
const route_manager = require('../lib/route_manager.js');
|
||||
const { src, dest, dev, server_routes } = require('../lib/config.js');
|
||||
|
||||
module.exports = {
|
||||
dev,
|
||||
|
||||
client: {
|
||||
entry: () => {
|
||||
return {
|
||||
main: `${dest}/main.js`
|
||||
};
|
||||
},
|
||||
|
||||
output: () => {
|
||||
return {
|
||||
path: `${dest}/client`,
|
||||
filename: '[name].[hash].js',
|
||||
chunkFilename: '[name].[id].[hash].js',
|
||||
publicPath: '/client/'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
server: {
|
||||
entry: () => {
|
||||
return {
|
||||
server_routes
|
||||
}
|
||||
},
|
||||
|
||||
output: () => {
|
||||
return {
|
||||
path: `${dest}/server`,
|
||||
filename: '[name].[hash].js',
|
||||
chunkFilename: '[name].[id].[hash].js',
|
||||
libraryTarget: 'commonjs2'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
1
webpack/hmr.js
Normal file
1
webpack/hmr.js
Normal file
@@ -0,0 +1 @@
|
||||
import 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000';
|
||||
Reference in New Issue
Block a user