mirror of
https://github.com/kevin-DL/sapper.git
synced 2026-01-13 11:35:28 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e723f781a8 | ||
|
|
a22e28a11f | ||
|
|
ad8a410ba4 |
66
README.md
66
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;
|
||||
@@ -73,12 +72,70 @@ 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) {
|
||||
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.
|
||||
41
connect.js
41
connect.js
@@ -1,14 +1,20 @@
|
||||
require('svelte/ssr/register');
|
||||
const esm = require('@std/esm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
const tmp = require('tmp');
|
||||
const create_matchers = require('./utils/create_matchers.js');
|
||||
const create_app = require('./utils/create_app.js');
|
||||
const create_webpack_compiler = require('./utils/create_webpack_compiler.js');
|
||||
|
||||
require = esm(module, {
|
||||
const esmRequire = esm(module, {
|
||||
esm: 'all'
|
||||
});
|
||||
|
||||
module.exports = function connect(opts = {}) {
|
||||
const dir = tmp.dirSync({ unsafeCleanup: true });
|
||||
|
||||
module.exports = function connect(opts) {
|
||||
const routes = path.resolve('routes');
|
||||
const out = path.resolve('.sapper');
|
||||
|
||||
@@ -18,19 +24,40 @@ module.exports = function connect(opts = {}) {
|
||||
let server_routes = glob.sync('**/*.+(js|mjs)', { cwd: routes });
|
||||
let server_route_matchers = create_matchers(server_routes);
|
||||
|
||||
// create_app(routes, dir.name, page_matchers, opts.dev);
|
||||
create_app(routes, out, page_matchers, opts.dev);
|
||||
|
||||
const webpack_compiler = create_webpack_compiler(
|
||||
path.join(out, 'main.js'),
|
||||
path.resolve('.sapper/webpack'),
|
||||
opts.dev
|
||||
);
|
||||
|
||||
return async function(req, res, next) {
|
||||
const url = req.url.replace(/\?.+/, '');
|
||||
|
||||
if (url.startsWith('/webpack/')) {
|
||||
fs.createReadStream(path.resolve('.sapper' + url)).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
}));
|
||||
const app = await webpack_compiler.app;
|
||||
|
||||
const page = opts.template({
|
||||
app,
|
||||
html: Component.render({
|
||||
params,
|
||||
query: req.query
|
||||
})
|
||||
});
|
||||
|
||||
res.end(page);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +66,7 @@ module.exports = function connect(opts = {}) {
|
||||
const matcher = server_route_matchers[i];
|
||||
if (matcher.test(url)) {
|
||||
req.params = matcher.exec(url);
|
||||
const route = require(`${routes}/${matcher.file}`);
|
||||
const route = esmRequire(`${routes}/${matcher.file}`);
|
||||
|
||||
const handler = route[req.method.toLowerCase()];
|
||||
if (handler) {
|
||||
|
||||
2738
package-lock.json
generated
2738
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,20 @@
|
||||
{
|
||||
"name": "sapper",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "Combat-ready apps, engineered by Svelte",
|
||||
"main": "connect.js",
|
||||
"directories": {
|
||||
"test": "test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@std/esm": "^0.18.0"
|
||||
"@std/esm": "^0.18.0",
|
||||
"extract-text-webpack-plugin": "^3.0.2",
|
||||
"tmp": "0.0.33",
|
||||
"webpack": "^3.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "^4.0.1",
|
||||
"svelte": "^1.47.0"
|
||||
"svelte": "^1.47.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "mocha --opts mocha.opts"
|
||||
|
||||
38
templates/main.js
Normal file
38
templates/main.js
Normal file
@@ -0,0 +1,38 @@
|
||||
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('main');
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
// ROUTES
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
navigate(window.location);
|
||||
27
utils/create_app.js
Normal file
27
utils/create_app.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const tmp = require('tmp');
|
||||
|
||||
const template = fs.readFileSync(path.resolve(__dirname, '../templates/main.js'), 'utf-8');
|
||||
|
||||
module.exports = function create_app(routes, dest, matchers, dev) {
|
||||
// TODO in dev mode, watch files
|
||||
|
||||
const code = matchers
|
||||
.map(matcher => {
|
||||
const condition = matcher.dynamic.length === 0 ?
|
||||
`url.pathname === '/${matcher.parts.join('/')}'` :
|
||||
`${matcher.pattern}.test(url.pathname)`;
|
||||
|
||||
return `
|
||||
if (${condition}) {
|
||||
import('../routes/${matcher.file}').then(render);
|
||||
}
|
||||
`.replace(/^\t{3}/gm, '').trim();
|
||||
})
|
||||
.join(' else ');
|
||||
|
||||
const main = template.replace('// ROUTES', code);
|
||||
|
||||
fs.writeFileSync(path.join(dest, 'main.js'), main);
|
||||
};
|
||||
@@ -30,6 +30,7 @@ module.exports = function create_matchers(files) {
|
||||
|
||||
return {
|
||||
file,
|
||||
pattern,
|
||||
test,
|
||||
exec,
|
||||
parts,
|
||||
@@ -41,12 +42,5 @@ module.exports = function create_matchers(files) {
|
||||
(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
|
||||
}
|
||||
});
|
||||
}
|
||||
81
utils/create_webpack_compiler.js
Normal file
81
utils/create_webpack_compiler.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const fs = require('fs');
|
||||
const webpack = require('webpack');
|
||||
const ExtractTextPlugin = require("extract-text-webpack-plugin");
|
||||
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
|
||||
|
||||
// TODO make the webpack config, err, configurable
|
||||
|
||||
module.exports = function create_webpack_compiler(main, dest, dev) {
|
||||
const compiler = {};
|
||||
|
||||
const _ = webpack({
|
||||
entry: {
|
||||
main
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.html']
|
||||
},
|
||||
output: {
|
||||
path: dest,
|
||||
filename: '[name].[hash].js',
|
||||
chunkFilename: '[name].[id].js',
|
||||
publicPath: '/webpack/'
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.html$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'svelte-loader',
|
||||
options: {
|
||||
emitCss: true,
|
||||
cascade: false,
|
||||
store: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ExtractTextPlugin.extract({
|
||||
fallback: 'style-loader',
|
||||
use: [{ loader: 'css-loader', options: { sourceMap: dev } }]
|
||||
})
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new ExtractTextPlugin('main.css'),
|
||||
!dev && new webpack.optimize.ModuleConcatenationPlugin(),
|
||||
!dev && new UglifyJSPlugin()
|
||||
].filter(Boolean),
|
||||
devtool: dev ? 'inline-source-map' : false
|
||||
});
|
||||
|
||||
if (false) { // TODO watch in dev
|
||||
// TODO how can we invalidate compiler.app when watcher restarts?
|
||||
compiler.app = new Promise((fulfil, reject) => {
|
||||
_.watch({}, (err, stats) => {
|
||||
if (err || stats.hasErrors()) {
|
||||
// TODO handle errors
|
||||
}
|
||||
|
||||
const filename = stats.toJson().assetsByChunkName.main;
|
||||
fulfil(`/webpack/${filename}`);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
compiler.app = new Promise((fulfil, reject) => {
|
||||
_.run((err, stats) => {
|
||||
if (err || stats.hasErrors()) {
|
||||
// TODO handle errors
|
||||
}
|
||||
|
||||
const filename = stats.toJson().assetsByChunkName.main;
|
||||
fulfil(`/webpack/${filename}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return compiler;
|
||||
};
|
||||
Reference in New Issue
Block a user